feat(canvas): gate legacy editing and hand off explicit migration safely

This commit is contained in:
2026-11-11 18:05:22 +08:00
parent 1f98d8d61f
commit 24c4592060
11 changed files with 440 additions and 33 deletions
+13 -3
View File
@@ -8,6 +8,8 @@ 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";
@@ -18,7 +20,7 @@ const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), {
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 };
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";
@@ -57,7 +59,15 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
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') { setLoaded({ board: result.board, snapshot: result.snapshot, route });setPreview(null); return; }
if (result.mode === 'legacy') {
const migration:LegacyMigrationContext={accountId:result.profile.id,rootId:result.migration?.rootId,registry:runtimeHost.registry,isCurrentIdentity:()=>!!currentIdentity?.(),onApplied:()=>{if(active&&currentIdentity?.())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});
@@ -75,7 +85,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
}, [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&&currentPreview ? <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={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} snapshot={current?.snapshot} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
{displayBoard && current?.engine ? <HeadlessGraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} engine={current.engine} renderers={renderers} initialViewport={previewViewport.current} /> : displayBoard&&currentPreview ? <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>;
}