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,
+ },
+ );
+}