diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 39391818d..61ce05789 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5912,5 +5912,6 @@ ## 2026-08-03 图片画布复制固定为布局实例复用项目资源 - 背景:画布复制逻辑曾为副本生成 `local-resource-copy-*`,但没有创建对应项目资源;自动保存虽然触发,结构化画布会拒绝缺资源引用,legacy 快照刷新时也无法 hydrate 副本。 -- 决策:`layerId` 是同一 canvas 内唯一的布局实例身份,`resourceId` 是允许多个布局实例共享的项目媒体身份。复制、粘贴、创建副本和剪切后粘贴只生成新 `layerId`,复用来源 `resourceId`;已登记资源不得再次上传或创建。临时 `local-*` 资源按用户、项目和临时 ID 合并创建,正式 ID 返回后更新全部共享该临时 ID 的当前图层;session 项目缓存也按 `resourceId` 只保留一份资源快照。 +- 决策:`layerId` 是同一 canvas 内唯一的布局实例身份,`resourceId` 是允许多个布局实例共享的项目媒体身份。复制、粘贴、创建副本和剪切后粘贴只生成新 `layerId`,复用来源正式 `resourceId`;已登记资源不得再次上传或创建。只要目标中存在 `resourceId` 仍以 `local-*` 开头的未登记图层,整次复制、剪切或创建副本都失败关闭,右键动作禁用并由快捷键反馈短提示;正式 ID 回填后恢复,layout PATCH 是否 pending 不参与判断,系统剪贴板图片导入不受影响。 +- 并发边界:未登记图层被禁止复制后,不再按临时资源 ID 合并项目资源创建请求,也不再用一次响应批量改写共享临时 ID。每个合法新增图层保留自己的响应快照与回调;layout PATCH 的串行 latest-wins 队列、正式资源的多布局引用和 session 资源快照按正式 `resourceId` 去重继续保留,它们与资源创建 single-flight 是不同机制。 - 后端边界:现有 `editor_canvas_layer` 已以 `layer_id` 建立行身份并单独保存 `resource_id`,天然支持多 layer 引用同一 resource;本次不修改 HTTP DTO、SpacetimeDB schema、迁移或 bindings,只增加结构化 round-trip 契约测试。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index aa8b12b47..49bac809c 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -457,6 +457,14 @@ - 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand` 应覆盖裁扩先上传再创建项目资源,以及去背景队列完成后对未完成占位进行二次项目读取。 - 关联:`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 +## 图片画布未登记素材复制会放大异步资源竞态 + +- 现象:刚加入画布、`resourceId` 仍为 `local-*` 的图层被复制后,权威项目快照刷新与资源创建响应交错,副本可能刷新后消失;用临时 ID single-flight 合并请求还会吞掉后续调用自己的快照或回调。 +- 原因:复制语义本应只复用已经存在的正式项目资源,但未登记素材还没有可稳定复用的资源身份。把多个布局调用压进一个 Promise 并不能自动合并每次调用的恢复上下文。 +- 处理:只要目标集合含 `local-*` 图层,复制、剪切、创建副本和内部粘贴整体拒绝;正式 `resourceId` 回填后再开放。不要把普通 layout PATCH pending 当作未完成,也不要阻止系统剪贴板图片导入。资源创建不再按临时 ID single-flight 合并;布局保存的串行 latest-wins 队列仍保留。 +- 验证:`ImageCanvasLayerCommandModel.test.ts`、`useImageCanvasLayerCommands.test.tsx` 和 `ImageCanvasContextMenusView.test.tsx` 覆盖多选整体拒绝、回填后恢复和菜单禁用;`useImageCanvasProjectPersistence.test.tsx` 继续覆盖单个资源响应与权威快照交错恢复。 +- 关联:`src/components/image-editor/ImageCanvasLayerCommandModel.ts`、`src/components/image-editor/useImageCanvasLayerCommands.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`。 + ## 图片画布项目封面上传失败要有本地展示兜底 - 现象:画布项目已反复打开、保存或操作,但 `/project` 列表卡片仍只显示“项目”占位,没有封面图。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index cde78649d..d57abb6a9 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -57,7 +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`;副本可独立移动、排序、分组、显隐和删除,但不得重复上传媒体或为同一图片创建虚假项目资源。没有内部画布剪贴板时,系统剪贴板图片仍按新素材上传。 +- 画布内部复制 / 粘贴和创建副本只新增独立 `layerId` 布局实例,继续引用来源图层的同一正式项目 `resourceId`;副本可独立移动、排序、分组、显隐和删除,但不得重复上传媒体或为同一图片创建虚假项目资源。`resourceId` 仍以 `local-*` 开头时表示素材正在登记,复制、剪切、创建副本和内部画布粘贴必须整体拒绝,右键动作禁用,快捷键提示“素材仍在保存,请稍后重试”;正式 ID 回填后立即恢复,不因普通 layout PATCH 仍在保存而禁用。没有内部画布剪贴板时,系统剪贴板图片仍按新素材上传。临时资源创建不再做同 ID single-flight 合并,每个合法新增图层独立承接响应、快照恢复和回调。 - 背景色控件只修改编辑器工作区底色,不恢复网格线或棋盘格底纹,也不影响图片本体。 - 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。 - 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。 diff --git a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md index f55dd1f7b..16739a06d 100644 --- a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md +++ b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md @@ -31,7 +31,7 @@ 保存 `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 的布局实例。 +同一 canvas 内 `layer_id` 是唯一的布局实例身份,`resource_id` 是可复用的媒体资源引用,不要求唯一。复制、粘贴或创建副本只创建新的 `layer_id` 和独立几何 / 层级状态,继续引用来源图层的同一正式 `resource_id`;不得为同一媒体生成虚假的本地资源 ID、重复上传对象或重复创建项目资源。`resource_id` 仍以 `local-*` 开头表示素材尚未完成项目资源登记:只要复制目标中有一个此类图层,整次复制、剪切或创建副本都必须拒绝;正式 ID 回填后立即恢复操作。该约束不绑定 layout 自动保存状态,也不影响从系统剪贴板导入新图片。每个合法新增图层独立完成自己的资源登记,前端不再按临时资源 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,兼容现有画布快照语义。 diff --git a/src/components/image-editor/ImageCanvasContextMenusView.test.tsx b/src/components/image-editor/ImageCanvasContextMenusView.test.tsx index b93c848a4..6a737bb18 100644 --- a/src/components/image-editor/ImageCanvasContextMenusView.test.tsx +++ b/src/components/image-editor/ImageCanvasContextMenusView.test.tsx @@ -36,6 +36,8 @@ function renderContextMenus( contextMenuLayer: null, contextShouldShowLayer: false, contextShouldUnlockLayer: false, + canCopyContextLayers: true, + isCanvasClipboardCopyBlocked: false, onPasteCanvasClipboard: vi.fn(), onCopyContextLayers: vi.fn(), onDuplicateContextLayers: vi.fn(), @@ -183,6 +185,36 @@ describe('ImageCanvasContextMenusView', () => { expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1); }); + it('disables copy actions while the target resource is still being saved', () => { + const layer = createLayer({ resourceId: 'local-resource-pending' }); + const props = renderContextMenus({ + contextMenu: { + kind: 'layer', + x: 16, + y: 18, + layerId: layer.id, + canvasPoint: { x: 40, y: 42 }, + }, + contextMenuLayer: layer, + canCopyContextLayers: false, + }); + + expect( + (screen.getByRole('menuitem', { name: '复制' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('menuitem', { name: '剪切' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('menuitem', { name: '创建副本' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect(props.onCopyContextLayers).not.toHaveBeenCalled(); + expect(props.onDuplicateContextLayers).not.toHaveBeenCalled(); + }); + it('keeps measured layer menus and submenus inside the viewport', async () => { const originalInnerWidth = window.innerWidth; const originalInnerHeight = window.innerHeight; diff --git a/src/components/image-editor/ImageCanvasContextMenusView.tsx b/src/components/image-editor/ImageCanvasContextMenusView.tsx index 91c143bf6..319aea6bf 100644 --- a/src/components/image-editor/ImageCanvasContextMenusView.tsx +++ b/src/components/image-editor/ImageCanvasContextMenusView.tsx @@ -29,6 +29,8 @@ type ImageCanvasContextMenusViewProps = { contextMenuLayer: CanvasLayer | null; contextShouldShowLayer: boolean; contextShouldUnlockLayer: boolean; + canCopyContextLayers: boolean; + isCanvasClipboardCopyBlocked: boolean; onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void; onCopyContextLayers: (options?: { cut?: boolean }) => void; onDuplicateContextLayers: () => void; @@ -69,6 +71,8 @@ export function ImageCanvasContextMenusView({ contextMenuLayer, contextShouldShowLayer, contextShouldUnlockLayer, + canCopyContextLayers, + isCanvasClipboardCopyBlocked, onPasteCanvasClipboard, onCopyContextLayers, onDuplicateContextLayers, @@ -282,6 +286,7 @@ export function ImageCanvasContextMenusView({ + ); } @@ -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 -