From c3cefb8b9640de8703cd029596645f84b948ce3d Mon Sep 17 00:00:00 2001 From: oj-afraid-student <1373241747@qq.com> Date: Fri, 17 Jul 2026 19:07:31 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E6=92=A4=E9=94=80=E4=BF=9D=E6=8A=A4=E4=B8=8E?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为画布历史记录补充操作类型和内容消失保护 增加撤销成功及撤销阻止提示并在三秒后隐藏 补充拖动、上传、生成和替换等操作的历史语义 移除重做按钮、快捷键和重做历史 补充撤销规则的定向测试和说明文档 --- docs/README.md | 1 + ...片画布】撤销范围与操作提示方案-2026-07-17.md | 35 +++++++ .../ImageCanvasEditorShellView.test.tsx | 3 +- .../image-editor/ImageCanvasEditorTypes.ts | 57 +++++++++++ .../ImageCanvasEditorView.test.tsx | 36 ++++--- .../image-editor/ImageCanvasEditorView.tsx | 67 ++++++++++--- .../ImageCanvasHistoryModel.test.ts | 97 ++++++++++++++++++ .../image-editor/ImageCanvasHistoryModel.ts | 98 +++++++++++++++++++ .../ImageCanvasPanelDockView.test.tsx | 10 +- .../image-editor/ImageCanvasPanelDockView.tsx | 12 --- .../image-editor/ImageCanvasShortcutModel.ts | 1 - .../ImageCanvasSidebarView.test.tsx | 2 - .../image-editor/ImageCanvasStageView.tsx | 19 ++-- .../image-editor/useCanvasHistory.test.tsx | 31 ++---- .../image-editor/useCanvasHistory.ts | 69 +++++++------ .../useImageCanvasAssetCanvasBridge.ts | 5 +- .../useImageCanvasGenerationSurface.tsx | 13 ++- .../useImageCanvasGenerationWorkflow.test.tsx | 5 +- .../useImageCanvasGenerationWorkflow.ts | 32 ++++-- .../useImageCanvasKeyboardShortcuts.test.tsx | 13 +-- .../useImageCanvasKeyboardShortcuts.ts | 7 +- .../useImageCanvasLayerCommands.ts | 70 +++++++++---- .../useImageCanvasStageInteractions.test.tsx | 28 +++++- .../useImageCanvasStageInteractions.ts | 61 ++++++++++++ .../useImageCanvasUploadWorkflow.test.tsx | 1 + .../useImageCanvasUploadWorkflow.ts | 5 + .../useImageCanvasViewportControls.ts | 9 +- src/index.css | 10 ++ 28 files changed, 623 insertions(+), 174 deletions(-) create mode 100644 docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md create mode 100644 src/components/image-editor/ImageCanvasHistoryModel.test.ts create mode 100644 src/components/image-editor/ImageCanvasHistoryModel.ts diff --git a/docs/README.md b/docs/README.md index 549b216a7..d8911947e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md b/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md new file mode 100644 index 000000000..29fd3f799 --- /dev/null +++ b/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md @@ -0,0 +1,35 @@ +# 图片画布撤销范围与操作提示方案 + +更新时间:`2026-07-17` + +## 产品规则 + +- 用户通过画布“撤销”按钮或 `Ctrl+Z` 触发撤销。 +- 撤销成功后,在画布工作区顶部居中显示 `已撤销:XX操作`,3 秒后自动消失。 +- 如果目标快照会删除当前图层、把当前可见图层重新隐藏,或者用旧素材替换当前素材,则不得恢复快照,并显示 `无法撤销:XX操作,可能会使图片消失`,3 秒后自动消失。 +- 被阻止的历史记录不得出栈,也不得自动跳过到更早记录;画布状态不变,因此不会触发项目自动保存。 +- 图片画布不提供重做按钮、重做快捷键或重做历史栈。 + +## 操作边界 + +允许撤销的典型操作包括移动图片、移动生成结果、调整层级、组合与取消组合、删除或剪切图片、隐藏图片、锁定与解锁、翻转、修改素材类型和调整工具栏视口。删除与剪切允许撤销,是因为恢复目标只会让内容重新出现。 + +添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的操作必须被撤销安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布撤销历史。 + +## 技术实现 + +- 每条历史记录保存操作类型、操作前 `CanvasHistorySnapshot` 和创建时间,最多保留 60 条。 +- 操作类型只负责生成用户提示;能否恢复由当前快照与目标快照的差异检查决定。 +- 图层安全检查以稳定的 `layer.id` 判断当前图层是否仍存在,以媒体地址、对象存储标识和序列帧等字段组成内容签名,识别同 ID 图层的素材替换;仅刷新内部资源 ID 不视为图片消失。 +- 位置、尺寸、层级、分组、锁定、翻转、标题和素材类型不进入内容签名,避免误拦截普通编辑。 +- 鼠标拖动在按下时暂存操作前快照,位移超过点击阈值后只提交一条历史;单击不产生历史记录。 +- 顶部消息复用 `PlatformRuntimeStatusToast`,成功使用中性色,被阻止使用警告色;连续触发会替换消息并重新开始 3 秒计时。 + +## 验收重点 + +1. 添加、上传、粘贴、复制、生成和替换图片后撤销,当前图片不消失且出现准确警告。 +2. 删除、剪切、隐藏、移动、分组和层级调整可以正常撤销,并出现成功提示。 +3. 被阻止后历史记录仍位于栈顶,连续撤销不会越过保护边界。 +4. 一次鼠标拖动只产生一条历史,单击不产生历史。 +5. 页面不存在重做按钮和可用的 `Ctrl+Shift+Z` 重做入口。 +6. 提示在 3 秒后消失,连续提示按最后一次触发重新计时。 diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 88c14ae62..0d72923a2 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -162,7 +162,7 @@ function createStageProps(): ImageCanvasStageViewProps { contextShouldShowLayer: false, contextShouldUnlockLayer: false, canUndo: false, - canRedo: false, + undoFeedback: null, isZoomMenuOpen: false, isBackgroundSettingsOpen: false, activeSidebarPanel: null, @@ -227,7 +227,6 @@ function createStageProps(): ImageCanvasStageViewProps { onUpdateScaleFromCenter: vi.fn(), onFitLayers: vi.fn(), onUndoCanvasChange: vi.fn(), - onRedoCanvasChange: vi.fn(), onToggleZoomMenu: vi.fn(), onCloseZoomMenu: vi.fn(), onToggleBackgroundSettings: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 429a7d172..c46349ffd 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -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 CanvasUndoResult = + | { + status: 'success'; + action: CanvasHistoryAction; + } + | { + status: 'blocked'; + action: CanvasHistoryAction; + reason: 'content-may-disappear'; + } + | { + status: 'empty'; + }; + +export type CanvasUndoFeedback = { + id: number; + tone: 'neutral' | 'warning'; + text: string; +}; + export type CanvasClipboard = { layers: CanvasLayer[]; mode: 'copy' | 'cut'; diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 5867a2b85..65b8bf8c4 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -2624,17 +2624,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(); expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty( 'disabled', true, ); - expect(screen.getByRole('button', { name: '重做' })).toHaveProperty( - 'disabled', - true, - ); + expect(screen.queryByRole('button', { name: '重做' })).toBeNull(); openAssetSidebar(); await act(async () => { @@ -2650,19 +2647,19 @@ describe('ImageCanvasEditorView', () => { fireEvent.click(screen.getByRole('button', { name: '撤销' })); }); - expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull(); - expect(screen.getByRole('button', { name: '重做' })).toHaveProperty( - 'disabled', - false, - ); - await act(async () => { - 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(); + 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 and ignores the removed redo shortcut', async () => { render(); openAssetSidebar(); @@ -2671,10 +2668,11 @@ 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(); await act(async () => { fireEvent.keyDown(window, { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 367cae2e7..d7f51fb34 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -44,8 +44,10 @@ import type { CanvasAssetKind, CanvasContextMenuState, CanvasGenerationDialogState, + CanvasHistoryAction, CanvasLayer, CanvasTool, + CanvasUndoFeedback, CanvasViewport, CharacterReferenceImage, EditorAsset, @@ -59,6 +61,7 @@ import { applyEditorGenerationPricingConfig, isCanvasGenerationDialog, } from './ImageCanvasGenerationModel'; +import { formatCanvasHistoryAction } from './ImageCanvasHistoryModel'; import { fitViewportToBounds } from './ImageCanvasInteractionModel'; import { isCanvasGenerationComposerVisible, @@ -307,7 +310,9 @@ export function ImageCanvasEditorView({ const canvasGenerationDialogsRef = useRef([]); const viewportRef = useRef(DEFAULT_IMAGE_CANVAS_VIEWPORT); const canvasBackgroundColorRef = useRef(DEFAULT_CANVAS_BACKGROUND_COLOR); - const captureCanvasHistoryRef = useRef<() => void>(() => {}); + const captureCanvasHistoryRef = useRef< + (action: CanvasHistoryAction) => void + >(() => {}); const resetCanvasInteractionStateRef = useRef<() => void>(() => {}); const closeGenerationTransientStateRef = useRef<() => void>(() => {}); const specToolWrapRef = useRef(null); @@ -430,7 +435,7 @@ export function ImageCanvasEditorView({ >(null); const [isShortcutDialogOpen, setIsShortcutDialogOpen] = useState(false); const captureViewportHistory = useCallback(() => { - captureCanvasHistoryRef.current(); + captureCanvasHistoryRef.current({ type: 'change-viewport' }); }, []); const { viewport, @@ -763,16 +768,40 @@ export function ImageCanvasEditorView({ ); const { canUndo, - canRedo, + getCanvasHistorySnapshot, captureCanvasHistory, undoCanvasChange, - redoCanvasChange, } = useCanvasHistory({ refs: canvasHistoryRefs, setters: canvasHistorySetters, resetters: canvasHistoryResetters, }); captureCanvasHistoryRef.current = captureCanvasHistory; + const [undoFeedback, setUndoFeedback] = useState( + null, + ); + useEffect(() => { + if (!undoFeedback) { + return; + } + const timer = window.setTimeout(() => setUndoFeedback(null), 3000); + return () => window.clearTimeout(timer); + }, [undoFeedback]); + const handleUndoCanvasChange = useCallback(() => { + const result = undoCanvasChange(); + if (result.status === 'empty') { + return; + } + const actionLabel = formatCanvasHistoryAction(result.action); + setUndoFeedback((currentFeedback) => ({ + id: (currentFeedback?.id ?? 0) + 1, + tone: result.status === 'blocked' ? 'warning' : 'neutral', + text: + result.status === 'blocked' + ? `无法撤销:${actionLabel}操作,可能会使图片消失` + : `已撤销:${actionLabel}操作`, + })); + }, [undoCanvasChange]); const selectSingleLayer = useCallback( (layerId: string | null) => { setSelectedLayerId(layerId); @@ -1096,11 +1125,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) { @@ -1195,6 +1228,7 @@ export function ImageCanvasEditorView({ appendUiAssetExtractionReferences: (references) => appendUiAssetExtractionReferencesRef.current(references), appendCanvasLayersWithResources, + captureCanvasHistory, selectSingleLayer, }); const generationSurface = useImageCanvasGenerationSurface({ @@ -1614,6 +1648,8 @@ export function ImageCanvasEditorView({ onViewportInteractionStart: beginViewportInteraction, onViewportInteractionEnd: endViewportInteraction, onCloseImageContextMenu: () => setImageContextMenu(null), + getCanvasHistorySnapshot, + captureCanvasHistory, }); resetCanvasInteractionStateRef.current = clearActiveInteraction; useEffect(() => { @@ -1657,7 +1693,7 @@ export function ImageCanvasEditorView({ ); const removeCanvasGenerationDialog = useCallback( (dialogId: string) => { - captureCanvasHistory(); + captureCanvasHistory({ type: 'delete-generation-result', count: 1 }); removeCanvasGenerationDialogById(dialogId); setSelectedLayerId(null); setSelectedLayerIds([]); @@ -1702,7 +1738,7 @@ export function ImageCanvasEditorView({ if (!targetLayer || targetLayer.assetKind === assetKind) { return; } - captureCanvasHistory(); + captureCanvasHistory({ type: 'change-asset-kind', count: 1 }); const nextLayer = { ...targetLayer, assetKind, @@ -1770,7 +1806,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) => @@ -1840,8 +1881,7 @@ export function ImageCanvasEditorView({ generateDialogRef, selectedLayerIdRef, selectedLayerIdsRef, - redoCanvasChange, - undoCanvasChange, + undoCanvasChange: handleUndoCanvasChange, deleteLayerById: deleteLayerByIdFromShortcut, deleteSelectedCanvasObjects: deleteSelectedLayer, selectAllCanvasObjects, @@ -2127,7 +2167,7 @@ export function ImageCanvasEditorView({ contextShouldShowLayer, contextShouldUnlockLayer, canUndo, - canRedo, + undoFeedback, isZoomMenuOpen, isBackgroundSettingsOpen, activeSidebarPanel, @@ -2202,8 +2242,7 @@ export function ImageCanvasEditorView({ onCloseImageContextMenu: () => setImageContextMenu(null), onUpdateScaleFromCenter: updateScaleFromCenter, onFitLayers: fitLayers, - onUndoCanvasChange: undoCanvasChange, - onRedoCanvasChange: redoCanvasChange, + onUndoCanvasChange: handleUndoCanvasChange, onToggleZoomMenu: toggleZoomMenu, onCloseZoomMenu: closeZoomMenu, onToggleBackgroundSettings: toggleBackgroundSettings, diff --git a/src/components/image-editor/ImageCanvasHistoryModel.test.ts b/src/components/image-editor/ImageCanvasHistoryModel.test.ts new file mode 100644 index 000000000..b842f2e3e --- /dev/null +++ b/src/components/image-editor/ImageCanvasHistoryModel.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; + +import type { + CanvasHistorySnapshot, + CanvasLayer, +} from './ImageCanvasEditorTypes'; +import { + canRestoreCanvasHistorySnapshotWithoutContentLoss, + formatCanvasHistoryAction, + isProtectedCanvasHistoryAction, +} from './ImageCanvasHistoryModel'; + +function createLayer(overrides: Partial = {}): CanvasLayer { + return { + id: 'layer-1', + resourceId: 'resource-1', + title: '图片', + src: '/image-1.png', + x: 0, + y: 0, + width: 100, + height: 100, + originalWidth: 100, + originalHeight: 100, + zIndex: 1, + sourceType: 'uploaded', + ...overrides, + }; +} + +function createSnapshot(layers: CanvasLayer[]): CanvasHistorySnapshot { + return { + layers, + viewport: { x: 0, y: 0, scale: 1 }, + generateDialog: null, + inactiveGenerateDialogs: [], + selectedLayerId: null, + selectedLayerIds: [], + }; +} + +describe('ImageCanvasHistoryModel', () => { + it('allows restoring position and restoring a previously deleted layer', () => { + expect( + canRestoreCanvasHistorySnapshotWithoutContentLoss({ + current: createSnapshot([createLayer({ x: 80 })]), + target: createSnapshot([createLayer({ x: 10 })]), + }), + ).toBe(true); + expect( + canRestoreCanvasHistorySnapshotWithoutContentLoss({ + current: createSnapshot([createLayer({ resourceId: 'resource-new' })]), + target: createSnapshot([createLayer({ resourceId: 'resource-old' })]), + }), + ).toBe(true); + expect( + canRestoreCanvasHistorySnapshotWithoutContentLoss({ + current: createSnapshot([]), + target: createSnapshot([createLayer()]), + }), + ).toBe(true); + }); + + it('blocks removing, hiding, or replacing current canvas content', () => { + const current = createSnapshot([createLayer()]); + expect( + canRestoreCanvasHistorySnapshotWithoutContentLoss({ + current, + target: createSnapshot([]), + }), + ).toBe(false); + expect( + canRestoreCanvasHistorySnapshotWithoutContentLoss({ + current, + target: createSnapshot([createLayer({ hidden: true })]), + }), + ).toBe(false); + expect( + canRestoreCanvasHistorySnapshotWithoutContentLoss({ + current, + target: createSnapshot([ + createLayer({ resourceId: 'resource-old', src: '/old.png' }), + ]), + }), + ).toBe(false); + }); + + it('formats action names without duplicating the 操作 suffix', () => { + expect(formatCanvasHistoryAction({ type: 'replace-image' })).toBe( + '替换图片', + ); + expect(isProtectedCanvasHistoryAction({ type: 'replace-image' })).toBe( + true, + ); + expect(isProtectedCanvasHistoryAction({ type: 'move-image' })).toBe(false); + }); +}); diff --git a/src/components/image-editor/ImageCanvasHistoryModel.ts b/src/components/image-editor/ImageCanvasHistoryModel.ts new file mode 100644 index 000000000..7589086a3 --- /dev/null +++ b/src/components/image-editor/ImageCanvasHistoryModel.ts @@ -0,0 +1,98 @@ +import type { + CanvasHistoryAction, + CanvasHistorySnapshot, + CanvasLayer, +} 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 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, + }); +} + +export function canRestoreCanvasHistorySnapshotWithoutContentLoss({ + current, + target, +}: { + current: CanvasHistorySnapshot; + target: CanvasHistorySnapshot; +}): boolean { + const targetLayerById = new Map( + target.layers.map((layer) => [layer.id, layer] as const), + ); + + return current.layers.every((currentLayer) => { + const targetLayer = targetLayerById.get(currentLayer.id); + if (!targetLayer) { + return false; + } + if (!currentLayer.hidden && targetLayer.hidden) { + return false; + } + return ( + getLayerContentSignature(currentLayer) === + getLayerContentSignature(targetLayer) + ); + }); +} diff --git a/src/components/image-editor/ImageCanvasPanelDockView.test.tsx b/src/components/image-editor/ImageCanvasPanelDockView.test.tsx index a594faf01..a03c6da0f 100644 --- a/src/components/image-editor/ImageCanvasPanelDockView.test.tsx +++ b/src/components/image-editor/ImageCanvasPanelDockView.test.tsx @@ -14,7 +14,6 @@ function renderPanelDock( canvasBackgroundColor: '#f8fafc', canvasBackgroundHexValue: '#f8fafc', canUndo: true, - canRedo: false, isZoomMenuOpen: false, isBackgroundSettingsOpen: false, activeSidebarPanel: null, @@ -24,7 +23,6 @@ function renderPanelDock( minimapModel: null, onFitLayers: vi.fn(), onUndoCanvasChange: vi.fn(), - onRedoCanvasChange: vi.fn(), onUpdateScaleFromCenter: vi.fn(), onToggleZoomMenu: vi.fn(), onCloseZoomMenu: vi.fn(), @@ -83,13 +81,7 @@ describe('ImageCanvasPanelDockView', () => { .getByRole('button', { name: '打开素材' }) .getAttribute('aria-pressed'), ).toBe('true'); - expect( - ( - within(toolbar).getByRole('button', { - name: '重做', - }) as HTMLButtonElement - ).disabled, - ).toBe(true); + expect(within(toolbar).queryByRole('button', { name: '重做' })).toBeNull(); expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '重置画布视图' })); diff --git a/src/components/image-editor/ImageCanvasPanelDockView.tsx b/src/components/image-editor/ImageCanvasPanelDockView.tsx index 171daa690..6282913ff 100644 --- a/src/components/image-editor/ImageCanvasPanelDockView.tsx +++ b/src/components/image-editor/ImageCanvasPanelDockView.tsx @@ -3,7 +3,6 @@ import { Layers, Map as MapIcon, MessageCircle, - Redo2, RotateCcw, Undo2, X, @@ -38,7 +37,6 @@ type ImageCanvasPanelDockViewProps = { canvasBackgroundColor: string; canvasBackgroundHexValue: string; canUndo: boolean; - canRedo: boolean; isZoomMenuOpen: boolean; isBackgroundSettingsOpen: boolean; activeSidebarPanel: SidebarPanel | null; @@ -48,7 +46,6 @@ type ImageCanvasPanelDockViewProps = { minimapModel: StageMinimapModel | null; onFitLayers: () => void; onUndoCanvasChange: () => void; - onRedoCanvasChange: () => void; onUpdateScaleFromCenter: (nextScale: number) => void; onToggleZoomMenu: () => void; onCloseZoomMenu: () => void; @@ -205,7 +202,6 @@ export function ImageCanvasPanelDockView({ canvasBackgroundColor, canvasBackgroundHexValue, canUndo, - canRedo, isZoomMenuOpen, isBackgroundSettingsOpen, activeSidebarPanel, @@ -215,7 +211,6 @@ export function ImageCanvasPanelDockView({ minimapModel, onFitLayers, onUndoCanvasChange, - onRedoCanvasChange, onUpdateScaleFromCenter, onToggleZoomMenu, onCloseZoomMenu, @@ -337,13 +332,6 @@ export function ImageCanvasPanelDockView({ disabled={!canUndo} onClick={onUndoCanvasChange} /> -
void; onFitLayers: () => void; onUndoCanvasChange: () => void; - onRedoCanvasChange: () => void; onToggleZoomMenu: () => void; onCloseZoomMenu: () => void; onToggleBackgroundSettings: () => void; @@ -234,7 +235,7 @@ export function ImageCanvasStageView({ contextShouldShowLayer, contextShouldUnlockLayer, canUndo, - canRedo, + undoFeedback, isZoomMenuOpen, isBackgroundSettingsOpen, activeSidebarPanel, @@ -303,7 +304,6 @@ export function ImageCanvasStageView({ onUpdateScaleFromCenter, onFitLayers, onUndoCanvasChange, - onRedoCanvasChange, onToggleZoomMenu, onCloseZoomMenu, onToggleBackgroundSettings, @@ -335,6 +335,15 @@ export function ImageCanvasStageView({ onDrop={isInteractionPaused ? undefined : onCanvasDrop} onContextMenu={isInteractionPaused ? undefined : onCanvasContextMenu} > + {undoFeedback ? ( + + {undoFeedback.text} + + ) : null} {uploadDropTarget === 'canvas' ? (
void }) { {selectedLayerIds.join(',')} {String(history.canUndo)} - {String(history.canRedo)} -
); } describe('useCanvasHistory', () => { - it('captures, restores, and replays canvas history snapshots', () => { + it('captures and restores canvas history snapshots without redo', () => { const clearDragState = vi.fn(); render(); @@ -186,7 +177,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(() => { @@ -198,16 +189,6 @@ describe('useCanvasHistory', () => { expect(screen.getByTestId('dialog').textContent).toBe('active prompt'); expect(screen.getByTestId('inactive').textContent).toBe('archived prompt'); expect(screen.getByTestId('selection').textContent).toBe('first'); - expect(screen.getByTestId('can-redo').textContent).toBe('true'); expect(clearDragState).toHaveBeenCalledTimes(1); - - act(() => { - screen.getByRole('button', { name: 'redo' }).click(); - }); - - expect(screen.getByTestId('layers').textContent).toBe('second: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'); }); }); diff --git a/src/components/image-editor/useCanvasHistory.ts b/src/components/image-editor/useCanvasHistory.ts index a9e1ae120..eb533b80c 100644 --- a/src/components/image-editor/useCanvasHistory.ts +++ b/src/components/image-editor/useCanvasHistory.ts @@ -3,11 +3,18 @@ import { type RefObject, useCallback, useRef, useState } from 'react'; import { MAX_HISTORY_STEPS } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, + CanvasHistoryAction, + CanvasHistoryEntry, CanvasHistorySnapshot, CanvasLayer, + CanvasUndoResult, CanvasViewport, GenerateDialogState, } from './ImageCanvasEditorTypes'; +import { + canRestoreCanvasHistorySnapshotWithoutContentLoss, + isProtectedCanvasHistoryAction, +} from './ImageCanvasHistoryModel'; type CanvasHistoryRefs = { layersRef: RefObject; @@ -60,8 +67,7 @@ export function useCanvasHistory({ setters: CanvasHistorySetters; resetters: CanvasHistoryResetters; }) { - const undoStackRef = useRef([]); - const redoStackRef = useRef([]); + const undoStackRef = useRef([]); const [historyVersion, setHistoryVersion] = useState(0); const getCanvasHistorySnapshot = useCallback( @@ -101,55 +107,54 @@ export function useCanvasHistory({ ); 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 = []; - } setHistoryVersion((version) => version + 1); }, [getCanvasHistorySnapshot], ); - const undoCanvasChange = useCallback(() => { - const previousSnapshot = undoStackRef.current.at(-1); - if (!previousSnapshot) { - return; + const undoCanvasChange = useCallback((): CanvasUndoResult => { + 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(), - ]; - restoreCanvasHistorySnapshot(previousSnapshot); - setHistoryVersion((version) => version + 1); - }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); - - const redoCanvasChange = useCallback(() => { - const nextSnapshot = redoStackRef.current.at(-1); - if (!nextSnapshot) { - return; - } - redoStackRef.current = redoStackRef.current.slice(0, -1); - undoStackRef.current = [ - ...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), - getCanvasHistorySnapshot(), - ]; - restoreCanvasHistorySnapshot(nextSnapshot); + restoreCanvasHistorySnapshot(previousEntry.snapshot); setHistoryVersion((version) => version + 1); + return { status: 'success', action: previousEntry.action }; }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); return { canUndo: undoStackRef.current.length > 0, - canRedo: redoStackRef.current.length > 0, historyVersion, getCanvasHistorySnapshot, restoreCanvasHistorySnapshot, captureCanvasHistory, undoCanvasChange, - redoCanvasChange, }; } diff --git a/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts b/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts index 1e4753de9..c039ba2fc 100644 --- a/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts +++ b/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts @@ -13,6 +13,7 @@ import { } from './ImageCanvasEditorModel'; import type { AssetPointerDragState, + CanvasHistoryAction, CanvasLayer, CanvasViewport, EditorAsset, @@ -54,7 +55,7 @@ type UseImageCanvasAssetCanvasBridgeOptions = { setHoveredLayerId: Dispatch>; 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: ( @@ -157,7 +158,7 @@ export function useImageCanvasAssetCanvasBridge({ }, { applyCascadeOffset: position === undefined }, ); - captureCanvasHistory(); + captureCanvasHistory({ type: 'add-image', count: 1 }); appendCanvasLayersWithResources([nextLayer]); selectSingleLayer(nextLayer.id); setHoveredLayerId(null); diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index ab1ee8477..240379ca1 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -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>; setActiveSidebarPanel: Dispatch>; setMetadataLayer: Dispatch>; @@ -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; }; diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index f226754fe..3b4fb3787 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -1906,7 +1906,10 @@ describe('useImageCanvasGenerationWorkflow', () => { }, }); }); - expect(applyProjectSnapshot).toHaveBeenCalledWith(project); + expect(applyProjectSnapshot).toHaveBeenCalledWith(project, { + type: 'split-atlas', + count: 1, + }); expect(screen.getByTestId('sidebar').textContent).toBe('layers'); }); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index f06958895..2c1495b3b 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -23,6 +23,7 @@ import { import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel'; import type { CanvasGenerationDialogState, + CanvasHistoryAction, CanvasLayer, CanvasTool, CanvasViewport, @@ -610,8 +611,11 @@ type GenerationWorkflowOptions = { ) => 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>; setActiveSidebarPanel: Dispatch>; setMetadataLayer: Dispatch>; @@ -622,7 +626,10 @@ type GenerationWorkflowOptions = { currentUserId?: string | null; assetFolderId?: string | null; upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void; - applyProjectSnapshot?: (project: EditorProjectSnapshot) => void; + applyProjectSnapshot?: ( + project: EditorProjectSnapshot, + action?: CanvasHistoryAction, + ) => void; onWalletBalanceMayHaveChanged?: () => void; }; @@ -1229,7 +1236,7 @@ export function useImageCanvasGenerationWorkflow({ return; } const updatedLayer = updater(sourceLayer); - captureCanvasHistory(); + captureCanvasHistory({ type: 'replace-image', count: 1 }); setLayers((currentLayers) => currentLayers.map((layer) => layer.id === sourceLayerId ? updatedLayer : layer, @@ -1240,7 +1247,7 @@ export function useImageCanvasGenerationWorkflow({ } selectSingleLayer(sourceLayerId); if (options.fit !== false) { - fitLayers([updatedLayer]); + fitLayers([updatedLayer], { captureHistory: false }); } }, [ @@ -1581,11 +1588,11 @@ export function useImageCanvasGenerationWorkflow({ sourceAssetId: null, assetKind: cropExpandAssetKind, }; - captureCanvasHistory(); + captureCanvasHistory({ type: 'expand-image', count: 1 }); appendCanvasLayersWithResources([nextLayer]); persistGeneratedAsset?.(nextLayer); selectSingleLayer(nextLayer.id); - fitLayers([cropExpandSourceLayer, nextLayer]); + fitLayers([cropExpandSourceLayer, nextLayer], { captureHistory: false }); setCropExpandPanel(null); setActiveSidebarPanel('layers'); } catch (error) { @@ -1673,7 +1680,11 @@ export function useImageCanvasGenerationWorkflow({ await applyQueuedEditorGenerationProject( result, projectId, - applyProjectSnapshot, + (project) => + applyProjectSnapshot?.(project, { + type: 'remove-background', + count: 1, + }), refreshTaskListForQueuedGeneration, onWalletBalanceMayHaveChanged, setGenerationWarning, @@ -1746,7 +1757,10 @@ export function useImageCanvasGenerationWorkflow({ }, }, }); - applyProjectSnapshot(result.project); + applyProjectSnapshot(result.project, { + type: 'split-atlas', + count: 1, + }); setActiveTool('select'); setActiveSidebarPanel('layers'); } catch (error) { diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx index 5b7216ed1..13a5d6e66 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx @@ -65,7 +65,6 @@ function KeyboardShortcutsHarness({ initialTool = 'select', isInteractionPaused = false, undoCanvasChange = vi.fn(), - redoCanvasChange = vi.fn(), deleteLayerById = vi.fn(), deleteSelectedCanvasObjects = vi.fn(), selectAllCanvasObjects = vi.fn(), @@ -95,7 +94,6 @@ function KeyboardShortcutsHarness({ initialTool?: CanvasTool; isInteractionPaused?: boolean; undoCanvasChange?: () => void; - redoCanvasChange?: () => void; deleteLayerById?: (layerId: string | null) => void; deleteSelectedCanvasObjects?: () => void; selectAllCanvasObjects?: () => void; @@ -166,7 +164,6 @@ function KeyboardShortcutsHarness({ generateDialogRef, selectedLayerIdRef, selectedLayerIdsRef, - redoCanvasChange, undoCanvasChange, deleteLayerById, deleteSelectedCanvasObjects, @@ -262,14 +259,10 @@ function KeyboardShortcutsHarness({ } describe('useImageCanvasKeyboardShortcuts', () => { - it('routes undo and redo shortcuts while ignoring editable inputs', () => { + it('routes undo while ignoring redo and editable inputs', () => { const undoCanvasChange = vi.fn(); - const redoCanvasChange = vi.fn(); render( - , + , ); act(() => { @@ -285,7 +278,7 @@ describe('useImageCanvasKeyboardShortcuts', () => { shiftKey: true, }); }); - expect(redoCanvasChange).toHaveBeenCalledTimes(1); + expect(undoCanvasChange).toHaveBeenCalledTimes(1); act(() => { fireEvent.keyDown(screen.getByLabelText('快捷键输入框'), { diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts index 390a6ab2b..8c1068bf1 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts @@ -13,7 +13,6 @@ type UseImageCanvasKeyboardShortcutsOptions = { generateDialogRef: RefObject; selectedLayerIdRef: RefObject; selectedLayerIdsRef?: RefObject; - redoCanvasChange: () => void; undoCanvasChange: () => void; deleteLayerById: (layerId: string | null) => void; deleteSelectedCanvasObjects?: () => void; @@ -161,7 +160,6 @@ export function useImageCanvasKeyboardShortcuts({ generateDialogRef, selectedLayerIdRef, selectedLayerIdsRef, - redoCanvasChange, undoCanvasChange, deleteLayerById, deleteSelectedCanvasObjects, @@ -272,9 +270,7 @@ export function useImageCanvasKeyboardShortcuts({ !isEditableTarget(event) ) { event.preventDefault(); - if (event.shiftKey) { - redoCanvasChange(); - } else { + if (!event.shiftKey) { undoCanvasChange(); } return; @@ -521,7 +517,6 @@ export function useImageCanvasKeyboardShortcuts({ moveSelectedCanvasLayers, nudgeSelectedCanvasObjects, requestRemoveCanvasGenerationDialog, - redoCanvasChange, selectAllCanvasObjects, selectedLayerIdRef, selectedLayerIdsRef, diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index ce71fe2e1..720f0f78f 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -10,6 +10,7 @@ import type { CanvasClipboard, CanvasContextMenuState, CanvasGenerationDialogState, + CanvasHistoryAction, CanvasLayer, } from './ImageCanvasEditorTypes'; import { readLayerImageBlob } from './ImageCanvasExportModel'; @@ -49,7 +50,7 @@ type LayerCommandsOptions = { setContextMenu: Dispatch>; setImageContextMenu: (menu: null) => void; setActiveTool: (tool: 'select') => void; - captureCanvasHistory: () => void; + captureCanvasHistory: (action: CanvasHistoryAction) => void; selectSingleLayer: (layerId: string | null) => void; onDeleteLayerSideEffects: (targetLayerId: string) => void; onDeleteGenerationDialogSideEffects?: (targetDialogId: string) => void; @@ -193,7 +194,7 @@ export function useImageCanvasLayerCommands({ if (!nextLayers.length) { return false; } - captureCanvasHistory(); + captureCanvasHistory({ type: 'paste-image', count: nextLayers.length }); setLayers((currentLayers) => [...currentLayers, ...nextLayers]); setSelectedLayerIds(nextLayers.map((layer) => layer.id)); setSelectedLayerId(nextLayers[0]?.id ?? null); @@ -229,7 +230,7 @@ export function useImageCanvasLayerCommands({ copyLayerImageToSystemClipboard(clipboard.layers); } if (options.cut) { - captureCanvasHistory(); + captureCanvasHistory({ type: 'cut-image', count: targetIds.length }); setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds), ); @@ -272,7 +273,7 @@ export function useImageCanvasLayerCommands({ copyLayerImageToSystemClipboard(clipboard.layers); } if (options.cut) { - captureCanvasHistory(); + captureCanvasHistory({ type: 'cut-image', count: targetIds.length }); setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds), ); @@ -307,7 +308,7 @@ export function useImageCanvasLayerCommands({ if (!nextLayers.length) { return; } - captureCanvasHistory(); + captureCanvasHistory({ type: 'duplicate-image', count: nextLayers.length }); setLayers((currentLayers) => [...currentLayers, ...nextLayers]); setSelectedLayerIds(nextLayers.map((layer) => layer.id)); setSelectedLayerId(nextLayers[0]?.id ?? null); @@ -330,7 +331,7 @@ export function useImageCanvasLayerCommands({ if (!nextLayers.length) { return; } - captureCanvasHistory(); + captureCanvasHistory({ type: 'duplicate-image', count: nextLayers.length }); setLayers((currentLayers) => [...currentLayers, ...nextLayers]); setSelectedLayerIds(nextLayers.map((layer) => layer.id)); setSelectedLayerId(nextLayers[0]?.id ?? null); @@ -354,7 +355,7 @@ export function useImageCanvasLayerCommands({ if (!targetIds.length) { return; } - captureCanvasHistory(); + captureCanvasHistory({ type: 'change-asset-kind', count: targetIds.length }); setLayers((currentLayers) => updateCanvasLayersByIds(currentLayers, targetIds, updater), ); @@ -374,7 +375,10 @@ export function useImageCanvasLayerCommands({ if (!targetIds.length) { return; } - captureCanvasHistory(); + captureCanvasHistory({ + type: 'change-layer-order', + count: targetIds.length, + }); setLayers((currentLayers) => moveCanvasLayers(currentLayers, targetIds, mode), ); @@ -394,7 +398,10 @@ export function useImageCanvasLayerCommands({ if (!targetIds.length) { return; } - captureCanvasHistory(); + captureCanvasHistory({ + type: 'change-layer-order', + count: targetIds.length, + }); setLayers((currentLayers) => moveCanvasLayers(currentLayers, targetIds, mode), ); @@ -415,7 +422,7 @@ export function useImageCanvasLayerCommands({ if (!targetIds.length) { return; } - captureCanvasHistory(); + captureCanvasHistory({ type: 'group-images', count: targetIds.length }); setLayers((currentLayers) => groupCanvasLayers(currentLayers, targetIds, createGroupId()), ); @@ -432,7 +439,7 @@ export function useImageCanvasLayerCommands({ if (!targetIds.length) { return; } - captureCanvasHistory(); + captureCanvasHistory({ type: 'ungroup-images', count: targetIds.length }); setLayers((currentLayers) => ungroupCanvasLayers(currentLayers, targetIds)); closeContextMenus(); }, [ @@ -447,7 +454,13 @@ export function useImageCanvasLayerCommands({ if (!targetIds.length) { return; } - captureCanvasHistory(); + const shouldHide = getCanvasLayersByIds(layers, targetIds).some( + (layer) => !layer.hidden, + ); + captureCanvasHistory({ + type: shouldHide ? 'hide-image' : 'show-image', + count: targetIds.length, + }); setLayers((currentLayers) => toggleCanvasLayersVisibility(currentLayers, targetIds), ); @@ -456,6 +469,7 @@ export function useImageCanvasLayerCommands({ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, + layers, setLayers, ]); @@ -464,7 +478,13 @@ export function useImageCanvasLayerCommands({ if (!targetIds.length) { return; } - captureCanvasHistory(); + const shouldLock = getCanvasLayersByIds(layers, targetIds).some( + (layer) => !layer.locked, + ); + captureCanvasHistory({ + type: shouldLock ? 'lock-image' : 'unlock-image', + count: targetIds.length, + }); setLayers((currentLayers) => toggleCanvasLayersLock(currentLayers, targetIds), ); @@ -473,6 +493,7 @@ export function useImageCanvasLayerCommands({ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, + layers, setLayers, ]); @@ -482,7 +503,7 @@ export function useImageCanvasLayerCommands({ if (!targetIds.length) { return; } - captureCanvasHistory(); + captureCanvasHistory({ type: 'flip-image', count: targetIds.length }); setLayers((currentLayers) => flipCanvasLayers(currentLayers, targetIds, axis), ); @@ -508,7 +529,12 @@ export function useImageCanvasLayerCommands({ if (!targetLayerIds.length && !targetDialogIds.length) { return; } - captureCanvasHistory(); + captureCanvasHistory({ + type: targetLayerIds.length + ? 'delete-image' + : 'delete-generation-result', + count: targetLayerIds.length || targetDialogIds.length, + }); if (targetLayerIds.length) { setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetLayerIds), @@ -563,7 +589,7 @@ export function useImageCanvasLayerCommands({ } setImageContextMenu(null); setContextMenu(null); - captureCanvasHistory(); + captureCanvasHistory({ type: 'delete-image', count: 1 }); setLayers((currentLayers) => { const nextLayers = currentLayers.filter( (layer) => layer.id !== targetLayerId, @@ -612,7 +638,12 @@ export function useImageCanvasLayerCommands({ deleteLayerById(targetLayerIds[0] ?? null); return; } - captureCanvasHistory(); + captureCanvasHistory({ + type: targetLayerIds.length + ? 'delete-image' + : 'delete-generation-result', + count: targetLayerIds.length || targetDialogIds.length, + }); setImageContextMenu(null); setContextMenu(null); if (targetLayerIds.length) { @@ -668,7 +699,10 @@ export function useImageCanvasLayerCommands({ if (!targetLayerIds.length) { return; } - captureCanvasHistory(); + captureCanvasHistory({ + type: 'group-images', + count: targetLayerIds.length, + }); setLayers((currentLayers) => groupCanvasLayers(currentLayers, targetLayerIds, createGroupId()), ); diff --git a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx index 0db87d40a..cc8e1c428 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx +++ b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx @@ -10,6 +10,8 @@ import { describe, expect, it, vi } from 'vitest'; import type { CanvasGenerationDialogState, + CanvasHistoryAction, + CanvasHistorySnapshot, CanvasLayer, CanvasViewport, GenerateDialogState, @@ -118,6 +120,7 @@ function StageInteractionsHarness({ flushMinimapViewportDrag = vi.fn(), onViewportInteractionStart = vi.fn(), onViewportInteractionEnd = vi.fn(), + captureCanvasHistory = vi.fn(), }: { pickCharacterSpecFromLayer?: (layer: CanvasLayer) => void; pickGenerationReferenceFromLayer?: (layer: CanvasLayer) => void; @@ -139,6 +142,10 @@ function StageInteractionsHarness({ flushMinimapViewportDrag?: () => void; onViewportInteractionStart?: () => void; onViewportInteractionEnd?: () => void; + captureCanvasHistory?: ( + action: CanvasHistoryAction, + options?: { snapshot?: CanvasHistorySnapshot }, + ) => void; }) { const canvasViewportRef = useRef(null); const worldRef = useRef(null); @@ -239,6 +246,15 @@ function StageInteractionsHarness({ onViewportInteractionEnd, onCloseImageContextMenu: () => setImageMenuCloseCount((currentCount) => currentCount + 1), + getCanvasHistorySnapshot: () => ({ + layers, + viewport, + generateDialog, + inactiveGenerateDialogs: [], + selectedLayerId, + selectedLayerIds, + }), + captureCanvasHistory, }); const getViewportElement = () => { const element = canvasViewportRef.current; @@ -694,7 +710,12 @@ function StageInteractionsHarness({ describe('useImageCanvasStageInteractions', () => { it('selects and drags multiple layers from stage pointer events', () => { - render(); + const captureCanvasHistory = vi.fn(); + render( + , + ); act(() => { screen.getByRole('button', { name: '直接选第一层' }).click(); @@ -718,6 +739,11 @@ describe('useImageCanvasStageInteractions', () => { expect(screen.getByTestId('layers').textContent).toContain( 'second:250.0,90.0', ); + expect(captureCanvasHistory).toHaveBeenCalledTimes(1); + expect(captureCanvasHistory).toHaveBeenCalledWith( + { type: 'move-image', count: 2 }, + expect.objectContaining({ snapshot: expect.any(Object) }), + ); act(() => { screen.getByRole('button', { name: '直接结束图层拖拽' }).click(); diff --git a/src/components/image-editor/useImageCanvasStageInteractions.ts b/src/components/image-editor/useImageCanvasStageInteractions.ts index 1b94876b2..ee2c946fa 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.ts +++ b/src/components/image-editor/useImageCanvasStageInteractions.ts @@ -12,6 +12,8 @@ import { import type { CanvasGenerationDialogState, + CanvasHistoryAction, + CanvasHistorySnapshot, CanvasLayer, CanvasMarqueeState, CanvasTool, @@ -112,6 +114,19 @@ type UseImageCanvasStageInteractionsOptions = { onViewportInteractionStart?: () => void; onViewportInteractionEnd?: () => void; onCloseImageContextMenu: () => void; + getCanvasHistorySnapshot: () => CanvasHistorySnapshot; + captureCanvasHistory: ( + action: CanvasHistoryAction, + options?: { snapshot?: CanvasHistorySnapshot }, + ) => void; +}; + +type PendingDragHistory = { + snapshot: CanvasHistorySnapshot; + action: CanvasHistoryAction; + startClientX: number; + startClientY: number; + committed: boolean; }; function focusCanvasInteractionTarget(target: HTMLElement) { @@ -171,8 +186,11 @@ export function useImageCanvasStageInteractions({ onViewportInteractionStart, onViewportInteractionEnd, onCloseImageContextMenu, + getCanvasHistorySnapshot, + captureCanvasHistory, }: UseImageCanvasStageInteractionsOptions) { const dragStateRef = useRef(null); + const pendingDragHistoryRef = useRef(null); const pendingClickCollapseRef = useRef(null); const isShiftPressedRef = useRef(false); const suppressNextLayerClickRef = useRef(false); @@ -207,6 +225,7 @@ export function useImageCanvasStageInteractions({ flushMinimapViewportDrag(); } dragStateRef.current = null; + pendingDragHistoryRef.current = null; pendingClickCollapseRef.current = null; setCanvasMarquee(null); setIsPanning(false); @@ -362,6 +381,7 @@ export function useImageCanvasStageInteractions({ event.preventDefault(); event.stopPropagation(); const pointer = getPointerClient(event); + const dragHistorySnapshot = getCanvasHistorySnapshot(); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); const isMultiSelectGesture = event.shiftKey || isShiftPressedRef.current; const layerDragStart = createLayerDragStart({ @@ -380,6 +400,16 @@ export function useImageCanvasStageInteractions({ updateGenerateDialogForLayerPointerDown(currentDialog, layer.id), ); dragStateRef.current = layerDragStart.dragState; + pendingDragHistoryRef.current = { + snapshot: dragHistorySnapshot, + action: { + type: 'move-image', + count: layerDragStart.selectedLayerIds.length, + }, + startClientX: pointer.x, + startClientY: pointer.y, + committed: false, + }; pendingClickCollapseRef.current = { kind: 'layer', pointerId: getPointerId(event), @@ -392,6 +422,7 @@ export function useImageCanvasStageInteractions({ [ canvasViewportRef, canvasGenerationDialogs, + getCanvasHistorySnapshot, effectiveTool, generateDialog?.mode, isPickingCharacterSpecFromCanvas, @@ -494,6 +525,7 @@ export function useImageCanvasStageInteractions({ event.preventDefault(); event.stopPropagation(); const pointer = getPointerClient(event); + const dragHistorySnapshot = getCanvasHistorySnapshot(); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); const isMultiSelectGesture = event.shiftKey || isShiftPressedRef.current; if (!isMultiSelectGesture) { @@ -512,6 +544,16 @@ export function useImageCanvasStageInteractions({ setSelectedLayerId(frameDragStart.selectedLayerId); setSelectedLayerIds(frameDragStart.selectedLayerIds); dragStateRef.current = frameDragStart.dragState; + pendingDragHistoryRef.current = { + snapshot: dragHistorySnapshot, + action: { + type: 'move-generation-result', + count: frameDragStart.selectedLayerIds.length, + }, + startClientX: pointer.x, + startClientY: pointer.y, + committed: false, + }; pendingClickCollapseRef.current = { kind: 'generation-frame', pointerId: getPointerId(event), @@ -526,6 +568,7 @@ export function useImageCanvasStageInteractions({ canvasViewportRef, canvasGenerationDialogs, effectiveTool, + getCanvasHistorySnapshot, layers, selectedLayerIds, setSelectedLayerId, @@ -597,6 +640,22 @@ export function useImageCanvasStageInteractions({ return; } + const dragPointer = getPointerClient(event); + const pendingDragHistory = pendingDragHistoryRef.current; + if ( + pendingDragHistory && + !pendingDragHistory.committed && + Math.hypot( + dragPointer.x - pendingDragHistory.startClientX, + dragPointer.y - pendingDragHistory.startClientY, + ) >= CLICK_COLLAPSE_MOVEMENT_THRESHOLD_PX + ) { + captureCanvasHistory(pendingDragHistory.action, { + snapshot: pendingDragHistory.snapshot, + }); + pendingDragHistory.committed = true; + } + if (dragState.kind === 'pan') { const pointer = getPointerClient(event); setViewport(moveViewportFromPan(dragState, pointer)); @@ -669,6 +728,7 @@ export function useImageCanvasStageInteractions({ }, [ canvasMarquee, + captureCanvasHistory, canvasViewportRef, canvasGenerationDialogs, layers, @@ -729,6 +789,7 @@ export function useImageCanvasStageInteractions({ flushMinimapViewportDrag(); } dragStateRef.current = null; + pendingDragHistoryRef.current = null; setIsPanning(false); setSnapGuide(null); if (dragState.kind === 'pan' || dragState.kind === 'minimap') { diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx index efa115b08..30e2b669c 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx @@ -178,6 +178,7 @@ function UploadWorkflowHarness({ appendCanvasLayersWithResources: (nextLayers) => { setLayers((currentLayers) => [...currentLayers, ...nextLayers]); }, + captureCanvasHistory: vi.fn(), selectSingleLayer: setSelectedLayerId, }); diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.ts b/src/components/image-editor/useImageCanvasUploadWorkflow.ts index 0c005d430..d73d0fecc 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.ts +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.ts @@ -21,6 +21,7 @@ import { resolveUploadAssetFolder, } from './ImageCanvasAssetLibraryModel'; import type { + CanvasHistoryAction, CanvasLayer, CanvasMediaType, CanvasTool, @@ -97,6 +98,7 @@ type UseImageCanvasUploadWorkflowOptions = { references: CharacterReferenceImage[], ) => void; appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void; + captureCanvasHistory: (action: CanvasHistoryAction) => void; selectSingleLayer: (layerId: string | null) => void; }; @@ -134,6 +136,7 @@ export function useImageCanvasUploadWorkflow({ setQuickEditPanel, appendUiAssetExtractionReferences, appendCanvasLayersWithResources, + captureCanvasHistory, selectSingleLayer, }: UseImageCanvasUploadWorkflowOptions) { const uploadInputRef = useRef(null); @@ -1021,6 +1024,7 @@ export function useImageCanvasUploadWorkflow({ }); if (options.addToCanvas) { + captureCanvasHistory({ type: 'upload-image', count: 1 }); appendCanvasLayersWithResources([nextLayer]); selectSingleLayer(nextLayer.id); } @@ -1112,6 +1116,7 @@ export function useImageCanvasUploadWorkflow({ [ activeUploadFolderId, appendCanvasLayersWithResources, + captureCanvasHistory, assetFolders, canvasSize, openEditorLoginModal, diff --git a/src/components/image-editor/useImageCanvasViewportControls.ts b/src/components/image-editor/useImageCanvasViewportControls.ts index 9cbf6b30c..4e5d0ba1d 100644 --- a/src/components/image-editor/useImageCanvasViewportControls.ts +++ b/src/components/image-editor/useImageCanvasViewportControls.ts @@ -117,7 +117,10 @@ export function useImageCanvasViewportControls({ ); const fitLayers = useCallback( - (targetLayers: CanvasLayer[] = layers) => { + ( + targetLayers: CanvasLayer[] = layers, + options: { captureHistory?: boolean } = {}, + ) => { const nextViewport = fitViewportToLayers({ layers: targetLayers, canvasSize, @@ -126,7 +129,9 @@ export function useImageCanvasViewportControls({ return; } - captureCanvasHistory(); + if (options.captureHistory !== false) { + captureCanvasHistory(); + } setViewport(nextViewport); }, [captureCanvasHistory, canvasSize, layers], diff --git a/src/index.css b/src/index.css index 86b43d828..6894f4a08 100644 --- a/src/index.css +++ b/src/index.css @@ -5044,6 +5044,16 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { cursor: grab; } +.image-canvas-editor__undo-toast { + position: absolute; + top: 1rem; + left: 50%; + z-index: 73; + max-width: min(32rem, calc(100% - 2rem)); + transform: translateX(-50%); + pointer-events: none; +} + .image-canvas-editor__world { position: absolute; left: 0; -- 2.52.0 From f4bbf66fc60a18390fd4630d8b1774ceb41c9010 Mon Sep 17 00:00:00 2001 From: oj-afraid-student <1373241747@qq.com> Date: Fri, 17 Jul 2026 20:32:40 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E5=AE=89=E5=85=A8=E6=81=A2=E5=A4=8D=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加撤销与恢复双向历史栈并在新编辑后清空恢复记录 增加恢复按钮和 Ctrl+Shift+Z 快捷键 阻止可能导致图片、素材或生成结果消失的恢复操作 增加恢复成功及恢复阻止提示并在三秒后隐藏 补充历史模型、快捷键、组件和页面集成测试 更新图片画布撤销与恢复规则文档 --- ...片画布】撤销范围与操作提示方案-2026-07-17.md | 38 ++++++---- .../ImageCanvasEditorShellView.test.tsx | 4 +- .../image-editor/ImageCanvasEditorTypes.ts | 4 +- .../ImageCanvasEditorView.test.tsx | 49 ++++++++++++- .../image-editor/ImageCanvasEditorView.tsx | 37 +++++++--- .../ImageCanvasHistoryModel.test.ts | 30 ++++++++ .../image-editor/ImageCanvasHistoryModel.ts | 18 ++++- .../ImageCanvasPanelDockView.test.tsx | 6 +- .../image-editor/ImageCanvasPanelDockView.tsx | 12 ++++ .../image-editor/ImageCanvasShortcutModel.ts | 1 + .../ImageCanvasSidebarView.test.tsx | 2 + .../image-editor/ImageCanvasStageView.tsx | 20 ++++-- .../image-editor/useCanvasHistory.test.tsx | 72 ++++++++++++++++--- .../image-editor/useCanvasHistory.ts | 48 ++++++++++++- .../useImageCanvasKeyboardShortcuts.test.tsx | 13 +++- .../useImageCanvasKeyboardShortcuts.ts | 7 +- 16 files changed, 309 insertions(+), 52 deletions(-) diff --git a/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md b/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md index 29fd3f799..c65e7bfd9 100644 --- a/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md +++ b/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md @@ -1,26 +1,31 @@ -# 图片画布撤销范围与操作提示方案 +# 图片画布撤销、恢复范围与操作提示方案 更新时间:`2026-07-17` ## 产品规则 -- 用户通过画布“撤销”按钮或 `Ctrl+Z` 触发撤销。 -- 撤销成功后,在画布工作区顶部居中显示 `已撤销:XX操作`,3 秒后自动消失。 -- 如果目标快照会删除当前图层、把当前可见图层重新隐藏,或者用旧素材替换当前素材,则不得恢复快照,并显示 `无法撤销:XX操作,可能会使图片消失`,3 秒后自动消失。 -- 被阻止的历史记录不得出栈,也不得自动跳过到更早记录;画布状态不变,因此不会触发项目自动保存。 -- 图片画布不提供重做按钮、重做快捷键或重做历史栈。 +- 用户通过画布“撤销”按钮或 `Ctrl+Z` 触发撤销,通过“恢复”按钮或 `Ctrl+Shift+Z` 恢复最近一次成功撤销的操作。 +- 撤销成功显示 `已撤销:XX操作`,恢复成功显示 `已恢复:XX操作`;提示位于画布工作区顶部居中,3 秒后自动消失。 +- 如果目标快照会删除当前图层或生成结果、把当前可见图层重新隐藏,或者用其它素材替换当前素材,则不得恢复快照。 +- 撤销被阻止时显示 `无法撤销:XX操作,可能会使图片消失`;恢复被阻止时显示 `无法恢复:XX操作,可能会使图片消失`。 +- 被阻止的记录不得出栈,也不得跳过到更早记录;画布状态不变,因此不会触发项目自动保存。 +- 成功撤销会生成一条恢复记录;成功恢复后仍可再次撤销。撤销后发生新的画布编辑时,清空恢复历史。 ## 操作边界 -允许撤销的典型操作包括移动图片、移动生成结果、调整层级、组合与取消组合、删除或剪切图片、隐藏图片、锁定与解锁、翻转、修改素材类型和调整工具栏视口。删除与剪切允许撤销,是因为恢复目标只会让内容重新出现。 +允许撤销的典型操作包括移动图片、移动生成结果、调整层级、组合与取消组合、删除或剪切图片、隐藏图片、锁定与解锁、翻转、修改素材类型和调整画布视图。删除、剪切和隐藏允许撤销,是因为目标只会让内容重新出现。 -添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的操作必须被撤销安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布撤销历史。 +添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的撤销必须被安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布历史。 + +恢复同样执行动态安全检查。移动、层级、分组、锁定、翻转和视图等不会减少内容的操作可以恢复;重新执行删除、剪切、隐藏、删除生成结果或替换当前素材时必须被阻止。 ## 技术实现 -- 每条历史记录保存操作类型、操作前 `CanvasHistorySnapshot` 和创建时间,最多保留 60 条。 -- 操作类型只负责生成用户提示;能否恢复由当前快照与目标快照的差异检查决定。 -- 图层安全检查以稳定的 `layer.id` 判断当前图层是否仍存在,以媒体地址、对象存储标识和序列帧等字段组成内容签名,识别同 ID 图层的素材替换;仅刷新内部资源 ID 不视为图片消失。 +- 撤销栈和恢复栈均保存操作类型、目标 `CanvasHistorySnapshot` 和创建时间,分别最多保留 60 条。 +- 新画布操作把操作前快照写入撤销栈并清空恢复栈;成功撤销把当前快照写入恢复栈,成功恢复把当前快照写回撤销栈。 +- 操作类型只负责生成用户提示;能否应用快照由当前快照与目标快照的差异检查决定。 +- 安全检查以稳定的 `layer.id` 判断当前图层是否仍存在,以媒体地址、对象存储标识和序列帧等字段组成内容签名,识别同 ID 图层的素材替换;仅刷新内部资源 ID 不视为图片消失。 +- 当前生成对话框和非活动生成结果按稳定 ID 纳入内容存在性检查,避免恢复操作删除当前生成结果。 - 位置、尺寸、层级、分组、锁定、翻转、标题和素材类型不进入内容签名,避免误拦截普通编辑。 - 鼠标拖动在按下时暂存操作前快照,位移超过点击阈值后只提交一条历史;单击不产生历史记录。 - 顶部消息复用 `PlatformRuntimeStatusToast`,成功使用中性色,被阻止使用警告色;连续触发会替换消息并重新开始 3 秒计时。 @@ -29,7 +34,10 @@ 1. 添加、上传、粘贴、复制、生成和替换图片后撤销,当前图片不消失且出现准确警告。 2. 删除、剪切、隐藏、移动、分组和层级调整可以正常撤销,并出现成功提示。 -3. 被阻止后历史记录仍位于栈顶,连续撤销不会越过保护边界。 -4. 一次鼠标拖动只产生一条历史,单击不产生历史。 -5. 页面不存在重做按钮和可用的 `Ctrl+Shift+Z` 重做入口。 -6. 提示在 3 秒后消失,连续提示按最后一次触发重新计时。 +3. 移动、层级、分组、锁定、翻转和视图等安全操作可以恢复,并可再次撤销。 +4. 删除、剪切、隐藏、删除生成结果和替换素材的恢复会被阻止,当前内容保持不变。 +5. 被阻止的记录仍位于栈顶,连续撤销或恢复不会越过保护边界。 +6. 撤销后发生新编辑会清空恢复栈;恢复按钮随栈状态正确启用或禁用。 +7. 一次鼠标拖动只产生一条历史,单击不产生历史。 +8. `Ctrl+Z` 与 `Ctrl+Shift+Z` 分别触发撤销和恢复,在可编辑输入区域内不拦截原生行为。 +9. 提示在 3 秒后消失,连续提示按最后一次触发重新计时。 diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 0d72923a2..23f387937 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -162,7 +162,8 @@ function createStageProps(): ImageCanvasStageViewProps { contextShouldShowLayer: false, contextShouldUnlockLayer: false, canUndo: false, - undoFeedback: null, + canRedo: false, + historyFeedback: null, isZoomMenuOpen: false, isBackgroundSettingsOpen: false, activeSidebarPanel: null, @@ -227,6 +228,7 @@ function createStageProps(): ImageCanvasStageViewProps { onUpdateScaleFromCenter: vi.fn(), onFitLayers: vi.fn(), onUndoCanvasChange: vi.fn(), + onRedoCanvasChange: vi.fn(), onToggleZoomMenu: vi.fn(), onCloseZoomMenu: vi.fn(), onToggleBackgroundSettings: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index c46349ffd..f6d0e73f2 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -311,7 +311,7 @@ export type CanvasHistoryEntry = { createdAt: number; }; -export type CanvasUndoResult = +export type CanvasHistoryApplyResult = | { status: 'success'; action: CanvasHistoryAction; @@ -325,7 +325,7 @@ export type CanvasUndoResult = status: 'empty'; }; -export type CanvasUndoFeedback = { +export type CanvasHistoryFeedback = { id: number; tone: 'neutral' | 'warning'; text: string; diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 65b8bf8c4..06a1b513b 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -2631,7 +2631,10 @@ describe('ImageCanvasEditorView', () => { 'disabled', true, ); - expect(screen.queryByRole('button', { name: '重做' })).toBeNull(); + expect(screen.getByRole('button', { name: '恢复' })).toHaveProperty( + 'disabled', + true, + ); openAssetSidebar(); await act(async () => { @@ -2657,9 +2660,18 @@ describe('ImageCanvasEditorView', () => { fireEvent.click(screen.getByRole('button', { name: '撤销' })); expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); expect(screen.getByText('已撤销:删除图片操作')).toBeTruthy(); + expect(screen.getByRole('button', { name: '恢复' })).toHaveProperty( + 'disabled', + false, + ); + fireEvent.click(screen.getByRole('button', { name: '恢复' })); + expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); + expect( + screen.getByText('无法恢复:删除图片操作,可能会使图片消失'), + ).toBeTruthy(); }); - it('blocks protected keyboard undo and ignores the removed redo shortcut', async () => { + it('blocks protected keyboard undo without creating a restore entry', async () => { render(); openAssetSidebar(); @@ -2673,6 +2685,10 @@ describe('ImageCanvasEditorView', () => { expect( screen.getByText('无法撤销:添加图片操作,可能会使图片消失'), ).toBeTruthy(); + expect(screen.getByRole('button', { name: '恢复' })).toHaveProperty( + 'disabled', + true, + ); await act(async () => { fireEvent.keyDown(window, { @@ -2684,4 +2700,33 @@ describe('ImageCanvasEditorView', () => { }); expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); }); + + it('restores a safely undone move from the panel and keyboard shortcut', async () => { + render(); + + 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(); + }); }); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index d7f51fb34..6f98e541a 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -45,9 +45,9 @@ import type { CanvasContextMenuState, CanvasGenerationDialogState, CanvasHistoryAction, + CanvasHistoryFeedback, CanvasLayer, CanvasTool, - CanvasUndoFeedback, CanvasViewport, CharacterReferenceImage, EditorAsset, @@ -768,32 +768,33 @@ export function ImageCanvasEditorView({ ); const { canUndo, + canRedo, getCanvasHistorySnapshot, captureCanvasHistory, undoCanvasChange, + redoCanvasChange, } = useCanvasHistory({ refs: canvasHistoryRefs, setters: canvasHistorySetters, resetters: canvasHistoryResetters, }); captureCanvasHistoryRef.current = captureCanvasHistory; - const [undoFeedback, setUndoFeedback] = useState( - null, - ); + const [historyFeedback, setHistoryFeedback] = + useState(null); useEffect(() => { - if (!undoFeedback) { + if (!historyFeedback) { return; } - const timer = window.setTimeout(() => setUndoFeedback(null), 3000); + const timer = window.setTimeout(() => setHistoryFeedback(null), 3000); return () => window.clearTimeout(timer); - }, [undoFeedback]); + }, [historyFeedback]); const handleUndoCanvasChange = useCallback(() => { const result = undoCanvasChange(); if (result.status === 'empty') { return; } const actionLabel = formatCanvasHistoryAction(result.action); - setUndoFeedback((currentFeedback) => ({ + setHistoryFeedback((currentFeedback) => ({ id: (currentFeedback?.id ?? 0) + 1, tone: result.status === 'blocked' ? 'warning' : 'neutral', text: @@ -802,6 +803,21 @@ export function ImageCanvasEditorView({ : `已撤销:${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); @@ -1882,6 +1898,7 @@ export function ImageCanvasEditorView({ selectedLayerIdRef, selectedLayerIdsRef, undoCanvasChange: handleUndoCanvasChange, + redoCanvasChange: handleRedoCanvasChange, deleteLayerById: deleteLayerByIdFromShortcut, deleteSelectedCanvasObjects: deleteSelectedLayer, selectAllCanvasObjects, @@ -2167,7 +2184,8 @@ export function ImageCanvasEditorView({ contextShouldShowLayer, contextShouldUnlockLayer, canUndo, - undoFeedback, + canRedo, + historyFeedback, isZoomMenuOpen, isBackgroundSettingsOpen, activeSidebarPanel, @@ -2243,6 +2261,7 @@ export function ImageCanvasEditorView({ onUpdateScaleFromCenter: updateScaleFromCenter, onFitLayers: fitLayers, onUndoCanvasChange: handleUndoCanvasChange, + onRedoCanvasChange: handleRedoCanvasChange, onToggleZoomMenu: toggleZoomMenu, onCloseZoomMenu: closeZoomMenu, onToggleBackgroundSettings: toggleBackgroundSettings, diff --git a/src/components/image-editor/ImageCanvasHistoryModel.test.ts b/src/components/image-editor/ImageCanvasHistoryModel.test.ts index b842f2e3e..c8c163265 100644 --- a/src/components/image-editor/ImageCanvasHistoryModel.test.ts +++ b/src/components/image-editor/ImageCanvasHistoryModel.test.ts @@ -85,6 +85,36 @@ describe('ImageCanvasHistoryModel', () => { ).toBe(false); }); + it('blocks removing a current generation result from the target snapshot', () => { + const current = createSnapshot([]); + current.inactiveGenerateDialogs = [ + { + id: 'generation-1', + mode: 'generate', + prompt: '生成结果', + status: 'idle', + }, + ]; + + expect( + canRestoreCanvasHistorySnapshotWithoutContentLoss({ + current, + target: createSnapshot([]), + }), + ).toBe(false); + + const target = createSnapshot([]); + target.generateDialog = { + id: 'generation-1', + mode: 'generate', + prompt: '生成结果', + status: 'idle', + }; + expect( + canRestoreCanvasHistorySnapshotWithoutContentLoss({ current, target }), + ).toBe(true); + }); + 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 7589086a3..9233c4d84 100644 --- a/src/components/image-editor/ImageCanvasHistoryModel.ts +++ b/src/components/image-editor/ImageCanvasHistoryModel.ts @@ -82,7 +82,7 @@ export function canRestoreCanvasHistorySnapshotWithoutContentLoss({ target.layers.map((layer) => [layer.id, layer] as const), ); - return current.layers.every((currentLayer) => { + const preservesLayers = current.layers.every((currentLayer) => { const targetLayer = targetLayerById.get(currentLayer.id); if (!targetLayer) { return false; @@ -95,4 +95,20 @@ export function canRestoreCanvasHistorySnapshotWithoutContentLoss({ getLayerContentSignature(targetLayer) ); }); + if (!preservesLayers) { + return false; + } + + const getDialogIds = (snapshot: CanvasHistorySnapshot): Set => + 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), + ); } diff --git a/src/components/image-editor/ImageCanvasPanelDockView.test.tsx b/src/components/image-editor/ImageCanvasPanelDockView.test.tsx index a03c6da0f..65d36a7ec 100644 --- a/src/components/image-editor/ImageCanvasPanelDockView.test.tsx +++ b/src/components/image-editor/ImageCanvasPanelDockView.test.tsx @@ -14,6 +14,7 @@ function renderPanelDock( canvasBackgroundColor: '#f8fafc', canvasBackgroundHexValue: '#f8fafc', canUndo: true, + canRedo: true, isZoomMenuOpen: false, isBackgroundSettingsOpen: false, activeSidebarPanel: null, @@ -23,6 +24,7 @@ function renderPanelDock( minimapModel: null, onFitLayers: vi.fn(), onUndoCanvasChange: vi.fn(), + onRedoCanvasChange: vi.fn(), onUpdateScaleFromCenter: vi.fn(), onToggleZoomMenu: vi.fn(), onCloseZoomMenu: vi.fn(), @@ -81,11 +83,12 @@ describe('ImageCanvasPanelDockView', () => { .getByRole('button', { name: '打开素材' }) .getAttribute('aria-pressed'), ).toBe('true'); - expect(within(toolbar).queryByRole('button', { name: '重做' })).toBeNull(); + 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' }), @@ -96,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); diff --git a/src/components/image-editor/ImageCanvasPanelDockView.tsx b/src/components/image-editor/ImageCanvasPanelDockView.tsx index 6282913ff..c53f0b755 100644 --- a/src/components/image-editor/ImageCanvasPanelDockView.tsx +++ b/src/components/image-editor/ImageCanvasPanelDockView.tsx @@ -3,6 +3,7 @@ import { Layers, Map as MapIcon, MessageCircle, + Redo2, RotateCcw, Undo2, X, @@ -37,6 +38,7 @@ type ImageCanvasPanelDockViewProps = { canvasBackgroundColor: string; canvasBackgroundHexValue: string; canUndo: boolean; + canRedo: boolean; isZoomMenuOpen: boolean; isBackgroundSettingsOpen: boolean; activeSidebarPanel: SidebarPanel | null; @@ -46,6 +48,7 @@ type ImageCanvasPanelDockViewProps = { minimapModel: StageMinimapModel | null; onFitLayers: () => void; onUndoCanvasChange: () => void; + onRedoCanvasChange: () => void; onUpdateScaleFromCenter: (nextScale: number) => void; onToggleZoomMenu: () => void; onCloseZoomMenu: () => void; @@ -202,6 +205,7 @@ export function ImageCanvasPanelDockView({ canvasBackgroundColor, canvasBackgroundHexValue, canUndo, + canRedo, isZoomMenuOpen, isBackgroundSettingsOpen, activeSidebarPanel, @@ -211,6 +215,7 @@ export function ImageCanvasPanelDockView({ minimapModel, onFitLayers, onUndoCanvasChange, + onRedoCanvasChange, onUpdateScaleFromCenter, onToggleZoomMenu, onCloseZoomMenu, @@ -332,6 +337,13 @@ export function ImageCanvasPanelDockView({ disabled={!canUndo} onClick={onUndoCanvasChange} /> +
void; onFitLayers: () => void; onUndoCanvasChange: () => void; + onRedoCanvasChange: () => void; onToggleZoomMenu: () => void; onCloseZoomMenu: () => void; onToggleBackgroundSettings: () => void; @@ -235,7 +237,8 @@ export function ImageCanvasStageView({ contextShouldShowLayer, contextShouldUnlockLayer, canUndo, - undoFeedback, + canRedo, + historyFeedback, isZoomMenuOpen, isBackgroundSettingsOpen, activeSidebarPanel, @@ -304,6 +307,7 @@ export function ImageCanvasStageView({ onUpdateScaleFromCenter, onFitLayers, onUndoCanvasChange, + onRedoCanvasChange, onToggleZoomMenu, onCloseZoomMenu, onToggleBackgroundSettings, @@ -335,13 +339,13 @@ export function ImageCanvasStageView({ onDrop={isInteractionPaused ? undefined : onCanvasDrop} onContextMenu={isInteractionPaused ? undefined : onCanvasContextMenu} > - {undoFeedback ? ( + {historyFeedback ? ( - {undoFeedback.text} + {historyFeedback.text} ) : null} {uploadDropTarget === 'canvas' ? ( @@ -457,6 +461,7 @@ export function ImageCanvasStageView({ canvasBackgroundColor={canvasBackgroundColor} canvasBackgroundHexValue={canvasBackgroundHexValue} canUndo={canUndo} + canRedo={canRedo} isZoomMenuOpen={isZoomMenuOpen} isBackgroundSettingsOpen={isBackgroundSettingsOpen} activeSidebarPanel={activeSidebarPanel} @@ -466,6 +471,7 @@ export function ImageCanvasStageView({ minimapModel={minimapModel} onFitLayers={onFitLayers} onUndoCanvasChange={onUndoCanvasChange} + onRedoCanvasChange={onRedoCanvasChange} onUpdateScaleFromCenter={onUpdateScaleFromCenter} onToggleZoomMenu={onToggleZoomMenu} onCloseZoomMenu={onCloseZoomMenu} diff --git a/src/components/image-editor/useCanvasHistory.test.tsx b/src/components/image-editor/useCanvasHistory.test.tsx index 310795062..6884efa2d 100644 --- a/src/components/image-editor/useCanvasHistory.test.tsx +++ b/src/components/image-editor/useCanvasHistory.test.tsx @@ -73,6 +73,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) { ]); const [selectedLayerId, setSelectedLayerId] = useState('first'); const [selectedLayerIds, setSelectedLayerIds] = useState(['first']); + const [lastHistoryStatus, setLastHistoryStatus] = useState('empty'); const layersRef = useRef(layers); const viewportRef = useRef(viewport); @@ -126,6 +127,8 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) { {selectedLayerIds.join(',')} {String(history.canUndo)} + {String(history.canRedo)} + {lastHistoryStatus} + +
); } describe('useCanvasHistory', () => { - it('captures and restores canvas history snapshots without redo', () => { + it('captures, undoes, and redoes safe canvas history snapshots', () => { const clearDragState = vi.fn(); render(); @@ -189,6 +196,53 @@ describe('useCanvasHistory', () => { expect(screen.getByTestId('dialog').textContent).toBe('active prompt'); expect(screen.getByTestId('inactive').textContent).toBe('archived prompt'); expect(screen.getByTestId('selection').textContent).toBe('first'); + expect(screen.getByTestId('can-redo').textContent).toBe('true'); expect(clearDragState).toHaveBeenCalledTimes(1); + + act(() => { + screen.getByRole('button', { name: 'redo' }).click(); + }); + expect(screen.getByTestId('layers').textContent).toBe('first:90'); + expect(screen.getByTestId('viewport').textContent).toBe('9,8,2'); + 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( {}} />); + + 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( {}} />); + + 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'); }); }); diff --git a/src/components/image-editor/useCanvasHistory.ts b/src/components/image-editor/useCanvasHistory.ts index eb533b80c..bf5a5cf23 100644 --- a/src/components/image-editor/useCanvasHistory.ts +++ b/src/components/image-editor/useCanvasHistory.ts @@ -4,10 +4,10 @@ import { MAX_HISTORY_STEPS } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasHistoryAction, + CanvasHistoryApplyResult, CanvasHistoryEntry, CanvasHistorySnapshot, CanvasLayer, - CanvasUndoResult, CanvasViewport, GenerateDialogState, } from './ImageCanvasEditorTypes'; @@ -68,6 +68,7 @@ export function useCanvasHistory({ resetters: CanvasHistoryResetters; }) { const undoStackRef = useRef([]); + const redoStackRef = useRef([]); const [historyVersion, setHistoryVersion] = useState(0); const getCanvasHistorySnapshot = useCallback( @@ -119,12 +120,13 @@ export function useCanvasHistory({ createdAt: Date.now(), }, ]; + redoStackRef.current = []; setHistoryVersion((version) => version + 1); }, [getCanvasHistorySnapshot], ); - const undoCanvasChange = useCallback((): CanvasUndoResult => { + const undoCanvasChange = useCallback((): CanvasHistoryApplyResult => { const previousEntry = undoStackRef.current.at(-1); if (!previousEntry) { return { status: 'empty' }; @@ -144,17 +146,59 @@ export function useCanvasHistory({ }; } undoStackRef.current = undoStackRef.current.slice(0, -1); + redoStackRef.current = [ + ...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), + { + snapshot: currentSnapshot, + action: previousEntry.action, + createdAt: Date.now(), + }, + ]; restoreCanvasHistorySnapshot(previousEntry.snapshot); setHistoryVersion((version) => version + 1); return { status: 'success', action: previousEntry.action }; }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); + 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)), + { + snapshot: currentSnapshot, + action: nextEntry.action, + createdAt: Date.now(), + }, + ]; + restoreCanvasHistorySnapshot(nextEntry.snapshot); + setHistoryVersion((version) => version + 1); + return { status: 'success', action: nextEntry.action }; + }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); + return { canUndo: undoStackRef.current.length > 0, + canRedo: redoStackRef.current.length > 0, historyVersion, getCanvasHistorySnapshot, restoreCanvasHistorySnapshot, captureCanvasHistory, undoCanvasChange, + redoCanvasChange, }; } diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx index 13a5d6e66..8d14c05b6 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx @@ -65,6 +65,7 @@ function KeyboardShortcutsHarness({ initialTool = 'select', isInteractionPaused = false, undoCanvasChange = vi.fn(), + redoCanvasChange = vi.fn(), deleteLayerById = vi.fn(), deleteSelectedCanvasObjects = vi.fn(), selectAllCanvasObjects = vi.fn(), @@ -94,6 +95,7 @@ function KeyboardShortcutsHarness({ initialTool?: CanvasTool; isInteractionPaused?: boolean; undoCanvasChange?: () => void; + redoCanvasChange?: () => void; deleteLayerById?: (layerId: string | null) => void; deleteSelectedCanvasObjects?: () => void; selectAllCanvasObjects?: () => void; @@ -165,6 +167,7 @@ function KeyboardShortcutsHarness({ selectedLayerIdRef, selectedLayerIdsRef, undoCanvasChange, + redoCanvasChange, deleteLayerById, deleteSelectedCanvasObjects, selectAllCanvasObjects, @@ -259,16 +262,21 @@ function KeyboardShortcutsHarness({ } describe('useImageCanvasKeyboardShortcuts', () => { - it('routes undo while ignoring redo and editable inputs', () => { + it('routes undo and redo while ignoring editable inputs', () => { const undoCanvasChange = vi.fn(); + const redoCanvasChange = vi.fn(); render( - , + , ); act(() => { fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true }); }); expect(undoCanvasChange).toHaveBeenCalledTimes(1); + expect(redoCanvasChange).toHaveBeenCalledTimes(0); act(() => { fireEvent.keyDown(window, { @@ -279,6 +287,7 @@ describe('useImageCanvasKeyboardShortcuts', () => { }); }); expect(undoCanvasChange).toHaveBeenCalledTimes(1); + expect(redoCanvasChange).toHaveBeenCalledTimes(1); act(() => { fireEvent.keyDown(screen.getByLabelText('快捷键输入框'), { diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts index 8c1068bf1..2cc6b1305 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts @@ -14,6 +14,7 @@ type UseImageCanvasKeyboardShortcutsOptions = { selectedLayerIdRef: RefObject; selectedLayerIdsRef?: RefObject; undoCanvasChange: () => void; + redoCanvasChange: () => void; deleteLayerById: (layerId: string | null) => void; deleteSelectedCanvasObjects?: () => void; selectAllCanvasObjects?: () => void; @@ -161,6 +162,7 @@ export function useImageCanvasKeyboardShortcuts({ selectedLayerIdRef, selectedLayerIdsRef, undoCanvasChange, + redoCanvasChange, deleteLayerById, deleteSelectedCanvasObjects, selectAllCanvasObjects, @@ -270,7 +272,9 @@ export function useImageCanvasKeyboardShortcuts({ !isEditableTarget(event) ) { event.preventDefault(); - if (!event.shiftKey) { + if (event.shiftKey) { + redoCanvasChange(); + } else { undoCanvasChange(); } return; @@ -517,6 +521,7 @@ export function useImageCanvasKeyboardShortcuts({ moveSelectedCanvasLayers, nudgeSelectedCanvasObjects, requestRemoveCanvasGenerationDialog, + redoCanvasChange, selectAllCanvasObjects, selectedLayerIdRef, selectedLayerIdsRef, -- 2.52.0 From b66832396be14da460055f6699ac0ae846fdb874 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 18 Jul 2026 17:43:57 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E6=92=A4=E9=94=80=E6=81=A2=E5=A4=8D=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留图层资源关联和生成对象当前任务与尺寸,仅恢复安全布局状态 按目标槽位恢复生成面板开关并按稳定媒体标识判断内容变化 补齐本地生成结果历史并避免自动视图覆盖栈顶 阻止阈值内指针抖动产生不可撤销位移 补充异步回填、2K尺寸和跨槽位恢复测试并同步文档与踩坑记录 --- docs/project-memory/shared-memory/pitfalls.md | 8 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 3 +- ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 10 +- ...片画布】撤销范围与操作提示方案-2026-07-17.md | 18 +- .../ImageCanvasHistoryModel.test.ts | 450 ++++++++++++++++++ .../image-editor/ImageCanvasHistoryModel.ts | 175 ++++++- .../image-editor/useCanvasHistory.test.tsx | 156 +++++- .../image-editor/useCanvasHistory.ts | 30 +- ...anvasGenerationSubmissionWorkflow.test.tsx | 73 ++- ...ImageCanvasGenerationSubmissionWorkflow.ts | 27 +- .../useImageCanvasGenerationWorkflow.ts | 1 + .../useImageCanvasStageInteractions.test.tsx | 33 ++ .../useImageCanvasStageInteractions.ts | 12 +- 13 files changed, 942 insertions(+), 54 deletions(-) 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 + + + + + + + +