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
+33 -4
View File
@@ -12,6 +12,8 @@ Public exports:
| `/runtime` | Project/Workspace/Host, storage and service port types, recovery, scoped loader and compute/display pools |
| `/service` | Foundry protocol codecs and transport adapters; credentials remain in the host |
| `/browser` | Explicit IndexedDB/Web Locks and module Worker adapters |
| `/bindings` | Portable projection, renderer descriptors, caches, connectors and hit queries |
| `/react-flow` | Explicit client adapter, renderer command context and framework node/edge types |
The portable entries have no React, Next, React Flow or other runtime package
dependency. They use standard platform APIs such as AbortController, Blob, URL,
@@ -30,10 +32,9 @@ TypeScript consumers without the DOM library, and exercises compiled browser ada
and module Worker URLs in isolated Chromium. The browser checks load the emitted JS
directly, without TypeScript stripping or a Next bundler.
This preview does not yet package the React renderer/bindings. Their legacy
projection DTO types still require an explicit adapter boundary. No registry
publication, release support promise, server deployment or full-goal completion is
implied by this local package.
The React adapter has an explicit entry and optional peer dependencies; portable
consumers do not load it. No registry publication, release support promise, server
deployment or full-goal completion is implied by this local package.
Experimental partial-model runtime: pass a validated prefix plus `loading` in
`EngineOptions`. Version 1 uses recovery format 7; version 2 carries snapshot-bound direct canvas
@@ -180,3 +181,31 @@ Undo/redo, source failure, quota retry and late chunks do not re-upload bytes or
restore deleted incarnations. Earlier loading/recovery versions retain their old
restrictions; future format 13 is rejected. Generation, cross-project durable depot
sharing and broader lifecycle/acceptance work remain separate unfinished goals.
The package now exposes `/bindings` for pure projection, renderer descriptors,
geometry/projection caches, hit queries, connectors and resource-view handles.
`projectCanvas` consumes the captured Document/Registry/queries and optional gesture
geometry. It returns view DTOs; it does not own another document, history or queue.
Projection DTOs and compatibility card/edge hints no longer import React Flow types
or the legacy element classes. Legacy views re-export the same shared data types.
`/react-flow` is the explicit client/framework entry. It exports renderer factories,
the command context/hook, and `ReactFlowProjectionNode`/`ReactFlowProjectionEdge`
structural adapter types. React, React DOM and React Flow are optional peers at the
package level, required when a host opts into this entry. Core/runtime/service and
pure bindings remain usable without them. The declared floors are React/React DOM
19.3.0 and React Flow 12.12.1; tests use the locally installed compatible versions,
not a claim to have tested every allowed version.
A host retains the immutable renderer catalog and creates view-local projection
caches; installed components submit commands through its scoped Provider. Attaching
or removing UI does not replace the ProjectEngine or terminate durable jobs.
Compatibility DTOs are rendering hints, not the model schema or a migration format.
The existing application now uses the same explicit framework entry.
The packed Node consumer projects and hits its custom counter using only public
exports, with DOM globals and external runtime dependency resolution blocked. Its
NodeNext type check excludes DOM libraries. A separate peer consumer renders a
registered component, verifies its command context, and checks typed NodeProps and
EdgeProps with the declared peers. Browser tests still execute the compiled ESM
storage and Worker artifacts independently of Next.js/TypeScript stripping.
+5 -1
View File
@@ -6,6 +6,7 @@ import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { createDocument, createRegistry, builtinComponents, defineComponent, editable, movable, resizable, rectangleGeometry, applyChanges, validateDocument } from '@endoleap/headless-canvas/core';
import { openProjectEngine } from '@endoleap/headless-canvas/runtime';
import {projectCanvas,createRendererCatalog,builtinRendererDefinitions,createGeometryProjectionCache,hitProjectedCanvas} from '@endoleap/headless-canvas/bindings';
import { createFoundryPersistence, loadFoundryModel } from '@endoleap/headless-canvas/service';
const scope={accountId:'10000000-0000-4000-8000-000000000001',workspaceId:'10000000-0000-4000-8000-000000000002',projectId:'10000000-0000-4000-8000-000000000003'};
@@ -60,5 +61,8 @@ try{
const loaded=await loadFoundryModel(transport,scope.workspaceId,scope.projectId,registry);assert.equal(loaded.status,'ready');assert.equal(loaded.document.elements.size,2);
const snapshot=engine.getSnapshot(),undoDepth=snapshot.history.undo.length;await engine.close();engine=await open();await engine.settle();
assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(engine.getSnapshot().document.elements.get(target.id).data.count,2);assert.equal(engine.getSnapshot().history.undo.length,undoDepth);assert.equal(engine.sessions.getSnapshot().views.size,0);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].every(record=>record.phase==='resolved'));
console.log(JSON.stringify({status:'ok',elements:2,count:2,undoDepth,reopened:true,transientViews:0,protocol:2,requests:requests.size,sourceFiles:files.size}));
const current=engine.getSnapshot(),queries=engine.editor.getQueries(),renderers=createRendererCatalog([...builtinRendererDefinitions,{componentId:'example.counter',dataVersion:1,structure:'node',rendererId:'counter',projection:'record-v1'}]);
const projection=projectCanvas(current.document,scope.projectId,registry,new Set(),()=>undefined,null,renderers,{queries,geometry:createGeometryProjectionCache()});assert.equal(projection.nodes.length,2);assert.ok(projection.nodes.every(node=>node.type==='counter'&&node.data.__headlessRecord));
const hit=hitProjectedCanvas(projection,queries,registry,'portable-view',{space:'scene',point:{x:40,y:10},zoom:1});assert.equal(hit.complete,true);assert.ok(hit.hits.some(hit=>hit.target.id===target.id));
console.log(JSON.stringify({status:'ok',portableBindings:true,elements:2,count:2,undoDepth,reopened:true,transientViews:0,protocol:2,requests:requests.size,sourceFiles:files.size}));
}finally{await engine.close();storage.close();}
+53 -8
View File
@@ -3,13 +3,58 @@
"version": "0.1.0",
"private": true,
"type": "module",
"engines": { "node": ">=22" },
"exports": {
".": { "types": "./modules/canvas-core/index.d.ts", "import": "./modules/canvas-core/index.js" },
"./core": { "types": "./modules/canvas-core/index.d.ts", "import": "./modules/canvas-core/index.js" },
"./runtime": { "types": "./modules/canvas-runtime/index.d.ts", "import": "./modules/canvas-runtime/index.js" },
"./browser": { "types": "./modules/canvas-runtime/browser.d.ts", "import": "./modules/canvas-runtime/browser.js" },
"./service": { "types": "./modules/canvas-service/index.d.ts", "import": "./modules/canvas-service/index.js" }
"engines": {
"node": ">=22"
},
"files": ["modules", "consumer.mjs", "README.md"]
"exports": {
".": {
"types": "./modules/canvas-core/index.d.ts",
"import": "./modules/canvas-core/index.js"
},
"./core": {
"types": "./modules/canvas-core/index.d.ts",
"import": "./modules/canvas-core/index.js"
},
"./runtime": {
"types": "./modules/canvas-runtime/index.d.ts",
"import": "./modules/canvas-runtime/index.js"
},
"./browser": {
"types": "./modules/canvas-runtime/browser.d.ts",
"import": "./modules/canvas-runtime/browser.js"
},
"./service": {
"types": "./modules/canvas-service/index.d.ts",
"import": "./modules/canvas-service/index.js"
},
"./bindings": {
"types": "./modules/canvas-bindings/index.d.ts",
"import": "./modules/canvas-bindings/index.js"
},
"./react-flow": {
"types": "./modules/canvas-bindings/react-flow.d.ts",
"import": "./modules/canvas-bindings/react-flow.js"
}
},
"files": [
"modules",
"consumer.mjs",
"README.md"
],
"peerDependencies": {
"react": ">=19.3.0 <20",
"react-dom": ">=19.3.0 <20",
"@xyflow/react": ">=12.12.1 <13"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"@xyflow/react": {
"optional": true
}
}
}
+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;
+33 -4
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
import { mkdtemp, readFile, realpath, rm, writeFile, mkdir, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -17,7 +17,7 @@ try {
const metadata=JSON.parse(await readFile(join(packageRoot,'package.json'),'utf8'));
assert.equal(metadata.private,true);assert.equal(metadata.type,'module');
assert.equal(Object.keys(metadata.dependencies??{}).length,0);
assert.equal(Object.keys(metadata.peerDependencies??{}).length,0);
assert.deepEqual(Object.keys(metadata.peerDependencies??{}).sort(),['@xyflow/react','react','react-dom']);assert.ok(Object.values(metadata.peerDependenciesMeta).every(value=>value.optional===true));
// Resolve every import through the packed artifact, never checkout node_modules.
await writeFile(join(temporary,'guard-loader.mjs'),`
import {fileURLToPath} from 'node:url';
@@ -36,11 +36,12 @@ try {
for(const name of ['window','document','navigator','indexedDB','Worker'])Object.defineProperty(globalThis,name,{configurable:true,get(){throw Error('Browser global: '+name);}});
`);
const report=JSON.parse(run(process.execPath,['--import',join(temporary,'guard.mjs'),join(packageRoot,'consumer.mjs')],{cwd:temporary}));
assert.equal(report.status,'ok');assert.equal(report.reopened,true);assert.equal(report.elements,2);
assert.equal(report.status,'ok');assert.equal(report.portableBindings,true);assert.equal(report.reopened,true);assert.equal(report.elements,2);
// No DOM library: browser adapter declarations must not leak into portable ports.
await writeFile(join(packageRoot,'consumer-types.mts'),`
import {createRegistry,builtinComponents,createDocument} from '@endoleap/headless-canvas/core';
import {openProjectEngine,type StoragePort,type StorageScope,type LoadingState} from '@endoleap/headless-canvas/runtime';
import {projectCanvas,hitProjectedCanvas,type ProjectionNode,type ProjectionEdge} from '@endoleap/headless-canvas/bindings';
import {createFoundryPersistence,type ServiceTransport} from '@endoleap/headless-canvas/service';
const registry=createRegistry(builtinComponents());
export async function completePartial(engine:Awaited<ReturnType<typeof openProjectEngine>>,document:Parameters<typeof openProjectEngine>[0]['document'],versions:ReadonlyMap<string,number>,state:LoadingState){
@@ -60,14 +61,42 @@ try {
const complete:boolean=queries.membersResult(engine.getSnapshot().document.projectId).complete;
return {id,complete,projectComplete:queries.coverage.projectComplete};
}
export function project(engine:Awaited<ReturnType<typeof openProjectEngine>>){const snapshot=engine.getSnapshot(),queries=engine.editor.getQueries();const scene=projectCanvas(snapshot.document,snapshot.document.projectId,registry,new Set(),()=>undefined);const nodes:ProjectionNode[]=scene.nodes,edges:ProjectionEdge[]=scene.edges;return {nodes,edges,hit:hitProjectedCanvas(scene,queries,registry,'view',{space:'scene',point:{x:0,y:0},zoom:1})};}
export function open(scope:StorageScope,storage:StoragePort,transport:ServiceTransport){
return openProjectEngine({scope,storage,registry,document:createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]),versions:new Map([[scope.projectId,1]]),persistence:createFoundryPersistence({registry,transport,rootId:scope.projectId,workspaceId:scope.workspaceId})});
}
`);
run(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'--noEmit','--strict','--target','ES2022','--module','NodeNext','--moduleResolution','NodeNext','--lib','ES2022','--types','node','--typeRoots',join(root,'node_modules/@types'),join(packageRoot,'consumer-types.mts')],{cwd:temporary});
// A separate consumer opts into framework peers. The portable guard above
// remains strict and has already proved /bindings does not resolve any peers.
await mkdir(join(packageRoot,'node_modules/@xyflow'),{recursive:true});
for(const peer of ['react','react-dom','@xyflow/react'])await symlink(await realpath(join(root,'node_modules',peer)),join(packageRoot,'node_modules',peer),'dir');
await mkdir(join(packageRoot,'node_modules/@types'),{recursive:true});
for(const peer of ['react','react-dom'])await symlink(await realpath(join(root,'node_modules/@types',peer)),join(packageRoot,'node_modules/@types',peer),'dir');
await writeFile(join(packageRoot,'react-consumer.mjs'),`
import assert from 'node:assert/strict';
import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';
import {createReactRenderers,CanvasRendererCommandsContext,useCanvasRendererCommands} from '@endoleap/headless-canvas/react-flow';
import {createRegistry,builtinComponents} from '@endoleap/headless-canvas/core';
const registry=createRegistry(builtinComponents());let dispatched=0,seen;const commands={dispatch(){dispatched++;return{status:'applied',revision:1,operationIds:[]};},transaction(){throw Error('unused');}};
function View(){seen=useCanvasRendererCommands();return React.createElement('span',null,'peer renderer');}
const renderers=createReactRenderers([{componentId:'text',dataVersion:1,structure:'node',rendererId:'fixture',render:View}],registry);
const html=renderToStaticMarkup(React.createElement(CanvasRendererCommandsContext.Provider,{value:commands},React.createElement(renderers.nodeTypes.fixture)));
assert.equal(html,'<span>peer renderer</span>');assert.strictEqual(seen,commands);seen.dispatch({type:'element.data',target:{id:'fixture',incarnation:1},patch:{title:'command'}});assert.equal(dispatched,1);console.log('PASS explicit React peer renderer/context consumer');
`);
const react=run(process.execPath,[join(packageRoot,'react-consumer.mjs')],{cwd:temporary});process.stdout.write(react);
await writeFile(join(packageRoot,'react-consumer-types.mts'),`
import {createElement} from 'react';import type {NodeProps,EdgeProps} from '@xyflow/react';
import {createReactRenderers,type ReactFlowProjectionNode,type ReactFlowProjectionEdge} from '@endoleap/headless-canvas/react-flow';
function Card({data}:NodeProps<ReactFlowProjectionNode>){return createElement('div',null,data.title);}
function Link({data}:EdgeProps<ReactFlowProjectionEdge>){return createElement('span',null,data?.__headlessRecord?.id);}
export const adapter=createReactRenderers([{componentId:'text',dataVersion:1,structure:'node',rendererId:'card',render:Card},{componentId:'connection',dataVersion:1,structure:'connection',rendererId:'link',render:Link}]);
`);
run(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'--noEmit','--strict','--target','ES2022','--module','NodeNext','--moduleResolution','NodeNext','--lib','ES2022,DOM,DOM.Iterable','--types','react,react-dom,node','--typeRoots',join(root,'node_modules/@types'),join(packageRoot,'react-consumer-types.mts')],{cwd:temporary});
const browser=run(process.execPath,['tests/headless/browser-storage.mjs','--package']);
process.stdout.write(browser);
console.log(JSON.stringify({compiledBrowserAdapters:true,status:'ok',packedFiles:packed[0].files.length,consumer:report,nodeTypes:true,browserGlobalsBlocked:true,externalRuntimeDependenciesBlocked:true}));
console.log(JSON.stringify({compiledBrowserAdapters:true,portableBindings:true,explicitReactPeers:true,status:'ok',packedFiles:packed[0].files.length,consumer:report,nodeTypes:true,browserGlobalsBlocked:true,externalRuntimeDependenciesBlocked:true}));
} catch(error){
if(error.stdout)process.stderr.write(error.stdout);
if(error.stderr)process.stderr.write(error.stderr);
+3 -3
View File
@@ -1,9 +1,9 @@
"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';
import type {ReactFlowProjectionNode as ProjectionNode,ReactFlowProjectionEdge as ProjectionEdge} from '@/lib/canvas-bindings/react-flow';
import type { CanvasRuntimeExtension } from '@/lib/canvas-bindings/react-flow';
import { useCanvasRendererCommands } from '@/lib/canvas-bindings/react-flow';
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}}>
+10 -2
View File
@@ -13,6 +13,14 @@
"types": [],
"plugins": []
},
"include": ["src/lib/canvas-core/**/*.ts", "src/lib/canvas-runtime/**/*.ts", "src/lib/canvas-service/**/*.ts"],
"exclude": ["node_modules", ".headless-dist"]
"include": [
"src/lib/canvas-core/**/*.ts",
"src/lib/canvas-runtime/**/*.ts",
"src/lib/canvas-service/**/*.ts",
"src/lib/canvas-bindings/**/*.ts"
],
"exclude": [
"node_modules",
".headless-dist"
]
}