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

新增画布历史 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,172 @@
/* @vitest-environment jsdom */
import { act, render, screen, waitFor } from '@testing-library/react';
import { useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
typeof import('../../services/image-editor/editorProjectClient')
>('../../services/image-editor/editorProjectClient');
return {
...actual,
createEditorProjectResource: createEditorProjectResourceMock,
loadEditorProject: loadEditorProjectMock,
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
saveEditorProjectLayout: saveEditorProjectLayoutMock,
};
});
function createLayer(id: string): CanvasLayer {
return {
id,
resourceId: `local-${id}`,
title: '账号素材A',
src: 'data:image/png;base64,YQ==',
x: 10,
y: 20,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 3,
sourceType: 'uploaded',
sourceAssetId: 'asset-a',
};
}
function ProjectPersistenceHarness() {
const [layers, setLayers] = useState<CanvasLayer[]>([]);
const [viewport, setViewport] = useState<CanvasViewport>({
x: 0,
y: 0,
scale: 1,
});
const [projectTitle, setProjectTitle] = useState('');
const [projectRenameValue, setProjectRenameValue] = useState('');
const layersRef = useRef(layers);
const viewportRef = useRef(viewport);
const selectedLayerRef = useRef<string | null>(null);
const layerCounterRef = useRef(0);
layersRef.current = layers;
viewportRef.current = viewport;
const persistence = useImageCanvasProjectPersistence({
refs: {
layersRef,
viewportRef,
},
setters: {
setProjectTitle,
setProjectRenameValue,
setViewport,
setLayers,
selectSingleLayer: (layerId) => {
selectedLayerRef.current = layerId;
},
setLayerCounter: (value) => {
layerCounterRef.current = value;
},
},
layers,
viewport,
openEditorLoginModal: vi.fn(),
});
return (
<div>
<span data-testid="project-id">{persistence.projectId ?? '-'}</span>
<span data-testid="project-title">{projectTitle}</span>
<span data-testid="project-rename">{projectRenameValue}</span>
<span data-testid="layers">
{layers.map((layer) => `${layer.id}:${layer.resourceId}`).join(',')}
</span>
<span data-testid="selected">{selectedLayerRef.current ?? '-'}</span>
<span data-testid="counter">{layerCounterRef.current}</span>
<button
type="button"
onClick={() => {
persistence.appendCanvasLayersWithResources([createLayer('layer-a')]);
}}
>
append
</button>
</div>
);
}
describe('useImageCanvasProjectPersistence', () => {
beforeEach(() => {
vi.clearAllMocks();
loadOrCreateRecentEditorProjectMock.mockResolvedValue({
projectId: 'editor-project-default',
title: '空画布项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
createEditorProjectResourceMock.mockResolvedValue({
resourceId: 'resource-added-asset-a',
projectId: 'editor-project-default',
imageSrc: 'data:image/png;base64,YQ==',
width: 320,
height: 240,
sourceType: 'uploaded',
});
saveEditorProjectLayoutMock.mockResolvedValue({
projectId: 'editor-project-default',
title: '空画布项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
});
it('saves appended layers with the server resource id immediately after resource creation', async () => {
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
expect(screen.getByTestId('project-title').textContent).toBe('空画布项目');
expect(screen.getByTestId('project-rename').textContent).toBe(
'空画布项目',
);
act(() => {
screen.getByRole('button', { name: 'append' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe(
'layer-a:local-layer-a',
);
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toBe(
'layer-a:resource-added-asset-a',
);
});
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
layerId: 'layer-a',
resourceId: 'resource-added-asset-a',
sourceAssetId: 'asset-a',
}),
]),
}),
);
});
});
});