Files
Genarrative/src/components/image-editor/useImageCanvasProjectPersistence.ts
T
kdletters 946308b75e 保存图片画布生成器快照
将生成器对话框作为画布布局项序列化和恢复

生成成功后保留生成器快照并锚定到成品图层

图片类生成结果同步写入账号素材库

补充生成器持久化测试和浏览器回归相关文档
2026-06-17 23:57:25 +08:00

300 lines
8.7 KiB
TypeScript

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,
serializeCanvasLayout,
splitCanvasLayoutItems,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
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>;
canvasGenerationDialogsRef: RefObject<CanvasGenerationDialogState[]>;
};
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;
restoreCanvasGenerationDialogs: (
dialogs: CanvasGenerationDialogState[],
) => void;
};
function isEditorAuthError(error: unknown) {
return (
error instanceof ApiClientError &&
(error.status === 401 || error.status === 403)
);
}
export function useImageCanvasProjectPersistence({
refs,
setters,
layers,
canvasGenerationDialogs,
viewport,
canAccessProtectedData,
openEditorLoginModal,
}: {
refs: ImageCanvasProjectPersistenceRefs;
setters: ImageCanvasProjectPersistenceSetters;
layers: CanvasLayer[];
canvasGenerationDialogs: CanvasGenerationDialogState[];
viewport: CanvasViewport;
canAccessProtectedData: boolean;
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 {
setProjectTitle,
setProjectRenameValue,
setViewport,
setLayers,
selectSingleLayer,
setLayerCounter,
restoreCanvasGenerationDialogs,
} = setters;
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;
setLayers(nextLayers);
if (nextLayers.length) {
void saveEditorProjectLayout(readyProjectId, {
viewport: refs.viewportRef.current,
layers: serializeCanvasLayout({
layers: nextLayers,
canvasGenerationDialogs:
refs.canvasGenerationDialogsRef.current,
}),
}).catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
});
}
})
.catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
});
},
[openEditorLoginModal, refs, setLayers],
);
const appendCanvasLayersWithResources = useCallback(
(nextLayers: CanvasLayer[]) => {
if (!nextLayers.length) {
return;
}
const snapshotLayers = [...refs.layersRef.current, ...nextLayers];
refs.layersRef.current = snapshotLayers;
setLayers(snapshotLayers);
nextLayers.forEach((layer) =>
createProjectResourceForLayer(layer, { snapshotLayers }),
);
},
[createProjectResourceForLayer, refs, setLayers],
);
useEffect(() => {
if (!canAccessProtectedData) {
setIsProjectReady(false);
return undefined;
}
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 { layerItems, generationDialogs } = splitCanvasLayoutItems(
project.layers,
);
const hydratedLayers = layerItems
.map((layer) => hydrateLayer(layer, resourcesById))
.filter((layer): layer is CanvasLayer => Boolean(layer));
restoreCanvasGenerationDialogs(generationDialogs);
if (hydratedLayers.length > 0) {
setLayerCounter(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;
};
}, [
canAccessProtectedData,
createProjectResourceForLayer,
openEditorLoginModal,
restoreCanvasGenerationDialogs,
selectSingleLayer,
setLayerCounter,
setLayers,
setProjectRenameValue,
setProjectTitle,
setViewport,
]);
useEffect(() => {
if (!projectId || !isProjectReady) {
return undefined;
}
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = window.setTimeout(() => {
saveEditorProjectLayout(projectId, {
viewport,
layers: serializeCanvasLayout({
layers,
canvasGenerationDialogs,
}),
}).catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
});
}, 450);
return () => {
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
}
};
}, [
isProjectReady,
canvasGenerationDialogs,
layers,
openEditorLoginModal,
projectId,
refs,
viewport,
]);
return {
projectId,
isProjectReady,
projectIdRef,
createProjectResourceForLayer,
appendCanvasLayersWithResources,
};
}