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

新增画布历史 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

@@ -121,3 +121,5 @@
- 2026-06-17 前端拆分第五阶段:新增 `ImageCanvasLayerCommandModel`,把右键图层目标解析、复制 / 粘贴 / 创建副本、层级移动、分组 / 解组、显隐、锁定、翻转和删除的数据规则从主视图抽出;主视图只保留历史、选中态、菜单关闭、元数据清理和导出下载副作用。验证命令:`npm run test -- src/components/image-editor/ImageCanvasLayerCommandModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,背景入口打开完整 `画布背景设置` 面板;登录后上传素材成功,点击素材可加入画布,图片右键打开 `图片功能面板`,创建副本、水平翻转、锁定和隐藏均生效,`AI画布工具栏` 保持可见。
- 2026-06-17 前端拆分第六阶段:新增 `ImageCanvasInteractionModel`把适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动的纯规则从主视图抽出主视图保留事件、pointer capture、history、生成对象回写、选中态和状态更新。验证命令`npm run test -- src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 登录态刷新后素材、画布图层、小地图和 `AI画布工具栏` 保持可见Ctrl 滚轮从 110% 缩放到 121%,普通滚轮不改变缩放,浏览器控制台无 passive wheel 错误。
- 2026-06-17 新增素材持久化修正:素材库图片、上传到画布、生成图、修改图和图标素材加入画布时会先用当前图层快照更新本地画布,再在资源创建完成后立刻保存带真实 `resourceId` 的 layout避免资源创建异步返回时把空 `layers` 写回工程。验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录弹出 `账号入口`,登录后上传素材、点击素材加入画布并刷新,画布图片和 `AI画布工具栏` 均保持可见。
- 2026-06-17 前端拆分第七阶段:新增 `useCanvasHistory`,把画布历史快照、撤销、重做、历史栈长度限制和 `canUndo` / `canRedo` 派生状态从主视图抽出;主视图只在具体动作前捕获历史,并注入恢复快照后的菜单 / hover / 框选 / 拖拽清理。验证命令:`npm run test -- src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck`
- 2026-06-17 前端拆分第八阶段:新增 `useImageCanvasProjectPersistence`,把项目加载、`projectId` 状态、未就绪资源队列、工程资源创建、资源创建后即时保存和 450ms 自动保存从主视图抽出;新增 hook 单测锁定新增图层资源创建后保存真实 `resourceId` 的 layout。验证命令`npm run test -- src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck`

View File

@@ -71,11 +71,24 @@
- 主视图继续负责 React 事件对象、pointer capture、history 快照、生成对象回写、选中态和 `setState`
- 该模块用独立单测覆盖小地图灵敏度、吸附、多选拖拽和滚轮缩放等之前容易回退的交互规则。
## 第七阶段模块
- `useCanvasHistory.ts`
- 承载画布历史栈:快照创建、快照恢复、撤销、重做、历史栈长度限制和 `canUndo` / `canRedo` 派生状态。
- 主视图继续负责在具体用户动作前调用 `captureCanvasHistory`,并通过 hook 注入恢复快照后需要清理的 hover、元数据、框选、吸附、右键菜单和平移拖拽状态。
- 该 hook 有独立测试覆盖图层、视口、active / archived 生成对话框和选中态的撤销 / 重做恢复,避免后续把 history 逻辑继续埋回主视图。
## 第八阶段模块
- `useImageCanvasProjectPersistence.ts`
- 承载图片画布工程持久化协调:项目加载、`projectId` 维护、未就绪资源队列、工程资源创建、资源创建后即时 layout 保存、450ms 自动保存和鉴权失败登录弹窗。
- 该 hook 以“项目持久化协调器”整体抽出,避免把加载、保存和资源创建拆成多个小 hook 后打散 `projectIdRef``pendingProjectResourceLayersRef``isProjectReady``saveTimerRef` 的时序约束。
- 主视图继续负责项目重命名 UI、素材库管理、上传流程和用户动作触发新增图层仍通过 `appendCanvasLayersWithResources` 先写本地图层快照,再创建 project resource 并保存带真实 `resourceId` 的 layout。
## 后续阶段
- 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。
- 上传 / 素材状态模型:上传占位卡片、素材文件夹移动、账号级素材库和拖拽遮罩仍在主视图与侧栏之间协作,后续需要等上传错误恢复和批量操作规则稳定后再收口。
- 资源持久化稳定性:新增图层时先使用当前画布图层快照更新本地状态,再等待工程资源创建并即时保存带真实 `resourceId` 的 layout。后续如果继续拆上传或生成状态机必须保留这一时序避免 React 状态刷新和异步资源返回交错时写回空图层。
## 验证计划

