Install canvas renderers through validated host extensions
This commit is contained in:
@@ -25,6 +25,10 @@ function connector(node: InternalNode<CanvasNode>, handle: string | null | undef
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function nativeConnector(x:number,y:number,side:ConnectorSide):Connector{
|
||||
const normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}};
|
||||
return {point:{x,y},normal:normals[side]};
|
||||
}
|
||||
function Marker({ id, value, color }: { id: string; value?: EdgeMarker; color: string }) {
|
||||
const type = value?.type || "none";
|
||||
if (type === "none") return null;
|
||||
@@ -47,7 +51,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
if (current?.capture.hasPointerCapture(current.pointerId)) current.capture.releasePointerCapture(current.pointerId);
|
||||
}, []);
|
||||
if (!source || !target) return null;
|
||||
const from = connector(source, props.sourceHandleId, "right"), to = connector(target, props.targetHandleId, "left");
|
||||
const from = (source.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.sourceX,props.sourceY,props.sourcePosition) : connector(source, props.sourceHandleId, "right"), to = (target.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.targetX,props.targetY,props.targetPosition) : connector(target, props.targetHandleId, "left");
|
||||
const data = props.data || {}, appearance = data.appearance || {}, animation = data.animation || {};
|
||||
const segments = curveSegments(from, to, data.path), path = curvePath(segments);
|
||||
const midpoint = cubicPoint(segments[Math.floor(segments.length / 2)], .5);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
import type { NodeProps } from '@xyflow/react';
|
||||
import type { CanvasNode } from '@/lib/canvas-elements';
|
||||
import { builtinRendererDefinitions } from '@/lib/canvas-bindings/renderers';
|
||||
import { createReactRenderers, type ReactRendererDefinition } from '@/lib/canvas-bindings/react-renderers';
|
||||
import { MaterialNode } from './material-node';
|
||||
import { PlainTextNode } from './plain-text-node';
|
||||
import { PlainImageNode } from './plain-image-node';
|
||||
import { FrameNode } from './frame-node';
|
||||
import { IconNode } from './icon-node';
|
||||
import { CanvasEdge } from './canvas-edge';
|
||||
function HeadlessFrameNode(props:NodeProps<CanvasNode>){return <FrameNode {...props} directAnchors />;}
|
||||
const nodes={material:MaterialNode,plainText:PlainTextNode,plainImage:PlainImageNode,frame:HeadlessFrameNode,icon:IconNode};
|
||||
export const defaultCanvasRenderers=createReactRenderers(builtinRendererDefinitions.map(definition=>({...definition,render:definition.structure==='node'?nodes[definition.rendererId as keyof typeof nodes]:CanvasEdge}) as ReactRendererDefinition));
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react";
|
||||
import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, type Connection, type NodeProps } from "@xyflow/react";
|
||||
import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, type Connection } from "@xyflow/react";
|
||||
import { Copy, Download, Frame, Hand, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react";
|
||||
import type { Board } from "@/lib/workspace";
|
||||
import type { CanvasNode } from "@/lib/canvas-elements";
|
||||
import type { ReactRenderers } from "@/lib/canvas-bindings/react-renderers";
|
||||
import { CanvasRendererCommandsContext, type CanvasRendererCommands } from "@/lib/canvas-bindings/renderer-context";
|
||||
import { defaultCanvasRenderers } from "./canvas-renderers";
|
||||
import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types";
|
||||
import { equal } from "@/lib/canvas-core/values";
|
||||
import { createProjectionCache } from "@/lib/canvas-bindings/projection-cache";
|
||||
@@ -21,22 +23,11 @@ import { HeadlessAIPanel } from "./headless-ai-panel";
|
||||
import { HeadlessImports } from "./headless-imports";
|
||||
import { HeadlessRecoveryDialog } from "./headless-recovery-dialog";
|
||||
import { UnsupportedNode, UnsupportedEdge } from "./unsupported-elements";
|
||||
import { CanvasEdge } from "./canvas-edge";
|
||||
import { MaterialNode } from "./material-node";
|
||||
import { PlainTextNode } from "./plain-text-node";
|
||||
import { PlainImageNode } from "./plain-image-node";
|
||||
import { FrameNode } from "./frame-node";
|
||||
import { IconNode } from "./icon-node";
|
||||
import { ImagePreviews } from "./canvas-image";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
function HeadlessFrameNode(props: NodeProps<CanvasNode>) { return <FrameNode {...props} directAnchors />; }
|
||||
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, plainImage: PlainImageNode, frame: HeadlessFrameNode, icon: IconNode,
|
||||
unsupported: UnsupportedNode,
|
||||
};
|
||||
const edgeTypes = { default: CanvasEdge, unsupported: UnsupportedEdge };
|
||||
const json = (value: unknown): Data => JSON.parse(JSON.stringify(value));
|
||||
function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: Board; viewId: Id }) {
|
||||
function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngine; board: Board; viewId: Id; renderers:ReactRenderers }) {
|
||||
const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]);
|
||||
const root = useSyncExternalStore(subscribe, engine.getSnapshot, engine.getSnapshot);
|
||||
const sessions = useSyncExternalStore(engine.sessions.subscribe, engine.sessions.getSnapshot, engine.sessions.getSnapshot);
|
||||
@@ -55,14 +46,15 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
|
||||
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
|
||||
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id, Id>());
|
||||
const projection = useMemo(() => {
|
||||
const result = projectCanvas(root.document, view.canvasId, engine.editor.registry, view.selection, engine.sessions.effectiveGeometry, sessions.connectionGesture);
|
||||
const result = projectCanvas(root.document, view.canvasId, engine.editor.registry, view.selection, engine.sessions.effectiveGeometry, sessions.connectionGesture,renderers.catalog);
|
||||
// Preserve renderer measurements across metadata/preview updates. Dropping them
|
||||
// makes React Flow discard handle bounds and unmount active edge controls.
|
||||
const visibleIds = new Set(result.nodes.map(node => node.id));
|
||||
for (const id of measurements.current.keys()) if (!visibleIds.has(id)) measurements.current.delete(id);
|
||||
for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height && projectionCache.current!.canRetainMeasurement(engine,root.document,view.canvasId,node)) node.measured = cached.measured; }
|
||||
return projectionCache.current!.reuse(engine,root.document,view.canvasId,result);
|
||||
}, [engine, root.document, sessions, view]);
|
||||
}, [engine, root.document, sessions, view,renderers]);
|
||||
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
|
||||
const current = useRef({ root, projection, view, engine }); current.current = { root, projection, view, engine };
|
||||
const report = useCallback((result: Result | { enabled: boolean; code?: string }) => {
|
||||
if ('status' in result && result.status === 'rejected' || 'enabled' in result && !result.enabled) setError(errorText(new Error(result.code)));
|
||||
@@ -174,6 +166,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
|
||||
},
|
||||
|
||||
}),[engine,viewId,report]);
|
||||
const rendererCommands=useMemo<CanvasRendererCommands>(()=>({dispatch(command,options){const result=engine.editor.dispatch(command,{label:options?.label,groupId:options?.groupId});report(result);return result;},transaction(callback,options){const result=engine.editor.transaction(callback,{label:options?.label,groupId:options?.groupId});report(result);return result;}}),[engine,report]);
|
||||
if (!liveView) return <p role="status">画布已关闭</p>;
|
||||
return <div className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas tabIndex={-1}
|
||||
onFocusCapture={event => { if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setEditorFocused(true); } }}
|
||||
@@ -192,6 +185,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
|
||||
else if (event.key.toLowerCase() === 'v') engine.sessions.tool(viewId, 'select');
|
||||
}}>
|
||||
<ImagePreviews.Provider value={imagePreviews}>
|
||||
<CanvasRendererCommandsContext.Provider value={rendererCommands}>
|
||||
<NodeEditing.Provider value={nodeEditing}>
|
||||
<EdgeEditing.Provider value={edgeEditing}>
|
||||
<ReactFlow<ProjectionNode> onlyRenderVisibleElements={cullVisible} nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose}
|
||||
@@ -218,7 +212,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
|
||||
onConnect={connect} onPaneClick={() => engine.sessions.select(viewId, [])}>
|
||||
<Background variant={BackgroundVariant.Dots} gap={24} size={1} color="#ded7ce" />
|
||||
</ReactFlow>
|
||||
</EdgeEditing.Provider></NodeEditing.Provider></ImagePreviews.Provider>
|
||||
</EdgeEditing.Provider></NodeEditing.Provider></CanvasRendererCommandsContext.Provider></ImagePreviews.Provider>
|
||||
<div className={styles.topRight}><div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
|
||||
<IconButton icon={Undo2} label="撤销" disabled={!root.history.undo.length} onClick={() => report(engine.editor.history.undo())} /><IconButton icon={Redo2} label="重做" disabled={!root.history.redo.length} onClick={() => report(engine.editor.history.redo())} />
|
||||
<IconButton icon={Copy} label={copying || preparingCopy ? "正在准备复制" : "深复制选中内容"} disabled={copying || preparingCopy || !view.selection.size} onClick={() => {
|
||||
@@ -241,8 +235,8 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
|
||||
{error && <div className={styles.error} role="alert">{error} <button onClick={() => setError('')}>关闭</button></div>}
|
||||
</div>;
|
||||
}
|
||||
export default function HeadlessGraphCanvas({ engine, board }: { engine: ProjectEngine; board: Board }) {
|
||||
export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers }) {
|
||||
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false);
|
||||
useEffect(() => { const detach = engine.sessions.attach(viewId, board.id); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id]);
|
||||
return attached ? <ReactFlowProvider><CanvasView engine={engine} board={board} viewId={viewId} /></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
||||
return attached ? <ReactFlowProvider><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} /></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Document, ElementRecord, Geometry, Id, Json, Point, Registry } from '../canvas-core/types.ts';
|
||||
import { builtinRendererCatalog, type RendererCatalog } from './renderers.ts';
|
||||
import { connectionPathValid } from '../canvas-core/features.ts';
|
||||
import { unitScale } from '../canvas-core/units.ts';
|
||||
import { fault } from '../canvas-core/values.ts';
|
||||
@@ -9,12 +10,13 @@ import { childCanvasTransform, toWorld, transformPath, type Transform } from '..
|
||||
export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts';
|
||||
const origin:Transform={x:0,y:0,scale:1,rotation:0};
|
||||
export type FallbackProjection = {reason:'unknown-component'|'unsupported-version'|'renderer-unavailable';componentId:string;dataVersion:number;handles:{id:string;side:'top'|'right'|'bottom'|'left'}[]};
|
||||
export type ProjectionNode = Omit<CanvasNode, 'type'|'data'> & { type: NonNullable<CanvasNode['type']> | 'unsupported';data:CanvasNode['data']&{__headlessFallback?:FallbackProjection} };
|
||||
const nodeTypes: Record<string, NonNullable<CanvasNode['type']>> = { text:'material', image:'material', script:'material', 'plain-text': 'plainText', 'plain-image': 'plainImage', frame: 'frame', icon: 'icon' };
|
||||
export type ProjectionNode = Omit<CanvasNode, 'type'|'data'> & { type: string;data:CanvasNode['data']&{__headlessFallback?:FallbackProjection;__headlessRecord?:ElementRecord;__headlessTransform?:Transform} };
|
||||
|
||||
export type ProjectionEdge=Omit<CanvasEdgeModel,'data'>&{data:EdgeData&{__headlessRecord?:ElementRecord;__headlessTransform?:Transform}};
|
||||
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
|
||||
/** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */
|
||||
export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet<Id> | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null) {
|
||||
export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet<Id> | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null, renderers:RendererCatalog=builtinRendererCatalog) {
|
||||
if (!document.canvases.has(canvasId)) throw fault('canvas_not_loaded', [canvasId]);
|
||||
const transforms = new Map<Id, Transform>([[canvasId, origin]]), hiddenBy = new Map<Id, Id>(), childByEntry = new Map<Id, Id>();
|
||||
for (const canvas of document.canvases.values()) if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas.id);
|
||||
@@ -26,13 +28,13 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
|
||||
for (const element of members.get(id) ?? []) if (element.structure === 'node') {
|
||||
const g = geometry(element.id) ?? element.geometry, center = toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 });
|
||||
const width = g.width * transform.scale, height = g.height * transform.scale, known = registry.resolve(element) === 'known';
|
||||
const resolved=registry.resolve(element),renderer=element.dataVersion===1?nodeTypes[element.componentId]:undefined;
|
||||
const resolved=registry.resolve(element),renderer=renderers.resolve(element);
|
||||
const fallback:FallbackProjection|undefined=!known||!renderer?{reason:resolved==='known'?'renderer-unavailable':resolved,componentId:element.componentId,dataVersion:element.dataVersion,handles:[]}:undefined;
|
||||
const type = fallback ? 'unsupported' : renderer!;
|
||||
const type = fallback ? 'unsupported' : renderer!.rendererId;
|
||||
const child = childByEntry.get(element.id), data = element.data;
|
||||
const projected: ProjectionNode = { id: element.id, type, position: { x: center.x - width / 2, y: center.y - height / 2 }, width, height, selected: selected.has(element.id), draggable: known && registry.supports(element, 'geometry.move'), zIndex: element.componentId === 'frame' ? depth - 100 : depth * 2,
|
||||
style: { width, height, ...(element.componentId === 'frame'&&!fallback ? { pointerEvents: 'none' as const } : {}) },
|
||||
data: { ...(fallback?{}:data), kind: element.componentId as CanvasNode['data']['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: !fallback&&typeof data.text === 'string' ? data.text : '', __headlessFallback:fallback, rotation: (g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNode['data'],
|
||||
style: { width, height, ...(element.componentId === 'frame'&&!fallback&&renderer?.projection==='compat-v1' ? { pointerEvents: 'none' as const } : {}) },
|
||||
data: { ...(fallback?{}:data), kind: element.componentId as CanvasNode['data']['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: !fallback&&typeof data.text === 'string' ? data.text : '', __headlessFallback:fallback, __headlessRecord:!fallback&&renderer?.projection==='record-v1'?element:undefined,__headlessTransform:!fallback&&renderer?.projection==='record-v1'?transform:undefined, rotation: (g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNode['data'],
|
||||
};
|
||||
nodes.push(projected); visible.add(element.id);
|
||||
if (child) {
|
||||
@@ -46,16 +48,16 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
|
||||
}
|
||||
};
|
||||
visit(canvasId, 0);
|
||||
const edges: CanvasEdgeModel[] = [];
|
||||
const edges: ProjectionEdge[] = [];
|
||||
for (const element of document.elements.values()) if (element.structure === 'connection' && transforms.has(element.ownerCanvasId)) {
|
||||
const endpoint = (id: Id) => visible.has(id) ? id : hiddenBy.get(document.elements.get(id)?.ownerCanvasId ?? '');
|
||||
const source = endpoint(element.source.elementId), target = endpoint(element.target.elementId);
|
||||
if (!source || !target || source === target) continue;
|
||||
const fallbackEdge=registry.resolve(element)!=='known'||element.componentId!=='connection'||element.dataVersion!==1;
|
||||
const path = fallbackEdge ? undefined : transformPath(edgePreview?.target.id === element.id ? edgePreview.path : element.path, transforms.get(element.ownerCanvasId)!);
|
||||
const renderer=renderers.resolve(element),fallbackEdge=registry.resolve(element)!=='known'||!renderer;
|
||||
const path = fallbackEdge||renderer?.projection==='record-v1' ? undefined : transformPath(edgePreview?.target.id === element.id ? edgePreview.path : element.path, transforms.get(element.ownerCanvasId)!);
|
||||
const renderPath = path!==undefined&&connectionPathValid(path) ? path : undefined;
|
||||
const { path: unusedPath, ...edgeData } = element.data; void unusedPath;
|
||||
edges.push({ id: element.id, ...(fallbackEdge?{type:'unsupported'}:{}), source, target, sourceHandle: source === element.source.elementId ? element.source.portId : undefined, targetHandle: target === element.target.elementId ? element.target.portId : undefined, selected: selected.has(element.id), data: fallbackEdge?{}:{ ...edgeData, ...(renderPath ? { path: renderPath } : {}) } as EdgeData });
|
||||
edges.push({ id: element.id, ...(fallbackEdge?{type:'unsupported'}:renderer!.rendererId==='default'?{}:{type:renderer!.rendererId}), source, target, sourceHandle: source === element.source.elementId ? element.source.portId : undefined, targetHandle: target === element.target.elementId ? element.target.portId : undefined, selected: selected.has(element.id), data: fallbackEdge?{}:renderer?.projection==='record-v1'?{__headlessRecord:element,__headlessTransform:transforms.get(element.ownerCanvasId)}:{ ...edgeData, ...(renderPath ? { path: renderPath } : {}) } as EdgeData });
|
||||
}
|
||||
// Handles come from known envelope endpoints, never from opaque component payloads.
|
||||
const nodesById=new Map(nodes.map(node=>[node.id,node]));
|
||||
@@ -65,7 +67,7 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
|
||||
for(const edge of edges)for(const end of ['source','target'] as const){
|
||||
const fallback=fallbackNodes.get(edge[end]);
|
||||
if(!fallback){
|
||||
if(edge.type==='unsupported'){
|
||||
if(edge.type==='unsupported'&&!nodesById.get(edge[end])?.data.__headlessRecord){
|
||||
const node=nodesById.get(edge[end]),field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field];
|
||||
if(!handleIds.has(edge[end])){const ids=new Set(['top','right','bottom','left']);if(Array.isArray(node?.data.ports))for(const port of node.data.ports)if(port&&typeof port.id==='string')ids.add(`port:${port.id}`);handleIds.set(edge[end],ids);}
|
||||
if(handle&&!handleIds.get(edge[end])!.has(handle))edge[field]=undefined;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { NodeTypes, EdgeTypes } from '@xyflow/react';
|
||||
import type { ComponentDefinition, Registry } from '../canvas-core/types.ts';
|
||||
import { fault } from '../canvas-core/values.ts';
|
||||
import { createRendererCatalog, type RendererDescriptor, type RendererCatalog } from './renderers.ts';
|
||||
export type ReactRendererDefinition=RendererDescriptor&({readonly structure:'node';readonly render:NodeTypes[string]}|{readonly structure:'connection';readonly render:EdgeTypes[string]});
|
||||
export type ReactRenderers={readonly catalog:RendererCatalog;readonly definitions:readonly ReactRendererDefinition[];readonly nodeTypes:NodeTypes;readonly edgeTypes:EdgeTypes};
|
||||
export type CanvasRuntimeExtension={readonly components?:readonly ComponentDefinition[];readonly renderers?:readonly ReactRendererDefinition[]};
|
||||
export function createReactRenderers(definitions:readonly ReactRendererDefinition[],registry?:Registry):ReactRenderers{
|
||||
const catalog=createRendererCatalog(definitions),nodes:NodeTypes=Object.create(null),edges:EdgeTypes=Object.create(null),captured:ReactRendererDefinition[]=[];
|
||||
for(let index=0;index<definitions.length;index++){
|
||||
const definition=definitions[index],descriptor=catalog.definitions[index],render=definition.render,tag=render&&typeof render==='object'?(render as {$$typeof?:symbol}).$$typeof:undefined;
|
||||
if(typeof render!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(tag as symbol))throw fault('invalid_react_renderer');
|
||||
if(registry){const model=registry.components.get(descriptor.componentId);if(!model||model.dataVersion!==descriptor.dataVersion||model.structure!==descriptor.structure)throw fault('renderer_model_mismatch',[descriptor.componentId]);}
|
||||
const table=descriptor.structure==='node'?nodes:edges;
|
||||
if(Object.hasOwn(table,descriptor.rendererId)&&table[descriptor.rendererId]!==render)throw fault('renderer_id_conflict',[descriptor.rendererId]);
|
||||
Object.defineProperty(table,descriptor.rendererId,{value:render,enumerable:true,configurable:true});captured.push(Object.freeze({...descriptor,render}) as ReactRendererDefinition);
|
||||
}
|
||||
return Object.freeze({catalog,definitions:Object.freeze(captured),nodeTypes:Object.freeze(nodes),edgeTypes:Object.freeze(edges)});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { Transaction } from '../canvas-core/editor.ts';
|
||||
import type { Command, Result } from '../canvas-core/types.ts';
|
||||
export type RendererCommandOptions={label?:string;groupId?:string};
|
||||
export type CanvasRendererCommands={dispatch(command:Command,options?:RendererCommandOptions):Result;transaction(callback:(transaction:Transaction)=>unknown,options?:RendererCommandOptions):Result};
|
||||
export const CanvasRendererCommandsContext=createContext<CanvasRendererCommands|null>(null);
|
||||
/** Trusted installed renderers submit normal validated commands, never write a second model. */
|
||||
export function useCanvasRendererCommands(){const commands=useContext(CanvasRendererCommandsContext);if(!commands)throw Error('Canvas renderer commands are unavailable');return commands;}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ElementRecord } from '../canvas-core/types.ts';
|
||||
import { fault } from '../canvas-core/values.ts';
|
||||
export type RendererDescriptor={readonly componentId:string;readonly dataVersion:number;readonly structure:'node'|'connection';readonly rendererId:string;readonly projection?:'compat-v1'|'record-v1'};
|
||||
export type RendererCatalog={readonly definitions:readonly RendererDescriptor[];resolve(element:Pick<ElementRecord,'componentId'|'dataVersion'|'structure'>):RendererDescriptor|undefined};
|
||||
const key=(value:Pick<RendererDescriptor,'componentId'|'dataVersion'|'structure'>)=>JSON.stringify([value.componentId,value.dataVersion,value.structure]);
|
||||
export function createRendererCatalog(definitions:readonly RendererDescriptor[]):RendererCatalog{
|
||||
const entries=new Map<string,RendererDescriptor>();
|
||||
for(const value of definitions){
|
||||
if(!value||typeof value.componentId!=='string'||!value.componentId||!Number.isSafeInteger(value.dataVersion)||value.dataVersion<1||!['node','connection'].includes(value.structure)||typeof value.rendererId!=='string'||!value.rendererId||['unsupported','__proto__','prototype','constructor'].includes(value.rendererId)||value.projection!==undefined&&!['compat-v1','record-v1'].includes(value.projection))throw fault('invalid_renderer_definition');
|
||||
const id=key(value);if(entries.has(id))throw fault('duplicate_renderer_definition',[value.componentId]);entries.set(id,Object.freeze({componentId:value.componentId,dataVersion:value.dataVersion,structure:value.structure,rendererId:value.rendererId,projection:value.projection??'record-v1'}));
|
||||
}
|
||||
return Object.freeze({definitions:Object.freeze([...entries.values()]),resolve:(element:Pick<ElementRecord,'componentId'|'dataVersion'|'structure'>)=>entries.get(key(element))});
|
||||
}
|
||||
export const builtinRendererDefinitions:readonly RendererDescriptor[]=Object.freeze([
|
||||
...['text','image','script'].map(componentId=>Object.freeze({componentId,dataVersion:1,structure:'node' as const,rendererId:'material',projection:'compat-v1' as const})),
|
||||
...Object.entries({'plain-text':'plainText','plain-image':'plainImage',frame:'frame',icon:'icon'}).map(([componentId,rendererId])=>Object.freeze({componentId,dataVersion:1,structure:'node' as const,rendererId,projection:'compat-v1' as const})),
|
||||
Object.freeze({componentId:'connection',dataVersion:1,structure:'connection',rendererId:'default',projection:'compat-v1'}),
|
||||
]);
|
||||
export const builtinRendererCatalog=createRendererCatalog(builtinRendererDefinitions);
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
import { Handle, Position, BaseEdge, getBezierPath, type NodeProps, type EdgeProps } from '@xyflow/react';
|
||||
import { defineComponent, editable, movable } from '@/lib/canvas-core/index';
|
||||
import type { ProjectionNode, ProjectionEdge } from '@/lib/canvas-bindings/projection';
|
||||
import type { CanvasRuntimeExtension } from '@/lib/canvas-bindings/react-renderers';
|
||||
import { useCanvasRendererCommands } from '@/lib/canvas-bindings/renderer-context';
|
||||
function Meter({data}:NodeProps<ProjectionNode>){
|
||||
const commands=useCanvasRendererCommands(),record=data.__headlessRecord!;
|
||||
return <div data-renderer-fixture="meter" style={{width:'100%',height:'100%',background:'white',border:'1px solid #ded7ce',padding:16}}>
|
||||
<span>Metric {String(record.data.metric)}</span><button className="nodrag" aria-label={`Increase ${record.id}`} onClick={event=>{event.stopPropagation();commands.transaction(tx=>{tx.dispatch({type:'element.data',target:{id:record.id,incarnation:record.incarnation},patch:{metric:Number(record.data.metric)+1}});tx.dispatch({type:'element.data',target:{id:record.id,incarnation:record.incarnation},patch:{raw:{retained:true,edited:true}}});},{label:'Increase metric'});}}>Increase</button>
|
||||
<Handle type="source" id="custom-out" position={Position.Right}/><Handle type="source" id="custom-in" position={Position.Left}/>
|
||||
</div>;
|
||||
}
|
||||
function MeterEdge(props:EdgeProps<ProjectionEdge>){const [path]=getBezierPath(props);return <g data-renderer-fixture="metric-edge"><title>Metric {String(props.data?.__headlessRecord?.data.metric)}</title><BaseEdge id={props.id} path={path}/></g>;}
|
||||
export const fixtureExtension:CanvasRuntimeExtension={
|
||||
components:[defineComponent({id:'fixture:meter',dataVersion:1,structure:'node',validate:data=>Number.isSafeInteger(data.metric),features:[editable(),movable()],copy:{references:[],omit:[]}}),defineComponent({id:'fixture:metric-edge',dataVersion:1,structure:'connection',validate:data=>typeof data.metric==='number',features:[],copy:{references:[],omit:[]}})],
|
||||
renderers:[{componentId:'fixture:meter',dataVersion:1,structure:'node',rendererId:'fixture-meter',render:Meter},{componentId:'fixture:metric-edge',dataVersion:1,structure:'connection',rendererId:'fixture-metric-edge',render:MeterEdge}],
|
||||
};
|
||||
@@ -87,6 +87,11 @@ let next, chrome, socket, logs = '';
|
||||
try {
|
||||
await cp(join(source, 'src'), join(directory, 'src'), { recursive: true });
|
||||
await cp(join(source, 'public'), join(directory, 'public'), { recursive: true });
|
||||
if(!performanceMode){
|
||||
await copyFile(join(source,'tests/headless/renderer-extension.tsx'),join(directory,'src/components/canvas/renderer-fixture.tsx'));
|
||||
const hostFile=join(directory,'src/components/workspace/canvas-runtime-host.tsx'),host=await readFile(hostFile,'utf8');assert.ok(host.includes('extensions=[]'),'host extension installation point changed');await writeFile(hostFile,host.replace('"use client";','"use client";\nimport {fixtureExtension} from "@/components/canvas/renderer-fixture";').replace('extensions=[]','extensions=[fixtureExtension]'));
|
||||
}
|
||||
|
||||
for (const file of ['package.json', 'tsconfig.json', 'postcss.config.mjs']) await copyFile(join(source, file), join(directory, file));
|
||||
await symlink(join(source, 'node_modules'), join(directory, 'node_modules'));
|
||||
next = spawn(process.execPath, [join(source, 'node_modules/next/dist/bin/next'), 'dev', '--webpack', '--hostname', '127.0.0.1', '--port', String(port)], { cwd: directory, detached: true, env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1', FOUNDRY_API_ORIGIN: `http://127.0.0.1:${upstream.address().port}`, NEXT_PUBLIC_FOUNDRY_PROJECT: 'endoleap' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
@@ -286,6 +291,11 @@ try {
|
||||
await writeFile(join(tmpdir(),'headless-opaque-renderer.png'),Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64'));
|
||||
console.log('PASS unknown types and future versions render safe read-only placeholders with custom/partition connections, preserve opaque payloads through edits and recovery, and never load opaque asset fields');
|
||||
|
||||
const extensionRoot=id(810),meter={...node('meter-one',extensionRoot,350),componentId:'fixture:meter',data:{metric:3,raw:{retained:true}}},meterTwo={...meter,id:'meter-two',geometry:{...meter.geometry,x:750},data:{metric:9}},meterEdge={id:'meter-edge',incarnation:1,structure:'connection',componentId:'fixture:metric-edge',dataVersion:1,ownerCanvasId:extensionRoot,source:{elementId:meter.id,portId:'custom-out'},target:{elementId:meterTwo.id,portId:'custom-in'},data:{metric:55},path:{type:'metric-curve',retained:true}};
|
||||
documents.set(extensionRoot,createDocument(extensionRoot,[{id:extensionRoot,parent:null}],[meter,meterTwo,meterEdge,{...meterEdge,id:'meter-builtin-edge',componentId:'connection',data:{},path:null}]));versions.set(extensionRoot,1);boards.push({id:extensionRoot,workspace_id:workspace,kind:'board',version:1,title:'Extension board'});await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${extensionRoot}`});await until(`document.querySelectorAll('[data-renderer-fixture="meter"]').length===2&&document.querySelectorAll('[data-renderer-fixture="metric-edge"]').length===1`,'host-installed custom node/edge renderers did not mount');await until(`document.querySelectorAll('.react-flow__edge').length===2`,'built-in edge did not connect custom renderer ports');assert.equal(await evaluate(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length`),0);await click('Increase meter-one');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data,{metric:4,raw:{retained:true,edited:true}});assert.deepEqual(documents.get(extensionRoot).elements.get('meter-edge'),meterEdge);
|
||||
await click('撤销');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==3;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,3);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data.raw,{retained:true});await click('重做');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,4);await call('Page.reload');await until(`document.querySelector('[data-id="meter-one"] [data-renderer-fixture="meter"]')?.textContent.includes('Metric 4')&&!!document.querySelector('[data-renderer-fixture="metric-edge"]')`,'registered renderer edits did not recover');
|
||||
console.log('PASS Host-installed custom model/node/edge definitions render without core type switches; command-backed edits preserve raw fields and recover through undo/redo/reload');
|
||||
|
||||
await click('账户和工作空间');await until(`!!document.querySelector('#workspace-account:popover-open')`,'account menu did not open');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='退出登录').click()`);
|
||||
await until(`location.pathname==='/login'&&!document.body.innerText.includes('This page couldn’t load')`,'logout did not safely detach the view');
|
||||
console.log('PASS logout retires the runtime before dropping its view without a render error');
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRendererCatalog,builtinRendererDefinitions} from '../../src/lib/canvas-bindings/renderers.ts';
|
||||
import {createReactRenderers} from '../../src/lib/canvas-bindings/react-renderers.ts';
|
||||
import {createDocument,createRegistry,builtinComponents,defineComponent,movable} from '../../src/lib/canvas-core/index.ts';
|
||||
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
|
||||
const descriptor={componentId:'plugin:meter',dataVersion:1,structure:'node',rendererId:'meter'};
|
||||
const definition=defineComponent({id:'plugin:meter',dataVersion:1,structure:'node',validate:data=>typeof data.metric==='number',features:[movable()]}),registry=createRegistry([...builtinComponents(),definition]);
|
||||
const node={id:'meter',incarnation:1,ownerCanvasId:'root',structure:'node',componentId:'plugin:meter',dataVersion:1,data:{metric:7,title:{opaque:true},__headlessRecord:{untrusted:true}},geometry:{x:0,y:0,width:200,height:100,rotation:0}};
|
||||
test('renderer catalog captures exact version/structure mappings without React values or mutable descriptors',()=>{
|
||||
const input={...descriptor,render(){}};const catalog=createRendererCatalog([input]);input.rendererId='changed';assert.equal(catalog.resolve(node).rendererId,'meter');assert.equal(catalog.resolve({...node,dataVersion:2}),undefined);assert.equal(catalog.resolve({...node,structure:'connection'}),undefined);assert.equal(catalog.definitions[0].render,undefined);assert.ok(Object.isFrozen(catalog.definitions[0]));assert.throws(()=>createRendererCatalog([descriptor,descriptor]),/duplicate_renderer_definition/);assert.throws(()=>createRendererCatalog([{...descriptor,rendererId:'unsupported'}]),/invalid_renderer_definition/);
|
||||
});
|
||||
test('React assembly preserves aliases only for the same component and rejects invalid renderers or model contracts',()=>{
|
||||
const render=()=>null,assembly=createReactRenderers([{...descriptor,render}],registry);assert.equal(assembly.nodeTypes.meter,render);assert.ok(Object.isFrozen(assembly.nodeTypes));assert.equal(assembly.edgeTypes.meter,undefined);
|
||||
assert.throws(()=>createReactRenderers([{...descriptor,render:null}]),/invalid_react_renderer/);assert.throws(()=>createReactRenderers([{...descriptor,render},{...descriptor,componentId:'other',render:()=>null}]),/renderer_id_conflict/);assert.throws(()=>createReactRenderers([{...descriptor,dataVersion:2,render}],registry),/renderer_model_mismatch/);
|
||||
let reads=0;const getter=createReactRenderers([{...descriptor,get render(){reads++;return render;}}]);assert.equal(reads,1);assert.equal(getter.definitions[0].render,getter.nodeTypes.meter);
|
||||
const alias=createReactRenderers([{...descriptor,render},{...descriptor,componentId:'other',render}]);assert.equal(alias.nodeTypes.meter,render);const memo={$$typeof:Symbol.for('react.memo'),type:render};assert.equal(createReactRenderers([{...descriptor,render:memo}]).nodeTypes.meter,memo);
|
||||
});
|
||||
test('installed node and edge renderers receive original records and transforms while absent schemas still fall back',()=>{
|
||||
const customEdge=defineComponent({id:'plugin:edge',dataVersion:1,structure:'connection',validate:()=>true,features:[]}),r=createRegistry([...builtinComponents(),definition,customEdge]),other={...node,id:'second',geometry:{...node.geometry,x:300}},edge={id:'edge',incarnation:1,structure:'connection',componentId:'plugin:edge',dataVersion:1,ownerCanvasId:'root',source:{elementId:'meter',portId:'custom-out'},target:{elementId:'second',portId:'custom-in'},path:{type:'custom',retained:true},data:{path:{own:true},metric:99}},source=createDocument('project',[{id:'root',parent:null}],[node,other,edge]),catalog=createRendererCatalog([...builtinRendererDefinitions,descriptor,{...descriptor,componentId:'plugin:edge',structure:'connection',rendererId:'meter-edge'}]),p=projectCanvas(source,'root',r,new Set(),()=>undefined,null,catalog);
|
||||
assert.equal(p.nodes[0].type,'meter');assert.equal(p.nodes[0].data.__headlessFallback,undefined);assert.equal(p.nodes[0].data.__headlessRecord,source.elements.get('meter'));assert.deepEqual(p.nodes[0].data.__headlessRecord.data.title,{opaque:true});assert.equal(p.nodes[0].data.__headlessTransform.scale,1);assert.equal(p.edges[0].type,'meter-edge');assert.equal(p.edges[0].data.__headlessRecord,source.elements.get('edge'));assert.deepEqual(p.edges[0].data.__headlessRecord.path,{type:'custom',retained:true});
|
||||
const missing=projectCanvas(source,'root',r,new Set(),()=>undefined);assert.equal(missing.nodes[0].type,'unsupported');assert.equal(missing.edges[0].type,'unsupported');const future=createDocument('project',[{id:'root',parent:null}],[{...node,dataVersion:2}]);assert.equal(projectCanvas(future,'root',r,new Set(),()=>undefined,null,catalog).nodes[0].type,'unsupported');
|
||||
});
|
||||
Reference in New Issue
Block a user