import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'; import { ApiClientError } from '../../services/apiClient'; import { createEditorProjectResource, loadEditorProject, loadOrCreateRecentEditorProject, saveEditorProjectLayout, type EditorProjectSnapshot, } from '../../services/image-editor/editorProjectClient'; import { canvasDisplayViewportToViewport, type CanvasLayerResourceMetadata, hydrateLayer, serializeCanvasLayout, splitCanvasLayoutItems, viewportToCanvasDisplayViewport, } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasLayer, CanvasViewport, } from './ImageCanvasEditorTypes'; type ProjectResourceOptions = { onCreated?: (resourceId: string) => void; snapshotLayers?: CanvasLayer[]; }; type PendingProjectResourceLayer = { layer: CanvasLayer; options: ProjectResourceOptions; }; type PendingProjectLayoutSave = { projectId: string; input: Parameters[1]; }; type ImageCanvasProjectPersistenceRefs = { layersRef: RefObject; viewportRef: RefObject; canvasGenerationDialogsRef: RefObject; }; 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) ); } function isLocalProjectResourceId(resourceId: string) { return resourceId.startsWith('local-'); } function resolveProjectResourceCreateImageSrc(layer: CanvasLayer) { const objectKey = layer.objectKey?.trim(); if (objectKey) { return `/${objectKey.replace(/^\/+/u, '')}`; } return layer.src; } 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(null); const pendingProjectResourceLayersRef = useRef( [], ); const pendingProjectLayoutSaveRef = useRef(null); const isProjectLayoutSaveRunningRef = useRef(false); const saveTimerRef = useRef(null); const [projectId, setProjectId] = useState(null); const [isProjectReady, setIsProjectReady] = useState(false); const { setProjectTitle, setProjectRenameValue, setViewport, setLayers, selectSingleLayer, setLayerCounter, restoreCanvasGenerationDialogs, } = setters; const runPendingProjectLayoutSave = useCallback( function runPendingProjectLayoutSave() { if (isProjectLayoutSaveRunningRef.current) { return; } const pendingSave = pendingProjectLayoutSaveRef.current; if (!pendingSave) { return; } pendingProjectLayoutSaveRef.current = null; isProjectLayoutSaveRunningRef.current = true; Promise.resolve( saveEditorProjectLayout(pendingSave.projectId, pendingSave.input), ) .catch((error: unknown) => { if (isEditorAuthError(error)) { openEditorLoginModal(); } }) .finally(() => { isProjectLayoutSaveRunningRef.current = false; if ( pendingProjectLayoutSaveRef.current && saveTimerRef.current === null ) { runPendingProjectLayoutSave(); } }); }, [openEditorLoginModal], ); const queueProjectLayoutSave = useCallback( ( nextProjectId: string, input: Parameters[1], options: { delayMs?: number } = {}, ) => { pendingProjectLayoutSaveRef.current = { projectId: nextProjectId, input, }; if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } const delayMs = options.delayMs ?? 0; if (delayMs > 0) { saveTimerRef.current = window.setTimeout(() => { saveTimerRef.current = null; runPendingProjectLayoutSave(); }, delayMs); return; } runPendingProjectLayoutSave(); }, [runPendingProjectLayoutSave], ); const createProjectResourceForLayer = useCallback( (layer: CanvasLayer, options: ProjectResourceOptions = {}) => { const readyProjectId = projectIdRef.current; if (!readyProjectId) { pendingProjectResourceLayersRef.current.push({ layer, options }); return; } createEditorProjectResource(readyProjectId, { imageSrc: resolveProjectResourceCreateImageSrc(layer), 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, durationSeconds: layer.durationSeconds, sourceResourceId: layer.sourceResourceId, assetKind: layer.assetKind, generationInputs: layer.generationInputs, }) .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) { queueProjectLayoutSave(readyProjectId, { viewport: viewportToCanvasDisplayViewport(refs.viewportRef.current), layers: serializeCanvasLayout({ layers: nextLayers, canvasGenerationDialogs: refs.canvasGenerationDialogsRef.current, }), }); } }) .catch((error: unknown) => { if (isEditorAuthError(error)) { openEditorLoginModal(); } }); }, [openEditorLoginModal, queueProjectLayoutSave, refs, setLayers], ); const applyProjectSnapshot = useCallback( (project: EditorProjectSnapshot) => { projectIdRef.current = project.projectId; setProjectId(project.projectId); const nextProjectTitle = project.title?.trim() || '未命名画布'; setProjectTitle(nextProjectTitle); setProjectRenameValue(nextProjectTitle); const nextViewport = canvasDisplayViewportToViewport(project.viewport); setViewport(nextViewport); refs.viewportRef.current = nextViewport; const resourcesById = new Map( project.resources.map((resource) => [ resource.resourceId, { imageSrc: resource.imageSrc, resourceId: resource.resourceId, objectKey: resource.objectKey, assetObjectId: resource.assetObjectId, width: resource.width, height: resource.height, sourceType: resource.sourceType, prompt: resource.prompt, actualPrompt: resource.actualPrompt, model: resource.model, provider: resource.provider, taskId: resource.taskId, durationSeconds: resource.durationSeconds, sourceResourceId: resource.sourceResourceId, assetKind: resource.assetKind, generationInputs: resource.generationInputs, createdAt: resource.createdAt, updatedAt: resource.updatedAt, }, ]), ); const { layerItems, generationDialogs } = splitCanvasLayoutItems( project.layers, resourcesById, ); const hydratedLayers = layerItems .map((layer) => hydrateLayer(layer, resourcesById)) .filter((layer): layer is CanvasLayer => Boolean(layer)); setLayerCounter(hydratedLayers.length); refs.layersRef.current = hydratedLayers; setLayers(hydratedLayers); selectSingleLayer(hydratedLayers[0]?.id ?? null); refs.canvasGenerationDialogsRef.current = generationDialogs; restoreCanvasGenerationDialogs(generationDialogs); }, [ refs, restoreCanvasGenerationDialogs, selectSingleLayer, setLayerCounter, setLayers, setProjectRenameValue, setProjectTitle, setViewport, ], ); const appendCanvasLayersWithResources = useCallback( (nextLayers: CanvasLayer[]) => { if (!nextLayers.length) { return; } const snapshotLayers = [...refs.layersRef.current, ...nextLayers]; refs.layersRef.current = snapshotLayers; setLayers(snapshotLayers); nextLayers.forEach((layer) => isLocalProjectResourceId(layer.resourceId) ? createProjectResourceForLayer(layer, { snapshotLayers }) : undefined, ); }, [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; } applyProjectSnapshot(project); const pendingLayers = pendingProjectResourceLayersRef.current.splice(0); pendingLayers.forEach(({ layer, options }) => { createProjectResourceForLayer(layer, options); }); setIsProjectReady(true); }) .catch((error: unknown) => { if (cancelled) { return; } setIsProjectReady(false); if (isEditorAuthError(error)) { openEditorLoginModal(() => { window.location.reload(); }); } }); return () => { cancelled = true; }; }, [ canAccessProtectedData, applyProjectSnapshot, createProjectResourceForLayer, openEditorLoginModal, ]); useEffect(() => { if (!projectId || !isProjectReady) { return undefined; } queueProjectLayoutSave( projectId, { viewport: viewportToCanvasDisplayViewport(viewport), layers: serializeCanvasLayout({ layers, canvasGenerationDialogs, }), }, { delayMs: 450 }, ); return () => { if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } }; }, [ isProjectReady, canvasGenerationDialogs, layers, projectId, queueProjectLayoutSave, refs, viewport, ]); return { projectId, isProjectReady, projectIdRef, createProjectResourceForLayer, appendCanvasLayersWithResources, applyProjectSnapshot, }; }