diff --git a/TRACKING.md b/TRACKING.md index 3d010f0e..9cfad3ae 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -128,3 +128,4 @@ - 2026-06-17 前端拆分第十一阶段:新增 `ImageCanvasFileModel` 和 `useImageCanvasUploadWorkflow`,把隐藏上传 input、上传目标分发、未登录续传、上传占位卡片、素材落库、拖到画布建层、生成参考图上传从主视图抽出;主视图保留画布 drop 外层判断和项目资源持久化注入。验证命令:`npm run test -- src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.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` 未登录刷新弹出 `账号入口`,登录临时开发账号后 `画布背景设置` 面板点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后显示 `Image Generator` 占位框和 `生成图片` 对话框且 `AI画布工具栏` 保持可见;上传图片后素材数增加,点击素材加入画布,切换 `图层` 面板可看到 2 个图层,登录后控制台无前端 error。 - 2026-06-17 前端拆分第十二阶段:新增 `ImageCanvasGenerationLayerModel`,把普通生图、修改图片、快速编辑和图标素材批量生成结果落画布的图层 id、临时 resourceId、标题、位置、原始分辨率尺寸、zIndex、source metadata、源图关联和 `generationInputs` 纯规则从主视图抽出;主视图继续负责 API 提交、生成对象状态、资源持久化、选中态、侧栏和适合视图副作用。验证命令:`npm run test -- src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts 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` 清空会话后未登录刷新弹出 `账号入口`,登录临时开发账号后 `画布背景设置` 面板保留色相 / 自定义颜色 / 预设 / HEX / 恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后显示 `Image Generator` 占位框和 `生成图片` 对话框且 `AI画布工具栏` 保持可见;真实上传图片后素材数从 2 增至 3,登录后控制台无前端 error。 - 2026-06-17 前端拆分第十三阶段:新增 `useImageCanvasAssetExportWorkflow`,把画布素材导出状态、单图右键导出、整包 ZIP 组包、图片去重、读取失败记录、metadata / manifest 和下载链接副作用从主视图抽出;主视图保留右键目标解析和状态提示渲染。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetExportWorkflow.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` 清空会话后未登录刷新弹出 `账号入口`,登录临时开发账号后下载按钮启用,点击后触发真实下载 `未命名画布-画布素材-20260617.zip` 并显示导出状态;背景设置点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后 `生成图片` 对话框出现且 `AI画布工具栏` 保持可见,登录后控制台无前端 error。 +- 2026-06-17 前端拆分第十四阶段:新增 `useImageCanvasLayerCommands`,把画布剪贴板、右键目标解析、复制 / 剪切 / 粘贴、创建副本、层级移动、分组 / 解组、显隐、锁定、翻转、删除选中图层、按 id 删除和单图导出委托从主视图抽出;主视图保留菜单定位、画布事件、生成、上传、项目持久化和实际导出下载。验证命令:`npm run test -- src/components/image-editor/useImageCanvasLayerCommands.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` 清空会话后未登录刷新弹出 `账号入口`,关闭后 `画布背景色` 打开完整 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后 `Image Generator` 占位框、`生成图片` 对话框和 `AI画布工具栏` 均可见;登录临时开发账号后新标签素材、画布图层、返回项目入口、小地图和底部工具栏可见,控制台无前端 error。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 11192fda..e2884f12 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -124,6 +124,13 @@ - 主视图继续负责右键菜单目标解析、下载按钮渲染和状态提示展示;导出 hook 不接管图层选择、右键菜单生命周期或画布状态。 - 该 hook 用独立单测覆盖 ZIP 内容、重复图层复用同一图片文件、失败图片 metadata、manifest 失败数量、空画布提示和单图文件名清理。 +## 第十四阶段模块 + +- `useImageCanvasLayerCommands.ts` + - 承载图层命令工作流:画布剪贴板、右键目标解析、复制 / 剪切 / 粘贴、创建副本、层级移动、分组 / 解组、显示 / 隐藏、锁定 / 解锁、翻转、删除选中图层、按 id 删除图层和单图导出委托。 + - 主视图继续负责右键菜单定位、画布事件、生成提交、素材上传、项目持久化和实际下载实现;hook 只接收必要 setter 与副作用回调,不反向读取 DOM 或路由。 + - 该 hook 用独立单测覆盖菜单关闭、历史捕获、选中态更新、删除副作用、剪贴板和导出委托,避免主视图后续继续堆积右键菜单 orchestration。 + ## 后续阶段 - 生成工作流 hook:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再抽出 `useImageCanvasGenerationWorkflow` 这类深 hook;它应整体承接打开入口、提交状态、API 调用、错误映射和结果落图协调,而不是把主视图拆成大量 setter 透传。 @@ -131,7 +138,7 @@ ## 验证计划 -- `npm run test -- src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx` +- `npm run test -- src/components/image-editor/useImageCanvasLayerCommands.test.tsx src/components/image-editor/ImageCanvasEditorView.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 c3153e9f..e2655fb5 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -46,20 +46,8 @@ import { zoomViewportFromWheel, } from './ImageCanvasInteractionModel'; import { - createCanvasLayerClipboard, - duplicateCanvasLayers, - flipCanvasLayers, getCanvasLayersByIds, - groupCanvasLayers, - moveCanvasLayers, - removeCanvasLayers, resolveContextTargetLayerIds, - toggleCanvasLayersLock, - toggleCanvasLayersVisibility, - ungroupCanvasLayers, - updateCanvasLayersByIds, - type CanvasLayerFlipAxis, - type CanvasLayerMoveMode, } from './ImageCanvasLayerCommandModel'; import { ImageCanvasSidebarView } from './ImageCanvasSidebarView'; import { ImageCanvasStageView } from './ImageCanvasStageView'; @@ -122,7 +110,6 @@ import { } from './ImageCanvasGenerationModel'; import type { AssetPointerDragState, - CanvasClipboard, CanvasContextMenuState, CanvasGenerationDialogState, CanvasGenerationInputs, @@ -145,6 +132,7 @@ import { useCanvasHistory } from './useCanvasHistory'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary'; import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow'; +import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow'; @@ -288,8 +276,6 @@ export function ImageCanvasEditorView() { const [contextMenu, setContextMenu] = useState( null, ); - const [canvasClipboard, setCanvasClipboard] = - useState(null); const [quickEditPanel, setQuickEditPanel] = useState(null); const [characterAnimationPanel, setCharacterAnimationPanel] = @@ -681,6 +667,58 @@ export function ImageCanvasEditorView() { projectId, projectTitle, }); + const handleDeletedLayerSideEffects = useCallback( + (targetLayerId: string) => { + setQuickEditPanel((currentPanel) => + currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel, + ); + setCharacterAnimationPanel((currentPanel) => + currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel, + ); + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'edit' && + currentDialog.sourceLayerId === targetLayerId + ? null + : currentDialog, + ); + removeCanvasGenerationDialogsByLayerId(targetLayerId); + }, + [removeCanvasGenerationDialogsByLayerId, setGenerateDialog], + ); + const { + canvasClipboard, + pasteCanvasClipboard, + copyContextLayers, + duplicateContextLayers, + moveContextLayers, + groupContextLayers, + ungroupContextLayers, + toggleContextLayerVisibility, + toggleContextLayerLock, + flipContextLayers, + deleteContextLayers, + exportContextLayer, + deleteLayerById, + deleteSelectedLayer, + groupSelectedLayers, + } = useImageCanvasLayerCommands({ + layers, + contextMenu, + selectedLayerId, + selectedLayerIds, + setLayers, + setSelectedLayerId, + setSelectedLayerIds, + setHoveredLayerId, + setMetadataLayer, + setContextMenu, + setImageContextMenu, + setActiveTool, + captureCanvasHistory, + selectSingleLayer, + onDeleteLayerSideEffects: handleDeletedLayerSideEffects, + exportLayerImage, + }); const { uploadInputRef, setUploadTarget, @@ -1024,187 +1062,6 @@ export function ImageCanvasEditorView() { }); }; - const duplicateLayersToPoint = ( - sourceLayers: CanvasLayer[], - canvasPoint?: { x: number; y: number }, - options: { renameCopies?: boolean } = {}, - ) => - duplicateCanvasLayers({ - sourceLayers, - allLayers: layersRef.current, - canvasPoint, - renameCopies: options.renameCopies !== false, - }); - - const pasteCanvasClipboard = (canvasPoint?: { x: number; y: number }) => { - if (!canvasClipboard?.layers.length) { - return; - } - const nextLayers = duplicateLayersToPoint( - canvasClipboard.layers, - canvasPoint, - { - renameCopies: canvasClipboard.mode !== 'cut', - }, - ); - if (!nextLayers.length) { - return; - } - captureCanvasHistory(); - setLayers((currentLayers) => [...currentLayers, ...nextLayers]); - setSelectedLayerIds(nextLayers.map((layer) => layer.id)); - setSelectedLayerId(nextLayers[0]?.id ?? null); - setActiveTool('select'); - setContextMenu(null); - }; - - const copyContextLayers = (options: { cut?: boolean } = {}) => { - const targetIds = getContextTargetLayerIds(); - const clipboard = createCanvasLayerClipboard( - layers, - targetIds, - options.cut ? 'cut' : 'copy', - ); - if (!clipboard) { - return; - } - setCanvasClipboard(clipboard); - if (options.cut) { - captureCanvasHistory(); - setLayers((currentLayers) => - removeCanvasLayers(currentLayers, targetIds), - ); - selectSingleLayer(null); - setMetadataLayer((currentLayer) => - currentLayer && targetIds.includes(currentLayer.id) - ? null - : currentLayer, - ); - } - setContextMenu(null); - }; - - const duplicateContextLayers = () => { - const targetIds = getContextTargetLayerIds(); - const targetLayers = getCanvasLayersByIds(layers, targetIds); - const nextLayers = duplicateLayersToPoint(targetLayers); - if (!nextLayers.length) { - return; - } - captureCanvasHistory(); - setLayers((currentLayers) => [...currentLayers, ...nextLayers]); - setSelectedLayerIds(nextLayers.map((layer) => layer.id)); - setSelectedLayerId(nextLayers[0]?.id ?? null); - setContextMenu(null); - }; - - const updateContextLayers = ( - updater: (layer: CanvasLayer, targetIds: string[]) => CanvasLayer, - ) => { - const targetIds = getContextTargetLayerIds(); - if (!targetIds.length) { - return; - } - captureCanvasHistory(); - setLayers((currentLayers) => - updateCanvasLayersByIds(currentLayers, targetIds, updater), - ); - setContextMenu(null); - }; - - const moveContextLayers = (mode: CanvasLayerMoveMode) => { - const targetIds = getContextTargetLayerIds(); - if (!targetIds.length) { - return; - } - captureCanvasHistory(); - setLayers((currentLayers) => - moveCanvasLayers(currentLayers, targetIds, mode), - ); - setContextMenu(null); - }; - - const groupContextLayers = () => { - const targetIds = getContextTargetLayerIds(); - if (!targetIds.length) { - return; - } - const groupId = `layer-group-${Date.now()}`; - captureCanvasHistory(); - setLayers((currentLayers) => - groupCanvasLayers(currentLayers, targetIds, groupId), - ); - setContextMenu(null); - }; - - const ungroupContextLayers = () => { - const targetIds = getContextTargetLayerIds(); - if (!targetIds.length) { - return; - } - captureCanvasHistory(); - setLayers((currentLayers) => ungroupCanvasLayers(currentLayers, targetIds)); - setContextMenu(null); - }; - - const toggleContextLayerVisibility = () => { - const targetIds = getContextTargetLayerIds(); - if (!targetIds.length) { - return; - } - captureCanvasHistory(); - setLayers((currentLayers) => - toggleCanvasLayersVisibility(currentLayers, targetIds), - ); - setContextMenu(null); - }; - - const toggleContextLayerLock = () => { - const targetIds = getContextTargetLayerIds(); - if (!targetIds.length) { - return; - } - captureCanvasHistory(); - setLayers((currentLayers) => - toggleCanvasLayersLock(currentLayers, targetIds), - ); - setContextMenu(null); - }; - - const flipContextLayers = (axis: CanvasLayerFlipAxis) => { - const targetIds = getContextTargetLayerIds(); - if (!targetIds.length) { - return; - } - captureCanvasHistory(); - setLayers((currentLayers) => - flipCanvasLayers(currentLayers, targetIds, axis), - ); - setContextMenu(null); - }; - - const deleteContextLayers = () => { - const targetIds = getContextTargetLayerIds(); - if (!targetIds.length) { - return; - } - captureCanvasHistory(); - setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds)); - selectSingleLayer(null); - setHoveredLayerId(null); - setMetadataLayer((currentLayer) => - currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, - ); - setContextMenu(null); - }; - - const exportContextLayer = () => { - const targetIds = getContextTargetLayerIds(); - const targetLayer = layers.find((layer) => targetIds.includes(layer.id)); - exportLayerImage(targetLayer ?? null); - setContextMenu(null); - }; - const addAssetLayer = ( asset: EditorAsset, position?: { x: number; y: number }, @@ -1318,84 +1175,8 @@ export function ImageCanvasEditorView() { setImageContextMenu(null); }; - const deleteLayerById = (targetLayerId: string | null) => { - if (!targetLayerId) { - return; - } - setImageContextMenu(null); - setContextMenu(null); - captureCanvasHistory(); - setLayers((currentLayers) => { - const nextLayers = currentLayers.filter( - (layer) => layer.id !== targetLayerId, - ); - const nextSelectedLayer = nextLayers - .slice() - .sort((left, right) => right.zIndex - left.zIndex)[0]; - selectSingleLayer(nextSelectedLayer?.id ?? null); - return nextLayers; - }); - setHoveredLayerId(null); - setMetadataLayer((currentLayer) => - currentLayer?.id === targetLayerId ? null : currentLayer, - ); - setQuickEditPanel((currentPanel) => - currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel, - ); - setCharacterAnimationPanel((currentPanel) => - currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel, - ); - setGenerateDialog((currentDialog) => - currentDialog?.mode === 'edit' && - currentDialog.sourceLayerId === targetLayerId - ? null - : currentDialog, - ); - removeCanvasGenerationDialogsByLayerId(targetLayerId); - }; - deleteLayerByIdRef.current = deleteLayerById; - const deleteSelectedLayer = () => { - const targetIds = selectedLayerIds.length - ? selectedLayerIds - : selectedLayerId - ? [selectedLayerId] - : []; - if (targetIds.length <= 1) { - deleteLayerById(targetIds[0] ?? null); - return; - } - captureCanvasHistory(); - setImageContextMenu(null); - setContextMenu(null); - setLayers((currentLayers) => { - const nextLayers = currentLayers.filter( - (layer) => !targetIds.includes(layer.id), - ); - const nextSelectedLayer = nextLayers - .slice() - .sort((left, right) => right.zIndex - left.zIndex)[0]; - selectSingleLayer(nextSelectedLayer?.id ?? null); - return nextLayers; - }); - setHoveredLayerId(null); - setMetadataLayer((currentLayer) => - currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, - ); - setQuickEditPanel((currentPanel) => - currentPanel && targetIds.includes(currentPanel.sourceLayerId) - ? null - : currentPanel, - ); - setCharacterAnimationPanel((currentPanel) => - currentPanel && targetIds.includes(currentPanel.sourceLayerId) - ? null - : currentPanel, - ); - targetIds.forEach(removeCanvasGenerationDialogsByLayerId); - }; - const openGenerateDialog = () => { const placeholderWidth = 420; const placeholderHeight = 420; @@ -2460,29 +2241,6 @@ export function ImageCanvasEditorView() { ); }; - const groupSelectedLayers = () => { - const targetIds = selectedLayerIds.length - ? selectedLayerIds - : selectedLayerId - ? [selectedLayerId] - : []; - if (!targetIds.length) { - return; - } - const groupId = `layer-group-${Date.now()}`; - captureCanvasHistory(); - setLayers((currentLayers) => - currentLayers.map((layer) => - targetIds.includes(layer.id) - ? { - ...layer, - groupId, - } - : layer, - ), - ); - }; - const updateSpecFormValue = (key: keyof SpecFormValues, value: string) => { setGenerateDialog((currentDialog) => { if (currentDialog?.mode !== 'spec') { diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx new file mode 100644 index 00000000..7202fa74 --- /dev/null +++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx @@ -0,0 +1,279 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { + CanvasContextMenuState, + CanvasLayer, +} from './ImageCanvasEditorTypes'; +import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands'; + +function createLayer(id: string, x: number, zIndex: number): CanvasLayer { + return { + id, + resourceId: `resource-${id}`, + title: id, + src: `data:image/png;base64,${id}`, + x, + y: 20, + width: 100, + height: 80, + originalWidth: 100, + originalHeight: 80, + zIndex, + sourceType: 'uploaded', + }; +} + +function LayerCommandsHarness({ + exportLayerImage = vi.fn(), + onDeleteLayerSideEffects = vi.fn(), +}: { + exportLayerImage?: (layer: CanvasLayer | null) => void; + onDeleteLayerSideEffects?: (layerId: string) => void; +}) { + const [layers, setLayers] = useState([ + createLayer('first', 10, 1), + createLayer('second', 160, 2), + createLayer('third', 310, 3), + ]); + const [selectedLayerId, setSelectedLayerId] = useState( + 'first', + ); + const [selectedLayerIds, setSelectedLayerIds] = useState([ + 'first', + 'second', + ]); + const [metadataLayer, setMetadataLayer] = useState( + layers[0] ?? null, + ); + const [hoveredLayerId, setHoveredLayerId] = useState('first'); + const [contextMenu, setContextMenu] = useState( + { + kind: 'layer', + layerId: 'first', + x: 0, + y: 0, + canvasPoint: { x: 0, y: 0 }, + }, + ); + const [historyCount, setHistoryCount] = useState(0); + const [imageContextClosedCount, setImageContextClosedCount] = useState(0); + const [activeTool, setActiveTool] = useState('shape'); + + const selectSingleLayer = (layerId: string | null) => { + setSelectedLayerId(layerId); + setSelectedLayerIds(layerId ? [layerId] : []); + }; + + const commands = useImageCanvasLayerCommands({ + layers, + contextMenu, + selectedLayerId, + selectedLayerIds, + setLayers, + setSelectedLayerId, + setSelectedLayerIds, + setHoveredLayerId, + setMetadataLayer, + setContextMenu, + setImageContextMenu: () => + setImageContextClosedCount((currentCount) => currentCount + 1), + setActiveTool, + captureCanvasHistory: () => + setHistoryCount((currentCount) => currentCount + 1), + selectSingleLayer, + onDeleteLayerSideEffects, + exportLayerImage, + }); + + return ( +
+ + {layers + .map( + (layer) => + `${layer.id}:${layer.title}:${layer.x}:${layer.zIndex}:${layer.groupId ?? '-'}:${layer.hidden ? 'hidden' : 'shown'}:${layer.locked ? 'locked' : 'open'}:${layer.flipX ? 'flipX' : '-'}:${layer.flipY ? 'flipY' : '-'}`, + ) + .join('|')} + + + {selectedLayerId ?? '-'}:{selectedLayerIds.join(',')} + + {metadataLayer?.id ?? '-'} + {hoveredLayerId ?? '-'} + {contextMenu ? 'open' : 'closed'} + {historyCount} + + {imageContextClosedCount} + + {activeTool} + + {commands.canvasClipboard + ? `${commands.canvasClipboard.mode}:${commands.canvasClipboard.layers.length}` + : '-'} + + + + + + + + + + + + + + + + + + +
+ ); +} + +describe('useImageCanvasLayerCommands', () => { + it('copies, cuts, and pastes context layers with history and selection updates', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '复制' })); + expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); + expect(screen.getByTestId('context').textContent).toBe('closed'); + + fireEvent.click(screen.getByRole('button', { name: '粘贴' })); + expect(screen.getByTestId('layers').textContent).toContain( + 'first 副本:500:4', + ); + expect(screen.getByTestId('layers').textContent).toContain( + 'second 副本:650:5', + ); + expect(screen.getByTestId('selection').textContent).toContain( + 'layer-copy-', + ); + expect(screen.getByTestId('tool').textContent).toBe('select'); + + fireEvent.click(screen.getByRole('button', { name: '右键第三层' })); + fireEvent.click(screen.getByRole('button', { name: '只选第三层' })); + fireEvent.click(screen.getByRole('button', { name: '剪切' })); + + expect(screen.getByTestId('clipboard').textContent).toBe('cut:1'); + expect(screen.getByTestId('layers').textContent).not.toContain('third'); + expect(screen.getByTestId('selection').textContent).toBe('-:'); + expect(Number(screen.getByTestId('history').textContent)).toBeGreaterThan( + 1, + ); + }); + + it('applies layer commands and clears menus without owning menu positioning', () => { + const exportLayerImage = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: '创建组' })); + expect(screen.getByTestId('layers').textContent).toContain('layer-group-'); + + fireEvent.click(screen.getByRole('button', { name: '右键第三层' })); + fireEvent.click(screen.getByRole('button', { name: '显隐' })); + expect(screen.getByTestId('layers').textContent).toContain(':hidden:'); + + fireEvent.click(screen.getByRole('button', { name: '右键第三层' })); + fireEvent.click(screen.getByRole('button', { name: '锁定' })); + expect(screen.getByTestId('layers').textContent).toContain(':locked:'); + + fireEvent.click(screen.getByRole('button', { name: '右键第三层' })); + fireEvent.click(screen.getByRole('button', { name: '翻转' })); + expect(screen.getByTestId('layers').textContent).toContain(':flipX:'); + + fireEvent.click(screen.getByRole('button', { name: '右键第三层' })); + fireEvent.click(screen.getByRole('button', { name: '导出' })); + expect(exportLayerImage).toHaveBeenCalledWith( + expect.objectContaining({ id: 'third' }), + ); + expect(screen.getByTestId('context').textContent).toBe('closed'); + }); + + it('deletes selected and direct layers while running delete side effects', () => { + const onDeleteLayerSideEffects = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '删除选中' })); + + expect(screen.getByTestId('layers').textContent).not.toContain('first'); + expect(screen.getByTestId('layers').textContent).not.toContain('second'); + expect(screen.getByTestId('selection').textContent).toBe('third:third'); + expect(screen.getByTestId('metadata').textContent).toBe('-'); + expect(screen.getByTestId('image-context-closed').textContent).toBe('1'); + expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('first'); + expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('second'); + + fireEvent.click(screen.getByRole('button', { name: '删除单图' })); + expect(screen.getByTestId('image-context-closed').textContent).toBe('2'); + }); +}); diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts new file mode 100644 index 00000000..fda622de --- /dev/null +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -0,0 +1,422 @@ +import { + useCallback, + useState, + type Dispatch, + type SetStateAction, +} from 'react'; + +import type { + CanvasClipboard, + CanvasContextMenuState, + CanvasLayer, +} from './ImageCanvasEditorTypes'; +import { + createCanvasLayerClipboard, + duplicateCanvasLayers, + flipCanvasLayers, + getCanvasLayersByIds, + groupCanvasLayers, + moveCanvasLayers, + removeCanvasLayers, + resolveContextTargetLayerIds, + toggleCanvasLayersLock, + toggleCanvasLayersVisibility, + ungroupCanvasLayers, + updateCanvasLayersByIds, + type CanvasLayerFlipAxis, + type CanvasLayerMoveMode, +} from './ImageCanvasLayerCommandModel'; + +type LayerCommandsOptions = { + layers: CanvasLayer[]; + contextMenu: CanvasContextMenuState | null; + selectedLayerId: string | null; + selectedLayerIds: string[]; + setLayers: Dispatch>; + setSelectedLayerId: Dispatch>; + setSelectedLayerIds: Dispatch>; + setHoveredLayerId: Dispatch>; + setMetadataLayer: Dispatch>; + setContextMenu: Dispatch>; + setImageContextMenu: (menu: null) => void; + setActiveTool: (tool: 'select') => void; + captureCanvasHistory: () => void; + selectSingleLayer: (layerId: string | null) => void; + onDeleteLayerSideEffects: (targetLayerId: string) => void; + exportLayerImage: (layer: CanvasLayer | null) => void; +}; + +function createGroupId() { + return `layer-group-${Date.now()}`; +} + +export function useImageCanvasLayerCommands({ + layers, + contextMenu, + selectedLayerId, + selectedLayerIds, + setLayers, + setSelectedLayerId, + setSelectedLayerIds, + setHoveredLayerId, + setMetadataLayer, + setContextMenu, + setImageContextMenu, + setActiveTool, + captureCanvasHistory, + selectSingleLayer, + onDeleteLayerSideEffects, + exportLayerImage, +}: LayerCommandsOptions) { + const [canvasClipboard, setCanvasClipboard] = + useState(null); + + const getContextTargetLayerIds = useCallback( + (menu: CanvasContextMenuState | null = contextMenu) => + resolveContextTargetLayerIds(menu, selectedLayerIds), + [contextMenu, selectedLayerIds], + ); + + const duplicateLayersToPoint = useCallback( + ( + sourceLayers: CanvasLayer[], + canvasPoint?: { x: number; y: number }, + options: { renameCopies?: boolean } = {}, + ) => + duplicateCanvasLayers({ + sourceLayers, + allLayers: layers, + canvasPoint, + renameCopies: options.renameCopies !== false, + }), + [layers], + ); + + const pasteCanvasClipboard = useCallback( + (canvasPoint?: { x: number; y: number }) => { + if (!canvasClipboard?.layers.length) { + return; + } + const nextLayers = duplicateLayersToPoint( + canvasClipboard.layers, + canvasPoint, + { + renameCopies: canvasClipboard.mode !== 'cut', + }, + ); + if (!nextLayers.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => [...currentLayers, ...nextLayers]); + setSelectedLayerIds(nextLayers.map((layer) => layer.id)); + setSelectedLayerId(nextLayers[0]?.id ?? null); + setActiveTool('select'); + setContextMenu(null); + }, + [ + canvasClipboard, + captureCanvasHistory, + duplicateLayersToPoint, + setActiveTool, + setContextMenu, + setLayers, + setSelectedLayerId, + setSelectedLayerIds, + ], + ); + + const copyContextLayers = useCallback( + (options: { cut?: boolean } = {}) => { + const targetIds = getContextTargetLayerIds(); + const clipboard = createCanvasLayerClipboard( + layers, + targetIds, + options.cut ? 'cut' : 'copy', + ); + if (!clipboard) { + return; + } + setCanvasClipboard(clipboard); + if (options.cut) { + captureCanvasHistory(); + setLayers((currentLayers) => + removeCanvasLayers(currentLayers, targetIds), + ); + selectSingleLayer(null); + setMetadataLayer((currentLayer) => + currentLayer && targetIds.includes(currentLayer.id) + ? null + : currentLayer, + ); + } + setContextMenu(null); + }, + [ + captureCanvasHistory, + getContextTargetLayerIds, + layers, + selectSingleLayer, + setContextMenu, + setLayers, + setMetadataLayer, + ], + ); + + const duplicateContextLayers = useCallback(() => { + const targetIds = getContextTargetLayerIds(); + const targetLayers = getCanvasLayersByIds(layers, targetIds); + const nextLayers = duplicateLayersToPoint(targetLayers); + if (!nextLayers.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => [...currentLayers, ...nextLayers]); + setSelectedLayerIds(nextLayers.map((layer) => layer.id)); + setSelectedLayerId(nextLayers[0]?.id ?? null); + setContextMenu(null); + }, [ + captureCanvasHistory, + duplicateLayersToPoint, + getContextTargetLayerIds, + layers, + setContextMenu, + setLayers, + setSelectedLayerId, + setSelectedLayerIds, + ]); + + const updateContextLayers = useCallback( + (updater: (layer: CanvasLayer, targetIds: string[]) => CanvasLayer) => { + const targetIds = getContextTargetLayerIds(); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => + updateCanvasLayersByIds(currentLayers, targetIds, updater), + ); + setContextMenu(null); + }, + [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers], + ); + + const moveContextLayers = useCallback( + (mode: CanvasLayerMoveMode) => { + const targetIds = getContextTargetLayerIds(); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => + moveCanvasLayers(currentLayers, targetIds, mode), + ); + setContextMenu(null); + }, + [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers], + ); + + const groupContextLayers = useCallback(() => { + const targetIds = getContextTargetLayerIds(); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => + groupCanvasLayers(currentLayers, targetIds, createGroupId()), + ); + setContextMenu(null); + }, [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers]); + + const ungroupContextLayers = useCallback(() => { + const targetIds = getContextTargetLayerIds(); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => ungroupCanvasLayers(currentLayers, targetIds)); + setContextMenu(null); + }, [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers]); + + const toggleContextLayerVisibility = useCallback(() => { + const targetIds = getContextTargetLayerIds(); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => + toggleCanvasLayersVisibility(currentLayers, targetIds), + ); + setContextMenu(null); + }, [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers]); + + const toggleContextLayerLock = useCallback(() => { + const targetIds = getContextTargetLayerIds(); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => + toggleCanvasLayersLock(currentLayers, targetIds), + ); + setContextMenu(null); + }, [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers]); + + const flipContextLayers = useCallback( + (axis: CanvasLayerFlipAxis) => { + const targetIds = getContextTargetLayerIds(); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => + flipCanvasLayers(currentLayers, targetIds, axis), + ); + setContextMenu(null); + }, + [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers], + ); + + const deleteContextLayers = useCallback(() => { + const targetIds = getContextTargetLayerIds(); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds)); + selectSingleLayer(null); + setHoveredLayerId(null); + setMetadataLayer((currentLayer) => + currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, + ); + setContextMenu(null); + }, [ + captureCanvasHistory, + getContextTargetLayerIds, + selectSingleLayer, + setContextMenu, + setHoveredLayerId, + setLayers, + setMetadataLayer, + ]); + + const exportContextLayer = useCallback(() => { + const targetIds = getContextTargetLayerIds(); + const targetLayer = layers.find((layer) => targetIds.includes(layer.id)); + exportLayerImage(targetLayer ?? null); + setContextMenu(null); + }, [exportLayerImage, getContextTargetLayerIds, layers, setContextMenu]); + + const deleteLayerById = useCallback( + (targetLayerId: string | null) => { + if (!targetLayerId) { + return; + } + setImageContextMenu(null); + setContextMenu(null); + captureCanvasHistory(); + setLayers((currentLayers) => { + const nextLayers = currentLayers.filter( + (layer) => layer.id !== targetLayerId, + ); + const nextSelectedLayer = nextLayers + .slice() + .sort((left, right) => right.zIndex - left.zIndex)[0]; + selectSingleLayer(nextSelectedLayer?.id ?? null); + return nextLayers; + }); + setHoveredLayerId(null); + setMetadataLayer((currentLayer) => + currentLayer?.id === targetLayerId ? null : currentLayer, + ); + onDeleteLayerSideEffects(targetLayerId); + }, + [ + captureCanvasHistory, + onDeleteLayerSideEffects, + selectSingleLayer, + setContextMenu, + setHoveredLayerId, + setImageContextMenu, + setLayers, + setMetadataLayer, + ], + ); + + const deleteSelectedLayer = useCallback(() => { + const targetIds = selectedLayerIds.length + ? selectedLayerIds + : selectedLayerId + ? [selectedLayerId] + : []; + if (targetIds.length <= 1) { + deleteLayerById(targetIds[0] ?? null); + return; + } + captureCanvasHistory(); + setImageContextMenu(null); + setContextMenu(null); + setLayers((currentLayers) => { + const nextLayers = currentLayers.filter( + (layer) => !targetIds.includes(layer.id), + ); + const nextSelectedLayer = nextLayers + .slice() + .sort((left, right) => right.zIndex - left.zIndex)[0]; + selectSingleLayer(nextSelectedLayer?.id ?? null); + return nextLayers; + }); + setHoveredLayerId(null); + setMetadataLayer((currentLayer) => + currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, + ); + targetIds.forEach((targetId) => onDeleteLayerSideEffects(targetId)); + }, [ + captureCanvasHistory, + deleteLayerById, + onDeleteLayerSideEffects, + selectSingleLayer, + selectedLayerId, + selectedLayerIds, + setContextMenu, + setHoveredLayerId, + setImageContextMenu, + setLayers, + setMetadataLayer, + ]); + + const groupSelectedLayers = useCallback(() => { + const targetIds = selectedLayerIds.length + ? selectedLayerIds + : selectedLayerId + ? [selectedLayerId] + : []; + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => + groupCanvasLayers(currentLayers, targetIds, createGroupId()), + ); + }, [captureCanvasHistory, selectedLayerId, selectedLayerIds, setLayers]); + + return { + canvasClipboard, + getContextTargetLayerIds, + pasteCanvasClipboard, + copyContextLayers, + duplicateContextLayers, + updateContextLayers, + moveContextLayers, + groupContextLayers, + ungroupContextLayers, + toggleContextLayerVisibility, + toggleContextLayerLock, + flipContextLayers, + deleteContextLayers, + exportContextLayer, + deleteLayerById, + deleteSelectedLayer, + groupSelectedLayers, + }; +}