支持画布多选生成器
新增画布生成器选择 ID 模型,支持图层与生成器混合选择 补齐框选、Shift 点选、拖拽和删除对生成器占位的批量操作 修复音频控件焦点删除和手动素材标签资源持久化 更新图片画布编辑器多选语义文档
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user