From 7b5d74037ae6f8c4957fe2bde78ce7d875dcc6e1 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 03:37:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=BD=E5=87=BA=E5=9B=BE=E7=89=87=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E5=9B=BE=E5=B1=82=E5=91=BD=E4=BB=A4=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 ImageCanvasLayerCommandModel 收口右键图层复制、粘贴、层级、分组、显隐、锁定、翻转和删除规则 主视图保留历史、选中态、菜单关闭、元数据清理和导出副作用 补充图片右键菜单真实浏览器冒泡回归测试 更新图片画布前端拆分计划和 TRACKING 验证记录 --- TRACKING.md | 1 + ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 9 +- .../ImageCanvasEditorView.test.tsx | 28 +++ .../image-editor/ImageCanvasEditorView.tsx | 184 +++++++-------- .../ImageCanvasLayerCommandModel.test.ts | 195 ++++++++++++++++ .../ImageCanvasLayerCommandModel.ts | 219 ++++++++++++++++++ 6 files changed, 531 insertions(+), 105 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasLayerCommandModel.test.ts create mode 100644 src/components/image-editor/ImageCanvasLayerCommandModel.ts diff --git a/TRACKING.md b/TRACKING.md index 173f757f..0c06a903 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -118,3 +118,4 @@ - 2026-06-17 舞台拆分浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`;关闭登录后点击 `画布背景色` 打开完整 `画布背景设置` dialog,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)`;使用临时开发账号密码登录后上传 `smoke.png` 成功进入 `项目素材`,点击素材添加到画布,切换 `图层` 后显示同一图层,图片浮动工具栏、小地图和 `AI画布工具栏` 保持可见。 - 2026-06-17 前端拆分第四阶段:新增 `ImageCanvasGenerationComposerView`,把生成图片、生成规范、生成角色形象、生成图标素材、快速编辑、角色动画和修改图片弹窗从主视图抽出;生成提交、上传 input、引用选择、占位框拖拽、结果回写、历史和画布状态机仍保留在主视图。验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx`、`npm run typecheck`。 - 2026-06-17 生成面板拆分浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空浏览器数据后未登录刷新弹出 `账号入口`;关闭登录后 `画布背景色` 打开 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)`;点击 `生成工具` 后画布显示 `Image Generator` 占位框和 `生成图片` 跟随对话框,`AI画布工具栏` 保持可见;使用临时开发账号密码登录后上传素材成功,点击素材可添加到画布,切换 `图层` 面板可看到对应图层。 +- 2026-06-17 前端拆分第五阶段:新增 `ImageCanvasLayerCommandModel`,把右键图层目标解析、复制 / 粘贴 / 创建副本、层级移动、分组 / 解组、显隐、锁定、翻转和删除的数据规则从主视图抽出;主视图只保留历史、选中态、菜单关闭、元数据清理和导出下载副作用。验证命令:`npm run test -- src/components/image-editor/ImageCanvasLayerCommandModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,背景入口打开完整 `画布背景设置` 面板;登录后上传素材成功,点击素材可加入画布,图片右键打开 `图片功能面板`,创建副本、水平翻转、锁定和隐藏均生效,`AI画布工具栏` 保持可见。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 478a7274..17ffb915 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -57,10 +57,17 @@ - 保留生成对象状态机、提交 API、上传文件 input、引用选择、生成结果回写、图层历史和坐标锚定在主视图内,避免把 Lovart 式生成对象拆成不可追踪的远程状态。 - 该组件可以管理局部字段输入和菜单展示,但所有会影响画布事实的动作都通过主视图回调落回原有状态机。 +## 第五阶段模块 + +- `ImageCanvasLayerCommandModel.ts` + - 承载图层命令的纯数据规则:右键目标解析、复制快照、粘贴 / 创建副本定位、层级移动、分组 / 解组、显示 / 隐藏、锁定 / 解锁、水平 / 垂直翻转和删除。 + - 主视图继续负责命令触发时机、历史快照、选中态、菜单关闭、元数据清理和导出下载等 UI / 浏览器副作用。 + - 该模块有独立单测锁定当前右键菜单语义,避免后续调整 UI 时顺手改变图层数据规则。 + ## 后续阶段 - 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。 -- 画布命令模型:右键菜单、图层层级、分组、锁定和隐藏命令可在保持历史快照一致后继续收口。 +- 画布交互模型:拖拽、吸附、框选、小地图和滚轮缩放仍散在主视图内,后续应在保证坐标源一致后继续收口。 ## 验证计划 diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index f604082b..106b37cf 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1610,6 +1610,34 @@ describe('ImageCanvasEditorView', () => { ).toBeTruthy(); }); + it('keeps right-clicking a canvas layer from falling through to blank pan menu handling', () => { + render(); + + const layerButton = screen + .getByAltText('画布图片:拼图素材') + .closest('button')!; + const rightPointerDown = new MouseEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 2, + clientX: 510, + clientY: 330, + }); + + const wasNotCanceled = layerButton.dispatchEvent(rightPointerDown); + + expect(wasNotCanceled).toBe(true); + expect(rightPointerDown.defaultPrevented).toBe(false); + + fireEvent.contextMenu(layerButton, { + clientX: 510, + clientY: 330, + }); + + expect(screen.getByRole('menu', { name: '图片功能面板' })).toBeTruthy(); + expect(screen.getByRole('menuitem', { name: '创建副本' })).toBeTruthy(); + }); + it('copies, cuts, and pastes layers from the context menus', () => { render(); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 525d7aae..e0d91d5e 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -48,6 +48,22 @@ import { UnifiedModal } from '../common/UnifiedModal'; import { useAuthUi } from '../auth/AuthUiContext'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView'; +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'; import { @@ -536,20 +552,12 @@ export function ImageCanvasEditorView() { ? (layers.find((layer) => layer.id === imageContextMenu.layerId) ?? null) : null; const getContextTargetLayerIds = useCallback( - (menu: CanvasContextMenuState | null = contextMenu) => { - if (menu?.kind !== 'layer') { - return []; - } - return selectedLayerIdsRef.current.includes(menu.layerId) - ? selectedLayerIdsRef.current - : [menu.layerId]; - }, + (menu: CanvasContextMenuState | null = contextMenu) => + resolveContextTargetLayerIds(menu, selectedLayerIdsRef.current), [contextMenu], ); const contextTargetIds = getContextTargetLayerIds(contextMenu); - const contextTargetLayers = layers.filter((layer) => - contextTargetIds.includes(layer.id), - ); + const contextTargetLayers = getCanvasLayersByIds(layers, contextTargetIds); const contextShouldShowLayer = contextTargetLayers.some( (layer) => layer.hidden, ); @@ -1383,28 +1391,13 @@ export function ImageCanvasEditorView() { sourceLayers: CanvasLayer[], canvasPoint?: { x: number; y: number }, options: { renameCopies?: boolean } = {}, - ) => { - if (!sourceLayers.length) { - return []; - } - const minX = Math.min(...sourceLayers.map((layer) => layer.x)); - const minY = Math.min(...sourceLayers.map((layer) => layer.y)); - const maxZIndex = layersRef.current.reduce( - (maxZ, layer) => Math.max(maxZ, layer.zIndex), - 0, - ); - const stamp = Date.now(); - return sourceLayers.map((layer, index) => ({ - ...layer, - id: `layer-copy-${stamp}-${index}`, - resourceId: `local-resource-copy-${stamp}-${index}`, - title: options.renameCopies === false ? layer.title : `${layer.title} 副本`, - x: canvasPoint ? canvasPoint.x + (layer.x - minX) : layer.x + 32, - y: canvasPoint ? canvasPoint.y + (layer.y - minY) : layer.y + 32, - zIndex: maxZIndex + index + 1, - groupId: null, - })); - }; + ) => + duplicateCanvasLayers({ + sourceLayers, + allLayers: layersRef.current, + canvasPoint, + renameCopies: options.renameCopies !== false, + }); const pasteCanvasClipboard = (canvasPoint?: { x: number; y: number }) => { if (!canvasClipboard?.layers.length) { @@ -1426,19 +1419,18 @@ export function ImageCanvasEditorView() { const copyContextLayers = (options: { cut?: boolean } = {}) => { const targetIds = getContextTargetLayerIds(); - const targetLayers = layers.filter((layer) => targetIds.includes(layer.id)); - if (!targetLayers.length) { + const clipboard = createCanvasLayerClipboard( + layers, + targetIds, + options.cut ? 'cut' : 'copy', + ); + if (!clipboard) { return; } - setCanvasClipboard({ - layers: targetLayers.map((layer) => ({ ...layer })), - mode: options.cut ? 'cut' : 'copy', - }); + setCanvasClipboard(clipboard); if (options.cut) { captureCanvasHistory(); - setLayers((currentLayers) => - currentLayers.filter((layer) => !targetIds.includes(layer.id)), - ); + setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds)); selectSingleLayer(null); setMetadataLayer((currentLayer) => currentLayer && targetIds.includes(currentLayer.id) @@ -1451,7 +1443,7 @@ export function ImageCanvasEditorView() { const duplicateContextLayers = () => { const targetIds = getContextTargetLayerIds(); - const targetLayers = layers.filter((layer) => targetIds.includes(layer.id)); + const targetLayers = getCanvasLayersByIds(layers, targetIds); const nextLayers = duplicateLayersToPoint(targetLayers); if (!nextLayers.length) { return; @@ -1472,40 +1464,21 @@ export function ImageCanvasEditorView() { } captureCanvasHistory(); setLayers((currentLayers) => - currentLayers.map((layer) => - targetIds.includes(layer.id) ? updater(layer, targetIds) : layer, - ), + updateCanvasLayersByIds(currentLayers, targetIds, updater), ); setContextMenu(null); }; - const moveContextLayers = (mode: 'up' | 'down' | 'top' | 'bottom') => { + const moveContextLayers = (mode: CanvasLayerMoveMode) => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } - const maxZIndex = layers.reduce( - (maxZ, layer) => Math.max(maxZ, layer.zIndex), - 0, + captureCanvasHistory(); + setLayers((currentLayers) => + moveCanvasLayers(currentLayers, targetIds, mode), ); - const minZIndex = layers.reduce( - (minZ, layer) => Math.min(minZ, layer.zIndex), - 0, - ); - let offsetIndex = 0; - updateContextLayers((layer) => { - if (mode === 'up') { - return { ...layer, zIndex: layer.zIndex + 1 }; - } - if (mode === 'down') { - return { ...layer, zIndex: layer.zIndex - 1 }; - } - offsetIndex += 1; - if (mode === 'top') { - return { ...layer, zIndex: maxZIndex + offsetIndex }; - } - return { ...layer, zIndex: minZIndex - (targetIds.length - offsetIndex + 1) }; - }); + setContextMenu(null); }; const groupContextLayers = () => { @@ -1514,53 +1487,57 @@ export function ImageCanvasEditorView() { return; } const groupId = `layer-group-${Date.now()}`; - updateContextLayers((layer) => ({ - ...layer, - groupId, - })); + captureCanvasHistory(); + setLayers((currentLayers) => + groupCanvasLayers(currentLayers, targetIds, groupId), + ); + setContextMenu(null); }; const ungroupContextLayers = () => { - updateContextLayers((layer) => ({ - ...layer, - groupId: null, - })); + const targetIds = getContextTargetLayerIds(); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => ungroupCanvasLayers(currentLayers, targetIds)); + setContextMenu(null); }; const toggleContextLayerVisibility = () => { const targetIds = getContextTargetLayerIds(); - const shouldHide = layers - .filter((layer) => targetIds.includes(layer.id)) - .some((layer) => !layer.hidden); - updateContextLayers((layer) => ({ - ...layer, - hidden: shouldHide, - })); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => + toggleCanvasLayersVisibility(currentLayers, targetIds), + ); + setContextMenu(null); }; const toggleContextLayerLock = () => { const targetIds = getContextTargetLayerIds(); - const shouldLock = layers - .filter((layer) => targetIds.includes(layer.id)) - .some((layer) => !layer.locked); - updateContextLayers((layer) => ({ - ...layer, - locked: shouldLock, - })); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => + toggleCanvasLayersLock(currentLayers, targetIds), + ); + setContextMenu(null); }; - const flipContextLayers = (axis: 'x' | 'y') => { - updateContextLayers((layer) => - axis === 'x' - ? { - ...layer, - flipX: !layer.flipX, - } - : { - ...layer, - flipY: !layer.flipY, - }, + const flipContextLayers = (axis: CanvasLayerFlipAxis) => { + const targetIds = getContextTargetLayerIds(); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => + flipCanvasLayers(currentLayers, targetIds, axis), ); + setContextMenu(null); }; const deleteContextLayers = () => { @@ -1569,9 +1546,7 @@ export function ImageCanvasEditorView() { return; } captureCanvasHistory(); - setLayers((currentLayers) => - currentLayers.filter((layer) => !targetIds.includes(layer.id)), - ); + setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds)); selectSingleLayer(null); setHoveredLayerId(null); setMetadataLayer((currentLayer) => @@ -3516,6 +3491,7 @@ export function ImageCanvasEditorView() { return; } if (button !== 0) { + event.stopPropagation(); return; } if ( diff --git a/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts b/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts new file mode 100644 index 00000000..4cc91f02 --- /dev/null +++ b/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest'; + +import { + createCanvasLayerClipboard, + duplicateCanvasLayers, + flipCanvasLayers, + getCanvasLayersByIds, + groupCanvasLayers, + moveCanvasLayers, + removeCanvasLayers, + resolveContextTargetLayerIds, + toggleCanvasLayersLock, + toggleCanvasLayersVisibility, + ungroupCanvasLayers, +} from './ImageCanvasLayerCommandModel'; +import type { + CanvasContextMenuState, + CanvasLayer, +} from './ImageCanvasEditorTypes'; + +function createLayer(overrides: Partial): CanvasLayer { + const id = overrides.id ?? 'layer-a'; + return { + id, + resourceId: `resource-${id}`, + title: id, + src: `data:image/png;base64,${id}`, + x: 10, + y: 20, + width: 100, + height: 80, + originalWidth: 100, + originalHeight: 80, + zIndex: 1, + sourceType: 'uploaded', + ...overrides, + }; +} + +describe('ImageCanvasLayerCommandModel', () => { + it('resolves context menu targets from the current multi-selection', () => { + const menu: CanvasContextMenuState = { + kind: 'layer', + x: 0, + y: 0, + layerId: 'layer-b', + canvasPoint: { x: 10, y: 20 }, + }; + + expect(resolveContextTargetLayerIds(menu, ['layer-a', 'layer-b'])).toEqual([ + 'layer-a', + 'layer-b', + ]); + expect(resolveContextTargetLayerIds(menu, ['layer-a'])).toEqual(['layer-b']); + expect( + resolveContextTargetLayerIds( + { kind: 'blank', x: 0, y: 0, canvasPoint: { x: 0, y: 0 } }, + ['layer-a'], + ), + ).toEqual([]); + }); + + it('duplicates layers to a canvas point while preserving relative offsets', () => { + const first = createLayer({ id: 'first', title: '第一张', x: 20, y: 30 }); + const second = createLayer({ + id: 'second', + title: '第二张', + x: 70, + y: 90, + zIndex: 4, + groupId: 'group-old', + }); + + const duplicated = duplicateCanvasLayers({ + sourceLayers: [first, second], + allLayers: [first, second], + canvasPoint: { x: 300, y: 200 }, + stamp: 'test', + }); + + expect(duplicated).toMatchObject([ + { + id: 'layer-copy-test-0', + resourceId: 'local-resource-copy-test-0', + title: '第一张 副本', + x: 300, + y: 200, + zIndex: 5, + groupId: null, + }, + { + id: 'layer-copy-test-1', + resourceId: 'local-resource-copy-test-1', + title: '第二张 副本', + x: 350, + y: 260, + zIndex: 6, + groupId: null, + }, + ]); + + const cutPaste = duplicateCanvasLayers({ + sourceLayers: [first], + allLayers: [first, second], + renameCopies: false, + stamp: 'cut', + }); + expect(cutPaste[0]?.title).toBe('第一张'); + expect(cutPaste[0]?.x).toBe(first.x + 32); + }); + + it('creates a cloned clipboard and removes target layers', () => { + const layers = [ + createLayer({ id: 'first' }), + createLayer({ id: 'second', generationInputs: { fields: [], references: [] } }), + ]; + + const clipboard = createCanvasLayerClipboard(layers, ['second'], 'copy'); + + expect(clipboard?.mode).toBe('copy'); + expect(clipboard?.layers).toEqual([layers[1]]); + expect(clipboard?.layers[0]).not.toBe(layers[1]); + expect(getCanvasLayersByIds(layers, ['first'])).toEqual([layers[0]]); + expect(removeCanvasLayers(layers, ['first'])).toEqual([layers[1]]); + }); + + it('moves layer z-indexes with the same commands as the context menu', () => { + const layers = [ + createLayer({ id: 'bottom', zIndex: 1 }), + createLayer({ id: 'middle', zIndex: 3 }), + createLayer({ id: 'top', zIndex: 8 }), + ]; + + expect(moveCanvasLayers(layers, ['middle'], 'up')[1]?.zIndex).toBe(4); + expect(moveCanvasLayers(layers, ['middle'], 'down')[1]?.zIndex).toBe(2); + expect(moveCanvasLayers(layers, ['bottom', 'middle'], 'top')).toMatchObject([ + { id: 'bottom', zIndex: 9 }, + { id: 'middle', zIndex: 10 }, + { id: 'top', zIndex: 8 }, + ]); + expect( + moveCanvasLayers(layers, ['bottom', 'middle'], 'bottom'), + ).toMatchObject([ + { id: 'bottom', zIndex: -2 }, + { id: 'middle', zIndex: -1 }, + { id: 'top', zIndex: 8 }, + ]); + }); + + it('groups, ungroups, toggles visibility and lock, and flips layers', () => { + const layers = [ + createLayer({ id: 'first', hidden: true, locked: true }), + createLayer({ id: 'second' }), + createLayer({ id: 'third' }), + ]; + const targetIds = ['first', 'second']; + + const groupedLayers = groupCanvasLayers(layers, targetIds, 'group-next'); + expect(groupedLayers[0]?.groupId).toBe('group-next'); + expect(groupedLayers[1]?.groupId).toBe('group-next'); + expect(groupedLayers[2]?.groupId).toBeUndefined(); + + const ungroupedLayers = ungroupCanvasLayers(groupedLayers, targetIds); + expect(ungroupedLayers[0]?.groupId).toBeNull(); + expect(ungroupedLayers[1]?.groupId).toBeNull(); + expect(ungroupedLayers[2]?.groupId).toBeUndefined(); + + const hiddenLayers = toggleCanvasLayersVisibility(layers, targetIds); + expect(hiddenLayers[0]?.hidden).toBe(true); + expect(hiddenLayers[1]?.hidden).toBe(true); + expect(hiddenLayers[2]?.hidden).toBeUndefined(); + + const shownLayers = toggleCanvasLayersVisibility([ + createLayer({ id: 'first', hidden: true }), + createLayer({ id: 'second', hidden: true }), + ], targetIds); + expect(shownLayers[0]?.hidden).toBe(false); + expect(shownLayers[1]?.hidden).toBe(false); + + const lockedLayers = toggleCanvasLayersLock(layers, targetIds); + expect(lockedLayers[0]?.locked).toBe(true); + expect(lockedLayers[1]?.locked).toBe(true); + expect(lockedLayers[2]?.locked).toBeUndefined(); + + const flippedXLayers = flipCanvasLayers(layers, targetIds, 'x'); + expect(flippedXLayers[0]?.flipX).toBe(true); + expect(flippedXLayers[1]?.flipX).toBe(true); + expect(flippedXLayers[2]?.flipX).toBeUndefined(); + + const flippedYLayers = flipCanvasLayers(layers, targetIds, 'y'); + expect(flippedYLayers[0]?.flipY).toBe(true); + expect(flippedYLayers[1]?.flipY).toBe(true); + expect(flippedYLayers[2]?.flipY).toBeUndefined(); + }); +}); diff --git a/src/components/image-editor/ImageCanvasLayerCommandModel.ts b/src/components/image-editor/ImageCanvasLayerCommandModel.ts new file mode 100644 index 00000000..d2dab1a7 --- /dev/null +++ b/src/components/image-editor/ImageCanvasLayerCommandModel.ts @@ -0,0 +1,219 @@ +import type { + CanvasClipboard, + CanvasContextMenuState, + CanvasLayer, +} from './ImageCanvasEditorTypes'; + +export type CanvasLayerMoveMode = 'up' | 'down' | 'top' | 'bottom'; + +export type CanvasLayerFlipAxis = 'x' | 'y'; + +type CanvasPoint = { + x: number; + y: number; +}; + +function cloneLayer(layer: CanvasLayer): CanvasLayer { + return { + ...layer, + generationInputs: layer.generationInputs + ? { + fields: layer.generationInputs.fields.map((field) => ({ ...field })), + references: layer.generationInputs.references.map((reference) => ({ + ...reference, + })), + } + : layer.generationInputs, + }; +} + +export function resolveContextTargetLayerIds( + menu: CanvasContextMenuState | null, + selectedLayerIds: string[], +) { + if (menu?.kind !== 'layer') { + return []; + } + return selectedLayerIds.includes(menu.layerId) + ? [...selectedLayerIds] + : [menu.layerId]; +} + +export function getCanvasLayersByIds( + layers: CanvasLayer[], + targetIds: string[], +) { + return layers.filter((layer) => targetIds.includes(layer.id)); +} + +export function duplicateCanvasLayers({ + sourceLayers, + allLayers, + canvasPoint, + renameCopies = true, + stamp = Date.now(), +}: { + sourceLayers: CanvasLayer[]; + allLayers: CanvasLayer[]; + canvasPoint?: CanvasPoint; + renameCopies?: boolean; + stamp?: number | string; +}) { + if (!sourceLayers.length) { + return []; + } + + const minX = Math.min(...sourceLayers.map((layer) => layer.x)); + const minY = Math.min(...sourceLayers.map((layer) => layer.y)); + const maxZIndex = allLayers.reduce( + (maxZ, layer) => Math.max(maxZ, layer.zIndex), + 0, + ); + + return sourceLayers.map((layer, index) => ({ + ...cloneLayer(layer), + id: `layer-copy-${stamp}-${index}`, + resourceId: `local-resource-copy-${stamp}-${index}`, + title: renameCopies ? `${layer.title} 副本` : layer.title, + x: canvasPoint ? canvasPoint.x + (layer.x - minX) : layer.x + 32, + y: canvasPoint ? canvasPoint.y + (layer.y - minY) : layer.y + 32, + zIndex: maxZIndex + index + 1, + groupId: null, + })); +} + +export function createCanvasLayerClipboard( + layers: CanvasLayer[], + targetIds: string[], + mode: CanvasClipboard['mode'], +): CanvasClipboard | null { + const targetLayers = getCanvasLayersByIds(layers, targetIds); + if (!targetLayers.length) { + return null; + } + return { + layers: targetLayers.map(cloneLayer), + mode, + }; +} + +export function removeCanvasLayers(layers: CanvasLayer[], targetIds: string[]) { + if (!targetIds.length) { + return layers; + } + return layers.filter((layer) => !targetIds.includes(layer.id)); +} + +export function updateCanvasLayersByIds( + layers: CanvasLayer[], + targetIds: string[], + updater: (layer: CanvasLayer, targetIds: string[]) => CanvasLayer, +) { + if (!targetIds.length) { + return layers; + } + return layers.map((layer) => + targetIds.includes(layer.id) ? updater(layer, targetIds) : layer, + ); +} + +export function moveCanvasLayers( + layers: CanvasLayer[], + targetIds: string[], + mode: CanvasLayerMoveMode, +) { + if (!targetIds.length) { + return layers; + } + const maxZIndex = layers.reduce( + (maxZ, layer) => Math.max(maxZ, layer.zIndex), + 0, + ); + const minZIndex = layers.reduce( + (minZ, layer) => Math.min(minZ, layer.zIndex), + 0, + ); + let offsetIndex = 0; + + return updateCanvasLayersByIds(layers, targetIds, (layer) => { + if (mode === 'up') { + return { ...layer, zIndex: layer.zIndex + 1 }; + } + if (mode === 'down') { + return { ...layer, zIndex: layer.zIndex - 1 }; + } + offsetIndex += 1; + if (mode === 'top') { + return { ...layer, zIndex: maxZIndex + offsetIndex }; + } + return { + ...layer, + zIndex: minZIndex - (targetIds.length - offsetIndex + 1), + }; + }); +} + +export function groupCanvasLayers( + layers: CanvasLayer[], + targetIds: string[], + groupId: string, +) { + return updateCanvasLayersByIds(layers, targetIds, (layer) => ({ + ...layer, + groupId, + })); +} + +export function ungroupCanvasLayers( + layers: CanvasLayer[], + targetIds: string[], +) { + return updateCanvasLayersByIds(layers, targetIds, (layer) => ({ + ...layer, + groupId: null, + })); +} + +export function toggleCanvasLayersVisibility( + layers: CanvasLayer[], + targetIds: string[], +) { + const shouldHide = getCanvasLayersByIds(layers, targetIds).some( + (layer) => !layer.hidden, + ); + return updateCanvasLayersByIds(layers, targetIds, (layer) => ({ + ...layer, + hidden: shouldHide, + })); +} + +export function toggleCanvasLayersLock( + layers: CanvasLayer[], + targetIds: string[], +) { + const shouldLock = getCanvasLayersByIds(layers, targetIds).some( + (layer) => !layer.locked, + ); + return updateCanvasLayersByIds(layers, targetIds, (layer) => ({ + ...layer, + locked: shouldLock, + })); +} + +export function flipCanvasLayers( + layers: CanvasLayer[], + targetIds: string[], + axis: CanvasLayerFlipAxis, +) { + return updateCanvasLayersByIds(layers, targetIds, (layer) => + axis === 'x' + ? { + ...layer, + flipX: !layer.flipX, + } + : { + ...layer, + flipY: !layer.flipY, + }, + ); +}