From 627a043d928071ae2021e22ce3650043a173335e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 19:08:58 +0800 Subject: [PATCH] Install canvas renderers through validated host extensions --- src/components/canvas/canvas-edge.tsx | 6 +++- src/components/canvas/canvas-renderers.tsx | 14 ++++++++ .../canvas/headless-graph-canvas.tsx | 32 ++++++++----------- .../workspace/canvas-runtime-host.tsx | 15 ++++++--- src/components/workspace/graph-page.tsx | 5 +-- src/lib/canvas-bindings/projection.ts | 26 ++++++++------- src/lib/canvas-bindings/react-renderers.ts | 19 +++++++++++ src/lib/canvas-bindings/renderer-context.ts | 9 ++++++ src/lib/canvas-bindings/renderers.ts | 19 +++++++++++ tests/headless/renderer-extension.tsx | 18 +++++++++++ tests/headless/renderer.integration.mjs | 10 ++++++ tests/headless/renderers.test.mjs | 23 +++++++++++++ 12 files changed, 157 insertions(+), 39 deletions(-) create mode 100644 src/components/canvas/canvas-renderers.tsx create mode 100644 src/lib/canvas-bindings/react-renderers.ts create mode 100644 src/lib/canvas-bindings/renderer-context.ts create mode 100644 src/lib/canvas-bindings/renderers.ts create mode 100644 tests/headless/renderer-extension.tsx create mode 100644 tests/headless/renderers.test.mjs diff --git a/src/components/canvas/canvas-edge.tsx b/src/components/canvas/canvas-edge.tsx index 844d395..2fd137d 100644 --- a/src/components/canvas/canvas-edge.tsx +++ b/src/components/canvas/canvas-edge.tsx @@ -25,6 +25,10 @@ function connector(node: InternalNode, 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) { 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); diff --git a/src/components/canvas/canvas-renderers.tsx b/src/components/canvas/canvas-renderers.tsx new file mode 100644 index 0000000..fc20aec --- /dev/null +++ b/src/components/canvas/canvas-renderers.tsx @@ -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){return ;} +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)); diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 8629f40..0679570 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -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) { return ; } -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()); const imageInput = useRef(null), groups = useRef(new Map()); 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(()=>({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

画布已关闭

; return
{ 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'); }}> + 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, [])}> - +
report(engine.editor.history.undo())} /> report(engine.editor.history.redo())} /> { @@ -241,8 +235,8 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B {error &&
{error}
}
; } -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 ? :

正在准备画布…

; + return attached ? :

正在准备画布…

; } diff --git a/src/components/workspace/canvas-runtime-host.tsx b/src/components/workspace/canvas-runtime-host.tsx index 33bb0ef..7bf4090 100644 --- a/src/components/workspace/canvas-runtime-host.tsx +++ b/src/components/workspace/canvas-runtime-host.tsx @@ -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(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 {children}; + return {children}; } export function useCanvasRuntimeHost() { const host = useContext(Context); diff --git a/src/components/workspace/graph-page.tsx b/src/components/workspace/graph-page.tsx index c8a760d..2db2f77 100644 --- a/src/components/workspace/graph-page.tsx +++ b/src/components/workspace/graph-page.tsx @@ -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: () =>

正在准备画布…

}); 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
- {displayBoard && current?.engine ? : displayBoard ? :
} + {displayBoard && current?.engine ? : displayBoard ? :
} {error ?
{error}
: !board &&

正在打开图谱…

}
; } diff --git a/src/lib/canvas-bindings/projection.ts b/src/lib/canvas-bindings/projection.ts index 3e3c40f..4b39daa 100644 --- a/src/lib/canvas-bindings/projection.ts +++ b/src/lib/canvas-bindings/projection.ts @@ -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 & { type: NonNullable | 'unsupported';data:CanvasNode['data']&{__headlessFallback?:FallbackProjection} }; -const nodeTypes: Record> = { text:'material', image:'material', script:'material', 'plain-text': 'plainText', 'plain-image': 'plainImage', frame: 'frame', icon: 'icon' }; +export type ProjectionNode = Omit & { type: string;data:CanvasNode['data']&{__headlessFallback?:FallbackProjection;__headlessRecord?:ElementRecord;__headlessTransform?:Transform} }; + +export type ProjectionEdge=Omit&{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 | { 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 | { 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([[canvasId, origin]]), hiddenBy = new Map(), childByEntry = new Map(); 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; diff --git a/src/lib/canvas-bindings/react-renderers.ts b/src/lib/canvas-bindings/react-renderers.ts new file mode 100644 index 0000000..271a0c1 --- /dev/null +++ b/src/lib/canvas-bindings/react-renderers.ts @@ -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;indexunknown,options?:RendererCommandOptions):Result}; +export const CanvasRendererCommandsContext=createContext(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;} diff --git a/src/lib/canvas-bindings/renderers.ts b/src/lib/canvas-bindings/renderers.ts new file mode 100644 index 0000000..a14c1b8 --- /dev/null +++ b/src/lib/canvas-bindings/renderers.ts @@ -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):RendererDescriptor|undefined}; +const key=(value:Pick)=>JSON.stringify([value.componentId,value.dataVersion,value.structure]); +export function createRendererCatalog(definitions:readonly RendererDescriptor[]):RendererCatalog{ + const entries=new Map(); + 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)=>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); diff --git a/tests/headless/renderer-extension.tsx b/tests/headless/renderer-extension.tsx new file mode 100644 index 0000000..ee1d76b --- /dev/null +++ b/tests/headless/renderer-extension.tsx @@ -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){ + const commands=useCanvasRendererCommands(),record=data.__headlessRecord!; + return
+ Metric {String(record.data.metric)} + +
; +} +function MeterEdge(props:EdgeProps){const [path]=getBezierPath(props);return Metric {String(props.data?.__headlessRecord?.data.metric)};} +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}], +}; diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 0df34f3..33407c6 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -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'); diff --git a/tests/headless/renderers.test.mjs b/tests/headless/renderers.test.mjs new file mode 100644 index 0000000..c6f23f1 --- /dev/null +++ b/tests/headless/renderers.test.mjs @@ -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'); +});