92 lines
8.3 KiB
TypeScript
92 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import type {Viewport} from '@xyflow/react';
|
|
import type {LiveModelPreview} from '@/lib/canvas-runtime/model-preview';
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { api, errorText } from "@/lib/api";
|
|
import { readDemoBoards } from "@/lib/demo-workspace";
|
|
import { matchesRef, type Board } from "@/lib/workspace";
|
|
import { loadCanvasEntry } from "@/lib/canvas-entry";
|
|
import {createBrowserCanvasMigration,type LegacyMigrationContext} from "@/lib/browser-canvas-migration";
|
|
import {readCanvasMigration} from "@/lib/canvas-migration";
|
|
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
|
|
import { useCanvasRuntimeHost, useCanvasRenderers } from "./canvas-runtime-host";
|
|
import type { CanvasSnapshot } from "@/lib/canvas-sync";
|
|
import styles from "./workspace.module.css";
|
|
import { useActiveBoard } from "./active-board";
|
|
import dynamic from "next/dynamic";
|
|
const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
|
|
|
|
const HeadlessGraphCanvas = dynamic(() => import("@/components/canvas/headless-graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
|
|
const LoadingGraphCanvas=dynamic(()=>import("@/components/canvas/loading-graph-canvas"),{ssr:false});
|
|
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string; engine?: ProjectEngine; migration?:LegacyMigrationContext };
|
|
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
|
|
const renderers=useCanvasRenderers();
|
|
const demo = useSearchParams().get("demo") === "1";
|
|
const router = useRouter();
|
|
const runtimeHost = useCanvasRuntimeHost();
|
|
const route = workspaceId + ":" + boardId;
|
|
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
|
|
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
|
const [preview,setPreview]=useState<{board:Board;value:LiveModelPreview;route:string}|null>(null);
|
|
const previewViewport=useRef<Viewport|undefined>(undefined);
|
|
const [error, setError] = useState("");
|
|
const [retry, setRetry] = useState(0);
|
|
const navigationGeneration=useRef(0);
|
|
useEffect(() => {
|
|
setLoaded(null);setPreview(null);previewViewport.current=undefined; setError("");
|
|
if (demo) {
|
|
const item = readDemoBoards(workspaceId).find((board) => matchesRef(board, boardId));
|
|
if (item) setLoaded({ board: item, route });
|
|
else setError("图谱不存在。");
|
|
return;
|
|
}
|
|
let active = true;
|
|
const controller = new AbortController();
|
|
const startingIdentity=runtimeHost.identity();let currentIdentity:(()=>boolean)|undefined;
|
|
let latest:{value:LiveModelPreview;board:Board;accountId:string}|undefined,pumping:Promise<void>|undefined,partialEngine:ProjectEngine|undefined,partialRuntime:ReturnType<typeof runtimeHost.workspace>|undefined;
|
|
const reportBackground=(error:unknown)=>{if(active)setError(errorText(error));};
|
|
const completeInBackground=()=>{if(partialEngine?.loading.getState()&¤tIdentity?.())void partialRuntime?.resumeLoading(partialEngine.getSnapshot().document.projectId).catch(reportBackground);};
|
|
function pump(){
|
|
if(pumping)return pumping;
|
|
const task=(async()=>{while(latest){const next=latest;latest=undefined;if(!currentIdentity?.())return;const runtime=runtimeHost.workspace(next.accountId,next.board.workspace_id);partialRuntime=runtime;const engine=await runtime.openPartial(next.value.document,next.value.versions,next.value.loading);partialEngine=engine;if(active&¤tIdentity?.()){setLoaded(previous=>previous?.route===route&&previous.engine===engine?previous:{board:next.board,engine,route});setPreview(null);}else{completeInBackground();return;}await engine.persist();}})();
|
|
pumping=task.finally(()=>{pumping=undefined;if(latest&&active)void pump().catch(reportBackground);});return pumping;
|
|
}
|
|
|
|
loadCanvasEntry({ request: api, workspaceRef: workspaceId, boardRef: boardId, registry: runtimeHost.registry, signal: controller.signal,navigationGeneration:++navigationGeneration.current,
|
|
onPreview:(value,board,profile)=>{if(active&¤tIdentity?.()&&value.document.canvases.has(board.id)){if(!partialEngine)setPreview({board,value,route});if(value.document.elements.size){latest={value,board,accountId:profile.id};void pump().catch(reportBackground);}}},
|
|
readModel:input=>{const identity=runtimeHost.identity();if(identity.authEpoch!==startingIdentity.authEpoch&&identity.accountId!==input.accountId)throw new Error('identity_changed');currentIdentity=runtimeHost.bindAccount(input.accountId);return runtimeHost.loadModel(input.accountId,input.workspaceId,input.canvasId,{signal:input.signal,navigationGeneration:input.navigationGeneration,onPreview:input.onPreview});},
|
|
}).then(async result => {
|
|
if (!active||currentIdentity&&!currentIdentity()) return;
|
|
if (result.mode === 'legacy') {
|
|
const migration:LegacyMigrationContext={accountId:result.profile.id,rootId:result.migration?.rootId,registry:runtimeHost.registry,isCurrentIdentity:()=>!!currentIdentity?.(),onApplied:()=>{if(active&¤tIdentity?.())setRetry(value=>value+1);}};
|
|
setLoaded({board:result.board,snapshot:result.snapshot,route,migration});setPreview(null);return;
|
|
}
|
|
const pending=readCanvasMigration(localStorage,{accountId:result.profile.id,workspaceId:result.board.workspace_id});
|
|
if(pending?.phase==='pending'&&pending.rootId===result.model.document.projectId){
|
|
try{await createBrowserCanvasMigration({scope:{accountId:result.profile.id,workspaceId:result.board.workspace_id},registry:runtimeHost.registry,isCurrentIdentity:()=>active&&!!currentIdentity?.(),signal:controller.signal}).resume();}
|
|
catch(error){reportBackground(error);}
|
|
}
|
|
while(pumping)await pumping;
|
|
if(!active||!currentIdentity?.())return;
|
|
const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions,{sourceToken:result.model.sourceToken});
|
|
if (active&&(!currentIdentity||currentIdentity())){setLoaded({ board: result.board, engine, route });setPreview(null);}
|
|
})
|
|
.catch((error) => { if (active) { setError(errorText(error)); if (error.message === "invalid_session") router.replace("/login"); } });
|
|
return () => { active = false;latest=undefined; controller.abort();completeInBackground(); };
|
|
}, [workspaceId, boardId, route, demo, retry, router, runtimeHost]);
|
|
const current = loaded?.route === route ? loaded : null;
|
|
const currentPreview=preview?.route===route?preview:null;
|
|
const board = current?.board??currentPreview?.board;
|
|
useEffect(() => {
|
|
if (board) setActiveBoard({ id: board.id, slug: board.slug, workspace_id: board.workspace_id, workspace_ref: workspaceId, title: board.title });
|
|
return () => setActiveBoard(null);
|
|
}, [board, workspaceId, setActiveBoard]);
|
|
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === board.workspace_id ? { ...board, title: activeBoard.title } : board;
|
|
return <main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!current && !error}>
|
|
{displayBoard && current?.engine ? <HeadlessGraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} engine={current.engine} renderers={renderers} initialViewport={previewViewport.current} /> : displayBoard&¤tPreview ? <LoadingGraphCanvas key={displayBoard.workspace_id+":"+displayBoard.id} preview={currentPreview.value} canvasId={displayBoard.id} workspaceId={displayBoard.workspace_id} registry={runtimeHost.registry} renderers={renderers} onViewport={viewport=>{previewViewport.current=viewport;}} /> : displayBoard ? <GraphCanvas key={(current?.migration?.accountId??"demo")+":"+displayBoard.workspace_id+":"+displayBoard.id} board={displayBoard} snapshot={current?.snapshot} migration={current?.migration} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
|
|
{error ? <div className={styles.graphStatus} role="alert">{error} <button onClick={() => setRetry((value) => value + 1)}>重新加载</button></div> : !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
|
|
</main>;
|
|
}
|