拆分图片画布历史与持久化协调器

新增画布历史 hook 承接撤销重做快照逻辑
新增项目持久化 hook 承接加载资源创建与自动保存时序
补充 hook 单测并更新图片画布拆分跟踪文档
This commit is contained in:
2026-06-17 05:00:53 +08:00
parent f794a8dd1f
commit 9f45641ccd
7 changed files with 894 additions and 305 deletions

View 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');
});
});