diff --git a/headless/README.md b/headless/README.md index e3beb75..4577f43 100644 --- a/headless/README.md +++ b/headless/README.md @@ -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. diff --git a/headless/consumer.mjs b/headless/consumer.mjs index 010d801..69341fb 100644 --- a/headless/consumer.mjs +++ b/headless/consumer.mjs @@ -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();} diff --git a/headless/package.json b/headless/package.json index a689e9f..b5dd996 100644 --- a/headless/package.json +++ b/headless/package.json @@ -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 + } + } } diff --git a/src/components/canvas/canvas-renderers.tsx b/src/components/canvas/canvas-renderers.tsx index fc20aec..ce857c5 100644 --- a/src/components/canvas/canvas-renderers.tsx +++ b/src/components/canvas/canvas-renderers.tsx @@ -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'; diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 68b78bf..c5340bc 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -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"; diff --git a/src/components/canvas/loading-graph-canvas.tsx b/src/components/canvas/loading-graph-canvas.tsx index 18a46b1..e23a1b9 100644 --- a/src/components/canvas/loading-graph-canvas.tsx +++ b/src/components/canvas/loading-graph-canvas.tsx @@ -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'; diff --git a/src/components/canvas/unsupported-elements.tsx b/src/components/canvas/unsupported-elements.tsx index f56aec9..b4dc361 100644 --- a/src/components/canvas/unsupported-elements.tsx +++ b/src/components/canvas/unsupported-elements.tsx @@ -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}; diff --git a/src/components/workspace/canvas-runtime-host.tsx b/src/components/workspace/canvas-runtime-host.tsx index 9109df5..78e4571 100644 --- a/src/components/workspace/canvas-runtime-host.tsx +++ b/src/components/workspace/canvas-runtime-host.tsx @@ -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"; diff --git a/src/lib/canvas-bindings/compat-types.ts b/src/lib/canvas-bindings/compat-types.ts new file mode 100644 index 0000000..b207bc5 --- /dev/null +++ b/src/lib/canvas-bindings/compat-types.ts @@ -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 }; +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}}; diff --git a/src/lib/canvas-bindings/connectors.ts b/src/lib/canvas-bindings/connectors.ts index ca43f66..f717263 100644 --- a/src/lib/canvas-bindings/connectors.ts +++ b/src/lib/canvas-bindings/connectors.ts @@ -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; +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}}}; diff --git a/src/lib/canvas-bindings/index.ts b/src/lib/canvas-bindings/index.ts new file mode 100644 index 0000000..bfcc872 --- /dev/null +++ b/src/lib/canvas-bindings/index.ts @@ -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'; diff --git a/src/lib/canvas-bindings/projection.ts b/src/lib/canvas-bindings/projection.ts index 0c12dd0..82954ef 100644 --- a/src/lib/canvas-bindings/projection.ts +++ b/src/lib/canvas-bindings/projection.ts @@ -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 & { 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&{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) { diff --git a/src/lib/canvas-bindings/react-flow.ts b/src/lib/canvas-bindings/react-flow.ts new file mode 100644 index 0000000..8e5adb5 --- /dev/null +++ b/src/lib/canvas-bindings/react-flow.ts @@ -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; +export type ReactFlowProjectionEdge=Edge&ProjectionEdge; diff --git a/src/lib/canvas-elements.ts b/src/lib/canvas-elements.ts index 66f3f9f..8782ada 100644 --- a/src/lib/canvas-elements.ts +++ b/src/lib/canvas-elements.ts @@ -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 & { pendingUpload?: string }; export type ElementCapabilities = { resizable: boolean; rotatable: boolean; connectable: boolean; branchable: boolean }; diff --git a/src/lib/canvas-geometry.ts b/src/lib/canvas-geometry.ts index 3868620..04a9826 100644 --- a/src/lib/canvas-geometry.ts +++ b/src/lib/canvas-geometry.ts @@ -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 }; diff --git a/src/lib/canvas-v2.ts b/src/lib/canvas-v2.ts index d67330e..7e7bde0 100644 --- a/src/lib/canvas-v2.ts +++ b/src/lib/canvas-v2.ts @@ -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; -export type AIConfig = { enabled?: boolean; modelId?: string; prompt?: string; propagate?: boolean; parameters?: Record }; -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; diff --git a/tests/headless/package.integration.mjs b/tests/headless/package.integration.mjs index f59fbe8..bca2f5b 100644 --- a/tests/headless/package.integration.mjs +++ b/tests/headless/package.integration.mjs @@ -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>,document:Parameters[0]['document'],versions:ReadonlyMap,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>){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,'peer renderer');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){return createElement('div',null,data.title);} + function Link({data}:EdgeProps){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); diff --git a/tests/headless/renderer-extension.tsx b/tests/headless/renderer-extension.tsx index ee1d76b..6921bbb 100644 --- a/tests/headless/renderer-extension.tsx +++ b/tests/headless/renderer-extension.tsx @@ -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){ const commands=useCanvasRendererCommands(),record=data.__headlessRecord!; return
diff --git a/tsconfig.headless.json b/tsconfig.headless.json index e7db69b..1c396a0 100644 --- a/tsconfig.headless.json +++ b/tsconfig.headless.json @@ -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" + ] }