diff --git a/TRACKING.md b/TRACKING.md index 485aa786..b6fe00a2 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -139,3 +139,4 @@ - 2026-06-17 前端拆分第二十一阶段:新增 `useImageCanvasKeyboardShortcuts`,把 Ctrl / Cmd + Z 撤销、Ctrl / Cmd + Shift + Z 重做、Shift 状态、Backspace / Delete 删除、Escape 关闭临时面板和 Space 临时抓手从主视图抽出;主视图继续注入图层删除、生成对话框、快速编辑和 chrome 面板 setter。新增 hook 单测覆盖输入框忽略快捷键、删除选中图层、删除生成占位、Escape 保留生成中面板、Space 和 Shift;主视图从 1337 行降至 1250 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录直接弹出 `账号入口` 且未抢跑 `/api/editor/*`,登录后 `/api/editor/assets/library` 和 `/api/editor/projects/recent` 为 200,`AI画布工具栏` 与 `画布面板入口` 可见,viewport 背景为 `rgb(248, 250, 252)` 且 `background-image: none`;按住 Space 从 `文字工具` 临时切到 `抓手工具`,松开恢复 `文字工具`;`画布背景设置` 点击 `暖灰` 后背景变为 `rgb(243, 240, 234)`;点击 `生成工具` 后 `Image Generator` 占位框、`生成图片` 对话框和 `AI画布工具栏` 同时可见,登录后控制台无前端 error。 - 2026-06-17 前端拆分第二十二阶段:新增 `useImageCanvasAssetPointerDragBridge`,把素材卡片 pointer 拖拽到画布 / 文件夹的全局监听、拖拽激活、画布 drop 提示、文件夹高亮、移动素材、加入画布和点击抑制从主视图抽出;主视图继续负责素材库事实、画布建层、历史和工程资源持久化。同步恢复 `账号入口` 认证弹窗 portal 渲染,避免全屏画布遮住登录弹窗;`画布背景设置` 调整为独立白色浮层,包含当前色、色域、色相、自定义颜色、预设网格、HEX 输入和恢复默认。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 未登录打开直接显示 `账号入口`;登录临时开发账号后 `画布背景设置` 显示当前色、色域、色相、自定义颜色、预设、HEX 和恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;上传图片进入 `项目素材`,真实 pointer 拖拽素材到画布后图层数从 0 到 1 且 `AI画布工具栏` 保持可见;新建 `SmokeFolder` 后真实 pointer 拖拽素材到文件夹,`项目素材` 变 0、`SmokeFolder` 变 1,素材执行移动而非拷贝。控制台仅有未登录 refresh 401,登录后编辑器 API 均为 200。 - 2026-06-17 前端拆分第二十三阶段:新增 `ImageCanvasOverlayModel`,把生成输入框锚定、图标素材生成面板宽度、选中图片工具栏边界、快速编辑面板和角色动画面板定位从主视图抽出为纯模型;主视图继续保留生成 / quick edit / 角色动画状态机和舞台编排。新增模型单测覆盖锚定优先级、生成中隐藏、icon 宽度、工具栏 clamp、quick edit / 角色动画边界和生成 dialog 模式识别;主视图从 1182 行降至 1133 行。验证命令:`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run test -- src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 未登录打开显示 `账号入口`,登录后 `画布背景设置` 点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见;素材库已有素材真实 pointer 拖入画布后图层数从 0 到 1,工具栏保持可见,截图留存于 `output/playwright/editor-overlay-model-regression-20260617.png`。 +- 2026-06-17 前端拆分第二十四阶段:新增 `useImageCanvasAssetCanvasBridge`,把删除素材清理画布图层、素材加入画布、素材 pointer 拖入画布 / 文件夹、画布 drag/drop 分流和文件拖入画布参数组装从主视图抽成素材到画布桥接 hook;主视图继续保留素材库事实、上传读取、工程资源持久化和历史触发。新增 hook 单测覆盖素材建层、pointer drop 入画布和删除素材清理关联图层 / 选中态;主视图从 1133 行降至 1086 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 清空会话后未登录直接显示 `账号入口`;登录临时开发账号后 `画布背景设置` 是完整设置面板,包含当前色、色域、色相、自定义颜色、预设、HEX 和恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,HEX 输入 `#ffffff` 后变为白色,按 Escape 关闭面板;登录后上传图片请求 `/api/editor/assets` 返回 200,素材库出现上传素材,`AI画布工具栏` 保持可见。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 2ca1b736..ca76110d 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -199,6 +199,14 @@ - 该模块用独立单测覆盖生成结果优先锚定、生成中 / 手动关闭隐藏输入框、icon 描述项宽度、图片工具栏 clamp、quick edit 和角色动画面板边界,以及画布生成 dialog 模式识别。 - 本阶段主视图从 1182 行降至 1133 行;下一步若继续拆分,可在该模型基础上抽更大的 `useImageCanvasStageController`,承接舞台派生状态、右键菜单和工具切换胶水。 +## 第二十四阶段模块 + +- `useImageCanvasAssetCanvasBridge.ts` + - 承载素材库与画布之间的桥接工作流:删除素材时清理关联画布图层、素材加入画布创建图层、素材 pointer 拖入画布 / 文件夹、画布区域 drag over / leave / drop 分流,以及拖拽文件上传到画布的参数组装。 + - 主视图继续掌握素材库事实、上传文件读取、工程资源持久化、历史捕获触发时机和实际 API 副作用;该 hook 只负责把已有素材 / 文件 drop 转成画布动作,不反向读取路由、登录态或项目数据。 + - 该 hook 用独立单测覆盖素材建层、pointer drop 入画布和删除素材清理关联图层 / 选中态;原有 pointer drag 和 canvas drop hook 单测继续保留,主视图 DOM 测试继续覆盖真实素材库拖入画布路径。 + - 本阶段主视图从 1133 行降至 1086 行;下一步可继续抽 `useImageCanvasStageController` 或顶栏视图,但应优先选择能收敛舞台派生状态和右键菜单胶水的深边界。 + ## 后续阶段 - 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。 @@ -206,7 +214,7 @@ ## 验证计划 -- `npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx` +- `npm run test -- src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx` - `npm run typecheck` - `npm run check:encoding` - `git diff --check` diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index b534a315..004c0739 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -21,9 +21,7 @@ import { import { ImageCanvasSidebarView } from './ImageCanvasSidebarView'; import { ImageCanvasStageView } from './ImageCanvasStageView'; import { - createLayerFromAsset, isGeneratedLayer, - isLayerLinkedToAsset, resolveContextMenuPosition, } from './ImageCanvasEditorModel'; import { @@ -46,15 +44,16 @@ import type { CanvasLayer, CanvasTool, CanvasViewport, - EditorAsset, ImageContextMenuState, } from './ImageCanvasEditorTypes'; import { useCanvasHistory } from './useCanvasHistory'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary'; +import { + useImageCanvasAssetCanvasBridge, + useImageCanvasAssetLayerCleanup, +} from './useImageCanvasAssetCanvasBridge'; import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow'; -import { useImageCanvasAssetPointerDragBridge } from './useImageCanvasAssetPointerDragBridge'; -import { useImageCanvasCanvasDropWorkflow } from './useImageCanvasCanvasDropWorkflow'; import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome'; import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow'; import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts'; @@ -178,41 +177,12 @@ export function ImageCanvasEditorView() { toggleBackgroundSettings, toggleMinimap, } = useImageCanvasEditorChrome({ openEditorLoginModal }); - const removeCanvasLayersLinkedToAssets = useCallback( - (deletedAssets: EditorAsset[]) => { - if (!deletedAssets.length) { - return; - } - setLayers((currentLayers) => - currentLayers.filter( - (layer) => - !deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), - ), - ); - setSelectedLayerIds((currentIds) => - currentIds.filter((layerId) => - layers.every( - (layer) => - layer.id !== layerId || - !deletedAssets.some((asset) => - isLayerLinkedToAsset(layer, asset), - ), - ), - ), - ); - setSelectedLayerId((currentId) => { - if (!currentId) { - return currentId; - } - const currentLayer = layers.find((layer) => layer.id === currentId); - return currentLayer && - deletedAssets.some((asset) => isLayerLinkedToAsset(currentLayer, asset)) - ? null - : currentId; - }); - }, - [layers], - ); + const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({ + layers, + setLayers, + setSelectedLayerId, + setSelectedLayerIds, + }); const { assetFolders, setAssetFolders, @@ -664,53 +634,36 @@ export function ImageCanvasEditorView() { }; }, []); - const addAssetLayer = ( - asset: EditorAsset, - position?: { x: number; y: number }, - ) => { - setActiveUploadFolderId(asset.folderId); - layerCounterRef.current += 1; - const nextLayer = createLayerFromAsset( - asset, - layerCounterRef.current, - viewport, - { - x: position?.x ?? canvasSize.width / 2, - y: position?.y ?? canvasSize.height / 2, - }, - ); - captureCanvasHistory(); - appendCanvasLayersWithResources([nextLayer]); - selectSingleLayer(nextLayer.id); - setHoveredLayerId(null); - }; - - useImageCanvasAssetPointerDragBridge({ + const { + addAssetLayer, + handleCanvasDragOver, + handleCanvasDragLeave, + handleCanvasDrop, + } = useImageCanvasAssetCanvasBridge({ assetPointerDragRef, suppressAssetClickRef, assets, + assetFolders, + layerCounterRef, + viewport, + canvasSize, resolveAssetFolderId, resolveCanvasPoint, + getCanvasDropPoint, setAssetPointerDrag, + setActiveUploadFolderId, setUploadDropTarget, + setHoveredLayerId, updateAssetMoveDropFolder, moveAssetToFolder, - addAssetLayer, + captureCanvasHistory, + appendCanvasLayersWithResources, + selectSingleLayer, + addUploadedFiles, }); deleteLayerByIdRef.current = deleteLayerById; - const { handleCanvasDragOver, handleCanvasDragLeave, handleCanvasDrop } = - useImageCanvasCanvasDropWorkflow({ - assets, - assetFolders, - setUploadDropTarget, - updateAssetMoveDropFolder, - getCanvasDropPoint, - addAssetLayer, - addUploadedFiles, - }); - const handleCanvasContextMenu = (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); diff --git a/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx b/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx new file mode 100644 index 00000000..6b38dcfe --- /dev/null +++ b/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx @@ -0,0 +1,252 @@ +/* @vitest-environment jsdom */ + +import { act, render, screen } from '@testing-library/react'; +import { useRef, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { + AssetPointerDragState, + CanvasLayer, + EditorAsset, + EditorAssetFolder, +} from './ImageCanvasEditorTypes'; +import { + useImageCanvasAssetCanvasBridge, + useImageCanvasAssetLayerCleanup, +} from './useImageCanvasAssetCanvasBridge'; + +const defaultAsset: EditorAsset = { + id: 'asset-1', + label: '素材一', + src: 'data:image/png;base64,asset-1', + width: 320, + height: 240, + folderId: 'project', + sourceKind: 'uploaded', + sourceType: 'uploaded', + persisted: true, + assetObjectId: 'asset-object-1', +}; + +const defaultFolder: EditorAssetFolder = { + id: 'project', + label: '项目素材', + collapsed: false, + systemDefault: true, + persisted: true, +}; + +function createLayer(overrides: Partial = {}): CanvasLayer { + return { + id: 'layer-asset-1', + resourceId: 'asset-object-1', + title: '素材一', + src: 'data:image/png;base64,asset-1', + x: 10, + y: 20, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + sourceType: 'uploaded', + sourceAssetId: 'asset-1', + ...overrides, + }; +} + +function dispatchPointerEvent( + type: string, + init: MouseEventInit & { pointerId: number }, +) { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + ...init, + }); + Object.defineProperty(event, 'pointerId', { value: init.pointerId }); + window.dispatchEvent(event); +} + +function AssetCanvasBridgeHarness({ + asset = defaultAsset, + resolveCanvasPoint = vi.fn(() => null), + appendCanvasLayersWithResources = vi.fn(), + captureCanvasHistory = vi.fn(), + selectSingleLayer = vi.fn(), +}: { + asset?: EditorAsset; + resolveCanvasPoint?: (clientX: number, clientY: number) => { + x: number; + y: number; + } | null; + appendCanvasLayersWithResources?: (nextLayers: CanvasLayer[]) => void; + captureCanvasHistory?: () => void; + selectSingleLayer?: (layerId: string | null) => void; +}) { + const assetPointerDragRef = useRef({ + assetId: asset.id, + pointerId: 7, + startClientX: 10, + startClientY: 10, + currentClientX: 40, + currentClientY: 40, + active: true, + dropFolderId: null, + }); + const suppressAssetClickRef = useRef(false); + const layerCounterRef = useRef(0); + const [activeUploadFolderId, setActiveUploadFolderId] = useState('project'); + const [hoveredLayerId, setHoveredLayerId] = useState('hovered'); + const [assetPointerDrag, setAssetPointerDrag] = + useState(assetPointerDragRef.current); + const [uploadDropTarget, setUploadDropTarget] = useState< + 'canvas' | 'assets' | null + >(null); + + const bridge = useImageCanvasAssetCanvasBridge({ + assetPointerDragRef, + suppressAssetClickRef, + assets: [asset], + assetFolders: [defaultFolder], + layerCounterRef, + viewport: { x: 10, y: 20, scale: 2 }, + canvasSize: { width: 900, height: 640 }, + resolveAssetFolderId: () => null, + resolveCanvasPoint, + getCanvasDropPoint: (clientX, clientY) => ({ + x: clientX - 10, + y: clientY - 20, + }), + setAssetPointerDrag, + setActiveUploadFolderId, + setUploadDropTarget, + setHoveredLayerId, + updateAssetMoveDropFolder: vi.fn(), + moveAssetToFolder: vi.fn(), + captureCanvasHistory, + appendCanvasLayersWithResources, + selectSingleLayer, + addUploadedFiles: vi.fn(), + }); + + return ( +
+ + {activeUploadFolderId} + {hoveredLayerId ?? '-'} + {assetPointerDrag ? 'dragging' : 'none'} + {uploadDropTarget ?? '-'} +
+ ); +} + +function AssetCleanupHarness({ + deletedAssets = [], +}: { + deletedAssets?: EditorAsset[]; +}) { + const [layers, setLayers] = useState([ + createLayer({ id: 'linked', sourceAssetId: 'asset-1' }), + createLayer({ + id: 'kept', + resourceId: 'asset-object-2', + src: 'data:image/png;base64,asset-2', + sourceAssetId: 'asset-2', + }), + ]); + const [selectedLayerId, setSelectedLayerId] = useState('linked'); + const [selectedLayerIds, setSelectedLayerIds] = useState(['linked', 'kept']); + const cleanup = useImageCanvasAssetLayerCleanup({ + layers, + setLayers, + setSelectedLayerId, + setSelectedLayerIds, + }); + + return ( +
+ + {layers.map((layer) => layer.id).join(',')} + {selectedLayerId ?? '-'} + {selectedLayerIds.join(',')} +
+ ); +} + +describe('useImageCanvasAssetCanvasBridge', () => { + it('creates a canvas layer from an asset and clears hover state', () => { + const appendCanvasLayersWithResources = vi.fn(); + const captureCanvasHistory = vi.fn(); + const selectSingleLayer = vi.fn(); + render( + , + ); + + act(() => { + screen.getByRole('button', { name: '添加到画布' }).click(); + }); + + expect(captureCanvasHistory).toHaveBeenCalledTimes(1); + expect(appendCanvasLayersWithResources).toHaveBeenCalledWith([ + expect.objectContaining({ + id: 'layer-asset-1-1', + sourceAssetId: 'asset-1', + x: 94, + y: 64, + width: 320, + height: 240, + }), + ]); + expect(selectSingleLayer).toHaveBeenCalledWith('layer-asset-1-1'); + expect(screen.getByTestId('folder').textContent).toBe('project'); + expect(screen.getByTestId('hover').textContent).toBe('-'); + }); + + it('delegates active pointer drops over the canvas into the layer path', () => { + const appendCanvasLayersWithResources = vi.fn(); + render( + ({ x: 480, y: 640 })} + appendCanvasLayersWithResources={appendCanvasLayersWithResources} + />, + ); + + act(() => { + dispatchPointerEvent('pointerup', { + pointerId: 7, + clientX: 48, + clientY: 64, + }); + }); + + expect(appendCanvasLayersWithResources).toHaveBeenCalledWith([ + expect.objectContaining({ sourceAssetId: 'asset-1' }), + ]); + expect(screen.getByTestId('drag').textContent).toBe('none'); + expect(screen.getByTestId('drop-target').textContent).toBe('-'); + }); + + it('removes canvas layers linked to deleted assets and keeps unrelated selection', () => { + render(); + + act(() => { + screen.getByRole('button', { name: '清理素材' }).click(); + }); + + expect(screen.getByTestId('layers').textContent).toBe('kept'); + expect(screen.getByTestId('selected').textContent).toBe('-'); + expect(screen.getByTestId('selected-many').textContent).toBe('kept'); + }); +}); diff --git a/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts b/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts new file mode 100644 index 00000000..fd93a600 --- /dev/null +++ b/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts @@ -0,0 +1,191 @@ +import { + type Dispatch, + type MutableRefObject, + type RefObject, + type SetStateAction, + useCallback, + useMemo, +} from 'react'; + +import { + createLayerFromAsset, + isLayerLinkedToAsset, +} from './ImageCanvasEditorModel'; +import type { + AssetPointerDragState, + CanvasLayer, + CanvasViewport, + EditorAsset, + EditorAssetFolder, +} from './ImageCanvasEditorTypes'; +import { useImageCanvasAssetPointerDragBridge } from './useImageCanvasAssetPointerDragBridge'; +import { useImageCanvasCanvasDropWorkflow } from './useImageCanvasCanvasDropWorkflow'; + +type CanvasPoint = { x: number; y: number }; + +type UploadFilesToCanvasOptions = { + folderId?: string; + canvasPoint: CanvasPoint; + addToCanvas: true; +}; + +type UseImageCanvasAssetLayerCleanupOptions = { + layers: CanvasLayer[]; + setLayers: Dispatch>; + setSelectedLayerId: Dispatch>; + setSelectedLayerIds: Dispatch>; +}; + +type UseImageCanvasAssetCanvasBridgeOptions = { + assetPointerDragRef: RefObject; + suppressAssetClickRef: RefObject; + assets: EditorAsset[]; + assetFolders: EditorAssetFolder[]; + layerCounterRef: MutableRefObject; + viewport: CanvasViewport; + canvasSize: { width: number; height: number }; + resolveAssetFolderId: (clientX: number, clientY: number) => string | null; + resolveCanvasPoint: (clientX: number, clientY: number) => CanvasPoint | null; + getCanvasDropPoint: (clientX: number, clientY: number) => CanvasPoint; + setAssetPointerDrag: Dispatch>; + setActiveUploadFolderId: Dispatch>; + setUploadDropTarget: Dispatch>; + setHoveredLayerId: Dispatch>; + updateAssetMoveDropFolder: (folderId: string | null) => void; + moveAssetToFolder: (assetId: string, folderId: string) => void; + captureCanvasHistory: () => void; + appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void; + selectSingleLayer: (layerId: string | null) => void; + addUploadedFiles: ( + files: FileList | File[], + options: UploadFilesToCanvasOptions, + ) => void; +}; + +export function useImageCanvasAssetLayerCleanup({ + layers, + setLayers, + setSelectedLayerId, + setSelectedLayerIds, +}: UseImageCanvasAssetLayerCleanupOptions) { + return useCallback( + (deletedAssets: EditorAsset[]) => { + if (!deletedAssets.length) { + return; + } + setLayers((currentLayers) => + currentLayers.filter( + (layer) => + !deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), + ), + ); + setSelectedLayerIds((currentIds) => + currentIds.filter((layerId) => + layers.every( + (layer) => + layer.id !== layerId || + !deletedAssets.some((asset) => + isLayerLinkedToAsset(layer, asset), + ), + ), + ), + ); + setSelectedLayerId((currentId) => { + if (!currentId) { + return currentId; + } + const currentLayer = layers.find((layer) => layer.id === currentId); + return currentLayer && + deletedAssets.some((asset) => isLayerLinkedToAsset(currentLayer, asset)) + ? null + : currentId; + }); + }, + [layers, setLayers, setSelectedLayerId, setSelectedLayerIds], + ); +} + +export function useImageCanvasAssetCanvasBridge({ + assetPointerDragRef, + suppressAssetClickRef, + assets, + assetFolders, + layerCounterRef, + viewport, + canvasSize, + resolveAssetFolderId, + resolveCanvasPoint, + getCanvasDropPoint, + setAssetPointerDrag, + setActiveUploadFolderId, + setUploadDropTarget, + setHoveredLayerId, + updateAssetMoveDropFolder, + moveAssetToFolder, + captureCanvasHistory, + appendCanvasLayersWithResources, + selectSingleLayer, + addUploadedFiles, +}: UseImageCanvasAssetCanvasBridgeOptions) { + const addAssetLayer = useCallback( + (asset: EditorAsset, position?: CanvasPoint) => { + setActiveUploadFolderId(asset.folderId); + layerCounterRef.current += 1; + const nextLayer = createLayerFromAsset( + asset, + layerCounterRef.current, + viewport, + { + x: position?.x ?? canvasSize.width / 2, + y: position?.y ?? canvasSize.height / 2, + }, + ); + captureCanvasHistory(); + appendCanvasLayersWithResources([nextLayer]); + selectSingleLayer(nextLayer.id); + setHoveredLayerId(null); + }, + [ + appendCanvasLayersWithResources, + canvasSize.height, + canvasSize.width, + captureCanvasHistory, + layerCounterRef, + selectSingleLayer, + setActiveUploadFolderId, + setHoveredLayerId, + viewport, + ], + ); + + useImageCanvasAssetPointerDragBridge({ + assetPointerDragRef, + suppressAssetClickRef, + assets, + resolveAssetFolderId, + resolveCanvasPoint, + setAssetPointerDrag, + setUploadDropTarget, + updateAssetMoveDropFolder, + moveAssetToFolder, + addAssetLayer, + }); + + const canvasDropWorkflow = useImageCanvasCanvasDropWorkflow({ + assets, + assetFolders, + setUploadDropTarget, + updateAssetMoveDropFolder, + getCanvasDropPoint, + addAssetLayer, + addUploadedFiles, + }); + + return useMemo( + () => ({ + addAssetLayer, + ...canvasDropWorkflow, + }), + [addAssetLayer, canvasDropWorkflow], + ); +}