Install canvas renderers through validated host extensions

This commit is contained in:
2026-11-08 19:08:58 +08:00
parent 361e636ce7
commit 627a043d92
12 changed files with 157 additions and 39 deletions
@@ -1,6 +1,8 @@
"use client";
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { defaultCanvasRenderers } from "@/components/canvas/canvas-renderers";
import { createReactRenderers, type CanvasRuntimeExtension } from "@/lib/canvas-bindings/react-renderers";
import { notify } from "@/lib/notifications";
import { errorText } from "@/lib/api";
import { createRegistry, builtinComponents } from "@/lib/canvas-core/index";
@@ -10,10 +12,12 @@ import { createBrowserStorage } from "@/lib/canvas-runtime/storage";
import { createFoundryAssets, createFoundryPersistence, createFoundryTransport, createFoundryRuns } from "@/lib/canvas-service/index";
const Context = createContext<RuntimeHost | null>(null);
export function CanvasRuntimeHost({ children }: { children: ReactNode }) {
const [host] = useState(() => {
const registry = createRegistry(builtinComponents());
return createRuntimeHost({ registry,
const Renderers=createContext(defaultCanvasRenderers);
export function useCanvasRenderers(){return useContext(Renderers);}
export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactNode;extensions?:readonly CanvasRuntimeExtension[] }) {
const [{host,renderers}] = useState(() => {
const registry = createRegistry([...builtinComponents(),...extensions.flatMap(extension=>extension.components??[])]),renderers=createReactRenderers([...defaultCanvasRenderers.definitions,...extensions.flatMap(extension=>extension.renderers??[])],registry);
const host=createRuntimeHost({ registry,
workspace(accountId, workspaceId, authEpoch, isCurrentIdentity) {
return createWorkspaceRuntime({ accountId, workspaceId, authEpoch, isCurrentIdentity, registry, storage: createBrowserStorage(),
adapters(rootId, current) {
@@ -25,6 +29,7 @@ export function CanvasRuntimeHost({ children }: { children: ReactNode }) {
},
onError: error => notify({ id: "canvas-host-close", title: "本地修改尚未保存成功", description: errorText(error), level: "error" }),
});
return {host,renderers};
});
const lifetime = useRef(0);
useEffect(() => {
@@ -32,7 +37,7 @@ export function CanvasRuntimeHost({ children }: { children: ReactNode }) {
// Strict Mode replays effects synchronously; only a real detach retires the host.
return () => { queueMicrotask(() => { if (lifetime.current === generation) void host.close().catch(error => console.error("[canvas-host]", error.message)); }); };
}, [host]);
return <Context.Provider value={host}>{children}</Context.Provider>;
return <Context.Provider value={host}><Renderers.Provider value={renderers}>{children}</Renderers.Provider></Context.Provider>;
}
export function useCanvasRuntimeHost() {
const host = useContext(Context);
+3 -2
View File
@@ -8,7 +8,7 @@ import { matchesRef, type Board } from "@/lib/workspace";
import { loadCanvasEntry } from "@/lib/canvas-entry";
import { createFoundryTransport } from "@/lib/canvas-service/foundry";
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
import { useCanvasRuntimeHost } from "./canvas-runtime-host";
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";
@@ -18,6 +18,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> });
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string; engine?: ProjectEngine };
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();
@@ -55,7 +56,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={!board && !error}>
{displayBoard && current?.engine ? <HeadlessGraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} engine={current.engine} /> : 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} /> : displayBoard ? <GraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} snapshot={current?.snapshot} /> : <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>;
}