View File

@@ -17,7 +17,6 @@ import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/
import {
createEditorAsset,
createEditorAssetFolder,
createEditorProjectResource,
deleteEditorAsset,
deleteEditorAssetFolder,
editEditorImage,
@@ -28,10 +27,7 @@ import {
generateEditorIconSpritesheet,
generateEditorImage,
loadEditorAssetLibrary,
loadEditorProject,
loadOrCreateRecentEditorProject,
renameEditorProject,
saveEditorProjectLayout,
updateEditorAsset,
updateEditorAssetFolder,
} from '../../services/image-editor/editorProjectClient';
@@ -80,7 +76,6 @@ import {
DEFAULT_CANVAS_BACKGROUND_COLOR,
DEFAULT_CANVAS_SIZE,
EDITOR_ASSET_FOLDERS,
MAX_HISTORY_STEPS,
TOOLBAR_HALF_WIDTH,
clamp,
createLayerFromAsset,
@@ -88,7 +83,6 @@ import {
formatImageSizeValue,
getDraggedAssetId,
hasDataTransferType,
hydrateLayer,
isGeneratedLayer,
isLayerLinkedToAsset,
normalizeAssetLibrary,
@@ -151,7 +145,6 @@ import type {
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasGenerationInputs,
CanvasHistorySnapshot,
CanvasLayer,
CanvasMarqueeState,
CanvasTool,
@@ -169,6 +162,8 @@ import type {
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { useCanvasHistory } from './useCanvasHistory';
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
function isImageFile(file: File) {
return file.type.startsWith('image/');
@@ -250,28 +245,15 @@ export function ImageCanvasEditorView() {
const isShiftPressedRef = useRef(false);
const layerCounterRef = useRef(0);
const generationDialogCounterRef = useRef(0);
const saveTimerRef = useRef<number | null>(null);
const undoStackRef = useRef<CanvasHistorySnapshot[]>([]);
const redoStackRef = useRef<CanvasHistorySnapshot[]>([]);
const layersRef = useRef<CanvasLayer[]>([]);
const viewportRef = useRef<CanvasViewport>({
x: -260,
y: 70,
scale: 0.82,
});
const projectIdRef = useRef<string | null>(null);
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const pendingProjectResourceLayersRef = useRef<
Array<{
layer: CanvasLayer;
options: {
onCreated?: (resourceId: string) => void;
snapshotLayers?: CanvasLayer[];
};
}>
>([]);
const selectedLayerIdRef = useRef<string | null>(null);
const selectedLayerIdsRef = useRef<string[]>([]);
const generateDialogRef = useRef<GenerateDialogState | null>(null);
@@ -280,7 +262,6 @@ export function ImageCanvasEditorView() {
() => {},
);
const suppressAssetClickRef = useRef(false);
const [projectId, setProjectId] = useState<string | null>(null);
const [projectTitle, setProjectTitle] = useState('未命名画布');
const [projectRenameValue, setProjectRenameValue] = useState('未命名画布');
const [isRenamingProject, setIsRenamingProject] = useState(false);
@@ -288,7 +269,6 @@ export function ImageCanvasEditorView() {
const [projectRenameError, setProjectRenameError] = useState<string | null>(
null,
);
const [isProjectReady, setIsProjectReady] = useState(false);
const [assetExportStatus, setAssetExportStatus] = useState<{
tone: 'info' | 'success' | 'error';
message: string;
@@ -375,7 +355,6 @@ export function ImageCanvasEditorView() {
);
const [canvasClipboard, setCanvasClipboard] =
useState<CanvasClipboard | null>(null);
const [historyVersion, setHistoryVersion] = useState(0);
const [quickEditPanel, setQuickEditPanel] =
useState<QuickEditPanelState | null>(null);
const [characterAnimationPanel, setCharacterAnimationPanel] =
@@ -568,9 +547,55 @@ export function ImageCanvasEditorView() {
const contextShouldUnlockLayer = contextTargetLayers.some(
(layer) => layer.locked,
);
const canUndo = undoStackRef.current.length > 0;
const canRedo = redoStackRef.current.length > 0;
void historyVersion;
const canvasHistoryRefs = useMemo(
() => ({
layersRef,
viewportRef,
generateDialogRef,
inactiveGenerateDialogsRef,
selectedLayerIdRef,
selectedLayerIdsRef,
}),
[],
);
const canvasHistorySetters = useMemo(
() => ({
setLayers,
setViewport,
setGenerateDialog,
setInactiveGenerateDialogs,
setSelectedLayerId,
setSelectedLayerIds,
}),
[],
);
const clearHistoryDragState = useCallback(() => {
dragStateRef.current = null;
}, []);
const canvasHistoryResetters = useMemo(
() => ({
setHoveredLayerId,
setMetadataLayer,
setCanvasMarquee,
setSnapGuide,
setImageContextMenu,
setContextMenu,
setIsPanning,
clearDragState: clearHistoryDragState,
}),
[clearHistoryDragState],
);
const {
canUndo,
canRedo,
captureCanvasHistory,
undoCanvasChange,
redoCanvasChange,
} = useCanvasHistory({
refs: canvasHistoryRefs,
setters: canvasHistorySetters,
resetters: canvasHistoryResetters,
});
const groupedAssets = useMemo(
() =>
assetFolders.map((folder) => ({
@@ -688,110 +713,6 @@ export function ImageCanvasEditorView() {
);
};
const getCanvasHistorySnapshot = useCallback(
(): CanvasHistorySnapshot => ({
layers: layersRef.current.map((layer) => ({ ...layer })),
viewport: { ...viewportRef.current },
generateDialog: generateDialogRef.current
? {
...generateDialogRef.current,
placeholder: generateDialogRef.current.placeholder
? { ...generateDialogRef.current.placeholder }
: undefined,
}
: null,
inactiveGenerateDialogs: inactiveGenerateDialogsRef.current.map(
(dialog) => ({
...dialog,
placeholder: dialog.placeholder
? { ...dialog.placeholder }
: undefined,
}),
),
selectedLayerId: selectedLayerIdRef.current,
selectedLayerIds: [...selectedLayerIdsRef.current],
}),
[],
);
const restoreCanvasHistorySnapshot = useCallback(
(snapshot: CanvasHistorySnapshot) => {
setLayers(snapshot.layers.map((layer) => ({ ...layer })));
setViewport({ ...snapshot.viewport });
setGenerateDialog(
snapshot.generateDialog
? {
...snapshot.generateDialog,
placeholder: snapshot.generateDialog.placeholder
? { ...snapshot.generateDialog.placeholder }
: undefined,
}
: null,
);
setInactiveGenerateDialogs(
snapshot.inactiveGenerateDialogs.map((dialog) => ({
...dialog,
placeholder: dialog.placeholder
? { ...dialog.placeholder }
: undefined,
})),
);
setSelectedLayerId(snapshot.selectedLayerId);
setSelectedLayerIds([...snapshot.selectedLayerIds]);
setHoveredLayerId(null);
setMetadataLayer(null);
setCanvasMarquee(null);
setSnapGuide(null);
setImageContextMenu(null);
setContextMenu(null);
setIsPanning(false);
dragStateRef.current = null;
},
[],
);
const captureCanvasHistory = useCallback(
(options: { clearRedo?: boolean } = {}) => {
undoStackRef.current = [
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
getCanvasHistorySnapshot(),
];
if (options.clearRedo !== false) {
redoStackRef.current = [];
}
setHistoryVersion((version) => version + 1);
},
[getCanvasHistorySnapshot],
);
const undoCanvasChange = useCallback(() => {
const previousSnapshot = undoStackRef.current.at(-1);
if (!previousSnapshot) {
return;
}
undoStackRef.current = undoStackRef.current.slice(0, -1);
redoStackRef.current = [
...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
getCanvasHistorySnapshot(),
];
restoreCanvasHistorySnapshot(previousSnapshot);
setHistoryVersion((version) => version + 1);
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
const redoCanvasChange = useCallback(() => {
const nextSnapshot = redoStackRef.current.at(-1);
if (!nextSnapshot) {
return;
}
redoStackRef.current = redoStackRef.current.slice(0, -1);
undoStackRef.current = [
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
getCanvasHistorySnapshot(),
];
restoreCanvasHistorySnapshot(nextSnapshot);
setHistoryVersion((version) => version + 1);
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
const selectSingleLayer = useCallback((layerId: string | null) => {
setSelectedLayerId(layerId);
setSelectedLayerIds(layerId ? [layerId] : []);
@@ -809,6 +730,34 @@ export function ImageCanvasEditorView() {
);
}
}, []);
const projectPersistenceRefs = useMemo(
() => ({
layersRef,
viewportRef,
}),
[],
);
const projectPersistenceSetters = useMemo(
() => ({
setProjectTitle,
setProjectRenameValue,
setViewport,
setLayers,
selectSingleLayer,
setLayerCounter: (value: number) => {
layerCounterRef.current = value;
},
}),
[selectSingleLayer],
);
const { projectId, appendCanvasLayersWithResources } =
useImageCanvasProjectPersistence({
refs: projectPersistenceRefs,
setters: projectPersistenceSetters,
layers,
viewport,
openEditorLoginModal,
});
const hideGeneratedLayerPanelAfterBlur = useCallback(() => {
setGenerateDialog((currentDialog) =>
@@ -856,144 +805,11 @@ export function ImageCanvasEditorView() {
[],
);
const createProjectResourceForLayer = useCallback(
(
layer: CanvasLayer,
options: {
onCreated?: (resourceId: string) => void;
snapshotLayers?: CanvasLayer[];
} = {},
) => {
const readyProjectId = projectIdRef.current;
if (!readyProjectId) {
pendingProjectResourceLayersRef.current.push({ layer, options });
return;
}
createEditorProjectResource(readyProjectId, {
imageSrc: layer.src,
objectKey: layer.objectKey,
assetObjectId: layer.assetObjectId,
width: layer.originalWidth,
height: layer.originalHeight,
sourceType: layer.sourceType,
prompt: layer.prompt,
actualPrompt: layer.actualPrompt,
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
sourceResourceId: layer.sourceResourceId,
})
.then((resource) => {
options.onCreated?.(resource.resourceId);
const layerWithResourceId = {
...layer,
resourceId: resource.resourceId,
};
const currentLayers = layersRef.current;
const nextLayers = currentLayers.some(
(currentLayer) => currentLayer.id === layer.id,
)
? currentLayers.map((currentLayer) =>
currentLayer.id === layer.id
? layerWithResourceId
: currentLayer,
)
: options.snapshotLayers?.some(
(snapshotLayer) => snapshotLayer.id === layer.id,
)
? options.snapshotLayers.map((snapshotLayer) =>
snapshotLayer.id === layer.id
? layerWithResourceId
: snapshotLayer,
)
: currentLayers;
layersRef.current = nextLayers;
setLayers(nextLayers);
if (nextLayers.length) {
void saveEditorProjectLayout(readyProjectId, {
viewport: viewportRef.current,
layers: nextLayers.map(serializeLayer),
}).catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
});
}
})
.catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
});
},
[openEditorLoginModal],
);
const minimapModel = useMemo(
() => createMinimapModel({ layers, viewport, canvasSize }),
[canvasSize, layers, viewport],
);
useEffect(() => {
let cancelled = false;
const projectIdFromQuery =
typeof window === 'undefined'
? null
: new URLSearchParams(window.location.search)
.get('projectid')
?.trim() || null;
const loadProject = projectIdFromQuery
? loadEditorProject(projectIdFromQuery)
: loadOrCreateRecentEditorProject();
loadProject
.then((project) => {
if (cancelled) {
return;
}
projectIdRef.current = project.projectId;
setProjectId(project.projectId);
const nextProjectTitle = project.title?.trim() || '未命名画布';
setProjectTitle(nextProjectTitle);
setProjectRenameValue(nextProjectTitle);
const pendingLayers = pendingProjectResourceLayersRef.current.splice(0);
pendingLayers.forEach(({ layer, options }) => {
createProjectResourceForLayer(layer, options);
});
setViewport(project.viewport);
const resourcesById = new Map(
project.resources.map((resource) => [
resource.resourceId,
{ imageSrc: resource.imageSrc },
]),
);
const hydratedLayers = project.layers
.map((layer) => hydrateLayer(layer, resourcesById))
.filter((layer): layer is CanvasLayer => Boolean(layer));
if (hydratedLayers.length > 0) {
layerCounterRef.current = hydratedLayers.length;
setLayers(hydratedLayers);
selectSingleLayer(hydratedLayers[0]?.id ?? null);
}
setIsProjectReady(true);
})
.catch((error: unknown) => {
if (cancelled) {
return;
}
setIsProjectReady(false);
if (isEditorAuthError(error)) {
openEditorLoginModal(() => {
window.location.reload();
});
}
});
return () => {
cancelled = true;
};
}, [createProjectResourceForLayer, openEditorLoginModal, selectSingleLayer]);
useEffect(() => {
let cancelled = false;
loadEditorAssetLibrary()
@@ -1245,32 +1061,6 @@ export function ImageCanvasEditorView() {
};
}, []);
useEffect(() => {
if (!projectId || !isProjectReady) {
return undefined;
}
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = window.setTimeout(() => {
saveEditorProjectLayout(projectId, {
viewport,
layers: layers.map(serializeLayer),
}).catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
});
}, 450);
return () => {
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
}
};
}, [isProjectReady, layers, openEditorLoginModal, projectId, viewport]);
const fitLayers = useCallback(
(targetLayers: CanvasLayer[] = layers) => {
const nextViewport = fitViewportToLayers({
@@ -1575,21 +1365,6 @@ export function ImageCanvasEditorView() {
);
};
const appendCanvasLayersWithResources = useCallback(
(nextLayers: CanvasLayer[]) => {
if (!nextLayers.length) {
return;
}
const snapshotLayers = [...layersRef.current, ...nextLayers];
layersRef.current = snapshotLayers;
setLayers(snapshotLayers);
nextLayers.forEach((layer) =>
createProjectResourceForLayer(layer, { snapshotLayers }),
);
},
[createProjectResourceForLayer],
);
const addAssetLayer = (
asset: EditorAsset,
position?: { x: number; y: number },

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

View File

@@ -0,0 +1,169 @@
import { type RefObject, useCallback, useRef, useState } from 'react';
import { MAX_HISTORY_STEPS } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasHistorySnapshot,
CanvasLayer,
CanvasMarqueeState,
CanvasContextMenuState,
CanvasViewport,
GenerateDialogState,
ImageContextMenuState,
SnapGuide,
} from './ImageCanvasEditorTypes';
type CanvasHistoryRefs = {
layersRef: RefObject<CanvasLayer[]>;
viewportRef: RefObject<CanvasViewport>;
generateDialogRef: RefObject<GenerateDialogState | null>;
inactiveGenerateDialogsRef: RefObject<CanvasGenerationDialogState[]>;
selectedLayerIdRef: RefObject<string | null>;
selectedLayerIdsRef: RefObject<string[]>;
};
type CanvasHistorySetters = {
setLayers: (layers: CanvasLayer[]) => void;
setViewport: (viewport: CanvasViewport) => void;
setGenerateDialog: (dialog: GenerateDialogState | null) => void;
setInactiveGenerateDialogs: (
dialogs: CanvasGenerationDialogState[],
) => void;
setSelectedLayerId: (layerId: string | null) => void;
setSelectedLayerIds: (layerIds: string[]) => void;
};
type CanvasHistoryResetters = {
setHoveredLayerId: (layerId: string | null) => void;
setMetadataLayer: (layer: CanvasLayer | null) => void;
setCanvasMarquee: (marquee: CanvasMarqueeState | null) => void;
setSnapGuide: (guide: SnapGuide | null) => void;
setImageContextMenu: (menu: ImageContextMenuState | null) => void;
setContextMenu: (menu: CanvasContextMenuState | null) => void;
setIsPanning: (isPanning: boolean) => void;
clearDragState: () => void;
};
function cloneGenerateDialog(dialog: GenerateDialogState): GenerateDialogState {
return {
...dialog,
placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined,
};
}
function cloneCanvasGenerationDialog(
dialog: CanvasGenerationDialogState,
): CanvasGenerationDialogState {
return {
...dialog,
placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined,
};
}
export function useCanvasHistory({
refs,
setters,
resetters,
}: {
refs: CanvasHistoryRefs;
setters: CanvasHistorySetters;
resetters: CanvasHistoryResetters;
}) {
const undoStackRef = useRef<CanvasHistorySnapshot[]>([]);
const redoStackRef = useRef<CanvasHistorySnapshot[]>([]);
const [historyVersion, setHistoryVersion] = useState(0);
const getCanvasHistorySnapshot = useCallback(
(): CanvasHistorySnapshot => ({
layers: refs.layersRef.current.map((layer) => ({ ...layer })),
viewport: { ...refs.viewportRef.current },
generateDialog: refs.generateDialogRef.current
? cloneGenerateDialog(refs.generateDialogRef.current)
: null,
inactiveGenerateDialogs:
refs.inactiveGenerateDialogsRef.current.map(cloneCanvasGenerationDialog),
selectedLayerId: refs.selectedLayerIdRef.current,
selectedLayerIds: [...refs.selectedLayerIdsRef.current],
}),
[refs],
);
const restoreCanvasHistorySnapshot = useCallback(
(snapshot: CanvasHistorySnapshot) => {
setters.setLayers(snapshot.layers.map((layer) => ({ ...layer })));
setters.setViewport({ ...snapshot.viewport });
setters.setGenerateDialog(
snapshot.generateDialog
? cloneGenerateDialog(snapshot.generateDialog)
: null,
);
setters.setInactiveGenerateDialogs(
snapshot.inactiveGenerateDialogs.map(cloneCanvasGenerationDialog),
);
setters.setSelectedLayerId(snapshot.selectedLayerId);
setters.setSelectedLayerIds([...snapshot.selectedLayerIds]);
resetters.setHoveredLayerId(null);
resetters.setMetadataLayer(null);
resetters.setCanvasMarquee(null);
resetters.setSnapGuide(null);
resetters.setImageContextMenu(null);
resetters.setContextMenu(null);
resetters.setIsPanning(false);
resetters.clearDragState();
},
[resetters, setters],
);
const captureCanvasHistory = useCallback(
(options: { clearRedo?: boolean } = {}) => {
undoStackRef.current = [
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
getCanvasHistorySnapshot(),
];
if (options.clearRedo !== false) {
redoStackRef.current = [];
}
setHistoryVersion((version) => version + 1);
},
[getCanvasHistorySnapshot],
);
const undoCanvasChange = useCallback(() => {
const previousSnapshot = undoStackRef.current.at(-1);
if (!previousSnapshot) {
return;
}
undoStackRef.current = undoStackRef.current.slice(0, -1);
redoStackRef.current = [
...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
getCanvasHistorySnapshot(),
];
restoreCanvasHistorySnapshot(previousSnapshot);
setHistoryVersion((version) => version + 1);
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
const redoCanvasChange = useCallback(() => {
const nextSnapshot = redoStackRef.current.at(-1);
if (!nextSnapshot) {
return;
}
redoStackRef.current = redoStackRef.current.slice(0, -1);
undoStackRef.current = [
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
getCanvasHistorySnapshot(),
];
restoreCanvasHistorySnapshot(nextSnapshot);
setHistoryVersion((version) => version + 1);
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
return {
canUndo: undoStackRef.current.length > 0,
canRedo: redoStackRef.current.length > 0,
historyVersion,
getCanvasHistorySnapshot,
restoreCanvasHistorySnapshot,
captureCanvasHistory,
undoCanvasChange,
redoCanvasChange,
};
}

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

View File

@@ -0,0 +1,240 @@
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react';
import { ApiClientError } from '../../services/apiClient';
import {
createEditorProjectResource,
loadEditorProject,
loadOrCreateRecentEditorProject,
saveEditorProjectLayout,
} from '../../services/image-editor/editorProjectClient';
import { hydrateLayer, serializeLayer } from './ImageCanvasEditorModel';
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
type ProjectResourceOptions = {
onCreated?: (resourceId: string) => void;
snapshotLayers?: CanvasLayer[];
};
type PendingProjectResourceLayer = {
layer: CanvasLayer;
options: ProjectResourceOptions;
};
type ImageCanvasProjectPersistenceRefs = {
layersRef: RefObject<CanvasLayer[]>;
viewportRef: RefObject<CanvasViewport>;
};
type ImageCanvasProjectPersistenceSetters = {
setProjectTitle: (title: string) => void;
setProjectRenameValue: (title: string) => void;
setViewport: (viewport: CanvasViewport) => void;
setLayers: (layers: CanvasLayer[]) => void;
selectSingleLayer: (layerId: string | null) => void;
setLayerCounter: (value: number) => void;
};
function isEditorAuthError(error: unknown) {
return (
error instanceof ApiClientError &&
(error.status === 401 || error.status === 403)
);
}
export function useImageCanvasProjectPersistence({
refs,
setters,
layers,
viewport,
openEditorLoginModal,
}: {
refs: ImageCanvasProjectPersistenceRefs;
setters: ImageCanvasProjectPersistenceSetters;
layers: CanvasLayer[];
viewport: CanvasViewport;
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
}) {
const projectIdRef = useRef<string | null>(null);
const pendingProjectResourceLayersRef = useRef<PendingProjectResourceLayer[]>(
[],
);
const saveTimerRef = useRef<number | null>(null);
const [projectId, setProjectId] = useState<string | null>(null);
const [isProjectReady, setIsProjectReady] = useState(false);
const createProjectResourceForLayer = useCallback(
(layer: CanvasLayer, options: ProjectResourceOptions = {}) => {
const readyProjectId = projectIdRef.current;
if (!readyProjectId) {
pendingProjectResourceLayersRef.current.push({ layer, options });
return;
}
createEditorProjectResource(readyProjectId, {
imageSrc: layer.src,
objectKey: layer.objectKey,
assetObjectId: layer.assetObjectId,
width: layer.originalWidth,
height: layer.originalHeight,
sourceType: layer.sourceType,
prompt: layer.prompt,
actualPrompt: layer.actualPrompt,
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
sourceResourceId: layer.sourceResourceId,
})
.then((resource) => {
options.onCreated?.(resource.resourceId);
const layerWithResourceId = {
...layer,
resourceId: resource.resourceId,
};
const currentLayers = refs.layersRef.current;
const nextLayers = currentLayers.some(
(currentLayer) => currentLayer.id === layer.id,
)
? currentLayers.map((currentLayer) =>
currentLayer.id === layer.id
? layerWithResourceId
: currentLayer,
)
: options.snapshotLayers?.some(
(snapshotLayer) => snapshotLayer.id === layer.id,
)
? options.snapshotLayers.map((snapshotLayer) =>
snapshotLayer.id === layer.id
? layerWithResourceId
: snapshotLayer,
)
: currentLayers;
refs.layersRef.current = nextLayers;
setters.setLayers(nextLayers);
if (nextLayers.length) {
void saveEditorProjectLayout(readyProjectId, {
viewport: refs.viewportRef.current,
layers: nextLayers.map(serializeLayer),
}).catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
});
}
})
.catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
});
},
[openEditorLoginModal, refs, setters],
);
const appendCanvasLayersWithResources = useCallback(
(nextLayers: CanvasLayer[]) => {
if (!nextLayers.length) {
return;
}
const snapshotLayers = [...refs.layersRef.current, ...nextLayers];
refs.layersRef.current = snapshotLayers;
setters.setLayers(snapshotLayers);
nextLayers.forEach((layer) =>
createProjectResourceForLayer(layer, { snapshotLayers }),
);
},
[createProjectResourceForLayer, refs, setters],
);
useEffect(() => {
let cancelled = false;
const projectIdFromQuery =
typeof window === 'undefined'
? null
: new URLSearchParams(window.location.search)
.get('projectid')
?.trim() || null;
const loadProject = projectIdFromQuery
? loadEditorProject(projectIdFromQuery)
: loadOrCreateRecentEditorProject();
loadProject
.then((project) => {
if (cancelled) {
return;
}
projectIdRef.current = project.projectId;
setProjectId(project.projectId);
const nextProjectTitle = project.title?.trim() || '未命名画布';
setters.setProjectTitle(nextProjectTitle);
setters.setProjectRenameValue(nextProjectTitle);
const pendingLayers = pendingProjectResourceLayersRef.current.splice(0);
pendingLayers.forEach(({ layer, options }) => {
createProjectResourceForLayer(layer, options);
});
setters.setViewport(project.viewport);
const resourcesById = new Map(
project.resources.map((resource) => [
resource.resourceId,
{ imageSrc: resource.imageSrc },
]),
);
const hydratedLayers = project.layers
.map((layer) => hydrateLayer(layer, resourcesById))
.filter((layer): layer is CanvasLayer => Boolean(layer));
if (hydratedLayers.length > 0) {
setters.setLayerCounter(hydratedLayers.length);
setters.setLayers(hydratedLayers);
setters.selectSingleLayer(hydratedLayers[0]?.id ?? null);
}
setIsProjectReady(true);
})
.catch((error: unknown) => {
if (cancelled) {
return;
}
setIsProjectReady(false);
if (isEditorAuthError(error)) {
openEditorLoginModal(() => {
window.location.reload();
});
}
});
return () => {
cancelled = true;
};
}, [createProjectResourceForLayer, openEditorLoginModal, setters]);
useEffect(() => {
if (!projectId || !isProjectReady) {
return undefined;
}
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = window.setTimeout(() => {
saveEditorProjectLayout(projectId, {
viewport,
layers: layers.map(serializeLayer),
}).catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
});
}, 450);
return () => {
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
}
};
}, [isProjectReady, layers, openEditorLoginModal, projectId, viewport]);
return {
projectId,
isProjectReady,
projectIdRef,
createProjectResourceForLayer,
appendCanvasLayersWithResources,
};
}