修复素材框选捕获丢失后的状态清理

新增 lostpointercapture 指针匹配清理且不重复释放捕获
贯通素材选择 Hook 到素材列表的丢失捕获事件
补充生命周期回归测试和前端拆分文档
This commit is contained in:
2026-07-31 20:17:30 +08:00
parent 2da3dc0544
commit 7570134f00
9 changed files with 74 additions and 2 deletions
@@ -101,7 +101,7 @@
- 主视图继续保留上传文件读取、上传占位卡片进度、拖到画布坐标、创建画布图层、工程资源持久化和画布图层清理;素材删除通过 `onDeleteAssets` 回调通知主视图清理关联图层。
- 该 hook 有独立单测覆盖素材库加载归一化、401 登录、新建文件夹临时 id 替换、素材移动、删除回调和多选删除,避免后续整理侧栏 JSX 时丢失素材库能力。
- `useImageCanvasAssetSelection.ts`
- 作为素材选择的唯一状态边界,统一持有选择模式、完整选中集合、范围锚点、可选素材有效性 reconcile、单项 / Shift / 当前可见全选增量、框选几何与框选生命周期,并向批量下载 / 删除只暴露已经按素材顺序解析的 `selectedAssets`;删除入口根据当前 `visibleAssetIds` 识别未显示选择并在执行完整集合删除前弹出危险确认。
- 作为素材选择的唯一状态边界,统一持有选择模式、完整选中集合、范围锚点、可选素材有效性 reconcile、单项 / Shift / 当前可见全选增量、框选几何与框选生命周期;框选除 `pointerup / pointercancel` 外必须在匹配的 `lostpointercapture` 到达时只清理框选状态,不得再次释放已经丢失的 capture。该 hook 向批量下载 / 删除只暴露已经按素材顺序解析的 `selectedAssets`;删除入口根据当前 `visibleAssetIds` 识别未显示选择并在执行完整集合删除前弹出危险确认。
- 搜索和文件夹折叠只在侧栏产生 `visibleAssetIds` 并传入增量动作,不得直接修改或 reconcile 选择集合;选择行为测试集中在该 hook,素材库 model 不再维护第二套选择状态机。
## 第十一阶段模块
@@ -80,6 +80,9 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
onAssetMarqueePointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueeLostPointerCapture: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
updateAssetMoveDropFolder: (folderId: string | null) => void;
addUploadedFiles: (
files: FileList | File[],
@@ -136,6 +139,7 @@ export function ImageCanvasAssetLibraryPanelView({
onAssetMarqueePointerDown,
onAssetMarqueePointerMove,
onAssetMarqueePointerUp,
onAssetMarqueeLostPointerCapture,
updateAssetMoveDropFolder,
addUploadedFiles,
requestUpload,
@@ -191,6 +195,7 @@ export function ImageCanvasAssetLibraryPanelView({
onPointerMove={onAssetMarqueePointerMove}
onPointerUp={onAssetMarqueePointerUp}
onPointerCancel={onAssetMarqueePointerUp}
onLostPointerCapture={onAssetMarqueeLostPointerCapture}
>
{pinnedAssetMoveFolderId ? (
<div
@@ -63,6 +63,7 @@ function createSidebarProps(): ImageCanvasSidebarViewProps {
onAssetMarqueePointerDown: vi.fn(),
onAssetMarqueePointerMove: vi.fn(),
onAssetMarqueePointerUp: vi.fn(),
onAssetMarqueeLostPointerCapture: vi.fn(),
updateAssetMoveDropFolder: vi.fn(),
addUploadedFiles: vi.fn(),
requestUpload: vi.fn(),
@@ -714,6 +714,7 @@ export function ImageCanvasEditorView({
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
handleAssetMarqueeLostPointerCapture,
refreshAssetLibrary,
} = useImageCanvasAssetLibrary({
assetListRef,
@@ -2273,6 +2274,7 @@ export function ImageCanvasEditorView({
onAssetMarqueePointerDown: handleAssetMarqueePointerDown,
onAssetMarqueePointerMove: handleAssetMarqueePointerMove,
onAssetMarqueePointerUp: handleAssetMarqueePointerUp,
onAssetMarqueeLostPointerCapture: handleAssetMarqueeLostPointerCapture,
updateAssetMoveDropFolder,
addUploadedFiles,
requestUpload,
@@ -101,6 +101,7 @@ function createSidebarProps(
onAssetMarqueePointerDown: vi.fn(),
onAssetMarqueePointerMove: vi.fn(),
onAssetMarqueePointerUp: vi.fn(),
onAssetMarqueeLostPointerCapture: vi.fn(),
updateAssetMoveDropFolder: vi.fn(),
addUploadedFiles: vi.fn(),
requestUpload: vi.fn(),
@@ -76,6 +76,9 @@ export type ImageCanvasSidebarViewProps = {
onAssetMarqueePointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueeLostPointerCapture: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
updateAssetMoveDropFolder: (folderId: string | null) => void;
addUploadedFiles: (
files: FileList | File[],
@@ -148,6 +151,7 @@ export function ImageCanvasSidebarView({
onAssetMarqueePointerDown,
onAssetMarqueePointerMove,
onAssetMarqueePointerUp,
onAssetMarqueeLostPointerCapture,
updateAssetMoveDropFolder,
addUploadedFiles,
requestUpload,
@@ -335,6 +339,7 @@ export function ImageCanvasSidebarView({
onAssetMarqueePointerDown={onAssetMarqueePointerDown}
onAssetMarqueePointerMove={onAssetMarqueePointerMove}
onAssetMarqueePointerUp={onAssetMarqueePointerUp}
onAssetMarqueeLostPointerCapture={onAssetMarqueeLostPointerCapture}
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
addUploadedFiles={addUploadedFiles}
requestUpload={requestUpload}
@@ -139,6 +139,7 @@ export function useImageCanvasAssetLibrary({
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
handleAssetMarqueeLostPointerCapture,
} = useImageCanvasAssetSelection({
assetListRef,
assets,
@@ -539,6 +540,7 @@ export function useImageCanvasAssetLibrary({
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
handleAssetMarqueeLostPointerCapture,
refreshAssetLibrary,
};
}
@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */
import { act, renderHook, waitFor } from '@testing-library/react';
import { createRef } from 'react';
import { createRef, type PointerEvent as ReactPointerEvent } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { EditorAsset } from './ImageCanvasEditorTypes';
@@ -229,4 +229,50 @@ describe('useImageCanvasAssetSelection', () => {
expect(result.current.isAssetSelectionMode).toBe(false);
expect(result.current.selectedAssetIds.size).toBe(0);
});
it('clears only the matching marquee when pointer capture is lost', () => {
const assetList = document.createElement('div');
const releasePointerCapture = vi.fn();
Object.assign(assetList, {
setPointerCapture: vi.fn(),
releasePointerCapture,
});
const assetListRef = { current: assetList };
const { result } = renderHook(() =>
useImageCanvasAssetSelection({
assetListRef,
assets: [createAsset()],
}),
);
const target = document.createElement('div');
act(() => result.current.openAssetSelectionMode());
act(() =>
result.current.handleAssetMarqueePointerDown({
target,
button: 0,
pointerId: 7,
clientX: 10,
clientY: 20,
ctrlKey: false,
metaKey: false,
preventDefault: vi.fn(),
} as unknown as ReactPointerEvent<HTMLDivElement>),
);
expect(result.current.assetMarquee?.pointerId).toBe(7);
act(() =>
result.current.handleAssetMarqueeLostPointerCapture({
pointerId: 8,
} as ReactPointerEvent<HTMLDivElement>),
);
expect(result.current.assetMarquee?.pointerId).toBe(7);
act(() =>
result.current.handleAssetMarqueeLostPointerCapture({
pointerId: 7,
} as ReactPointerEvent<HTMLDivElement>),
);
expect(result.current.assetMarquee).toBeNull();
expect(releasePointerCapture).not.toHaveBeenCalled();
});
});
@@ -433,6 +433,15 @@ export function useImageCanvasAssetSelection({
[assetListRef, assetMarquee],
);
const handleAssetMarqueeLostPointerCapture = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
setAssetMarquee((currentMarquee) =>
currentMarquee?.pointerId === event.pointerId ? null : currentMarquee,
);
},
[],
);
return {
selectableAssets,
selectedAssets,
@@ -447,5 +456,6 @@ export function useImageCanvasAssetSelection({
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
handleAssetMarqueeLostPointerCapture,
};
}