From 7570134f00b6fbe1269c3645185c9c56643b143f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 31 Jul 2026 20:17:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=B4=A0=E6=9D=90=E6=A1=86?= =?UTF-8?q?=E9=80=89=E6=8D=95=E8=8E=B7=E4=B8=A2=E5=A4=B1=E5=90=8E=E7=9A=84?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 lostpointercapture 指针匹配清理且不重复释放捕获 贯通素材选择 Hook 到素材列表的丢失捕获事件 补充生命周期回归测试和前端拆分文档 --- ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 2 +- .../ImageCanvasAssetLibraryPanelView.tsx | 5 ++ .../ImageCanvasEditorShellView.test.tsx | 1 + .../image-editor/ImageCanvasEditorView.tsx | 2 + .../ImageCanvasSidebarView.test.tsx | 1 + .../image-editor/ImageCanvasSidebarView.tsx | 5 ++ .../useImageCanvasAssetLibrary.ts | 2 + .../useImageCanvasAssetSelection.test.tsx | 48 ++++++++++++++++++- .../useImageCanvasAssetSelection.ts | 10 ++++ 9 files changed, 74 insertions(+), 2 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 5252d1513..972f3c997 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -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 不再维护第二套选择状态机。 ## 第十一阶段模块 diff --git a/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx b/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx index 19069ec12..e20f4e122 100644 --- a/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx +++ b/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx @@ -80,6 +80,9 @@ export type ImageCanvasAssetLibraryPanelViewProps = { onAssetMarqueePointerDown: (event: ReactPointerEvent) => void; onAssetMarqueePointerMove: (event: ReactPointerEvent) => void; onAssetMarqueePointerUp: (event: ReactPointerEvent) => void; + onAssetMarqueeLostPointerCapture: ( + event: ReactPointerEvent, + ) => 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 ? (
) => void; onAssetMarqueePointerMove: (event: ReactPointerEvent) => void; onAssetMarqueePointerUp: (event: ReactPointerEvent) => void; + onAssetMarqueeLostPointerCapture: ( + event: ReactPointerEvent, + ) => 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} diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.ts b/src/components/image-editor/useImageCanvasAssetLibrary.ts index f0589881c..38f7c69ff 100644 --- a/src/components/image-editor/useImageCanvasAssetLibrary.ts +++ b/src/components/image-editor/useImageCanvasAssetLibrary.ts @@ -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, }; } diff --git a/src/components/image-editor/useImageCanvasAssetSelection.test.tsx b/src/components/image-editor/useImageCanvasAssetSelection.test.tsx index f11f8ddfc..3922e92cf 100644 --- a/src/components/image-editor/useImageCanvasAssetSelection.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetSelection.test.tsx @@ -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), + ); + expect(result.current.assetMarquee?.pointerId).toBe(7); + + act(() => + result.current.handleAssetMarqueeLostPointerCapture({ + pointerId: 8, + } as ReactPointerEvent), + ); + expect(result.current.assetMarquee?.pointerId).toBe(7); + act(() => + result.current.handleAssetMarqueeLostPointerCapture({ + pointerId: 7, + } as ReactPointerEvent), + ); + expect(result.current.assetMarquee).toBeNull(); + expect(releasePointerCapture).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/image-editor/useImageCanvasAssetSelection.ts b/src/components/image-editor/useImageCanvasAssetSelection.ts index a8e58656d..219b7f5e8 100644 --- a/src/components/image-editor/useImageCanvasAssetSelection.ts +++ b/src/components/image-editor/useImageCanvasAssetSelection.ts @@ -433,6 +433,15 @@ export function useImageCanvasAssetSelection({ [assetListRef, assetMarquee], ); + const handleAssetMarqueeLostPointerCapture = useCallback( + (event: ReactPointerEvent) => { + setAssetMarquee((currentMarquee) => + currentMarquee?.pointerId === event.pointerId ? null : currentMarquee, + ); + }, + [], + ); + return { selectableAssets, selectedAssets, @@ -447,5 +456,6 @@ export function useImageCanvasAssetSelection({ handleAssetMarqueePointerDown, handleAssetMarqueePointerMove, handleAssetMarqueePointerUp, + handleAssetMarqueeLostPointerCapture, }; }