diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 88da8aae3..80f3f8a7c 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -91,6 +91,7 @@ - 生成图片点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 - 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。 - 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。宣发素材生成器刷新后必须继续显示正确的卡片类型、游戏名、分类、描述和已绑定参考图。 +- 画布多选语义必须同时覆盖普通图层和仍显示占位框的生成器对象:Shift 点选或框选可把生成器加入当前选择;拖动任一已选图层或生成器时,所有已选普通图层和生成器占位框同步移动;删除 / Backspace / Delete 作用于完整选择集合,移除所有已选图层和生成器对象。生成器对象在选择集合中使用稳定 `generation-dialog:` 目标 ID,不把生成器伪装成普通图层,也不新增后端表。 - 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。 - 生成规范类图片面板底部必须以禁用态参数按钮显示 `16:9 · 2K` 和 `gpt-image-2`,视觉对齐可编辑面板参数控件,提交到 `/api/editor/images/generations` 时也固定携带这些参数。 - 快速编辑面板底部必须以禁用态参数按钮显示原图尺寸和原图模型,视觉对齐可编辑面板参数控件;参考图顺序为额外参考图在前、原图在最后。 diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index c12c02e04..18ad713ed 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -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(), diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index b33731925..c9e23ef47 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -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; } | { diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index c500cebf0..460d90e81 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -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 () => { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index f3acda463..d556f5cce 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -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) => diff --git a/src/components/image-editor/ImageCanvasInteractionModel.test.ts b/src/components/image-editor/ImageCanvasInteractionModel.test.ts index b4b55e3cd..31995d2f6 100644 --- a/src/components/image-editor/ImageCanvasInteractionModel.test.ts +++ b/src/components/image-editor/ImageCanvasInteractionModel.test.ts @@ -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 { const id = overrides.id ?? 'layer-a'; @@ -37,6 +45,27 @@ function createLayer(overrides: Partial): CanvasLayer { }; } +function createGenerationDialog( + overrides: Partial = {}, +): 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', () => { diff --git a/src/components/image-editor/ImageCanvasInteractionModel.ts b/src/components/image-editor/ImageCanvasInteractionModel.ts index a1c8e65a1..b4eb3ca3d 100644 --- a/src/components/image-editor/ImageCanvasInteractionModel.ts +++ b/src/components/image-editor/ImageCanvasInteractionModel.ts @@ -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; + 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; + 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, diff --git a/src/components/image-editor/ImageCanvasSelectionModel.ts b/src/components/image-editor/ImageCanvasSelectionModel.ts new file mode 100644 index 000000000..df1151e7a --- /dev/null +++ b/src/components/image-editor/ImageCanvasSelectionModel.ts @@ -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); +} diff --git a/src/components/image-editor/ImageCanvasStageControllerModel.ts b/src/components/image-editor/ImageCanvasStageControllerModel.ts index 5e0291df8..c60b46602 100644 --- a/src/components/image-editor/ImageCanvasStageControllerModel.ts +++ b/src/components/image-editor/ImageCanvasStageControllerModel.ts @@ -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; diff --git a/src/components/image-editor/ImageCanvasStageInteractionModel.test.ts b/src/components/image-editor/ImageCanvasStageInteractionModel.test.ts index c42f7ab3a..191c5f1c6 100644 --- a/src/components/image-editor/ImageCanvasStageInteractionModel.test.ts +++ b/src/components/image-editor/ImageCanvasStageInteractionModel.test.ts @@ -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 { 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, + }, + }); + }); }); diff --git a/src/components/image-editor/ImageCanvasStageInteractionModel.ts b/src/components/image-editor/ImageCanvasStageInteractionModel.ts index 8ca4a9b74..08ceb1a0d 100644 --- a/src/components/image-editor/ImageCanvasStageInteractionModel.ts +++ b/src/components/image-editor/ImageCanvasStageInteractionModel.ts @@ -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; } { 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 | 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, }; } diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index efd228840..74f7ef067 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -82,6 +82,7 @@ export type ImageCanvasStageViewProps = { event: ReactMouseEvent, layer: CanvasLayer, ) => void; + onSelectLayer: (layerId: string | null) => void; onLayerContextMenu: ( event: ReactMouseEvent, 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} diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 75a8bdbfb..a35816958 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -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(), diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index ad4807549..759b834b5 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -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, layer: CanvasLayer, ) => void; + onSelectLayer: (layerId: string | null) => void; onLayerContextMenu: ( event: ReactMouseEvent, layer: CanvasLayer, @@ -278,6 +280,14 @@ function stopMediaEventPropagation( event.stopPropagation(); } +function selectMediaLayerWithoutDrag( + event: ReactPointerEvent, + onSelectLayer: () => void, +) { + event.stopPropagation(); + onSelectLayer(); +} + function handleLayerKeyboardActivation( event: ReactKeyboardEvent, ) { @@ -299,6 +309,15 @@ function handleLayerTagKeyboardActivation( event.currentTarget.click(); } +function stopMediaControlKeyPropagation( + event: ReactKeyboardEvent, +) { + 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(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({
event.stopPropagation()} - onPointerDown={stopMediaEventPropagation} + onKeyDown={stopMediaControlKeyPropagation} + onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)} >
); } @@ -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( + , + ); + + 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', + ); + }); }); diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index fda622de7..2bc968522 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -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>; setSelectedLayerId: Dispatch>; setSelectedLayerIds: Dispatch>; @@ -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]); diff --git a/src/components/image-editor/useImageCanvasStageController.ts b/src/components/image-editor/useImageCanvasStageController.ts index b9d685a1a..92fbc291c 100644 --- a/src/components/image-editor/useImageCanvasStageController.ts +++ b/src/components/image-editor/useImageCanvasStageController.ts @@ -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, diff --git a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx index fec939e2b..edafe3af5 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx +++ b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx @@ -15,6 +15,7 @@ import type { GenerateDialogState, } from './ImageCanvasEditorTypes'; import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions'; +import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel'; function createLayer(overrides: Partial): 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)}` : '-'} + + {getCanvasGenerationSelectionId('dialog-1')} + {String(generateDialog?.composerOpen ?? false)} @@ -369,6 +377,7 @@ function StageInteractionsHarness({ }), firstLayer, ); + interaction.setShiftPressed(false); }} > 直接选第一层 @@ -515,6 +524,27 @@ function StageInteractionsHarness({ > 直接拖生成占位 +