完善图片画布撤销保护与操作提示 #89

Merged
kdletters merged 5 commits from feat/canvas-undo-protection into master 2026-07-20 15:19:48 +08:00
36 changed files with 2148 additions and 144 deletions
+1
View File
@@ -20,6 +20,7 @@
- [图片画布编辑器前端拆分计划](./technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md)
- [画布 Agent 对话面板](./【编辑器】画布Agent对话面板-2026-07-03.md)
- [画布 Agent 会话消息存 OSS](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md)
- [图片画布撤销范围与操作提示方案](./【图片画布】撤销范围与操作提示方案-2026-07-17.md)
- [浏览器内 AI Web 工程沙箱预览](./technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md)
- [AI Web 工程 Runner 安全模型](./technical/【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md)
@@ -119,6 +119,14 @@
- 验证:画板生成 workflow 测试覆盖 queueState 持续 `running` 到前端等待窗口结束时,不进入 failed、不显示该排队文案、不添加本地临时结果层。
- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx`
## 图片画布历史不能回退当前权威状态或复活后端已删素材
- 现象:生成占位框移动后开始生成,撤销移动会把仍在运行的生成对象恢复成待生成状态;切换到 2K 或改变比例后撤销位置,旧占位框还可能把当前尺寸回退。上传图层落库后,普通移动撤销可能被提示“可能会使图片消失”并永久卡在栈顶;即使安全检查已放行,直接恢复旧图层快照也会丢失刚回填的资源关联。素材库后端删除关联素材后,更早的移动快照还可能把已删图层重新加入并自动保存;修改素材类型虽然界面提示撤销成功,刷新后却可能从仍指向新类型的 resource 回弹。无稳定 ID 的“修改图片”草稿也可能被 target-null 快照直接关闭。
- 原因:内容消失安全检查和历史快照合并是两道独立边界。图层内容签名若严格比较 `sourceAssetId``sourceResourceId` 等延迟回填的内部关联 ID,会把同一媒体误判为替换;放行后若仍用目标快照整体覆盖同 ID 图层或占位框,又会回退当前权威关联、内容或尺寸。相同 dialog ID 直接恢复整个旧对话框快照还会覆盖当前 `generating` / 完成态;没有 ID 的 edit 草稿则根本不会进入存在性检查。外部素材删除不写画布历史,若不主动剪除包含关联图层的旧目标快照,target-only 图层会被当作正常撤销删除完整恢复。`assetKind` 的正式事实保存在项目 resource,历史只改内存字段而保留当前 `resourceId` 时无法跨刷新成立。
- 处理:图层内容身份按对象存储 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`、当前参数、任务生命周期、提示词、参考图和结果保持一致;edit 草稿使用基于来源图层的稳定 ID。current 中不存在对应 ID 时属于撤销完整删除,可从 target 全量恢复对象。素材库删除必须用 `isLayerLinkedToAsset` matcher 同步过滤 undo / redo 中所有包含关联图层的 entry,即使图层只存在于历史中也要过滤;普通画布删除不调用该接口。assetKind undo / redo 每次重新创建匹配恢复类型的正式 resource,并按 layer 请求版本只接受最新响应。即时生成结果在追加图层前捕获生成历史,自动适合视图不再压入另一条历史。
- 验证:覆盖 idle 生成框移动后进入 generating 再撤销、上传图层异步回填 `resourceId` / `sourceAssetId` / `sourceResourceId` 后撤销移动仍保留当前关联值、切换到 2K 或改变比例后撤销位置仍保留当前占位尺寸、非活动生成框被激活并拖动后撤销可恢复原 active / inactive 打开状态、撤销完整删除可以全量恢复对象、即时生成后第一次撤销直接命中生成保护、阈值内指针抖动既不移动也不产生历史、素材库删除后旧 undo / redo 无法复活关联 layer、edit 草稿不会被 target-null 快照吞掉,以及 assetKind 撤销与乱序 resource 响应后刷新仍保持最终类型。
- 关联:`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 签名读取并送给外部生成供应商。
@@ -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>` 目标 ID,不把生成器伪装成普通图层,也不新增后端表。
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。角色形象生成面板每次成功绑定角色规范后,在当前编辑器生命周期内缓存为上一张角色规范;再次新建角色形象时自动带入该缓存。图标素材和 UI 设计图面板每次成功绑定图标规范后,同样缓存为上一张图标规范;再次新建需要图标规范的素材时自动带入该缓存。生成规范菜单里的图标规范对象自身只把首行参考图作为可选参考,不要求必须先绑定图标规范。
@@ -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 临时抓手、撤销 / 恢复、安全阻止提示和画布基础渲染仍正常。
@@ -0,0 +1,57 @@
# 图片画布撤销、恢复范围与操作提示方案
更新时间:`2026-07-20`
## 产品规则
- 用户通过画布“撤销”按钮或 `Ctrl+Z` 触发撤销,通过“恢复”按钮或 `Ctrl+Shift+Z` 恢复最近一次成功撤销的操作。
- 撤销成功显示 `已撤销:XX操作`,恢复成功显示 `已恢复:XX操作`;提示位于画布工作区顶部居中,3 秒后自动消失。
- 如果目标快照会删除当前图层或生成结果、把当前可见图层重新隐藏,或者用其它素材替换当前素材,则不得恢复快照。
- 撤销被阻止时显示 `无法撤销:XX操作,可能会使图片消失`;恢复被阻止时显示 `无法恢复:XX操作,可能会使图片消失`
- 被阻止的记录不得出栈,也不得跳过到更早记录;画布状态不变,因此不会触发项目自动保存。
- 成功撤销会生成一条恢复记录;成功恢复后仍可再次撤销。撤销后发生新的画布编辑时,清空恢复历史。
## 操作边界
允许撤销的典型操作包括移动图片、移动生成结果、调整层级、组合与取消组合、删除或剪切图片、隐藏图片、锁定与解锁、翻转、修改素材类型和调整画布视图。删除、剪切和隐藏允许撤销,是因为目标只会让内容重新出现。
添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的撤销必须被安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布历史;素材库后端删除发生后,同时剪除撤销栈和恢复栈中所有包含关联图层的目标快照,不能让更早的画布历史复活已删除素材。
恢复同样执行动态安全检查。移动、层级、分组、锁定、翻转和视图等不会减少内容的操作可以恢复;重新执行删除、剪切、隐藏、删除生成结果或替换当前素材时必须被阻止。
## 技术实现
- 撤销栈和恢复栈均保存操作类型、目标 `CanvasHistorySnapshot` 和创建时间,分别最多保留 60 条。
- 新画布操作把操作前快照写入撤销栈并清空恢复栈;成功撤销把当前快照写入恢复栈,成功恢复把当前快照写回撤销栈。
- 操作类型用于生成用户提示,并对添加、上传、生成和替换等明确会移除当前结果的撤销做保护;其它操作能否应用由当前快照与目标快照的差异检查决定。
- 安全检查以稳定的 `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;撤销目标没有该草稿时必须阻止恢复,目标包含同 ID 旧草稿时保留当前未提交提示词和参数。
- 当前快照中已经没有对应 ID 的完整删除对象不执行上述字段级合并;撤销删除或剪切时仍可从目标快照全量恢复图层或生成对话框。
- 位置、尺寸、层级、分组、锁定、翻转、标题和素材类型不进入内容签名,避免误拦截普通编辑。
- 素材库删除通过本次删除素材与历史图层的关联 matcher,同时过滤撤销栈和恢复栈;只删除目标快照中包含关联图层的记录,保留不可能复活该素材的其它画布历史。即使关联图层当前已经不在画布、只存在于历史快照中,也必须执行过滤。
- 修改素材类型的撤销与恢复仍以当前图层内容为权威,但每次成功恢复类型后都要创建与恢复类型一致的正式项目 resource,再回填新 `resourceId`。同一图层的 resource 创建请求使用单调版本号,迟到的旧类型响应不得覆盖更晚的 undo / redo 结果。
- 鼠标拖动在按下时暂存操作前快照;屏幕位移达到点击阈值后才开始改变画布坐标并只提交一条历史,阈值内的指针抖动和单击都不产生位移或历史记录。
- 本地即时结果与后端项目快照结果都必须在生成图层加入画布前写入一条生成历史;生成完成后的自动适合视图不再额外压入视口历史,保证用户第一次撤销就命中生成保护。
- 顶部消息复用 `PlatformRuntimeStatusToast`,成功使用中性色,被阻止使用警告色;连续触发会替换消息并重新开始 3 秒计时。
## 验收重点
1. 添加、上传、粘贴、复制、生成和替换图片后撤销,当前图片不消失且出现准确警告。
2. 删除、剪切、隐藏、移动、分组和层级调整可以正常撤销,并出现成功提示。
3. 移动、层级、分组、锁定、翻转和视图等安全操作可以恢复,并可再次撤销。
4. 删除、剪切、隐藏、删除生成结果和替换素材的恢复会被阻止,当前内容保持不变。
5. 被阻止的记录仍位于栈顶,连续撤销或恢复不会越过保护边界。
6. 撤销后发生新编辑会清空恢复栈;恢复按钮随栈状态正确启用或禁用。
7. 一次鼠标拖动只产生一条历史,单击不产生历史。
8. `Ctrl+Z``Ctrl+Shift+Z` 分别触发撤销和恢复,在可编辑输入区域内不拦截原生行为。
9. 提示在 3 秒后消失,连续提示按最后一次触发重新计时。
10. 生成占位框移动后开始生成,再撤销移动时,只恢复位置,不得把正在运行的同 ID 生成对象回退为待生成状态。
11. 上传素材落库异步补齐 `resourceId``sourceResourceId``sourceAssetId` 后撤销移动,位置恢复且三个当前关联值保持不变。
12. 生成参数切换到 2K 或改变比例并同步占位尺寸后撤销位置,只恢复占位框 `x` / `y`,不得回退当前宽高、原始宽高或生成参数。
13. 拖动非活动生成框触发 active / inactive 互换后撤销,原 active 生成框恢复打开,原非活动生成框恢复关闭,任务状态与尺寸不回退。
14. 即时生成、本地结果和后端项目快照结果都只生成一条准确的生成历史,自动适合视图不会遮挡该历史。
15. 图层移动后从素材库删除关联素材,随后撤销或恢复都不得把已删除图层重新加入画布;普通画布删除未伴随素材库删除时仍可撤销。
16. 打开“修改图片”并输入未提交提示词后,从按钮等非输入控件触发撤销不得关闭弹窗或回退当前草稿。
17. 修改素材类型后立即撤销或快速撤销再恢复,最终只允许最新类型的 resource 响应回填;刷新项目后类型与最后一次成功历史操作一致。
@@ -830,6 +830,12 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(screen.getByAltText('画布图片:账号素材A')).toBeTruthy();
expect(screen.getByAltText('画布图片:账号素材B')).toBeTruthy();
fireEvent.keyDown(window, { key: 'ArrowRight', code: 'ArrowRight' });
expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty(
'disabled',
false,
);
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
await user.click(
within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByRole(
@@ -857,6 +863,9 @@ describe('ImageCanvasEditorView asset library integration', () => {
});
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b');
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
expect(screen.queryByAltText('画布图片:账号素材A')).toBeNull();
expect(screen.queryByAltText('画布图片:账号素材B')).toBeNull();
});
it('saves the canvas layout after deleting an asset linked by source resource id', async () => {
@@ -163,6 +163,7 @@ function createStageProps(): ImageCanvasStageViewProps {
contextShouldUnlockLayer: false,
canUndo: false,
canRedo: false,
historyFeedback: null,
isZoomMenuOpen: false,
isBackgroundSettingsOpen: false,
activeSidebarPanel: null,
@@ -274,6 +274,63 @@ export type CanvasHistorySnapshot = {
selectedLayerIds: string[];
};
export type CanvasHistoryActionType =
| 'move-image'
| 'move-generation-result'
| 'delete-image'
| 'delete-generation-result'
| 'cut-image'
| 'paste-image'
| 'duplicate-image'
| 'add-image'
| 'upload-image'
| 'generate-image'
| 'expand-image'
| 'remove-background'
| 'split-atlas'
| 'replace-image'
| 'show-image'
| 'hide-image'
| 'change-layer-order'
| 'group-images'
| 'ungroup-images'
| 'lock-image'
| 'unlock-image'
| 'flip-image'
| 'change-asset-kind'
| 'change-viewport';
export type CanvasHistoryAction = {
type: CanvasHistoryActionType;
count?: number;
};
export type CanvasHistoryEntry = {
snapshot: CanvasHistorySnapshot;
action: CanvasHistoryAction;
createdAt: number;
};
export type CanvasHistoryApplyResult =
| {
status: 'success';
action: CanvasHistoryAction;
}
| {
status: 'blocked';
action: CanvasHistoryAction;
reason: 'content-may-disappear';
}
| {
status: 'empty';
};
export type CanvasHistoryFeedback = {
id: number;
tone: 'neutral' | 'warning';
text: string;
};
export type CanvasClipboard = {
layers: CanvasLayer[];
mode: 'copy' | 'cut';
@@ -12,11 +12,13 @@ import userEvent from '@testing-library/user-event';
import JSZip from 'jszip';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { EditorProjectResourceSnapshot } from '../../services/image-editor/editorProjectClient';
import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation';
import {
ApiClientError,
AuthUiContext,
createAuthValue,
createDeferred,
defaultEditorProjectLayers,
defaultEditorProjectResources,
dispatchPointerEvent,
@@ -2048,6 +2050,84 @@ describe('ImageCanvasEditorView', () => {
});
});
it('persists an undone asset kind and ignores the older pending response', async () => {
const characterResource = createDeferred<EditorProjectResourceSnapshot>();
const restoredResource = createDeferred<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock
.mockImplementationOnce(() => characterResource.promise)
.mockImplementationOnce(() => restoredResource.promise);
render(<ImageCanvasEditorView />);
const layerButton = await screen.findByRole('button', {
name: '选择拼图素材',
});
fireEvent.click(
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
'menuitem',
{ name: '角色' },
),
);
await screen.findByRole('button', { name: '素材标签:角色' });
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
});
expect(createEditorProjectResourceMock).toHaveBeenNthCalledWith(
2,
'editor-project-default',
expect.objectContaining({ assetKind: null }),
);
await act(async () => {
restoredResource.resolve({
resourceId: 'resource-restored-kind',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: null,
});
await restoredResource.promise;
});
await waitFor(() => {
expect(
saveEditorProjectLayoutMock.mock.calls.some((call) =>
call[1].layers.some(
(layer: { resourceId?: unknown }) =>
layer.resourceId === 'resource-restored-kind',
),
),
).toBe(true);
});
await act(async () => {
characterResource.resolve({
resourceId: 'resource-stale-character',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: 'character',
});
await characterResource.promise;
});
expect(
saveEditorProjectLayoutMock.mock.calls.some((call) =>
call[1].layers.some(
(layer: { resourceId?: unknown }) =>
layer.resourceId === 'resource-stale-character',
),
),
).toBe(false);
});
it('offers Lovart-style zoom menu commands', async () => {
render(<ImageCanvasEditorView />);
@@ -2624,14 +2704,14 @@ describe('ImageCanvasEditorView', () => {
).toBeNull();
});
it('undoes and redoes canvas layer changes from the panel controls', async () => {
it('blocks panel undo when an added image would disappear', async () => {
render(<ImageCanvasEditorView />);
expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty(
'disabled',
true,
);
expect(screen.getByRole('button', { name: '重做' })).toHaveProperty(
expect(screen.getByRole('button', { name: '恢复' })).toHaveProperty(
'disabled',
true,
);
@@ -2650,19 +2730,28 @@ describe('ImageCanvasEditorView', () => {
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
});
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(
screen.getByText('无法撤销:添加图片操作,可能会使图片消失'),
).toBeTruthy();
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull();
expect(screen.getByRole('button', { name: '重做' })).toHaveProperty(
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(screen.getByText('已撤销:删除图片操作')).toBeTruthy();
expect(screen.getByRole('button', { name: '恢复' })).toHaveProperty(
'disabled',
false,
);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '重做' }));
});
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(
screen.getByText('无法恢复:删除图片操作,可能会使图片消失'),
).toBeTruthy();
});
it('supports undo and redo keyboard shortcuts inside the editor', async () => {
it('blocks protected keyboard undo without creating a restore entry', async () => {
render(<ImageCanvasEditorView />);
openAssetSidebar();
@@ -2671,10 +2760,15 @@ describe('ImageCanvasEditorView', () => {
});
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
await act(async () => {
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
});
expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull();
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(
screen.getByText('无法撤销:添加图片操作,可能会使图片消失'),
).toBeTruthy();
expect(screen.getByRole('button', { name: '恢复' })).toHaveProperty(
'disabled',
true,
);
await act(async () => {
fireEvent.keyDown(window, {
@@ -2686,4 +2780,33 @@ describe('ImageCanvasEditorView', () => {
});
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
});
it('restores a safely undone move from the panel and keyboard shortcut', async () => {
render(<ImageCanvasEditorView />);
openAssetSidebar();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
});
fireEvent.keyDown(window, { key: 'ArrowRight', code: 'ArrowRight' });
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
expect(screen.getByText('已撤销:移动图片操作')).toBeTruthy();
expect(screen.getByRole('button', { name: '恢复' })).toHaveProperty(
'disabled',
false,
);
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
expect(screen.getByText('已恢复:移动图片操作')).toBeTruthy();
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
fireEvent.keyDown(window, {
key: 'Z',
code: 'KeyZ',
ctrlKey: true,
shiftKey: true,
});
expect(screen.getByText('已恢复:移动图片操作')).toBeTruthy();
});
});
@@ -44,6 +44,8 @@ import type {
CanvasAssetKind,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasHistoryFeedback,
CanvasLayer,
CanvasTool,
CanvasViewport,
@@ -59,6 +61,7 @@ import {
applyEditorGenerationPricingConfig,
isCanvasGenerationDialog,
} from './ImageCanvasGenerationModel';
import { formatCanvasHistoryAction } from './ImageCanvasHistoryModel';
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
import {
isCanvasGenerationComposerVisible,
@@ -307,7 +310,16 @@ export function ImageCanvasEditorView({
const canvasGenerationDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
const canvasBackgroundColorRef = useRef(DEFAULT_CANVAS_BACKGROUND_COLOR);
const captureCanvasHistoryRef = useRef<() => void>(() => {});
const captureCanvasHistoryRef = useRef<
(action: CanvasHistoryAction) => void
>(() => {});
const discardCanvasHistoryEntriesContainingLayerRef = useRef<
(matchesLayer: (layer: CanvasLayer) => boolean) => void
>(() => {});
const persistRestoredAssetKindRef = useRef<(layer: CanvasLayer) => void>(
() => {},
);
const layerResourcePersistenceVersionRef = useRef(new Map<string, number>());
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
const closeGenerationTransientStateRef = useRef<() => void>(() => {});
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
@@ -430,7 +442,7 @@ export function ImageCanvasEditorView({
>(null);
const [isShortcutDialogOpen, setIsShortcutDialogOpen] = useState(false);
const captureViewportHistory = useCallback(() => {
captureCanvasHistoryRef.current();
captureCanvasHistoryRef.current({ type: 'change-viewport' });
}, []);
const {
viewport,
@@ -638,6 +650,11 @@ export function ImageCanvasEditorView({
toggleMinimap,
} = useImageCanvasEditorChrome({ openEditorLoginModal });
canvasBackgroundColorRef.current = canvasBackgroundColor;
const discardAssetRelatedCanvasHistory = useCallback(
(matchesLayer: (layer: CanvasLayer) => boolean) =>
discardCanvasHistoryEntriesContainingLayerRef.current(matchesLayer),
[],
);
const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({
layers,
setLayers,
@@ -645,6 +662,7 @@ export function ImageCanvasEditorView({
setSelectedLayerIds,
onDeleteLayerSideEffects: (layerId) =>
clearDeletedLayerGenerationStateRef.current(layerId),
discardHistoryEntriesContainingLayer: discardAssetRelatedCanvasHistory,
});
const {
assetFolders,
@@ -765,18 +783,69 @@ export function ImageCanvasEditorView({
}),
[],
);
const canvasHistoryEffects = useMemo(
() => ({
persistRestoredAssetKind: (layer: CanvasLayer) =>
persistRestoredAssetKindRef.current(layer),
}),
[],
);
const {
canUndo,
canRedo,
getCanvasHistorySnapshot,
captureCanvasHistory,
discardCanvasHistoryEntriesContainingLayer,
undoCanvasChange,
redoCanvasChange,
} = useCanvasHistory({
refs: canvasHistoryRefs,
setters: canvasHistorySetters,
resetters: canvasHistoryResetters,
effects: canvasHistoryEffects,
});
captureCanvasHistoryRef.current = captureCanvasHistory;
discardCanvasHistoryEntriesContainingLayerRef.current =
discardCanvasHistoryEntriesContainingLayer;
const [historyFeedback, setHistoryFeedback] =
useState<CanvasHistoryFeedback | null>(null);
useEffect(() => {
if (!historyFeedback) {
return;
}
const timer = window.setTimeout(() => setHistoryFeedback(null), 3000);
return () => window.clearTimeout(timer);
}, [historyFeedback]);
const handleUndoCanvasChange = useCallback(() => {
const result = undoCanvasChange();
if (result.status === 'empty') {
return;
}
const actionLabel = formatCanvasHistoryAction(result.action);
setHistoryFeedback((currentFeedback) => ({
id: (currentFeedback?.id ?? 0) + 1,
tone: result.status === 'blocked' ? 'warning' : 'neutral',
text:
result.status === 'blocked'
? `${actionLabel}使`
: `${actionLabel}`,
}));
}, [undoCanvasChange]);
const handleRedoCanvasChange = useCallback(() => {
const result = redoCanvasChange();
if (result.status === 'empty') {
return;
}
const actionLabel = formatCanvasHistoryAction(result.action);
setHistoryFeedback((currentFeedback) => ({
id: (currentFeedback?.id ?? 0) + 1,
tone: result.status === 'blocked' ? 'warning' : 'neutral',
text:
result.status === 'blocked'
? `${actionLabel}使`
: `${actionLabel}`,
}));
}, [redoCanvasChange]);
const selectSingleLayer = useCallback(
(layerId: string | null) => {
setSelectedLayerId(layerId);
@@ -1100,11 +1169,15 @@ export function ImageCanvasEditorView({
setEditorProjectContextId(projectId);
}, [projectId, setEditorProjectContextId]);
const applyGeneratedProjectSnapshot = useCallback(
(project: EditorProjectSnapshot) => {
(
project: EditorProjectSnapshot,
action: CanvasHistoryAction = { type: 'generate-image', count: 1 },
) => {
captureCanvasHistory(action);
applyProjectSnapshot(project);
void refreshAssetLibrary();
},
[applyProjectSnapshot, refreshAssetLibrary],
[applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary],
);
const handleEditorAgentCanvasRefreshRequested = useCallback(() => {
if (!projectId) {
@@ -1123,6 +1196,9 @@ export function ImageCanvasEditorView({
if (!imageSrc) {
return;
}
const requestVersion =
(layerResourcePersistenceVersionRef.current.get(layer.id) ?? 0) + 1;
layerResourcePersistenceVersionRef.current.set(layer.id, requestVersion);
void createEditorProjectResource(projectId, {
imageSrc,
objectKey: layer.objectKey,
@@ -1140,6 +1216,12 @@ export function ImageCanvasEditorView({
assetKind: layer.assetKind,
generationInputs: layer.generationInputs,
}).then((resource) => {
if (
layerResourcePersistenceVersionRef.current.get(layer.id) !==
requestVersion
) {
return;
}
setLayers((currentLayers) => {
const nextLayers = currentLayers.map((currentLayer) =>
currentLayer.id === layer.id
@@ -1156,6 +1238,7 @@ export function ImageCanvasEditorView({
},
[projectId],
);
persistRestoredAssetKindRef.current = persistUpdatedLayerResource;
const {
assetExportStatus,
isExportingAssets,
@@ -1199,6 +1282,7 @@ export function ImageCanvasEditorView({
appendUiAssetExtractionReferences: (references) =>
appendUiAssetExtractionReferencesRef.current(references),
appendCanvasLayersWithResources,
captureCanvasHistory,
selectSingleLayer,
});
const generationSurface = useImageCanvasGenerationSurface({
@@ -1619,6 +1703,8 @@ export function ImageCanvasEditorView({
onViewportInteractionStart: beginViewportInteraction,
onViewportInteractionEnd: endViewportInteraction,
onCloseImageContextMenu: () => setImageContextMenu(null),
getCanvasHistorySnapshot,
captureCanvasHistory,
});
resetCanvasInteractionStateRef.current = clearActiveInteraction;
useEffect(() => {
@@ -1662,7 +1748,7 @@ export function ImageCanvasEditorView({
);
const removeCanvasGenerationDialog = useCallback(
(dialogId: string) => {
captureCanvasHistory();
captureCanvasHistory({ type: 'delete-generation-result', count: 1 });
removeCanvasGenerationDialogById(dialogId);
setSelectedLayerId(null);
setSelectedLayerIds([]);
@@ -1707,7 +1793,7 @@ export function ImageCanvasEditorView({
if (!targetLayer || targetLayer.assetKind === assetKind) {
return;
}
captureCanvasHistory();
captureCanvasHistory({ type: 'change-asset-kind', count: 1 });
const nextLayer = {
...targetLayer,
assetKind,
@@ -1775,7 +1861,12 @@ export function ImageCanvasEditorView({
if (!targetLayerIds.length && !targetDialogIds.length) {
return;
}
captureCanvasHistory();
captureCanvasHistory({
type: targetLayerIds.length
? 'move-image'
: 'move-generation-result',
count: targetLayerIds.length || targetDialogIds.length,
});
if (targetLayerIds.length) {
setLayers((currentLayers) =>
currentLayers.map((layer) =>
@@ -1845,8 +1936,8 @@ export function ImageCanvasEditorView({
generateDialogRef,
selectedLayerIdRef,
selectedLayerIdsRef,
redoCanvasChange,
undoCanvasChange,
undoCanvasChange: handleUndoCanvasChange,
redoCanvasChange: handleRedoCanvasChange,
deleteLayerById: deleteLayerByIdFromShortcut,
deleteSelectedCanvasObjects: deleteSelectedLayer,
selectAllCanvasObjects,
@@ -2134,6 +2225,7 @@ export function ImageCanvasEditorView({
contextShouldUnlockLayer,
canUndo,
canRedo,
historyFeedback,
isZoomMenuOpen,
isBackgroundSettingsOpen,
activeSidebarPanel,
@@ -2208,8 +2300,8 @@ export function ImageCanvasEditorView({
onCloseImageContextMenu: () => setImageContextMenu(null),
onUpdateScaleFromCenter: updateScaleFromCenter,
onFitLayers: fitLayers,
onUndoCanvasChange: undoCanvasChange,
onRedoCanvasChange: redoCanvasChange,
onUndoCanvasChange: handleUndoCanvasChange,
onRedoCanvasChange: handleRedoCanvasChange,
onToggleZoomMenu: toggleZoomMenu,
onCloseZoomMenu: closeZoomMenu,
onToggleBackgroundSettings: toggleBackgroundSettings,
@@ -383,6 +383,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
expect(createEditDialogDraft(sourceLayer)).toEqual({
id: 'edit-dialog:layer-source',
mode: 'edit',
prompt: '原图提示',
status: 'idle',
@@ -36,9 +36,9 @@ import {
inferEditorImageSizeLabel,
normalizeEditorImageModel,
PUBLICATION_FRAME_ORIGINAL_SIZE,
resizeGenerationPlaceholderToImageSelection,
resolveEditorImageGenerationPixelSize,
resolveEditorVideoGenerationPixelSize,
resizeGenerationPlaceholderToImageSelection,
SPEC_FRAME_ORIGINAL_SIZE,
} from './ImageCanvasGenerationModel';
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
@@ -1244,6 +1244,7 @@ export function createEditDialogDraft(
sourceLayer.originalHeight,
);
return {
id: `edit-dialog:${sourceLayer.id}`,
mode: 'edit',
prompt: resolveUserGenerationPromptSnapshot(sourceLayer),
status: 'idle',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,261 @@
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasHistorySnapshot,
CanvasLayer,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
const CANVAS_HISTORY_ACTION_LABELS: Record<
CanvasHistoryAction['type'],
string
> = {
'move-image': '移动图片',
'move-generation-result': '移动生成结果',
'delete-image': '删除图片',
'delete-generation-result': '删除生成结果',
'cut-image': '剪切图片',
'paste-image': '粘贴图片',
'duplicate-image': '复制图片',
'add-image': '添加图片',
'upload-image': '上传图片',
'generate-image': '生成图片',
'expand-image': '扩展图片',
'remove-background': '移除背景',
'split-atlas': '拆分图集',
'replace-image': '替换图片',
'show-image': '显示图片',
'hide-image': '隐藏图片',
'change-layer-order': '调整图片层级',
'group-images': '组合图片',
'ungroup-images': '取消组合',
'lock-image': '锁定图片',
'unlock-image': '解锁图片',
'flip-image': '翻转图片',
'change-asset-kind': '修改素材类型',
'change-viewport': '调整画布视图',
};
const PROTECTED_CANVAS_HISTORY_ACTION_TYPES = new Set<
CanvasHistoryAction['type']
>([
'paste-image',
'duplicate-image',
'add-image',
'upload-image',
'generate-image',
'expand-image',
'remove-background',
'split-atlas',
'replace-image',
]);
export function formatCanvasHistoryAction(action: CanvasHistoryAction): string {
return CANVAS_HISTORY_ACTION_LABELS[action.type];
}
export function isProtectedCanvasHistoryAction(
action: CanvasHistoryAction,
): boolean {
return PROTECTED_CANVAS_HISTORY_ACTION_TYPES.has(action.type);
}
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<string, GenerateDialogState>();
for (const dialog of current.inactiveGenerateDialogs) {
currentDialogById.set(dialog.id, dialog);
}
if (current.generateDialog?.id) {
currentDialogById.set(current.generateDialog.id, current.generateDialog);
}
const mergeTargetDialog = <T extends GenerateDialogState>(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,
}: {
current: CanvasHistorySnapshot;
target: CanvasHistorySnapshot;
}): boolean {
const targetLayerById = new Map(
target.layers.map((layer) => [layer.id, layer] as const),
);
const preservesLayers = current.layers.every((currentLayer) => {
const targetLayer = targetLayerById.get(currentLayer.id);
if (!targetLayer) {
return false;
}
if (!currentLayer.hidden && targetLayer.hidden) {
return false;
}
return hasSameLayerContent(currentLayer, targetLayer);
});
if (!preservesLayers) {
return false;
}
const getDialogIds = (snapshot: CanvasHistorySnapshot): Set<string> =>
new Set(
[
snapshot.generateDialog?.id,
...snapshot.inactiveGenerateDialogs.map((dialog) => dialog.id),
].filter((dialogId): dialogId is string => Boolean(dialogId)),
);
const targetDialogIds = getDialogIds(target);
return [...getDialogIds(current)].every((dialogId) =>
targetDialogIds.has(dialogId),
);
}
@@ -14,7 +14,7 @@ function renderPanelDock(
canvasBackgroundColor: '#f8fafc',
canvasBackgroundHexValue: '#f8fafc',
canUndo: true,
canRedo: false,
canRedo: true,
isZoomMenuOpen: false,
isBackgroundSettingsOpen: false,
activeSidebarPanel: null,
@@ -83,17 +83,12 @@ describe('ImageCanvasPanelDockView', () => {
.getByRole('button', { name: '打开素材' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
(
within(toolbar).getByRole('button', {
name: '重做',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(within(toolbar).getByRole('button', { name: '恢复' })).toBeTruthy();
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '重置画布视图' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '撤销' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '恢复' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '打开图层' }));
fireEvent.click(
within(toolbar).getByRole('button', { name: '画布 Agent' }),
@@ -104,6 +99,7 @@ describe('ImageCanvasPanelDockView', () => {
expect(props.onFitLayers).toHaveBeenCalledTimes(1);
expect(props.onUndoCanvasChange).toHaveBeenCalledTimes(1);
expect(props.onRedoCanvasChange).toHaveBeenCalledTimes(1);
expect(props.onToggleSidebarPanel).toHaveBeenCalledWith('layers');
expect(props.onToggleAgentConversation).toHaveBeenCalledTimes(1);
expect(props.onToggleMinimap).toHaveBeenCalledTimes(1);
@@ -338,8 +338,8 @@ export function ImageCanvasPanelDockView({
onClick={onUndoCanvasChange}
/>
<EditorIconButton
label="重做"
title="重做"
label="恢复"
title="恢复上一次撤销"
icon={Redo2}
disabled={!canRedo}
onClick={onRedoCanvasChange}
@@ -16,7 +16,7 @@ export const IMAGE_CANVAS_SHORTCUT_SECTIONS: ImageCanvasShortcutSection[] = [
title: '编辑',
items: [
{ action: '撤销', keys: ['Ctrl', 'Z'], status: 'ready' },
{ action: '重做', keys: ['Ctrl', 'Shift', 'Z'], status: 'ready' },
{ action: '恢复', keys: ['Ctrl', 'Shift', 'Z'], status: 'ready' },
{ action: '全选画布元素', keys: ['Ctrl', 'A'], status: 'new' },
{ action: '复制选中图层', keys: ['Ctrl', 'C'], status: 'new' },
{ action: '粘贴图层', keys: ['Ctrl', 'V'], status: 'new' },
@@ -10,6 +10,7 @@ import type {
import type { ExternalGenerationTaskRecord } from '@/packages/shared/src';
import { EditorAgentConversationPanelView } from '@/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx';
import { PlatformRuntimeStatusToast } from '../common/PlatformRuntimeStatusToast';
import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView';
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
import type {
@@ -17,6 +18,7 @@ import type {
CanvasClipboard,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasHistoryFeedback,
CanvasLayer,
CanvasMarqueeState,
CanvasTool,
@@ -86,6 +88,7 @@ export type ImageCanvasStageViewProps = {
contextShouldUnlockLayer: boolean;
canUndo: boolean;
canRedo: boolean;
historyFeedback: CanvasHistoryFeedback | null;
isZoomMenuOpen: boolean;
isBackgroundSettingsOpen: boolean;
activeSidebarPanel: SidebarPanel | null;
@@ -239,6 +242,7 @@ export function ImageCanvasStageView({
contextShouldUnlockLayer,
canUndo,
canRedo,
historyFeedback,
isZoomMenuOpen,
isBackgroundSettingsOpen,
activeSidebarPanel,
@@ -339,6 +343,15 @@ export function ImageCanvasStageView({
onDrop={isInteractionPaused ? undefined : onCanvasDrop}
onContextMenu={isInteractionPaused ? undefined : onCanvasContextMenu}
>
{historyFeedback ? (
<PlatformRuntimeStatusToast
key={historyFeedback.id}
tone={historyFeedback.tone}
className="image-canvas-editor__undo-toast"
>
{historyFeedback.text}
</PlatformRuntimeStatusToast>
) : null}
{uploadDropTarget === 'canvas' ? (
<div
className="image-canvas-editor__upload-drop-overlay image-canvas-editor__upload-drop-overlay--canvas"
@@ -29,7 +29,13 @@ function createLayer(id: string, x: number): CanvasLayer {
};
}
function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
function HistoryHarness({
onClearDrag,
persistRestoredAssetKind = () => {},
}: {
onClearDrag: () => void;
persistRestoredAssetKind?: (layer: CanvasLayer) => void;
}) {
const [layers, setLayers] = useState<CanvasLayer[]>([
createLayer('first', 10),
]);
@@ -44,6 +50,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
mode: 'generate',
prompt: 'active prompt',
status: 'idle',
imageSize: '1K',
placeholder: {
x: 10,
y: 20,
@@ -71,8 +78,11 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
},
},
]);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>('first');
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(
'first',
);
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>(['first']);
const [lastHistoryStatus, setLastHistoryStatus] = useState('empty');
const layersRef = useRef(layers);
const viewportRef = useRef(viewport);
@@ -110,6 +120,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
setMetadataLayer: () => {},
resetCanvasInteractionState: onClearDrag,
},
effects: { persistRestoredAssetKind },
});
return (
@@ -117,20 +128,45 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
<span data-testid="layers">
{layers.map((layer) => `${layer.id}:${layer.x}`).join(',')}
</span>
<span data-testid="layer-relations">
{layers
.map(
(layer) =>
`${layer.resourceId}:${layer.sourceResourceId ?? '-'}:${layer.sourceAssetId ?? '-'}`,
)
.join(',')}
</span>
<span data-testid="layer-asset-kind">
{layers
.map((layer) => `${layer.resourceId}:${layer.assetKind ?? '-'}`)
.join(',')}
</span>
<span data-testid="viewport">
{viewport.x},{viewport.y},{viewport.scale}
</span>
<span data-testid="dialog">{generateDialog?.prompt ?? '-'}</span>
<span data-testid="dialog-state">
{generateDialog
? `${generateDialog.status}:${generateDialog.placeholder?.x ?? '-'}:${generateDialog.generatedLayerId ?? '-'}:${generateDialog.generationReferences?.[0]?.label ?? '-'}:${generateDialog.generationStartedAt ?? '-'}`
: '-'}
</span>
<span data-testid="dialog-size">
{generateDialog
? `${generateDialog.imageSize ?? '-'}:${generateDialog.placeholder?.width ?? '-'}:${generateDialog.placeholder?.height ?? '-'}:${generateDialog.placeholder?.originalWidth ?? '-'}:${generateDialog.placeholder?.originalHeight ?? '-'}`
: '-'}
</span>
<span data-testid="inactive">
{inactiveGenerateDialogs.map((dialog) => dialog.prompt).join(',')}
</span>
<span data-testid="selection">{selectedLayerIds.join(',')}</span>
<span data-testid="can-undo">{String(history.canUndo)}</span>
<span data-testid="can-redo">{String(history.canRedo)}</span>
<span data-testid="history-status">{lastHistoryStatus}</span>
<span data-testid="history-version">{history.historyVersion}</span>
<button
type="button"
onClick={() => {
history.captureCanvasHistory();
history.captureCanvasHistory({ type: 'move-image', count: 1 });
}}
>
capture
@@ -138,25 +174,125 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
<button
type="button"
onClick={() => {
setLayers([createLayer('second', 90)]);
setViewport({ x: 9, y: 8, scale: 2 });
setGenerateDialog({
id: 'dialog-next',
mode: 'spec',
prompt: 'next prompt',
status: 'idle',
history.captureCanvasHistory({
type: 'change-asset-kind',
count: 1,
});
setInactiveGenerateDialogs([]);
setSelectedLayerId('second');
setSelectedLayerIds(['second']);
}}
>
capture-asset-kind
</button>
<button
type="button"
onClick={() => {
setLayers([createLayer('first', 90)]);
setViewport({ x: 9, y: 8, scale: 2 });
setSelectedLayerId('first');
setSelectedLayerIds(['first']);
}}
>
mutate
</button>
<button
type="button"
onClick={() =>
setLayers([
{
...createLayer('first', 90),
resourceId: 'persisted-resource-first',
sourceResourceId: 'source-resource-late',
sourceAssetId: 'source-asset-late',
objectKey: 'uploads/first.png',
},
])
}
>
persist-and-move
</button>
<button type="button" onClick={() => setLayers([])}>
delete
</button>
<button
type="button"
onClick={() =>
setLayers((currentLayers) =>
currentLayers.map((layer) =>
layer.id === 'first'
? {
...layer,
resourceId: 'resource-first-icon',
assetKind: 'icon',
}
: layer,
),
)
}
>
change-asset-kind
</button>
<button
type="button"
onClick={() =>
history.discardCanvasHistoryEntriesContainingLayer(
(layer) => layer.id === 'first',
)
}
>
discard-first-history
</button>
<button
type="button"
onClick={() =>
history.discardCanvasHistoryEntriesContainingLayer(
(layer) => layer.id === 'missing',
)
}
>
discard-missing-history
</button>
<button
type="button"
onClick={() =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...currentDialog,
prompt: 'current prompt',
status: 'generating',
generatedLayerId: 'generated-layer-current',
generationReferences: [
{
id: 'current-reference',
label: 'current reference',
src: '/current-reference.png',
},
],
generationStartedAt: 100,
imageSize: '2K',
placeholder: currentDialog.placeholder
? {
...currentDialog.placeholder,
x: 90,
width: 640,
height: 480,
originalWidth: 640,
originalHeight: 480,
}
: undefined,
}
: currentDialog,
)
}
>
advance-dialog
</button>
<button type="button" onClick={() => setGenerateDialog(null)}>
delete-dialog
</button>
<button
type="button"
onClick={() => {
history.undoCanvasChange();
setLastHistoryStatus(history.undoCanvasChange().status);
}}
>
undo
@@ -164,7 +300,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
<button
type="button"
onClick={() => {
history.redoCanvasChange();
setLastHistoryStatus(history.redoCanvasChange().status);
}}
>
redo
@@ -174,7 +310,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
}
describe('useCanvasHistory', () => {
it('captures, restores, and replays canvas history snapshots', () => {
it('captures, undoes, and redoes safe canvas history snapshots', () => {
const clearDragState = vi.fn();
render(<HistoryHarness onClearDrag={clearDragState} />);
@@ -186,7 +322,7 @@ describe('useCanvasHistory', () => {
act(() => {
screen.getByRole('button', { name: 'mutate' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe('second:90');
expect(screen.getByTestId('layers').textContent).toBe('first:90');
expect(screen.getByTestId('viewport').textContent).toBe('9,8,2');
act(() => {
@@ -204,10 +340,210 @@ describe('useCanvasHistory', () => {
act(() => {
screen.getByRole('button', { name: 'redo' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe('second:90');
expect(screen.getByTestId('layers').textContent).toBe('first:90');
expect(screen.getByTestId('viewport').textContent).toBe('9,8,2');
expect(screen.getByTestId('dialog').textContent).toBe('next prompt');
expect(screen.getByTestId('selection').textContent).toBe('second');
expect(screen.getByTestId('can-redo').textContent).toBe('false');
expect(screen.getByTestId('can-undo').textContent).toBe('true');
});
it('blocks redo when replaying a deletion would remove canvas content', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
screen.getByRole('button', { name: 'delete' }).click();
});
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe('first:10');
act(() => {
screen.getByRole('button', { name: 'redo' }).click();
});
expect(screen.getByTestId('history-status').textContent).toBe('blocked');
expect(screen.getByTestId('layers').textContent).toBe('first:10');
expect(screen.getByTestId('can-redo').textContent).toBe('true');
});
it('clears redo history after a new canvas edit is captured', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
screen.getByRole('button', { name: 'mutate' }).click();
});
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('can-redo').textContent).toBe('true');
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
});
expect(screen.getByTestId('can-redo').textContent).toBe('false');
});
it('undoes dialog movement without reverting its generation lifecycle', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
});
act(() => {
screen.getByRole('button', { name: 'advance-dialog' }).click();
});
expect(screen.getByTestId('dialog-state').textContent).toBe(
'generating:90:generated-layer-current:current reference:100',
);
expect(screen.getByTestId('dialog-size').textContent).toBe(
'2K:640:480:640:480',
);
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('dialog').textContent).toBe('current prompt');
expect(screen.getByTestId('dialog-state').textContent).toBe(
'generating:10:generated-layer-current:current reference:100',
);
expect(screen.getByTestId('dialog-size').textContent).toBe(
'2K:640:480:640:480',
);
act(() => {
screen.getByRole('button', { name: 'redo' }).click();
});
expect(screen.getByTestId('dialog').textContent).toBe('current prompt');
expect(screen.getByTestId('dialog-state').textContent).toBe(
'generating:90:generated-layer-current:current reference:100',
);
expect(screen.getByTestId('dialog-size').textContent).toBe(
'2K:640:480:640:480',
);
});
it('restores a generation dialog that was deleted after capture', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
screen.getByRole('button', { name: 'delete-dialog' }).click();
});
expect(screen.getByTestId('dialog').textContent).toBe('-');
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('dialog').textContent).toBe('active prompt');
expect(screen.getByTestId('dialog-state').textContent).toBe(
'idle:10:-:-:-',
);
});
it('undoes movement after source asset relation ids arrive asynchronously', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
});
act(() => {
screen.getByRole('button', { name: 'persist-and-move' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe('first:90');
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('history-status').textContent).toBe('success');
expect(screen.getByTestId('layers').textContent).toBe('first:10');
expect(screen.getByTestId('layer-relations').textContent).toBe(
'persisted-resource-first:source-resource-late:source-asset-late',
);
});
it('discards undo and redo entries whose snapshots contain a deleted asset layer', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
});
act(() => {
screen.getByRole('button', { name: 'mutate' }).click();
});
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('can-redo').textContent).toBe('true');
act(() => {
screen.getByRole('button', { name: 'discard-first-history' }).click();
});
expect(screen.getByTestId('can-undo').textContent).toBe('false');
expect(screen.getByTestId('can-redo').textContent).toBe('false');
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('history-status').textContent).toBe('empty');
});
it('does not update history state when an invalidation matcher misses', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
});
const versionBeforeDiscard =
screen.getByTestId('history-version').textContent;
act(() => {
screen.getByRole('button', { name: 'discard-missing-history' }).click();
});
expect(screen.getByTestId('can-undo').textContent).toBe('true');
expect(screen.getByTestId('history-version').textContent).toBe(
versionBeforeDiscard,
);
});
it('persists asset kind changes restored by undo and redo', () => {
const persistRestoredAssetKind = vi.fn();
render(
<HistoryHarness
onClearDrag={() => {}}
persistRestoredAssetKind={persistRestoredAssetKind}
/>,
);
act(() => {
screen.getByRole('button', { name: 'capture-asset-kind' }).click();
});
act(() => {
screen.getByRole('button', { name: 'change-asset-kind' }).click();
});
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('layer-asset-kind').textContent).toBe(
'resource-first-icon:-',
);
expect(persistRestoredAssetKind).toHaveBeenLastCalledWith(
expect.objectContaining({
id: 'first',
resourceId: 'resource-first-icon',
assetKind: undefined,
}),
);
act(() => {
screen.getByRole('button', { name: 'redo' }).click();
});
expect(screen.getByTestId('layer-asset-kind').textContent).toBe(
'resource-first-icon:icon',
);
expect(persistRestoredAssetKind).toHaveBeenLastCalledWith(
expect.objectContaining({ assetKind: 'icon' }),
);
expect(persistRestoredAssetKind).toHaveBeenCalledTimes(2);
});
});
+125 -33
View File
@@ -3,11 +3,19 @@ import { type RefObject, useCallback, useRef, useState } from 'react';
import { MAX_HISTORY_STEPS } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasHistoryApplyResult,
CanvasHistoryEntry,
CanvasHistorySnapshot,
CanvasLayer,
CanvasViewport,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import {
canRestoreCanvasHistorySnapshotWithoutContentLoss,
isProtectedCanvasHistoryAction,
mergeCanvasHistorySnapshotForRestore,
} from './ImageCanvasHistoryModel';
type CanvasHistoryRefs = {
layersRef: RefObject<CanvasLayer[]>;
@@ -22,9 +30,7 @@ type CanvasHistorySetters = {
setLayers: (layers: CanvasLayer[]) => void;
setViewport: (viewport: CanvasViewport) => void;
setGenerateDialog: (dialog: GenerateDialogState | null) => void;
setInactiveGenerateDialogs: (
dialogs: CanvasGenerationDialogState[],
) => void;
setInactiveGenerateDialogs: (dialogs: CanvasGenerationDialogState[]) => void;
setSelectedLayerId: (layerId: string | null) => void;
setSelectedLayerIds: (layerIds: string[]) => void;
};
@@ -35,6 +41,10 @@ type CanvasHistoryResetters = {
resetCanvasInteractionState: () => void;
};
type CanvasHistoryEffects = {
persistRestoredAssetKind?: (layer: CanvasLayer) => void;
};
function cloneGenerateDialog(dialog: GenerateDialogState): GenerateDialogState {
return {
...dialog,
@@ -55,13 +65,15 @@ export function useCanvasHistory({
refs,
setters,
resetters,
effects,
}: {
refs: CanvasHistoryRefs;
setters: CanvasHistorySetters;
resetters: CanvasHistoryResetters;
effects?: CanvasHistoryEffects;
}) {
const undoStackRef = useRef<CanvasHistorySnapshot[]>([]);
const redoStackRef = useRef<CanvasHistorySnapshot[]>([]);
const undoStackRef = useRef<CanvasHistoryEntry[]>([]);
const redoStackRef = useRef<CanvasHistoryEntry[]>([]);
const [historyVersion, setHistoryVersion] = useState(0);
const getCanvasHistorySnapshot = useCallback(
@@ -71,8 +83,9 @@ export function useCanvasHistory({
generateDialog: refs.generateDialogRef.current
? cloneGenerateDialog(refs.generateDialogRef.current)
: null,
inactiveGenerateDialogs:
refs.inactiveGenerateDialogsRef.current.map(cloneCanvasGenerationDialog),
inactiveGenerateDialogs: refs.inactiveGenerateDialogsRef.current.map(
cloneCanvasGenerationDialog,
),
selectedLayerId: refs.selectedLayerIdRef.current,
selectedLayerIds: [...refs.selectedLayerIdsRef.current],
}),
@@ -80,66 +93,144 @@ export function useCanvasHistory({
);
const restoreCanvasHistorySnapshot = useCallback(
(snapshot: CanvasHistorySnapshot) => {
setters.setLayers(snapshot.layers.map((layer) => ({ ...layer })));
setters.setViewport({ ...snapshot.viewport });
(snapshot: CanvasHistorySnapshot, action?: CanvasHistoryAction) => {
const currentSnapshot = getCanvasHistorySnapshot();
const mergedSnapshot = mergeCanvasHistorySnapshotForRestore({
current: currentSnapshot,
target: snapshot,
});
setters.setLayers(mergedSnapshot.layers.map((layer) => ({ ...layer })));
setters.setViewport({ ...mergedSnapshot.viewport });
setters.setGenerateDialog(
snapshot.generateDialog
? cloneGenerateDialog(snapshot.generateDialog)
mergedSnapshot.generateDialog
? cloneGenerateDialog(mergedSnapshot.generateDialog)
: null,
);
setters.setInactiveGenerateDialogs(
snapshot.inactiveGenerateDialogs.map(cloneCanvasGenerationDialog),
mergedSnapshot.inactiveGenerateDialogs.map(cloneCanvasGenerationDialog),
);
setters.setSelectedLayerId(snapshot.selectedLayerId);
setters.setSelectedLayerIds([...snapshot.selectedLayerIds]);
setters.setSelectedLayerId(mergedSnapshot.selectedLayerId);
setters.setSelectedLayerIds([...mergedSnapshot.selectedLayerIds]);
resetters.setHoveredLayerId(null);
resetters.setMetadataLayer(null);
resetters.resetCanvasInteractionState();
if (action?.type === 'change-asset-kind') {
const currentLayerById = new Map(
currentSnapshot.layers.map((layer) => [layer.id, layer] as const),
);
for (const layer of mergedSnapshot.layers) {
const currentLayer = currentLayerById.get(layer.id);
if (currentLayer && currentLayer.assetKind !== layer.assetKind) {
effects?.persistRestoredAssetKind?.({ ...layer });
}
}
}
},
[resetters, setters],
[effects, getCanvasHistorySnapshot, resetters, setters],
);
const captureCanvasHistory = useCallback(
(options: { clearRedo?: boolean } = {}) => {
(
action: CanvasHistoryAction,
options: { snapshot?: CanvasHistorySnapshot } = {},
) => {
undoStackRef.current = [
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
getCanvasHistorySnapshot(),
{
snapshot: options.snapshot ?? getCanvasHistorySnapshot(),
action,
createdAt: Date.now(),
},
];
if (options.clearRedo !== false) {
redoStackRef.current = [];
}
redoStackRef.current = [];
setHistoryVersion((version) => version + 1);
},
[getCanvasHistorySnapshot],
);
const undoCanvasChange = useCallback(() => {
const previousSnapshot = undoStackRef.current.at(-1);
if (!previousSnapshot) {
return;
const discardCanvasHistoryEntriesContainingLayer = useCallback(
(matchesLayer: (layer: CanvasLayer) => boolean) => {
const keepEntry = (entry: CanvasHistoryEntry) =>
!entry.snapshot.layers.some(matchesLayer);
const nextUndoStack = undoStackRef.current.filter(keepEntry);
const nextRedoStack = redoStackRef.current.filter(keepEntry);
if (
nextUndoStack.length === undoStackRef.current.length &&
nextRedoStack.length === redoStackRef.current.length
) {
return;
}
undoStackRef.current = nextUndoStack;
redoStackRef.current = nextRedoStack;
setHistoryVersion((version) => version + 1);
},
[],
);
const undoCanvasChange = useCallback((): CanvasHistoryApplyResult => {
const previousEntry = undoStackRef.current.at(-1);
if (!previousEntry) {
return { status: 'empty' };
}
const currentSnapshot = getCanvasHistorySnapshot();
if (
isProtectedCanvasHistoryAction(previousEntry.action) ||
!canRestoreCanvasHistorySnapshotWithoutContentLoss({
current: currentSnapshot,
target: previousEntry.snapshot,
})
) {
return {
status: 'blocked',
action: previousEntry.action,
reason: 'content-may-disappear',
};
}
undoStackRef.current = undoStackRef.current.slice(0, -1);
redoStackRef.current = [
...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
getCanvasHistorySnapshot(),
{
snapshot: currentSnapshot,
action: previousEntry.action,
createdAt: Date.now(),
},
];
restoreCanvasHistorySnapshot(previousSnapshot);
restoreCanvasHistorySnapshot(previousEntry.snapshot, previousEntry.action);
setHistoryVersion((version) => version + 1);
return { status: 'success', action: previousEntry.action };
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
const redoCanvasChange = useCallback(() => {
const nextSnapshot = redoStackRef.current.at(-1);
if (!nextSnapshot) {
return;
const redoCanvasChange = useCallback((): CanvasHistoryApplyResult => {
const nextEntry = redoStackRef.current.at(-1);
if (!nextEntry) {
return { status: 'empty' };
}
const currentSnapshot = getCanvasHistorySnapshot();
if (
!canRestoreCanvasHistorySnapshotWithoutContentLoss({
current: currentSnapshot,
target: nextEntry.snapshot,
})
) {
return {
status: 'blocked',
action: nextEntry.action,
reason: 'content-may-disappear',
};
}
redoStackRef.current = redoStackRef.current.slice(0, -1);
undoStackRef.current = [
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
getCanvasHistorySnapshot(),
{
snapshot: currentSnapshot,
action: nextEntry.action,
createdAt: Date.now(),
},
];
restoreCanvasHistorySnapshot(nextSnapshot);
restoreCanvasHistorySnapshot(nextEntry.snapshot, nextEntry.action);
setHistoryVersion((version) => version + 1);
return { status: 'success', action: nextEntry.action };
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
return {
@@ -149,6 +240,7 @@ export function useCanvasHistory({
getCanvasHistorySnapshot,
restoreCanvasHistorySnapshot,
captureCanvasHistory,
discardCanvasHistoryEntriesContainingLayer,
undoCanvasChange,
redoCanvasChange,
};
@@ -158,10 +158,14 @@ function AssetCleanupHarness({
}),
],
onDeleteLayerSideEffects = vi.fn(),
discardHistoryEntriesContainingLayer = vi.fn(),
}: {
deletedAssets?: EditorAsset[];
initialLayers?: CanvasLayer[];
onDeleteLayerSideEffects?: (layerId: string) => void;
discardHistoryEntriesContainingLayer?: (
matchesLayer: (layer: CanvasLayer) => boolean,
) => void;
}) {
const [layers, setLayers] = useState(initialLayers);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>('linked');
@@ -172,6 +176,7 @@ function AssetCleanupHarness({
setSelectedLayerId,
setSelectedLayerIds,
onDeleteLayerSideEffects,
discardHistoryEntriesContainingLayer,
});
return (
@@ -249,10 +254,14 @@ describe('useImageCanvasAssetCanvasBridge', () => {
it('removes canvas layers linked to deleted assets and keeps unrelated selection', () => {
const onDeleteLayerSideEffects = vi.fn();
const discardHistoryEntriesContainingLayer = vi.fn();
render(
<AssetCleanupHarness
deletedAssets={[defaultAsset]}
onDeleteLayerSideEffects={onDeleteLayerSideEffects}
discardHistoryEntriesContainingLayer={
discardHistoryEntriesContainingLayer
}
/>,
);
@@ -264,6 +273,45 @@ describe('useImageCanvasAssetCanvasBridge', () => {
expect(screen.getByTestId('selected').textContent).toBe('-');
expect(screen.getByTestId('selected-many').textContent).toBe('kept');
expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('linked');
const matchesLayer =
discardHistoryEntriesContainingLayer.mock.calls[0]?.[0];
expect(discardHistoryEntriesContainingLayer).toHaveBeenCalledTimes(1);
expect(matchesLayer(createLayer({ sourceAssetId: 'asset-1' }))).toBe(true);
expect(
matchesLayer(
createLayer({
id: 'layer-other',
resourceId: 'resource-other',
src: 'data:image/png;base64,other',
objectKey: 'uploads/other.png',
assetObjectId: 'asset-object-other',
sourceResourceId: 'source-resource-other',
sourceAssetId: 'asset-other',
}),
),
).toBe(false);
});
it('invalidates matching history even when no linked layer is currently mounted', () => {
const discardHistoryEntriesContainingLayer = vi.fn();
render(
<AssetCleanupHarness
deletedAssets={[defaultAsset]}
initialLayers={[]}
discardHistoryEntriesContainingLayer={
discardHistoryEntriesContainingLayer
}
/>,
);
act(() => {
screen.getByRole('button', { name: '清理素材' }).click();
});
const matchesLayer =
discardHistoryEntriesContainingLayer.mock.calls[0]?.[0];
expect(discardHistoryEntriesContainingLayer).toHaveBeenCalledTimes(1);
expect(matchesLayer(createLayer({ sourceAssetId: 'asset-1' }))).toBe(true);
});
it('removes canvas layers linked by the asset source resource id', () => {
@@ -13,6 +13,7 @@ import {
} from './ImageCanvasEditorModel';
import type {
AssetPointerDragState,
CanvasHistoryAction,
CanvasLayer,
CanvasViewport,
EditorAsset,
@@ -35,6 +36,9 @@ type UseImageCanvasAssetLayerCleanupOptions = {
setSelectedLayerId: Dispatch<SetStateAction<string | null>>;
setSelectedLayerIds: Dispatch<SetStateAction<string[]>>;
onDeleteLayerSideEffects?: (layerId: string) => void;
discardHistoryEntriesContainingLayer?: (
matchesLayer: (layer: CanvasLayer) => boolean,
) => void;
};
type UseImageCanvasAssetCanvasBridgeOptions = {
@@ -54,7 +58,7 @@ type UseImageCanvasAssetCanvasBridgeOptions = {
setHoveredLayerId: Dispatch<SetStateAction<string | null>>;
updateAssetMoveDropFolder: (folderId: string | null) => void;
moveAssetToFolder: (assetId: string, folderId: string) => void;
captureCanvasHistory: () => void;
captureCanvasHistory: (action: CanvasHistoryAction) => void;
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
selectSingleLayer: (layerId: string | null) => void;
addUploadedFiles: (
@@ -69,12 +73,16 @@ export function useImageCanvasAssetLayerCleanup({
setSelectedLayerId,
setSelectedLayerIds,
onDeleteLayerSideEffects,
discardHistoryEntriesContainingLayer,
}: UseImageCanvasAssetLayerCleanupOptions) {
return useCallback(
(deletedAssets: EditorAsset[]) => {
if (!deletedAssets.length) {
return;
}
discardHistoryEntriesContainingLayer?.((layer) =>
deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)),
);
const deletedLayerIds = layers
.filter((layer) =>
deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)),
@@ -112,6 +120,7 @@ export function useImageCanvasAssetLayerCleanup({
);
},
[
discardHistoryEntriesContainingLayer,
layers,
onDeleteLayerSideEffects,
setLayers,
@@ -157,7 +166,7 @@ export function useImageCanvasAssetCanvasBridge({
},
{ applyCascadeOffset: position === undefined },
);
captureCanvasHistory();
captureCanvasHistory({ type: 'add-image', count: 1 });
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
setHoveredLayerId(null);
@@ -13,6 +13,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasLayer,
CanvasTool,
CharacterAnimationPanelState,
@@ -194,6 +195,9 @@ function SubmissionWorkflowHarness({
applyProjectSnapshot = undefined,
onQueuedGenerationTask = undefined,
onWalletBalanceMayHaveChanged = undefined,
captureCanvasHistory = () => undefined,
fitLayers = undefined,
onAppendCanvasLayers = undefined,
}: {
initialDialog?: GenerateDialogState | null;
initialQuickEditPanel?: QuickEditPanelState | null;
@@ -214,6 +218,12 @@ function SubmissionWorkflowHarness({
onWalletBalanceMayHaveChanged?: Parameters<
typeof useImageCanvasGenerationSubmissionWorkflow
>[0]['onWalletBalanceMayHaveChanged'];
captureCanvasHistory?: (action: CanvasHistoryAction) => void;
fitLayers?: (
targetLayers?: CanvasLayer[],
options?: { captureHistory?: boolean },
) => void;
onAppendCanvasLayers?: (nextLayers: CanvasLayer[]) => void;
}) {
const [layers, setLayers] = useState<CanvasLayer[]>(initialLayers);
const [activeTool, setActiveTool] = useState<CanvasTool>('generate');
@@ -230,7 +240,14 @@ function SubmissionWorkflowHarness({
null,
);
const rememberedImageModelRef = useRef<string | null>(null);
const fitLayersMockRef = useRef(vi.fn());
const fitLayersRef = useRef(fitLayers);
fitLayersRef.current = fitLayers;
const fitLayersMockRef = useRef(
vi.fn(
(targetLayers?: CanvasLayer[], options?: { captureHistory?: boolean }) =>
fitLayersRef.current?.(targetLayers, options),
),
);
const layerCounterRef = useRef(0);
const dialogs = useCanvasGenerationDialogs();
@@ -269,8 +286,11 @@ function SubmissionWorkflowHarness({
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources: (nextLayers) =>
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
appendCanvasLayersWithResources: (nextLayers) => {
onAppendCanvasLayers?.(nextLayers);
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
},
captureCanvasHistory,
updateSourceLayer: (sourceLayerId, updater) =>
setLayers((currentLayers) =>
currentLayers.map((layer) =>
@@ -1358,13 +1378,19 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
});
it('submits edit dialogs beside the source and clears the modal state', async () => {
it('captures a local edit fallback when the project response has no snapshot', async () => {
const applyProjectSnapshot = vi.fn();
const captureCanvasHistory = vi.fn();
const fitLayers = vi.fn();
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '修改当前图片' }),
);
render(
<SubmissionWorkflowHarness
projectId="project-edit-size"
applyProjectSnapshot={applyProjectSnapshot}
captureCanvasHistory={captureCanvasHistory}
fitLayers={fitLayers}
initialDialog={{
mode: 'edit',
prompt: '',
@@ -1406,14 +1432,32 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('dialog').textContent).toBe('-');
expect(screen.getByTestId('tool').textContent).toBe('select');
expect(screen.getByTestId('fit-count').textContent).toBe('1');
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
expect(captureCanvasHistory).toHaveBeenCalledWith({
type: 'generate-image',
count: 1,
});
expect(applyProjectSnapshot).not.toHaveBeenCalled();
expect(fitLayers).toHaveBeenCalledWith(expect.any(Array), {
captureHistory: false,
});
});
it('uses the latest moved canvas placeholder for local-only generation fallback', async () => {
const callOrder: string[] = [];
const captureCanvasHistory = vi.fn((action: CanvasHistoryAction) => {
callOrder.push(`capture:${action.type}:${action.count}`);
});
const onAppendCanvasLayers = vi.fn(() => {
callOrder.push('append');
});
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ width: 512, height: 512 }),
);
render(
<SubmissionWorkflowHarness
captureCanvasHistory={captureCanvasHistory}
onAppendCanvasLayers={onAppendCanvasLayers}
initialDialog={{
id: 'dialog-1',
mode: 'generate',
@@ -1444,10 +1488,17 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:idle:open:layer-generated-1:placeholder:-',
);
expect(captureCanvasHistory).toHaveBeenCalledWith({
type: 'generate-image',
count: 1,
});
expect(onAppendCanvasLayers).toHaveBeenCalledTimes(1);
expect(callOrder).toEqual(['capture:generate-image:1', 'append']);
});
it('passes canvas completion context and refreshes from the backend project snapshot', async () => {
const applyProjectSnapshot = vi.fn();
const captureCanvasHistory = vi.fn();
generateEditorImageMock.mockResolvedValueOnce({
...createGenerated({
width: 1024,
@@ -1496,6 +1547,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
captureCanvasHistory={captureCanvasHistory}
initialDialog={{
id: 'dialog-character',
mode: 'character',
@@ -1570,6 +1622,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-generated-1',
);
expect(captureCanvasHistory).not.toHaveBeenCalled();
});
it('submits audio generations with asset library context and upserts the returned asset', async () => {
@@ -2387,6 +2440,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
it('extracts UI design assets into a spritesheet layer and sliced icon layers', async () => {
const captureCanvasHistory = vi.fn();
const fitLayers = vi.fn();
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
spritesheetWidth: 512,
@@ -2404,6 +2459,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
render(
<SubmissionWorkflowHarness
captureCanvasHistory={captureCanvasHistory}
fitLayers={fitLayers}
initialLayers={[
createLayer({
assetKind: 'ui-design',
@@ -2440,6 +2497,14 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
'layer-icon-spritesheet-1',
);
expect(screen.getByTestId('fit-count').textContent).toBe('1');
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
expect(captureCanvasHistory).toHaveBeenCalledWith({
type: 'generate-image',
count: 2,
});
expect(fitLayers).toHaveBeenCalledWith(expect.any(Array), {
captureHistory: false,
});
});
it('keeps the extracted spritesheet when extracted icon results are missing', async () => {
@@ -32,6 +32,7 @@ import {
import type {
CanvasGenerationDialogState,
CanvasGenerationInputs,
CanvasHistoryAction,
CanvasLayer,
CanvasTool,
CanvasViewport,
@@ -285,13 +286,17 @@ type GenerationSubmissionWorkflowOptions = {
dialog: GenerateDialogState,
) => GenerateDialogState['placeholder'];
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
captureCanvasHistory: (action: CanvasHistoryAction) => void;
updateSourceLayer: (
sourceLayerId: string,
updater: (layer: CanvasLayer) => CanvasLayer,
options?: { fit?: boolean; persist?: boolean },
) => void;
selectSingleLayer: (layerId: string | null) => void;
fitLayers: (targetLayers?: CanvasLayer[]) => void;
fitLayers: (
targetLayers?: CanvasLayer[],
options?: { captureHistory?: boolean },
) => void;
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
rememberImageModel: (imageModel: string) => void;
@@ -600,6 +605,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
hasCanvasGenerationDialogById,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
captureCanvasHistory,
updateSourceLayer,
selectSingleLayer,
fitLayers,
@@ -616,6 +622,13 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}: GenerationSubmissionWorkflowOptions) {
const addGeneratedLayersToCanvas = useCallback(
(nextLayers: CanvasLayer[]) => {
if (!nextLayers.length) {
return;
}
captureCanvasHistory({
type: 'generate-image',
count: nextLayers.length,
});
appendCanvasLayersWithResources(nextLayers);
nextLayers.forEach((layer) => {
const asset = layer.generatedAssetSnapshot;
@@ -624,7 +637,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
});
},
[appendCanvasLayersWithResources, upsertGeneratedAsset],
[
appendCanvasLayersWithResources,
captureCanvasHistory,
upsertGeneratedAsset,
],
);
const addGeneratedResultLayer = useCallback(
@@ -675,7 +692,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}));
}
if (options.sourceLayer) {
fitLayers([options.sourceLayer, nextLayer]);
fitLayers([options.sourceLayer, nextLayer], { captureHistory: false });
}
},
[
@@ -753,7 +770,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
);
}
}
fitLayers([sourceLayer, nextLayer]);
fitLayers([sourceLayer, nextLayer], { captureHistory: false });
},
[
addGeneratedLayersToCanvas,
@@ -1156,7 +1173,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
},
);
if (nextLayers.length) {
fitLayers([sourceLayer, ...nextLayers]);
fitLayers([sourceLayer, ...nextLayers], { captureHistory: false });
}
} catch (error) {
if (options.suppressAlert) {
@@ -23,6 +23,7 @@ import {
import { PlatformRuntimeStatusToast } from '../common/PlatformRuntimeStatusToast';
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasLayer,
CanvasTool,
CanvasViewport,
@@ -85,8 +86,11 @@ type ImageCanvasGenerationSurfaceOptions = {
) => GenerateDialogState['placeholder'];
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
selectSingleLayer: (layerId: string | null) => void;
fitLayers: (targetLayers?: CanvasLayer[]) => void;
captureCanvasHistory: () => void;
fitLayers: (
targetLayers?: CanvasLayer[],
options?: { captureHistory?: boolean },
) => void;
captureCanvasHistory: (action: CanvasHistoryAction) => void;
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
@@ -98,7 +102,10 @@ type ImageCanvasGenerationSurfaceOptions = {
currentUserId?: string | null;
assetFolderId?: string | null;
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
applyProjectSnapshot?: (project: EditorProjectSnapshot) => void;
applyProjectSnapshot?: (
project: EditorProjectSnapshot,
action?: CanvasHistoryAction,
) => void;
onWalletBalanceMayHaveChanged?: () => void;
};

Some files were not shown because too many files have changed in this diff Show More