/** * 画布路由的客户端生命周期桥。把路径引用解析成board/profile,再选择新版engine、渐进预览或旧snapshot编辑器。 * 页面只持显示与导航状态,项目由RuntimeHost/WorkspaceRuntime拥有;卸载中断本页订阅,但已开始的项目加载可转后台完成。 */ "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"; /** 旧编辑器只在浏览器加载,避免服务端执行依赖DOM的画布实现。 */ const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), { ssr: false, loading: () =>

正在准备画布…

}); /** 新版编辑器同样独立客户端bundle,模型选择由loadCanvasEntry结果决定,而非界面手动猜格式。 */ const HeadlessGraphCanvas = dynamic(() => import("@/components/canvas/headless-graph-canvas"), { ssr: false, loading: () =>

正在准备画布…

}); /** 只读渐进预览先出现,已知内容足够后可升级为partial engine,保持原视口。 */ const LoadingGraphCanvas=dynamic(()=>import("@/components/canvas/loading-graph-canvas"),{ssr:false}); /** route将结果绑定到这次路径;engine/snapshot/migration分别来自实际选择的协议路径。 */ type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string; engine?: ProjectEngine; migration?:LegacyMigrationContext }; /** 当前页面只借用host与renderer,retry触发重新读取;不在页面state复制业务文档或history。 */ 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(null); const [preview,setPreview]=useState<{board:Board;value:LiveModelPreview;route:string}|null>(null); // 预览视口暂存于ref,切成可编辑视图时交作initialViewport;新路由重置,不当文档编辑保存。 const previewViewport=useRef(undefined); const [error, setError] = useState(""); const [retry, setRetry] = useState(0); const navigationGeneration=useRef(0); /** 每条路由/重试新建读取生命周期,清旧显示;active、AbortController和host身份护栏共同隔离迟到结果。 */ useEffect(() => { setLoaded(null);setPreview(null);previewViewport.current=undefined; setError(""); // 演示目录走旧本地画布,不发canonical读取、不绑定真实账户项目。 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(); // 保存开始时身份,并在profile解析后取得currentIdentity闭包,后续await返回均据此判断是否还能采用结果。 const startingIdentity=runtimeHost.identity();let currentIdentity:(()=>boolean)|undefined; // latest合并待采用的预览,pumping串行openPartial/persist,防快流把多个partial打开交错到同一项目。 let latest:{value:LiveModelPreview;board:Board;accountId:string}|undefined,pumping:Promise|undefined,partialEngine:ProjectEngine|undefined,partialRuntime:ReturnType|undefined; /** 后台任务失败仅在页面仍活跃时显示,离开页面不再更新本页error。 */ const reportBackground=(error:unknown)=>{if(active)setError(errorText(error));}; /** 已建立partial engine且身份仍有效时,交工作区继续完整读取;视图离开不等于关闭可恢复项目。 */ const completeInBackground=()=>{if(partialEngine?.loading.getState()&¤tIdentity?.())void partialRuntime?.resumeLoading(partialEngine.getSnapshot().document.projectId).catch(reportBackground);}; /** 串行采用最新预览,先openPartial再发布UI并保存;期间新预览覆盖latest等待槽而非并行改根。 */ 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; } // 导航代次递增传入共享loader,查询身份与取消订阅由host/loader维护;入口优先canonical防旧writer误开。 loadCanvasEntry({ request: api, workspaceRef: workspaceId, boardRef: boardId, registry: runtimeHost.registry, signal: controller.signal,navigationGeneration:++navigationGeneration.current, /** 只有本board已在前缀中才显示;有元素后推动partial engine,尚未建立engine时用只读预览。 */ 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);}}}, /** profile解析后绑定账户并读取模型;若开始后的authEpoch已变且当前账户不同于响应账户,拒绝旧导航认领。 */ 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; // 迁移完成回调只递增retry重开入口,不在旧组件内手工切换文档格式。 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; } // 服务已返回新模型但本地仍有同根pending时,查询并收尾原迁移记录,不生成新迁移请求。 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);} } // 完整模型到来先等partial采用链结束,再open同一项目,避免尾部覆盖尚在发布的前缀根。 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"); } }); // 清掉未采用预览并中断本页读取;已拥有的partial项目交后台,绝不在这里直接engine.close。 return () => { active = false;latest=undefined; controller.abort();completeInBackground(); }; }, [workspaceId, boardId, route, demo, retry, router, runtimeHost]); // 即使effect清理/新读取尚未完成,只显示route匹配的结果,不短暂闪出上个board。 const current = loaded?.route === route ? loaded : null; const currentPreview=preview?.route===route?preview:null; const board = current?.board??currentPreview?.board; /** 发布当前board元数据供侧栏标题使用,卸载/换board归还该Context显示状态,不删除服务端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]); // 本board在侧栏改名后优先读共享新标题;其它board的活动状态不能覆盖当前名称。 const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === board.workspace_id ? { ...board, title: activeBoard.title } : board; // 显示优先级:engine→只读预览→旧snapshot→读取占位;重试错误提示与已显示前缀可并存。 return
{displayBoard && current?.engine ? : displayBoard&¤tPreview ? {previewViewport.current=viewport;}} /> : displayBoard ? :
} {error ?
{error}
: !board &&

正在打开图谱…

}
; }