diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d1b8299ea..24af5e5a6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -119,6 +119,14 @@ - 验证:画板生成 workflow 测试覆盖 queueState 持续 `running` 到前端等待窗口结束时,不进入 failed、不显示该排队文案、不添加本地临时结果层。 - 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx`。 +## 图片画布布局历史不能回退生成任务状态或把资源 ID 回填当成换图 + +- 现象:生成占位框移动后开始生成,撤销移动会把仍在运行的生成对象恢复成待生成状态;切换到 2K 或改变比例后撤销位置,旧占位框还可能把当前尺寸回退。上传图层落库后,普通移动撤销可能被提示“可能会使图片消失”并永久卡在栈顶;即使安全检查已放行,直接恢复旧图层快照也会丢失刚回填的资源关联。 +- 原因:内容消失安全检查和历史快照合并是两道独立边界。图层内容签名若严格比较 `sourceAssetId`、`sourceResourceId` 等延迟回填的内部关联 ID,会把同一媒体误判为替换;放行后若仍用目标快照整体覆盖同 ID 图层或占位框,又会回退当前权威关联、内容或尺寸。相同 dialog ID 直接恢复整个旧对话框快照还会覆盖当前 `generating` / 完成态。 +- 处理:图层内容身份按对象存储 key、对象标识和媒体地址的稳定优先级比较,内部关联 ID 的补齐不参与内容消失判断。同 ID 图层以 current 为权威,只从历史覆盖 `x`、`y`、`zIndex`、`groupId`、`assetKind`、`hidden`、`locked`、`flipX`、`flipY`;current 的资源关联、内容、媒体、生成元数据、尺寸和标题全部保留。同 ID generation dialog 从 target 恢复 placeholder 的 `x` / `y` 和 active / inactive 槽位对应的 `composerOpen`,current 的 `width` / `height` / `originalWidth` / `originalHeight`、当前参数、任务生命周期、提示词、参考图和结果保持一致。current 中不存在对应 ID 时属于撤销完整删除,可从 target 全量恢复对象。即时生成结果在追加图层前捕获生成历史,自动适合视图不再压入另一条历史。 +- 验证:覆盖 idle 生成框移动后进入 generating 再撤销、上传图层异步回填 `resourceId` / `sourceAssetId` / `sourceResourceId` 后撤销移动仍保留当前关联值、切换到 2K 或改变比例后撤销位置仍保留当前占位尺寸、非活动生成框被激活并拖动后撤销可恢复原 active / inactive 打开状态、撤销完整删除可以全量恢复对象、即时生成后第一次撤销直接命中生成保护,以及阈值内指针抖动既不移动也不产生历史。 +- 关联:`src/components/image-editor/ImageCanvasHistoryModel.ts`、`src/components/image-editor/useCanvasHistory.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/useImageCanvasStageInteractions.ts`、`docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md`。 + ## 画板参考图 objectKey 必须先做归属校验 - 现象:画板生成、快速编辑、图标素材或 UI 素材提取如果允许直接提交 generated objectKey,用户只要知道其他账号的私有 objectKey,就可能让 api-server 签名读取并送给外部生成供应商。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index c9b43b5ab..60f37cedb 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -36,7 +36,7 @@ - `适合视图` 的正式语义为“显示画布所有可见元素”,不再回到固定 `x/y/scale`。 - 右上角缩放控件只展示当前缩放百分比;点击后弹出菜单:放大、缩小、显示画布所有元素、缩放至 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` 重命名项目;快捷键只触发对应画布交互,不绕过既有保存 / 生成 / 上传工作流。 +- 右上角提供快捷键入口,点击后打开独立快捷键弹窗;画布不常驻展示说明文案。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) 为准。 - 背景色控件只修改编辑器工作区底色,不恢复网格线或棋盘格底纹,也不影响图片本体。 - 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。 - 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。 @@ -117,6 +117,7 @@ - 选择 `1K / 2K` 或切换比例后,占位框在待生成和生成中阶段都必须立即显示对应目标像素尺寸;从普通图片、角色、图标图集或 UI 设计图再次改造时同样适用,完成落图前后不得从默认 1K 框跳变为 2K 成品。 - 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。 - 画布常用快捷键必须与右上角快捷键弹窗一致;新增快捷键时应同步更新 `ImageCanvasShortcutModel`、快捷键 hook 单测和本方案。输入框、文本域和 contenteditable 聚焦时不得触发画布编辑快捷键。 +- 撤销或恢复画布布局时不得覆盖同 ID 生成对象当前的任务生命周期、提示词、参考图和结果;上传持久化延迟回填内部资源 ID 不得把安全移动误判为素材替换。生成结果必须在加入画布前写入生成历史,自动适合视图不得覆盖这条栈顶记录。 - 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。宣发素材生成器刷新后必须继续显示正确的卡片类型、游戏名、分类、描述和已绑定参考图。 - 画布多选语义必须同时覆盖普通图层和仍显示占位框的生成器对象:Shift 点选或框选可把生成器加入当前选择;拖动任一已选图层或生成器时,所有已选普通图层和生成器占位框同步移动;删除 / Backspace / Delete 作用于完整选择集合,移除所有已选图层和生成器对象。生成器对象在选择集合中使用稳定 `generation-dialog:` 目标 ID,不把生成器伪装成普通图层,也不新增后端表。 - 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。角色形象生成面板每次成功绑定角色规范后,在当前编辑器生命周期内缓存为上一张角色规范;再次新建角色形象时自动带入该缓存。图标素材和 UI 设计图面板每次成功绑定图标规范后,同样缓存为上一张图标规范;再次新建需要图标规范的素材时自动带入该缓存。生成规范菜单里的图标规范对象自身只把首行参考图作为可选参考,不要求必须先绑定图标规范。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 695067656..4a633479f 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -75,9 +75,9 @@ ## 第七阶段模块 - `useCanvasHistory.ts` - - 承载画布历史栈:快照创建、快照恢复、撤销、重做、历史栈长度限制和 `canUndo` / `canRedo` 派生状态。 + - 承载画布历史栈:快照创建、安全快照恢复、撤销、恢复、历史栈长度限制和 `canUndo` / `canRedo` 派生状态。 - 主视图继续负责在具体用户动作前调用 `captureCanvasHistory`,并通过 hook 注入恢复快照后需要清理的 hover、元数据、框选、吸附、右键菜单和平移拖拽状态。 - - 该 hook 有独立测试覆盖图层、视口、active / archived 生成对话框和选中态的撤销 / 重做恢复,避免后续把 history 逻辑继续埋回主视图。 + - 该 hook 有独立测试覆盖图层、视口、active / archived 生成对话框和选中态的撤销 / 恢复,并保证布局历史不会把同 ID 生成对象的当前任务状态恢复成旧快照,避免后续把 history 逻辑继续埋回主视图。 ## 第八阶段模块 @@ -181,9 +181,9 @@ ## 第二十一阶段模块 - `useImageCanvasKeyboardShortcuts.ts` - - 承载图片画布全局键盘快捷键: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+[ 层级调整、方向键微移、Backspace / Delete 删除选中元素、Escape 关闭临时面板并取消选择,以及 Space 临时抓手。 + - 承载图片画布全局键盘快捷键: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+[ 层级调整、方向键微移、Backspace / Delete 删除选中元素、Escape 关闭临时面板并取消选择,以及 Space 临时抓手。 - 主视图继续保留各工作流状态和具体副作用,例如图层删除、生成对话框、规格菜单、快速编辑面板和 chrome 面板状态;快捷键 hook 只接收 ref、setter 与回调,不直接读写素材库、路由或 API。 - - 该 hook 用独立单测覆盖输入框忽略快捷键、撤销重做、编辑命令、视图 / 层级命令、工具 / 面板命令、方向键移动、选中元素删除、生成占位删除、Escape 保留生成中面板、Space 临时抓手和 Shift 状态;主视图 DOM 测试继续覆盖真实编辑器里的 Backspace、Escape、Space 和 undo / redo 集成路径。 + - 该 hook 用独立单测覆盖输入框忽略快捷键、撤销与恢复、编辑命令、视图 / 层级命令、工具 / 面板命令、方向键移动、选中元素删除、生成占位删除、Escape 保留生成中面板、Space 临时抓手和 Shift 状态;主视图 DOM 测试继续覆盖真实编辑器里的 Backspace、Escape、Space 和 undo / redo 集成路径。 - `ImageCanvasShortcutDialogView.tsx` / `ImageCanvasShortcutModel.ts` - 承载右上角快捷键入口打开后的独立弹窗和 Windows 快捷键表;快捷键文案、键帽和“新增”状态集中在模型中维护,避免 UI 表格和 hook 行为漂移。 @@ -250,4 +250,4 @@ - `npm run typecheck` - `npm run check:encoding` - `git diff --check` -- 浏览器回归 `/editor/canvas`:确认登录弹窗、素材上传、背景设置面板、底部工具栏、Space 临时抓手、撤销 / 重做和画布基础渲染仍正常。 +- 浏览器回归 `/editor/canvas`:确认登录弹窗、素材上传、背景设置面板、底部工具栏、Space 临时抓手、撤销 / 恢复、安全阻止提示和画布基础渲染仍正常。 diff --git a/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md b/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md index c65e7bfd9..472c5dd30 100644 --- a/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md +++ b/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md @@ -1,6 +1,6 @@ # 图片画布撤销、恢复范围与操作提示方案 -更新时间:`2026-07-17` +更新时间:`2026-07-18` ## 产品规则 @@ -23,11 +23,14 @@ - 撤销栈和恢复栈均保存操作类型、目标 `CanvasHistorySnapshot` 和创建时间,分别最多保留 60 条。 - 新画布操作把操作前快照写入撤销栈并清空恢复栈;成功撤销把当前快照写入恢复栈,成功恢复把当前快照写回撤销栈。 -- 操作类型只负责生成用户提示;能否应用快照由当前快照与目标快照的差异检查决定。 -- 安全检查以稳定的 `layer.id` 判断当前图层是否仍存在,以媒体地址、对象存储标识和序列帧等字段组成内容签名,识别同 ID 图层的素材替换;仅刷新内部资源 ID 不视为图片消失。 -- 当前生成对话框和非活动生成结果按稳定 ID 纳入内容存在性检查,避免恢复操作删除当前生成结果。 +- 操作类型用于生成用户提示,并对添加、上传、生成和替换等明确会移除当前结果的撤销做保护;其它操作能否应用由当前快照与目标快照的差异检查决定。 +- 安全检查以稳定的 `layer.id` 判断当前图层是否仍存在;内容身份优先比较对象存储 key、对象标识和媒体地址,序列帧结果比较完整帧列表。`resourceId`、`sourceResourceId`、`sourceAssetId` 等内部关联 ID 的延迟回填不视为图片替换。 +- 恢复历史快照时,相同 ID 的图层以当前对象为权威,只从目标快照覆盖 `x`、`y`、`zIndex`、`groupId`、`assetKind`、`hidden`、`locked`、`flipX`、`flipY`。当前图层的资源关联、内容、媒体、生成元数据、`width` / `height` / `originalWidth` / `originalHeight` 和标题必须保留,不能被异步回填前的旧快照覆盖。 +- 当前生成对话框和非活动生成结果按稳定 ID 纳入内容存在性检查,避免恢复操作删除当前生成结果。相同 ID 的生成对话框只从目标快照恢复占位框 `x` / `y` 以及 active / inactive 槽位对应的 `composerOpen`,当前占位框的 `width` / `height` / `originalWidth` / `originalHeight`、当前比例与清晰度等参数、`generating` / `failed` / 完成态、提示词、参考图和任务结果继续以当前状态为准,不能被旧历史快照降级。 +- 当前快照中已经没有对应 ID 的完整删除对象不执行上述字段级合并;撤销删除或剪切时仍可从目标快照全量恢复图层或生成对话框。 - 位置、尺寸、层级、分组、锁定、翻转、标题和素材类型不进入内容签名,避免误拦截普通编辑。 -- 鼠标拖动在按下时暂存操作前快照,位移超过点击阈值后只提交一条历史;单击不产生历史记录。 +- 鼠标拖动在按下时暂存操作前快照;屏幕位移达到点击阈值后才开始改变画布坐标并只提交一条历史,阈值内的指针抖动和单击都不产生位移或历史记录。 +- 本地即时结果与后端项目快照结果都必须在生成图层加入画布前写入一条生成历史;生成完成后的自动适合视图不再额外压入视口历史,保证用户第一次撤销就命中生成保护。 - 顶部消息复用 `PlatformRuntimeStatusToast`,成功使用中性色,被阻止使用警告色;连续触发会替换消息并重新开始 3 秒计时。 ## 验收重点 @@ -41,3 +44,8 @@ 7. 一次鼠标拖动只产生一条历史,单击不产生历史。 8. `Ctrl+Z` 与 `Ctrl+Shift+Z` 分别触发撤销和恢复,在可编辑输入区域内不拦截原生行为。 9. 提示在 3 秒后消失,连续提示按最后一次触发重新计时。 +10. 生成占位框移动后开始生成,再撤销移动时,只恢复位置,不得把正在运行的同 ID 生成对象回退为待生成状态。 +11. 上传素材落库异步补齐 `resourceId`、`sourceResourceId`、`sourceAssetId` 后撤销移动,位置恢复且三个当前关联值保持不变。 +12. 生成参数切换到 2K 或改变比例并同步占位尺寸后撤销位置,只恢复占位框 `x` / `y`,不得回退当前宽高、原始宽高或生成参数。 +13. 拖动非活动生成框触发 active / inactive 互换后撤销,原 active 生成框恢复打开,原非活动生成框恢复关闭,任务状态与尺寸不回退。 +14. 即时生成、本地结果和后端项目快照结果都只生成一条准确的生成历史,自动适合视图不会遮挡该历史。 diff --git a/src/components/image-editor/ImageCanvasHistoryModel.test.ts b/src/components/image-editor/ImageCanvasHistoryModel.test.ts index c8c163265..37d251104 100644 --- a/src/components/image-editor/ImageCanvasHistoryModel.test.ts +++ b/src/components/image-editor/ImageCanvasHistoryModel.test.ts @@ -8,6 +8,7 @@ import { canRestoreCanvasHistorySnapshotWithoutContentLoss, formatCanvasHistoryAction, isProtectedCanvasHistoryAction, + mergeCanvasHistorySnapshotForRestore, } from './ImageCanvasHistoryModel'; function createLayer(overrides: Partial = {}): CanvasLayer { @@ -85,6 +86,184 @@ describe('ImageCanvasHistoryModel', () => { ).toBe(false); }); + it('ignores asynchronously filled relation ids for unchanged media content', () => { + const targetLayer = createLayer({ + resourceId: 'local-resource', + sourceResourceId: undefined, + sourceAssetId: undefined, + objectKey: 'uploads/same.png', + assetObjectId: 'asset-object-same', + x: 10, + }); + const currentLayer = createLayer({ + resourceId: 'persisted-resource', + sourceResourceId: 'source-resource-late', + sourceAssetId: 'source-asset-late', + objectKey: 'uploads/same.png', + assetObjectId: 'asset-object-same', + x: 80, + }); + + expect( + canRestoreCanvasHistorySnapshotWithoutContentLoss({ + current: createSnapshot([currentLayer]), + target: createSnapshot([targetLayer]), + }), + ).toBe(true); + }); + + it('restores only reversible layer fields while preserving current content metadata', () => { + const targetLayer = createLayer({ + resourceId: 'local-resource', + title: '旧标题', + src: '/signed/old.png', + thumbnailSrc: '/signed/old-thumbnail.png', + x: 10, + y: 20, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + prompt: '旧提示词', + objectKey: 'uploads/stable.png', + assetObjectId: 'asset-object-stable', + sourceResourceId: undefined, + sourceAssetId: undefined, + groupId: 'target-group', + assetKind: 'spec', + hidden: false, + locked: false, + flipX: true, + flipY: false, + }); + const currentLayer = createLayer({ + resourceId: 'persisted-resource', + title: '当前标题', + src: '/signed/current.png', + thumbnailSrc: '/signed/current-thumbnail.png', + x: 90, + y: 80, + width: 640, + height: 480, + originalWidth: 640, + originalHeight: 480, + zIndex: 9, + prompt: '当前提示词', + objectKey: 'uploads/stable.png', + assetObjectId: 'asset-object-stable', + sourceResourceId: 'source-resource-late', + sourceAssetId: 'source-asset-late', + groupId: 'current-group', + assetKind: 'ui-design', + hidden: true, + locked: true, + flipX: false, + flipY: true, + }); + + const merged = mergeCanvasHistorySnapshotForRestore({ + current: createSnapshot([currentLayer]), + target: createSnapshot([targetLayer]), + }); + + expect(merged.layers).toEqual([ + { + ...currentLayer, + x: targetLayer.x, + y: targetLayer.y, + zIndex: targetLayer.zIndex, + groupId: targetLayer.groupId, + assetKind: targetLayer.assetKind, + hidden: targetLayer.hidden, + locked: targetLayer.locked, + flipX: targetLayer.flipX, + flipY: targetLayer.flipY, + }, + ]); + }); + + it('restores the complete target layer after that layer was deleted', () => { + const targetLayer = createLayer({ + resourceId: 'deleted-resource', + title: '被删除图片', + x: 10, + y: 20, + width: 320, + height: 240, + }); + + const merged = mergeCanvasHistorySnapshotForRestore({ + current: createSnapshot([]), + target: createSnapshot([targetLayer]), + }); + + expect(merged.layers).toEqual([targetLayer]); + }); + + it('uses stable media references while blocking actual media replacement', () => { + expect( + canRestoreCanvasHistorySnapshotWithoutContentLoss({ + current: createSnapshot([ + createLayer({ + src: '/signed/current.png', + objectKey: 'uploads/stable.png', + }), + ]), + target: createSnapshot([ + createLayer({ + src: '/signed/expired.png', + objectKey: 'uploads/stable.png', + }), + ]), + }), + ).toBe(true); + expect( + canRestoreCanvasHistorySnapshotWithoutContentLoss({ + current: createSnapshot([createLayer({ src: '/current.png' })]), + target: createSnapshot([createLayer({ src: '/target.png' })]), + }), + ).toBe(false); + expect( + canRestoreCanvasHistorySnapshotWithoutContentLoss({ + current: createSnapshot([ + createLayer({ objectKey: 'uploads/current.png' }), + ]), + target: createSnapshot([ + createLayer({ objectKey: 'uploads/target.png' }), + ]), + }), + ).toBe(false); + }); + + it('blocks replacing an image sequence frame', () => { + const frame = { + frameIndex: 0, + imageSrc: '/frames/0.png', + objectKey: 'frames/0.png', + width: 100, + height: 100, + }; + expect( + canRestoreCanvasHistorySnapshotWithoutContentLoss({ + current: createSnapshot([ + createLayer({ + mediaType: 'image-sequence', + imageSequenceFrames: [ + { ...frame, objectKey: 'frames/replacement.png' }, + ], + }), + ]), + target: createSnapshot([ + createLayer({ + mediaType: 'image-sequence', + imageSequenceFrames: [frame], + }), + ]), + }), + ).toBe(false); + }); + it('blocks removing a current generation result from the target snapshot', () => { const current = createSnapshot([]); current.inactiveGenerateDialogs = [ @@ -115,6 +294,277 @@ describe('ImageCanvasHistoryModel', () => { ).toBe(true); }); + it('restores only position for an existing generation dialog', () => { + const target = createSnapshot([]); + target.generateDialog = { + id: 'generation-1', + mode: 'character-animation', + prompt: '旧提示词', + status: 'idle', + imageSize: '1K', + generationReferences: [{ id: 'old', label: '旧参考图', src: '/old.png' }], + placeholder: { + x: 10, + y: 20, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }; + const current = createSnapshot([]); + current.generateDialog = { + id: 'generation-1', + mode: 'character-animation', + prompt: '当前提示词', + status: 'generating', + imageSize: '2K', + generatedLayerId: 'generated-layer-1', + generationReferences: [ + { id: 'current', label: '当前参考图', src: '/current.png' }, + ], + generationStartedAt: 100, + placeholder: { + x: 90, + y: 80, + width: 640, + height: 480, + originalWidth: 640, + originalHeight: 480, + }, + }; + + const merged = mergeCanvasHistorySnapshotForRestore({ current, target }); + + expect(merged.generateDialog).toMatchObject({ + id: 'generation-1', + prompt: '当前提示词', + status: 'generating', + imageSize: '2K', + generatedLayerId: 'generated-layer-1', + generationStartedAt: 100, + generationReferences: [ + { id: 'current', label: '当前参考图', src: '/current.png' }, + ], + placeholder: { + x: 10, + y: 20, + width: 640, + height: 480, + originalWidth: 640, + originalHeight: 480, + }, + }); + }); + + it('uses the target placeholder when the current dialog has none', () => { + const target = createSnapshot([]); + target.generateDialog = { + id: 'generation-1', + mode: 'generate', + prompt: '旧提示词', + status: 'idle', + placeholder: { + x: 10, + y: 20, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }; + const current = createSnapshot([]); + current.generateDialog = { + id: 'generation-1', + mode: 'generate', + prompt: '当前提示词', + status: 'generating', + imageSize: '2K', + }; + + const merged = mergeCanvasHistorySnapshotForRestore({ current, target }); + + expect(merged.generateDialog).toMatchObject({ + prompt: '当前提示词', + status: 'generating', + imageSize: '2K', + placeholder: target.generateDialog.placeholder, + }); + }); + + it('keeps the target active state while preserving current dialog progress', () => { + const target = createSnapshot([]); + target.generateDialog = { + id: 'generation-a', + mode: 'generate', + prompt: 'A 旧提示词', + status: 'idle', + composerOpen: true, + placeholder: { + x: 10, + y: 20, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }; + target.inactiveGenerateDialogs = [ + { + id: 'generation-b', + mode: 'icon', + prompt: 'B 旧提示词', + status: 'idle', + composerOpen: false, + placeholder: { + x: 30, + y: 40, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }, + ]; + + const current = createSnapshot([]); + current.generateDialog = { + id: 'generation-b', + mode: 'icon', + prompt: 'B 当前提示词', + status: 'generating', + composerOpen: true, + imageSize: '2K', + placeholder: { + x: 90, + y: 80, + width: 640, + height: 480, + originalWidth: 640, + originalHeight: 480, + }, + }; + current.inactiveGenerateDialogs = [ + { + id: 'generation-a', + mode: 'generate', + prompt: 'A 当前提示词', + status: 'generating', + composerOpen: false, + imageSize: '2K', + placeholder: { + x: 70, + y: 60, + width: 640, + height: 480, + originalWidth: 640, + originalHeight: 480, + }, + }, + ]; + + const merged = mergeCanvasHistorySnapshotForRestore({ current, target }); + + expect(merged.generateDialog).toMatchObject({ + id: 'generation-a', + prompt: 'A 当前提示词', + status: 'generating', + composerOpen: true, + imageSize: '2K', + placeholder: { + x: 10, + y: 20, + width: 640, + height: 480, + originalWidth: 640, + originalHeight: 480, + }, + }); + expect(merged.inactiveGenerateDialogs).toEqual([ + expect.objectContaining({ + id: 'generation-b', + prompt: 'B 当前提示词', + status: 'generating', + composerOpen: false, + imageSize: '2K', + placeholder: { + x: 30, + y: 40, + width: 640, + height: 480, + originalWidth: 640, + originalHeight: 480, + }, + }), + ]); + }); + + it('keeps the current placeholder when the target dialog has none', () => { + const target = createSnapshot([]); + target.generateDialog = { + id: 'generation-1', + mode: 'generate', + prompt: '旧提示词', + status: 'idle', + composerOpen: true, + }; + const current = createSnapshot([]); + current.generateDialog = { + id: 'generation-1', + mode: 'generate', + prompt: '当前提示词', + status: 'generating', + composerOpen: false, + imageSize: '2K', + placeholder: { + x: 90, + y: 80, + width: 640, + height: 480, + originalWidth: 640, + originalHeight: 480, + }, + }; + + const merged = mergeCanvasHistorySnapshotForRestore({ current, target }); + + expect(merged.generateDialog).toMatchObject({ + prompt: '当前提示词', + status: 'generating', + composerOpen: true, + imageSize: '2K', + placeholder: current.generateDialog.placeholder, + }); + }); + + it('restores the complete target dialog after that dialog was deleted', () => { + const target = createSnapshot([]); + target.generateDialog = { + id: 'generation-deleted', + mode: 'generate', + prompt: '被删除的生成框', + status: 'idle', + generationReferences: [ + { id: 'reference-1', label: '参考图', src: '/reference.png' }, + ], + placeholder: { + x: 10, + y: 20, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }; + + const merged = mergeCanvasHistorySnapshotForRestore({ + current: createSnapshot([]), + target, + }); + + expect(merged.generateDialog).toEqual(target.generateDialog); + }); + it('formats action names without duplicating the 操作 suffix', () => { expect(formatCanvasHistoryAction({ type: 'replace-image' })).toBe( '替换图片', diff --git a/src/components/image-editor/ImageCanvasHistoryModel.ts b/src/components/image-editor/ImageCanvasHistoryModel.ts index 9233c4d84..6f2c2dd71 100644 --- a/src/components/image-editor/ImageCanvasHistoryModel.ts +++ b/src/components/image-editor/ImageCanvasHistoryModel.ts @@ -1,7 +1,9 @@ import type { + CanvasGenerationDialogState, CanvasHistoryAction, CanvasHistorySnapshot, CanvasLayer, + GenerateDialogState, } from './ImageCanvasEditorTypes'; const CANVAS_HISTORY_ACTION_LABELS: Record< @@ -58,19 +60,167 @@ export function isProtectedCanvasHistoryAction( return PROTECTED_CANVAS_HISTORY_ACTION_TYPES.has(action.type); } -function getLayerContentSignature(layer: CanvasLayer): string { - return JSON.stringify({ - src: layer.src, - mediaType: layer.mediaType ?? null, - objectKey: layer.objectKey ?? null, - assetObjectId: layer.assetObjectId ?? null, - sourceResourceId: layer.sourceResourceId ?? null, - sourceAssetId: layer.sourceAssetId ?? null, - previewVideoPath: layer.previewVideoPath ?? null, - imageSequenceFrames: layer.imageSequenceFrames ?? null, +function hasSameStableMediaReference( + current: { + src: string; + objectKey?: string | null; + assetObjectId?: string | null; + }, + target: { + src: string; + objectKey?: string | null; + assetObjectId?: string | null; + }, +): boolean { + if (current.objectKey && target.objectKey) { + return current.objectKey === target.objectKey; + } + if (current.assetObjectId && target.assetObjectId) { + return current.assetObjectId === target.assetObjectId; + } + return current.src === target.src; +} + +function hasSameImageSequenceFrames( + current: CanvasLayer, + target: CanvasLayer, +): boolean { + const currentFrames = current.imageSequenceFrames ?? []; + const targetFrames = target.imageSequenceFrames ?? []; + if (currentFrames.length !== targetFrames.length) { + return false; + } + return currentFrames.every((currentFrame, index) => { + const targetFrame = targetFrames[index]; + return ( + targetFrame !== undefined && + currentFrame.frameIndex === targetFrame.frameIndex && + hasSameStableMediaReference( + { + src: currentFrame.imageSrc, + objectKey: currentFrame.objectKey, + assetObjectId: currentFrame.assetObjectId, + }, + { + src: targetFrame.imageSrc, + objectKey: targetFrame.objectKey, + assetObjectId: targetFrame.assetObjectId, + }, + ) + ); }); } +function hasSameLayerContent( + current: CanvasLayer, + target: CanvasLayer, +): boolean { + return ( + (current.mediaType ?? 'image') === (target.mediaType ?? 'image') && + hasSameStableMediaReference(current, target) && + hasSameImageSequenceFrames(current, target) + ); +} + +function cloneDialogPlaceholder( + dialog: GenerateDialogState, +): GenerateDialogState['placeholder'] { + return dialog.placeholder ? { ...dialog.placeholder } : undefined; +} + +function mergeLayerForHistoryRestore( + target: CanvasLayer, + current: CanvasLayer | undefined, +): CanvasLayer { + if (!current) { + return { ...target }; + } + return { + ...current, + x: target.x, + y: target.y, + zIndex: target.zIndex, + groupId: target.groupId, + assetKind: target.assetKind, + hidden: target.hidden, + locked: target.locked, + flipX: target.flipX, + flipY: target.flipY, + }; +} + +function mergeDialogPlaceholderForHistoryRestore( + target: GenerateDialogState, + current: GenerateDialogState, +): GenerateDialogState['placeholder'] { + if (!current.placeholder) { + return cloneDialogPlaceholder(target); + } + if (!target.placeholder) { + return cloneDialogPlaceholder(current); + } + return { + ...current.placeholder, + x: target.placeholder.x, + y: target.placeholder.y, + }; +} + +function mergeDialogForHistoryRestore< + TTarget extends GenerateDialogState, + TCurrent extends GenerateDialogState, +>(target: TTarget, current: TCurrent | undefined): TTarget | TCurrent { + if (!current) { + return { + ...target, + placeholder: cloneDialogPlaceholder(target), + }; + } + return { + ...current, + composerOpen: target.composerOpen, + placeholder: mergeDialogPlaceholderForHistoryRestore(target, current), + }; +} + +export function mergeCanvasHistorySnapshotForRestore({ + current, + target, +}: { + current: CanvasHistorySnapshot; + target: CanvasHistorySnapshot; +}): CanvasHistorySnapshot { + const currentLayerById = new Map( + current.layers.map((layer) => [layer.id, layer] as const), + ); + const currentDialogById = new Map(); + for (const dialog of current.inactiveGenerateDialogs) { + currentDialogById.set(dialog.id, dialog); + } + if (current.generateDialog?.id) { + currentDialogById.set(current.generateDialog.id, current.generateDialog); + } + + const mergeTargetDialog = (dialog: T): T => + mergeDialogForHistoryRestore( + dialog, + dialog.id ? currentDialogById.get(dialog.id) : undefined, + ) as T; + + return { + ...target, + layers: target.layers.map((layer) => + mergeLayerForHistoryRestore(layer, currentLayerById.get(layer.id)), + ), + generateDialog: target.generateDialog + ? mergeTargetDialog(target.generateDialog) + : null, + inactiveGenerateDialogs: target.inactiveGenerateDialogs.map( + (dialog): CanvasGenerationDialogState => mergeTargetDialog(dialog), + ), + }; +} + export function canRestoreCanvasHistorySnapshotWithoutContentLoss({ current, target, @@ -90,10 +240,7 @@ export function canRestoreCanvasHistorySnapshotWithoutContentLoss({ if (!currentLayer.hidden && targetLayer.hidden) { return false; } - return ( - getLayerContentSignature(currentLayer) === - getLayerContentSignature(targetLayer) - ); + return hasSameLayerContent(currentLayer, targetLayer); }); if (!preservesLayers) { return false; diff --git a/src/components/image-editor/useCanvasHistory.test.tsx b/src/components/image-editor/useCanvasHistory.test.tsx index 6884efa2d..6e40421e0 100644 --- a/src/components/image-editor/useCanvasHistory.test.tsx +++ b/src/components/image-editor/useCanvasHistory.test.tsx @@ -44,6 +44,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) { mode: 'generate', prompt: 'active prompt', status: 'idle', + imageSize: '1K', placeholder: { x: 10, y: 20, @@ -71,7 +72,9 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) { }, }, ]); - const [selectedLayerId, setSelectedLayerId] = useState('first'); + const [selectedLayerId, setSelectedLayerId] = useState( + 'first', + ); const [selectedLayerIds, setSelectedLayerIds] = useState(['first']); const [lastHistoryStatus, setLastHistoryStatus] = useState('empty'); @@ -118,10 +121,28 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) { {layers.map((layer) => `${layer.id}:${layer.x}`).join(',')} + + {layers + .map( + (layer) => + `${layer.resourceId}:${layer.sourceResourceId ?? '-'}:${layer.sourceAssetId ?? '-'}`, + ) + .join(',')} + {viewport.x},{viewport.y},{viewport.scale} {generateDialog?.prompt ?? '-'} + + {generateDialog + ? `${generateDialog.status}:${generateDialog.placeholder?.x ?? '-'}:${generateDialog.generatedLayerId ?? '-'}:${generateDialog.generationReferences?.[0]?.label ?? '-'}:${generateDialog.generationStartedAt ?? '-'}` + : '-'} + + + {generateDialog + ? `${generateDialog.imageSize ?? '-'}:${generateDialog.placeholder?.width ?? '-'}:${generateDialog.placeholder?.height ?? '-'}:${generateDialog.placeholder?.originalWidth ?? '-'}:${generateDialog.placeholder?.originalHeight ?? '-'}` + : '-'} + {inactiveGenerateDialogs.map((dialog) => dialog.prompt).join(',')} @@ -148,9 +169,64 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) { > mutate + + + +