Files
ttstd-infinite-canvas/web/src/pages/canvas/use-canvas-project-lifecycle.ts

294 lines
14 KiB
TypeScript

import { startTransition, useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { App } from "antd";
import { useNavigate } from "react-router";
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
import type { CanvasHistorySnapshot } from "./use-canvas-history";
type UseCanvasProjectLifecycleOptions = {
projectId: string;
projectLoaded: boolean;
nodes: CanvasNodeData[];
connections: CanvasConnection[];
chatSessions: CanvasAssistantSession[];
activeChatId: string | null;
canvasAppearance: CanvasAppearance;
backgroundMode: CanvasBackgroundMode;
showImageInfo: boolean;
viewport: ViewportTransform;
nodesRef: MutableRefObject<CanvasNodeData[]>;
connectionsRef: MutableRefObject<CanvasConnection[]>;
viewportRef: MutableRefObject<ViewportTransform>;
historyPausedRef: MutableRefObject<boolean>;
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setChatSessions: Dispatch<SetStateAction<CanvasAssistantSession[]>>;
setActiveChatId: Dispatch<SetStateAction<string | null>>;
setCanvasAppearance: Dispatch<SetStateAction<CanvasAppearance>>;
setBackgroundMode: Dispatch<SetStateAction<CanvasBackgroundMode>>;
setShowImageInfo: Dispatch<SetStateAction<boolean>>;
setViewport: Dispatch<SetStateAction<ViewportTransform>>;
setProjectLoaded: Dispatch<SetStateAction<boolean>>;
resetHistory: (snapshot: CanvasHistorySnapshot) => void;
cleanupAssetImages: (options?: unknown) => void;
cleanupCanvasFiles: (extra?: unknown) => void;
};
export function useCanvasProjectLifecycle({
projectId,
projectLoaded,
nodes,
connections,
chatSessions,
activeChatId,
canvasAppearance,
backgroundMode,
showImageInfo,
viewport,
nodesRef,
connectionsRef,
viewportRef,
historyPausedRef,
setNodes,
setConnections,
setChatSessions,
setActiveChatId,
setCanvasAppearance,
setBackgroundMode,
setShowImageInfo,
setViewport,
setProjectLoaded,
resetHistory,
cleanupAssetImages,
cleanupCanvasFiles,
}: UseCanvasProjectLifecycleOptions) {
const { message } = App.useApp();
const navigate = useNavigate();
const hydrated = useCanvasStore((state) => state.hydrated);
const sessionHydrated = useUserStore((state) => state.hydrated);
const openProject = useCanvasStore((state) => state.openProject);
const updateProject = useCanvasStore((state) => state.updateProject);
const renameProject = useCanvasStore((state) => state.renameProject);
const currentProject = useCanvasStore((state) => state.projects.find((project) => project.id === projectId));
const [addedSkills, setAddedSkills] = useState<Skill[]>([]);
const [loadError, setLoadError] = useState("");
const [loadAttempt, setLoadAttempt] = useState(0);
const viewportSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!hydrated || !sessionHydrated) return;
let cancelled = false;
setProjectLoaded(false);
setLoadError("");
const load = async () => {
const project = await loadCanvasProjectForEditing(projectId);
if (cancelled) return;
if (!project) {
navigate("/canvas", { replace: true });
return;
}
const applyRestoredProject = (restoredNodes: CanvasNodeData[], restoredSessions: CanvasAssistantSession[]) => {
if (cancelled) return;
const fallbackTheme = useThemeStore.getState().theme;
const restoredAppearance = project.appearance
? normalizeCanvasAppearance(project.appearance, fallbackTheme)
: canvasAppearanceForTheme(fallbackTheme);
const snapshot: CanvasHistorySnapshot = {
nodes: restoredNodes,
connections: project.connections,
chatSessions: restoredSessions,
activeChatId: project.activeChatId || null,
canvasAppearance: restoredAppearance,
backgroundMode: project.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE,
showImageInfo: project.showImageInfo || false,
};
nodesRef.current = snapshot.nodes;
connectionsRef.current = snapshot.connections;
viewportRef.current = project.viewport;
setNodes(snapshot.nodes);
setConnections(snapshot.connections);
setChatSessions(snapshot.chatSessions);
setActiveChatId(snapshot.activeChatId);
setCanvasAppearance(snapshot.canvasAppearance);
useThemeStore.getState().setTheme(canvasAppearanceBaseTheme(snapshot.canvasAppearance, fallbackTheme));
setBackgroundMode(snapshot.backgroundMode);
setShowImageInfo(snapshot.showImageInfo);
setViewport(project.viewport);
resetHistory(snapshot);
setProjectLoaded(true);
};
const restore = async () => {
const initialNodes = normalizeCanvasNodeTimestamps(resetInterruptedGeneration(project.nodes), {
createdAt: project.createdAt,
updatedAt: project.updatedAt,
});
const initialSessions = project.chatSessions || [];
// 先恢复可交互的节点和布局,媒体缓存/资源校验放到后台,避免首屏被远程资源拖住。
startTransition(() => applyRestoredProject(initialNodes, initialSessions));
// 画布媒体由节点自己的视口观察器按需加载;打开时遍历并解析全部节点会让大画布形成 N+1 资源读取。
void hydrateAssistantImages(initialSessions)
.then((hydratedSessions) => {
if (!cancelled) setChatSessions((current) => mergeHydratedSessions(current, hydratedSessions));
})
.catch(() => {
if (!cancelled) message.warning("部分助手会话素材恢复失败,已使用项目记录继续打开");
});
};
await restore();
};
void load().catch((error) => {
if (!cancelled) setLoadError(error instanceof Error ? error.message : "读取画布失败,请重试");
});
return () => {
cancelled = true;
};
}, [hydrated, sessionHydrated, loadAttempt, message, navigate, openProject, projectId, resetHistory, setActiveChatId, setBackgroundMode, setCanvasAppearance, setChatSessions, setConnections, setNodes, setShowImageInfo, setViewport]);
useEffect(() => {
if (!projectLoaded) return;
let cancelled = false;
listAddedSkills()
.then(({ skills }) => {
if (!cancelled) setAddedSkills(skills);
})
.catch(() => {
if (!cancelled) setAddedSkills([]);
});
return () => {
cancelled = true;
};
}, [projectLoaded]);
useEffect(() => {
if (!projectLoaded || historyPausedRef.current) return;
updateProject(projectId, { nodes, connections, chatSessions, activeChatId, appearance: canvasAppearance, backgroundMode, showImageInfo });
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connections, historyPausedRef, nodes, projectId, projectLoaded, showImageInfo, updateProject]);
useEffect(() => {
if (!projectLoaded) return;
if (viewportSaveTimerRef.current) clearTimeout(viewportSaveTimerRef.current);
viewportSaveTimerRef.current = setTimeout(() => {
updateProject(projectId, { viewport: viewportRef.current });
viewportSaveTimerRef.current = null;
}, 500);
return () => {
if (viewportSaveTimerRef.current) clearTimeout(viewportSaveTimerRef.current);
};
}, [projectId, projectLoaded, updateProject, viewport, viewportRef]);
useEffect(() => () => {
if (!projectLoaded) return;
if (viewportSaveTimerRef.current) clearTimeout(viewportSaveTimerRef.current);
updateProject(projectId, { viewport: viewportRef.current });
}, [projectId, projectLoaded, updateProject, viewportRef]);
const createAndOpenProject = useCallback(() => {
void createCanvasProjectWithRemoteSync(`自由画布 ${useCanvasStore.getState().projects.length + 1}`).then(({ id, syncError }) => {
if (syncError) message.warning(syncError instanceof Error ? `画布已在本地创建,云端同步失败:${syncError.message}` : "画布已在本地创建,云端同步失败");
navigate(`/canvas/${id}`);
});
}, [message, navigate]);
const deleteCurrentProject = useCallback(async () => {
const drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []);
try {
await deleteCanvasProjectsWithRemoteSync([projectId]);
} catch (error) {
message.error(error instanceof Error ? `删除画布失败:${error.message}` : "删除画布失败,请稍后重试");
return;
}
if (drawingIds.length) {
void Promise.all(drawingIds.map((drawingId) => removeCanvasDrawing(projectId, drawingId)))
.catch(() => message.warning("项目已删除,但部分本地绘图缓存清理失败"));
}
cleanupAssetImages();
navigate("/canvas");
}, [cleanupAssetImages, message, navigate, nodesRef, projectId]);
const renameCurrentProject = useCallback((title: string) => {
renameProject(projectId, title);
}, [projectId, renameProject]);
const saveCanvasProject = useCallback(async (): Promise<boolean> => {
try {
updateProject(projectId, {
nodes: nodesRef.current,
connections: connectionsRef.current,
chatSessions,
activeChatId,
appearance: canvasAppearance,
backgroundMode,
showImageInfo,
viewport: viewportRef.current,
directorScenes: currentProject?.directorScenes || [],
});
await flushCanvasStorePersistence();
} catch {
message.error("画布保存失败,请稍后重试");
return false;
}
try {
await saveRemoteUserDataNow();
message.success("画布布局和位置已保存");
} catch (error) {
// 本地 IndexedDB 已写入;云端失败只排队重试,不能把导入方的画布状态回滚。
message.warning(localSavedRemotePendingMessage("本地画布布局已保存", error));
}
return true;
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
const clearCanvasFiles = useCallback(() => {
cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] });
}, [cleanupCanvasFiles, projectId]);
return {
loadError,
retryLoad: () => setLoadAttempt((attempt) => attempt + 1),
addedSkills,
clearCanvasFiles,
createAndOpenProject,
currentProject,
deleteCurrentProject,
renameCurrentProject,
saveCanvasProject,
updateProject,
};
}
function mergeHydratedSessions(currentSessions: CanvasAssistantSession[], hydratedSessions: CanvasAssistantSession[]) {
const hydratedById = new Map(hydratedSessions.map((session) => [session.id, session]));
return currentSessions.map((session) => {
const hydrated = hydratedById.get(session.id);
if (!hydrated) return session;
const hydratedMessages = new Map(hydrated.messages.map((message) => [message.id, message]));
return {
...session,
messages: session.messages.map((message) => {
const hydratedMessage = hydratedMessages.get(message.id);
if (!hydratedMessage || !message.references?.length) return message;
const hydratedReferences = new Map((hydratedMessage.references || []).map((reference) => [reference.id, reference]));
return {
...message,
references: message.references.map((reference) => {
const hydratedReference = hydratedReferences.get(reference.id);
return hydratedReference ? { ...reference, dataUrl: hydratedReference.dataUrl, storageKey: hydratedReference.storageKey } : reference;
}),
};
}),
};
});
}