From b27c0e8443a3a11a698f60f13555c339a02f45a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 3 Aug 2026 14:04:53 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=89=AF=E6=9C=AC=E5=88=B7=E6=96=B0=E5=90=8E?= =?UTF-8?q?=E6=B6=88=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复制粘贴和创建副本改为复用原项目资源 补齐临时资源共享回填与缓存去重 新增副本保存重进和结构化布局回归测试 同步画布持久化文档与项目决策记录 --- .../shared-memory/decision-log.md | 6 ++ ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 1 + ...片画布结构化持久化与迁移回滚方案-2026-07-19.md | 2 + .../src/editor_project_storage.rs | 30 ++++++ ...mageCanvasEditorAssetsIntegration.test.tsx | 59 ++++++++++++ .../image-editor/ImageCanvasEditorView.tsx | 1 + .../ImageCanvasLayerCommandModel.test.ts | 5 +- .../ImageCanvasLayerCommandModel.ts | 1 - .../useImageCanvasLayerCommands.test.tsx | 7 ++ .../useImageCanvasLayerCommands.ts | 24 +++-- .../useImageCanvasProjectPersistence.test.tsx | 94 +++++++++++++++++++ .../useImageCanvasProjectPersistence.ts | 87 +++++++++++++++-- 12 files changed, 296 insertions(+), 21 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0975b8153..39391818d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5908,3 +5908,9 @@ - 显式协作合同:autonomous 的旧 `code-prototype + quality-review` 首批合同退出。显式 project collaboration policy 或持久 batch 恢复若进入首批 `agent.delegate` 路径,只允许且要求三个 Director 各一次;策划与程序 Director 是只读规划且 `expectedArtifacts=[]`,美术 Director 是非只读规范图任务且必须交付 `assets/art-spec.png`。任何非 repair 底层委派与 isolated child 都在首批失败关闭;默认 manifest DAG 仍是唯一自动首轮执行链,不额外复制三个 Director 委派。 - 输出决策:保留未提交 `streaming / ready` 的当前 revision 门;已提交的专业 Agent final reply 继续使用既有 durable response-stream 身份,后续项目 revision 变化不再隐藏早期阶段回复。 - 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`start-dev-stack.mjs`、`src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`response_stream.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +## 2026-08-03 图片画布复制固定为布局实例复用项目资源 + +- 背景:画布复制逻辑曾为副本生成 `local-resource-copy-*`,但没有创建对应项目资源;自动保存虽然触发,结构化画布会拒绝缺资源引用,legacy 快照刷新时也无法 hydrate 副本。 +- 决策:`layerId` 是同一 canvas 内唯一的布局实例身份,`resourceId` 是允许多个布局实例共享的项目媒体身份。复制、粘贴、创建副本和剪切后粘贴只生成新 `layerId`,复用来源 `resourceId`;已登记资源不得再次上传或创建。临时 `local-*` 资源按用户、项目和临时 ID 合并创建,正式 ID 返回后更新全部共享该临时 ID 的当前图层;session 项目缓存也按 `resourceId` 只保留一份资源快照。 +- 后端边界:现有 `editor_canvas_layer` 已以 `layer_id` 建立行身份并单独保存 `resource_id`,天然支持多 layer 引用同一 resource;本次不修改 HTTP DTO、SpacetimeDB schema、迁移或 bindings,只增加结构化 round-trip 契约测试。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index ef0e0f8ff..cde78649d 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -57,6 +57,7 @@ - 右上角缩放控件只展示当前缩放百分比;点击后弹出菜单:放大、缩小、显示画布所有元素、缩放至 50%、缩放至 100%、缩放至 200%。缩放百分比以实际画布 `viewport.scale = 0.5` 作为显示 `100%` 的基准,菜单中的 `50% / 100% / 200%` 分别对应实际 `0.25 / 0.5 / 1`,工程持久化保存和读取仍使用用户可见缩放语义。 - 缩放菜单支持 `Ctrl/Cmd +`、`Ctrl/Cmd -` 和 `Shift + 1`;快捷键只改变 viewport,不修改工程资源。 - 右上角提供快捷键入口,点击后打开独立快捷键弹窗;画布不常驻展示说明文案。Windows 快捷键覆盖 `Ctrl+Z` / `Ctrl+Shift+Z` 撤销与恢复、`Ctrl+A/C/V/X/D` 全选 / 复制 / 粘贴 / 剪切 / 复制一份、`Ctrl+0/1/+/-` 视图控制、`V/H/U/G/Shift+V/M` 工具切换、`Alt+1/Alt+2/Alt+M` 面板切换、`Ctrl+]` / `Ctrl+[` / `Ctrl+Shift+]` / `Ctrl+Shift+[` 层级调整、方向键微移 / `Shift+方向键` 大步移动、`Ctrl+Shift+S` 下载画布素材和 `F2` 重命名项目;快捷键只触发对应画布交互,不绕过既有保存 / 生成 / 上传工作流。撤销与恢复的内容保护、提示和验收边界统一以 [`图片画布撤销、恢复范围与操作提示方案`](../【图片画布】撤销范围与操作提示方案-2026-07-17.md) 为准。 +- 画布内部复制 / 粘贴和创建副本只新增独立 `layerId` 布局实例,继续引用来源图层的同一项目 `resourceId`;副本可独立移动、排序、分组、显隐和删除,但不得重复上传媒体或为同一图片创建虚假项目资源。没有内部画布剪贴板时,系统剪贴板图片仍按新素材上传。 - 背景色控件只修改编辑器工作区底色,不恢复网格线或棋盘格底纹,也不影响图片本体。 - 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。 - 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。 diff --git a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md index bb6795e99..f55dd1f7b 100644 --- a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md +++ b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md @@ -31,6 +31,8 @@ 保存 `layer_id`、`canvas_id`、`project_id`、`owner_user_id`、坐标、宽高、原始尺寸、层级顺序、可选 `group_id`、hidden / locked / flip 状态、`resource_id`、有界的未结构化扩展 JSON、创建与更新时间。查询有 canvas / project 索引;同一 canvas 的层级顺序由 `sort_order` 决定。 +同一 canvas 内 `layer_id` 是唯一的布局实例身份,`resource_id` 是可复用的媒体资源引用,不要求唯一。复制、粘贴或创建副本只创建新的 `layer_id` 和独立几何 / 层级状态,继续引用来源图层的同一 `resource_id`;不得为同一媒体生成虚假的本地资源 ID、重复上传对象或重复创建项目资源。临时 `local-*` 资源尚在登记时,前端按临时 ID 合并资源创建,并在正式 ID 返回后更新全部引用该临时 ID 的布局实例。 + layer 只表达“某个资源怎样放在画布上”。`src / prompt / actualPrompt / model / provider / taskId / objectKey / assetObjectId / sourceResourceId / assetKind / generationInputs / sourceType` 不得为方便展示而重复进入 `item_json` 或成为 layer 真相;迁移或结构化保存时先逐字段核对 `editor_project_resource`,缺少资源、字段冲突或无法无损重组时必须 fail-closed。历史 layout 若把 `sourceResourceId` 错写成当前图层自己的 `resourceId`,这是无意义的自引用,不作为 A/B 来源冲突:迁移时删除该重复字段并以项目资源表为真相;其他非空且不一致的来源 ID 继续拒绝。唯一存量缺资源例外是历史角色动作产生的自包含本地图层:`resourceId` 必须以 `local-` 开头、`sourceType=generated`、`mediaType=image-sequence`,至少包含一帧;帧序号必须从 1 连续递增,宽高必须是有限正数;所有 `imageSequenceFrames[].imageSrc` 及可选 `thumbnailSrc / previewVideoPath` 都必须是无 query / fragment、无路径回退段的站内根路径,且不能含 `data:`、`blob:`、HTTP 或签名 URL;可选帧 `objectKey` 必须与 `imageSrc` 去掉首斜杠后完全一致,图层级 `imageSrc / objectKey / assetObjectId` 必须为空,图层级 `src` 只允许为空或与首帧 `imageSrc` 完全一致。满足这些条件但资源行已不存在时,保留其有界媒体扩展和生成元数据以便前端从首帧恢复,不把这些字段从 canonical hash 中剥离;`sourceResourceId` 目标仍存在时必须属于同工程和 owner,目标已删除时保留原引用参与 hash,不据此伪造资源行。该例外不适用于普通图片、视频、音频、非本地 ID、空帧序列或已有资源字段冲突。存量 `assetKind / generationInputs` 仅允许在资源行尚未记录时由 apply 事务补入资源表,dry-run 只计算预览而不写库。前端读取项目快照时继续使用随项目返回的 resources 按 `resource_id` hydrate,兼容现有画布快照语义。 ### 3.2 `editor_canvas_generation_dialog` diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 5df4afb18..2bc6ad74b 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -9691,6 +9691,36 @@ mod tests { ); } + #[test] + fn structured_canvas_allows_distinct_layers_to_share_one_resource() { + let shared = r#"[ + {"layerId":"layer-1","resourceId":"resource-shared","x":10}, + {"layerId":"layer-2","resourceId":"resource-shared","x":42} + ]"#; + let layout = parse_structured_canvas_layout(shared) + .expect("distinct layout instances may share a resource"); + + assert_eq!(layout.layers.len(), 2); + assert_eq!(layout.layers[0].resource_id, "resource-shared"); + assert_eq!(layout.layers[1].resource_id, "resource-shared"); + + let rebuilt = serialize_structured_canvas_layout(&layout) + .expect("shared-resource layout should serialize"); + let rebuilt_value: JsonValue = + serde_json::from_str(&rebuilt).expect("rebuilt layout should be valid JSON"); + assert_eq!(rebuilt_value.as_array().map(Vec::len), Some(2)); + + let shared_integrity = canvas_layout_integrity(shared).expect("shared integrity"); + let single_integrity = + canvas_layout_integrity(r#"[{"layerId":"layer-1","resourceId":"resource-shared"}]"#) + .expect("single integrity"); + assert_eq!(shared_integrity.layer_count, 2); + assert_eq!( + shared_integrity.resource_refs_sha256, + single_integrity.resource_refs_sha256 + ); + } + #[test] fn structured_canvas_rollback_rejects_layout_over_two_mib() { let oversized = "x".repeat(EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES + 1); diff --git a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx index 27e840204..4241e5ef5 100644 --- a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx @@ -17,6 +17,7 @@ import { createAuthValue, createDataTransferStub, createDeferred, + defaultEditorProjectResources, dispatchPointerEvent, ImageCanvasEditorView, setupImageCanvasEditorViewTestLifecycle, @@ -1287,6 +1288,64 @@ describe('ImageCanvasEditorView asset library integration', () => { ); }); + it('restores a copied layout that references the same project resource after re-entering', async () => { + const firstRender = render(); + await waitFor(() => { + expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); + }); + saveEditorProjectLayoutMock.mockClear(); + createEditorProjectResourceMock.mockClear(); + + fireEvent.contextMenu( + screen.getByAltText('画布图片:拼图素材').closest('button')!, + { + clientX: 510, + clientY: 330, + }, + ); + fireEvent.click(screen.getByRole('menuitem', { name: '复制' })); + fireEvent.contextMenu(screen.getByLabelText('画布工作区'), { + clientX: 360, + clientY: 240, + }); + fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' })); + + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalled(); + }); + const savedLayers = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1] + ?.layers as Array<{ layerId: string; resourceId: string }>; + const copiedPuzzleLayouts = savedLayers.filter( + (layer) => layer.resourceId === 'resource-puzzle', + ); + expect(copiedPuzzleLayouts).toHaveLength(2); + expect( + new Set(copiedPuzzleLayouts.map((layer) => layer.layerId)).size, + ).toBe(2); + expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); + + firstRender.unmount(); + window.sessionStorage.clear(); + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce( + withEditorProjectCanvasRevision( + { + projectId: 'editor-project-default', + title: '默认项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: savedLayers, + resources: defaultEditorProjectResources, + updatedAt: '2026-08-03T00:00:00.000Z', + }, + 1, + ), + ); + + render(); + await waitFor(() => { + expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2); + }); + }); + it('drops files into the asset panel only once without creating canvas layers', async () => { render(); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index e6f8a7236..eff2653ef 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1793,6 +1793,7 @@ export function ImageCanvasEditorView({ selectedLayerIds, canvasGenerationDialogs, setLayers, + appendCanvasLayersWithResources, setSelectedLayerId, setSelectedLayerIds, setHoveredLayerId, diff --git a/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts b/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts index 88b353c96..fb56d8119 100644 --- a/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts +++ b/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts @@ -81,7 +81,7 @@ describe('ImageCanvasLayerCommandModel', () => { expect(duplicated).toMatchObject([ { id: 'layer-copy-test-0', - resourceId: 'local-resource-copy-test-0', + resourceId: 'resource-first', title: '第一张 副本', x: 300, y: 200, @@ -90,7 +90,7 @@ describe('ImageCanvasLayerCommandModel', () => { }, { id: 'layer-copy-test-1', - resourceId: 'local-resource-copy-test-1', + resourceId: 'resource-second', title: '第二张 副本', x: 350, y: 260, @@ -107,6 +107,7 @@ describe('ImageCanvasLayerCommandModel', () => { }); expect(cutPaste[0]?.title).toBe('第一张'); expect(cutPaste[0]?.x).toBe(first.x + 32); + expect(cutPaste[0]?.resourceId).toBe(first.resourceId); }); it('creates a cloned clipboard and removes target layers', () => { diff --git a/src/components/image-editor/ImageCanvasLayerCommandModel.ts b/src/components/image-editor/ImageCanvasLayerCommandModel.ts index 24079124e..16d18bb0a 100644 --- a/src/components/image-editor/ImageCanvasLayerCommandModel.ts +++ b/src/components/image-editor/ImageCanvasLayerCommandModel.ts @@ -76,7 +76,6 @@ export function duplicateCanvasLayers({ return sourceLayers.map((layer, index) => ({ ...cloneLayer(layer), id: `layer-copy-${stamp}-${index}`, - resourceId: `local-resource-copy-${stamp}-${index}`, title: renameCopies ? `${layer.title} 副本` : layer.title, x: canvasPoint ? canvasPoint.x + (layer.x - minX) : layer.x + 32, y: canvasPoint ? canvasPoint.y + (layer.y - minY) : layer.y + 32, diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx index 3f063c6aa..795bd699a 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx +++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx @@ -140,6 +140,8 @@ function LayerCommandsHarness({ selectedLayerIds, canvasGenerationDialogs, setLayers, + appendCanvasLayersWithResources: (nextLayers) => + setLayers((currentLayers) => [...currentLayers, ...nextLayers]), setSelectedLayerId, setSelectedLayerIds, setHoveredLayerId, @@ -492,6 +494,11 @@ describe('useImageCanvasLayerCommands', () => { expect(screen.getByTestId('selection').textContent).toContain( 'layer-copy-', ); + fireEvent.click(screen.getByRole('button', { name: '创建选中副本' })); + const layerIds = (screen.getByTestId('layers').textContent ?? '') + .split('|') + .map((entry) => entry.split(':')[0]); + expect(new Set(layerIds).size).toBe(layerIds.length); fireEvent.click(screen.getByRole('button', { name: '只选第三层' })); fireEvent.click(screen.getByRole('button', { name: '选中置顶' })); diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index 4aa6ecc98..6be4188c5 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -43,6 +43,7 @@ type LayerCommandsOptions = { selectedLayerIds: string[]; canvasGenerationDialogs?: CanvasGenerationDialogState[]; setLayers: Dispatch>; + appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void; setSelectedLayerId: Dispatch>; setSelectedLayerIds: Dispatch>; setHoveredLayerId: Dispatch>; @@ -132,6 +133,7 @@ export function useImageCanvasLayerCommands({ selectedLayerIds, canvasGenerationDialogs = [], setLayers, + appendCanvasLayersWithResources, setSelectedLayerId, setSelectedLayerIds, setHoveredLayerId, @@ -148,6 +150,7 @@ export function useImageCanvasLayerCommands({ const [canvasClipboard, setCanvasClipboard] = useState(null); const canvasClipboardRef = useRef(null); + const duplicateLayerSequenceRef = useRef(0); const closeContextMenus = useCallback(() => { setContextMenu(null); setImageContextMenu(null); @@ -172,13 +175,16 @@ export function useImageCanvasLayerCommands({ sourceLayers: CanvasLayer[], canvasPoint?: { x: number; y: number }, options: { renameCopies?: boolean } = {}, - ) => - duplicateCanvasLayers({ + ) => { + duplicateLayerSequenceRef.current += 1; + return duplicateCanvasLayers({ sourceLayers, allLayers: layers, canvasPoint, renameCopies: options.renameCopies !== false, - }), + stamp: `${Date.now()}-${duplicateLayerSequenceRef.current}`, + }); + }, [layers], ); @@ -195,7 +201,7 @@ export function useImageCanvasLayerCommands({ return false; } captureCanvasHistory({ type: 'paste-image', count: nextLayers.length }); - setLayers((currentLayers) => [...currentLayers, ...nextLayers]); + appendCanvasLayersWithResources(nextLayers); setSelectedLayerIds(nextLayers.map((layer) => layer.id)); setSelectedLayerId(nextLayers[0]?.id ?? null); setActiveTool('select'); @@ -206,8 +212,8 @@ export function useImageCanvasLayerCommands({ captureCanvasHistory, closeContextMenus, duplicateLayersToPoint, + appendCanvasLayersWithResources, setActiveTool, - setLayers, setSelectedLayerId, setSelectedLayerIds, ], @@ -309,7 +315,7 @@ export function useImageCanvasLayerCommands({ return; } captureCanvasHistory({ type: 'duplicate-image', count: nextLayers.length }); - setLayers((currentLayers) => [...currentLayers, ...nextLayers]); + appendCanvasLayersWithResources(nextLayers); setSelectedLayerIds(nextLayers.map((layer) => layer.id)); setSelectedLayerId(nextLayers[0]?.id ?? null); closeContextMenus(); @@ -319,7 +325,7 @@ export function useImageCanvasLayerCommands({ duplicateLayersToPoint, getContextTargetLayerIds, layers, - setLayers, + appendCanvasLayersWithResources, setSelectedLayerId, setSelectedLayerIds, ]); @@ -332,7 +338,7 @@ export function useImageCanvasLayerCommands({ return; } captureCanvasHistory({ type: 'duplicate-image', count: nextLayers.length }); - setLayers((currentLayers) => [...currentLayers, ...nextLayers]); + appendCanvasLayersWithResources(nextLayers); setSelectedLayerIds(nextLayers.map((layer) => layer.id)); setSelectedLayerId(nextLayers[0]?.id ?? null); setContextMenu(null); @@ -342,9 +348,9 @@ export function useImageCanvasLayerCommands({ duplicateLayersToPoint, getSelectedTargetLayerIds, layers, + appendCanvasLayersWithResources, setContextMenu, setImageContextMenu, - setLayers, setSelectedLayerId, setSelectedLayerIds, ]); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 03f7d4439..af99d9ece 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -264,6 +264,30 @@ function ProjectPersistenceHarness({ > append + + ); } @@ -511,6 +528,38 @@ describe('useImageCanvasLayerCommands', () => { expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('third'); }); + it('blocks the whole copy operation until every selected resource is registered', () => { + const onPendingResourceCopyBlocked = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '复制选中' })); + fireEvent.click(screen.getByRole('button', { name: '剪切选中' })); + fireEvent.click(screen.getByRole('button', { name: '创建选中副本' })); + + expect(onPendingResourceCopyBlocked).toHaveBeenCalledTimes(3); + expect(screen.getByTestId('clipboard').textContent).toBe('-'); + expect(screen.getByTestId('layers').textContent).not.toContain('副本'); + expect(screen.getByTestId('layers').textContent).toContain('second'); + + fireEvent.click(screen.getByRole('button', { name: '完成素材保存' })); + fireEvent.click(screen.getByRole('button', { name: '复制选中' })); + + expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); + expect(onPendingResourceCopyBlocked).toHaveBeenCalledTimes(3); + }); + it('applies layer commands and clears menus without owning menu positioning', () => { const exportLayerImage = vi.fn(); render(); diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index 6be4188c5..169a4757b 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -15,6 +15,7 @@ import type { } from './ImageCanvasEditorTypes'; import { readLayerImageBlob } from './ImageCanvasExportModel'; import { + canCopyCanvasLayers, type CanvasLayerFlipAxis, type CanvasLayerMoveMode, createCanvasLayerClipboard, @@ -59,6 +60,7 @@ type LayerCommandsOptions = { layer: CanvasLayer | null, options?: { mode?: ImageSequenceExportMode }, ) => void; + onPendingResourceCopyBlocked?: () => void; }; function createGroupId() { @@ -146,6 +148,7 @@ export function useImageCanvasLayerCommands({ onDeleteLayerSideEffects, onDeleteGenerationDialogSideEffects, exportLayerImage, + onPendingResourceCopyBlocked, }: LayerCommandsOptions) { const [canvasClipboard, setCanvasClipboard] = useState(null); @@ -188,12 +191,28 @@ export function useImageCanvasLayerCommands({ [layers], ); + const rejectPendingResourceCopy = useCallback( + (targetLayers: CanvasLayer[]) => { + if (canCopyCanvasLayers(targetLayers)) { + return false; + } + if (targetLayers.length) { + onPendingResourceCopyBlocked?.(); + } + return true; + }, + [onPendingResourceCopyBlocked], + ); + const pasteCanvasClipboard = useCallback( (canvasPoint?: { x: number; y: number }) => { const clipboard = canvasClipboardRef.current; if (!clipboard?.layers.length) { return false; } + if (rejectPendingResourceCopy(clipboard.layers)) { + return true; + } const nextLayers = duplicateLayersToPoint(clipboard.layers, canvasPoint, { renameCopies: clipboard.mode !== 'cut', }); @@ -213,6 +232,7 @@ export function useImageCanvasLayerCommands({ closeContextMenus, duplicateLayersToPoint, appendCanvasLayersWithResources, + rejectPendingResourceCopy, setActiveTool, setSelectedLayerId, setSelectedLayerIds, @@ -222,6 +242,10 @@ export function useImageCanvasLayerCommands({ const copyContextLayers = useCallback( (options: { cut?: boolean } = {}) => { const targetIds = getContextTargetLayerIds(); + const targetLayers = getCanvasLayersByIds(layers, targetIds); + if (rejectPendingResourceCopy(targetLayers)) { + return; + } const clipboard = createCanvasLayerClipboard( layers, targetIds, @@ -256,6 +280,7 @@ export function useImageCanvasLayerCommands({ getContextTargetLayerIds, layers, onDeleteLayerSideEffects, + rejectPendingResourceCopy, selectSingleLayer, setLayers, setMetadataLayer, @@ -265,6 +290,10 @@ export function useImageCanvasLayerCommands({ const copySelectedLayers = useCallback( (options: { cut?: boolean } = {}) => { const targetIds = getSelectedTargetLayerIds(); + const targetLayers = getCanvasLayersByIds(layers, targetIds); + if (rejectPendingResourceCopy(targetLayers)) { + return; + } const clipboard = createCanvasLayerClipboard( layers, targetIds, @@ -299,6 +328,7 @@ export function useImageCanvasLayerCommands({ getSelectedTargetLayerIds, layers, onDeleteLayerSideEffects, + rejectPendingResourceCopy, selectSingleLayer, setContextMenu, setImageContextMenu, @@ -310,6 +340,9 @@ export function useImageCanvasLayerCommands({ const duplicateContextLayers = useCallback(() => { const targetIds = getContextTargetLayerIds(); const targetLayers = getCanvasLayersByIds(layers, targetIds); + if (rejectPendingResourceCopy(targetLayers)) { + return; + } const nextLayers = duplicateLayersToPoint(targetLayers); if (!nextLayers.length) { return; @@ -326,6 +359,7 @@ export function useImageCanvasLayerCommands({ getContextTargetLayerIds, layers, appendCanvasLayersWithResources, + rejectPendingResourceCopy, setSelectedLayerId, setSelectedLayerIds, ]); @@ -333,6 +367,9 @@ export function useImageCanvasLayerCommands({ const duplicateSelectedLayers = useCallback(() => { const targetIds = getSelectedTargetLayerIds(); const targetLayers = getCanvasLayersByIds(layers, targetIds); + if (rejectPendingResourceCopy(targetLayers)) { + return; + } const nextLayers = duplicateLayersToPoint(targetLayers); if (!nextLayers.length) { return; @@ -349,6 +386,7 @@ export function useImageCanvasLayerCommands({ getSelectedTargetLayerIds, layers, appendCanvasLayersWithResources, + rejectPendingResourceCopy, setContextMenu, setImageContextMenu, setSelectedLayerId, @@ -718,8 +756,17 @@ export function useImageCanvasLayerCommands({ ); }, [captureCanvasHistory, selectedLayerId, selectedLayerIds, setLayers]); + const contextTargetLayers = getCanvasLayersByIds( + layers, + getContextTargetLayerIds(), + ); + return { canvasClipboard, + canCopyContextLayers: canCopyCanvasLayers(contextTargetLayers), + isCanvasClipboardCopyBlocked: Boolean( + canvasClipboard && !canCopyCanvasLayers(canvasClipboard.layers), + ), getContextTargetLayerIds, pasteCanvasClipboard, copyContextLayers, diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index af99d9ece..03f7d4439 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -264,30 +264,6 @@ function ProjectPersistenceHarness({ > append -