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

新增 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

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