补充图片画布安全恢复功能
增加撤销与恢复双向历史栈并在新编辑后清空恢复记录 增加恢复按钮和 Ctrl+Shift+Z 快捷键 阻止可能导致图片、素材或生成结果消失的恢复操作 增加恢复成功及恢复阻止提示并在三秒后隐藏 补充历史模型、快捷键、组件和页面集成测试 更新图片画布撤销与恢复规则文档
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user