抽出图片画布图层命令模型

新增 ImageCanvasLayerCommandModel 收口右键图层复制、粘贴、层级、分组、显隐、锁定、翻转和删除规则

主视图保留历史、选中态、菜单关闭、元数据清理和导出副作用

补充图片右键菜单真实浏览器冒泡回归测试

更新图片画布前端拆分计划和 TRACKING 验证记录
This commit is contained in:
2026-06-17 03:37:52 +08:00
parent 13d25c8f1a
commit 7b5d74037a
6 changed files with 531 additions and 105 deletions

View File

@@ -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画布工具栏` 保持可见。

View File

@@ -57,10 +57,17 @@
- 保留生成对象状态机、提交 API、上传文件 input、引用选择、生成结果回写、图层历史和坐标锚定在主视图内避免把 Lovart 式生成对象拆成不可追踪的远程状态。
- 该组件可以管理局部字段输入和菜单展示,但所有会影响画布事实的动作都通过主视图回调落回原有状态机。
## 第五阶段模块
- `ImageCanvasLayerCommandModel.ts`
- 承载图层命令的纯数据规则:右键目标解析、复制快照、粘贴 / 创建副本定位、层级移动、分组 / 解组、显示 / 隐藏、锁定 / 解锁、水平 / 垂直翻转和删除。
- 主视图继续负责命令触发时机、历史快照、选中态、菜单关闭、元数据清理和导出下载等 UI / 浏览器副作用。
- 该模块有独立单测锁定当前右键菜单语义,避免后续调整 UI 时顺手改变图层数据规则。
## 后续阶段
- 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。
- 画布命令模型:右键菜单、图层层级、分组、锁定和隐藏命令可在保持历史快照一致后继续收口。
- 画布交互模型:拖拽、吸附、框选、小地图和滚轮缩放仍散在主视图内,后续应在保证坐标源一致后继续收口。
## 验证计划

View File

@@ -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 />);

View File

@@ -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 (

View 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();
});
});

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