拆分图片画布历史与持久化协调器
新增画布历史 hook 承接撤销重做快照逻辑 新增项目持久化 hook 承接加载资源创建与自动保存时序 补充 hook 单测并更新图片画布拆分跟踪文档
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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 状态刷新和异步资源返回交错时写回空图层。
|
||||
|
||||
## 验证计划
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
169
src/components/image-editor/useCanvasHistory.ts
Normal file
169
src/components/image-editor/useCanvasHistory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
240
src/components/image-editor/useImageCanvasProjectPersistence.ts
Normal file
240
src/components/image-editor/useImageCanvasProjectPersistence.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user