补充图片画布安全恢复功能

增加撤销与恢复双向历史栈并在新编辑后清空恢复记录

增加恢复按钮和 Ctrl+Shift+Z 快捷键

阻止可能导致图片、素材或生成结果消失的恢复操作

增加恢复成功及恢复阻止提示并在三秒后隐藏

补充历史模型、快捷键、组件和页面集成测试

更新图片画布撤销与恢复规则文档
This commit is contained in:
2026-07-17 20:32:40 +08:00
committed by kdletters
parent c3cefb8b96
commit f4bbf66fc6
16 changed files with 309 additions and 52 deletions
@@ -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(),
@@ -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;
@@ -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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
openAssetSidebar();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
});
fireEvent.keyDown(window, { key: 'ArrowRight', code: 'ArrowRight' });
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
expect(screen.getByText('已撤销:移动图片操作')).toBeTruthy();
expect(screen.getByRole('button', { name: '恢复' })).toHaveProperty(
'disabled',
false,
);
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
expect(screen.getByText('已恢复:移动图片操作')).toBeTruthy();
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
fireEvent.keyDown(window, {
key: 'Z',
code: 'KeyZ',
ctrlKey: true,
shiftKey: true,
});
expect(screen.getByText('已恢复:移动图片操作')).toBeTruthy();
});
});
@@ -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<CanvasUndoFeedback | null>(
null,
);
const [historyFeedback, setHistoryFeedback] =
useState<CanvasHistoryFeedback | null>(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,
@@ -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(
'替换图片',
@@ -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<string> =>
new Set(
[
snapshot.generateDialog?.id,
...snapshot.inactiveGenerateDialogs.map((dialog) => dialog.id),
].filter((dialogId): dialogId is string => Boolean(dialogId)),
);
const targetDialogIds = getDialogIds(target);
return [...getDialogIds(current)].every((dialogId) =>
targetDialogIds.has(dialogId),
);
}
@@ -14,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);
@@ -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}
/>
<EditorIconButton
label="恢复"
title="恢复上一次撤销"
icon={Redo2}
disabled={!canRedo}
onClick={onRedoCanvasChange}
/>
<div className="image-canvas-editor__zoom-menu-wrap">
<PlatformInlineOptionButton
className="image-canvas-editor__zoom-trigger"
@@ -16,6 +16,7 @@ export const IMAGE_CANVAS_SHORTCUT_SECTIONS: ImageCanvasShortcutSection[] = [
title: '编辑',
items: [
{ action: '撤销', keys: ['Ctrl', 'Z'], status: 'ready' },
{ action: '恢复', keys: ['Ctrl', 'Shift', 'Z'], status: 'ready' },
{ action: '全选画布元素', keys: ['Ctrl', 'A'], status: 'new' },
{ action: '复制选中图层', keys: ['Ctrl', 'C'], status: 'new' },
{ action: '粘贴图层', keys: ['Ctrl', 'V'], status: 'new' },
@@ -153,6 +153,7 @@ function SidebarTabsHarness() {
canvasBackgroundColor={chrome.canvasBackgroundColor}
canvasBackgroundHexValue={chrome.canvasBackgroundHexValue}
canUndo={false}
canRedo={false}
isZoomMenuOpen={false}
isBackgroundSettingsOpen={false}
activeSidebarPanel={chrome.activeSidebarPanel}
@@ -162,6 +163,7 @@ function SidebarTabsHarness() {
minimapModel={null}
onFitLayers={vi.fn()}
onUndoCanvasChange={vi.fn()}
onRedoCanvasChange={vi.fn()}
onUpdateScaleFromCenter={vi.fn()}
onToggleZoomMenu={vi.fn()}
onCloseZoomMenu={vi.fn()}
@@ -18,10 +18,10 @@ import type {
CanvasClipboard,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasHistoryFeedback,
CanvasLayer,
CanvasMarqueeState,
CanvasTool,
CanvasUndoFeedback,
CanvasViewport,
CropExpandPanelState,
CropExpandResizeHandle,
@@ -84,7 +84,8 @@ export type ImageCanvasStageViewProps = {
contextShouldShowLayer: boolean;
contextShouldUnlockLayer: boolean;
canUndo: boolean;
undoFeedback: CanvasUndoFeedback | null;
canRedo: boolean;
historyFeedback: CanvasHistoryFeedback | null;
isZoomMenuOpen: boolean;
isBackgroundSettingsOpen: boolean;
activeSidebarPanel: SidebarPanel | null;
@@ -176,6 +177,7 @@ export type ImageCanvasStageViewProps = {
onUpdateScaleFromCenter: (nextScale: number) => 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 ? (
<PlatformRuntimeStatusToast
key={undoFeedback.id}
tone={undoFeedback.tone}
key={historyFeedback.id}
tone={historyFeedback.tone}
className="image-canvas-editor__undo-toast"
>
{undoFeedback.text}
{historyFeedback.text}
</PlatformRuntimeStatusToast>
) : 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}
@@ -73,6 +73,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
]);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>('first');
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>(['first']);
const [lastHistoryStatus, setLastHistoryStatus] = useState('empty');
const layersRef = useRef(layers);
const viewportRef = useRef(viewport);
@@ -126,6 +127,8 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
</span>
<span data-testid="selection">{selectedLayerIds.join(',')}</span>
<span data-testid="can-undo">{String(history.canUndo)}</span>
<span data-testid="can-redo">{String(history.canRedo)}</span>
<span data-testid="history-status">{lastHistoryStatus}</span>
<button
type="button"
onClick={() => {
@@ -139,33 +142,37 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
onClick={() => {
setLayers([createLayer('first', 90)]);
setViewport({ x: 9, y: 8, scale: 2 });
setGenerateDialog({
id: 'dialog-next',
mode: 'spec',
prompt: 'next prompt',
status: 'idle',
});
setInactiveGenerateDialogs([]);
setSelectedLayerId('first');
setSelectedLayerIds(['first']);
}}
>
mutate
</button>
<button type="button" onClick={() => setLayers([])}>
delete
</button>
<button
type="button"
onClick={() => {
history.undoCanvasChange();
setLastHistoryStatus(history.undoCanvasChange().status);
}}
>
undo
</button>
<button
type="button"
onClick={() => {
setLastHistoryStatus(history.redoCanvasChange().status);
}}
>
redo
</button>
</div>
);
}
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(<HistoryHarness onClearDrag={clearDragState} />);
@@ -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(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
screen.getByRole('button', { name: 'delete' }).click();
});
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe('first:10');
act(() => {
screen.getByRole('button', { name: 'redo' }).click();
});
expect(screen.getByTestId('history-status').textContent).toBe('blocked');
expect(screen.getByTestId('layers').textContent).toBe('first:10');
expect(screen.getByTestId('can-redo').textContent).toBe('true');
});
it('clears redo history after a new canvas edit is captured', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
screen.getByRole('button', { name: 'mutate' }).click();
});
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('can-redo').textContent).toBe('true');
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
});
expect(screen.getByTestId('can-redo').textContent).toBe('false');
});
});
@@ -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<CanvasHistoryEntry[]>([]);
const redoStackRef = useRef<CanvasHistoryEntry[]>([]);
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,
};
}
@@ -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(
<KeyboardShortcutsHarness undoCanvasChange={undoCanvasChange} />,
<KeyboardShortcutsHarness
undoCanvasChange={undoCanvasChange}
redoCanvasChange={redoCanvasChange}
/>,
);
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('快捷键输入框'), {
@@ -14,6 +14,7 @@ type UseImageCanvasKeyboardShortcutsOptions = {
selectedLayerIdRef: RefObject<string | null>;
selectedLayerIdsRef?: RefObject<string[]>;
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,