Expose portable scene bindings and an explicit React Flow peer adapter
This commit is contained in:
+33
-4
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}};
|
||||
@@ -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}}};
|
||||
|
||||
@@ -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';
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user