Expose portable scene bindings and an explicit React Flow peer adapter

This commit is contained in:
2026-11-09 15:07:18 +08:00
parent bceaf2eca6
commit 196ca4514e
19 changed files with 218 additions and 73 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
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 { createReactRenderers, type ReactRendererDefinition } from '@/lib/canvas-bindings/react-flow';
import { MaterialNode } from './material-node';
import { PlainTextNode } from './plain-text-node';
import { PlainImageNode } from './plain-image-node';
@@ -4,8 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore
import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, type Connection,type Viewport } from "@xyflow/react";
import { Copy, Download, Frame, Hand, ImagePlus, Maximize, Minus, Ratio, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react";
import type { Board } from "@/lib/workspace";
import type { ReactRenderers } from "@/lib/canvas-bindings/react-renderers";
import { CanvasRendererCommandsContext, type CanvasRendererCommands } from "@/lib/canvas-bindings/renderer-context";
import type { ReactRenderers } from "@/lib/canvas-bindings/react-flow";
import { CanvasRendererCommandsContext, type CanvasRendererCommands } from "@/lib/canvas-bindings/react-flow";
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";
@@ -4,10 +4,10 @@ import {useEffect,useMemo,useRef} from 'react';
import {Background,BackgroundVariant,ReactFlow,useReactFlow,type Viewport,type NodeTypes} from '@xyflow/react';
import type {Registry} from '@/lib/canvas-core/types';
import type {ModelPreview} from '@/lib/canvas-runtime/model-preview';
import type {ReactRenderers} from '@/lib/canvas-bindings/react-renderers';
import type {ReactRenderers} from '@/lib/canvas-bindings/react-flow';
import {createLoadingRendererCatalog} from '@/lib/canvas-bindings/renderers';
import {projectCanvas} from '@/lib/canvas-bindings/projection';
import {CanvasRendererCommandsContext,type CanvasRendererCommands} from '@/lib/canvas-bindings/renderer-context';
import {CanvasRendererCommandsContext,type CanvasRendererCommands} from '@/lib/canvas-bindings/react-flow';
import {UnsupportedNode,UnsupportedEdge} from './unsupported-elements';
import styles from './canvas.module.css';
@@ -1,7 +1,7 @@
"use client";
import { BaseEdge, Handle, Position, getBezierPath, type EdgeProps, type NodeProps } from "@xyflow/react";
import type { ProjectionNode } from "@/lib/canvas-bindings/projection";
import type {ReactFlowProjectionNode as ProjectionNode} from "@/lib/canvas-bindings/react-flow";
import type { CanvasEdgeModel } from "@/lib/canvas-v2";
import styles from "./canvas.module.css";
const positions={top:Position.Top,right:Position.Right,bottom:Position.Bottom,left:Position.Left};
@@ -2,7 +2,7 @@
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 { createReactRenderers, type CanvasRuntimeExtension } from "@/lib/canvas-bindings/react-flow";
import { notify } from "@/lib/notifications";
import { errorText } from "@/lib/api";
import { createRegistry, builtinComponents } from "@/lib/canvas-core/index";
+41
View File
@@ -0,0 +1,41 @@
type Point={x:number;y:number};
/** Compatibility renderer data only; never the authoritative element schema. */
export type AIConfig = { enabled?: boolean; modelId?: string; prompt?: string; propagate?: boolean; parameters?: Record<string, unknown> };
export type FramePort = { id: string; direction: "input" | "output"; nodeId: string; handle: string };
export type FramePresentation = { collapsed?: boolean; contentScale?: number; expandedSize?: { width: number; height: number }; collapsedSize?: { width: number; height: number } };
export type ElementKind = "text" | "image" | "script" | "plain-text" | "icon" | "frame" | "plain-image";
export type FrameLabelMode = "hidden" | "fixed" | "scaled";
export type TextStyle = { color?: string; background?: string; bold?: boolean; italic?: boolean; underline?: boolean; align?: "left" | "center" | "right"; fontFamily?: "sans" | "serif" | "mono"; lineHeight?: number };
export type CanvasNodeData = {
kind: ElementKind;
title: string;
text: string;
src?: string;
rotation?: number;
fontSize?: number;
textStyle?: TextStyle;
icon?: string;
frameId?: string;
labelMode?: FrameLabelMode;
ai?: AIConfig;
assetId?: string;
subgraph?: { boardId: string };
ports?: FramePort[];
presentation?: FramePresentation;
viewScale?: number;
};
export type MarkerShape = "none" | "open-arrow" | "triangle" | "circle" | "diamond" | "square";
export type EdgeMarker = { type: MarkerShape; size?: number; filled?: boolean };
export type BezierKnot = { id: string; position: Point; in: Point; out: Point; mode: "corner" | "smooth" | "symmetric" };
export type EdgeData = {
appearance?: { color?: string; width?: number; opacity?: number; line?: "solid" | "dashed" | "dotted"; head?: EdgeMarker; tail?: EdgeMarker };
animation?: { mode?: "off" | "always" | "executing"; effect?: "flow" | "dash" | "pulse"; direction?: "forward" | "reverse"; durationMs?: number; color?: string };
path?: { type: "bezier-chain"; startControl?: Point; endControl?: Point; knots: BezierKnot[] };
execution?: { enabled?: boolean };
};
/** Geometry input consumed by the compatibility layout, independent of UI nodes. */
export type CanvasGeometryNode={position:Point;width?:number;height?:number;style?:{width?:unknown;height?:unknown};measured?:{width?:number;height?:number};data:{kind:string;rotation?:number;fontSize?:number;viewScale?:number}};
+4 -3
View File
@@ -1,9 +1,10 @@
import type {CanvasNode} from '../canvas-elements.ts';
import type {CanvasGeometryNode} from './compat-types.ts';
import type {ProjectionNode} from './projection.ts';
import {nodeConnector,type Connector,type ConnectorSide} from '../canvas-geometry.ts';
type CompatNode=Pick<CanvasNode,'position'|'width'|'height'|'style'|'measured'|'data'>;
type CompatNode=CanvasGeometryNode&{data:ProjectionNode['data']};
/** Same card-border and port layout used by CanvasEdge. Never infer it for record-v1 DOM. */
export function compatConnector(node:CompatNode,handle:string|null|undefined,fallback:ConnectorSide):Connector{
const port=Array.isArray(node.data.ports)?node.data.ports.find(port=>port&&`port:${port.id}`===handle):undefined,hints=(node.data as CanvasNode['data']&{__headlessFallback?:{handles?:{id:string;side:ConnectorSide}[]}}).__headlessFallback?.handles,projected=Array.isArray(hints)?hints.find(hint=>hint?.id===handle&&['top','right','bottom','left'].includes(hint.side))?.side:undefined,side=projected??(['top','right','bottom','left'].includes(handle??'')?handle as ConnectorSide:port?.direction==='input'?'left':port?'right':fallback),result=nodeConnector(node as CanvasNode,side);
const port=Array.isArray(node.data.ports)?node.data.ports.find(port=>port&&`port:${port.id}`===handle):undefined,hints=(node.data as ProjectionNode['data']&{__headlessFallback?:{handles?:{id:string;side:ConnectorSide}[]}}).__headlessFallback?.handles,projected=Array.isArray(hints)?hints.find(hint=>hint?.id===handle&&['top','right','bottom','left'].includes(hint.side))?.side:undefined,side=projected??(['top','right','bottom','left'].includes(handle??'')?handle as ConnectorSide:port?.direction==='input'?'left':port?'right':fallback),result=nodeConnector(node,side);
if(port){const peers=node.data.ports!.filter(item=>item.direction===port.direction);result.point.y+=((peers.indexOf(port)+1)/(peers.length+1)-.5)*(node.measured?.height||node.height||400);}return result;
}
export type MeasuredNode={readonly internals:{readonly positionAbsolute:{x:number;y:number};readonly handleBounds?:{readonly source?:readonly MeasuredHandle[]|null;readonly target?:readonly MeasuredHandle[]|null}}};
+9
View File
@@ -0,0 +1,9 @@
/** Portable scene projection and hit queries: no React or React Flow imports. */
export * from './projection.ts';
export * from './geometry-cache.ts';
export * from './projection-cache.ts';
export * from './hit-query.ts';
export * from './renderers.ts';
export * from './connectors.ts';
export * from './resource-view.ts';
export type * from './compat-types.ts';
+4 -5
View File
@@ -5,16 +5,15 @@ 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';
import type { CanvasNode } from '../canvas-elements.ts';
import type { CanvasEdgeModel, EdgeData } from '../canvas-v2.ts';
import type {CanvasNodeData,EdgeData} from './compat-types.ts';
import { childCanvasTransform, toWorld, transformPath, type Transform } from '../canvas-core/coordinates.ts';
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: string;data:CanvasNode['data']&{__headlessFallback?:FallbackProjection;__headlessRecord?:ElementRecord;__headlessTransform?:Transform} };
export type ProjectionNode = {id:Id;type:string;position:Point;width?:number;height?:number;selected?:boolean;draggable?:boolean;zIndex?:number;measured?:{width?:number;height?:number};style?:{width?:number|string;height?:number|string;pointerEvents?:'none'|'auto'};data:CanvasNodeData&{__headlessFallback?:FallbackProjection;__headlessRecord?:ElementRecord;__headlessTransform?:Transform}};
export type ProjectionEdge=Omit<CanvasEdgeModel,'data'>&{data:EdgeData&{__headlessRecord?:ElementRecord;__headlessTransform?:Transform}};
export type ProjectionEdge={id:Id;type?:string;source:Id;target:Id;sourceHandle?:string|null;targetHandle?:string|null;selected?:boolean;zIndex?:number;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. */
@@ -38,7 +37,7 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
const child = queries?queries.relations.childCanvasByEntry.get(element.id):childByEntry.get(element.id), data = element.data;
const projected: ProjectionNode = { id: element.id, type, position: projectedGeometry?{...projectedGeometry.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&&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: projectedGeometry?.rotation??(g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNode['data'],
data: { ...(fallback?{}:data), kind: element.componentId as CanvasNodeData['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: projectedGeometry?.rotation??(g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNodeData,
};
nodes.push(projected); visible.add(element.id);
if (child) {
+10
View File
@@ -0,0 +1,10 @@
"use client";
/** Explicit framework boundary; consumers install the declared optional peers. */
export * from './react-renderers.ts';
export * from './renderer-context.ts';
import type {Node,Edge} from '@xyflow/react';
import type {ProjectionNode,ProjectionEdge} from './projection.ts';
/** Structural adapter types add framework-owned optional metadata, never model state. */
export type ReactFlowProjectionNode=Node<ProjectionNode['data'],string>&ProjectionNode;
export type ReactFlowProjectionEdge=Edge<ProjectionEdge['data']>&ProjectionEdge;
+2 -22
View File
@@ -1,27 +1,7 @@
import type { Node, XYPosition } from "@xyflow/react";
import type { AIConfig, FramePort, FramePresentation } from "./canvas-v2";
import type { ElementKind, CanvasNodeData } from "./canvas-bindings/compat-types";
export type {ElementKind,FrameLabelMode,TextStyle,CanvasNodeData} from "./canvas-bindings/compat-types";
export type ElementKind = "text" | "image" | "script" | "plain-text" | "icon" | "frame" | "plain-image";
export type FrameLabelMode = "hidden" | "fixed" | "scaled";
export type TextStyle = { color?: string; background?: string; bold?: boolean; italic?: boolean; underline?: boolean; align?: "left" | "center" | "right"; fontFamily?: "sans" | "serif" | "mono"; lineHeight?: number };
export type CanvasNodeData = {
kind: ElementKind;
title: string;
text: string;
src?: string;
rotation?: number;
fontSize?: number;
textStyle?: TextStyle;
icon?: string;
frameId?: string;
labelMode?: FrameLabelMode;
ai?: AIConfig;
assetId?: string;
subgraph?: { boardId: string };
ports?: FramePort[];
presentation?: FramePresentation;
viewScale?: number;
};
export type CanvasNode = Node<CanvasNodeData, "material" | "plainText" | "icon" | "frame" | "plainImage"> & { pendingUpload?: string };
export type ElementCapabilities = { resizable: boolean; rotatable: boolean; connectable: boolean; branchable: boolean };
+2 -2
View File
@@ -1,5 +1,5 @@
import type { XYPosition } from "@xyflow/react";
import type { CanvasNode } from "./canvas-elements";
import type {CanvasGeometryNode as CanvasNode} from "./canvas-bindings/compat-types.ts";
type XYPosition={x:number;y:number};
export type ResizeDirection = "top" | "right" | "bottom" | "left" | "top-left" | "top-right" | "bottom-left" | "bottom-right";
export type NodeTransform = { position?: XYPosition; width?: number; height?: number; rotation?: number; fontSize?: number };
+2 -12
View File
@@ -1,17 +1,7 @@
import type { Edge, XYPosition } from "@xyflow/react";
export type MarkerShape = "none" | "open-arrow" | "triangle" | "circle" | "diamond" | "square";
export type EdgeMarker = { type: MarkerShape; size?: number; filled?: boolean };
export type BezierKnot = { id: string; position: XYPosition; in: XYPosition; out: XYPosition; mode: "corner" | "smooth" | "symmetric" };
export type EdgeData = {
appearance?: { color?: string; width?: number; opacity?: number; line?: "solid" | "dashed" | "dotted"; head?: EdgeMarker; tail?: EdgeMarker };
animation?: { mode?: "off" | "always" | "executing"; effect?: "flow" | "dash" | "pulse"; direction?: "forward" | "reverse"; durationMs?: number; color?: string };
path?: { type: "bezier-chain"; startControl?: XYPosition; endControl?: XYPosition; knots: BezierKnot[] };
execution?: { enabled?: boolean };
};
import type {EdgeData,BezierKnot,AIConfig,FramePort,FramePresentation} from "./canvas-bindings/compat-types";
export type {MarkerShape,EdgeMarker,BezierKnot,EdgeData,AIConfig,FramePort,FramePresentation} from "./canvas-bindings/compat-types";
export type CanvasEdgeModel = Edge<EdgeData>;
export type AIConfig = { enabled?: boolean; modelId?: string; prompt?: string; propagate?: boolean; parameters?: Record<string, unknown> };
export type FramePort = { id: string; direction: "input" | "output"; nodeId: string; handle: string };
export type FramePresentation = { collapsed?: boolean; contentScale?: number; expandedSize?: { width: number; height: number }; collapsedSize?: { width: number; height: number } };
export const CANVAS_ACCEPT = "application/vnd.foundry.canvas+json;version=2";
export const wireID = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
export const numberIn = (value: unknown, min: number, max: number): value is number => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;