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;