支持画布多选生成器

新增画布生成器选择 ID 模型,支持图层与生成器混合选择
补齐框选、Shift 点选、拖拽和删除对生成器占位的批量操作
修复音频控件焦点删除和手动素材标签资源持久化
更新图片画布编辑器多选语义文档
This commit is contained in:
2026-06-21 15:59:34 +08:00
parent afe78a72f2
commit 29d9c9ff5b
21 changed files with 962 additions and 89 deletions
@@ -91,6 +91,7 @@
- 生成图片点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。
- 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。
- 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。宣发素材生成器刷新后必须继续显示正确的卡片类型、游戏名、分类、描述和已绑定参考图。
- 画布多选语义必须同时覆盖普通图层和仍显示占位框的生成器对象:Shift 点选或框选可把生成器加入当前选择;拖动任一已选图层或生成器时,所有已选普通图层和生成器占位框同步移动;删除 / Backspace / Delete 作用于完整选择集合,移除所有已选图层和生成器对象。生成器对象在选择集合中使用稳定 `generation-dialog:<id>` 目标 ID,不把生成器伪装成普通图层,也不新增后端表。
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。
- 生成规范类图片面板底部必须以禁用态参数按钮显示 `16:9 · 2K` 和 `gpt-image-2`,视觉对齐可编辑面板参数控件,提交到 `/api/editor/images/generations` 时也固定携带这些参数。
- 快速编辑面板底部必须以禁用态参数按钮显示原图尺寸和原图模型,视觉对齐可编辑面板参数控件;参考图顺序为额外参考图在前、原图在最后。
@@ -155,6 +155,7 @@ function createStageProps(): ImageCanvasStageViewProps {
onCanvasContextMenu: vi.fn(),
onLayerPointerDown: vi.fn(),
onLayerClick: vi.fn(),
onSelectLayer: vi.fn(),
onLayerContextMenu: vi.fn(),
onLayerMouseEnter: vi.fn(),
onLayerMouseLeave: vi.fn(),
@@ -390,21 +390,27 @@ export type DragState =
pointerId: number;
layerId: string;
layerIds: string[];
generationDialogIds?: string[];
startClientX: number;
startClientY: number;
startLayerX: number;
startLayerY: number;
startLayers: Array<{ id: string; x: number; y: number }>;
startGenerationFrames?: Array<{ id: string; x: number; y: number }>;
startScale: number;
}
| {
kind: 'generation-frame';
dialogId: string;
dialogIds: string[];
layerIds: string[];
pointerId: number;
startClientX: number;
startClientY: number;
startFrameX: number;
startFrameY: number;
startFrames: Array<{ id: string; x: number; y: number }>;
startLayers: Array<{ id: string; x: number; y: number }>;
startScale: number;
}
| {
@@ -1047,19 +1047,38 @@ describe('ImageCanvasEditorView', () => {
),
);
await screen.findByRole('button', { name: '素材标签:角色' });
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '拼图素材',
assetKind: 'character',
}),
]),
imageSrc: '/creation-type-references/puzzle.webp',
assetKind: 'character',
}),
);
});
await waitFor(() => {
const savedLayout = saveEditorProjectLayoutMock.mock.calls
.map((call) => call[1])
.find((layout) =>
layout.layers.some(
(layer: { resourceId?: unknown; title?: unknown }) =>
layer.title === '拼图素材' &&
layer.resourceId === 'resource-editor-project-default-640',
),
);
expect(savedLayout).toBeTruthy();
expect(savedLayout.layers).toEqual(
expect.arrayContaining([
expect.not.objectContaining({
title: '拼图素材',
assetKind: expect.anything(),
}),
]),
);
});
});
it('offers Lovart-style zoom menu commands', async () => {
@@ -518,18 +518,23 @@ export function ImageCanvasEditorView() {
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
durationSeconds: layer.durationSeconds,
sourceResourceId: layer.sourceResourceId,
assetKind: layer.assetKind,
generationInputs: layer.generationInputs,
}).then((resource) => {
setLayers((currentLayers) =>
currentLayers.map((currentLayer) =>
setLayers((currentLayers) => {
const nextLayers = currentLayers.map((currentLayer) =>
currentLayer.id === layer.id
? {
...currentLayer,
resourceId: resource.resourceId,
}
: currentLayer,
),
);
);
layersRef.current = nextLayers;
return nextLayers;
});
});
},
[projectId],
@@ -670,6 +675,7 @@ export function ImageCanvasEditorView() {
handleLayerContextMenu,
} = useImageCanvasStageController({
layers,
canvasGenerationDialogs,
selectedLayerId,
selectedLayerIds,
imageContextMenu,
@@ -697,12 +703,14 @@ export function ImageCanvasEditorView() {
deleteContextLayers,
exportContextLayer,
deleteLayerById,
deleteSelectedLayer,
groupSelectedLayers,
} = useImageCanvasLayerCommands({
layers,
contextMenu,
selectedLayerId,
selectedLayerIds,
canvasGenerationDialogs,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
@@ -714,6 +722,7 @@ export function ImageCanvasEditorView() {
captureCanvasHistory,
selectSingleLayer,
onDeleteLayerSideEffects: clearDeletedLayerGenerationState,
onDeleteGenerationDialogSideEffects: removeCanvasGenerationDialogById,
exportLayerImage,
});
const {
@@ -742,6 +751,7 @@ export function ImageCanvasEditorView() {
setSelectedLayerId,
setSelectedLayerIds,
generateDialog,
canvasGenerationDialogs,
setGenerateDialog,
isPickingCharacterSpecFromCanvas,
isPickingGenerationReferenceFromCanvas,
@@ -820,26 +830,31 @@ export function ImageCanvasEditorView() {
...targetLayer,
assetKind,
};
setLayers((currentLayers) =>
currentLayers.map((layer) =>
setLayers((currentLayers) => {
const nextLayers = currentLayers.map((layer) =>
layer.id === layerId ? { ...layer, assetKind } : layer,
),
);
);
layersRef.current = nextLayers;
return nextLayers;
});
setMetadataLayer((currentLayer) =>
currentLayer?.id === layerId ? nextLayer : currentLayer,
);
persistUpdatedLayerResource(nextLayer);
setImageContextMenu(null);
setContextMenu(null);
},
[captureCanvasHistory],
[captureCanvasHistory, persistUpdatedLayerResource],
);
useImageCanvasKeyboardShortcuts({
generateDialogRef,
selectedLayerIdRef,
selectedLayerIdsRef,
redoCanvasChange,
undoCanvasChange,
deleteLayerById: deleteLayerByIdFromShortcut,
deleteSelectedCanvasObjects: deleteSelectedLayer,
requestRemoveCanvasGenerationDialog,
setActiveTool,
setGenerateDialog,
@@ -1056,6 +1071,7 @@ export function ImageCanvasEditorView() {
onCanvasContextMenu: handleCanvasContextMenu,
onLayerPointerDown: handleLayerPointerDown,
onLayerClick: handleLayerClick,
onSelectLayer: selectSingleLayer,
onLayerContextMenu: handleLayerContextMenu,
onLayerMouseEnter: setHoveredLayerId,
onLayerMouseLeave: (layerId: string) =>
@@ -7,16 +7,24 @@ import {
getCanvasPointFromClient,
getWorldPointFromClient,
moveGenerationFrameFromDrag,
moveGenerationFramesFromDrag,
moveDragSelectedLayers,
moveLayersFromDrag,
moveViewportFromMinimapDrag,
moveViewportFromMinimapPointer,
moveViewportFromPan,
scaleViewportFromScreenPoint,
scrollViewportVertically,
selectCanvasObjectsInsideMarquee,
selectLayersInsideMarquee,
zoomViewportFromWheel,
} from './ImageCanvasInteractionModel';
import type { CanvasLayer, DragState } from './ImageCanvasEditorTypes';
import type {
CanvasGenerationDialogState,
CanvasLayer,
DragState,
} from './ImageCanvasEditorTypes';
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
function createLayer(overrides: Partial<CanvasLayer>): CanvasLayer {
const id = overrides.id ?? 'layer-a';
@@ -37,6 +45,27 @@ function createLayer(overrides: Partial<CanvasLayer>): CanvasLayer {
};
}
function createGenerationDialog(
overrides: Partial<CanvasGenerationDialogState> = {},
): CanvasGenerationDialogState {
const id = overrides.id ?? 'dialog-a';
return {
id,
mode: 'generate',
prompt: id,
status: 'idle',
placeholder: {
x: 100,
y: 120,
width: 200,
height: 100,
originalWidth: 200,
originalHeight: 100,
},
...overrides,
};
}
describe('ImageCanvasInteractionModel', () => {
it('fits layers into the visible canvas without upscaling past 100%', () => {
const viewport = fitViewportToLayers({
@@ -133,6 +162,44 @@ describe('ImageCanvasInteractionModel', () => {
});
expect(selectedIds).toEqual(['inside']);
expect(
selectCanvasObjectsInsideMarquee({
marquee: {
pointerId: 1,
startX: 90,
startY: 90,
currentX: 90,
currentY: 90,
},
currentPoint: { x: 360, y: 260 },
viewport: { x: 20, y: 10, scale: 1 },
layers: [createLayer({ id: 'inside', x: 100, y: 120 })],
generationDialogs: [
createGenerationDialog({
id: 'dialog-inside',
placeholder: {
x: 130,
y: 130,
width: 80,
height: 80,
originalWidth: 80,
originalHeight: 80,
},
}),
createGenerationDialog({
id: 'dialog-outside',
placeholder: {
x: 900,
y: 900,
width: 80,
height: 80,
originalWidth: 80,
originalHeight: 80,
},
}),
],
}),
).toEqual(['inside', getCanvasGenerationSelectionId('dialog-inside')]);
});
it('moves pan, generation frames, and layer groups from drag state', () => {
@@ -154,11 +221,15 @@ describe('ImageCanvasInteractionModel', () => {
{
kind: 'generation-frame',
dialogId: 'dialog-1',
dialogIds: ['dialog-1'],
layerIds: [],
pointerId: 1,
startClientX: 100,
startClientY: 200,
startFrameX: 300,
startFrameY: 400,
startFrames: [{ id: 'dialog-1', x: 300, y: 400 }],
startLayers: [],
startScale: 2,
},
{ x: 140, y: 170 },
@@ -202,6 +273,67 @@ describe('ImageCanvasInteractionModel', () => {
vertical: 300,
horizontal: 90,
});
const generationDialogs = [
createGenerationDialog({ id: 'dialog-1' }),
createGenerationDialog({
id: 'dialog-2',
placeholder: {
x: 400,
y: 120,
width: 200,
height: 100,
originalWidth: 200,
originalHeight: 100,
},
}),
];
expect(
moveGenerationFramesFromDrag({
generationDialogs,
pointer: { x: 140, y: 170 },
dragState: {
kind: 'generation-frame',
dialogId: 'dialog-1',
dialogIds: ['dialog-1', 'dialog-2'],
layerIds: ['moving'],
pointerId: 1,
startClientX: 100,
startClientY: 200,
startFrameX: 100,
startFrameY: 120,
startFrames: [
{ id: 'dialog-1', x: 100, y: 120 },
{ id: 'dialog-2', x: 400, y: 120 },
],
startLayers: [{ id: 'moving', x: 90, y: 90 }],
startScale: 2,
},
}).map((dialog) => [dialog.id, dialog.placeholder?.x, dialog.placeholder?.y]),
).toEqual([
['dialog-1', 120, 105],
['dialog-2', 420, 105],
]);
expect(
moveDragSelectedLayers({
layers,
pointer: { x: 140, y: 170 },
dragState: {
kind: 'generation-frame',
dialogId: 'dialog-1',
dialogIds: ['dialog-1'],
layerIds: ['moving'],
pointerId: 1,
startClientX: 100,
startClientY: 200,
startFrameX: 100,
startFrameY: 120,
startFrames: [{ id: 'dialog-1', x: 100, y: 120 }],
startLayers: [{ id: 'moving', x: 90, y: 90 }],
startScale: 2,
},
}).find((layer) => layer.id === 'moving'),
).toMatchObject({ x: 110, y: 75 });
});
it('builds minimap projection and moves the viewport from minimap interactions', () => {
@@ -1,6 +1,7 @@
import type { CSSProperties } from 'react';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasMarqueeState,
CanvasViewport,
@@ -18,6 +19,7 @@ import {
getLayerBounds,
resolveSnappedLayerPosition,
} from './ImageCanvasEditorModel';
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
export type CanvasSize = {
width: number;
@@ -225,6 +227,48 @@ export function moveGenerationFrameFromDrag(
};
}
function getMarqueeRect({
marquee,
currentPoint,
}: {
marquee: CanvasMarqueeState;
currentPoint: CanvasPoint;
}) {
return {
left: Math.min(marquee.startX, currentPoint.x),
right: Math.max(marquee.startX, currentPoint.x),
top: Math.min(marquee.startY, currentPoint.y),
bottom: Math.max(marquee.startY, currentPoint.y),
};
}
function intersectsScreenRect({
x,
y,
width,
height,
viewport,
rect,
}: {
x: number;
y: number;
width: number;
height: number;
viewport: CanvasViewport;
rect: { left: number; right: number; top: number; bottom: number };
}) {
const itemLeft = viewport.x + x * viewport.scale;
const itemTop = viewport.y + y * viewport.scale;
const itemRight = itemLeft + width * viewport.scale;
const itemBottom = itemTop + height * viewport.scale;
return (
itemLeft <= rect.right &&
itemRight >= rect.left &&
itemTop <= rect.bottom &&
itemBottom >= rect.top
);
}
export function selectLayersInsideMarquee({
marquee,
currentPoint,
@@ -236,27 +280,61 @@ export function selectLayersInsideMarquee({
layers: CanvasLayer[];
viewport: CanvasViewport;
}) {
const left = Math.min(marquee.startX, currentPoint.x);
const right = Math.max(marquee.startX, currentPoint.x);
const top = Math.min(marquee.startY, currentPoint.y);
const bottom = Math.max(marquee.startY, currentPoint.y);
const rect = getMarqueeRect({ marquee, currentPoint });
return layers
.filter((layer) => {
const layerLeft = viewport.x + layer.x * viewport.scale;
const layerTop = viewport.y + layer.y * viewport.scale;
const layerRight = layerLeft + layer.width * viewport.scale;
const layerBottom = layerTop + layer.height * viewport.scale;
return (
layerLeft <= right &&
layerRight >= left &&
layerTop <= bottom &&
layerBottom >= top
);
return intersectsScreenRect({
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
viewport,
rect,
});
})
.map((layer) => layer.id);
}
export function selectCanvasObjectsInsideMarquee({
marquee,
currentPoint,
layers,
generationDialogs,
viewport,
}: {
marquee: CanvasMarqueeState;
currentPoint: CanvasPoint;
layers: CanvasLayer[];
generationDialogs: CanvasGenerationDialogState[];
viewport: CanvasViewport;
}) {
const rect = getMarqueeRect({ marquee, currentPoint });
const layerIds = selectLayersInsideMarquee({
marquee,
currentPoint,
layers,
viewport,
});
const generationDialogIds = generationDialogs
.filter((dialog) => {
const placeholder = dialog.placeholder;
if (!placeholder) {
return false;
}
return intersectsScreenRect({
x: placeholder.x,
y: placeholder.y,
width: placeholder.width,
height: placeholder.height,
viewport,
rect,
});
})
.map((dialog) => getCanvasGenerationSelectionId(dialog.id));
return [...layerIds, ...generationDialogIds];
}
export function moveLayersFromDrag({
dragState,
layers,
@@ -311,6 +389,77 @@ export function moveLayersFromDrag({
};
}
export function moveGenerationFramesFromDrag({
dragState,
generationDialogs,
pointer,
}: {
dragState: Extract<DragState, { kind: 'layer' | 'generation-frame' }>;
generationDialogs: CanvasGenerationDialogState[];
pointer: CanvasPoint;
}) {
const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale;
const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale;
const targetDialogIds =
dragState.kind === 'generation-frame'
? dragState.dialogIds
: (dragState.generationDialogIds ?? []);
if (!targetDialogIds.length) {
return generationDialogs;
}
const startFrames =
dragState.kind === 'generation-frame'
? dragState.startFrames
: (dragState.startGenerationFrames ?? []);
return generationDialogs.map((dialog) => {
if (!targetDialogIds.includes(dialog.id) || !dialog.placeholder) {
return dialog;
}
const startFrame = startFrames.find((frame) => frame.id === dialog.id);
if (!startFrame) {
return dialog;
}
return {
...dialog,
placeholder: {
...dialog.placeholder,
x: startFrame.x + deltaX,
y: startFrame.y + deltaY,
},
};
});
}
export function moveDragSelectedLayers({
dragState,
layers,
pointer,
}: {
dragState: Extract<DragState, { kind: 'generation-frame' }>;
layers: CanvasLayer[];
pointer: CanvasPoint;
}) {
if (!dragState.layerIds.length) {
return layers;
}
const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale;
const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale;
return layers.map((layer) => {
if (!dragState.layerIds.includes(layer.id)) {
return layer;
}
const startLayer = dragState.startLayers.find((item) => item.id === layer.id);
if (!startLayer) {
return layer;
}
return {
...layer,
x: startLayer.x + deltaX,
y: startLayer.y + deltaY,
};
});
}
export function createMinimapModel({
layers,
viewport,
@@ -0,0 +1,84 @@
import type {
CanvasGenerationDialogState,
CanvasLayer,
} from './ImageCanvasEditorTypes';
export const CANVAS_GENERATION_SELECTION_PREFIX = 'generation-dialog:';
export function getCanvasGenerationSelectionId(dialogId: string) {
return `${CANVAS_GENERATION_SELECTION_PREFIX}${dialogId}`;
}
export function getCanvasGenerationDialogIdFromSelectionId(targetId: string) {
return targetId.startsWith(CANVAS_GENERATION_SELECTION_PREFIX)
? targetId.slice(CANVAS_GENERATION_SELECTION_PREFIX.length)
: null;
}
export function isCanvasGenerationSelectionId(targetId: string) {
return getCanvasGenerationDialogIdFromSelectionId(targetId) !== null;
}
export function getSelectedLayerIds(selectionIds: string[]) {
return selectionIds.filter(
(selectionId) => !isCanvasGenerationSelectionId(selectionId),
);
}
export function getSelectedGenerationDialogIds(selectionIds: string[]) {
return selectionIds.flatMap((selectionId) => {
const dialogId = getCanvasGenerationDialogIdFromSelectionId(selectionId);
return dialogId ? [dialogId] : [];
});
}
export function dedupeSelectionIds(selectionIds: string[]) {
return Array.from(new Set(selectionIds));
}
export function firstSelectedLayerId(selectionIds: string[]) {
return getSelectedLayerIds(selectionIds)[0] ?? null;
}
export function normalizeCanvasSelectionIds({
selectionIds,
layers,
generationDialogs,
}: {
selectionIds: string[];
layers: CanvasLayer[];
generationDialogs: CanvasGenerationDialogState[];
}) {
const layerIds = new Set(layers.map((layer) => layer.id));
const generationDialogIds = new Set(
generationDialogs.map((dialog) => dialog.id),
);
return dedupeSelectionIds(selectionIds).filter((selectionId) => {
if (isCanvasGenerationSelectionId(selectionId)) {
const dialogId = getCanvasGenerationDialogIdFromSelectionId(selectionId);
return Boolean(dialogId && generationDialogIds.has(dialogId));
}
return layerIds.has(selectionId);
});
}
export function resolvePointerSelection({
targetId,
selectedIds,
isMultiSelectGesture,
}: {
targetId: string;
selectedIds: string[];
isMultiSelectGesture: boolean;
}) {
if (!isMultiSelectGesture) {
return [targetId];
}
if (!selectedIds.includes(targetId)) {
return [...selectedIds, targetId];
}
if (selectedIds.length <= 1) {
return [targetId];
}
return selectedIds.filter((selectedId) => selectedId !== targetId);
}
@@ -7,6 +7,7 @@ import {
} from './ImageCanvasEditorModel';
import type {
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
ImageContextMenuState,
@@ -14,6 +15,10 @@ import type {
import {
resolveSelectedToolbarStyle,
} from './ImageCanvasOverlayModel';
import {
getSelectedGenerationDialogIds,
getSelectedLayerIds,
} from './ImageCanvasSelectionModel';
export type ImageCanvasStageControllerModel = {
selectedLayer: CanvasLayer | null;
@@ -29,6 +34,7 @@ export type ImageCanvasStageControllerModel = {
type ResolveImageCanvasStageControllerModelOptions = {
layers: CanvasLayer[];
canvasGenerationDialogs?: CanvasGenerationDialogState[];
selectedLayerId: string | null;
selectedLayerIds: string[];
imageContextMenu: ImageContextMenuState | null;
@@ -39,6 +45,7 @@ type ResolveImageCanvasStageControllerModelOptions = {
export function resolveImageCanvasStageControllerModel({
layers,
canvasGenerationDialogs = [],
selectedLayerId,
selectedLayerIds,
imageContextMenu,
@@ -48,7 +55,16 @@ export function resolveImageCanvasStageControllerModel({
}: ResolveImageCanvasStageControllerModelOptions): ImageCanvasStageControllerModel {
const selectedLayer =
layers.find((layer) => layer.id === selectedLayerId) ?? null;
const selectedLayerCount = selectedLayerIds.length;
const selectedCanvasLayerIds = getSelectedLayerIds(selectedLayerIds);
const selectedGenerationDialogIds =
getSelectedGenerationDialogIds(selectedLayerIds);
const selectedLayerCount =
selectedCanvasLayerIds.filter((layerId) =>
layers.some((layer) => layer.id === layerId),
).length +
selectedGenerationDialogIds.filter((dialogId) =>
canvasGenerationDialogs.some((dialog) => dialog.id === dialogId),
).length;
const imageContextMenuLayer = imageContextMenu
? (layers.find((layer) => layer.id === imageContextMenu.layerId) ?? null)
: null;
@@ -6,6 +6,7 @@ import type {
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import {
createGenerationFrameSelectionStart,
createCanvasMarqueeState,
createGenerationFrameDragState,
createLayerDragStart,
@@ -20,6 +21,7 @@ import {
updateGenerateDialogForLayerPointerDown,
updateMinimapDragMovement,
} from './ImageCanvasStageInteractionModel';
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
const id = overrides.id ?? 'layer-a';
@@ -149,6 +151,7 @@ describe('ImageCanvasStageInteractionModel', () => {
createLayerDragStart({
layer: layers[1]!,
layers,
generationDialogs: [],
selectedLayerIds: ['a'],
isMultiSelectGesture: true,
pointerId: 4,
@@ -157,11 +160,13 @@ describe('ImageCanvasStageInteractionModel', () => {
}),
).toEqual({
selectedLayerIds: ['a', 'b'],
selectedLayerId: 'b',
dragState: {
kind: 'layer',
pointerId: 4,
layerId: 'b',
layerIds: ['a', 'b'],
generationDialogIds: [],
startClientX: 300,
startClientY: 260,
startLayerX: 120,
@@ -170,6 +175,7 @@ describe('ImageCanvasStageInteractionModel', () => {
{ id: 'a', x: 40, y: 50 },
{ id: 'b', x: 120, y: 150 },
],
startGenerationFrames: [],
startScale: 1.25,
},
});
@@ -252,11 +258,15 @@ describe('ImageCanvasStageInteractionModel', () => {
).toEqual({
kind: 'generation-frame',
dialogId: 'dialog-1',
dialogIds: ['dialog-1'],
layerIds: [],
pointerId: 11,
startClientX: 240,
startClientY: 200,
startFrameX: 200,
startFrameY: 160,
startFrames: [{ id: 'dialog-1', x: 200, y: 160 }],
startLayers: [],
startScale: 2,
});
expect(
@@ -291,4 +301,68 @@ describe('ImageCanvasStageInteractionModel', () => {
moved: true,
});
});
it('creates generation frame drag state from mixed selection targets', () => {
const dialog: CanvasGenerationDialogState = {
id: 'dialog-1',
mode: 'generate',
prompt: '',
status: 'idle',
placeholder: {
x: 200,
y: 160,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
};
const anotherDialog: CanvasGenerationDialogState = {
...dialog,
id: 'dialog-2',
placeholder: {
...dialog.placeholder!,
x: 680,
y: 160,
},
};
const layers = [createLayer({ id: 'layer-a', x: 40, y: 50 })];
expect(
createGenerationFrameSelectionStart({
dialog: anotherDialog,
layers,
generationDialogs: [dialog, anotherDialog],
selectedLayerIds: ['layer-a', getCanvasGenerationSelectionId('dialog-1')],
isMultiSelectGesture: true,
pointerId: 12,
pointer: { x: 360, y: 260 },
viewportScale: 1,
}),
).toEqual({
selectedLayerIds: [
'layer-a',
getCanvasGenerationSelectionId('dialog-1'),
getCanvasGenerationSelectionId('dialog-2'),
],
selectedLayerId: 'layer-a',
dragState: {
kind: 'generation-frame',
dialogId: 'dialog-2',
dialogIds: ['dialog-1', 'dialog-2'],
layerIds: ['layer-a'],
pointerId: 12,
startClientX: 360,
startClientY: 260,
startFrameX: 680,
startFrameY: 160,
startFrames: [
{ id: 'dialog-1', x: 200, y: 160 },
{ id: 'dialog-2', x: 680, y: 160 },
],
startLayers: [{ id: 'layer-a', x: 40, y: 50 }],
startScale: 1,
},
});
});
});
@@ -7,6 +7,13 @@ import type {
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import type { CanvasPoint } from './ImageCanvasInteractionModel';
import {
firstSelectedLayerId,
getCanvasGenerationSelectionId,
getSelectedGenerationDialogIds,
getSelectedLayerIds,
resolvePointerSelection,
} from './ImageCanvasSelectionModel';
type PointerSource = {
button?: number;
@@ -157,21 +164,17 @@ export function resolveLayerPointerSelection({
selectedLayerIds: string[];
isMultiSelectGesture: boolean;
}) {
if (!isMultiSelectGesture) {
return [layerId];
}
if (!selectedLayerIds.includes(layerId)) {
return [...selectedLayerIds, layerId];
}
if (selectedLayerIds.length <= 1) {
return [layerId];
}
return selectedLayerIds.filter((currentLayerId) => currentLayerId !== layerId);
return resolvePointerSelection({
targetId: layerId,
selectedIds: selectedLayerIds,
isMultiSelectGesture,
});
}
export function createLayerDragStart({
layer,
layers,
generationDialogs = [],
selectedLayerIds,
isMultiSelectGesture,
pointerId,
@@ -180,6 +183,7 @@ export function createLayerDragStart({
}: {
layer: CanvasLayer;
layers: CanvasLayer[];
generationDialogs?: CanvasGenerationDialogState[];
selectedLayerIds: string[];
isMultiSelectGesture: boolean;
pointerId: number;
@@ -187,6 +191,7 @@ export function createLayerDragStart({
viewportScale: number;
}): {
selectedLayerIds: string[];
selectedLayerId: string | null;
dragState: Extract<DragState, { kind: 'layer' }>;
} {
const nextSelectedLayerIds = resolveLayerPointerSelection({
@@ -194,8 +199,12 @@ export function createLayerDragStart({
selectedLayerIds,
isMultiSelectGesture,
});
const dragLayerIds = nextSelectedLayerIds.includes(layer.id)
? nextSelectedLayerIds
const selectedCanvasLayerIds = getSelectedLayerIds(nextSelectedLayerIds);
const selectedGenerationDialogIds = getSelectedGenerationDialogIds(
nextSelectedLayerIds,
);
const dragLayerIds = selectedCanvasLayerIds.includes(layer.id)
? selectedCanvasLayerIds
: [layer.id];
const startLayers = layers
.filter((currentLayer) => dragLayerIds.includes(currentLayer.id))
@@ -204,19 +213,34 @@ export function createLayerDragStart({
x: currentLayer.x,
y: currentLayer.y,
}));
const startGenerationFrames = generationDialogs
.filter(
(dialog) =>
selectedGenerationDialogIds.includes(dialog.id) && dialog.placeholder,
)
.map((dialog) => ({
id: dialog.id,
x: dialog.placeholder?.x ?? 0,
y: dialog.placeholder?.y ?? 0,
}));
return {
selectedLayerIds: nextSelectedLayerIds,
selectedLayerId: nextSelectedLayerIds.includes(layer.id)
? layer.id
: firstSelectedLayerId(nextSelectedLayerIds),
dragState: {
kind: 'layer',
pointerId,
layerId: layer.id,
layerIds: dragLayerIds,
generationDialogIds: selectedGenerationDialogIds,
startClientX: pointer.x,
startClientY: pointer.y,
startLayerX: layer.x,
startLayerY: layer.y,
startLayers,
startGenerationFrames,
startScale: viewportScale,
},
};
@@ -247,13 +271,91 @@ export function updateGenerateDialogForLayerClick(
};
}
export function createGenerationFrameDragState({
export function createGenerationFrameSelectionStart({
dialog,
layers,
generationDialogs,
selectedLayerIds,
isMultiSelectGesture,
pointerId,
pointer,
viewportScale,
}: {
dialog: CanvasGenerationDialogState;
layers: CanvasLayer[];
generationDialogs: CanvasGenerationDialogState[];
selectedLayerIds: string[];
isMultiSelectGesture: boolean;
pointerId: number;
pointer: CanvasPoint;
viewportScale: number;
}): {
selectedLayerIds: string[];
selectedLayerId: string | null;
dragState: Extract<DragState, { kind: 'generation-frame' }> | null;
} {
const targetSelectionId = getCanvasGenerationSelectionId(dialog.id);
const nextSelectedLayerIds = resolvePointerSelection({
targetId: targetSelectionId,
selectedIds: selectedLayerIds,
isMultiSelectGesture,
});
const selectedGenerationDialogIds = getSelectedGenerationDialogIds(
nextSelectedLayerIds,
);
const selectedCanvasLayerIds = getSelectedLayerIds(nextSelectedLayerIds);
const dragDialogIds = selectedGenerationDialogIds.includes(dialog.id)
? selectedGenerationDialogIds
: [dialog.id];
const startFrames = generationDialogs
.filter(
(currentDialog) =>
dragDialogIds.includes(currentDialog.id) && currentDialog.placeholder,
)
.map((currentDialog) => ({
id: currentDialog.id,
x: currentDialog.placeholder?.x ?? 0,
y: currentDialog.placeholder?.y ?? 0,
}));
const startLayers = layers
.filter((currentLayer) => selectedCanvasLayerIds.includes(currentLayer.id))
.map((currentLayer) => ({
id: currentLayer.id,
x: currentLayer.x,
y: currentLayer.y,
}));
return {
selectedLayerIds: nextSelectedLayerIds,
selectedLayerId: firstSelectedLayerId(nextSelectedLayerIds),
dragState: createGenerationFrameDragState({
dialog,
dialogIds: dragDialogIds,
layerIds: selectedCanvasLayerIds,
startFrames,
startLayers,
pointerId,
pointer,
viewportScale,
}),
};
}
export function createGenerationFrameDragState({
dialog,
dialogIds = [dialog.id],
layerIds = [],
startFrames,
startLayers = [],
pointerId,
pointer,
viewportScale,
}: {
dialog: CanvasGenerationDialogState;
dialogIds?: string[];
layerIds?: string[];
startFrames?: Array<{ id: string; x: number; y: number }>;
startLayers?: Array<{ id: string; x: number; y: number }>;
pointerId: number;
pointer: CanvasPoint;
viewportScale: number;
@@ -264,11 +366,24 @@ export function createGenerationFrameDragState({
return {
kind: 'generation-frame',
dialogId: dialog.id,
dialogIds,
layerIds,
pointerId,
startClientX: pointer.x,
startClientY: pointer.y,
startFrameX: dialog.placeholder.x,
startFrameY: dialog.placeholder.y,
startFrames:
startFrames && startFrames.length
? startFrames
: [
{
id: dialog.id,
x: dialog.placeholder.x,
y: dialog.placeholder.y,
},
],
startLayers,
startScale: viewportScale,
};
}
@@ -82,6 +82,7 @@ export type ImageCanvasStageViewProps = {
event: ReactMouseEvent<HTMLElement>,
layer: CanvasLayer,
) => void;
onSelectLayer: (layerId: string | null) => void;
onLayerContextMenu: (
event: ReactMouseEvent<HTMLElement>,
layer: CanvasLayer,
@@ -190,6 +191,7 @@ export function ImageCanvasStageView({
onCanvasContextMenu,
onLayerPointerDown,
onLayerClick,
onSelectLayer,
onLayerContextMenu,
onLayerMouseEnter,
onLayerMouseLeave,
@@ -273,6 +275,7 @@ export function ImageCanvasStageView({
generationComposerStyle={generationComposerStyle}
onLayerPointerDown={onLayerPointerDown}
onLayerClick={onLayerClick}
onSelectLayer={onSelectLayer}
onLayerContextMenu={onLayerContextMenu}
onLayerMouseEnter={onLayerMouseEnter}
onLayerMouseLeave={onLayerMouseLeave}
@@ -71,6 +71,7 @@ function renderWorldView(
generationComposerStyle: null,
onLayerPointerDown: vi.fn(),
onLayerClick: vi.fn(),
onSelectLayer: vi.fn(),
onLayerContextMenu: vi.fn(),
onLayerMouseEnter: vi.fn(),
onLayerMouseLeave: vi.fn(),
@@ -51,6 +51,7 @@ import {
getCanvasMediaPreviewClassName,
getCanvasMediaPreviewMarker,
} from './ImageCanvasMediaModel';
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
function getGenerationPlaceholderMeta(dialog: CanvasGenerationDialogState) {
switch (dialog.mode) {
@@ -236,6 +237,7 @@ export type ImageCanvasWorldViewProps = {
event: ReactMouseEvent<HTMLElement>,
layer: CanvasLayer,
) => void;
onSelectLayer: (layerId: string | null) => void;
onLayerContextMenu: (
event: ReactMouseEvent<HTMLElement>,
layer: CanvasLayer,
@@ -278,6 +280,14 @@ function stopMediaEventPropagation(
event.stopPropagation();
}
function selectMediaLayerWithoutDrag(
event: ReactPointerEvent<HTMLElement>,
onSelectLayer: () => void,
) {
event.stopPropagation();
onSelectLayer();
}
function handleLayerKeyboardActivation(
event: ReactKeyboardEvent<HTMLElement>,
) {
@@ -299,6 +309,15 @@ function handleLayerTagKeyboardActivation(
event.currentTarget.click();
}
function stopMediaControlKeyPropagation(
event: ReactKeyboardEvent<HTMLElement>,
) {
if (event.key === 'Backspace' || event.key === 'Delete') {
return;
}
event.stopPropagation();
}
function getLayerMetricLabel(layer: CanvasLayer) {
return `${Math.round(layer.originalWidth)} x ${Math.round(
layer.originalHeight,
@@ -335,9 +354,11 @@ function formatAudioControlTime(seconds: number) {
function ImageCanvasAudioLayerCard({
layer,
isHovered,
onSelectLayer,
}: {
layer: CanvasLayer;
isHovered: boolean;
onSelectLayer: () => void;
}) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const previewClassName = getCanvasMediaPreviewClassName(
@@ -432,7 +453,7 @@ function ImageCanvasAudioLayerCard({
event.stopPropagation();
void togglePlayback();
}}
onPointerDown={stopMediaEventPropagation}
onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)}
>
{showPlaybackButton ? (
isPlaying ? (
@@ -450,8 +471,8 @@ function ImageCanvasAudioLayerCard({
<div
className="image-canvas-editor__audio-card-controls image-canvas-editor__audio-card-controls--integrated"
onClick={stopMediaEventPropagation}
onKeyDown={(event) => event.stopPropagation()}
onPointerDown={stopMediaEventPropagation}
onKeyDown={stopMediaControlKeyPropagation}
onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)}
>
<audio
ref={audioRef}
@@ -460,7 +481,7 @@ function ImageCanvasAudioLayerCard({
preload="metadata"
aria-label={`画布音频:${layer.title}`}
onClick={stopMediaEventPropagation}
onPointerDown={stopMediaEventPropagation}
onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)}
onLoadedMetadata={(event) => {
const nextDuration = event.currentTarget.duration;
setDuration(
@@ -503,7 +524,7 @@ function ImageCanvasAudioLayerCard({
event.stopPropagation();
toggleMute();
}}
onPointerDown={stopMediaEventPropagation}
onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)}
>
{isMuted ? (
<VolumeX className="h-3.5 w-3.5" aria-hidden="true" />
@@ -615,6 +636,7 @@ export function ImageCanvasWorldView({
generationComposerStyle,
onLayerPointerDown,
onLayerClick,
onSelectLayer,
onLayerContextMenu,
onLayerMouseEnter,
onLayerMouseLeave,
@@ -718,6 +740,7 @@ export function ImageCanvasWorldView({
<ImageCanvasAudioLayerCard
layer={layer}
isHovered={isHovered}
onSelectLayer={() => onSelectLayer(layer.id)}
/>
) : layer.mediaType === 'video' ? (
<ImageCanvasVideoLayer
@@ -890,8 +913,11 @@ export function ImageCanvasWorldView({
return dialog.placeholder && !hasGeneratedLayer
? (() => {
const isFocused = generateDialog?.id === dialog.id;
const isSelected = selectedLayerIds.includes(
getCanvasGenerationSelectionId(dialog.id),
);
const showFocusedChrome =
isFocused || dialog.status === 'generating';
isFocused || isSelected || dialog.status === 'generating';
const placeholderMeta = getGenerationPlaceholderMeta(dialog);
return (
@@ -904,7 +930,7 @@ export function ImageCanvasWorldView({
? 'image-canvas-editor__generation-frame--generating'
: ''
} ${
isFocused
isFocused || isSelected
? 'image-canvas-editor__generation-frame--focused'
: ''
}`}
@@ -54,22 +54,26 @@ function applyQuickEditPanelUpdater(
function KeyboardShortcutsHarness({
selectedLayerId = null,
selectedLayerIds = selectedLayerId ? [selectedLayerId] : [],
initialGenerateDialog = null,
initialQuickEditPanel = null,
initialTool = 'select',
undoCanvasChange = vi.fn(),
redoCanvasChange = vi.fn(),
deleteLayerById = vi.fn(),
deleteSelectedCanvasObjects = vi.fn(),
requestRemoveCanvasGenerationDialog = vi.fn(),
closeEditorChromePanels = vi.fn(),
}: {
selectedLayerId?: string | null;
selectedLayerIds?: string[];
initialGenerateDialog?: GenerateDialogState | null;
initialQuickEditPanel?: QuickEditPanelState | null;
initialTool?: CanvasTool;
undoCanvasChange?: () => void;
redoCanvasChange?: () => void;
deleteLayerById?: (layerId: string | null) => void;
deleteSelectedCanvasObjects?: () => void;
requestRemoveCanvasGenerationDialog?: (
dialog: CanvasGenerationDialogState,
) => void;
@@ -111,15 +115,19 @@ function KeyboardShortcutsHarness({
const [shiftPressed, setShiftPressed] = useState(false);
const generateDialogRef = useRef<GenerateDialogState | null>(generateDialog);
const selectedLayerIdRef = useRef<string | null>(selectedLayerId);
const selectedLayerIdsRef = useRef<string[]>(selectedLayerIds);
generateDialogRef.current = generateDialog;
selectedLayerIdRef.current = selectedLayerId;
selectedLayerIdsRef.current = selectedLayerIds;
useImageCanvasKeyboardShortcuts({
generateDialogRef,
selectedLayerIdRef,
selectedLayerIdsRef,
redoCanvasChange,
undoCanvasChange,
deleteLayerById,
deleteSelectedCanvasObjects,
requestRemoveCanvasGenerationDialog,
setActiveTool: (tool) => setActiveTool(tool),
setGenerateDialog: (updater) =>
@@ -237,6 +245,7 @@ describe('useImageCanvasKeyboardShortcuts', () => {
render(
<KeyboardShortcutsHarness
selectedLayerId="layer-selected"
selectedLayerIds={[]}
deleteLayerById={deleteLayerById}
/>,
);
@@ -248,6 +257,26 @@ describe('useImageCanvasKeyboardShortcuts', () => {
expect(deleteLayerById).toHaveBeenCalledWith('layer-selected');
});
it('deletes complete canvas selection before falling back to active layer', () => {
const deleteLayerById = vi.fn();
const deleteSelectedCanvasObjects = vi.fn();
render(
<KeyboardShortcutsHarness
selectedLayerId="layer-selected"
selectedLayerIds={['layer-selected', 'generation-dialog:dialog-1']}
deleteLayerById={deleteLayerById}
deleteSelectedCanvasObjects={deleteSelectedCanvasObjects}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
});
expect(deleteSelectedCanvasObjects).toHaveBeenCalledTimes(1);
expect(deleteLayerById).not.toHaveBeenCalled();
});
it('requests deleting editable generation placeholders with Backspace', () => {
const requestRemoveCanvasGenerationDialog = vi.fn();
const dialog = createPlaceholderDialog('character');
@@ -9,9 +9,11 @@ import type {
type UseImageCanvasKeyboardShortcutsOptions = {
generateDialogRef: RefObject<GenerateDialogState | null>;
selectedLayerIdRef: RefObject<string | null>;
selectedLayerIdsRef?: RefObject<string[]>;
redoCanvasChange: () => void;
undoCanvasChange: () => void;
deleteLayerById: (layerId: string | null) => void;
deleteSelectedCanvasObjects?: () => void;
requestRemoveCanvasGenerationDialog?: (
dialog: CanvasGenerationDialogState,
) => void;
@@ -56,6 +58,9 @@ function isEditableTarget(event: KeyboardEvent) {
if (!target) {
return false;
}
if (target.tagName === 'AUDIO' || target.tagName === 'VIDEO') {
return false;
}
return (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
@@ -91,9 +96,11 @@ function isCanvasGenerationDialogState(
export function useImageCanvasKeyboardShortcuts({
generateDialogRef,
selectedLayerIdRef,
selectedLayerIdsRef,
redoCanvasChange,
undoCanvasChange,
deleteLayerById,
deleteSelectedCanvasObjects,
requestRemoveCanvasGenerationDialog,
setActiveTool,
setGenerateDialog,
@@ -190,6 +197,11 @@ export function useImageCanvasKeyboardShortcuts({
!event.repeat &&
!isEditableTarget(event)
) {
if (selectedLayerIdsRef?.current.length) {
event.preventDefault();
deleteSelectedCanvasObjects?.();
return;
}
const currentSelectedLayerId = selectedLayerIdRef.current;
if (currentSelectedLayerId) {
event.preventDefault();
@@ -253,11 +265,13 @@ export function useImageCanvasKeyboardShortcuts({
};
}, [
closeEditorChromePanels,
deleteSelectedCanvasObjects,
deleteLayerById,
generateDialogRef,
requestRemoveCanvasGenerationDialog,
redoCanvasChange,
selectedLayerIdRef,
selectedLayerIdsRef,
setActiveTool,
setContextMenu,
setGenerateDialog,
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import type {
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
@@ -30,9 +31,11 @@ function createLayer(id: string, x: number, zIndex: number): CanvasLayer {
function LayerCommandsHarness({
exportLayerImage = vi.fn(),
onDeleteLayerSideEffects = vi.fn(),
onDeleteGenerationDialogSideEffects = vi.fn(),
}: {
exportLayerImage?: (layer: CanvasLayer | null) => void;
onDeleteLayerSideEffects?: (layerId: string) => void;
onDeleteGenerationDialogSideEffects?: (dialogId: string) => void;
}) {
const [layers, setLayers] = useState<CanvasLayer[]>([
createLayer('first', 10, 1),
@@ -46,6 +49,22 @@ function LayerCommandsHarness({
'first',
'second',
]);
const [canvasGenerationDialogs] = useState<CanvasGenerationDialogState[]>([
{
id: 'dialog-1',
mode: 'generate',
prompt: '生成',
status: 'idle',
placeholder: {
x: 20,
y: 20,
width: 100,
height: 80,
originalWidth: 100,
originalHeight: 80,
},
},
]);
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(
layers[0] ?? null,
);
@@ -73,6 +92,7 @@ function LayerCommandsHarness({
contextMenu,
selectedLayerId,
selectedLayerIds,
canvasGenerationDialogs,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
@@ -86,6 +106,7 @@ function LayerCommandsHarness({
setHistoryCount((currentCount) => currentCount + 1),
selectSingleLayer,
onDeleteLayerSideEffects,
onDeleteGenerationDialogSideEffects,
exportLayerImage,
});
@@ -192,6 +213,15 @@ function LayerCommandsHarness({
>
只选第三层
</button>
<button
type="button"
onClick={() => {
setSelectedLayerId('first');
setSelectedLayerIds(['first', 'generation-dialog:dialog-1']);
}}
>
混合选择
</button>
</div>
);
}
@@ -276,4 +306,26 @@ describe('useImageCanvasLayerCommands', () => {
fireEvent.click(screen.getByRole('button', { name: '删除单图' }));
expect(screen.getByTestId('image-context-closed').textContent).toBe('2');
});
it('deletes selected generation dialogs together with layer targets', () => {
const onDeleteLayerSideEffects = vi.fn();
const onDeleteGenerationDialogSideEffects = vi.fn();
render(
<LayerCommandsHarness
onDeleteLayerSideEffects={onDeleteLayerSideEffects}
onDeleteGenerationDialogSideEffects={onDeleteGenerationDialogSideEffects}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '混合选择' }));
fireEvent.click(screen.getByRole('button', { name: '删除选中' }));
expect(screen.getByTestId('layers').textContent).not.toContain('first');
expect(screen.getByTestId('layers').textContent).toContain('second');
expect(screen.getByTestId('selection').textContent).toBe('-:');
expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('first');
expect(onDeleteGenerationDialogSideEffects).toHaveBeenCalledWith(
'dialog-1',
);
});
});
@@ -8,6 +8,7 @@ import {
import type {
CanvasClipboard,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import {
@@ -26,12 +27,17 @@ import {
type CanvasLayerFlipAxis,
type CanvasLayerMoveMode,
} from './ImageCanvasLayerCommandModel';
import {
getCanvasGenerationDialogIdFromSelectionId,
getSelectedLayerIds,
} from './ImageCanvasSelectionModel';
type LayerCommandsOptions = {
layers: CanvasLayer[];
contextMenu: CanvasContextMenuState | null;
selectedLayerId: string | null;
selectedLayerIds: string[];
canvasGenerationDialogs?: CanvasGenerationDialogState[];
setLayers: Dispatch<SetStateAction<CanvasLayer[]>>;
setSelectedLayerId: Dispatch<SetStateAction<string | null>>;
setSelectedLayerIds: Dispatch<SetStateAction<string[]>>;
@@ -43,6 +49,7 @@ type LayerCommandsOptions = {
captureCanvasHistory: () => void;
selectSingleLayer: (layerId: string | null) => void;
onDeleteLayerSideEffects: (targetLayerId: string) => void;
onDeleteGenerationDialogSideEffects?: (targetDialogId: string) => void;
exportLayerImage: (layer: CanvasLayer | null) => void;
};
@@ -55,6 +62,7 @@ export function useImageCanvasLayerCommands({
contextMenu,
selectedLayerId,
selectedLayerIds,
canvasGenerationDialogs = [],
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
@@ -66,6 +74,7 @@ export function useImageCanvasLayerCommands({
captureCanvasHistory,
selectSingleLayer,
onDeleteLayerSideEffects,
onDeleteGenerationDialogSideEffects,
exportLayerImage,
}: LayerCommandsOptions) {
const [canvasClipboard, setCanvasClipboard] =
@@ -349,31 +358,55 @@ export function useImageCanvasLayerCommands({
: selectedLayerId
? [selectedLayerId]
: [];
if (targetIds.length <= 1) {
deleteLayerById(targetIds[0] ?? null);
const targetLayerIds = getSelectedLayerIds(targetIds);
const targetDialogIds = targetIds
.map(getCanvasGenerationDialogIdFromSelectionId)
.filter((dialogId): dialogId is string => Boolean(dialogId))
.filter((dialogId) =>
canvasGenerationDialogs.some((dialog) => dialog.id === dialogId),
);
if (!targetLayerIds.length && !targetDialogIds.length) {
return;
}
if (targetLayerIds.length === 1 && !targetDialogIds.length) {
deleteLayerById(targetLayerIds[0] ?? null);
return;
}
captureCanvasHistory();
setImageContextMenu(null);
setContextMenu(null);
setLayers((currentLayers) => {
const nextLayers = currentLayers.filter(
(layer) => !targetIds.includes(layer.id),
);
const nextSelectedLayer = nextLayers
.slice()
.sort((left, right) => right.zIndex - left.zIndex)[0];
selectSingleLayer(nextSelectedLayer?.id ?? null);
return nextLayers;
});
if (targetLayerIds.length) {
setLayers((currentLayers) => {
const nextLayers = currentLayers.filter(
(layer) => !targetLayerIds.includes(layer.id),
);
if (!targetDialogIds.length) {
const nextSelectedLayer = nextLayers
.slice()
.sort((left, right) => right.zIndex - left.zIndex)[0];
selectSingleLayer(nextSelectedLayer?.id ?? null);
}
return nextLayers;
});
}
if (targetDialogIds.length || !targetLayerIds.length) {
selectSingleLayer(null);
}
setHoveredLayerId(null);
setMetadataLayer((currentLayer) =>
currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer,
currentLayer && targetLayerIds.includes(currentLayer.id)
? null
: currentLayer,
);
targetLayerIds.forEach((targetId) => onDeleteLayerSideEffects(targetId));
targetDialogIds.forEach((targetId) =>
onDeleteGenerationDialogSideEffects?.(targetId),
);
targetIds.forEach((targetId) => onDeleteLayerSideEffects(targetId));
}, [
captureCanvasHistory,
canvasGenerationDialogs,
deleteLayerById,
onDeleteGenerationDialogSideEffects,
onDeleteLayerSideEffects,
selectSingleLayer,
selectedLayerId,
@@ -391,12 +424,13 @@ export function useImageCanvasLayerCommands({
: selectedLayerId
? [selectedLayerId]
: [];
if (!targetIds.length) {
const targetLayerIds = getSelectedLayerIds(targetIds);
if (!targetLayerIds.length) {
return;
}
captureCanvasHistory();
setLayers((currentLayers) =>
groupCanvasLayers(currentLayers, targetIds, createGroupId()),
groupCanvasLayers(currentLayers, targetLayerIds, createGroupId()),
);
}, [captureCanvasHistory, selectedLayerId, selectedLayerIds, setLayers]);
@@ -8,6 +8,7 @@ import {
import type {
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
ImageContextMenuState,
@@ -20,6 +21,7 @@ import {
type UseImageCanvasStageControllerOptions = {
layers: CanvasLayer[];
canvasGenerationDialogs?: CanvasGenerationDialogState[];
selectedLayerId: string | null;
selectedLayerIds: string[];
imageContextMenu: ImageContextMenuState | null;
@@ -39,6 +41,7 @@ type UseImageCanvasStageControllerOptions = {
export function useImageCanvasStageController({
layers,
canvasGenerationDialogs,
selectedLayerId,
selectedLayerIds,
imageContextMenu,
@@ -56,6 +59,7 @@ export function useImageCanvasStageController({
() =>
resolveImageCanvasStageControllerModel({
layers,
canvasGenerationDialogs,
selectedLayerId,
selectedLayerIds,
imageContextMenu,
@@ -65,6 +69,7 @@ export function useImageCanvasStageController({
}),
[
canvasSize,
canvasGenerationDialogs,
contextMenu,
imageContextMenu,
layers,
@@ -15,6 +15,7 @@ import type {
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions';
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
function createLayer(overrides: Partial<CanvasLayer>): CanvasLayer {
const id = overrides.id ?? 'layer-a';
@@ -163,6 +164,10 @@ function StageInteractionsHarness({
originalHeight: 120,
},
});
const activeGenerationDialog = asCanvasGenerationDialog(generateDialog);
const canvasGenerationDialogs = activeGenerationDialog
? [activeGenerationDialog]
: [];
const [clearCount, setClearCount] = useState(0);
const [imageMenuCloseCount, setImageMenuCloseCount] = useState(0);
@@ -177,6 +182,7 @@ function StageInteractionsHarness({
setSelectedLayerId,
setSelectedLayerIds,
generateDialog,
canvasGenerationDialogs,
setGenerateDialog,
isPickingCharacterSpecFromCanvas: false,
isPickingGenerationReferenceFromCanvas,
@@ -225,7 +231,6 @@ function StageInteractionsHarness({
onCloseImageContextMenu: () =>
setImageMenuCloseCount((currentCount) => currentCount + 1),
});
const activeGenerationDialog = asCanvasGenerationDialog(generateDialog);
const getViewportElement = () => {
const element = canvasViewportRef.current;
if (!element) {
@@ -316,6 +321,9 @@ function StageInteractionsHarness({
? `${generateDialog.placeholder.x.toFixed(1)},${generateDialog.placeholder.y.toFixed(1)}`
: '-'}
</span>
<span data-testid="dialog-selection-id">
{getCanvasGenerationSelectionId('dialog-1')}
</span>
<span data-testid="dialog-open">
{String(generateDialog?.composerOpen ?? false)}
</span>
@@ -369,6 +377,7 @@ function StageInteractionsHarness({
}),
firstLayer,
);
interaction.setShiftPressed(false);
}}
>
直接选第一层
@@ -515,6 +524,27 @@ function StageInteractionsHarness({
>
直接拖生成占位
</button>
<button
type="button"
onClick={(event) => {
const frameElement = document.createElement('div');
const dialog = asCanvasGenerationDialog(generateDialog);
if (!dialog) {
return;
}
interaction.setShiftPressed(true);
interaction.handleGenerationFramePointerDown(
createPointerEvent(frameElement, {
pointerId: 8,
clientX: 300,
clientY: 200,
}),
dialog,
);
}}
>
直接追加生成器
</button>
<button
type="button"
onClick={(event) => {
@@ -642,6 +672,30 @@ describe('useImageCanvasStageInteractions', () => {
);
});
it('selects generation frames and keeps mixed selections movable', () => {
render(<StageInteractionsHarness />);
act(() => {
screen.getByRole('button', { name: '直接选第一层' }).click();
});
act(() => {
screen.getByRole('button', { name: '直接追加生成器' }).click();
});
expect(screen.getByTestId('selection').textContent).toBe(
`first:first,${screen.getByTestId('dialog-selection-id').textContent}`,
);
act(() => {
screen.getByRole('button', { name: '直接拖生成占位' }).click();
});
expect(screen.getByTestId('dialog-position').textContent).toBe(
'340.0,230.0',
);
expect(screen.getByTestId('layers').textContent).toContain(
'first:80.0,70.0',
);
});
it('distinguishes minimap click and drag movement', () => {
const moveViewportFromMinimapPointer = vi.fn();
const updateViewportFromMinimapDrag = vi.fn();
@@ -11,14 +11,15 @@ import {
} from 'react';
import {
moveGenerationFrameFromDrag,
moveGenerationFramesFromDrag,
moveDragSelectedLayers,
moveLayersFromDrag,
moveViewportFromPan,
selectLayersInsideMarquee,
selectCanvasObjectsInsideMarquee,
} from './ImageCanvasInteractionModel';
import {
createCanvasMarqueeState,
createGenerationFrameDragState,
createGenerationFrameSelectionStart,
createLayerDragStart,
createMinimapDragState,
createPanDragState,
@@ -52,6 +53,7 @@ type UseImageCanvasStageInteractionsOptions = {
setSelectedLayerId: Dispatch<SetStateAction<string | null>>;
setSelectedLayerIds: Dispatch<SetStateAction<string[]>>;
generateDialog: GenerateDialogState | null;
canvasGenerationDialogs: CanvasGenerationDialogState[];
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
isPickingCharacterSpecFromCanvas: boolean;
isPickingGenerationReferenceFromCanvas: boolean;
@@ -98,6 +100,7 @@ export function useImageCanvasStageInteractions({
setSelectedLayerId,
setSelectedLayerIds,
generateDialog,
canvasGenerationDialogs,
setGenerateDialog,
isPickingCharacterSpecFromCanvas,
isPickingGenerationReferenceFromCanvas,
@@ -281,13 +284,14 @@ export function useImageCanvasStageInteractions({
const layerDragStart = createLayerDragStart({
layer,
layers,
generationDialogs: canvasGenerationDialogs,
selectedLayerIds,
isMultiSelectGesture,
pointerId: getPointerId(event),
pointer,
viewportScale: viewport.scale,
});
setSelectedLayerId(layer.id);
setSelectedLayerId(layerDragStart.selectedLayerId);
setSelectedLayerIds(layerDragStart.selectedLayerIds);
setGenerateDialog((currentDialog) =>
updateGenerateDialogForLayerPointerDown(currentDialog, layer.id),
@@ -296,6 +300,7 @@ export function useImageCanvasStageInteractions({
},
[
canvasViewportRef,
canvasGenerationDialogs,
effectiveTool,
generateDialog?.mode,
isPickingCharacterSpecFromCanvas,
@@ -394,18 +399,33 @@ export function useImageCanvasStageInteractions({
event.stopPropagation();
const pointer = getPointerClient(event);
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
activateCanvasGenerationDialog(dialog);
dragStateRef.current = createGenerationFrameDragState({
const isMultiSelectGesture = event.shiftKey || isShiftPressedRef.current;
if (!isMultiSelectGesture) {
activateCanvasGenerationDialog(dialog);
}
const frameDragStart = createGenerationFrameSelectionStart({
dialog,
layers,
generationDialogs: canvasGenerationDialogs,
selectedLayerIds,
isMultiSelectGesture,
pointerId: getPointerId(event),
pointer,
viewportScale: viewport.scale,
});
setSelectedLayerId(frameDragStart.selectedLayerId);
setSelectedLayerIds(frameDragStart.selectedLayerIds);
dragStateRef.current = frameDragStart.dragState;
},
[
activateCanvasGenerationDialog,
canvasViewportRef,
canvasGenerationDialogs,
effectiveTool,
layers,
selectedLayerIds,
setSelectedLayerId,
setSelectedLayerIds,
startPan,
viewport.scale,
],
@@ -445,14 +465,19 @@ export function useImageCanvasStageInteractions({
}
: null,
);
const selectedIds = selectLayersInsideMarquee({
const selectedIds = selectCanvasObjectsInsideMarquee({
marquee: canvasMarquee,
currentPoint,
layers,
generationDialogs: canvasGenerationDialogs,
viewport,
});
setSelectedLayerIds(selectedIds);
setSelectedLayerId(selectedIds[0] ?? null);
setSelectedLayerId(
selectedIds.find(
(selectedId) => !selectedId.startsWith('generation-dialog:'),
) ?? null,
);
return;
}
@@ -475,18 +500,23 @@ export function useImageCanvasStageInteractions({
if (dragState.kind === 'generation-frame') {
const pointer = getPointerClient(event);
const nextFramePoint = moveGenerationFrameFromDrag(dragState, pointer);
updateCanvasGenerationDialogById(dragState.dialogId, (currentDialog) =>
currentDialog.placeholder
? {
...currentDialog,
placeholder: {
...currentDialog.placeholder,
x: nextFramePoint.x,
y: nextFramePoint.y,
},
}
: currentDialog,
const movedDialogs = moveGenerationFramesFromDrag({
dragState,
generationDialogs: canvasGenerationDialogs,
pointer,
});
movedDialogs.forEach((nextDialog) => {
if (!dragState.dialogIds.includes(nextDialog.id)) {
return;
}
updateCanvasGenerationDialogById(nextDialog.id, () => nextDialog);
});
setLayers((currentLayers) =>
moveDragSelectedLayers({
dragState,
layers: currentLayers,
pointer,
}),
);
return;
}
@@ -510,10 +540,22 @@ export function useImageCanvasStageInteractions({
}
setSnapGuide(movedLayers.snapGuide);
setLayers(movedLayers.layers);
const movedDialogs = moveGenerationFramesFromDrag({
dragState,
generationDialogs: canvasGenerationDialogs,
pointer,
});
movedDialogs.forEach((nextDialog) => {
if (!dragState.generationDialogIds?.includes(nextDialog.id)) {
return;
}
updateCanvasGenerationDialogById(nextDialog.id, () => nextDialog);
});
},
[
canvasMarquee,
canvasViewportRef,
canvasGenerationDialogs,
layers,
setLayers,
setSelectedLayerId,