抽出图片画布图层命令模型
新增 ImageCanvasLayerCommandModel 收口右键图层复制、粘贴、层级、分组、显隐、锁定、翻转和删除规则 主视图保留历史、选中态、菜单关闭、元数据清理和导出副作用 补充图片右键菜单真实浏览器冒泡回归测试 更新图片画布前端拆分计划和 TRACKING 验证记录
This commit is contained in:
@@ -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画布工具栏` 保持可见。
|
||||
|
||||
@@ -57,10 +57,17 @@
|
||||
- 保留生成对象状态机、提交 API、上传文件 input、引用选择、生成结果回写、图层历史和坐标锚定在主视图内,避免把 Lovart 式生成对象拆成不可追踪的远程状态。
|
||||
- 该组件可以管理局部字段输入和菜单展示,但所有会影响画布事实的动作都通过主视图回调落回原有状态机。
|
||||
|
||||
## 第五阶段模块
|
||||
|
||||
- `ImageCanvasLayerCommandModel.ts`
|
||||
- 承载图层命令的纯数据规则:右键目标解析、复制快照、粘贴 / 创建副本定位、层级移动、分组 / 解组、显示 / 隐藏、锁定 / 解锁、水平 / 垂直翻转和删除。
|
||||
- 主视图继续负责命令触发时机、历史快照、选中态、菜单关闭、元数据清理和导出下载等 UI / 浏览器副作用。
|
||||
- 该模块有独立单测锁定当前右键菜单语义,避免后续调整 UI 时顺手改变图层数据规则。
|
||||
|
||||
## 后续阶段
|
||||
|
||||
- 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。
|
||||
- 画布命令模型:右键菜单、图层层级、分组、锁定和隐藏命令可在保持历史快照一致后继续收口。
|
||||
- 画布交互模型:拖拽、吸附、框选、小地图和滚轮缩放仍散在主视图内,后续应在保证坐标源一致后继续收口。
|
||||
|
||||
## 验证计划
|
||||
|
||||
|
||||
@@ -1610,6 +1610,34 @@ describe('ImageCanvasEditorView', () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps right-clicking a canvas layer from falling through to blank pan menu handling', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
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(<ImageCanvasEditorView />);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
195
src/components/image-editor/ImageCanvasLayerCommandModel.test.ts
Normal file
195
src/components/image-editor/ImageCanvasLayerCommandModel.test.ts
Normal file
@@ -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>): 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();
|
||||
});
|
||||
});
|
||||
219
src/components/image-editor/ImageCanvasLayerCommandModel.ts
Normal file
219
src/components/image-editor/ImageCanvasLayerCommandModel.ts
Normal file
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user