拆分图片画布历史与持久化协调器
新增画布历史 hook 承接撤销重做快照逻辑 新增项目持久化 hook 承接加载资源创建与自动保存时序 补充 hook 单测并更新图片画布拆分跟踪文档
This commit is contained in:
218
src/components/image-editor/useCanvasHistory.test.tsx
Normal file
218
src/components/image-editor/useCanvasHistory.test.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
GenerateDialogState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { useCanvasHistory } from './useCanvasHistory';
|
||||
|
||||
function createLayer(id: string, x: number): CanvasLayer {
|
||||
return {
|
||||
id,
|
||||
resourceId: `resource-${id}`,
|
||||
title: id,
|
||||
src: `data:image/png;base64,${id}`,
|
||||
x,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 80,
|
||||
originalWidth: 100,
|
||||
originalHeight: 80,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
};
|
||||
}
|
||||
|
||||
function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
|
||||
const [layers, setLayers] = useState<CanvasLayer[]>([
|
||||
createLayer('first', 10),
|
||||
]);
|
||||
const [viewport, setViewport] = useState<CanvasViewport>({
|
||||
x: 1,
|
||||
y: 2,
|
||||
scale: 1,
|
||||
});
|
||||
const [generateDialog, setGenerateDialog] =
|
||||
useState<GenerateDialogState | null>({
|
||||
id: 'dialog-active',
|
||||
mode: 'generate',
|
||||
prompt: 'active prompt',
|
||||
status: 'idle',
|
||||
placeholder: {
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
},
|
||||
});
|
||||
const [inactiveGenerateDialogs, setInactiveGenerateDialogs] = useState<
|
||||
CanvasGenerationDialogState[]
|
||||
>([
|
||||
{
|
||||
id: 'dialog-inactive',
|
||||
mode: 'character',
|
||||
prompt: 'archived prompt',
|
||||
status: 'idle',
|
||||
placeholder: {
|
||||
x: 30,
|
||||
y: 40,
|
||||
width: 512,
|
||||
height: 512,
|
||||
originalWidth: 512,
|
||||
originalHeight: 512,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const [selectedLayerId, setSelectedLayerId] = useState<string | null>('first');
|
||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>(['first']);
|
||||
|
||||
const layersRef = useRef(layers);
|
||||
const viewportRef = useRef(viewport);
|
||||
const generateDialogRef = useRef(generateDialog);
|
||||
const inactiveGenerateDialogsRef = useRef(inactiveGenerateDialogs);
|
||||
const selectedLayerIdRef = useRef(selectedLayerId);
|
||||
const selectedLayerIdsRef = useRef(selectedLayerIds);
|
||||
|
||||
layersRef.current = layers;
|
||||
viewportRef.current = viewport;
|
||||
generateDialogRef.current = generateDialog;
|
||||
inactiveGenerateDialogsRef.current = inactiveGenerateDialogs;
|
||||
selectedLayerIdRef.current = selectedLayerId;
|
||||
selectedLayerIdsRef.current = selectedLayerIds;
|
||||
|
||||
const history = useCanvasHistory({
|
||||
refs: {
|
||||
layersRef,
|
||||
viewportRef,
|
||||
generateDialogRef,
|
||||
inactiveGenerateDialogsRef,
|
||||
selectedLayerIdRef,
|
||||
selectedLayerIdsRef,
|
||||
},
|
||||
setters: {
|
||||
setLayers,
|
||||
setViewport,
|
||||
setGenerateDialog,
|
||||
setInactiveGenerateDialogs,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
},
|
||||
resetters: {
|
||||
setHoveredLayerId: () => {},
|
||||
setMetadataLayer: () => {},
|
||||
setCanvasMarquee: () => {},
|
||||
setSnapGuide: () => {},
|
||||
setImageContextMenu: () => {},
|
||||
setContextMenu: () => {},
|
||||
setIsPanning: () => {},
|
||||
clearDragState: onClearDrag,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="layers">
|
||||
{layers.map((layer) => `${layer.id}:${layer.x}`).join(',')}
|
||||
</span>
|
||||
<span data-testid="viewport">
|
||||
{viewport.x},{viewport.y},{viewport.scale}
|
||||
</span>
|
||||
<span data-testid="dialog">{generateDialog?.prompt ?? '-'}</span>
|
||||
<span data-testid="inactive">
|
||||
{inactiveGenerateDialogs.map((dialog) => dialog.prompt).join(',')}
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
history.captureCanvasHistory();
|
||||
}}
|
||||
>
|
||||
capture
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLayers([createLayer('second', 90)]);
|
||||
setViewport({ x: 9, y: 8, scale: 2 });
|
||||
setGenerateDialog({
|
||||
id: 'dialog-next',
|
||||
mode: 'spec',
|
||||
prompt: 'next prompt',
|
||||
status: 'idle',
|
||||
});
|
||||
setInactiveGenerateDialogs([]);
|
||||
setSelectedLayerId('second');
|
||||
setSelectedLayerIds(['second']);
|
||||
}}
|
||||
>
|
||||
mutate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
history.undoCanvasChange();
|
||||
}}
|
||||
>
|
||||
undo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
history.redoCanvasChange();
|
||||
}}
|
||||
>
|
||||
redo
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useCanvasHistory', () => {
|
||||
it('captures, restores, and replays canvas history snapshots', () => {
|
||||
const clearDragState = vi.fn();
|
||||
render(<HistoryHarness onClearDrag={clearDragState} />);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'capture' }).click();
|
||||
});
|
||||
expect(screen.getByTestId('can-undo').textContent).toBe('true');
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'mutate' }).click();
|
||||
});
|
||||
expect(screen.getByTestId('layers').textContent).toBe('second:90');
|
||||
expect(screen.getByTestId('viewport').textContent).toBe('9,8,2');
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'undo' }).click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('layers').textContent).toBe('first:10');
|
||||
expect(screen.getByTestId('viewport').textContent).toBe('1,2,1');
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user