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

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

增加恢复按钮和 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
@@ -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 秒后消失,连续提示按最后一次触发重新计时。
@@ -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,