Appearance
FlowCanvas — live demo
This is the real @rozie-ui/rete-vue package running on this page (VitePress is itself a Vue app) — driving an actual Rete.js v2 node editor. Drag a node, drag from one socket to another to connect them, scroll to zoom, or use the controls below. Everything is driven by the same FlowCanvas.rozie source that compiles to all six frameworks, through a vanilla render layer (no framework-specific Rete render plugin). Rete ships no stylesheet — every node, socket, and connection you see is styled by the component itself.
The graph is one controlled object (v-model:graph) — the single source of truth, shaped { nodes: [{ id, type, x, y, data }], connections: [...] }. Each node's type selects a <NodeType> template; the canvas renders every node from its type (render-by-type) and writes back into the bound object: dragging a node rewrites its x/y, and drawing or removing an edge rewrites connections — you never hand-reconcile. Add node writes a fresh graph object and the wrapper reconciles it into the live editor with no remount. zoom is two-way bound with v-model:zoom — the readout tracks it as you scroll, and Fit drives the imperative handle (zoomToFit()), which echoes the new zoom back into the binding. Each node body is your own <NodeType>'s #body template, rendered per node through the reactive body portal. The two built-in overlays are both visible above: the Controls cluster (bottom-left, zoom / fit — :controls, on by default) and the opt-in MiniMap (bottom-right, :minimap="true") — a scaled map of every node (at its measured size) plus the current viewport window, pannable to recenter. See the full API for the complete prop / event / handle surface.
One source, six outputs
You author the component once as a .rozie file:
html
<!--
FlowCanvas.rozie — data-bound port of Rete.js v2 (retejs/rete), a node-based
flow / graph editor engine.
THE CROSS-FRAMEWORK GAP THIS FILLS. There is no single node-flow editor that
ships idiomatic React + Vue + Svelte + Angular + Solid + Lit:
- @xyflow/react (React Flow) + @xyflow/svelte (Svelte Flow) — React & Svelte only
- @vue-flow/core — a SEPARATE Vue reimplementation, not a shared core
- @foblex/flow / ngx-graph — Angular only
- Solid has only a single-author experiment; Lit has nothing
Rete.js v2 is the one framework-agnostic engine whose CORE owns the graph model
(NodeEditor) and ALL pointer interaction (AreaPlugin = pan/zoom/drag,
ConnectionPlugin = drag-to-connect), with rendering delegated to a swappable
render layer. Rete ships render plugins for React/Vue/Angular/Svelte/Lit (5 of
6, no Solid) — five divergent codebases to maintain. ONE Rozie source replaces
all of them with a single VANILLA render layer and gives Solid (and a far
thinner Lit) a category-leading node editor for free.
WHY A VANILLA RENDER LAYER (the heart of this port). A Rete render plugin's only
job is to (a) fill each engine-created node element with DOM, (b) draw each
connection's SVG path, and (c) tell the ConnectionPlugin where the sockets are.
The official plugins do (a)+(b) with a framework's component tree — that's the
coupling. Here the engine owns state + interaction and Rozie is a THIN VIEW: a
custom render pipe on the AreaPlugin
1. NODE — fills the engine's nodeView element with either the consumer's
`node` portal-slot fragment (the reactive multi-instance portal —
one handle per node, the MapLibre marker pattern) or default
chrome, PLUS the input/output socket DOM.
2. SOCKET — for every socket it `area.emit({ type:'render', data:{ type:'socket'
… }})`. This is load-bearing: the ConnectionPlugin and the
`getDOMSocketPosition` watcher both LISTEN for socket render
signals — without them there are no connection anchors and
drag-to-connect is dead. (Confirmed against rete-connection-plugin
`Requires` + rete-render-utils `ExpectArea2DExtra`.)
3. CONNECTION — mounts an <svg><path> into the engine's connection element and
redraws it via `classicConnectionPath` whenever either endpoint
socket moves (`socketWatcher.listen(nodeId, side, key, cb)` fires
on node drag / zoom / pan).
Authoring model (Phase 41 CONTROLLED-GRAPH redesign — the xyflow `nodeTypes` +
controlled-state mental model, Vue-natural). The consumer binds ONE graph object
and declares node TYPE templates; FlowCanvas is the middleware: it renders each
node by its `type`, owns drag/zoom/connect/validation, and WRITES BACK layout
(x/y on drag) + connections (on connect/disconnect) INTO the bound r-model object
so the developer never hand-reconciles:
<FlowCanvas r-model:graph="$data.graph" :validate-types="true"
@connection-rejected="onReject">
<NodeType type="source">
<template #body="{ node }">{{ node.data.label }}</template>
<Port output="num" type="number" />
<Port output="str" type="string" />
</NodeType>
<NodeType type="merge">
<template #body="{ node }">Merge</template>
<Port input="num" type="number" multiple />
<Port input="str" type="string" multiple />
</NodeType>
</FlowCanvas>
with $data.graph = { nodes:[{ id, type, x, y, data }], connections:[{ id?,
source, sourceOutput, target, targetInput }] } — the SINGLE SOURCE OF TRUTH.
Dragging a node writes a FRESH graph object (x/y); drawing/removing an edge
writes a fresh graph object (connections). A type-mismatched connection is
auto-rejected (validate-types) and surfaces `connection-rejected`.
WRITE-BACK CONTRACT (Wave-0-proven, 41-01): the canvas emits a FRESH top-level
`{ nodes, connections }` object via `$model.graph` (immutable React-Flow
applyNodeChanges style — in-place deep mutation is SILENT on React/Solid/Lit/
Angular). Echo-guarded by the `programmatic` counter + the no-op-diff property;
high-frequency drag is rAF-coalesced (one write per frame, plus a flush on
drag-end).
ENGINE-OWNED-STATE discipline (the wrap pattern sidesteps prop-drilling):
deeply-nested node bodies never read a shared Rozie store — each type's `#body`
template (or the low-level `#node` portal slot) receives `{ node, selected, emit }`
via portal scope, and the live editor/area are reachable through the `$expose`
handle (getEditor/getArea).
Engine CSS: Rete ships NO stylesheet — node/socket/connection chrome is styled
entirely by the scoped <style> below + the `:root {}` engine-DOM escape hatch
(engine-created node/connection DOM never carries Rozie's [data-rozie-s-*] scope
attribute, so reach it via nested `:root`, NOT :global() → ROZ128).
$refs.canvasEl is read ONLY inside $onMount (ROZ123); it needs explicit
dimensions (.rozie-flow-canvas sets them).
-->
<rozie name="FlowCanvas" inherit-attrs="false" inherit-listeners="false" adopt-document-styles>
<props>
{
// THE GRAPH — the single source of truth (D1/D2), a two-way r-model object.
// { nodes: [{ id (required, string), type, x, y, data? }],
// connections: [{ id?, source, sourceOutput?='out', target, targetInput?='in' }] }
// - `type` selects the node's TYPE template (render-by-type + its port schema).
// - `data` is the opaque consumer payload handed to the type's `#body` scope.
// The canvas WRITES BACK a FRESH top-level object via `$model.graph` on every
// drag (x/y) and connect/disconnect (connections) — immutable applyNodeChanges
// style (in-place deep mutation is silent on React/Solid/Lit/Angular, Wave-0).
graph: { type: Object, default: () => ({ nodes: [], connections: [] }), model: true,
docs: {
description:
"The single source of truth (two-way `r-model`) — `{ nodes: [{ id, type, x, y, data? }], connections: [{ id?, source, sourceOutput?, target, targetInput?, label?, stroke?, dashed? }] }`. A node's `type` selects its `<NodeType>` template (render-by-type + port schema); `data` is the opaque payload handed to that type's `#body` scope. The canvas writes back a FRESH top-level object on every drag (x/y) and connect/disconnect (connections) — immutable applyNodeChanges style. `sourceOutput`/`targetInput` default to `out`/`in`; a missing connection `id` is derived from the endpoints.",
example: '<FlowCanvas r-model:graph="graph" :validate-types="true" />',
} },
// Automatic typed-socket validation (D3, default ON). When true, the canvas
// resolves each endpoint's port TYPE from the per-TYPE port schema (declared on
// <Port type>) and auto-rejects a type-mismatched connection. `canConnect`
// survives as the optional custom-rule OVERRIDE (runs in addition); either path
// fires `connection-rejected`. Set false for pure-`canConnect` (type as metadata).
validateTypes: { type: Boolean, default: true,
docs: {
description:
"Automatic typed-socket validation (default ON). When `true`, the canvas resolves each endpoint's port type from the per-`<NodeType>` `<Port type>` schema and auto-rejects a type-mismatched connection (firing `connection-rejected`). `canConnect` survives as the optional custom-rule override that runs in addition. Set `false` for pure-`canConnect` (type as metadata only).",
} },
// Two-way zoom (model). NOTE: no `zoom`/`zoomed` EMIT — a same-named emit
// collides with the model on Vue (defineModel vs defineEmits) and Angular
// (ModelSignal vs OutputEmitterRef) — the MapLibre zoom/pitch lesson. The
// two-way binding conveys zoom changes; consumers wanting a discrete signal use
// `@translated` for pan or read the bound zoom.
zoom: { type: Number, default: 1, model: true,
docs: {
description:
"The viewport zoom level (two-way `r-model`). Scroll/pinch writes the new zoom back through the model (echo-guarded against the wrapper's own programmatic zooms); a consumer write zooms the live area. There is deliberately no `zoom`/`zoomed` emit — a same-named emit collides with the model on Vue and Angular — so the two-way binding is the channel for zoom changes.",
} },
// interaction toggles — applied at construction (drag/zoom handlers, selection).
pannable: { type: Boolean, default: true,
docs: {
description:
"Whether the canvas can be panned by dragging the background (applied at construction). Set `false` to detach the area's drag handler.",
} },
zoomable: { type: Boolean, default: true,
docs: {
description:
"Whether the canvas can be zoomed by scroll/pinch (applied at construction). Set `false` to detach the area's zoom handler.",
} },
selectable: { type: Boolean, default: true,
docs: {
description:
'Whether nodes can be selected (click; ctrl-click to accumulate). Reflected as the `selected` flag in the `<NodeType>` `#body` scope and surfaced to the consumer via the `@selection-change` event.',
} },
// when true: no node drag, no connection editing (read-only viewer).
readonly: { type: Boolean, default: false,
docs: {
description:
'Read-only viewer mode — no node drag, no connection editing, and no selection. View-only zoom/fit (Controls, the `zoomTo`/`zoomToFit` verbs) stay enabled.',
} },
// zoom clamp (restrictor extension). 0 disables the corresponding bound.
minZoom: { type: Number, default: 0.1,
docs: {
description:
"Minimum zoom level — the lower bound of the area's zoom restrictor. `0` disables the bound.",
} },
maxZoom: { type: Number, default: 4,
docs: {
description:
"Maximum zoom level — the upper bound of the area's zoom restrictor. `0` disables the bound.",
} },
// snap-to-grid size in px (0 = off).
snapGrid: { type: Number, default: 0,
docs: {
description:
'Snap-to-grid size in pixels for node dragging. `0` turns snapping off.',
} },
// ctrl-accumulating multi-select.
accumulateOnCtrl: { type: Boolean, default: true,
docs: {
description:
'When selectable, hold Ctrl to add to the current selection instead of replacing it.',
} },
// connection bezier curvature (classicConnectionPath).
curvature: { type: Number, default: 0.3,
docs: {
description:
'The bezier curvature of connection paths (`classicConnectionPath`).',
} },
// auto-fit the viewport to all nodes after the initial graph mounts.
fitOnMount: { type: Boolean, default: true,
docs: {
description:
'After the initial graph mounts, pan/zoom the viewport to fit all nodes (`AreaExtensions.zoomAt`).',
} },
// built-in Controls overlay (zoom in / zoom out / fit) — default ON; opt out with
// :controls="false". type:Boolean lowers cleanly cross-target (no Lit object-prop
// JSON.parse landmine). The buttons reuse the same zoom/fit path as the zoomTo/
// zoomToFit $expose verbs (one implementation).
controls: { type: Boolean, default: true,
docs: {
description:
'Render the built-in Controls overlay — a zoom in / zoom out / fit-view button cluster (the React Flow `<Controls/>` parity). The buttons drive the same zoom/fit path as the `zoomTo`/`zoomToFit` handle verbs (clamped to `minZoom`/`maxZoom`) and stay enabled in `readonly`. Opt out with `:controls="false"`.',
} },
// built-in MiniMap overlay (opt-in, default OFF — the React-Flow `<MiniMap/>` style).
// When true an absolute light-DOM SVG overlay (bottom-right; Controls is bottom-left)
// renders a scaled map of every node rect — sized by the MEASURED engine node-view
// element dims (target-agnostic, the same Rete engine on all 6, like the render pipe)
// — plus the current viewport window (the area outside dimmed), and is PANNABLE: a
// pointer-drag on the minimap recenters the main viewport (via the new setCenter
// verb). Default OFF keeps existing demos + the FlowCanvasScreenshot pixel baseline
// unchanged (no rebless unless a demo turns it on). type:Boolean lowers cleanly
// cross-target (no Lit object-prop JSON.parse landmine). Like pannable/zoomable/
// controls, evaluated at CONSTRUCTION (read once in $onMount) — set it at mount time.
minimap: { type: Boolean, default: false,
docs: {
description:
'Render the built-in MiniMap overlay (opt-in, default OFF — the React Flow `<MiniMap/>` parity) — an absolute SVG panel (bottom-right) showing a scaled map of every node (sized from the measured engine node-view dims) plus the current viewport window (the area outside dimmed). It is pannable: dragging the minimap recenters the main viewport (via `setCenter`). Evaluated at construction, like `pannable`/`zoomable`/`controls` — set it at mount time.',
} },
// Connection-validation predicate. Receives the normalized candidate connection
// { source, sourceOutput, target, targetInput }; return false to REJECT (the
// wrapper cancels Rete's connectioncreate pre-event — no edge committed, no ghost
// path — and emits `connection-rejected`). Absent/null = allow all connections
// (back-compat: zero behavioral change for existing demos). Gates ALL three paths
// (drag-to-connect, imperative addConnection, config-array reconcile) uniformly.
canConnect: { type: Function, default: null,
docs: {
description:
'Connection-validation predicate `(conn) => boolean`, receiving the normalized candidate connection `{ source, sourceOutput, target, targetInput }`. Return `false` to reject the connection — no edge is committed, no ghost path is drawn, and `connection-rejected` fires. Runs in addition to the automatic `:validate-types` check (the custom-rule override) and gates all connection paths uniformly (drag-to-connect, imperative `addConnection`, graph reconcile). Absent/`null` imposes no custom rule.',
} },
// T1.3 — UNDO/REDO toggle (D-02, on-by-default). When true (default) every gesture
// (drag / connect / disconnect / delete) pushes ONE capped (~100) snapshot of the
// bound graph (nodes incl x/y + connections; NOT the viewport) and undo()/redo() +
// Ctrl+Z/Shift+Z/Y restore it through the model (echo-guarded). `:history=false` is
// the cheap escape hatch — pushHistory bails immediately so the stacks stay empty and
// undo/redo no-op. type:Boolean lowers cleanly cross-target (no Lit object-prop
// JSON.parse landmine), default true so existing demos get undo for free.
history: { type: Boolean, default: true,
docs: {
description:
'Undo/redo, on by default. Every gesture (drag, connect, disconnect, delete) pushes ONE capped (~100) snapshot of the bound graph (nodes incl. x/y + connections; not the viewport), and `undo()`/`redo()` plus Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, and Ctrl/Cmd+Y restore it through the two-way `graph` model (echo-guarded). One gesture = one undo step; a fresh edit after an undo discards the redo branch. Opt out with `:history="false"` (the snapshot stack stays empty and the verbs no-op).',
} },
// T2.4 — interaction MODE (D-05), a two-way r-model string: 'pan' (default) | 'select'.
// The Figma-style pan↔select toggle. In 'pan' (default) an empty-canvas drag PANS the
// viewport — UNCHANGED, zero behavioral drift for every existing demo. In 'select' an
// empty-canvas drag draws a rubber-band MARQUEE box that multi-selects the intersecting
// nodes (via the selectableNodes select handle) and surfaces @selection-change (the
// EXISTING emit — no new emit). Read $props.mode, write $model.mode (model:true). A
// node drag still drags the node in BOTH modes (only the EMPTY-canvas drag changes). The
// mode swap is implemented as a CAPTURE-phase pointerdown guard on the container (a
// persistent mode-flag interception — RESEARCH Q2/A8) rather than setDragHandler(null)/
// re-create, because rete's internal Drag class is NOT exported (can't re-instantiate to
// restore pan). type:String, default 'pan' → React generates a setMode setter (NOT a
// lifecycle/emit/prop collision). Only 'select' triggers the marquee branch; any other
// value uses the default pan path (Threat T-44-04-1).
mode: { type: String, default: 'pan', model: true,
docs: {
description:
"Two-way interaction mode (`r-model`) — the Figma-style pan ↔ select toggle, `'pan'` (default) or `'select'`. In `'pan'` an empty-canvas drag pans the viewport (unchanged). In `'select'` an empty-canvas drag draws a rubber-band marquee box that multi-selects the intersecting nodes (surfacing `@selection-change`). A node drag still drags the node in both modes — only the empty-canvas drag changes. The canvas writes it back when the built-in mode button toggles (see `marquee`).",
} },
// T2.4 — gate the 4th Controls MODE button (D-05). Default false so the built-in Controls
// overlay keeps its 3 buttons (zoom-in/out/fit) → FlowCanvasScreenshot stays byte-identical
// (gate over rebless). Set :marquee="true" to render the pan↔select toggle button in the
// Controls cluster (it two-way-writes $model.mode). The marquee BEHAVIOR works regardless
// of this flag (a consumer can drive `mode` directly); this only governs the built-in button.
marquee: { type: Boolean, default: false,
docs: {
description:
'Render the 4th Controls button — the pan ↔ select mode toggle (it two-way-writes `mode`). Default OFF so the default Controls overlay keeps its three buttons. The marquee behavior works whenever `mode === \'select\'` regardless of this flag (a consumer can drive `mode` directly); this only governs the built-in button.',
} },
// T2.8 — NodeToolbar (D-06, OPT-IN, default OFF). When true, selecting a node pops a
// floating toolbar OVER the selected node (positioned from the engine node-view rect +
// the area transform, re-tracked on pan/zoom/drag). Default buttons: delete (→ the
// controlled-graph deleteNode) + duplicate (clone the selected node spec at an offset
// with a NEW id into a FRESH graph object); both fire @node-action (the EXISTING emit —
// no new emit). A consumer can override the buttons by filling the `#toolbar` reactive
// portal slot. Default OFF keeps existing demos + the FlowCanvasScreenshot pixel baseline
// unchanged (selecting a node never pops a toolbar unless opted in). type:Boolean lowers
// cleanly cross-target (no Lit object-prop JSON.parse landmine).
nodeToolbar: { type: Boolean, default: false,
docs: {
description:
"Render the opt-in NodeToolbar (default OFF) — a floating toolbar over the single selected node (positioned from the engine node-view rect + the area transform, re-tracked on pan/zoom/drag). Default content is Delete (cascading controlled-graph `deleteNode`) + Duplicate (clone the node spec at an offset with a new id into a fresh `graph` object); both fire `@node-action` (`name: 'delete' | 'duplicate'`). Override the content by filling the `#toolbar` reactive slot.",
} },
}
</props>
<data>
{
// PER-TYPE template registry (Phase 41 controlled-graph — the per-TYPE shift of
// the shipped per-INSTANCE Phase-37 registries). A reactive `type → { bodyRenderer? }`
// map that the $provide registerType/unregisterType API WHOLE-OBJECT-REPLACES on
// every mutation (never an in-place $data.typeReg[type] = spec — silent on React/
// Solid/Angular/Lit because $watch change-detection is reference equality). A
// <NodeType type="source">'s registerType lands its `#body` renderer here ONCE;
// every graph node whose `type === 'source'` renders that template (render-by-type).
typeReg: {},
// PER-TYPE port schema — a FLAT map keyed UNIQUELY by `type::side::key` →
// { type, side, key, portType, label, multiple }. A nested <Port>'s addTypePort()
// runs in its OWN mount hook, which on React/Vue/Svelte/Angular fires BEFORE its
// parent <NodeType>.registerType() (child-before-parent mount order). Storing ports
// HERE (keyed by TYPE, not instance) makes registration ORDER-INDEPENDENT and
// target-agnostic: buildNode reads (the portReg entries whose key starts
// `type + '::'`) for EVERY graph node of that type. CRITICAL — the key is the FULL
// per-port id (type+side+key), NOT just the type: two <Port>s of the SAME type
// addTypePort in one React commit, and a read-modify-write of a `portReg[type]`
// ARRAY would clobber (the emitter lowers `$data.portReg = {...$data.portReg, [k]: v}`
// to a functional `setPortReg(prev => ({...prev, [k]: v}))`, concurrency-safe ONLY
// when the keys differ). A unique per-port key makes each Port's write independent.
portReg: {}
}
</data>
<script>
import { NodeEditor, ClassicPreset, Scope } from 'rete'
import { AreaPlugin, AreaExtensions } from 'rete-area-plugin'
import { ConnectionPlugin, Presets as ConnectionPresets } from 'rete-connection-plugin'
import { getDOMSocketPosition, classicConnectionPath } from 'rete-render-utils'
// T2.6 — auto-layout (D-08, verb-only). The 3 deps (rete-auto-arrange-plugin / elkjs
// @0.8.2 / web-worker) are OPTIONAL leaf peers, installed + bundle-smoked on all 6 in
// Plan 00 (the Vite/Angular-AOT/Lit rollup build resolves elkjs to the SYNCHRONOUS
// elk.bundled.js entry — no web-worker resolution error, no manual fallback switch). Only
// a consumer calling autoArrange() pulls these in.
import { AutoArrangePlugin, Presets as ArrangePresets } from 'rete-auto-arrange-plugin'
// ── engine instances — null-lets so typeNeutralize types them `any` (the
// MapLibre `let instance = null` discipline). Rete's NodeEditor / AreaPlugin /
// ConnectionPlugin / DOMSocketPosition carry rich generic Schemes types that the
// loosely-typed .rozie props (any[]) don't satisfy under the strict react/solid/
// lit leaf tsc; routing every engine call through an `any` instance is the
// .rozie-native fix (no lang="ts", no codegen type-aid). These are top-level lets
// referenced from hooks → React auto-hoists each to a useRef. ──
let editor = null
let area = null
let connectionPlugin = null
let socketWatcher = null
let renderScope = null
let selector = null
// T2.6 — the AutoArrangePlugin instance (elkjs-backed). COMPONENT-scope (NOT $onMount-local)
// so the top-level autoArrange() verb sees it (the editor/area discipline). null until $onMount
// wires it; the verb no-ops before mount.
let arrange = null
// Win 1: the Delete/Backspace keydown listener + its host container. COMPONENT-scope
// (NOT $onMount-local) so the $onMount-returned teardown — which the Solid emitter
// hoists into a sibling onCleanup() OUTSIDE the mount IIFE — can still see them to
// removeEventListener (the same component-scope discipline as nodeInstances below).
let keydownContainer = null
let onCanvasKeydown = null
// Phase 42 MiniMap (opt-in :minimap) — the absolute SVG overlay host + its imperative
// SVG layer + the pointer-pan listeners. COMPONENT-scope (NOT $onMount-local) so the
// $onMount-returned teardown — which the Solid emitter hoists into a sibling
// onCleanup() OUTSIDE the mount IIFE — can still removeEventListener them (the same
// keydown / nodeInstances discipline). `minimapMap` is the live minimap-px ↔ graph-
// coord mapping the pointer-pan handlers read; `scheduleMinimapRedraw` is the bridge
// the top-level $watch + the engine pipes call (assigned inside $onMount, like the
// reconcilers). minimapRedrawRaf coalesces the viewport-rect redraw to one per frame
// (the drag-write-back discipline — the viewport rect redraws on every pan/zoom).
let minimapHost = null
let minimapSvg = null
let minimapRedrawRaf = 0
let minimapMap = null
let minimapPanning = false
let onMinimapPointerDown = null
let onMinimapPointerMove = null
let onMinimapPointerUp = null
let scheduleMinimapRedraw = null
// T2.4 MARQUEE select (mode:'select') — the programmatic-select handle captured from
// AreaExtensions.selectableNodes ({ select(id, accumulate), unselect(id) }), the rubber-
// band overlay box (component-template DOM, scoped CSS), and the capture-phase pointerdown
// guard + window pointer listeners that draw the box in select mode. COMPONENT-scope (NOT
// $onMount-local) so the Solid-hoisted teardown can removeEventListener them (the keydown /
// minimap discipline). `marqueeBox` is the absolute overlay <div>; `marqueeActive` gates the
// in-progress drag; `marqueeStart`/`marqueeCur` are container-relative px corners.
let nodeSelectApi = null
let marqueeBox = null
let marqueeActive = false
let marqueeStart = null
let marqueeCur = null
let onCanvasPointerDownCapture = null
let onMarqueePointerMove = null
let onMarqueePointerUp = null
// T2.8 NodeToolbar (opt-in :node-toolbar) — a floating component-template overlay (scoped
// CSS, like the marquee box + Controls) over the SELECTED node, positioned from the engine
// node-view element's rect relative to the canvas container + the area transform. COMPONENT-
// scope (NOT $onMount-local) so the Solid-hoisted teardown sees them. `toolbarHost` is the
// absolute overlay <div> (the $refs.toolbarEl element); `toolbarSelectedId` is the id of the
// node the toolbar currently tracks (the SINGLE selected node — null when nothing or >1 is
// selected, or selection is empty); `toolbarHandle` is the optional `#toolbar` reactive-
// portal handle ({ update, dispose }) when the consumer fills the slot; `scheduleToolbarTrack`
// is the rAF-coalesced reposition bridge (assigned in $onMount, called by the area pipes +
// the selection emit, like scheduleMinimapRedraw); `toolbarTrackRaf` coalesces it to one per
// frame. `toolbarDeleteBtn`/`toolbarDuplicateBtn` are the default buttons (kept so teardown
// can removeEventListener them); their pointerup handlers are `onToolbarDelete`/`onToolbarDup`.
let toolbarHost = null
let toolbarSelectedId = null
let toolbarHandle = null
let scheduleToolbarTrack = null
// component-scope bridge to the $onMount-local syncToolbar (the scheduleMinimapRedraw
// discipline) — called from maybeEmitSelectionChange + the area pipes so a pick/unpick /
// pan / zoom / drag re-tracks the toolbar over the selected node.
let syncToolbarSelection = null
let toolbarTrackRaf = 0
let toolbarDeleteBtn = null
let toolbarDuplicateBtn = null
let onToolbarDelete = null
let onToolbarDup = null
// MiniMap geometry (px) — MUST match the .rozie-flow-minimap CSS box below.
const MINIMAP_W = 200
const MINIMAP_H = 150
// Fallback node-rect dims when a node-view element isn't measurable yet (Lit async
// first paint, REQ-30) — re-measured on the next render (the render pipe re-schedules).
const MINIMAP_DEFAULT_NODE_W = 140
const MINIMAP_DEFAULT_NODE_H = 52
const SVGNS = 'http://www.w3.org/2000/svg'
// One Socket shared by every port (Rete sockets gate compatibility by identity;
// a single socket = "anything connects to anything", the common editor default).
const SOCKET = new ClassicPreset.Socket('flow')
// Live engine bookkeeping — COMPONENT-scope (NOT $onMount-local) so the
// $onMount-returned teardown, which the Solid emitter hoists into a sibling
// onCleanup() OUTSIDE the mount IIFE, keeps them in scope (the MapLibre
// markerEntries lesson).
// nodeInstances : id → live ClassicPreset.Node (engine truth)
// nodeMeta : id → the consumer's node spec object (for the slot scope)
// connInstances : id → live ClassicPreset.Connection (engine truth)
// nodeEntries : id → { element, bodyHost, handle, socketDisposers }
// connEntries : id → { element, dispose }
const nodeInstances = new Map()
const nodeMeta = new Map()
const connInstances = new Map()
const nodeEntries = new Map()
const connEntries = new Map()
// connMeta : id → the consumer's connection spec ({ …, label?, stroke?, dashed? }) — the
// connection-side analog of nodeMeta, read by renderConnection for per-edge label/styling (F3).
const connMeta = new Map()
// ids last applied FROM THE BOUND GRAPH, so reconcile removes only graph-managed
// entities — an imperative $expose addNode/addConnection is NOT auto-reaped on the
// next graph change (the power-user escape hatch stays alive). MapLibre reconciles
// every marker because markers are purely prop-driven; a flow editor also accepts
// imperative edits, so it tracks provenance. (Phase 41: nodes/connections now come
// ONLY from the single `graph` model — the per-instance declarative-children
// registries are gone; node TYPE templates + port schemas live in typeReg/portReg.)
let lastPropNodeIds = null
let lastPropConnIds = null
// Re-entrant suppression counter: while > 0 the editor/area event handlers skip
// echoing back into $emit / $model (our own programmatic add/remove/translate/
// zoom must not bounce out as if the user did it — the MapLibre PROGRAMMATIC
// eventData guard, in counter form so batched/nested ops never race).
let programmatic = 0
// Win 2: the last emitted selection id-set, joined to a stable string, so
// @selection-change fires ONLY on an actual change (a repeated identical pick/unpick
// set does not spam the consumer). `null` until the first emit (so the initial empty
// selection does not emit on mount). COMPONENT-scope so it survives across area events.
let lastSelectionIds = null
// T1.1 — EDGE SELECTION (D-08). The currently-selected CONNECTION id, or null. Lives
// PURELY in component script (the selectedNodeIds echo-safety discipline) — NEVER
// written into $model.graph, so the controlled-graph write-back assertions are
// unaffected (Threat T-44-01-2: no spurious model write). COMPONENT-scope so it
// survives across area events + so the Solid-hoisted teardown can clear it. The
// `.is-selected` class is toggled imperatively on the engine-DOM __path; this id is the
// source of truth the Delete branch reads. `selectedPathEl` caches the live <path>
// element so a background-click clear (and re-select) can drop `.is-selected` without
// re-walking the DOM. `edgeClickGuard` is a one-shot flag the area-background pointerup
// branch checks so an edge click (which fires its own pointerup on the path AND lets the
// area's background pointerup run) does not immediately clear the selection it just made
// — reset on the next microtask, after the gesture settles.
let selectedConnId = null
let selectedPathEl = null
let edgeClickGuard = false
// T1.3 — UNDO / REDO (D-02 on-by-default, D-03 per-gesture graph-only scope, D-04
// echo-guarded restore). A CAPPED snapshot stack over the BOUND GRAPH only — nodes
// (incl x/y) + connections — and explicitly NOT the viewport (pan/zoom is excluded,
// D-03). One entry is pushed per COMPLETED gesture: a drag = ONE entry (snapshot taken
// on pointer-down, committed on the first translate — never per pointermove frame), a
// connect / disconnect / delete = one each. A push is gated on `!programmatic` so a
// restore-driven write (which runs INSIDE the programmatic guard) never re-enters the
// history (D-04). Pushing clears the redo branch and drops the oldest entry beyond the
// cap (Threat T-44-03-1: bounded memory). Snapshots are deep clones of the consumer's own
// serializable graph JSON (Pattern 7; the `$clone` sigil — a deep, de-proxied copy
// that strips the Vue/Svelte reactivity Proxy that a bare `structuredClone` THROWS
// on) — no external input, so the restore (T-44-03-2 accept)
// cannot loop (it rides the programmatic guard + the existing $watch(graph) reconcile).
// Undo is ALWAYS on for v1; `:history=false` (the `history` prop) is the cheap escape
// hatch that skips every push (the stacks stay empty → undo/redo are no-ops).
// COMPONENT-scope so the stack survives across area events + the Solid-hoisted teardown.
const HISTORY_CAP = 100
// Two-stack model (simpler + correct than a single cursor): `historyStack` holds
// PRE-gesture snapshots (the states to UNDO back to, newest last); `redoStack` holds
// snapshots an undo popped off (the states to REDO forward to, newest last). A new
// gesture (pushHistory) snapshots the PRE-gesture graph onto historyStack and CLEARS
// redoStack (a fresh edit discards the redo branch). undo() pops historyStack → pushes
// the CURRENT (pre-undo) graph onto redoStack → restores the popped snapshot. redo()
// pops redoStack → pushes the current graph back onto historyStack → restores it.
let historyStack = []
let redoStack = []
// One-shot per-drag guard: a drag fires `nodetranslated` (→ flushDragWriteBack) on EVERY
// pointermove frame, so a push-per-flush would record many entries for ONE gesture. We
// snapshot the PRE-drag graph on `nodepicked` (pointer-DOWN, definitively before any
// movement — capturing it on the first `nodetranslated` is too late: the engine has
// already applied the initial delta + may have flushed a write-back, so $props.graph no
// longer holds the start position), stash it in `pendingDragSnapshot`, and COMMIT it to
// the history stack on the FIRST `nodetranslated` of the gesture (a pick WITHOUT a drag
// must not create a history entry). `dragGestureActive` then holds until the drag-ending
// `pointerup` resets it. D-03: a drag = ONE undo step.
let dragGestureActive = false
let pendingDragSnapshot = null
// T2.5 — RECONNECT coalescing (D-08 reconnectable edges, D-03 one-gesture-one-entry).
// Dragging an existing edge endpoint to a new socket is a SINGLE user gesture, but the
// shipped `Presets.classic.setup()` implements it as `editor.removeConnection(old)` then
// `editor.addConnection(new)` — so the write-back pipe sees a `connectionremoved` followed
// by a `connectioncreated`, which would push TWO history entries (Pitfall 2: two Ctrl+Z to
// undo one drag). The fix is to COALESCE: the ConnectionPlugin emits `connectionpick` when
// the user grabs a socket and `connectiondrop` when they release. While a reconnect is in
// flight (`reconnectInFlight > 0`) we SUPPRESS the per-event history pushes that
// writeBackConnectionRemoved / writeBackConnectionCreated normally do (the graph write-back
// itself STILL runs — the controlled graph stays correct), capturing the PRE-gesture
// snapshot ONCE on connectionpick (`reconnectPreSnapshot`). On `connectiondrop` we push that
// single snapshot (whether the drop landed on a new socket → `created:true` = a real
// reconnect, OR on an empty pane → `created:false` = the edge was removed with no re-add)
// and clear the flag. A plain drag-to-connect from an UNCONNECTED output socket also fires
// connectionpick/drop, but there is no remove in that gesture — the single `connectioncreated`
// write-back's own pushHistory is suppressed and the one coalesced snapshot is pushed on drop
// instead, so the per-gesture count stays exactly one either way. Counter form (not a bool)
// so a re-pick mid-gesture can't desync. COMPONENT-scope (survives across area events).
let reconnectInFlight = 0
let reconnectPreSnapshot = null
// Set true if a write-back (remove or add) actually ran during the in-flight window, so a
// connectionpick→drop that changed NOTHING (e.g. clicking a socket then releasing on the
// pane with no edge created/removed) does NOT push an empty history entry.
let reconnectDidWriteBack = false
// One-shot guard for the DEFERRED close (the drop fires BEFORE the trailing remove+add
// writeBacks, so the window must close on a macrotask AFTER they settle — see the
// connectiondrop branch). A re-pick before the deferred close runs cancels it.
let reconnectCloseScheduled = false
// ─── controlled-graph write-back (D4 — the central NEW capability) ─────────────
// On every drag/connect/disconnect the canvas emits a FRESH top-level
// `{ nodes, connections }` object via `$model.graph` — immutable React-Flow
// applyNodeChanges style (Wave-0-proven 6/6; in-place deep mutation is SILENT on
// React/Solid/Lit/Angular). Echo-guarded by the `programmatic` counter + the
// no-op-diff property: the write-back value already matches engine truth (the node
// is already at x/y; the edge already exists) so the consumer's re-bind →
// $watch(graph) → reconcile is a no-op diff.
//
// DRAG COALESCING (Pitfall 2): `nodetranslated` fires on every pointermove during a
// drag; emitting a fresh graph + full reconcile per frame is a rebuild storm. We
// accumulate the latest position per node (pendingDragPositions) and flush ONE fresh
// graph write per animation frame (dragFlushRaf), plus a final flush so the last
// position always lands. requestAnimationFrame coalesces multiple moves in a frame
// into a single $model.graph emit.
const pendingDragPositions = new Map() // id → { x, y } (latest during a drag)
let dragFlushRaf = 0
// The current bound graph — NEVER mutated in place.
const currentGraph = () => $props.graph || { nodes: [], connections: [] }
// T1.3 — deep-clone a graph snapshot. The graph is serializable JSON (nodes/connections of
// primitives), so JSON round-trip is the robust path: it strips framework reactivity
// wrappers — a Vue `reactive()` Proxy / Svelte `$state` proxy that a bare
// `structuredClone` THROWS on ("could not be cloned"), the silent vue/svelte-only
// failure that left the history stack empty. Phase 45 replaced the hand-rolled
// JSON-first clone helper with the `$clone(x)` sigil at every call site below: it
// lowers to `rozieDeepClone(x)` on Vue (Phase 45-07 — a recursive proxy-safe deep
// clone in @rozie/runtime-vue that de-proxies nested INDEPENDENT reactive members,
// not just the top level), `$state.snapshot(x)` on Svelte, and `structuredClone(x)`
// on the other four — a deep, independent, de-proxied copy on all six (and
// `$clone(null)` → `null` on all six, preserving the old `g == null` early-return
// implicitly). The Rete graph is JSON-serializable, so `$clone` never throws here;
// the former null-return fallbacks at the call sites are now dead but harmless.
// T1.3 — the canvas's OWN last-written graph. Every write-back funnels through
// `commitGraph`, which sets `$model.graph` AND records the written value here. undo/redo
// use THIS (not the round-tripped `$props.graph`) as the "current" state to push onto the
// opposite stack — `$props.graph` lags a drag write-back on React/Vue/Svelte (the
// two-way re-bind is async / batched), so reading it at undo time captured an
// INTERMEDIATE drag position. `lastWrittenGraph` is exact + synchronous. Seeded from the
// bound graph in $onMount.
let lastWrittenGraph = null
// Funnel for every component-driven graph write: record the value, then emit it. A deep
// clone is stored so a later consumer mutation of the live bound object can't corrupt the
// recorded state. (Echo-guarding is the CALLER's responsibility — restoreGraph wraps this
// in the programmatic guard.) `selfWriteInFlight` suppresses the resulting $watch(graph)
// tick from clobbering `lastWrittenGraph` with the (possibly still-stale, async) bound
// prop value — the value we just wrote IS the truth.
let selfWriteInFlight = false
const commitGraph = (g) => {
const c = $clone(g)
lastWrittenGraph = c != null ? c : g
selfWriteInFlight = true
$model.graph = g
}
// Capture the canvas's current graph state (its own last write, falling back to the bound
// prop before the first write). Always a fresh deep clone.
const snapshotCurrent = () => {
const src = lastWrittenGraph != null ? lastWrittenGraph : currentGraph()
return $clone(src)
}
// The BASE graph a write-back builds its fresh object from: the canvas's own last write if
// present (immune to the async prop re-bind lag), else the bound prop. This keeps a rapid
// gesture sequence (e.g. drag then immediately disconnect) consistent even before the
// consumer's two-way re-bind has propagated the prior write back into `$props.graph`.
const baseGraph = () => (lastWrittenGraph != null ? lastWrittenGraph : currentGraph())
// Commit an ALREADY-CAPTURED snapshot onto the undo stack (caps + clears redo). Gated on
// the `history` prop. Used by both the synchronous-commit path (connect/disconnect/delete)
// and the drag gesture (pre-move snapshot taken on pointer-down, committed on first translate).
const pushHistorySnapshot = (snap) => {
if ($props.history === false) return
if (!snap) return
historyStack.push(snap)
if (historyStack.length > HISTORY_CAP) {
historyStack = historyStack.slice(historyStack.length - HISTORY_CAP)
}
redoStack = []
}
// Snapshot the canvas's CURRENT graph state + commit it onto the undo stack (the connect /
// disconnect / delete path — called BEFORE the write-back so the snapshot is the
// pre-gesture state). Gated on `!programmatic` (echo-guard) + history. D-03: one per gesture.
const pushHistory = () => {
if (programmatic) return
if ($props.history === false) return
pushHistorySnapshot(snapshotCurrent())
}
// T2.5 — close the reconnect coalesce window. Called on a DEFERRED macrotask after a
// connectiondrop, so the trailing connectionremoved + connectioncreated writeBacks (which
// the classic preset fires AFTER the drop) have all run with the window still open
// (suppressing their per-event pushHistory, flagging reconnectDidWriteBack). Pushes the
// SINGLE pre-gesture snapshot iff the gesture actually changed the graph, then resets the
// per-gesture state. Idempotent + gated on the one-shot scheduled flag so a re-pick can
// cancel a pending close.
const closeReconnectGesture = () => {
if (!reconnectCloseScheduled) return
reconnectCloseScheduled = false
if (reconnectInFlight > 0) reconnectInFlight = 0
if (!programmatic && $props.history !== false && reconnectDidWriteBack && reconnectPreSnapshot) {
pushHistorySnapshot(reconnectPreSnapshot)
}
reconnectPreSnapshot = null
reconnectDidWriteBack = false
}
// Schedule the deferred close on a macrotask (setTimeout 0) — runs after the synchronous +
// microtask writeBack signals settle. Falls back to a microtask where setTimeout is absent.
const scheduleReconnectClose = () => {
if (reconnectCloseScheduled) return
reconnectCloseScheduled = true
if (typeof setTimeout === 'function') setTimeout(closeReconnectGesture, 0)
else Promise.resolve().then(closeReconnectGesture)
}
// T1.3 — restore a captured snapshot by writing a FRESH `{ nodes, connections }` via
// `commitGraph` (→ `$model.graph`), wrapped in the `programmatic` guard so the consumer's
// re-bind → $watch(graph) → reconcile applies it WITHOUT re-entering history (D-04 —
// pushHistory / the write-back helpers all bail while `programmatic` is raised). Recorded
// in `lastWrittenGraph` so a following undo/redo sees the restored state as "current".
// Graph-ONLY (D-03): the viewport transform is untouched.
const restoreGraph = (snap) => {
if (!snap) return
// Cancel any in-flight drag write-back so a queued frame can't clobber the restore with
// a stale position after the programmatic guard releases.
pendingDragPositions.clear()
if (dragFlushRaf) {
if (typeof cancelAnimationFrame === 'function') { try { cancelAnimationFrame(dragFlushRaf) } catch (e) {} }
dragFlushRaf = 0
}
programmatic++
try {
const fresh = { nodes: (snap.nodes || []).map((n) => ({ ...n })), connections: (snap.connections || []).map((c) => ({ ...c })) }
commitGraph(fresh)
} finally { programmatic-- }
}
// undo() — pop the newest PRE-gesture snapshot, push the CURRENT graph onto the redo
// stack, and restore the snapshot. No-op when nothing to undo.
const undo = () => {
if (historyStack.length === 0) return
const cur = snapshotCurrent()
const snap = historyStack.pop()
if (cur) redoStack.push(cur)
restoreGraph(snap)
}
// redo() — pop the newest redo snapshot, push the CURRENT graph back onto the undo
// stack, and restore it. No-op when nothing to redo.
const redo = () => {
if (redoStack.length === 0) return
const cur = snapshotCurrent()
const snap = redoStack.pop()
if (cur) historyStack.push(cur)
restoreGraph(snap)
}
const canUndo = () => historyStack.length > 0
const canRedo = () => redoStack.length > 0
// Flush the coalesced drag positions: one fresh graph object with every pending
// node's x/y applied. Echo-guarded. Clears the pending map.
const flushDragWriteBack = () => {
dragFlushRaf = 0
if (programmatic) { pendingDragPositions.clear(); return }
if (pendingDragPositions.size === 0) return
const g = baseGraph()
const nodes = (g.nodes || []).map((n) => {
const p = n && n.id != null ? pendingDragPositions.get(n.id) : null
return p ? { ...n, x: p.x, y: p.y } : n
})
pendingDragPositions.clear()
commitGraph({ ...g, nodes })
}
// Schedule a coalesced drag write-back (rAF; falls back to a microtask where rAF is
// unavailable — e.g. a non-DOM test env).
const scheduleDragFlush = () => {
if (dragFlushRaf) return
if (typeof requestAnimationFrame === 'function') {
dragFlushRaf = requestAnimationFrame(flushDragWriteBack)
} else {
dragFlushRaf = 1
Promise.resolve().then(flushDragWriteBack)
}
}
// CONNECT — append a fresh connection into a fresh graph object. Echo-guarded.
const writeBackConnectionCreated = (c) => {
if (programmatic) return
// T1.3 — one history entry per CONNECT gesture (BEFORE the write so the snapshot is the
// pre-connect state — snapshotCurrent reads lastWrittenGraph, still the pre-connect value).
// T2.5 — SUPPRESS while a reconnect is in flight: the paired remove+add of a reconnect
// (and a plain new-connection drag, which also rides connectionpick/drop) push ONE
// coalesced snapshot on connectiondrop instead (D-03 one-gesture-one-entry).
if (reconnectInFlight) reconnectDidWriteBack = true
else pushHistory()
const g = baseGraph()
const conn = { id: c.id, source: c.source, sourceOutput: c.sourceOutput, target: c.target, targetInput: c.targetInput }
commitGraph({ ...g, connections: [...(g.connections || []), conn] })
}
// DISCONNECT — filter the id out into a fresh graph object. Echo-guarded.
const writeBackConnectionRemoved = (id) => {
if (programmatic) return
// T1.3 — one history entry per DISCONNECT / edge-delete gesture (BEFORE the write).
// T2.5 — SUPPRESS while a reconnect is in flight: the remove half of a reconnect is
// coalesced with its paired add into ONE snapshot pushed on connectiondrop (D-03).
if (reconnectInFlight) reconnectDidWriteBack = true
else pushHistory()
const g = baseGraph()
commitGraph({ ...g, connections: (g.connections || []).filter((e) => e && e.id !== id) })
}
// T1.1 — EDGE SELECTION helpers (D-08). Selection state is kept PURELY in script
// (selectedConnId / selectedPathEl) and surfaced to the consumer via @edge-click /
// @edge-selected — never written into $model.graph (echo-safe like selectedNodeIds).
//
// `clearEdgeSelection` drops `.is-selected` from the live <path> (if still attached) and
// nulls the selection. `selectEdge` is invoked from the per-edge pointerup listener: it
// clears any prior selection, marks THIS path `.is-selected`, records the id + element,
// raises the one-shot `edgeClickGuard` (so the area's own background-pointerup branch
// does not immediately clear what this click just selected — the same pointerup gesture
// fires on the path AND lets the area pipe run), and emits BOTH @edge-click and
// @edge-selected ({ id }). The guard self-resets on the next microtask once the gesture
// has settled.
const clearEdgeSelection = () => {
if (selectedPathEl && selectedPathEl.classList) {
try { selectedPathEl.classList.remove('is-selected') } catch (e) {}
}
selectedConnId = null
selectedPathEl = null
}
const selectEdge = (id, pathEl) => {
if (id == null) return
clearEdgeSelection()
selectedConnId = id
selectedPathEl = pathEl
if (pathEl && pathEl.classList) {
try { pathEl.classList.add('is-selected') } catch (e) {}
}
edgeClickGuard = true
Promise.resolve().then(() => { edgeClickGuard = false })
$emit('edge-click', { id })
$emit('edge-selected', { id })
}
// CASCADING DELETE (the PUBLIC controlled-graph node delete — Win 1). Distinct from
// the engine-only `removeNode` $expose verb: `removeNode` operates directly on the
// editor and is NOT written back to the model (the provenance-tracked imperative
// escape hatch); `deleteNode` is the BLESSED controlled-graph delete — it filters the
// node AND every incident connection out of FRESH arrays and writes ONE fresh
// top-level `{ ...g, nodes, connections }` object via `$model.graph` (the Phase-41
// write-back contract — in-place mutation is silently dropped on React/Solid/Lit/
// Angular). The wrapper's own `$watch(graph)` reconcile then reaps the live engine
// node + edges — we do NOT call editor.removeNode here (a double-remove would race the
// reconcile into Rete's "cannot find node"; the controlled-model filter is the single
// removal path). NOT echo-guarded with `programmatic` — this is a CONSUMER-driven write
// that SHOULD update the bound model (mirrors the demo's per-node ✕ filter). Returns
// true if a node was removed. The id-coerce-to-String mirrors the demo's onRemoveClick.
const deleteNode = (id) => {
if (id == null) return false
const g = baseGraph()
const sid = String(id)
const nodes = (g.nodes || []).filter((n) => n && String(n.id) !== sid)
if (nodes.length === (g.nodes || []).length) return false
const connections = (g.connections || []).filter(
(c) => c && String(c.source) !== sid && String(c.target) !== sid,
)
// T1.3 — one history entry per DELETE gesture (node + its incident edges = ONE undo).
pushHistory()
commitGraph({ ...g, nodes, connections })
return true
}
// T2.8 — a fresh unique node id for a duplicated node. Derived from the source id + an
// incrementing suffix, skipping any id already present in the live graph so a repeated
// duplicate never collides (Threat T-44-06-2: a NEW unique id, never a forged/colliding
// one). String ids only (mirrors the graph contract).
const freshNodeId = (baseId, existing) => {
const taken = new Set((existing || []).map((n) => (n && n.id != null ? String(n.id) : '')))
const root = baseId != null ? String(baseId) : 'node'
let i = 1
let candidate = root + '-copy'
while (taken.has(candidate)) { i++; candidate = root + '-copy-' + i }
return candidate
}
// T2.8 — DUPLICATE the given node: clone its spec at a small offset with a NEW unique id
// into a FRESH `{ ...g, nodes:[...g.nodes, clone] }` object (the controlled-graph write-back
// contract — never an in-place push). The clone's `data` is deep-cloned ($clone strips
// any reactivity proxy) so the copy is independent of the source. Connections are NOT cloned
// (a duplicate is an isolated node — the React-Flow default). One history entry per
// duplicate gesture (pushHistory, gated on !programmatic + history). Returns the new id, or
// null if the source isn't found. NOT echo-guarded — a duplicate SHOULD update the model.
const duplicateNode = (id) => {
if (id == null) return null
const g = baseGraph()
const sid = String(id)
const src = (g.nodes || []).find((n) => n && String(n.id) === sid)
if (!src) return null
const newId = freshNodeId(src.id, g.nodes)
// Phase 45-07 (WR-02/WR-06): `$clone` is now a recursive proxy-safe deep clone
// on every target (Vue's lowering de-proxies nested reactive members via the
// `rozieDeepClone` runtime helper). The historical `$clone({ d: src.data }).d`
// object-literal wrapper — which never actually dodged the old single-toRaw
// throw on a live nested proxy — is no longer needed; clone `src.data` directly.
const clonedData = src.data != null ? $clone(src.data) : undefined
const clone = {
...src,
id: newId,
x: (typeof src.x === 'number' ? src.x : 0) + 28,
y: (typeof src.y === 'number' ? src.y : 0) + 28,
data: clonedData,
}
pushHistory()
commitGraph({ ...g, nodes: [...(g.nodes || []), clone] })
return newId
}
// Collect the currently-SELECTED node ids from the live selector (Win 1 + Win 2). The
// AreaExtensions.selector() `entities` Map holds the picked entities ({ label, id });
// for selectable nodes each entity's `id` is the node id. Empty when nothing is picked
// or selection is disabled. Read-only — no $data / engine write.
const selectedNodeIds = () => {
if (!selector || !selector.entities) return []
const ids = []
for (const e of selector.entities.values()) {
if (e && e.id != null) ids.push(e.id)
}
return ids
}
// Win 2: surface selection changes to the consumer via @selection-change ({ ids }).
// Computes the current selected-id set, dedupes against the last-emitted set (joined
// string), and emits only on an ACTUAL change. Echo-guarded by `programmatic` so a
// PROGRAMMATIC unselect (clear/deleteNode may unpick) does not surface as a user
// selection. Selection is kept PURELY in the emit — never written into the graph model
// — so the controlled-graph echo-safety (the drag write-back assertions) is unaffected.
// Sorted before joining so the dedup key is order-independent (the selector Map order
// is not guaranteed stable across pick/unpick).
const maybeEmitSelectionChange = () => {
if (programmatic) return
const ids = selectedNodeIds()
const key = [...ids].map((x) => String(x)).sort().join(' ')
if (key === lastSelectionIds) return
lastSelectionIds = key
$emit('selection-change', { ids })
// the selected set changed → repaint the minimap (selected nodes are highlighted).
if (scheduleMinimapRedraw) scheduleMinimapRedraw()
// T2.8 — the selection changed → re-track the NodeToolbar (it follows the single
// selected node; hides on multi-select / empty selection). No-op when :node-toolbar off.
if (syncToolbarSelection) syncToolbarSelection()
}
// Schedule the selection recompute AFTER the engine's own async selection update has
// settled. AreaExtensions.selectableNodes does its pick / unselectAll via AWAITED
// area.update() calls, so a bare microtask can run before `selector.entities` reflects
// the new state. A microtask AND an rAF together guarantee we recompute once the engine
// chain has flushed (the dedup collapses the pair to at most one emit). Falls back to a
// double microtask where rAF is unavailable (non-DOM test env).
const scheduleSelectionEmit = () => {
Promise.resolve().then(maybeEmitSelectionChange)
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(maybeEmitSelectionChange)
} else {
Promise.resolve().then(() => Promise.resolve().then(maybeEmitSelectionChange))
}
}
// The $portals/$emit-capturing reconcilers are built INSIDE $onMount ($portals
// referenced at top level fails the bundled-leaf strict typecheck — the CM/
// TipTap/MapLibre portal discipline) and bridged here so the top-level $watch can
// call them.
let reconcileNodes = null
let reconcileConnections = null
// Re-entrancy guard for reconcileNodes. The declarative-children path can fire the
// node reconcile RE-ENTRANTLY on async-context targets (Lit): a <FlowNode>'s
// $onMount register starts reconcile #1, and its late-context $onUpdate registration
// (REQ-30) — or the registry $watch the register triggers — starts reconcile #2 while
// #1's awaits (editor.addNode / area.translate / area.update) are still pending. Two
// overlapping reconciles racing the same engine throw Rete's "cannot find node" (one
// updates/translates a node-view the other just rebuilt), which aborts the whole graph
// build (only the config-array `cfg` node survives on Lit). This flag serializes them:
// a reconcile requested while one is running sets a "run again" bit and returns; the
// in-flight reconcile re-runs once it finishes, so every registry mutation is folded
// into a fresh non-overlapping pass. The config-array-only path never re-enters (props
// change once per tick), so this is byte-transparent to its behavior.
let reconcileNodesRunning = false
let reconcileNodesPending = false
// ── pure helpers (no sigils → safe at top level) ──
const serializeConn = (c) => ({
id: c.id,
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput,
})
// Resolve a node TYPE's port schema from the flat per-TYPE portReg — the entries
// whose key starts `type + '::'`. Returns { inputs:[{key,label,multiple,portType}],
// outputs:[…] }. Pure (no $data write) so buildNode / buildSocketRow can call it on
// every run regardless of the order the <NodeType> vs its <Port> children registered.
const portSchemaForType = (type, portReg) => {
const inputs = []
const outputs = []
if (type == null || !portReg) return { inputs, outputs }
const prefix = type + '::'
for (const k in portReg) {
if (k.indexOf(prefix) !== 0) continue
const p = portReg[k]
if (!p || p.key == null) continue
const entry = { key: p.key, label: p.label, multiple: p.multiple, portType: p.portType }
if (p.side === 'input') inputs.push(entry)
else outputs.push(entry)
}
return { inputs, outputs }
}
// Build a live Rete node from a graph-node spec ({ id, type, x, y, data }). The
// consumer's `id` is assigned onto the node so positions, portal keys, and
// connection source/target ids all align with the author's identifiers (Rete would
// otherwise auto-generate ids). Sockets come from the node's TYPE port schema
// (portReg keyed `type::side::key`) — a type's ports declared ONCE apply to every
// instance (render-by-type). The single shared SOCKET still gates compatibility by
// identity; the per-port `portType` drives typed VALIDATION, not socket identity.
const buildNode = (spec, portReg) => {
const label = spec.data && spec.data.label != null ? String(spec.data.label) : ''
const node = new ClassicPreset.Node(label)
node.id = spec.id
const { inputs, outputs } = portSchemaForType(spec.type, portReg)
for (const inp of inputs) {
if (!inp || inp.key == null) continue
node.addInput(inp.key, new ClassicPreset.Input(SOCKET, inp.label, inp.multiple === true))
}
for (const out of outputs) {
if (!out || out.key == null) continue
node.addOutput(out.key, new ClassicPreset.Output(SOCKET, out.label, out.multiple !== false))
}
return node
}
// NOTE: portTypeOf (the validation-pipe port-type resolver) is DEFINED INSIDE
// $onMount (next to the editor.addPipe that uses it), NOT here at top level. It reads
// $data.portReg, and a top-level definition lowers on React to a `useCallback` whose
// captured `portReg` is FROZEN at the snapshot when the validation pipe (set up once in
// the mount effect) was created — i.e. the INITIAL empty {} before any <Port> registered.
// A stale-empty portReg makes portTypeOf return null for every port, so the typed-socket
// validation `srcType != null && tgtType != null && srcType !== tgtType` check is SKIPPED
// and a cross-type connection is WRONGLY ALLOWED (the React-only "reject didn't fire" bug
// the advanced VR cell surfaced). Defined inside $onMount, the emitter lowers its
// $data.portReg read to the live `_portRegRef.current` (the same ref the reconcilers use),
// so validation always sees the current schema. The 5 non-React targets read live signals
// so they were correct either way; this is the React stale-closure fix (the MapLibre/PDF
// $watch-reroute lesson, here as a mount-scoped definition). ZERO emitter change.
// ─── per-TYPE registry (Phase 41 controlled-graph — the per-TYPE shift of the
// Phase 37 per-instance $provide/$inject dogfood) ────────────────────────────────
// The 'rete:canvas' registry API CONSUMED BY <NodeType>/<Port> (41-03). CRITICAL
// reactive-write discipline (Pitfall 1): every mutation WHOLE-OBJECT-REPLACES the
// registry so the watched $data.typeReg/$data.portReg reference changes exactly once
// per call — a bare in-place $data.typeReg[type] = spec is silent on React/Solid/
// Angular/Lit. THE CROSS-PLAN CONTRACT (41-03 calls EXACTLY these verbs):
// registerType(type, spec) → type-template registry (<NodeType>)
// unregisterType(type) → drop a type on <NodeType> unmount
// addTypePort(type, side, key, portType, label, multiple) → per-TYPE port schema (<Port>)
// bodyHostFor(nodeId) → the engine `body` host div
// (render-by-type callback target)
$provide('rete:canvas', {
// Register/replace a node TYPE template. `spec` carries an optional
// `bodyRenderer(host, { node })` — the render-by-type projection (mounted per graph
// node of this type into the engine body host, see renderNode). Whole-object replace.
registerType: (type, spec) => { if (type != null) $data.typeReg = { ...$data.typeReg, [type]: spec } },
// Drop a type on <NodeType> unmount (whole-object replace).
unregisterType: (type) => { const t = { ...$data.typeReg }; delete t[type]; $data.typeReg = t },
// A <Port> registers a port against its TYPE + side. Stored in the flat portReg
// under a UNIQUE per-port key `type::side::key` so registration is order-independent
// AND concurrency-safe: two <Port>s of the same type addTypePort in one React commit,
// and a pure `{ ...portReg, [uniqueKey]: port }` write (functional setState) merges
// both (an array read-modify-write under one type key would clobber). buildNode reads
// the type's portReg entries on every run regardless of mount order. The unique key
// also makes a re-fired addTypePort (late Lit context) idempotent — same key, same value.
// `side` is derived by <Port> from which of output=/input= is set (output⇒'output', input⇒'input');
// `portType` carries the port type that drives validate-types + the typed-port color.
// `position` (F2) is the socket's VISUAL placement (left|right|top|bottom; default by
// side) — drives the render-pipe socket layout + the connection-anchor axis.
addTypePort: (type, side, key, portType, label, multiple, position) => {
if (type == null || key == null) return
const portKey = type + '::' + side + '::' + key
$data.portReg = { ...$data.portReg, [portKey]: { type, side, key, portType, label, multiple, position } }
},
// Render-by-type callback target. Returns the engine-created body host div for a
// graph node (nodeEntries.get(nodeId).body). The render-by-type projection mounts
// the node's TYPE template `#body` INTO this host via $portals — the Wave-0 A3
// finding (a Lit child cannot relocate its own shadow <slot> across the boundary),
// so the body is projected by the parent reusing the $portals host discipline.
bodyHostFor: (nodeId) => {
const entry = nodeEntries.get(nodeId)
return entry ? entry.body : null
},
})
$onMount(() => {
const container = $refs.canvasEl
lastPropNodeIds = []
lastPropConnIds = []
editor = new NodeEditor()
area = new AreaPlugin(container)
connectionPlugin = new ConnectionPlugin()
connectionPlugin.addPreset(ConnectionPresets.classic.setup())
// Resolve a port's VISUAL position (F2) from the per-TYPE port schema (portReg, keyed
// `type::side::key`), defaulting by DIRECTION (input → left, output → right) for exact
// back-compat. DEFINED HERE inside $onMount (NOT top level) so its $data.portReg read
// lowers on React to the live `_portRegRef.current`, not a stale-empty mount-time
// closure (the portTypeOf discipline). Used by both the socket-anchor offset below and
// renderNode's socket layout.
const resolvePortPosition = (type, side, key) => {
const entry = type != null && key != null ? $data.portReg[type + '::' + side + '::' + key] : null
const p = entry && entry.position != null ? entry.position : null
if (p === 'left' || p === 'right' || p === 'top' || p === 'bottom') return p
return side === 'input' ? 'left' : 'right'
}
// DOM-based socket position watcher — feeds connection-path redraw + the
// ConnectionPlugin's drag-to-connect hit-testing. A CUSTOM `offset` (F2): the rete
// default shifts the anchor 12px OUTWARD on the X axis only (`x + 12·(input?−1:1)`) —
// correct for left/right, wrong for top/bottom. We resolve each socket's visual
// position and shift on the matching axis (±x for left/right — IDENTICAL to the default,
// so the rete-flow-align cell stays green; ±y for top/bottom). The position is looked up
// live via nodeMeta→type→portReg, so it tracks late-registered ports.
const SOCKET_SHIFT = 12
const socketOffset = (position, nodeId, side, key) => {
const meta = nodeMeta.get(nodeId)
const p = meta ? resolvePortPosition(meta.type, side, key) : (side === 'input' ? 'left' : 'right')
if (p === 'top') return { x: position.x, y: position.y - SOCKET_SHIFT }
if (p === 'bottom') return { x: position.x, y: position.y + SOCKET_SHIFT }
if (p === 'left') return { x: position.x - SOCKET_SHIFT, y: position.y }
return { x: position.x + SOCKET_SHIFT, y: position.y }
}
socketWatcher = getDOMSocketPosition({ offset: socketOffset })
editor.use(area)
area.use(connectionPlugin)
// ── T2.5 RECONNECT coalescing pipe (D-08 reconnectable edges, D-03 one-gesture-one-entry) ──
// `connectionpick` / `connectiondrop` are emitted on the ConnectionPlugin's OWN scope (they
// are NOT editor signals like connectioncreated/removed, nor area signals like nodepicked),
// so they must be observed via a pipe attached DIRECTLY to `connectionPlugin` — they do not
// propagate into editor.addPipe / area.addPipe. Grabbing an already-connected input socket
// fires connectionpick, then the classic preset removes the old edge + (on drop over a new
// socket) adds a new one — a remove+add pair that would push TWO history entries (Pitfall 2).
// We open a reconnect-in-flight window on connectionpick (capturing the PRE-gesture snapshot
// ONCE) and close it on connectiondrop (pushing that single snapshot iff the gesture actually
// changed the graph) — so the whole reconnect is ONE undoable step.
connectionPlugin.addPipe((context) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context
if (context.type === 'connectionpick') {
// Open the coalesce window + capture the pre-gesture snapshot once. Gated on
// !programmatic + history (a restore-driven engine op must not record history). A
// re-pick while a close is pending cancels the pending close (the gesture continues).
if (!programmatic && $props.history !== false) {
reconnectInFlight++
reconnectPreSnapshot = snapshotCurrent()
reconnectDidWriteBack = false
reconnectCloseScheduled = false
}
} else if (context.type === 'connectiondrop') {
// The gesture ended. CRITICAL ORDERING: the classic preset emits `connectiondrop`
// BEFORE the editor's `connectionremoved` / `connectioncreated` signals fire (the
// pseudo-connection is dropped, THEN the real add/remove run — verified in the event
// trace: drop → connectioncreate → connectioncreated → connectionremove →
// connectionremoved). So we must NOT close the window synchronously here, or the
// trailing writeBacks would run with inFlight=0 and each push its own (wrong) history
// entry. Instead DEFER the close to a macrotask (setTimeout 0), which runs after all
// the synchronous + microtask writeBack signals have settled. The window stays open
// across the remove+add (both suppress their per-event push, setting
// reconnectDidWriteBack), then closeReconnectGesture pushes the SINGLE pre-gesture
// snapshot iff the graph actually changed. Re-entrant picks can't desync because the
// close is gated on a one-shot scheduled flag.
scheduleReconnectClose()
// ── T2.7 CONNECT-END-ON-PANE (D-07, pure emit) ──
// A drag that STARTED on an output socket and ENDED on empty canvas (no target
// socket, no connection created) surfaces `@connect-end { source, sourceOutput,
// position }` so the consumer can run its OWN node-picker / create-node flow at the
// drop point (the n8n "drag off a port → drop on the pane → pick a node" UX). The
// component owns ONLY this hook — it creates NO node and shows NO picker (D-07,
// consumer-owns-creation, exactly like screenToFlowPosition + the palette drop).
// Detection: `socket == null` (released over the pane, not a socket) && `created ==
// false` (no edge was made) && `initial.side === 'output'` (we only surface OUTPUT-
// started drags — an input-started drag off the pane has no "source output" to seed
// a downstream node from, and the reconnect path already owns input-endpoint drags).
// Position = `area.area.pointer` (the AreaPlugin's live pointer, ALREADY in graph
// coords — the same origin screenToFlowPosition projects into), so no client→graph
// projection is needed; we still fall back to screenToFlowPosition over a raw
// clientX/clientY if a future plugin build stops tracking area.area.pointer. Gated on
// !programmatic so a restore/imperative-driven drop never emits. NO node is created.
const cd = context.data
if (cd && !cd.socket && cd.created === false && cd.initial && cd.initial.side === 'output' && !programmatic) {
let pos = null
const inner = area && area.area ? area.area : null
if (inner && inner.pointer && typeof inner.pointer.x === 'number' && typeof inner.pointer.y === 'number') {
pos = { x: inner.pointer.x, y: inner.pointer.y }
}
if ((!pos || typeof pos.x !== 'number' || typeof pos.y !== 'number') && cd.initial && cd.initial.element && typeof cd.initial.element.getBoundingClientRect === 'function') {
// Fallback: project the last-known pointer client coords through the shipped
// screenToFlowPosition (graph-coord inverse of the area transform). The drop event
// carries no pointer; use the source socket element's center as a degraded anchor.
const r = cd.initial.element.getBoundingClientRect()
pos = screenToFlowPosition(r.left + r.width / 2, r.top + r.height / 2)
}
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
$emit('connect-end', {
source: cd.initial.nodeId,
sourceOutput: cd.initial.key,
position: { x: pos.x, y: pos.y },
})
}
}
}
return context
})
// The socket-position watcher (and, conceptually, our vanilla "render plugin")
// must attach to a CHILD scope of the area — `attach` calls
// `scope.parentScope(BaseAreaPlugin)`, which walks UP one level, so the scope's
// parent must BE the area. Attaching to `area` itself fails ("actual parent is
// not instance of type") because area's parent is the NodeEditor. So we add a
// minimal child Scope and attach the watcher to it. Rete forwards every area
// signal (render/nodetranslated/unmount/…) into this child's signal, so the
// watcher sees socket renders + node moves and recomputes socket positions.
renderScope = new Scope('rozie-vanilla-render')
area.use(renderScope)
socketWatcher.attach(renderScope)
// ── T2.6 auto-layout (D-08, verb-only) ──
// Wire the AutoArrangePlugin (elkjs classic preset) so the top-level autoArrange() verb
// can run a layered relayout on demand. area.use(arrange) installs it as an area-scope
// plugin; arrange.layout() mutates the engine node positions directly (calls area.translate
// internally). The verb reads the arranged positions BACK into a FRESH $model.graph (the
// controlled-graph contract — the engine is never the source of truth). NO auto-trigger —
// the consumer calls autoArrange() (the MapLibre verb-first stance).
arrange = new AutoArrangePlugin()
arrange.addPreset(ArrangePresets.classic.setup())
area.use(arrange)
// ── selection (selectableNodes) ──
// Capture the returned handle ({ select(id, accumulate), unselect(id) }) so the T2.4
// marquee can PROGRAMMATICALLY select each intersecting node (select(id, true) =
// accumulate). The handle is null when selection is off (readonly / !selectable), in
// which case the marquee branch no-ops.
if ($props.selectable && !$props.readonly) {
selector = AreaExtensions.selector()
nodeSelectApi = AreaExtensions.selectableNodes(area, selector, {
accumulating: $props.accumulateOnCtrl ? AreaExtensions.accumulateOnCtrl() : { active: () => false },
})
}
// raise the picked node above its siblings.
AreaExtensions.simpleNodesOrder(area)
// ── zoom clamp (restrictor) ──
const min = typeof $props.minZoom === 'number' && $props.minZoom > 0 ? $props.minZoom : 0
const max = typeof $props.maxZoom === 'number' && $props.maxZoom > 0 ? $props.maxZoom : 0
if (min || max) {
AreaExtensions.restrictor(area, {
scaling: { min: min || 0.01, max: max || 100 },
})
}
// ── snap-to-grid ──
if (typeof $props.snapGrid === 'number' && $props.snapGrid > 0) {
AreaExtensions.snapGrid(area, { size: $props.snapGrid, dynamic: true })
}
// ── interaction toggles ──
if (!$props.pannable) area.area.setDragHandler(null)
if (!$props.zoomable) area.area.setZoomHandler(null)
// ── Delete / Backspace key → cascading delete of the selected node(s) (Win 1) ──
// Attached to the engine container ($refs.canvasEl, which carries tabindex="0" in
// the template so it can receive key focus) rather than `document`: the listener
// lives INSIDE the Lit shadow root alongside the canvas, so a canvas-focused key
// reaches it on Lit too (a `:target="document"` listener does not reliably see
// shadow-scoped focus across all 6 — the canvas-element listener is the robust
// cross-target path). Gated on selectable && !readonly. We guard against deleting
// while focus is in a node-body text field (INPUT/TEXTAREA/contenteditable) so
// typing in a node never nukes it. The listener is removed in the teardown.
if ($props.selectable && !$props.readonly && container && typeof container.addEventListener === 'function') {
onCanvasKeydown = (e) => {
if (!e) return
const t = e.target
// Focus-guard (verbatim with the Delete branch): never act while focus is in a
// node-body text field (INPUT/TEXTAREA/contenteditable) — Ctrl+Z must reach the
// browser's native text undo there, and Delete must not nuke the node.
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
// ── T1.3 — Undo / Redo keybinds (D-02). Ctrl/Cmd+Z → undo; Ctrl/Cmd+Shift+Z and
// Ctrl/Cmd+Y → redo. Gated on the SAME focus-guard as Delete. preventDefault so the
// browser's page-level undo doesn't also fire. `metaKey` covers macOS Cmd. ──
if ((e.ctrlKey || e.metaKey) && !e.altKey) {
const k = typeof e.key === 'string' ? e.key.toLowerCase() : ''
if (k === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return }
if ((k === 'z' && e.shiftKey) || k === 'y') { e.preventDefault(); redo(); return }
}
if (e.key !== 'Delete' && e.key !== 'Backspace') return
const ids = selectedNodeIds()
if (ids.length > 0) {
e.preventDefault()
for (const id of ids) deleteNode(id)
return
}
// T1.1 — EDGE DELETE (D-08). No node is picked but an edge is selected → remove
// exactly that edge via the controlled-graph write-back (the disconnect path: a
// fresh `{ ...g, connections: filtered }` object), then clear the selection. The
// wrapper's own $watch(graph) reconcile reaps the live engine connection (the
// single removal path — we do NOT also call editor.removeConnection, which would
// race the reconcile into "cannot find connection", mirroring deleteNode). Node
// delete takes precedence (handled above); this only runs when nothing's picked.
if (selectedConnId != null) {
e.preventDefault()
const id = selectedConnId
clearEdgeSelection()
writeBackConnectionRemoved(id)
}
}
keydownContainer = container
container.addEventListener('keydown', onCanvasKeydown)
}
// ─────────────────────────────────────────────────────────────────────────
// THE VANILLA RENDER PIPE. Intercepts the AreaPlugin's render/unmount signals.
// ALWAYS returns context (returning undefined would halt the signal chain and
// break the ConnectionPlugin / socket watcher downstream).
// ─────────────────────────────────────────────────────────────────────────
area.addPipe((context) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context
if (context.type === 'render') {
const data = context.data
if (data.type === 'node') renderNode(data.element, data.payload)
else if (data.type === 'connection') renderConnection(data.element, data.payload, data.start, data.end)
// data.type === 'socket' (our own re-emitted signals) falls through
// untouched so the ConnectionPlugin + socketWatcher consume them.
} else if (context.type === 'unmount') {
cleanupElement(context.data.element)
}
return context
})
// ── node renderer ──
// Fills the engine-created nodeView element with: input sockets, the body
// (consumer `node` portal fragment OR default chrome), and output sockets.
// Re-render (area.update('node', id)) reuses the same element → update in place.
// NOTE: the engine-node parameter is `reteNode`, NOT `node` — on Svelte the
// `$slots.node` slot lowers to a top-level `const node`, and a parameter named
// `node` here would SHADOW it, so `if ($slots.node)` would read the (always-
// truthy) engine node and wrongly take the portal branch even when the slot is
// unfilled (dropping the default-chrome title). The cross-target slot-name ==
// local-binding shadow trap.
const renderNode = (element, reteNode) => {
// a (re)render means node DOM exists / changed → refresh the minimap (its node
// rects measure these elements; coalesced, so calling it on every render is cheap,
// and it covers Lit's measure-after-first-paint).
if (scheduleMinimapRedraw) scheduleMinimapRedraw()
const id = reteNode.id
const meta = nodeMeta.get(id) || { id, type: undefined, data: {} }
const existing = nodeEntries.get(id)
const selected = reteNode.selected === true
// default-chrome fallback label (only when a node's type has no #body template).
const chromeLabel = meta.data && meta.data.label != null ? String(meta.data.label) : (meta.type != null ? String(meta.type) : '')
if (existing && existing.element === element) {
// in-place update — refresh chrome + reactive portal scope, leave sockets.
existing.box.classList.toggle('is-selected', selected)
if (existing.handle) {
existing.handle.update({ node: meta, selected, emit: existing.emit })
} else if (existing.titleEl) {
existing.titleEl.textContent = chromeLabel
}
return
}
// fresh build
element.innerHTML = ''
const box = document.createElement('div')
box.className = 'rozie-flow-node' + (selected ? ' is-selected' : '')
const body = document.createElement('div')
body.className = 'rozie-flow-node__body'
// ── socket layout (F2: position-aware) ───────────────────────────────────────
// Bucket the node's ports by VISUAL position (default input→left, output→right).
// When NO port is top/bottom (every pre-F2 graph), render the EXACT classic
// [inputsCol | body | outputsCol] 3-column structure — byte-identical DOM, so the
// FlowCanvasScreenshot pixel baseline is untouched. A node that declares ANY top/
// bottom port gets the 3-ROW structure (topRow / midRow[left|body|right] / bottomRow).
const socketDisposers = []
const portEntries = []
for (const key of Object.keys(reteNode.inputs)) portEntries.push({ side: 'input', key, position: resolvePortPosition(meta.type, 'input', key) })
for (const key of Object.keys(reteNode.outputs)) portEntries.push({ side: 'output', key, position: resolvePortPosition(meta.type, 'output', key) })
const hasVertical = portEntries.some((p) => p.position === 'top' || p.position === 'bottom')
if (!hasVertical) {
// CLASSIC left/right layout — byte-for-byte identical to pre-F2 (pixel-baseline safe).
const inputsCol = document.createElement('div')
inputsCol.className = 'rozie-flow-node__col rozie-flow-node__col--in'
const outputsCol = document.createElement('div')
outputsCol.className = 'rozie-flow-node__col rozie-flow-node__col--out'
box.appendChild(inputsCol)
box.appendChild(body)
box.appendChild(outputsCol)
element.appendChild(box)
for (const p of portEntries) {
renderSocketInto(p.position === 'right' ? outputsCol : inputsCol, reteNode, p.side, p.key, p.position, socketDisposers)
}
} else {
// VERTICAL-capable 3-row layout (only when a top/bottom port exists).
box.classList.add('rozie-flow-node--rows')
const topRow = document.createElement('div')
topRow.className = 'rozie-flow-node__row rozie-flow-node__row--top'
const midRow = document.createElement('div')
midRow.className = 'rozie-flow-node__mid'
const leftCol = document.createElement('div')
leftCol.className = 'rozie-flow-node__col rozie-flow-node__col--in'
const rightCol = document.createElement('div')
rightCol.className = 'rozie-flow-node__col rozie-flow-node__col--out'
const bottomRow = document.createElement('div')
bottomRow.className = 'rozie-flow-node__row rozie-flow-node__row--bottom'
midRow.appendChild(leftCol)
midRow.appendChild(body)
midRow.appendChild(rightCol)
box.appendChild(topRow)
box.appendChild(midRow)
box.appendChild(bottomRow)
element.appendChild(box)
for (const p of portEntries) {
const zone = p.position === 'top' ? topRow : p.position === 'bottom' ? bottomRow : p.position === 'right' ? rightCol : leftCol
renderSocketInto(zone, reteNode, p.side, p.key, p.position, socketDisposers)
}
}
// emit per-node event helper handed to the slot scope so a consumer node body
// can raise a custom event carrying its id (e.g. a delete button).
const emit = (name, detail) => $emit('node-action', { id, name, detail })
const entry = { element, box, body, handle: null, bodyHandle: null, titleEl: null, bodyMoved: false, emit, socketDisposers }
// ── RENDER-BY-TYPE: select the body by `node.type` ──────────────────────────
// 1) the node's TYPE template (typeReg[type].bodyRenderer) — the primary path
// (41-03 <NodeType><template #body>); 2) the low-level `#node` portal slot
// (consumer switches on node.type itself — escape hatch); 3) default chrome.
const typeSpec = meta.type != null ? $data.typeReg[meta.type] : null
if (typeSpec && typeof typeSpec.bodyRenderer === 'function') {
// RENDER-BY-TYPE callback path. The <NodeType> cannot relocate its OWN <slot>
// across the Lit shadow boundary (Wave-0 A3), so the PARENT projects the body
// here from its own render scope: the type's registered bodyRenderer(host, scope)
// mounts the type's `#body` portal INTO the engine `body` div (a FRESH render
// root per node — no framework DOM relocation, the Phase-37 D-04 trap avoided).
// nodeEntries must exist before the callback runs (bodyHostFor reads it), so
// register first. The graph node's `data` flows in as scope → one template per
// type renders every instance of that type.
nodeEntries.set(id, entry)
entry.bodyHandle = typeSpec.bodyRenderer(body, { node: meta, selected, emit })
entry.bodyMoved = true
return
}
if ($slots.node) {
// reactive multi-instance portal — one handle per node, re-rendered in
// place on meta change (the MapLibre marker discipline). Low-level escape
// hatch: the consumer switches on node.type inside the single `#node` slot.
entry.handle = $portals.node(body, { node: meta, selected, emit })
} else {
// default chrome: a title bar (the type name / data.label).
const title = document.createElement('div')
title.className = 'rozie-flow-node__title'
title.textContent = chromeLabel
body.appendChild(title)
entry.titleEl = title
}
nodeEntries.set(id, entry)
}
// Render ONE socket into a zone and, crucially, EMIT its render signal so the
// ConnectionPlugin + position watcher register it. `position` is the socket's visual
// placement (left|right|top|bottom). For left/right the DOM is byte-identical to pre-F2
// (the classic horizontal port row); top/bottom get a vertical port (socket above its
// label) + a `--<position>` socket class so the socket straddles the matching edge.
const renderSocketInto = (zone, reteNode, side, key, position, socketDisposers) => {
const port = (side === 'input' ? reteNode.inputs : reteNode.outputs)[key]
if (!port) return
const vertical = position === 'top' || position === 'bottom'
const row = document.createElement('div')
row.className = 'rozie-flow-port rozie-flow-port--' + side + (vertical ? ' rozie-flow-port--vertical' : '')
const socketEl = document.createElement('div')
socketEl.className = 'rozie-flow-socket rozie-flow-socket--' + side + (vertical ? ' rozie-flow-socket--' + position : '')
socketEl.setAttribute('data-testid', 'socket')
const label = document.createElement('span')
label.className = 'rozie-flow-port__label'
label.textContent = port.label != null ? String(port.label) : key
// CLASSIC: inputs socket-first, outputs label-first (byte-identical to pre-F2).
// VERTICAL: socket-first (the socket sits on the edge, label tucked inward).
if (side === 'input' || vertical) { row.appendChild(socketEl); row.appendChild(label) }
else { row.appendChild(label); row.appendChild(socketEl) }
zone.appendChild(row)
// LOAD-BEARING: announce the socket to the rest of the area's child plugins.
// 'render' lets the ConnectionPlugin register the socket as a drag anchor.
area.emit({ type: 'render', data: { type: 'socket', side, key, nodeId: reteNode.id, element: socketEl, payload: { socket: port.socket } } })
// ALSO LOAD-BEARING (the socket-position contract): getDOMSocketPosition measures +
// stores a socket's DOM position ONLY on a 'rendered' socket signal — the render-plugin
// lifecycle's post-mount phase. Our vanilla pipe creates + appends the socket DOM
// synchronously, so we fire 'rendered' right after 'render'. WITHOUT IT the position
// store stays empty, every socketWatcher.listen() callback reads null, and NO
// connection path (committed OR drag preview) is ever drawn.
area.emit({ type: 'rendered', data: { type: 'socket', side, key, nodeId: reteNode.id, element: socketEl, payload: { socket: port.socket } } })
socketDisposers.push(() => {
area.emit({ type: 'unmount', data: { element: socketEl } })
})
}
// ── hand-written edge-type path generators (T1.2, D-01) ───────────────────────
// `rete-render-utils` ships ONLY `classicConnectionPath` (bezier) + `loopConnectionPath`;
// step/smoothstep/straight do NOT exist in any installed rete package, so they are
// hand-written here matching React-Flow's `step|smoothstep|straight` semantics. Each is a
// PURE `(start, end) → d-string` function over `{x,y}` graph-screen points; the `d` is
// composed from numeric coords + literal SVG commands and written via setAttribute (never
// innerHTML — no injection, T-44-02-2 accept). The default branch stays
// `classicConnectionPath` → byte-identical bezier (pixel-baseline safe).
// straight: a single line, no curvature.
const straightPath = (s, e) => `M ${s.x} ${s.y} L ${e.x} ${e.y}`
// step: orthogonal HV-VH with a mid-X break.
const stepPath = (s, e) => {
const mx = (s.x + e.x) / 2
return `M ${s.x} ${s.y} L ${mx} ${s.y} L ${mx} ${e.y} L ${e.x} ${e.y}`
}
// smoothstep: step with rounded corners (radius r, clamped to half the shorter leg).
const smoothstepPath = (s, e, r = 8) => {
const mx = (s.x + e.x) / 2
const dir = e.y >= s.y ? 1 : -1
const rr = Math.min(r, Math.abs(mx - s.x), Math.abs(e.y - s.y) / 2)
return [
`M ${s.x} ${s.y}`,
`L ${mx - rr} ${s.y}`,
`Q ${mx} ${s.y} ${mx} ${s.y + dir * rr}`,
`L ${mx} ${e.y - dir * rr}`,
`Q ${mx} ${e.y} ${mx + rr} ${e.y}`,
`L ${e.x} ${e.y}`,
].join(' ')
}
// ── connection renderer ──
// Mounts an <svg><path> and redraws it whenever either endpoint socket moves
// (real connection) OR the dragged pointer moves (user drag-to-connect pseudo).
//
// A USER DRAG renders a *pseudo-connection* (rete-connection-plugin): the render
// signal carries a literal pointer coordinate (`endPointer`/`data.end` when
// dragging FROM an output, `startPointer`/`data.start` when dragging FROM an
// input) alongside a payload with ONE DANGLING endpoint — `target:''`/
// `targetInput:''` (output-side drag) or `source:''`/`sourceOutput:''`
// (input-side drag). The dangling side has no socket to watch, so its coordinate
// MUST come from the pointer; the live side stays watcher-driven. The
// ConnectionPlugin re-emits this render on EVERY pointermove with a fresh pointer
// — so the same pseudo element is re-rendered repeatedly and the dangling
// coordinate must update in place (no SVG rebuild, no listener re-subscribe).
const renderConnection = (element, connection, startPointer, endPointer) => {
const id = connection.id
// A side is dangling when its node id OR its port key is empty/nullish.
const srcDangling = !connection.source || !connection.sourceOutput
const tgtDangling = !connection.target || !connection.targetInput
// RE-RENDER of the SAME element (the pseudo on each pointermove): do NOT rebuild
// the SVG or re-subscribe listeners (would leak) — just update the dangling
// side's coordinate and redraw. This replaces the old unconditional early-return
// that froze the preview line. For a REAL connection updatePointer is a no-op,
// so a re-render of a committed edge is byte-for-byte the old early-return.
const prev = connEntries.get(id)
if (prev && prev.element === element) {
prev.updatePointer(startPointer, endPointer)
return
}
element.innerHTML = ''
element.classList.add('rozie-flow-connection')
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('class', 'rozie-flow-connection__svg')
// ── direction arrowhead (Win 3) ─────────────────────────────────────────────
// A <defs><marker> in THIS connection's own <svg>, referenced by `marker-end` so
// the triangle sits at the path END (the input socket — the path runs output→input,
// so marker-end points INTO the target). The marker id is UNIQUE per connection
// (`rozie-arrow-<id>`) so two edges' markers never collide on a shared document id
// (url(#id) resolves to the first match otherwise). The def lives in the SAME
// per-edge <svg> inside the SAME shadow root as the path, so url(#id) resolves
// within that root — no cross-root reference (Lit-safe). markerUnits="userSpaceOnUse"
// keeps a constant pixel size under the area zoom transform. Inline fill (#64748b,
// matching the connection stroke) is the cross-target-safe choice — no scoped-CSS /
// :root rule needed for the marker DOM. The marker does NOT change the path `d`
// or the socket geometry (the rete-flow-align cell stays green) — redraw() only
// sets the head's `orient` and a `stroke-dasharray` that visually trims the last
// ARROW_LEN of the stroke so the line meets the head without poking through it.
const markerId = 'rozie-arrow-' + String(id)
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs')
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker')
marker.setAttribute('id', markerId)
// Sized in userSpaceOnUse (constant pixels under zoom). A 12×10 head reads
// clearly at default zoom (the old 6×6 was barely visible). refX=12 sits the
// TIP exactly at the path-end vertex (the socket); refY=5 centers it. `orient`
// is recomputed per-redraw from the path's final-segment tangent, and the
// visible stroke is trimmed back to the arrow base, so the head points along
// the edge's actual approach AND the line meets it cleanly — see redraw().
marker.setAttribute('markerWidth', '13')
marker.setAttribute('markerHeight', '10')
marker.setAttribute('refX', '12')
marker.setAttribute('refY', '5')
marker.setAttribute('orient', 'auto')
marker.setAttribute('markerUnits', 'userSpaceOnUse')
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path')
arrow.setAttribute('class', 'rozie-flow-connection__arrow')
arrow.setAttribute('d', 'M0,0 L12,5 L0,10 Z')
arrow.setAttribute('fill', '#64748b')
marker.appendChild(arrow)
defs.appendChild(marker)
svg.appendChild(defs)
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('class', 'rozie-flow-connection__path')
path.setAttribute('marker-end', 'url(#' + markerId + ')')
svg.appendChild(path)
// ── T1.1 edge-select listener (D-08) ─────────────────────────────────────────
// Attach an IMPERATIVE pointerup listener on the engine-DOM <path> (NOT a template
// `@` — the path is engine-created; NOT click — Rete swallows it; NOT pointerdown —
// Rete stopPropagations it: the Phase-41 connector landmine, playbook §6a item 7).
// Gated on `selectable && !readonly` (mirrors node delete) and ONLY for COMMITTED
// edges — a drag-to-connect pseudo (either side dangling) carries no stable id and
// must not be selectable. `selectEdge` reads the id back off the closure (the
// committed connection.id == the graph connection id — conn.id = spec.id at build),
// so it always matches what `writeBackConnectionRemoved` filters. `.stop` keeps the
// pointerup from reaching the area's pan/background handling beneath the path.
if ($props.selectable && !$props.readonly && !srcDangling && !tgtDangling) {
path.style.cursor = 'pointer'
path.addEventListener('pointerup', (e) => {
if (e && typeof e.stopPropagation === 'function') e.stopPropagation()
selectEdge(connection.id, path)
})
}
// ── per-edge label + styling (F3) ────────────────────────────────────────────
// The consumer's connection spec ({ id, source, …, label?, stroke?, dashed? }) is kept
// in connMeta keyed by id (the connection-side analog of nodeMeta). A committed edge
// resolves its label/style here; a drag-preview pseudo (no committed id) has none.
// Styling is applied as INLINE attributes (the arrowhead-marker discipline — engine DOM
// carries no scope attr); a `label` renders an SVG <text> at the path midpoint (white
// halo via paint-order for legibility over the line), repositioned in redraw().
const emeta = connMeta.get(connection.id) || null
if (emeta) {
if (emeta.stroke != null) {
const s = String(emeta.stroke)
path.setAttribute('stroke', s)
arrow.setAttribute('fill', s)
}
if (emeta.dashed === true) path.setAttribute('stroke-dasharray', '7 5')
}
// ── resolved edge type (T1.2) ────────────────────────────────────────────────
// The consumer-supplied `connection.type` selects a path generator. ALLOWLIST it
// (`bezier|step|smoothstep|straight`); any other/absent value falls through to the
// bezier default — no dynamic path-fn lookup keyed on the raw string, no eval
// (T-44-02-1 mitigate). A dangling drag-preview pseudo has no committed connMeta
// entry, so it stays bezier too.
const rawType = emeta && emeta.type != null ? String(emeta.type) : 'bezier'
const edgeType = (rawType === 'step' || rawType === 'smoothstep' || rawType === 'straight') ? rawType : 'bezier'
// Arrowhead geometry (redraw): the head is oriented along the path's tangent
// over its LAST `ARROW_LEN` (angled for a descending edge, aligned with where
// the line actually meets the head — unlike the chord, which diverges from the
// bezier's flattened end tangent), and the visible stroke is trimmed back to
// the arrow base on SOLID edges so the line's width can't poke past the
// tapering tip (the "square tip"). Dashed edges keep their pattern untrimmed.
const ARROW_LEN = 12
const isDashed = !!(emeta && emeta.dashed === true)
let labelEl = null
const edgeLabel = emeta && emeta.label != null ? String(emeta.label) : null
if (edgeLabel) {
labelEl = document.createElementNS('http://www.w3.org/2000/svg', 'text')
labelEl.setAttribute('class', 'rozie-flow-connection__label')
labelEl.setAttribute('text-anchor', 'middle')
labelEl.setAttribute('dominant-baseline', 'middle')
labelEl.textContent = edgeLabel
svg.appendChild(labelEl)
}
element.appendChild(svg)
let start = null
let end = null
const curvature = typeof $props.curvature === 'number' ? $props.curvature : 0.3
const redraw = () => {
if (!start || !end) return
// branch on the resolved edge type; default (bezier/unknown) stays
// classicConnectionPath UNCHANGED → byte-identical bezier output.
const d = edgeType === 'step' ? stepPath(start, end)
: edgeType === 'smoothstep' ? smoothstepPath(start, end)
: edgeType === 'straight' ? straightPath(start, end)
: classicConnectionPath([start, end], curvature)
path.setAttribute('d', d)
// Orient the head and trim the visible stroke back to the arrow base (solid
// edges) so the line meets the head without poking through the tip.
// getTotalLength/getPointAtLength are SVGGeometryElement methods unavailable
// in a non-rendering env (jsdom) → guard and fall back to orient='auto' / untrimmed.
let pathLen = 0
try { pathLen = path.getTotalLength() } catch (e) { pathLen = 0 }
if (pathLen > ARROW_LEN + 1) {
// BACKWARD edge (target socket left of the source socket): the classic
// bezier overshoots both control points, looping the curve into tight
// u-turns right at the sockets, so a sampled local tangent is unstable and
// the head curls. Use the path's TRUE end tangent (orient='auto' — the
// horizontal entry into the input) for a stable, standard arrow. FORWARD
// edges keep the final-ARROW_LEN tangent, which follows a descending edge
// AND aligns with where the line meets the head.
if (end.x < start.x) {
marker.setAttribute('orient', 'auto')
} else {
const tip = path.getPointAtLength(pathLen)
const back = path.getPointAtLength(pathLen - ARROW_LEN)
marker.setAttribute('orient', String(Math.atan2(tip.y - back.y, tip.x - back.x) * 180 / Math.PI))
}
if (!isDashed) path.setAttribute('stroke-dasharray', (pathLen - ARROW_LEN) + ' ' + pathLen)
} else {
marker.setAttribute('orient', 'auto')
if (!isDashed) path.removeAttribute('stroke-dasharray')
}
if (labelEl) {
labelEl.setAttribute('x', String((start.x + end.x) / 2))
labelEl.setAttribute('y', String((start.y + end.y) / 2))
}
}
// Seed the DANGLING side's coordinate from the pointer FIRST — socketWatcher
// .listen() synchronously replays the current socket snapshot on subscribe, so
// seeding before subscribing the live side means redraw() already has the
// dangling coordinate and the preview line draws immediately on the first render.
if (srcDangling && startPointer) start = startPointer
if (tgtDangling && endPointer) end = endPointer
// LIVE endpoints stay watcher-driven (exactly as before the fix — committed
// connections behave byte-for-byte). DANGLING endpoints subscribe NO listener
// (it would never fire — there is no socket); their coordinate is the pointer.
let un1 = null
let un2 = null
if (!srcDangling) un1 = socketWatcher.listen(connection.source, 'output', connection.sourceOutput, (p) => { start = p; redraw() })
if (!tgtDangling) un2 = socketWatcher.listen(connection.target, 'input', connection.targetInput, (p) => { end = p; redraw() })
// Update only the DANGLING side(s) from a fresh pointer on each subsequent
// render call. For a REAL connection (neither side dangling) this is a no-op,
// so committed connections never have a pointer override and keep behaving
// exactly as before.
const updatePointer = (sp, ep) => {
let moved = false
if (srcDangling && sp) { start = sp; moved = true }
if (tgtDangling && ep) { end = ep; moved = true }
if (moved) redraw()
}
// Draw once now: a pseudo seeded with an initial pointer (+ its live side
// already replayed) draws immediately; a real connection whose sockets are
// already known also draws (idempotent — same `d` the listeners just set).
redraw()
connEntries.set(id, {
element,
updatePointer,
dispose: () => { try { un1 && un1() } catch (e) {} try { un2 && un2() } catch (e) {} },
})
}
// ── unmount cleanup (keyed by the engine element area hands back) ──
const cleanupElement = (element) => {
for (const [id, entry] of nodeEntries) {
if (entry.element === element) {
if (entry.handle) entry.handle.dispose()
if (entry.bodyHandle && entry.bodyHandle.dispose) { try { entry.bodyHandle.dispose() } catch (e) {} }
for (const d of entry.socketDisposers) { try { d() } catch (e) {} }
nodeEntries.delete(id)
return
}
}
for (const [id, entry] of connEntries) {
if (entry.element === element) {
entry.dispose()
connEntries.delete(id)
return
}
}
}
// Resolve a single port's TYPE for the validation pipe: look up the live node's
// `type` (via nodeMeta) then the portReg entry keyed `type::side::key`. Returns the
// portType string or null (null on either side ⇒ no type constraint ⇒ allow). DEFINED
// HERE (inside $onMount) — NOT at top level — so its $data.portReg read lowers on React
// to the live `_portRegRef.current` rather than a stale-empty closure snapshot captured
// when this once-only mount effect first ran (the cross-type-reject-didn't-fire bug).
const portTypeOf = (nodeId, side, key) => {
const meta = nodeMeta.get(nodeId)
if (!meta || meta.type == null || key == null) return null
const entry = $data.portReg[meta.type + '::' + side + '::' + key]
return entry ? entry.portType : null
}
// ─── connection-validation gate (D2/D3 — typed-socket validation + override) ──
// Cancels Rete's cancellable `connectioncreate` pre-event when the connection is
// rejected. TWO independent reject paths, both surfacing `connection-rejected`:
// 1. AUTOMATIC typed validation (`:validate-types`, default ON, D3 option a):
// resolve src/tgt port TYPE from the per-TYPE port schema (via each endpoint
// node's `type`); if both are non-null and UNEQUAL → reject. A null on either
// side (untyped port / unknown type) imposes no constraint → allow.
// 2. `canConnect` OVERRIDE (Phase-40 contract, SURVIVES): a consumer custom rule;
// runs IN ADDITION to (after) the automatic check; returning false rejects.
// Cancelling makes editor.addConnection return false WITHOUT pushing the connection
// or emitting `connectioncreated` — no ghost edge, no `connection-created`. Gates
// drag-to-connect, imperative addConnection, and reconcile uniformly. Both predicates
// are PURE (no $data write / engine call) — reads only. The block (return undefined)
// stays UNCONDITIONAL so rejection is enforced on every path; only the EMIT is
// echo-guarded (a programmatic reconcile the rule would reject must not surface as a
// user-facing rejection — mirrors connection-created/connection-removed).
editor.addPipe((context) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context
if (context.type === 'connectioncreate') {
const c = context.data
// ClassicPreset.Connection fields: { id, source, sourceOutput, target, targetInput }.
// Same shape as serializeConn minus the engine-assigned `id` (never created).
const conn = {
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput,
}
// 1. AUTOMATIC typed validation (default ON; opt out via :validate-types="false").
if ($props.validateTypes !== false) {
const srcType = portTypeOf(c.source, 'output', c.sourceOutput)
const tgtType = portTypeOf(c.target, 'input', c.targetInput)
if (srcType != null && tgtType != null && srcType !== tgtType) {
if (!programmatic) $emit('connection-rejected', conn)
return undefined // ← CANCEL: type mismatch
}
}
// 2. canConnect OVERRIDE (Phase-40 contract — custom rule, in addition).
if (typeof $props.canConnect === 'function' && $props.canConnect(conn) === false) {
if (!programmatic) $emit('connection-rejected', conn)
return undefined // ← CANCEL: Signal.emit halts, addConnection returns false
}
}
return context
})
// ─── forward engine events (echo-guarded via `programmatic`) ───────────────
editor.addPipe((context) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context
if (context.type === 'connectioncreated') {
// keep engine truth in sync so reconcile diffs correctly — a user-drawn
// connection (auto id) must register here or the next graph pass re-adds it.
connInstances.set(context.data.id, context.data)
if (!programmatic) {
// WRITE-BACK: append the new connection into a fresh graph object (D4).
writeBackConnectionCreated(context.data)
// keep the discrete event too (back-compat).
$emit('connection-created', serializeConn(context.data))
}
} else if (context.type === 'connectionremoved') {
connInstances.delete(context.data.id)
connMeta.delete(context.data.id)
if (!programmatic) {
// WRITE-BACK: filter the removed connection out of a fresh graph object (D4).
writeBackConnectionRemoved(context.data.id)
$emit('connection-removed', { id: context.data.id })
}
}
return context
})
area.addPipe((context) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context
if (context.type === 'nodepicked') {
$emit('node-picked', { id: context.data.id })
// T1.3 — pointer-DOWN: stash the PRE-drag graph snapshot (before any movement). It
// is committed to history on the first `nodetranslated` (only if a drag follows;
// gated on !programmatic + history). A re-pick mid-drag won't overwrite a live one.
if (!programmatic && $props.history !== false && !dragGestureActive) {
pendingDragSnapshot = snapshotCurrent()
}
// Win 2: a pick changed the selection — surface @selection-change after the
// engine's awaited select() for THIS pick has flushed the selector entities.
scheduleSelectionEmit()
} else if (context.type === 'pointerup') {
// Win 2: AreaExtensions.selectableNodes UNSELECTS all on a click-like background
// pointerUP (its `twitch < 4` deselect — NOT on pointerdown, verified against
// rete-area-plugin's selectable pipe). Its unselectAll() is async and its pipe
// runs before ours, so recompute AFTER its awaited unselectAll() flushes (the
// microtask + rAF schedule). The dedup makes a no-op when nothing changed (e.g. a
// pointerup that ended a node pick — already surfaced by the nodepicked branch).
scheduleSelectionEmit()
// T1.3 — a pointerup ends any in-progress drag gesture, so the NEXT drag pushes a
// fresh history snapshot (one gesture = one undo step, D-03). Drop any stashed
// pre-drag snapshot that was never committed (a pick with no drag).
dragGestureActive = false
pendingDragSnapshot = null
// T1.1: a background pointerup (anywhere not on a connection path) clears the edge
// selection — UNLESS this same gesture just selected an edge (the path's own
// pointerup ran in the same tick and raised `edgeClickGuard`; the guard self-resets
// on the next microtask). Mirrors the node selectable's click-to-deselect.
if (!edgeClickGuard && selectedConnId != null) clearEdgeSelection()
} else if (context.type === 'nodetranslated') {
if (!programmatic) {
const id = context.data.id
const pos = context.data.position
const meta = nodeMeta.get(id)
if (meta) { meta.x = pos.x; meta.y = pos.y }
// T1.3 — commit ONE history snapshot per drag gesture, at its FIRST translate:
// the pre-move snapshot stashed on nodepicked (a drag truly happened now, not just
// a pick). dragGestureActive holds until the drag-ending pointerup resets it, so a
// continuous drag = ONE undo step (D-03).
if (!dragGestureActive) {
dragGestureActive = true
if (pendingDragSnapshot) { pushHistorySnapshot(pendingDragSnapshot); pendingDragSnapshot = null }
}
// WRITE-BACK (coalesced): accumulate the latest position for this node and
// flush ONE fresh graph object per animation frame (Pitfall 2 — the drag
// storm). The discrete `node-moved` emit stays per-translate (back-compat).
pendingDragPositions.set(id, { x: pos.x, y: pos.y })
scheduleDragFlush()
$emit('node-moved', { id, x: pos.x, y: pos.y })
}
// a node moved → its minimap rect moves (works during a programmatic translate too).
if (scheduleMinimapRedraw) scheduleMinimapRedraw()
// T2.8 — the selected node moved → re-track its toolbar overlay (no-op if off).
if (scheduleToolbarTrack) scheduleToolbarTrack()
} else if (context.type === 'translated') {
$emit('translated', { x: context.data.position.x, y: context.data.position.y })
// the viewport window moved → redraw the minimap viewport rect + mask.
if (scheduleMinimapRedraw) scheduleMinimapRedraw()
// T2.8 — a pan shifts the node's screen rect → re-track the toolbar (no-op if off).
if (scheduleToolbarTrack) scheduleToolbarTrack()
} else if (context.type === 'zoomed') {
if (!programmatic) {
const k = area.area.transform.k
if (k !== $props.zoom) $model.zoom = k
}
// the viewport window resized (zoom) → redraw the minimap viewport rect + mask.
if (scheduleMinimapRedraw) scheduleMinimapRedraw()
// T2.8 — a zoom changes the node's screen rect/size → re-track the toolbar (no-op if off).
if (scheduleToolbarTrack) scheduleToolbarTrack()
} else if (context.type === 'contextmenu') {
// suppress the native browser menu over the canvas; surface a hook instead.
context.data.event.preventDefault()
const ctx = context.data.context
$emit('context-menu', { id: ctx && ctx.id ? ctx.id : null })
}
return context
})
// ─── reconciler off the bound graph, bridged to the top-level $watch ──────────
// Nodes come ONLY from `$props.graph.nodes` (the single source of truth, D1/D2);
// sockets come from each node's TYPE port schema (portReg keyed `type::side::key`).
// A port-schema change ($data.portReg, when a <Port> registers late on Lit) ALSO
// drives this reconcile so a node whose type just gained ports re-renders. An
// imperative $expose addNode (provenance NOT in lastPropNodeIds) survives the reaper.
// Wrapped by reconcileNodes (below) with a re-entrancy guard so two passes never
// race the engine (the Lit "cannot find node" fix).
const reconcileNodesPass = async () => {
if (!editor || !area) return
const graphNodes = Array.isArray($props.graph && $props.graph.nodes) ? $props.graph.nodes : []
const want = []
programmatic++
try {
for (const spec of graphNodes) {
if (!spec || spec.id == null) continue
want.push(spec.id)
nodeMeta.set(spec.id, spec)
let node = nodeInstances.get(spec.id)
if (!node) {
node = buildNode(spec, $data.portReg)
nodeInstances.set(spec.id, node)
await editor.addNode(node)
await area.translate(spec.id, { x: spec.x || 0, y: spec.y || 0 })
} else {
// Sync any ports this node's TYPE gained AFTER the node was first built —
// a nested <Port>'s addTypePort can land after reconcileNodes already
// created the node (the node registered before its ports on some targets,
// or a <Port> registered late on Lit). buildNode only runs for NEW nodes,
// so add the missing inputs/outputs onto the live instance here from the
// TYPE schema, then re-render.
let portsAdded = false
const { inputs: wantIn, outputs: wantOut } = portSchemaForType(spec.type, $data.portReg)
for (const inp of wantIn) {
if (!inp || inp.key == null || node.inputs[inp.key]) continue
node.addInput(inp.key, new ClassicPreset.Input(SOCKET, inp.label, inp.multiple === true))
portsAdded = true
}
for (const out of wantOut) {
if (!out || out.key == null || node.outputs[out.key]) continue
node.addOutput(out.key, new ClassicPreset.Output(SOCKET, out.label, out.multiple !== false))
portsAdded = true
}
const view = area.nodeViews.get(spec.id)
if (view && spec.x != null && spec.y != null && (view.position.x !== spec.x || view.position.y !== spec.y)) {
await area.translate(spec.id, { x: spec.x, y: spec.y })
}
if (portsAdded) {
// renderNode's in-place branch deliberately leaves existing sockets
// untouched; to render the NEW sockets, drop this node's render entry so
// area.update takes the fresh-build path (re-runs buildSocketRow + re-
// emits the socket render signals the ConnectionPlugin/watcher need). The
// render-by-type body host is re-projected by the type's bodyRenderer
// (mounts a fresh portal root into the same host — idempotent).
const entry = nodeEntries.get(spec.id)
if (entry) {
if (entry.handle) entry.handle.dispose()
if (entry.bodyHandle && entry.bodyHandle.dispose) { try { entry.bodyHandle.dispose() } catch (e) {} }
for (const d of entry.socketDisposers) { try { d() } catch (e) {} }
nodeEntries.delete(spec.id)
}
}
await area.update('node', spec.id)
// a port change must re-run connections — an edge that was skipped because
// its endpoint port didn't exist yet can now be drawn.
if (portsAdded && reconcileConnections) await reconcileConnections()
}
}
// remove dropped GRAPH-managed nodes (+ their connections) — imperatively added
// nodes (NOT in lastPropNodeIds) survive (the power-user escape hatch).
const tracked = new Set(lastPropNodeIds)
for (const id of tracked) {
if (!want.includes(id) && nodeInstances.has(id)) {
for (const c of editor.getConnections()) {
if (c.source === id || c.target === id) await editor.removeConnection(c.id)
}
await editor.removeNode(id)
nodeInstances.delete(id)
nodeMeta.delete(id)
}
}
lastPropNodeIds = want
} finally {
programmatic--
}
}
// Re-entrancy-guarded entry point. If a pass is already running, mark a re-run and
// return — the in-flight pass loops until no further request is pending. Serializing
// overlapping reconciles is what stops the Lit async-context cascade from racing the
// engine into "cannot find node" (which otherwise aborts the declarative graph build).
reconcileNodes = async () => {
if (reconcileNodesRunning) { reconcileNodesPending = true; return }
reconcileNodesRunning = true
try {
do {
reconcileNodesPending = false
await reconcileNodesPass()
} while (reconcileNodesPending)
} finally {
reconcileNodesRunning = false
}
}
reconcileConnections = async () => {
if (!editor) return
// Edges come ONLY from the bound graph's `connections` (the single source of
// truth — declarative <Connection> children are gone). Normalize id-defaulting
// (a connection authored without an id gets a stable derived id) so an edge the
// canvas wrote back (carrying the engine id) and a hand-authored edge dedup.
const graphConns = Array.isArray($props.graph && $props.graph.connections) ? $props.graph.connections : []
const norm = (spec) => {
if (!spec || spec.source == null || spec.target == null) return null
const srcOut = spec.sourceOutput != null ? spec.sourceOutput : 'out'
const tgtIn = spec.targetInput != null ? spec.targetInput : 'in'
const id = spec.id != null ? spec.id : `${spec.source}:${srcOut}->${spec.target}:${tgtIn}`
// carry the optional per-edge label/style (F3) through to connMeta → renderConnection.
return { id, source: spec.source, sourceOutput: srcOut, target: spec.target, targetInput: tgtIn, label: spec.label, stroke: spec.stroke, dashed: spec.dashed, type: spec.type }
}
// cheap style signature so a label/style/type change on an EXISTING edge re-renders it.
const edgeStyleSig = (s) => (s ? String(s.label) + '|' + String(s.stroke) + '|' + String(s.dashed) + '|' + String(s.type) : '')
const merged = graphConns.map(norm).filter(Boolean)
const want = []
programmatic++
try {
for (const spec of merged) {
if (!spec || spec.id == null) continue
want.push(spec.id)
if (connInstances.has(spec.id)) {
// existing edge — relabel/restyle in place if its label/style changed (the
// controlled-graph expectation: edit the bound graph → see the change). Drop the
// render entry so area.update takes the fresh-build path (re-applies label/style).
const changed = edgeStyleSig(connMeta.get(spec.id)) !== edgeStyleSig(spec)
connMeta.set(spec.id, spec)
if (changed) {
const entry = connEntries.get(spec.id)
if (entry) { entry.dispose(); connEntries.delete(spec.id) }
await area.update('connection', spec.id)
}
continue
}
const sourceNode = nodeInstances.get(spec.source)
const targetNode = nodeInstances.get(spec.target)
if (!sourceNode || !targetNode) continue
// DEFENSIVE: the referenced output/input ports must exist on the live node
// instances before addConnection (Rete throws "source node doesn't have
// output with a key out" otherwise, aborting the loop). An edge may reference
// a port the node's TYPE schema has not flushed yet (a <Port> registered
// after the <NodeType>); skip until the ports exist — reconcileNodes re-runs
// reconcileConnections after a port-schema change, so the edge lands later.
if (!sourceNode.outputs || !sourceNode.outputs[spec.sourceOutput]) continue
if (!targetNode.inputs || !targetNode.inputs[spec.targetInput]) continue
const conn = new ClassicPreset.Connection(sourceNode, spec.sourceOutput, targetNode, spec.targetInput)
conn.id = spec.id
connInstances.set(spec.id, conn)
// seed connMeta BEFORE addConnection so renderConnection sees the label/style on
// its first render (the render fires synchronously inside addConnection's pipe).
connMeta.set(spec.id, spec)
await editor.addConnection(conn)
}
// remove dropped GRAPH-managed edges — imperatively added edges survive.
const tracked = new Set(lastPropConnIds)
for (const id of tracked) {
if (!want.includes(id) && connInstances.has(id)) {
await editor.removeConnection(id)
connInstances.delete(id)
connMeta.delete(id)
}
}
lastPropConnIds = want
} finally {
programmatic--
}
}
// ─── built-in MiniMap (opt-in :minimap, Phase 42) ────────────────────────────
// An absolute light-DOM SVG overlay (bottom-right) showing a scaled map of every
// node + the current viewport window (outside dimmed), PANNABLE (drag recenters via
// setCenter). The host div is COMPONENT-template DOM (carries the [data-rozie-s-*]
// scope attr → plain scoped CSS positions it); its SVG children are built
// IMPERATIVELY with createElementNS (the connection-renderer discipline) so SVG
// namespacing is identical on all 6 (no SVG-in-template cross-target risk) and styled
// with INLINE attributes (the arrowhead-marker lesson — no scoped-CSS / :root rule
// needed for engine-style DOM). Node dims come from the MEASURED engine node-view
// elements (area.nodeViews.get(id).element offsetW/H — target-agnostic, like the
// render pipe) with a default-rect fallback for Lit's unmeasured first paint.
const measureNodeSize = (id) => {
const view = area && area.nodeViews ? area.nodeViews.get(id) : null
const el = view && view.element ? view.element : null
const w = el && el.offsetWidth ? el.offsetWidth : MINIMAP_DEFAULT_NODE_W
const h = el && el.offsetHeight ? el.offsetHeight : MINIMAP_DEFAULT_NODE_H
return { w, h }
}
const mkMinimapRect = (x, y, w, h, cls, fill, stroke, strokeW) => {
const r = document.createElementNS(SVGNS, 'rect')
r.setAttribute('class', cls)
r.setAttribute('x', String(x))
r.setAttribute('y', String(y))
r.setAttribute('width', String(Math.max(w, 0)))
r.setAttribute('height', String(Math.max(h, 0)))
if (fill) r.setAttribute('fill', fill)
if (stroke) { r.setAttribute('stroke', stroke); r.setAttribute('stroke-width', String(strokeW || 1)) }
return r
}
// Rebuild the minimap SVG: node rects (selected highlighted) + a dim mask outside the
// viewport (evenodd punch-out) + the viewport window outline. The bounds union the
// node rects AND the viewport window so the viewport indicator stays in-frame even
// when panned past the nodes. Stores `minimapMap` (the px↔graph mapping the pointer-
// pan handlers read). Cheap (a handful of rects) → a full rebuild per frame is fine.
const redrawMinimap = () => {
minimapRedrawRaf = 0
if (!$props.minimap || !minimapSvg || !area || !container) return
const t = area.area.transform
const k = t.k || 1
const cw = container.clientWidth || MINIMAP_W
const ch = container.clientHeight || MINIMAP_H
// viewport window in GRAPH coords (screen [0,cw]×[0,ch] → graph).
const vx = -t.x / k, vy = -t.y / k, vw = cw / k, vh = ch / k
const graphNodes = (currentGraph().nodes) || []
const selIds = new Set(selectedNodeIds().map((s) => String(s)))
const rects = []
for (const n of graphNodes) {
if (!n || n.id == null) continue
const view = area.nodeViews.get(n.id)
const gx = view ? view.position.x : (n.x || 0)
const gy = view ? view.position.y : (n.y || 0)
const sz = measureNodeSize(n.id)
rects.push({ gx, gy, gw: sz.w, gh: sz.h, selected: selIds.has(String(n.id)) })
}
let minX = vx, minY = vy, maxX = vx + vw, maxY = vy + vh
for (const r of rects) {
if (r.gx < minX) minX = r.gx
if (r.gy < minY) minY = r.gy
if (r.gx + r.gw > maxX) maxX = r.gx + r.gw
if (r.gy + r.gh > maxY) maxY = r.gy + r.gh
}
const padX = (maxX - minX) * 0.1 || 20
const padY = (maxY - minY) * 0.1 || 20
minX -= padX; minY -= padY; maxX += padX; maxY += padY
const bw = (maxX - minX) || 1
const bh = (maxY - minY) || 1
const scale = Math.min(MINIMAP_W / bw, MINIMAP_H / bh)
const offX = (MINIMAP_W - bw * scale) / 2
const offY = (MINIMAP_H - bh * scale) / 2
minimapMap = { minX, minY, scale, offX, offY }
const toMMx = (gx) => (gx - minX) * scale + offX
const toMMy = (gy) => (gy - minY) * scale + offY
minimapSvg.innerHTML = ''
for (const r of rects) {
const fill = r.selected ? '#3b82f6' : '#94a3b8'
minimapSvg.appendChild(
mkMinimapRect(toMMx(r.gx), toMMy(r.gy), r.gw * scale, r.gh * scale, 'rozie-flow-minimap__node', fill, null, 0),
)
}
// dim mask OUTSIDE the viewport: full minimap rect with the viewport rect punched
// out (both subpaths same winding → fill-rule:evenodd leaves the viewport a hole).
const mvx = toMMx(vx), mvy = toMMy(vy), mvw = vw * scale, mvh = vh * scale
const mask = document.createElementNS(SVGNS, 'path')
mask.setAttribute('class', 'rozie-flow-minimap__mask')
mask.setAttribute('fill-rule', 'evenodd')
mask.setAttribute('fill', 'rgba(15, 23, 42, 0.18)')
mask.setAttribute(
'd',
'M0 0 H' + MINIMAP_W + ' V' + MINIMAP_H + ' H0 Z ' +
'M' + mvx + ' ' + mvy + ' h' + mvw + ' v' + mvh + ' h' + (-mvw) + ' Z',
)
minimapSvg.appendChild(mask)
minimapSvg.appendChild(
mkMinimapRect(mvx, mvy, mvw, mvh, 'rozie-flow-minimap__viewport', 'none', '#3b82f6', 1.5),
)
}
// rAF-coalesced scheduler (bridged to the top-level $watch + the engine pipes). No-op
// when :minimap is off (the bridge stays callable everywhere, cheap).
scheduleMinimapRedraw = () => {
if (!$props.minimap || minimapRedrawRaf) return
if (typeof requestAnimationFrame === 'function') {
minimapRedrawRaf = requestAnimationFrame(redrawMinimap)
} else {
minimapRedrawRaf = 1
Promise.resolve().then(redrawMinimap)
}
}
// Map a minimap pointer event → graph coords (via the stored minimapMap) → setCenter.
// Pan is a view op → allowed even when readonly, but gated by `pannable` (mirror the
// main-canvas pannable gate). Pointer capture keeps the drag tracking off the box.
const minimapPointerToGraph = (e) => {
if (!minimapMap || !minimapHost) return null
const box = minimapHost.getBoundingClientRect()
const rw = box.width || MINIMAP_W
const rh = box.height || MINIMAP_H
const mx = (e.clientX - box.left) * (MINIMAP_W / rw)
const my = (e.clientY - box.top) * (MINIMAP_H / rh)
return {
gx: minimapMap.minX + (mx - minimapMap.offX) / minimapMap.scale,
gy: minimapMap.minY + (my - minimapMap.offY) / minimapMap.scale,
}
}
if ($props.minimap && $refs.minimapEl) {
minimapHost = $refs.minimapEl
minimapSvg = document.createElementNS(SVGNS, 'svg')
minimapSvg.setAttribute('class', 'rozie-flow-minimap__svg')
minimapSvg.setAttribute('viewBox', '0 0 ' + MINIMAP_W + ' ' + MINIMAP_H)
minimapSvg.setAttribute('preserveAspectRatio', 'none')
minimapHost.appendChild(minimapSvg)
onMinimapPointerDown = (e) => {
if (!$props.pannable) return
const g = minimapPointerToGraph(e)
if (!g) return
minimapPanning = true
try { if (e.target && e.target.setPointerCapture && e.pointerId != null) e.target.setPointerCapture(e.pointerId) } catch (err) {}
e.preventDefault()
e.stopPropagation()
setCenter(g.gx, g.gy, null)
}
onMinimapPointerMove = (e) => {
if (!minimapPanning || !$props.pannable) return
const g = minimapPointerToGraph(e)
if (!g) return
e.preventDefault()
setCenter(g.gx, g.gy, null)
}
onMinimapPointerUp = (e) => {
if (!minimapPanning) return
minimapPanning = false
try { if (e.target && e.target.releasePointerCapture && e.pointerId != null) e.target.releasePointerCapture(e.pointerId) } catch (err) {}
}
minimapHost.addEventListener('pointerdown', onMinimapPointerDown)
minimapHost.addEventListener('pointermove', onMinimapPointerMove)
minimapHost.addEventListener('pointerup', onMinimapPointerUp)
}
// ─── T2.8 NodeToolbar (opt-in :node-toolbar) ─────────────────────────────────
// A floating component-template overlay over the SELECTED node. The host div
// (ref="toolbarEl") carries the [data-rozie-s-*] scope attr → PLAIN scoped CSS positions
// it absolutely (NOT the :root engine-DOM escape hatch — it's component DOM, like the
// marquee box + Controls). It is positioned from the engine node-view ELEMENT's rect
// (which the AreaPlugin transforms for pan/zoom/drag) relative to the canvas container, so
// the area transform is honored automatically — we read getBoundingClientRect() and
// subtract the container's rect (the screenToFlowPosition discipline, but the other way).
// Re-tracked on translated/zoomed/nodetranslated (the pipe branches that schedule the
// minimap redraw) + on every selection emit. OPT-IN (default OFF) → existing demos +
// FlowCanvasScreenshot are pixel-identical (the host div is r-if'd off when :node-toolbar
// is false; selecting a node never pops it).
// Resolve the SINGLE selected node id the toolbar should track: the one picked node when
// EXACTLY one is selected, else null (no toolbar over a multi-select or empty selection —
// a per-node action needs an unambiguous target). Read-only.
const singleSelectedNodeId = () => {
const ids = selectedNodeIds()
return ids.length === 1 ? ids[0] : null
}
// Position the toolbar host over the tracked node's engine element, or hide it. The
// node-view element is already transformed by the AreaPlugin (pan/zoom/drag), so its
// client rect minus the container's client rect gives the toolbar's container-relative
// px — no manual transform math. Placed just ABOVE the node (bottom of the toolbar at the
// node's top edge); clamped so it never goes off the top of the container.
const trackToolbar = () => {
toolbarTrackRaf = 0
if (!$props.nodeToolbar || !toolbarHost || !area || !container) return
const id = toolbarSelectedId
if (id == null) { toolbarHost.style.display = 'none'; return }
const view = area.nodeViews ? area.nodeViews.get(id) : null
const el = view && view.element ? view.element : null
const rect = el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null
if (!rect) { toolbarHost.style.display = 'none'; return }
const cbox = container.getBoundingClientRect()
// container-relative px of the node's top-left + width.
const nx = rect.left - cbox.left
const ny = rect.top - cbox.top
const tbH = toolbarHost.offsetHeight || 30
let top = ny - tbH - 6
if (top < 2) top = ny + rect.height + 6 // flip below if it would clip the top
toolbarHost.style.left = nx + 'px'
toolbarHost.style.top = top + 'px'
toolbarHost.style.display = 'flex'
}
scheduleToolbarTrack = () => {
if (!$props.nodeToolbar || toolbarTrackRaf) return
if (typeof requestAnimationFrame === 'function') {
toolbarTrackRaf = requestAnimationFrame(trackToolbar)
} else {
toolbarTrackRaf = 1
Promise.resolve().then(trackToolbar)
}
}
// Recompute the tracked node from the live selection + (re)mount the toolbar content for
// it. Called from the selection emit (a pick/unpick changed the selection). When the
// tracked id changes: if the consumer fills `#toolbar`, (re)render the reactive portal
// with the new node scope; else the default buttons stay put (they read the live tracked
// id at click time, so no re-mount needed). Then reposition.
const syncToolbar = () => {
if (!$props.nodeToolbar || !toolbarHost) return
const id = singleSelectedNodeId()
if (id === toolbarSelectedId && (id == null) === (toolbarSelectedId == null)) {
// same target — just reposition (e.g. after a drag).
scheduleToolbarTrack()
return
}
toolbarSelectedId = id
if ($slots.toolbar && id != null) {
const meta = nodeMeta.get(id) || { id, type: undefined, data: {} }
const scope = { node: meta, emit: toolbarEmit }
if (toolbarHandle && toolbarHandle.update) {
toolbarHandle.update(scope)
} else {
toolbarHandle = $portals.toolbar(toolbarHost, scope)
}
}
scheduleToolbarTrack()
}
syncToolbarSelection = syncToolbar
// The @node-action emit helper for the toolbar's actions (the EXISTING emit — no new emit,
// T2.8). Carries the tracked node id. Handed to the `#toolbar` slot scope so a consumer
// override can raise its own actions too.
const toolbarEmit = (name, detail) => {
const id = toolbarSelectedId
$emit('node-action', { id, name, detail })
}
if ($props.nodeToolbar && $refs.toolbarEl) {
toolbarHost = $refs.toolbarEl
toolbarHost.style.display = 'none'
if (!$slots.toolbar) {
// default chrome: delete + duplicate buttons. Static literal labels (Threat
// T-44-06-1: no node-derived text rendered via innerHTML — these are fixed strings
// set via textContent). Both fire @node-action on the tracked node.
toolbarDeleteBtn = document.createElement('button')
toolbarDeleteBtn.type = 'button'
toolbarDeleteBtn.className = 'rozie-flow-toolbar__btn rozie-flow-toolbar__btn--delete'
toolbarDeleteBtn.setAttribute('data-testid', 'flow-toolbar-delete')
toolbarDeleteBtn.setAttribute('aria-label', 'Delete node')
toolbarDeleteBtn.textContent = 'Delete'
toolbarDuplicateBtn = document.createElement('button')
toolbarDuplicateBtn.type = 'button'
toolbarDuplicateBtn.className = 'rozie-flow-toolbar__btn rozie-flow-toolbar__btn--duplicate'
toolbarDuplicateBtn.setAttribute('data-testid', 'flow-toolbar-duplicate')
toolbarDuplicateBtn.setAttribute('aria-label', 'Duplicate node')
toolbarDuplicateBtn.textContent = 'Duplicate'
onToolbarDelete = (e) => {
if (e) { e.preventDefault(); e.stopPropagation() }
const id = toolbarSelectedId
if (id == null) return
toolbarEmit('delete', { id })
toolbarSelectedId = null
deleteNode(id)
scheduleToolbarTrack()
}
onToolbarDup = (e) => {
if (e) { e.preventDefault(); e.stopPropagation() }
const id = toolbarSelectedId
if (id == null) return
const newId = duplicateNode(id)
toolbarEmit('duplicate', { id, newId })
scheduleToolbarTrack()
}
// pointerup (NOT click — Rete swallows clicks during node interaction; the §6a item-7
// discipline) on the COMPONENT-template buttons.
toolbarDeleteBtn.addEventListener('pointerup', onToolbarDelete)
toolbarDuplicateBtn.addEventListener('pointerup', onToolbarDup)
toolbarHost.appendChild(toolbarDeleteBtn)
toolbarHost.appendChild(toolbarDuplicateBtn)
}
}
// ─── T2.4 MARQUEE select (mode:'select') ─────────────────────────────────────
// A Figma-style rubber-band box. RESTORE-PATH resolution (RESEARCH Q2/A8): rete's
// internal `Drag` class is NOT exported, so setDragHandler(null) can't be cleanly
// reversed (re-instantiating Drag is impossible). Instead we leave the default pan Drag
// installed and intercept the EMPTY-canvas pointerdown in the CAPTURE phase on the
// container — the default Drag attaches its own bubble-phase pointerdown listener on the
// SAME container (verified rete-area-plugin@2.1.5: setDragHandler → Drag.initialize(
// this.container)), so a capture listener fires FIRST and stopPropagation() blocks pan
// before it starts. The interception is gated PURELY on the live `$props.mode` flag, so
// switching back to 'pan' restores pan with ZERO engine mutation (the persistent
// mode-guard the research preferred). A node drag is UNTOUCHED in both modes: we only act
// when the pointerdown target is NOT inside a node element (empty canvas).
//
// The box is a COMPONENT-TEMPLATE overlay div (ref="marqueeEl") — it carries the
// [data-rozie-s-*] scope attr so a PLAIN scoped rule styles it (NOT the :root engine-DOM
// escape hatch). On release we hit-test every graph node's rect (graph coords via
// area.nodeViews.get(id).position + measureNodeSize) against the box (converted to graph
// coords through the live transform) and nodeSelectApi.select(id, true) each intersector,
// then scheduleSelectionEmit() (the existing @selection-change path — NO new emit).
// Marquee changes only SELECTION (script-state), never the graph model → no history push.
const nodeAt = (target) => {
if (!target || typeof target.closest !== 'function') return null
return target.closest('.rozie-flow-node')
}
// container-relative px → GRAPH coords (the inverse area transform, like
// screenToFlowPosition but already container-relative). px = transform + graph·k.
const containerPxToGraph = (px, py) => {
const t = area.area.transform
const k = t.k || 1
return { x: (px - t.x) / k, y: (py - t.y) / k }
}
const updateMarqueeBox = () => {
if (!marqueeBox || !marqueeStart || !marqueeCur) return
const x = Math.min(marqueeStart.x, marqueeCur.x)
const y = Math.min(marqueeStart.y, marqueeCur.y)
const w = Math.abs(marqueeCur.x - marqueeStart.x)
const h = Math.abs(marqueeCur.y - marqueeStart.y)
marqueeBox.style.left = x + 'px'
marqueeBox.style.top = y + 'px'
marqueeBox.style.width = w + 'px'
marqueeBox.style.height = h + 'px'
marqueeBox.style.display = 'block'
}
const finishMarquee = () => {
if (!marqueeActive) return
marqueeActive = false
if (marqueeBox) marqueeBox.style.display = 'none'
if (!marqueeStart || !marqueeCur || !nodeSelectApi) { marqueeStart = null; marqueeCur = null; return }
// box in graph coords (two opposite corners → min/max).
const a = containerPxToGraph(marqueeStart.x, marqueeStart.y)
const b = containerPxToGraph(marqueeCur.x, marqueeCur.y)
const bx0 = Math.min(a.x, b.x), by0 = Math.min(a.y, b.y)
const bx1 = Math.max(a.x, b.x), by1 = Math.max(a.y, b.y)
marqueeStart = null; marqueeCur = null
const graphNodes = (currentGraph().nodes) || []
let first = true
for (const n of graphNodes) {
if (!n || n.id == null) continue
const view = area.nodeViews.get(n.id)
const gx = view ? view.position.x : (n.x || 0)
const gy = view ? view.position.y : (n.y || 0)
const sz = measureNodeSize(n.id)
// a node intersects the box if their rects overlap (AABB), in graph coords.
const overlaps = gx < bx1 && gx + sz.w > bx0 && gy < by1 && gy + sz.h > by0
if (overlaps) {
// accumulate=true keeps every intersector selected (first one replaces the prior
// selection so an old pick doesn't linger; rest accumulate). select(id, accumulate).
nodeSelectApi.select(n.id, !first)
first = false
}
}
// surface @selection-change once the engine's awaited select() chain has flushed.
scheduleSelectionEmit()
}
if ($props.selectable && !$props.readonly && container && typeof container.addEventListener === 'function') {
marqueeBox = $refs.marqueeEl || null
onCanvasPointerDownCapture = (e) => {
// only in select mode, only the EMPTY canvas (not on a node — those still drag), only
// the primary button. A live `$props.mode` read = the persistent mode-guard (restoring
// pan is just this check returning early; no engine mutation).
if ($props.mode !== 'select') return
if (e && e.button != null && e.button !== 0) return
if (nodeAt(e.target)) return
// BLOCK rete's pan Drag (its bubble-phase pointerdown on the same container) — capture
// phase runs first, so stopPropagation() here pre-empts pan; the marquee owns this drag.
e.stopPropagation()
e.preventDefault()
const box = container.getBoundingClientRect()
marqueeActive = true
marqueeStart = { x: e.clientX - box.left, y: e.clientY - box.top }
marqueeCur = { x: marqueeStart.x, y: marqueeStart.y }
try { if (container.setPointerCapture && e.pointerId != null) container.setPointerCapture(e.pointerId) } catch (err) {}
updateMarqueeBox()
}
onMarqueePointerMove = (e) => {
if (!marqueeActive) return
const box = container.getBoundingClientRect()
marqueeCur = { x: e.clientX - box.left, y: e.clientY - box.top }
updateMarqueeBox()
}
onMarqueePointerUp = (e) => {
if (!marqueeActive) return
try { if (container.releasePointerCapture && e && e.pointerId != null) container.releasePointerCapture(e.pointerId) } catch (err) {}
finishMarquee()
}
container.addEventListener('pointerdown', onCanvasPointerDownCapture, true)
container.addEventListener('pointermove', onMarqueePointerMove)
container.addEventListener('pointerup', onMarqueePointerUp)
}
// ─── initial graph: nodes first, then connections (connections reference live
// node instances), then optional fit. Sequenced via an async IIFE so the
// $onMount-returned teardown stays synchronous. ──────────────────────────────
;(async () => {
// T1.3 — seed the canvas's own last-written graph from the initial bound value so the
// first gesture's snapshot/base reflects the mounted graph (immune to prop re-bind lag).
lastWrittenGraph = $clone(currentGraph())
await reconcileNodes()
await reconcileConnections()
if (typeof $props.zoom === 'number' && $props.zoom !== 1) {
programmatic++
try { await area.area.zoom($props.zoom) } finally { programmatic-- }
}
if ($props.fitOnMount && editor.getNodes().length) {
programmatic++
try { await AreaExtensions.zoomAt(area, editor.getNodes()) } finally { programmatic-- }
if (area) { const k = area.area.transform.k; if (k !== $props.zoom) $model.zoom = k }
}
// draw the minimap once the graph + fit have settled (also redrawn on every
// render / pan / zoom / drag / selection / graph change below).
if (scheduleMinimapRedraw) scheduleMinimapRedraw()
})()
return () => {
if (onCanvasKeydown && keydownContainer && typeof keydownContainer.removeEventListener === 'function') {
try { keydownContainer.removeEventListener('keydown', onCanvasKeydown) } catch (e) {}
}
if (dragFlushRaf && typeof cancelAnimationFrame === 'function') { try { cancelAnimationFrame(dragFlushRaf) } catch (e) {} }
dragFlushRaf = 0
pendingDragPositions.clear()
// T1.1: drop the edge-selection state + its cached <path> reference on teardown.
clearEdgeSelection()
// MiniMap teardown — remove the pointer-pan listeners + cancel a pending redraw.
if (minimapHost) {
if (onMinimapPointerDown) { try { minimapHost.removeEventListener('pointerdown', onMinimapPointerDown) } catch (e) {} }
if (onMinimapPointerMove) { try { minimapHost.removeEventListener('pointermove', onMinimapPointerMove) } catch (e) {} }
if (onMinimapPointerUp) { try { minimapHost.removeEventListener('pointerup', onMinimapPointerUp) } catch (e) {} }
}
if (minimapRedrawRaf && typeof cancelAnimationFrame === 'function') { try { cancelAnimationFrame(minimapRedrawRaf) } catch (e) {} }
minimapRedrawRaf = 0
// T2.8 NodeToolbar teardown — remove the default-button listeners, dispose the optional
// `#toolbar` reactive portal handle, and cancel a pending reposition.
if (toolbarDeleteBtn && onToolbarDelete) { try { toolbarDeleteBtn.removeEventListener('pointerup', onToolbarDelete) } catch (e) {} }
if (toolbarDuplicateBtn && onToolbarDup) { try { toolbarDuplicateBtn.removeEventListener('pointerup', onToolbarDup) } catch (e) {} }
if (toolbarHandle && toolbarHandle.dispose) { try { toolbarHandle.dispose() } catch (e) {} }
toolbarHandle = null
toolbarSelectedId = null
if (toolbarTrackRaf && typeof cancelAnimationFrame === 'function') { try { cancelAnimationFrame(toolbarTrackRaf) } catch (e) {} }
toolbarTrackRaf = 0
// T2.4 Marquee teardown — remove the capture-phase pointerdown guard + window listeners.
if (keydownContainer) {
if (onCanvasPointerDownCapture) { try { keydownContainer.removeEventListener('pointerdown', onCanvasPointerDownCapture, true) } catch (e) {} }
if (onMarqueePointerMove) { try { keydownContainer.removeEventListener('pointermove', onMarqueePointerMove) } catch (e) {} }
if (onMarqueePointerUp) { try { keydownContainer.removeEventListener('pointerup', onMarqueePointerUp) } catch (e) {} }
}
marqueeActive = false; marqueeStart = null; marqueeCur = null
for (const [, entry] of nodeEntries) {
if (entry.handle) entry.handle.dispose()
if (entry.bodyHandle && entry.bodyHandle.dispose) { try { entry.bodyHandle.dispose() } catch (e) {} }
for (const d of entry.socketDisposers) { try { d() } catch (e) {} }
}
nodeEntries.clear()
for (const [, entry] of connEntries) entry.dispose()
connEntries.clear()
if (area) area.destroy()
}
})
// ─── reconcile graph changes into the live engine (no remount) ───────────────
// The SINGLE bound `graph` object drives BOTH reconcilers: a consumer re-bind (incl.
// the canvas's own write-back) refires this watch with a fresh reference. Nodes
// reconcile feeds connections (an edge references live node instances), so the graph
// watch sequences nodes→connections. The write-back's no-op-diff property keeps the
// echo cheap (the node is already at x/y; the edge already exists → reconcile is a
// no-op). The per-TYPE port schema (portReg) ALSO drives the node reconcile — a
// <Port> that registered before OR after its node/graph lands either way (the
// mount-order fix); a port-schema change re-runs connections too (a skipped edge
// whose endpoint port just appeared draws). A type-template registration (typeReg)
// re-renders node bodies (a <NodeType> whose #body resolves after its nodes mounted).
$watch(() => $props.graph, () => {
// T1.3 — keep the canvas's own last-written graph in sync with an EXTERNAL (non-
// programmatic) consumer change, so undo/redo's "current" state tracks reality (our own
// write-backs / restores set lastWrittenGraph synchronously under the programmatic guard;
// this only refreshes it for a genuine outside edit).
if (selfWriteInFlight) {
// our own commitGraph write echoing back — lastWrittenGraph is already authoritative.
selfWriteInFlight = false
} else if (!programmatic) {
const c = $clone(currentGraph())
if (c != null) lastWrittenGraph = c
}
if (reconcileNodes) {
Promise.resolve(reconcileNodes()).then(() => { if (reconcileConnections) reconcileConnections() })
}
// graph changed (nodes added/removed/moved) → refresh the minimap node rects.
if (scheduleMinimapRedraw) scheduleMinimapRedraw()
})
$watch(() => $data.portReg, () => {
if (reconcileNodes) {
Promise.resolve(reconcileNodes()).then(() => { if (reconcileConnections) reconcileConnections() })
}
})
$watch(() => $data.typeReg, () => { if (reconcileNodes) reconcileNodes() })
$watch(() => $props.zoom, (v) => {
if (!area || typeof v !== 'number') return
if (v === area.area.transform.k) return
programmatic++
Promise.resolve(area.area.zoom(v)).finally(() => { programmatic-- })
})
// ─── imperative handle (Phase 21 $expose) ────────────────────────────────────
// Collision discipline (ROZ121/ROZ524/Lit-lifecycle):
// - NO `setZoom` — `zoom` is a model prop, so React auto-generates a `setZoom`
// state setter (the MapLibre setCenter/setZoom lesson); the verb is `zoomTo`.
// - NONE equals a Lit reserved lifecycle name (update/render/firstUpdated/
// updated/willUpdate/requestUpdate) — note `clear` and `getNodes` are safe.
// - NONE equals an emitted event name (node-moved/node-picked/connection-*
// /translated/context-menu/node-action) or a prop name.
// addNode/addConnection/removeNode/removeConnection operate on the engine
// directly and are NOT reaped by props reconcile (provenance-tracked).
function getEditor() { return editor }
function getArea() { return area }
async function addNode(spec) {
if (!editor || !spec || spec.id == null) return null
const node = buildNode(spec, $data.portReg)
nodeInstances.set(spec.id, node)
nodeMeta.set(spec.id, spec)
programmatic++
try {
await editor.addNode(node)
await area.translate(spec.id, { x: spec.x || 0, y: spec.y || 0 })
} finally { programmatic-- }
return spec.id
}
async function removeNode(id) {
if (!editor || id == null || !nodeInstances.has(id)) return false
programmatic++
try {
for (const c of editor.getConnections()) {
if (c.source === id || c.target === id) await editor.removeConnection(c.id)
}
await editor.removeNode(id)
} finally { programmatic-- }
nodeInstances.delete(id)
nodeMeta.delete(id)
return true
}
async function addConnection(spec) {
if (!editor || !spec || spec.source == null || spec.target == null) return null
const srcOut = spec.sourceOutput != null ? spec.sourceOutput : 'out'
const tgtIn = spec.targetInput != null ? spec.targetInput : 'in'
const sourceNode = nodeInstances.get(spec.source)
const targetNode = nodeInstances.get(spec.target)
if (!sourceNode || !targetNode) return null
const conn = new ClassicPreset.Connection(sourceNode, srcOut, targetNode, tgtIn)
if (spec.id != null) conn.id = spec.id
programmatic++
try { await editor.addConnection(conn) } finally { programmatic-- }
connInstances.set(conn.id, conn)
return conn.id
}
async function removeConnection(id) {
if (!editor || id == null) return false
programmatic++
try { await editor.removeConnection(id) } finally { programmatic-- }
connInstances.delete(id)
return true
}
async function clear() {
if (!editor) return
programmatic++
try { await editor.clear() } finally { programmatic-- }
nodeInstances.clear(); nodeMeta.clear(); connInstances.clear(); connMeta.clear()
lastPropNodeIds = []; lastPropConnIds = []
}
async function zoomToFit() {
if (!area || !editor) return
programmatic++
try { await AreaExtensions.zoomAt(area, editor.getNodes()) } finally { programmatic-- }
const k = area.area.transform.k
if (k !== $props.zoom) $model.zoom = k
}
async function zoomTo(k) {
if (!area || typeof k !== 'number') return
programmatic++
try { await area.area.zoom(k) } finally { programmatic-- }
if (k !== $props.zoom) $model.zoom = k
}
// ─── viewport API (Phase 42 — the T11 gap + what the pannable minimap needs) ─────
// Both write the AreaPlugin transform via the CONFIRMED Rete v2 area API: with the
// origin omitted `area.area.zoom(k)` leaves x/y unchanged (transform.x += 0·d), and
// `area.area.translate(x, y)` sets the pan ABSOLUTELY (verified against rete-area-
// plugin@2.1.5). Echo-guarded with `programmatic` so the transform write doesn't loop
// back through the zoomed/nodetranslated write-back (the `translated` emit stays
// UNCONDITIONAL, so @translated still surfaces a programmatic recenter — a real
// viewport change the consumer asked for). After, echo `$model.zoom` (mirrors zoomTo).
// Collision discipline: setCenter/setViewport are NOT Lit lifecycle names, NOT emit
// names, NOT prop names, NOT React model-setters (`graph`/`zoom` → setGraph/setZoom),
// and NOT inherited DOM methods (the Embla scrollTo lesson) — clean on all 6.
//
// setViewport({ x, y, k }) — set the raw transform (any field omitted keeps its
// current value).
async function setViewport(vp) {
if (!area || !vp || typeof vp !== 'object') return
const tf = area.area.transform
const k = typeof vp.k === 'number' ? vp.k : tf.k
const x = typeof vp.x === 'number' ? vp.x : tf.x
const y = typeof vp.y === 'number' ? vp.y : tf.y
programmatic++
try {
if (k !== area.area.transform.k) await area.area.zoom(k)
await area.area.translate(x, y)
} finally { programmatic-- }
if (k !== $props.zoom) $model.zoom = k
}
// setCenter(x, y, opts?) — center the viewport on graph-coords (x, y), optionally
// setting zoom (`opts.zoom`). The transform that puts graph point (x,y) at the canvas
// center is tx = W/2 − x·k, ty = H/2 − y·k (screen = graph·k + transform). W/H are the
// engine container's pixel dims (area.container — public on AreaPlugin, no $refs read).
async function setCenter(x, y, opts) {
if (!area || typeof x !== 'number' || typeof y !== 'number') return
const k = opts && typeof opts.zoom === 'number' ? opts.zoom : area.area.transform.k
const el = area.container
const cw = el && el.clientWidth ? el.clientWidth : 0
const ch = el && el.clientHeight ? el.clientHeight : 0
const tx = cw / 2 - x * k
const ty = ch / 2 - y * k
programmatic++
try {
if (k !== area.area.transform.k) await area.area.zoom(k)
await area.area.translate(tx, ty)
} finally { programmatic-- }
if (k !== $props.zoom) $model.zoom = k
}
// ─── built-in Controls overlay handlers (Win 4) ──────────────────────────────
// Wired to the in-template zoom in / out / fit buttons (gated r-if="$props.controls").
// They REUSE the zoomTo / zoomToFit verbs (one implementation — no logic duplication),
// clamping the step to [minZoom, maxZoom] so a button never exceeds the restrictor
// bounds. Zoom/fit are view-only, so they stay enabled even when readonly (they do not
// edit the graph). A no-op before the area mounts.
const ZOOM_STEP = 1.2
const clampZoom = (k) => {
let lo = typeof $props.minZoom === 'number' && $props.minZoom > 0 ? $props.minZoom : 0.01
let hi = typeof $props.maxZoom === 'number' && $props.maxZoom > 0 ? $props.maxZoom : 100
if (k < lo) return lo
if (k > hi) return hi
return k
}
const controlZoomIn = () => {
if (!area) return
zoomTo(clampZoom(area.area.transform.k * ZOOM_STEP))
}
const controlZoomOut = () => {
if (!area) return
zoomTo(clampZoom(area.area.transform.k / ZOOM_STEP))
}
const controlFit = () => { zoomToFit() }
// T2.4 — the gated 4th Controls button toggles the two-way mode (pan ↔ select). Writes
// $model.mode (model:true); the consumer's r-model:mode (or the internal demo state) updates.
const toggleMode = () => { $model.mode = $props.mode === 'select' ? 'pan' : 'select' }
function getNodes() {
if (!area) return []
const out = []
for (const [id, node] of nodeInstances) {
const view = area.nodeViews.get(id)
out.push({ id, label: node.label, x: view ? view.position.x : 0, y: view ? view.position.y : 0 })
}
return out
}
function getConnections() {
return editor ? editor.getConnections().map(serializeConn) : []
}
function getTransform() {
return area ? { x: area.area.transform.x, y: area.area.transform.y, k: area.area.transform.k } : null
}
// screenToFlowPosition(clientX, clientY) → { x, y } in GRAPH coords (Phase 43 — the
// palette-drop / no-code-builder primitive, the React-Flow `screenToFlowPosition`
// parity). The INVERSE of the area transform: a graph point projects to the screen as
// `screen = containerOrigin + transform.{x,y} + graph·k`, so
// `graph = (client − containerOrigin − transform) / k`. `area.container` is public on
// the AreaPlugin (no $refs read). Returns null before the area mounts. The component
// owns ONLY this projection — the consumer owns the drag/drop (a palette item's
// `draggable` + the canvas `@dragover.prevent`/`@drop`) and writes the new node into the
// bound `graph` at the returned coords, exactly like React Flow (which does not own the
// palette either).
function screenToFlowPosition(clientX, clientY) {
if (!area || typeof clientX !== 'number' || typeof clientY !== 'number') return null
const el = area.container
const rect = el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null
if (!rect) return null
const t = area.area.transform
const k = t.k || 1
return {
x: (clientX - rect.left - t.x) / k,
y: (clientY - rect.top - t.y) / k,
}
}
// T2.6 — autoArrange(opts?) — relayout the graph into a non-overlapping LAYERED arrangement
// (D-08, verb-only, NO auto-trigger — the MapLibre verb-first stance). Runs the
// AutoArrangePlugin (elkjs classic preset), then READS the arranged positions BACK into a
// FRESH `{ nodes, connections }` object written through `$model.graph` (the controlled-graph
// contract — the engine is never the source of truth, mirroring the drag write-back).
//
// PITFALL 3 (Plan 00 / RESEARCH): elkjs needs each node's `width`/`height`; our nodes are
// plain `ClassicPreset.Node` with no dimensions, so without dims the classic preset collapses
// every node to (0,0). We set `node.width`/`node.height` from the MEASURED engine node-view
// element (area.nodeViews.get(id).element offsetW/H — target-agnostic, the measureNodeSize
// discipline) BEFORE layout, falling back to MINIMAP_DEFAULT_NODE_W/H for Lit's unmeasured
// first paint. (measureNodeSize itself is $onMount-local; the verb is top-level, so the same
// measure is inlined here over the component-scope `area` + `nodeInstances`.)
//
// Echo-guarded (programmatic++ around layout AND the write-back) so the engine relayout and
// the resulting $model.graph re-bind → $watch(graph) → reconcile don't re-enter; ONE history
// snapshot is pushed for the whole gesture (D-03, gated on !programmatic + history). The
// optional `opts.options` (elk layout options — direction/spacing) is forwarded to
// arrange.layout() (D-01 discretion — default-only is fine; the arg stays optional).
//
// Collision discipline: `autoArrange` is NOT a Lit lifecycle name (update/render/firstUpdated/
// updated/willUpdate/requestUpdate), NOT an inherited DOM method (the Embla scrollTo lesson),
// NOT an emit (node-*/connection-*/translated/context-menu/selection-change/edge-*/node-action),
// NOT a prop, NOT a React model-setter (graph/zoom → setGraph/setZoom) — clean on all 6.
async function autoArrange(opts) {
if (!arrange || !area) return
// Set elkjs dimensions on every live node instance from its measured node-view element
// (Pitfall 3) — without dims the classic preset stacks all nodes at (0,0).
for (const [id, node] of nodeInstances) {
const view = area.nodeViews ? area.nodeViews.get(id) : null
const el = view && view.element ? view.element : null
node.width = el && el.offsetWidth ? el.offsetWidth : MINIMAP_DEFAULT_NODE_W
node.height = el && el.offsetHeight ? el.offsetHeight : MINIMAP_DEFAULT_NODE_H
}
// ONE history entry for the arrange gesture, captured BEFORE the write (pushHistory reads
// lastWrittenGraph, still the pre-arrange state). Gated on !programmatic + history.
pushHistory()
programmatic++
try {
await arrange.layout(opts && opts.options ? { options: opts.options } : undefined)
} finally { programmatic-- }
// Read the arranged positions back into a FRESH graph object (controlled-graph contract).
// Echo-guarded: commitGraph → $model.graph re-bind must not re-enter the reconcile as a new
// gesture. (The arrange already moved the engine to these coords, so the reconcile is a
// no-op diff; the guard is belt-and-braces + suppresses any history re-entry.)
programmatic++
try {
const g = baseGraph()
const nodes = (g.nodes || []).map((n) => {
const v = n && n.id != null && area.nodeViews ? area.nodeViews.get(n.id) : null
return v && v.position ? { ...n, x: v.position.x, y: v.position.y } : n
})
commitGraph({ ...g, nodes })
} finally { programmatic-- }
}
// ─── imperative selection control ────────────────────────────────────────────
// Selection was previously PUSH-ONLY (the `selection-change` emit fires on change,
// but a consumer couldn't READ or DRIVE selection). These reuse the internal
// `selector` / `nodeSelectApi` (AreaExtensions.selector + selectableNodes) already
// wired for the marquee — no new engine state. All no-op when selection is off
// (readonly / !selectable, when `nodeSelectApi` is null). Each schedules the same
// post-settle `selection-change` recompute the marquee uses, so an imperative
// select keeps the consumer's bound state in sync (the zoomTo→$model.zoom echo
// stance). Collision discipline: `selectNode` is NOT bare `select` — `select` is
// an inherited HTMLElement method (Lit shadow, the Embla scrollTo lesson) AND a
// FullCalendar-style emit hazard; getSelectedNodes/clearSelection/selectAll/
// centerOnNode are NOT emits (selection-change/node-*/edge-*), NOT props, NOT
// React model-setters (graph/zoom → setGraph/setZoom), NOT Lit lifecycle.
//
// getSelectedNodes() — the currently-selected nodes as { id, label, x, y } (the
// getNodes() shape, filtered to the live selection). Empty when nothing selected.
function getSelectedNodes() {
const sel = new Set(selectedNodeIds().map((x) => String(x)))
return getNodes().filter((n) => sel.has(String(n.id)))
}
// selectNode(id, accumulate?) — programmatically select a node (sidebar/search →
// highlight). accumulate=true adds to the current selection; falsy replaces it.
function selectNode(id, accumulate) {
if (!nodeSelectApi || id == null) return
nodeSelectApi.select(id, !!accumulate)
scheduleSelectionEmit()
}
// clearSelection() — unselect every selected node (and any selected edge).
function clearSelection() {
if (nodeSelectApi) {
for (const id of selectedNodeIds()) nodeSelectApi.unselect(id)
}
clearEdgeSelection()
scheduleSelectionEmit()
}
// selectAll() — select every node (Ctrl+A is not bound; marquee only covers a
// dragged region). Mirrors the marquee's first-replaces / rest-accumulate pattern.
function selectAll() {
if (!nodeSelectApi) return
let first = true
for (const n of getNodes()) { nodeSelectApi.select(n.id, !first); first = false }
scheduleSelectionEmit()
}
// centerOnNode(id, opts?) — pan (and optionally zoom via opts.zoom) to center the
// viewport on a node by id. setCenter is coordinate-based; this measures the node
// to compute its center in GRAPH coords (position is the top-left; offsetW/H are
// unscaled graph units), falling back to the minimap default dims pre-measure.
async function centerOnNode(id, opts) {
if (!area || id == null) return
const view = area.nodeViews ? area.nodeViews.get(id) : null
if (!view || !view.position) return
const el = view.element
const w = el && el.offsetWidth ? el.offsetWidth : MINIMAP_DEFAULT_NODE_W
const h = el && el.offsetHeight ? el.offsetHeight : MINIMAP_DEFAULT_NODE_H
await setCenter(view.position.x + w / 2, view.position.y + h / 2, opts)
}
$expose({
getEditor, getArea, addNode, removeNode, deleteNode, addConnection, removeConnection,
clear, zoomToFit, zoomTo, setCenter, setViewport, screenToFlowPosition,
getNodes, getConnections, getTransform,
// T2.6 — auto-layout verb (D-08, verb-only). Collision-clean (see autoArrange above).
autoArrange,
// T1.3 — undo/redo verbs + canUndo/canRedo getters (D-02). NONE collide: not a Lit
// lifecycle name (update/render/firstUpdated/updated/willUpdate/requestUpdate), not an
// emit name (node-*/connection-*/translated/context-menu/selection-change/edge-*), not
// a prop name (history is the prop; undo/redo are verbs), not a React model-setter
// (graph/zoom → setGraph/setZoom), not an inherited DOM method (the Embla scrollTo
// lesson) — clean on all 6 (RESEARCH Pitfall 4 pre-checked).
undo, redo, canUndo, canRedo,
// imperative selection control (reuses selector/nodeSelectApi — see block above).
getSelectedNodes, selectNode, clearSelection, selectAll, centerOnNode,
})
</script>
<template>
<div class="rozie-flow-canvas" ref="canvasEl" tabindex="0">
<!--
built-in Controls overlay (Win 4) — zoom in / out / fit, gated on :controls
(default ON; :controls="false" opts out). Rendered INSIDE the engine container
(position:relative) but the AreaPlugin only APPENDS its own transform layer, it
never clears the container, so the overlay coexists. The overlay box is
pointer-events:none (it must not steal pan/zoom from the canvas); only the buttons
re-enable pointer events. These are COMPONENT-template DOM (NOT engine-mounted), so
they carry the [data-rozie-s-*] scope attr and a PLAIN scoped rule styles them (no
:root escape hatch needed). The buttons reuse the zoomTo/zoomToFit verbs.
-->
<div class="rozie-flow-controls" r-if="$props.controls">
<button
type="button"
class="rozie-flow-controls__btn"
data-testid="flow-zoom-in"
aria-label="Zoom in"
@click="controlZoomIn"
>+</button>
<button
type="button"
class="rozie-flow-controls__btn"
data-testid="flow-zoom-out"
aria-label="Zoom out"
@click="controlZoomOut"
>−</button>
<button
type="button"
class="rozie-flow-controls__btn"
data-testid="flow-fit"
aria-label="Fit view"
@click="controlFit"
>☐</button>
<!--
T2.4 — the 4th MODE button (pan ↔ select), GATED behind :marquee (default false) so
the default Controls demo keeps its 3 buttons → FlowCanvasScreenshot byte-identical
(D-05, gate over rebless). It two-way-writes $model.mode and reflects the current mode
(✥ move/pan vs ▢ select). Reuses the same plain-scoped overlay button styling.
-->
<button
type="button"
r-if="$props.marquee"
class="rozie-flow-controls__btn"
:class="{ 'is-active': $props.mode === 'select' }"
data-testid="flow-mode"
:aria-label="$props.mode === 'select' ? 'Select mode (click to pan)' : 'Pan mode (click to select)'"
@click="toggleMode"
>{{ $props.mode === 'select' ? '▢' : '✥' }}</button>
</div>
<!--
built-in MiniMap overlay (Phase 42) — opt-in :minimap (default OFF). An empty
light-DOM host div (carries the [data-rozie-s-*] scope attr → plain scoped CSS
positions it bottom-right). The SVG map (node rects + viewport window + dim mask)
is built IMPERATIVELY into it ($onMount, createElementNS — the connection-renderer
discipline → identical SVG namespacing + inline-attribute styling on all 6), and
the host is pointer-pannable (drag recenters via setCenter). r-if so the box is
absent when :minimap is off (keeps existing demos + the screenshot baseline
untouched).
-->
<div class="rozie-flow-minimap" r-if="$props.minimap" ref="minimapEl" data-testid="flow-minimap"></div>
<!--
T2.4 — the MARQUEE rubber-band box (mode:'select'). A COMPONENT-template overlay div
(carries the [data-rozie-s-*] scope attr → plain scoped CSS, NOT the :root engine-DOM
escape hatch) sized/positioned IMPERATIVELY (left/top/width/height) by the pointer-drag
handlers; hidden (display:none) until a select-mode drag starts. pointer-events:none so
it never intercepts the drag it visualizes. Always present (cheap empty div) so the
$refs.marqueeEl read in $onMount resolves on all 6 regardless of mode — the drag guard
itself is the behavioral gate.
-->
<div class="rozie-flow-marquee" ref="marqueeEl" data-testid="flow-marquee"></div>
<!--
T2.8 — NodeToolbar overlay (opt-in :node-toolbar, default OFF). A floating component-
template overlay (carries the [data-rozie-s-*] scope attr → plain scoped CSS, NOT the
:root engine-DOM escape hatch) positioned IMPERATIVELY (left/top) over the SINGLE
selected node by the selection/pan/zoom/drag tracking ($onMount); hidden (display:none)
until a node is selected. Default content = delete + duplicate buttons (built
imperatively into it); a consumer can override via the `#toolbar` reactive portal slot.
r-if so the host is ABSENT when :node-toolbar is off — existing demos + the
FlowCanvasScreenshot pixel baseline are untouched (selecting a node pops nothing).
-->
<div class="rozie-flow-toolbar" r-if="$props.nodeToolbar" ref="toolbarEl" data-testid="flow-toolbar"></div>
</div>
<!--
node — REACTIVE MULTI-INSTANCE portal slot — the LOW-LEVEL render-by-type escape
hatch (the consumer switches on node.type inside one #node slot). Declared but NOT
rendered inline (per-target emitters skip it). The vanilla render pipe invokes it
from script via $portals.node(bodyHost, { node, selected, emit }) per node whose
type has NO <NodeType> template; the reactive handle ({ update, dispose })
re-renders it in place as the node's data/selection changes. ROZ127-clean: slot
`node` ≠ any prop name (graph/validateTypes/zoom/…).
-->
<slot name="node" portal reactive :params="['node', 'selected', 'emit']" />
<!--
toolbar — REACTIVE portal slot (T2.8, opt-in :node-toolbar). When the consumer fills
`#toolbar`, the canvas mounts that fragment into the floating overlay over the SELECTED
node (instead of the default delete/duplicate buttons), re-rendering it in place as the
selection changes. Invoked from script via $portals.toolbar(toolbarHost, { node, emit }).
ROZ127-clean: slot `toolbar` ≠ any prop name (graph/validateTypes/zoom/.../nodeToolbar/…).
-->
<slot name="toolbar" portal reactive :params="['node', 'emit']" />
<!--
default slot — hosts the declarative <NodeType>/<Port> TYPE-template children
(Phase 41). They are renderless: a <NodeType>'s #body is projected by the canvas
into each matching graph node's engine body host via the render-by-type
bodyRenderer callback (see renderNode); each child's $onMount registers its TYPE
template + port schema (typeReg/portReg). Distinct slot name from the `node` portal
slot — they coexist.
-->
<slot />
</template>
<style>
.rozie-flow-canvas {
width: 100%;
height: 100%;
min-height: 360px;
position: relative;
overflow: hidden;
border-radius: 8px;
background:
radial-gradient(circle, rgba(0, 0, 0, 0.08) 1px, transparent 1px) 0 0 / 20px 20px,
#f7f8fa;
border: 1px solid rgba(0, 0, 0, 0.1);
}
/* built-in Controls overlay (Win 4) — COMPONENT-template DOM, so plain scoped CSS
styles it (it carries the [data-rozie-s-*] scope attr; no :root escape hatch). The
cluster is pointer-events:none so it never steals pan/zoom from the canvas; the
buttons re-enable pointer events. Positioned bottom-left over the canvas. */
.rozie-flow-controls {
position: absolute;
left: 10px;
bottom: 10px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 2px;
pointer-events: none;
}
.rozie-flow-controls__btn {
pointer-events: auto;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
font: 600 16px/1 system-ui, sans-serif;
color: #334155;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
cursor: pointer;
user-select: none;
}
.rozie-flow-controls__btn:hover { background: #f1f5f9; }
.rozie-flow-controls__btn:active { background: #e2e8f0; }
/* T2.4 — the gated mode button's active (select-mode) state. */
.rozie-flow-controls__btn.is-active { background: #dbeafe; color: #1d4ed8; border-color: #3b82f6; }
/* T2.4 — the marquee rubber-band box (mode:'select'). COMPONENT-template overlay (carries
the [data-rozie-s-*] scope attr → plain scoped CSS). Positioned/sized imperatively by the
drag handlers; hidden until a select-mode drag starts. pointer-events:none so it never
intercepts the very drag it visualizes (the capture-phase guard owns the pointer). */
.rozie-flow-marquee {
position: absolute;
display: none;
z-index: 9;
pointer-events: none;
background: rgba(59, 130, 246, 0.12);
border: 1px solid #3b82f6;
border-radius: 2px;
}
/* built-in MiniMap overlay (Phase 42) — COMPONENT-template host div, so plain scoped
CSS positions it (it carries the [data-rozie-s-*] scope attr). The inner SVG is built
imperatively + styled with INLINE attributes (the arrowhead-marker discipline), so no
:root escape hatch is needed for the map content. Bottom-right (Controls is bottom-
left). The box dimensions MUST match the MINIMAP_W/MINIMAP_H script constants
(200×150). pointer-events:auto + touch-action:none so the drag-to-recenter works. */
.rozie-flow-minimap {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 10;
width: 200px;
height: 150px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
overflow: hidden;
cursor: pointer;
touch-action: none;
}
.rozie-flow-minimap__svg { display: block; width: 100%; height: 100%; }
/* T2.8 — the NodeToolbar floating overlay (opt-in :node-toolbar). COMPONENT-template DOM
(carries the [data-rozie-s-*] scope attr → plain scoped CSS, like Controls/MiniMap/marquee).
Positioned imperatively (left/top) over the selected node by the tracking code; hidden
until a node is selected. z-index above the canvas chrome; pointer-events:auto so its
buttons are clickable. */
.rozie-flow-toolbar {
position: absolute;
display: none;
z-index: 11;
gap: 4px;
padding: 3px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
pointer-events: auto;
white-space: nowrap;
}
.rozie-flow-toolbar__btn {
font: 600 12px/1 system-ui, sans-serif;
color: #334155;
background: #f8fafc;
border: 1px solid rgba(0, 0, 0, 0.14);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
user-select: none;
}
.rozie-flow-toolbar__btn:hover { background: #eef2f7; }
.rozie-flow-toolbar__btn:active { background: #e2e8f0; }
.rozie-flow-toolbar__btn--delete { color: #b91c1c; }
:root {
/* Engine-rendered node / socket / connection DOM never carries the
[data-rozie-s-*] scope attribute — reach it via the Phase-34 :root engine-DOM
escape hatch (NOT :global(), a ROZ128 hard error). Rete ships NO stylesheet,
so ALL flow chrome lives here. */
.rozie-flow-canvas .rozie-flow-node {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
min-width: 140px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
user-select: none;
cursor: grab;
font: 13px/1.4 system-ui, sans-serif;
}
.rozie-flow-canvas .rozie-flow-node.is-selected {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5), 0 2px 8px rgba(0, 0, 0, 0.15);
}
.rozie-flow-canvas .rozie-flow-node__title {
padding: 0.5rem 0.75rem;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
}
.rozie-flow-canvas .rozie-flow-node__body { min-width: 0; }
.rozie-flow-canvas .rozie-flow-node__col {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 0;
}
.rozie-flow-canvas .rozie-flow-port {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: #6b7280;
}
.rozie-flow-canvas .rozie-flow-port--output { justify-content: flex-end; }
.rozie-flow-canvas .rozie-flow-socket {
width: 12px;
height: 12px;
border-radius: 50%;
background: #94a3b8;
border: 2px solid #ffffff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
cursor: crosshair;
flex: none;
}
.rozie-flow-canvas .rozie-flow-socket--input { margin-left: -6px; }
.rozie-flow-canvas .rozie-flow-socket--output { margin-right: -6px; }
.rozie-flow-canvas .rozie-flow-socket:hover { background: #3b82f6; }
/* ── F2: top/bottom handle positioning (only on nodes that declare a top/bottom
<Port position>; left/right nodes keep the classic 3-column grid above, so the
FlowCanvasScreenshot pixel baseline is untouched). The node box becomes a flex
column [topRow / midRow(grid) / bottomRow]; the mid row IS the classic grid. */
.rozie-flow-canvas .rozie-flow-node--rows {
display: flex;
flex-direction: column;
}
.rozie-flow-canvas .rozie-flow-node__mid {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
}
.rozie-flow-canvas .rozie-flow-node__row {
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
padding: 0 0.5rem;
}
/* a vertical port stacks the socket above/below its label. */
.rozie-flow-canvas .rozie-flow-port--vertical {
flex-direction: column;
align-items: center;
gap: 0.125rem;
font-size: 0.7rem;
}
/* top/bottom sockets straddle the matching edge (and drop the left/right −6px nudge). */
.rozie-flow-canvas .rozie-flow-socket--top,
.rozie-flow-canvas .rozie-flow-socket--bottom { margin-left: 0; margin-right: 0; }
.rozie-flow-canvas .rozie-flow-socket--top { margin-top: -6px; }
.rozie-flow-canvas .rozie-flow-socket--bottom { margin-bottom: -6px; }
.rozie-flow-canvas .rozie-flow-connection { position: absolute; }
.rozie-flow-canvas .rozie-flow-connection__svg {
/* display:block is LOAD-BEARING, not cosmetic. An <svg> is display:inline by
default, so the 1px-tall connection SVG sits on the connection element's TEXT
BASELINE — which, with the engine container's default line-height, pushes the
whole path DOWN ~14px. That offset is in screen space (the connection element
is the area-transform origin), so EVERY connection endpoint lands ~14px below
its socket — visibly anchoring connectors at the BOTTOM of each node instead
of on the socket. The socket positions reported by getDOMSocketPosition are
already correct (offsetTop/offsetLeft within the node-view); the inline
baseline is the sole cause of the vertical drift. block (or equivalently
line-height:0 / vertical-align:top on the inline box) removes the baseline gap
so the path renders at its true coordinates. Verified: drops the endpoint→
socket vertical offset from ~13.9px to ~0.1px on all 6 targets. */
display: block;
overflow: visible;
width: 1px;
height: 1px;
pointer-events: none;
}
.rozie-flow-canvas .rozie-flow-connection__path {
fill: none;
stroke: #64748b;
stroke-width: 3px;
pointer-events: auto;
}
/* T1.1 (D-08): a selected edge (the `.is-selected` class is toggled imperatively on
the engine-DOM __path by selectEdge) draws in the selection blue and slightly
thicker — the connection analog of `.rozie-flow-node.is-selected`. Engine DOM
carries no scope attr, so this lives under the :root engine-DOM escape hatch. */
.rozie-flow-canvas .rozie-flow-connection__path.is-selected {
stroke: #3b82f6;
stroke-width: 4px;
}
/* F3: per-edge label — an SVG <text> at the path midpoint with a white halo
(paint-order:stroke) so it stays legible over the connection line. */
.rozie-flow-canvas .rozie-flow-connection__label {
font: 600 11px system-ui, sans-serif;
fill: #334155;
paint-order: stroke;
stroke: #ffffff;
stroke-width: 3px;
stroke-linejoin: round;
pointer-events: none;
user-select: none;
}
}
</style>
</rozie>…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/rete-{react,vue,svelte,angular,solid,lit}):
tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { flushSync } from 'react-dom';
import { clsx, rozieAttr, rozieContext, rozieDisplay, useControllableState } from '@rozie/runtime-react';
import './FlowCanvas.css';
import './FlowCanvas.global.css';
import { NodeEditor, ClassicPreset, Scope } from 'rete';
import { AreaPlugin, AreaExtensions } from 'rete-area-plugin';
import { ConnectionPlugin, Presets as ConnectionPresets } from 'rete-connection-plugin';
import { getDOMSocketPosition, classicConnectionPath } from 'rete-render-utils';
// T2.6 — auto-layout (D-08, verb-only). The 3 deps (rete-auto-arrange-plugin / elkjs
// @0.8.2 / web-worker) are OPTIONAL leaf peers, installed + bundle-smoked on all 6 in
// Plan 00 (the Vite/Angular-AOT/Lit rollup build resolves elkjs to the SYNCHRONOUS
// elk.bundled.js entry — no web-worker resolution error, no manual fallback switch). Only
// a consumer calling autoArrange() pulls these in.
// T2.6 — auto-layout (D-08, verb-only). The 3 deps (rete-auto-arrange-plugin / elkjs
// @0.8.2 / web-worker) are OPTIONAL leaf peers, installed + bundle-smoked on all 6 in
// Plan 00 (the Vite/Angular-AOT/Lit rollup build resolves elkjs to the SYNCHRONOUS
// elk.bundled.js entry — no web-worker resolution error, no manual fallback switch). Only
// a consumer calling autoArrange() pulls these in.
import { AutoArrangePlugin, Presets as ArrangePresets } from 'rete-auto-arrange-plugin';
// ── engine instances — null-lets so typeNeutralize types them `any` (the
// MapLibre `let instance = null` discipline). Rete's NodeEditor / AreaPlugin /
// ConnectionPlugin / DOMSocketPosition carry rich generic Schemes types that the
// loosely-typed .rozie props (any[]) don't satisfy under the strict react/solid/
// lit leaf tsc; routing every engine call through an `any` instance is the
// .rozie-native fix (no lang="ts", no codegen type-aid). These are top-level lets
// referenced from hooks → React auto-hoists each to a useRef. ──
interface NodeCtx { node: any; selected: any; emit: any; }
interface ToolbarCtx { node: any; emit: any; }
interface FlowCanvasProps {
/**
* The single source of truth (two-way `r-model`) — `{ nodes: [{ id, type, x, y, data? }], connections: [{ id?, source, sourceOutput?, target, targetInput?, label?, stroke?, dashed? }] }`. A node's `type` selects its `<NodeType>` template (render-by-type + port schema); `data` is the opaque payload handed to that type's `#body` scope. The canvas writes back a FRESH top-level object on every drag (x/y) and connect/disconnect (connections) — immutable applyNodeChanges style. `sourceOutput`/`targetInput` default to `out`/`in`; a missing connection `id` is derived from the endpoints.
* @example
* <FlowCanvas r-model:graph="graph" :validate-types="true" />
*/
graph?: Record<string, any>;
defaultGraph?: Record<string, any>;
onGraphChange?: (graph: Record<string, any>) => void;
/**
* Automatic typed-socket validation (default ON). When `true`, the canvas resolves each endpoint's port type from the per-`<NodeType>` `<Port type>` schema and auto-rejects a type-mismatched connection (firing `connection-rejected`). `canConnect` survives as the optional custom-rule override that runs in addition. Set `false` for pure-`canConnect` (type as metadata only).
*/
validateTypes?: boolean;
/**
* The viewport zoom level (two-way `r-model`). Scroll/pinch writes the new zoom back through the model (echo-guarded against the wrapper's own programmatic zooms); a consumer write zooms the live area. There is deliberately no `zoom`/`zoomed` emit — a same-named emit collides with the model on Vue and Angular — so the two-way binding is the channel for zoom changes.
*/
zoom?: number;
defaultZoom?: number;
onZoomChange?: (zoom: number) => void;
/**
* Whether the canvas can be panned by dragging the background (applied at construction). Set `false` to detach the area's drag handler.
*/
pannable?: boolean;
/**
* Whether the canvas can be zoomed by scroll/pinch (applied at construction). Set `false` to detach the area's zoom handler.
*/
zoomable?: boolean;
/**
* Whether nodes can be selected (click; ctrl-click to accumulate). Reflected as the `selected` flag in the `<NodeType>` `#body` scope and surfaced to the consumer via the `@selection-change` event.
*/
selectable?: boolean;
/**
* Read-only viewer mode — no node drag, no connection editing, and no selection. View-only zoom/fit (Controls, the `zoomTo`/`zoomToFit` verbs) stay enabled.
*/
readonly?: boolean;
/**
* Minimum zoom level — the lower bound of the area's zoom restrictor. `0` disables the bound.
*/
minZoom?: number;
/**
* Maximum zoom level — the upper bound of the area's zoom restrictor. `0` disables the bound.
*/
maxZoom?: number;
/**
* Snap-to-grid size in pixels for node dragging. `0` turns snapping off.
*/
snapGrid?: number;
/**
* When selectable, hold Ctrl to add to the current selection instead of replacing it.
*/
accumulateOnCtrl?: boolean;
/**
* The bezier curvature of connection paths (`classicConnectionPath`).
*/
curvature?: number;
/**
* After the initial graph mounts, pan/zoom the viewport to fit all nodes (`AreaExtensions.zoomAt`).
*/
fitOnMount?: boolean;
/**
* Render the built-in Controls overlay — a zoom in / zoom out / fit-view button cluster (the React Flow `<Controls/>` parity). The buttons drive the same zoom/fit path as the `zoomTo`/`zoomToFit` handle verbs (clamped to `minZoom`/`maxZoom`) and stay enabled in `readonly`. Opt out with `:controls="false"`.
*/
controls?: boolean;
/**
* Render the built-in MiniMap overlay (opt-in, default OFF — the React Flow `<MiniMap/>` parity) — an absolute SVG panel (bottom-right) showing a scaled map of every node (sized from the measured engine node-view dims) plus the current viewport window (the area outside dimmed). It is pannable: dragging the minimap recenters the main viewport (via `setCenter`). Evaluated at construction, like `pannable`/`zoomable`/`controls` — set it at mount time.
*/
minimap?: boolean;
/**
* Connection-validation predicate `(conn) => boolean`, receiving the normalized candidate connection `{ source, sourceOutput, target, targetInput }`. Return `false` to reject the connection — no edge is committed, no ghost path is drawn, and `connection-rejected` fires. Runs in addition to the automatic `:validate-types` check (the custom-rule override) and gates all connection paths uniformly (drag-to-connect, imperative `addConnection`, graph reconcile). Absent/`null` imposes no custom rule.
*/
canConnect?: ((...args: any[]) => any) | null;
/**
* Undo/redo, on by default. Every gesture (drag, connect, disconnect, delete) pushes ONE capped (~100) snapshot of the bound graph (nodes incl. x/y + connections; not the viewport), and `undo()`/`redo()` plus Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, and Ctrl/Cmd+Y restore it through the two-way `graph` model (echo-guarded). One gesture = one undo step; a fresh edit after an undo discards the redo branch. Opt out with `:history="false"` (the snapshot stack stays empty and the verbs no-op).
*/
history?: boolean;
/**
* Two-way interaction mode (`r-model`) — the Figma-style pan ↔ select toggle, `'pan'` (default) or `'select'`. In `'pan'` an empty-canvas drag pans the viewport (unchanged). In `'select'` an empty-canvas drag draws a rubber-band marquee box that multi-selects the intersecting nodes (surfacing `@selection-change`). A node drag still drags the node in both modes — only the empty-canvas drag changes. The canvas writes it back when the built-in mode button toggles (see `marquee`).
*/
mode?: string;
defaultMode?: string;
onModeChange?: (mode: string) => void;
/**
* Render the 4th Controls button — the pan ↔ select mode toggle (it two-way-writes `mode`). Default OFF so the default Controls overlay keeps its three buttons. The marquee behavior works whenever `mode === 'select'` regardless of this flag (a consumer can drive `mode` directly); this only governs the built-in button.
*/
marquee?: boolean;
/**
* Render the opt-in NodeToolbar (default OFF) — a floating toolbar over the single selected node (positioned from the engine node-view rect + the area transform, re-tracked on pan/zoom/drag). Default content is Delete (cascading controlled-graph `deleteNode`) + Duplicate (clone the node spec at an offset with a new id into a fresh `graph` object); both fire `@node-action` (`name: 'delete' | 'duplicate'`). Override the content by filling the `#toolbar` reactive slot.
*/
nodeToolbar?: boolean;
onEdgeClick?: (...args: any[]) => void;
onEdgeSelected?: (...args: any[]) => void;
onSelectionChange?: (...args: any[]) => void;
onConnectEnd?: (...args: any[]) => void;
onNodeAction?: (...args: any[]) => void;
onConnectionRejected?: (...args: any[]) => void;
onConnectionCreated?: (...args: any[]) => void;
onConnectionRemoved?: (...args: any[]) => void;
onNodePicked?: (...args: any[]) => void;
onNodeMoved?: (...args: any[]) => void;
onTranslated?: (...args: any[]) => void;
onContextMenu?: (...args: any[]) => void;
renderNode?: (ctx: NodeCtx) => ReactNode;
renderToolbar?: (ctx: ToolbarCtx) => ReactNode;
children?: ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export interface FlowCanvasHandle {
getEditor: (...args: any[]) => any;
getArea: (...args: any[]) => any;
addNode: (...args: any[]) => any;
removeNode: (...args: any[]) => any;
deleteNode: (...args: any[]) => any;
addConnection: (...args: any[]) => any;
removeConnection: (...args: any[]) => any;
clear: (...args: any[]) => any;
zoomToFit: (...args: any[]) => any;
zoomTo: (...args: any[]) => any;
setCenter: (...args: any[]) => any;
setViewport: (...args: any[]) => any;
screenToFlowPosition: (...args: any[]) => any;
getNodes: (...args: any[]) => any;
getConnections: (...args: any[]) => any;
getTransform: (...args: any[]) => any;
autoArrange: (...args: any[]) => any;
undo: (...args: any[]) => any;
redo: (...args: any[]) => any;
canUndo: (...args: any[]) => any;
canRedo: (...args: any[]) => any;
getSelectedNodes: (...args: any[]) => any;
selectNode: (...args: any[]) => any;
clearSelection: (...args: any[]) => any;
selectAll: (...args: any[]) => any;
centerOnNode: (...args: any[]) => any;
}
const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function FlowCanvas(_props: FlowCanvasProps, ref): JSX.Element {
const __ctx_rete_canvas = rozieContext("rete:canvas");
const portalRoots = useRef<Set<Root>>(new Set());
const props: Omit<FlowCanvasProps, 'validateTypes' | 'pannable' | 'zoomable' | 'selectable' | 'readonly' | 'minZoom' | 'maxZoom' | 'snapGrid' | 'accumulateOnCtrl' | 'curvature' | 'fitOnMount' | 'controls' | 'minimap' | 'canConnect' | 'history' | 'marquee' | 'nodeToolbar'> & { validateTypes: boolean; pannable: boolean; zoomable: boolean; selectable: boolean; readonly: boolean; minZoom: number; maxZoom: number; snapGrid: number; accumulateOnCtrl: boolean; curvature: number; fitOnMount: boolean; controls: boolean; minimap: boolean; canConnect: ((...args: any[]) => any) | null; history: boolean; marquee: boolean; nodeToolbar: boolean } = {
..._props,
validateTypes: _props.validateTypes ?? true,
pannable: _props.pannable ?? true,
zoomable: _props.zoomable ?? true,
selectable: _props.selectable ?? true,
readonly: _props.readonly ?? false,
minZoom: _props.minZoom ?? 0.1,
maxZoom: _props.maxZoom ?? 4,
snapGrid: _props.snapGrid ?? 0,
accumulateOnCtrl: _props.accumulateOnCtrl ?? true,
curvature: _props.curvature ?? 0.3,
fitOnMount: _props.fitOnMount ?? true,
controls: _props.controls ?? true,
minimap: _props.minimap ?? false,
canConnect: _props.canConnect ?? null,
history: _props.history ?? true,
marquee: _props.marquee ?? false,
nodeToolbar: _props.nodeToolbar ?? false,
};
const _renderNodeRef = useRef(props.renderNode);
_renderNodeRef.current = props.renderNode;
const _renderToolbarRef = useRef(props.renderToolbar);
_renderToolbarRef.current = props.renderToolbar;
const lastPropNodeIds = useRef<any>(null);
const lastPropConnIds = useRef<any>(null);
const editor = useRef<any>(null);
const area = useRef<any>(null);
const connectionPlugin = useRef<any>(null);
const socketWatcher = useRef<any>(null);
const programmatic = useRef(0);
const reconnectInFlight = useRef(0);
const reconnectPreSnapshot = useRef<any>(null);
const reconnectDidWriteBack = useRef(false);
const reconnectCloseScheduled = useRef(false);
const renderScope = useRef<any>(null);
const arrange = useRef<any>(null);
const selector = useRef<any>(null);
const nodeSelectApi = useRef<any>(null);
const onCanvasKeydown = useRef<any>(null);
const selectedConnId = useRef<any>(null);
const keydownContainer = useRef<any>(null);
const scheduleMinimapRedraw = useRef<any>(null);
const dragGestureActive = useRef(false);
const pendingDragSnapshot = useRef<any>(null);
const edgeClickGuard = useRef(false);
const scheduleToolbarTrack = useRef<any>(null);
const reconcileConnections = useRef<any>(null);
const reconcileNodes = useRef<any>(null);
const reconcileNodesRunning = useRef(false);
const reconcileNodesPending = useRef(false);
const minimapRedrawRaf = useRef(0);
const minimapSvg = useRef<any>(null);
const minimapMap = useRef<any>(null);
const minimapHost = useRef<any>(null);
const onMinimapPointerDown = useRef<any>(null);
const minimapPanning = useRef(false);
const onMinimapPointerMove = useRef<any>(null);
const onMinimapPointerUp = useRef<any>(null);
const toolbarTrackRaf = useRef(0);
const toolbarHost = useRef<any>(null);
const toolbarSelectedId = useRef<any>(null);
const toolbarHandle = useRef<any>(null);
const syncToolbarSelection = useRef<any>(null);
const toolbarDeleteBtn = useRef<any>(null);
const toolbarDuplicateBtn = useRef<any>(null);
const onToolbarDelete = useRef<any>(null);
const onToolbarDup = useRef<any>(null);
const marqueeBox = useRef<any>(null);
const marqueeStart = useRef<any>(null);
const marqueeCur = useRef<any>(null);
const marqueeActive = useRef(false);
const onCanvasPointerDownCapture = useRef<any>(null);
const onMarqueePointerMove = useRef<any>(null);
const onMarqueePointerUp = useRef<any>(null);
const lastWrittenGraph = useRef<any>(null);
const historyStack = useRef([]);
const redoStack = useRef([]);
const dragFlushRaf = useRef(0);
const selfWriteInFlight = useRef(false);
const selectedPathEl = useRef<any>(null);
const lastSelectionIds = useRef<any>(null);
const [graph, setGraph] = useControllableState({
value: props.graph,
defaultValue: props.defaultGraph ?? (() => ({
nodes: [],
connections: []
}))(),
onValueChange: props.onGraphChange,
});
const [zoom, setZoom] = useControllableState({
value: props.zoom,
defaultValue: props.defaultZoom ?? 1,
onValueChange: props.onZoomChange,
});
const [mode, setMode] = useControllableState({
value: props.mode,
defaultValue: props.defaultMode ?? 'pan',
onValueChange: props.onModeChange,
});
const _graphRef = useRef(graph);
_graphRef.current = graph;
const _modeRef = useRef(mode);
_modeRef.current = mode;
const _zoomRef = useRef(zoom);
_zoomRef.current = zoom;
const [typeReg, setTypeReg] = useState<Record<string, any>>({});
const [portReg, setPortReg] = useState<Record<string, any>>({});
const _portRegRef = useRef(portReg);
_portRegRef.current = portReg;
const _typeRegRef = useRef(typeReg);
_typeRegRef.current = typeReg;
const canvasEl = useRef<HTMLDivElement | null>(null);
const minimapEl = useRef<HTMLDivElement | null>(null);
const marqueeEl = useRef<HTMLDivElement | null>(null);
const toolbarEl = useRef<HTMLDivElement | null>(null);
const _watch0First = useRef(true);
const _watch1First = useRef(true);
const _watch2First = useRef(true);
const _watch3First = useRef(true);
const MINIMAP_W = useMemo(() => 200, []);
const MINIMAP_H = useMemo(() => 150, []);
const MINIMAP_DEFAULT_NODE_W = useMemo(() => 140, []);
const MINIMAP_DEFAULT_NODE_H = useMemo(() => 52, []);
const SVGNS = useMemo(() => 'http://www.w3.org/2000/svg', []);
const SOCKET = useMemo(() => new ClassicPreset.Socket('flow'), []);
const nodeInstances = useMemo(() => new Map(), []);
const nodeMeta = useMemo(() => new Map(), []);
const connInstances = useMemo(() => new Map(), []);
const nodeEntries = useMemo(() => new Map(), []);
const connEntries = useMemo(() => new Map(), []);
const connMeta = useMemo(() => new Map(), []);
// T1.3 — UNDO / REDO (D-02 on-by-default, D-03 per-gesture graph-only scope, D-04
// echo-guarded restore). A CAPPED snapshot stack over the BOUND GRAPH only — nodes
// (incl x/y) + connections — and explicitly NOT the viewport (pan/zoom is excluded,
// D-03). One entry is pushed per COMPLETED gesture: a drag = ONE entry (snapshot taken
// on pointer-down, committed on the first translate — never per pointermove frame), a
// connect / disconnect / delete = one each. A push is gated on `!programmatic` so a
// restore-driven write (which runs INSIDE the programmatic guard) never re-enters the
// history (D-04). Pushing clears the redo branch and drops the oldest entry beyond the
// cap (Threat T-44-03-1: bounded memory). Snapshots are deep clones of the consumer's own
// serializable graph JSON (Pattern 7; the `$clone` sigil — a deep, de-proxied copy
// that strips the Vue/Svelte reactivity Proxy that a bare `structuredClone` THROWS
// on) — no external input, so the restore (T-44-03-2 accept)
// cannot loop (it rides the programmatic guard + the existing $watch(graph) reconcile).
// Undo is ALWAYS on for v1; `:history=false` (the `history` prop) is the cheap escape
// hatch that skips every push (the stacks stay empty → undo/redo are no-ops).
// COMPONENT-scope so the stack survives across area events + the Solid-hoisted teardown.
const HISTORY_CAP = 100;
// Two-stack model (simpler + correct than a single cursor): `historyStack` holds
// PRE-gesture snapshots (the states to UNDO back to, newest last); `redoStack` holds
// snapshots an undo popped off (the states to REDO forward to, newest last). A new
// gesture (pushHistory) snapshots the PRE-gesture graph onto historyStack and CLEARS
// redoStack (a fresh edit discards the redo branch). undo() pops historyStack → pushes
// the CURRENT (pre-undo) graph onto redoStack → restores the popped snapshot. redo()
// pops redoStack → pushes the current graph back onto historyStack → restores it.
const pendingDragPositions = useMemo(() => new Map(), []);
const currentGraph = useCallback(() => graph || {
nodes: [],
connections: []
}, [graph]);
function commitGraph(g: any) {
const c = structuredClone(g);
lastWrittenGraph.current = c != null ? c : g;
selfWriteInFlight.current = true;
setGraph(g);
}
const snapshotCurrent = useCallback(() => {
const src = lastWrittenGraph.current != null ? lastWrittenGraph.current : currentGraph();
return structuredClone(src);
}, [currentGraph]);
function baseGraph() {
return lastWrittenGraph.current != null ? lastWrittenGraph.current : currentGraph();
}
const pushHistorySnapshot = useCallback((snap: any) => {
if (props.history === false) return;
if (!snap) return;
historyStack.current.push(snap);
if (historyStack.current.length > HISTORY_CAP) {
historyStack.current = historyStack.current.slice(historyStack.current.length - HISTORY_CAP);
}
redoStack.current = [];
}, [props.history]);
function pushHistory() {
if (programmatic.current) return;
if (props.history === false) return;
pushHistorySnapshot(snapshotCurrent());
}
function closeReconnectGesture() {
if (!reconnectCloseScheduled.current) return;
reconnectCloseScheduled.current = false;
if (reconnectInFlight.current > 0) reconnectInFlight.current = 0;
if (!programmatic.current && props.history !== false && reconnectDidWriteBack.current && reconnectPreSnapshot.current) {
pushHistorySnapshot(reconnectPreSnapshot.current);
}
reconnectPreSnapshot.current = null;
reconnectDidWriteBack.current = false;
}
const scheduleReconnectClose = useCallback(() => {
if (reconnectCloseScheduled.current) return;
reconnectCloseScheduled.current = true;
if (typeof setTimeout === 'function') setTimeout(closeReconnectGesture, 0);else Promise.resolve().then(closeReconnectGesture);
}, [closeReconnectGesture]);
function restoreGraph(snap: any) {
if (!snap) return;
// Cancel any in-flight drag write-back so a queued frame can't clobber the restore with
// a stale position after the programmatic guard releases.
pendingDragPositions.clear();
if (dragFlushRaf.current) {
if (typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(dragFlushRaf.current);
} catch (e: any) {}
}
dragFlushRaf.current = 0;
}
programmatic.current++;
try {
const fresh = {
nodes: (snap.nodes || []).map((n: any) => ({
...n
})),
connections: (snap.connections || []).map((c: any) => ({
...c
}))
};
commitGraph(fresh);
} finally {
programmatic.current--;
}
}
const undo = useCallback(() => {
if (historyStack.current.length === 0) return;
const cur = snapshotCurrent();
const snap = historyStack.current.pop();
if (cur) redoStack.current.push(cur);
restoreGraph(snap);
}, [restoreGraph, snapshotCurrent]);
const redo = useCallback(() => {
if (redoStack.current.length === 0) return;
const cur = snapshotCurrent();
const snap = redoStack.current.pop();
if (cur) historyStack.current.push(cur);
restoreGraph(snap);
}, [restoreGraph, snapshotCurrent]);
function canUndo() {
return historyStack.current.length > 0;
}
function canRedo() {
return redoStack.current.length > 0;
}
function flushDragWriteBack() {
dragFlushRaf.current = 0;
if (programmatic.current) {
pendingDragPositions.clear();
return;
}
if (pendingDragPositions.size === 0) return;
const g = baseGraph();
const nodes = (g.nodes || []).map((n: any) => {
const p = n && n.id != null ? pendingDragPositions.get(n.id) : null;
return p ? {
...n,
x: p.x,
y: p.y
} : n;
});
pendingDragPositions.clear();
commitGraph({
...g,
nodes
});
}
const scheduleDragFlush = useCallback(() => {
if (dragFlushRaf.current) return;
if (typeof requestAnimationFrame === 'function') {
dragFlushRaf.current = requestAnimationFrame(flushDragWriteBack);
} else {
dragFlushRaf.current = 1;
Promise.resolve().then(flushDragWriteBack);
}
}, [flushDragWriteBack]);
const writeBackConnectionCreated = useCallback((c: any) => {
if (programmatic.current) return;
// T1.3 — one history entry per CONNECT gesture (BEFORE the write so the snapshot is the
// pre-connect state — snapshotCurrent reads lastWrittenGraph, still the pre-connect value).
// T2.5 — SUPPRESS while a reconnect is in flight: the paired remove+add of a reconnect
// (and a plain new-connection drag, which also rides connectionpick/drop) push ONE
// coalesced snapshot on connectiondrop instead (D-03 one-gesture-one-entry).
if (reconnectInFlight.current) reconnectDidWriteBack.current = true;else pushHistory();
const g = baseGraph();
const conn = {
id: c.id,
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
};
commitGraph({
...g,
connections: [...(g.connections || []), conn]
});
}, [baseGraph, commitGraph, pushHistory]);
const writeBackConnectionRemoved = useCallback((id: any) => {
if (programmatic.current) return;
// T1.3 — one history entry per DISCONNECT / edge-delete gesture (BEFORE the write).
// T2.5 — SUPPRESS while a reconnect is in flight: the remove half of a reconnect is
// coalesced with its paired add into ONE snapshot pushed on connectiondrop (D-03).
if (reconnectInFlight.current) reconnectDidWriteBack.current = true;else pushHistory();
const g = baseGraph();
commitGraph({
...g,
connections: (g.connections || []).filter((e: any) => e && e.id !== id)
});
}, [baseGraph, commitGraph, pushHistory]);
const clearEdgeSelection = useCallback(() => {
if (selectedPathEl.current && selectedPathEl.current.classList) {
try {
selectedPathEl.current.classList.remove('is-selected');
} catch (e: any) {}
}
selectedConnId.current = null;
selectedPathEl.current = null;
}, []);
const { onEdgeClick: _rozieProp_onEdgeClick, onEdgeSelected: _rozieProp_onEdgeSelected } = props;
const selectEdge = useCallback((id: any, pathEl: any) => {
if (id == null) return;
clearEdgeSelection();
selectedConnId.current = id;
selectedPathEl.current = pathEl;
if (pathEl && pathEl.classList) {
try {
pathEl.classList.add('is-selected');
} catch (e: any) {}
}
edgeClickGuard.current = true;
Promise.resolve().then(() => {
edgeClickGuard.current = false;
});
_rozieProp_onEdgeClick && _rozieProp_onEdgeClick({
id
});
_rozieProp_onEdgeSelected && _rozieProp_onEdgeSelected({
id
});
}, [_rozieProp_onEdgeClick, _rozieProp_onEdgeSelected, clearEdgeSelection]);
const deleteNode = useCallback((id: any) => {
if (id == null) return false;
const g = baseGraph();
const sid = String(id);
const nodes = (g.nodes || []).filter((n: any) => n && String(n.id) !== sid);
if (nodes.length === (g.nodes || []).length) return false;
const connections = (g.connections || []).filter((c: any) => c && String(c.source) !== sid && String(c.target) !== sid);
// T1.3 — one history entry per DELETE gesture (node + its incident edges = ONE undo).
pushHistory();
commitGraph({
...g,
nodes,
connections
});
return true;
}, [baseGraph, commitGraph, pushHistory]);
function freshNodeId(baseId: any, existing: any) {
const taken = new Set((existing || []).map((n: any) => n && n.id != null ? String(n.id) : ''));
const root = baseId != null ? String(baseId) : 'node';
let i = 1;
let candidate = root + '-copy';
while (taken.has(candidate)) {
i++;
candidate = root + '-copy-' + i;
}
return candidate;
}
const duplicateNode = useCallback((id: any) => {
if (id == null) return null;
const g = baseGraph();
const sid = String(id);
const src = (g.nodes || []).find((n: any) => n && String(n.id) === sid);
if (!src) return null;
const newId = freshNodeId(src.id, g.nodes);
// Phase 45-07 (WR-02/WR-06): `$clone` is now a recursive proxy-safe deep clone
// on every target (Vue's lowering de-proxies nested reactive members via the
// `rozieDeepClone` runtime helper). The historical `$clone({ d: src.data }).d`
// object-literal wrapper — which never actually dodged the old single-toRaw
// throw on a live nested proxy — is no longer needed; clone `src.data` directly.
const clonedData = src.data != null ? structuredClone(src.data) : undefined;
const clone = {
...src,
id: newId,
x: (typeof src.x === 'number' ? src.x : 0) + 28,
y: (typeof src.y === 'number' ? src.y : 0) + 28,
data: clonedData
};
pushHistory();
commitGraph({
...g,
nodes: [...(g.nodes || []), clone]
});
return newId;
}, [baseGraph, commitGraph, freshNodeId, pushHistory]);
const selectedNodeIds = useCallback(() => {
if (!selector.current || !selector.current.entities) return [];
const ids = [];
for (const e of selector.current.entities.values() as any) {
if (e && e.id != null) ids.push(e.id);
}
return ids;
}, []);
function maybeEmitSelectionChange() {
if (programmatic.current) return;
const ids = selectedNodeIds();
const key = [...ids].map((x: any) => String(x)).sort().join(' ');
if (key === lastSelectionIds.current) return;
lastSelectionIds.current = key;
props.onSelectionChange && props.onSelectionChange({
ids
});
// the selected set changed → repaint the minimap (selected nodes are highlighted).
if (scheduleMinimapRedraw.current) scheduleMinimapRedraw.current();
// T2.8 — the selection changed → re-track the NodeToolbar (it follows the single
// selected node; hides on multi-select / empty selection). No-op when :node-toolbar off.
if (syncToolbarSelection.current) syncToolbarSelection.current();
}
const scheduleSelectionEmit = useCallback(() => {
Promise.resolve().then(maybeEmitSelectionChange);
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(maybeEmitSelectionChange);
} else {
Promise.resolve().then(() => Promise.resolve().then(maybeEmitSelectionChange));
}
}, [maybeEmitSelectionChange]);
const serializeConn = useCallback((c: any) => ({
id: c.id,
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
}), []);
const portSchemaForType = useCallback((type: any, portReg: any) => {
const inputs = [];
const outputs = [];
if (type == null || !portReg) return {
inputs,
outputs
};
const prefix = type + '::';
for (const k in portReg) {
if (k.indexOf(prefix) !== 0) continue;
const p = portReg[k];
if (!p || p.key == null) continue;
const entry = {
key: p.key,
label: p.label,
multiple: p.multiple,
portType: p.portType
};
if (p.side === 'input') inputs.push(entry);else outputs.push(entry);
}
return {
inputs,
outputs
};
}, [portReg]);
const buildNode = useCallback((spec: any, portReg: any) => {
const label = spec.data && spec.data.label != null ? String(spec.data.label) : '';
const node = new ClassicPreset.Node(label);
node.id = spec.id;
const {
inputs,
outputs
} = portSchemaForType(spec.type, portReg);
for (const inp of inputs as any) {
if (!inp || inp.key == null) continue;
node.addInput(inp.key, new ClassicPreset.Input(SOCKET, inp.label, inp.multiple === true));
}
for (const out of outputs as any) {
if (!out || out.key == null) continue;
node.addOutput(out.key, new ClassicPreset.Output(SOCKET, out.label, out.multiple !== false));
}
return node;
}, [portReg, portSchemaForType]);
// ─── imperative handle (Phase 21 $expose) ────────────────────────────────────
// Collision discipline (ROZ121/ROZ524/Lit-lifecycle):
// - NO `setZoom` — `zoom` is a model prop, so React auto-generates a `setZoom`
// state setter (the MapLibre setCenter/setZoom lesson); the verb is `zoomTo`.
// - NONE equals a Lit reserved lifecycle name (update/render/firstUpdated/
// updated/willUpdate/requestUpdate) — note `clear` and `getNodes` are safe.
// - NONE equals an emitted event name (node-moved/node-picked/connection-*
// /translated/context-menu/node-action) or a prop name.
// addNode/addConnection/removeNode/removeConnection operate on the engine
// directly and are NOT reaped by props reconcile (provenance-tracked).
function getEditor() {
return editor.current;
}
function getArea() {
return area.current;
}
async function addNode(spec: any) {
if (!editor.current || !spec || spec.id == null) return null;
const node = buildNode(spec, portReg);
nodeInstances.set(spec.id, node);
nodeMeta.set(spec.id, spec);
programmatic.current++;
try {
await editor.current.addNode(node);
await area.current.translate(spec.id, {
x: spec.x || 0,
y: spec.y || 0
});
} finally {
programmatic.current--;
}
return spec.id;
}
async function removeNode(id: any) {
if (!editor.current || id == null || !nodeInstances.has(id)) return false;
programmatic.current++;
try {
for (const c of editor.current.getConnections() as any) {
if (c.source === id || c.target === id) await editor.current.removeConnection(c.id);
}
await editor.current.removeNode(id);
} finally {
programmatic.current--;
}
nodeInstances.delete(id);
nodeMeta.delete(id);
return true;
}
async function addConnection(spec: any) {
if (!editor.current || !spec || spec.source == null || spec.target == null) return null;
const srcOut = spec.sourceOutput != null ? spec.sourceOutput : 'out';
const tgtIn = spec.targetInput != null ? spec.targetInput : 'in';
const sourceNode = nodeInstances.get(spec.source);
const targetNode = nodeInstances.get(spec.target);
if (!sourceNode || !targetNode) return null;
const conn = new ClassicPreset.Connection(sourceNode, srcOut, targetNode, tgtIn);
if (spec.id != null) conn.id = spec.id;
programmatic.current++;
try {
await editor.current.addConnection(conn);
} finally {
programmatic.current--;
}
connInstances.set(conn.id, conn);
return conn.id;
}
async function removeConnection(id: any) {
if (!editor.current || id == null) return false;
programmatic.current++;
try {
await editor.current.removeConnection(id);
} finally {
programmatic.current--;
}
connInstances.delete(id);
return true;
}
async function clear() {
if (!editor.current) return;
programmatic.current++;
try {
await editor.current.clear();
} finally {
programmatic.current--;
}
nodeInstances.clear();
nodeMeta.clear();
connInstances.clear();
connMeta.clear();
lastPropNodeIds.current = [];
lastPropConnIds.current = [];
}
async function zoomToFit() {
if (!area.current || !editor.current) return;
programmatic.current++;
try {
await AreaExtensions.zoomAt(area.current, editor.current.getNodes());
} finally {
programmatic.current--;
}
const k = area.current.area.transform.k;
if (k !== zoom) setZoom(k);
}
async function zoomTo(k: any) {
if (!area.current || typeof k !== 'number') return;
programmatic.current++;
try {
await area.current.area.zoom(k);
} finally {
programmatic.current--;
}
if (k !== zoom) setZoom(k);
}
// ─── viewport API (Phase 42 — the T11 gap + what the pannable minimap needs) ─────
// Both write the AreaPlugin transform via the CONFIRMED Rete v2 area API: with the
// origin omitted `area.area.zoom(k)` leaves x/y unchanged (transform.x += 0·d), and
// `area.area.translate(x, y)` sets the pan ABSOLUTELY (verified against rete-area-
// plugin@2.1.5). Echo-guarded with `programmatic` so the transform write doesn't loop
// back through the zoomed/nodetranslated write-back (the `translated` emit stays
// UNCONDITIONAL, so @translated still surfaces a programmatic recenter — a real
// viewport change the consumer asked for). After, echo `$model.zoom` (mirrors zoomTo).
// Collision discipline: setCenter/setViewport are NOT Lit lifecycle names, NOT emit
// names, NOT prop names, NOT React model-setters (`graph`/`zoom` → setGraph/setZoom),
// and NOT inherited DOM methods (the Embla scrollTo lesson) — clean on all 6.
//
// setViewport({ x, y, k }) — set the raw transform (any field omitted keeps its
// current value).
// ─── viewport API (Phase 42 — the T11 gap + what the pannable minimap needs) ─────
// Both write the AreaPlugin transform via the CONFIRMED Rete v2 area API: with the
// origin omitted `area.area.zoom(k)` leaves x/y unchanged (transform.x += 0·d), and
// `area.area.translate(x, y)` sets the pan ABSOLUTELY (verified against rete-area-
// plugin@2.1.5). Echo-guarded with `programmatic` so the transform write doesn't loop
// back through the zoomed/nodetranslated write-back (the `translated` emit stays
// UNCONDITIONAL, so @translated still surfaces a programmatic recenter — a real
// viewport change the consumer asked for). After, echo `$model.zoom` (mirrors zoomTo).
// Collision discipline: setCenter/setViewport are NOT Lit lifecycle names, NOT emit
// names, NOT prop names, NOT React model-setters (`graph`/`zoom` → setGraph/setZoom),
// and NOT inherited DOM methods (the Embla scrollTo lesson) — clean on all 6.
//
// setViewport({ x, y, k }) — set the raw transform (any field omitted keeps its
// current value).
async function setViewport(vp: any) {
if (!area.current || !vp || typeof vp !== 'object') return;
const tf = area.current.area.transform;
const k = typeof vp.k === 'number' ? vp.k : tf.k;
const x = typeof vp.x === 'number' ? vp.x : tf.x;
const y = typeof vp.y === 'number' ? vp.y : tf.y;
programmatic.current++;
try {
if (k !== area.current.area.transform.k) await area.current.area.zoom(k);
await area.current.area.translate(x, y);
} finally {
programmatic.current--;
}
if (k !== zoom) setZoom(k);
}
// setCenter(x, y, opts?) — center the viewport on graph-coords (x, y), optionally
// setting zoom (`opts.zoom`). The transform that puts graph point (x,y) at the canvas
// center is tx = W/2 − x·k, ty = H/2 − y·k (screen = graph·k + transform). W/H are the
// engine container's pixel dims (area.container — public on AreaPlugin, no $refs read).
// setCenter(x, y, opts?) — center the viewport on graph-coords (x, y), optionally
// setting zoom (`opts.zoom`). The transform that puts graph point (x,y) at the canvas
// center is tx = W/2 − x·k, ty = H/2 − y·k (screen = graph·k + transform). W/H are the
// engine container's pixel dims (area.container — public on AreaPlugin, no $refs read).
async function setCenter(x: any, y: any, opts: any) {
if (!area.current || typeof x !== 'number' || typeof y !== 'number') return;
const k = opts && typeof opts.zoom === 'number' ? opts.zoom : area.current.area.transform.k;
const el = area.current.container;
const cw = el && el.clientWidth ? el.clientWidth : 0;
const ch = el && el.clientHeight ? el.clientHeight : 0;
const tx = cw / 2 - x * k;
const ty = ch / 2 - y * k;
programmatic.current++;
try {
if (k !== area.current.area.transform.k) await area.current.area.zoom(k);
await area.current.area.translate(tx, ty);
} finally {
programmatic.current--;
}
if (k !== zoom) setZoom(k);
}
// ─── built-in Controls overlay handlers (Win 4) ──────────────────────────────
// Wired to the in-template zoom in / out / fit buttons (gated r-if="$props.controls").
// They REUSE the zoomTo / zoomToFit verbs (one implementation — no logic duplication),
// clamping the step to [minZoom, maxZoom] so a button never exceeds the restrictor
// bounds. Zoom/fit are view-only, so they stay enabled even when readonly (they do not
// edit the graph). A no-op before the area mounts.
// ─── built-in Controls overlay handlers (Win 4) ──────────────────────────────
// Wired to the in-template zoom in / out / fit buttons (gated r-if="$props.controls").
// They REUSE the zoomTo / zoomToFit verbs (one implementation — no logic duplication),
// clamping the step to [minZoom, maxZoom] so a button never exceeds the restrictor
// bounds. Zoom/fit are view-only, so they stay enabled even when readonly (they do not
// edit the graph). A no-op before the area mounts.
const ZOOM_STEP = 1.2;
function clampZoom(k: any) {
let lo = typeof props.minZoom === 'number' && props.minZoom > 0 ? props.minZoom : 0.01;
let hi = typeof props.maxZoom === 'number' && props.maxZoom > 0 ? props.maxZoom : 100;
if (k < lo) return lo;
if (k > hi) return hi;
return k;
}
const controlZoomIn = useCallback(() => {
if (!area.current) return;
zoomTo(clampZoom(area.current.area.transform.k * ZOOM_STEP));
}, [clampZoom, zoomTo]);
const controlZoomOut = useCallback(() => {
if (!area.current) return;
zoomTo(clampZoom(area.current.area.transform.k / ZOOM_STEP));
}, [clampZoom, zoomTo]);
const controlFit = useCallback(() => {
zoomToFit();
}, [zoomToFit]);
const toggleMode = useCallback(() => {
setMode(prev => prev === 'select' ? 'pan' : 'select');
}, [setMode]);
function getNodes() {
if (!area.current) return [];
const out = [];
for (const [id, node] of nodeInstances as any) {
const view = area.current.nodeViews.get(id);
out.push({
id,
label: node.label,
x: view ? view.position.x : 0,
y: view ? view.position.y : 0
});
}
return out;
}
function getConnections() {
return editor.current ? editor.current.getConnections().map(serializeConn) : [];
}
function getTransform() {
return area.current ? {
x: area.current.area.transform.x,
y: area.current.area.transform.y,
k: area.current.area.transform.k
} : null;
}
// screenToFlowPosition(clientX, clientY) → { x, y } in GRAPH coords (Phase 43 — the
// palette-drop / no-code-builder primitive, the React-Flow `screenToFlowPosition`
// parity). The INVERSE of the area transform: a graph point projects to the screen as
// `screen = containerOrigin + transform.{x,y} + graph·k`, so
// `graph = (client − containerOrigin − transform) / k`. `area.container` is public on
// the AreaPlugin (no $refs read). Returns null before the area mounts. The component
// owns ONLY this projection — the consumer owns the drag/drop (a palette item's
// `draggable` + the canvas `@dragover.prevent`/`@drop`) and writes the new node into the
// bound `graph` at the returned coords, exactly like React Flow (which does not own the
// palette either).
// screenToFlowPosition(clientX, clientY) → { x, y } in GRAPH coords (Phase 43 — the
// palette-drop / no-code-builder primitive, the React-Flow `screenToFlowPosition`
// parity). The INVERSE of the area transform: a graph point projects to the screen as
// `screen = containerOrigin + transform.{x,y} + graph·k`, so
// `graph = (client − containerOrigin − transform) / k`. `area.container` is public on
// the AreaPlugin (no $refs read). Returns null before the area mounts. The component
// owns ONLY this projection — the consumer owns the drag/drop (a palette item's
// `draggable` + the canvas `@dragover.prevent`/`@drop`) and writes the new node into the
// bound `graph` at the returned coords, exactly like React Flow (which does not own the
// palette either).
function screenToFlowPosition(clientX: any, clientY: any) {
if (!area.current || typeof clientX !== 'number' || typeof clientY !== 'number') return null;
const el = area.current.container;
const rect = el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null;
if (!rect) return null;
const t = area.current.area.transform;
const k = t.k || 1;
return {
x: (clientX - rect.left - t.x) / k,
y: (clientY - rect.top - t.y) / k
};
}
// T2.6 — autoArrange(opts?) — relayout the graph into a non-overlapping LAYERED arrangement
// (D-08, verb-only, NO auto-trigger — the MapLibre verb-first stance). Runs the
// AutoArrangePlugin (elkjs classic preset), then READS the arranged positions BACK into a
// FRESH `{ nodes, connections }` object written through `$model.graph` (the controlled-graph
// contract — the engine is never the source of truth, mirroring the drag write-back).
//
// PITFALL 3 (Plan 00 / RESEARCH): elkjs needs each node's `width`/`height`; our nodes are
// plain `ClassicPreset.Node` with no dimensions, so without dims the classic preset collapses
// every node to (0,0). We set `node.width`/`node.height` from the MEASURED engine node-view
// element (area.nodeViews.get(id).element offsetW/H — target-agnostic, the measureNodeSize
// discipline) BEFORE layout, falling back to MINIMAP_DEFAULT_NODE_W/H for Lit's unmeasured
// first paint. (measureNodeSize itself is $onMount-local; the verb is top-level, so the same
// measure is inlined here over the component-scope `area` + `nodeInstances`.)
//
// Echo-guarded (programmatic++ around layout AND the write-back) so the engine relayout and
// the resulting $model.graph re-bind → $watch(graph) → reconcile don't re-enter; ONE history
// snapshot is pushed for the whole gesture (D-03, gated on !programmatic + history). The
// optional `opts.options` (elk layout options — direction/spacing) is forwarded to
// arrange.layout() (D-01 discretion — default-only is fine; the arg stays optional).
//
// Collision discipline: `autoArrange` is NOT a Lit lifecycle name (update/render/firstUpdated/
// updated/willUpdate/requestUpdate), NOT an inherited DOM method (the Embla scrollTo lesson),
// NOT an emit (node-*/connection-*/translated/context-menu/selection-change/edge-*/node-action),
// NOT a prop, NOT a React model-setter (graph/zoom → setGraph/setZoom) — clean on all 6.
// T2.6 — autoArrange(opts?) — relayout the graph into a non-overlapping LAYERED arrangement
// (D-08, verb-only, NO auto-trigger — the MapLibre verb-first stance). Runs the
// AutoArrangePlugin (elkjs classic preset), then READS the arranged positions BACK into a
// FRESH `{ nodes, connections }` object written through `$model.graph` (the controlled-graph
// contract — the engine is never the source of truth, mirroring the drag write-back).
//
// PITFALL 3 (Plan 00 / RESEARCH): elkjs needs each node's `width`/`height`; our nodes are
// plain `ClassicPreset.Node` with no dimensions, so without dims the classic preset collapses
// every node to (0,0). We set `node.width`/`node.height` from the MEASURED engine node-view
// element (area.nodeViews.get(id).element offsetW/H — target-agnostic, the measureNodeSize
// discipline) BEFORE layout, falling back to MINIMAP_DEFAULT_NODE_W/H for Lit's unmeasured
// first paint. (measureNodeSize itself is $onMount-local; the verb is top-level, so the same
// measure is inlined here over the component-scope `area` + `nodeInstances`.)
//
// Echo-guarded (programmatic++ around layout AND the write-back) so the engine relayout and
// the resulting $model.graph re-bind → $watch(graph) → reconcile don't re-enter; ONE history
// snapshot is pushed for the whole gesture (D-03, gated on !programmatic + history). The
// optional `opts.options` (elk layout options — direction/spacing) is forwarded to
// arrange.layout() (D-01 discretion — default-only is fine; the arg stays optional).
//
// Collision discipline: `autoArrange` is NOT a Lit lifecycle name (update/render/firstUpdated/
// updated/willUpdate/requestUpdate), NOT an inherited DOM method (the Embla scrollTo lesson),
// NOT an emit (node-*/connection-*/translated/context-menu/selection-change/edge-*/node-action),
// NOT a prop, NOT a React model-setter (graph/zoom → setGraph/setZoom) — clean on all 6.
async function autoArrange(opts: any) {
if (!arrange.current || !area.current) return;
// Set elkjs dimensions on every live node instance from its measured node-view element
// (Pitfall 3) — without dims the classic preset stacks all nodes at (0,0).
for (const [id, node] of nodeInstances as any) {
const view = area.current.nodeViews ? area.current.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
node.width = el && el.offsetWidth ? el.offsetWidth : MINIMAP_DEFAULT_NODE_W;
node.height = el && el.offsetHeight ? el.offsetHeight : MINIMAP_DEFAULT_NODE_H;
}
// ONE history entry for the arrange gesture, captured BEFORE the write (pushHistory reads
// lastWrittenGraph, still the pre-arrange state). Gated on !programmatic + history.
pushHistory();
programmatic.current++;
try {
await arrange.current.layout(opts && opts.options ? {
options: opts.options
} : undefined);
} finally {
programmatic.current--;
}
// Read the arranged positions back into a FRESH graph object (controlled-graph contract).
// Echo-guarded: commitGraph → $model.graph re-bind must not re-enter the reconcile as a new
// gesture. (The arrange already moved the engine to these coords, so the reconcile is a
// no-op diff; the guard is belt-and-braces + suppresses any history re-entry.)
programmatic.current++;
try {
const g = baseGraph();
const nodes = (g.nodes || []).map((n: any) => {
const v = n && n.id != null && area.current.nodeViews ? area.current.nodeViews.get(n.id) : null;
return v && v.position ? {
...n,
x: v.position.x,
y: v.position.y
} : n;
});
commitGraph({
...g,
nodes
});
} finally {
programmatic.current--;
}
}
// ─── imperative selection control ────────────────────────────────────────────
// Selection was previously PUSH-ONLY (the `selection-change` emit fires on change,
// but a consumer couldn't READ or DRIVE selection). These reuse the internal
// `selector` / `nodeSelectApi` (AreaExtensions.selector + selectableNodes) already
// wired for the marquee — no new engine state. All no-op when selection is off
// (readonly / !selectable, when `nodeSelectApi` is null). Each schedules the same
// post-settle `selection-change` recompute the marquee uses, so an imperative
// select keeps the consumer's bound state in sync (the zoomTo→$model.zoom echo
// stance). Collision discipline: `selectNode` is NOT bare `select` — `select` is
// an inherited HTMLElement method (Lit shadow, the Embla scrollTo lesson) AND a
// FullCalendar-style emit hazard; getSelectedNodes/clearSelection/selectAll/
// centerOnNode are NOT emits (selection-change/node-*/edge-*), NOT props, NOT
// React model-setters (graph/zoom → setGraph/setZoom), NOT Lit lifecycle.
//
// getSelectedNodes() — the currently-selected nodes as { id, label, x, y } (the
// getNodes() shape, filtered to the live selection). Empty when nothing selected.
// ─── imperative selection control ────────────────────────────────────────────
// Selection was previously PUSH-ONLY (the `selection-change` emit fires on change,
// but a consumer couldn't READ or DRIVE selection). These reuse the internal
// `selector` / `nodeSelectApi` (AreaExtensions.selector + selectableNodes) already
// wired for the marquee — no new engine state. All no-op when selection is off
// (readonly / !selectable, when `nodeSelectApi` is null). Each schedules the same
// post-settle `selection-change` recompute the marquee uses, so an imperative
// select keeps the consumer's bound state in sync (the zoomTo→$model.zoom echo
// stance). Collision discipline: `selectNode` is NOT bare `select` — `select` is
// an inherited HTMLElement method (Lit shadow, the Embla scrollTo lesson) AND a
// FullCalendar-style emit hazard; getSelectedNodes/clearSelection/selectAll/
// centerOnNode are NOT emits (selection-change/node-*/edge-*), NOT props, NOT
// React model-setters (graph/zoom → setGraph/setZoom), NOT Lit lifecycle.
//
// getSelectedNodes() — the currently-selected nodes as { id, label, x, y } (the
// getNodes() shape, filtered to the live selection). Empty when nothing selected.
function getSelectedNodes() {
const sel = new Set(selectedNodeIds().map((x: any) => String(x)));
return getNodes().filter((n: any) => sel.has(String(n.id)));
}
// selectNode(id, accumulate?) — programmatically select a node (sidebar/search →
// highlight). accumulate=true adds to the current selection; falsy replaces it.
// selectNode(id, accumulate?) — programmatically select a node (sidebar/search →
// highlight). accumulate=true adds to the current selection; falsy replaces it.
function selectNode(id: any, accumulate: any) {
if (!nodeSelectApi.current || id == null) return;
nodeSelectApi.current.select(id, !!accumulate);
scheduleSelectionEmit();
}
// clearSelection() — unselect every selected node (and any selected edge).
// clearSelection() — unselect every selected node (and any selected edge).
function clearSelection() {
if (nodeSelectApi.current) {
for (const id of selectedNodeIds() as any) nodeSelectApi.current.unselect(id);
}
clearEdgeSelection();
scheduleSelectionEmit();
}
// selectAll() — select every node (Ctrl+A is not bound; marquee only covers a
// dragged region). Mirrors the marquee's first-replaces / rest-accumulate pattern.
// selectAll() — select every node (Ctrl+A is not bound; marquee only covers a
// dragged region). Mirrors the marquee's first-replaces / rest-accumulate pattern.
function selectAll() {
if (!nodeSelectApi.current) return;
let first = true;
for (const n of getNodes() as any) {
nodeSelectApi.current.select(n.id, !first);
first = false;
}
scheduleSelectionEmit();
}
// centerOnNode(id, opts?) — pan (and optionally zoom via opts.zoom) to center the
// viewport on a node by id. setCenter is coordinate-based; this measures the node
// to compute its center in GRAPH coords (position is the top-left; offsetW/H are
// unscaled graph units), falling back to the minimap default dims pre-measure.
// centerOnNode(id, opts?) — pan (and optionally zoom via opts.zoom) to center the
// viewport on a node by id. setCenter is coordinate-based; this measures the node
// to compute its center in GRAPH coords (position is the top-left; offsetW/H are
// unscaled graph units), falling back to the minimap default dims pre-measure.
async function centerOnNode(id: any, opts: any) {
if (!area.current || id == null) return;
const view = area.current.nodeViews ? area.current.nodeViews.get(id) : null;
if (!view || !view.position) return;
const el = view.element;
const w = el && el.offsetWidth ? el.offsetWidth : MINIMAP_DEFAULT_NODE_W;
const h = el && el.offsetHeight ? el.offsetHeight : MINIMAP_DEFAULT_NODE_H;
await setCenter(view.position.x + w / 2, view.position.y + h / 2, opts);
}
useEffect(() => {
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portals = {
node: (container: HTMLElement, scope: { node: unknown; selected: unknown; emit: unknown }): ReactivePortalHandle => {
const slot = _renderNodeRef.current ?? props.slots?.['node'];
if (typeof slot !== 'function') return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
// Cascades the @portal node { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-node', 'cd396d6a');
const root = createRoot(container);
const renderScope = (s: { node: unknown; selected: unknown; emit: unknown }): void => {
flushSync(() => root.render(slot(s)));
};
renderScope(scope);
portalRoots.current.add(root);
return {
update: (s: { node: unknown; selected: unknown; emit: unknown }): void => renderScope(s),
dispose: (): void => {
root.unmount();
portalRoots.current.delete(root);
},
};
},
toolbar: (container: HTMLElement, scope: { node: unknown; emit: unknown }): ReactivePortalHandle => {
const slot = _renderToolbarRef.current ?? props.slots?.['toolbar'];
if (typeof slot !== 'function') return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
// Cascades the @portal toolbar { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-toolbar', 'cd396d6a');
const root = createRoot(container);
const renderScope = (s: { node: unknown; emit: unknown }): void => {
flushSync(() => root.render(slot(s)));
};
renderScope(scope);
portalRoots.current.add(root);
return {
update: (s: { node: unknown; emit: unknown }): void => renderScope(s),
dispose: (): void => {
root.unmount();
portalRoots.current.delete(root);
},
};
},
};
const container = canvasEl.current;
lastPropNodeIds.current = [];
lastPropConnIds.current = [];
editor.current = new NodeEditor();
area.current = new AreaPlugin(container);
connectionPlugin.current = new ConnectionPlugin();
connectionPlugin.current.addPreset(ConnectionPresets.classic.setup());
// Resolve a port's VISUAL position (F2) from the per-TYPE port schema (portReg, keyed
// `type::side::key`), defaulting by DIRECTION (input → left, output → right) for exact
// back-compat. DEFINED HERE inside $onMount (NOT top level) so its $data.portReg read
// lowers on React to the live `_portRegRef.current`, not a stale-empty mount-time
// closure (the portTypeOf discipline). Used by both the socket-anchor offset below and
// renderNode's socket layout.
const resolvePortPosition = (type: any, side: any, key: any) => {
const entry = type != null && key != null ? _portRegRef.current[type + '::' + side + '::' + key] : null;
const p = entry && entry.position != null ? entry.position : null;
if (p === 'left' || p === 'right' || p === 'top' || p === 'bottom') return p;
return side === 'input' ? 'left' : 'right';
};
// DOM-based socket position watcher — feeds connection-path redraw + the
// ConnectionPlugin's drag-to-connect hit-testing. A CUSTOM `offset` (F2): the rete
// default shifts the anchor 12px OUTWARD on the X axis only (`x + 12·(input?−1:1)`) —
// correct for left/right, wrong for top/bottom. We resolve each socket's visual
// position and shift on the matching axis (±x for left/right — IDENTICAL to the default,
// so the rete-flow-align cell stays green; ±y for top/bottom). The position is looked up
// live via nodeMeta→type→portReg, so it tracks late-registered ports.
const SOCKET_SHIFT = 12;
const socketOffset = (position: any, nodeId: any, side: any, key: any) => {
const meta = nodeMeta.get(nodeId);
const p = meta ? resolvePortPosition(meta.type, side, key) : side === 'input' ? 'left' : 'right';
if (p === 'top') return {
x: position.x,
y: position.y - SOCKET_SHIFT
};
if (p === 'bottom') return {
x: position.x,
y: position.y + SOCKET_SHIFT
};
if (p === 'left') return {
x: position.x - SOCKET_SHIFT,
y: position.y
};
return {
x: position.x + SOCKET_SHIFT,
y: position.y
};
};
socketWatcher.current = getDOMSocketPosition({
offset: socketOffset
});
editor.current.use(area.current);
area.current.use(connectionPlugin.current);
// ── T2.5 RECONNECT coalescing pipe (D-08 reconnectable edges, D-03 one-gesture-one-entry) ──
// `connectionpick` / `connectiondrop` are emitted on the ConnectionPlugin's OWN scope (they
// are NOT editor signals like connectioncreated/removed, nor area signals like nodepicked),
// so they must be observed via a pipe attached DIRECTLY to `connectionPlugin` — they do not
// propagate into editor.addPipe / area.addPipe. Grabbing an already-connected input socket
// fires connectionpick, then the classic preset removes the old edge + (on drop over a new
// socket) adds a new one — a remove+add pair that would push TWO history entries (Pitfall 2).
// We open a reconnect-in-flight window on connectionpick (capturing the PRE-gesture snapshot
// ONCE) and close it on connectiondrop (pushing that single snapshot iff the gesture actually
// changed the graph) — so the whole reconnect is ONE undoable step.
connectionPlugin.current.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectionpick') {
// Open the coalesce window + capture the pre-gesture snapshot once. Gated on
// !programmatic + history (a restore-driven engine op must not record history). A
// re-pick while a close is pending cancels the pending close (the gesture continues).
if (!programmatic.current && props.history !== false) {
reconnectInFlight.current++;
reconnectPreSnapshot.current = snapshotCurrent();
reconnectDidWriteBack.current = false;
reconnectCloseScheduled.current = false;
}
} else if (context.type === 'connectiondrop') {
// The gesture ended. CRITICAL ORDERING: the classic preset emits `connectiondrop`
// BEFORE the editor's `connectionremoved` / `connectioncreated` signals fire (the
// pseudo-connection is dropped, THEN the real add/remove run — verified in the event
// trace: drop → connectioncreate → connectioncreated → connectionremove →
// connectionremoved). So we must NOT close the window synchronously here, or the
// trailing writeBacks would run with inFlight=0 and each push its own (wrong) history
// entry. Instead DEFER the close to a macrotask (setTimeout 0), which runs after all
// the synchronous + microtask writeBack signals have settled. The window stays open
// across the remove+add (both suppress their per-event push, setting
// reconnectDidWriteBack), then closeReconnectGesture pushes the SINGLE pre-gesture
// snapshot iff the graph actually changed. Re-entrant picks can't desync because the
// close is gated on a one-shot scheduled flag.
scheduleReconnectClose();
// ── T2.7 CONNECT-END-ON-PANE (D-07, pure emit) ──
// A drag that STARTED on an output socket and ENDED on empty canvas (no target
// socket, no connection created) surfaces `@connect-end { source, sourceOutput,
// position }` so the consumer can run its OWN node-picker / create-node flow at the
// drop point (the n8n "drag off a port → drop on the pane → pick a node" UX). The
// component owns ONLY this hook — it creates NO node and shows NO picker (D-07,
// consumer-owns-creation, exactly like screenToFlowPosition + the palette drop).
// Detection: `socket == null` (released over the pane, not a socket) && `created ==
// false` (no edge was made) && `initial.side === 'output'` (we only surface OUTPUT-
// started drags — an input-started drag off the pane has no "source output" to seed
// a downstream node from, and the reconnect path already owns input-endpoint drags).
// Position = `area.area.pointer` (the AreaPlugin's live pointer, ALREADY in graph
// coords — the same origin screenToFlowPosition projects into), so no client→graph
// projection is needed; we still fall back to screenToFlowPosition over a raw
// clientX/clientY if a future plugin build stops tracking area.area.pointer. Gated on
// !programmatic so a restore/imperative-driven drop never emits. NO node is created.
const cd = context.data;
if (cd && !cd.socket && cd.created === false && cd.initial && cd.initial.side === 'output' && !programmatic.current) {
let pos: any = null;
const inner = area.current && area.current.area ? area.current.area : null;
if (inner && inner.pointer && typeof inner.pointer.x === 'number' && typeof inner.pointer.y === 'number') {
pos = {
x: inner.pointer.x,
y: inner.pointer.y
};
}
if ((!pos || typeof pos.x !== 'number' || typeof pos.y !== 'number') && cd.initial && cd.initial.element && typeof cd.initial.element.getBoundingClientRect === 'function') {
// Fallback: project the last-known pointer client coords through the shipped
// screenToFlowPosition (graph-coord inverse of the area transform). The drop event
// carries no pointer; use the source socket element's center as a degraded anchor.
const r = cd.initial.element.getBoundingClientRect();
pos = screenToFlowPosition(r.left + r.width / 2, r.top + r.height / 2);
}
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
props.onConnectEnd && props.onConnectEnd({
source: cd.initial.nodeId,
sourceOutput: cd.initial.key,
position: {
x: pos.x,
y: pos.y
}
});
}
}
}
return context;
});
// The socket-position watcher (and, conceptually, our vanilla "render plugin")
// must attach to a CHILD scope of the area — `attach` calls
// `scope.parentScope(BaseAreaPlugin)`, which walks UP one level, so the scope's
// parent must BE the area. Attaching to `area` itself fails ("actual parent is
// not instance of type") because area's parent is the NodeEditor. So we add a
// minimal child Scope and attach the watcher to it. Rete forwards every area
// signal (render/nodetranslated/unmount/…) into this child's signal, so the
// watcher sees socket renders + node moves and recomputes socket positions.
renderScope.current = new Scope('rozie-vanilla-render');
area.current.use(renderScope.current);
socketWatcher.current.attach(renderScope.current);
// ── T2.6 auto-layout (D-08, verb-only) ──
// Wire the AutoArrangePlugin (elkjs classic preset) so the top-level autoArrange() verb
// can run a layered relayout on demand. area.use(arrange) installs it as an area-scope
// plugin; arrange.layout() mutates the engine node positions directly (calls area.translate
// internally). The verb reads the arranged positions BACK into a FRESH $model.graph (the
// controlled-graph contract — the engine is never the source of truth). NO auto-trigger —
// the consumer calls autoArrange() (the MapLibre verb-first stance).
arrange.current = new AutoArrangePlugin();
arrange.current.addPreset(ArrangePresets.classic.setup());
area.current.use(arrange.current);
// ── selection (selectableNodes) ──
// Capture the returned handle ({ select(id, accumulate), unselect(id) }) so the T2.4
// marquee can PROGRAMMATICALLY select each intersecting node (select(id, true) =
// accumulate). The handle is null when selection is off (readonly / !selectable), in
// which case the marquee branch no-ops.
if (props.selectable && !props.readonly) {
selector.current = AreaExtensions.selector();
nodeSelectApi.current = AreaExtensions.selectableNodes(area.current, selector.current, {
accumulating: props.accumulateOnCtrl ? AreaExtensions.accumulateOnCtrl() : {
active: () => false
}
});
}
// raise the picked node above its siblings.
AreaExtensions.simpleNodesOrder(area.current);
// ── zoom clamp (restrictor) ──
const min = typeof props.minZoom === 'number' && props.minZoom > 0 ? props.minZoom : 0;
const max = typeof props.maxZoom === 'number' && props.maxZoom > 0 ? props.maxZoom : 0;
if (min || max) {
AreaExtensions.restrictor(area.current, {
scaling: {
min: min || 0.01,
max: max || 100
}
});
}
// ── snap-to-grid ──
if (typeof props.snapGrid === 'number' && props.snapGrid > 0) {
AreaExtensions.snapGrid(area.current, {
size: props.snapGrid,
dynamic: true
});
}
// ── interaction toggles ──
if (!props.pannable) area.current.area.setDragHandler(null);
if (!props.zoomable) area.current.area.setZoomHandler(null);
// ── Delete / Backspace key → cascading delete of the selected node(s) (Win 1) ──
// Attached to the engine container ($refs.canvasEl, which carries tabindex="0" in
// the template so it can receive key focus) rather than `document`: the listener
// lives INSIDE the Lit shadow root alongside the canvas, so a canvas-focused key
// reaches it on Lit too (a `:target="document"` listener does not reliably see
// shadow-scoped focus across all 6 — the canvas-element listener is the robust
// cross-target path). Gated on selectable && !readonly. We guard against deleting
// while focus is in a node-body text field (INPUT/TEXTAREA/contenteditable) so
// typing in a node never nukes it. The listener is removed in the teardown.
if (props.selectable && !props.readonly && container && typeof container.addEventListener === 'function') {
onCanvasKeydown.current = (e: any) => {
if (!e) return;
const t = e.target;
// Focus-guard (verbatim with the Delete branch): never act while focus is in a
// node-body text field (INPUT/TEXTAREA/contenteditable) — Ctrl+Z must reach the
// browser's native text undo there, and Delete must not nuke the node.
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
// ── T1.3 — Undo / Redo keybinds (D-02). Ctrl/Cmd+Z → undo; Ctrl/Cmd+Shift+Z and
// Ctrl/Cmd+Y → redo. Gated on the SAME focus-guard as Delete. preventDefault so the
// browser's page-level undo doesn't also fire. `metaKey` covers macOS Cmd. ──
if ((e.ctrlKey || e.metaKey) && !e.altKey) {
const k = typeof e.key === 'string' ? e.key.toLowerCase() : '';
if (k === 'z' && !e.shiftKey) {
e.preventDefault();
undo();
return;
}
if (k === 'z' && e.shiftKey || k === 'y') {
e.preventDefault();
redo();
return;
}
}
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
const ids = selectedNodeIds();
if (ids.length > 0) {
e.preventDefault();
for (const id of ids as any) deleteNode(id);
return;
}
// T1.1 — EDGE DELETE (D-08). No node is picked but an edge is selected → remove
// exactly that edge via the controlled-graph write-back (the disconnect path: a
// fresh `{ ...g, connections: filtered }` object), then clear the selection. The
// wrapper's own $watch(graph) reconcile reaps the live engine connection (the
// single removal path — we do NOT also call editor.removeConnection, which would
// race the reconcile into "cannot find connection", mirroring deleteNode). Node
// delete takes precedence (handled above); this only runs when nothing's picked.
if (selectedConnId.current != null) {
e.preventDefault();
const id = selectedConnId.current;
clearEdgeSelection();
writeBackConnectionRemoved(id);
}
};
keydownContainer.current = container;
container.addEventListener('keydown', onCanvasKeydown.current);
}
// ─────────────────────────────────────────────────────────────────────────
// THE VANILLA RENDER PIPE. Intercepts the AreaPlugin's render/unmount signals.
// ALWAYS returns context (returning undefined would halt the signal chain and
// break the ConnectionPlugin / socket watcher downstream).
// ─────────────────────────────────────────────────────────────────────────
area.current.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'render') {
const data = context.data;
if (data.type === 'node') renderNode(data.element, data.payload);else if (data.type === 'connection') renderConnection(data.element, data.payload, data.start, data.end);
// data.type === 'socket' (our own re-emitted signals) falls through
// untouched so the ConnectionPlugin + socketWatcher consume them.
} else if (context.type === 'unmount') {
cleanupElement(context.data.element);
}
return context;
});
// ── node renderer ──
// Fills the engine-created nodeView element with: input sockets, the body
// (consumer `node` portal fragment OR default chrome), and output sockets.
// Re-render (area.update('node', id)) reuses the same element → update in place.
// NOTE: the engine-node parameter is `reteNode`, NOT `node` — on Svelte the
// `$slots.node` slot lowers to a top-level `const node`, and a parameter named
// `node` here would SHADOW it, so `if ($slots.node)` would read the (always-
// truthy) engine node and wrongly take the portal branch even when the slot is
// unfilled (dropping the default-chrome title). The cross-target slot-name ==
// local-binding shadow trap.
const renderNode = (element: any, reteNode: any) => {
// a (re)render means node DOM exists / changed → refresh the minimap (its node
// rects measure these elements; coalesced, so calling it on every render is cheap,
// and it covers Lit's measure-after-first-paint).
if (scheduleMinimapRedraw.current) scheduleMinimapRedraw.current();
const id = reteNode.id;
const meta = nodeMeta.get(id) || {
id,
type: undefined,
data: {}
};
const existing = nodeEntries.get(id);
const selected = reteNode.selected === true;
// default-chrome fallback label (only when a node's type has no #body template).
const chromeLabel = meta.data && meta.data.label != null ? String(meta.data.label) : meta.type != null ? String(meta.type) : '';
if (existing && existing.element === element) {
// in-place update — refresh chrome + reactive portal scope, leave sockets.
existing.box.classList.toggle('is-selected', selected);
if (existing.handle) {
existing.handle.update({
node: meta,
selected,
emit: existing.emit
});
} else if (existing.titleEl) {
existing.titleEl.textContent = chromeLabel;
}
return;
}
// fresh build
element.innerHTML = '';
const box = document.createElement('div');
box.className = 'rozie-flow-node' + (selected ? ' is-selected' : '');
const body = document.createElement('div');
body.className = 'rozie-flow-node__body';
// ── socket layout (F2: position-aware) ───────────────────────────────────────
// Bucket the node's ports by VISUAL position (default input→left, output→right).
// When NO port is top/bottom (every pre-F2 graph), render the EXACT classic
// [inputsCol | body | outputsCol] 3-column structure — byte-identical DOM, so the
// FlowCanvasScreenshot pixel baseline is untouched. A node that declares ANY top/
// bottom port gets the 3-ROW structure (topRow / midRow[left|body|right] / bottomRow).
const socketDisposers = [];
const portEntries = [];
for (const key of Object.keys(reteNode.inputs) as any) portEntries.push({
side: 'input',
key,
position: resolvePortPosition(meta.type, 'input', key)
});
for (const key of Object.keys(reteNode.outputs) as any) portEntries.push({
side: 'output',
key,
position: resolvePortPosition(meta.type, 'output', key)
});
const hasVertical = portEntries.some((p: any) => p.position === 'top' || p.position === 'bottom');
if (!hasVertical) {
// CLASSIC left/right layout — byte-for-byte identical to pre-F2 (pixel-baseline safe).
const inputsCol = document.createElement('div');
inputsCol.className = 'rozie-flow-node__col rozie-flow-node__col--in';
const outputsCol = document.createElement('div');
outputsCol.className = 'rozie-flow-node__col rozie-flow-node__col--out';
box.appendChild(inputsCol);
box.appendChild(body);
box.appendChild(outputsCol);
element.appendChild(box);
for (const p of portEntries as any) {
renderSocketInto(p.position === 'right' ? outputsCol : inputsCol, reteNode, p.side, p.key, p.position, socketDisposers);
}
} else {
// VERTICAL-capable 3-row layout (only when a top/bottom port exists).
box.classList.add('rozie-flow-node--rows');
const topRow = document.createElement('div');
topRow.className = 'rozie-flow-node__row rozie-flow-node__row--top';
const midRow = document.createElement('div');
midRow.className = 'rozie-flow-node__mid';
const leftCol = document.createElement('div');
leftCol.className = 'rozie-flow-node__col rozie-flow-node__col--in';
const rightCol = document.createElement('div');
rightCol.className = 'rozie-flow-node__col rozie-flow-node__col--out';
const bottomRow = document.createElement('div');
bottomRow.className = 'rozie-flow-node__row rozie-flow-node__row--bottom';
midRow.appendChild(leftCol);
midRow.appendChild(body);
midRow.appendChild(rightCol);
box.appendChild(topRow);
box.appendChild(midRow);
box.appendChild(bottomRow);
element.appendChild(box);
for (const p of portEntries as any) {
const zone = p.position === 'top' ? topRow : p.position === 'bottom' ? bottomRow : p.position === 'right' ? rightCol : leftCol;
renderSocketInto(zone, reteNode, p.side, p.key, p.position, socketDisposers);
}
}
// emit per-node event helper handed to the slot scope so a consumer node body
// can raise a custom event carrying its id (e.g. a delete button).
const emit = (name: any, detail: any) => props.onNodeAction && props.onNodeAction({
id,
name,
detail
});
const entry = {
element,
box,
body,
handle: null,
bodyHandle: null,
titleEl: null,
bodyMoved: false,
emit,
socketDisposers
};
// ── RENDER-BY-TYPE: select the body by `node.type` ──────────────────────────
// 1) the node's TYPE template (typeReg[type].bodyRenderer) — the primary path
// (41-03 <NodeType><template #body>); 2) the low-level `#node` portal slot
// (consumer switches on node.type itself — escape hatch); 3) default chrome.
const typeSpec = meta.type != null ? _typeRegRef.current[meta.type] : null;
if (typeSpec && typeof typeSpec.bodyRenderer === 'function') {
// RENDER-BY-TYPE callback path. The <NodeType> cannot relocate its OWN <slot>
// across the Lit shadow boundary (Wave-0 A3), so the PARENT projects the body
// here from its own render scope: the type's registered bodyRenderer(host, scope)
// mounts the type's `#body` portal INTO the engine `body` div (a FRESH render
// root per node — no framework DOM relocation, the Phase-37 D-04 trap avoided).
// nodeEntries must exist before the callback runs (bodyHostFor reads it), so
// register first. The graph node's `data` flows in as scope → one template per
// type renders every instance of that type.
nodeEntries.set(id, entry);
entry.bodyHandle = typeSpec.bodyRenderer(body, {
node: meta,
selected,
emit
});
entry.bodyMoved = true;
return;
}
if ((props.renderNode ?? props.slots?.["node"])) {
// reactive multi-instance portal — one handle per node, re-rendered in
// place on meta change (the MapLibre marker discipline). Low-level escape
// hatch: the consumer switches on node.type inside the single `#node` slot.
entry.handle = portals.node(body, {
node: meta,
selected,
emit
});
} else {
// default chrome: a title bar (the type name / data.label).
const title = document.createElement('div');
title.className = 'rozie-flow-node__title';
title.textContent = chromeLabel;
body.appendChild(title);
entry.titleEl = title;
}
nodeEntries.set(id, entry);
};
// Render ONE socket into a zone and, crucially, EMIT its render signal so the
// ConnectionPlugin + position watcher register it. `position` is the socket's visual
// placement (left|right|top|bottom). For left/right the DOM is byte-identical to pre-F2
// (the classic horizontal port row); top/bottom get a vertical port (socket above its
// label) + a `--<position>` socket class so the socket straddles the matching edge.
const renderSocketInto = (zone: any, reteNode: any, side: any, key: any, position: any, socketDisposers: any) => {
const port = (side === 'input' ? reteNode.inputs : reteNode.outputs)[key];
if (!port) return;
const vertical = position === 'top' || position === 'bottom';
const row = document.createElement('div');
row.className = 'rozie-flow-port rozie-flow-port--' + side + (vertical ? ' rozie-flow-port--vertical' : '');
const socketEl = document.createElement('div');
socketEl.className = 'rozie-flow-socket rozie-flow-socket--' + side + (vertical ? ' rozie-flow-socket--' + position : '');
socketEl.setAttribute('data-testid', 'socket');
const label = document.createElement('span');
label.className = 'rozie-flow-port__label';
label.textContent = port.label != null ? String(port.label) : key;
// CLASSIC: inputs socket-first, outputs label-first (byte-identical to pre-F2).
// VERTICAL: socket-first (the socket sits on the edge, label tucked inward).
if (side === 'input' || vertical) {
row.appendChild(socketEl);
row.appendChild(label);
} else {
row.appendChild(label);
row.appendChild(socketEl);
}
zone.appendChild(row);
// LOAD-BEARING: announce the socket to the rest of the area's child plugins.
// 'render' lets the ConnectionPlugin register the socket as a drag anchor.
area.current.emit({
type: 'render',
data: {
type: 'socket',
side,
key,
nodeId: reteNode.id,
element: socketEl,
payload: {
socket: port.socket
}
}
});
// ALSO LOAD-BEARING (the socket-position contract): getDOMSocketPosition measures +
// stores a socket's DOM position ONLY on a 'rendered' socket signal — the render-plugin
// lifecycle's post-mount phase. Our vanilla pipe creates + appends the socket DOM
// synchronously, so we fire 'rendered' right after 'render'. WITHOUT IT the position
// store stays empty, every socketWatcher.listen() callback reads null, and NO
// connection path (committed OR drag preview) is ever drawn.
area.current.emit({
type: 'rendered',
data: {
type: 'socket',
side,
key,
nodeId: reteNode.id,
element: socketEl,
payload: {
socket: port.socket
}
}
});
socketDisposers.push(() => {
area.current.emit({
type: 'unmount',
data: {
element: socketEl
}
});
});
};
// ── hand-written edge-type path generators (T1.2, D-01) ───────────────────────
// `rete-render-utils` ships ONLY `classicConnectionPath` (bezier) + `loopConnectionPath`;
// step/smoothstep/straight do NOT exist in any installed rete package, so they are
// hand-written here matching React-Flow's `step|smoothstep|straight` semantics. Each is a
// PURE `(start, end) → d-string` function over `{x,y}` graph-screen points; the `d` is
// composed from numeric coords + literal SVG commands and written via setAttribute (never
// innerHTML — no injection, T-44-02-2 accept). The default branch stays
// `classicConnectionPath` → byte-identical bezier (pixel-baseline safe).
// straight: a single line, no curvature.
const straightPath = (s: any, e: any) => `M ${s.x} ${s.y} L ${e.x} ${e.y}`;
// step: orthogonal HV-VH with a mid-X break.
const stepPath = (s: any, e: any) => {
const mx = (s.x + e.x) / 2;
return `M ${s.x} ${s.y} L ${mx} ${s.y} L ${mx} ${e.y} L ${e.x} ${e.y}`;
};
// smoothstep: step with rounded corners (radius r, clamped to half the shorter leg).
const smoothstepPath = (s: any, e: any, r = 8) => {
const mx = (s.x + e.x) / 2;
const dir = e.y >= s.y ? 1 : -1;
const rr = Math.min(r, Math.abs(mx - s.x), Math.abs(e.y - s.y) / 2);
return [`M ${s.x} ${s.y}`, `L ${mx - rr} ${s.y}`, `Q ${mx} ${s.y} ${mx} ${s.y + dir * rr}`, `L ${mx} ${e.y - dir * rr}`, `Q ${mx} ${e.y} ${mx + rr} ${e.y}`, `L ${e.x} ${e.y}`].join(' ');
};
// ── connection renderer ──
// Mounts an <svg><path> and redraws it whenever either endpoint socket moves
// (real connection) OR the dragged pointer moves (user drag-to-connect pseudo).
//
// A USER DRAG renders a *pseudo-connection* (rete-connection-plugin): the render
// signal carries a literal pointer coordinate (`endPointer`/`data.end` when
// dragging FROM an output, `startPointer`/`data.start` when dragging FROM an
// input) alongside a payload with ONE DANGLING endpoint — `target:''`/
// `targetInput:''` (output-side drag) or `source:''`/`sourceOutput:''`
// (input-side drag). The dangling side has no socket to watch, so its coordinate
// MUST come from the pointer; the live side stays watcher-driven. The
// ConnectionPlugin re-emits this render on EVERY pointermove with a fresh pointer
// — so the same pseudo element is re-rendered repeatedly and the dangling
// coordinate must update in place (no SVG rebuild, no listener re-subscribe).
const renderConnection = (element: any, connection: any, startPointer: any, endPointer: any) => {
const id = connection.id;
// A side is dangling when its node id OR its port key is empty/nullish.
const srcDangling = !connection.source || !connection.sourceOutput;
const tgtDangling = !connection.target || !connection.targetInput;
// RE-RENDER of the SAME element (the pseudo on each pointermove): do NOT rebuild
// the SVG or re-subscribe listeners (would leak) — just update the dangling
// side's coordinate and redraw. This replaces the old unconditional early-return
// that froze the preview line. For a REAL connection updatePointer is a no-op,
// so a re-render of a committed edge is byte-for-byte the old early-return.
const prev = connEntries.get(id);
if (prev && prev.element === element) {
prev.updatePointer(startPointer, endPointer);
return;
}
element.innerHTML = '';
element.classList.add('rozie-flow-connection');
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'rozie-flow-connection__svg');
// ── direction arrowhead (Win 3) ─────────────────────────────────────────────
// A <defs><marker> in THIS connection's own <svg>, referenced by `marker-end` so
// the triangle sits at the path END (the input socket — the path runs output→input,
// so marker-end points INTO the target). The marker id is UNIQUE per connection
// (`rozie-arrow-<id>`) so two edges' markers never collide on a shared document id
// (url(#id) resolves to the first match otherwise). The def lives in the SAME
// per-edge <svg> inside the SAME shadow root as the path, so url(#id) resolves
// within that root — no cross-root reference (Lit-safe). markerUnits="userSpaceOnUse"
// keeps a constant pixel size under the area zoom transform. Inline fill (#64748b,
// matching the connection stroke) is the cross-target-safe choice — no scoped-CSS /
// :root rule needed for the marker DOM. The marker does NOT change the path `d`
// or the socket geometry (the rete-flow-align cell stays green) — redraw() only
// sets the head's `orient` and a `stroke-dasharray` that visually trims the last
// ARROW_LEN of the stroke so the line meets the head without poking through it.
const markerId = 'rozie-arrow-' + String(id);
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
marker.setAttribute('id', markerId);
// Sized in userSpaceOnUse (constant pixels under zoom). A 12×10 head reads
// clearly at default zoom (the old 6×6 was barely visible). refX=12 sits the
// TIP exactly at the path-end vertex (the socket); refY=5 centers it. `orient`
// is recomputed per-redraw from the path's final-segment tangent, and the
// visible stroke is trimmed back to the arrow base, so the head points along
// the edge's actual approach AND the line meets it cleanly — see redraw().
marker.setAttribute('markerWidth', '13');
marker.setAttribute('markerHeight', '10');
marker.setAttribute('refX', '12');
marker.setAttribute('refY', '5');
marker.setAttribute('orient', 'auto');
marker.setAttribute('markerUnits', 'userSpaceOnUse');
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
arrow.setAttribute('class', 'rozie-flow-connection__arrow');
arrow.setAttribute('d', 'M0,0 L12,5 L0,10 Z');
arrow.setAttribute('fill', '#64748b');
marker.appendChild(arrow);
defs.appendChild(marker);
svg.appendChild(defs);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('class', 'rozie-flow-connection__path');
path.setAttribute('marker-end', 'url(#' + markerId + ')');
svg.appendChild(path);
// ── T1.1 edge-select listener (D-08) ─────────────────────────────────────────
// Attach an IMPERATIVE pointerup listener on the engine-DOM <path> (NOT a template
// `@` — the path is engine-created; NOT click — Rete swallows it; NOT pointerdown —
// Rete stopPropagations it: the Phase-41 connector landmine, playbook §6a item 7).
// Gated on `selectable && !readonly` (mirrors node delete) and ONLY for COMMITTED
// edges — a drag-to-connect pseudo (either side dangling) carries no stable id and
// must not be selectable. `selectEdge` reads the id back off the closure (the
// committed connection.id == the graph connection id — conn.id = spec.id at build),
// so it always matches what `writeBackConnectionRemoved` filters. `.stop` keeps the
// pointerup from reaching the area's pan/background handling beneath the path.
if (props.selectable && !props.readonly && !srcDangling && !tgtDangling) {
path.style.cursor = 'pointer';
path.addEventListener('pointerup', (e: any) => {
if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
selectEdge(connection.id, path);
});
}
// ── per-edge label + styling (F3) ────────────────────────────────────────────
// The consumer's connection spec ({ id, source, …, label?, stroke?, dashed? }) is kept
// in connMeta keyed by id (the connection-side analog of nodeMeta). A committed edge
// resolves its label/style here; a drag-preview pseudo (no committed id) has none.
// Styling is applied as INLINE attributes (the arrowhead-marker discipline — engine DOM
// carries no scope attr); a `label` renders an SVG <text> at the path midpoint (white
// halo via paint-order for legibility over the line), repositioned in redraw().
const emeta = connMeta.get(connection.id) || null;
if (emeta) {
if (emeta.stroke != null) {
const s = String(emeta.stroke);
path.setAttribute('stroke', s);
arrow.setAttribute('fill', s);
}
if (emeta.dashed === true) path.setAttribute('stroke-dasharray', '7 5');
}
// ── resolved edge type (T1.2) ────────────────────────────────────────────────
// The consumer-supplied `connection.type` selects a path generator. ALLOWLIST it
// (`bezier|step|smoothstep|straight`); any other/absent value falls through to the
// bezier default — no dynamic path-fn lookup keyed on the raw string, no eval
// (T-44-02-1 mitigate). A dangling drag-preview pseudo has no committed connMeta
// entry, so it stays bezier too.
const rawType = emeta && emeta.type != null ? String(emeta.type) : 'bezier';
const edgeType = rawType === 'step' || rawType === 'smoothstep' || rawType === 'straight' ? rawType : 'bezier';
// Arrowhead geometry (redraw): the head is oriented along the path's tangent
// over its LAST `ARROW_LEN` (angled for a descending edge, aligned with where
// the line actually meets the head — unlike the chord, which diverges from the
// bezier's flattened end tangent), and the visible stroke is trimmed back to
// the arrow base on SOLID edges so the line's width can't poke past the
// tapering tip (the "square tip"). Dashed edges keep their pattern untrimmed.
const ARROW_LEN = 12;
const isDashed = !!(emeta && emeta.dashed === true);
let labelEl: any = null;
const edgeLabel = emeta && emeta.label != null ? String(emeta.label) : null;
if (edgeLabel) {
labelEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
labelEl.setAttribute('class', 'rozie-flow-connection__label');
labelEl.setAttribute('text-anchor', 'middle');
labelEl.setAttribute('dominant-baseline', 'middle');
labelEl.textContent = edgeLabel;
svg.appendChild(labelEl);
}
element.appendChild(svg);
let start: any = null;
let end: any = null;
const curvature = typeof props.curvature === 'number' ? props.curvature : 0.3;
const redraw = () => {
if (!start || !end) return;
// branch on the resolved edge type; default (bezier/unknown) stays
// classicConnectionPath UNCHANGED → byte-identical bezier output.
const d = edgeType === 'step' ? stepPath(start, end) : edgeType === 'smoothstep' ? smoothstepPath(start, end) : edgeType === 'straight' ? straightPath(start, end) : classicConnectionPath([start, end], curvature);
path.setAttribute('d', d);
// Orient the head and trim the visible stroke back to the arrow base (solid
// edges) so the line meets the head without poking through the tip.
// getTotalLength/getPointAtLength are SVGGeometryElement methods unavailable
// in a non-rendering env (jsdom) → guard and fall back to orient='auto' / untrimmed.
let pathLen = 0;
try {
pathLen = path.getTotalLength();
} catch (e: any) {
pathLen = 0;
}
if (pathLen > ARROW_LEN + 1) {
// BACKWARD edge (target socket left of the source socket): the classic
// bezier overshoots both control points, looping the curve into tight
// u-turns right at the sockets, so a sampled local tangent is unstable and
// the head curls. Use the path's TRUE end tangent (orient='auto' — the
// horizontal entry into the input) for a stable, standard arrow. FORWARD
// edges keep the final-ARROW_LEN tangent, which follows a descending edge
// AND aligns with where the line meets the head.
if (end.x < start.x) {
marker.setAttribute('orient', 'auto');
} else {
const tip = path.getPointAtLength(pathLen);
const back = path.getPointAtLength(pathLen - ARROW_LEN);
marker.setAttribute('orient', String(Math.atan2(tip.y - back.y, tip.x - back.x) * 180 / Math.PI));
}
if (!isDashed) path.setAttribute('stroke-dasharray', pathLen - ARROW_LEN + ' ' + pathLen);
} else {
marker.setAttribute('orient', 'auto');
if (!isDashed) path.removeAttribute('stroke-dasharray');
}
if (labelEl) {
labelEl.setAttribute('x', String((start.x + end.x) / 2));
labelEl.setAttribute('y', String((start.y + end.y) / 2));
}
};
// Seed the DANGLING side's coordinate from the pointer FIRST — socketWatcher
// .listen() synchronously replays the current socket snapshot on subscribe, so
// seeding before subscribing the live side means redraw() already has the
// dangling coordinate and the preview line draws immediately on the first render.
if (srcDangling && startPointer) start = startPointer;
if (tgtDangling && endPointer) end = endPointer;
// LIVE endpoints stay watcher-driven (exactly as before the fix — committed
// connections behave byte-for-byte). DANGLING endpoints subscribe NO listener
// (it would never fire — there is no socket); their coordinate is the pointer.
let un1: any = null;
let un2: any = null;
if (!srcDangling) un1 = socketWatcher.current.listen(connection.source, 'output', connection.sourceOutput, (p: any) => {
start = p;
redraw();
});
if (!tgtDangling) un2 = socketWatcher.current.listen(connection.target, 'input', connection.targetInput, (p: any) => {
end = p;
redraw();
});
// Update only the DANGLING side(s) from a fresh pointer on each subsequent
// render call. For a REAL connection (neither side dangling) this is a no-op,
// so committed connections never have a pointer override and keep behaving
// exactly as before.
const updatePointer = (sp: any, ep: any) => {
let moved = false;
if (srcDangling && sp) {
start = sp;
moved = true;
}
if (tgtDangling && ep) {
end = ep;
moved = true;
}
if (moved) redraw();
};
// Draw once now: a pseudo seeded with an initial pointer (+ its live side
// already replayed) draws immediately; a real connection whose sockets are
// already known also draws (idempotent — same `d` the listeners just set).
redraw();
connEntries.set(id, {
element,
updatePointer,
dispose: () => {
try {
un1 && un1();
} catch (e: any) {}
try {
un2 && un2();
} catch (e: any) {}
}
});
};
// ── unmount cleanup (keyed by the engine element area hands back) ──
const cleanupElement = (element: any) => {
for (const [id, entry] of nodeEntries as any) {
if (entry.element === element) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
nodeEntries.delete(id);
return;
}
}
for (const [id, entry] of connEntries as any) {
if (entry.element === element) {
entry.dispose();
connEntries.delete(id);
return;
}
}
};
// Resolve a single port's TYPE for the validation pipe: look up the live node's
// `type` (via nodeMeta) then the portReg entry keyed `type::side::key`. Returns the
// portType string or null (null on either side ⇒ no type constraint ⇒ allow). DEFINED
// HERE (inside $onMount) — NOT at top level — so its $data.portReg read lowers on React
// to the live `_portRegRef.current` rather than a stale-empty closure snapshot captured
// when this once-only mount effect first ran (the cross-type-reject-didn't-fire bug).
const portTypeOf = (nodeId: any, side: any, key: any) => {
const meta = nodeMeta.get(nodeId);
if (!meta || meta.type == null || key == null) return null;
const entry = _portRegRef.current[meta.type + '::' + side + '::' + key];
return entry ? entry.portType : null;
};
// ─── connection-validation gate (D2/D3 — typed-socket validation + override) ──
// Cancels Rete's cancellable `connectioncreate` pre-event when the connection is
// rejected. TWO independent reject paths, both surfacing `connection-rejected`:
// 1. AUTOMATIC typed validation (`:validate-types`, default ON, D3 option a):
// resolve src/tgt port TYPE from the per-TYPE port schema (via each endpoint
// node's `type`); if both are non-null and UNEQUAL → reject. A null on either
// side (untyped port / unknown type) imposes no constraint → allow.
// 2. `canConnect` OVERRIDE (Phase-40 contract, SURVIVES): a consumer custom rule;
// runs IN ADDITION to (after) the automatic check; returning false rejects.
// Cancelling makes editor.addConnection return false WITHOUT pushing the connection
// or emitting `connectioncreated` — no ghost edge, no `connection-created`. Gates
// drag-to-connect, imperative addConnection, and reconcile uniformly. Both predicates
// are PURE (no $data write / engine call) — reads only. The block (return undefined)
// stays UNCONDITIONAL so rejection is enforced on every path; only the EMIT is
// echo-guarded (a programmatic reconcile the rule would reject must not surface as a
// user-facing rejection — mirrors connection-created/connection-removed).
editor.current.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectioncreate') {
const c = context.data;
// ClassicPreset.Connection fields: { id, source, sourceOutput, target, targetInput }.
// Same shape as serializeConn minus the engine-assigned `id` (never created).
const conn = {
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
};
// 1. AUTOMATIC typed validation (default ON; opt out via :validate-types="false").
if (props.validateTypes !== false) {
const srcType = portTypeOf(c.source, 'output', c.sourceOutput);
const tgtType = portTypeOf(c.target, 'input', c.targetInput);
if (srcType != null && tgtType != null && srcType !== tgtType) {
if (!programmatic.current) props.onConnectionRejected && props.onConnectionRejected(conn);
return undefined; // ← CANCEL: type mismatch
}
}
// 2. canConnect OVERRIDE (Phase-40 contract — custom rule, in addition).
if (typeof props.canConnect === 'function' && props.canConnect(conn) === false) {
if (!programmatic.current) props.onConnectionRejected && props.onConnectionRejected(conn);
return undefined; // ← CANCEL: Signal.emit halts, addConnection returns false
}
}
return context;
});
// ─── forward engine events (echo-guarded via `programmatic`) ───────────────
editor.current.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectioncreated') {
// keep engine truth in sync so reconcile diffs correctly — a user-drawn
// connection (auto id) must register here or the next graph pass re-adds it.
connInstances.set(context.data.id, context.data);
if (!programmatic.current) {
// WRITE-BACK: append the new connection into a fresh graph object (D4).
writeBackConnectionCreated(context.data);
// keep the discrete event too (back-compat).
props.onConnectionCreated && props.onConnectionCreated(serializeConn(context.data));
}
} else if (context.type === 'connectionremoved') {
connInstances.delete(context.data.id);
connMeta.delete(context.data.id);
if (!programmatic.current) {
// WRITE-BACK: filter the removed connection out of a fresh graph object (D4).
writeBackConnectionRemoved(context.data.id);
props.onConnectionRemoved && props.onConnectionRemoved({
id: context.data.id
});
}
}
return context;
});
area.current.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'nodepicked') {
props.onNodePicked && props.onNodePicked({
id: context.data.id
});
// T1.3 — pointer-DOWN: stash the PRE-drag graph snapshot (before any movement). It
// is committed to history on the first `nodetranslated` (only if a drag follows;
// gated on !programmatic + history). A re-pick mid-drag won't overwrite a live one.
if (!programmatic.current && props.history !== false && !dragGestureActive.current) {
pendingDragSnapshot.current = snapshotCurrent();
}
// Win 2: a pick changed the selection — surface @selection-change after the
// engine's awaited select() for THIS pick has flushed the selector entities.
scheduleSelectionEmit();
} else if (context.type === 'pointerup') {
// Win 2: AreaExtensions.selectableNodes UNSELECTS all on a click-like background
// pointerUP (its `twitch < 4` deselect — NOT on pointerdown, verified against
// rete-area-plugin's selectable pipe). Its unselectAll() is async and its pipe
// runs before ours, so recompute AFTER its awaited unselectAll() flushes (the
// microtask + rAF schedule). The dedup makes a no-op when nothing changed (e.g. a
// pointerup that ended a node pick — already surfaced by the nodepicked branch).
scheduleSelectionEmit();
// T1.3 — a pointerup ends any in-progress drag gesture, so the NEXT drag pushes a
// fresh history snapshot (one gesture = one undo step, D-03). Drop any stashed
// pre-drag snapshot that was never committed (a pick with no drag).
dragGestureActive.current = false;
pendingDragSnapshot.current = null;
// T1.1: a background pointerup (anywhere not on a connection path) clears the edge
// selection — UNLESS this same gesture just selected an edge (the path's own
// pointerup ran in the same tick and raised `edgeClickGuard`; the guard self-resets
// on the next microtask). Mirrors the node selectable's click-to-deselect.
if (!edgeClickGuard.current && selectedConnId.current != null) clearEdgeSelection();
} else if (context.type === 'nodetranslated') {
if (!programmatic.current) {
const id = context.data.id;
const pos = context.data.position;
const meta = nodeMeta.get(id);
if (meta) {
meta.x = pos.x;
meta.y = pos.y;
}
// T1.3 — commit ONE history snapshot per drag gesture, at its FIRST translate:
// the pre-move snapshot stashed on nodepicked (a drag truly happened now, not just
// a pick). dragGestureActive holds until the drag-ending pointerup resets it, so a
// continuous drag = ONE undo step (D-03).
if (!dragGestureActive.current) {
dragGestureActive.current = true;
if (pendingDragSnapshot.current) {
pushHistorySnapshot(pendingDragSnapshot.current);
pendingDragSnapshot.current = null;
}
}
// WRITE-BACK (coalesced): accumulate the latest position for this node and
// flush ONE fresh graph object per animation frame (Pitfall 2 — the drag
// storm). The discrete `node-moved` emit stays per-translate (back-compat).
pendingDragPositions.set(id, {
x: pos.x,
y: pos.y
});
scheduleDragFlush();
props.onNodeMoved && props.onNodeMoved({
id,
x: pos.x,
y: pos.y
});
}
// a node moved → its minimap rect moves (works during a programmatic translate too).
if (scheduleMinimapRedraw.current) scheduleMinimapRedraw.current();
// T2.8 — the selected node moved → re-track its toolbar overlay (no-op if off).
if (scheduleToolbarTrack.current) scheduleToolbarTrack.current();
} else if (context.type === 'translated') {
props.onTranslated && props.onTranslated({
x: context.data.position.x,
y: context.data.position.y
});
// the viewport window moved → redraw the minimap viewport rect + mask.
if (scheduleMinimapRedraw.current) scheduleMinimapRedraw.current();
// T2.8 — a pan shifts the node's screen rect → re-track the toolbar (no-op if off).
if (scheduleToolbarTrack.current) scheduleToolbarTrack.current();
} else if (context.type === 'zoomed') {
if (!programmatic.current) {
const k = area.current.area.transform.k;
if (k !== _zoomRef.current) setZoom(k);
}
// the viewport window resized (zoom) → redraw the minimap viewport rect + mask.
if (scheduleMinimapRedraw.current) scheduleMinimapRedraw.current();
// T2.8 — a zoom changes the node's screen rect/size → re-track the toolbar (no-op if off).
if (scheduleToolbarTrack.current) scheduleToolbarTrack.current();
} else if (context.type === 'contextmenu') {
// suppress the native browser menu over the canvas; surface a hook instead.
context.data.event.preventDefault();
const ctx = context.data.context;
props.onContextMenu && props.onContextMenu({
id: ctx && ctx.id ? ctx.id : null
});
}
return context;
});
// ─── reconciler off the bound graph, bridged to the top-level $watch ──────────
// Nodes come ONLY from `$props.graph.nodes` (the single source of truth, D1/D2);
// sockets come from each node's TYPE port schema (portReg keyed `type::side::key`).
// A port-schema change ($data.portReg, when a <Port> registers late on Lit) ALSO
// drives this reconcile so a node whose type just gained ports re-renders. An
// imperative $expose addNode (provenance NOT in lastPropNodeIds) survives the reaper.
// Wrapped by reconcileNodes (below) with a re-entrancy guard so two passes never
// race the engine (the Lit "cannot find node" fix).
const reconcileNodesPass = async () => {
if (!editor.current || !area.current) return;
const graphNodes = Array.isArray(_graphRef.current && _graphRef.current.nodes) ? _graphRef.current.nodes : [];
const want = [];
programmatic.current++;
try {
for (const spec of graphNodes as any) {
if (!spec || spec.id == null) continue;
want.push(spec.id);
nodeMeta.set(spec.id, spec);
let node = nodeInstances.get(spec.id);
if (!node) {
node = buildNode(spec, _portRegRef.current);
nodeInstances.set(spec.id, node);
await editor.current.addNode(node);
await area.current.translate(spec.id, {
x: spec.x || 0,
y: spec.y || 0
});
} else {
// Sync any ports this node's TYPE gained AFTER the node was first built —
// a nested <Port>'s addTypePort can land after reconcileNodes already
// created the node (the node registered before its ports on some targets,
// or a <Port> registered late on Lit). buildNode only runs for NEW nodes,
// so add the missing inputs/outputs onto the live instance here from the
// TYPE schema, then re-render.
let portsAdded = false;
const {
inputs: wantIn,
outputs: wantOut
} = portSchemaForType(spec.type, _portRegRef.current);
for (const inp of wantIn as any) {
if (!inp || inp.key == null || node.inputs[inp.key]) continue;
node.addInput(inp.key, new ClassicPreset.Input(SOCKET, inp.label, inp.multiple === true));
portsAdded = true;
}
for (const out of wantOut as any) {
if (!out || out.key == null || node.outputs[out.key]) continue;
node.addOutput(out.key, new ClassicPreset.Output(SOCKET, out.label, out.multiple !== false));
portsAdded = true;
}
const view = area.current.nodeViews.get(spec.id);
if (view && spec.x != null && spec.y != null && (view.position.x !== spec.x || view.position.y !== spec.y)) {
await area.current.translate(spec.id, {
x: spec.x,
y: spec.y
});
}
if (portsAdded) {
// renderNode's in-place branch deliberately leaves existing sockets
// untouched; to render the NEW sockets, drop this node's render entry so
// area.update takes the fresh-build path (re-runs buildSocketRow + re-
// emits the socket render signals the ConnectionPlugin/watcher need). The
// render-by-type body host is re-projected by the type's bodyRenderer
// (mounts a fresh portal root into the same host — idempotent).
const entry = nodeEntries.get(spec.id);
if (entry) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
nodeEntries.delete(spec.id);
}
}
await area.current.update('node', spec.id);
// a port change must re-run connections — an edge that was skipped because
// its endpoint port didn't exist yet can now be drawn.
if (portsAdded && reconcileConnections.current) await reconcileConnections.current();
}
}
// remove dropped GRAPH-managed nodes (+ their connections) — imperatively added
// nodes (NOT in lastPropNodeIds) survive (the power-user escape hatch).
const tracked = new Set(lastPropNodeIds.current);
for (const id of tracked as any) {
if (!want.includes(id) && nodeInstances.has(id)) {
for (const c of editor.current.getConnections() as any) {
if (c.source === id || c.target === id) await editor.current.removeConnection(c.id);
}
await editor.current.removeNode(id);
nodeInstances.delete(id);
nodeMeta.delete(id);
}
}
lastPropNodeIds.current = want;
} finally {
programmatic.current--;
}
};
// Re-entrancy-guarded entry point. If a pass is already running, mark a re-run and
// return — the in-flight pass loops until no further request is pending. Serializing
// overlapping reconciles is what stops the Lit async-context cascade from racing the
// engine into "cannot find node" (which otherwise aborts the declarative graph build).
reconcileNodes.current = async () => {
if (reconcileNodesRunning.current) {
reconcileNodesPending.current = true;
return;
}
reconcileNodesRunning.current = true;
try {
do {
reconcileNodesPending.current = false;
await reconcileNodesPass();
} while (reconcileNodesPending.current);
} finally {
reconcileNodesRunning.current = false;
}
};
reconcileConnections.current = async () => {
if (!editor.current) return;
// Edges come ONLY from the bound graph's `connections` (the single source of
// truth — declarative <Connection> children are gone). Normalize id-defaulting
// (a connection authored without an id gets a stable derived id) so an edge the
// canvas wrote back (carrying the engine id) and a hand-authored edge dedup.
const graphConns = Array.isArray(_graphRef.current && _graphRef.current.connections) ? _graphRef.current.connections : [];
const norm = (spec: any) => {
if (!spec || spec.source == null || spec.target == null) return null;
const srcOut = spec.sourceOutput != null ? spec.sourceOutput : 'out';
const tgtIn = spec.targetInput != null ? spec.targetInput : 'in';
const id = spec.id != null ? spec.id : `${spec.source}:${srcOut}->${spec.target}:${tgtIn}`;
// carry the optional per-edge label/style (F3) through to connMeta → renderConnection.
return {
id,
source: spec.source,
sourceOutput: srcOut,
target: spec.target,
targetInput: tgtIn,
label: spec.label,
stroke: spec.stroke,
dashed: spec.dashed,
type: spec.type
};
};
// cheap style signature so a label/style/type change on an EXISTING edge re-renders it.
const edgeStyleSig = (s: any) => s ? String(s.label) + '|' + String(s.stroke) + '|' + String(s.dashed) + '|' + String(s.type) : '';
const merged = graphConns.map(norm).filter(Boolean);
const want = [];
programmatic.current++;
try {
for (const spec of merged as any) {
if (!spec || spec.id == null) continue;
want.push(spec.id);
if (connInstances.has(spec.id)) {
// existing edge — relabel/restyle in place if its label/style changed (the
// controlled-graph expectation: edit the bound graph → see the change). Drop the
// render entry so area.update takes the fresh-build path (re-applies label/style).
const changed = edgeStyleSig(connMeta.get(spec.id)) !== edgeStyleSig(spec);
connMeta.set(spec.id, spec);
if (changed) {
const entry = connEntries.get(spec.id);
if (entry) {
entry.dispose();
connEntries.delete(spec.id);
}
await area.current.update('connection', spec.id);
}
continue;
}
const sourceNode = nodeInstances.get(spec.source);
const targetNode = nodeInstances.get(spec.target);
if (!sourceNode || !targetNode) continue;
// DEFENSIVE: the referenced output/input ports must exist on the live node
// instances before addConnection (Rete throws "source node doesn't have
// output with a key out" otherwise, aborting the loop). An edge may reference
// a port the node's TYPE schema has not flushed yet (a <Port> registered
// after the <NodeType>); skip until the ports exist — reconcileNodes re-runs
// reconcileConnections after a port-schema change, so the edge lands later.
if (!sourceNode.outputs || !sourceNode.outputs[spec.sourceOutput]) continue;
if (!targetNode.inputs || !targetNode.inputs[spec.targetInput]) continue;
const conn = new ClassicPreset.Connection(sourceNode, spec.sourceOutput, targetNode, spec.targetInput);
conn.id = spec.id;
connInstances.set(spec.id, conn);
// seed connMeta BEFORE addConnection so renderConnection sees the label/style on
// its first render (the render fires synchronously inside addConnection's pipe).
connMeta.set(spec.id, spec);
await editor.current.addConnection(conn);
}
// remove dropped GRAPH-managed edges — imperatively added edges survive.
const tracked = new Set(lastPropConnIds.current);
for (const id of tracked as any) {
if (!want.includes(id) && connInstances.has(id)) {
await editor.current.removeConnection(id);
connInstances.delete(id);
connMeta.delete(id);
}
}
lastPropConnIds.current = want;
} finally {
programmatic.current--;
}
};
// ─── built-in MiniMap (opt-in :minimap, Phase 42) ────────────────────────────
// An absolute light-DOM SVG overlay (bottom-right) showing a scaled map of every
// node + the current viewport window (outside dimmed), PANNABLE (drag recenters via
// setCenter). The host div is COMPONENT-template DOM (carries the [data-rozie-s-*]
// scope attr → plain scoped CSS positions it); its SVG children are built
// IMPERATIVELY with createElementNS (the connection-renderer discipline) so SVG
// namespacing is identical on all 6 (no SVG-in-template cross-target risk) and styled
// with INLINE attributes (the arrowhead-marker lesson — no scoped-CSS / :root rule
// needed for engine-style DOM). Node dims come from the MEASURED engine node-view
// elements (area.nodeViews.get(id).element offsetW/H — target-agnostic, like the
// render pipe) with a default-rect fallback for Lit's unmeasured first paint.
const measureNodeSize = (id: any) => {
const view = area.current && area.current.nodeViews ? area.current.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
const w = el && el.offsetWidth ? el.offsetWidth : MINIMAP_DEFAULT_NODE_W;
const h = el && el.offsetHeight ? el.offsetHeight : MINIMAP_DEFAULT_NODE_H;
return {
w,
h
};
};
const mkMinimapRect = (x: any, y: any, w: any, h: any, cls: any, fill: any, stroke: any, strokeW: any) => {
const r = document.createElementNS(SVGNS, 'rect');
r.setAttribute('class', cls);
r.setAttribute('x', String(x));
r.setAttribute('y', String(y));
r.setAttribute('width', String(Math.max(w, 0)));
r.setAttribute('height', String(Math.max(h, 0)));
if (fill) r.setAttribute('fill', fill);
if (stroke) {
r.setAttribute('stroke', stroke);
r.setAttribute('stroke-width', String(strokeW || 1));
}
return r;
};
// Rebuild the minimap SVG: node rects (selected highlighted) + a dim mask outside the
// viewport (evenodd punch-out) + the viewport window outline. The bounds union the
// node rects AND the viewport window so the viewport indicator stays in-frame even
// when panned past the nodes. Stores `minimapMap` (the px↔graph mapping the pointer-
// pan handlers read). Cheap (a handful of rects) → a full rebuild per frame is fine.
const redrawMinimap = () => {
minimapRedrawRaf.current = 0;
if (!props.minimap || !minimapSvg.current || !area.current || !container) return;
const t = area.current.area.transform;
const k = t.k || 1;
const cw = container.clientWidth || MINIMAP_W;
const ch = container.clientHeight || MINIMAP_H;
// viewport window in GRAPH coords (screen [0,cw]×[0,ch] → graph).
const vx = -t.x / k,
vy = -t.y / k,
vw = cw / k,
vh = ch / k;
const graphNodes = currentGraph().nodes || [];
const selIds = new Set(selectedNodeIds().map((s: any) => String(s)));
const rects = [];
for (const n of graphNodes as any) {
if (!n || n.id == null) continue;
const view = area.current.nodeViews.get(n.id);
const gx = view ? view.position.x : n.x || 0;
const gy = view ? view.position.y : n.y || 0;
const sz = measureNodeSize(n.id);
rects.push({
gx,
gy,
gw: sz.w,
gh: sz.h,
selected: selIds.has(String(n.id))
});
}
let minX = vx,
minY = vy,
maxX = vx + vw,
maxY = vy + vh;
for (const r of rects as any) {
if (r.gx < minX) minX = r.gx;
if (r.gy < minY) minY = r.gy;
if (r.gx + r.gw > maxX) maxX = r.gx + r.gw;
if (r.gy + r.gh > maxY) maxY = r.gy + r.gh;
}
const padX = (maxX - minX) * 0.1 || 20;
const padY = (maxY - minY) * 0.1 || 20;
minX -= padX;
minY -= padY;
maxX += padX;
maxY += padY;
const bw = maxX - minX || 1;
const bh = maxY - minY || 1;
const scale = Math.min(MINIMAP_W / bw, MINIMAP_H / bh);
const offX = (MINIMAP_W - bw * scale) / 2;
const offY = (MINIMAP_H - bh * scale) / 2;
minimapMap.current = {
minX,
minY,
scale,
offX,
offY
};
const toMMx = (gx: any) => (gx - minX) * scale + offX;
const toMMy = (gy: any) => (gy - minY) * scale + offY;
minimapSvg.current.innerHTML = '';
for (const r of rects as any) {
const fill = r.selected ? '#3b82f6' : '#94a3b8';
minimapSvg.current.appendChild(mkMinimapRect(toMMx(r.gx), toMMy(r.gy), r.gw * scale, r.gh * scale, 'rozie-flow-minimap__node', fill, null, 0));
}
// dim mask OUTSIDE the viewport: full minimap rect with the viewport rect punched
// out (both subpaths same winding → fill-rule:evenodd leaves the viewport a hole).
const mvx = toMMx(vx),
mvy = toMMy(vy),
mvw = vw * scale,
mvh = vh * scale;
const mask = document.createElementNS(SVGNS, 'path');
mask.setAttribute('class', 'rozie-flow-minimap__mask');
mask.setAttribute('fill-rule', 'evenodd');
mask.setAttribute('fill', 'rgba(15, 23, 42, 0.18)');
mask.setAttribute('d', 'M0 0 H' + MINIMAP_W + ' V' + MINIMAP_H + ' H0 Z ' + 'M' + mvx + ' ' + mvy + ' h' + mvw + ' v' + mvh + ' h' + -mvw + ' Z');
minimapSvg.current.appendChild(mask);
minimapSvg.current.appendChild(mkMinimapRect(mvx, mvy, mvw, mvh, 'rozie-flow-minimap__viewport', 'none', '#3b82f6', 1.5));
};
// rAF-coalesced scheduler (bridged to the top-level $watch + the engine pipes). No-op
// when :minimap is off (the bridge stays callable everywhere, cheap).
scheduleMinimapRedraw.current = () => {
if (!props.minimap || minimapRedrawRaf.current) return;
if (typeof requestAnimationFrame === 'function') {
minimapRedrawRaf.current = requestAnimationFrame(redrawMinimap);
} else {
minimapRedrawRaf.current = 1;
Promise.resolve().then(redrawMinimap);
}
};
// Map a minimap pointer event → graph coords (via the stored minimapMap) → setCenter.
// Pan is a view op → allowed even when readonly, but gated by `pannable` (mirror the
// main-canvas pannable gate). Pointer capture keeps the drag tracking off the box.
const minimapPointerToGraph = (e: any) => {
if (!minimapMap.current || !minimapHost.current) return null;
const box = minimapHost.current.getBoundingClientRect();
const rw = box.width || MINIMAP_W;
const rh = box.height || MINIMAP_H;
const mx = (e.clientX - box.left) * (MINIMAP_W / rw);
const my = (e.clientY - box.top) * (MINIMAP_H / rh);
return {
gx: minimapMap.current.minX + (mx - minimapMap.current.offX) / minimapMap.current.scale,
gy: minimapMap.current.minY + (my - minimapMap.current.offY) / minimapMap.current.scale
};
};
if (props.minimap && minimapEl.current) {
minimapHost.current = minimapEl.current;
minimapSvg.current = document.createElementNS(SVGNS, 'svg');
minimapSvg.current.setAttribute('class', 'rozie-flow-minimap__svg');
minimapSvg.current.setAttribute('viewBox', '0 0 ' + MINIMAP_W + ' ' + MINIMAP_H);
minimapSvg.current.setAttribute('preserveAspectRatio', 'none');
minimapHost.current.appendChild(minimapSvg.current);
onMinimapPointerDown.current = (e: any) => {
if (!props.pannable) return;
const g = minimapPointerToGraph(e);
if (!g) return;
minimapPanning.current = true;
try {
if (e.target && e.target.setPointerCapture && e.pointerId != null) e.target.setPointerCapture(e.pointerId);
} catch (err: any) {}
e.preventDefault();
e.stopPropagation();
setCenter(g.gx, g.gy, null);
};
onMinimapPointerMove.current = (e: any) => {
if (!minimapPanning.current || !props.pannable) return;
const g = minimapPointerToGraph(e);
if (!g) return;
e.preventDefault();
setCenter(g.gx, g.gy, null);
};
onMinimapPointerUp.current = (e: any) => {
if (!minimapPanning.current) return;
minimapPanning.current = false;
try {
if (e.target && e.target.releasePointerCapture && e.pointerId != null) e.target.releasePointerCapture(e.pointerId);
} catch (err: any) {}
};
minimapHost.current.addEventListener('pointerdown', onMinimapPointerDown.current);
minimapHost.current.addEventListener('pointermove', onMinimapPointerMove.current);
minimapHost.current.addEventListener('pointerup', onMinimapPointerUp.current);
}
// ─── T2.8 NodeToolbar (opt-in :node-toolbar) ─────────────────────────────────
// A floating component-template overlay over the SELECTED node. The host div
// (ref="toolbarEl") carries the [data-rozie-s-*] scope attr → PLAIN scoped CSS positions
// it absolutely (NOT the :root engine-DOM escape hatch — it's component DOM, like the
// marquee box + Controls). It is positioned from the engine node-view ELEMENT's rect
// (which the AreaPlugin transforms for pan/zoom/drag) relative to the canvas container, so
// the area transform is honored automatically — we read getBoundingClientRect() and
// subtract the container's rect (the screenToFlowPosition discipline, but the other way).
// Re-tracked on translated/zoomed/nodetranslated (the pipe branches that schedule the
// minimap redraw) + on every selection emit. OPT-IN (default OFF) → existing demos +
// FlowCanvasScreenshot are pixel-identical (the host div is r-if'd off when :node-toolbar
// is false; selecting a node never pops it).
// Resolve the SINGLE selected node id the toolbar should track: the one picked node when
// EXACTLY one is selected, else null (no toolbar over a multi-select or empty selection —
// a per-node action needs an unambiguous target). Read-only.
const singleSelectedNodeId = () => {
const ids = selectedNodeIds();
return ids.length === 1 ? ids[0] : null;
};
// Position the toolbar host over the tracked node's engine element, or hide it. The
// node-view element is already transformed by the AreaPlugin (pan/zoom/drag), so its
// client rect minus the container's client rect gives the toolbar's container-relative
// px — no manual transform math. Placed just ABOVE the node (bottom of the toolbar at the
// node's top edge); clamped so it never goes off the top of the container.
const trackToolbar = () => {
toolbarTrackRaf.current = 0;
if (!props.nodeToolbar || !toolbarHost.current || !area.current || !container) return;
const id = toolbarSelectedId.current;
if (id == null) {
toolbarHost.current.style.display = 'none';
return;
}
const view = area.current.nodeViews ? area.current.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
const rect = el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null;
if (!rect) {
toolbarHost.current.style.display = 'none';
return;
}
const cbox = container.getBoundingClientRect();
// container-relative px of the node's top-left + width.
const nx = rect.left - cbox.left;
const ny = rect.top - cbox.top;
const tbH = toolbarHost.current.offsetHeight || 30;
let top = ny - tbH - 6;
if (top < 2) top = ny + rect.height + 6; // flip below if it would clip the top
toolbarHost.current.style.left = nx + 'px';
toolbarHost.current.style.top = top + 'px';
toolbarHost.current.style.display = 'flex';
};
scheduleToolbarTrack.current = () => {
if (!props.nodeToolbar || toolbarTrackRaf.current) return;
if (typeof requestAnimationFrame === 'function') {
toolbarTrackRaf.current = requestAnimationFrame(trackToolbar);
} else {
toolbarTrackRaf.current = 1;
Promise.resolve().then(trackToolbar);
}
};
// Recompute the tracked node from the live selection + (re)mount the toolbar content for
// it. Called from the selection emit (a pick/unpick changed the selection). When the
// tracked id changes: if the consumer fills `#toolbar`, (re)render the reactive portal
// with the new node scope; else the default buttons stay put (they read the live tracked
// id at click time, so no re-mount needed). Then reposition.
const syncToolbar = () => {
if (!props.nodeToolbar || !toolbarHost.current) return;
const id = singleSelectedNodeId();
if (id === toolbarSelectedId.current && id == null === (toolbarSelectedId.current == null)) {
// same target — just reposition (e.g. after a drag).
scheduleToolbarTrack.current();
return;
}
toolbarSelectedId.current = id;
if ((props.renderToolbar ?? props.slots?.["toolbar"]) && id != null) {
const meta = nodeMeta.get(id) || {
id,
type: undefined,
data: {}
};
const scope = {
node: meta,
emit: toolbarEmit
};
if (toolbarHandle.current && toolbarHandle.current.update) {
toolbarHandle.current.update(scope);
} else {
toolbarHandle.current = portals.toolbar(toolbarHost.current, scope);
}
}
scheduleToolbarTrack.current();
};
syncToolbarSelection.current = syncToolbar;
// The @node-action emit helper for the toolbar's actions (the EXISTING emit — no new emit,
// T2.8). Carries the tracked node id. Handed to the `#toolbar` slot scope so a consumer
// override can raise its own actions too.
const toolbarEmit = (name: any, detail: any) => {
const id = toolbarSelectedId.current;
props.onNodeAction && props.onNodeAction({
id,
name,
detail
});
};
if (props.nodeToolbar && toolbarEl.current) {
toolbarHost.current = toolbarEl.current;
toolbarHost.current.style.display = 'none';
if (!(props.renderToolbar ?? props.slots?.["toolbar"])) {
// default chrome: delete + duplicate buttons. Static literal labels (Threat
// T-44-06-1: no node-derived text rendered via innerHTML — these are fixed strings
// set via textContent). Both fire @node-action on the tracked node.
toolbarDeleteBtn.current = document.createElement('button');
toolbarDeleteBtn.current.type = 'button';
toolbarDeleteBtn.current.className = 'rozie-flow-toolbar__btn rozie-flow-toolbar__btn--delete';
toolbarDeleteBtn.current.setAttribute('data-testid', 'flow-toolbar-delete');
toolbarDeleteBtn.current.setAttribute('aria-label', 'Delete node');
toolbarDeleteBtn.current.textContent = 'Delete';
toolbarDuplicateBtn.current = document.createElement('button');
toolbarDuplicateBtn.current.type = 'button';
toolbarDuplicateBtn.current.className = 'rozie-flow-toolbar__btn rozie-flow-toolbar__btn--duplicate';
toolbarDuplicateBtn.current.setAttribute('data-testid', 'flow-toolbar-duplicate');
toolbarDuplicateBtn.current.setAttribute('aria-label', 'Duplicate node');
toolbarDuplicateBtn.current.textContent = 'Duplicate';
onToolbarDelete.current = (e: any) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const id = toolbarSelectedId.current;
if (id == null) return;
toolbarEmit('delete', {
id
});
toolbarSelectedId.current = null;
deleteNode(id);
scheduleToolbarTrack.current();
};
onToolbarDup.current = (e: any) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const id = toolbarSelectedId.current;
if (id == null) return;
const newId = duplicateNode(id);
toolbarEmit('duplicate', {
id,
newId
});
scheduleToolbarTrack.current();
};
// pointerup (NOT click — Rete swallows clicks during node interaction; the §6a item-7
// discipline) on the COMPONENT-template buttons.
toolbarDeleteBtn.current.addEventListener('pointerup', onToolbarDelete.current);
toolbarDuplicateBtn.current.addEventListener('pointerup', onToolbarDup.current);
toolbarHost.current.appendChild(toolbarDeleteBtn.current);
toolbarHost.current.appendChild(toolbarDuplicateBtn.current);
}
}
// ─── T2.4 MARQUEE select (mode:'select') ─────────────────────────────────────
// A Figma-style rubber-band box. RESTORE-PATH resolution (RESEARCH Q2/A8): rete's
// internal `Drag` class is NOT exported, so setDragHandler(null) can't be cleanly
// reversed (re-instantiating Drag is impossible). Instead we leave the default pan Drag
// installed and intercept the EMPTY-canvas pointerdown in the CAPTURE phase on the
// container — the default Drag attaches its own bubble-phase pointerdown listener on the
// SAME container (verified rete-area-plugin@2.1.5: setDragHandler → Drag.initialize(
// this.container)), so a capture listener fires FIRST and stopPropagation() blocks pan
// before it starts. The interception is gated PURELY on the live `$props.mode` flag, so
// switching back to 'pan' restores pan with ZERO engine mutation (the persistent
// mode-guard the research preferred). A node drag is UNTOUCHED in both modes: we only act
// when the pointerdown target is NOT inside a node element (empty canvas).
//
// The box is a COMPONENT-TEMPLATE overlay div (ref="marqueeEl") — it carries the
// [data-rozie-s-*] scope attr so a PLAIN scoped rule styles it (NOT the :root engine-DOM
// escape hatch). On release we hit-test every graph node's rect (graph coords via
// area.nodeViews.get(id).position + measureNodeSize) against the box (converted to graph
// coords through the live transform) and nodeSelectApi.select(id, true) each intersector,
// then scheduleSelectionEmit() (the existing @selection-change path — NO new emit).
// Marquee changes only SELECTION (script-state), never the graph model → no history push.
const nodeAt = (target: any) => {
if (!target || typeof target.closest !== 'function') return null;
return target.closest('.rozie-flow-node');
};
// container-relative px → GRAPH coords (the inverse area transform, like
// screenToFlowPosition but already container-relative). px = transform + graph·k.
const containerPxToGraph = (px: any, py: any) => {
const t = area.current.area.transform;
const k = t.k || 1;
return {
x: (px - t.x) / k,
y: (py - t.y) / k
};
};
const updateMarqueeBox = () => {
if (!marqueeBox.current || !marqueeStart.current || !marqueeCur.current) return;
const x = Math.min(marqueeStart.current.x, marqueeCur.current.x);
const y = Math.min(marqueeStart.current.y, marqueeCur.current.y);
const w = Math.abs(marqueeCur.current.x - marqueeStart.current.x);
const h = Math.abs(marqueeCur.current.y - marqueeStart.current.y);
marqueeBox.current.style.left = x + 'px';
marqueeBox.current.style.top = y + 'px';
marqueeBox.current.style.width = w + 'px';
marqueeBox.current.style.height = h + 'px';
marqueeBox.current.style.display = 'block';
};
const finishMarquee = () => {
if (!marqueeActive.current) return;
marqueeActive.current = false;
if (marqueeBox.current) marqueeBox.current.style.display = 'none';
if (!marqueeStart.current || !marqueeCur.current || !nodeSelectApi.current) {
marqueeStart.current = null;
marqueeCur.current = null;
return;
}
// box in graph coords (two opposite corners → min/max).
const a = containerPxToGraph(marqueeStart.current.x, marqueeStart.current.y);
const b = containerPxToGraph(marqueeCur.current.x, marqueeCur.current.y);
const bx0 = Math.min(a.x, b.x),
by0 = Math.min(a.y, b.y);
const bx1 = Math.max(a.x, b.x),
by1 = Math.max(a.y, b.y);
marqueeStart.current = null;
marqueeCur.current = null;
const graphNodes = currentGraph().nodes || [];
let first = true;
for (const n of graphNodes as any) {
if (!n || n.id == null) continue;
const view = area.current.nodeViews.get(n.id);
const gx = view ? view.position.x : n.x || 0;
const gy = view ? view.position.y : n.y || 0;
const sz = measureNodeSize(n.id);
// a node intersects the box if their rects overlap (AABB), in graph coords.
const overlaps = gx < bx1 && gx + sz.w > bx0 && gy < by1 && gy + sz.h > by0;
if (overlaps) {
// accumulate=true keeps every intersector selected (first one replaces the prior
// selection so an old pick doesn't linger; rest accumulate). select(id, accumulate).
nodeSelectApi.current.select(n.id, !first);
first = false;
}
}
// surface @selection-change once the engine's awaited select() chain has flushed.
scheduleSelectionEmit();
};
if (props.selectable && !props.readonly && container && typeof container.addEventListener === 'function') {
marqueeBox.current = marqueeEl.current || null;
onCanvasPointerDownCapture.current = (e: any) => {
// only in select mode, only the EMPTY canvas (not on a node — those still drag), only
// the primary button. A live `$props.mode` read = the persistent mode-guard (restoring
// pan is just this check returning early; no engine mutation).
if (_modeRef.current !== 'select') return;
if (e && e.button != null && e.button !== 0) return;
if (nodeAt(e.target)) return;
// BLOCK rete's pan Drag (its bubble-phase pointerdown on the same container) — capture
// phase runs first, so stopPropagation() here pre-empts pan; the marquee owns this drag.
e.stopPropagation();
e.preventDefault();
const box = container.getBoundingClientRect();
marqueeActive.current = true;
marqueeStart.current = {
x: e.clientX - box.left,
y: e.clientY - box.top
};
marqueeCur.current = {
x: marqueeStart.current.x,
y: marqueeStart.current.y
};
try {
if (container.setPointerCapture && e.pointerId != null) container.setPointerCapture(e.pointerId);
} catch (err: any) {}
updateMarqueeBox();
};
onMarqueePointerMove.current = (e: any) => {
if (!marqueeActive.current) return;
const box = container.getBoundingClientRect();
marqueeCur.current = {
x: e.clientX - box.left,
y: e.clientY - box.top
};
updateMarqueeBox();
};
onMarqueePointerUp.current = (e: any) => {
if (!marqueeActive.current) return;
try {
if (container.releasePointerCapture && e && e.pointerId != null) container.releasePointerCapture(e.pointerId);
} catch (err: any) {}
finishMarquee();
};
container.addEventListener('pointerdown', onCanvasPointerDownCapture.current, true);
container.addEventListener('pointermove', onMarqueePointerMove.current);
container.addEventListener('pointerup', onMarqueePointerUp.current);
}
// ─── initial graph: nodes first, then connections (connections reference live
// node instances), then optional fit. Sequenced via an async IIFE so the
// $onMount-returned teardown stays synchronous. ──────────────────────────────
;
(async () => {
// T1.3 — seed the canvas's own last-written graph from the initial bound value so the
// first gesture's snapshot/base reflects the mounted graph (immune to prop re-bind lag).
lastWrittenGraph.current = structuredClone(currentGraph());
await reconcileNodes.current();
await reconcileConnections.current();
if (typeof _zoomRef.current === 'number' && _zoomRef.current !== 1) {
programmatic.current++;
try {
await area.current.area.zoom(_zoomRef.current);
} finally {
programmatic.current--;
}
}
if (props.fitOnMount && editor.current.getNodes().length) {
programmatic.current++;
try {
await AreaExtensions.zoomAt(area.current, editor.current.getNodes());
} finally {
programmatic.current--;
}
if (area.current) {
const k = area.current.area.transform.k;
if (k !== _zoomRef.current) setZoom(k);
}
}
// draw the minimap once the graph + fit have settled (also redrawn on every
// render / pan / zoom / drag / selection / graph change below).
if (scheduleMinimapRedraw.current) scheduleMinimapRedraw.current();
})();
return () => {
for (const root of portalRoots.current) root.unmount();
portalRoots.current.clear();
if (onCanvasKeydown.current && keydownContainer.current && typeof keydownContainer.current.removeEventListener === 'function') {
try {
keydownContainer.current.removeEventListener('keydown', onCanvasKeydown.current);
} catch (e: any) {}
}
if (dragFlushRaf.current && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(dragFlushRaf.current);
} catch (e: any) {}
}
dragFlushRaf.current = 0;
pendingDragPositions.clear();
// T1.1: drop the edge-selection state + its cached <path> reference on teardown.
clearEdgeSelection();
// MiniMap teardown — remove the pointer-pan listeners + cancel a pending redraw.
if (minimapHost.current) {
if (onMinimapPointerDown.current) {
try {
minimapHost.current.removeEventListener('pointerdown', onMinimapPointerDown.current);
} catch (e: any) {}
}
if (onMinimapPointerMove.current) {
try {
minimapHost.current.removeEventListener('pointermove', onMinimapPointerMove.current);
} catch (e: any) {}
}
if (onMinimapPointerUp.current) {
try {
minimapHost.current.removeEventListener('pointerup', onMinimapPointerUp.current);
} catch (e: any) {}
}
}
if (minimapRedrawRaf.current && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(minimapRedrawRaf.current);
} catch (e: any) {}
}
minimapRedrawRaf.current = 0;
// T2.8 NodeToolbar teardown — remove the default-button listeners, dispose the optional
// `#toolbar` reactive portal handle, and cancel a pending reposition.
if (toolbarDeleteBtn.current && onToolbarDelete.current) {
try {
toolbarDeleteBtn.current.removeEventListener('pointerup', onToolbarDelete.current);
} catch (e: any) {}
}
if (toolbarDuplicateBtn.current && onToolbarDup.current) {
try {
toolbarDuplicateBtn.current.removeEventListener('pointerup', onToolbarDup.current);
} catch (e: any) {}
}
if (toolbarHandle.current && toolbarHandle.current.dispose) {
try {
toolbarHandle.current.dispose();
} catch (e: any) {}
}
toolbarHandle.current = null;
toolbarSelectedId.current = null;
if (toolbarTrackRaf.current && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(toolbarTrackRaf.current);
} catch (e: any) {}
}
toolbarTrackRaf.current = 0;
// T2.4 Marquee teardown — remove the capture-phase pointerdown guard + window listeners.
if (keydownContainer.current) {
if (onCanvasPointerDownCapture.current) {
try {
keydownContainer.current.removeEventListener('pointerdown', onCanvasPointerDownCapture.current, true);
} catch (e: any) {}
}
if (onMarqueePointerMove.current) {
try {
keydownContainer.current.removeEventListener('pointermove', onMarqueePointerMove.current);
} catch (e: any) {}
}
if (onMarqueePointerUp.current) {
try {
keydownContainer.current.removeEventListener('pointerup', onMarqueePointerUp.current);
} catch (e: any) {}
}
}
marqueeActive.current = false;
marqueeStart.current = null;
marqueeCur.current = null;
for (const [, entry] of nodeEntries as any) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
}
nodeEntries.clear();
for (const [, entry] of connEntries as any) entry.dispose();
connEntries.clear();
if (area.current) area.current.destroy();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch0First.current) { _watch0First.current = false; return; }
// T1.3 — keep the canvas's own last-written graph in sync with an EXTERNAL (non-
// programmatic) consumer change, so undo/redo's "current" state tracks reality (our own
// write-backs / restores set lastWrittenGraph synchronously under the programmatic guard;
// this only refreshes it for a genuine outside edit).
if (selfWriteInFlight.current) {
// our own commitGraph write echoing back — lastWrittenGraph is already authoritative.
selfWriteInFlight.current = false;
} else if (!programmatic.current) {
const c = structuredClone(currentGraph());
if (c != null) lastWrittenGraph.current = c;
}
if (reconcileNodes.current) {
Promise.resolve(reconcileNodes.current()).then(() => {
if (reconcileConnections.current) reconcileConnections.current();
});
}
// graph changed (nodes added/removed/moved) → refresh the minimap node rects.
if (scheduleMinimapRedraw.current) scheduleMinimapRedraw.current();
}, [graph]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch1First.current) { _watch1First.current = false; return; }
if (reconcileNodes.current) {
Promise.resolve(reconcileNodes.current()).then(() => {
if (reconcileConnections.current) reconcileConnections.current();
});
}
}, [portReg]);
useEffect(() => {
if (_watch2First.current) { _watch2First.current = false; return; }
if (reconcileNodes.current) reconcileNodes.current();
}, [typeReg]);
useEffect(() => {
if (_watch3First.current) { _watch3First.current = false; return; }
const v = zoom;
if (!area.current || typeof v !== 'number') return;
if (v === area.current.area.transform.k) return;
programmatic.current++;
Promise.resolve(area.current.area.zoom(v)).finally(() => {
programmatic.current--;
});
}, [zoom]);
const _rozieExposeRef = useRef({ getEditor, getArea, addNode, removeNode, deleteNode, addConnection, removeConnection, clear, zoomToFit, zoomTo, setCenter, setViewport, screenToFlowPosition, getNodes, getConnections, getTransform, autoArrange, undo, redo, canUndo, canRedo, getSelectedNodes, selectNode, clearSelection, selectAll, centerOnNode });
_rozieExposeRef.current = { getEditor, getArea, addNode, removeNode, deleteNode, addConnection, removeConnection, clear, zoomToFit, zoomTo, setCenter, setViewport, screenToFlowPosition, getNodes, getConnections, getTransform, autoArrange, undo, redo, canUndo, canRedo, getSelectedNodes, selectNode, clearSelection, selectAll, centerOnNode };
useImperativeHandle(ref, () => ({ getEditor: (...args: Parameters<typeof getEditor>): ReturnType<typeof getEditor> => _rozieExposeRef.current.getEditor(...args), getArea: (...args: Parameters<typeof getArea>): ReturnType<typeof getArea> => _rozieExposeRef.current.getArea(...args), addNode: (...args: Parameters<typeof addNode>): ReturnType<typeof addNode> => _rozieExposeRef.current.addNode(...args), removeNode: (...args: Parameters<typeof removeNode>): ReturnType<typeof removeNode> => _rozieExposeRef.current.removeNode(...args), deleteNode: (...args: Parameters<typeof deleteNode>): ReturnType<typeof deleteNode> => _rozieExposeRef.current.deleteNode(...args), addConnection: (...args: Parameters<typeof addConnection>): ReturnType<typeof addConnection> => _rozieExposeRef.current.addConnection(...args), removeConnection: (...args: Parameters<typeof removeConnection>): ReturnType<typeof removeConnection> => _rozieExposeRef.current.removeConnection(...args), clear: (...args: Parameters<typeof clear>): ReturnType<typeof clear> => _rozieExposeRef.current.clear(...args), zoomToFit: (...args: Parameters<typeof zoomToFit>): ReturnType<typeof zoomToFit> => _rozieExposeRef.current.zoomToFit(...args), zoomTo: (...args: Parameters<typeof zoomTo>): ReturnType<typeof zoomTo> => _rozieExposeRef.current.zoomTo(...args), setCenter: (...args: Parameters<typeof setCenter>): ReturnType<typeof setCenter> => _rozieExposeRef.current.setCenter(...args), setViewport: (...args: Parameters<typeof setViewport>): ReturnType<typeof setViewport> => _rozieExposeRef.current.setViewport(...args), screenToFlowPosition: (...args: Parameters<typeof screenToFlowPosition>): ReturnType<typeof screenToFlowPosition> => _rozieExposeRef.current.screenToFlowPosition(...args), getNodes: (...args: Parameters<typeof getNodes>): ReturnType<typeof getNodes> => _rozieExposeRef.current.getNodes(...args), getConnections: (...args: Parameters<typeof getConnections>): ReturnType<typeof getConnections> => _rozieExposeRef.current.getConnections(...args), getTransform: (...args: Parameters<typeof getTransform>): ReturnType<typeof getTransform> => _rozieExposeRef.current.getTransform(...args), autoArrange: (...args: Parameters<typeof autoArrange>): ReturnType<typeof autoArrange> => _rozieExposeRef.current.autoArrange(...args), undo: (...args: Parameters<typeof undo>): ReturnType<typeof undo> => _rozieExposeRef.current.undo(...args), redo: (...args: Parameters<typeof redo>): ReturnType<typeof redo> => _rozieExposeRef.current.redo(...args), canUndo: (...args: Parameters<typeof canUndo>): ReturnType<typeof canUndo> => _rozieExposeRef.current.canUndo(...args), canRedo: (...args: Parameters<typeof canRedo>): ReturnType<typeof canRedo> => _rozieExposeRef.current.canRedo(...args), getSelectedNodes: (...args: Parameters<typeof getSelectedNodes>): ReturnType<typeof getSelectedNodes> => _rozieExposeRef.current.getSelectedNodes(...args), selectNode: (...args: Parameters<typeof selectNode>): ReturnType<typeof selectNode> => _rozieExposeRef.current.selectNode(...args), clearSelection: (...args: Parameters<typeof clearSelection>): ReturnType<typeof clearSelection> => _rozieExposeRef.current.clearSelection(...args), selectAll: (...args: Parameters<typeof selectAll>): ReturnType<typeof selectAll> => _rozieExposeRef.current.selectAll(...args), centerOnNode: (...args: Parameters<typeof centerOnNode>): ReturnType<typeof centerOnNode> => _rozieExposeRef.current.centerOnNode(...args) }), []);
return (
<__ctx_rete_canvas.Provider value={{
// Register/replace a node TYPE template. `spec` carries an optional
// `bodyRenderer(host, { node })` — the render-by-type projection (mounted per graph
// node of this type into the engine body host, see renderNode). Whole-object replace.
registerType: (type: any, spec: any) => {
if (type != null) setTypeReg(prev => ({
...prev,
[type]: spec
}));
},
// Drop a type on <NodeType> unmount (whole-object replace).
unregisterType: (type: any) => {
const t = {
...typeReg
};
delete t[type];
setTypeReg(t);
},
// A <Port> registers a port against its TYPE + side. Stored in the flat portReg
// under a UNIQUE per-port key `type::side::key` so registration is order-independent
// AND concurrency-safe: two <Port>s of the same type addTypePort in one React commit,
// and a pure `{ ...portReg, [uniqueKey]: port }` write (functional setState) merges
// both (an array read-modify-write under one type key would clobber). buildNode reads
// the type's portReg entries on every run regardless of mount order. The unique key
// also makes a re-fired addTypePort (late Lit context) idempotent — same key, same value.
// `side` is derived by <Port> from which of output=/input= is set (output⇒'output', input⇒'input');
// `portType` carries the port type that drives validate-types + the typed-port color.
// `position` (F2) is the socket's VISUAL placement (left|right|top|bottom; default by
// side) — drives the render-pipe socket layout + the connection-anchor axis.
addTypePort: (type: any, side: any, key: any, portType: any, label: any, multiple: any, position: any) => {
if (type == null || key == null) return;
const portKey = type + '::' + side + '::' + key;
setPortReg(prev => ({
...prev,
[portKey]: {
type,
side,
key,
portType,
label,
multiple,
position
}
}));
},
// Render-by-type callback target. Returns the engine-created body host div for a
// graph node (nodeEntries.get(nodeId).body). The render-by-type projection mounts
// the node's TYPE template `#body` INTO this host via $portals — the Wave-0 A3
// finding (a Lit child cannot relocate its own shadow <slot> across the boundary),
// so the body is projected by the parent reusing the $portals host discipline.
bodyHostFor: (nodeId: any) => {
const entry = nodeEntries.get(nodeId);
return entry ? entry.body : null;
}
}}>
<>
<div className={"rozie-flow-canvas"} ref={canvasEl} tabIndex={0} data-rozie-s-cd396d6a="">
{(props.controls) && <div className={"rozie-flow-controls"} data-rozie-s-cd396d6a="">
<button type="button" className={"rozie-flow-controls__btn"} data-testid="flow-zoom-in" aria-label="Zoom in" onClick={controlZoomIn} data-rozie-s-cd396d6a="">+</button>
<button type="button" className={"rozie-flow-controls__btn"} data-testid="flow-zoom-out" aria-label="Zoom out" onClick={controlZoomOut} data-rozie-s-cd396d6a="">−</button>
<button type="button" className={"rozie-flow-controls__btn"} data-testid="flow-fit" aria-label="Fit view" onClick={controlFit} data-rozie-s-cd396d6a="">☐</button>
{(props.marquee) && <button type="button" className={clsx("rozie-flow-controls__btn", { "is-active": mode === 'select' })} data-testid="flow-mode" aria-label={rozieAttr(mode === 'select' ? 'Select mode (click to pan)' : 'Pan mode (click to select)')} onClick={toggleMode} data-rozie-s-cd396d6a="">{rozieDisplay(mode === 'select' ? '▢' : '✥')}</button>}</div>}{(props.minimap) && <div className={"rozie-flow-minimap"} ref={minimapEl} data-testid="flow-minimap" data-rozie-s-cd396d6a="" />}<div className={"rozie-flow-marquee"} ref={marqueeEl} data-testid="flow-marquee" data-rozie-s-cd396d6a="" />
{(props.nodeToolbar) && <div className={"rozie-flow-toolbar"} ref={toolbarEl} data-testid="flow-toolbar" data-rozie-s-cd396d6a="" />}</div>
{(typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)() : (props.children ?? props.slots?.['']))}
</>
</__ctx_rete_canvas.Provider>
);
});
export default FlowCanvas;vue
<template>
<div class="rozie-flow-canvas" ref="canvasElRef" tabindex="0">
<div v-if="props.controls" class="rozie-flow-controls">
<button type="button" class="rozie-flow-controls__btn" data-testid="flow-zoom-in" aria-label="Zoom in" @click="controlZoomIn">+</button>
<button type="button" class="rozie-flow-controls__btn" data-testid="flow-zoom-out" aria-label="Zoom out" @click="controlZoomOut">−</button>
<button type="button" class="rozie-flow-controls__btn" data-testid="flow-fit" aria-label="Fit view" @click="controlFit">☐</button>
<button v-if="props.marquee" type="button" :class="['rozie-flow-controls__btn', { 'is-active': mode === 'select' }]" data-testid="flow-mode" :aria-label="mode === 'select' ? 'Select mode (click to pan)' : 'Pan mode (click to select)'" @click="toggleMode">{{ mode === 'select' ? '▢' : '✥' }}</button></div><div v-if="props.minimap" class="rozie-flow-minimap" ref="minimapElRef" data-testid="flow-minimap"></div><div class="rozie-flow-marquee" ref="marqueeElRef" data-testid="flow-marquee"></div>
<div v-if="props.nodeToolbar" class="rozie-flow-toolbar" ref="toolbarElRef" data-testid="flow-toolbar"></div></div>
<slot></slot>
</template>
<script setup lang="ts">
import { Fragment, h, onBeforeUnmount, onMounted, provide, ref, render, useSlots, watch } from 'vue';
import { rozieDeepClone } from '@rozie/runtime-vue';
const props = withDefaults(
defineProps<{
/**
* Automatic typed-socket validation (default ON). When `true`, the canvas resolves each endpoint's port type from the per-`<NodeType>` `<Port type>` schema and auto-rejects a type-mismatched connection (firing `connection-rejected`). `canConnect` survives as the optional custom-rule override that runs in addition. Set `false` for pure-`canConnect` (type as metadata only).
*/
validateTypes?: boolean;
/**
* Whether the canvas can be panned by dragging the background (applied at construction). Set `false` to detach the area's drag handler.
*/
pannable?: boolean;
/**
* Whether the canvas can be zoomed by scroll/pinch (applied at construction). Set `false` to detach the area's zoom handler.
*/
zoomable?: boolean;
/**
* Whether nodes can be selected (click; ctrl-click to accumulate). Reflected as the `selected` flag in the `<NodeType>` `#body` scope and surfaced to the consumer via the `@selection-change` event.
*/
selectable?: boolean;
/**
* Read-only viewer mode — no node drag, no connection editing, and no selection. View-only zoom/fit (Controls, the `zoomTo`/`zoomToFit` verbs) stay enabled.
*/
readonly?: boolean;
/**
* Minimum zoom level — the lower bound of the area's zoom restrictor. `0` disables the bound.
*/
minZoom?: number;
/**
* Maximum zoom level — the upper bound of the area's zoom restrictor. `0` disables the bound.
*/
maxZoom?: number;
/**
* Snap-to-grid size in pixels for node dragging. `0` turns snapping off.
*/
snapGrid?: number;
/**
* When selectable, hold Ctrl to add to the current selection instead of replacing it.
*/
accumulateOnCtrl?: boolean;
/**
* The bezier curvature of connection paths (`classicConnectionPath`).
*/
curvature?: number;
/**
* After the initial graph mounts, pan/zoom the viewport to fit all nodes (`AreaExtensions.zoomAt`).
*/
fitOnMount?: boolean;
/**
* Render the built-in Controls overlay — a zoom in / zoom out / fit-view button cluster (the React Flow `<Controls/>` parity). The buttons drive the same zoom/fit path as the `zoomTo`/`zoomToFit` handle verbs (clamped to `minZoom`/`maxZoom`) and stay enabled in `readonly`. Opt out with `:controls="false"`.
*/
controls?: boolean;
/**
* Render the built-in MiniMap overlay (opt-in, default OFF — the React Flow `<MiniMap/>` parity) — an absolute SVG panel (bottom-right) showing a scaled map of every node (sized from the measured engine node-view dims) plus the current viewport window (the area outside dimmed). It is pannable: dragging the minimap recenters the main viewport (via `setCenter`). Evaluated at construction, like `pannable`/`zoomable`/`controls` — set it at mount time.
*/
minimap?: boolean;
/**
* Connection-validation predicate `(conn) => boolean`, receiving the normalized candidate connection `{ source, sourceOutput, target, targetInput }`. Return `false` to reject the connection — no edge is committed, no ghost path is drawn, and `connection-rejected` fires. Runs in addition to the automatic `:validate-types` check (the custom-rule override) and gates all connection paths uniformly (drag-to-connect, imperative `addConnection`, graph reconcile). Absent/`null` imposes no custom rule.
*/
canConnect?: ((...args: any[]) => any) | null;
/**
* Undo/redo, on by default. Every gesture (drag, connect, disconnect, delete) pushes ONE capped (~100) snapshot of the bound graph (nodes incl. x/y + connections; not the viewport), and `undo()`/`redo()` plus Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, and Ctrl/Cmd+Y restore it through the two-way `graph` model (echo-guarded). One gesture = one undo step; a fresh edit after an undo discards the redo branch. Opt out with `:history="false"` (the snapshot stack stays empty and the verbs no-op).
*/
history?: boolean;
/**
* Render the 4th Controls button — the pan ↔ select mode toggle (it two-way-writes `mode`). Default OFF so the default Controls overlay keeps its three buttons. The marquee behavior works whenever `mode === 'select'` regardless of this flag (a consumer can drive `mode` directly); this only governs the built-in button.
*/
marquee?: boolean;
/**
* Render the opt-in NodeToolbar (default OFF) — a floating toolbar over the single selected node (positioned from the engine node-view rect + the area transform, re-tracked on pan/zoom/drag). Default content is Delete (cascading controlled-graph `deleteNode`) + Duplicate (clone the node spec at an offset with a new id into a fresh `graph` object); both fire `@node-action` (`name: 'delete' | 'duplicate'`). Override the content by filling the `#toolbar` reactive slot.
*/
nodeToolbar?: boolean;
}>(),
{ validateTypes: true, pannable: true, zoomable: true, selectable: true, readonly: false, minZoom: 0.1, maxZoom: 4, snapGrid: 0, accumulateOnCtrl: true, curvature: 0.3, fitOnMount: true, controls: true, minimap: false, canConnect: null, history: true, marquee: false, nodeToolbar: false }
);
/**
* The single source of truth (two-way `r-model`) — `{ nodes: [{ id, type, x, y, data? }], connections: [{ id?, source, sourceOutput?, target, targetInput?, label?, stroke?, dashed? }] }`. A node's `type` selects its `<NodeType>` template (render-by-type + port schema); `data` is the opaque payload handed to that type's `#body` scope. The canvas writes back a FRESH top-level object on every drag (x/y) and connect/disconnect (connections) — immutable applyNodeChanges style. `sourceOutput`/`targetInput` default to `out`/`in`; a missing connection `id` is derived from the endpoints.
* @example
* <FlowCanvas r-model:graph="graph" :validate-types="true" />
*/
const graph = defineModel<Record<string, any>>('graph', { default: () => ({
nodes: [],
connections: []
}) });
/**
* The viewport zoom level (two-way `r-model`). Scroll/pinch writes the new zoom back through the model (echo-guarded against the wrapper's own programmatic zooms); a consumer write zooms the live area. There is deliberately no `zoom`/`zoomed` emit — a same-named emit collides with the model on Vue and Angular — so the two-way binding is the channel for zoom changes.
*/
const zoom = defineModel<number>('zoom', { default: 1 });
/**
* Two-way interaction mode (`r-model`) — the Figma-style pan ↔ select toggle, `'pan'` (default) or `'select'`. In `'pan'` an empty-canvas drag pans the viewport (unchanged). In `'select'` an empty-canvas drag draws a rubber-band marquee box that multi-selects the intersecting nodes (surfacing `@selection-change`). A node drag still drags the node in both modes — only the empty-canvas drag changes. The canvas writes it back when the built-in mode button toggles (see `marquee`).
*/
const mode = defineModel<string>('mode', { default: 'pan' });
const emit = defineEmits<{
'edge-click': [...args: any[]];
'edge-selected': [...args: any[]];
'selection-change': [...args: any[]];
'connect-end': [...args: any[]];
'node-action': [...args: any[]];
'connection-rejected': [...args: any[]];
'connection-created': [...args: any[]];
'connection-removed': [...args: any[]];
'node-picked': [...args: any[]];
'node-moved': [...args: any[]];
translated: [...args: any[]];
'context-menu': [...args: any[]];
}>();
defineSlots<{
node(props: { node: any; selected: any; emit: any }): any;
toolbar(props: { node: any; emit: any }): any;
default(props: { }): any;
}>();
const slots = useSlots();
const typeReg = ref({});
const portReg = ref({});
const canvasElRef = ref<HTMLElement>();
const minimapElRef = ref<HTMLElement>();
const marqueeElRef = ref<HTMLElement>();
const toolbarElRef = ref<HTMLElement>();
import { NodeEditor, ClassicPreset, Scope } from 'rete';
import { AreaPlugin, AreaExtensions } from 'rete-area-plugin';
import { ConnectionPlugin, Presets as ConnectionPresets } from 'rete-connection-plugin';
import { getDOMSocketPosition, classicConnectionPath } from 'rete-render-utils';
// T2.6 — auto-layout (D-08, verb-only). The 3 deps (rete-auto-arrange-plugin / elkjs
// @0.8.2 / web-worker) are OPTIONAL leaf peers, installed + bundle-smoked on all 6 in
// Plan 00 (the Vite/Angular-AOT/Lit rollup build resolves elkjs to the SYNCHRONOUS
// elk.bundled.js entry — no web-worker resolution error, no manual fallback switch). Only
// a consumer calling autoArrange() pulls these in.
// T2.6 — auto-layout (D-08, verb-only). The 3 deps (rete-auto-arrange-plugin / elkjs
// @0.8.2 / web-worker) are OPTIONAL leaf peers, installed + bundle-smoked on all 6 in
// Plan 00 (the Vite/Angular-AOT/Lit rollup build resolves elkjs to the SYNCHRONOUS
// elk.bundled.js entry — no web-worker resolution error, no manual fallback switch). Only
// a consumer calling autoArrange() pulls these in.
import { AutoArrangePlugin, Presets as ArrangePresets } from 'rete-auto-arrange-plugin';
// ── engine instances — null-lets so typeNeutralize types them `any` (the
// MapLibre `let instance = null` discipline). Rete's NodeEditor / AreaPlugin /
// ConnectionPlugin / DOMSocketPosition carry rich generic Schemes types that the
// loosely-typed .rozie props (any[]) don't satisfy under the strict react/solid/
// lit leaf tsc; routing every engine call through an `any` instance is the
// .rozie-native fix (no lang="ts", no codegen type-aid). These are top-level lets
// referenced from hooks → React auto-hoists each to a useRef. ──
// ── engine instances — null-lets so typeNeutralize types them `any` (the
// MapLibre `let instance = null` discipline). Rete's NodeEditor / AreaPlugin /
// ConnectionPlugin / DOMSocketPosition carry rich generic Schemes types that the
// loosely-typed .rozie props (any[]) don't satisfy under the strict react/solid/
// lit leaf tsc; routing every engine call through an `any` instance is the
// .rozie-native fix (no lang="ts", no codegen type-aid). These are top-level lets
// referenced from hooks → React auto-hoists each to a useRef. ──
let editor: any = null;
let area: any = null;
let connectionPlugin: any = null;
let socketWatcher: any = null;
let renderScope: any = null;
let selector: any = null;
// T2.6 — the AutoArrangePlugin instance (elkjs-backed). COMPONENT-scope (NOT $onMount-local)
// so the top-level autoArrange() verb sees it (the editor/area discipline). null until $onMount
// wires it; the verb no-ops before mount.
// T2.6 — the AutoArrangePlugin instance (elkjs-backed). COMPONENT-scope (NOT $onMount-local)
// so the top-level autoArrange() verb sees it (the editor/area discipline). null until $onMount
// wires it; the verb no-ops before mount.
let arrange: any = null;
// Win 1: the Delete/Backspace keydown listener + its host container. COMPONENT-scope
// (NOT $onMount-local) so the $onMount-returned teardown — which the Solid emitter
// hoists into a sibling onCleanup() OUTSIDE the mount IIFE — can still see them to
// removeEventListener (the same component-scope discipline as nodeInstances below).
// Win 1: the Delete/Backspace keydown listener + its host container. COMPONENT-scope
// (NOT $onMount-local) so the $onMount-returned teardown — which the Solid emitter
// hoists into a sibling onCleanup() OUTSIDE the mount IIFE — can still see them to
// removeEventListener (the same component-scope discipline as nodeInstances below).
let keydownContainer: any = null;
let onCanvasKeydown: any = null;
// Phase 42 MiniMap (opt-in :minimap) — the absolute SVG overlay host + its imperative
// SVG layer + the pointer-pan listeners. COMPONENT-scope (NOT $onMount-local) so the
// $onMount-returned teardown — which the Solid emitter hoists into a sibling
// onCleanup() OUTSIDE the mount IIFE — can still removeEventListener them (the same
// keydown / nodeInstances discipline). `minimapMap` is the live minimap-px ↔ graph-
// coord mapping the pointer-pan handlers read; `scheduleMinimapRedraw` is the bridge
// the top-level $watch + the engine pipes call (assigned inside $onMount, like the
// reconcilers). minimapRedrawRaf coalesces the viewport-rect redraw to one per frame
// (the drag-write-back discipline — the viewport rect redraws on every pan/zoom).
// Phase 42 MiniMap (opt-in :minimap) — the absolute SVG overlay host + its imperative
// SVG layer + the pointer-pan listeners. COMPONENT-scope (NOT $onMount-local) so the
// $onMount-returned teardown — which the Solid emitter hoists into a sibling
// onCleanup() OUTSIDE the mount IIFE — can still removeEventListener them (the same
// keydown / nodeInstances discipline). `minimapMap` is the live minimap-px ↔ graph-
// coord mapping the pointer-pan handlers read; `scheduleMinimapRedraw` is the bridge
// the top-level $watch + the engine pipes call (assigned inside $onMount, like the
// reconcilers). minimapRedrawRaf coalesces the viewport-rect redraw to one per frame
// (the drag-write-back discipline — the viewport rect redraws on every pan/zoom).
let minimapHost: any = null;
let minimapSvg: any = null;
let minimapRedrawRaf = 0;
let minimapMap: any = null;
let minimapPanning = false;
let onMinimapPointerDown: any = null;
let onMinimapPointerMove: any = null;
let onMinimapPointerUp: any = null;
let scheduleMinimapRedraw: any = null;
// T2.4 MARQUEE select (mode:'select') — the programmatic-select handle captured from
// AreaExtensions.selectableNodes ({ select(id, accumulate), unselect(id) }), the rubber-
// band overlay box (component-template DOM, scoped CSS), and the capture-phase pointerdown
// guard + window pointer listeners that draw the box in select mode. COMPONENT-scope (NOT
// $onMount-local) so the Solid-hoisted teardown can removeEventListener them (the keydown /
// minimap discipline). `marqueeBox` is the absolute overlay <div>; `marqueeActive` gates the
// in-progress drag; `marqueeStart`/`marqueeCur` are container-relative px corners.
// T2.4 MARQUEE select (mode:'select') — the programmatic-select handle captured from
// AreaExtensions.selectableNodes ({ select(id, accumulate), unselect(id) }), the rubber-
// band overlay box (component-template DOM, scoped CSS), and the capture-phase pointerdown
// guard + window pointer listeners that draw the box in select mode. COMPONENT-scope (NOT
// $onMount-local) so the Solid-hoisted teardown can removeEventListener them (the keydown /
// minimap discipline). `marqueeBox` is the absolute overlay <div>; `marqueeActive` gates the
// in-progress drag; `marqueeStart`/`marqueeCur` are container-relative px corners.
let nodeSelectApi: any = null;
let marqueeBox: any = null;
let marqueeActive = false;
let marqueeStart: any = null;
let marqueeCur: any = null;
let onCanvasPointerDownCapture: any = null;
let onMarqueePointerMove: any = null;
let onMarqueePointerUp: any = null;
// T2.8 NodeToolbar (opt-in :node-toolbar) — a floating component-template overlay (scoped
// CSS, like the marquee box + Controls) over the SELECTED node, positioned from the engine
// node-view element's rect relative to the canvas container + the area transform. COMPONENT-
// scope (NOT $onMount-local) so the Solid-hoisted teardown sees them. `toolbarHost` is the
// absolute overlay <div> (the $refs.toolbarEl element); `toolbarSelectedId` is the id of the
// node the toolbar currently tracks (the SINGLE selected node — null when nothing or >1 is
// selected, or selection is empty); `toolbarHandle` is the optional `#toolbar` reactive-
// portal handle ({ update, dispose }) when the consumer fills the slot; `scheduleToolbarTrack`
// is the rAF-coalesced reposition bridge (assigned in $onMount, called by the area pipes +
// the selection emit, like scheduleMinimapRedraw); `toolbarTrackRaf` coalesces it to one per
// frame. `toolbarDeleteBtn`/`toolbarDuplicateBtn` are the default buttons (kept so teardown
// can removeEventListener them); their pointerup handlers are `onToolbarDelete`/`onToolbarDup`.
// T2.8 NodeToolbar (opt-in :node-toolbar) — a floating component-template overlay (scoped
// CSS, like the marquee box + Controls) over the SELECTED node, positioned from the engine
// node-view element's rect relative to the canvas container + the area transform. COMPONENT-
// scope (NOT $onMount-local) so the Solid-hoisted teardown sees them. `toolbarHost` is the
// absolute overlay <div> (the $refs.toolbarEl element); `toolbarSelectedId` is the id of the
// node the toolbar currently tracks (the SINGLE selected node — null when nothing or >1 is
// selected, or selection is empty); `toolbarHandle` is the optional `#toolbar` reactive-
// portal handle ({ update, dispose }) when the consumer fills the slot; `scheduleToolbarTrack`
// is the rAF-coalesced reposition bridge (assigned in $onMount, called by the area pipes +
// the selection emit, like scheduleMinimapRedraw); `toolbarTrackRaf` coalesces it to one per
// frame. `toolbarDeleteBtn`/`toolbarDuplicateBtn` are the default buttons (kept so teardown
// can removeEventListener them); their pointerup handlers are `onToolbarDelete`/`onToolbarDup`.
let toolbarHost: any = null;
let toolbarSelectedId: any = null;
let toolbarHandle: any = null;
let scheduleToolbarTrack: any = null;
// component-scope bridge to the $onMount-local syncToolbar (the scheduleMinimapRedraw
// discipline) — called from maybeEmitSelectionChange + the area pipes so a pick/unpick /
// pan / zoom / drag re-tracks the toolbar over the selected node.
// component-scope bridge to the $onMount-local syncToolbar (the scheduleMinimapRedraw
// discipline) — called from maybeEmitSelectionChange + the area pipes so a pick/unpick /
// pan / zoom / drag re-tracks the toolbar over the selected node.
let syncToolbarSelection: any = null;
let toolbarTrackRaf = 0;
let toolbarDeleteBtn: any = null;
let toolbarDuplicateBtn: any = null;
let onToolbarDelete: any = null;
let onToolbarDup: any = null;
// MiniMap geometry (px) — MUST match the .rozie-flow-minimap CSS box below.
// MiniMap geometry (px) — MUST match the .rozie-flow-minimap CSS box below.
const MINIMAP_W = 200;
const MINIMAP_H = 150;
// Fallback node-rect dims when a node-view element isn't measurable yet (Lit async
// first paint, REQ-30) — re-measured on the next render (the render pipe re-schedules).
// Fallback node-rect dims when a node-view element isn't measurable yet (Lit async
// first paint, REQ-30) — re-measured on the next render (the render pipe re-schedules).
const MINIMAP_DEFAULT_NODE_W = 140;
const MINIMAP_DEFAULT_NODE_H = 52;
const SVGNS = 'http://www.w3.org/2000/svg';
// One Socket shared by every port (Rete sockets gate compatibility by identity;
// a single socket = "anything connects to anything", the common editor default).
// One Socket shared by every port (Rete sockets gate compatibility by identity;
// a single socket = "anything connects to anything", the common editor default).
const SOCKET = new ClassicPreset.Socket('flow');
// Live engine bookkeeping — COMPONENT-scope (NOT $onMount-local) so the
// $onMount-returned teardown, which the Solid emitter hoists into a sibling
// onCleanup() OUTSIDE the mount IIFE, keeps them in scope (the MapLibre
// markerEntries lesson).
// nodeInstances : id → live ClassicPreset.Node (engine truth)
// nodeMeta : id → the consumer's node spec object (for the slot scope)
// connInstances : id → live ClassicPreset.Connection (engine truth)
// nodeEntries : id → { element, bodyHost, handle, socketDisposers }
// connEntries : id → { element, dispose }
// Live engine bookkeeping — COMPONENT-scope (NOT $onMount-local) so the
// $onMount-returned teardown, which the Solid emitter hoists into a sibling
// onCleanup() OUTSIDE the mount IIFE, keeps them in scope (the MapLibre
// markerEntries lesson).
// nodeInstances : id → live ClassicPreset.Node (engine truth)
// nodeMeta : id → the consumer's node spec object (for the slot scope)
// connInstances : id → live ClassicPreset.Connection (engine truth)
// nodeEntries : id → { element, bodyHost, handle, socketDisposers }
// connEntries : id → { element, dispose }
const nodeInstances = new Map();
const nodeMeta = new Map();
const connInstances = new Map();
const nodeEntries = new Map();
const connEntries = new Map();
// connMeta : id → the consumer's connection spec ({ …, label?, stroke?, dashed? }) — the
// connection-side analog of nodeMeta, read by renderConnection for per-edge label/styling (F3).
// connMeta : id → the consumer's connection spec ({ …, label?, stroke?, dashed? }) — the
// connection-side analog of nodeMeta, read by renderConnection for per-edge label/styling (F3).
const connMeta = new Map();
// ids last applied FROM THE BOUND GRAPH, so reconcile removes only graph-managed
// entities — an imperative $expose addNode/addConnection is NOT auto-reaped on the
// next graph change (the power-user escape hatch stays alive). MapLibre reconciles
// every marker because markers are purely prop-driven; a flow editor also accepts
// imperative edits, so it tracks provenance. (Phase 41: nodes/connections now come
// ONLY from the single `graph` model — the per-instance declarative-children
// registries are gone; node TYPE templates + port schemas live in typeReg/portReg.)
// ids last applied FROM THE BOUND GRAPH, so reconcile removes only graph-managed
// entities — an imperative $expose addNode/addConnection is NOT auto-reaped on the
// next graph change (the power-user escape hatch stays alive). MapLibre reconciles
// every marker because markers are purely prop-driven; a flow editor also accepts
// imperative edits, so it tracks provenance. (Phase 41: nodes/connections now come
// ONLY from the single `graph` model — the per-instance declarative-children
// registries are gone; node TYPE templates + port schemas live in typeReg/portReg.)
let lastPropNodeIds: any = null;
let lastPropConnIds: any = null;
// Re-entrant suppression counter: while > 0 the editor/area event handlers skip
// echoing back into $emit / $model (our own programmatic add/remove/translate/
// zoom must not bounce out as if the user did it — the MapLibre PROGRAMMATIC
// eventData guard, in counter form so batched/nested ops never race).
// Re-entrant suppression counter: while > 0 the editor/area event handlers skip
// echoing back into $emit / $model (our own programmatic add/remove/translate/
// zoom must not bounce out as if the user did it — the MapLibre PROGRAMMATIC
// eventData guard, in counter form so batched/nested ops never race).
let programmatic = 0;
// Win 2: the last emitted selection id-set, joined to a stable string, so
// @selection-change fires ONLY on an actual change (a repeated identical pick/unpick
// set does not spam the consumer). `null` until the first emit (so the initial empty
// selection does not emit on mount). COMPONENT-scope so it survives across area events.
// Win 2: the last emitted selection id-set, joined to a stable string, so
// @selection-change fires ONLY on an actual change (a repeated identical pick/unpick
// set does not spam the consumer). `null` until the first emit (so the initial empty
// selection does not emit on mount). COMPONENT-scope so it survives across area events.
let lastSelectionIds: any = null;
// T1.1 — EDGE SELECTION (D-08). The currently-selected CONNECTION id, or null. Lives
// PURELY in component script (the selectedNodeIds echo-safety discipline) — NEVER
// written into $model.graph, so the controlled-graph write-back assertions are
// unaffected (Threat T-44-01-2: no spurious model write). COMPONENT-scope so it
// survives across area events + so the Solid-hoisted teardown can clear it. The
// `.is-selected` class is toggled imperatively on the engine-DOM __path; this id is the
// source of truth the Delete branch reads. `selectedPathEl` caches the live <path>
// element so a background-click clear (and re-select) can drop `.is-selected` without
// re-walking the DOM. `edgeClickGuard` is a one-shot flag the area-background pointerup
// branch checks so an edge click (which fires its own pointerup on the path AND lets the
// area's background pointerup run) does not immediately clear the selection it just made
// — reset on the next microtask, after the gesture settles.
// T1.1 — EDGE SELECTION (D-08). The currently-selected CONNECTION id, or null. Lives
// PURELY in component script (the selectedNodeIds echo-safety discipline) — NEVER
// written into $model.graph, so the controlled-graph write-back assertions are
// unaffected (Threat T-44-01-2: no spurious model write). COMPONENT-scope so it
// survives across area events + so the Solid-hoisted teardown can clear it. The
// `.is-selected` class is toggled imperatively on the engine-DOM __path; this id is the
// source of truth the Delete branch reads. `selectedPathEl` caches the live <path>
// element so a background-click clear (and re-select) can drop `.is-selected` without
// re-walking the DOM. `edgeClickGuard` is a one-shot flag the area-background pointerup
// branch checks so an edge click (which fires its own pointerup on the path AND lets the
// area's background pointerup run) does not immediately clear the selection it just made
// — reset on the next microtask, after the gesture settles.
let selectedConnId: any = null;
let selectedPathEl: any = null;
let edgeClickGuard = false;
// T1.3 — UNDO / REDO (D-02 on-by-default, D-03 per-gesture graph-only scope, D-04
// echo-guarded restore). A CAPPED snapshot stack over the BOUND GRAPH only — nodes
// (incl x/y) + connections — and explicitly NOT the viewport (pan/zoom is excluded,
// D-03). One entry is pushed per COMPLETED gesture: a drag = ONE entry (snapshot taken
// on pointer-down, committed on the first translate — never per pointermove frame), a
// connect / disconnect / delete = one each. A push is gated on `!programmatic` so a
// restore-driven write (which runs INSIDE the programmatic guard) never re-enters the
// history (D-04). Pushing clears the redo branch and drops the oldest entry beyond the
// cap (Threat T-44-03-1: bounded memory). Snapshots are deep clones of the consumer's own
// serializable graph JSON (Pattern 7; the `$clone` sigil — a deep, de-proxied copy
// that strips the Vue/Svelte reactivity Proxy that a bare `structuredClone` THROWS
// on) — no external input, so the restore (T-44-03-2 accept)
// cannot loop (it rides the programmatic guard + the existing $watch(graph) reconcile).
// Undo is ALWAYS on for v1; `:history=false` (the `history` prop) is the cheap escape
// hatch that skips every push (the stacks stay empty → undo/redo are no-ops).
// COMPONENT-scope so the stack survives across area events + the Solid-hoisted teardown.
// T1.3 — UNDO / REDO (D-02 on-by-default, D-03 per-gesture graph-only scope, D-04
// echo-guarded restore). A CAPPED snapshot stack over the BOUND GRAPH only — nodes
// (incl x/y) + connections — and explicitly NOT the viewport (pan/zoom is excluded,
// D-03). One entry is pushed per COMPLETED gesture: a drag = ONE entry (snapshot taken
// on pointer-down, committed on the first translate — never per pointermove frame), a
// connect / disconnect / delete = one each. A push is gated on `!programmatic` so a
// restore-driven write (which runs INSIDE the programmatic guard) never re-enters the
// history (D-04). Pushing clears the redo branch and drops the oldest entry beyond the
// cap (Threat T-44-03-1: bounded memory). Snapshots are deep clones of the consumer's own
// serializable graph JSON (Pattern 7; the `$clone` sigil — a deep, de-proxied copy
// that strips the Vue/Svelte reactivity Proxy that a bare `structuredClone` THROWS
// on) — no external input, so the restore (T-44-03-2 accept)
// cannot loop (it rides the programmatic guard + the existing $watch(graph) reconcile).
// Undo is ALWAYS on for v1; `:history=false` (the `history` prop) is the cheap escape
// hatch that skips every push (the stacks stay empty → undo/redo are no-ops).
// COMPONENT-scope so the stack survives across area events + the Solid-hoisted teardown.
const HISTORY_CAP = 100;
// Two-stack model (simpler + correct than a single cursor): `historyStack` holds
// PRE-gesture snapshots (the states to UNDO back to, newest last); `redoStack` holds
// snapshots an undo popped off (the states to REDO forward to, newest last). A new
// gesture (pushHistory) snapshots the PRE-gesture graph onto historyStack and CLEARS
// redoStack (a fresh edit discards the redo branch). undo() pops historyStack → pushes
// the CURRENT (pre-undo) graph onto redoStack → restores the popped snapshot. redo()
// pops redoStack → pushes the current graph back onto historyStack → restores it.
// Two-stack model (simpler + correct than a single cursor): `historyStack` holds
// PRE-gesture snapshots (the states to UNDO back to, newest last); `redoStack` holds
// snapshots an undo popped off (the states to REDO forward to, newest last). A new
// gesture (pushHistory) snapshots the PRE-gesture graph onto historyStack and CLEARS
// redoStack (a fresh edit discards the redo branch). undo() pops historyStack → pushes
// the CURRENT (pre-undo) graph onto redoStack → restores the popped snapshot. redo()
// pops redoStack → pushes the current graph back onto historyStack → restores it.
let historyStack = [];
let redoStack = [];
// One-shot per-drag guard: a drag fires `nodetranslated` (→ flushDragWriteBack) on EVERY
// pointermove frame, so a push-per-flush would record many entries for ONE gesture. We
// snapshot the PRE-drag graph on `nodepicked` (pointer-DOWN, definitively before any
// movement — capturing it on the first `nodetranslated` is too late: the engine has
// already applied the initial delta + may have flushed a write-back, so $props.graph no
// longer holds the start position), stash it in `pendingDragSnapshot`, and COMMIT it to
// the history stack on the FIRST `nodetranslated` of the gesture (a pick WITHOUT a drag
// must not create a history entry). `dragGestureActive` then holds until the drag-ending
// `pointerup` resets it. D-03: a drag = ONE undo step.
// One-shot per-drag guard: a drag fires `nodetranslated` (→ flushDragWriteBack) on EVERY
// pointermove frame, so a push-per-flush would record many entries for ONE gesture. We
// snapshot the PRE-drag graph on `nodepicked` (pointer-DOWN, definitively before any
// movement — capturing it on the first `nodetranslated` is too late: the engine has
// already applied the initial delta + may have flushed a write-back, so $props.graph no
// longer holds the start position), stash it in `pendingDragSnapshot`, and COMMIT it to
// the history stack on the FIRST `nodetranslated` of the gesture (a pick WITHOUT a drag
// must not create a history entry). `dragGestureActive` then holds until the drag-ending
// `pointerup` resets it. D-03: a drag = ONE undo step.
let dragGestureActive = false;
let pendingDragSnapshot: any = null;
// T2.5 — RECONNECT coalescing (D-08 reconnectable edges, D-03 one-gesture-one-entry).
// Dragging an existing edge endpoint to a new socket is a SINGLE user gesture, but the
// shipped `Presets.classic.setup()` implements it as `editor.removeConnection(old)` then
// `editor.addConnection(new)` — so the write-back pipe sees a `connectionremoved` followed
// by a `connectioncreated`, which would push TWO history entries (Pitfall 2: two Ctrl+Z to
// undo one drag). The fix is to COALESCE: the ConnectionPlugin emits `connectionpick` when
// the user grabs a socket and `connectiondrop` when they release. While a reconnect is in
// flight (`reconnectInFlight > 0`) we SUPPRESS the per-event history pushes that
// writeBackConnectionRemoved / writeBackConnectionCreated normally do (the graph write-back
// itself STILL runs — the controlled graph stays correct), capturing the PRE-gesture
// snapshot ONCE on connectionpick (`reconnectPreSnapshot`). On `connectiondrop` we push that
// single snapshot (whether the drop landed on a new socket → `created:true` = a real
// reconnect, OR on an empty pane → `created:false` = the edge was removed with no re-add)
// and clear the flag. A plain drag-to-connect from an UNCONNECTED output socket also fires
// connectionpick/drop, but there is no remove in that gesture — the single `connectioncreated`
// write-back's own pushHistory is suppressed and the one coalesced snapshot is pushed on drop
// instead, so the per-gesture count stays exactly one either way. Counter form (not a bool)
// so a re-pick mid-gesture can't desync. COMPONENT-scope (survives across area events).
// T2.5 — RECONNECT coalescing (D-08 reconnectable edges, D-03 one-gesture-one-entry).
// Dragging an existing edge endpoint to a new socket is a SINGLE user gesture, but the
// shipped `Presets.classic.setup()` implements it as `editor.removeConnection(old)` then
// `editor.addConnection(new)` — so the write-back pipe sees a `connectionremoved` followed
// by a `connectioncreated`, which would push TWO history entries (Pitfall 2: two Ctrl+Z to
// undo one drag). The fix is to COALESCE: the ConnectionPlugin emits `connectionpick` when
// the user grabs a socket and `connectiondrop` when they release. While a reconnect is in
// flight (`reconnectInFlight > 0`) we SUPPRESS the per-event history pushes that
// writeBackConnectionRemoved / writeBackConnectionCreated normally do (the graph write-back
// itself STILL runs — the controlled graph stays correct), capturing the PRE-gesture
// snapshot ONCE on connectionpick (`reconnectPreSnapshot`). On `connectiondrop` we push that
// single snapshot (whether the drop landed on a new socket → `created:true` = a real
// reconnect, OR on an empty pane → `created:false` = the edge was removed with no re-add)
// and clear the flag. A plain drag-to-connect from an UNCONNECTED output socket also fires
// connectionpick/drop, but there is no remove in that gesture — the single `connectioncreated`
// write-back's own pushHistory is suppressed and the one coalesced snapshot is pushed on drop
// instead, so the per-gesture count stays exactly one either way. Counter form (not a bool)
// so a re-pick mid-gesture can't desync. COMPONENT-scope (survives across area events).
let reconnectInFlight = 0;
let reconnectPreSnapshot: any = null;
// Set true if a write-back (remove or add) actually ran during the in-flight window, so a
// connectionpick→drop that changed NOTHING (e.g. clicking a socket then releasing on the
// pane with no edge created/removed) does NOT push an empty history entry.
// Set true if a write-back (remove or add) actually ran during the in-flight window, so a
// connectionpick→drop that changed NOTHING (e.g. clicking a socket then releasing on the
// pane with no edge created/removed) does NOT push an empty history entry.
let reconnectDidWriteBack = false;
// One-shot guard for the DEFERRED close (the drop fires BEFORE the trailing remove+add
// writeBacks, so the window must close on a macrotask AFTER they settle — see the
// connectiondrop branch). A re-pick before the deferred close runs cancels it.
// One-shot guard for the DEFERRED close (the drop fires BEFORE the trailing remove+add
// writeBacks, so the window must close on a macrotask AFTER they settle — see the
// connectiondrop branch). A re-pick before the deferred close runs cancels it.
let reconnectCloseScheduled = false;
// ─── controlled-graph write-back (D4 — the central NEW capability) ─────────────
// On every drag/connect/disconnect the canvas emits a FRESH top-level
// `{ nodes, connections }` object via `$model.graph` — immutable React-Flow
// applyNodeChanges style (Wave-0-proven 6/6; in-place deep mutation is SILENT on
// React/Solid/Lit/Angular). Echo-guarded by the `programmatic` counter + the
// no-op-diff property: the write-back value already matches engine truth (the node
// is already at x/y; the edge already exists) so the consumer's re-bind →
// $watch(graph) → reconcile is a no-op diff.
//
// DRAG COALESCING (Pitfall 2): `nodetranslated` fires on every pointermove during a
// drag; emitting a fresh graph + full reconcile per frame is a rebuild storm. We
// accumulate the latest position per node (pendingDragPositions) and flush ONE fresh
// graph write per animation frame (dragFlushRaf), plus a final flush so the last
// position always lands. requestAnimationFrame coalesces multiple moves in a frame
// into a single $model.graph emit.
// ─── controlled-graph write-back (D4 — the central NEW capability) ─────────────
// On every drag/connect/disconnect the canvas emits a FRESH top-level
// `{ nodes, connections }` object via `$model.graph` — immutable React-Flow
// applyNodeChanges style (Wave-0-proven 6/6; in-place deep mutation is SILENT on
// React/Solid/Lit/Angular). Echo-guarded by the `programmatic` counter + the
// no-op-diff property: the write-back value already matches engine truth (the node
// is already at x/y; the edge already exists) so the consumer's re-bind →
// $watch(graph) → reconcile is a no-op diff.
//
// DRAG COALESCING (Pitfall 2): `nodetranslated` fires on every pointermove during a
// drag; emitting a fresh graph + full reconcile per frame is a rebuild storm. We
// accumulate the latest position per node (pendingDragPositions) and flush ONE fresh
// graph write per animation frame (dragFlushRaf), plus a final flush so the last
// position always lands. requestAnimationFrame coalesces multiple moves in a frame
// into a single $model.graph emit.
const pendingDragPositions = new Map(); // id → { x, y } (latest during a drag)
// id → { x, y } (latest during a drag)
let dragFlushRaf = 0;
// The current bound graph — NEVER mutated in place.
// The current bound graph — NEVER mutated in place.
const currentGraph = () => graph.value || {
nodes: [],
connections: []
};
// T1.3 — deep-clone a graph snapshot. The graph is serializable JSON (nodes/connections of
// primitives), so JSON round-trip is the robust path: it strips framework reactivity
// wrappers — a Vue `reactive()` Proxy / Svelte `$state` proxy that a bare
// `structuredClone` THROWS on ("could not be cloned"), the silent vue/svelte-only
// failure that left the history stack empty. Phase 45 replaced the hand-rolled
// JSON-first clone helper with the `$clone(x)` sigil at every call site below: it
// lowers to `rozieDeepClone(x)` on Vue (Phase 45-07 — a recursive proxy-safe deep
// clone in @rozie/runtime-vue that de-proxies nested INDEPENDENT reactive members,
// not just the top level), `$state.snapshot(x)` on Svelte, and `structuredClone(x)`
// on the other four — a deep, independent, de-proxied copy on all six (and
// `$clone(null)` → `null` on all six, preserving the old `g == null` early-return
// implicitly). The Rete graph is JSON-serializable, so `$clone` never throws here;
// the former null-return fallbacks at the call sites are now dead but harmless.
// T1.3 — the canvas's OWN last-written graph. Every write-back funnels through
// `commitGraph`, which sets `$model.graph` AND records the written value here. undo/redo
// use THIS (not the round-tripped `$props.graph`) as the "current" state to push onto the
// opposite stack — `$props.graph` lags a drag write-back on React/Vue/Svelte (the
// two-way re-bind is async / batched), so reading it at undo time captured an
// INTERMEDIATE drag position. `lastWrittenGraph` is exact + synchronous. Seeded from the
// bound graph in $onMount.
// T1.3 — deep-clone a graph snapshot. The graph is serializable JSON (nodes/connections of
// primitives), so JSON round-trip is the robust path: it strips framework reactivity
// wrappers — a Vue `reactive()` Proxy / Svelte `$state` proxy that a bare
// `structuredClone` THROWS on ("could not be cloned"), the silent vue/svelte-only
// failure that left the history stack empty. Phase 45 replaced the hand-rolled
// JSON-first clone helper with the `$clone(x)` sigil at every call site below: it
// lowers to `rozieDeepClone(x)` on Vue (Phase 45-07 — a recursive proxy-safe deep
// clone in @rozie/runtime-vue that de-proxies nested INDEPENDENT reactive members,
// not just the top level), `$state.snapshot(x)` on Svelte, and `structuredClone(x)`
// on the other four — a deep, independent, de-proxied copy on all six (and
// `$clone(null)` → `null` on all six, preserving the old `g == null` early-return
// implicitly). The Rete graph is JSON-serializable, so `$clone` never throws here;
// the former null-return fallbacks at the call sites are now dead but harmless.
// T1.3 — the canvas's OWN last-written graph. Every write-back funnels through
// `commitGraph`, which sets `$model.graph` AND records the written value here. undo/redo
// use THIS (not the round-tripped `$props.graph`) as the "current" state to push onto the
// opposite stack — `$props.graph` lags a drag write-back on React/Vue/Svelte (the
// two-way re-bind is async / batched), so reading it at undo time captured an
// INTERMEDIATE drag position. `lastWrittenGraph` is exact + synchronous. Seeded from the
// bound graph in $onMount.
let lastWrittenGraph: any = null;
// Funnel for every component-driven graph write: record the value, then emit it. A deep
// clone is stored so a later consumer mutation of the live bound object can't corrupt the
// recorded state. (Echo-guarding is the CALLER's responsibility — restoreGraph wraps this
// in the programmatic guard.) `selfWriteInFlight` suppresses the resulting $watch(graph)
// tick from clobbering `lastWrittenGraph` with the (possibly still-stale, async) bound
// prop value — the value we just wrote IS the truth.
// Funnel for every component-driven graph write: record the value, then emit it. A deep
// clone is stored so a later consumer mutation of the live bound object can't corrupt the
// recorded state. (Echo-guarding is the CALLER's responsibility — restoreGraph wraps this
// in the programmatic guard.) `selfWriteInFlight` suppresses the resulting $watch(graph)
// tick from clobbering `lastWrittenGraph` with the (possibly still-stale, async) bound
// prop value — the value we just wrote IS the truth.
let selfWriteInFlight = false;
const commitGraph = (g: any) => {
const c = rozieDeepClone(g);
lastWrittenGraph = c != null ? c : g;
selfWriteInFlight = true;
graph.value = g;
};
// Capture the canvas's current graph state (its own last write, falling back to the bound
// prop before the first write). Always a fresh deep clone.
// Capture the canvas's current graph state (its own last write, falling back to the bound
// prop before the first write). Always a fresh deep clone.
const snapshotCurrent = () => {
const src = lastWrittenGraph != null ? lastWrittenGraph : currentGraph();
return rozieDeepClone(src);
};
// The BASE graph a write-back builds its fresh object from: the canvas's own last write if
// present (immune to the async prop re-bind lag), else the bound prop. This keeps a rapid
// gesture sequence (e.g. drag then immediately disconnect) consistent even before the
// consumer's two-way re-bind has propagated the prior write back into `$props.graph`.
// The BASE graph a write-back builds its fresh object from: the canvas's own last write if
// present (immune to the async prop re-bind lag), else the bound prop. This keeps a rapid
// gesture sequence (e.g. drag then immediately disconnect) consistent even before the
// consumer's two-way re-bind has propagated the prior write back into `$props.graph`.
const baseGraph = () => lastWrittenGraph != null ? lastWrittenGraph : currentGraph();
// Commit an ALREADY-CAPTURED snapshot onto the undo stack (caps + clears redo). Gated on
// the `history` prop. Used by both the synchronous-commit path (connect/disconnect/delete)
// and the drag gesture (pre-move snapshot taken on pointer-down, committed on first translate).
// Commit an ALREADY-CAPTURED snapshot onto the undo stack (caps + clears redo). Gated on
// the `history` prop. Used by both the synchronous-commit path (connect/disconnect/delete)
// and the drag gesture (pre-move snapshot taken on pointer-down, committed on first translate).
const pushHistorySnapshot = (snap: any) => {
if (props.history === false) return;
if (!snap) return;
historyStack.push(snap);
if (historyStack.length > HISTORY_CAP) {
historyStack = historyStack.slice(historyStack.length - HISTORY_CAP);
}
redoStack = [];
};
// Snapshot the canvas's CURRENT graph state + commit it onto the undo stack (the connect /
// disconnect / delete path — called BEFORE the write-back so the snapshot is the
// pre-gesture state). Gated on `!programmatic` (echo-guard) + history. D-03: one per gesture.
// Snapshot the canvas's CURRENT graph state + commit it onto the undo stack (the connect /
// disconnect / delete path — called BEFORE the write-back so the snapshot is the
// pre-gesture state). Gated on `!programmatic` (echo-guard) + history. D-03: one per gesture.
const pushHistory = () => {
if (programmatic) return;
if (props.history === false) return;
pushHistorySnapshot(snapshotCurrent());
};
// T2.5 — close the reconnect coalesce window. Called on a DEFERRED macrotask after a
// connectiondrop, so the trailing connectionremoved + connectioncreated writeBacks (which
// the classic preset fires AFTER the drop) have all run with the window still open
// (suppressing their per-event pushHistory, flagging reconnectDidWriteBack). Pushes the
// SINGLE pre-gesture snapshot iff the gesture actually changed the graph, then resets the
// per-gesture state. Idempotent + gated on the one-shot scheduled flag so a re-pick can
// cancel a pending close.
// T2.5 — close the reconnect coalesce window. Called on a DEFERRED macrotask after a
// connectiondrop, so the trailing connectionremoved + connectioncreated writeBacks (which
// the classic preset fires AFTER the drop) have all run with the window still open
// (suppressing their per-event pushHistory, flagging reconnectDidWriteBack). Pushes the
// SINGLE pre-gesture snapshot iff the gesture actually changed the graph, then resets the
// per-gesture state. Idempotent + gated on the one-shot scheduled flag so a re-pick can
// cancel a pending close.
const closeReconnectGesture = () => {
if (!reconnectCloseScheduled) return;
reconnectCloseScheduled = false;
if (reconnectInFlight > 0) reconnectInFlight = 0;
if (!programmatic && props.history !== false && reconnectDidWriteBack && reconnectPreSnapshot) {
pushHistorySnapshot(reconnectPreSnapshot);
}
reconnectPreSnapshot = null;
reconnectDidWriteBack = false;
};
// Schedule the deferred close on a macrotask (setTimeout 0) — runs after the synchronous +
// microtask writeBack signals settle. Falls back to a microtask where setTimeout is absent.
// Schedule the deferred close on a macrotask (setTimeout 0) — runs after the synchronous +
// microtask writeBack signals settle. Falls back to a microtask where setTimeout is absent.
const scheduleReconnectClose = () => {
if (reconnectCloseScheduled) return;
reconnectCloseScheduled = true;
if (typeof setTimeout === 'function') setTimeout(closeReconnectGesture, 0);else Promise.resolve().then(closeReconnectGesture);
};
// T1.3 — restore a captured snapshot by writing a FRESH `{ nodes, connections }` via
// `commitGraph` (→ `$model.graph`), wrapped in the `programmatic` guard so the consumer's
// re-bind → $watch(graph) → reconcile applies it WITHOUT re-entering history (D-04 —
// pushHistory / the write-back helpers all bail while `programmatic` is raised). Recorded
// in `lastWrittenGraph` so a following undo/redo sees the restored state as "current".
// Graph-ONLY (D-03): the viewport transform is untouched.
// T1.3 — restore a captured snapshot by writing a FRESH `{ nodes, connections }` via
// `commitGraph` (→ `$model.graph`), wrapped in the `programmatic` guard so the consumer's
// re-bind → $watch(graph) → reconcile applies it WITHOUT re-entering history (D-04 —
// pushHistory / the write-back helpers all bail while `programmatic` is raised). Recorded
// in `lastWrittenGraph` so a following undo/redo sees the restored state as "current".
// Graph-ONLY (D-03): the viewport transform is untouched.
const restoreGraph = (snap: any) => {
if (!snap) return;
// Cancel any in-flight drag write-back so a queued frame can't clobber the restore with
// a stale position after the programmatic guard releases.
pendingDragPositions.clear();
if (dragFlushRaf) {
if (typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(dragFlushRaf);
} catch (e: any) {}
}
dragFlushRaf = 0;
}
programmatic++;
try {
const fresh = {
nodes: (snap.nodes || []).map((n: any) => ({
...n
})),
connections: (snap.connections || []).map((c: any) => ({
...c
}))
};
commitGraph(fresh);
} finally {
programmatic--;
}
};
// undo() — pop the newest PRE-gesture snapshot, push the CURRENT graph onto the redo
// stack, and restore the snapshot. No-op when nothing to undo.
// undo() — pop the newest PRE-gesture snapshot, push the CURRENT graph onto the redo
// stack, and restore the snapshot. No-op when nothing to undo.
const undo = () => {
if (historyStack.length === 0) return;
const cur = snapshotCurrent();
const snap = historyStack.pop();
if (cur) redoStack.push(cur);
restoreGraph(snap);
};
// redo() — pop the newest redo snapshot, push the CURRENT graph back onto the undo
// stack, and restore it. No-op when nothing to redo.
// redo() — pop the newest redo snapshot, push the CURRENT graph back onto the undo
// stack, and restore it. No-op when nothing to redo.
const redo = () => {
if (redoStack.length === 0) return;
const cur = snapshotCurrent();
const snap = redoStack.pop();
if (cur) historyStack.push(cur);
restoreGraph(snap);
};
const canUndo = () => historyStack.length > 0;
const canRedo = () => redoStack.length > 0;
// Flush the coalesced drag positions: one fresh graph object with every pending
// node's x/y applied. Echo-guarded. Clears the pending map.
// Flush the coalesced drag positions: one fresh graph object with every pending
// node's x/y applied. Echo-guarded. Clears the pending map.
const flushDragWriteBack = () => {
dragFlushRaf = 0;
if (programmatic) {
pendingDragPositions.clear();
return;
}
if (pendingDragPositions.size === 0) return;
const g = baseGraph();
const nodes = (g.nodes || []).map((n: any) => {
const p = n && n.id != null ? pendingDragPositions.get(n.id) : null;
return p ? {
...n,
x: p.x,
y: p.y
} : n;
});
pendingDragPositions.clear();
commitGraph({
...g,
nodes
});
};
// Schedule a coalesced drag write-back (rAF; falls back to a microtask where rAF is
// unavailable — e.g. a non-DOM test env).
// Schedule a coalesced drag write-back (rAF; falls back to a microtask where rAF is
// unavailable — e.g. a non-DOM test env).
const scheduleDragFlush = () => {
if (dragFlushRaf) return;
if (typeof requestAnimationFrame === 'function') {
dragFlushRaf = requestAnimationFrame(flushDragWriteBack);
} else {
dragFlushRaf = 1;
Promise.resolve().then(flushDragWriteBack);
}
};
// CONNECT — append a fresh connection into a fresh graph object. Echo-guarded.
// CONNECT — append a fresh connection into a fresh graph object. Echo-guarded.
const writeBackConnectionCreated = (c: any) => {
if (programmatic) return;
// T1.3 — one history entry per CONNECT gesture (BEFORE the write so the snapshot is the
// pre-connect state — snapshotCurrent reads lastWrittenGraph, still the pre-connect value).
// T2.5 — SUPPRESS while a reconnect is in flight: the paired remove+add of a reconnect
// (and a plain new-connection drag, which also rides connectionpick/drop) push ONE
// coalesced snapshot on connectiondrop instead (D-03 one-gesture-one-entry).
if (reconnectInFlight) reconnectDidWriteBack = true;else pushHistory();
const g = baseGraph();
const conn = {
id: c.id,
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
};
commitGraph({
...g,
connections: [...(g.connections || []), conn]
});
};
// DISCONNECT — filter the id out into a fresh graph object. Echo-guarded.
// DISCONNECT — filter the id out into a fresh graph object. Echo-guarded.
const writeBackConnectionRemoved = (id: any) => {
if (programmatic) return;
// T1.3 — one history entry per DISCONNECT / edge-delete gesture (BEFORE the write).
// T2.5 — SUPPRESS while a reconnect is in flight: the remove half of a reconnect is
// coalesced with its paired add into ONE snapshot pushed on connectiondrop (D-03).
if (reconnectInFlight) reconnectDidWriteBack = true;else pushHistory();
const g = baseGraph();
commitGraph({
...g,
connections: (g.connections || []).filter((e: any) => e && e.id !== id)
});
};
// T1.1 — EDGE SELECTION helpers (D-08). Selection state is kept PURELY in script
// (selectedConnId / selectedPathEl) and surfaced to the consumer via @edge-click /
// @edge-selected — never written into $model.graph (echo-safe like selectedNodeIds).
//
// `clearEdgeSelection` drops `.is-selected` from the live <path> (if still attached) and
// nulls the selection. `selectEdge` is invoked from the per-edge pointerup listener: it
// clears any prior selection, marks THIS path `.is-selected`, records the id + element,
// raises the one-shot `edgeClickGuard` (so the area's own background-pointerup branch
// does not immediately clear what this click just selected — the same pointerup gesture
// fires on the path AND lets the area pipe run), and emits BOTH @edge-click and
// @edge-selected ({ id }). The guard self-resets on the next microtask once the gesture
// has settled.
// T1.1 — EDGE SELECTION helpers (D-08). Selection state is kept PURELY in script
// (selectedConnId / selectedPathEl) and surfaced to the consumer via @edge-click /
// @edge-selected — never written into $model.graph (echo-safe like selectedNodeIds).
//
// `clearEdgeSelection` drops `.is-selected` from the live <path> (if still attached) and
// nulls the selection. `selectEdge` is invoked from the per-edge pointerup listener: it
// clears any prior selection, marks THIS path `.is-selected`, records the id + element,
// raises the one-shot `edgeClickGuard` (so the area's own background-pointerup branch
// does not immediately clear what this click just selected — the same pointerup gesture
// fires on the path AND lets the area pipe run), and emits BOTH @edge-click and
// @edge-selected ({ id }). The guard self-resets on the next microtask once the gesture
// has settled.
const clearEdgeSelection = () => {
if (selectedPathEl && selectedPathEl.classList) {
try {
selectedPathEl.classList.remove('is-selected');
} catch (e: any) {}
}
selectedConnId = null;
selectedPathEl = null;
};
const selectEdge = (id: any, pathEl: any) => {
if (id == null) return;
clearEdgeSelection();
selectedConnId = id;
selectedPathEl = pathEl;
if (pathEl && pathEl.classList) {
try {
pathEl.classList.add('is-selected');
} catch (e: any) {}
}
edgeClickGuard = true;
Promise.resolve().then(() => {
edgeClickGuard = false;
});
emit('edge-click', {
id
});
emit('edge-selected', {
id
});
};
// CASCADING DELETE (the PUBLIC controlled-graph node delete — Win 1). Distinct from
// the engine-only `removeNode` $expose verb: `removeNode` operates directly on the
// editor and is NOT written back to the model (the provenance-tracked imperative
// escape hatch); `deleteNode` is the BLESSED controlled-graph delete — it filters the
// node AND every incident connection out of FRESH arrays and writes ONE fresh
// top-level `{ ...g, nodes, connections }` object via `$model.graph` (the Phase-41
// write-back contract — in-place mutation is silently dropped on React/Solid/Lit/
// Angular). The wrapper's own `$watch(graph)` reconcile then reaps the live engine
// node + edges — we do NOT call editor.removeNode here (a double-remove would race the
// reconcile into Rete's "cannot find node"; the controlled-model filter is the single
// removal path). NOT echo-guarded with `programmatic` — this is a CONSUMER-driven write
// that SHOULD update the bound model (mirrors the demo's per-node ✕ filter). Returns
// true if a node was removed. The id-coerce-to-String mirrors the demo's onRemoveClick.
// CASCADING DELETE (the PUBLIC controlled-graph node delete — Win 1). Distinct from
// the engine-only `removeNode` $expose verb: `removeNode` operates directly on the
// editor and is NOT written back to the model (the provenance-tracked imperative
// escape hatch); `deleteNode` is the BLESSED controlled-graph delete — it filters the
// node AND every incident connection out of FRESH arrays and writes ONE fresh
// top-level `{ ...g, nodes, connections }` object via `$model.graph` (the Phase-41
// write-back contract — in-place mutation is silently dropped on React/Solid/Lit/
// Angular). The wrapper's own `$watch(graph)` reconcile then reaps the live engine
// node + edges — we do NOT call editor.removeNode here (a double-remove would race the
// reconcile into Rete's "cannot find node"; the controlled-model filter is the single
// removal path). NOT echo-guarded with `programmatic` — this is a CONSUMER-driven write
// that SHOULD update the bound model (mirrors the demo's per-node ✕ filter). Returns
// true if a node was removed. The id-coerce-to-String mirrors the demo's onRemoveClick.
const deleteNode = (id: any) => {
if (id == null) return false;
const g = baseGraph();
const sid = String(id);
const nodes = (g.nodes || []).filter((n: any) => n && String(n.id) !== sid);
if (nodes.length === (g.nodes || []).length) return false;
const connections = (g.connections || []).filter((c: any) => c && String(c.source) !== sid && String(c.target) !== sid);
// T1.3 — one history entry per DELETE gesture (node + its incident edges = ONE undo).
pushHistory();
commitGraph({
...g,
nodes,
connections
});
return true;
};
// T2.8 — a fresh unique node id for a duplicated node. Derived from the source id + an
// incrementing suffix, skipping any id already present in the live graph so a repeated
// duplicate never collides (Threat T-44-06-2: a NEW unique id, never a forged/colliding
// one). String ids only (mirrors the graph contract).
// T2.8 — a fresh unique node id for a duplicated node. Derived from the source id + an
// incrementing suffix, skipping any id already present in the live graph so a repeated
// duplicate never collides (Threat T-44-06-2: a NEW unique id, never a forged/colliding
// one). String ids only (mirrors the graph contract).
const freshNodeId = (baseId: any, existing: any) => {
const taken = new Set((existing || []).map((n: any) => n && n.id != null ? String(n.id) : ''));
const root = baseId != null ? String(baseId) : 'node';
let i = 1;
let candidate = root + '-copy';
while (taken.has(candidate)) {
i++;
candidate = root + '-copy-' + i;
}
return candidate;
};
// T2.8 — DUPLICATE the given node: clone its spec at a small offset with a NEW unique id
// into a FRESH `{ ...g, nodes:[...g.nodes, clone] }` object (the controlled-graph write-back
// contract — never an in-place push). The clone's `data` is deep-cloned ($clone strips
// any reactivity proxy) so the copy is independent of the source. Connections are NOT cloned
// (a duplicate is an isolated node — the React-Flow default). One history entry per
// duplicate gesture (pushHistory, gated on !programmatic + history). Returns the new id, or
// null if the source isn't found. NOT echo-guarded — a duplicate SHOULD update the model.
// T2.8 — DUPLICATE the given node: clone its spec at a small offset with a NEW unique id
// into a FRESH `{ ...g, nodes:[...g.nodes, clone] }` object (the controlled-graph write-back
// contract — never an in-place push). The clone's `data` is deep-cloned ($clone strips
// any reactivity proxy) so the copy is independent of the source. Connections are NOT cloned
// (a duplicate is an isolated node — the React-Flow default). One history entry per
// duplicate gesture (pushHistory, gated on !programmatic + history). Returns the new id, or
// null if the source isn't found. NOT echo-guarded — a duplicate SHOULD update the model.
const duplicateNode = (id: any) => {
if (id == null) return null;
const g = baseGraph();
const sid = String(id);
const src = (g.nodes || []).find((n: any) => n && String(n.id) === sid);
if (!src) return null;
const newId = freshNodeId(src.id, g.nodes);
// Phase 45-07 (WR-02/WR-06): `$clone` is now a recursive proxy-safe deep clone
// on every target (Vue's lowering de-proxies nested reactive members via the
// `rozieDeepClone` runtime helper). The historical `$clone({ d: src.data }).d`
// object-literal wrapper — which never actually dodged the old single-toRaw
// throw on a live nested proxy — is no longer needed; clone `src.data` directly.
const clonedData = src.data != null ? rozieDeepClone(src.data) : undefined;
const clone = {
...src,
id: newId,
x: (typeof src.x === 'number' ? src.x : 0) + 28,
y: (typeof src.y === 'number' ? src.y : 0) + 28,
data: clonedData
};
pushHistory();
commitGraph({
...g,
nodes: [...(g.nodes || []), clone]
});
return newId;
};
// Collect the currently-SELECTED node ids from the live selector (Win 1 + Win 2). The
// AreaExtensions.selector() `entities` Map holds the picked entities ({ label, id });
// for selectable nodes each entity's `id` is the node id. Empty when nothing is picked
// or selection is disabled. Read-only — no $data / engine write.
// Collect the currently-SELECTED node ids from the live selector (Win 1 + Win 2). The
// AreaExtensions.selector() `entities` Map holds the picked entities ({ label, id });
// for selectable nodes each entity's `id` is the node id. Empty when nothing is picked
// or selection is disabled. Read-only — no $data / engine write.
const selectedNodeIds = () => {
if (!selector || !selector.entities) return [];
const ids = [];
for (const e of selector.entities.values() as any) {
if (e && e.id != null) ids.push(e.id);
}
return ids;
};
// Win 2: surface selection changes to the consumer via @selection-change ({ ids }).
// Computes the current selected-id set, dedupes against the last-emitted set (joined
// string), and emits only on an ACTUAL change. Echo-guarded by `programmatic` so a
// PROGRAMMATIC unselect (clear/deleteNode may unpick) does not surface as a user
// selection. Selection is kept PURELY in the emit — never written into the graph model
// — so the controlled-graph echo-safety (the drag write-back assertions) is unaffected.
// Sorted before joining so the dedup key is order-independent (the selector Map order
// is not guaranteed stable across pick/unpick).
// Win 2: surface selection changes to the consumer via @selection-change ({ ids }).
// Computes the current selected-id set, dedupes against the last-emitted set (joined
// string), and emits only on an ACTUAL change. Echo-guarded by `programmatic` so a
// PROGRAMMATIC unselect (clear/deleteNode may unpick) does not surface as a user
// selection. Selection is kept PURELY in the emit — never written into the graph model
// — so the controlled-graph echo-safety (the drag write-back assertions) is unaffected.
// Sorted before joining so the dedup key is order-independent (the selector Map order
// is not guaranteed stable across pick/unpick).
const maybeEmitSelectionChange = () => {
if (programmatic) return;
const ids = selectedNodeIds();
const key = [...ids].map((x: any) => String(x)).sort().join(' ');
if (key === lastSelectionIds) return;
lastSelectionIds = key;
emit('selection-change', {
ids
});
// the selected set changed → repaint the minimap (selected nodes are highlighted).
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
// T2.8 — the selection changed → re-track the NodeToolbar (it follows the single
// selected node; hides on multi-select / empty selection). No-op when :node-toolbar off.
if (syncToolbarSelection) syncToolbarSelection();
};
// Schedule the selection recompute AFTER the engine's own async selection update has
// settled. AreaExtensions.selectableNodes does its pick / unselectAll via AWAITED
// area.update() calls, so a bare microtask can run before `selector.entities` reflects
// the new state. A microtask AND an rAF together guarantee we recompute once the engine
// chain has flushed (the dedup collapses the pair to at most one emit). Falls back to a
// double microtask where rAF is unavailable (non-DOM test env).
// Schedule the selection recompute AFTER the engine's own async selection update has
// settled. AreaExtensions.selectableNodes does its pick / unselectAll via AWAITED
// area.update() calls, so a bare microtask can run before `selector.entities` reflects
// the new state. A microtask AND an rAF together guarantee we recompute once the engine
// chain has flushed (the dedup collapses the pair to at most one emit). Falls back to a
// double microtask where rAF is unavailable (non-DOM test env).
const scheduleSelectionEmit = () => {
Promise.resolve().then(maybeEmitSelectionChange);
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(maybeEmitSelectionChange);
} else {
Promise.resolve().then(() => Promise.resolve().then(maybeEmitSelectionChange));
}
};
// The $portals/$emit-capturing reconcilers are built INSIDE $onMount ($portals
// referenced at top level fails the bundled-leaf strict typecheck — the CM/
// TipTap/MapLibre portal discipline) and bridged here so the top-level $watch can
// call them.
// The $portals/$emit-capturing reconcilers are built INSIDE $onMount ($portals
// referenced at top level fails the bundled-leaf strict typecheck — the CM/
// TipTap/MapLibre portal discipline) and bridged here so the top-level $watch can
// call them.
let reconcileNodes: any = null;
let reconcileConnections: any = null;
// Re-entrancy guard for reconcileNodes. The declarative-children path can fire the
// node reconcile RE-ENTRANTLY on async-context targets (Lit): a <FlowNode>'s
// $onMount register starts reconcile #1, and its late-context $onUpdate registration
// (REQ-30) — or the registry $watch the register triggers — starts reconcile #2 while
// #1's awaits (editor.addNode / area.translate / area.update) are still pending. Two
// overlapping reconciles racing the same engine throw Rete's "cannot find node" (one
// updates/translates a node-view the other just rebuilt), which aborts the whole graph
// build (only the config-array `cfg` node survives on Lit). This flag serializes them:
// a reconcile requested while one is running sets a "run again" bit and returns; the
// in-flight reconcile re-runs once it finishes, so every registry mutation is folded
// into a fresh non-overlapping pass. The config-array-only path never re-enters (props
// change once per tick), so this is byte-transparent to its behavior.
// Re-entrancy guard for reconcileNodes. The declarative-children path can fire the
// node reconcile RE-ENTRANTLY on async-context targets (Lit): a <FlowNode>'s
// $onMount register starts reconcile #1, and its late-context $onUpdate registration
// (REQ-30) — or the registry $watch the register triggers — starts reconcile #2 while
// #1's awaits (editor.addNode / area.translate / area.update) are still pending. Two
// overlapping reconciles racing the same engine throw Rete's "cannot find node" (one
// updates/translates a node-view the other just rebuilt), which aborts the whole graph
// build (only the config-array `cfg` node survives on Lit). This flag serializes them:
// a reconcile requested while one is running sets a "run again" bit and returns; the
// in-flight reconcile re-runs once it finishes, so every registry mutation is folded
// into a fresh non-overlapping pass. The config-array-only path never re-enters (props
// change once per tick), so this is byte-transparent to its behavior.
let reconcileNodesRunning = false;
let reconcileNodesPending = false;
// ── pure helpers (no sigils → safe at top level) ──
// ── pure helpers (no sigils → safe at top level) ──
const serializeConn = (c: any) => ({
id: c.id,
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
});
// Resolve a node TYPE's port schema from the flat per-TYPE portReg — the entries
// whose key starts `type + '::'`. Returns { inputs:[{key,label,multiple,portType}],
// outputs:[…] }. Pure (no $data write) so buildNode / buildSocketRow can call it on
// every run regardless of the order the <NodeType> vs its <Port> children registered.
// Resolve a node TYPE's port schema from the flat per-TYPE portReg — the entries
// whose key starts `type + '::'`. Returns { inputs:[{key,label,multiple,portType}],
// outputs:[…] }. Pure (no $data write) so buildNode / buildSocketRow can call it on
// every run regardless of the order the <NodeType> vs its <Port> children registered.
const portSchemaForType = (type: any, portReg: any) => {
const inputs = [];
const outputs = [];
if (type == null || !portReg) return {
inputs,
outputs
};
const prefix = type + '::';
for (const k in portReg) {
if (k.indexOf(prefix) !== 0) continue;
const p = portReg[k];
if (!p || p.key == null) continue;
const entry = {
key: p.key,
label: p.label,
multiple: p.multiple,
portType: p.portType
};
if (p.side === 'input') inputs.push(entry);else outputs.push(entry);
}
return {
inputs,
outputs
};
};
// Build a live Rete node from a graph-node spec ({ id, type, x, y, data }). The
// consumer's `id` is assigned onto the node so positions, portal keys, and
// connection source/target ids all align with the author's identifiers (Rete would
// otherwise auto-generate ids). Sockets come from the node's TYPE port schema
// (portReg keyed `type::side::key`) — a type's ports declared ONCE apply to every
// instance (render-by-type). The single shared SOCKET still gates compatibility by
// identity; the per-port `portType` drives typed VALIDATION, not socket identity.
// Build a live Rete node from a graph-node spec ({ id, type, x, y, data }). The
// consumer's `id` is assigned onto the node so positions, portal keys, and
// connection source/target ids all align with the author's identifiers (Rete would
// otherwise auto-generate ids). Sockets come from the node's TYPE port schema
// (portReg keyed `type::side::key`) — a type's ports declared ONCE apply to every
// instance (render-by-type). The single shared SOCKET still gates compatibility by
// identity; the per-port `portType` drives typed VALIDATION, not socket identity.
const buildNode = (spec: any, portReg: any) => {
const label = spec.data && spec.data.label != null ? String(spec.data.label) : '';
const node = new ClassicPreset.Node(label);
node.id = spec.id;
const {
inputs,
outputs
} = portSchemaForType(spec.type, portReg);
for (const inp of inputs as any) {
if (!inp || inp.key == null) continue;
node.addInput(inp.key, new ClassicPreset.Input(SOCKET, inp.label, inp.multiple === true));
}
for (const out of outputs as any) {
if (!out || out.key == null) continue;
node.addOutput(out.key, new ClassicPreset.Output(SOCKET, out.label, out.multiple !== false));
}
return node;
};
// NOTE: portTypeOf (the validation-pipe port-type resolver) is DEFINED INSIDE
// $onMount (next to the editor.addPipe that uses it), NOT here at top level. It reads
// $data.portReg, and a top-level definition lowers on React to a `useCallback` whose
// captured `portReg` is FROZEN at the snapshot when the validation pipe (set up once in
// the mount effect) was created — i.e. the INITIAL empty {} before any <Port> registered.
// A stale-empty portReg makes portTypeOf return null for every port, so the typed-socket
// validation `srcType != null && tgtType != null && srcType !== tgtType` check is SKIPPED
// and a cross-type connection is WRONGLY ALLOWED (the React-only "reject didn't fire" bug
// the advanced VR cell surfaced). Defined inside $onMount, the emitter lowers its
// $data.portReg read to the live `_portRegRef.current` (the same ref the reconcilers use),
// so validation always sees the current schema. The 5 non-React targets read live signals
// so they were correct either way; this is the React stale-closure fix (the MapLibre/PDF
// $watch-reroute lesson, here as a mount-scoped definition). ZERO emitter change.
// ─── per-TYPE registry (Phase 41 controlled-graph — the per-TYPE shift of the
// Phase 37 per-instance $provide/$inject dogfood) ────────────────────────────────
// The 'rete:canvas' registry API CONSUMED BY <NodeType>/<Port> (41-03). CRITICAL
// reactive-write discipline (Pitfall 1): every mutation WHOLE-OBJECT-REPLACES the
// registry so the watched $data.typeReg/$data.portReg reference changes exactly once
// per call — a bare in-place $data.typeReg[type] = spec is silent on React/Solid/
// Angular/Lit. THE CROSS-PLAN CONTRACT (41-03 calls EXACTLY these verbs):
// registerType(type, spec) → type-template registry (<NodeType>)
// unregisterType(type) → drop a type on <NodeType> unmount
// addTypePort(type, side, key, portType, label, multiple) → per-TYPE port schema (<Port>)
// bodyHostFor(nodeId) → the engine `body` host div
// (render-by-type callback target)
// ─── imperative handle (Phase 21 $expose) ────────────────────────────────────
// Collision discipline (ROZ121/ROZ524/Lit-lifecycle):
// - NO `setZoom` — `zoom` is a model prop, so React auto-generates a `setZoom`
// state setter (the MapLibre setCenter/setZoom lesson); the verb is `zoomTo`.
// - NONE equals a Lit reserved lifecycle name (update/render/firstUpdated/
// updated/willUpdate/requestUpdate) — note `clear` and `getNodes` are safe.
// - NONE equals an emitted event name (node-moved/node-picked/connection-*
// /translated/context-menu/node-action) or a prop name.
// addNode/addConnection/removeNode/removeConnection operate on the engine
// directly and are NOT reaped by props reconcile (provenance-tracked).
function getEditor() {
return editor;
}
function getArea() {
return area;
}
async function addNode(spec: any) {
if (!editor || !spec || spec.id == null) return null;
const node = buildNode(spec, portReg.value);
nodeInstances.set(spec.id, node);
nodeMeta.set(spec.id, spec);
programmatic++;
try {
await editor.addNode(node);
await area.translate(spec.id, {
x: spec.x || 0,
y: spec.y || 0
});
} finally {
programmatic--;
}
return spec.id;
}
async function removeNode(id: any) {
if (!editor || id == null || !nodeInstances.has(id)) return false;
programmatic++;
try {
for (const c of editor.getConnections() as any) {
if (c.source === id || c.target === id) await editor.removeConnection(c.id);
}
await editor.removeNode(id);
} finally {
programmatic--;
}
nodeInstances.delete(id);
nodeMeta.delete(id);
return true;
}
async function addConnection(spec: any) {
if (!editor || !spec || spec.source == null || spec.target == null) return null;
const srcOut = spec.sourceOutput != null ? spec.sourceOutput : 'out';
const tgtIn = spec.targetInput != null ? spec.targetInput : 'in';
const sourceNode = nodeInstances.get(spec.source);
const targetNode = nodeInstances.get(spec.target);
if (!sourceNode || !targetNode) return null;
const conn = new ClassicPreset.Connection(sourceNode, srcOut, targetNode, tgtIn);
if (spec.id != null) conn.id = spec.id;
programmatic++;
try {
await editor.addConnection(conn);
} finally {
programmatic--;
}
connInstances.set(conn.id, conn);
return conn.id;
}
async function removeConnection(id: any) {
if (!editor || id == null) return false;
programmatic++;
try {
await editor.removeConnection(id);
} finally {
programmatic--;
}
connInstances.delete(id);
return true;
}
async function clear() {
if (!editor) return;
programmatic++;
try {
await editor.clear();
} finally {
programmatic--;
}
nodeInstances.clear();
nodeMeta.clear();
connInstances.clear();
connMeta.clear();
lastPropNodeIds = [];
lastPropConnIds = [];
}
async function zoomToFit() {
if (!area || !editor) return;
programmatic++;
try {
await AreaExtensions.zoomAt(area, editor.getNodes());
} finally {
programmatic--;
}
const k = area.area.transform.k;
if (k !== zoom.value) zoom.value = k;
}
async function zoomTo(k: any) {
if (!area || typeof k !== 'number') return;
programmatic++;
try {
await area.area.zoom(k);
} finally {
programmatic--;
}
if (k !== zoom.value) zoom.value = k;
}
// ─── viewport API (Phase 42 — the T11 gap + what the pannable minimap needs) ─────
// Both write the AreaPlugin transform via the CONFIRMED Rete v2 area API: with the
// origin omitted `area.area.zoom(k)` leaves x/y unchanged (transform.x += 0·d), and
// `area.area.translate(x, y)` sets the pan ABSOLUTELY (verified against rete-area-
// plugin@2.1.5). Echo-guarded with `programmatic` so the transform write doesn't loop
// back through the zoomed/nodetranslated write-back (the `translated` emit stays
// UNCONDITIONAL, so @translated still surfaces a programmatic recenter — a real
// viewport change the consumer asked for). After, echo `$model.zoom` (mirrors zoomTo).
// Collision discipline: setCenter/setViewport are NOT Lit lifecycle names, NOT emit
// names, NOT prop names, NOT React model-setters (`graph`/`zoom` → setGraph/setZoom),
// and NOT inherited DOM methods (the Embla scrollTo lesson) — clean on all 6.
//
// setViewport({ x, y, k }) — set the raw transform (any field omitted keeps its
// current value).
// ─── viewport API (Phase 42 — the T11 gap + what the pannable minimap needs) ─────
// Both write the AreaPlugin transform via the CONFIRMED Rete v2 area API: with the
// origin omitted `area.area.zoom(k)` leaves x/y unchanged (transform.x += 0·d), and
// `area.area.translate(x, y)` sets the pan ABSOLUTELY (verified against rete-area-
// plugin@2.1.5). Echo-guarded with `programmatic` so the transform write doesn't loop
// back through the zoomed/nodetranslated write-back (the `translated` emit stays
// UNCONDITIONAL, so @translated still surfaces a programmatic recenter — a real
// viewport change the consumer asked for). After, echo `$model.zoom` (mirrors zoomTo).
// Collision discipline: setCenter/setViewport are NOT Lit lifecycle names, NOT emit
// names, NOT prop names, NOT React model-setters (`graph`/`zoom` → setGraph/setZoom),
// and NOT inherited DOM methods (the Embla scrollTo lesson) — clean on all 6.
//
// setViewport({ x, y, k }) — set the raw transform (any field omitted keeps its
// current value).
async function setViewport(vp: any) {
if (!area || !vp || typeof vp !== 'object') return;
const tf = area.area.transform;
const k = typeof vp.k === 'number' ? vp.k : tf.k;
const x = typeof vp.x === 'number' ? vp.x : tf.x;
const y = typeof vp.y === 'number' ? vp.y : tf.y;
programmatic++;
try {
if (k !== area.area.transform.k) await area.area.zoom(k);
await area.area.translate(x, y);
} finally {
programmatic--;
}
if (k !== zoom.value) zoom.value = k;
}
// setCenter(x, y, opts?) — center the viewport on graph-coords (x, y), optionally
// setting zoom (`opts.zoom`). The transform that puts graph point (x,y) at the canvas
// center is tx = W/2 − x·k, ty = H/2 − y·k (screen = graph·k + transform). W/H are the
// engine container's pixel dims (area.container — public on AreaPlugin, no $refs read).
// setCenter(x, y, opts?) — center the viewport on graph-coords (x, y), optionally
// setting zoom (`opts.zoom`). The transform that puts graph point (x,y) at the canvas
// center is tx = W/2 − x·k, ty = H/2 − y·k (screen = graph·k + transform). W/H are the
// engine container's pixel dims (area.container — public on AreaPlugin, no $refs read).
async function setCenter(x: any, y: any, opts: any) {
if (!area || typeof x !== 'number' || typeof y !== 'number') return;
const k = opts && typeof opts.zoom === 'number' ? opts.zoom : area.area.transform.k;
const el = area.container;
const cw = el && el.clientWidth ? el.clientWidth : 0;
const ch = el && el.clientHeight ? el.clientHeight : 0;
const tx = cw / 2 - x * k;
const ty = ch / 2 - y * k;
programmatic++;
try {
if (k !== area.area.transform.k) await area.area.zoom(k);
await area.area.translate(tx, ty);
} finally {
programmatic--;
}
if (k !== zoom.value) zoom.value = k;
}
// ─── built-in Controls overlay handlers (Win 4) ──────────────────────────────
// Wired to the in-template zoom in / out / fit buttons (gated r-if="$props.controls").
// They REUSE the zoomTo / zoomToFit verbs (one implementation — no logic duplication),
// clamping the step to [minZoom, maxZoom] so a button never exceeds the restrictor
// bounds. Zoom/fit are view-only, so they stay enabled even when readonly (they do not
// edit the graph). A no-op before the area mounts.
// ─── built-in Controls overlay handlers (Win 4) ──────────────────────────────
// Wired to the in-template zoom in / out / fit buttons (gated r-if="$props.controls").
// They REUSE the zoomTo / zoomToFit verbs (one implementation — no logic duplication),
// clamping the step to [minZoom, maxZoom] so a button never exceeds the restrictor
// bounds. Zoom/fit are view-only, so they stay enabled even when readonly (they do not
// edit the graph). A no-op before the area mounts.
const ZOOM_STEP = 1.2;
const clampZoom = (k: any) => {
let lo = typeof props.minZoom === 'number' && props.minZoom > 0 ? props.minZoom : 0.01;
let hi = typeof props.maxZoom === 'number' && props.maxZoom > 0 ? props.maxZoom : 100;
if (k < lo) return lo;
if (k > hi) return hi;
return k;
};
const controlZoomIn = () => {
if (!area) return;
zoomTo(clampZoom(area.area.transform.k * ZOOM_STEP));
};
const controlZoomOut = () => {
if (!area) return;
zoomTo(clampZoom(area.area.transform.k / ZOOM_STEP));
};
const controlFit = () => {
zoomToFit();
};
// T2.4 — the gated 4th Controls button toggles the two-way mode (pan ↔ select). Writes
// $model.mode (model:true); the consumer's r-model:mode (or the internal demo state) updates.
// T2.4 — the gated 4th Controls button toggles the two-way mode (pan ↔ select). Writes
// $model.mode (model:true); the consumer's r-model:mode (or the internal demo state) updates.
const toggleMode = () => {
mode.value = mode.value === 'select' ? 'pan' : 'select';
};
function getNodes() {
if (!area) return [];
const out = [];
for (const [id, node] of nodeInstances as any) {
const view = area.nodeViews.get(id);
out.push({
id,
label: node.label,
x: view ? view.position.x : 0,
y: view ? view.position.y : 0
});
}
return out;
}
function getConnections() {
return editor ? editor.getConnections().map(serializeConn) : [];
}
function getTransform() {
return area ? {
x: area.area.transform.x,
y: area.area.transform.y,
k: area.area.transform.k
} : null;
}
// screenToFlowPosition(clientX, clientY) → { x, y } in GRAPH coords (Phase 43 — the
// palette-drop / no-code-builder primitive, the React-Flow `screenToFlowPosition`
// parity). The INVERSE of the area transform: a graph point projects to the screen as
// `screen = containerOrigin + transform.{x,y} + graph·k`, so
// `graph = (client − containerOrigin − transform) / k`. `area.container` is public on
// the AreaPlugin (no $refs read). Returns null before the area mounts. The component
// owns ONLY this projection — the consumer owns the drag/drop (a palette item's
// `draggable` + the canvas `@dragover.prevent`/`@drop`) and writes the new node into the
// bound `graph` at the returned coords, exactly like React Flow (which does not own the
// palette either).
// screenToFlowPosition(clientX, clientY) → { x, y } in GRAPH coords (Phase 43 — the
// palette-drop / no-code-builder primitive, the React-Flow `screenToFlowPosition`
// parity). The INVERSE of the area transform: a graph point projects to the screen as
// `screen = containerOrigin + transform.{x,y} + graph·k`, so
// `graph = (client − containerOrigin − transform) / k`. `area.container` is public on
// the AreaPlugin (no $refs read). Returns null before the area mounts. The component
// owns ONLY this projection — the consumer owns the drag/drop (a palette item's
// `draggable` + the canvas `@dragover.prevent`/`@drop`) and writes the new node into the
// bound `graph` at the returned coords, exactly like React Flow (which does not own the
// palette either).
function screenToFlowPosition(clientX: any, clientY: any) {
if (!area || typeof clientX !== 'number' || typeof clientY !== 'number') return null;
const el = area.container;
const rect = el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null;
if (!rect) return null;
const t = area.area.transform;
const k = t.k || 1;
return {
x: (clientX - rect.left - t.x) / k,
y: (clientY - rect.top - t.y) / k
};
}
// T2.6 — autoArrange(opts?) — relayout the graph into a non-overlapping LAYERED arrangement
// (D-08, verb-only, NO auto-trigger — the MapLibre verb-first stance). Runs the
// AutoArrangePlugin (elkjs classic preset), then READS the arranged positions BACK into a
// FRESH `{ nodes, connections }` object written through `$model.graph` (the controlled-graph
// contract — the engine is never the source of truth, mirroring the drag write-back).
//
// PITFALL 3 (Plan 00 / RESEARCH): elkjs needs each node's `width`/`height`; our nodes are
// plain `ClassicPreset.Node` with no dimensions, so without dims the classic preset collapses
// every node to (0,0). We set `node.width`/`node.height` from the MEASURED engine node-view
// element (area.nodeViews.get(id).element offsetW/H — target-agnostic, the measureNodeSize
// discipline) BEFORE layout, falling back to MINIMAP_DEFAULT_NODE_W/H for Lit's unmeasured
// first paint. (measureNodeSize itself is $onMount-local; the verb is top-level, so the same
// measure is inlined here over the component-scope `area` + `nodeInstances`.)
//
// Echo-guarded (programmatic++ around layout AND the write-back) so the engine relayout and
// the resulting $model.graph re-bind → $watch(graph) → reconcile don't re-enter; ONE history
// snapshot is pushed for the whole gesture (D-03, gated on !programmatic + history). The
// optional `opts.options` (elk layout options — direction/spacing) is forwarded to
// arrange.layout() (D-01 discretion — default-only is fine; the arg stays optional).
//
// Collision discipline: `autoArrange` is NOT a Lit lifecycle name (update/render/firstUpdated/
// updated/willUpdate/requestUpdate), NOT an inherited DOM method (the Embla scrollTo lesson),
// NOT an emit (node-*/connection-*/translated/context-menu/selection-change/edge-*/node-action),
// NOT a prop, NOT a React model-setter (graph/zoom → setGraph/setZoom) — clean on all 6.
// T2.6 — autoArrange(opts?) — relayout the graph into a non-overlapping LAYERED arrangement
// (D-08, verb-only, NO auto-trigger — the MapLibre verb-first stance). Runs the
// AutoArrangePlugin (elkjs classic preset), then READS the arranged positions BACK into a
// FRESH `{ nodes, connections }` object written through `$model.graph` (the controlled-graph
// contract — the engine is never the source of truth, mirroring the drag write-back).
//
// PITFALL 3 (Plan 00 / RESEARCH): elkjs needs each node's `width`/`height`; our nodes are
// plain `ClassicPreset.Node` with no dimensions, so without dims the classic preset collapses
// every node to (0,0). We set `node.width`/`node.height` from the MEASURED engine node-view
// element (area.nodeViews.get(id).element offsetW/H — target-agnostic, the measureNodeSize
// discipline) BEFORE layout, falling back to MINIMAP_DEFAULT_NODE_W/H for Lit's unmeasured
// first paint. (measureNodeSize itself is $onMount-local; the verb is top-level, so the same
// measure is inlined here over the component-scope `area` + `nodeInstances`.)
//
// Echo-guarded (programmatic++ around layout AND the write-back) so the engine relayout and
// the resulting $model.graph re-bind → $watch(graph) → reconcile don't re-enter; ONE history
// snapshot is pushed for the whole gesture (D-03, gated on !programmatic + history). The
// optional `opts.options` (elk layout options — direction/spacing) is forwarded to
// arrange.layout() (D-01 discretion — default-only is fine; the arg stays optional).
//
// Collision discipline: `autoArrange` is NOT a Lit lifecycle name (update/render/firstUpdated/
// updated/willUpdate/requestUpdate), NOT an inherited DOM method (the Embla scrollTo lesson),
// NOT an emit (node-*/connection-*/translated/context-menu/selection-change/edge-*/node-action),
// NOT a prop, NOT a React model-setter (graph/zoom → setGraph/setZoom) — clean on all 6.
async function autoArrange(opts: any) {
if (!arrange || !area) return;
// Set elkjs dimensions on every live node instance from its measured node-view element
// (Pitfall 3) — without dims the classic preset stacks all nodes at (0,0).
for (const [id, node] of nodeInstances as any) {
const view = area.nodeViews ? area.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
node.width = el && el.offsetWidth ? el.offsetWidth : MINIMAP_DEFAULT_NODE_W;
node.height = el && el.offsetHeight ? el.offsetHeight : MINIMAP_DEFAULT_NODE_H;
}
// ONE history entry for the arrange gesture, captured BEFORE the write (pushHistory reads
// lastWrittenGraph, still the pre-arrange state). Gated on !programmatic + history.
pushHistory();
programmatic++;
try {
await arrange.layout(opts && opts.options ? {
options: opts.options
} : undefined);
} finally {
programmatic--;
}
// Read the arranged positions back into a FRESH graph object (controlled-graph contract).
// Echo-guarded: commitGraph → $model.graph re-bind must not re-enter the reconcile as a new
// gesture. (The arrange already moved the engine to these coords, so the reconcile is a
// no-op diff; the guard is belt-and-braces + suppresses any history re-entry.)
programmatic++;
try {
const g = baseGraph();
const nodes = (g.nodes || []).map((n: any) => {
const v = n && n.id != null && area.nodeViews ? area.nodeViews.get(n.id) : null;
return v && v.position ? {
...n,
x: v.position.x,
y: v.position.y
} : n;
});
commitGraph({
...g,
nodes
});
} finally {
programmatic--;
}
}
// ─── imperative selection control ────────────────────────────────────────────
// Selection was previously PUSH-ONLY (the `selection-change` emit fires on change,
// but a consumer couldn't READ or DRIVE selection). These reuse the internal
// `selector` / `nodeSelectApi` (AreaExtensions.selector + selectableNodes) already
// wired for the marquee — no new engine state. All no-op when selection is off
// (readonly / !selectable, when `nodeSelectApi` is null). Each schedules the same
// post-settle `selection-change` recompute the marquee uses, so an imperative
// select keeps the consumer's bound state in sync (the zoomTo→$model.zoom echo
// stance). Collision discipline: `selectNode` is NOT bare `select` — `select` is
// an inherited HTMLElement method (Lit shadow, the Embla scrollTo lesson) AND a
// FullCalendar-style emit hazard; getSelectedNodes/clearSelection/selectAll/
// centerOnNode are NOT emits (selection-change/node-*/edge-*), NOT props, NOT
// React model-setters (graph/zoom → setGraph/setZoom), NOT Lit lifecycle.
//
// getSelectedNodes() — the currently-selected nodes as { id, label, x, y } (the
// getNodes() shape, filtered to the live selection). Empty when nothing selected.
// ─── imperative selection control ────────────────────────────────────────────
// Selection was previously PUSH-ONLY (the `selection-change` emit fires on change,
// but a consumer couldn't READ or DRIVE selection). These reuse the internal
// `selector` / `nodeSelectApi` (AreaExtensions.selector + selectableNodes) already
// wired for the marquee — no new engine state. All no-op when selection is off
// (readonly / !selectable, when `nodeSelectApi` is null). Each schedules the same
// post-settle `selection-change` recompute the marquee uses, so an imperative
// select keeps the consumer's bound state in sync (the zoomTo→$model.zoom echo
// stance). Collision discipline: `selectNode` is NOT bare `select` — `select` is
// an inherited HTMLElement method (Lit shadow, the Embla scrollTo lesson) AND a
// FullCalendar-style emit hazard; getSelectedNodes/clearSelection/selectAll/
// centerOnNode are NOT emits (selection-change/node-*/edge-*), NOT props, NOT
// React model-setters (graph/zoom → setGraph/setZoom), NOT Lit lifecycle.
//
// getSelectedNodes() — the currently-selected nodes as { id, label, x, y } (the
// getNodes() shape, filtered to the live selection). Empty when nothing selected.
function getSelectedNodes() {
const sel = new Set(selectedNodeIds().map((x: any) => String(x)));
return getNodes().filter((n: any) => sel.has(String(n.id)));
}
// selectNode(id, accumulate?) — programmatically select a node (sidebar/search →
// highlight). accumulate=true adds to the current selection; falsy replaces it.
// selectNode(id, accumulate?) — programmatically select a node (sidebar/search →
// highlight). accumulate=true adds to the current selection; falsy replaces it.
function selectNode(id: any, accumulate: any) {
if (!nodeSelectApi || id == null) return;
nodeSelectApi.select(id, !!accumulate);
scheduleSelectionEmit();
}
// clearSelection() — unselect every selected node (and any selected edge).
// clearSelection() — unselect every selected node (and any selected edge).
function clearSelection() {
if (nodeSelectApi) {
for (const id of selectedNodeIds() as any) nodeSelectApi.unselect(id);
}
clearEdgeSelection();
scheduleSelectionEmit();
}
// selectAll() — select every node (Ctrl+A is not bound; marquee only covers a
// dragged region). Mirrors the marquee's first-replaces / rest-accumulate pattern.
// selectAll() — select every node (Ctrl+A is not bound; marquee only covers a
// dragged region). Mirrors the marquee's first-replaces / rest-accumulate pattern.
function selectAll() {
if (!nodeSelectApi) return;
let first = true;
for (const n of getNodes() as any) {
nodeSelectApi.select(n.id, !first);
first = false;
}
scheduleSelectionEmit();
}
// centerOnNode(id, opts?) — pan (and optionally zoom via opts.zoom) to center the
// viewport on a node by id. setCenter is coordinate-based; this measures the node
// to compute its center in GRAPH coords (position is the top-left; offsetW/H are
// unscaled graph units), falling back to the minimap default dims pre-measure.
// centerOnNode(id, opts?) — pan (and optionally zoom via opts.zoom) to center the
// viewport on a node by id. setCenter is coordinate-based; this measures the node
// to compute its center in GRAPH coords (position is the top-left; offsetW/H are
// unscaled graph units), falling back to the minimap default dims pre-measure.
async function centerOnNode(id: any, opts: any) {
if (!area || id == null) return;
const view = area.nodeViews ? area.nodeViews.get(id) : null;
if (!view || !view.position) return;
const el = view.element;
const w = el && el.offsetWidth ? el.offsetWidth : MINIMAP_DEFAULT_NODE_W;
const h = el && el.offsetHeight ? el.offsetHeight : MINIMAP_DEFAULT_NODE_H;
await setCenter(view.position.x + w / 2, view.position.y + h / 2, opts);
}
provide('rete:canvas', {
// Register/replace a node TYPE template. `spec` carries an optional
// `bodyRenderer(host, { node })` — the render-by-type projection (mounted per graph
// node of this type into the engine body host, see renderNode). Whole-object replace.
registerType: (type: any, spec: any) => {
if (type != null) typeReg.value = {
...typeReg.value,
[type]: spec
};
},
// Drop a type on <NodeType> unmount (whole-object replace).
unregisterType: (type: any) => {
const t = {
...typeReg.value
};
delete t[type];
typeReg.value = t;
},
// A <Port> registers a port against its TYPE + side. Stored in the flat portReg
// under a UNIQUE per-port key `type::side::key` so registration is order-independent
// AND concurrency-safe: two <Port>s of the same type addTypePort in one React commit,
// and a pure `{ ...portReg, [uniqueKey]: port }` write (functional setState) merges
// both (an array read-modify-write under one type key would clobber). buildNode reads
// the type's portReg entries on every run regardless of mount order. The unique key
// also makes a re-fired addTypePort (late Lit context) idempotent — same key, same value.
// `side` is derived by <Port> from which of output=/input= is set (output⇒'output', input⇒'input');
// `portType` carries the port type that drives validate-types + the typed-port color.
// `position` (F2) is the socket's VISUAL placement (left|right|top|bottom; default by
// side) — drives the render-pipe socket layout + the connection-anchor axis.
addTypePort: (type: any, side: any, key: any, portType: any, label: any, multiple: any, position: any) => {
if (type == null || key == null) return;
const portKey = type + '::' + side + '::' + key;
portReg.value = {
...portReg.value,
[portKey]: {
type,
side,
key,
portType,
label,
multiple,
position
}
};
},
// Render-by-type callback target. Returns the engine-created body host div for a
// graph node (nodeEntries.get(nodeId).body). The render-by-type projection mounts
// the node's TYPE template `#body` INTO this host via $portals — the Wave-0 A3
// finding (a Lit child cannot relocate its own shadow <slot> across the boundary),
// so the body is projected by the parent reusing the $portals host discipline.
bodyHostFor: (nodeId: any) => {
const entry = nodeEntries.get(nodeId);
return entry ? entry.body : null;
}
});
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portalContainers = new Set<HTMLElement>();
const portals = {
node: (container: HTMLElement, scope: { node: unknown; selected: unknown; emit: unknown }): ReactivePortalHandle => {
const slotFn = slots.node;
if (!slotFn) return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection. Cascades the @portal
// node { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-node', 'cd396d6a');
const renderScope = (s: unknown): void => {
render(h(Fragment, null, slotFn(s)), container);
};
renderScope(scope);
portalContainers.add(container);
return {
update: (s: unknown): void => renderScope(s),
dispose: (): void => {
render(null, container);
portalContainers.delete(container);
},
};
},
toolbar: (container: HTMLElement, scope: { node: unknown; emit: unknown }): ReactivePortalHandle => {
const slotFn = slots.toolbar;
if (!slotFn) return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection. Cascades the @portal
// toolbar { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-toolbar', 'cd396d6a');
const renderScope = (s: unknown): void => {
render(h(Fragment, null, slotFn(s)), container);
};
renderScope(scope);
portalContainers.add(container);
return {
update: (s: unknown): void => renderScope(s),
dispose: (): void => {
render(null, container);
portalContainers.delete(container);
},
};
},
};
onBeforeUnmount(() => {
for (const container of portalContainers) render(null, container);
portalContainers.clear();
});
let _cleanup_0: (() => void) | undefined;
onMounted(() => {
const container = canvasElRef.value;
lastPropNodeIds = [];
lastPropConnIds = [];
editor = new NodeEditor();
area = new AreaPlugin(container);
connectionPlugin = new ConnectionPlugin();
connectionPlugin.addPreset(ConnectionPresets.classic.setup());
// Resolve a port's VISUAL position (F2) from the per-TYPE port schema (portReg, keyed
// `type::side::key`), defaulting by DIRECTION (input → left, output → right) for exact
// back-compat. DEFINED HERE inside $onMount (NOT top level) so its $data.portReg read
// lowers on React to the live `_portRegRef.current`, not a stale-empty mount-time
// closure (the portTypeOf discipline). Used by both the socket-anchor offset below and
// renderNode's socket layout.
const resolvePortPosition = (type: any, side: any, key: any) => {
const entry = type != null && key != null ? portReg.value[type + '::' + side + '::' + key] : null;
const p = entry && entry.position != null ? entry.position : null;
if (p === 'left' || p === 'right' || p === 'top' || p === 'bottom') return p;
return side === 'input' ? 'left' : 'right';
};
// DOM-based socket position watcher — feeds connection-path redraw + the
// ConnectionPlugin's drag-to-connect hit-testing. A CUSTOM `offset` (F2): the rete
// default shifts the anchor 12px OUTWARD on the X axis only (`x + 12·(input?−1:1)`) —
// correct for left/right, wrong for top/bottom. We resolve each socket's visual
// position and shift on the matching axis (±x for left/right — IDENTICAL to the default,
// so the rete-flow-align cell stays green; ±y for top/bottom). The position is looked up
// live via nodeMeta→type→portReg, so it tracks late-registered ports.
const SOCKET_SHIFT = 12;
const socketOffset = (position: any, nodeId: any, side: any, key: any) => {
const meta = nodeMeta.get(nodeId);
const p = meta ? resolvePortPosition(meta.type, side, key) : side === 'input' ? 'left' : 'right';
if (p === 'top') return {
x: position.x,
y: position.y - SOCKET_SHIFT
};
if (p === 'bottom') return {
x: position.x,
y: position.y + SOCKET_SHIFT
};
if (p === 'left') return {
x: position.x - SOCKET_SHIFT,
y: position.y
};
return {
x: position.x + SOCKET_SHIFT,
y: position.y
};
};
socketWatcher = getDOMSocketPosition({
offset: socketOffset
});
editor.use(area);
area.use(connectionPlugin);
// ── T2.5 RECONNECT coalescing pipe (D-08 reconnectable edges, D-03 one-gesture-one-entry) ──
// `connectionpick` / `connectiondrop` are emitted on the ConnectionPlugin's OWN scope (they
// are NOT editor signals like connectioncreated/removed, nor area signals like nodepicked),
// so they must be observed via a pipe attached DIRECTLY to `connectionPlugin` — they do not
// propagate into editor.addPipe / area.addPipe. Grabbing an already-connected input socket
// fires connectionpick, then the classic preset removes the old edge + (on drop over a new
// socket) adds a new one — a remove+add pair that would push TWO history entries (Pitfall 2).
// We open a reconnect-in-flight window on connectionpick (capturing the PRE-gesture snapshot
// ONCE) and close it on connectiondrop (pushing that single snapshot iff the gesture actually
// changed the graph) — so the whole reconnect is ONE undoable step.
connectionPlugin.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectionpick') {
// Open the coalesce window + capture the pre-gesture snapshot once. Gated on
// !programmatic + history (a restore-driven engine op must not record history). A
// re-pick while a close is pending cancels the pending close (the gesture continues).
if (!programmatic && props.history !== false) {
reconnectInFlight++;
reconnectPreSnapshot = snapshotCurrent();
reconnectDidWriteBack = false;
reconnectCloseScheduled = false;
}
} else if (context.type === 'connectiondrop') {
// The gesture ended. CRITICAL ORDERING: the classic preset emits `connectiondrop`
// BEFORE the editor's `connectionremoved` / `connectioncreated` signals fire (the
// pseudo-connection is dropped, THEN the real add/remove run — verified in the event
// trace: drop → connectioncreate → connectioncreated → connectionremove →
// connectionremoved). So we must NOT close the window synchronously here, or the
// trailing writeBacks would run with inFlight=0 and each push its own (wrong) history
// entry. Instead DEFER the close to a macrotask (setTimeout 0), which runs after all
// the synchronous + microtask writeBack signals have settled. The window stays open
// across the remove+add (both suppress their per-event push, setting
// reconnectDidWriteBack), then closeReconnectGesture pushes the SINGLE pre-gesture
// snapshot iff the graph actually changed. Re-entrant picks can't desync because the
// close is gated on a one-shot scheduled flag.
scheduleReconnectClose();
// ── T2.7 CONNECT-END-ON-PANE (D-07, pure emit) ──
// A drag that STARTED on an output socket and ENDED on empty canvas (no target
// socket, no connection created) surfaces `@connect-end { source, sourceOutput,
// position }` so the consumer can run its OWN node-picker / create-node flow at the
// drop point (the n8n "drag off a port → drop on the pane → pick a node" UX). The
// component owns ONLY this hook — it creates NO node and shows NO picker (D-07,
// consumer-owns-creation, exactly like screenToFlowPosition + the palette drop).
// Detection: `socket == null` (released over the pane, not a socket) && `created ==
// false` (no edge was made) && `initial.side === 'output'` (we only surface OUTPUT-
// started drags — an input-started drag off the pane has no "source output" to seed
// a downstream node from, and the reconnect path already owns input-endpoint drags).
// Position = `area.area.pointer` (the AreaPlugin's live pointer, ALREADY in graph
// coords — the same origin screenToFlowPosition projects into), so no client→graph
// projection is needed; we still fall back to screenToFlowPosition over a raw
// clientX/clientY if a future plugin build stops tracking area.area.pointer. Gated on
// !programmatic so a restore/imperative-driven drop never emits. NO node is created.
const cd = context.data;
if (cd && !cd.socket && cd.created === false && cd.initial && cd.initial.side === 'output' && !programmatic) {
let pos: any = null;
const inner = area && area.area ? area.area : null;
if (inner && inner.pointer && typeof inner.pointer.x === 'number' && typeof inner.pointer.y === 'number') {
pos = {
x: inner.pointer.x,
y: inner.pointer.y
};
}
if ((!pos || typeof pos.x !== 'number' || typeof pos.y !== 'number') && cd.initial && cd.initial.element && typeof cd.initial.element.getBoundingClientRect === 'function') {
// Fallback: project the last-known pointer client coords through the shipped
// screenToFlowPosition (graph-coord inverse of the area transform). The drop event
// carries no pointer; use the source socket element's center as a degraded anchor.
const r = cd.initial.element.getBoundingClientRect();
pos = screenToFlowPosition(r.left + r.width / 2, r.top + r.height / 2);
}
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
emit('connect-end', {
source: cd.initial.nodeId,
sourceOutput: cd.initial.key,
position: {
x: pos.x,
y: pos.y
}
});
}
}
}
return context;
});
// The socket-position watcher (and, conceptually, our vanilla "render plugin")
// must attach to a CHILD scope of the area — `attach` calls
// `scope.parentScope(BaseAreaPlugin)`, which walks UP one level, so the scope's
// parent must BE the area. Attaching to `area` itself fails ("actual parent is
// not instance of type") because area's parent is the NodeEditor. So we add a
// minimal child Scope and attach the watcher to it. Rete forwards every area
// signal (render/nodetranslated/unmount/…) into this child's signal, so the
// watcher sees socket renders + node moves and recomputes socket positions.
renderScope = new Scope('rozie-vanilla-render');
area.use(renderScope);
socketWatcher.attach(renderScope);
// ── T2.6 auto-layout (D-08, verb-only) ──
// Wire the AutoArrangePlugin (elkjs classic preset) so the top-level autoArrange() verb
// can run a layered relayout on demand. area.use(arrange) installs it as an area-scope
// plugin; arrange.layout() mutates the engine node positions directly (calls area.translate
// internally). The verb reads the arranged positions BACK into a FRESH $model.graph (the
// controlled-graph contract — the engine is never the source of truth). NO auto-trigger —
// the consumer calls autoArrange() (the MapLibre verb-first stance).
arrange = new AutoArrangePlugin();
arrange.addPreset(ArrangePresets.classic.setup());
area.use(arrange);
// ── selection (selectableNodes) ──
// Capture the returned handle ({ select(id, accumulate), unselect(id) }) so the T2.4
// marquee can PROGRAMMATICALLY select each intersecting node (select(id, true) =
// accumulate). The handle is null when selection is off (readonly / !selectable), in
// which case the marquee branch no-ops.
if (props.selectable && !props.readonly) {
selector = AreaExtensions.selector();
nodeSelectApi = AreaExtensions.selectableNodes(area, selector, {
accumulating: props.accumulateOnCtrl ? AreaExtensions.accumulateOnCtrl() : {
active: () => false
}
});
}
// raise the picked node above its siblings.
AreaExtensions.simpleNodesOrder(area);
// ── zoom clamp (restrictor) ──
const min = typeof props.minZoom === 'number' && props.minZoom > 0 ? props.minZoom : 0;
const max = typeof props.maxZoom === 'number' && props.maxZoom > 0 ? props.maxZoom : 0;
if (min || max) {
AreaExtensions.restrictor(area, {
scaling: {
min: min || 0.01,
max: max || 100
}
});
}
// ── snap-to-grid ──
if (typeof props.snapGrid === 'number' && props.snapGrid > 0) {
AreaExtensions.snapGrid(area, {
size: props.snapGrid,
dynamic: true
});
}
// ── interaction toggles ──
if (!props.pannable) area.area.setDragHandler(null);
if (!props.zoomable) area.area.setZoomHandler(null);
// ── Delete / Backspace key → cascading delete of the selected node(s) (Win 1) ──
// Attached to the engine container ($refs.canvasEl, which carries tabindex="0" in
// the template so it can receive key focus) rather than `document`: the listener
// lives INSIDE the Lit shadow root alongside the canvas, so a canvas-focused key
// reaches it on Lit too (a `:target="document"` listener does not reliably see
// shadow-scoped focus across all 6 — the canvas-element listener is the robust
// cross-target path). Gated on selectable && !readonly. We guard against deleting
// while focus is in a node-body text field (INPUT/TEXTAREA/contenteditable) so
// typing in a node never nukes it. The listener is removed in the teardown.
if (props.selectable && !props.readonly && container && typeof container.addEventListener === 'function') {
onCanvasKeydown = (e: any) => {
if (!e) return;
const t = e.target;
// Focus-guard (verbatim with the Delete branch): never act while focus is in a
// node-body text field (INPUT/TEXTAREA/contenteditable) — Ctrl+Z must reach the
// browser's native text undo there, and Delete must not nuke the node.
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
// ── T1.3 — Undo / Redo keybinds (D-02). Ctrl/Cmd+Z → undo; Ctrl/Cmd+Shift+Z and
// Ctrl/Cmd+Y → redo. Gated on the SAME focus-guard as Delete. preventDefault so the
// browser's page-level undo doesn't also fire. `metaKey` covers macOS Cmd. ──
if ((e.ctrlKey || e.metaKey) && !e.altKey) {
const k = typeof e.key === 'string' ? e.key.toLowerCase() : '';
if (k === 'z' && !e.shiftKey) {
e.preventDefault();
undo();
return;
}
if (k === 'z' && e.shiftKey || k === 'y') {
e.preventDefault();
redo();
return;
}
}
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
const ids = selectedNodeIds();
if (ids.length > 0) {
e.preventDefault();
for (const id of ids as any) deleteNode(id);
return;
}
// T1.1 — EDGE DELETE (D-08). No node is picked but an edge is selected → remove
// exactly that edge via the controlled-graph write-back (the disconnect path: a
// fresh `{ ...g, connections: filtered }` object), then clear the selection. The
// wrapper's own $watch(graph) reconcile reaps the live engine connection (the
// single removal path — we do NOT also call editor.removeConnection, which would
// race the reconcile into "cannot find connection", mirroring deleteNode). Node
// delete takes precedence (handled above); this only runs when nothing's picked.
if (selectedConnId != null) {
e.preventDefault();
const id = selectedConnId;
clearEdgeSelection();
writeBackConnectionRemoved(id);
}
};
keydownContainer = container;
container.addEventListener('keydown', onCanvasKeydown);
}
// ─────────────────────────────────────────────────────────────────────────
// THE VANILLA RENDER PIPE. Intercepts the AreaPlugin's render/unmount signals.
// ALWAYS returns context (returning undefined would halt the signal chain and
// break the ConnectionPlugin / socket watcher downstream).
// ─────────────────────────────────────────────────────────────────────────
area.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'render') {
const data = context.data;
if (data.type === 'node') renderNode(data.element, data.payload);else if (data.type === 'connection') renderConnection(data.element, data.payload, data.start, data.end);
// data.type === 'socket' (our own re-emitted signals) falls through
// untouched so the ConnectionPlugin + socketWatcher consume them.
} else if (context.type === 'unmount') {
cleanupElement(context.data.element);
}
return context;
});
// ── node renderer ──
// Fills the engine-created nodeView element with: input sockets, the body
// (consumer `node` portal fragment OR default chrome), and output sockets.
// Re-render (area.update('node', id)) reuses the same element → update in place.
// NOTE: the engine-node parameter is `reteNode`, NOT `node` — on Svelte the
// `$slots.node` slot lowers to a top-level `const node`, and a parameter named
// `node` here would SHADOW it, so `if ($slots.node)` would read the (always-
// truthy) engine node and wrongly take the portal branch even when the slot is
// unfilled (dropping the default-chrome title). The cross-target slot-name ==
// local-binding shadow trap.
const renderNode = (element: any, reteNode: any) => {
// a (re)render means node DOM exists / changed → refresh the minimap (its node
// rects measure these elements; coalesced, so calling it on every render is cheap,
// and it covers Lit's measure-after-first-paint).
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
const id = reteNode.id;
const meta = nodeMeta.get(id) || {
id,
type: undefined,
data: {}
};
const existing = nodeEntries.get(id);
const selected = reteNode.selected === true;
// default-chrome fallback label (only when a node's type has no #body template).
const chromeLabel = meta.data && meta.data.label != null ? String(meta.data.label) : meta.type != null ? String(meta.type) : '';
if (existing && existing.element === element) {
// in-place update — refresh chrome + reactive portal scope, leave sockets.
existing.box.classList.toggle('is-selected', selected);
if (existing.handle) {
existing.handle.update({
node: meta,
selected,
emit: existing.emit
});
} else if (existing.titleEl) {
existing.titleEl.textContent = chromeLabel;
}
return;
}
// fresh build
element.innerHTML = '';
const box = document.createElement('div');
box.className = 'rozie-flow-node' + (selected ? ' is-selected' : '');
const body = document.createElement('div');
body.className = 'rozie-flow-node__body';
// ── socket layout (F2: position-aware) ───────────────────────────────────────
// Bucket the node's ports by VISUAL position (default input→left, output→right).
// When NO port is top/bottom (every pre-F2 graph), render the EXACT classic
// [inputsCol | body | outputsCol] 3-column structure — byte-identical DOM, so the
// FlowCanvasScreenshot pixel baseline is untouched. A node that declares ANY top/
// bottom port gets the 3-ROW structure (topRow / midRow[left|body|right] / bottomRow).
const socketDisposers = [];
const portEntries = [];
for (const key of Object.keys(reteNode.inputs) as any) portEntries.push({
side: 'input',
key,
position: resolvePortPosition(meta.type, 'input', key)
});
for (const key of Object.keys(reteNode.outputs) as any) portEntries.push({
side: 'output',
key,
position: resolvePortPosition(meta.type, 'output', key)
});
const hasVertical = portEntries.some((p: any) => p.position === 'top' || p.position === 'bottom');
if (!hasVertical) {
// CLASSIC left/right layout — byte-for-byte identical to pre-F2 (pixel-baseline safe).
const inputsCol = document.createElement('div');
inputsCol.className = 'rozie-flow-node__col rozie-flow-node__col--in';
const outputsCol = document.createElement('div');
outputsCol.className = 'rozie-flow-node__col rozie-flow-node__col--out';
box.appendChild(inputsCol);
box.appendChild(body);
box.appendChild(outputsCol);
element.appendChild(box);
for (const p of portEntries as any) {
renderSocketInto(p.position === 'right' ? outputsCol : inputsCol, reteNode, p.side, p.key, p.position, socketDisposers);
}
} else {
// VERTICAL-capable 3-row layout (only when a top/bottom port exists).
box.classList.add('rozie-flow-node--rows');
const topRow = document.createElement('div');
topRow.className = 'rozie-flow-node__row rozie-flow-node__row--top';
const midRow = document.createElement('div');
midRow.className = 'rozie-flow-node__mid';
const leftCol = document.createElement('div');
leftCol.className = 'rozie-flow-node__col rozie-flow-node__col--in';
const rightCol = document.createElement('div');
rightCol.className = 'rozie-flow-node__col rozie-flow-node__col--out';
const bottomRow = document.createElement('div');
bottomRow.className = 'rozie-flow-node__row rozie-flow-node__row--bottom';
midRow.appendChild(leftCol);
midRow.appendChild(body);
midRow.appendChild(rightCol);
box.appendChild(topRow);
box.appendChild(midRow);
box.appendChild(bottomRow);
element.appendChild(box);
for (const p of portEntries as any) {
const zone = p.position === 'top' ? topRow : p.position === 'bottom' ? bottomRow : p.position === 'right' ? rightCol : leftCol;
renderSocketInto(zone, reteNode, p.side, p.key, p.position, socketDisposers);
}
}
// emit per-node event helper handed to the slot scope so a consumer node body
// can raise a custom event carrying its id (e.g. a delete button).
const emit = (name: any, detail: any) => emit('node-action', {
id,
name,
detail
});
const entry = {
element,
box,
body,
handle: null,
bodyHandle: null,
titleEl: null,
bodyMoved: false,
emit,
socketDisposers
};
// ── RENDER-BY-TYPE: select the body by `node.type` ──────────────────────────
// 1) the node's TYPE template (typeReg[type].bodyRenderer) — the primary path
// (41-03 <NodeType><template #body>); 2) the low-level `#node` portal slot
// (consumer switches on node.type itself — escape hatch); 3) default chrome.
const typeSpec = meta.type != null ? typeReg.value[meta.type] : null;
if (typeSpec && typeof typeSpec.bodyRenderer === 'function') {
// RENDER-BY-TYPE callback path. The <NodeType> cannot relocate its OWN <slot>
// across the Lit shadow boundary (Wave-0 A3), so the PARENT projects the body
// here from its own render scope: the type's registered bodyRenderer(host, scope)
// mounts the type's `#body` portal INTO the engine `body` div (a FRESH render
// root per node — no framework DOM relocation, the Phase-37 D-04 trap avoided).
// nodeEntries must exist before the callback runs (bodyHostFor reads it), so
// register first. The graph node's `data` flows in as scope → one template per
// type renders every instance of that type.
nodeEntries.set(id, entry);
entry.bodyHandle = typeSpec.bodyRenderer(body, {
node: meta,
selected,
emit
});
entry.bodyMoved = true;
return;
}
if (slots.node) {
// reactive multi-instance portal — one handle per node, re-rendered in
// place on meta change (the MapLibre marker discipline). Low-level escape
// hatch: the consumer switches on node.type inside the single `#node` slot.
entry.handle = portals.node(body, {
node: meta,
selected,
emit
});
} else {
// default chrome: a title bar (the type name / data.label).
const title = document.createElement('div');
title.className = 'rozie-flow-node__title';
title.textContent = chromeLabel;
body.appendChild(title);
entry.titleEl = title;
}
nodeEntries.set(id, entry);
};
// Render ONE socket into a zone and, crucially, EMIT its render signal so the
// ConnectionPlugin + position watcher register it. `position` is the socket's visual
// placement (left|right|top|bottom). For left/right the DOM is byte-identical to pre-F2
// (the classic horizontal port row); top/bottom get a vertical port (socket above its
// label) + a `--<position>` socket class so the socket straddles the matching edge.
const renderSocketInto = (zone: any, reteNode: any, side: any, key: any, position: any, socketDisposers: any) => {
const port = (side === 'input' ? reteNode.inputs : reteNode.outputs)[key];
if (!port) return;
const vertical = position === 'top' || position === 'bottom';
const row = document.createElement('div');
row.className = 'rozie-flow-port rozie-flow-port--' + side + (vertical ? ' rozie-flow-port--vertical' : '');
const socketEl = document.createElement('div');
socketEl.className = 'rozie-flow-socket rozie-flow-socket--' + side + (vertical ? ' rozie-flow-socket--' + position : '');
socketEl.setAttribute('data-testid', 'socket');
const label = document.createElement('span');
label.className = 'rozie-flow-port__label';
label.textContent = port.label != null ? String(port.label) : key;
// CLASSIC: inputs socket-first, outputs label-first (byte-identical to pre-F2).
// VERTICAL: socket-first (the socket sits on the edge, label tucked inward).
if (side === 'input' || vertical) {
row.appendChild(socketEl);
row.appendChild(label);
} else {
row.appendChild(label);
row.appendChild(socketEl);
}
zone.appendChild(row);
// LOAD-BEARING: announce the socket to the rest of the area's child plugins.
// 'render' lets the ConnectionPlugin register the socket as a drag anchor.
area.emit({
type: 'render',
data: {
type: 'socket',
side,
key,
nodeId: reteNode.id,
element: socketEl,
payload: {
socket: port.socket
}
}
});
// ALSO LOAD-BEARING (the socket-position contract): getDOMSocketPosition measures +
// stores a socket's DOM position ONLY on a 'rendered' socket signal — the render-plugin
// lifecycle's post-mount phase. Our vanilla pipe creates + appends the socket DOM
// synchronously, so we fire 'rendered' right after 'render'. WITHOUT IT the position
// store stays empty, every socketWatcher.listen() callback reads null, and NO
// connection path (committed OR drag preview) is ever drawn.
area.emit({
type: 'rendered',
data: {
type: 'socket',
side,
key,
nodeId: reteNode.id,
element: socketEl,
payload: {
socket: port.socket
}
}
});
socketDisposers.push(() => {
area.emit({
type: 'unmount',
data: {
element: socketEl
}
});
});
};
// ── hand-written edge-type path generators (T1.2, D-01) ───────────────────────
// `rete-render-utils` ships ONLY `classicConnectionPath` (bezier) + `loopConnectionPath`;
// step/smoothstep/straight do NOT exist in any installed rete package, so they are
// hand-written here matching React-Flow's `step|smoothstep|straight` semantics. Each is a
// PURE `(start, end) → d-string` function over `{x,y}` graph-screen points; the `d` is
// composed from numeric coords + literal SVG commands and written via setAttribute (never
// innerHTML — no injection, T-44-02-2 accept). The default branch stays
// `classicConnectionPath` → byte-identical bezier (pixel-baseline safe).
// straight: a single line, no curvature.
const straightPath = (s: any, e: any) => `M ${s.x} ${s.y} L ${e.x} ${e.y}`;
// step: orthogonal HV-VH with a mid-X break.
const stepPath = (s: any, e: any) => {
const mx = (s.x + e.x) / 2;
return `M ${s.x} ${s.y} L ${mx} ${s.y} L ${mx} ${e.y} L ${e.x} ${e.y}`;
};
// smoothstep: step with rounded corners (radius r, clamped to half the shorter leg).
const smoothstepPath = (s: any, e: any, r = 8) => {
const mx = (s.x + e.x) / 2;
const dir = e.y >= s.y ? 1 : -1;
const rr = Math.min(r, Math.abs(mx - s.x), Math.abs(e.y - s.y) / 2);
return [`M ${s.x} ${s.y}`, `L ${mx - rr} ${s.y}`, `Q ${mx} ${s.y} ${mx} ${s.y + dir * rr}`, `L ${mx} ${e.y - dir * rr}`, `Q ${mx} ${e.y} ${mx + rr} ${e.y}`, `L ${e.x} ${e.y}`].join(' ');
};
// ── connection renderer ──
// Mounts an <svg><path> and redraws it whenever either endpoint socket moves
// (real connection) OR the dragged pointer moves (user drag-to-connect pseudo).
//
// A USER DRAG renders a *pseudo-connection* (rete-connection-plugin): the render
// signal carries a literal pointer coordinate (`endPointer`/`data.end` when
// dragging FROM an output, `startPointer`/`data.start` when dragging FROM an
// input) alongside a payload with ONE DANGLING endpoint — `target:''`/
// `targetInput:''` (output-side drag) or `source:''`/`sourceOutput:''`
// (input-side drag). The dangling side has no socket to watch, so its coordinate
// MUST come from the pointer; the live side stays watcher-driven. The
// ConnectionPlugin re-emits this render on EVERY pointermove with a fresh pointer
// — so the same pseudo element is re-rendered repeatedly and the dangling
// coordinate must update in place (no SVG rebuild, no listener re-subscribe).
const renderConnection = (element: any, connection: any, startPointer: any, endPointer: any) => {
const id = connection.id;
// A side is dangling when its node id OR its port key is empty/nullish.
const srcDangling = !connection.source || !connection.sourceOutput;
const tgtDangling = !connection.target || !connection.targetInput;
// RE-RENDER of the SAME element (the pseudo on each pointermove): do NOT rebuild
// the SVG or re-subscribe listeners (would leak) — just update the dangling
// side's coordinate and redraw. This replaces the old unconditional early-return
// that froze the preview line. For a REAL connection updatePointer is a no-op,
// so a re-render of a committed edge is byte-for-byte the old early-return.
const prev = connEntries.get(id);
if (prev && prev.element === element) {
prev.updatePointer(startPointer, endPointer);
return;
}
element.innerHTML = '';
element.classList.add('rozie-flow-connection');
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'rozie-flow-connection__svg');
// ── direction arrowhead (Win 3) ─────────────────────────────────────────────
// A <defs><marker> in THIS connection's own <svg>, referenced by `marker-end` so
// the triangle sits at the path END (the input socket — the path runs output→input,
// so marker-end points INTO the target). The marker id is UNIQUE per connection
// (`rozie-arrow-<id>`) so two edges' markers never collide on a shared document id
// (url(#id) resolves to the first match otherwise). The def lives in the SAME
// per-edge <svg> inside the SAME shadow root as the path, so url(#id) resolves
// within that root — no cross-root reference (Lit-safe). markerUnits="userSpaceOnUse"
// keeps a constant pixel size under the area zoom transform. Inline fill (#64748b,
// matching the connection stroke) is the cross-target-safe choice — no scoped-CSS /
// :root rule needed for the marker DOM. The marker does NOT change the path `d`
// or the socket geometry (the rete-flow-align cell stays green) — redraw() only
// sets the head's `orient` and a `stroke-dasharray` that visually trims the last
// ARROW_LEN of the stroke so the line meets the head without poking through it.
const markerId = 'rozie-arrow-' + String(id);
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
marker.setAttribute('id', markerId);
// Sized in userSpaceOnUse (constant pixels under zoom). A 12×10 head reads
// clearly at default zoom (the old 6×6 was barely visible). refX=12 sits the
// TIP exactly at the path-end vertex (the socket); refY=5 centers it. `orient`
// is recomputed per-redraw from the path's final-segment tangent, and the
// visible stroke is trimmed back to the arrow base, so the head points along
// the edge's actual approach AND the line meets it cleanly — see redraw().
marker.setAttribute('markerWidth', '13');
marker.setAttribute('markerHeight', '10');
marker.setAttribute('refX', '12');
marker.setAttribute('refY', '5');
marker.setAttribute('orient', 'auto');
marker.setAttribute('markerUnits', 'userSpaceOnUse');
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
arrow.setAttribute('class', 'rozie-flow-connection__arrow');
arrow.setAttribute('d', 'M0,0 L12,5 L0,10 Z');
arrow.setAttribute('fill', '#64748b');
marker.appendChild(arrow);
defs.appendChild(marker);
svg.appendChild(defs);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('class', 'rozie-flow-connection__path');
path.setAttribute('marker-end', 'url(#' + markerId + ')');
svg.appendChild(path);
// ── T1.1 edge-select listener (D-08) ─────────────────────────────────────────
// Attach an IMPERATIVE pointerup listener on the engine-DOM <path> (NOT a template
// `@` — the path is engine-created; NOT click — Rete swallows it; NOT pointerdown —
// Rete stopPropagations it: the Phase-41 connector landmine, playbook §6a item 7).
// Gated on `selectable && !readonly` (mirrors node delete) and ONLY for COMMITTED
// edges — a drag-to-connect pseudo (either side dangling) carries no stable id and
// must not be selectable. `selectEdge` reads the id back off the closure (the
// committed connection.id == the graph connection id — conn.id = spec.id at build),
// so it always matches what `writeBackConnectionRemoved` filters. `.stop` keeps the
// pointerup from reaching the area's pan/background handling beneath the path.
if (props.selectable && !props.readonly && !srcDangling && !tgtDangling) {
path.style.cursor = 'pointer';
path.addEventListener('pointerup', (e: any) => {
if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
selectEdge(connection.id, path);
});
}
// ── per-edge label + styling (F3) ────────────────────────────────────────────
// The consumer's connection spec ({ id, source, …, label?, stroke?, dashed? }) is kept
// in connMeta keyed by id (the connection-side analog of nodeMeta). A committed edge
// resolves its label/style here; a drag-preview pseudo (no committed id) has none.
// Styling is applied as INLINE attributes (the arrowhead-marker discipline — engine DOM
// carries no scope attr); a `label` renders an SVG <text> at the path midpoint (white
// halo via paint-order for legibility over the line), repositioned in redraw().
const emeta = connMeta.get(connection.id) || null;
if (emeta) {
if (emeta.stroke != null) {
const s = String(emeta.stroke);
path.setAttribute('stroke', s);
arrow.setAttribute('fill', s);
}
if (emeta.dashed === true) path.setAttribute('stroke-dasharray', '7 5');
}
// ── resolved edge type (T1.2) ────────────────────────────────────────────────
// The consumer-supplied `connection.type` selects a path generator. ALLOWLIST it
// (`bezier|step|smoothstep|straight`); any other/absent value falls through to the
// bezier default — no dynamic path-fn lookup keyed on the raw string, no eval
// (T-44-02-1 mitigate). A dangling drag-preview pseudo has no committed connMeta
// entry, so it stays bezier too.
const rawType = emeta && emeta.type != null ? String(emeta.type) : 'bezier';
const edgeType = rawType === 'step' || rawType === 'smoothstep' || rawType === 'straight' ? rawType : 'bezier';
// Arrowhead geometry (redraw): the head is oriented along the path's tangent
// over its LAST `ARROW_LEN` (angled for a descending edge, aligned with where
// the line actually meets the head — unlike the chord, which diverges from the
// bezier's flattened end tangent), and the visible stroke is trimmed back to
// the arrow base on SOLID edges so the line's width can't poke past the
// tapering tip (the "square tip"). Dashed edges keep their pattern untrimmed.
const ARROW_LEN = 12;
const isDashed = !!(emeta && emeta.dashed === true);
let labelEl: any = null;
const edgeLabel = emeta && emeta.label != null ? String(emeta.label) : null;
if (edgeLabel) {
labelEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
labelEl.setAttribute('class', 'rozie-flow-connection__label');
labelEl.setAttribute('text-anchor', 'middle');
labelEl.setAttribute('dominant-baseline', 'middle');
labelEl.textContent = edgeLabel;
svg.appendChild(labelEl);
}
element.appendChild(svg);
let start: any = null;
let end: any = null;
const curvature = typeof props.curvature === 'number' ? props.curvature : 0.3;
const redraw = () => {
if (!start || !end) return;
// branch on the resolved edge type; default (bezier/unknown) stays
// classicConnectionPath UNCHANGED → byte-identical bezier output.
const d = edgeType === 'step' ? stepPath(start, end) : edgeType === 'smoothstep' ? smoothstepPath(start, end) : edgeType === 'straight' ? straightPath(start, end) : classicConnectionPath([start, end], curvature);
path.setAttribute('d', d);
// Orient the head and trim the visible stroke back to the arrow base (solid
// edges) so the line meets the head without poking through the tip.
// getTotalLength/getPointAtLength are SVGGeometryElement methods unavailable
// in a non-rendering env (jsdom) → guard and fall back to orient='auto' / untrimmed.
let pathLen = 0;
try {
pathLen = path.getTotalLength();
} catch (e: any) {
pathLen = 0;
}
if (pathLen > ARROW_LEN + 1) {
// BACKWARD edge (target socket left of the source socket): the classic
// bezier overshoots both control points, looping the curve into tight
// u-turns right at the sockets, so a sampled local tangent is unstable and
// the head curls. Use the path's TRUE end tangent (orient='auto' — the
// horizontal entry into the input) for a stable, standard arrow. FORWARD
// edges keep the final-ARROW_LEN tangent, which follows a descending edge
// AND aligns with where the line meets the head.
if (end.x < start.x) {
marker.setAttribute('orient', 'auto');
} else {
const tip = path.getPointAtLength(pathLen);
const back = path.getPointAtLength(pathLen - ARROW_LEN);
marker.setAttribute('orient', String(Math.atan2(tip.y - back.y, tip.x - back.x) * 180 / Math.PI));
}
if (!isDashed) path.setAttribute('stroke-dasharray', pathLen - ARROW_LEN + ' ' + pathLen);
} else {
marker.setAttribute('orient', 'auto');
if (!isDashed) path.removeAttribute('stroke-dasharray');
}
if (labelEl) {
labelEl.setAttribute('x', String((start.x + end.x) / 2));
labelEl.setAttribute('y', String((start.y + end.y) / 2));
}
};
// Seed the DANGLING side's coordinate from the pointer FIRST — socketWatcher
// .listen() synchronously replays the current socket snapshot on subscribe, so
// seeding before subscribing the live side means redraw() already has the
// dangling coordinate and the preview line draws immediately on the first render.
if (srcDangling && startPointer) start = startPointer;
if (tgtDangling && endPointer) end = endPointer;
// LIVE endpoints stay watcher-driven (exactly as before the fix — committed
// connections behave byte-for-byte). DANGLING endpoints subscribe NO listener
// (it would never fire — there is no socket); their coordinate is the pointer.
let un1: any = null;
let un2: any = null;
if (!srcDangling) un1 = socketWatcher.listen(connection.source, 'output', connection.sourceOutput, (p: any) => {
start = p;
redraw();
});
if (!tgtDangling) un2 = socketWatcher.listen(connection.target, 'input', connection.targetInput, (p: any) => {
end = p;
redraw();
});
// Update only the DANGLING side(s) from a fresh pointer on each subsequent
// render call. For a REAL connection (neither side dangling) this is a no-op,
// so committed connections never have a pointer override and keep behaving
// exactly as before.
const updatePointer = (sp: any, ep: any) => {
let moved = false;
if (srcDangling && sp) {
start = sp;
moved = true;
}
if (tgtDangling && ep) {
end = ep;
moved = true;
}
if (moved) redraw();
};
// Draw once now: a pseudo seeded with an initial pointer (+ its live side
// already replayed) draws immediately; a real connection whose sockets are
// already known also draws (idempotent — same `d` the listeners just set).
redraw();
connEntries.set(id, {
element,
updatePointer,
dispose: () => {
try {
un1 && un1();
} catch (e: any) {}
try {
un2 && un2();
} catch (e: any) {}
}
});
};
// ── unmount cleanup (keyed by the engine element area hands back) ──
const cleanupElement = (element: any) => {
for (const [id, entry] of nodeEntries as any) {
if (entry.element === element) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
nodeEntries.delete(id);
return;
}
}
for (const [id, entry] of connEntries as any) {
if (entry.element === element) {
entry.dispose();
connEntries.delete(id);
return;
}
}
};
// Resolve a single port's TYPE for the validation pipe: look up the live node's
// `type` (via nodeMeta) then the portReg entry keyed `type::side::key`. Returns the
// portType string or null (null on either side ⇒ no type constraint ⇒ allow). DEFINED
// HERE (inside $onMount) — NOT at top level — so its $data.portReg read lowers on React
// to the live `_portRegRef.current` rather than a stale-empty closure snapshot captured
// when this once-only mount effect first ran (the cross-type-reject-didn't-fire bug).
const portTypeOf = (nodeId: any, side: any, key: any) => {
const meta = nodeMeta.get(nodeId);
if (!meta || meta.type == null || key == null) return null;
const entry = portReg.value[meta.type + '::' + side + '::' + key];
return entry ? entry.portType : null;
};
// ─── connection-validation gate (D2/D3 — typed-socket validation + override) ──
// Cancels Rete's cancellable `connectioncreate` pre-event when the connection is
// rejected. TWO independent reject paths, both surfacing `connection-rejected`:
// 1. AUTOMATIC typed validation (`:validate-types`, default ON, D3 option a):
// resolve src/tgt port TYPE from the per-TYPE port schema (via each endpoint
// node's `type`); if both are non-null and UNEQUAL → reject. A null on either
// side (untyped port / unknown type) imposes no constraint → allow.
// 2. `canConnect` OVERRIDE (Phase-40 contract, SURVIVES): a consumer custom rule;
// runs IN ADDITION to (after) the automatic check; returning false rejects.
// Cancelling makes editor.addConnection return false WITHOUT pushing the connection
// or emitting `connectioncreated` — no ghost edge, no `connection-created`. Gates
// drag-to-connect, imperative addConnection, and reconcile uniformly. Both predicates
// are PURE (no $data write / engine call) — reads only. The block (return undefined)
// stays UNCONDITIONAL so rejection is enforced on every path; only the EMIT is
// echo-guarded (a programmatic reconcile the rule would reject must not surface as a
// user-facing rejection — mirrors connection-created/connection-removed).
editor.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectioncreate') {
const c = context.data;
// ClassicPreset.Connection fields: { id, source, sourceOutput, target, targetInput }.
// Same shape as serializeConn minus the engine-assigned `id` (never created).
const conn = {
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
};
// 1. AUTOMATIC typed validation (default ON; opt out via :validate-types="false").
if (props.validateTypes !== false) {
const srcType = portTypeOf(c.source, 'output', c.sourceOutput);
const tgtType = portTypeOf(c.target, 'input', c.targetInput);
if (srcType != null && tgtType != null && srcType !== tgtType) {
if (!programmatic) emit('connection-rejected', conn);
return undefined; // ← CANCEL: type mismatch
}
}
// 2. canConnect OVERRIDE (Phase-40 contract — custom rule, in addition).
if (typeof props.canConnect === 'function' && props.canConnect(conn) === false) {
if (!programmatic) emit('connection-rejected', conn);
return undefined; // ← CANCEL: Signal.emit halts, addConnection returns false
}
}
return context;
});
// ─── forward engine events (echo-guarded via `programmatic`) ───────────────
editor.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectioncreated') {
// keep engine truth in sync so reconcile diffs correctly — a user-drawn
// connection (auto id) must register here or the next graph pass re-adds it.
connInstances.set(context.data.id, context.data);
if (!programmatic) {
// WRITE-BACK: append the new connection into a fresh graph object (D4).
writeBackConnectionCreated(context.data);
// keep the discrete event too (back-compat).
emit('connection-created', serializeConn(context.data));
}
} else if (context.type === 'connectionremoved') {
connInstances.delete(context.data.id);
connMeta.delete(context.data.id);
if (!programmatic) {
// WRITE-BACK: filter the removed connection out of a fresh graph object (D4).
writeBackConnectionRemoved(context.data.id);
emit('connection-removed', {
id: context.data.id
});
}
}
return context;
});
area.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'nodepicked') {
emit('node-picked', {
id: context.data.id
});
// T1.3 — pointer-DOWN: stash the PRE-drag graph snapshot (before any movement). It
// is committed to history on the first `nodetranslated` (only if a drag follows;
// gated on !programmatic + history). A re-pick mid-drag won't overwrite a live one.
if (!programmatic && props.history !== false && !dragGestureActive) {
pendingDragSnapshot = snapshotCurrent();
}
// Win 2: a pick changed the selection — surface @selection-change after the
// engine's awaited select() for THIS pick has flushed the selector entities.
scheduleSelectionEmit();
} else if (context.type === 'pointerup') {
// Win 2: AreaExtensions.selectableNodes UNSELECTS all on a click-like background
// pointerUP (its `twitch < 4` deselect — NOT on pointerdown, verified against
// rete-area-plugin's selectable pipe). Its unselectAll() is async and its pipe
// runs before ours, so recompute AFTER its awaited unselectAll() flushes (the
// microtask + rAF schedule). The dedup makes a no-op when nothing changed (e.g. a
// pointerup that ended a node pick — already surfaced by the nodepicked branch).
scheduleSelectionEmit();
// T1.3 — a pointerup ends any in-progress drag gesture, so the NEXT drag pushes a
// fresh history snapshot (one gesture = one undo step, D-03). Drop any stashed
// pre-drag snapshot that was never committed (a pick with no drag).
dragGestureActive = false;
pendingDragSnapshot = null;
// T1.1: a background pointerup (anywhere not on a connection path) clears the edge
// selection — UNLESS this same gesture just selected an edge (the path's own
// pointerup ran in the same tick and raised `edgeClickGuard`; the guard self-resets
// on the next microtask). Mirrors the node selectable's click-to-deselect.
if (!edgeClickGuard && selectedConnId != null) clearEdgeSelection();
} else if (context.type === 'nodetranslated') {
if (!programmatic) {
const id = context.data.id;
const pos = context.data.position;
const meta = nodeMeta.get(id);
if (meta) {
meta.x = pos.x;
meta.y = pos.y;
}
// T1.3 — commit ONE history snapshot per drag gesture, at its FIRST translate:
// the pre-move snapshot stashed on nodepicked (a drag truly happened now, not just
// a pick). dragGestureActive holds until the drag-ending pointerup resets it, so a
// continuous drag = ONE undo step (D-03).
if (!dragGestureActive) {
dragGestureActive = true;
if (pendingDragSnapshot) {
pushHistorySnapshot(pendingDragSnapshot);
pendingDragSnapshot = null;
}
}
// WRITE-BACK (coalesced): accumulate the latest position for this node and
// flush ONE fresh graph object per animation frame (Pitfall 2 — the drag
// storm). The discrete `node-moved` emit stays per-translate (back-compat).
pendingDragPositions.set(id, {
x: pos.x,
y: pos.y
});
scheduleDragFlush();
emit('node-moved', {
id,
x: pos.x,
y: pos.y
});
}
// a node moved → its minimap rect moves (works during a programmatic translate too).
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
// T2.8 — the selected node moved → re-track its toolbar overlay (no-op if off).
if (scheduleToolbarTrack) scheduleToolbarTrack();
} else if (context.type === 'translated') {
emit('translated', {
x: context.data.position.x,
y: context.data.position.y
});
// the viewport window moved → redraw the minimap viewport rect + mask.
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
// T2.8 — a pan shifts the node's screen rect → re-track the toolbar (no-op if off).
if (scheduleToolbarTrack) scheduleToolbarTrack();
} else if (context.type === 'zoomed') {
if (!programmatic) {
const k = area.area.transform.k;
if (k !== zoom.value) zoom.value = k;
}
// the viewport window resized (zoom) → redraw the minimap viewport rect + mask.
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
// T2.8 — a zoom changes the node's screen rect/size → re-track the toolbar (no-op if off).
if (scheduleToolbarTrack) scheduleToolbarTrack();
} else if (context.type === 'contextmenu') {
// suppress the native browser menu over the canvas; surface a hook instead.
context.data.event.preventDefault();
const ctx = context.data.context;
emit('context-menu', {
id: ctx && ctx.id ? ctx.id : null
});
}
return context;
});
// ─── reconciler off the bound graph, bridged to the top-level $watch ──────────
// Nodes come ONLY from `$props.graph.nodes` (the single source of truth, D1/D2);
// sockets come from each node's TYPE port schema (portReg keyed `type::side::key`).
// A port-schema change ($data.portReg, when a <Port> registers late on Lit) ALSO
// drives this reconcile so a node whose type just gained ports re-renders. An
// imperative $expose addNode (provenance NOT in lastPropNodeIds) survives the reaper.
// Wrapped by reconcileNodes (below) with a re-entrancy guard so two passes never
// race the engine (the Lit "cannot find node" fix).
const reconcileNodesPass = async () => {
if (!editor || !area) return;
const graphNodes = Array.isArray(graph.value && graph.value.nodes) ? graph.value.nodes : [];
const want = [];
programmatic++;
try {
for (const spec of graphNodes as any) {
if (!spec || spec.id == null) continue;
want.push(spec.id);
nodeMeta.set(spec.id, spec);
let node = nodeInstances.get(spec.id);
if (!node) {
node = buildNode(spec, portReg.value);
nodeInstances.set(spec.id, node);
await editor.addNode(node);
await area.translate(spec.id, {
x: spec.x || 0,
y: spec.y || 0
});
} else {
// Sync any ports this node's TYPE gained AFTER the node was first built —
// a nested <Port>'s addTypePort can land after reconcileNodes already
// created the node (the node registered before its ports on some targets,
// or a <Port> registered late on Lit). buildNode only runs for NEW nodes,
// so add the missing inputs/outputs onto the live instance here from the
// TYPE schema, then re-render.
let portsAdded = false;
const {
inputs: wantIn,
outputs: wantOut
} = portSchemaForType(spec.type, portReg.value);
for (const inp of wantIn as any) {
if (!inp || inp.key == null || node.inputs[inp.key]) continue;
node.addInput(inp.key, new ClassicPreset.Input(SOCKET, inp.label, inp.multiple === true));
portsAdded = true;
}
for (const out of wantOut as any) {
if (!out || out.key == null || node.outputs[out.key]) continue;
node.addOutput(out.key, new ClassicPreset.Output(SOCKET, out.label, out.multiple !== false));
portsAdded = true;
}
const view = area.nodeViews.get(spec.id);
if (view && spec.x != null && spec.y != null && (view.position.x !== spec.x || view.position.y !== spec.y)) {
await area.translate(spec.id, {
x: spec.x,
y: spec.y
});
}
if (portsAdded) {
// renderNode's in-place branch deliberately leaves existing sockets
// untouched; to render the NEW sockets, drop this node's render entry so
// area.update takes the fresh-build path (re-runs buildSocketRow + re-
// emits the socket render signals the ConnectionPlugin/watcher need). The
// render-by-type body host is re-projected by the type's bodyRenderer
// (mounts a fresh portal root into the same host — idempotent).
const entry = nodeEntries.get(spec.id);
if (entry) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
nodeEntries.delete(spec.id);
}
}
await area.update('node', spec.id);
// a port change must re-run connections — an edge that was skipped because
// its endpoint port didn't exist yet can now be drawn.
if (portsAdded && reconcileConnections) await reconcileConnections();
}
}
// remove dropped GRAPH-managed nodes (+ their connections) — imperatively added
// nodes (NOT in lastPropNodeIds) survive (the power-user escape hatch).
const tracked = new Set(lastPropNodeIds);
for (const id of tracked as any) {
if (!want.includes(id) && nodeInstances.has(id)) {
for (const c of editor.getConnections() as any) {
if (c.source === id || c.target === id) await editor.removeConnection(c.id);
}
await editor.removeNode(id);
nodeInstances.delete(id);
nodeMeta.delete(id);
}
}
lastPropNodeIds = want;
} finally {
programmatic--;
}
};
// Re-entrancy-guarded entry point. If a pass is already running, mark a re-run and
// return — the in-flight pass loops until no further request is pending. Serializing
// overlapping reconciles is what stops the Lit async-context cascade from racing the
// engine into "cannot find node" (which otherwise aborts the declarative graph build).
reconcileNodes = async () => {
if (reconcileNodesRunning) {
reconcileNodesPending = true;
return;
}
reconcileNodesRunning = true;
try {
do {
reconcileNodesPending = false;
await reconcileNodesPass();
} while (reconcileNodesPending);
} finally {
reconcileNodesRunning = false;
}
};
reconcileConnections = async () => {
if (!editor) return;
// Edges come ONLY from the bound graph's `connections` (the single source of
// truth — declarative <Connection> children are gone). Normalize id-defaulting
// (a connection authored without an id gets a stable derived id) so an edge the
// canvas wrote back (carrying the engine id) and a hand-authored edge dedup.
const graphConns = Array.isArray(graph.value && graph.value.connections) ? graph.value.connections : [];
const norm = (spec: any) => {
if (!spec || spec.source == null || spec.target == null) return null;
const srcOut = spec.sourceOutput != null ? spec.sourceOutput : 'out';
const tgtIn = spec.targetInput != null ? spec.targetInput : 'in';
const id = spec.id != null ? spec.id : `${spec.source}:${srcOut}->${spec.target}:${tgtIn}`;
// carry the optional per-edge label/style (F3) through to connMeta → renderConnection.
return {
id,
source: spec.source,
sourceOutput: srcOut,
target: spec.target,
targetInput: tgtIn,
label: spec.label,
stroke: spec.stroke,
dashed: spec.dashed,
type: spec.type
};
};
// cheap style signature so a label/style/type change on an EXISTING edge re-renders it.
const edgeStyleSig = (s: any) => s ? String(s.label) + '|' + String(s.stroke) + '|' + String(s.dashed) + '|' + String(s.type) : '';
const merged = graphConns.map(norm).filter(Boolean);
const want = [];
programmatic++;
try {
for (const spec of merged as any) {
if (!spec || spec.id == null) continue;
want.push(spec.id);
if (connInstances.has(spec.id)) {
// existing edge — relabel/restyle in place if its label/style changed (the
// controlled-graph expectation: edit the bound graph → see the change). Drop the
// render entry so area.update takes the fresh-build path (re-applies label/style).
const changed = edgeStyleSig(connMeta.get(spec.id)) !== edgeStyleSig(spec);
connMeta.set(spec.id, spec);
if (changed) {
const entry = connEntries.get(spec.id);
if (entry) {
entry.dispose();
connEntries.delete(spec.id);
}
await area.update('connection', spec.id);
}
continue;
}
const sourceNode = nodeInstances.get(spec.source);
const targetNode = nodeInstances.get(spec.target);
if (!sourceNode || !targetNode) continue;
// DEFENSIVE: the referenced output/input ports must exist on the live node
// instances before addConnection (Rete throws "source node doesn't have
// output with a key out" otherwise, aborting the loop). An edge may reference
// a port the node's TYPE schema has not flushed yet (a <Port> registered
// after the <NodeType>); skip until the ports exist — reconcileNodes re-runs
// reconcileConnections after a port-schema change, so the edge lands later.
if (!sourceNode.outputs || !sourceNode.outputs[spec.sourceOutput]) continue;
if (!targetNode.inputs || !targetNode.inputs[spec.targetInput]) continue;
const conn = new ClassicPreset.Connection(sourceNode, spec.sourceOutput, targetNode, spec.targetInput);
conn.id = spec.id;
connInstances.set(spec.id, conn);
// seed connMeta BEFORE addConnection so renderConnection sees the label/style on
// its first render (the render fires synchronously inside addConnection's pipe).
connMeta.set(spec.id, spec);
await editor.addConnection(conn);
}
// remove dropped GRAPH-managed edges — imperatively added edges survive.
const tracked = new Set(lastPropConnIds);
for (const id of tracked as any) {
if (!want.includes(id) && connInstances.has(id)) {
await editor.removeConnection(id);
connInstances.delete(id);
connMeta.delete(id);
}
}
lastPropConnIds = want;
} finally {
programmatic--;
}
};
// ─── built-in MiniMap (opt-in :minimap, Phase 42) ────────────────────────────
// An absolute light-DOM SVG overlay (bottom-right) showing a scaled map of every
// node + the current viewport window (outside dimmed), PANNABLE (drag recenters via
// setCenter). The host div is COMPONENT-template DOM (carries the [data-rozie-s-*]
// scope attr → plain scoped CSS positions it); its SVG children are built
// IMPERATIVELY with createElementNS (the connection-renderer discipline) so SVG
// namespacing is identical on all 6 (no SVG-in-template cross-target risk) and styled
// with INLINE attributes (the arrowhead-marker lesson — no scoped-CSS / :root rule
// needed for engine-style DOM). Node dims come from the MEASURED engine node-view
// elements (area.nodeViews.get(id).element offsetW/H — target-agnostic, like the
// render pipe) with a default-rect fallback for Lit's unmeasured first paint.
const measureNodeSize = (id: any) => {
const view = area && area.nodeViews ? area.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
const w = el && el.offsetWidth ? el.offsetWidth : MINIMAP_DEFAULT_NODE_W;
const h = el && el.offsetHeight ? el.offsetHeight : MINIMAP_DEFAULT_NODE_H;
return {
w,
h
};
};
const mkMinimapRect = (x: any, y: any, w: any, h: any, cls: any, fill: any, stroke: any, strokeW: any) => {
const r = document.createElementNS(SVGNS, 'rect');
r.setAttribute('class', cls);
r.setAttribute('x', String(x));
r.setAttribute('y', String(y));
r.setAttribute('width', String(Math.max(w, 0)));
r.setAttribute('height', String(Math.max(h, 0)));
if (fill) r.setAttribute('fill', fill);
if (stroke) {
r.setAttribute('stroke', stroke);
r.setAttribute('stroke-width', String(strokeW || 1));
}
return r;
};
// Rebuild the minimap SVG: node rects (selected highlighted) + a dim mask outside the
// viewport (evenodd punch-out) + the viewport window outline. The bounds union the
// node rects AND the viewport window so the viewport indicator stays in-frame even
// when panned past the nodes. Stores `minimapMap` (the px↔graph mapping the pointer-
// pan handlers read). Cheap (a handful of rects) → a full rebuild per frame is fine.
const redrawMinimap = () => {
minimapRedrawRaf = 0;
if (!props.minimap || !minimapSvg || !area || !container) return;
const t = area.area.transform;
const k = t.k || 1;
const cw = container.clientWidth || MINIMAP_W;
const ch = container.clientHeight || MINIMAP_H;
// viewport window in GRAPH coords (screen [0,cw]×[0,ch] → graph).
const vx = -t.x / k,
vy = -t.y / k,
vw = cw / k,
vh = ch / k;
const graphNodes = currentGraph().nodes || [];
const selIds = new Set(selectedNodeIds().map((s: any) => String(s)));
const rects = [];
for (const n of graphNodes as any) {
if (!n || n.id == null) continue;
const view = area.nodeViews.get(n.id);
const gx = view ? view.position.x : n.x || 0;
const gy = view ? view.position.y : n.y || 0;
const sz = measureNodeSize(n.id);
rects.push({
gx,
gy,
gw: sz.w,
gh: sz.h,
selected: selIds.has(String(n.id))
});
}
let minX = vx,
minY = vy,
maxX = vx + vw,
maxY = vy + vh;
for (const r of rects as any) {
if (r.gx < minX) minX = r.gx;
if (r.gy < minY) minY = r.gy;
if (r.gx + r.gw > maxX) maxX = r.gx + r.gw;
if (r.gy + r.gh > maxY) maxY = r.gy + r.gh;
}
const padX = (maxX - minX) * 0.1 || 20;
const padY = (maxY - minY) * 0.1 || 20;
minX -= padX;
minY -= padY;
maxX += padX;
maxY += padY;
const bw = maxX - minX || 1;
const bh = maxY - minY || 1;
const scale = Math.min(MINIMAP_W / bw, MINIMAP_H / bh);
const offX = (MINIMAP_W - bw * scale) / 2;
const offY = (MINIMAP_H - bh * scale) / 2;
minimapMap = {
minX,
minY,
scale,
offX,
offY
};
const toMMx = (gx: any) => (gx - minX) * scale + offX;
const toMMy = (gy: any) => (gy - minY) * scale + offY;
minimapSvg.innerHTML = '';
for (const r of rects as any) {
const fill = r.selected ? '#3b82f6' : '#94a3b8';
minimapSvg.appendChild(mkMinimapRect(toMMx(r.gx), toMMy(r.gy), r.gw * scale, r.gh * scale, 'rozie-flow-minimap__node', fill, null, 0));
}
// dim mask OUTSIDE the viewport: full minimap rect with the viewport rect punched
// out (both subpaths same winding → fill-rule:evenodd leaves the viewport a hole).
const mvx = toMMx(vx),
mvy = toMMy(vy),
mvw = vw * scale,
mvh = vh * scale;
const mask = document.createElementNS(SVGNS, 'path');
mask.setAttribute('class', 'rozie-flow-minimap__mask');
mask.setAttribute('fill-rule', 'evenodd');
mask.setAttribute('fill', 'rgba(15, 23, 42, 0.18)');
mask.setAttribute('d', 'M0 0 H' + MINIMAP_W + ' V' + MINIMAP_H + ' H0 Z ' + 'M' + mvx + ' ' + mvy + ' h' + mvw + ' v' + mvh + ' h' + -mvw + ' Z');
minimapSvg.appendChild(mask);
minimapSvg.appendChild(mkMinimapRect(mvx, mvy, mvw, mvh, 'rozie-flow-minimap__viewport', 'none', '#3b82f6', 1.5));
};
// rAF-coalesced scheduler (bridged to the top-level $watch + the engine pipes). No-op
// when :minimap is off (the bridge stays callable everywhere, cheap).
scheduleMinimapRedraw = () => {
if (!props.minimap || minimapRedrawRaf) return;
if (typeof requestAnimationFrame === 'function') {
minimapRedrawRaf = requestAnimationFrame(redrawMinimap);
} else {
minimapRedrawRaf = 1;
Promise.resolve().then(redrawMinimap);
}
};
// Map a minimap pointer event → graph coords (via the stored minimapMap) → setCenter.
// Pan is a view op → allowed even when readonly, but gated by `pannable` (mirror the
// main-canvas pannable gate). Pointer capture keeps the drag tracking off the box.
const minimapPointerToGraph = (e: any) => {
if (!minimapMap || !minimapHost) return null;
const box = minimapHost.getBoundingClientRect();
const rw = box.width || MINIMAP_W;
const rh = box.height || MINIMAP_H;
const mx = (e.clientX - box.left) * (MINIMAP_W / rw);
const my = (e.clientY - box.top) * (MINIMAP_H / rh);
return {
gx: minimapMap.minX + (mx - minimapMap.offX) / minimapMap.scale,
gy: minimapMap.minY + (my - minimapMap.offY) / minimapMap.scale
};
};
if (props.minimap && minimapElRef.value) {
minimapHost = minimapElRef.value;
minimapSvg = document.createElementNS(SVGNS, 'svg');
minimapSvg.setAttribute('class', 'rozie-flow-minimap__svg');
minimapSvg.setAttribute('viewBox', '0 0 ' + MINIMAP_W + ' ' + MINIMAP_H);
minimapSvg.setAttribute('preserveAspectRatio', 'none');
minimapHost.appendChild(minimapSvg);
onMinimapPointerDown = (e: any) => {
if (!props.pannable) return;
const g = minimapPointerToGraph(e);
if (!g) return;
minimapPanning = true;
try {
if (e.target && e.target.setPointerCapture && e.pointerId != null) e.target.setPointerCapture(e.pointerId);
} catch (err: any) {}
e.preventDefault();
e.stopPropagation();
setCenter(g.gx, g.gy, null);
};
onMinimapPointerMove = (e: any) => {
if (!minimapPanning || !props.pannable) return;
const g = minimapPointerToGraph(e);
if (!g) return;
e.preventDefault();
setCenter(g.gx, g.gy, null);
};
onMinimapPointerUp = (e: any) => {
if (!minimapPanning) return;
minimapPanning = false;
try {
if (e.target && e.target.releasePointerCapture && e.pointerId != null) e.target.releasePointerCapture(e.pointerId);
} catch (err: any) {}
};
minimapHost.addEventListener('pointerdown', onMinimapPointerDown);
minimapHost.addEventListener('pointermove', onMinimapPointerMove);
minimapHost.addEventListener('pointerup', onMinimapPointerUp);
}
// ─── T2.8 NodeToolbar (opt-in :node-toolbar) ─────────────────────────────────
// A floating component-template overlay over the SELECTED node. The host div
// (ref="toolbarEl") carries the [data-rozie-s-*] scope attr → PLAIN scoped CSS positions
// it absolutely (NOT the :root engine-DOM escape hatch — it's component DOM, like the
// marquee box + Controls). It is positioned from the engine node-view ELEMENT's rect
// (which the AreaPlugin transforms for pan/zoom/drag) relative to the canvas container, so
// the area transform is honored automatically — we read getBoundingClientRect() and
// subtract the container's rect (the screenToFlowPosition discipline, but the other way).
// Re-tracked on translated/zoomed/nodetranslated (the pipe branches that schedule the
// minimap redraw) + on every selection emit. OPT-IN (default OFF) → existing demos +
// FlowCanvasScreenshot are pixel-identical (the host div is r-if'd off when :node-toolbar
// is false; selecting a node never pops it).
// Resolve the SINGLE selected node id the toolbar should track: the one picked node when
// EXACTLY one is selected, else null (no toolbar over a multi-select or empty selection —
// a per-node action needs an unambiguous target). Read-only.
const singleSelectedNodeId = () => {
const ids = selectedNodeIds();
return ids.length === 1 ? ids[0] : null;
};
// Position the toolbar host over the tracked node's engine element, or hide it. The
// node-view element is already transformed by the AreaPlugin (pan/zoom/drag), so its
// client rect minus the container's client rect gives the toolbar's container-relative
// px — no manual transform math. Placed just ABOVE the node (bottom of the toolbar at the
// node's top edge); clamped so it never goes off the top of the container.
const trackToolbar = () => {
toolbarTrackRaf = 0;
if (!props.nodeToolbar || !toolbarHost || !area || !container) return;
const id = toolbarSelectedId;
if (id == null) {
toolbarHost.style.display = 'none';
return;
}
const view = area.nodeViews ? area.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
const rect = el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null;
if (!rect) {
toolbarHost.style.display = 'none';
return;
}
const cbox = container.getBoundingClientRect();
// container-relative px of the node's top-left + width.
const nx = rect.left - cbox.left;
const ny = rect.top - cbox.top;
const tbH = toolbarHost.offsetHeight || 30;
let top = ny - tbH - 6;
if (top < 2) top = ny + rect.height + 6; // flip below if it would clip the top
toolbarHost.style.left = nx + 'px';
toolbarHost.style.top = top + 'px';
toolbarHost.style.display = 'flex';
};
scheduleToolbarTrack = () => {
if (!props.nodeToolbar || toolbarTrackRaf) return;
if (typeof requestAnimationFrame === 'function') {
toolbarTrackRaf = requestAnimationFrame(trackToolbar);
} else {
toolbarTrackRaf = 1;
Promise.resolve().then(trackToolbar);
}
};
// Recompute the tracked node from the live selection + (re)mount the toolbar content for
// it. Called from the selection emit (a pick/unpick changed the selection). When the
// tracked id changes: if the consumer fills `#toolbar`, (re)render the reactive portal
// with the new node scope; else the default buttons stay put (they read the live tracked
// id at click time, so no re-mount needed). Then reposition.
const syncToolbar = () => {
if (!props.nodeToolbar || !toolbarHost) return;
const id = singleSelectedNodeId();
if (id === toolbarSelectedId && id == null === (toolbarSelectedId == null)) {
// same target — just reposition (e.g. after a drag).
scheduleToolbarTrack();
return;
}
toolbarSelectedId = id;
if (slots.toolbar && id != null) {
const meta = nodeMeta.get(id) || {
id,
type: undefined,
data: {}
};
const scope = {
node: meta,
emit: toolbarEmit
};
if (toolbarHandle && toolbarHandle.update) {
toolbarHandle.update(scope);
} else {
toolbarHandle = portals.toolbar(toolbarHost, scope);
}
}
scheduleToolbarTrack();
};
syncToolbarSelection = syncToolbar;
// The @node-action emit helper for the toolbar's actions (the EXISTING emit — no new emit,
// T2.8). Carries the tracked node id. Handed to the `#toolbar` slot scope so a consumer
// override can raise its own actions too.
const toolbarEmit = (name: any, detail: any) => {
const id = toolbarSelectedId;
emit('node-action', {
id,
name,
detail
});
};
if (props.nodeToolbar && toolbarElRef.value) {
toolbarHost = toolbarElRef.value;
toolbarHost.style.display = 'none';
if (!slots.toolbar) {
// default chrome: delete + duplicate buttons. Static literal labels (Threat
// T-44-06-1: no node-derived text rendered via innerHTML — these are fixed strings
// set via textContent). Both fire @node-action on the tracked node.
toolbarDeleteBtn = document.createElement('button');
toolbarDeleteBtn.type = 'button';
toolbarDeleteBtn.className = 'rozie-flow-toolbar__btn rozie-flow-toolbar__btn--delete';
toolbarDeleteBtn.setAttribute('data-testid', 'flow-toolbar-delete');
toolbarDeleteBtn.setAttribute('aria-label', 'Delete node');
toolbarDeleteBtn.textContent = 'Delete';
toolbarDuplicateBtn = document.createElement('button');
toolbarDuplicateBtn.type = 'button';
toolbarDuplicateBtn.className = 'rozie-flow-toolbar__btn rozie-flow-toolbar__btn--duplicate';
toolbarDuplicateBtn.setAttribute('data-testid', 'flow-toolbar-duplicate');
toolbarDuplicateBtn.setAttribute('aria-label', 'Duplicate node');
toolbarDuplicateBtn.textContent = 'Duplicate';
onToolbarDelete = (e: any) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const id = toolbarSelectedId;
if (id == null) return;
toolbarEmit('delete', {
id
});
toolbarSelectedId = null;
deleteNode(id);
scheduleToolbarTrack();
};
onToolbarDup = (e: any) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const id = toolbarSelectedId;
if (id == null) return;
const newId = duplicateNode(id);
toolbarEmit('duplicate', {
id,
newId
});
scheduleToolbarTrack();
};
// pointerup (NOT click — Rete swallows clicks during node interaction; the §6a item-7
// discipline) on the COMPONENT-template buttons.
toolbarDeleteBtn.addEventListener('pointerup', onToolbarDelete);
toolbarDuplicateBtn.addEventListener('pointerup', onToolbarDup);
toolbarHost.appendChild(toolbarDeleteBtn);
toolbarHost.appendChild(toolbarDuplicateBtn);
}
}
// ─── T2.4 MARQUEE select (mode:'select') ─────────────────────────────────────
// A Figma-style rubber-band box. RESTORE-PATH resolution (RESEARCH Q2/A8): rete's
// internal `Drag` class is NOT exported, so setDragHandler(null) can't be cleanly
// reversed (re-instantiating Drag is impossible). Instead we leave the default pan Drag
// installed and intercept the EMPTY-canvas pointerdown in the CAPTURE phase on the
// container — the default Drag attaches its own bubble-phase pointerdown listener on the
// SAME container (verified rete-area-plugin@2.1.5: setDragHandler → Drag.initialize(
// this.container)), so a capture listener fires FIRST and stopPropagation() blocks pan
// before it starts. The interception is gated PURELY on the live `$props.mode` flag, so
// switching back to 'pan' restores pan with ZERO engine mutation (the persistent
// mode-guard the research preferred). A node drag is UNTOUCHED in both modes: we only act
// when the pointerdown target is NOT inside a node element (empty canvas).
//
// The box is a COMPONENT-TEMPLATE overlay div (ref="marqueeEl") — it carries the
// [data-rozie-s-*] scope attr so a PLAIN scoped rule styles it (NOT the :root engine-DOM
// escape hatch). On release we hit-test every graph node's rect (graph coords via
// area.nodeViews.get(id).position + measureNodeSize) against the box (converted to graph
// coords through the live transform) and nodeSelectApi.select(id, true) each intersector,
// then scheduleSelectionEmit() (the existing @selection-change path — NO new emit).
// Marquee changes only SELECTION (script-state), never the graph model → no history push.
const nodeAt = (target: any) => {
if (!target || typeof target.closest !== 'function') return null;
return target.closest('.rozie-flow-node');
};
// container-relative px → GRAPH coords (the inverse area transform, like
// screenToFlowPosition but already container-relative). px = transform + graph·k.
const containerPxToGraph = (px: any, py: any) => {
const t = area.area.transform;
const k = t.k || 1;
return {
x: (px - t.x) / k,
y: (py - t.y) / k
};
};
const updateMarqueeBox = () => {
if (!marqueeBox || !marqueeStart || !marqueeCur) return;
const x = Math.min(marqueeStart.x, marqueeCur.x);
const y = Math.min(marqueeStart.y, marqueeCur.y);
const w = Math.abs(marqueeCur.x - marqueeStart.x);
const h = Math.abs(marqueeCur.y - marqueeStart.y);
marqueeBox.style.left = x + 'px';
marqueeBox.style.top = y + 'px';
marqueeBox.style.width = w + 'px';
marqueeBox.style.height = h + 'px';
marqueeBox.style.display = 'block';
};
const finishMarquee = () => {
if (!marqueeActive) return;
marqueeActive = false;
if (marqueeBox) marqueeBox.style.display = 'none';
if (!marqueeStart || !marqueeCur || !nodeSelectApi) {
marqueeStart = null;
marqueeCur = null;
return;
}
// box in graph coords (two opposite corners → min/max).
const a = containerPxToGraph(marqueeStart.x, marqueeStart.y);
const b = containerPxToGraph(marqueeCur.x, marqueeCur.y);
const bx0 = Math.min(a.x, b.x),
by0 = Math.min(a.y, b.y);
const bx1 = Math.max(a.x, b.x),
by1 = Math.max(a.y, b.y);
marqueeStart = null;
marqueeCur = null;
const graphNodes = currentGraph().nodes || [];
let first = true;
for (const n of graphNodes as any) {
if (!n || n.id == null) continue;
const view = area.nodeViews.get(n.id);
const gx = view ? view.position.x : n.x || 0;
const gy = view ? view.position.y : n.y || 0;
const sz = measureNodeSize(n.id);
// a node intersects the box if their rects overlap (AABB), in graph coords.
const overlaps = gx < bx1 && gx + sz.w > bx0 && gy < by1 && gy + sz.h > by0;
if (overlaps) {
// accumulate=true keeps every intersector selected (first one replaces the prior
// selection so an old pick doesn't linger; rest accumulate). select(id, accumulate).
nodeSelectApi.select(n.id, !first);
first = false;
}
}
// surface @selection-change once the engine's awaited select() chain has flushed.
scheduleSelectionEmit();
};
if (props.selectable && !props.readonly && container && typeof container.addEventListener === 'function') {
marqueeBox = marqueeElRef.value || null;
onCanvasPointerDownCapture = (e: any) => {
// only in select mode, only the EMPTY canvas (not on a node — those still drag), only
// the primary button. A live `$props.mode` read = the persistent mode-guard (restoring
// pan is just this check returning early; no engine mutation).
if (mode.value !== 'select') return;
if (e && e.button != null && e.button !== 0) return;
if (nodeAt(e.target)) return;
// BLOCK rete's pan Drag (its bubble-phase pointerdown on the same container) — capture
// phase runs first, so stopPropagation() here pre-empts pan; the marquee owns this drag.
e.stopPropagation();
e.preventDefault();
const box = container.getBoundingClientRect();
marqueeActive = true;
marqueeStart = {
x: e.clientX - box.left,
y: e.clientY - box.top
};
marqueeCur = {
x: marqueeStart.x,
y: marqueeStart.y
};
try {
if (container.setPointerCapture && e.pointerId != null) container.setPointerCapture(e.pointerId);
} catch (err: any) {}
updateMarqueeBox();
};
onMarqueePointerMove = (e: any) => {
if (!marqueeActive) return;
const box = container.getBoundingClientRect();
marqueeCur = {
x: e.clientX - box.left,
y: e.clientY - box.top
};
updateMarqueeBox();
};
onMarqueePointerUp = (e: any) => {
if (!marqueeActive) return;
try {
if (container.releasePointerCapture && e && e.pointerId != null) container.releasePointerCapture(e.pointerId);
} catch (err: any) {}
finishMarquee();
};
container.addEventListener('pointerdown', onCanvasPointerDownCapture, true);
container.addEventListener('pointermove', onMarqueePointerMove);
container.addEventListener('pointerup', onMarqueePointerUp);
}
// ─── initial graph: nodes first, then connections (connections reference live
// node instances), then optional fit. Sequenced via an async IIFE so the
// $onMount-returned teardown stays synchronous. ──────────────────────────────
;
(async () => {
// T1.3 — seed the canvas's own last-written graph from the initial bound value so the
// first gesture's snapshot/base reflects the mounted graph (immune to prop re-bind lag).
lastWrittenGraph = rozieDeepClone(currentGraph());
await reconcileNodes();
await reconcileConnections();
if (typeof zoom.value === 'number' && zoom.value !== 1) {
programmatic++;
try {
await area.area.zoom(zoom.value);
} finally {
programmatic--;
}
}
if (props.fitOnMount && editor.getNodes().length) {
programmatic++;
try {
await AreaExtensions.zoomAt(area, editor.getNodes());
} finally {
programmatic--;
}
if (area) {
const k = area.area.transform.k;
if (k !== zoom.value) zoom.value = k;
}
}
// draw the minimap once the graph + fit have settled (also redrawn on every
// render / pan / zoom / drag / selection / graph change below).
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
})();
_cleanup_0 = () => {
if (onCanvasKeydown && keydownContainer && typeof keydownContainer.removeEventListener === 'function') {
try {
keydownContainer.removeEventListener('keydown', onCanvasKeydown);
} catch (e: any) {}
}
if (dragFlushRaf && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(dragFlushRaf);
} catch (e: any) {}
}
dragFlushRaf = 0;
pendingDragPositions.clear();
// T1.1: drop the edge-selection state + its cached <path> reference on teardown.
clearEdgeSelection();
// MiniMap teardown — remove the pointer-pan listeners + cancel a pending redraw.
if (minimapHost) {
if (onMinimapPointerDown) {
try {
minimapHost.removeEventListener('pointerdown', onMinimapPointerDown);
} catch (e: any) {}
}
if (onMinimapPointerMove) {
try {
minimapHost.removeEventListener('pointermove', onMinimapPointerMove);
} catch (e: any) {}
}
if (onMinimapPointerUp) {
try {
minimapHost.removeEventListener('pointerup', onMinimapPointerUp);
} catch (e: any) {}
}
}
if (minimapRedrawRaf && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(minimapRedrawRaf);
} catch (e: any) {}
}
minimapRedrawRaf = 0;
// T2.8 NodeToolbar teardown — remove the default-button listeners, dispose the optional
// `#toolbar` reactive portal handle, and cancel a pending reposition.
if (toolbarDeleteBtn && onToolbarDelete) {
try {
toolbarDeleteBtn.removeEventListener('pointerup', onToolbarDelete);
} catch (e: any) {}
}
if (toolbarDuplicateBtn && onToolbarDup) {
try {
toolbarDuplicateBtn.removeEventListener('pointerup', onToolbarDup);
} catch (e: any) {}
}
if (toolbarHandle && toolbarHandle.dispose) {
try {
toolbarHandle.dispose();
} catch (e: any) {}
}
toolbarHandle = null;
toolbarSelectedId = null;
if (toolbarTrackRaf && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(toolbarTrackRaf);
} catch (e: any) {}
}
toolbarTrackRaf = 0;
// T2.4 Marquee teardown — remove the capture-phase pointerdown guard + window listeners.
if (keydownContainer) {
if (onCanvasPointerDownCapture) {
try {
keydownContainer.removeEventListener('pointerdown', onCanvasPointerDownCapture, true);
} catch (e: any) {}
}
if (onMarqueePointerMove) {
try {
keydownContainer.removeEventListener('pointermove', onMarqueePointerMove);
} catch (e: any) {}
}
if (onMarqueePointerUp) {
try {
keydownContainer.removeEventListener('pointerup', onMarqueePointerUp);
} catch (e: any) {}
}
}
marqueeActive = false;
marqueeStart = null;
marqueeCur = null;
for (const [, entry] of nodeEntries as any) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
}
nodeEntries.clear();
for (const [, entry] of connEntries as any) entry.dispose();
connEntries.clear();
if (area) area.destroy();
};
});
onBeforeUnmount(() => { _cleanup_0?.(); });
watch(() => graph.value, () => {
// T1.3 — keep the canvas's own last-written graph in sync with an EXTERNAL (non-
// programmatic) consumer change, so undo/redo's "current" state tracks reality (our own
// write-backs / restores set lastWrittenGraph synchronously under the programmatic guard;
// this only refreshes it for a genuine outside edit).
if (selfWriteInFlight) {
// our own commitGraph write echoing back — lastWrittenGraph is already authoritative.
selfWriteInFlight = false;
} else if (!programmatic) {
const c = rozieDeepClone(currentGraph());
if (c != null) lastWrittenGraph = c;
}
if (reconcileNodes) {
Promise.resolve(reconcileNodes()).then(() => {
if (reconcileConnections) reconcileConnections();
});
}
// graph changed (nodes added/removed/moved) → refresh the minimap node rects.
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
});
watch(() => portReg.value, () => {
if (reconcileNodes) {
Promise.resolve(reconcileNodes()).then(() => {
if (reconcileConnections) reconcileConnections();
});
}
});
watch(() => typeReg.value, () => {
if (reconcileNodes) reconcileNodes();
});
watch(() => zoom.value, (v: any) => {
if (!area || typeof v !== 'number') return;
if (v === area.area.transform.k) return;
programmatic++;
Promise.resolve(area.area.zoom(v)).finally(() => {
programmatic--;
});
});
defineExpose({ getEditor, getArea, addNode, removeNode, deleteNode, addConnection, removeConnection, clear, zoomToFit, zoomTo, setCenter, setViewport, screenToFlowPosition, getNodes, getConnections, getTransform, autoArrange, undo, redo, canUndo, canRedo, getSelectedNodes, selectNode, clearSelection, selectAll, centerOnNode });
</script>
<style scoped>
.rozie-flow-canvas {
width: 100%;
height: 100%;
min-height: 360px;
position: relative;
overflow: hidden;
border-radius: 8px;
background:
radial-gradient(circle, rgba(0, 0, 0, 0.08) 1px, transparent 1px) 0 0 / 20px 20px,
#f7f8fa;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.rozie-flow-controls {
position: absolute;
left: 10px;
bottom: 10px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 2px;
pointer-events: none;
}
.rozie-flow-controls__btn {
pointer-events: auto;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
font: 600 16px/1 system-ui, sans-serif;
color: #334155;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
cursor: pointer;
user-select: none;
}
.rozie-flow-controls__btn:hover { background: #f1f5f9; }
.rozie-flow-controls__btn:active { background: #e2e8f0; }
.rozie-flow-controls__btn.is-active { background: #dbeafe; color: #1d4ed8; border-color: #3b82f6; }
.rozie-flow-marquee {
position: absolute;
display: none;
z-index: 9;
pointer-events: none;
background: rgba(59, 130, 246, 0.12);
border: 1px solid #3b82f6;
border-radius: 2px;
}
.rozie-flow-minimap {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 10;
width: 200px;
height: 150px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
overflow: hidden;
cursor: pointer;
touch-action: none;
}
.rozie-flow-minimap__svg { display: block; width: 100%; height: 100%; }
.rozie-flow-toolbar {
position: absolute;
display: none;
z-index: 11;
gap: 4px;
padding: 3px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
pointer-events: auto;
white-space: nowrap;
}
.rozie-flow-toolbar__btn {
font: 600 12px/1 system-ui, sans-serif;
color: #334155;
background: #f8fafc;
border: 1px solid rgba(0, 0, 0, 0.14);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
user-select: none;
}
.rozie-flow-toolbar__btn:hover { background: #eef2f7; }
.rozie-flow-toolbar__btn:active { background: #e2e8f0; }
.rozie-flow-toolbar__btn--delete { color: #b91c1c; }
</style>
<style>
.rozie-flow-canvas .rozie-flow-node {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
min-width: 140px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
user-select: none;
cursor: grab;
font: 13px/1.4 system-ui, sans-serif;
}
.rozie-flow-canvas .rozie-flow-node.is-selected {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5), 0 2px 8px rgba(0, 0, 0, 0.15);
}
.rozie-flow-canvas .rozie-flow-node__title {
padding: 0.5rem 0.75rem;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
}
.rozie-flow-canvas .rozie-flow-node__body { min-width: 0; }
.rozie-flow-canvas .rozie-flow-node__col {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 0;
}
.rozie-flow-canvas .rozie-flow-port {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: #6b7280;
}
.rozie-flow-canvas .rozie-flow-port--output { justify-content: flex-end; }
.rozie-flow-canvas .rozie-flow-socket {
width: 12px;
height: 12px;
border-radius: 50%;
background: #94a3b8;
border: 2px solid #ffffff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
cursor: crosshair;
flex: none;
}
.rozie-flow-canvas .rozie-flow-socket--input { margin-left: -6px; }
.rozie-flow-canvas .rozie-flow-socket--output { margin-right: -6px; }
.rozie-flow-canvas .rozie-flow-socket:hover { background: #3b82f6; }
.rozie-flow-canvas .rozie-flow-node--rows {
display: flex;
flex-direction: column;
}
.rozie-flow-canvas .rozie-flow-node__mid {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
}
.rozie-flow-canvas .rozie-flow-node__row {
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
padding: 0 0.5rem;
}
.rozie-flow-canvas .rozie-flow-port--vertical {
flex-direction: column;
align-items: center;
gap: 0.125rem;
font-size: 0.7rem;
}
.rozie-flow-canvas .rozie-flow-socket--top,
.rozie-flow-canvas .rozie-flow-socket--bottom { margin-left: 0; margin-right: 0; }
.rozie-flow-canvas .rozie-flow-socket--top { margin-top: -6px; }
.rozie-flow-canvas .rozie-flow-socket--bottom { margin-bottom: -6px; }
.rozie-flow-canvas .rozie-flow-connection { position: absolute; }
.rozie-flow-canvas .rozie-flow-connection__svg {
/* display:block is LOAD-BEARING, not cosmetic. An <svg> is display:inline by
default, so the 1px-tall connection SVG sits on the connection element's TEXT
BASELINE — which, with the engine container's default line-height, pushes the
whole path DOWN ~14px. That offset is in screen space (the connection element
is the area-transform origin), so EVERY connection endpoint lands ~14px below
its socket — visibly anchoring connectors at the BOTTOM of each node instead
of on the socket. The socket positions reported by getDOMSocketPosition are
already correct (offsetTop/offsetLeft within the node-view); the inline
baseline is the sole cause of the vertical drift. block (or equivalently
line-height:0 / vertical-align:top on the inline box) removes the baseline gap
so the path renders at its true coordinates. Verified: drops the endpoint→
socket vertical offset from ~13.9px to ~0.1px on all 6 targets. */
display: block;
overflow: visible;
width: 1px;
height: 1px;
pointer-events: none;
}
.rozie-flow-canvas .rozie-flow-connection__path {
fill: none;
stroke: #64748b;
stroke-width: 3px;
pointer-events: auto;
}
.rozie-flow-canvas .rozie-flow-connection__path.is-selected {
stroke: #3b82f6;
stroke-width: 4px;
}
.rozie-flow-canvas .rozie-flow-connection__label {
font: 600 11px system-ui, sans-serif;
fill: #334155;
paint-order: stroke;
stroke: #ffffff;
stroke-width: 3px;
stroke-linejoin: round;
pointer-events: none;
user-select: none;
}
</style>svelte
<script lang="ts">
import { rozieAttr, rozieDisplay } from '@rozie/runtime-svelte';
import type { Snippet } from 'svelte';
import { mount, unmount } from 'svelte';
import PortalHostReactive from '@rozie/runtime-svelte/PortalHostReactive.svelte';
import { onMount, setContext, untrack } from 'svelte';
interface Props {
/**
* The single source of truth (two-way `r-model`) — `{ nodes: [{ id, type, x, y, data? }], connections: [{ id?, source, sourceOutput?, target, targetInput?, label?, stroke?, dashed? }] }`. A node's `type` selects its `<NodeType>` template (render-by-type + port schema); `data` is the opaque payload handed to that type's `#body` scope. The canvas writes back a FRESH top-level object on every drag (x/y) and connect/disconnect (connections) — immutable applyNodeChanges style. `sourceOutput`/`targetInput` default to `out`/`in`; a missing connection `id` is derived from the endpoints.
* @example
* <FlowCanvas r-model:graph="graph" :validate-types="true" />
*/
graph?: any;
/**
* Automatic typed-socket validation (default ON). When `true`, the canvas resolves each endpoint's port type from the per-`<NodeType>` `<Port type>` schema and auto-rejects a type-mismatched connection (firing `connection-rejected`). `canConnect` survives as the optional custom-rule override that runs in addition. Set `false` for pure-`canConnect` (type as metadata only).
*/
validateTypes?: boolean;
/**
* The viewport zoom level (two-way `r-model`). Scroll/pinch writes the new zoom back through the model (echo-guarded against the wrapper's own programmatic zooms); a consumer write zooms the live area. There is deliberately no `zoom`/`zoomed` emit — a same-named emit collides with the model on Vue and Angular — so the two-way binding is the channel for zoom changes.
*/
zoom?: number;
/**
* Whether the canvas can be panned by dragging the background (applied at construction). Set `false` to detach the area's drag handler.
*/
pannable?: boolean;
/**
* Whether the canvas can be zoomed by scroll/pinch (applied at construction). Set `false` to detach the area's zoom handler.
*/
zoomable?: boolean;
/**
* Whether nodes can be selected (click; ctrl-click to accumulate). Reflected as the `selected` flag in the `<NodeType>` `#body` scope and surfaced to the consumer via the `@selection-change` event.
*/
selectable?: boolean;
/**
* Read-only viewer mode — no node drag, no connection editing, and no selection. View-only zoom/fit (Controls, the `zoomTo`/`zoomToFit` verbs) stay enabled.
*/
readonly?: boolean;
/**
* Minimum zoom level — the lower bound of the area's zoom restrictor. `0` disables the bound.
*/
minZoom?: number;
/**
* Maximum zoom level — the upper bound of the area's zoom restrictor. `0` disables the bound.
*/
maxZoom?: number;
/**
* Snap-to-grid size in pixels for node dragging. `0` turns snapping off.
*/
snapGrid?: number;
/**
* When selectable, hold Ctrl to add to the current selection instead of replacing it.
*/
accumulateOnCtrl?: boolean;
/**
* The bezier curvature of connection paths (`classicConnectionPath`).
*/
curvature?: number;
/**
* After the initial graph mounts, pan/zoom the viewport to fit all nodes (`AreaExtensions.zoomAt`).
*/
fitOnMount?: boolean;
/**
* Render the built-in Controls overlay — a zoom in / zoom out / fit-view button cluster (the React Flow `<Controls/>` parity). The buttons drive the same zoom/fit path as the `zoomTo`/`zoomToFit` handle verbs (clamped to `minZoom`/`maxZoom`) and stay enabled in `readonly`. Opt out with `:controls="false"`.
*/
controls?: boolean;
/**
* Render the built-in MiniMap overlay (opt-in, default OFF — the React Flow `<MiniMap/>` parity) — an absolute SVG panel (bottom-right) showing a scaled map of every node (sized from the measured engine node-view dims) plus the current viewport window (the area outside dimmed). It is pannable: dragging the minimap recenters the main viewport (via `setCenter`). Evaluated at construction, like `pannable`/`zoomable`/`controls` — set it at mount time.
*/
minimap?: boolean;
/**
* Connection-validation predicate `(conn) => boolean`, receiving the normalized candidate connection `{ source, sourceOutput, target, targetInput }`. Return `false` to reject the connection — no edge is committed, no ghost path is drawn, and `connection-rejected` fires. Runs in addition to the automatic `:validate-types` check (the custom-rule override) and gates all connection paths uniformly (drag-to-connect, imperative `addConnection`, graph reconcile). Absent/`null` imposes no custom rule.
*/
canConnect?: ((...args: any[]) => any) | null;
/**
* Undo/redo, on by default. Every gesture (drag, connect, disconnect, delete) pushes ONE capped (~100) snapshot of the bound graph (nodes incl. x/y + connections; not the viewport), and `undo()`/`redo()` plus Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, and Ctrl/Cmd+Y restore it through the two-way `graph` model (echo-guarded). One gesture = one undo step; a fresh edit after an undo discards the redo branch. Opt out with `:history="false"` (the snapshot stack stays empty and the verbs no-op).
*/
history?: boolean;
/**
* Two-way interaction mode (`r-model`) — the Figma-style pan ↔ select toggle, `'pan'` (default) or `'select'`. In `'pan'` an empty-canvas drag pans the viewport (unchanged). In `'select'` an empty-canvas drag draws a rubber-band marquee box that multi-selects the intersecting nodes (surfacing `@selection-change`). A node drag still drags the node in both modes — only the empty-canvas drag changes. The canvas writes it back when the built-in mode button toggles (see `marquee`).
*/
mode?: string;
/**
* Render the 4th Controls button — the pan ↔ select mode toggle (it two-way-writes `mode`). Default OFF so the default Controls overlay keeps its three buttons. The marquee behavior works whenever `mode === 'select'` regardless of this flag (a consumer can drive `mode` directly); this only governs the built-in button.
*/
marquee?: boolean;
/**
* Render the opt-in NodeToolbar (default OFF) — a floating toolbar over the single selected node (positioned from the engine node-view rect + the area transform, re-tracked on pan/zoom/drag). Default content is Delete (cascading controlled-graph `deleteNode`) + Duplicate (clone the node spec at an offset with a new id into a fresh `graph` object); both fire `@node-action` (`name: 'delete' | 'duplicate'`). Override the content by filling the `#toolbar` reactive slot.
*/
nodeToolbar?: boolean;
node?: Snippet<[{ node: any; selected: any; emit: any }]>;
toolbar?: Snippet<[{ node: any; emit: any }]>;
children?: Snippet;
snippets?: Record<string, any>;
onedgeclick?: (...args: unknown[]) => void;
onedgeselected?: (...args: unknown[]) => void;
onselectionchange?: (...args: unknown[]) => void;
onconnectend?: (...args: unknown[]) => void;
onnodeaction?: (...args: unknown[]) => void;
onconnectionrejected?: (...args: unknown[]) => void;
onconnectioncreated?: (...args: unknown[]) => void;
onconnectionremoved?: (...args: unknown[]) => void;
onnodepicked?: (...args: unknown[]) => void;
onnodemoved?: (...args: unknown[]) => void;
ontranslated?: (...args: unknown[]) => void;
oncontextmenu?: (...args: unknown[]) => void;
}
let {
graph = $bindable((() => ({
nodes: [],
connections: []
}))()),
validateTypes = true,
zoom = $bindable(1),
pannable = true,
zoomable = true,
selectable = true,
readonly = false,
minZoom = 0.1,
maxZoom = 4,
snapGrid = 0,
accumulateOnCtrl = true,
curvature = 0.3,
fitOnMount = true,
controls = true,
minimap = false,
canConnect = null,
history = true,
mode = $bindable('pan'),
marquee = false,
nodeToolbar = false,
node: __nodeProp,
toolbar: __toolbarProp,
children: __childrenProp,
snippets,
onedgeclick,
onedgeselected,
onselectionchange,
onconnectend,
onnodeaction,
onconnectionrejected,
onconnectioncreated,
onconnectionremoved,
onnodepicked,
onnodemoved,
ontranslated,
oncontextmenu
}: Props = $props();
const node = $derived(__nodeProp ?? snippets?.node);
const toolbar = $derived(__toolbarProp ?? snippets?.toolbar);
const children = $derived(__childrenProp ?? snippets?.children);
let typeReg = $state({});
let portReg = $state({});
let canvasEl = $state<HTMLElement | undefined>(undefined);
let minimapEl = $state<HTMLElement | undefined>(undefined);
let marqueeEl = $state<HTMLElement | undefined>(undefined);
let toolbarEl = $state<HTMLElement | undefined>(undefined);
import { NodeEditor, ClassicPreset, Scope } from 'rete';
import { AreaPlugin, AreaExtensions } from 'rete-area-plugin';
import { ConnectionPlugin, Presets as ConnectionPresets } from 'rete-connection-plugin';
import { getDOMSocketPosition, classicConnectionPath } from 'rete-render-utils';
// T2.6 — auto-layout (D-08, verb-only). The 3 deps (rete-auto-arrange-plugin / elkjs
// @0.8.2 / web-worker) are OPTIONAL leaf peers, installed + bundle-smoked on all 6 in
// Plan 00 (the Vite/Angular-AOT/Lit rollup build resolves elkjs to the SYNCHRONOUS
// elk.bundled.js entry — no web-worker resolution error, no manual fallback switch). Only
// a consumer calling autoArrange() pulls these in.
// T2.6 — auto-layout (D-08, verb-only). The 3 deps (rete-auto-arrange-plugin / elkjs
// @0.8.2 / web-worker) are OPTIONAL leaf peers, installed + bundle-smoked on all 6 in
// Plan 00 (the Vite/Angular-AOT/Lit rollup build resolves elkjs to the SYNCHRONOUS
// elk.bundled.js entry — no web-worker resolution error, no manual fallback switch). Only
// a consumer calling autoArrange() pulls these in.
import { AutoArrangePlugin, Presets as ArrangePresets } from 'rete-auto-arrange-plugin';
// ── engine instances — null-lets so typeNeutralize types them `any` (the
// MapLibre `let instance = null` discipline). Rete's NodeEditor / AreaPlugin /
// ConnectionPlugin / DOMSocketPosition carry rich generic Schemes types that the
// loosely-typed .rozie props (any[]) don't satisfy under the strict react/solid/
// lit leaf tsc; routing every engine call through an `any` instance is the
// .rozie-native fix (no lang="ts", no codegen type-aid). These are top-level lets
// referenced from hooks → React auto-hoists each to a useRef. ──
// ── engine instances — null-lets so typeNeutralize types them `any` (the
// MapLibre `let instance = null` discipline). Rete's NodeEditor / AreaPlugin /
// ConnectionPlugin / DOMSocketPosition carry rich generic Schemes types that the
// loosely-typed .rozie props (any[]) don't satisfy under the strict react/solid/
// lit leaf tsc; routing every engine call through an `any` instance is the
// .rozie-native fix (no lang="ts", no codegen type-aid). These are top-level lets
// referenced from hooks → React auto-hoists each to a useRef. ──
let editor: any = null;
let area: any = null;
let connectionPlugin: any = null;
let socketWatcher: any = null;
let renderScope: any = null;
let selector: any = null;
// T2.6 — the AutoArrangePlugin instance (elkjs-backed). COMPONENT-scope (NOT $onMount-local)
// so the top-level autoArrange() verb sees it (the editor/area discipline). null until $onMount
// wires it; the verb no-ops before mount.
// T2.6 — the AutoArrangePlugin instance (elkjs-backed). COMPONENT-scope (NOT $onMount-local)
// so the top-level autoArrange() verb sees it (the editor/area discipline). null until $onMount
// wires it; the verb no-ops before mount.
let arrange: any = null;
// Win 1: the Delete/Backspace keydown listener + its host container. COMPONENT-scope
// (NOT $onMount-local) so the $onMount-returned teardown — which the Solid emitter
// hoists into a sibling onCleanup() OUTSIDE the mount IIFE — can still see them to
// removeEventListener (the same component-scope discipline as nodeInstances below).
// Win 1: the Delete/Backspace keydown listener + its host container. COMPONENT-scope
// (NOT $onMount-local) so the $onMount-returned teardown — which the Solid emitter
// hoists into a sibling onCleanup() OUTSIDE the mount IIFE — can still see them to
// removeEventListener (the same component-scope discipline as nodeInstances below).
let keydownContainer: any = null;
let onCanvasKeydown: any = null;
// Phase 42 MiniMap (opt-in :minimap) — the absolute SVG overlay host + its imperative
// SVG layer + the pointer-pan listeners. COMPONENT-scope (NOT $onMount-local) so the
// $onMount-returned teardown — which the Solid emitter hoists into a sibling
// onCleanup() OUTSIDE the mount IIFE — can still removeEventListener them (the same
// keydown / nodeInstances discipline). `minimapMap` is the live minimap-px ↔ graph-
// coord mapping the pointer-pan handlers read; `scheduleMinimapRedraw` is the bridge
// the top-level $watch + the engine pipes call (assigned inside $onMount, like the
// reconcilers). minimapRedrawRaf coalesces the viewport-rect redraw to one per frame
// (the drag-write-back discipline — the viewport rect redraws on every pan/zoom).
// Phase 42 MiniMap (opt-in :minimap) — the absolute SVG overlay host + its imperative
// SVG layer + the pointer-pan listeners. COMPONENT-scope (NOT $onMount-local) so the
// $onMount-returned teardown — which the Solid emitter hoists into a sibling
// onCleanup() OUTSIDE the mount IIFE — can still removeEventListener them (the same
// keydown / nodeInstances discipline). `minimapMap` is the live minimap-px ↔ graph-
// coord mapping the pointer-pan handlers read; `scheduleMinimapRedraw` is the bridge
// the top-level $watch + the engine pipes call (assigned inside $onMount, like the
// reconcilers). minimapRedrawRaf coalesces the viewport-rect redraw to one per frame
// (the drag-write-back discipline — the viewport rect redraws on every pan/zoom).
let minimapHost: any = null;
let minimapSvg: any = null;
let minimapRedrawRaf = 0;
let minimapMap: any = null;
let minimapPanning = false;
let onMinimapPointerDown: any = null;
let onMinimapPointerMove: any = null;
let onMinimapPointerUp: any = null;
let scheduleMinimapRedraw: any = null;
// T2.4 MARQUEE select (mode:'select') — the programmatic-select handle captured from
// AreaExtensions.selectableNodes ({ select(id, accumulate), unselect(id) }), the rubber-
// band overlay box (component-template DOM, scoped CSS), and the capture-phase pointerdown
// guard + window pointer listeners that draw the box in select mode. COMPONENT-scope (NOT
// $onMount-local) so the Solid-hoisted teardown can removeEventListener them (the keydown /
// minimap discipline). `marqueeBox` is the absolute overlay <div>; `marqueeActive` gates the
// in-progress drag; `marqueeStart`/`marqueeCur` are container-relative px corners.
// T2.4 MARQUEE select (mode:'select') — the programmatic-select handle captured from
// AreaExtensions.selectableNodes ({ select(id, accumulate), unselect(id) }), the rubber-
// band overlay box (component-template DOM, scoped CSS), and the capture-phase pointerdown
// guard + window pointer listeners that draw the box in select mode. COMPONENT-scope (NOT
// $onMount-local) so the Solid-hoisted teardown can removeEventListener them (the keydown /
// minimap discipline). `marqueeBox` is the absolute overlay <div>; `marqueeActive` gates the
// in-progress drag; `marqueeStart`/`marqueeCur` are container-relative px corners.
let nodeSelectApi: any = null;
let marqueeBox: any = null;
let marqueeActive = false;
let marqueeStart: any = null;
let marqueeCur: any = null;
let onCanvasPointerDownCapture: any = null;
let onMarqueePointerMove: any = null;
let onMarqueePointerUp: any = null;
// T2.8 NodeToolbar (opt-in :node-toolbar) — a floating component-template overlay (scoped
// CSS, like the marquee box + Controls) over the SELECTED node, positioned from the engine
// node-view element's rect relative to the canvas container + the area transform. COMPONENT-
// scope (NOT $onMount-local) so the Solid-hoisted teardown sees them. `toolbarHost` is the
// absolute overlay <div> (the $refs.toolbarEl element); `toolbarSelectedId` is the id of the
// node the toolbar currently tracks (the SINGLE selected node — null when nothing or >1 is
// selected, or selection is empty); `toolbarHandle` is the optional `#toolbar` reactive-
// portal handle ({ update, dispose }) when the consumer fills the slot; `scheduleToolbarTrack`
// is the rAF-coalesced reposition bridge (assigned in $onMount, called by the area pipes +
// the selection emit, like scheduleMinimapRedraw); `toolbarTrackRaf` coalesces it to one per
// frame. `toolbarDeleteBtn`/`toolbarDuplicateBtn` are the default buttons (kept so teardown
// can removeEventListener them); their pointerup handlers are `onToolbarDelete`/`onToolbarDup`.
// T2.8 NodeToolbar (opt-in :node-toolbar) — a floating component-template overlay (scoped
// CSS, like the marquee box + Controls) over the SELECTED node, positioned from the engine
// node-view element's rect relative to the canvas container + the area transform. COMPONENT-
// scope (NOT $onMount-local) so the Solid-hoisted teardown sees them. `toolbarHost` is the
// absolute overlay <div> (the $refs.toolbarEl element); `toolbarSelectedId` is the id of the
// node the toolbar currently tracks (the SINGLE selected node — null when nothing or >1 is
// selected, or selection is empty); `toolbarHandle` is the optional `#toolbar` reactive-
// portal handle ({ update, dispose }) when the consumer fills the slot; `scheduleToolbarTrack`
// is the rAF-coalesced reposition bridge (assigned in $onMount, called by the area pipes +
// the selection emit, like scheduleMinimapRedraw); `toolbarTrackRaf` coalesces it to one per
// frame. `toolbarDeleteBtn`/`toolbarDuplicateBtn` are the default buttons (kept so teardown
// can removeEventListener them); their pointerup handlers are `onToolbarDelete`/`onToolbarDup`.
let toolbarHost: any = null;
let toolbarSelectedId: any = null;
let toolbarHandle: any = null;
let scheduleToolbarTrack: any = null;
// component-scope bridge to the $onMount-local syncToolbar (the scheduleMinimapRedraw
// discipline) — called from maybeEmitSelectionChange + the area pipes so a pick/unpick /
// pan / zoom / drag re-tracks the toolbar over the selected node.
// component-scope bridge to the $onMount-local syncToolbar (the scheduleMinimapRedraw
// discipline) — called from maybeEmitSelectionChange + the area pipes so a pick/unpick /
// pan / zoom / drag re-tracks the toolbar over the selected node.
let syncToolbarSelection: any = null;
let toolbarTrackRaf = 0;
let toolbarDeleteBtn: any = null;
let toolbarDuplicateBtn: any = null;
let onToolbarDelete: any = null;
let onToolbarDup: any = null;
// MiniMap geometry (px) — MUST match the .rozie-flow-minimap CSS box below.
// MiniMap geometry (px) — MUST match the .rozie-flow-minimap CSS box below.
const MINIMAP_W = 200;
const MINIMAP_H = 150;
// Fallback node-rect dims when a node-view element isn't measurable yet (Lit async
// first paint, REQ-30) — re-measured on the next render (the render pipe re-schedules).
// Fallback node-rect dims when a node-view element isn't measurable yet (Lit async
// first paint, REQ-30) — re-measured on the next render (the render pipe re-schedules).
const MINIMAP_DEFAULT_NODE_W = 140;
const MINIMAP_DEFAULT_NODE_H = 52;
const SVGNS = 'http://www.w3.org/2000/svg';
// One Socket shared by every port (Rete sockets gate compatibility by identity;
// a single socket = "anything connects to anything", the common editor default).
// One Socket shared by every port (Rete sockets gate compatibility by identity;
// a single socket = "anything connects to anything", the common editor default).
const SOCKET = new ClassicPreset.Socket('flow');
// Live engine bookkeeping — COMPONENT-scope (NOT $onMount-local) so the
// $onMount-returned teardown, which the Solid emitter hoists into a sibling
// onCleanup() OUTSIDE the mount IIFE, keeps them in scope (the MapLibre
// markerEntries lesson).
// nodeInstances : id → live ClassicPreset.Node (engine truth)
// nodeMeta : id → the consumer's node spec object (for the slot scope)
// connInstances : id → live ClassicPreset.Connection (engine truth)
// nodeEntries : id → { element, bodyHost, handle, socketDisposers }
// connEntries : id → { element, dispose }
// Live engine bookkeeping — COMPONENT-scope (NOT $onMount-local) so the
// $onMount-returned teardown, which the Solid emitter hoists into a sibling
// onCleanup() OUTSIDE the mount IIFE, keeps them in scope (the MapLibre
// markerEntries lesson).
// nodeInstances : id → live ClassicPreset.Node (engine truth)
// nodeMeta : id → the consumer's node spec object (for the slot scope)
// connInstances : id → live ClassicPreset.Connection (engine truth)
// nodeEntries : id → { element, bodyHost, handle, socketDisposers }
// connEntries : id → { element, dispose }
const nodeInstances = new Map();
const nodeMeta = new Map();
const connInstances = new Map();
const nodeEntries = new Map();
const connEntries = new Map();
// connMeta : id → the consumer's connection spec ({ …, label?, stroke?, dashed? }) — the
// connection-side analog of nodeMeta, read by renderConnection for per-edge label/styling (F3).
// connMeta : id → the consumer's connection spec ({ …, label?, stroke?, dashed? }) — the
// connection-side analog of nodeMeta, read by renderConnection for per-edge label/styling (F3).
const connMeta = new Map();
// ids last applied FROM THE BOUND GRAPH, so reconcile removes only graph-managed
// entities — an imperative $expose addNode/addConnection is NOT auto-reaped on the
// next graph change (the power-user escape hatch stays alive). MapLibre reconciles
// every marker because markers are purely prop-driven; a flow editor also accepts
// imperative edits, so it tracks provenance. (Phase 41: nodes/connections now come
// ONLY from the single `graph` model — the per-instance declarative-children
// registries are gone; node TYPE templates + port schemas live in typeReg/portReg.)
// ids last applied FROM THE BOUND GRAPH, so reconcile removes only graph-managed
// entities — an imperative $expose addNode/addConnection is NOT auto-reaped on the
// next graph change (the power-user escape hatch stays alive). MapLibre reconciles
// every marker because markers are purely prop-driven; a flow editor also accepts
// imperative edits, so it tracks provenance. (Phase 41: nodes/connections now come
// ONLY from the single `graph` model — the per-instance declarative-children
// registries are gone; node TYPE templates + port schemas live in typeReg/portReg.)
let lastPropNodeIds: any = null;
let lastPropConnIds: any = null;
// Re-entrant suppression counter: while > 0 the editor/area event handlers skip
// echoing back into $emit / $model (our own programmatic add/remove/translate/
// zoom must not bounce out as if the user did it — the MapLibre PROGRAMMATIC
// eventData guard, in counter form so batched/nested ops never race).
// Re-entrant suppression counter: while > 0 the editor/area event handlers skip
// echoing back into $emit / $model (our own programmatic add/remove/translate/
// zoom must not bounce out as if the user did it — the MapLibre PROGRAMMATIC
// eventData guard, in counter form so batched/nested ops never race).
let programmatic = 0;
// Win 2: the last emitted selection id-set, joined to a stable string, so
// @selection-change fires ONLY on an actual change (a repeated identical pick/unpick
// set does not spam the consumer). `null` until the first emit (so the initial empty
// selection does not emit on mount). COMPONENT-scope so it survives across area events.
// Win 2: the last emitted selection id-set, joined to a stable string, so
// @selection-change fires ONLY on an actual change (a repeated identical pick/unpick
// set does not spam the consumer). `null` until the first emit (so the initial empty
// selection does not emit on mount). COMPONENT-scope so it survives across area events.
let lastSelectionIds: any = null;
// T1.1 — EDGE SELECTION (D-08). The currently-selected CONNECTION id, or null. Lives
// PURELY in component script (the selectedNodeIds echo-safety discipline) — NEVER
// written into $model.graph, so the controlled-graph write-back assertions are
// unaffected (Threat T-44-01-2: no spurious model write). COMPONENT-scope so it
// survives across area events + so the Solid-hoisted teardown can clear it. The
// `.is-selected` class is toggled imperatively on the engine-DOM __path; this id is the
// source of truth the Delete branch reads. `selectedPathEl` caches the live <path>
// element so a background-click clear (and re-select) can drop `.is-selected` without
// re-walking the DOM. `edgeClickGuard` is a one-shot flag the area-background pointerup
// branch checks so an edge click (which fires its own pointerup on the path AND lets the
// area's background pointerup run) does not immediately clear the selection it just made
// — reset on the next microtask, after the gesture settles.
// T1.1 — EDGE SELECTION (D-08). The currently-selected CONNECTION id, or null. Lives
// PURELY in component script (the selectedNodeIds echo-safety discipline) — NEVER
// written into $model.graph, so the controlled-graph write-back assertions are
// unaffected (Threat T-44-01-2: no spurious model write). COMPONENT-scope so it
// survives across area events + so the Solid-hoisted teardown can clear it. The
// `.is-selected` class is toggled imperatively on the engine-DOM __path; this id is the
// source of truth the Delete branch reads. `selectedPathEl` caches the live <path>
// element so a background-click clear (and re-select) can drop `.is-selected` without
// re-walking the DOM. `edgeClickGuard` is a one-shot flag the area-background pointerup
// branch checks so an edge click (which fires its own pointerup on the path AND lets the
// area's background pointerup run) does not immediately clear the selection it just made
// — reset on the next microtask, after the gesture settles.
let selectedConnId: any = null;
let selectedPathEl: any = null;
let edgeClickGuard = false;
// T1.3 — UNDO / REDO (D-02 on-by-default, D-03 per-gesture graph-only scope, D-04
// echo-guarded restore). A CAPPED snapshot stack over the BOUND GRAPH only — nodes
// (incl x/y) + connections — and explicitly NOT the viewport (pan/zoom is excluded,
// D-03). One entry is pushed per COMPLETED gesture: a drag = ONE entry (snapshot taken
// on pointer-down, committed on the first translate — never per pointermove frame), a
// connect / disconnect / delete = one each. A push is gated on `!programmatic` so a
// restore-driven write (which runs INSIDE the programmatic guard) never re-enters the
// history (D-04). Pushing clears the redo branch and drops the oldest entry beyond the
// cap (Threat T-44-03-1: bounded memory). Snapshots are deep clones of the consumer's own
// serializable graph JSON (Pattern 7; the `$clone` sigil — a deep, de-proxied copy
// that strips the Vue/Svelte reactivity Proxy that a bare `structuredClone` THROWS
// on) — no external input, so the restore (T-44-03-2 accept)
// cannot loop (it rides the programmatic guard + the existing $watch(graph) reconcile).
// Undo is ALWAYS on for v1; `:history=false` (the `history` prop) is the cheap escape
// hatch that skips every push (the stacks stay empty → undo/redo are no-ops).
// COMPONENT-scope so the stack survives across area events + the Solid-hoisted teardown.
// T1.3 — UNDO / REDO (D-02 on-by-default, D-03 per-gesture graph-only scope, D-04
// echo-guarded restore). A CAPPED snapshot stack over the BOUND GRAPH only — nodes
// (incl x/y) + connections — and explicitly NOT the viewport (pan/zoom is excluded,
// D-03). One entry is pushed per COMPLETED gesture: a drag = ONE entry (snapshot taken
// on pointer-down, committed on the first translate — never per pointermove frame), a
// connect / disconnect / delete = one each. A push is gated on `!programmatic` so a
// restore-driven write (which runs INSIDE the programmatic guard) never re-enters the
// history (D-04). Pushing clears the redo branch and drops the oldest entry beyond the
// cap (Threat T-44-03-1: bounded memory). Snapshots are deep clones of the consumer's own
// serializable graph JSON (Pattern 7; the `$clone` sigil — a deep, de-proxied copy
// that strips the Vue/Svelte reactivity Proxy that a bare `structuredClone` THROWS
// on) — no external input, so the restore (T-44-03-2 accept)
// cannot loop (it rides the programmatic guard + the existing $watch(graph) reconcile).
// Undo is ALWAYS on for v1; `:history=false` (the `history` prop) is the cheap escape
// hatch that skips every push (the stacks stay empty → undo/redo are no-ops).
// COMPONENT-scope so the stack survives across area events + the Solid-hoisted teardown.
const HISTORY_CAP = 100;
// Two-stack model (simpler + correct than a single cursor): `historyStack` holds
// PRE-gesture snapshots (the states to UNDO back to, newest last); `redoStack` holds
// snapshots an undo popped off (the states to REDO forward to, newest last). A new
// gesture (pushHistory) snapshots the PRE-gesture graph onto historyStack and CLEARS
// redoStack (a fresh edit discards the redo branch). undo() pops historyStack → pushes
// the CURRENT (pre-undo) graph onto redoStack → restores the popped snapshot. redo()
// pops redoStack → pushes the current graph back onto historyStack → restores it.
// Two-stack model (simpler + correct than a single cursor): `historyStack` holds
// PRE-gesture snapshots (the states to UNDO back to, newest last); `redoStack` holds
// snapshots an undo popped off (the states to REDO forward to, newest last). A new
// gesture (pushHistory) snapshots the PRE-gesture graph onto historyStack and CLEARS
// redoStack (a fresh edit discards the redo branch). undo() pops historyStack → pushes
// the CURRENT (pre-undo) graph onto redoStack → restores the popped snapshot. redo()
// pops redoStack → pushes the current graph back onto historyStack → restores it.
let historyStack = [];
let redoStack = [];
// One-shot per-drag guard: a drag fires `nodetranslated` (→ flushDragWriteBack) on EVERY
// pointermove frame, so a push-per-flush would record many entries for ONE gesture. We
// snapshot the PRE-drag graph on `nodepicked` (pointer-DOWN, definitively before any
// movement — capturing it on the first `nodetranslated` is too late: the engine has
// already applied the initial delta + may have flushed a write-back, so $props.graph no
// longer holds the start position), stash it in `pendingDragSnapshot`, and COMMIT it to
// the history stack on the FIRST `nodetranslated` of the gesture (a pick WITHOUT a drag
// must not create a history entry). `dragGestureActive` then holds until the drag-ending
// `pointerup` resets it. D-03: a drag = ONE undo step.
// One-shot per-drag guard: a drag fires `nodetranslated` (→ flushDragWriteBack) on EVERY
// pointermove frame, so a push-per-flush would record many entries for ONE gesture. We
// snapshot the PRE-drag graph on `nodepicked` (pointer-DOWN, definitively before any
// movement — capturing it on the first `nodetranslated` is too late: the engine has
// already applied the initial delta + may have flushed a write-back, so $props.graph no
// longer holds the start position), stash it in `pendingDragSnapshot`, and COMMIT it to
// the history stack on the FIRST `nodetranslated` of the gesture (a pick WITHOUT a drag
// must not create a history entry). `dragGestureActive` then holds until the drag-ending
// `pointerup` resets it. D-03: a drag = ONE undo step.
let dragGestureActive = false;
let pendingDragSnapshot: any = null;
// T2.5 — RECONNECT coalescing (D-08 reconnectable edges, D-03 one-gesture-one-entry).
// Dragging an existing edge endpoint to a new socket is a SINGLE user gesture, but the
// shipped `Presets.classic.setup()` implements it as `editor.removeConnection(old)` then
// `editor.addConnection(new)` — so the write-back pipe sees a `connectionremoved` followed
// by a `connectioncreated`, which would push TWO history entries (Pitfall 2: two Ctrl+Z to
// undo one drag). The fix is to COALESCE: the ConnectionPlugin emits `connectionpick` when
// the user grabs a socket and `connectiondrop` when they release. While a reconnect is in
// flight (`reconnectInFlight > 0`) we SUPPRESS the per-event history pushes that
// writeBackConnectionRemoved / writeBackConnectionCreated normally do (the graph write-back
// itself STILL runs — the controlled graph stays correct), capturing the PRE-gesture
// snapshot ONCE on connectionpick (`reconnectPreSnapshot`). On `connectiondrop` we push that
// single snapshot (whether the drop landed on a new socket → `created:true` = a real
// reconnect, OR on an empty pane → `created:false` = the edge was removed with no re-add)
// and clear the flag. A plain drag-to-connect from an UNCONNECTED output socket also fires
// connectionpick/drop, but there is no remove in that gesture — the single `connectioncreated`
// write-back's own pushHistory is suppressed and the one coalesced snapshot is pushed on drop
// instead, so the per-gesture count stays exactly one either way. Counter form (not a bool)
// so a re-pick mid-gesture can't desync. COMPONENT-scope (survives across area events).
// T2.5 — RECONNECT coalescing (D-08 reconnectable edges, D-03 one-gesture-one-entry).
// Dragging an existing edge endpoint to a new socket is a SINGLE user gesture, but the
// shipped `Presets.classic.setup()` implements it as `editor.removeConnection(old)` then
// `editor.addConnection(new)` — so the write-back pipe sees a `connectionremoved` followed
// by a `connectioncreated`, which would push TWO history entries (Pitfall 2: two Ctrl+Z to
// undo one drag). The fix is to COALESCE: the ConnectionPlugin emits `connectionpick` when
// the user grabs a socket and `connectiondrop` when they release. While a reconnect is in
// flight (`reconnectInFlight > 0`) we SUPPRESS the per-event history pushes that
// writeBackConnectionRemoved / writeBackConnectionCreated normally do (the graph write-back
// itself STILL runs — the controlled graph stays correct), capturing the PRE-gesture
// snapshot ONCE on connectionpick (`reconnectPreSnapshot`). On `connectiondrop` we push that
// single snapshot (whether the drop landed on a new socket → `created:true` = a real
// reconnect, OR on an empty pane → `created:false` = the edge was removed with no re-add)
// and clear the flag. A plain drag-to-connect from an UNCONNECTED output socket also fires
// connectionpick/drop, but there is no remove in that gesture — the single `connectioncreated`
// write-back's own pushHistory is suppressed and the one coalesced snapshot is pushed on drop
// instead, so the per-gesture count stays exactly one either way. Counter form (not a bool)
// so a re-pick mid-gesture can't desync. COMPONENT-scope (survives across area events).
let reconnectInFlight = 0;
let reconnectPreSnapshot: any = null;
// Set true if a write-back (remove or add) actually ran during the in-flight window, so a
// connectionpick→drop that changed NOTHING (e.g. clicking a socket then releasing on the
// pane with no edge created/removed) does NOT push an empty history entry.
// Set true if a write-back (remove or add) actually ran during the in-flight window, so a
// connectionpick→drop that changed NOTHING (e.g. clicking a socket then releasing on the
// pane with no edge created/removed) does NOT push an empty history entry.
let reconnectDidWriteBack = false;
// One-shot guard for the DEFERRED close (the drop fires BEFORE the trailing remove+add
// writeBacks, so the window must close on a macrotask AFTER they settle — see the
// connectiondrop branch). A re-pick before the deferred close runs cancels it.
// One-shot guard for the DEFERRED close (the drop fires BEFORE the trailing remove+add
// writeBacks, so the window must close on a macrotask AFTER they settle — see the
// connectiondrop branch). A re-pick before the deferred close runs cancels it.
let reconnectCloseScheduled = false;
// ─── controlled-graph write-back (D4 — the central NEW capability) ─────────────
// On every drag/connect/disconnect the canvas emits a FRESH top-level
// `{ nodes, connections }` object via `$model.graph` — immutable React-Flow
// applyNodeChanges style (Wave-0-proven 6/6; in-place deep mutation is SILENT on
// React/Solid/Lit/Angular). Echo-guarded by the `programmatic` counter + the
// no-op-diff property: the write-back value already matches engine truth (the node
// is already at x/y; the edge already exists) so the consumer's re-bind →
// $watch(graph) → reconcile is a no-op diff.
//
// DRAG COALESCING (Pitfall 2): `nodetranslated` fires on every pointermove during a
// drag; emitting a fresh graph + full reconcile per frame is a rebuild storm. We
// accumulate the latest position per node (pendingDragPositions) and flush ONE fresh
// graph write per animation frame (dragFlushRaf), plus a final flush so the last
// position always lands. requestAnimationFrame coalesces multiple moves in a frame
// into a single $model.graph emit.
// ─── controlled-graph write-back (D4 — the central NEW capability) ─────────────
// On every drag/connect/disconnect the canvas emits a FRESH top-level
// `{ nodes, connections }` object via `$model.graph` — immutable React-Flow
// applyNodeChanges style (Wave-0-proven 6/6; in-place deep mutation is SILENT on
// React/Solid/Lit/Angular). Echo-guarded by the `programmatic` counter + the
// no-op-diff property: the write-back value already matches engine truth (the node
// is already at x/y; the edge already exists) so the consumer's re-bind →
// $watch(graph) → reconcile is a no-op diff.
//
// DRAG COALESCING (Pitfall 2): `nodetranslated` fires on every pointermove during a
// drag; emitting a fresh graph + full reconcile per frame is a rebuild storm. We
// accumulate the latest position per node (pendingDragPositions) and flush ONE fresh
// graph write per animation frame (dragFlushRaf), plus a final flush so the last
// position always lands. requestAnimationFrame coalesces multiple moves in a frame
// into a single $model.graph emit.
const pendingDragPositions = new Map(); // id → { x, y } (latest during a drag)
// id → { x, y } (latest during a drag)
let dragFlushRaf = 0;
// The current bound graph — NEVER mutated in place.
// The current bound graph — NEVER mutated in place.
const currentGraph = () => graph || {
nodes: [],
connections: []
};
// T1.3 — deep-clone a graph snapshot. The graph is serializable JSON (nodes/connections of
// primitives), so JSON round-trip is the robust path: it strips framework reactivity
// wrappers — a Vue `reactive()` Proxy / Svelte `$state` proxy that a bare
// `structuredClone` THROWS on ("could not be cloned"), the silent vue/svelte-only
// failure that left the history stack empty. Phase 45 replaced the hand-rolled
// JSON-first clone helper with the `$clone(x)` sigil at every call site below: it
// lowers to `rozieDeepClone(x)` on Vue (Phase 45-07 — a recursive proxy-safe deep
// clone in @rozie/runtime-vue that de-proxies nested INDEPENDENT reactive members,
// not just the top level), `$state.snapshot(x)` on Svelte, and `structuredClone(x)`
// on the other four — a deep, independent, de-proxied copy on all six (and
// `$clone(null)` → `null` on all six, preserving the old `g == null` early-return
// implicitly). The Rete graph is JSON-serializable, so `$clone` never throws here;
// the former null-return fallbacks at the call sites are now dead but harmless.
// T1.3 — the canvas's OWN last-written graph. Every write-back funnels through
// `commitGraph`, which sets `$model.graph` AND records the written value here. undo/redo
// use THIS (not the round-tripped `$props.graph`) as the "current" state to push onto the
// opposite stack — `$props.graph` lags a drag write-back on React/Vue/Svelte (the
// two-way re-bind is async / batched), so reading it at undo time captured an
// INTERMEDIATE drag position. `lastWrittenGraph` is exact + synchronous. Seeded from the
// bound graph in $onMount.
// T1.3 — deep-clone a graph snapshot. The graph is serializable JSON (nodes/connections of
// primitives), so JSON round-trip is the robust path: it strips framework reactivity
// wrappers — a Vue `reactive()` Proxy / Svelte `$state` proxy that a bare
// `structuredClone` THROWS on ("could not be cloned"), the silent vue/svelte-only
// failure that left the history stack empty. Phase 45 replaced the hand-rolled
// JSON-first clone helper with the `$clone(x)` sigil at every call site below: it
// lowers to `rozieDeepClone(x)` on Vue (Phase 45-07 — a recursive proxy-safe deep
// clone in @rozie/runtime-vue that de-proxies nested INDEPENDENT reactive members,
// not just the top level), `$state.snapshot(x)` on Svelte, and `structuredClone(x)`
// on the other four — a deep, independent, de-proxied copy on all six (and
// `$clone(null)` → `null` on all six, preserving the old `g == null` early-return
// implicitly). The Rete graph is JSON-serializable, so `$clone` never throws here;
// the former null-return fallbacks at the call sites are now dead but harmless.
// T1.3 — the canvas's OWN last-written graph. Every write-back funnels through
// `commitGraph`, which sets `$model.graph` AND records the written value here. undo/redo
// use THIS (not the round-tripped `$props.graph`) as the "current" state to push onto the
// opposite stack — `$props.graph` lags a drag write-back on React/Vue/Svelte (the
// two-way re-bind is async / batched), so reading it at undo time captured an
// INTERMEDIATE drag position. `lastWrittenGraph` is exact + synchronous. Seeded from the
// bound graph in $onMount.
let lastWrittenGraph: any = null;
// Funnel for every component-driven graph write: record the value, then emit it. A deep
// clone is stored so a later consumer mutation of the live bound object can't corrupt the
// recorded state. (Echo-guarding is the CALLER's responsibility — restoreGraph wraps this
// in the programmatic guard.) `selfWriteInFlight` suppresses the resulting $watch(graph)
// tick from clobbering `lastWrittenGraph` with the (possibly still-stale, async) bound
// prop value — the value we just wrote IS the truth.
// Funnel for every component-driven graph write: record the value, then emit it. A deep
// clone is stored so a later consumer mutation of the live bound object can't corrupt the
// recorded state. (Echo-guarding is the CALLER's responsibility — restoreGraph wraps this
// in the programmatic guard.) `selfWriteInFlight` suppresses the resulting $watch(graph)
// tick from clobbering `lastWrittenGraph` with the (possibly still-stale, async) bound
// prop value — the value we just wrote IS the truth.
let selfWriteInFlight = false;
const commitGraph = (g: any) => {
const c = $state.snapshot(g);
lastWrittenGraph = c != null ? c : g;
selfWriteInFlight = true;
graph = g;
};
// Capture the canvas's current graph state (its own last write, falling back to the bound
// prop before the first write). Always a fresh deep clone.
// Capture the canvas's current graph state (its own last write, falling back to the bound
// prop before the first write). Always a fresh deep clone.
const snapshotCurrent = () => {
const src = lastWrittenGraph != null ? lastWrittenGraph : currentGraph();
return $state.snapshot(src);
};
// The BASE graph a write-back builds its fresh object from: the canvas's own last write if
// present (immune to the async prop re-bind lag), else the bound prop. This keeps a rapid
// gesture sequence (e.g. drag then immediately disconnect) consistent even before the
// consumer's two-way re-bind has propagated the prior write back into `$props.graph`.
// The BASE graph a write-back builds its fresh object from: the canvas's own last write if
// present (immune to the async prop re-bind lag), else the bound prop. This keeps a rapid
// gesture sequence (e.g. drag then immediately disconnect) consistent even before the
// consumer's two-way re-bind has propagated the prior write back into `$props.graph`.
const baseGraph = () => lastWrittenGraph != null ? lastWrittenGraph : currentGraph();
// Commit an ALREADY-CAPTURED snapshot onto the undo stack (caps + clears redo). Gated on
// the `history` prop. Used by both the synchronous-commit path (connect/disconnect/delete)
// and the drag gesture (pre-move snapshot taken on pointer-down, committed on first translate).
// Commit an ALREADY-CAPTURED snapshot onto the undo stack (caps + clears redo). Gated on
// the `history` prop. Used by both the synchronous-commit path (connect/disconnect/delete)
// and the drag gesture (pre-move snapshot taken on pointer-down, committed on first translate).
const pushHistorySnapshot = (snap: any) => {
if (history === false) return;
if (!snap) return;
historyStack.push(snap);
if (historyStack.length > HISTORY_CAP) {
historyStack = historyStack.slice(historyStack.length - HISTORY_CAP);
}
redoStack = [];
};
// Snapshot the canvas's CURRENT graph state + commit it onto the undo stack (the connect /
// disconnect / delete path — called BEFORE the write-back so the snapshot is the
// pre-gesture state). Gated on `!programmatic` (echo-guard) + history. D-03: one per gesture.
// Snapshot the canvas's CURRENT graph state + commit it onto the undo stack (the connect /
// disconnect / delete path — called BEFORE the write-back so the snapshot is the
// pre-gesture state). Gated on `!programmatic` (echo-guard) + history. D-03: one per gesture.
const pushHistory = () => {
if (programmatic) return;
if (history === false) return;
pushHistorySnapshot(snapshotCurrent());
};
// T2.5 — close the reconnect coalesce window. Called on a DEFERRED macrotask after a
// connectiondrop, so the trailing connectionremoved + connectioncreated writeBacks (which
// the classic preset fires AFTER the drop) have all run with the window still open
// (suppressing their per-event pushHistory, flagging reconnectDidWriteBack). Pushes the
// SINGLE pre-gesture snapshot iff the gesture actually changed the graph, then resets the
// per-gesture state. Idempotent + gated on the one-shot scheduled flag so a re-pick can
// cancel a pending close.
// T2.5 — close the reconnect coalesce window. Called on a DEFERRED macrotask after a
// connectiondrop, so the trailing connectionremoved + connectioncreated writeBacks (which
// the classic preset fires AFTER the drop) have all run with the window still open
// (suppressing their per-event pushHistory, flagging reconnectDidWriteBack). Pushes the
// SINGLE pre-gesture snapshot iff the gesture actually changed the graph, then resets the
// per-gesture state. Idempotent + gated on the one-shot scheduled flag so a re-pick can
// cancel a pending close.
const closeReconnectGesture = () => {
if (!reconnectCloseScheduled) return;
reconnectCloseScheduled = false;
if (reconnectInFlight > 0) reconnectInFlight = 0;
if (!programmatic && history !== false && reconnectDidWriteBack && reconnectPreSnapshot) {
pushHistorySnapshot(reconnectPreSnapshot);
}
reconnectPreSnapshot = null;
reconnectDidWriteBack = false;
};
// Schedule the deferred close on a macrotask (setTimeout 0) — runs after the synchronous +
// microtask writeBack signals settle. Falls back to a microtask where setTimeout is absent.
// Schedule the deferred close on a macrotask (setTimeout 0) — runs after the synchronous +
// microtask writeBack signals settle. Falls back to a microtask where setTimeout is absent.
const scheduleReconnectClose = () => {
if (reconnectCloseScheduled) return;
reconnectCloseScheduled = true;
if (typeof setTimeout === 'function') setTimeout(closeReconnectGesture, 0);else Promise.resolve().then(closeReconnectGesture);
};
// T1.3 — restore a captured snapshot by writing a FRESH `{ nodes, connections }` via
// `commitGraph` (→ `$model.graph`), wrapped in the `programmatic` guard so the consumer's
// re-bind → $watch(graph) → reconcile applies it WITHOUT re-entering history (D-04 —
// pushHistory / the write-back helpers all bail while `programmatic` is raised). Recorded
// in `lastWrittenGraph` so a following undo/redo sees the restored state as "current".
// Graph-ONLY (D-03): the viewport transform is untouched.
// T1.3 — restore a captured snapshot by writing a FRESH `{ nodes, connections }` via
// `commitGraph` (→ `$model.graph`), wrapped in the `programmatic` guard so the consumer's
// re-bind → $watch(graph) → reconcile applies it WITHOUT re-entering history (D-04 —
// pushHistory / the write-back helpers all bail while `programmatic` is raised). Recorded
// in `lastWrittenGraph` so a following undo/redo sees the restored state as "current".
// Graph-ONLY (D-03): the viewport transform is untouched.
const restoreGraph = (snap: any) => {
if (!snap) return;
// Cancel any in-flight drag write-back so a queued frame can't clobber the restore with
// a stale position after the programmatic guard releases.
pendingDragPositions.clear();
if (dragFlushRaf) {
if (typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(dragFlushRaf);
} catch (e: any) {}
}
dragFlushRaf = 0;
}
programmatic++;
try {
const fresh = {
nodes: (snap.nodes || []).map((n: any) => ({
...n
})),
connections: (snap.connections || []).map((c: any) => ({
...c
}))
};
commitGraph(fresh);
} finally {
programmatic--;
}
};
// undo() — pop the newest PRE-gesture snapshot, push the CURRENT graph onto the redo
// stack, and restore the snapshot. No-op when nothing to undo.
// undo() — pop the newest PRE-gesture snapshot, push the CURRENT graph onto the redo
// stack, and restore the snapshot. No-op when nothing to undo.
export const undo = () => {
if (historyStack.length === 0) return;
const cur = snapshotCurrent();
const snap = historyStack.pop();
if (cur) redoStack.push(cur);
restoreGraph(snap);
};
// redo() — pop the newest redo snapshot, push the CURRENT graph back onto the undo
// stack, and restore it. No-op when nothing to redo.
// redo() — pop the newest redo snapshot, push the CURRENT graph back onto the undo
// stack, and restore it. No-op when nothing to redo.
export const redo = () => {
if (redoStack.length === 0) return;
const cur = snapshotCurrent();
const snap = redoStack.pop();
if (cur) historyStack.push(cur);
restoreGraph(snap);
};
export const canUndo = () => historyStack.length > 0;
export const canRedo = () => redoStack.length > 0;
// Flush the coalesced drag positions: one fresh graph object with every pending
// node's x/y applied. Echo-guarded. Clears the pending map.
// Flush the coalesced drag positions: one fresh graph object with every pending
// node's x/y applied. Echo-guarded. Clears the pending map.
const flushDragWriteBack = () => {
dragFlushRaf = 0;
if (programmatic) {
pendingDragPositions.clear();
return;
}
if (pendingDragPositions.size === 0) return;
const g = baseGraph();
const nodes = (g.nodes || []).map((n: any) => {
const p = n && n.id != null ? pendingDragPositions.get(n.id) : null;
return p ? {
...n,
x: p.x,
y: p.y
} : n;
});
pendingDragPositions.clear();
commitGraph({
...g,
nodes
});
};
// Schedule a coalesced drag write-back (rAF; falls back to a microtask where rAF is
// unavailable — e.g. a non-DOM test env).
// Schedule a coalesced drag write-back (rAF; falls back to a microtask where rAF is
// unavailable — e.g. a non-DOM test env).
const scheduleDragFlush = () => {
if (dragFlushRaf) return;
if (typeof requestAnimationFrame === 'function') {
dragFlushRaf = requestAnimationFrame(flushDragWriteBack);
} else {
dragFlushRaf = 1;
Promise.resolve().then(flushDragWriteBack);
}
};
// CONNECT — append a fresh connection into a fresh graph object. Echo-guarded.
// CONNECT — append a fresh connection into a fresh graph object. Echo-guarded.
const writeBackConnectionCreated = (c: any) => {
if (programmatic) return;
// T1.3 — one history entry per CONNECT gesture (BEFORE the write so the snapshot is the
// pre-connect state — snapshotCurrent reads lastWrittenGraph, still the pre-connect value).
// T2.5 — SUPPRESS while a reconnect is in flight: the paired remove+add of a reconnect
// (and a plain new-connection drag, which also rides connectionpick/drop) push ONE
// coalesced snapshot on connectiondrop instead (D-03 one-gesture-one-entry).
if (reconnectInFlight) reconnectDidWriteBack = true;else pushHistory();
const g = baseGraph();
const conn = {
id: c.id,
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
};
commitGraph({
...g,
connections: [...(g.connections || []), conn]
});
};
// DISCONNECT — filter the id out into a fresh graph object. Echo-guarded.
// DISCONNECT — filter the id out into a fresh graph object. Echo-guarded.
const writeBackConnectionRemoved = (id: any) => {
if (programmatic) return;
// T1.3 — one history entry per DISCONNECT / edge-delete gesture (BEFORE the write).
// T2.5 — SUPPRESS while a reconnect is in flight: the remove half of a reconnect is
// coalesced with its paired add into ONE snapshot pushed on connectiondrop (D-03).
if (reconnectInFlight) reconnectDidWriteBack = true;else pushHistory();
const g = baseGraph();
commitGraph({
...g,
connections: (g.connections || []).filter((e: any) => e && e.id !== id)
});
};
// T1.1 — EDGE SELECTION helpers (D-08). Selection state is kept PURELY in script
// (selectedConnId / selectedPathEl) and surfaced to the consumer via @edge-click /
// @edge-selected — never written into $model.graph (echo-safe like selectedNodeIds).
//
// `clearEdgeSelection` drops `.is-selected` from the live <path> (if still attached) and
// nulls the selection. `selectEdge` is invoked from the per-edge pointerup listener: it
// clears any prior selection, marks THIS path `.is-selected`, records the id + element,
// raises the one-shot `edgeClickGuard` (so the area's own background-pointerup branch
// does not immediately clear what this click just selected — the same pointerup gesture
// fires on the path AND lets the area pipe run), and emits BOTH @edge-click and
// @edge-selected ({ id }). The guard self-resets on the next microtask once the gesture
// has settled.
// T1.1 — EDGE SELECTION helpers (D-08). Selection state is kept PURELY in script
// (selectedConnId / selectedPathEl) and surfaced to the consumer via @edge-click /
// @edge-selected — never written into $model.graph (echo-safe like selectedNodeIds).
//
// `clearEdgeSelection` drops `.is-selected` from the live <path> (if still attached) and
// nulls the selection. `selectEdge` is invoked from the per-edge pointerup listener: it
// clears any prior selection, marks THIS path `.is-selected`, records the id + element,
// raises the one-shot `edgeClickGuard` (so the area's own background-pointerup branch
// does not immediately clear what this click just selected — the same pointerup gesture
// fires on the path AND lets the area pipe run), and emits BOTH @edge-click and
// @edge-selected ({ id }). The guard self-resets on the next microtask once the gesture
// has settled.
const clearEdgeSelection = () => {
if (selectedPathEl && selectedPathEl.classList) {
try {
selectedPathEl.classList.remove('is-selected');
} catch (e: any) {}
}
selectedConnId = null;
selectedPathEl = null;
};
const selectEdge = (id: any, pathEl: any) => {
if (id == null) return;
clearEdgeSelection();
selectedConnId = id;
selectedPathEl = pathEl;
if (pathEl && pathEl.classList) {
try {
pathEl.classList.add('is-selected');
} catch (e: any) {}
}
edgeClickGuard = true;
Promise.resolve().then(() => {
edgeClickGuard = false;
});
onedgeclick?.({
id
});
onedgeselected?.({
id
});
};
// CASCADING DELETE (the PUBLIC controlled-graph node delete — Win 1). Distinct from
// the engine-only `removeNode` $expose verb: `removeNode` operates directly on the
// editor and is NOT written back to the model (the provenance-tracked imperative
// escape hatch); `deleteNode` is the BLESSED controlled-graph delete — it filters the
// node AND every incident connection out of FRESH arrays and writes ONE fresh
// top-level `{ ...g, nodes, connections }` object via `$model.graph` (the Phase-41
// write-back contract — in-place mutation is silently dropped on React/Solid/Lit/
// Angular). The wrapper's own `$watch(graph)` reconcile then reaps the live engine
// node + edges — we do NOT call editor.removeNode here (a double-remove would race the
// reconcile into Rete's "cannot find node"; the controlled-model filter is the single
// removal path). NOT echo-guarded with `programmatic` — this is a CONSUMER-driven write
// that SHOULD update the bound model (mirrors the demo's per-node ✕ filter). Returns
// true if a node was removed. The id-coerce-to-String mirrors the demo's onRemoveClick.
// CASCADING DELETE (the PUBLIC controlled-graph node delete — Win 1). Distinct from
// the engine-only `removeNode` $expose verb: `removeNode` operates directly on the
// editor and is NOT written back to the model (the provenance-tracked imperative
// escape hatch); `deleteNode` is the BLESSED controlled-graph delete — it filters the
// node AND every incident connection out of FRESH arrays and writes ONE fresh
// top-level `{ ...g, nodes, connections }` object via `$model.graph` (the Phase-41
// write-back contract — in-place mutation is silently dropped on React/Solid/Lit/
// Angular). The wrapper's own `$watch(graph)` reconcile then reaps the live engine
// node + edges — we do NOT call editor.removeNode here (a double-remove would race the
// reconcile into Rete's "cannot find node"; the controlled-model filter is the single
// removal path). NOT echo-guarded with `programmatic` — this is a CONSUMER-driven write
// that SHOULD update the bound model (mirrors the demo's per-node ✕ filter). Returns
// true if a node was removed. The id-coerce-to-String mirrors the demo's onRemoveClick.
export const deleteNode = (id: any) => {
if (id == null) return false;
const g = baseGraph();
const sid = String(id);
const nodes = (g.nodes || []).filter((n: any) => n && String(n.id) !== sid);
if (nodes.length === (g.nodes || []).length) return false;
const connections = (g.connections || []).filter((c: any) => c && String(c.source) !== sid && String(c.target) !== sid);
// T1.3 — one history entry per DELETE gesture (node + its incident edges = ONE undo).
pushHistory();
commitGraph({
...g,
nodes,
connections
});
return true;
};
// T2.8 — a fresh unique node id for a duplicated node. Derived from the source id + an
// incrementing suffix, skipping any id already present in the live graph so a repeated
// duplicate never collides (Threat T-44-06-2: a NEW unique id, never a forged/colliding
// one). String ids only (mirrors the graph contract).
// T2.8 — a fresh unique node id for a duplicated node. Derived from the source id + an
// incrementing suffix, skipping any id already present in the live graph so a repeated
// duplicate never collides (Threat T-44-06-2: a NEW unique id, never a forged/colliding
// one). String ids only (mirrors the graph contract).
const freshNodeId = (baseId: any, existing: any) => {
const taken = new Set((existing || []).map((n: any) => n && n.id != null ? String(n.id) : ''));
const root = baseId != null ? String(baseId) : 'node';
let i = 1;
let candidate = root + '-copy';
while (taken.has(candidate)) {
i++;
candidate = root + '-copy-' + i;
}
return candidate;
};
// T2.8 — DUPLICATE the given node: clone its spec at a small offset with a NEW unique id
// into a FRESH `{ ...g, nodes:[...g.nodes, clone] }` object (the controlled-graph write-back
// contract — never an in-place push). The clone's `data` is deep-cloned ($clone strips
// any reactivity proxy) so the copy is independent of the source. Connections are NOT cloned
// (a duplicate is an isolated node — the React-Flow default). One history entry per
// duplicate gesture (pushHistory, gated on !programmatic + history). Returns the new id, or
// null if the source isn't found. NOT echo-guarded — a duplicate SHOULD update the model.
// T2.8 — DUPLICATE the given node: clone its spec at a small offset with a NEW unique id
// into a FRESH `{ ...g, nodes:[...g.nodes, clone] }` object (the controlled-graph write-back
// contract — never an in-place push). The clone's `data` is deep-cloned ($clone strips
// any reactivity proxy) so the copy is independent of the source. Connections are NOT cloned
// (a duplicate is an isolated node — the React-Flow default). One history entry per
// duplicate gesture (pushHistory, gated on !programmatic + history). Returns the new id, or
// null if the source isn't found. NOT echo-guarded — a duplicate SHOULD update the model.
const duplicateNode = (id: any) => {
if (id == null) return null;
const g = baseGraph();
const sid = String(id);
const src = (g.nodes || []).find((n: any) => n && String(n.id) === sid);
if (!src) return null;
const newId = freshNodeId(src.id, g.nodes);
// Phase 45-07 (WR-02/WR-06): `$clone` is now a recursive proxy-safe deep clone
// on every target (Vue's lowering de-proxies nested reactive members via the
// `rozieDeepClone` runtime helper). The historical `$clone({ d: src.data }).d`
// object-literal wrapper — which never actually dodged the old single-toRaw
// throw on a live nested proxy — is no longer needed; clone `src.data` directly.
const clonedData = src.data != null ? $state.snapshot(src.data) : undefined;
const clone = {
...src,
id: newId,
x: (typeof src.x === 'number' ? src.x : 0) + 28,
y: (typeof src.y === 'number' ? src.y : 0) + 28,
data: clonedData
};
pushHistory();
commitGraph({
...g,
nodes: [...(g.nodes || []), clone]
});
return newId;
};
// Collect the currently-SELECTED node ids from the live selector (Win 1 + Win 2). The
// AreaExtensions.selector() `entities` Map holds the picked entities ({ label, id });
// for selectable nodes each entity's `id` is the node id. Empty when nothing is picked
// or selection is disabled. Read-only — no $data / engine write.
// Collect the currently-SELECTED node ids from the live selector (Win 1 + Win 2). The
// AreaExtensions.selector() `entities` Map holds the picked entities ({ label, id });
// for selectable nodes each entity's `id` is the node id. Empty when nothing is picked
// or selection is disabled. Read-only — no $data / engine write.
const selectedNodeIds = () => {
if (!selector || !selector.entities) return [];
const ids = [];
for (const e of selector.entities.values() as any) {
if (e && e.id != null) ids.push(e.id);
}
return ids;
};
// Win 2: surface selection changes to the consumer via @selection-change ({ ids }).
// Computes the current selected-id set, dedupes against the last-emitted set (joined
// string), and emits only on an ACTUAL change. Echo-guarded by `programmatic` so a
// PROGRAMMATIC unselect (clear/deleteNode may unpick) does not surface as a user
// selection. Selection is kept PURELY in the emit — never written into the graph model
// — so the controlled-graph echo-safety (the drag write-back assertions) is unaffected.
// Sorted before joining so the dedup key is order-independent (the selector Map order
// is not guaranteed stable across pick/unpick).
// Win 2: surface selection changes to the consumer via @selection-change ({ ids }).
// Computes the current selected-id set, dedupes against the last-emitted set (joined
// string), and emits only on an ACTUAL change. Echo-guarded by `programmatic` so a
// PROGRAMMATIC unselect (clear/deleteNode may unpick) does not surface as a user
// selection. Selection is kept PURELY in the emit — never written into the graph model
// — so the controlled-graph echo-safety (the drag write-back assertions) is unaffected.
// Sorted before joining so the dedup key is order-independent (the selector Map order
// is not guaranteed stable across pick/unpick).
const maybeEmitSelectionChange = () => {
if (programmatic) return;
const ids = selectedNodeIds();
const key = [...ids].map((x: any) => String(x)).sort().join(' ');
if (key === lastSelectionIds) return;
lastSelectionIds = key;
onselectionchange?.({
ids
});
// the selected set changed → repaint the minimap (selected nodes are highlighted).
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
// T2.8 — the selection changed → re-track the NodeToolbar (it follows the single
// selected node; hides on multi-select / empty selection). No-op when :node-toolbar off.
if (syncToolbarSelection) syncToolbarSelection();
};
// Schedule the selection recompute AFTER the engine's own async selection update has
// settled. AreaExtensions.selectableNodes does its pick / unselectAll via AWAITED
// area.update() calls, so a bare microtask can run before `selector.entities` reflects
// the new state. A microtask AND an rAF together guarantee we recompute once the engine
// chain has flushed (the dedup collapses the pair to at most one emit). Falls back to a
// double microtask where rAF is unavailable (non-DOM test env).
// Schedule the selection recompute AFTER the engine's own async selection update has
// settled. AreaExtensions.selectableNodes does its pick / unselectAll via AWAITED
// area.update() calls, so a bare microtask can run before `selector.entities` reflects
// the new state. A microtask AND an rAF together guarantee we recompute once the engine
// chain has flushed (the dedup collapses the pair to at most one emit). Falls back to a
// double microtask where rAF is unavailable (non-DOM test env).
const scheduleSelectionEmit = () => {
Promise.resolve().then(maybeEmitSelectionChange);
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(maybeEmitSelectionChange);
} else {
Promise.resolve().then(() => Promise.resolve().then(maybeEmitSelectionChange));
}
};
// The $portals/$emit-capturing reconcilers are built INSIDE $onMount ($portals
// referenced at top level fails the bundled-leaf strict typecheck — the CM/
// TipTap/MapLibre portal discipline) and bridged here so the top-level $watch can
// call them.
// The $portals/$emit-capturing reconcilers are built INSIDE $onMount ($portals
// referenced at top level fails the bundled-leaf strict typecheck — the CM/
// TipTap/MapLibre portal discipline) and bridged here so the top-level $watch can
// call them.
let reconcileNodes: any = null;
let reconcileConnections: any = null;
// Re-entrancy guard for reconcileNodes. The declarative-children path can fire the
// node reconcile RE-ENTRANTLY on async-context targets (Lit): a <FlowNode>'s
// $onMount register starts reconcile #1, and its late-context $onUpdate registration
// (REQ-30) — or the registry $watch the register triggers — starts reconcile #2 while
// #1's awaits (editor.addNode / area.translate / area.update) are still pending. Two
// overlapping reconciles racing the same engine throw Rete's "cannot find node" (one
// updates/translates a node-view the other just rebuilt), which aborts the whole graph
// build (only the config-array `cfg` node survives on Lit). This flag serializes them:
// a reconcile requested while one is running sets a "run again" bit and returns; the
// in-flight reconcile re-runs once it finishes, so every registry mutation is folded
// into a fresh non-overlapping pass. The config-array-only path never re-enters (props
// change once per tick), so this is byte-transparent to its behavior.
// Re-entrancy guard for reconcileNodes. The declarative-children path can fire the
// node reconcile RE-ENTRANTLY on async-context targets (Lit): a <FlowNode>'s
// $onMount register starts reconcile #1, and its late-context $onUpdate registration
// (REQ-30) — or the registry $watch the register triggers — starts reconcile #2 while
// #1's awaits (editor.addNode / area.translate / area.update) are still pending. Two
// overlapping reconciles racing the same engine throw Rete's "cannot find node" (one
// updates/translates a node-view the other just rebuilt), which aborts the whole graph
// build (only the config-array `cfg` node survives on Lit). This flag serializes them:
// a reconcile requested while one is running sets a "run again" bit and returns; the
// in-flight reconcile re-runs once it finishes, so every registry mutation is folded
// into a fresh non-overlapping pass. The config-array-only path never re-enters (props
// change once per tick), so this is byte-transparent to its behavior.
let reconcileNodesRunning = false;
let reconcileNodesPending = false;
// ── pure helpers (no sigils → safe at top level) ──
// ── pure helpers (no sigils → safe at top level) ──
const serializeConn = (c: any) => ({
id: c.id,
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
});
// Resolve a node TYPE's port schema from the flat per-TYPE portReg — the entries
// whose key starts `type + '::'`. Returns { inputs:[{key,label,multiple,portType}],
// outputs:[…] }. Pure (no $data write) so buildNode / buildSocketRow can call it on
// every run regardless of the order the <NodeType> vs its <Port> children registered.
// Resolve a node TYPE's port schema from the flat per-TYPE portReg — the entries
// whose key starts `type + '::'`. Returns { inputs:[{key,label,multiple,portType}],
// outputs:[…] }. Pure (no $data write) so buildNode / buildSocketRow can call it on
// every run regardless of the order the <NodeType> vs its <Port> children registered.
const portSchemaForType = (type: any, portReg: any) => {
const inputs = [];
const outputs = [];
if (type == null || !portReg) return {
inputs,
outputs
};
const prefix = type + '::';
for (const k in portReg) {
if (k.indexOf(prefix) !== 0) continue;
const p = portReg[k];
if (!p || p.key == null) continue;
const entry = {
key: p.key,
label: p.label,
multiple: p.multiple,
portType: p.portType
};
if (p.side === 'input') inputs.push(entry);else outputs.push(entry);
}
return {
inputs,
outputs
};
};
// Build a live Rete node from a graph-node spec ({ id, type, x, y, data }). The
// consumer's `id` is assigned onto the node so positions, portal keys, and
// connection source/target ids all align with the author's identifiers (Rete would
// otherwise auto-generate ids). Sockets come from the node's TYPE port schema
// (portReg keyed `type::side::key`) — a type's ports declared ONCE apply to every
// instance (render-by-type). The single shared SOCKET still gates compatibility by
// identity; the per-port `portType` drives typed VALIDATION, not socket identity.
// Build a live Rete node from a graph-node spec ({ id, type, x, y, data }). The
// consumer's `id` is assigned onto the node so positions, portal keys, and
// connection source/target ids all align with the author's identifiers (Rete would
// otherwise auto-generate ids). Sockets come from the node's TYPE port schema
// (portReg keyed `type::side::key`) — a type's ports declared ONCE apply to every
// instance (render-by-type). The single shared SOCKET still gates compatibility by
// identity; the per-port `portType` drives typed VALIDATION, not socket identity.
const buildNode = (spec: any, portReg: any) => {
const label = spec.data && spec.data.label != null ? String(spec.data.label) : '';
const node = new ClassicPreset.Node(label);
node.id = spec.id;
const {
inputs,
outputs
} = portSchemaForType(spec.type, portReg);
for (const inp of inputs as any) {
if (!inp || inp.key == null) continue;
node.addInput(inp.key, new ClassicPreset.Input(SOCKET, inp.label, inp.multiple === true));
}
for (const out of outputs as any) {
if (!out || out.key == null) continue;
node.addOutput(out.key, new ClassicPreset.Output(SOCKET, out.label, out.multiple !== false));
}
return node;
};
// NOTE: portTypeOf (the validation-pipe port-type resolver) is DEFINED INSIDE
// $onMount (next to the editor.addPipe that uses it), NOT here at top level. It reads
// $data.portReg, and a top-level definition lowers on React to a `useCallback` whose
// captured `portReg` is FROZEN at the snapshot when the validation pipe (set up once in
// the mount effect) was created — i.e. the INITIAL empty {} before any <Port> registered.
// A stale-empty portReg makes portTypeOf return null for every port, so the typed-socket
// validation `srcType != null && tgtType != null && srcType !== tgtType` check is SKIPPED
// and a cross-type connection is WRONGLY ALLOWED (the React-only "reject didn't fire" bug
// the advanced VR cell surfaced). Defined inside $onMount, the emitter lowers its
// $data.portReg read to the live `_portRegRef.current` (the same ref the reconcilers use),
// so validation always sees the current schema. The 5 non-React targets read live signals
// so they were correct either way; this is the React stale-closure fix (the MapLibre/PDF
// $watch-reroute lesson, here as a mount-scoped definition). ZERO emitter change.
// ─── per-TYPE registry (Phase 41 controlled-graph — the per-TYPE shift of the
// Phase 37 per-instance $provide/$inject dogfood) ────────────────────────────────
// The 'rete:canvas' registry API CONSUMED BY <NodeType>/<Port> (41-03). CRITICAL
// reactive-write discipline (Pitfall 1): every mutation WHOLE-OBJECT-REPLACES the
// registry so the watched $data.typeReg/$data.portReg reference changes exactly once
// per call — a bare in-place $data.typeReg[type] = spec is silent on React/Solid/
// Angular/Lit. THE CROSS-PLAN CONTRACT (41-03 calls EXACTLY these verbs):
// registerType(type, spec) → type-template registry (<NodeType>)
// unregisterType(type) → drop a type on <NodeType> unmount
// addTypePort(type, side, key, portType, label, multiple) → per-TYPE port schema (<Port>)
// bodyHostFor(nodeId) → the engine `body` host div
// (render-by-type callback target)
// ─── imperative handle (Phase 21 $expose) ────────────────────────────────────
// Collision discipline (ROZ121/ROZ524/Lit-lifecycle):
// - NO `setZoom` — `zoom` is a model prop, so React auto-generates a `setZoom`
// state setter (the MapLibre setCenter/setZoom lesson); the verb is `zoomTo`.
// - NONE equals a Lit reserved lifecycle name (update/render/firstUpdated/
// updated/willUpdate/requestUpdate) — note `clear` and `getNodes` are safe.
// - NONE equals an emitted event name (node-moved/node-picked/connection-*
// /translated/context-menu/node-action) or a prop name.
// addNode/addConnection/removeNode/removeConnection operate on the engine
// directly and are NOT reaped by props reconcile (provenance-tracked).
export function getEditor() {
return editor;
}
export function getArea() {
return area;
}
export async function addNode(spec: any) {
if (!editor || !spec || spec.id == null) return null;
const node = buildNode(spec, portReg);
nodeInstances.set(spec.id, node);
nodeMeta.set(spec.id, spec);
programmatic++;
try {
await editor.addNode(node);
await area.translate(spec.id, {
x: spec.x || 0,
y: spec.y || 0
});
} finally {
programmatic--;
}
return spec.id;
}
export async function removeNode(id: any) {
if (!editor || id == null || !nodeInstances.has(id)) return false;
programmatic++;
try {
for (const c of editor.getConnections() as any) {
if (c.source === id || c.target === id) await editor.removeConnection(c.id);
}
await editor.removeNode(id);
} finally {
programmatic--;
}
nodeInstances.delete(id);
nodeMeta.delete(id);
return true;
}
export async function addConnection(spec: any) {
if (!editor || !spec || spec.source == null || spec.target == null) return null;
const srcOut = spec.sourceOutput != null ? spec.sourceOutput : 'out';
const tgtIn = spec.targetInput != null ? spec.targetInput : 'in';
const sourceNode = nodeInstances.get(spec.source);
const targetNode = nodeInstances.get(spec.target);
if (!sourceNode || !targetNode) return null;
const conn = new ClassicPreset.Connection(sourceNode, srcOut, targetNode, tgtIn);
if (spec.id != null) conn.id = spec.id;
programmatic++;
try {
await editor.addConnection(conn);
} finally {
programmatic--;
}
connInstances.set(conn.id, conn);
return conn.id;
}
export async function removeConnection(id: any) {
if (!editor || id == null) return false;
programmatic++;
try {
await editor.removeConnection(id);
} finally {
programmatic--;
}
connInstances.delete(id);
return true;
}
export async function clear() {
if (!editor) return;
programmatic++;
try {
await editor.clear();
} finally {
programmatic--;
}
nodeInstances.clear();
nodeMeta.clear();
connInstances.clear();
connMeta.clear();
lastPropNodeIds = [];
lastPropConnIds = [];
}
export async function zoomToFit() {
if (!area || !editor) return;
programmatic++;
try {
await AreaExtensions.zoomAt(area, editor.getNodes());
} finally {
programmatic--;
}
const k = area.area.transform.k;
if (k !== zoom) zoom = k;
}
export async function zoomTo(k: any) {
if (!area || typeof k !== 'number') return;
programmatic++;
try {
await area.area.zoom(k);
} finally {
programmatic--;
}
if (k !== zoom) zoom = k;
}
// ─── viewport API (Phase 42 — the T11 gap + what the pannable minimap needs) ─────
// Both write the AreaPlugin transform via the CONFIRMED Rete v2 area API: with the
// origin omitted `area.area.zoom(k)` leaves x/y unchanged (transform.x += 0·d), and
// `area.area.translate(x, y)` sets the pan ABSOLUTELY (verified against rete-area-
// plugin@2.1.5). Echo-guarded with `programmatic` so the transform write doesn't loop
// back through the zoomed/nodetranslated write-back (the `translated` emit stays
// UNCONDITIONAL, so @translated still surfaces a programmatic recenter — a real
// viewport change the consumer asked for). After, echo `$model.zoom` (mirrors zoomTo).
// Collision discipline: setCenter/setViewport are NOT Lit lifecycle names, NOT emit
// names, NOT prop names, NOT React model-setters (`graph`/`zoom` → setGraph/setZoom),
// and NOT inherited DOM methods (the Embla scrollTo lesson) — clean on all 6.
//
// setViewport({ x, y, k }) — set the raw transform (any field omitted keeps its
// current value).
// ─── viewport API (Phase 42 — the T11 gap + what the pannable minimap needs) ─────
// Both write the AreaPlugin transform via the CONFIRMED Rete v2 area API: with the
// origin omitted `area.area.zoom(k)` leaves x/y unchanged (transform.x += 0·d), and
// `area.area.translate(x, y)` sets the pan ABSOLUTELY (verified against rete-area-
// plugin@2.1.5). Echo-guarded with `programmatic` so the transform write doesn't loop
// back through the zoomed/nodetranslated write-back (the `translated` emit stays
// UNCONDITIONAL, so @translated still surfaces a programmatic recenter — a real
// viewport change the consumer asked for). After, echo `$model.zoom` (mirrors zoomTo).
// Collision discipline: setCenter/setViewport are NOT Lit lifecycle names, NOT emit
// names, NOT prop names, NOT React model-setters (`graph`/`zoom` → setGraph/setZoom),
// and NOT inherited DOM methods (the Embla scrollTo lesson) — clean on all 6.
//
// setViewport({ x, y, k }) — set the raw transform (any field omitted keeps its
// current value).
export async function setViewport(vp: any) {
if (!area || !vp || typeof vp !== 'object') return;
const tf = area.area.transform;
const k = typeof vp.k === 'number' ? vp.k : tf.k;
const x = typeof vp.x === 'number' ? vp.x : tf.x;
const y = typeof vp.y === 'number' ? vp.y : tf.y;
programmatic++;
try {
if (k !== area.area.transform.k) await area.area.zoom(k);
await area.area.translate(x, y);
} finally {
programmatic--;
}
if (k !== zoom) zoom = k;
}
// setCenter(x, y, opts?) — center the viewport on graph-coords (x, y), optionally
// setting zoom (`opts.zoom`). The transform that puts graph point (x,y) at the canvas
// center is tx = W/2 − x·k, ty = H/2 − y·k (screen = graph·k + transform). W/H are the
// engine container's pixel dims (area.container — public on AreaPlugin, no $refs read).
// setCenter(x, y, opts?) — center the viewport on graph-coords (x, y), optionally
// setting zoom (`opts.zoom`). The transform that puts graph point (x,y) at the canvas
// center is tx = W/2 − x·k, ty = H/2 − y·k (screen = graph·k + transform). W/H are the
// engine container's pixel dims (area.container — public on AreaPlugin, no $refs read).
export async function setCenter(x: any, y: any, opts: any) {
if (!area || typeof x !== 'number' || typeof y !== 'number') return;
const k = opts && typeof opts.zoom === 'number' ? opts.zoom : area.area.transform.k;
const el = area.container;
const cw = el && el.clientWidth ? el.clientWidth : 0;
const ch = el && el.clientHeight ? el.clientHeight : 0;
const tx = cw / 2 - x * k;
const ty = ch / 2 - y * k;
programmatic++;
try {
if (k !== area.area.transform.k) await area.area.zoom(k);
await area.area.translate(tx, ty);
} finally {
programmatic--;
}
if (k !== zoom) zoom = k;
}
// ─── built-in Controls overlay handlers (Win 4) ──────────────────────────────
// Wired to the in-template zoom in / out / fit buttons (gated r-if="$props.controls").
// They REUSE the zoomTo / zoomToFit verbs (one implementation — no logic duplication),
// clamping the step to [minZoom, maxZoom] so a button never exceeds the restrictor
// bounds. Zoom/fit are view-only, so they stay enabled even when readonly (they do not
// edit the graph). A no-op before the area mounts.
// ─── built-in Controls overlay handlers (Win 4) ──────────────────────────────
// Wired to the in-template zoom in / out / fit buttons (gated r-if="$props.controls").
// They REUSE the zoomTo / zoomToFit verbs (one implementation — no logic duplication),
// clamping the step to [minZoom, maxZoom] so a button never exceeds the restrictor
// bounds. Zoom/fit are view-only, so they stay enabled even when readonly (they do not
// edit the graph). A no-op before the area mounts.
const ZOOM_STEP = 1.2;
const clampZoom = (k: any) => {
let lo = typeof minZoom === 'number' && minZoom > 0 ? minZoom : 0.01;
let hi = typeof maxZoom === 'number' && maxZoom > 0 ? maxZoom : 100;
if (k < lo) return lo;
if (k > hi) return hi;
return k;
};
const controlZoomIn = () => {
if (!area) return;
zoomTo(clampZoom(area.area.transform.k * ZOOM_STEP));
};
const controlZoomOut = () => {
if (!area) return;
zoomTo(clampZoom(area.area.transform.k / ZOOM_STEP));
};
const controlFit = () => {
zoomToFit();
};
// T2.4 — the gated 4th Controls button toggles the two-way mode (pan ↔ select). Writes
// $model.mode (model:true); the consumer's r-model:mode (or the internal demo state) updates.
// T2.4 — the gated 4th Controls button toggles the two-way mode (pan ↔ select). Writes
// $model.mode (model:true); the consumer's r-model:mode (or the internal demo state) updates.
const toggleMode = () => {
mode = mode === 'select' ? 'pan' : 'select';
};
export function getNodes() {
if (!area) return [];
const out = [];
for (const [id, node] of nodeInstances as any) {
const view = area.nodeViews.get(id);
out.push({
id,
label: node.label,
x: view ? view.position.x : 0,
y: view ? view.position.y : 0
});
}
return out;
}
export function getConnections() {
return editor ? editor.getConnections().map(serializeConn) : [];
}
export function getTransform() {
return area ? {
x: area.area.transform.x,
y: area.area.transform.y,
k: area.area.transform.k
} : null;
}
// screenToFlowPosition(clientX, clientY) → { x, y } in GRAPH coords (Phase 43 — the
// palette-drop / no-code-builder primitive, the React-Flow `screenToFlowPosition`
// parity). The INVERSE of the area transform: a graph point projects to the screen as
// `screen = containerOrigin + transform.{x,y} + graph·k`, so
// `graph = (client − containerOrigin − transform) / k`. `area.container` is public on
// the AreaPlugin (no $refs read). Returns null before the area mounts. The component
// owns ONLY this projection — the consumer owns the drag/drop (a palette item's
// `draggable` + the canvas `@dragover.prevent`/`@drop`) and writes the new node into the
// bound `graph` at the returned coords, exactly like React Flow (which does not own the
// palette either).
// screenToFlowPosition(clientX, clientY) → { x, y } in GRAPH coords (Phase 43 — the
// palette-drop / no-code-builder primitive, the React-Flow `screenToFlowPosition`
// parity). The INVERSE of the area transform: a graph point projects to the screen as
// `screen = containerOrigin + transform.{x,y} + graph·k`, so
// `graph = (client − containerOrigin − transform) / k`. `area.container` is public on
// the AreaPlugin (no $refs read). Returns null before the area mounts. The component
// owns ONLY this projection — the consumer owns the drag/drop (a palette item's
// `draggable` + the canvas `@dragover.prevent`/`@drop`) and writes the new node into the
// bound `graph` at the returned coords, exactly like React Flow (which does not own the
// palette either).
export function screenToFlowPosition(clientX: any, clientY: any) {
if (!area || typeof clientX !== 'number' || typeof clientY !== 'number') return null;
const el = area.container;
const rect = el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null;
if (!rect) return null;
const t = area.area.transform;
const k = t.k || 1;
return {
x: (clientX - rect.left - t.x) / k,
y: (clientY - rect.top - t.y) / k
};
}
// T2.6 — autoArrange(opts?) — relayout the graph into a non-overlapping LAYERED arrangement
// (D-08, verb-only, NO auto-trigger — the MapLibre verb-first stance). Runs the
// AutoArrangePlugin (elkjs classic preset), then READS the arranged positions BACK into a
// FRESH `{ nodes, connections }` object written through `$model.graph` (the controlled-graph
// contract — the engine is never the source of truth, mirroring the drag write-back).
//
// PITFALL 3 (Plan 00 / RESEARCH): elkjs needs each node's `width`/`height`; our nodes are
// plain `ClassicPreset.Node` with no dimensions, so without dims the classic preset collapses
// every node to (0,0). We set `node.width`/`node.height` from the MEASURED engine node-view
// element (area.nodeViews.get(id).element offsetW/H — target-agnostic, the measureNodeSize
// discipline) BEFORE layout, falling back to MINIMAP_DEFAULT_NODE_W/H for Lit's unmeasured
// first paint. (measureNodeSize itself is $onMount-local; the verb is top-level, so the same
// measure is inlined here over the component-scope `area` + `nodeInstances`.)
//
// Echo-guarded (programmatic++ around layout AND the write-back) so the engine relayout and
// the resulting $model.graph re-bind → $watch(graph) → reconcile don't re-enter; ONE history
// snapshot is pushed for the whole gesture (D-03, gated on !programmatic + history). The
// optional `opts.options` (elk layout options — direction/spacing) is forwarded to
// arrange.layout() (D-01 discretion — default-only is fine; the arg stays optional).
//
// Collision discipline: `autoArrange` is NOT a Lit lifecycle name (update/render/firstUpdated/
// updated/willUpdate/requestUpdate), NOT an inherited DOM method (the Embla scrollTo lesson),
// NOT an emit (node-*/connection-*/translated/context-menu/selection-change/edge-*/node-action),
// NOT a prop, NOT a React model-setter (graph/zoom → setGraph/setZoom) — clean on all 6.
// T2.6 — autoArrange(opts?) — relayout the graph into a non-overlapping LAYERED arrangement
// (D-08, verb-only, NO auto-trigger — the MapLibre verb-first stance). Runs the
// AutoArrangePlugin (elkjs classic preset), then READS the arranged positions BACK into a
// FRESH `{ nodes, connections }` object written through `$model.graph` (the controlled-graph
// contract — the engine is never the source of truth, mirroring the drag write-back).
//
// PITFALL 3 (Plan 00 / RESEARCH): elkjs needs each node's `width`/`height`; our nodes are
// plain `ClassicPreset.Node` with no dimensions, so without dims the classic preset collapses
// every node to (0,0). We set `node.width`/`node.height` from the MEASURED engine node-view
// element (area.nodeViews.get(id).element offsetW/H — target-agnostic, the measureNodeSize
// discipline) BEFORE layout, falling back to MINIMAP_DEFAULT_NODE_W/H for Lit's unmeasured
// first paint. (measureNodeSize itself is $onMount-local; the verb is top-level, so the same
// measure is inlined here over the component-scope `area` + `nodeInstances`.)
//
// Echo-guarded (programmatic++ around layout AND the write-back) so the engine relayout and
// the resulting $model.graph re-bind → $watch(graph) → reconcile don't re-enter; ONE history
// snapshot is pushed for the whole gesture (D-03, gated on !programmatic + history). The
// optional `opts.options` (elk layout options — direction/spacing) is forwarded to
// arrange.layout() (D-01 discretion — default-only is fine; the arg stays optional).
//
// Collision discipline: `autoArrange` is NOT a Lit lifecycle name (update/render/firstUpdated/
// updated/willUpdate/requestUpdate), NOT an inherited DOM method (the Embla scrollTo lesson),
// NOT an emit (node-*/connection-*/translated/context-menu/selection-change/edge-*/node-action),
// NOT a prop, NOT a React model-setter (graph/zoom → setGraph/setZoom) — clean on all 6.
export async function autoArrange(opts: any) {
if (!arrange || !area) return;
// Set elkjs dimensions on every live node instance from its measured node-view element
// (Pitfall 3) — without dims the classic preset stacks all nodes at (0,0).
for (const [id, node] of nodeInstances as any) {
const view = area.nodeViews ? area.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
node.width = el && el.offsetWidth ? el.offsetWidth : MINIMAP_DEFAULT_NODE_W;
node.height = el && el.offsetHeight ? el.offsetHeight : MINIMAP_DEFAULT_NODE_H;
}
// ONE history entry for the arrange gesture, captured BEFORE the write (pushHistory reads
// lastWrittenGraph, still the pre-arrange state). Gated on !programmatic + history.
pushHistory();
programmatic++;
try {
await arrange.layout(opts && opts.options ? {
options: opts.options
} : undefined);
} finally {
programmatic--;
}
// Read the arranged positions back into a FRESH graph object (controlled-graph contract).
// Echo-guarded: commitGraph → $model.graph re-bind must not re-enter the reconcile as a new
// gesture. (The arrange already moved the engine to these coords, so the reconcile is a
// no-op diff; the guard is belt-and-braces + suppresses any history re-entry.)
programmatic++;
try {
const g = baseGraph();
const nodes = (g.nodes || []).map((n: any) => {
const v = n && n.id != null && area.nodeViews ? area.nodeViews.get(n.id) : null;
return v && v.position ? {
...n,
x: v.position.x,
y: v.position.y
} : n;
});
commitGraph({
...g,
nodes
});
} finally {
programmatic--;
}
}
// ─── imperative selection control ────────────────────────────────────────────
// Selection was previously PUSH-ONLY (the `selection-change` emit fires on change,
// but a consumer couldn't READ or DRIVE selection). These reuse the internal
// `selector` / `nodeSelectApi` (AreaExtensions.selector + selectableNodes) already
// wired for the marquee — no new engine state. All no-op when selection is off
// (readonly / !selectable, when `nodeSelectApi` is null). Each schedules the same
// post-settle `selection-change` recompute the marquee uses, so an imperative
// select keeps the consumer's bound state in sync (the zoomTo→$model.zoom echo
// stance). Collision discipline: `selectNode` is NOT bare `select` — `select` is
// an inherited HTMLElement method (Lit shadow, the Embla scrollTo lesson) AND a
// FullCalendar-style emit hazard; getSelectedNodes/clearSelection/selectAll/
// centerOnNode are NOT emits (selection-change/node-*/edge-*), NOT props, NOT
// React model-setters (graph/zoom → setGraph/setZoom), NOT Lit lifecycle.
//
// getSelectedNodes() — the currently-selected nodes as { id, label, x, y } (the
// getNodes() shape, filtered to the live selection). Empty when nothing selected.
// ─── imperative selection control ────────────────────────────────────────────
// Selection was previously PUSH-ONLY (the `selection-change` emit fires on change,
// but a consumer couldn't READ or DRIVE selection). These reuse the internal
// `selector` / `nodeSelectApi` (AreaExtensions.selector + selectableNodes) already
// wired for the marquee — no new engine state. All no-op when selection is off
// (readonly / !selectable, when `nodeSelectApi` is null). Each schedules the same
// post-settle `selection-change` recompute the marquee uses, so an imperative
// select keeps the consumer's bound state in sync (the zoomTo→$model.zoom echo
// stance). Collision discipline: `selectNode` is NOT bare `select` — `select` is
// an inherited HTMLElement method (Lit shadow, the Embla scrollTo lesson) AND a
// FullCalendar-style emit hazard; getSelectedNodes/clearSelection/selectAll/
// centerOnNode are NOT emits (selection-change/node-*/edge-*), NOT props, NOT
// React model-setters (graph/zoom → setGraph/setZoom), NOT Lit lifecycle.
//
// getSelectedNodes() — the currently-selected nodes as { id, label, x, y } (the
// getNodes() shape, filtered to the live selection). Empty when nothing selected.
export function getSelectedNodes() {
const sel = new Set(selectedNodeIds().map((x: any) => String(x)));
return getNodes().filter((n: any) => sel.has(String(n.id)));
}
// selectNode(id, accumulate?) — programmatically select a node (sidebar/search →
// highlight). accumulate=true adds to the current selection; falsy replaces it.
// selectNode(id, accumulate?) — programmatically select a node (sidebar/search →
// highlight). accumulate=true adds to the current selection; falsy replaces it.
export function selectNode(id: any, accumulate: any) {
if (!nodeSelectApi || id == null) return;
nodeSelectApi.select(id, !!accumulate);
scheduleSelectionEmit();
}
// clearSelection() — unselect every selected node (and any selected edge).
// clearSelection() — unselect every selected node (and any selected edge).
export function clearSelection() {
if (nodeSelectApi) {
for (const id of selectedNodeIds() as any) nodeSelectApi.unselect(id);
}
clearEdgeSelection();
scheduleSelectionEmit();
}
// selectAll() — select every node (Ctrl+A is not bound; marquee only covers a
// dragged region). Mirrors the marquee's first-replaces / rest-accumulate pattern.
// selectAll() — select every node (Ctrl+A is not bound; marquee only covers a
// dragged region). Mirrors the marquee's first-replaces / rest-accumulate pattern.
export function selectAll() {
if (!nodeSelectApi) return;
let first = true;
for (const n of getNodes() as any) {
nodeSelectApi.select(n.id, !first);
first = false;
}
scheduleSelectionEmit();
}
// centerOnNode(id, opts?) — pan (and optionally zoom via opts.zoom) to center the
// viewport on a node by id. setCenter is coordinate-based; this measures the node
// to compute its center in GRAPH coords (position is the top-left; offsetW/H are
// unscaled graph units), falling back to the minimap default dims pre-measure.
// centerOnNode(id, opts?) — pan (and optionally zoom via opts.zoom) to center the
// viewport on a node by id. setCenter is coordinate-based; this measures the node
// to compute its center in GRAPH coords (position is the top-left; offsetW/H are
// unscaled graph units), falling back to the minimap default dims pre-measure.
export async function centerOnNode(id: any, opts: any) {
if (!area || id == null) return;
const view = area.nodeViews ? area.nodeViews.get(id) : null;
if (!view || !view.position) return;
const el = view.element;
const w = el && el.offsetWidth ? el.offsetWidth : MINIMAP_DEFAULT_NODE_W;
const h = el && el.offsetHeight ? el.offsetHeight : MINIMAP_DEFAULT_NODE_H;
await setCenter(view.position.x + w / 2, view.position.y + h / 2, opts);
}
setContext('rete:canvas', {
// Register/replace a node TYPE template. `spec` carries an optional
// `bodyRenderer(host, { node })` — the render-by-type projection (mounted per graph
// node of this type into the engine body host, see renderNode). Whole-object replace.
registerType: (type: any, spec: any) => {
if (type != null) typeReg = {
...typeReg,
[type]: spec
};
},
// Drop a type on <NodeType> unmount (whole-object replace).
unregisterType: (type: any) => {
const t = {
...typeReg
};
delete t[type];
typeReg = t;
},
// A <Port> registers a port against its TYPE + side. Stored in the flat portReg
// under a UNIQUE per-port key `type::side::key` so registration is order-independent
// AND concurrency-safe: two <Port>s of the same type addTypePort in one React commit,
// and a pure `{ ...portReg, [uniqueKey]: port }` write (functional setState) merges
// both (an array read-modify-write under one type key would clobber). buildNode reads
// the type's portReg entries on every run regardless of mount order. The unique key
// also makes a re-fired addTypePort (late Lit context) idempotent — same key, same value.
// `side` is derived by <Port> from which of output=/input= is set (output⇒'output', input⇒'input');
// `portType` carries the port type that drives validate-types + the typed-port color.
// `position` (F2) is the socket's VISUAL placement (left|right|top|bottom; default by
// side) — drives the render-pipe socket layout + the connection-anchor axis.
addTypePort: (type: any, side: any, key: any, portType: any, label: any, multiple: any, position: any) => {
if (type == null || key == null) return;
const portKey = type + '::' + side + '::' + key;
portReg = {
...portReg,
[portKey]: {
type,
side,
key,
portType,
label,
multiple,
position
}
};
},
// Render-by-type callback target. Returns the engine-created body host div for a
// graph node (nodeEntries.get(nodeId).body). The render-by-type projection mounts
// the node's TYPE template `#body` INTO this host via $portals — the Wave-0 A3
// finding (a Lit child cannot relocate its own shadow <slot> across the boundary),
// so the body is projected by the parent reusing the $portals host discipline.
bodyHostFor: (nodeId: any) => {
const entry = nodeEntries.get(nodeId);
return entry ? entry.body : null;
}
});
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portalInstances = new Set<Record<string, unknown>>();
const portals = {
node: (container: HTMLElement, scope: { node: unknown; selected: unknown; emit: unknown }): ReactivePortalHandle => {
if (!node) return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-node', 'cd396d6a');
const inst = mount(PortalHostReactive, {
target: container,
props: { snippet: node, initialScope: scope },
});
portalInstances.add(inst as Record<string, unknown>);
return {
update: (s: unknown): void => {
(inst as unknown as { update(s: unknown): void }).update(s);
},
dispose: (): void => {
unmount(inst as Parameters<typeof unmount>[0]);
portalInstances.delete(inst as Record<string, unknown>);
},
};
},
toolbar: (container: HTMLElement, scope: { node: unknown; emit: unknown }): ReactivePortalHandle => {
if (!toolbar) return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-toolbar', 'cd396d6a');
const inst = mount(PortalHostReactive, {
target: container,
props: { snippet: toolbar, initialScope: scope },
});
portalInstances.add(inst as Record<string, unknown>);
return {
update: (s: unknown): void => {
(inst as unknown as { update(s: unknown): void }).update(s);
},
dispose: (): void => {
unmount(inst as Parameters<typeof unmount>[0]);
portalInstances.delete(inst as Record<string, unknown>);
},
};
},
};
$effect(() => () => {
for (const inst of portalInstances) unmount(inst as Parameters<typeof unmount>[0]);
portalInstances.clear();
});
onMount(() => {
const container = canvasEl;
lastPropNodeIds = [];
lastPropConnIds = [];
editor = new NodeEditor();
area = new AreaPlugin(container);
connectionPlugin = new ConnectionPlugin();
connectionPlugin.addPreset(ConnectionPresets.classic.setup());
// Resolve a port's VISUAL position (F2) from the per-TYPE port schema (portReg, keyed
// `type::side::key`), defaulting by DIRECTION (input → left, output → right) for exact
// back-compat. DEFINED HERE inside $onMount (NOT top level) so its $data.portReg read
// lowers on React to the live `_portRegRef.current`, not a stale-empty mount-time
// closure (the portTypeOf discipline). Used by both the socket-anchor offset below and
// renderNode's socket layout.
const resolvePortPosition = (type: any, side: any, key: any) => {
const entry = type != null && key != null ? portReg[type + '::' + side + '::' + key] : null;
const p = entry && entry.position != null ? entry.position : null;
if (p === 'left' || p === 'right' || p === 'top' || p === 'bottom') return p;
return side === 'input' ? 'left' : 'right';
};
// DOM-based socket position watcher — feeds connection-path redraw + the
// ConnectionPlugin's drag-to-connect hit-testing. A CUSTOM `offset` (F2): the rete
// default shifts the anchor 12px OUTWARD on the X axis only (`x + 12·(input?−1:1)`) —
// correct for left/right, wrong for top/bottom. We resolve each socket's visual
// position and shift on the matching axis (±x for left/right — IDENTICAL to the default,
// so the rete-flow-align cell stays green; ±y for top/bottom). The position is looked up
// live via nodeMeta→type→portReg, so it tracks late-registered ports.
const SOCKET_SHIFT = 12;
const socketOffset = (position: any, nodeId: any, side: any, key: any) => {
const meta = nodeMeta.get(nodeId);
const p = meta ? resolvePortPosition(meta.type, side, key) : side === 'input' ? 'left' : 'right';
if (p === 'top') return {
x: position.x,
y: position.y - SOCKET_SHIFT
};
if (p === 'bottom') return {
x: position.x,
y: position.y + SOCKET_SHIFT
};
if (p === 'left') return {
x: position.x - SOCKET_SHIFT,
y: position.y
};
return {
x: position.x + SOCKET_SHIFT,
y: position.y
};
};
socketWatcher = getDOMSocketPosition({
offset: socketOffset
});
editor.use(area);
area.use(connectionPlugin);
// ── T2.5 RECONNECT coalescing pipe (D-08 reconnectable edges, D-03 one-gesture-one-entry) ──
// `connectionpick` / `connectiondrop` are emitted on the ConnectionPlugin's OWN scope (they
// are NOT editor signals like connectioncreated/removed, nor area signals like nodepicked),
// so they must be observed via a pipe attached DIRECTLY to `connectionPlugin` — they do not
// propagate into editor.addPipe / area.addPipe. Grabbing an already-connected input socket
// fires connectionpick, then the classic preset removes the old edge + (on drop over a new
// socket) adds a new one — a remove+add pair that would push TWO history entries (Pitfall 2).
// We open a reconnect-in-flight window on connectionpick (capturing the PRE-gesture snapshot
// ONCE) and close it on connectiondrop (pushing that single snapshot iff the gesture actually
// changed the graph) — so the whole reconnect is ONE undoable step.
connectionPlugin.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectionpick') {
// Open the coalesce window + capture the pre-gesture snapshot once. Gated on
// !programmatic + history (a restore-driven engine op must not record history). A
// re-pick while a close is pending cancels the pending close (the gesture continues).
if (!programmatic && history !== false) {
reconnectInFlight++;
reconnectPreSnapshot = snapshotCurrent();
reconnectDidWriteBack = false;
reconnectCloseScheduled = false;
}
} else if (context.type === 'connectiondrop') {
// The gesture ended. CRITICAL ORDERING: the classic preset emits `connectiondrop`
// BEFORE the editor's `connectionremoved` / `connectioncreated` signals fire (the
// pseudo-connection is dropped, THEN the real add/remove run — verified in the event
// trace: drop → connectioncreate → connectioncreated → connectionremove →
// connectionremoved). So we must NOT close the window synchronously here, or the
// trailing writeBacks would run with inFlight=0 and each push its own (wrong) history
// entry. Instead DEFER the close to a macrotask (setTimeout 0), which runs after all
// the synchronous + microtask writeBack signals have settled. The window stays open
// across the remove+add (both suppress their per-event push, setting
// reconnectDidWriteBack), then closeReconnectGesture pushes the SINGLE pre-gesture
// snapshot iff the graph actually changed. Re-entrant picks can't desync because the
// close is gated on a one-shot scheduled flag.
scheduleReconnectClose();
// ── T2.7 CONNECT-END-ON-PANE (D-07, pure emit) ──
// A drag that STARTED on an output socket and ENDED on empty canvas (no target
// socket, no connection created) surfaces `@connect-end { source, sourceOutput,
// position }` so the consumer can run its OWN node-picker / create-node flow at the
// drop point (the n8n "drag off a port → drop on the pane → pick a node" UX). The
// component owns ONLY this hook — it creates NO node and shows NO picker (D-07,
// consumer-owns-creation, exactly like screenToFlowPosition + the palette drop).
// Detection: `socket == null` (released over the pane, not a socket) && `created ==
// false` (no edge was made) && `initial.side === 'output'` (we only surface OUTPUT-
// started drags — an input-started drag off the pane has no "source output" to seed
// a downstream node from, and the reconnect path already owns input-endpoint drags).
// Position = `area.area.pointer` (the AreaPlugin's live pointer, ALREADY in graph
// coords — the same origin screenToFlowPosition projects into), so no client→graph
// projection is needed; we still fall back to screenToFlowPosition over a raw
// clientX/clientY if a future plugin build stops tracking area.area.pointer. Gated on
// !programmatic so a restore/imperative-driven drop never emits. NO node is created.
const cd = context.data;
if (cd && !cd.socket && cd.created === false && cd.initial && cd.initial.side === 'output' && !programmatic) {
let pos: any = null;
const inner = area && area.area ? area.area : null;
if (inner && inner.pointer && typeof inner.pointer.x === 'number' && typeof inner.pointer.y === 'number') {
pos = {
x: inner.pointer.x,
y: inner.pointer.y
};
}
if ((!pos || typeof pos.x !== 'number' || typeof pos.y !== 'number') && cd.initial && cd.initial.element && typeof cd.initial.element.getBoundingClientRect === 'function') {
// Fallback: project the last-known pointer client coords through the shipped
// screenToFlowPosition (graph-coord inverse of the area transform). The drop event
// carries no pointer; use the source socket element's center as a degraded anchor.
const r = cd.initial.element.getBoundingClientRect();
pos = screenToFlowPosition(r.left + r.width / 2, r.top + r.height / 2);
}
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
onconnectend?.({
source: cd.initial.nodeId,
sourceOutput: cd.initial.key,
position: {
x: pos.x,
y: pos.y
}
});
}
}
}
return context;
});
// The socket-position watcher (and, conceptually, our vanilla "render plugin")
// must attach to a CHILD scope of the area — `attach` calls
// `scope.parentScope(BaseAreaPlugin)`, which walks UP one level, so the scope's
// parent must BE the area. Attaching to `area` itself fails ("actual parent is
// not instance of type") because area's parent is the NodeEditor. So we add a
// minimal child Scope and attach the watcher to it. Rete forwards every area
// signal (render/nodetranslated/unmount/…) into this child's signal, so the
// watcher sees socket renders + node moves and recomputes socket positions.
renderScope = new Scope('rozie-vanilla-render');
area.use(renderScope);
socketWatcher.attach(renderScope);
// ── T2.6 auto-layout (D-08, verb-only) ──
// Wire the AutoArrangePlugin (elkjs classic preset) so the top-level autoArrange() verb
// can run a layered relayout on demand. area.use(arrange) installs it as an area-scope
// plugin; arrange.layout() mutates the engine node positions directly (calls area.translate
// internally). The verb reads the arranged positions BACK into a FRESH $model.graph (the
// controlled-graph contract — the engine is never the source of truth). NO auto-trigger —
// the consumer calls autoArrange() (the MapLibre verb-first stance).
arrange = new AutoArrangePlugin();
arrange.addPreset(ArrangePresets.classic.setup());
area.use(arrange);
// ── selection (selectableNodes) ──
// Capture the returned handle ({ select(id, accumulate), unselect(id) }) so the T2.4
// marquee can PROGRAMMATICALLY select each intersecting node (select(id, true) =
// accumulate). The handle is null when selection is off (readonly / !selectable), in
// which case the marquee branch no-ops.
if (selectable && !readonly) {
selector = AreaExtensions.selector();
nodeSelectApi = AreaExtensions.selectableNodes(area, selector, {
accumulating: accumulateOnCtrl ? AreaExtensions.accumulateOnCtrl() : {
active: () => false
}
});
}
// raise the picked node above its siblings.
AreaExtensions.simpleNodesOrder(area);
// ── zoom clamp (restrictor) ──
const min = typeof minZoom === 'number' && minZoom > 0 ? minZoom : 0;
const max = typeof maxZoom === 'number' && maxZoom > 0 ? maxZoom : 0;
if (min || max) {
AreaExtensions.restrictor(area, {
scaling: {
min: min || 0.01,
max: max || 100
}
});
}
// ── snap-to-grid ──
if (typeof snapGrid === 'number' && snapGrid > 0) {
AreaExtensions.snapGrid(area, {
size: snapGrid,
dynamic: true
});
}
// ── interaction toggles ──
if (!pannable) area.area.setDragHandler(null);
if (!zoomable) area.area.setZoomHandler(null);
// ── Delete / Backspace key → cascading delete of the selected node(s) (Win 1) ──
// Attached to the engine container ($refs.canvasEl, which carries tabindex="0" in
// the template so it can receive key focus) rather than `document`: the listener
// lives INSIDE the Lit shadow root alongside the canvas, so a canvas-focused key
// reaches it on Lit too (a `:target="document"` listener does not reliably see
// shadow-scoped focus across all 6 — the canvas-element listener is the robust
// cross-target path). Gated on selectable && !readonly. We guard against deleting
// while focus is in a node-body text field (INPUT/TEXTAREA/contenteditable) so
// typing in a node never nukes it. The listener is removed in the teardown.
if (selectable && !readonly && container && typeof container.addEventListener === 'function') {
onCanvasKeydown = (e: any) => {
if (!e) return;
const t = e.target;
// Focus-guard (verbatim with the Delete branch): never act while focus is in a
// node-body text field (INPUT/TEXTAREA/contenteditable) — Ctrl+Z must reach the
// browser's native text undo there, and Delete must not nuke the node.
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
// ── T1.3 — Undo / Redo keybinds (D-02). Ctrl/Cmd+Z → undo; Ctrl/Cmd+Shift+Z and
// Ctrl/Cmd+Y → redo. Gated on the SAME focus-guard as Delete. preventDefault so the
// browser's page-level undo doesn't also fire. `metaKey` covers macOS Cmd. ──
if ((e.ctrlKey || e.metaKey) && !e.altKey) {
const k = typeof e.key === 'string' ? e.key.toLowerCase() : '';
if (k === 'z' && !e.shiftKey) {
e.preventDefault();
undo();
return;
}
if (k === 'z' && e.shiftKey || k === 'y') {
e.preventDefault();
redo();
return;
}
}
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
const ids = selectedNodeIds();
if (ids.length > 0) {
e.preventDefault();
for (const id of ids as any) deleteNode(id);
return;
}
// T1.1 — EDGE DELETE (D-08). No node is picked but an edge is selected → remove
// exactly that edge via the controlled-graph write-back (the disconnect path: a
// fresh `{ ...g, connections: filtered }` object), then clear the selection. The
// wrapper's own $watch(graph) reconcile reaps the live engine connection (the
// single removal path — we do NOT also call editor.removeConnection, which would
// race the reconcile into "cannot find connection", mirroring deleteNode). Node
// delete takes precedence (handled above); this only runs when nothing's picked.
if (selectedConnId != null) {
e.preventDefault();
const id = selectedConnId;
clearEdgeSelection();
writeBackConnectionRemoved(id);
}
};
keydownContainer = container;
container.addEventListener('keydown', onCanvasKeydown);
}
// ─────────────────────────────────────────────────────────────────────────
// THE VANILLA RENDER PIPE. Intercepts the AreaPlugin's render/unmount signals.
// ALWAYS returns context (returning undefined would halt the signal chain and
// break the ConnectionPlugin / socket watcher downstream).
// ─────────────────────────────────────────────────────────────────────────
area.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'render') {
const data = context.data;
if (data.type === 'node') renderNode(data.element, data.payload);else if (data.type === 'connection') renderConnection(data.element, data.payload, data.start, data.end);
// data.type === 'socket' (our own re-emitted signals) falls through
// untouched so the ConnectionPlugin + socketWatcher consume them.
} else if (context.type === 'unmount') {
cleanupElement(context.data.element);
}
return context;
});
// ── node renderer ──
// Fills the engine-created nodeView element with: input sockets, the body
// (consumer `node` portal fragment OR default chrome), and output sockets.
// Re-render (area.update('node', id)) reuses the same element → update in place.
// NOTE: the engine-node parameter is `reteNode`, NOT `node` — on Svelte the
// `$slots.node` slot lowers to a top-level `const node`, and a parameter named
// `node` here would SHADOW it, so `if ($slots.node)` would read the (always-
// truthy) engine node and wrongly take the portal branch even when the slot is
// unfilled (dropping the default-chrome title). The cross-target slot-name ==
// local-binding shadow trap.
const renderNode = (element: any, reteNode: any) => {
// a (re)render means node DOM exists / changed → refresh the minimap (its node
// rects measure these elements; coalesced, so calling it on every render is cheap,
// and it covers Lit's measure-after-first-paint).
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
const id = reteNode.id;
const meta = nodeMeta.get(id) || {
id,
type: undefined,
data: {}
};
const existing = nodeEntries.get(id);
const selected = reteNode.selected === true;
// default-chrome fallback label (only when a node's type has no #body template).
const chromeLabel = meta.data && meta.data.label != null ? String(meta.data.label) : meta.type != null ? String(meta.type) : '';
if (existing && existing.element === element) {
// in-place update — refresh chrome + reactive portal scope, leave sockets.
existing.box.classList.toggle('is-selected', selected);
if (existing.handle) {
existing.handle.update({
node: meta,
selected,
emit: existing.emit
});
} else if (existing.titleEl) {
existing.titleEl.textContent = chromeLabel;
}
return;
}
// fresh build
element.innerHTML = '';
const box = document.createElement('div');
box.className = 'rozie-flow-node' + (selected ? ' is-selected' : '');
const body = document.createElement('div');
body.className = 'rozie-flow-node__body';
// ── socket layout (F2: position-aware) ───────────────────────────────────────
// Bucket the node's ports by VISUAL position (default input→left, output→right).
// When NO port is top/bottom (every pre-F2 graph), render the EXACT classic
// [inputsCol | body | outputsCol] 3-column structure — byte-identical DOM, so the
// FlowCanvasScreenshot pixel baseline is untouched. A node that declares ANY top/
// bottom port gets the 3-ROW structure (topRow / midRow[left|body|right] / bottomRow).
const socketDisposers = [];
const portEntries = [];
for (const key of Object.keys(reteNode.inputs) as any) portEntries.push({
side: 'input',
key,
position: resolvePortPosition(meta.type, 'input', key)
});
for (const key of Object.keys(reteNode.outputs) as any) portEntries.push({
side: 'output',
key,
position: resolvePortPosition(meta.type, 'output', key)
});
const hasVertical = portEntries.some((p: any) => p.position === 'top' || p.position === 'bottom');
if (!hasVertical) {
// CLASSIC left/right layout — byte-for-byte identical to pre-F2 (pixel-baseline safe).
const inputsCol = document.createElement('div');
inputsCol.className = 'rozie-flow-node__col rozie-flow-node__col--in';
const outputsCol = document.createElement('div');
outputsCol.className = 'rozie-flow-node__col rozie-flow-node__col--out';
box.appendChild(inputsCol);
box.appendChild(body);
box.appendChild(outputsCol);
element.appendChild(box);
for (const p of portEntries as any) {
renderSocketInto(p.position === 'right' ? outputsCol : inputsCol, reteNode, p.side, p.key, p.position, socketDisposers);
}
} else {
// VERTICAL-capable 3-row layout (only when a top/bottom port exists).
box.classList.add('rozie-flow-node--rows');
const topRow = document.createElement('div');
topRow.className = 'rozie-flow-node__row rozie-flow-node__row--top';
const midRow = document.createElement('div');
midRow.className = 'rozie-flow-node__mid';
const leftCol = document.createElement('div');
leftCol.className = 'rozie-flow-node__col rozie-flow-node__col--in';
const rightCol = document.createElement('div');
rightCol.className = 'rozie-flow-node__col rozie-flow-node__col--out';
const bottomRow = document.createElement('div');
bottomRow.className = 'rozie-flow-node__row rozie-flow-node__row--bottom';
midRow.appendChild(leftCol);
midRow.appendChild(body);
midRow.appendChild(rightCol);
box.appendChild(topRow);
box.appendChild(midRow);
box.appendChild(bottomRow);
element.appendChild(box);
for (const p of portEntries as any) {
const zone = p.position === 'top' ? topRow : p.position === 'bottom' ? bottomRow : p.position === 'right' ? rightCol : leftCol;
renderSocketInto(zone, reteNode, p.side, p.key, p.position, socketDisposers);
}
}
// emit per-node event helper handed to the slot scope so a consumer node body
// can raise a custom event carrying its id (e.g. a delete button).
const emit = (name: any, detail: any) => onnodeaction?.({
id,
name,
detail
});
const entry = {
element,
box,
body,
handle: null,
bodyHandle: null,
titleEl: null,
bodyMoved: false,
emit,
socketDisposers
};
// ── RENDER-BY-TYPE: select the body by `node.type` ──────────────────────────
// 1) the node's TYPE template (typeReg[type].bodyRenderer) — the primary path
// (41-03 <NodeType><template #body>); 2) the low-level `#node` portal slot
// (consumer switches on node.type itself — escape hatch); 3) default chrome.
const typeSpec = meta.type != null ? typeReg[meta.type] : null;
if (typeSpec && typeof typeSpec.bodyRenderer === 'function') {
// RENDER-BY-TYPE callback path. The <NodeType> cannot relocate its OWN <slot>
// across the Lit shadow boundary (Wave-0 A3), so the PARENT projects the body
// here from its own render scope: the type's registered bodyRenderer(host, scope)
// mounts the type's `#body` portal INTO the engine `body` div (a FRESH render
// root per node — no framework DOM relocation, the Phase-37 D-04 trap avoided).
// nodeEntries must exist before the callback runs (bodyHostFor reads it), so
// register first. The graph node's `data` flows in as scope → one template per
// type renders every instance of that type.
nodeEntries.set(id, entry);
entry.bodyHandle = typeSpec.bodyRenderer(body, {
node: meta,
selected,
emit
});
entry.bodyMoved = true;
return;
}
if (node) {
// reactive multi-instance portal — one handle per node, re-rendered in
// place on meta change (the MapLibre marker discipline). Low-level escape
// hatch: the consumer switches on node.type inside the single `#node` slot.
entry.handle = portals.node(body, {
node: meta,
selected,
emit
});
} else {
// default chrome: a title bar (the type name / data.label).
const title = document.createElement('div');
title.className = 'rozie-flow-node__title';
title.textContent = chromeLabel;
body.appendChild(title);
entry.titleEl = title;
}
nodeEntries.set(id, entry);
};
// Render ONE socket into a zone and, crucially, EMIT its render signal so the
// ConnectionPlugin + position watcher register it. `position` is the socket's visual
// placement (left|right|top|bottom). For left/right the DOM is byte-identical to pre-F2
// (the classic horizontal port row); top/bottom get a vertical port (socket above its
// label) + a `--<position>` socket class so the socket straddles the matching edge.
const renderSocketInto = (zone: any, reteNode: any, side: any, key: any, position: any, socketDisposers: any) => {
const port = (side === 'input' ? reteNode.inputs : reteNode.outputs)[key];
if (!port) return;
const vertical = position === 'top' || position === 'bottom';
const row = document.createElement('div');
row.className = 'rozie-flow-port rozie-flow-port--' + side + (vertical ? ' rozie-flow-port--vertical' : '');
const socketEl = document.createElement('div');
socketEl.className = 'rozie-flow-socket rozie-flow-socket--' + side + (vertical ? ' rozie-flow-socket--' + position : '');
socketEl.setAttribute('data-testid', 'socket');
const label = document.createElement('span');
label.className = 'rozie-flow-port__label';
label.textContent = port.label != null ? String(port.label) : key;
// CLASSIC: inputs socket-first, outputs label-first (byte-identical to pre-F2).
// VERTICAL: socket-first (the socket sits on the edge, label tucked inward).
if (side === 'input' || vertical) {
row.appendChild(socketEl);
row.appendChild(label);
} else {
row.appendChild(label);
row.appendChild(socketEl);
}
zone.appendChild(row);
// LOAD-BEARING: announce the socket to the rest of the area's child plugins.
// 'render' lets the ConnectionPlugin register the socket as a drag anchor.
area.emit({
type: 'render',
data: {
type: 'socket',
side,
key,
nodeId: reteNode.id,
element: socketEl,
payload: {
socket: port.socket
}
}
});
// ALSO LOAD-BEARING (the socket-position contract): getDOMSocketPosition measures +
// stores a socket's DOM position ONLY on a 'rendered' socket signal — the render-plugin
// lifecycle's post-mount phase. Our vanilla pipe creates + appends the socket DOM
// synchronously, so we fire 'rendered' right after 'render'. WITHOUT IT the position
// store stays empty, every socketWatcher.listen() callback reads null, and NO
// connection path (committed OR drag preview) is ever drawn.
area.emit({
type: 'rendered',
data: {
type: 'socket',
side,
key,
nodeId: reteNode.id,
element: socketEl,
payload: {
socket: port.socket
}
}
});
socketDisposers.push(() => {
area.emit({
type: 'unmount',
data: {
element: socketEl
}
});
});
};
// ── hand-written edge-type path generators (T1.2, D-01) ───────────────────────
// `rete-render-utils` ships ONLY `classicConnectionPath` (bezier) + `loopConnectionPath`;
// step/smoothstep/straight do NOT exist in any installed rete package, so they are
// hand-written here matching React-Flow's `step|smoothstep|straight` semantics. Each is a
// PURE `(start, end) → d-string` function over `{x,y}` graph-screen points; the `d` is
// composed from numeric coords + literal SVG commands and written via setAttribute (never
// innerHTML — no injection, T-44-02-2 accept). The default branch stays
// `classicConnectionPath` → byte-identical bezier (pixel-baseline safe).
// straight: a single line, no curvature.
const straightPath = (s: any, e: any) => `M ${s.x} ${s.y} L ${e.x} ${e.y}`;
// step: orthogonal HV-VH with a mid-X break.
const stepPath = (s: any, e: any) => {
const mx = (s.x + e.x) / 2;
return `M ${s.x} ${s.y} L ${mx} ${s.y} L ${mx} ${e.y} L ${e.x} ${e.y}`;
};
// smoothstep: step with rounded corners (radius r, clamped to half the shorter leg).
const smoothstepPath = (s: any, e: any, r = 8) => {
const mx = (s.x + e.x) / 2;
const dir = e.y >= s.y ? 1 : -1;
const rr = Math.min(r, Math.abs(mx - s.x), Math.abs(e.y - s.y) / 2);
return [`M ${s.x} ${s.y}`, `L ${mx - rr} ${s.y}`, `Q ${mx} ${s.y} ${mx} ${s.y + dir * rr}`, `L ${mx} ${e.y - dir * rr}`, `Q ${mx} ${e.y} ${mx + rr} ${e.y}`, `L ${e.x} ${e.y}`].join(' ');
};
// ── connection renderer ──
// Mounts an <svg><path> and redraws it whenever either endpoint socket moves
// (real connection) OR the dragged pointer moves (user drag-to-connect pseudo).
//
// A USER DRAG renders a *pseudo-connection* (rete-connection-plugin): the render
// signal carries a literal pointer coordinate (`endPointer`/`data.end` when
// dragging FROM an output, `startPointer`/`data.start` when dragging FROM an
// input) alongside a payload with ONE DANGLING endpoint — `target:''`/
// `targetInput:''` (output-side drag) or `source:''`/`sourceOutput:''`
// (input-side drag). The dangling side has no socket to watch, so its coordinate
// MUST come from the pointer; the live side stays watcher-driven. The
// ConnectionPlugin re-emits this render on EVERY pointermove with a fresh pointer
// — so the same pseudo element is re-rendered repeatedly and the dangling
// coordinate must update in place (no SVG rebuild, no listener re-subscribe).
const renderConnection = (element: any, connection: any, startPointer: any, endPointer: any) => {
const id = connection.id;
// A side is dangling when its node id OR its port key is empty/nullish.
const srcDangling = !connection.source || !connection.sourceOutput;
const tgtDangling = !connection.target || !connection.targetInput;
// RE-RENDER of the SAME element (the pseudo on each pointermove): do NOT rebuild
// the SVG or re-subscribe listeners (would leak) — just update the dangling
// side's coordinate and redraw. This replaces the old unconditional early-return
// that froze the preview line. For a REAL connection updatePointer is a no-op,
// so a re-render of a committed edge is byte-for-byte the old early-return.
const prev = connEntries.get(id);
if (prev && prev.element === element) {
prev.updatePointer(startPointer, endPointer);
return;
}
element.innerHTML = '';
element.classList.add('rozie-flow-connection');
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'rozie-flow-connection__svg');
// ── direction arrowhead (Win 3) ─────────────────────────────────────────────
// A <defs><marker> in THIS connection's own <svg>, referenced by `marker-end` so
// the triangle sits at the path END (the input socket — the path runs output→input,
// so marker-end points INTO the target). The marker id is UNIQUE per connection
// (`rozie-arrow-<id>`) so two edges' markers never collide on a shared document id
// (url(#id) resolves to the first match otherwise). The def lives in the SAME
// per-edge <svg> inside the SAME shadow root as the path, so url(#id) resolves
// within that root — no cross-root reference (Lit-safe). markerUnits="userSpaceOnUse"
// keeps a constant pixel size under the area zoom transform. Inline fill (#64748b,
// matching the connection stroke) is the cross-target-safe choice — no scoped-CSS /
// :root rule needed for the marker DOM. The marker does NOT change the path `d`
// or the socket geometry (the rete-flow-align cell stays green) — redraw() only
// sets the head's `orient` and a `stroke-dasharray` that visually trims the last
// ARROW_LEN of the stroke so the line meets the head without poking through it.
const markerId = 'rozie-arrow-' + String(id);
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
marker.setAttribute('id', markerId);
// Sized in userSpaceOnUse (constant pixels under zoom). A 12×10 head reads
// clearly at default zoom (the old 6×6 was barely visible). refX=12 sits the
// TIP exactly at the path-end vertex (the socket); refY=5 centers it. `orient`
// is recomputed per-redraw from the path's final-segment tangent, and the
// visible stroke is trimmed back to the arrow base, so the head points along
// the edge's actual approach AND the line meets it cleanly — see redraw().
marker.setAttribute('markerWidth', '13');
marker.setAttribute('markerHeight', '10');
marker.setAttribute('refX', '12');
marker.setAttribute('refY', '5');
marker.setAttribute('orient', 'auto');
marker.setAttribute('markerUnits', 'userSpaceOnUse');
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
arrow.setAttribute('class', 'rozie-flow-connection__arrow');
arrow.setAttribute('d', 'M0,0 L12,5 L0,10 Z');
arrow.setAttribute('fill', '#64748b');
marker.appendChild(arrow);
defs.appendChild(marker);
svg.appendChild(defs);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('class', 'rozie-flow-connection__path');
path.setAttribute('marker-end', 'url(#' + markerId + ')');
svg.appendChild(path);
// ── T1.1 edge-select listener (D-08) ─────────────────────────────────────────
// Attach an IMPERATIVE pointerup listener on the engine-DOM <path> (NOT a template
// `@` — the path is engine-created; NOT click — Rete swallows it; NOT pointerdown —
// Rete stopPropagations it: the Phase-41 connector landmine, playbook §6a item 7).
// Gated on `selectable && !readonly` (mirrors node delete) and ONLY for COMMITTED
// edges — a drag-to-connect pseudo (either side dangling) carries no stable id and
// must not be selectable. `selectEdge` reads the id back off the closure (the
// committed connection.id == the graph connection id — conn.id = spec.id at build),
// so it always matches what `writeBackConnectionRemoved` filters. `.stop` keeps the
// pointerup from reaching the area's pan/background handling beneath the path.
if (selectable && !readonly && !srcDangling && !tgtDangling) {
path.style.cursor = 'pointer';
path.addEventListener('pointerup', (e: any) => {
if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
selectEdge(connection.id, path);
});
}
// ── per-edge label + styling (F3) ────────────────────────────────────────────
// The consumer's connection spec ({ id, source, …, label?, stroke?, dashed? }) is kept
// in connMeta keyed by id (the connection-side analog of nodeMeta). A committed edge
// resolves its label/style here; a drag-preview pseudo (no committed id) has none.
// Styling is applied as INLINE attributes (the arrowhead-marker discipline — engine DOM
// carries no scope attr); a `label` renders an SVG <text> at the path midpoint (white
// halo via paint-order for legibility over the line), repositioned in redraw().
const emeta = connMeta.get(connection.id) || null;
if (emeta) {
if (emeta.stroke != null) {
const s = String(emeta.stroke);
path.setAttribute('stroke', s);
arrow.setAttribute('fill', s);
}
if (emeta.dashed === true) path.setAttribute('stroke-dasharray', '7 5');
}
// ── resolved edge type (T1.2) ────────────────────────────────────────────────
// The consumer-supplied `connection.type` selects a path generator. ALLOWLIST it
// (`bezier|step|smoothstep|straight`); any other/absent value falls through to the
// bezier default — no dynamic path-fn lookup keyed on the raw string, no eval
// (T-44-02-1 mitigate). A dangling drag-preview pseudo has no committed connMeta
// entry, so it stays bezier too.
const rawType = emeta && emeta.type != null ? String(emeta.type) : 'bezier';
const edgeType = rawType === 'step' || rawType === 'smoothstep' || rawType === 'straight' ? rawType : 'bezier';
// Arrowhead geometry (redraw): the head is oriented along the path's tangent
// over its LAST `ARROW_LEN` (angled for a descending edge, aligned with where
// the line actually meets the head — unlike the chord, which diverges from the
// bezier's flattened end tangent), and the visible stroke is trimmed back to
// the arrow base on SOLID edges so the line's width can't poke past the
// tapering tip (the "square tip"). Dashed edges keep their pattern untrimmed.
const ARROW_LEN = 12;
const isDashed = !!(emeta && emeta.dashed === true);
let labelEl: any = null;
const edgeLabel = emeta && emeta.label != null ? String(emeta.label) : null;
if (edgeLabel) {
labelEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
labelEl.setAttribute('class', 'rozie-flow-connection__label');
labelEl.setAttribute('text-anchor', 'middle');
labelEl.setAttribute('dominant-baseline', 'middle');
labelEl.textContent = edgeLabel;
svg.appendChild(labelEl);
}
element.appendChild(svg);
let start: any = null;
let end: any = null;
const curvature$local = typeof curvature === 'number' ? curvature : 0.3;
const redraw = () => {
if (!start || !end) return;
// branch on the resolved edge type; default (bezier/unknown) stays
// classicConnectionPath UNCHANGED → byte-identical bezier output.
const d = edgeType === 'step' ? stepPath(start, end) : edgeType === 'smoothstep' ? smoothstepPath(start, end) : edgeType === 'straight' ? straightPath(start, end) : classicConnectionPath([start, end], curvature$local);
path.setAttribute('d', d);
// Orient the head and trim the visible stroke back to the arrow base (solid
// edges) so the line meets the head without poking through the tip.
// getTotalLength/getPointAtLength are SVGGeometryElement methods unavailable
// in a non-rendering env (jsdom) → guard and fall back to orient='auto' / untrimmed.
let pathLen = 0;
try {
pathLen = path.getTotalLength();
} catch (e: any) {
pathLen = 0;
}
if (pathLen > ARROW_LEN + 1) {
// BACKWARD edge (target socket left of the source socket): the classic
// bezier overshoots both control points, looping the curve into tight
// u-turns right at the sockets, so a sampled local tangent is unstable and
// the head curls. Use the path's TRUE end tangent (orient='auto' — the
// horizontal entry into the input) for a stable, standard arrow. FORWARD
// edges keep the final-ARROW_LEN tangent, which follows a descending edge
// AND aligns with where the line meets the head.
if (end.x < start.x) {
marker.setAttribute('orient', 'auto');
} else {
const tip = path.getPointAtLength(pathLen);
const back = path.getPointAtLength(pathLen - ARROW_LEN);
marker.setAttribute('orient', String(Math.atan2(tip.y - back.y, tip.x - back.x) * 180 / Math.PI));
}
if (!isDashed) path.setAttribute('stroke-dasharray', pathLen - ARROW_LEN + ' ' + pathLen);
} else {
marker.setAttribute('orient', 'auto');
if (!isDashed) path.removeAttribute('stroke-dasharray');
}
if (labelEl) {
labelEl.setAttribute('x', String((start.x + end.x) / 2));
labelEl.setAttribute('y', String((start.y + end.y) / 2));
}
};
// Seed the DANGLING side's coordinate from the pointer FIRST — socketWatcher
// .listen() synchronously replays the current socket snapshot on subscribe, so
// seeding before subscribing the live side means redraw() already has the
// dangling coordinate and the preview line draws immediately on the first render.
if (srcDangling && startPointer) start = startPointer;
if (tgtDangling && endPointer) end = endPointer;
// LIVE endpoints stay watcher-driven (exactly as before the fix — committed
// connections behave byte-for-byte). DANGLING endpoints subscribe NO listener
// (it would never fire — there is no socket); their coordinate is the pointer.
let un1: any = null;
let un2: any = null;
if (!srcDangling) un1 = socketWatcher.listen(connection.source, 'output', connection.sourceOutput, (p: any) => {
start = p;
redraw();
});
if (!tgtDangling) un2 = socketWatcher.listen(connection.target, 'input', connection.targetInput, (p: any) => {
end = p;
redraw();
});
// Update only the DANGLING side(s) from a fresh pointer on each subsequent
// render call. For a REAL connection (neither side dangling) this is a no-op,
// so committed connections never have a pointer override and keep behaving
// exactly as before.
const updatePointer = (sp: any, ep: any) => {
let moved = false;
if (srcDangling && sp) {
start = sp;
moved = true;
}
if (tgtDangling && ep) {
end = ep;
moved = true;
}
if (moved) redraw();
};
// Draw once now: a pseudo seeded with an initial pointer (+ its live side
// already replayed) draws immediately; a real connection whose sockets are
// already known also draws (idempotent — same `d` the listeners just set).
redraw();
connEntries.set(id, {
element,
updatePointer,
dispose: () => {
try {
un1 && un1();
} catch (e: any) {}
try {
un2 && un2();
} catch (e: any) {}
}
});
};
// ── unmount cleanup (keyed by the engine element area hands back) ──
const cleanupElement = (element: any) => {
for (const [id, entry] of nodeEntries as any) {
if (entry.element === element) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
nodeEntries.delete(id);
return;
}
}
for (const [id, entry] of connEntries as any) {
if (entry.element === element) {
entry.dispose();
connEntries.delete(id);
return;
}
}
};
// Resolve a single port's TYPE for the validation pipe: look up the live node's
// `type` (via nodeMeta) then the portReg entry keyed `type::side::key`. Returns the
// portType string or null (null on either side ⇒ no type constraint ⇒ allow). DEFINED
// HERE (inside $onMount) — NOT at top level — so its $data.portReg read lowers on React
// to the live `_portRegRef.current` rather than a stale-empty closure snapshot captured
// when this once-only mount effect first ran (the cross-type-reject-didn't-fire bug).
const portTypeOf = (nodeId: any, side: any, key: any) => {
const meta = nodeMeta.get(nodeId);
if (!meta || meta.type == null || key == null) return null;
const entry = portReg[meta.type + '::' + side + '::' + key];
return entry ? entry.portType : null;
};
// ─── connection-validation gate (D2/D3 — typed-socket validation + override) ──
// Cancels Rete's cancellable `connectioncreate` pre-event when the connection is
// rejected. TWO independent reject paths, both surfacing `connection-rejected`:
// 1. AUTOMATIC typed validation (`:validate-types`, default ON, D3 option a):
// resolve src/tgt port TYPE from the per-TYPE port schema (via each endpoint
// node's `type`); if both are non-null and UNEQUAL → reject. A null on either
// side (untyped port / unknown type) imposes no constraint → allow.
// 2. `canConnect` OVERRIDE (Phase-40 contract, SURVIVES): a consumer custom rule;
// runs IN ADDITION to (after) the automatic check; returning false rejects.
// Cancelling makes editor.addConnection return false WITHOUT pushing the connection
// or emitting `connectioncreated` — no ghost edge, no `connection-created`. Gates
// drag-to-connect, imperative addConnection, and reconcile uniformly. Both predicates
// are PURE (no $data write / engine call) — reads only. The block (return undefined)
// stays UNCONDITIONAL so rejection is enforced on every path; only the EMIT is
// echo-guarded (a programmatic reconcile the rule would reject must not surface as a
// user-facing rejection — mirrors connection-created/connection-removed).
editor.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectioncreate') {
const c = context.data;
// ClassicPreset.Connection fields: { id, source, sourceOutput, target, targetInput }.
// Same shape as serializeConn minus the engine-assigned `id` (never created).
const conn = {
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
};
// 1. AUTOMATIC typed validation (default ON; opt out via :validate-types="false").
if (validateTypes !== false) {
const srcType = portTypeOf(c.source, 'output', c.sourceOutput);
const tgtType = portTypeOf(c.target, 'input', c.targetInput);
if (srcType != null && tgtType != null && srcType !== tgtType) {
if (!programmatic) onconnectionrejected?.(conn);
return undefined; // ← CANCEL: type mismatch
}
}
// 2. canConnect OVERRIDE (Phase-40 contract — custom rule, in addition).
if (typeof canConnect === 'function' && canConnect(conn) === false) {
if (!programmatic) onconnectionrejected?.(conn);
return undefined; // ← CANCEL: Signal.emit halts, addConnection returns false
}
}
return context;
});
// ─── forward engine events (echo-guarded via `programmatic`) ───────────────
editor.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectioncreated') {
// keep engine truth in sync so reconcile diffs correctly — a user-drawn
// connection (auto id) must register here or the next graph pass re-adds it.
connInstances.set(context.data.id, context.data);
if (!programmatic) {
// WRITE-BACK: append the new connection into a fresh graph object (D4).
writeBackConnectionCreated(context.data);
// keep the discrete event too (back-compat).
onconnectioncreated?.(serializeConn(context.data));
}
} else if (context.type === 'connectionremoved') {
connInstances.delete(context.data.id);
connMeta.delete(context.data.id);
if (!programmatic) {
// WRITE-BACK: filter the removed connection out of a fresh graph object (D4).
writeBackConnectionRemoved(context.data.id);
onconnectionremoved?.({
id: context.data.id
});
}
}
return context;
});
area.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'nodepicked') {
onnodepicked?.({
id: context.data.id
});
// T1.3 — pointer-DOWN: stash the PRE-drag graph snapshot (before any movement). It
// is committed to history on the first `nodetranslated` (only if a drag follows;
// gated on !programmatic + history). A re-pick mid-drag won't overwrite a live one.
if (!programmatic && history !== false && !dragGestureActive) {
pendingDragSnapshot = snapshotCurrent();
}
// Win 2: a pick changed the selection — surface @selection-change after the
// engine's awaited select() for THIS pick has flushed the selector entities.
scheduleSelectionEmit();
} else if (context.type === 'pointerup') {
// Win 2: AreaExtensions.selectableNodes UNSELECTS all on a click-like background
// pointerUP (its `twitch < 4` deselect — NOT on pointerdown, verified against
// rete-area-plugin's selectable pipe). Its unselectAll() is async and its pipe
// runs before ours, so recompute AFTER its awaited unselectAll() flushes (the
// microtask + rAF schedule). The dedup makes a no-op when nothing changed (e.g. a
// pointerup that ended a node pick — already surfaced by the nodepicked branch).
scheduleSelectionEmit();
// T1.3 — a pointerup ends any in-progress drag gesture, so the NEXT drag pushes a
// fresh history snapshot (one gesture = one undo step, D-03). Drop any stashed
// pre-drag snapshot that was never committed (a pick with no drag).
dragGestureActive = false;
pendingDragSnapshot = null;
// T1.1: a background pointerup (anywhere not on a connection path) clears the edge
// selection — UNLESS this same gesture just selected an edge (the path's own
// pointerup ran in the same tick and raised `edgeClickGuard`; the guard self-resets
// on the next microtask). Mirrors the node selectable's click-to-deselect.
if (!edgeClickGuard && selectedConnId != null) clearEdgeSelection();
} else if (context.type === 'nodetranslated') {
if (!programmatic) {
const id = context.data.id;
const pos = context.data.position;
const meta = nodeMeta.get(id);
if (meta) {
meta.x = pos.x;
meta.y = pos.y;
}
// T1.3 — commit ONE history snapshot per drag gesture, at its FIRST translate:
// the pre-move snapshot stashed on nodepicked (a drag truly happened now, not just
// a pick). dragGestureActive holds until the drag-ending pointerup resets it, so a
// continuous drag = ONE undo step (D-03).
if (!dragGestureActive) {
dragGestureActive = true;
if (pendingDragSnapshot) {
pushHistorySnapshot(pendingDragSnapshot);
pendingDragSnapshot = null;
}
}
// WRITE-BACK (coalesced): accumulate the latest position for this node and
// flush ONE fresh graph object per animation frame (Pitfall 2 — the drag
// storm). The discrete `node-moved` emit stays per-translate (back-compat).
pendingDragPositions.set(id, {
x: pos.x,
y: pos.y
});
scheduleDragFlush();
onnodemoved?.({
id,
x: pos.x,
y: pos.y
});
}
// a node moved → its minimap rect moves (works during a programmatic translate too).
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
// T2.8 — the selected node moved → re-track its toolbar overlay (no-op if off).
if (scheduleToolbarTrack) scheduleToolbarTrack();
} else if (context.type === 'translated') {
ontranslated?.({
x: context.data.position.x,
y: context.data.position.y
});
// the viewport window moved → redraw the minimap viewport rect + mask.
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
// T2.8 — a pan shifts the node's screen rect → re-track the toolbar (no-op if off).
if (scheduleToolbarTrack) scheduleToolbarTrack();
} else if (context.type === 'zoomed') {
if (!programmatic) {
const k = area.area.transform.k;
if (k !== zoom) zoom = k;
}
// the viewport window resized (zoom) → redraw the minimap viewport rect + mask.
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
// T2.8 — a zoom changes the node's screen rect/size → re-track the toolbar (no-op if off).
if (scheduleToolbarTrack) scheduleToolbarTrack();
} else if (context.type === 'contextmenu') {
// suppress the native browser menu over the canvas; surface a hook instead.
context.data.event.preventDefault();
const ctx = context.data.context;
oncontextmenu?.({
id: ctx && ctx.id ? ctx.id : null
});
}
return context;
});
// ─── reconciler off the bound graph, bridged to the top-level $watch ──────────
// Nodes come ONLY from `$props.graph.nodes` (the single source of truth, D1/D2);
// sockets come from each node's TYPE port schema (portReg keyed `type::side::key`).
// A port-schema change ($data.portReg, when a <Port> registers late on Lit) ALSO
// drives this reconcile so a node whose type just gained ports re-renders. An
// imperative $expose addNode (provenance NOT in lastPropNodeIds) survives the reaper.
// Wrapped by reconcileNodes (below) with a re-entrancy guard so two passes never
// race the engine (the Lit "cannot find node" fix).
const reconcileNodesPass = async () => {
if (!editor || !area) return;
const graphNodes = Array.isArray(graph && graph.nodes) ? graph.nodes : [];
const want = [];
programmatic++;
try {
for (const spec of graphNodes as any) {
if (!spec || spec.id == null) continue;
want.push(spec.id);
nodeMeta.set(spec.id, spec);
let node = nodeInstances.get(spec.id);
if (!node) {
node = buildNode(spec, portReg);
nodeInstances.set(spec.id, node);
await editor.addNode(node);
await area.translate(spec.id, {
x: spec.x || 0,
y: spec.y || 0
});
} else {
// Sync any ports this node's TYPE gained AFTER the node was first built —
// a nested <Port>'s addTypePort can land after reconcileNodes already
// created the node (the node registered before its ports on some targets,
// or a <Port> registered late on Lit). buildNode only runs for NEW nodes,
// so add the missing inputs/outputs onto the live instance here from the
// TYPE schema, then re-render.
let portsAdded = false;
const {
inputs: wantIn,
outputs: wantOut
} = portSchemaForType(spec.type, portReg);
for (const inp of wantIn as any) {
if (!inp || inp.key == null || node.inputs[inp.key]) continue;
node.addInput(inp.key, new ClassicPreset.Input(SOCKET, inp.label, inp.multiple === true));
portsAdded = true;
}
for (const out of wantOut as any) {
if (!out || out.key == null || node.outputs[out.key]) continue;
node.addOutput(out.key, new ClassicPreset.Output(SOCKET, out.label, out.multiple !== false));
portsAdded = true;
}
const view = area.nodeViews.get(spec.id);
if (view && spec.x != null && spec.y != null && (view.position.x !== spec.x || view.position.y !== spec.y)) {
await area.translate(spec.id, {
x: spec.x,
y: spec.y
});
}
if (portsAdded) {
// renderNode's in-place branch deliberately leaves existing sockets
// untouched; to render the NEW sockets, drop this node's render entry so
// area.update takes the fresh-build path (re-runs buildSocketRow + re-
// emits the socket render signals the ConnectionPlugin/watcher need). The
// render-by-type body host is re-projected by the type's bodyRenderer
// (mounts a fresh portal root into the same host — idempotent).
const entry = nodeEntries.get(spec.id);
if (entry) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
nodeEntries.delete(spec.id);
}
}
await area.update('node', spec.id);
// a port change must re-run connections — an edge that was skipped because
// its endpoint port didn't exist yet can now be drawn.
if (portsAdded && reconcileConnections) await reconcileConnections();
}
}
// remove dropped GRAPH-managed nodes (+ their connections) — imperatively added
// nodes (NOT in lastPropNodeIds) survive (the power-user escape hatch).
const tracked = new Set(lastPropNodeIds);
for (const id of tracked as any) {
if (!want.includes(id) && nodeInstances.has(id)) {
for (const c of editor.getConnections() as any) {
if (c.source === id || c.target === id) await editor.removeConnection(c.id);
}
await editor.removeNode(id);
nodeInstances.delete(id);
nodeMeta.delete(id);
}
}
lastPropNodeIds = want;
} finally {
programmatic--;
}
};
// Re-entrancy-guarded entry point. If a pass is already running, mark a re-run and
// return — the in-flight pass loops until no further request is pending. Serializing
// overlapping reconciles is what stops the Lit async-context cascade from racing the
// engine into "cannot find node" (which otherwise aborts the declarative graph build).
reconcileNodes = async () => {
if (reconcileNodesRunning) {
reconcileNodesPending = true;
return;
}
reconcileNodesRunning = true;
try {
do {
reconcileNodesPending = false;
await reconcileNodesPass();
} while (reconcileNodesPending);
} finally {
reconcileNodesRunning = false;
}
};
reconcileConnections = async () => {
if (!editor) return;
// Edges come ONLY from the bound graph's `connections` (the single source of
// truth — declarative <Connection> children are gone). Normalize id-defaulting
// (a connection authored without an id gets a stable derived id) so an edge the
// canvas wrote back (carrying the engine id) and a hand-authored edge dedup.
const graphConns = Array.isArray(graph && graph.connections) ? graph.connections : [];
const norm = (spec: any) => {
if (!spec || spec.source == null || spec.target == null) return null;
const srcOut = spec.sourceOutput != null ? spec.sourceOutput : 'out';
const tgtIn = spec.targetInput != null ? spec.targetInput : 'in';
const id = spec.id != null ? spec.id : `${spec.source}:${srcOut}->${spec.target}:${tgtIn}`;
// carry the optional per-edge label/style (F3) through to connMeta → renderConnection.
return {
id,
source: spec.source,
sourceOutput: srcOut,
target: spec.target,
targetInput: tgtIn,
label: spec.label,
stroke: spec.stroke,
dashed: spec.dashed,
type: spec.type
};
};
// cheap style signature so a label/style/type change on an EXISTING edge re-renders it.
const edgeStyleSig = (s: any) => s ? String(s.label) + '|' + String(s.stroke) + '|' + String(s.dashed) + '|' + String(s.type) : '';
const merged = graphConns.map(norm).filter(Boolean);
const want = [];
programmatic++;
try {
for (const spec of merged as any) {
if (!spec || spec.id == null) continue;
want.push(spec.id);
if (connInstances.has(spec.id)) {
// existing edge — relabel/restyle in place if its label/style changed (the
// controlled-graph expectation: edit the bound graph → see the change). Drop the
// render entry so area.update takes the fresh-build path (re-applies label/style).
const changed = edgeStyleSig(connMeta.get(spec.id)) !== edgeStyleSig(spec);
connMeta.set(spec.id, spec);
if (changed) {
const entry = connEntries.get(spec.id);
if (entry) {
entry.dispose();
connEntries.delete(spec.id);
}
await area.update('connection', spec.id);
}
continue;
}
const sourceNode = nodeInstances.get(spec.source);
const targetNode = nodeInstances.get(spec.target);
if (!sourceNode || !targetNode) continue;
// DEFENSIVE: the referenced output/input ports must exist on the live node
// instances before addConnection (Rete throws "source node doesn't have
// output with a key out" otherwise, aborting the loop). An edge may reference
// a port the node's TYPE schema has not flushed yet (a <Port> registered
// after the <NodeType>); skip until the ports exist — reconcileNodes re-runs
// reconcileConnections after a port-schema change, so the edge lands later.
if (!sourceNode.outputs || !sourceNode.outputs[spec.sourceOutput]) continue;
if (!targetNode.inputs || !targetNode.inputs[spec.targetInput]) continue;
const conn = new ClassicPreset.Connection(sourceNode, spec.sourceOutput, targetNode, spec.targetInput);
conn.id = spec.id;
connInstances.set(spec.id, conn);
// seed connMeta BEFORE addConnection so renderConnection sees the label/style on
// its first render (the render fires synchronously inside addConnection's pipe).
connMeta.set(spec.id, spec);
await editor.addConnection(conn);
}
// remove dropped GRAPH-managed edges — imperatively added edges survive.
const tracked = new Set(lastPropConnIds);
for (const id of tracked as any) {
if (!want.includes(id) && connInstances.has(id)) {
await editor.removeConnection(id);
connInstances.delete(id);
connMeta.delete(id);
}
}
lastPropConnIds = want;
} finally {
programmatic--;
}
};
// ─── built-in MiniMap (opt-in :minimap, Phase 42) ────────────────────────────
// An absolute light-DOM SVG overlay (bottom-right) showing a scaled map of every
// node + the current viewport window (outside dimmed), PANNABLE (drag recenters via
// setCenter). The host div is COMPONENT-template DOM (carries the [data-rozie-s-*]
// scope attr → plain scoped CSS positions it); its SVG children are built
// IMPERATIVELY with createElementNS (the connection-renderer discipline) so SVG
// namespacing is identical on all 6 (no SVG-in-template cross-target risk) and styled
// with INLINE attributes (the arrowhead-marker lesson — no scoped-CSS / :root rule
// needed for engine-style DOM). Node dims come from the MEASURED engine node-view
// elements (area.nodeViews.get(id).element offsetW/H — target-agnostic, like the
// render pipe) with a default-rect fallback for Lit's unmeasured first paint.
const measureNodeSize = (id: any) => {
const view = area && area.nodeViews ? area.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
const w = el && el.offsetWidth ? el.offsetWidth : MINIMAP_DEFAULT_NODE_W;
const h = el && el.offsetHeight ? el.offsetHeight : MINIMAP_DEFAULT_NODE_H;
return {
w,
h
};
};
const mkMinimapRect = (x: any, y: any, w: any, h: any, cls: any, fill: any, stroke: any, strokeW: any) => {
const r = document.createElementNS(SVGNS, 'rect');
r.setAttribute('class', cls);
r.setAttribute('x', String(x));
r.setAttribute('y', String(y));
r.setAttribute('width', String(Math.max(w, 0)));
r.setAttribute('height', String(Math.max(h, 0)));
if (fill) r.setAttribute('fill', fill);
if (stroke) {
r.setAttribute('stroke', stroke);
r.setAttribute('stroke-width', String(strokeW || 1));
}
return r;
};
// Rebuild the minimap SVG: node rects (selected highlighted) + a dim mask outside the
// viewport (evenodd punch-out) + the viewport window outline. The bounds union the
// node rects AND the viewport window so the viewport indicator stays in-frame even
// when panned past the nodes. Stores `minimapMap` (the px↔graph mapping the pointer-
// pan handlers read). Cheap (a handful of rects) → a full rebuild per frame is fine.
const redrawMinimap = () => {
minimapRedrawRaf = 0;
if (!minimap || !minimapSvg || !area || !container) return;
const t = area.area.transform;
const k = t.k || 1;
const cw = container.clientWidth || MINIMAP_W;
const ch = container.clientHeight || MINIMAP_H;
// viewport window in GRAPH coords (screen [0,cw]×[0,ch] → graph).
const vx = -t.x / k,
vy = -t.y / k,
vw = cw / k,
vh = ch / k;
const graphNodes = currentGraph().nodes || [];
const selIds = new Set(selectedNodeIds().map((s: any) => String(s)));
const rects = [];
for (const n of graphNodes as any) {
if (!n || n.id == null) continue;
const view = area.nodeViews.get(n.id);
const gx = view ? view.position.x : n.x || 0;
const gy = view ? view.position.y : n.y || 0;
const sz = measureNodeSize(n.id);
rects.push({
gx,
gy,
gw: sz.w,
gh: sz.h,
selected: selIds.has(String(n.id))
});
}
let minX = vx,
minY = vy,
maxX = vx + vw,
maxY = vy + vh;
for (const r of rects as any) {
if (r.gx < minX) minX = r.gx;
if (r.gy < minY) minY = r.gy;
if (r.gx + r.gw > maxX) maxX = r.gx + r.gw;
if (r.gy + r.gh > maxY) maxY = r.gy + r.gh;
}
const padX = (maxX - minX) * 0.1 || 20;
const padY = (maxY - minY) * 0.1 || 20;
minX -= padX;
minY -= padY;
maxX += padX;
maxY += padY;
const bw = maxX - minX || 1;
const bh = maxY - minY || 1;
const scale = Math.min(MINIMAP_W / bw, MINIMAP_H / bh);
const offX = (MINIMAP_W - bw * scale) / 2;
const offY = (MINIMAP_H - bh * scale) / 2;
minimapMap = {
minX,
minY,
scale,
offX,
offY
};
const toMMx = (gx: any) => (gx - minX) * scale + offX;
const toMMy = (gy: any) => (gy - minY) * scale + offY;
minimapSvg.innerHTML = '';
for (const r of rects as any) {
const fill = r.selected ? '#3b82f6' : '#94a3b8';
minimapSvg.appendChild(mkMinimapRect(toMMx(r.gx), toMMy(r.gy), r.gw * scale, r.gh * scale, 'rozie-flow-minimap__node', fill, null, 0));
}
// dim mask OUTSIDE the viewport: full minimap rect with the viewport rect punched
// out (both subpaths same winding → fill-rule:evenodd leaves the viewport a hole).
const mvx = toMMx(vx),
mvy = toMMy(vy),
mvw = vw * scale,
mvh = vh * scale;
const mask = document.createElementNS(SVGNS, 'path');
mask.setAttribute('class', 'rozie-flow-minimap__mask');
mask.setAttribute('fill-rule', 'evenodd');
mask.setAttribute('fill', 'rgba(15, 23, 42, 0.18)');
mask.setAttribute('d', 'M0 0 H' + MINIMAP_W + ' V' + MINIMAP_H + ' H0 Z ' + 'M' + mvx + ' ' + mvy + ' h' + mvw + ' v' + mvh + ' h' + -mvw + ' Z');
minimapSvg.appendChild(mask);
minimapSvg.appendChild(mkMinimapRect(mvx, mvy, mvw, mvh, 'rozie-flow-minimap__viewport', 'none', '#3b82f6', 1.5));
};
// rAF-coalesced scheduler (bridged to the top-level $watch + the engine pipes). No-op
// when :minimap is off (the bridge stays callable everywhere, cheap).
scheduleMinimapRedraw = () => {
if (!minimap || minimapRedrawRaf) return;
if (typeof requestAnimationFrame === 'function') {
minimapRedrawRaf = requestAnimationFrame(redrawMinimap);
} else {
minimapRedrawRaf = 1;
Promise.resolve().then(redrawMinimap);
}
};
// Map a minimap pointer event → graph coords (via the stored minimapMap) → setCenter.
// Pan is a view op → allowed even when readonly, but gated by `pannable` (mirror the
// main-canvas pannable gate). Pointer capture keeps the drag tracking off the box.
const minimapPointerToGraph = (e: any) => {
if (!minimapMap || !minimapHost) return null;
const box = minimapHost.getBoundingClientRect();
const rw = box.width || MINIMAP_W;
const rh = box.height || MINIMAP_H;
const mx = (e.clientX - box.left) * (MINIMAP_W / rw);
const my = (e.clientY - box.top) * (MINIMAP_H / rh);
return {
gx: minimapMap.minX + (mx - minimapMap.offX) / minimapMap.scale,
gy: minimapMap.minY + (my - minimapMap.offY) / minimapMap.scale
};
};
if (minimap && minimapEl) {
minimapHost = minimapEl;
minimapSvg = document.createElementNS(SVGNS, 'svg');
minimapSvg.setAttribute('class', 'rozie-flow-minimap__svg');
minimapSvg.setAttribute('viewBox', '0 0 ' + MINIMAP_W + ' ' + MINIMAP_H);
minimapSvg.setAttribute('preserveAspectRatio', 'none');
minimapHost.appendChild(minimapSvg);
onMinimapPointerDown = (e: any) => {
if (!pannable) return;
const g = minimapPointerToGraph(e);
if (!g) return;
minimapPanning = true;
try {
if (e.target && e.target.setPointerCapture && e.pointerId != null) e.target.setPointerCapture(e.pointerId);
} catch (err: any) {}
e.preventDefault();
e.stopPropagation();
setCenter(g.gx, g.gy, null);
};
onMinimapPointerMove = (e: any) => {
if (!minimapPanning || !pannable) return;
const g = minimapPointerToGraph(e);
if (!g) return;
e.preventDefault();
setCenter(g.gx, g.gy, null);
};
onMinimapPointerUp = (e: any) => {
if (!minimapPanning) return;
minimapPanning = false;
try {
if (e.target && e.target.releasePointerCapture && e.pointerId != null) e.target.releasePointerCapture(e.pointerId);
} catch (err: any) {}
};
minimapHost.addEventListener('pointerdown', onMinimapPointerDown);
minimapHost.addEventListener('pointermove', onMinimapPointerMove);
minimapHost.addEventListener('pointerup', onMinimapPointerUp);
}
// ─── T2.8 NodeToolbar (opt-in :node-toolbar) ─────────────────────────────────
// A floating component-template overlay over the SELECTED node. The host div
// (ref="toolbarEl") carries the [data-rozie-s-*] scope attr → PLAIN scoped CSS positions
// it absolutely (NOT the :root engine-DOM escape hatch — it's component DOM, like the
// marquee box + Controls). It is positioned from the engine node-view ELEMENT's rect
// (which the AreaPlugin transforms for pan/zoom/drag) relative to the canvas container, so
// the area transform is honored automatically — we read getBoundingClientRect() and
// subtract the container's rect (the screenToFlowPosition discipline, but the other way).
// Re-tracked on translated/zoomed/nodetranslated (the pipe branches that schedule the
// minimap redraw) + on every selection emit. OPT-IN (default OFF) → existing demos +
// FlowCanvasScreenshot are pixel-identical (the host div is r-if'd off when :node-toolbar
// is false; selecting a node never pops it).
// Resolve the SINGLE selected node id the toolbar should track: the one picked node when
// EXACTLY one is selected, else null (no toolbar over a multi-select or empty selection —
// a per-node action needs an unambiguous target). Read-only.
const singleSelectedNodeId = () => {
const ids = selectedNodeIds();
return ids.length === 1 ? ids[0] : null;
};
// Position the toolbar host over the tracked node's engine element, or hide it. The
// node-view element is already transformed by the AreaPlugin (pan/zoom/drag), so its
// client rect minus the container's client rect gives the toolbar's container-relative
// px — no manual transform math. Placed just ABOVE the node (bottom of the toolbar at the
// node's top edge); clamped so it never goes off the top of the container.
const trackToolbar = () => {
toolbarTrackRaf = 0;
if (!nodeToolbar || !toolbarHost || !area || !container) return;
const id = toolbarSelectedId;
if (id == null) {
toolbarHost.style.display = 'none';
return;
}
const view = area.nodeViews ? area.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
const rect = el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null;
if (!rect) {
toolbarHost.style.display = 'none';
return;
}
const cbox = container.getBoundingClientRect();
// container-relative px of the node's top-left + width.
const nx = rect.left - cbox.left;
const ny = rect.top - cbox.top;
const tbH = toolbarHost.offsetHeight || 30;
let top = ny - tbH - 6;
if (top < 2) top = ny + rect.height + 6; // flip below if it would clip the top
toolbarHost.style.left = nx + 'px';
toolbarHost.style.top = top + 'px';
toolbarHost.style.display = 'flex';
};
scheduleToolbarTrack = () => {
if (!nodeToolbar || toolbarTrackRaf) return;
if (typeof requestAnimationFrame === 'function') {
toolbarTrackRaf = requestAnimationFrame(trackToolbar);
} else {
toolbarTrackRaf = 1;
Promise.resolve().then(trackToolbar);
}
};
// Recompute the tracked node from the live selection + (re)mount the toolbar content for
// it. Called from the selection emit (a pick/unpick changed the selection). When the
// tracked id changes: if the consumer fills `#toolbar`, (re)render the reactive portal
// with the new node scope; else the default buttons stay put (they read the live tracked
// id at click time, so no re-mount needed). Then reposition.
const syncToolbar = () => {
if (!nodeToolbar || !toolbarHost) return;
const id = singleSelectedNodeId();
if (id === toolbarSelectedId && id == null === (toolbarSelectedId == null)) {
// same target — just reposition (e.g. after a drag).
scheduleToolbarTrack();
return;
}
toolbarSelectedId = id;
if (toolbar && id != null) {
const meta = nodeMeta.get(id) || {
id,
type: undefined,
data: {}
};
const scope = {
node: meta,
emit: toolbarEmit
};
if (toolbarHandle && toolbarHandle.update) {
toolbarHandle.update(scope);
} else {
toolbarHandle = portals.toolbar(toolbarHost, scope);
}
}
scheduleToolbarTrack();
};
syncToolbarSelection = syncToolbar;
// The @node-action emit helper for the toolbar's actions (the EXISTING emit — no new emit,
// T2.8). Carries the tracked node id. Handed to the `#toolbar` slot scope so a consumer
// override can raise its own actions too.
const toolbarEmit = (name: any, detail: any) => {
const id = toolbarSelectedId;
onnodeaction?.({
id,
name,
detail
});
};
if (nodeToolbar && toolbarEl) {
toolbarHost = toolbarEl;
toolbarHost.style.display = 'none';
if (!toolbar) {
// default chrome: delete + duplicate buttons. Static literal labels (Threat
// T-44-06-1: no node-derived text rendered via innerHTML — these are fixed strings
// set via textContent). Both fire @node-action on the tracked node.
toolbarDeleteBtn = document.createElement('button');
toolbarDeleteBtn.type = 'button';
toolbarDeleteBtn.className = 'rozie-flow-toolbar__btn rozie-flow-toolbar__btn--delete';
toolbarDeleteBtn.setAttribute('data-testid', 'flow-toolbar-delete');
toolbarDeleteBtn.setAttribute('aria-label', 'Delete node');
toolbarDeleteBtn.textContent = 'Delete';
toolbarDuplicateBtn = document.createElement('button');
toolbarDuplicateBtn.type = 'button';
toolbarDuplicateBtn.className = 'rozie-flow-toolbar__btn rozie-flow-toolbar__btn--duplicate';
toolbarDuplicateBtn.setAttribute('data-testid', 'flow-toolbar-duplicate');
toolbarDuplicateBtn.setAttribute('aria-label', 'Duplicate node');
toolbarDuplicateBtn.textContent = 'Duplicate';
onToolbarDelete = (e: any) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const id = toolbarSelectedId;
if (id == null) return;
toolbarEmit('delete', {
id
});
toolbarSelectedId = null;
deleteNode(id);
scheduleToolbarTrack();
};
onToolbarDup = (e: any) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const id = toolbarSelectedId;
if (id == null) return;
const newId = duplicateNode(id);
toolbarEmit('duplicate', {
id,
newId
});
scheduleToolbarTrack();
};
// pointerup (NOT click — Rete swallows clicks during node interaction; the §6a item-7
// discipline) on the COMPONENT-template buttons.
toolbarDeleteBtn.addEventListener('pointerup', onToolbarDelete);
toolbarDuplicateBtn.addEventListener('pointerup', onToolbarDup);
toolbarHost.appendChild(toolbarDeleteBtn);
toolbarHost.appendChild(toolbarDuplicateBtn);
}
}
// ─── T2.4 MARQUEE select (mode:'select') ─────────────────────────────────────
// A Figma-style rubber-band box. RESTORE-PATH resolution (RESEARCH Q2/A8): rete's
// internal `Drag` class is NOT exported, so setDragHandler(null) can't be cleanly
// reversed (re-instantiating Drag is impossible). Instead we leave the default pan Drag
// installed and intercept the EMPTY-canvas pointerdown in the CAPTURE phase on the
// container — the default Drag attaches its own bubble-phase pointerdown listener on the
// SAME container (verified rete-area-plugin@2.1.5: setDragHandler → Drag.initialize(
// this.container)), so a capture listener fires FIRST and stopPropagation() blocks pan
// before it starts. The interception is gated PURELY on the live `$props.mode` flag, so
// switching back to 'pan' restores pan with ZERO engine mutation (the persistent
// mode-guard the research preferred). A node drag is UNTOUCHED in both modes: we only act
// when the pointerdown target is NOT inside a node element (empty canvas).
//
// The box is a COMPONENT-TEMPLATE overlay div (ref="marqueeEl") — it carries the
// [data-rozie-s-*] scope attr so a PLAIN scoped rule styles it (NOT the :root engine-DOM
// escape hatch). On release we hit-test every graph node's rect (graph coords via
// area.nodeViews.get(id).position + measureNodeSize) against the box (converted to graph
// coords through the live transform) and nodeSelectApi.select(id, true) each intersector,
// then scheduleSelectionEmit() (the existing @selection-change path — NO new emit).
// Marquee changes only SELECTION (script-state), never the graph model → no history push.
const nodeAt = (target: any) => {
if (!target || typeof target.closest !== 'function') return null;
return target.closest('.rozie-flow-node');
};
// container-relative px → GRAPH coords (the inverse area transform, like
// screenToFlowPosition but already container-relative). px = transform + graph·k.
const containerPxToGraph = (px: any, py: any) => {
const t = area.area.transform;
const k = t.k || 1;
return {
x: (px - t.x) / k,
y: (py - t.y) / k
};
};
const updateMarqueeBox = () => {
if (!marqueeBox || !marqueeStart || !marqueeCur) return;
const x = Math.min(marqueeStart.x, marqueeCur.x);
const y = Math.min(marqueeStart.y, marqueeCur.y);
const w = Math.abs(marqueeCur.x - marqueeStart.x);
const h = Math.abs(marqueeCur.y - marqueeStart.y);
marqueeBox.style.left = x + 'px';
marqueeBox.style.top = y + 'px';
marqueeBox.style.width = w + 'px';
marqueeBox.style.height = h + 'px';
marqueeBox.style.display = 'block';
};
const finishMarquee = () => {
if (!marqueeActive) return;
marqueeActive = false;
if (marqueeBox) marqueeBox.style.display = 'none';
if (!marqueeStart || !marqueeCur || !nodeSelectApi) {
marqueeStart = null;
marqueeCur = null;
return;
}
// box in graph coords (two opposite corners → min/max).
const a = containerPxToGraph(marqueeStart.x, marqueeStart.y);
const b = containerPxToGraph(marqueeCur.x, marqueeCur.y);
const bx0 = Math.min(a.x, b.x),
by0 = Math.min(a.y, b.y);
const bx1 = Math.max(a.x, b.x),
by1 = Math.max(a.y, b.y);
marqueeStart = null;
marqueeCur = null;
const graphNodes = currentGraph().nodes || [];
let first = true;
for (const n of graphNodes as any) {
if (!n || n.id == null) continue;
const view = area.nodeViews.get(n.id);
const gx = view ? view.position.x : n.x || 0;
const gy = view ? view.position.y : n.y || 0;
const sz = measureNodeSize(n.id);
// a node intersects the box if their rects overlap (AABB), in graph coords.
const overlaps = gx < bx1 && gx + sz.w > bx0 && gy < by1 && gy + sz.h > by0;
if (overlaps) {
// accumulate=true keeps every intersector selected (first one replaces the prior
// selection so an old pick doesn't linger; rest accumulate). select(id, accumulate).
nodeSelectApi.select(n.id, !first);
first = false;
}
}
// surface @selection-change once the engine's awaited select() chain has flushed.
scheduleSelectionEmit();
};
if (selectable && !readonly && container && typeof container.addEventListener === 'function') {
marqueeBox = marqueeEl || null;
onCanvasPointerDownCapture = (e: any) => {
// only in select mode, only the EMPTY canvas (not on a node — those still drag), only
// the primary button. A live `$props.mode` read = the persistent mode-guard (restoring
// pan is just this check returning early; no engine mutation).
if (mode !== 'select') return;
if (e && e.button != null && e.button !== 0) return;
if (nodeAt(e.target)) return;
// BLOCK rete's pan Drag (its bubble-phase pointerdown on the same container) — capture
// phase runs first, so stopPropagation() here pre-empts pan; the marquee owns this drag.
e.stopPropagation();
e.preventDefault();
const box = container.getBoundingClientRect();
marqueeActive = true;
marqueeStart = {
x: e.clientX - box.left,
y: e.clientY - box.top
};
marqueeCur = {
x: marqueeStart.x,
y: marqueeStart.y
};
try {
if (container.setPointerCapture && e.pointerId != null) container.setPointerCapture(e.pointerId);
} catch (err: any) {}
updateMarqueeBox();
};
onMarqueePointerMove = (e: any) => {
if (!marqueeActive) return;
const box = container.getBoundingClientRect();
marqueeCur = {
x: e.clientX - box.left,
y: e.clientY - box.top
};
updateMarqueeBox();
};
onMarqueePointerUp = (e: any) => {
if (!marqueeActive) return;
try {
if (container.releasePointerCapture && e && e.pointerId != null) container.releasePointerCapture(e.pointerId);
} catch (err: any) {}
finishMarquee();
};
container.addEventListener('pointerdown', onCanvasPointerDownCapture, true);
container.addEventListener('pointermove', onMarqueePointerMove);
container.addEventListener('pointerup', onMarqueePointerUp);
}
// ─── initial graph: nodes first, then connections (connections reference live
// node instances), then optional fit. Sequenced via an async IIFE so the
// $onMount-returned teardown stays synchronous. ──────────────────────────────
;
(async () => {
// T1.3 — seed the canvas's own last-written graph from the initial bound value so the
// first gesture's snapshot/base reflects the mounted graph (immune to prop re-bind lag).
lastWrittenGraph = $state.snapshot(currentGraph());
await reconcileNodes();
await reconcileConnections();
if (typeof zoom === 'number' && zoom !== 1) {
programmatic++;
try {
await area.area.zoom(zoom);
} finally {
programmatic--;
}
}
if (fitOnMount && editor.getNodes().length) {
programmatic++;
try {
await AreaExtensions.zoomAt(area, editor.getNodes());
} finally {
programmatic--;
}
if (area) {
const k = area.area.transform.k;
if (k !== zoom) zoom = k;
}
}
// draw the minimap once the graph + fit have settled (also redrawn on every
// render / pan / zoom / drag / selection / graph change below).
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
})();
return () => {
if (onCanvasKeydown && keydownContainer && typeof keydownContainer.removeEventListener === 'function') {
try {
keydownContainer.removeEventListener('keydown', onCanvasKeydown);
} catch (e: any) {}
}
if (dragFlushRaf && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(dragFlushRaf);
} catch (e: any) {}
}
dragFlushRaf = 0;
pendingDragPositions.clear();
// T1.1: drop the edge-selection state + its cached <path> reference on teardown.
clearEdgeSelection();
// MiniMap teardown — remove the pointer-pan listeners + cancel a pending redraw.
if (minimapHost) {
if (onMinimapPointerDown) {
try {
minimapHost.removeEventListener('pointerdown', onMinimapPointerDown);
} catch (e: any) {}
}
if (onMinimapPointerMove) {
try {
minimapHost.removeEventListener('pointermove', onMinimapPointerMove);
} catch (e: any) {}
}
if (onMinimapPointerUp) {
try {
minimapHost.removeEventListener('pointerup', onMinimapPointerUp);
} catch (e: any) {}
}
}
if (minimapRedrawRaf && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(minimapRedrawRaf);
} catch (e: any) {}
}
minimapRedrawRaf = 0;
// T2.8 NodeToolbar teardown — remove the default-button listeners, dispose the optional
// `#toolbar` reactive portal handle, and cancel a pending reposition.
if (toolbarDeleteBtn && onToolbarDelete) {
try {
toolbarDeleteBtn.removeEventListener('pointerup', onToolbarDelete);
} catch (e: any) {}
}
if (toolbarDuplicateBtn && onToolbarDup) {
try {
toolbarDuplicateBtn.removeEventListener('pointerup', onToolbarDup);
} catch (e: any) {}
}
if (toolbarHandle && toolbarHandle.dispose) {
try {
toolbarHandle.dispose();
} catch (e: any) {}
}
toolbarHandle = null;
toolbarSelectedId = null;
if (toolbarTrackRaf && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(toolbarTrackRaf);
} catch (e: any) {}
}
toolbarTrackRaf = 0;
// T2.4 Marquee teardown — remove the capture-phase pointerdown guard + window listeners.
if (keydownContainer) {
if (onCanvasPointerDownCapture) {
try {
keydownContainer.removeEventListener('pointerdown', onCanvasPointerDownCapture, true);
} catch (e: any) {}
}
if (onMarqueePointerMove) {
try {
keydownContainer.removeEventListener('pointermove', onMarqueePointerMove);
} catch (e: any) {}
}
if (onMarqueePointerUp) {
try {
keydownContainer.removeEventListener('pointerup', onMarqueePointerUp);
} catch (e: any) {}
}
}
marqueeActive = false;
marqueeStart = null;
marqueeCur = null;
for (const [, entry] of nodeEntries as any) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
}
nodeEntries.clear();
for (const [, entry] of connEntries as any) entry.dispose();
connEntries.clear();
if (area) area.destroy();
};
});
let __rozieWatchInitial_0 = true;
$effect(() => { (() => graph)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } (() => {
// T1.3 — keep the canvas's own last-written graph in sync with an EXTERNAL (non-
// programmatic) consumer change, so undo/redo's "current" state tracks reality (our own
// write-backs / restores set lastWrittenGraph synchronously under the programmatic guard;
// this only refreshes it for a genuine outside edit).
if (selfWriteInFlight) {
// our own commitGraph write echoing back — lastWrittenGraph is already authoritative.
selfWriteInFlight = false;
} else if (!programmatic) {
const c = $state.snapshot(currentGraph());
if (c != null) lastWrittenGraph = c;
}
if (reconcileNodes) {
Promise.resolve(reconcileNodes()).then(() => {
if (reconcileConnections) reconcileConnections();
});
}
// graph changed (nodes added/removed/moved) → refresh the minimap node rects.
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
})(); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { (() => portReg)(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } (() => {
if (reconcileNodes) {
Promise.resolve(reconcileNodes()).then(() => {
if (reconcileConnections) reconcileConnections();
});
}
})(); }); });
let __rozieWatchInitial_2 = true;
$effect(() => { (() => typeReg)(); untrack(() => { if (__rozieWatchInitial_2) { __rozieWatchInitial_2 = false; return; } (() => {
if (reconcileNodes) reconcileNodes();
})(); }); });
let __rozieWatchInitial_3 = true;
$effect(() => { const __watchVal = (() => zoom)(); untrack(() => { if (__rozieWatchInitial_3) { __rozieWatchInitial_3 = false; return; } ((v: any) => {
if (!area || typeof v !== 'number') return;
if (v === area.area.transform.k) return;
programmatic++;
Promise.resolve(area.area.zoom(v)).finally(() => {
programmatic--;
});
})(__watchVal); }); });
</script>
<div class="rozie-flow-canvas" bind:this={canvasEl} tabindex="0" data-rozie-s-cd396d6a>{#if controls}<div class="rozie-flow-controls" data-rozie-s-cd396d6a><button type="button" class="rozie-flow-controls__btn" data-testid="flow-zoom-in" aria-label="Zoom in" onclick={controlZoomIn} data-rozie-s-cd396d6a>+</button><button type="button" class="rozie-flow-controls__btn" data-testid="flow-zoom-out" aria-label="Zoom out" onclick={controlZoomOut} data-rozie-s-cd396d6a>−</button><button type="button" class="rozie-flow-controls__btn" data-testid="flow-fit" aria-label="Fit view" onclick={controlFit} data-rozie-s-cd396d6a>☐</button>{#if marquee}<button type="button" class={["rozie-flow-controls__btn", { 'is-active': mode === 'select' }]} data-testid="flow-mode" aria-label={rozieAttr(mode === 'select' ? 'Select mode (click to pan)' : 'Pan mode (click to select)')} onclick={toggleMode} data-rozie-s-cd396d6a>{rozieDisplay(mode === 'select' ? '▢' : '✥')}</button>{/if}</div>{/if}{#if minimap}<div class="rozie-flow-minimap" bind:this={minimapEl} data-testid="flow-minimap" data-rozie-s-cd396d6a></div>{/if}<div class="rozie-flow-marquee" bind:this={marqueeEl} data-testid="flow-marquee" data-rozie-s-cd396d6a></div>{#if nodeToolbar}<div class="rozie-flow-toolbar" bind:this={toolbarEl} data-testid="flow-toolbar" data-rozie-s-cd396d6a></div>{/if}</div>{@render children?.()}
<style>
:global {
.rozie-flow-canvas[data-rozie-s-cd396d6a] {
width: 100%;
height: 100%;
min-height: 360px;
position: relative;
overflow: hidden;
border-radius: 8px;
background:
radial-gradient(circle, rgba(0, 0, 0, 0.08) 1px, transparent 1px) 0 0 / 20px 20px,
#f7f8fa;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.rozie-flow-controls[data-rozie-s-cd396d6a] {
position: absolute;
left: 10px;
bottom: 10px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 2px;
pointer-events: none;
}
.rozie-flow-controls__btn[data-rozie-s-cd396d6a] {
pointer-events: auto;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
font: 600 16px/1 system-ui, sans-serif;
color: #334155;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
cursor: pointer;
user-select: none;
}
.rozie-flow-controls__btn[data-rozie-s-cd396d6a]:hover { background: #f1f5f9; }
.rozie-flow-controls__btn[data-rozie-s-cd396d6a]:active { background: #e2e8f0; }
.rozie-flow-controls__btn.is-active[data-rozie-s-cd396d6a] { background: #dbeafe; color: #1d4ed8; border-color: #3b82f6; }
.rozie-flow-marquee[data-rozie-s-cd396d6a] {
position: absolute;
display: none;
z-index: 9;
pointer-events: none;
background: rgba(59, 130, 246, 0.12);
border: 1px solid #3b82f6;
border-radius: 2px;
}
.rozie-flow-minimap[data-rozie-s-cd396d6a] {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 10;
width: 200px;
height: 150px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
overflow: hidden;
cursor: pointer;
touch-action: none;
}
.rozie-flow-minimap__svg[data-rozie-s-cd396d6a] { display: block; width: 100%; height: 100%; }
.rozie-flow-toolbar[data-rozie-s-cd396d6a] {
position: absolute;
display: none;
z-index: 11;
gap: 4px;
padding: 3px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
pointer-events: auto;
white-space: nowrap;
}
.rozie-flow-toolbar__btn[data-rozie-s-cd396d6a] {
font: 600 12px/1 system-ui, sans-serif;
color: #334155;
background: #f8fafc;
border: 1px solid rgba(0, 0, 0, 0.14);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
user-select: none;
}
.rozie-flow-toolbar__btn[data-rozie-s-cd396d6a]:hover { background: #eef2f7; }
.rozie-flow-toolbar__btn[data-rozie-s-cd396d6a]:active { background: #e2e8f0; }
.rozie-flow-toolbar__btn--delete[data-rozie-s-cd396d6a] { color: #b91c1c; }
}
:global {
.rozie-flow-canvas .rozie-flow-node {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
min-width: 140px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
user-select: none;
cursor: grab;
font: 13px/1.4 system-ui, sans-serif;
}
.rozie-flow-canvas .rozie-flow-node.is-selected {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5), 0 2px 8px rgba(0, 0, 0, 0.15);
}
.rozie-flow-canvas .rozie-flow-node__title {
padding: 0.5rem 0.75rem;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
}
.rozie-flow-canvas .rozie-flow-node__body { min-width: 0; }
.rozie-flow-canvas .rozie-flow-node__col {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 0;
}
.rozie-flow-canvas .rozie-flow-port {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: #6b7280;
}
.rozie-flow-canvas .rozie-flow-port--output { justify-content: flex-end; }
.rozie-flow-canvas .rozie-flow-socket {
width: 12px;
height: 12px;
border-radius: 50%;
background: #94a3b8;
border: 2px solid #ffffff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
cursor: crosshair;
flex: none;
}
.rozie-flow-canvas .rozie-flow-socket--input { margin-left: -6px; }
.rozie-flow-canvas .rozie-flow-socket--output { margin-right: -6px; }
.rozie-flow-canvas .rozie-flow-socket:hover { background: #3b82f6; }
.rozie-flow-canvas .rozie-flow-node--rows {
display: flex;
flex-direction: column;
}
.rozie-flow-canvas .rozie-flow-node__mid {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
}
.rozie-flow-canvas .rozie-flow-node__row {
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
padding: 0 0.5rem;
}
.rozie-flow-canvas .rozie-flow-port--vertical {
flex-direction: column;
align-items: center;
gap: 0.125rem;
font-size: 0.7rem;
}
.rozie-flow-canvas .rozie-flow-socket--top,
.rozie-flow-canvas .rozie-flow-socket--bottom { margin-left: 0; margin-right: 0; }
.rozie-flow-canvas .rozie-flow-socket--top { margin-top: -6px; }
.rozie-flow-canvas .rozie-flow-socket--bottom { margin-bottom: -6px; }
.rozie-flow-canvas .rozie-flow-connection { position: absolute; }
.rozie-flow-canvas .rozie-flow-connection__svg {
/* display:block is LOAD-BEARING, not cosmetic. An <svg> is display:inline by
default, so the 1px-tall connection SVG sits on the connection element's TEXT
BASELINE — which, with the engine container's default line-height, pushes the
whole path DOWN ~14px. That offset is in screen space (the connection element
is the area-transform origin), so EVERY connection endpoint lands ~14px below
its socket — visibly anchoring connectors at the BOTTOM of each node instead
of on the socket. The socket positions reported by getDOMSocketPosition are
already correct (offsetTop/offsetLeft within the node-view); the inline
baseline is the sole cause of the vertical drift. block (or equivalently
line-height:0 / vertical-align:top on the inline box) removes the baseline gap
so the path renders at its true coordinates. Verified: drops the endpoint→
socket vertical offset from ~13.9px to ~0.1px on all 6 targets. */
display: block;
overflow: visible;
width: 1px;
height: 1px;
pointer-events: none;
}
.rozie-flow-canvas .rozie-flow-connection__path {
fill: none;
stroke: #64748b;
stroke-width: 3px;
pointer-events: auto;
}
.rozie-flow-canvas .rozie-flow-connection__path.is-selected {
stroke: #3b82f6;
stroke-width: 4px;
}
.rozie-flow-canvas .rozie-flow-connection__label {
font: 600 11px system-ui, sans-serif;
fill: #334155;
paint-order: stroke;
stroke: #ffffff;
stroke-width: 3px;
stroke-linejoin: round;
pointer-events: none;
user-select: none;
}
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, EmbeddedViewRef, InjectionToken, TemplateRef, ViewContainerRef, ViewEncapsulation, contentChild, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { NodeEditor, ClassicPreset, Scope } from 'rete';
import { AreaPlugin, AreaExtensions } from 'rete-area-plugin';
import { ConnectionPlugin, Presets as ConnectionPresets } from 'rete-connection-plugin';
import { getDOMSocketPosition, classicConnectionPath } from 'rete-render-utils';
// T2.6 — auto-layout (D-08, verb-only). The 3 deps (rete-auto-arrange-plugin / elkjs
// @0.8.2 / web-worker) are OPTIONAL leaf peers, installed + bundle-smoked on all 6 in
// Plan 00 (the Vite/Angular-AOT/Lit rollup build resolves elkjs to the SYNCHRONOUS
// elk.bundled.js entry — no web-worker resolution error, no manual fallback switch). Only
// a consumer calling autoArrange() pulls these in.
// T2.6 — auto-layout (D-08, verb-only). The 3 deps (rete-auto-arrange-plugin / elkjs
// @0.8.2 / web-worker) are OPTIONAL leaf peers, installed + bundle-smoked on all 6 in
// Plan 00 (the Vite/Angular-AOT/Lit rollup build resolves elkjs to the SYNCHRONOUS
// elk.bundled.js entry — no web-worker resolution error, no manual fallback switch). Only
// a consumer calling autoArrange() pulls these in.
import { AutoArrangePlugin, Presets as ArrangePresets } from 'rete-auto-arrange-plugin';
// ── engine instances — null-lets so typeNeutralize types them `any` (the
// MapLibre `let instance = null` discipline). Rete's NodeEditor / AreaPlugin /
// ConnectionPlugin / DOMSocketPosition carry rich generic Schemes types that the
// loosely-typed .rozie props (any[]) don't satisfy under the strict react/solid/
// lit leaf tsc; routing every engine call through an `any` instance is the
// .rozie-native fix (no lang="ts", no codegen type-aid). These are top-level lets
// referenced from hooks → React auto-hoists each to a useRef. ──
interface NodeCtx {
$implicit: { node: any; selected: any; emit: any };
node: any;
selected: any;
emit: any;
}
interface ToolbarCtx {
$implicit: { node: any; emit: any };
node: any;
emit: any;
}
interface DefaultCtx {}
function __rozieDisplay(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'object') {
try {
return JSON.stringify(v, null, 2);
} catch {
// Circular structure or a non-serialisable value (BigInt nested in an
// object). Degrade to a non-throwing form so the wrap never crashes the
// render — that is the entire point of "safe" interpolation (SPEC-1).
return String(v);
}
}
return String(v);
}
function __rozieAttr(v: unknown): string | null {
return v == null ? null : __rozieDisplay(v);
}
const __rozieTokenRegistry: Map<string, InjectionToken<unknown>> =
((globalThis as Record<string, unknown>).__rozieCtx ??= new Map()) as Map<
string,
InjectionToken<unknown>
>;
function rozieToken(key: string): InjectionToken<unknown> {
let token = __rozieTokenRegistry.get(key);
if (!token) {
token = new InjectionToken<unknown>('rozie:' + key);
__rozieTokenRegistry.set(key, token);
}
return token;
}
@Component({
selector: 'rozie-flow-canvas',
standalone: true,
imports: [NgTemplateOutlet, NgClass],
template: `
<div class="rozie-flow-canvas" #canvasEl tabindex="0">
@if (controls()) {
<div class="rozie-flow-controls">
<button type="button" class="rozie-flow-controls__btn" data-testid="flow-zoom-in" aria-label="Zoom in" (click)="controlZoomIn()">+</button>
<button type="button" class="rozie-flow-controls__btn" data-testid="flow-zoom-out" aria-label="Zoom out" (click)="controlZoomOut()">−</button>
<button type="button" class="rozie-flow-controls__btn" data-testid="flow-fit" aria-label="Fit view" (click)="controlFit()">☐</button>
@if (marquee()) {
<button type="button" class="rozie-flow-controls__btn" [ngClass]="{ 'is-active': mode() === 'select' }" data-testid="flow-mode" [attr.aria-label]="rozieAttr(mode() === 'select' ? 'Select mode (click to pan)' : 'Pan mode (click to select)')" (click)="toggleMode()">{{ rozieDisplay(mode() === 'select' ? '▢' : '✥') }}</button>
}</div>
}@if (minimap()) {
<div class="rozie-flow-minimap" #minimapEl data-testid="flow-minimap"></div>
}<div class="rozie-flow-marquee" #marqueeEl data-testid="flow-marquee"></div>
@if (nodeToolbar()) {
<div class="rozie-flow-toolbar" #toolbarEl data-testid="flow-toolbar"></div>
}</div>
<ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot'])" />
<ng-container #rozie_portalAnchor></ng-container>
`,
styles: [`
.rozie-flow-canvas {
width: 100%;
height: 100%;
min-height: 360px;
position: relative;
overflow: hidden;
border-radius: 8px;
background:
radial-gradient(circle, rgba(0, 0, 0, 0.08) 1px, transparent 1px) 0 0 / 20px 20px,
#f7f8fa;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.rozie-flow-controls {
position: absolute;
left: 10px;
bottom: 10px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 2px;
pointer-events: none;
}
.rozie-flow-controls__btn {
pointer-events: auto;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
font: 600 16px/1 system-ui, sans-serif;
color: #334155;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
cursor: pointer;
user-select: none;
}
.rozie-flow-controls__btn:hover { background: #f1f5f9; }
.rozie-flow-controls__btn:active { background: #e2e8f0; }
.rozie-flow-controls__btn.is-active { background: #dbeafe; color: #1d4ed8; border-color: #3b82f6; }
.rozie-flow-marquee {
position: absolute;
display: none;
z-index: 9;
pointer-events: none;
background: rgba(59, 130, 246, 0.12);
border: 1px solid #3b82f6;
border-radius: 2px;
}
.rozie-flow-minimap {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 10;
width: 200px;
height: 150px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
overflow: hidden;
cursor: pointer;
touch-action: none;
}
.rozie-flow-minimap__svg { display: block; width: 100%; height: 100%; }
.rozie-flow-toolbar {
position: absolute;
display: none;
z-index: 11;
gap: 4px;
padding: 3px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
pointer-events: auto;
white-space: nowrap;
}
.rozie-flow-toolbar__btn {
font: 600 12px/1 system-ui, sans-serif;
color: #334155;
background: #f8fafc;
border: 1px solid rgba(0, 0, 0, 0.14);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
user-select: none;
}
.rozie-flow-toolbar__btn:hover { background: #eef2f7; }
.rozie-flow-toolbar__btn:active { background: #e2e8f0; }
.rozie-flow-toolbar__btn--delete { color: #b91c1c; }
::ng-deep .rozie-flow-canvas .rozie-flow-node {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
min-width: 140px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
user-select: none;
cursor: grab;
font: 13px/1.4 system-ui, sans-serif;
}
::ng-deep .rozie-flow-canvas .rozie-flow-node.is-selected {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5), 0 2px 8px rgba(0, 0, 0, 0.15);
}
::ng-deep .rozie-flow-canvas .rozie-flow-node__title {
padding: 0.5rem 0.75rem;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
}
::ng-deep .rozie-flow-canvas .rozie-flow-node__body { min-width: 0; }
::ng-deep .rozie-flow-canvas .rozie-flow-node__col {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 0;
}
::ng-deep .rozie-flow-canvas .rozie-flow-port {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: #6b7280;
}
::ng-deep .rozie-flow-canvas .rozie-flow-port--output { justify-content: flex-end; }
::ng-deep .rozie-flow-canvas .rozie-flow-socket {
width: 12px;
height: 12px;
border-radius: 50%;
background: #94a3b8;
border: 2px solid #ffffff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
cursor: crosshair;
flex: none;
}
::ng-deep .rozie-flow-canvas .rozie-flow-socket--input { margin-left: -6px; }
::ng-deep .rozie-flow-canvas .rozie-flow-socket--output { margin-right: -6px; }
::ng-deep .rozie-flow-canvas .rozie-flow-socket:hover { background: #3b82f6; }
::ng-deep .rozie-flow-canvas .rozie-flow-node--rows {
display: flex;
flex-direction: column;
}
::ng-deep .rozie-flow-canvas .rozie-flow-node__mid {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
}
::ng-deep .rozie-flow-canvas .rozie-flow-node__row {
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
padding: 0 0.5rem;
}
::ng-deep .rozie-flow-canvas .rozie-flow-port--vertical {
flex-direction: column;
align-items: center;
gap: 0.125rem;
font-size: 0.7rem;
}
::ng-deep .rozie-flow-canvas .rozie-flow-socket--top,
::ng-deep .rozie-flow-canvas .rozie-flow-socket--bottom { margin-left: 0; margin-right: 0; }
::ng-deep .rozie-flow-canvas .rozie-flow-socket--top { margin-top: -6px; }
::ng-deep .rozie-flow-canvas .rozie-flow-socket--bottom { margin-bottom: -6px; }
::ng-deep .rozie-flow-canvas .rozie-flow-connection { position: absolute; }
::ng-deep .rozie-flow-canvas .rozie-flow-connection__svg {
/* display:block is LOAD-BEARING, not cosmetic. An <svg> is display:inline by
default, so the 1px-tall connection SVG sits on the connection element's TEXT
BASELINE — which, with the engine container's default line-height, pushes the
whole path DOWN ~14px. That offset is in screen space (the connection element
is the area-transform origin), so EVERY connection endpoint lands ~14px below
its socket — visibly anchoring connectors at the BOTTOM of each node instead
of on the socket. The socket positions reported by getDOMSocketPosition are
already correct (offsetTop/offsetLeft within the node-view); the inline
baseline is the sole cause of the vertical drift. block (or equivalently
line-height:0 / vertical-align:top on the inline box) removes the baseline gap
so the path renders at its true coordinates. Verified: drops the endpoint→
socket vertical offset from ~13.9px to ~0.1px on all 6 targets. */
display: block;
overflow: visible;
width: 1px;
height: 1px;
pointer-events: none;
}
::ng-deep .rozie-flow-canvas .rozie-flow-connection__path {
fill: none;
stroke: #64748b;
stroke-width: 3px;
pointer-events: auto;
}
::ng-deep .rozie-flow-canvas .rozie-flow-connection__path.is-selected {
stroke: #3b82f6;
stroke-width: 4px;
}
::ng-deep .rozie-flow-canvas .rozie-flow-connection__label {
font: 600 11px system-ui, sans-serif;
fill: #334155;
paint-order: stroke;
stroke: #ffffff;
stroke-width: 3px;
stroke-linejoin: round;
pointer-events: none;
user-select: none;
}
`],
providers: [
{
provide: rozieToken('rete:canvas'),
useFactory: () => { const __rozieCtxHost = inject(forwardRef(() => FlowCanvas)); return ({
// Register/replace a node TYPE template. `spec` carries an optional
// `bodyRenderer(host, { node })` — the render-by-type projection (mounted per graph
// node of this type into the engine body host, see renderNode). Whole-object replace.
registerType: (type: any, spec: any) => {
if (type != null) __rozieCtxHost.typeReg.set({
...__rozieCtxHost.typeReg(),
[type]: spec
});
},
// Drop a type on <NodeType> unmount (whole-object replace).
unregisterType: (type: any) => {
const t = {
...__rozieCtxHost.typeReg()
};
delete t[type];
__rozieCtxHost.typeReg.set(t);
},
// A <Port> registers a port against its TYPE + side. Stored in the flat portReg
// under a UNIQUE per-port key `type::side::key` so registration is order-independent
// AND concurrency-safe: two <Port>s of the same type addTypePort in one React commit,
// and a pure `{ ...portReg, [uniqueKey]: port }` write (functional setState) merges
// both (an array read-modify-write under one type key would clobber). buildNode reads
// the type's portReg entries on every run regardless of mount order. The unique key
// also makes a re-fired addTypePort (late Lit context) idempotent — same key, same value.
// `side` is derived by <Port> from which of output=/input= is set (output⇒'output', input⇒'input');
// `portType` carries the port type that drives validate-types + the typed-port color.
// `position` (F2) is the socket's VISUAL placement (left|right|top|bottom; default by
// side) — drives the render-pipe socket layout + the connection-anchor axis.
addTypePort: (type: any, side: any, key: any, portType: any, label: any, multiple: any, position: any) => {
if (type == null || key == null) return;
const portKey = type + '::' + side + '::' + key;
__rozieCtxHost.portReg.set({
...__rozieCtxHost.portReg(),
[portKey]: {
type,
side,
key,
portType,
label,
multiple,
position
}
});
},
// Render-by-type callback target. Returns the engine-created body host div for a
// graph node (nodeEntries.get(nodeId).body). The render-by-type projection mounts
// the node's TYPE template `#body` INTO this host via $portals — the Wave-0 A3
// finding (a Lit child cannot relocate its own shadow <slot> across the boundary),
// so the body is projected by the parent reusing the $portals host discipline.
bodyHostFor: (nodeId: any) => {
const entry = __rozieCtxHost.nodeEntries.get(nodeId);
return entry ? entry.body : null;
}
}); },
},
],
})
export class FlowCanvas {
/**
* The single source of truth (two-way `r-model`) — `{ nodes: [{ id, type, x, y, data? }], connections: [{ id?, source, sourceOutput?, target, targetInput?, label?, stroke?, dashed? }] }`. A node's `type` selects its `<NodeType>` template (render-by-type + port schema); `data` is the opaque payload handed to that type's `#body` scope. The canvas writes back a FRESH top-level object on every drag (x/y) and connect/disconnect (connections) — immutable applyNodeChanges style. `sourceOutput`/`targetInput` default to `out`/`in`; a missing connection `id` is derived from the endpoints.
* @example
* <FlowCanvas r-model:graph="graph" :validate-types="true" />
*/
graph = model<Record<string, any>>((() => ({
nodes: [],
connections: []
}))());
/**
* Automatic typed-socket validation (default ON). When `true`, the canvas resolves each endpoint's port type from the per-`<NodeType>` `<Port type>` schema and auto-rejects a type-mismatched connection (firing `connection-rejected`). `canConnect` survives as the optional custom-rule override that runs in addition. Set `false` for pure-`canConnect` (type as metadata only).
*/
validateTypes = input<boolean>(true);
/**
* The viewport zoom level (two-way `r-model`). Scroll/pinch writes the new zoom back through the model (echo-guarded against the wrapper's own programmatic zooms); a consumer write zooms the live area. There is deliberately no `zoom`/`zoomed` emit — a same-named emit collides with the model on Vue and Angular — so the two-way binding is the channel for zoom changes.
*/
zoom = model<number>(1);
/**
* Whether the canvas can be panned by dragging the background (applied at construction). Set `false` to detach the area's drag handler.
*/
pannable = input<boolean>(true);
/**
* Whether the canvas can be zoomed by scroll/pinch (applied at construction). Set `false` to detach the area's zoom handler.
*/
zoomable = input<boolean>(true);
/**
* Whether nodes can be selected (click; ctrl-click to accumulate). Reflected as the `selected` flag in the `<NodeType>` `#body` scope and surfaced to the consumer via the `@selection-change` event.
*/
selectable = input<boolean>(true);
/**
* Read-only viewer mode — no node drag, no connection editing, and no selection. View-only zoom/fit (Controls, the `zoomTo`/`zoomToFit` verbs) stay enabled.
*/
readonly = input<boolean>(false);
/**
* Minimum zoom level — the lower bound of the area's zoom restrictor. `0` disables the bound.
*/
minZoom = input<number>(0.1);
/**
* Maximum zoom level — the upper bound of the area's zoom restrictor. `0` disables the bound.
*/
maxZoom = input<number>(4);
/**
* Snap-to-grid size in pixels for node dragging. `0` turns snapping off.
*/
snapGrid = input<number>(0);
/**
* When selectable, hold Ctrl to add to the current selection instead of replacing it.
*/
accumulateOnCtrl = input<boolean>(true);
/**
* The bezier curvature of connection paths (`classicConnectionPath`).
*/
curvature = input<number>(0.3);
/**
* After the initial graph mounts, pan/zoom the viewport to fit all nodes (`AreaExtensions.zoomAt`).
*/
fitOnMount = input<boolean>(true);
/**
* Render the built-in Controls overlay — a zoom in / zoom out / fit-view button cluster (the React Flow `<Controls/>` parity). The buttons drive the same zoom/fit path as the `zoomTo`/`zoomToFit` handle verbs (clamped to `minZoom`/`maxZoom`) and stay enabled in `readonly`. Opt out with `:controls="false"`.
*/
controls = input<boolean>(true);
/**
* Render the built-in MiniMap overlay (opt-in, default OFF — the React Flow `<MiniMap/>` parity) — an absolute SVG panel (bottom-right) showing a scaled map of every node (sized from the measured engine node-view dims) plus the current viewport window (the area outside dimmed). It is pannable: dragging the minimap recenters the main viewport (via `setCenter`). Evaluated at construction, like `pannable`/`zoomable`/`controls` — set it at mount time.
*/
minimap = input<boolean>(false);
/**
* Connection-validation predicate `(conn) => boolean`, receiving the normalized candidate connection `{ source, sourceOutput, target, targetInput }`. Return `false` to reject the connection — no edge is committed, no ghost path is drawn, and `connection-rejected` fires. Runs in addition to the automatic `:validate-types` check (the custom-rule override) and gates all connection paths uniformly (drag-to-connect, imperative `addConnection`, graph reconcile). Absent/`null` imposes no custom rule.
*/
canConnect = input<((...args: unknown[]) => unknown) | null>(null);
/**
* Undo/redo, on by default. Every gesture (drag, connect, disconnect, delete) pushes ONE capped (~100) snapshot of the bound graph (nodes incl. x/y + connections; not the viewport), and `undo()`/`redo()` plus Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, and Ctrl/Cmd+Y restore it through the two-way `graph` model (echo-guarded). One gesture = one undo step; a fresh edit after an undo discards the redo branch. Opt out with `:history="false"` (the snapshot stack stays empty and the verbs no-op).
*/
history = input<boolean>(true);
/**
* Two-way interaction mode (`r-model`) — the Figma-style pan ↔ select toggle, `'pan'` (default) or `'select'`. In `'pan'` an empty-canvas drag pans the viewport (unchanged). In `'select'` an empty-canvas drag draws a rubber-band marquee box that multi-selects the intersecting nodes (surfacing `@selection-change`). A node drag still drags the node in both modes — only the empty-canvas drag changes. The canvas writes it back when the built-in mode button toggles (see `marquee`).
*/
mode = model<string>('pan');
/**
* Render the 4th Controls button — the pan ↔ select mode toggle (it two-way-writes `mode`). Default OFF so the default Controls overlay keeps its three buttons. The marquee behavior works whenever `mode === 'select'` regardless of this flag (a consumer can drive `mode` directly); this only governs the built-in button.
*/
marquee = input<boolean>(false);
/**
* Render the opt-in NodeToolbar (default OFF) — a floating toolbar over the single selected node (positioned from the engine node-view rect + the area transform, re-tracked on pan/zoom/drag). Default content is Delete (cascading controlled-graph `deleteNode`) + Duplicate (clone the node spec at an offset with a new id into a fresh `graph` object); both fire `@node-action` (`name: 'delete' | 'duplicate'`). Override the content by filling the `#toolbar` reactive slot.
*/
nodeToolbar = input<boolean>(false);
typeReg = signal({});
portReg = signal({});
canvasEl = viewChild<ElementRef<HTMLDivElement>>('canvasEl');
minimapEl = viewChild<ElementRef<HTMLDivElement>>('minimapEl');
marqueeEl = viewChild<ElementRef<HTMLDivElement>>('marqueeEl');
toolbarEl = viewChild<ElementRef<HTMLDivElement>>('toolbarEl');
edgeClick = output<unknown>({ alias: 'edge-click' });
edgeSelected = output<unknown>({ alias: 'edge-selected' });
selectionChange = output<unknown>({ alias: 'selection-change' });
connectEnd = output<unknown>({ alias: 'connect-end' });
nodeAction = output<unknown>({ alias: 'node-action' });
connectionRejected = output<unknown>({ alias: 'connection-rejected' });
connectionCreated = output<unknown>({ alias: 'connection-created' });
connectionRemoved = output<unknown>({ alias: 'connection-removed' });
nodePicked = output<unknown>({ alias: 'node-picked' });
nodeMoved = output<unknown>({ alias: 'node-moved' });
translated = output<unknown>();
contextMenu = output<unknown>({ alias: 'context-menu' });
@ContentChild('node', { read: TemplateRef }) nodeTpl?: TemplateRef<NodeCtx>;
@ContentChild('toolbar', { read: TemplateRef }) toolbarTpl?: TemplateRef<ToolbarCtx>;
@ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
private _portalViews = new Set<EmbeddedViewRef<unknown>>();
private _portalAnchor = viewChild('rozie_portalAnchor', { read: ViewContainerRef });
private _nodeTpl = contentChild('node', { read: TemplateRef });
private _toolbarTpl = contentChild('toolbar', { read: TemplateRef });
private __rozieDestroyRef = inject(DestroyRef);
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_1 = true;
private __rozieWatchInitial_2 = true;
private __rozieWatchInitial_3 = true;
constructor() {
effect(() => { const __watchVal = (() => this.graph())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => {
// T1.3 — keep the canvas's own last-written graph in sync with an EXTERNAL (non-
// programmatic) consumer change, so undo/redo's "current" state tracks reality (our own
// write-backs / restores set lastWrittenGraph synchronously under the programmatic guard;
// this only refreshes it for a genuine outside edit).
if (this.selfWriteInFlight) {
// our own commitGraph write echoing back — lastWrittenGraph is already authoritative.
this.selfWriteInFlight = false;
} else if (!this.programmatic) {
const c = structuredClone(this.currentGraph());
if (c != null) this.lastWrittenGraph = c;
}
if (this.reconcileNodes) {
Promise.resolve(this.reconcileNodes()).then(() => {
if (this.reconcileConnections) this.reconcileConnections();
});
}
// graph changed (nodes added/removed/moved) → refresh the minimap node rects.
if (this.scheduleMinimapRedraw) this.scheduleMinimapRedraw();
})(); }); });
effect(() => { const __watchVal = (() => this.portReg())(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } (() => {
if (this.reconcileNodes) {
Promise.resolve(this.reconcileNodes()).then(() => {
if (this.reconcileConnections) this.reconcileConnections();
});
}
})(); }); });
effect(() => { const __watchVal = (() => this.typeReg())(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } (() => {
if (this.reconcileNodes) this.reconcileNodes();
})(); }); });
effect(() => { const __watchVal = (() => this.zoom())(); untracked(() => { if (this.__rozieWatchInitial_3) { this.__rozieWatchInitial_3 = false; return; } ((v: any) => {
if (!this.area || typeof v !== 'number') return;
if (v === this.area.area.transform.k) return;
this.programmatic++;
Promise.resolve(this.area.area.zoom(v)).finally(() => {
this.programmatic--;
});
})(__watchVal); }); });
}
ngAfterViewInit() {
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portals = {
node: (container: HTMLElement, scope: { node: unknown; selected: unknown; emit: unknown }): ReactivePortalHandle => {
const tpl = this._nodeTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-node', 'cd396d6a');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return {
update: (s: unknown): void => {
Object.assign(view.context as object, s as object);
view.detectChanges();
},
dispose: (): void => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
},
};
},
toolbar: (container: HTMLElement, scope: { node: unknown; emit: unknown }): ReactivePortalHandle => {
const tpl = this._toolbarTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-toolbar', 'cd396d6a');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return {
update: (s: unknown): void => {
Object.assign(view.context as object, s as object);
view.detectChanges();
},
dispose: (): void => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
},
};
},
};
const __selectable = this.selectable();
const __readonly = this.readonly();
const __minZoom = this.minZoom();
const __maxZoom = this.maxZoom();
const __snapGrid = this.snapGrid();
const container = this.canvasEl()?.nativeElement;
this.lastPropNodeIds = [];
this.lastPropConnIds = [];
this.editor = new NodeEditor();
this.area = new AreaPlugin(container);
this.connectionPlugin = new ConnectionPlugin();
this.connectionPlugin.addPreset(ConnectionPresets.classic.setup());
// Resolve a port's VISUAL position (F2) from the per-TYPE port schema (portReg, keyed
// `type::side::key`), defaulting by DIRECTION (input → left, output → right) for exact
// back-compat. DEFINED HERE inside $onMount (NOT top level) so its $data.portReg read
// lowers on React to the live `_portRegRef.current`, not a stale-empty mount-time
// closure (the portTypeOf discipline). Used by both the socket-anchor offset below and
// renderNode's socket layout.
// Resolve a port's VISUAL position (F2) from the per-TYPE port schema (portReg, keyed
// `type::side::key`), defaulting by DIRECTION (input → left, output → right) for exact
// back-compat. DEFINED HERE inside $onMount (NOT top level) so its $data.portReg read
// lowers on React to the live `_portRegRef.current`, not a stale-empty mount-time
// closure (the portTypeOf discipline). Used by both the socket-anchor offset below and
// renderNode's socket layout.
const resolvePortPosition = (type: any, side: any, key: any) => {
const entry = type != null && key != null ? this.portReg()[type + '::' + side + '::' + key] : null;
const p = entry && entry.position != null ? entry.position : null;
if (p === 'left' || p === 'right' || p === 'top' || p === 'bottom') return p;
return side === 'input' ? 'left' : 'right';
};
// DOM-based socket position watcher — feeds connection-path redraw + the
// ConnectionPlugin's drag-to-connect hit-testing. A CUSTOM `offset` (F2): the rete
// default shifts the anchor 12px OUTWARD on the X axis only (`x + 12·(input?−1:1)`) —
// correct for left/right, wrong for top/bottom. We resolve each socket's visual
// position and shift on the matching axis (±x for left/right — IDENTICAL to the default,
// so the rete-flow-align cell stays green; ±y for top/bottom). The position is looked up
// live via nodeMeta→type→portReg, so it tracks late-registered ports.
// DOM-based socket position watcher — feeds connection-path redraw + the
// ConnectionPlugin's drag-to-connect hit-testing. A CUSTOM `offset` (F2): the rete
// default shifts the anchor 12px OUTWARD on the X axis only (`x + 12·(input?−1:1)`) —
// correct for left/right, wrong for top/bottom. We resolve each socket's visual
// position and shift on the matching axis (±x for left/right — IDENTICAL to the default,
// so the rete-flow-align cell stays green; ±y for top/bottom). The position is looked up
// live via nodeMeta→type→portReg, so it tracks late-registered ports.
const SOCKET_SHIFT = 12;
const socketOffset = (position: any, nodeId: any, side: any, key: any) => {
const meta = this.nodeMeta.get(nodeId);
const p = meta ? resolvePortPosition(meta.type, side, key) : side === 'input' ? 'left' : 'right';
if (p === 'top') return {
x: position.x,
y: position.y - SOCKET_SHIFT
};
if (p === 'bottom') return {
x: position.x,
y: position.y + SOCKET_SHIFT
};
if (p === 'left') return {
x: position.x - SOCKET_SHIFT,
y: position.y
};
return {
x: position.x + SOCKET_SHIFT,
y: position.y
};
};
this.socketWatcher = getDOMSocketPosition({
offset: socketOffset
});
this.editor.use(this.area);
this.area.use(this.connectionPlugin);
// ── T2.5 RECONNECT coalescing pipe (D-08 reconnectable edges, D-03 one-gesture-one-entry) ──
// `connectionpick` / `connectiondrop` are emitted on the ConnectionPlugin's OWN scope (they
// are NOT editor signals like connectioncreated/removed, nor area signals like nodepicked),
// so they must be observed via a pipe attached DIRECTLY to `connectionPlugin` — they do not
// propagate into editor.addPipe / area.addPipe. Grabbing an already-connected input socket
// fires connectionpick, then the classic preset removes the old edge + (on drop over a new
// socket) adds a new one — a remove+add pair that would push TWO history entries (Pitfall 2).
// We open a reconnect-in-flight window on connectionpick (capturing the PRE-gesture snapshot
// ONCE) and close it on connectiondrop (pushing that single snapshot iff the gesture actually
// changed the graph) — so the whole reconnect is ONE undoable step.
// ── T2.5 RECONNECT coalescing pipe (D-08 reconnectable edges, D-03 one-gesture-one-entry) ──
// `connectionpick` / `connectiondrop` are emitted on the ConnectionPlugin's OWN scope (they
// are NOT editor signals like connectioncreated/removed, nor area signals like nodepicked),
// so they must be observed via a pipe attached DIRECTLY to `connectionPlugin` — they do not
// propagate into editor.addPipe / area.addPipe. Grabbing an already-connected input socket
// fires connectionpick, then the classic preset removes the old edge + (on drop over a new
// socket) adds a new one — a remove+add pair that would push TWO history entries (Pitfall 2).
// We open a reconnect-in-flight window on connectionpick (capturing the PRE-gesture snapshot
// ONCE) and close it on connectiondrop (pushing that single snapshot iff the gesture actually
// changed the graph) — so the whole reconnect is ONE undoable step.
this.connectionPlugin.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectionpick') {
// Open the coalesce window + capture the pre-gesture snapshot once. Gated on
// !programmatic + history (a restore-driven engine op must not record history). A
// re-pick while a close is pending cancels the pending close (the gesture continues).
if (!this.programmatic && this.history() !== false) {
this.reconnectInFlight++;
this.reconnectPreSnapshot = this.snapshotCurrent();
this.reconnectDidWriteBack = false;
this.reconnectCloseScheduled = false;
}
} else if (context.type === 'connectiondrop') {
// The gesture ended. CRITICAL ORDERING: the classic preset emits `connectiondrop`
// BEFORE the editor's `connectionremoved` / `connectioncreated` signals fire (the
// pseudo-connection is dropped, THEN the real add/remove run — verified in the event
// trace: drop → connectioncreate → connectioncreated → connectionremove →
// connectionremoved). So we must NOT close the window synchronously here, or the
// trailing writeBacks would run with inFlight=0 and each push its own (wrong) history
// entry. Instead DEFER the close to a macrotask (setTimeout 0), which runs after all
// the synchronous + microtask writeBack signals have settled. The window stays open
// across the remove+add (both suppress their per-event push, setting
// reconnectDidWriteBack), then closeReconnectGesture pushes the SINGLE pre-gesture
// snapshot iff the graph actually changed. Re-entrant picks can't desync because the
// close is gated on a one-shot scheduled flag.
this.scheduleReconnectClose();
// ── T2.7 CONNECT-END-ON-PANE (D-07, pure emit) ──
// A drag that STARTED on an output socket and ENDED on empty canvas (no target
// socket, no connection created) surfaces `@connect-end { source, sourceOutput,
// position }` so the consumer can run its OWN node-picker / create-node flow at the
// drop point (the n8n "drag off a port → drop on the pane → pick a node" UX). The
// component owns ONLY this hook — it creates NO node and shows NO picker (D-07,
// consumer-owns-creation, exactly like screenToFlowPosition + the palette drop).
// Detection: `socket == null` (released over the pane, not a socket) && `created ==
// false` (no edge was made) && `initial.side === 'output'` (we only surface OUTPUT-
// started drags — an input-started drag off the pane has no "source output" to seed
// a downstream node from, and the reconnect path already owns input-endpoint drags).
// Position = `area.area.pointer` (the AreaPlugin's live pointer, ALREADY in graph
// coords — the same origin screenToFlowPosition projects into), so no client→graph
// projection is needed; we still fall back to screenToFlowPosition over a raw
// clientX/clientY if a future plugin build stops tracking area.area.pointer. Gated on
// !programmatic so a restore/imperative-driven drop never emits. NO node is created.
const cd = context.data;
if (cd && !cd.socket && cd.created === false && cd.initial && cd.initial.side === 'output' && !this.programmatic) {
let pos: any = null;
const inner = this.area && this.area.area ? this.area.area : null;
if (inner && inner.pointer && typeof inner.pointer.x === 'number' && typeof inner.pointer.y === 'number') {
pos = {
x: inner.pointer.x,
y: inner.pointer.y
};
}
if ((!pos || typeof pos.x !== 'number' || typeof pos.y !== 'number') && cd.initial && cd.initial.element && typeof cd.initial.element.getBoundingClientRect === 'function') {
// Fallback: project the last-known pointer client coords through the shipped
// screenToFlowPosition (graph-coord inverse of the area transform). The drop event
// carries no pointer; use the source socket element's center as a degraded anchor.
const r = cd.initial.element.getBoundingClientRect();
pos = this.screenToFlowPosition(r.left + r.width / 2, r.top + r.height / 2);
}
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
this.connectEnd.emit({
source: cd.initial.nodeId,
sourceOutput: cd.initial.key,
position: {
x: pos.x,
y: pos.y
}
});
}
}
}
return context;
});
// The socket-position watcher (and, conceptually, our vanilla "render plugin")
// must attach to a CHILD scope of the area — `attach` calls
// `scope.parentScope(BaseAreaPlugin)`, which walks UP one level, so the scope's
// parent must BE the area. Attaching to `area` itself fails ("actual parent is
// not instance of type") because area's parent is the NodeEditor. So we add a
// minimal child Scope and attach the watcher to it. Rete forwards every area
// signal (render/nodetranslated/unmount/…) into this child's signal, so the
// watcher sees socket renders + node moves and recomputes socket positions.
// The socket-position watcher (and, conceptually, our vanilla "render plugin")
// must attach to a CHILD scope of the area — `attach` calls
// `scope.parentScope(BaseAreaPlugin)`, which walks UP one level, so the scope's
// parent must BE the area. Attaching to `area` itself fails ("actual parent is
// not instance of type") because area's parent is the NodeEditor. So we add a
// minimal child Scope and attach the watcher to it. Rete forwards every area
// signal (render/nodetranslated/unmount/…) into this child's signal, so the
// watcher sees socket renders + node moves and recomputes socket positions.
this.renderScope = new Scope('rozie-vanilla-render');
this.area.use(this.renderScope);
this.socketWatcher.attach(this.renderScope);
// ── T2.6 auto-layout (D-08, verb-only) ──
// Wire the AutoArrangePlugin (elkjs classic preset) so the top-level autoArrange() verb
// can run a layered relayout on demand. area.use(arrange) installs it as an area-scope
// plugin; arrange.layout() mutates the engine node positions directly (calls area.translate
// internally). The verb reads the arranged positions BACK into a FRESH $model.graph (the
// controlled-graph contract — the engine is never the source of truth). NO auto-trigger —
// the consumer calls autoArrange() (the MapLibre verb-first stance).
// ── T2.6 auto-layout (D-08, verb-only) ──
// Wire the AutoArrangePlugin (elkjs classic preset) so the top-level autoArrange() verb
// can run a layered relayout on demand. area.use(arrange) installs it as an area-scope
// plugin; arrange.layout() mutates the engine node positions directly (calls area.translate
// internally). The verb reads the arranged positions BACK into a FRESH $model.graph (the
// controlled-graph contract — the engine is never the source of truth). NO auto-trigger —
// the consumer calls autoArrange() (the MapLibre verb-first stance).
this.arrange = new AutoArrangePlugin();
this.arrange.addPreset(ArrangePresets.classic.setup());
this.area.use(this.arrange);
// ── selection (selectableNodes) ──
// Capture the returned handle ({ select(id, accumulate), unselect(id) }) so the T2.4
// marquee can PROGRAMMATICALLY select each intersecting node (select(id, true) =
// accumulate). The handle is null when selection is off (readonly / !selectable), in
// which case the marquee branch no-ops.
// ── selection (selectableNodes) ──
// Capture the returned handle ({ select(id, accumulate), unselect(id) }) so the T2.4
// marquee can PROGRAMMATICALLY select each intersecting node (select(id, true) =
// accumulate). The handle is null when selection is off (readonly / !selectable), in
// which case the marquee branch no-ops.
if (__selectable && !__readonly) {
this.selector = AreaExtensions.selector();
this.nodeSelectApi = AreaExtensions.selectableNodes(this.area, this.selector, {
accumulating: this.accumulateOnCtrl() ? AreaExtensions.accumulateOnCtrl() : {
active: () => false
}
});
}
// raise the picked node above its siblings.
// raise the picked node above its siblings.
AreaExtensions.simpleNodesOrder(this.area);
// ── zoom clamp (restrictor) ──
// ── zoom clamp (restrictor) ──
const min = typeof __minZoom === 'number' && __minZoom > 0 ? __minZoom : 0;
const max = typeof __maxZoom === 'number' && __maxZoom > 0 ? __maxZoom : 0;
if (min || max) {
AreaExtensions.restrictor(this.area, {
scaling: {
min: min || 0.01,
max: max || 100
}
});
}
// ── snap-to-grid ──
// ── snap-to-grid ──
if (typeof __snapGrid === 'number' && __snapGrid > 0) {
AreaExtensions.snapGrid(this.area, {
size: __snapGrid,
dynamic: true
});
}
// ── interaction toggles ──
// ── interaction toggles ──
if (!this.pannable()) this.area.area.setDragHandler(null);
if (!this.zoomable()) this.area.area.setZoomHandler(null);
// ── Delete / Backspace key → cascading delete of the selected node(s) (Win 1) ──
// Attached to the engine container ($refs.canvasEl, which carries tabindex="0" in
// the template so it can receive key focus) rather than `document`: the listener
// lives INSIDE the Lit shadow root alongside the canvas, so a canvas-focused key
// reaches it on Lit too (a `:target="document"` listener does not reliably see
// shadow-scoped focus across all 6 — the canvas-element listener is the robust
// cross-target path). Gated on selectable && !readonly. We guard against deleting
// while focus is in a node-body text field (INPUT/TEXTAREA/contenteditable) so
// typing in a node never nukes it. The listener is removed in the teardown.
// ── Delete / Backspace key → cascading delete of the selected node(s) (Win 1) ──
// Attached to the engine container ($refs.canvasEl, which carries tabindex="0" in
// the template so it can receive key focus) rather than `document`: the listener
// lives INSIDE the Lit shadow root alongside the canvas, so a canvas-focused key
// reaches it on Lit too (a `:target="document"` listener does not reliably see
// shadow-scoped focus across all 6 — the canvas-element listener is the robust
// cross-target path). Gated on selectable && !readonly. We guard against deleting
// while focus is in a node-body text field (INPUT/TEXTAREA/contenteditable) so
// typing in a node never nukes it. The listener is removed in the teardown.
if (__selectable && !__readonly && container && typeof container.addEventListener === 'function') {
this.onCanvasKeydown = (e: any) => {
if (!e) return;
const t = e.target;
// Focus-guard (verbatim with the Delete branch): never act while focus is in a
// node-body text field (INPUT/TEXTAREA/contenteditable) — Ctrl+Z must reach the
// browser's native text undo there, and Delete must not nuke the node.
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
// ── T1.3 — Undo / Redo keybinds (D-02). Ctrl/Cmd+Z → undo; Ctrl/Cmd+Shift+Z and
// Ctrl/Cmd+Y → redo. Gated on the SAME focus-guard as Delete. preventDefault so the
// browser's page-level undo doesn't also fire. `metaKey` covers macOS Cmd. ──
if ((e.ctrlKey || e.metaKey) && !e.altKey) {
const k = typeof e.key === 'string' ? e.key.toLowerCase() : '';
if (k === 'z' && !e.shiftKey) {
e.preventDefault();
this.undo();
return;
}
if (k === 'z' && e.shiftKey || k === 'y') {
e.preventDefault();
this.redo();
return;
}
}
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
const ids = this.selectedNodeIds();
if (ids.length > 0) {
e.preventDefault();
for (const id of ids as any) this.deleteNode(id);
return;
}
// T1.1 — EDGE DELETE (D-08). No node is picked but an edge is selected → remove
// exactly that edge via the controlled-graph write-back (the disconnect path: a
// fresh `{ ...g, connections: filtered }` object), then clear the selection. The
// wrapper's own $watch(graph) reconcile reaps the live engine connection (the
// single removal path — we do NOT also call editor.removeConnection, which would
// race the reconcile into "cannot find connection", mirroring deleteNode). Node
// delete takes precedence (handled above); this only runs when nothing's picked.
if (this.selectedConnId != null) {
e.preventDefault();
const id = this.selectedConnId;
this.clearEdgeSelection();
this.writeBackConnectionRemoved(id);
}
};
this.keydownContainer = container;
container.addEventListener('keydown', this.onCanvasKeydown);
}
// ─────────────────────────────────────────────────────────────────────────
// THE VANILLA RENDER PIPE. Intercepts the AreaPlugin's render/unmount signals.
// ALWAYS returns context (returning undefined would halt the signal chain and
// break the ConnectionPlugin / socket watcher downstream).
// ─────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────
// THE VANILLA RENDER PIPE. Intercepts the AreaPlugin's render/unmount signals.
// ALWAYS returns context (returning undefined would halt the signal chain and
// break the ConnectionPlugin / socket watcher downstream).
// ─────────────────────────────────────────────────────────────────────────
this.area.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'render') {
const data = context.data;
if (data.type === 'node') renderNode(data.element, data.payload);else if (data.type === 'connection') renderConnection(data.element, data.payload, data.start, data.end);
// data.type === 'socket' (our own re-emitted signals) falls through
// untouched so the ConnectionPlugin + socketWatcher consume them.
} else if (context.type === 'unmount') {
cleanupElement(context.data.element);
}
return context;
});
// ── node renderer ──
// Fills the engine-created nodeView element with: input sockets, the body
// (consumer `node` portal fragment OR default chrome), and output sockets.
// Re-render (area.update('node', id)) reuses the same element → update in place.
// NOTE: the engine-node parameter is `reteNode`, NOT `node` — on Svelte the
// `$slots.node` slot lowers to a top-level `const node`, and a parameter named
// `node` here would SHADOW it, so `if ($slots.node)` would read the (always-
// truthy) engine node and wrongly take the portal branch even when the slot is
// unfilled (dropping the default-chrome title). The cross-target slot-name ==
// local-binding shadow trap.
// ── node renderer ──
// Fills the engine-created nodeView element with: input sockets, the body
// (consumer `node` portal fragment OR default chrome), and output sockets.
// Re-render (area.update('node', id)) reuses the same element → update in place.
// NOTE: the engine-node parameter is `reteNode`, NOT `node` — on Svelte the
// `$slots.node` slot lowers to a top-level `const node`, and a parameter named
// `node` here would SHADOW it, so `if ($slots.node)` would read the (always-
// truthy) engine node and wrongly take the portal branch even when the slot is
// unfilled (dropping the default-chrome title). The cross-target slot-name ==
// local-binding shadow trap.
const renderNode = (element: any, reteNode: any) => {
// a (re)render means node DOM exists / changed → refresh the minimap (its node
// rects measure these elements; coalesced, so calling it on every render is cheap,
// and it covers Lit's measure-after-first-paint).
if (this.scheduleMinimapRedraw) this.scheduleMinimapRedraw();
const id = reteNode.id;
const meta = this.nodeMeta.get(id) || {
id,
type: undefined,
data: {}
};
const existing = this.nodeEntries.get(id);
const selected = reteNode.selected === true;
// default-chrome fallback label (only when a node's type has no #body template).
const chromeLabel = meta.data && meta.data.label != null ? String(meta.data.label) : meta.type != null ? String(meta.type) : '';
if (existing && existing.element === element) {
// in-place update — refresh chrome + reactive portal scope, leave sockets.
existing.box.classList.toggle('is-selected', selected);
if (existing.handle) {
existing.handle.update({
node: meta,
selected,
emit: existing.emit
});
} else if (existing.titleEl) {
existing.titleEl.textContent = chromeLabel;
}
return;
}
// fresh build
element.innerHTML = '';
const box = document.createElement('div');
box.className = 'rozie-flow-node' + (selected ? ' is-selected' : '');
const body = document.createElement('div');
body.className = 'rozie-flow-node__body';
// ── socket layout (F2: position-aware) ───────────────────────────────────────
// Bucket the node's ports by VISUAL position (default input→left, output→right).
// When NO port is top/bottom (every pre-F2 graph), render the EXACT classic
// [inputsCol | body | outputsCol] 3-column structure — byte-identical DOM, so the
// FlowCanvasScreenshot pixel baseline is untouched. A node that declares ANY top/
// bottom port gets the 3-ROW structure (topRow / midRow[left|body|right] / bottomRow).
const socketDisposers = [];
const portEntries = [];
for (const key of Object.keys(reteNode.inputs) as any) portEntries.push({
side: 'input',
key,
position: resolvePortPosition(meta.type, 'input', key)
});
for (const key of Object.keys(reteNode.outputs) as any) portEntries.push({
side: 'output',
key,
position: resolvePortPosition(meta.type, 'output', key)
});
const hasVertical = portEntries.some((p: any) => p.position === 'top' || p.position === 'bottom');
if (!hasVertical) {
// CLASSIC left/right layout — byte-for-byte identical to pre-F2 (pixel-baseline safe).
const inputsCol = document.createElement('div');
inputsCol.className = 'rozie-flow-node__col rozie-flow-node__col--in';
const outputsCol = document.createElement('div');
outputsCol.className = 'rozie-flow-node__col rozie-flow-node__col--out';
box.appendChild(inputsCol);
box.appendChild(body);
box.appendChild(outputsCol);
element.appendChild(box);
for (const p of portEntries as any) {
renderSocketInto(p.position === 'right' ? outputsCol : inputsCol, reteNode, p.side, p.key, p.position, socketDisposers);
}
} else {
// VERTICAL-capable 3-row layout (only when a top/bottom port exists).
box.classList.add('rozie-flow-node--rows');
const topRow = document.createElement('div');
topRow.className = 'rozie-flow-node__row rozie-flow-node__row--top';
const midRow = document.createElement('div');
midRow.className = 'rozie-flow-node__mid';
const leftCol = document.createElement('div');
leftCol.className = 'rozie-flow-node__col rozie-flow-node__col--in';
const rightCol = document.createElement('div');
rightCol.className = 'rozie-flow-node__col rozie-flow-node__col--out';
const bottomRow = document.createElement('div');
bottomRow.className = 'rozie-flow-node__row rozie-flow-node__row--bottom';
midRow.appendChild(leftCol);
midRow.appendChild(body);
midRow.appendChild(rightCol);
box.appendChild(topRow);
box.appendChild(midRow);
box.appendChild(bottomRow);
element.appendChild(box);
for (const p of portEntries as any) {
const zone = p.position === 'top' ? topRow : p.position === 'bottom' ? bottomRow : p.position === 'right' ? rightCol : leftCol;
renderSocketInto(zone, reteNode, p.side, p.key, p.position, socketDisposers);
}
}
// emit per-node event helper handed to the slot scope so a consumer node body
// can raise a custom event carrying its id (e.g. a delete button).
const emit = (name: any, detail: any) => this.nodeAction.emit({
id,
name,
detail
});
const entry = {
element,
box,
body,
handle: null,
bodyHandle: null,
titleEl: null,
bodyMoved: false,
emit,
socketDisposers
};
// ── RENDER-BY-TYPE: select the body by `node.type` ──────────────────────────
// 1) the node's TYPE template (typeReg[type].bodyRenderer) — the primary path
// (41-03 <NodeType><template #body>); 2) the low-level `#node` portal slot
// (consumer switches on node.type itself — escape hatch); 3) default chrome.
const typeSpec = meta.type != null ? this.typeReg()[meta.type] : null;
if (typeSpec && typeof typeSpec.bodyRenderer === 'function') {
// RENDER-BY-TYPE callback path. The <NodeType> cannot relocate its OWN <slot>
// across the Lit shadow boundary (Wave-0 A3), so the PARENT projects the body
// here from its own render scope: the type's registered bodyRenderer(host, scope)
// mounts the type's `#body` portal INTO the engine `body` div (a FRESH render
// root per node — no framework DOM relocation, the Phase-37 D-04 trap avoided).
// nodeEntries must exist before the callback runs (bodyHostFor reads it), so
// register first. The graph node's `data` flows in as scope → one template per
// type renders every instance of that type.
this.nodeEntries.set(id, entry);
entry.bodyHandle = typeSpec.bodyRenderer(body, {
node: meta,
selected,
emit
});
entry.bodyMoved = true;
return;
}
if ((this.nodeTpl ?? this.templates()?.['node'])) {
// reactive multi-instance portal — one handle per node, re-rendered in
// place on meta change (the MapLibre marker discipline). Low-level escape
// hatch: the consumer switches on node.type inside the single `#node` slot.
entry.handle = portals.node(body, {
node: meta,
selected,
emit
});
} else {
// default chrome: a title bar (the type name / data.label).
const title = document.createElement('div');
title.className = 'rozie-flow-node__title';
title.textContent = chromeLabel;
body.appendChild(title);
entry.titleEl = title;
}
this.nodeEntries.set(id, entry);
};
// Render ONE socket into a zone and, crucially, EMIT its render signal so the
// ConnectionPlugin + position watcher register it. `position` is the socket's visual
// placement (left|right|top|bottom). For left/right the DOM is byte-identical to pre-F2
// (the classic horizontal port row); top/bottom get a vertical port (socket above its
// label) + a `--<position>` socket class so the socket straddles the matching edge.
// Render ONE socket into a zone and, crucially, EMIT its render signal so the
// ConnectionPlugin + position watcher register it. `position` is the socket's visual
// placement (left|right|top|bottom). For left/right the DOM is byte-identical to pre-F2
// (the classic horizontal port row); top/bottom get a vertical port (socket above its
// label) + a `--<position>` socket class so the socket straddles the matching edge.
const renderSocketInto = (zone: any, reteNode: any, side: any, key: any, position: any, socketDisposers: any) => {
const port = (side === 'input' ? reteNode.inputs : reteNode.outputs)[key];
if (!port) return;
const vertical = position === 'top' || position === 'bottom';
const row = document.createElement('div');
row.className = 'rozie-flow-port rozie-flow-port--' + side + (vertical ? ' rozie-flow-port--vertical' : '');
const socketEl = document.createElement('div');
socketEl.className = 'rozie-flow-socket rozie-flow-socket--' + side + (vertical ? ' rozie-flow-socket--' + position : '');
socketEl.setAttribute('data-testid', 'socket');
const label = document.createElement('span');
label.className = 'rozie-flow-port__label';
label.textContent = port.label != null ? String(port.label) : key;
// CLASSIC: inputs socket-first, outputs label-first (byte-identical to pre-F2).
// VERTICAL: socket-first (the socket sits on the edge, label tucked inward).
if (side === 'input' || vertical) {
row.appendChild(socketEl);
row.appendChild(label);
} else {
row.appendChild(label);
row.appendChild(socketEl);
}
zone.appendChild(row);
// LOAD-BEARING: announce the socket to the rest of the area's child plugins.
// 'render' lets the ConnectionPlugin register the socket as a drag anchor.
this.area.emit({
type: 'render',
data: {
type: 'socket',
side,
key,
nodeId: reteNode.id,
element: socketEl,
payload: {
socket: port.socket
}
}
});
// ALSO LOAD-BEARING (the socket-position contract): getDOMSocketPosition measures +
// stores a socket's DOM position ONLY on a 'rendered' socket signal — the render-plugin
// lifecycle's post-mount phase. Our vanilla pipe creates + appends the socket DOM
// synchronously, so we fire 'rendered' right after 'render'. WITHOUT IT the position
// store stays empty, every socketWatcher.listen() callback reads null, and NO
// connection path (committed OR drag preview) is ever drawn.
this.area.emit({
type: 'rendered',
data: {
type: 'socket',
side,
key,
nodeId: reteNode.id,
element: socketEl,
payload: {
socket: port.socket
}
}
});
socketDisposers.push(() => {
this.area.emit({
type: 'unmount',
data: {
element: socketEl
}
});
});
};
// ── hand-written edge-type path generators (T1.2, D-01) ───────────────────────
// `rete-render-utils` ships ONLY `classicConnectionPath` (bezier) + `loopConnectionPath`;
// step/smoothstep/straight do NOT exist in any installed rete package, so they are
// hand-written here matching React-Flow's `step|smoothstep|straight` semantics. Each is a
// PURE `(start, end) → d-string` function over `{x,y}` graph-screen points; the `d` is
// composed from numeric coords + literal SVG commands and written via setAttribute (never
// innerHTML — no injection, T-44-02-2 accept). The default branch stays
// `classicConnectionPath` → byte-identical bezier (pixel-baseline safe).
// straight: a single line, no curvature.
// ── hand-written edge-type path generators (T1.2, D-01) ───────────────────────
// `rete-render-utils` ships ONLY `classicConnectionPath` (bezier) + `loopConnectionPath`;
// step/smoothstep/straight do NOT exist in any installed rete package, so they are
// hand-written here matching React-Flow's `step|smoothstep|straight` semantics. Each is a
// PURE `(start, end) → d-string` function over `{x,y}` graph-screen points; the `d` is
// composed from numeric coords + literal SVG commands and written via setAttribute (never
// innerHTML — no injection, T-44-02-2 accept). The default branch stays
// `classicConnectionPath` → byte-identical bezier (pixel-baseline safe).
// straight: a single line, no curvature.
const straightPath = (s: any, e: any) => `M ${s.x} ${s.y} L ${e.x} ${e.y}`;
// step: orthogonal HV-VH with a mid-X break.
// step: orthogonal HV-VH with a mid-X break.
const stepPath = (s: any, e: any) => {
const mx = (s.x + e.x) / 2;
return `M ${s.x} ${s.y} L ${mx} ${s.y} L ${mx} ${e.y} L ${e.x} ${e.y}`;
};
// smoothstep: step with rounded corners (radius r, clamped to half the shorter leg).
// smoothstep: step with rounded corners (radius r, clamped to half the shorter leg).
const smoothstepPath = (s: any, e: any, r = 8) => {
const mx = (s.x + e.x) / 2;
const dir = e.y >= s.y ? 1 : -1;
const rr = Math.min(r, Math.abs(mx - s.x), Math.abs(e.y - s.y) / 2);
return [`M ${s.x} ${s.y}`, `L ${mx - rr} ${s.y}`, `Q ${mx} ${s.y} ${mx} ${s.y + dir * rr}`, `L ${mx} ${e.y - dir * rr}`, `Q ${mx} ${e.y} ${mx + rr} ${e.y}`, `L ${e.x} ${e.y}`].join(' ');
};
// ── connection renderer ──
// Mounts an <svg><path> and redraws it whenever either endpoint socket moves
// (real connection) OR the dragged pointer moves (user drag-to-connect pseudo).
//
// A USER DRAG renders a *pseudo-connection* (rete-connection-plugin): the render
// signal carries a literal pointer coordinate (`endPointer`/`data.end` when
// dragging FROM an output, `startPointer`/`data.start` when dragging FROM an
// input) alongside a payload with ONE DANGLING endpoint — `target:''`/
// `targetInput:''` (output-side drag) or `source:''`/`sourceOutput:''`
// (input-side drag). The dangling side has no socket to watch, so its coordinate
// MUST come from the pointer; the live side stays watcher-driven. The
// ConnectionPlugin re-emits this render on EVERY pointermove with a fresh pointer
// — so the same pseudo element is re-rendered repeatedly and the dangling
// coordinate must update in place (no SVG rebuild, no listener re-subscribe).
// ── connection renderer ──
// Mounts an <svg><path> and redraws it whenever either endpoint socket moves
// (real connection) OR the dragged pointer moves (user drag-to-connect pseudo).
//
// A USER DRAG renders a *pseudo-connection* (rete-connection-plugin): the render
// signal carries a literal pointer coordinate (`endPointer`/`data.end` when
// dragging FROM an output, `startPointer`/`data.start` when dragging FROM an
// input) alongside a payload with ONE DANGLING endpoint — `target:''`/
// `targetInput:''` (output-side drag) or `source:''`/`sourceOutput:''`
// (input-side drag). The dangling side has no socket to watch, so its coordinate
// MUST come from the pointer; the live side stays watcher-driven. The
// ConnectionPlugin re-emits this render on EVERY pointermove with a fresh pointer
// — so the same pseudo element is re-rendered repeatedly and the dangling
// coordinate must update in place (no SVG rebuild, no listener re-subscribe).
const renderConnection = (element: any, connection: any, startPointer: any, endPointer: any) => {
const __curvature = this.curvature();
const id = connection.id;
// A side is dangling when its node id OR its port key is empty/nullish.
const srcDangling = !connection.source || !connection.sourceOutput;
const tgtDangling = !connection.target || !connection.targetInput;
// RE-RENDER of the SAME element (the pseudo on each pointermove): do NOT rebuild
// the SVG or re-subscribe listeners (would leak) — just update the dangling
// side's coordinate and redraw. This replaces the old unconditional early-return
// that froze the preview line. For a REAL connection updatePointer is a no-op,
// so a re-render of a committed edge is byte-for-byte the old early-return.
const prev = this.connEntries.get(id);
if (prev && prev.element === element) {
prev.updatePointer(startPointer, endPointer);
return;
}
element.innerHTML = '';
element.classList.add('rozie-flow-connection');
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'rozie-flow-connection__svg');
// ── direction arrowhead (Win 3) ─────────────────────────────────────────────
// A <defs><marker> in THIS connection's own <svg>, referenced by `marker-end` so
// the triangle sits at the path END (the input socket — the path runs output→input,
// so marker-end points INTO the target). The marker id is UNIQUE per connection
// (`rozie-arrow-<id>`) so two edges' markers never collide on a shared document id
// (url(#id) resolves to the first match otherwise). The def lives in the SAME
// per-edge <svg> inside the SAME shadow root as the path, so url(#id) resolves
// within that root — no cross-root reference (Lit-safe). markerUnits="userSpaceOnUse"
// keeps a constant pixel size under the area zoom transform. Inline fill (#64748b,
// matching the connection stroke) is the cross-target-safe choice — no scoped-CSS /
// :root rule needed for the marker DOM. The marker does NOT change the path `d`
// or the socket geometry (the rete-flow-align cell stays green) — redraw() only
// sets the head's `orient` and a `stroke-dasharray` that visually trims the last
// ARROW_LEN of the stroke so the line meets the head without poking through it.
const markerId = 'rozie-arrow-' + String(id);
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
marker.setAttribute('id', markerId);
// Sized in userSpaceOnUse (constant pixels under zoom). A 12×10 head reads
// clearly at default zoom (the old 6×6 was barely visible). refX=12 sits the
// TIP exactly at the path-end vertex (the socket); refY=5 centers it. `orient`
// is recomputed per-redraw from the path's final-segment tangent, and the
// visible stroke is trimmed back to the arrow base, so the head points along
// the edge's actual approach AND the line meets it cleanly — see redraw().
marker.setAttribute('markerWidth', '13');
marker.setAttribute('markerHeight', '10');
marker.setAttribute('refX', '12');
marker.setAttribute('refY', '5');
marker.setAttribute('orient', 'auto');
marker.setAttribute('markerUnits', 'userSpaceOnUse');
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
arrow.setAttribute('class', 'rozie-flow-connection__arrow');
arrow.setAttribute('d', 'M0,0 L12,5 L0,10 Z');
arrow.setAttribute('fill', '#64748b');
marker.appendChild(arrow);
defs.appendChild(marker);
svg.appendChild(defs);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('class', 'rozie-flow-connection__path');
path.setAttribute('marker-end', 'url(#' + markerId + ')');
svg.appendChild(path);
// ── T1.1 edge-select listener (D-08) ─────────────────────────────────────────
// Attach an IMPERATIVE pointerup listener on the engine-DOM <path> (NOT a template
// `@` — the path is engine-created; NOT click — Rete swallows it; NOT pointerdown —
// Rete stopPropagations it: the Phase-41 connector landmine, playbook §6a item 7).
// Gated on `selectable && !readonly` (mirrors node delete) and ONLY for COMMITTED
// edges — a drag-to-connect pseudo (either side dangling) carries no stable id and
// must not be selectable. `selectEdge` reads the id back off the closure (the
// committed connection.id == the graph connection id — conn.id = spec.id at build),
// so it always matches what `writeBackConnectionRemoved` filters. `.stop` keeps the
// pointerup from reaching the area's pan/background handling beneath the path.
if (__selectable && !__readonly && !srcDangling && !tgtDangling) {
path.style.cursor = 'pointer';
path.addEventListener('pointerup', (e: any) => {
if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
this.selectEdge(connection.id, path);
});
}
// ── per-edge label + styling (F3) ────────────────────────────────────────────
// The consumer's connection spec ({ id, source, …, label?, stroke?, dashed? }) is kept
// in connMeta keyed by id (the connection-side analog of nodeMeta). A committed edge
// resolves its label/style here; a drag-preview pseudo (no committed id) has none.
// Styling is applied as INLINE attributes (the arrowhead-marker discipline — engine DOM
// carries no scope attr); a `label` renders an SVG <text> at the path midpoint (white
// halo via paint-order for legibility over the line), repositioned in redraw().
const emeta = this.connMeta.get(connection.id) || null;
if (emeta) {
if (emeta.stroke != null) {
const s = String(emeta.stroke);
path.setAttribute('stroke', s);
arrow.setAttribute('fill', s);
}
if (emeta.dashed === true) path.setAttribute('stroke-dasharray', '7 5');
}
// ── resolved edge type (T1.2) ────────────────────────────────────────────────
// The consumer-supplied `connection.type` selects a path generator. ALLOWLIST it
// (`bezier|step|smoothstep|straight`); any other/absent value falls through to the
// bezier default — no dynamic path-fn lookup keyed on the raw string, no eval
// (T-44-02-1 mitigate). A dangling drag-preview pseudo has no committed connMeta
// entry, so it stays bezier too.
const rawType = emeta && emeta.type != null ? String(emeta.type) : 'bezier';
const edgeType = rawType === 'step' || rawType === 'smoothstep' || rawType === 'straight' ? rawType : 'bezier';
// Arrowhead geometry (redraw): the head is oriented along the path's tangent
// over its LAST `ARROW_LEN` (angled for a descending edge, aligned with where
// the line actually meets the head — unlike the chord, which diverges from the
// bezier's flattened end tangent), and the visible stroke is trimmed back to
// the arrow base on SOLID edges so the line's width can't poke past the
// tapering tip (the "square tip"). Dashed edges keep their pattern untrimmed.
const ARROW_LEN = 12;
const isDashed = !!(emeta && emeta.dashed === true);
let labelEl: any = null;
const edgeLabel = emeta && emeta.label != null ? String(emeta.label) : null;
if (edgeLabel) {
labelEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
labelEl.setAttribute('class', 'rozie-flow-connection__label');
labelEl.setAttribute('text-anchor', 'middle');
labelEl.setAttribute('dominant-baseline', 'middle');
labelEl.textContent = edgeLabel;
svg.appendChild(labelEl);
}
element.appendChild(svg);
let start: any = null;
let end: any = null;
const curvature = typeof __curvature === 'number' ? __curvature : 0.3;
const redraw = () => {
if (!start || !end) return;
// branch on the resolved edge type; default (bezier/unknown) stays
// classicConnectionPath UNCHANGED → byte-identical bezier output.
const d = edgeType === 'step' ? stepPath(start, end) : edgeType === 'smoothstep' ? smoothstepPath(start, end) : edgeType === 'straight' ? straightPath(start, end) : classicConnectionPath([start, end], curvature);
path.setAttribute('d', d);
// Orient the head and trim the visible stroke back to the arrow base (solid
// edges) so the line meets the head without poking through the tip.
// getTotalLength/getPointAtLength are SVGGeometryElement methods unavailable
// in a non-rendering env (jsdom) → guard and fall back to orient='auto' / untrimmed.
let pathLen = 0;
try {
pathLen = path.getTotalLength();
} catch (e: any) {
pathLen = 0;
}
if (pathLen > ARROW_LEN + 1) {
// BACKWARD edge (target socket left of the source socket): the classic
// bezier overshoots both control points, looping the curve into tight
// u-turns right at the sockets, so a sampled local tangent is unstable and
// the head curls. Use the path's TRUE end tangent (orient='auto' — the
// horizontal entry into the input) for a stable, standard arrow. FORWARD
// edges keep the final-ARROW_LEN tangent, which follows a descending edge
// AND aligns with where the line meets the head.
if (end.x < start.x) {
marker.setAttribute('orient', 'auto');
} else {
const tip = path.getPointAtLength(pathLen);
const back = path.getPointAtLength(pathLen - ARROW_LEN);
marker.setAttribute('orient', String(Math.atan2(tip.y - back.y, tip.x - back.x) * 180 / Math.PI));
}
if (!isDashed) path.setAttribute('stroke-dasharray', pathLen - ARROW_LEN + ' ' + pathLen);
} else {
marker.setAttribute('orient', 'auto');
if (!isDashed) path.removeAttribute('stroke-dasharray');
}
if (labelEl) {
labelEl.setAttribute('x', String((start.x + end.x) / 2));
labelEl.setAttribute('y', String((start.y + end.y) / 2));
}
};
// Seed the DANGLING side's coordinate from the pointer FIRST — socketWatcher
// .listen() synchronously replays the current socket snapshot on subscribe, so
// seeding before subscribing the live side means redraw() already has the
// dangling coordinate and the preview line draws immediately on the first render.
if (srcDangling && startPointer) start = startPointer;
if (tgtDangling && endPointer) end = endPointer;
// LIVE endpoints stay watcher-driven (exactly as before the fix — committed
// connections behave byte-for-byte). DANGLING endpoints subscribe NO listener
// (it would never fire — there is no socket); their coordinate is the pointer.
let un1: any = null;
let un2: any = null;
if (!srcDangling) un1 = this.socketWatcher.listen(connection.source, 'output', connection.sourceOutput, (p: any) => {
start = p;
redraw();
});
if (!tgtDangling) un2 = this.socketWatcher.listen(connection.target, 'input', connection.targetInput, (p: any) => {
end = p;
redraw();
});
// Update only the DANGLING side(s) from a fresh pointer on each subsequent
// render call. For a REAL connection (neither side dangling) this is a no-op,
// so committed connections never have a pointer override and keep behaving
// exactly as before.
const updatePointer = (sp: any, ep: any) => {
let moved = false;
if (srcDangling && sp) {
start = sp;
moved = true;
}
if (tgtDangling && ep) {
end = ep;
moved = true;
}
if (moved) redraw();
};
// Draw once now: a pseudo seeded with an initial pointer (+ its live side
// already replayed) draws immediately; a real connection whose sockets are
// already known also draws (idempotent — same `d` the listeners just set).
redraw();
this.connEntries.set(id, {
element,
updatePointer,
dispose: () => {
try {
un1 && un1();
} catch (e: any) {}
try {
un2 && un2();
} catch (e: any) {}
}
});
};
// ── unmount cleanup (keyed by the engine element area hands back) ──
// ── unmount cleanup (keyed by the engine element area hands back) ──
const cleanupElement = (element: any) => {
for (const [id, entry] of this.nodeEntries as any) {
if (entry.element === element) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
this.nodeEntries.delete(id);
return;
}
}
for (const [id, entry] of this.connEntries as any) {
if (entry.element === element) {
entry.dispose();
this.connEntries.delete(id);
return;
}
}
};
// Resolve a single port's TYPE for the validation pipe: look up the live node's
// `type` (via nodeMeta) then the portReg entry keyed `type::side::key`. Returns the
// portType string or null (null on either side ⇒ no type constraint ⇒ allow). DEFINED
// HERE (inside $onMount) — NOT at top level — so its $data.portReg read lowers on React
// to the live `_portRegRef.current` rather than a stale-empty closure snapshot captured
// when this once-only mount effect first ran (the cross-type-reject-didn't-fire bug).
// Resolve a single port's TYPE for the validation pipe: look up the live node's
// `type` (via nodeMeta) then the portReg entry keyed `type::side::key`. Returns the
// portType string or null (null on either side ⇒ no type constraint ⇒ allow). DEFINED
// HERE (inside $onMount) — NOT at top level — so its $data.portReg read lowers on React
// to the live `_portRegRef.current` rather than a stale-empty closure snapshot captured
// when this once-only mount effect first ran (the cross-type-reject-didn't-fire bug).
const portTypeOf = (nodeId: any, side: any, key: any) => {
const meta = this.nodeMeta.get(nodeId);
if (!meta || meta.type == null || key == null) return null;
const entry = this.portReg()[meta.type + '::' + side + '::' + key];
return entry ? entry.portType : null;
};
// ─── connection-validation gate (D2/D3 — typed-socket validation + override) ──
// Cancels Rete's cancellable `connectioncreate` pre-event when the connection is
// rejected. TWO independent reject paths, both surfacing `connection-rejected`:
// 1. AUTOMATIC typed validation (`:validate-types`, default ON, D3 option a):
// resolve src/tgt port TYPE from the per-TYPE port schema (via each endpoint
// node's `type`); if both are non-null and UNEQUAL → reject. A null on either
// side (untyped port / unknown type) imposes no constraint → allow.
// 2. `canConnect` OVERRIDE (Phase-40 contract, SURVIVES): a consumer custom rule;
// runs IN ADDITION to (after) the automatic check; returning false rejects.
// Cancelling makes editor.addConnection return false WITHOUT pushing the connection
// or emitting `connectioncreated` — no ghost edge, no `connection-created`. Gates
// drag-to-connect, imperative addConnection, and reconcile uniformly. Both predicates
// are PURE (no $data write / engine call) — reads only. The block (return undefined)
// stays UNCONDITIONAL so rejection is enforced on every path; only the EMIT is
// echo-guarded (a programmatic reconcile the rule would reject must not surface as a
// user-facing rejection — mirrors connection-created/connection-removed).
// ─── connection-validation gate (D2/D3 — typed-socket validation + override) ──
// Cancels Rete's cancellable `connectioncreate` pre-event when the connection is
// rejected. TWO independent reject paths, both surfacing `connection-rejected`:
// 1. AUTOMATIC typed validation (`:validate-types`, default ON, D3 option a):
// resolve src/tgt port TYPE from the per-TYPE port schema (via each endpoint
// node's `type`); if both are non-null and UNEQUAL → reject. A null on either
// side (untyped port / unknown type) imposes no constraint → allow.
// 2. `canConnect` OVERRIDE (Phase-40 contract, SURVIVES): a consumer custom rule;
// runs IN ADDITION to (after) the automatic check; returning false rejects.
// Cancelling makes editor.addConnection return false WITHOUT pushing the connection
// or emitting `connectioncreated` — no ghost edge, no `connection-created`. Gates
// drag-to-connect, imperative addConnection, and reconcile uniformly. Both predicates
// are PURE (no $data write / engine call) — reads only. The block (return undefined)
// stays UNCONDITIONAL so rejection is enforced on every path; only the EMIT is
// echo-guarded (a programmatic reconcile the rule would reject must not surface as a
// user-facing rejection — mirrors connection-created/connection-removed).
this.editor.addPipe((context: any) => {
const __canConnect = this.canConnect();
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectioncreate') {
const c = context.data;
// ClassicPreset.Connection fields: { id, source, sourceOutput, target, targetInput }.
// Same shape as serializeConn minus the engine-assigned `id` (never created).
const conn = {
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
};
// 1. AUTOMATIC typed validation (default ON; opt out via :validate-types="false").
if (this.validateTypes() !== false) {
const srcType = portTypeOf(c.source, 'output', c.sourceOutput);
const tgtType = portTypeOf(c.target, 'input', c.targetInput);
if (srcType != null && tgtType != null && srcType !== tgtType) {
if (!this.programmatic) this.connectionRejected.emit(conn);
return undefined; // ← CANCEL: type mismatch
}
}
// 2. canConnect OVERRIDE (Phase-40 contract — custom rule, in addition).
if (typeof __canConnect === 'function' && __canConnect(conn) === false) {
if (!this.programmatic) this.connectionRejected.emit(conn);
return undefined; // ← CANCEL: Signal.emit halts, addConnection returns false
}
}
return context;
});
// ─── forward engine events (echo-guarded via `programmatic`) ───────────────
// ─── forward engine events (echo-guarded via `programmatic`) ───────────────
this.editor.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectioncreated') {
// keep engine truth in sync so reconcile diffs correctly — a user-drawn
// connection (auto id) must register here or the next graph pass re-adds it.
this.connInstances.set(context.data.id, context.data);
if (!this.programmatic) {
// WRITE-BACK: append the new connection into a fresh graph object (D4).
this.writeBackConnectionCreated(context.data);
// keep the discrete event too (back-compat).
this.connectionCreated.emit(this.serializeConn(context.data));
}
} else if (context.type === 'connectionremoved') {
this.connInstances.delete(context.data.id);
this.connMeta.delete(context.data.id);
if (!this.programmatic) {
// WRITE-BACK: filter the removed connection out of a fresh graph object (D4).
this.writeBackConnectionRemoved(context.data.id);
this.connectionRemoved.emit({
id: context.data.id
});
}
}
return context;
});
this.area.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'nodepicked') {
this.nodePicked.emit({
id: context.data.id
});
// T1.3 — pointer-DOWN: stash the PRE-drag graph snapshot (before any movement). It
// is committed to history on the first `nodetranslated` (only if a drag follows;
// gated on !programmatic + history). A re-pick mid-drag won't overwrite a live one.
if (!this.programmatic && this.history() !== false && !this.dragGestureActive) {
this.pendingDragSnapshot = this.snapshotCurrent();
}
// Win 2: a pick changed the selection — surface @selection-change after the
// engine's awaited select() for THIS pick has flushed the selector entities.
this.scheduleSelectionEmit();
} else if (context.type === 'pointerup') {
// Win 2: AreaExtensions.selectableNodes UNSELECTS all on a click-like background
// pointerUP (its `twitch < 4` deselect — NOT on pointerdown, verified against
// rete-area-plugin's selectable pipe). Its unselectAll() is async and its pipe
// runs before ours, so recompute AFTER its awaited unselectAll() flushes (the
// microtask + rAF schedule). The dedup makes a no-op when nothing changed (e.g. a
// pointerup that ended a node pick — already surfaced by the nodepicked branch).
this.scheduleSelectionEmit();
// T1.3 — a pointerup ends any in-progress drag gesture, so the NEXT drag pushes a
// fresh history snapshot (one gesture = one undo step, D-03). Drop any stashed
// pre-drag snapshot that was never committed (a pick with no drag).
this.dragGestureActive = false;
this.pendingDragSnapshot = null;
// T1.1: a background pointerup (anywhere not on a connection path) clears the edge
// selection — UNLESS this same gesture just selected an edge (the path's own
// pointerup ran in the same tick and raised `edgeClickGuard`; the guard self-resets
// on the next microtask). Mirrors the node selectable's click-to-deselect.
if (!this.edgeClickGuard && this.selectedConnId != null) this.clearEdgeSelection();
} else if (context.type === 'nodetranslated') {
if (!this.programmatic) {
const id = context.data.id;
const pos = context.data.position;
const meta = this.nodeMeta.get(id);
if (meta) {
meta.x = pos.x;
meta.y = pos.y;
}
// T1.3 — commit ONE history snapshot per drag gesture, at its FIRST translate:
// the pre-move snapshot stashed on nodepicked (a drag truly happened now, not just
// a pick). dragGestureActive holds until the drag-ending pointerup resets it, so a
// continuous drag = ONE undo step (D-03).
if (!this.dragGestureActive) {
this.dragGestureActive = true;
if (this.pendingDragSnapshot) {
this.pushHistorySnapshot(this.pendingDragSnapshot);
this.pendingDragSnapshot = null;
}
}
// WRITE-BACK (coalesced): accumulate the latest position for this node and
// flush ONE fresh graph object per animation frame (Pitfall 2 — the drag
// storm). The discrete `node-moved` emit stays per-translate (back-compat).
this.pendingDragPositions.set(id, {
x: pos.x,
y: pos.y
});
this.scheduleDragFlush();
this.nodeMoved.emit({
id,
x: pos.x,
y: pos.y
});
}
// a node moved → its minimap rect moves (works during a programmatic translate too).
if (this.scheduleMinimapRedraw) this.scheduleMinimapRedraw();
// T2.8 — the selected node moved → re-track its toolbar overlay (no-op if off).
if (this.scheduleToolbarTrack) this.scheduleToolbarTrack();
} else if (context.type === 'translated') {
this.translated.emit({
x: context.data.position.x,
y: context.data.position.y
});
// the viewport window moved → redraw the minimap viewport rect + mask.
if (this.scheduleMinimapRedraw) this.scheduleMinimapRedraw();
// T2.8 — a pan shifts the node's screen rect → re-track the toolbar (no-op if off).
if (this.scheduleToolbarTrack) this.scheduleToolbarTrack();
} else if (context.type === 'zoomed') {
if (!this.programmatic) {
const k = this.area.area.transform.k;
if (k !== this.zoom()) this.zoom.set(k);
}
// the viewport window resized (zoom) → redraw the minimap viewport rect + mask.
if (this.scheduleMinimapRedraw) this.scheduleMinimapRedraw();
// T2.8 — a zoom changes the node's screen rect/size → re-track the toolbar (no-op if off).
if (this.scheduleToolbarTrack) this.scheduleToolbarTrack();
} else if (context.type === 'contextmenu') {
// suppress the native browser menu over the canvas; surface a hook instead.
context.data.event.preventDefault();
const ctx = context.data.context;
this.contextMenu.emit({
id: ctx && ctx.id ? ctx.id : null
});
}
return context;
});
// ─── reconciler off the bound graph, bridged to the top-level $watch ──────────
// Nodes come ONLY from `$props.graph.nodes` (the single source of truth, D1/D2);
// sockets come from each node's TYPE port schema (portReg keyed `type::side::key`).
// A port-schema change ($data.portReg, when a <Port> registers late on Lit) ALSO
// drives this reconcile so a node whose type just gained ports re-renders. An
// imperative $expose addNode (provenance NOT in lastPropNodeIds) survives the reaper.
// Wrapped by reconcileNodes (below) with a re-entrancy guard so two passes never
// race the engine (the Lit "cannot find node" fix).
// ─── reconciler off the bound graph, bridged to the top-level $watch ──────────
// Nodes come ONLY from `$props.graph.nodes` (the single source of truth, D1/D2);
// sockets come from each node's TYPE port schema (portReg keyed `type::side::key`).
// A port-schema change ($data.portReg, when a <Port> registers late on Lit) ALSO
// drives this reconcile so a node whose type just gained ports re-renders. An
// imperative $expose addNode (provenance NOT in lastPropNodeIds) survives the reaper.
// Wrapped by reconcileNodes (below) with a re-entrancy guard so two passes never
// race the engine (the Lit "cannot find node" fix).
const reconcileNodesPass = async () => {
const __graph = this.graph();
const __portReg = this.portReg();
if (!this.editor || !this.area) return;
const graphNodes = Array.isArray(__graph && __graph.nodes) ? __graph.nodes : [];
const want = [];
this.programmatic++;
try {
for (const spec of graphNodes as any) {
if (!spec || spec.id == null) continue;
want.push(spec.id);
this.nodeMeta.set(spec.id, spec);
let node = this.nodeInstances.get(spec.id);
if (!node) {
node = this.buildNode(spec, __portReg);
this.nodeInstances.set(spec.id, node);
await this.editor.addNode(node);
await this.area.translate(spec.id, {
x: spec.x || 0,
y: spec.y || 0
});
} else {
// Sync any ports this node's TYPE gained AFTER the node was first built —
// a nested <Port>'s addTypePort can land after reconcileNodes already
// created the node (the node registered before its ports on some targets,
// or a <Port> registered late on Lit). buildNode only runs for NEW nodes,
// so add the missing inputs/outputs onto the live instance here from the
// TYPE schema, then re-render.
let portsAdded = false;
const {
inputs: wantIn,
outputs: wantOut
} = this.portSchemaForType(spec.type, __portReg);
for (const inp of wantIn as any) {
if (!inp || inp.key == null || node.inputs[inp.key]) continue;
node.addInput(inp.key, new ClassicPreset.Input(this.SOCKET, inp.label, inp.multiple === true));
portsAdded = true;
}
for (const out of wantOut as any) {
if (!out || out.key == null || node.outputs[out.key]) continue;
node.addOutput(out.key, new ClassicPreset.Output(this.SOCKET, out.label, out.multiple !== false));
portsAdded = true;
}
const view = this.area.nodeViews.get(spec.id);
if (view && spec.x != null && spec.y != null && (view.position.x !== spec.x || view.position.y !== spec.y)) {
await this.area.translate(spec.id, {
x: spec.x,
y: spec.y
});
}
if (portsAdded) {
// renderNode's in-place branch deliberately leaves existing sockets
// untouched; to render the NEW sockets, drop this node's render entry so
// area.update takes the fresh-build path (re-runs buildSocketRow + re-
// emits the socket render signals the ConnectionPlugin/watcher need). The
// render-by-type body host is re-projected by the type's bodyRenderer
// (mounts a fresh portal root into the same host — idempotent).
const entry = this.nodeEntries.get(spec.id);
if (entry) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
this.nodeEntries.delete(spec.id);
}
}
await this.area.update('node', spec.id);
// a port change must re-run connections — an edge that was skipped because
// its endpoint port didn't exist yet can now be drawn.
if (portsAdded && this.reconcileConnections) await this.reconcileConnections();
}
}
// remove dropped GRAPH-managed nodes (+ their connections) — imperatively added
// nodes (NOT in lastPropNodeIds) survive (the power-user escape hatch).
const tracked = new Set(this.lastPropNodeIds);
for (const id of tracked as any) {
if (!want.includes(id) && this.nodeInstances.has(id)) {
for (const c of this.editor.getConnections() as any) {
if (c.source === id || c.target === id) await this.editor.removeConnection(c.id);
}
await this.editor.removeNode(id);
this.nodeInstances.delete(id);
this.nodeMeta.delete(id);
}
}
this.lastPropNodeIds = want;
} finally {
this.programmatic--;
}
};
// Re-entrancy-guarded entry point. If a pass is already running, mark a re-run and
// return — the in-flight pass loops until no further request is pending. Serializing
// overlapping reconciles is what stops the Lit async-context cascade from racing the
// engine into "cannot find node" (which otherwise aborts the declarative graph build).
// Re-entrancy-guarded entry point. If a pass is already running, mark a re-run and
// return — the in-flight pass loops until no further request is pending. Serializing
// overlapping reconciles is what stops the Lit async-context cascade from racing the
// engine into "cannot find node" (which otherwise aborts the declarative graph build).
this.reconcileNodes = async () => {
if (this.reconcileNodesRunning) {
this.reconcileNodesPending = true;
return;
}
this.reconcileNodesRunning = true;
try {
do {
this.reconcileNodesPending = false;
await reconcileNodesPass();
} while (this.reconcileNodesPending);
} finally {
this.reconcileNodesRunning = false;
}
};
this.reconcileConnections = async () => {
const __graph = this.graph();
if (!this.editor) return;
// Edges come ONLY from the bound graph's `connections` (the single source of
// truth — declarative <Connection> children are gone). Normalize id-defaulting
// (a connection authored without an id gets a stable derived id) so an edge the
// canvas wrote back (carrying the engine id) and a hand-authored edge dedup.
const graphConns = Array.isArray(__graph && __graph.connections) ? __graph.connections : [];
const norm = (spec: any) => {
if (!spec || spec.source == null || spec.target == null) return null;
const srcOut = spec.sourceOutput != null ? spec.sourceOutput : 'out';
const tgtIn = spec.targetInput != null ? spec.targetInput : 'in';
const id = spec.id != null ? spec.id : `${spec.source}:${srcOut}->${spec.target}:${tgtIn}`;
// carry the optional per-edge label/style (F3) through to connMeta → renderConnection.
return {
id,
source: spec.source,
sourceOutput: srcOut,
target: spec.target,
targetInput: tgtIn,
label: spec.label,
stroke: spec.stroke,
dashed: spec.dashed,
type: spec.type
};
};
// cheap style signature so a label/style/type change on an EXISTING edge re-renders it.
const edgeStyleSig = (s: any) => s ? String(s.label) + '|' + String(s.stroke) + '|' + String(s.dashed) + '|' + String(s.type) : '';
const merged = graphConns.map(norm).filter(Boolean);
const want = [];
this.programmatic++;
try {
for (const spec of merged as any) {
if (!spec || spec.id == null) continue;
want.push(spec.id);
if (this.connInstances.has(spec.id)) {
// existing edge — relabel/restyle in place if its label/style changed (the
// controlled-graph expectation: edit the bound graph → see the change). Drop the
// render entry so area.update takes the fresh-build path (re-applies label/style).
const changed = edgeStyleSig(this.connMeta.get(spec.id)) !== edgeStyleSig(spec);
this.connMeta.set(spec.id, spec);
if (changed) {
const entry = this.connEntries.get(spec.id);
if (entry) {
entry.dispose();
this.connEntries.delete(spec.id);
}
await this.area.update('connection', spec.id);
}
continue;
}
const sourceNode = this.nodeInstances.get(spec.source);
const targetNode = this.nodeInstances.get(spec.target);
if (!sourceNode || !targetNode) continue;
// DEFENSIVE: the referenced output/input ports must exist on the live node
// instances before addConnection (Rete throws "source node doesn't have
// output with a key out" otherwise, aborting the loop). An edge may reference
// a port the node's TYPE schema has not flushed yet (a <Port> registered
// after the <NodeType>); skip until the ports exist — reconcileNodes re-runs
// reconcileConnections after a port-schema change, so the edge lands later.
if (!sourceNode.outputs || !sourceNode.outputs[spec.sourceOutput]) continue;
if (!targetNode.inputs || !targetNode.inputs[spec.targetInput]) continue;
const conn = new ClassicPreset.Connection(sourceNode, spec.sourceOutput, targetNode, spec.targetInput);
conn.id = spec.id;
this.connInstances.set(spec.id, conn);
// seed connMeta BEFORE addConnection so renderConnection sees the label/style on
// its first render (the render fires synchronously inside addConnection's pipe).
this.connMeta.set(spec.id, spec);
await this.editor.addConnection(conn);
}
// remove dropped GRAPH-managed edges — imperatively added edges survive.
const tracked = new Set(this.lastPropConnIds);
for (const id of tracked as any) {
if (!want.includes(id) && this.connInstances.has(id)) {
await this.editor.removeConnection(id);
this.connInstances.delete(id);
this.connMeta.delete(id);
}
}
this.lastPropConnIds = want;
} finally {
this.programmatic--;
}
};
// ─── built-in MiniMap (opt-in :minimap, Phase 42) ────────────────────────────
// An absolute light-DOM SVG overlay (bottom-right) showing a scaled map of every
// node + the current viewport window (outside dimmed), PANNABLE (drag recenters via
// setCenter). The host div is COMPONENT-template DOM (carries the [data-rozie-s-*]
// scope attr → plain scoped CSS positions it); its SVG children are built
// IMPERATIVELY with createElementNS (the connection-renderer discipline) so SVG
// namespacing is identical on all 6 (no SVG-in-template cross-target risk) and styled
// with INLINE attributes (the arrowhead-marker lesson — no scoped-CSS / :root rule
// needed for engine-style DOM). Node dims come from the MEASURED engine node-view
// elements (area.nodeViews.get(id).element offsetW/H — target-agnostic, like the
// render pipe) with a default-rect fallback for Lit's unmeasured first paint.
// ─── built-in MiniMap (opt-in :minimap, Phase 42) ────────────────────────────
// An absolute light-DOM SVG overlay (bottom-right) showing a scaled map of every
// node + the current viewport window (outside dimmed), PANNABLE (drag recenters via
// setCenter). The host div is COMPONENT-template DOM (carries the [data-rozie-s-*]
// scope attr → plain scoped CSS positions it); its SVG children are built
// IMPERATIVELY with createElementNS (the connection-renderer discipline) so SVG
// namespacing is identical on all 6 (no SVG-in-template cross-target risk) and styled
// with INLINE attributes (the arrowhead-marker lesson — no scoped-CSS / :root rule
// needed for engine-style DOM). Node dims come from the MEASURED engine node-view
// elements (area.nodeViews.get(id).element offsetW/H — target-agnostic, like the
// render pipe) with a default-rect fallback for Lit's unmeasured first paint.
const measureNodeSize = (id: any) => {
const view = this.area && this.area.nodeViews ? this.area.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
const w = el && el.offsetWidth ? el.offsetWidth : this.MINIMAP_DEFAULT_NODE_W;
const h = el && el.offsetHeight ? el.offsetHeight : this.MINIMAP_DEFAULT_NODE_H;
return {
w,
h
};
};
const mkMinimapRect = (x: any, y: any, w: any, h: any, cls: any, fill: any, stroke: any, strokeW: any) => {
const r = document.createElementNS(this.SVGNS, 'rect');
r.setAttribute('class', cls);
r.setAttribute('x', String(x));
r.setAttribute('y', String(y));
r.setAttribute('width', String(Math.max(w, 0)));
r.setAttribute('height', String(Math.max(h, 0)));
if (fill) r.setAttribute('fill', fill);
if (stroke) {
r.setAttribute('stroke', stroke);
r.setAttribute('stroke-width', String(strokeW || 1));
}
return r;
};
// Rebuild the minimap SVG: node rects (selected highlighted) + a dim mask outside the
// viewport (evenodd punch-out) + the viewport window outline. The bounds union the
// node rects AND the viewport window so the viewport indicator stays in-frame even
// when panned past the nodes. Stores `minimapMap` (the px↔graph mapping the pointer-
// pan handlers read). Cheap (a handful of rects) → a full rebuild per frame is fine.
// Rebuild the minimap SVG: node rects (selected highlighted) + a dim mask outside the
// viewport (evenodd punch-out) + the viewport window outline. The bounds union the
// node rects AND the viewport window so the viewport indicator stays in-frame even
// when panned past the nodes. Stores `minimapMap` (the px↔graph mapping the pointer-
// pan handlers read). Cheap (a handful of rects) → a full rebuild per frame is fine.
const redrawMinimap = () => {
this.minimapRedrawRaf = 0;
if (!this.minimap() || !this.minimapSvg || !this.area || !container) return;
const t = this.area.area.transform;
const k = t.k || 1;
const cw = container.clientWidth || this.MINIMAP_W;
const ch = container.clientHeight || this.MINIMAP_H;
// viewport window in GRAPH coords (screen [0,cw]×[0,ch] → graph).
const vx = -t.x / k,
vy = -t.y / k,
vw = cw / k,
vh = ch / k;
const graphNodes = this.currentGraph().nodes || [];
const selIds = new Set(this.selectedNodeIds().map((s: any) => String(s)));
const rects = [];
for (const n of graphNodes as any) {
if (!n || n.id == null) continue;
const view = this.area.nodeViews.get(n.id);
const gx = view ? view.position.x : n.x || 0;
const gy = view ? view.position.y : n.y || 0;
const sz = measureNodeSize(n.id);
rects.push({
gx,
gy,
gw: sz.w,
gh: sz.h,
selected: selIds.has(String(n.id))
});
}
let minX = vx,
minY = vy,
maxX = vx + vw,
maxY = vy + vh;
for (const r of rects as any) {
if (r.gx < minX) minX = r.gx;
if (r.gy < minY) minY = r.gy;
if (r.gx + r.gw > maxX) maxX = r.gx + r.gw;
if (r.gy + r.gh > maxY) maxY = r.gy + r.gh;
}
const padX = (maxX - minX) * 0.1 || 20;
const padY = (maxY - minY) * 0.1 || 20;
minX -= padX;
minY -= padY;
maxX += padX;
maxY += padY;
const bw = maxX - minX || 1;
const bh = maxY - minY || 1;
const scale = Math.min(this.MINIMAP_W / bw, this.MINIMAP_H / bh);
const offX = (this.MINIMAP_W - bw * scale) / 2;
const offY = (this.MINIMAP_H - bh * scale) / 2;
this.minimapMap = {
minX,
minY,
scale,
offX,
offY
};
const toMMx = (gx: any) => (gx - minX) * scale + offX;
const toMMy = (gy: any) => (gy - minY) * scale + offY;
this.minimapSvg.innerHTML = '';
for (const r of rects as any) {
const fill = r.selected ? '#3b82f6' : '#94a3b8';
this.minimapSvg.appendChild(mkMinimapRect(toMMx(r.gx), toMMy(r.gy), r.gw * scale, r.gh * scale, 'rozie-flow-minimap__node', fill, null, 0));
}
// dim mask OUTSIDE the viewport: full minimap rect with the viewport rect punched
// out (both subpaths same winding → fill-rule:evenodd leaves the viewport a hole).
const mvx = toMMx(vx),
mvy = toMMy(vy),
mvw = vw * scale,
mvh = vh * scale;
const mask = document.createElementNS(this.SVGNS, 'path');
mask.setAttribute('class', 'rozie-flow-minimap__mask');
mask.setAttribute('fill-rule', 'evenodd');
mask.setAttribute('fill', 'rgba(15, 23, 42, 0.18)');
mask.setAttribute('d', 'M0 0 H' + this.MINIMAP_W + ' V' + this.MINIMAP_H + ' H0 Z ' + 'M' + mvx + ' ' + mvy + ' h' + mvw + ' v' + mvh + ' h' + -mvw + ' Z');
this.minimapSvg.appendChild(mask);
this.minimapSvg.appendChild(mkMinimapRect(mvx, mvy, mvw, mvh, 'rozie-flow-minimap__viewport', 'none', '#3b82f6', 1.5));
};
// rAF-coalesced scheduler (bridged to the top-level $watch + the engine pipes). No-op
// when :minimap is off (the bridge stays callable everywhere, cheap).
// rAF-coalesced scheduler (bridged to the top-level $watch + the engine pipes). No-op
// when :minimap is off (the bridge stays callable everywhere, cheap).
this.scheduleMinimapRedraw = () => {
if (!this.minimap() || this.minimapRedrawRaf) return;
if (typeof requestAnimationFrame === 'function') {
this.minimapRedrawRaf = requestAnimationFrame(redrawMinimap);
} else {
this.minimapRedrawRaf = 1;
Promise.resolve().then(redrawMinimap);
}
};
// Map a minimap pointer event → graph coords (via the stored minimapMap) → setCenter.
// Pan is a view op → allowed even when readonly, but gated by `pannable` (mirror the
// main-canvas pannable gate). Pointer capture keeps the drag tracking off the box.
// Map a minimap pointer event → graph coords (via the stored minimapMap) → setCenter.
// Pan is a view op → allowed even when readonly, but gated by `pannable` (mirror the
// main-canvas pannable gate). Pointer capture keeps the drag tracking off the box.
const minimapPointerToGraph = (e: any) => {
if (!this.minimapMap || !this.minimapHost) return null;
const box = this.minimapHost.getBoundingClientRect();
const rw = box.width || this.MINIMAP_W;
const rh = box.height || this.MINIMAP_H;
const mx = (e.clientX - box.left) * (this.MINIMAP_W / rw);
const my = (e.clientY - box.top) * (this.MINIMAP_H / rh);
return {
gx: this.minimapMap.minX + (mx - this.minimapMap.offX) / this.minimapMap.scale,
gy: this.minimapMap.minY + (my - this.minimapMap.offY) / this.minimapMap.scale
};
};
if (this.minimap() && this.minimapEl()?.nativeElement) {
this.minimapHost = this.minimapEl()?.nativeElement;
this.minimapSvg = document.createElementNS(this.SVGNS, 'svg');
this.minimapSvg.setAttribute('class', 'rozie-flow-minimap__svg');
this.minimapSvg.setAttribute('viewBox', '0 0 ' + this.MINIMAP_W + ' ' + this.MINIMAP_H);
this.minimapSvg.setAttribute('preserveAspectRatio', 'none');
this.minimapHost.appendChild(this.minimapSvg);
this.onMinimapPointerDown = (e: any) => {
if (!this.pannable()) return;
const g = minimapPointerToGraph(e);
if (!g) return;
this.minimapPanning = true;
try {
if (e.target && e.target.setPointerCapture && e.pointerId != null) e.target.setPointerCapture(e.pointerId);
} catch (err: any) {}
e.preventDefault();
e.stopPropagation();
this.setCenter(g.gx, g.gy, null);
};
this.onMinimapPointerMove = (e: any) => {
if (!this.minimapPanning || !this.pannable()) return;
const g = minimapPointerToGraph(e);
if (!g) return;
e.preventDefault();
this.setCenter(g.gx, g.gy, null);
};
this.onMinimapPointerUp = (e: any) => {
if (!this.minimapPanning) return;
this.minimapPanning = false;
try {
if (e.target && e.target.releasePointerCapture && e.pointerId != null) e.target.releasePointerCapture(e.pointerId);
} catch (err: any) {}
};
this.minimapHost.addEventListener('pointerdown', this.onMinimapPointerDown);
this.minimapHost.addEventListener('pointermove', this.onMinimapPointerMove);
this.minimapHost.addEventListener('pointerup', this.onMinimapPointerUp);
}
// ─── T2.8 NodeToolbar (opt-in :node-toolbar) ─────────────────────────────────
// A floating component-template overlay over the SELECTED node. The host div
// (ref="toolbarEl") carries the [data-rozie-s-*] scope attr → PLAIN scoped CSS positions
// it absolutely (NOT the :root engine-DOM escape hatch — it's component DOM, like the
// marquee box + Controls). It is positioned from the engine node-view ELEMENT's rect
// (which the AreaPlugin transforms for pan/zoom/drag) relative to the canvas container, so
// the area transform is honored automatically — we read getBoundingClientRect() and
// subtract the container's rect (the screenToFlowPosition discipline, but the other way).
// Re-tracked on translated/zoomed/nodetranslated (the pipe branches that schedule the
// minimap redraw) + on every selection emit. OPT-IN (default OFF) → existing demos +
// FlowCanvasScreenshot are pixel-identical (the host div is r-if'd off when :node-toolbar
// is false; selecting a node never pops it).
// Resolve the SINGLE selected node id the toolbar should track: the one picked node when
// EXACTLY one is selected, else null (no toolbar over a multi-select or empty selection —
// a per-node action needs an unambiguous target). Read-only.
// ─── T2.8 NodeToolbar (opt-in :node-toolbar) ─────────────────────────────────
// A floating component-template overlay over the SELECTED node. The host div
// (ref="toolbarEl") carries the [data-rozie-s-*] scope attr → PLAIN scoped CSS positions
// it absolutely (NOT the :root engine-DOM escape hatch — it's component DOM, like the
// marquee box + Controls). It is positioned from the engine node-view ELEMENT's rect
// (which the AreaPlugin transforms for pan/zoom/drag) relative to the canvas container, so
// the area transform is honored automatically — we read getBoundingClientRect() and
// subtract the container's rect (the screenToFlowPosition discipline, but the other way).
// Re-tracked on translated/zoomed/nodetranslated (the pipe branches that schedule the
// minimap redraw) + on every selection emit. OPT-IN (default OFF) → existing demos +
// FlowCanvasScreenshot are pixel-identical (the host div is r-if'd off when :node-toolbar
// is false; selecting a node never pops it).
// Resolve the SINGLE selected node id the toolbar should track: the one picked node when
// EXACTLY one is selected, else null (no toolbar over a multi-select or empty selection —
// a per-node action needs an unambiguous target). Read-only.
const singleSelectedNodeId = () => {
const ids = this.selectedNodeIds();
return ids.length === 1 ? ids[0] : null;
};
// Position the toolbar host over the tracked node's engine element, or hide it. The
// node-view element is already transformed by the AreaPlugin (pan/zoom/drag), so its
// client rect minus the container's client rect gives the toolbar's container-relative
// px — no manual transform math. Placed just ABOVE the node (bottom of the toolbar at the
// node's top edge); clamped so it never goes off the top of the container.
// Position the toolbar host over the tracked node's engine element, or hide it. The
// node-view element is already transformed by the AreaPlugin (pan/zoom/drag), so its
// client rect minus the container's client rect gives the toolbar's container-relative
// px — no manual transform math. Placed just ABOVE the node (bottom of the toolbar at the
// node's top edge); clamped so it never goes off the top of the container.
const trackToolbar = () => {
this.toolbarTrackRaf = 0;
if (!this.nodeToolbar() || !this.toolbarHost || !this.area || !container) return;
const id = this.toolbarSelectedId;
if (id == null) {
this.toolbarHost.style.display = 'none';
return;
}
const view = this.area.nodeViews ? this.area.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
const rect = el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null;
if (!rect) {
this.toolbarHost.style.display = 'none';
return;
}
const cbox = container.getBoundingClientRect();
// container-relative px of the node's top-left + width.
const nx = rect.left - cbox.left;
const ny = rect.top - cbox.top;
const tbH = this.toolbarHost.offsetHeight || 30;
let top = ny - tbH - 6;
if (top < 2) top = ny + rect.height + 6; // flip below if it would clip the top
this.toolbarHost.style.left = nx + 'px';
this.toolbarHost.style.top = top + 'px';
this.toolbarHost.style.display = 'flex';
};
this.scheduleToolbarTrack = () => {
if (!this.nodeToolbar() || this.toolbarTrackRaf) return;
if (typeof requestAnimationFrame === 'function') {
this.toolbarTrackRaf = requestAnimationFrame(trackToolbar);
} else {
this.toolbarTrackRaf = 1;
Promise.resolve().then(trackToolbar);
}
};
// Recompute the tracked node from the live selection + (re)mount the toolbar content for
// it. Called from the selection emit (a pick/unpick changed the selection). When the
// tracked id changes: if the consumer fills `#toolbar`, (re)render the reactive portal
// with the new node scope; else the default buttons stay put (they read the live tracked
// id at click time, so no re-mount needed). Then reposition.
// Recompute the tracked node from the live selection + (re)mount the toolbar content for
// it. Called from the selection emit (a pick/unpick changed the selection). When the
// tracked id changes: if the consumer fills `#toolbar`, (re)render the reactive portal
// with the new node scope; else the default buttons stay put (they read the live tracked
// id at click time, so no re-mount needed). Then reposition.
const syncToolbar = () => {
if (!this.nodeToolbar() || !this.toolbarHost) return;
const id = singleSelectedNodeId();
if (id === this.toolbarSelectedId && id == null === (this.toolbarSelectedId == null)) {
// same target — just reposition (e.g. after a drag).
this.scheduleToolbarTrack();
return;
}
this.toolbarSelectedId = id;
if ((this.toolbarTpl ?? this.templates()?.['toolbar']) && id != null) {
const meta = this.nodeMeta.get(id) || {
id,
type: undefined,
data: {}
};
const scope = {
node: meta,
emit: toolbarEmit
};
if (this.toolbarHandle && this.toolbarHandle.update) {
this.toolbarHandle.update(scope);
} else {
this.toolbarHandle = portals.toolbar(this.toolbarHost, scope);
}
}
this.scheduleToolbarTrack();
};
this.syncToolbarSelection = syncToolbar;
// The @node-action emit helper for the toolbar's actions (the EXISTING emit — no new emit,
// T2.8). Carries the tracked node id. Handed to the `#toolbar` slot scope so a consumer
// override can raise its own actions too.
// The @node-action emit helper for the toolbar's actions (the EXISTING emit — no new emit,
// T2.8). Carries the tracked node id. Handed to the `#toolbar` slot scope so a consumer
// override can raise its own actions too.
const toolbarEmit = (name: any, detail: any) => {
const id = this.toolbarSelectedId;
this.nodeAction.emit({
id,
name,
detail
});
};
if (this.nodeToolbar() && this.toolbarEl()?.nativeElement) {
this.toolbarHost = this.toolbarEl()?.nativeElement;
this.toolbarHost.style.display = 'none';
if (!(this.toolbarTpl ?? this.templates()?.['toolbar'])) {
// default chrome: delete + duplicate buttons. Static literal labels (Threat
// T-44-06-1: no node-derived text rendered via innerHTML — these are fixed strings
// set via textContent). Both fire @node-action on the tracked node.
this.toolbarDeleteBtn = document.createElement('button');
this.toolbarDeleteBtn.type = 'button';
this.toolbarDeleteBtn.className = 'rozie-flow-toolbar__btn rozie-flow-toolbar__btn--delete';
this.toolbarDeleteBtn.setAttribute('data-testid', 'flow-toolbar-delete');
this.toolbarDeleteBtn.setAttribute('aria-label', 'Delete node');
this.toolbarDeleteBtn.textContent = 'Delete';
this.toolbarDuplicateBtn = document.createElement('button');
this.toolbarDuplicateBtn.type = 'button';
this.toolbarDuplicateBtn.className = 'rozie-flow-toolbar__btn rozie-flow-toolbar__btn--duplicate';
this.toolbarDuplicateBtn.setAttribute('data-testid', 'flow-toolbar-duplicate');
this.toolbarDuplicateBtn.setAttribute('aria-label', 'Duplicate node');
this.toolbarDuplicateBtn.textContent = 'Duplicate';
this.onToolbarDelete = (e: any) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const id = this.toolbarSelectedId;
if (id == null) return;
toolbarEmit('delete', {
id
});
this.toolbarSelectedId = null;
this.deleteNode(id);
this.scheduleToolbarTrack();
};
this.onToolbarDup = (e: any) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const id = this.toolbarSelectedId;
if (id == null) return;
const newId = this.duplicateNode(id);
toolbarEmit('duplicate', {
id,
newId
});
this.scheduleToolbarTrack();
};
// pointerup (NOT click — Rete swallows clicks during node interaction; the §6a item-7
// discipline) on the COMPONENT-template buttons.
this.toolbarDeleteBtn.addEventListener('pointerup', this.onToolbarDelete);
this.toolbarDuplicateBtn.addEventListener('pointerup', this.onToolbarDup);
this.toolbarHost.appendChild(this.toolbarDeleteBtn);
this.toolbarHost.appendChild(this.toolbarDuplicateBtn);
}
}
// ─── T2.4 MARQUEE select (mode:'select') ─────────────────────────────────────
// A Figma-style rubber-band box. RESTORE-PATH resolution (RESEARCH Q2/A8): rete's
// internal `Drag` class is NOT exported, so setDragHandler(null) can't be cleanly
// reversed (re-instantiating Drag is impossible). Instead we leave the default pan Drag
// installed and intercept the EMPTY-canvas pointerdown in the CAPTURE phase on the
// container — the default Drag attaches its own bubble-phase pointerdown listener on the
// SAME container (verified rete-area-plugin@2.1.5: setDragHandler → Drag.initialize(
// this.container)), so a capture listener fires FIRST and stopPropagation() blocks pan
// before it starts. The interception is gated PURELY on the live `$props.mode` flag, so
// switching back to 'pan' restores pan with ZERO engine mutation (the persistent
// mode-guard the research preferred). A node drag is UNTOUCHED in both modes: we only act
// when the pointerdown target is NOT inside a node element (empty canvas).
//
// The box is a COMPONENT-TEMPLATE overlay div (ref="marqueeEl") — it carries the
// [data-rozie-s-*] scope attr so a PLAIN scoped rule styles it (NOT the :root engine-DOM
// escape hatch). On release we hit-test every graph node's rect (graph coords via
// area.nodeViews.get(id).position + measureNodeSize) against the box (converted to graph
// coords through the live transform) and nodeSelectApi.select(id, true) each intersector,
// then scheduleSelectionEmit() (the existing @selection-change path — NO new emit).
// Marquee changes only SELECTION (script-state), never the graph model → no history push.
// ─── T2.4 MARQUEE select (mode:'select') ─────────────────────────────────────
// A Figma-style rubber-band box. RESTORE-PATH resolution (RESEARCH Q2/A8): rete's
// internal `Drag` class is NOT exported, so setDragHandler(null) can't be cleanly
// reversed (re-instantiating Drag is impossible). Instead we leave the default pan Drag
// installed and intercept the EMPTY-canvas pointerdown in the CAPTURE phase on the
// container — the default Drag attaches its own bubble-phase pointerdown listener on the
// SAME container (verified rete-area-plugin@2.1.5: setDragHandler → Drag.initialize(
// this.container)), so a capture listener fires FIRST and stopPropagation() blocks pan
// before it starts. The interception is gated PURELY on the live `$props.mode` flag, so
// switching back to 'pan' restores pan with ZERO engine mutation (the persistent
// mode-guard the research preferred). A node drag is UNTOUCHED in both modes: we only act
// when the pointerdown target is NOT inside a node element (empty canvas).
//
// The box is a COMPONENT-TEMPLATE overlay div (ref="marqueeEl") — it carries the
// [data-rozie-s-*] scope attr so a PLAIN scoped rule styles it (NOT the :root engine-DOM
// escape hatch). On release we hit-test every graph node's rect (graph coords via
// area.nodeViews.get(id).position + measureNodeSize) against the box (converted to graph
// coords through the live transform) and nodeSelectApi.select(id, true) each intersector,
// then scheduleSelectionEmit() (the existing @selection-change path — NO new emit).
// Marquee changes only SELECTION (script-state), never the graph model → no history push.
const nodeAt = (target: any) => {
if (!target || typeof target.closest !== 'function') return null;
return target.closest('.rozie-flow-node');
};
// container-relative px → GRAPH coords (the inverse area transform, like
// screenToFlowPosition but already container-relative). px = transform + graph·k.
// container-relative px → GRAPH coords (the inverse area transform, like
// screenToFlowPosition but already container-relative). px = transform + graph·k.
const containerPxToGraph = (px: any, py: any) => {
const t = this.area.area.transform;
const k = t.k || 1;
return {
x: (px - t.x) / k,
y: (py - t.y) / k
};
};
const updateMarqueeBox = () => {
if (!this.marqueeBox || !this.marqueeStart || !this.marqueeCur) return;
const x = Math.min(this.marqueeStart.x, this.marqueeCur.x);
const y = Math.min(this.marqueeStart.y, this.marqueeCur.y);
const w = Math.abs(this.marqueeCur.x - this.marqueeStart.x);
const h = Math.abs(this.marqueeCur.y - this.marqueeStart.y);
this.marqueeBox.style.left = x + 'px';
this.marqueeBox.style.top = y + 'px';
this.marqueeBox.style.width = w + 'px';
this.marqueeBox.style.height = h + 'px';
this.marqueeBox.style.display = 'block';
};
const finishMarquee = () => {
if (!this.marqueeActive) return;
this.marqueeActive = false;
if (this.marqueeBox) this.marqueeBox.style.display = 'none';
if (!this.marqueeStart || !this.marqueeCur || !this.nodeSelectApi) {
this.marqueeStart = null;
this.marqueeCur = null;
return;
}
// box in graph coords (two opposite corners → min/max).
const a = containerPxToGraph(this.marqueeStart.x, this.marqueeStart.y);
const b = containerPxToGraph(this.marqueeCur.x, this.marqueeCur.y);
const bx0 = Math.min(a.x, b.x),
by0 = Math.min(a.y, b.y);
const bx1 = Math.max(a.x, b.x),
by1 = Math.max(a.y, b.y);
this.marqueeStart = null;
this.marqueeCur = null;
const graphNodes = this.currentGraph().nodes || [];
let first = true;
for (const n of graphNodes as any) {
if (!n || n.id == null) continue;
const view = this.area.nodeViews.get(n.id);
const gx = view ? view.position.x : n.x || 0;
const gy = view ? view.position.y : n.y || 0;
const sz = measureNodeSize(n.id);
// a node intersects the box if their rects overlap (AABB), in graph coords.
const overlaps = gx < bx1 && gx + sz.w > bx0 && gy < by1 && gy + sz.h > by0;
if (overlaps) {
// accumulate=true keeps every intersector selected (first one replaces the prior
// selection so an old pick doesn't linger; rest accumulate). select(id, accumulate).
this.nodeSelectApi.select(n.id, !first);
first = false;
}
}
// surface @selection-change once the engine's awaited select() chain has flushed.
this.scheduleSelectionEmit();
};
if (__selectable && !__readonly && container && typeof container.addEventListener === 'function') {
this.marqueeBox = this.marqueeEl()?.nativeElement || null;
this.onCanvasPointerDownCapture = (e: any) => {
// only in select mode, only the EMPTY canvas (not on a node — those still drag), only
// the primary button. A live `$props.mode` read = the persistent mode-guard (restoring
// pan is just this check returning early; no engine mutation).
if (this.mode() !== 'select') return;
if (e && e.button != null && e.button !== 0) return;
if (nodeAt(e.target)) return;
// BLOCK rete's pan Drag (its bubble-phase pointerdown on the same container) — capture
// phase runs first, so stopPropagation() here pre-empts pan; the marquee owns this drag.
e.stopPropagation();
e.preventDefault();
const box = container.getBoundingClientRect();
this.marqueeActive = true;
this.marqueeStart = {
x: e.clientX - box.left,
y: e.clientY - box.top
};
this.marqueeCur = {
x: this.marqueeStart.x,
y: this.marqueeStart.y
};
try {
if (container.setPointerCapture && e.pointerId != null) container.setPointerCapture(e.pointerId);
} catch (err: any) {}
updateMarqueeBox();
};
this.onMarqueePointerMove = (e: any) => {
if (!this.marqueeActive) return;
const box = container.getBoundingClientRect();
this.marqueeCur = {
x: e.clientX - box.left,
y: e.clientY - box.top
};
updateMarqueeBox();
};
this.onMarqueePointerUp = (e: any) => {
if (!this.marqueeActive) return;
try {
if (container.releasePointerCapture && e && e.pointerId != null) container.releasePointerCapture(e.pointerId);
} catch (err: any) {}
finishMarquee();
};
container.addEventListener('pointerdown', this.onCanvasPointerDownCapture, true);
container.addEventListener('pointermove', this.onMarqueePointerMove);
container.addEventListener('pointerup', this.onMarqueePointerUp);
}
// ─── initial graph: nodes first, then connections (connections reference live
// node instances), then optional fit. Sequenced via an async IIFE so the
// $onMount-returned teardown stays synchronous. ──────────────────────────────
// ─── initial graph: nodes first, then connections (connections reference live
// node instances), then optional fit. Sequenced via an async IIFE so the
// $onMount-returned teardown stays synchronous. ──────────────────────────────
;
(async () => {
// T1.3 — seed the canvas's own last-written graph from the initial bound value so the
// first gesture's snapshot/base reflects the mounted graph (immune to prop re-bind lag).
this.lastWrittenGraph = structuredClone(this.currentGraph());
await this.reconcileNodes();
await this.reconcileConnections();
if (typeof this.zoom() === 'number' && this.zoom() !== 1) {
this.programmatic++;
try {
await this.area.area.zoom(this.zoom());
} finally {
this.programmatic--;
}
}
if (this.fitOnMount() && this.editor.getNodes().length) {
this.programmatic++;
try {
await AreaExtensions.zoomAt(this.area, this.editor.getNodes());
} finally {
this.programmatic--;
}
if (this.area) {
const k = this.area.area.transform.k;
if (k !== this.zoom()) this.zoom.set(k);
}
}
// draw the minimap once the graph + fit have settled (also redrawn on every
// render / pan / zoom / drag / selection / graph change below).
if (this.scheduleMinimapRedraw) this.scheduleMinimapRedraw();
})();
this.__rozieDestroyRef.onDestroy(() => {
if (this.onCanvasKeydown && this.keydownContainer && typeof this.keydownContainer.removeEventListener === 'function') {
try {
this.keydownContainer.removeEventListener('keydown', this.onCanvasKeydown);
} catch (e: any) {}
}
if (this.dragFlushRaf && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(this.dragFlushRaf);
} catch (e: any) {}
}
this.dragFlushRaf = 0;
this.pendingDragPositions.clear();
// T1.1: drop the edge-selection state + its cached <path> reference on teardown.
this.clearEdgeSelection();
// MiniMap teardown — remove the pointer-pan listeners + cancel a pending redraw.
if (this.minimapHost) {
if (this.onMinimapPointerDown) {
try {
this.minimapHost.removeEventListener('pointerdown', this.onMinimapPointerDown);
} catch (e: any) {}
}
if (this.onMinimapPointerMove) {
try {
this.minimapHost.removeEventListener('pointermove', this.onMinimapPointerMove);
} catch (e: any) {}
}
if (this.onMinimapPointerUp) {
try {
this.minimapHost.removeEventListener('pointerup', this.onMinimapPointerUp);
} catch (e: any) {}
}
}
if (this.minimapRedrawRaf && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(this.minimapRedrawRaf);
} catch (e: any) {}
}
this.minimapRedrawRaf = 0;
// T2.8 NodeToolbar teardown — remove the default-button listeners, dispose the optional
// `#toolbar` reactive portal handle, and cancel a pending reposition.
if (this.toolbarDeleteBtn && this.onToolbarDelete) {
try {
this.toolbarDeleteBtn.removeEventListener('pointerup', this.onToolbarDelete);
} catch (e: any) {}
}
if (this.toolbarDuplicateBtn && this.onToolbarDup) {
try {
this.toolbarDuplicateBtn.removeEventListener('pointerup', this.onToolbarDup);
} catch (e: any) {}
}
if (this.toolbarHandle && this.toolbarHandle.dispose) {
try {
this.toolbarHandle.dispose();
} catch (e: any) {}
}
this.toolbarHandle = null;
this.toolbarSelectedId = null;
if (this.toolbarTrackRaf && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(this.toolbarTrackRaf);
} catch (e: any) {}
}
this.toolbarTrackRaf = 0;
// T2.4 Marquee teardown — remove the capture-phase pointerdown guard + window listeners.
if (this.keydownContainer) {
if (this.onCanvasPointerDownCapture) {
try {
this.keydownContainer.removeEventListener('pointerdown', this.onCanvasPointerDownCapture, true);
} catch (e: any) {}
}
if (this.onMarqueePointerMove) {
try {
this.keydownContainer.removeEventListener('pointermove', this.onMarqueePointerMove);
} catch (e: any) {}
}
if (this.onMarqueePointerUp) {
try {
this.keydownContainer.removeEventListener('pointerup', this.onMarqueePointerUp);
} catch (e: any) {}
}
}
this.marqueeActive = false;
this.marqueeStart = null;
this.marqueeCur = null;
for (const [, entry] of this.nodeEntries as any) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
}
this.nodeEntries.clear();
for (const [, entry] of this.connEntries as any) entry.dispose();
this.connEntries.clear();
if (this.area) this.area.destroy();
});
this.__rozieDestroyRef.onDestroy(() => {
for (const view of this._portalViews) view.destroy();
this._portalViews.clear();
});
}
editor: any = null;
area: any = null;
connectionPlugin: any = null;
socketWatcher: any = null;
renderScope: any = null;
selector: any = null;
arrange: any = null;
keydownContainer: any = null;
onCanvasKeydown: any = null;
minimapHost: any = null;
minimapSvg: any = null;
minimapRedrawRaf = 0;
minimapMap: any = null;
minimapPanning = false;
onMinimapPointerDown: any = null;
onMinimapPointerMove: any = null;
onMinimapPointerUp: any = null;
scheduleMinimapRedraw: any = null;
nodeSelectApi: any = null;
marqueeBox: any = null;
marqueeActive = false;
marqueeStart: any = null;
marqueeCur: any = null;
onCanvasPointerDownCapture: any = null;
onMarqueePointerMove: any = null;
onMarqueePointerUp: any = null;
toolbarHost: any = null;
toolbarSelectedId: any = null;
toolbarHandle: any = null;
scheduleToolbarTrack: any = null;
syncToolbarSelection: any = null;
toolbarTrackRaf = 0;
toolbarDeleteBtn: any = null;
toolbarDuplicateBtn: any = null;
onToolbarDelete: any = null;
onToolbarDup: any = null;
MINIMAP_W = 200;
MINIMAP_H = 150;
MINIMAP_DEFAULT_NODE_W = 140;
MINIMAP_DEFAULT_NODE_H = 52;
SVGNS = 'http://www.w3.org/2000/svg';
SOCKET = new ClassicPreset.Socket('flow');
nodeInstances = new Map();
nodeMeta = new Map();
connInstances = new Map();
nodeEntries = new Map();
connEntries = new Map();
connMeta = new Map();
lastPropNodeIds: any = null;
lastPropConnIds: any = null;
programmatic = 0;
lastSelectionIds: any = null;
selectedConnId: any = null;
selectedPathEl: any = null;
edgeClickGuard = false;
HISTORY_CAP = 100;
historyStack = [];
redoStack = [];
dragGestureActive = false;
pendingDragSnapshot: any = null;
reconnectInFlight = 0;
reconnectPreSnapshot: any = null;
reconnectDidWriteBack = false;
reconnectCloseScheduled = false;
pendingDragPositions = new Map();
dragFlushRaf = 0;
currentGraph = () => this.graph() || {
nodes: [],
connections: []
};
lastWrittenGraph: any = null;
selfWriteInFlight = false;
commitGraph = (g: any) => {
const c = structuredClone(g);
this.lastWrittenGraph = c != null ? c : g;
this.selfWriteInFlight = true;
this.graph.set(g);
};
snapshotCurrent = () => {
const src = this.lastWrittenGraph != null ? this.lastWrittenGraph : this.currentGraph();
return structuredClone(src);
};
baseGraph = () => this.lastWrittenGraph != null ? this.lastWrittenGraph : this.currentGraph();
pushHistorySnapshot = (snap: any) => {
if (this.history() === false) return;
if (!snap) return;
this.historyStack.push(snap);
if (this.historyStack.length > this.HISTORY_CAP) {
this.historyStack = this.historyStack.slice(this.historyStack.length - this.HISTORY_CAP);
}
this.redoStack = [];
};
pushHistory = () => {
if (this.programmatic) return;
if (this.history() === false) return;
this.pushHistorySnapshot(this.snapshotCurrent());
};
closeReconnectGesture = () => {
if (!this.reconnectCloseScheduled) return;
this.reconnectCloseScheduled = false;
if (this.reconnectInFlight > 0) this.reconnectInFlight = 0;
if (!this.programmatic && this.history() !== false && this.reconnectDidWriteBack && this.reconnectPreSnapshot) {
this.pushHistorySnapshot(this.reconnectPreSnapshot);
}
this.reconnectPreSnapshot = null;
this.reconnectDidWriteBack = false;
};
scheduleReconnectClose = () => {
if (this.reconnectCloseScheduled) return;
this.reconnectCloseScheduled = true;
if (typeof setTimeout === 'function') setTimeout(this.closeReconnectGesture, 0);else Promise.resolve().then(this.closeReconnectGesture);
};
restoreGraph = (snap: any) => {
if (!snap) return;
// Cancel any in-flight drag write-back so a queued frame can't clobber the restore with
// a stale position after the programmatic guard releases.
this.pendingDragPositions.clear();
if (this.dragFlushRaf) {
if (typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(this.dragFlushRaf);
} catch (e: any) {}
}
this.dragFlushRaf = 0;
}
this.programmatic++;
try {
const fresh = {
nodes: (snap.nodes || []).map((n: any) => ({
...n
})),
connections: (snap.connections || []).map((c: any) => ({
...c
}))
};
this.commitGraph(fresh);
} finally {
this.programmatic--;
}
};
undo = () => {
if (this.historyStack.length === 0) return;
const cur = this.snapshotCurrent();
const snap = this.historyStack.pop();
if (cur) this.redoStack.push(cur);
this.restoreGraph(snap);
};
redo = () => {
if (this.redoStack.length === 0) return;
const cur = this.snapshotCurrent();
const snap = this.redoStack.pop();
if (cur) this.historyStack.push(cur);
this.restoreGraph(snap);
};
canUndo = () => this.historyStack.length > 0;
canRedo = () => this.redoStack.length > 0;
flushDragWriteBack = () => {
this.dragFlushRaf = 0;
if (this.programmatic) {
this.pendingDragPositions.clear();
return;
}
if (this.pendingDragPositions.size === 0) return;
const g = this.baseGraph();
const nodes = (g.nodes || []).map((n: any) => {
const p = n && n.id != null ? this.pendingDragPositions.get(n.id) : null;
return p ? {
...n,
x: p.x,
y: p.y
} : n;
});
this.pendingDragPositions.clear();
this.commitGraph({
...g,
nodes
});
};
scheduleDragFlush = () => {
if (this.dragFlushRaf) return;
if (typeof requestAnimationFrame === 'function') {
this.dragFlushRaf = requestAnimationFrame(this.flushDragWriteBack);
} else {
this.dragFlushRaf = 1;
Promise.resolve().then(this.flushDragWriteBack);
}
};
writeBackConnectionCreated = (c: any) => {
if (this.programmatic) return;
// T1.3 — one history entry per CONNECT gesture (BEFORE the write so the snapshot is the
// pre-connect state — snapshotCurrent reads lastWrittenGraph, still the pre-connect value).
// T2.5 — SUPPRESS while a reconnect is in flight: the paired remove+add of a reconnect
// (and a plain new-connection drag, which also rides connectionpick/drop) push ONE
// coalesced snapshot on connectiondrop instead (D-03 one-gesture-one-entry).
if (this.reconnectInFlight) this.reconnectDidWriteBack = true;else this.pushHistory();
const g = this.baseGraph();
const conn = {
id: c.id,
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
};
this.commitGraph({
...g,
connections: [...(g.connections || []), conn]
});
};
writeBackConnectionRemoved = (id: any) => {
if (this.programmatic) return;
// T1.3 — one history entry per DISCONNECT / edge-delete gesture (BEFORE the write).
// T2.5 — SUPPRESS while a reconnect is in flight: the remove half of a reconnect is
// coalesced with its paired add into ONE snapshot pushed on connectiondrop (D-03).
if (this.reconnectInFlight) this.reconnectDidWriteBack = true;else this.pushHistory();
const g = this.baseGraph();
this.commitGraph({
...g,
connections: (g.connections || []).filter((e: any) => e && e.id !== id)
});
};
clearEdgeSelection = () => {
if (this.selectedPathEl && this.selectedPathEl.classList) {
try {
this.selectedPathEl.classList.remove('is-selected');
} catch (e: any) {}
}
this.selectedConnId = null;
this.selectedPathEl = null;
};
selectEdge = (id: any, pathEl: any) => {
if (id == null) return;
this.clearEdgeSelection();
this.selectedConnId = id;
this.selectedPathEl = pathEl;
if (pathEl && pathEl.classList) {
try {
pathEl.classList.add('is-selected');
} catch (e: any) {}
}
this.edgeClickGuard = true;
Promise.resolve().then(() => {
this.edgeClickGuard = false;
});
this.edgeClick.emit({
id
});
this.edgeSelected.emit({
id
});
};
deleteNode = (id: any) => {
if (id == null) return false;
const g = this.baseGraph();
const sid = String(id);
const nodes = (g.nodes || []).filter((n: any) => n && String(n.id) !== sid);
if (nodes.length === (g.nodes || []).length) return false;
const connections = (g.connections || []).filter((c: any) => c && String(c.source) !== sid && String(c.target) !== sid);
// T1.3 — one history entry per DELETE gesture (node + its incident edges = ONE undo).
this.pushHistory();
this.commitGraph({
...g,
nodes,
connections
});
return true;
};
freshNodeId = (baseId: any, existing: any) => {
const taken = new Set((existing || []).map((n: any) => n && n.id != null ? String(n.id) : ''));
const root = baseId != null ? String(baseId) : 'node';
let i = 1;
let candidate = root + '-copy';
while (taken.has(candidate)) {
i++;
candidate = root + '-copy-' + i;
}
return candidate;
};
duplicateNode = (id: any) => {
if (id == null) return null;
const g = this.baseGraph();
const sid = String(id);
const src = (g.nodes || []).find((n: any) => n && String(n.id) === sid);
if (!src) return null;
const newId = this.freshNodeId(src.id, g.nodes);
// Phase 45-07 (WR-02/WR-06): `$clone` is now a recursive proxy-safe deep clone
// on every target (Vue's lowering de-proxies nested reactive members via the
// `rozieDeepClone` runtime helper). The historical `$clone({ d: src.data }).d`
// object-literal wrapper — which never actually dodged the old single-toRaw
// throw on a live nested proxy — is no longer needed; clone `src.data` directly.
const clonedData = src.data != null ? structuredClone(src.data) : undefined;
const clone = {
...src,
id: newId,
x: (typeof src.x === 'number' ? src.x : 0) + 28,
y: (typeof src.y === 'number' ? src.y : 0) + 28,
data: clonedData
};
this.pushHistory();
this.commitGraph({
...g,
nodes: [...(g.nodes || []), clone]
});
return newId;
};
selectedNodeIds = () => {
if (!this.selector || !this.selector.entities) return [];
const ids = [];
for (const e of this.selector.entities.values() as any) {
if (e && e.id != null) ids.push(e.id);
}
return ids;
};
maybeEmitSelectionChange = () => {
if (this.programmatic) return;
const ids = this.selectedNodeIds();
const key = [...ids].map((x: any) => String(x)).sort().join(' ');
if (key === this.lastSelectionIds) return;
this.lastSelectionIds = key;
this.selectionChange.emit({
ids
});
// the selected set changed → repaint the minimap (selected nodes are highlighted).
if (this.scheduleMinimapRedraw) this.scheduleMinimapRedraw();
// T2.8 — the selection changed → re-track the NodeToolbar (it follows the single
// selected node; hides on multi-select / empty selection). No-op when :node-toolbar off.
if (this.syncToolbarSelection) this.syncToolbarSelection();
};
scheduleSelectionEmit = () => {
Promise.resolve().then(this.maybeEmitSelectionChange);
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(this.maybeEmitSelectionChange);
} else {
Promise.resolve().then(() => Promise.resolve().then(this.maybeEmitSelectionChange));
}
};
reconcileNodes: any = null;
reconcileConnections: any = null;
reconcileNodesRunning = false;
reconcileNodesPending = false;
serializeConn = (c: any) => ({
id: c.id,
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
});
portSchemaForType = (type: any, portReg: any) => {
const inputs = [];
const outputs = [];
if (type == null || !portReg) return {
inputs,
outputs
};
const prefix = type + '::';
for (const k in portReg) {
if (k.indexOf(prefix) !== 0) continue;
const p = portReg[k];
if (!p || p.key == null) continue;
const entry = {
key: p.key,
label: p.label,
multiple: p.multiple,
portType: p.portType
};
if (p.side === 'input') inputs.push(entry);else outputs.push(entry);
}
return {
inputs,
outputs
};
};
buildNode = (spec: any, portReg: any) => {
const label = spec.data && spec.data.label != null ? String(spec.data.label) : '';
const node = new ClassicPreset.Node(label);
node.id = spec.id;
const {
inputs,
outputs
} = this.portSchemaForType(spec.type, portReg);
for (const inp of inputs as any) {
if (!inp || inp.key == null) continue;
node.addInput(inp.key, new ClassicPreset.Input(this.SOCKET, inp.label, inp.multiple === true));
}
for (const out of outputs as any) {
if (!out || out.key == null) continue;
node.addOutput(out.key, new ClassicPreset.Output(this.SOCKET, out.label, out.multiple !== false));
}
return node;
};
getEditor = () => {
return this.editor;
};
getArea = () => {
return this.area;
};
addNode = async (spec: any) => {
if (!this.editor || !spec || spec.id == null) return null;
const node = this.buildNode(spec, this.portReg());
this.nodeInstances.set(spec.id, node);
this.nodeMeta.set(spec.id, spec);
this.programmatic++;
try {
await this.editor.addNode(node);
await this.area.translate(spec.id, {
x: spec.x || 0,
y: spec.y || 0
});
} finally {
this.programmatic--;
}
return spec.id;
};
removeNode = async (id: any) => {
if (!this.editor || id == null || !this.nodeInstances.has(id)) return false;
this.programmatic++;
try {
for (const c of this.editor.getConnections() as any) {
if (c.source === id || c.target === id) await this.editor.removeConnection(c.id);
}
await this.editor.removeNode(id);
} finally {
this.programmatic--;
}
this.nodeInstances.delete(id);
this.nodeMeta.delete(id);
return true;
};
addConnection = async (spec: any) => {
if (!this.editor || !spec || spec.source == null || spec.target == null) return null;
const srcOut = spec.sourceOutput != null ? spec.sourceOutput : 'out';
const tgtIn = spec.targetInput != null ? spec.targetInput : 'in';
const sourceNode = this.nodeInstances.get(spec.source);
const targetNode = this.nodeInstances.get(spec.target);
if (!sourceNode || !targetNode) return null;
const conn = new ClassicPreset.Connection(sourceNode, srcOut, targetNode, tgtIn);
if (spec.id != null) conn.id = spec.id;
this.programmatic++;
try {
await this.editor.addConnection(conn);
} finally {
this.programmatic--;
}
this.connInstances.set(conn.id, conn);
return conn.id;
};
removeConnection = async (id: any) => {
if (!this.editor || id == null) return false;
this.programmatic++;
try {
await this.editor.removeConnection(id);
} finally {
this.programmatic--;
}
this.connInstances.delete(id);
return true;
};
clear = async () => {
if (!this.editor) return;
this.programmatic++;
try {
await this.editor.clear();
} finally {
this.programmatic--;
}
this.nodeInstances.clear();
this.nodeMeta.clear();
this.connInstances.clear();
this.connMeta.clear();
this.lastPropNodeIds = [];
this.lastPropConnIds = [];
};
zoomToFit = async () => {
if (!this.area || !this.editor) return;
this.programmatic++;
try {
await AreaExtensions.zoomAt(this.area, this.editor.getNodes());
} finally {
this.programmatic--;
}
const k = this.area.area.transform.k;
if (k !== this.zoom()) this.zoom.set(k);
};
zoomTo = async (k: any) => {
if (!this.area || typeof k !== 'number') return;
this.programmatic++;
try {
await this.area.area.zoom(k);
} finally {
this.programmatic--;
}
if (k !== this.zoom()) this.zoom.set(k);
};
setViewport = async (vp: any) => {
if (!this.area || !vp || typeof vp !== 'object') return;
const tf = this.area.area.transform;
const k = typeof vp.k === 'number' ? vp.k : tf.k;
const x = typeof vp.x === 'number' ? vp.x : tf.x;
const y = typeof vp.y === 'number' ? vp.y : tf.y;
this.programmatic++;
try {
if (k !== this.area.area.transform.k) await this.area.area.zoom(k);
await this.area.area.translate(x, y);
} finally {
this.programmatic--;
}
if (k !== this.zoom()) this.zoom.set(k);
};
setCenter = async (x: any, y: any, opts: any) => {
if (!this.area || typeof x !== 'number' || typeof y !== 'number') return;
const k = opts && typeof opts.zoom === 'number' ? opts.zoom : this.area.area.transform.k;
const el = this.area.container;
const cw = el && el.clientWidth ? el.clientWidth : 0;
const ch = el && el.clientHeight ? el.clientHeight : 0;
const tx = cw / 2 - x * k;
const ty = ch / 2 - y * k;
this.programmatic++;
try {
if (k !== this.area.area.transform.k) await this.area.area.zoom(k);
await this.area.area.translate(tx, ty);
} finally {
this.programmatic--;
}
if (k !== this.zoom()) this.zoom.set(k);
};
ZOOM_STEP = 1.2;
clampZoom = (k: any) => {
const __minZoom = this.minZoom();
const __maxZoom = this.maxZoom();
let lo = typeof __minZoom === 'number' && __minZoom > 0 ? __minZoom : 0.01;
let hi = typeof __maxZoom === 'number' && __maxZoom > 0 ? __maxZoom : 100;
if (k < lo) return lo;
if (k > hi) return hi;
return k;
};
controlZoomIn = () => {
if (!this.area) return;
this.zoomTo(this.clampZoom(this.area.area.transform.k * this.ZOOM_STEP));
};
controlZoomOut = () => {
if (!this.area) return;
this.zoomTo(this.clampZoom(this.area.area.transform.k / this.ZOOM_STEP));
};
controlFit = () => {
this.zoomToFit();
};
toggleMode = () => {
this.mode.set(this.mode() === 'select' ? 'pan' : 'select');
};
getNodes = () => {
if (!this.area) return [];
const out = [];
for (const [id, node] of this.nodeInstances as any) {
const view = this.area.nodeViews.get(id);
out.push({
id,
label: node.label,
x: view ? view.position.x : 0,
y: view ? view.position.y : 0
});
}
return out;
};
getConnections = () => {
return this.editor ? this.editor.getConnections().map(this.serializeConn) : [];
};
getTransform = () => {
return this.area ? {
x: this.area.area.transform.x,
y: this.area.area.transform.y,
k: this.area.area.transform.k
} : null;
};
screenToFlowPosition = (clientX: any, clientY: any) => {
if (!this.area || typeof clientX !== 'number' || typeof clientY !== 'number') return null;
const el = this.area.container;
const rect = el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null;
if (!rect) return null;
const t = this.area.area.transform;
const k = t.k || 1;
return {
x: (clientX - rect.left - t.x) / k,
y: (clientY - rect.top - t.y) / k
};
};
autoArrange = async (opts: any) => {
if (!this.arrange || !this.area) return;
// Set elkjs dimensions on every live node instance from its measured node-view element
// (Pitfall 3) — without dims the classic preset stacks all nodes at (0,0).
for (const [id, node] of this.nodeInstances as any) {
const view = this.area.nodeViews ? this.area.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
node.width = el && el.offsetWidth ? el.offsetWidth : this.MINIMAP_DEFAULT_NODE_W;
node.height = el && el.offsetHeight ? el.offsetHeight : this.MINIMAP_DEFAULT_NODE_H;
}
// ONE history entry for the arrange gesture, captured BEFORE the write (pushHistory reads
// lastWrittenGraph, still the pre-arrange state). Gated on !programmatic + history.
this.pushHistory();
this.programmatic++;
try {
await this.arrange.layout(opts && opts.options ? {
options: opts.options
} : undefined);
} finally {
this.programmatic--;
}
// Read the arranged positions back into a FRESH graph object (controlled-graph contract).
// Echo-guarded: commitGraph → $model.graph re-bind must not re-enter the reconcile as a new
// gesture. (The arrange already moved the engine to these coords, so the reconcile is a
// no-op diff; the guard is belt-and-braces + suppresses any history re-entry.)
this.programmatic++;
try {
const g = this.baseGraph();
const nodes = (g.nodes || []).map((n: any) => {
const v = n && n.id != null && this.area.nodeViews ? this.area.nodeViews.get(n.id) : null;
return v && v.position ? {
...n,
x: v.position.x,
y: v.position.y
} : n;
});
this.commitGraph({
...g,
nodes
});
} finally {
this.programmatic--;
}
};
getSelectedNodes = () => {
const sel = new Set(this.selectedNodeIds().map((x: any) => String(x)));
return this.getNodes().filter((n: any) => sel.has(String(n.id)));
};
selectNode = (id: any, accumulate: any) => {
if (!this.nodeSelectApi || id == null) return;
this.nodeSelectApi.select(id, !!accumulate);
this.scheduleSelectionEmit();
};
clearSelection = () => {
if (this.nodeSelectApi) {
for (const id of this.selectedNodeIds() as any) this.nodeSelectApi.unselect(id);
}
this.clearEdgeSelection();
this.scheduleSelectionEmit();
};
selectAll = () => {
if (!this.nodeSelectApi) return;
let first = true;
for (const n of this.getNodes() as any) {
this.nodeSelectApi.select(n.id, !first);
first = false;
}
this.scheduleSelectionEmit();
};
centerOnNode = async (id: any, opts: any) => {
if (!this.area || id == null) return;
const view = this.area.nodeViews ? this.area.nodeViews.get(id) : null;
if (!view || !view.position) return;
const el = view.element;
const w = el && el.offsetWidth ? el.offsetWidth : this.MINIMAP_DEFAULT_NODE_W;
const h = el && el.offsetHeight ? el.offsetHeight : this.MINIMAP_DEFAULT_NODE_H;
await this.setCenter(view.position.x + w / 2, view.position.y + h / 2, opts);
};
static ngTemplateContextGuard(
_dir: FlowCanvas,
_ctx: unknown,
): _ctx is NodeCtx | ToolbarCtx | DefaultCtx {
return true;
}
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}
export default FlowCanvas;tsx
import type { JSX } from 'solid-js';
import { Show, createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { render } from 'solid-js/web';
import { __rozieInjectStyle, createControllableSignal, rozieAttr, rozieClass, rozieContext, rozieDisplay } from '@rozie/runtime-solid';
import { NodeEditor, ClassicPreset, Scope } from 'rete';
import { AreaPlugin, AreaExtensions } from 'rete-area-plugin';
import { ConnectionPlugin, Presets as ConnectionPresets } from 'rete-connection-plugin';
import { getDOMSocketPosition, classicConnectionPath } from 'rete-render-utils';
// T2.6 — auto-layout (D-08, verb-only). The 3 deps (rete-auto-arrange-plugin / elkjs
// @0.8.2 / web-worker) are OPTIONAL leaf peers, installed + bundle-smoked on all 6 in
// Plan 00 (the Vite/Angular-AOT/Lit rollup build resolves elkjs to the SYNCHRONOUS
// elk.bundled.js entry — no web-worker resolution error, no manual fallback switch). Only
// a consumer calling autoArrange() pulls these in.
// T2.6 — auto-layout (D-08, verb-only). The 3 deps (rete-auto-arrange-plugin / elkjs
// @0.8.2 / web-worker) are OPTIONAL leaf peers, installed + bundle-smoked on all 6 in
// Plan 00 (the Vite/Angular-AOT/Lit rollup build resolves elkjs to the SYNCHRONOUS
// elk.bundled.js entry — no web-worker resolution error, no manual fallback switch). Only
// a consumer calling autoArrange() pulls these in.
import { AutoArrangePlugin, Presets as ArrangePresets } from 'rete-auto-arrange-plugin';
// ── engine instances — null-lets so typeNeutralize types them `any` (the
// MapLibre `let instance = null` discipline). Rete's NodeEditor / AreaPlugin /
// ConnectionPlugin / DOMSocketPosition carry rich generic Schemes types that the
// loosely-typed .rozie props (any[]) don't satisfy under the strict react/solid/
// lit leaf tsc; routing every engine call through an `any` instance is the
// .rozie-native fix (no lang="ts", no codegen type-aid). These are top-level lets
// referenced from hooks → React auto-hoists each to a useRef. ──
__rozieInjectStyle('FlowCanvas-cd396d6a', `.rozie-flow-canvas[data-rozie-s-cd396d6a] {
width: 100%;
height: 100%;
min-height: 360px;
position: relative;
overflow: hidden;
border-radius: 8px;
background:
radial-gradient(circle, rgba(0, 0, 0, 0.08) 1px, transparent 1px) 0 0 / 20px 20px,
#f7f8fa;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.rozie-flow-controls[data-rozie-s-cd396d6a] {
position: absolute;
left: 10px;
bottom: 10px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 2px;
pointer-events: none;
}
.rozie-flow-controls__btn[data-rozie-s-cd396d6a] {
pointer-events: auto;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
font: 600 16px/1 system-ui, sans-serif;
color: #334155;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
cursor: pointer;
user-select: none;
}
.rozie-flow-controls__btn[data-rozie-s-cd396d6a]:hover { background: #f1f5f9; }
.rozie-flow-controls__btn[data-rozie-s-cd396d6a]:active { background: #e2e8f0; }
.rozie-flow-controls__btn.is-active[data-rozie-s-cd396d6a] { background: #dbeafe; color: #1d4ed8; border-color: #3b82f6; }
.rozie-flow-marquee[data-rozie-s-cd396d6a] {
position: absolute;
display: none;
z-index: 9;
pointer-events: none;
background: rgba(59, 130, 246, 0.12);
border: 1px solid #3b82f6;
border-radius: 2px;
}
.rozie-flow-minimap[data-rozie-s-cd396d6a] {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 10;
width: 200px;
height: 150px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
overflow: hidden;
cursor: pointer;
touch-action: none;
}
.rozie-flow-minimap__svg[data-rozie-s-cd396d6a] { display: block; width: 100%; height: 100%; }
.rozie-flow-toolbar[data-rozie-s-cd396d6a] {
position: absolute;
display: none;
z-index: 11;
gap: 4px;
padding: 3px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
pointer-events: auto;
white-space: nowrap;
}
.rozie-flow-toolbar__btn[data-rozie-s-cd396d6a] {
font: 600 12px/1 system-ui, sans-serif;
color: #334155;
background: #f8fafc;
border: 1px solid rgba(0, 0, 0, 0.14);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
user-select: none;
}
.rozie-flow-toolbar__btn[data-rozie-s-cd396d6a]:hover { background: #eef2f7; }
.rozie-flow-toolbar__btn[data-rozie-s-cd396d6a]:active { background: #e2e8f0; }
.rozie-flow-toolbar__btn--delete[data-rozie-s-cd396d6a] { color: #b91c1c; }
.rozie-flow-canvas .rozie-flow-node {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
min-width: 140px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
user-select: none;
cursor: grab;
font: 13px/1.4 system-ui, sans-serif;
}
.rozie-flow-canvas .rozie-flow-node.is-selected {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5), 0 2px 8px rgba(0, 0, 0, 0.15);
}
.rozie-flow-canvas .rozie-flow-node__title {
padding: 0.5rem 0.75rem;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
}
.rozie-flow-canvas .rozie-flow-node__body { min-width: 0; }
.rozie-flow-canvas .rozie-flow-node__col {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 0;
}
.rozie-flow-canvas .rozie-flow-port {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: #6b7280;
}
.rozie-flow-canvas .rozie-flow-port--output { justify-content: flex-end; }
.rozie-flow-canvas .rozie-flow-socket {
width: 12px;
height: 12px;
border-radius: 50%;
background: #94a3b8;
border: 2px solid #ffffff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
cursor: crosshair;
flex: none;
}
.rozie-flow-canvas .rozie-flow-socket--input { margin-left: -6px; }
.rozie-flow-canvas .rozie-flow-socket--output { margin-right: -6px; }
.rozie-flow-canvas .rozie-flow-socket:hover { background: #3b82f6; }
.rozie-flow-canvas .rozie-flow-node--rows {
display: flex;
flex-direction: column;
}
.rozie-flow-canvas .rozie-flow-node__mid {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
}
.rozie-flow-canvas .rozie-flow-node__row {
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
padding: 0 0.5rem;
}
.rozie-flow-canvas .rozie-flow-port--vertical {
flex-direction: column;
align-items: center;
gap: 0.125rem;
font-size: 0.7rem;
}
.rozie-flow-canvas .rozie-flow-socket--top,
.rozie-flow-canvas .rozie-flow-socket--bottom { margin-left: 0; margin-right: 0; }
.rozie-flow-canvas .rozie-flow-socket--top { margin-top: -6px; }
.rozie-flow-canvas .rozie-flow-socket--bottom { margin-bottom: -6px; }
.rozie-flow-canvas .rozie-flow-connection { position: absolute; }
.rozie-flow-canvas .rozie-flow-connection__svg {
/* display:block is LOAD-BEARING, not cosmetic. An <svg> is display:inline by
default, so the 1px-tall connection SVG sits on the connection element's TEXT
BASELINE — which, with the engine container's default line-height, pushes the
whole path DOWN ~14px. That offset is in screen space (the connection element
is the area-transform origin), so EVERY connection endpoint lands ~14px below
its socket — visibly anchoring connectors at the BOTTOM of each node instead
of on the socket. The socket positions reported by getDOMSocketPosition are
already correct (offsetTop/offsetLeft within the node-view); the inline
baseline is the sole cause of the vertical drift. block (or equivalently
line-height:0 / vertical-align:top on the inline box) removes the baseline gap
so the path renders at its true coordinates. Verified: drops the endpoint→
socket vertical offset from ~13.9px to ~0.1px on all 6 targets. */
display: block;
overflow: visible;
width: 1px;
height: 1px;
pointer-events: none;
}
.rozie-flow-canvas .rozie-flow-connection__path {
fill: none;
stroke: #64748b;
stroke-width: 3px;
pointer-events: auto;
}
.rozie-flow-canvas .rozie-flow-connection__path.is-selected {
stroke: #3b82f6;
stroke-width: 4px;
}
.rozie-flow-canvas .rozie-flow-connection__label {
font: 600 11px system-ui, sans-serif;
fill: #334155;
paint-order: stroke;
stroke: #ffffff;
stroke-width: 3px;
stroke-linejoin: round;
pointer-events: none;
user-select: none;
}`);
interface NodeSlotCtx { node: any; selected: any; emit: any; }
interface ToolbarSlotCtx { node: any; emit: any; }
interface FlowCanvasProps {
/**
* The single source of truth (two-way `r-model`) — `{ nodes: [{ id, type, x, y, data? }], connections: [{ id?, source, sourceOutput?, target, targetInput?, label?, stroke?, dashed? }] }`. A node's `type` selects its `<NodeType>` template (render-by-type + port schema); `data` is the opaque payload handed to that type's `#body` scope. The canvas writes back a FRESH top-level object on every drag (x/y) and connect/disconnect (connections) — immutable applyNodeChanges style. `sourceOutput`/`targetInput` default to `out`/`in`; a missing connection `id` is derived from the endpoints.
* @example
* <FlowCanvas r-model:graph="graph" :validate-types="true" />
*/
graph?: Record<string, any>;
defaultGraph?: Record<string, any>;
onGraphChange?: (graph: Record<string, any>) => void;
/**
* Automatic typed-socket validation (default ON). When `true`, the canvas resolves each endpoint's port type from the per-`<NodeType>` `<Port type>` schema and auto-rejects a type-mismatched connection (firing `connection-rejected`). `canConnect` survives as the optional custom-rule override that runs in addition. Set `false` for pure-`canConnect` (type as metadata only).
*/
validateTypes?: boolean;
/**
* The viewport zoom level (two-way `r-model`). Scroll/pinch writes the new zoom back through the model (echo-guarded against the wrapper's own programmatic zooms); a consumer write zooms the live area. There is deliberately no `zoom`/`zoomed` emit — a same-named emit collides with the model on Vue and Angular — so the two-way binding is the channel for zoom changes.
*/
zoom?: number;
defaultZoom?: number;
onZoomChange?: (zoom: number) => void;
/**
* Whether the canvas can be panned by dragging the background (applied at construction). Set `false` to detach the area's drag handler.
*/
pannable?: boolean;
/**
* Whether the canvas can be zoomed by scroll/pinch (applied at construction). Set `false` to detach the area's zoom handler.
*/
zoomable?: boolean;
/**
* Whether nodes can be selected (click; ctrl-click to accumulate). Reflected as the `selected` flag in the `<NodeType>` `#body` scope and surfaced to the consumer via the `@selection-change` event.
*/
selectable?: boolean;
/**
* Read-only viewer mode — no node drag, no connection editing, and no selection. View-only zoom/fit (Controls, the `zoomTo`/`zoomToFit` verbs) stay enabled.
*/
readonly?: boolean;
/**
* Minimum zoom level — the lower bound of the area's zoom restrictor. `0` disables the bound.
*/
minZoom?: number;
/**
* Maximum zoom level — the upper bound of the area's zoom restrictor. `0` disables the bound.
*/
maxZoom?: number;
/**
* Snap-to-grid size in pixels for node dragging. `0` turns snapping off.
*/
snapGrid?: number;
/**
* When selectable, hold Ctrl to add to the current selection instead of replacing it.
*/
accumulateOnCtrl?: boolean;
/**
* The bezier curvature of connection paths (`classicConnectionPath`).
*/
curvature?: number;
/**
* After the initial graph mounts, pan/zoom the viewport to fit all nodes (`AreaExtensions.zoomAt`).
*/
fitOnMount?: boolean;
/**
* Render the built-in Controls overlay — a zoom in / zoom out / fit-view button cluster (the React Flow `<Controls/>` parity). The buttons drive the same zoom/fit path as the `zoomTo`/`zoomToFit` handle verbs (clamped to `minZoom`/`maxZoom`) and stay enabled in `readonly`. Opt out with `:controls="false"`.
*/
controls?: boolean;
/**
* Render the built-in MiniMap overlay (opt-in, default OFF — the React Flow `<MiniMap/>` parity) — an absolute SVG panel (bottom-right) showing a scaled map of every node (sized from the measured engine node-view dims) plus the current viewport window (the area outside dimmed). It is pannable: dragging the minimap recenters the main viewport (via `setCenter`). Evaluated at construction, like `pannable`/`zoomable`/`controls` — set it at mount time.
*/
minimap?: boolean;
/**
* Connection-validation predicate `(conn) => boolean`, receiving the normalized candidate connection `{ source, sourceOutput, target, targetInput }`. Return `false` to reject the connection — no edge is committed, no ghost path is drawn, and `connection-rejected` fires. Runs in addition to the automatic `:validate-types` check (the custom-rule override) and gates all connection paths uniformly (drag-to-connect, imperative `addConnection`, graph reconcile). Absent/`null` imposes no custom rule.
*/
canConnect?: ((...args: unknown[]) => unknown) | null;
/**
* Undo/redo, on by default. Every gesture (drag, connect, disconnect, delete) pushes ONE capped (~100) snapshot of the bound graph (nodes incl. x/y + connections; not the viewport), and `undo()`/`redo()` plus Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, and Ctrl/Cmd+Y restore it through the two-way `graph` model (echo-guarded). One gesture = one undo step; a fresh edit after an undo discards the redo branch. Opt out with `:history="false"` (the snapshot stack stays empty and the verbs no-op).
*/
history?: boolean;
/**
* Two-way interaction mode (`r-model`) — the Figma-style pan ↔ select toggle, `'pan'` (default) or `'select'`. In `'pan'` an empty-canvas drag pans the viewport (unchanged). In `'select'` an empty-canvas drag draws a rubber-band marquee box that multi-selects the intersecting nodes (surfacing `@selection-change`). A node drag still drags the node in both modes — only the empty-canvas drag changes. The canvas writes it back when the built-in mode button toggles (see `marquee`).
*/
mode?: string;
defaultMode?: string;
onModeChange?: (mode: string) => void;
/**
* Render the 4th Controls button — the pan ↔ select mode toggle (it two-way-writes `mode`). Default OFF so the default Controls overlay keeps its three buttons. The marquee behavior works whenever `mode === 'select'` regardless of this flag (a consumer can drive `mode` directly); this only governs the built-in button.
*/
marquee?: boolean;
/**
* Render the opt-in NodeToolbar (default OFF) — a floating toolbar over the single selected node (positioned from the engine node-view rect + the area transform, re-tracked on pan/zoom/drag). Default content is Delete (cascading controlled-graph `deleteNode`) + Duplicate (clone the node spec at an offset with a new id into a fresh `graph` object); both fire `@node-action` (`name: 'delete' | 'duplicate'`). Override the content by filling the `#toolbar` reactive slot.
*/
nodeToolbar?: boolean;
onEdgeClick?: (...args: unknown[]) => void;
onEdgeSelected?: (...args: unknown[]) => void;
onSelectionChange?: (...args: unknown[]) => void;
onConnectEnd?: (...args: unknown[]) => void;
onNodeAction?: (...args: unknown[]) => void;
onConnectionRejected?: (...args: unknown[]) => void;
onConnectionCreated?: (...args: unknown[]) => void;
onConnectionRemoved?: (...args: unknown[]) => void;
onNodePicked?: (...args: unknown[]) => void;
onNodeMoved?: (...args: unknown[]) => void;
onTranslated?: (...args: unknown[]) => void;
onContextMenu?: (...args: unknown[]) => void;
nodeSlot?: (ctx: () => NodeSlotCtx) => JSX.Element;
toolbarSlot?: (ctx: () => ToolbarSlotCtx) => JSX.Element;
// D-131: default slot resolved via children() at body top
children?: JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
ref?: (h: FlowCanvasHandle) => void;
}
export interface FlowCanvasHandle {
getEditor: (...args: any[]) => any;
getArea: (...args: any[]) => any;
addNode: (...args: any[]) => any;
removeNode: (...args: any[]) => any;
deleteNode: (...args: any[]) => any;
addConnection: (...args: any[]) => any;
removeConnection: (...args: any[]) => any;
clear: (...args: any[]) => any;
zoomToFit: (...args: any[]) => any;
zoomTo: (...args: any[]) => any;
setCenter: (...args: any[]) => any;
setViewport: (...args: any[]) => any;
screenToFlowPosition: (...args: any[]) => any;
getNodes: (...args: any[]) => any;
getConnections: (...args: any[]) => any;
getTransform: (...args: any[]) => any;
autoArrange: (...args: any[]) => any;
undo: (...args: any[]) => any;
redo: (...args: any[]) => any;
canUndo: (...args: any[]) => any;
canRedo: (...args: any[]) => any;
getSelectedNodes: (...args: any[]) => any;
selectNode: (...args: any[]) => any;
clearSelection: (...args: any[]) => any;
selectAll: (...args: any[]) => any;
centerOnNode: (...args: any[]) => any;
}
export default function FlowCanvas(_props: FlowCanvasProps): JSX.Element {
const _merged = mergeProps({ validateTypes: true, pannable: true, zoomable: true, selectable: true, readonly: false, minZoom: 0.1, maxZoom: 4, snapGrid: 0, accumulateOnCtrl: true, curvature: 0.3, fitOnMount: true, controls: true, minimap: false, canConnect: null, history: true, marquee: false, nodeToolbar: false }, _props);
const [local, attrs] = splitProps(_merged, ['graph', 'validateTypes', 'zoom', 'pannable', 'zoomable', 'selectable', 'readonly', 'minZoom', 'maxZoom', 'snapGrid', 'accumulateOnCtrl', 'curvature', 'fitOnMount', 'controls', 'minimap', 'canConnect', 'history', 'mode', 'marquee', 'nodeToolbar', 'children', 'ref']);
const resolved = () => local.children;
onMount(() => { local.ref?.({ getEditor, getArea, addNode, removeNode, deleteNode, addConnection, removeConnection, clear, zoomToFit, zoomTo, setCenter, setViewport, screenToFlowPosition, getNodes, getConnections, getTransform, autoArrange, undo, redo, canUndo, canRedo, getSelectedNodes, selectNode, clearSelection, selectAll, centerOnNode }); });
const __ctx_rete_canvas = rozieContext("rete:canvas");
const [graph, setGraph] = createControllableSignal<Record<string, any>>(_props as unknown as Record<string, unknown>, 'graph', (() => ({
nodes: [],
connections: []
}))());
const [zoom, setZoom] = createControllableSignal<number>(_props as unknown as Record<string, unknown>, 'zoom', 1);
const [mode, setMode] = createControllableSignal<string>(_props as unknown as Record<string, unknown>, 'mode', 'pan');
const [typeReg, setTypeReg] = createSignal<Record<string, any>>({});
const [portReg, setPortReg] = createSignal<Record<string, any>>({});
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portalDisposers = new Set<() => void>();
const portals = {
node: (container: HTMLElement, scope: { node: unknown; selected: unknown; emit: unknown }): ReactivePortalHandle => {
const slot = _props.nodeSlot ?? _props.slots?.['node'];
if (typeof slot !== 'function') return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-node', 'cd396d6a');
const [scopeSig, setScopeSig] = createSignal<unknown>(scope, { equals: false });
const dispose = render(() => slot(scopeSig as unknown as (() => { node: unknown; selected: unknown; emit: unknown })), container);
portalDisposers.add(dispose);
return {
update: (s: unknown): void => {
setScopeSig(s);
},
dispose: (): void => {
dispose();
portalDisposers.delete(dispose);
},
};
},
toolbar: (container: HTMLElement, scope: { node: unknown; emit: unknown }): ReactivePortalHandle => {
const slot = _props.toolbarSlot ?? _props.slots?.['toolbar'];
if (typeof slot !== 'function') return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-toolbar', 'cd396d6a');
const [scopeSig, setScopeSig] = createSignal<unknown>(scope, { equals: false });
const dispose = render(() => slot(scopeSig as unknown as (() => { node: unknown; emit: unknown })), container);
portalDisposers.add(dispose);
return {
update: (s: unknown): void => {
setScopeSig(s);
},
dispose: (): void => {
dispose();
portalDisposers.delete(dispose);
},
};
},
};
onCleanup(() => {
for (const dispose of portalDisposers) dispose();
portalDisposers.clear();
});
onMount(() => {
const _cleanup = (() => {
const container = canvasElRef;
lastPropNodeIds = [];
lastPropConnIds = [];
editor = new NodeEditor();
area = new AreaPlugin(container);
connectionPlugin = new ConnectionPlugin();
connectionPlugin.addPreset(ConnectionPresets.classic.setup());
// Resolve a port's VISUAL position (F2) from the per-TYPE port schema (portReg, keyed
// `type::side::key`), defaulting by DIRECTION (input → left, output → right) for exact
// back-compat. DEFINED HERE inside $onMount (NOT top level) so its $data.portReg read
// lowers on React to the live `_portRegRef.current`, not a stale-empty mount-time
// closure (the portTypeOf discipline). Used by both the socket-anchor offset below and
// renderNode's socket layout.
const resolvePortPosition = (type: any, side: any, key: any) => {
const entry = type != null && key != null ? portReg()[type + '::' + side + '::' + key] : null;
const p = entry && entry.position != null ? entry.position : null;
if (p === 'left' || p === 'right' || p === 'top' || p === 'bottom') return p;
return side === 'input' ? 'left' : 'right';
};
// DOM-based socket position watcher — feeds connection-path redraw + the
// ConnectionPlugin's drag-to-connect hit-testing. A CUSTOM `offset` (F2): the rete
// default shifts the anchor 12px OUTWARD on the X axis only (`x + 12·(input?−1:1)`) —
// correct for left/right, wrong for top/bottom. We resolve each socket's visual
// position and shift on the matching axis (±x for left/right — IDENTICAL to the default,
// so the rete-flow-align cell stays green; ±y for top/bottom). The position is looked up
// live via nodeMeta→type→portReg, so it tracks late-registered ports.
const SOCKET_SHIFT = 12;
const socketOffset = (position: any, nodeId: any, side: any, key: any) => {
const meta = nodeMeta.get(nodeId);
const p = meta ? resolvePortPosition(meta.type, side, key) : side === 'input' ? 'left' : 'right';
if (p === 'top') return {
x: position.x,
y: position.y - SOCKET_SHIFT
};
if (p === 'bottom') return {
x: position.x,
y: position.y + SOCKET_SHIFT
};
if (p === 'left') return {
x: position.x - SOCKET_SHIFT,
y: position.y
};
return {
x: position.x + SOCKET_SHIFT,
y: position.y
};
};
socketWatcher = getDOMSocketPosition({
offset: socketOffset
});
editor.use(area);
area.use(connectionPlugin);
// ── T2.5 RECONNECT coalescing pipe (D-08 reconnectable edges, D-03 one-gesture-one-entry) ──
// `connectionpick` / `connectiondrop` are emitted on the ConnectionPlugin's OWN scope (they
// are NOT editor signals like connectioncreated/removed, nor area signals like nodepicked),
// so they must be observed via a pipe attached DIRECTLY to `connectionPlugin` — they do not
// propagate into editor.addPipe / area.addPipe. Grabbing an already-connected input socket
// fires connectionpick, then the classic preset removes the old edge + (on drop over a new
// socket) adds a new one — a remove+add pair that would push TWO history entries (Pitfall 2).
// We open a reconnect-in-flight window on connectionpick (capturing the PRE-gesture snapshot
// ONCE) and close it on connectiondrop (pushing that single snapshot iff the gesture actually
// changed the graph) — so the whole reconnect is ONE undoable step.
connectionPlugin.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectionpick') {
// Open the coalesce window + capture the pre-gesture snapshot once. Gated on
// !programmatic + history (a restore-driven engine op must not record history). A
// re-pick while a close is pending cancels the pending close (the gesture continues).
if (!programmatic && local.history !== false) {
reconnectInFlight++;
reconnectPreSnapshot = snapshotCurrent();
reconnectDidWriteBack = false;
reconnectCloseScheduled = false;
}
} else if (context.type === 'connectiondrop') {
// The gesture ended. CRITICAL ORDERING: the classic preset emits `connectiondrop`
// BEFORE the editor's `connectionremoved` / `connectioncreated` signals fire (the
// pseudo-connection is dropped, THEN the real add/remove run — verified in the event
// trace: drop → connectioncreate → connectioncreated → connectionremove →
// connectionremoved). So we must NOT close the window synchronously here, or the
// trailing writeBacks would run with inFlight=0 and each push its own (wrong) history
// entry. Instead DEFER the close to a macrotask (setTimeout 0), which runs after all
// the synchronous + microtask writeBack signals have settled. The window stays open
// across the remove+add (both suppress their per-event push, setting
// reconnectDidWriteBack), then closeReconnectGesture pushes the SINGLE pre-gesture
// snapshot iff the graph actually changed. Re-entrant picks can't desync because the
// close is gated on a one-shot scheduled flag.
scheduleReconnectClose();
// ── T2.7 CONNECT-END-ON-PANE (D-07, pure emit) ──
// A drag that STARTED on an output socket and ENDED on empty canvas (no target
// socket, no connection created) surfaces `@connect-end { source, sourceOutput,
// position }` so the consumer can run its OWN node-picker / create-node flow at the
// drop point (the n8n "drag off a port → drop on the pane → pick a node" UX). The
// component owns ONLY this hook — it creates NO node and shows NO picker (D-07,
// consumer-owns-creation, exactly like screenToFlowPosition + the palette drop).
// Detection: `socket == null` (released over the pane, not a socket) && `created ==
// false` (no edge was made) && `initial.side === 'output'` (we only surface OUTPUT-
// started drags — an input-started drag off the pane has no "source output" to seed
// a downstream node from, and the reconnect path already owns input-endpoint drags).
// Position = `area.area.pointer` (the AreaPlugin's live pointer, ALREADY in graph
// coords — the same origin screenToFlowPosition projects into), so no client→graph
// projection is needed; we still fall back to screenToFlowPosition over a raw
// clientX/clientY if a future plugin build stops tracking area.area.pointer. Gated on
// !programmatic so a restore/imperative-driven drop never emits. NO node is created.
const cd = context.data;
if (cd && !cd.socket && cd.created === false && cd.initial && cd.initial.side === 'output' && !programmatic) {
let pos: any = null;
const inner = area && area.area ? area.area : null;
if (inner && inner.pointer && typeof inner.pointer.x === 'number' && typeof inner.pointer.y === 'number') {
pos = {
x: inner.pointer.x,
y: inner.pointer.y
};
}
if ((!pos || typeof pos.x !== 'number' || typeof pos.y !== 'number') && cd.initial && cd.initial.element && typeof cd.initial.element.getBoundingClientRect === 'function') {
// Fallback: project the last-known pointer client coords through the shipped
// screenToFlowPosition (graph-coord inverse of the area transform). The drop event
// carries no pointer; use the source socket element's center as a degraded anchor.
const r = cd.initial.element.getBoundingClientRect();
pos = screenToFlowPosition(r.left + r.width / 2, r.top + r.height / 2);
}
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
_props.onConnectEnd?.({
source: cd.initial.nodeId,
sourceOutput: cd.initial.key,
position: {
x: pos.x,
y: pos.y
}
});
}
}
}
return context;
});
// The socket-position watcher (and, conceptually, our vanilla "render plugin")
// must attach to a CHILD scope of the area — `attach` calls
// `scope.parentScope(BaseAreaPlugin)`, which walks UP one level, so the scope's
// parent must BE the area. Attaching to `area` itself fails ("actual parent is
// not instance of type") because area's parent is the NodeEditor. So we add a
// minimal child Scope and attach the watcher to it. Rete forwards every area
// signal (render/nodetranslated/unmount/…) into this child's signal, so the
// watcher sees socket renders + node moves and recomputes socket positions.
renderScope = new Scope('rozie-vanilla-render');
area.use(renderScope);
socketWatcher.attach(renderScope);
// ── T2.6 auto-layout (D-08, verb-only) ──
// Wire the AutoArrangePlugin (elkjs classic preset) so the top-level autoArrange() verb
// can run a layered relayout on demand. area.use(arrange) installs it as an area-scope
// plugin; arrange.layout() mutates the engine node positions directly (calls area.translate
// internally). The verb reads the arranged positions BACK into a FRESH $model.graph (the
// controlled-graph contract — the engine is never the source of truth). NO auto-trigger —
// the consumer calls autoArrange() (the MapLibre verb-first stance).
arrange = new AutoArrangePlugin();
arrange.addPreset(ArrangePresets.classic.setup());
area.use(arrange);
// ── selection (selectableNodes) ──
// Capture the returned handle ({ select(id, accumulate), unselect(id) }) so the T2.4
// marquee can PROGRAMMATICALLY select each intersecting node (select(id, true) =
// accumulate). The handle is null when selection is off (readonly / !selectable), in
// which case the marquee branch no-ops.
if (local.selectable && !local.readonly) {
selector = AreaExtensions.selector();
nodeSelectApi = AreaExtensions.selectableNodes(area, selector, {
accumulating: local.accumulateOnCtrl ? AreaExtensions.accumulateOnCtrl() : {
active: () => false
}
});
}
// raise the picked node above its siblings.
AreaExtensions.simpleNodesOrder(area);
// ── zoom clamp (restrictor) ──
const min = typeof local.minZoom === 'number' && local.minZoom > 0 ? local.minZoom : 0;
const max = typeof local.maxZoom === 'number' && local.maxZoom > 0 ? local.maxZoom : 0;
if (min || max) {
AreaExtensions.restrictor(area, {
scaling: {
min: min || 0.01,
max: max || 100
}
});
}
// ── snap-to-grid ──
if (typeof local.snapGrid === 'number' && local.snapGrid > 0) {
AreaExtensions.snapGrid(area, {
size: local.snapGrid,
dynamic: true
});
}
// ── interaction toggles ──
if (!local.pannable) area.area.setDragHandler(null);
if (!local.zoomable) area.area.setZoomHandler(null);
// ── Delete / Backspace key → cascading delete of the selected node(s) (Win 1) ──
// Attached to the engine container ($refs.canvasEl, which carries tabindex="0" in
// the template so it can receive key focus) rather than `document`: the listener
// lives INSIDE the Lit shadow root alongside the canvas, so a canvas-focused key
// reaches it on Lit too (a `:target="document"` listener does not reliably see
// shadow-scoped focus across all 6 — the canvas-element listener is the robust
// cross-target path). Gated on selectable && !readonly. We guard against deleting
// while focus is in a node-body text field (INPUT/TEXTAREA/contenteditable) so
// typing in a node never nukes it. The listener is removed in the teardown.
if (local.selectable && !local.readonly && container && typeof container.addEventListener === 'function') {
onCanvasKeydown = (e: any) => {
if (!e) return;
const t = e.target;
// Focus-guard (verbatim with the Delete branch): never act while focus is in a
// node-body text field (INPUT/TEXTAREA/contenteditable) — Ctrl+Z must reach the
// browser's native text undo there, and Delete must not nuke the node.
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
// ── T1.3 — Undo / Redo keybinds (D-02). Ctrl/Cmd+Z → undo; Ctrl/Cmd+Shift+Z and
// Ctrl/Cmd+Y → redo. Gated on the SAME focus-guard as Delete. preventDefault so the
// browser's page-level undo doesn't also fire. `metaKey` covers macOS Cmd. ──
if ((e.ctrlKey || e.metaKey) && !e.altKey) {
const k = typeof e.key === 'string' ? e.key.toLowerCase() : '';
if (k === 'z' && !e.shiftKey) {
e.preventDefault();
undo();
return;
}
if (k === 'z' && e.shiftKey || k === 'y') {
e.preventDefault();
redo();
return;
}
}
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
const ids = selectedNodeIds();
if (ids.length > 0) {
e.preventDefault();
for (const id of ids as any) deleteNode(id);
return;
}
// T1.1 — EDGE DELETE (D-08). No node is picked but an edge is selected → remove
// exactly that edge via the controlled-graph write-back (the disconnect path: a
// fresh `{ ...g, connections: filtered }` object), then clear the selection. The
// wrapper's own $watch(graph) reconcile reaps the live engine connection (the
// single removal path — we do NOT also call editor.removeConnection, which would
// race the reconcile into "cannot find connection", mirroring deleteNode). Node
// delete takes precedence (handled above); this only runs when nothing's picked.
if (selectedConnId != null) {
e.preventDefault();
const id = selectedConnId;
clearEdgeSelection();
writeBackConnectionRemoved(id);
}
};
keydownContainer = container;
container.addEventListener('keydown', onCanvasKeydown);
}
// ─────────────────────────────────────────────────────────────────────────
// THE VANILLA RENDER PIPE. Intercepts the AreaPlugin's render/unmount signals.
// ALWAYS returns context (returning undefined would halt the signal chain and
// break the ConnectionPlugin / socket watcher downstream).
// ─────────────────────────────────────────────────────────────────────────
area.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'render') {
const data = context.data;
if (data.type === 'node') renderNode(data.element, data.payload);else if (data.type === 'connection') renderConnection(data.element, data.payload, data.start, data.end);
// data.type === 'socket' (our own re-emitted signals) falls through
// untouched so the ConnectionPlugin + socketWatcher consume them.
} else if (context.type === 'unmount') {
cleanupElement(context.data.element);
}
return context;
});
// ── node renderer ──
// Fills the engine-created nodeView element with: input sockets, the body
// (consumer `node` portal fragment OR default chrome), and output sockets.
// Re-render (area.update('node', id)) reuses the same element → update in place.
// NOTE: the engine-node parameter is `reteNode`, NOT `node` — on Svelte the
// `$slots.node` slot lowers to a top-level `const node`, and a parameter named
// `node` here would SHADOW it, so `if ($slots.node)` would read the (always-
// truthy) engine node and wrongly take the portal branch even when the slot is
// unfilled (dropping the default-chrome title). The cross-target slot-name ==
// local-binding shadow trap.
const renderNode = (element: any, reteNode: any) => {
// a (re)render means node DOM exists / changed → refresh the minimap (its node
// rects measure these elements; coalesced, so calling it on every render is cheap,
// and it covers Lit's measure-after-first-paint).
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
const id = reteNode.id;
const meta = nodeMeta.get(id) || {
id,
type: undefined,
data: {}
};
const existing = nodeEntries.get(id);
const selected = reteNode.selected === true;
// default-chrome fallback label (only when a node's type has no #body template).
const chromeLabel = meta.data && meta.data.label != null ? String(meta.data.label) : meta.type != null ? String(meta.type) : '';
if (existing && existing.element === element) {
// in-place update — refresh chrome + reactive portal scope, leave sockets.
existing.box.classList.toggle('is-selected', selected);
if (existing.handle) {
existing.handle.update({
node: meta,
selected,
emit: existing.emit
});
} else if (existing.titleEl) {
existing.titleEl.textContent = chromeLabel;
}
return;
}
// fresh build
element.innerHTML = '';
const box = document.createElement('div');
box.className = 'rozie-flow-node' + (selected ? ' is-selected' : '');
const body = document.createElement('div');
body.className = 'rozie-flow-node__body';
// ── socket layout (F2: position-aware) ───────────────────────────────────────
// Bucket the node's ports by VISUAL position (default input→left, output→right).
// When NO port is top/bottom (every pre-F2 graph), render the EXACT classic
// [inputsCol | body | outputsCol] 3-column structure — byte-identical DOM, so the
// FlowCanvasScreenshot pixel baseline is untouched. A node that declares ANY top/
// bottom port gets the 3-ROW structure (topRow / midRow[left|body|right] / bottomRow).
const socketDisposers = [];
const portEntries = [];
for (const key of Object.keys(reteNode.inputs) as any) portEntries.push({
side: 'input',
key,
position: resolvePortPosition(meta.type, 'input', key)
});
for (const key of Object.keys(reteNode.outputs) as any) portEntries.push({
side: 'output',
key,
position: resolvePortPosition(meta.type, 'output', key)
});
const hasVertical = portEntries.some((p: any) => p.position === 'top' || p.position === 'bottom');
if (!hasVertical) {
// CLASSIC left/right layout — byte-for-byte identical to pre-F2 (pixel-baseline safe).
const inputsCol = document.createElement('div');
inputsCol.className = 'rozie-flow-node__col rozie-flow-node__col--in';
const outputsCol = document.createElement('div');
outputsCol.className = 'rozie-flow-node__col rozie-flow-node__col--out';
box.appendChild(inputsCol);
box.appendChild(body);
box.appendChild(outputsCol);
element.appendChild(box);
for (const p of portEntries as any) {
renderSocketInto(p.position === 'right' ? outputsCol : inputsCol, reteNode, p.side, p.key, p.position, socketDisposers);
}
} else {
// VERTICAL-capable 3-row layout (only when a top/bottom port exists).
box.classList.add('rozie-flow-node--rows');
const topRow = document.createElement('div');
topRow.className = 'rozie-flow-node__row rozie-flow-node__row--top';
const midRow = document.createElement('div');
midRow.className = 'rozie-flow-node__mid';
const leftCol = document.createElement('div');
leftCol.className = 'rozie-flow-node__col rozie-flow-node__col--in';
const rightCol = document.createElement('div');
rightCol.className = 'rozie-flow-node__col rozie-flow-node__col--out';
const bottomRow = document.createElement('div');
bottomRow.className = 'rozie-flow-node__row rozie-flow-node__row--bottom';
midRow.appendChild(leftCol);
midRow.appendChild(body);
midRow.appendChild(rightCol);
box.appendChild(topRow);
box.appendChild(midRow);
box.appendChild(bottomRow);
element.appendChild(box);
for (const p of portEntries as any) {
const zone = p.position === 'top' ? topRow : p.position === 'bottom' ? bottomRow : p.position === 'right' ? rightCol : leftCol;
renderSocketInto(zone, reteNode, p.side, p.key, p.position, socketDisposers);
}
}
// emit per-node event helper handed to the slot scope so a consumer node body
// can raise a custom event carrying its id (e.g. a delete button).
const emit = (name: any, detail: any) => _props.onNodeAction?.({
id,
name,
detail
});
const entry = {
element,
box,
body,
handle: null,
bodyHandle: null,
titleEl: null,
bodyMoved: false,
emit,
socketDisposers
};
// ── RENDER-BY-TYPE: select the body by `node.type` ──────────────────────────
// 1) the node's TYPE template (typeReg[type].bodyRenderer) — the primary path
// (41-03 <NodeType><template #body>); 2) the low-level `#node` portal slot
// (consumer switches on node.type itself — escape hatch); 3) default chrome.
const typeSpec = meta.type != null ? typeReg()[meta.type] : null;
if (typeSpec && typeof typeSpec.bodyRenderer === 'function') {
// RENDER-BY-TYPE callback path. The <NodeType> cannot relocate its OWN <slot>
// across the Lit shadow boundary (Wave-0 A3), so the PARENT projects the body
// here from its own render scope: the type's registered bodyRenderer(host, scope)
// mounts the type's `#body` portal INTO the engine `body` div (a FRESH render
// root per node — no framework DOM relocation, the Phase-37 D-04 trap avoided).
// nodeEntries must exist before the callback runs (bodyHostFor reads it), so
// register first. The graph node's `data` flows in as scope → one template per
// type renders every instance of that type.
nodeEntries.set(id, entry);
entry.bodyHandle = typeSpec.bodyRenderer(body, {
node: meta,
selected,
emit
});
entry.bodyMoved = true;
return;
}
if ((_props.nodeSlot ?? _props.slots?.["node"])) {
// reactive multi-instance portal — one handle per node, re-rendered in
// place on meta change (the MapLibre marker discipline). Low-level escape
// hatch: the consumer switches on node.type inside the single `#node` slot.
entry.handle = portals.node(body, {
node: meta,
selected,
emit
});
} else {
// default chrome: a title bar (the type name / data.label).
const title = document.createElement('div');
title.className = 'rozie-flow-node__title';
title.textContent = chromeLabel;
body.appendChild(title);
entry.titleEl = title;
}
nodeEntries.set(id, entry);
};
// Render ONE socket into a zone and, crucially, EMIT its render signal so the
// ConnectionPlugin + position watcher register it. `position` is the socket's visual
// placement (left|right|top|bottom). For left/right the DOM is byte-identical to pre-F2
// (the classic horizontal port row); top/bottom get a vertical port (socket above its
// label) + a `--<position>` socket class so the socket straddles the matching edge.
const renderSocketInto = (zone: any, reteNode: any, side: any, key: any, position: any, socketDisposers: any) => {
const port = (side === 'input' ? reteNode.inputs : reteNode.outputs)[key];
if (!port) return;
const vertical = position === 'top' || position === 'bottom';
const row = document.createElement('div');
row.className = 'rozie-flow-port rozie-flow-port--' + side + (vertical ? ' rozie-flow-port--vertical' : '');
const socketEl = document.createElement('div');
socketEl.className = 'rozie-flow-socket rozie-flow-socket--' + side + (vertical ? ' rozie-flow-socket--' + position : '');
socketEl.setAttribute('data-testid', 'socket');
const label = document.createElement('span');
label.className = 'rozie-flow-port__label';
label.textContent = port.label != null ? String(port.label) : key;
// CLASSIC: inputs socket-first, outputs label-first (byte-identical to pre-F2).
// VERTICAL: socket-first (the socket sits on the edge, label tucked inward).
if (side === 'input' || vertical) {
row.appendChild(socketEl);
row.appendChild(label);
} else {
row.appendChild(label);
row.appendChild(socketEl);
}
zone.appendChild(row);
// LOAD-BEARING: announce the socket to the rest of the area's child plugins.
// 'render' lets the ConnectionPlugin register the socket as a drag anchor.
area.emit({
type: 'render',
data: {
type: 'socket',
side,
key,
nodeId: reteNode.id,
element: socketEl,
payload: {
socket: port.socket
}
}
});
// ALSO LOAD-BEARING (the socket-position contract): getDOMSocketPosition measures +
// stores a socket's DOM position ONLY on a 'rendered' socket signal — the render-plugin
// lifecycle's post-mount phase. Our vanilla pipe creates + appends the socket DOM
// synchronously, so we fire 'rendered' right after 'render'. WITHOUT IT the position
// store stays empty, every socketWatcher.listen() callback reads null, and NO
// connection path (committed OR drag preview) is ever drawn.
area.emit({
type: 'rendered',
data: {
type: 'socket',
side,
key,
nodeId: reteNode.id,
element: socketEl,
payload: {
socket: port.socket
}
}
});
socketDisposers.push(() => {
area.emit({
type: 'unmount',
data: {
element: socketEl
}
});
});
};
// ── hand-written edge-type path generators (T1.2, D-01) ───────────────────────
// `rete-render-utils` ships ONLY `classicConnectionPath` (bezier) + `loopConnectionPath`;
// step/smoothstep/straight do NOT exist in any installed rete package, so they are
// hand-written here matching React-Flow's `step|smoothstep|straight` semantics. Each is a
// PURE `(start, end) → d-string` function over `{x,y}` graph-screen points; the `d` is
// composed from numeric coords + literal SVG commands and written via setAttribute (never
// innerHTML — no injection, T-44-02-2 accept). The default branch stays
// `classicConnectionPath` → byte-identical bezier (pixel-baseline safe).
// straight: a single line, no curvature.
const straightPath = (s: any, e: any) => `M ${s.x} ${s.y} L ${e.x} ${e.y}`;
// step: orthogonal HV-VH with a mid-X break.
const stepPath = (s: any, e: any) => {
const mx = (s.x + e.x) / 2;
return `M ${s.x} ${s.y} L ${mx} ${s.y} L ${mx} ${e.y} L ${e.x} ${e.y}`;
};
// smoothstep: step with rounded corners (radius r, clamped to half the shorter leg).
const smoothstepPath = (s: any, e: any, r = 8) => {
const mx = (s.x + e.x) / 2;
const dir = e.y >= s.y ? 1 : -1;
const rr = Math.min(r, Math.abs(mx - s.x), Math.abs(e.y - s.y) / 2);
return [`M ${s.x} ${s.y}`, `L ${mx - rr} ${s.y}`, `Q ${mx} ${s.y} ${mx} ${s.y + dir * rr}`, `L ${mx} ${e.y - dir * rr}`, `Q ${mx} ${e.y} ${mx + rr} ${e.y}`, `L ${e.x} ${e.y}`].join(' ');
};
// ── connection renderer ──
// Mounts an <svg><path> and redraws it whenever either endpoint socket moves
// (real connection) OR the dragged pointer moves (user drag-to-connect pseudo).
//
// A USER DRAG renders a *pseudo-connection* (rete-connection-plugin): the render
// signal carries a literal pointer coordinate (`endPointer`/`data.end` when
// dragging FROM an output, `startPointer`/`data.start` when dragging FROM an
// input) alongside a payload with ONE DANGLING endpoint — `target:''`/
// `targetInput:''` (output-side drag) or `source:''`/`sourceOutput:''`
// (input-side drag). The dangling side has no socket to watch, so its coordinate
// MUST come from the pointer; the live side stays watcher-driven. The
// ConnectionPlugin re-emits this render on EVERY pointermove with a fresh pointer
// — so the same pseudo element is re-rendered repeatedly and the dangling
// coordinate must update in place (no SVG rebuild, no listener re-subscribe).
const renderConnection = (element: any, connection: any, startPointer: any, endPointer: any) => {
const id = connection.id;
// A side is dangling when its node id OR its port key is empty/nullish.
const srcDangling = !connection.source || !connection.sourceOutput;
const tgtDangling = !connection.target || !connection.targetInput;
// RE-RENDER of the SAME element (the pseudo on each pointermove): do NOT rebuild
// the SVG or re-subscribe listeners (would leak) — just update the dangling
// side's coordinate and redraw. This replaces the old unconditional early-return
// that froze the preview line. For a REAL connection updatePointer is a no-op,
// so a re-render of a committed edge is byte-for-byte the old early-return.
const prev = connEntries.get(id);
if (prev && prev.element === element) {
prev.updatePointer(startPointer, endPointer);
return;
}
element.innerHTML = '';
element.classList.add('rozie-flow-connection');
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'rozie-flow-connection__svg');
// ── direction arrowhead (Win 3) ─────────────────────────────────────────────
// A <defs><marker> in THIS connection's own <svg>, referenced by `marker-end` so
// the triangle sits at the path END (the input socket — the path runs output→input,
// so marker-end points INTO the target). The marker id is UNIQUE per connection
// (`rozie-arrow-<id>`) so two edges' markers never collide on a shared document id
// (url(#id) resolves to the first match otherwise). The def lives in the SAME
// per-edge <svg> inside the SAME shadow root as the path, so url(#id) resolves
// within that root — no cross-root reference (Lit-safe). markerUnits="userSpaceOnUse"
// keeps a constant pixel size under the area zoom transform. Inline fill (#64748b,
// matching the connection stroke) is the cross-target-safe choice — no scoped-CSS /
// :root rule needed for the marker DOM. The marker does NOT change the path `d`
// or the socket geometry (the rete-flow-align cell stays green) — redraw() only
// sets the head's `orient` and a `stroke-dasharray` that visually trims the last
// ARROW_LEN of the stroke so the line meets the head without poking through it.
const markerId = 'rozie-arrow-' + String(id);
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
marker.setAttribute('id', markerId);
// Sized in userSpaceOnUse (constant pixels under zoom). A 12×10 head reads
// clearly at default zoom (the old 6×6 was barely visible). refX=12 sits the
// TIP exactly at the path-end vertex (the socket); refY=5 centers it. `orient`
// is recomputed per-redraw from the path's final-segment tangent, and the
// visible stroke is trimmed back to the arrow base, so the head points along
// the edge's actual approach AND the line meets it cleanly — see redraw().
marker.setAttribute('markerWidth', '13');
marker.setAttribute('markerHeight', '10');
marker.setAttribute('refX', '12');
marker.setAttribute('refY', '5');
marker.setAttribute('orient', 'auto');
marker.setAttribute('markerUnits', 'userSpaceOnUse');
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
arrow.setAttribute('class', 'rozie-flow-connection__arrow');
arrow.setAttribute('d', 'M0,0 L12,5 L0,10 Z');
arrow.setAttribute('fill', '#64748b');
marker.appendChild(arrow);
defs.appendChild(marker);
svg.appendChild(defs);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('class', 'rozie-flow-connection__path');
path.setAttribute('marker-end', 'url(#' + markerId + ')');
svg.appendChild(path);
// ── T1.1 edge-select listener (D-08) ─────────────────────────────────────────
// Attach an IMPERATIVE pointerup listener on the engine-DOM <path> (NOT a template
// `@` — the path is engine-created; NOT click — Rete swallows it; NOT pointerdown —
// Rete stopPropagations it: the Phase-41 connector landmine, playbook §6a item 7).
// Gated on `selectable && !readonly` (mirrors node delete) and ONLY for COMMITTED
// edges — a drag-to-connect pseudo (either side dangling) carries no stable id and
// must not be selectable. `selectEdge` reads the id back off the closure (the
// committed connection.id == the graph connection id — conn.id = spec.id at build),
// so it always matches what `writeBackConnectionRemoved` filters. `.stop` keeps the
// pointerup from reaching the area's pan/background handling beneath the path.
if (local.selectable && !local.readonly && !srcDangling && !tgtDangling) {
path.style.cursor = 'pointer';
path.addEventListener('pointerup', (e: any) => {
if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
selectEdge(connection.id, path);
});
}
// ── per-edge label + styling (F3) ────────────────────────────────────────────
// The consumer's connection spec ({ id, source, …, label?, stroke?, dashed? }) is kept
// in connMeta keyed by id (the connection-side analog of nodeMeta). A committed edge
// resolves its label/style here; a drag-preview pseudo (no committed id) has none.
// Styling is applied as INLINE attributes (the arrowhead-marker discipline — engine DOM
// carries no scope attr); a `label` renders an SVG <text> at the path midpoint (white
// halo via paint-order for legibility over the line), repositioned in redraw().
const emeta = connMeta.get(connection.id) || null;
if (emeta) {
if (emeta.stroke != null) {
const s = String(emeta.stroke);
path.setAttribute('stroke', s);
arrow.setAttribute('fill', s);
}
if (emeta.dashed === true) path.setAttribute('stroke-dasharray', '7 5');
}
// ── resolved edge type (T1.2) ────────────────────────────────────────────────
// The consumer-supplied `connection.type` selects a path generator. ALLOWLIST it
// (`bezier|step|smoothstep|straight`); any other/absent value falls through to the
// bezier default — no dynamic path-fn lookup keyed on the raw string, no eval
// (T-44-02-1 mitigate). A dangling drag-preview pseudo has no committed connMeta
// entry, so it stays bezier too.
const rawType = emeta && emeta.type != null ? String(emeta.type) : 'bezier';
const edgeType = rawType === 'step' || rawType === 'smoothstep' || rawType === 'straight' ? rawType : 'bezier';
// Arrowhead geometry (redraw): the head is oriented along the path's tangent
// over its LAST `ARROW_LEN` (angled for a descending edge, aligned with where
// the line actually meets the head — unlike the chord, which diverges from the
// bezier's flattened end tangent), and the visible stroke is trimmed back to
// the arrow base on SOLID edges so the line's width can't poke past the
// tapering tip (the "square tip"). Dashed edges keep their pattern untrimmed.
const ARROW_LEN = 12;
const isDashed = !!(emeta && emeta.dashed === true);
let labelEl: any = null;
const edgeLabel = emeta && emeta.label != null ? String(emeta.label) : null;
if (edgeLabel) {
labelEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
labelEl.setAttribute('class', 'rozie-flow-connection__label');
labelEl.setAttribute('text-anchor', 'middle');
labelEl.setAttribute('dominant-baseline', 'middle');
labelEl.textContent = edgeLabel;
svg.appendChild(labelEl);
}
element.appendChild(svg);
let start: any = null;
let end: any = null;
const curvature$local = typeof local.curvature === 'number' ? local.curvature : 0.3;
const redraw = () => {
if (!start || !end) return;
// branch on the resolved edge type; default (bezier/unknown) stays
// classicConnectionPath UNCHANGED → byte-identical bezier output.
const d = edgeType === 'step' ? stepPath(start, end) : edgeType === 'smoothstep' ? smoothstepPath(start, end) : edgeType === 'straight' ? straightPath(start, end) : classicConnectionPath([start, end], curvature$local);
path.setAttribute('d', d);
// Orient the head and trim the visible stroke back to the arrow base (solid
// edges) so the line meets the head without poking through the tip.
// getTotalLength/getPointAtLength are SVGGeometryElement methods unavailable
// in a non-rendering env (jsdom) → guard and fall back to orient='auto' / untrimmed.
let pathLen = 0;
try {
pathLen = path.getTotalLength();
} catch (e: any) {
pathLen = 0;
}
if (pathLen > ARROW_LEN + 1) {
// BACKWARD edge (target socket left of the source socket): the classic
// bezier overshoots both control points, looping the curve into tight
// u-turns right at the sockets, so a sampled local tangent is unstable and
// the head curls. Use the path's TRUE end tangent (orient='auto' — the
// horizontal entry into the input) for a stable, standard arrow. FORWARD
// edges keep the final-ARROW_LEN tangent, which follows a descending edge
// AND aligns with where the line meets the head.
if (end.x < start.x) {
marker.setAttribute('orient', 'auto');
} else {
const tip = path.getPointAtLength(pathLen);
const back = path.getPointAtLength(pathLen - ARROW_LEN);
marker.setAttribute('orient', String(Math.atan2(tip.y - back.y, tip.x - back.x) * 180 / Math.PI));
}
if (!isDashed) path.setAttribute('stroke-dasharray', pathLen - ARROW_LEN + ' ' + pathLen);
} else {
marker.setAttribute('orient', 'auto');
if (!isDashed) path.removeAttribute('stroke-dasharray');
}
if (labelEl) {
labelEl.setAttribute('x', String((start.x + end.x) / 2));
labelEl.setAttribute('y', String((start.y + end.y) / 2));
}
};
// Seed the DANGLING side's coordinate from the pointer FIRST — socketWatcher
// .listen() synchronously replays the current socket snapshot on subscribe, so
// seeding before subscribing the live side means redraw() already has the
// dangling coordinate and the preview line draws immediately on the first render.
if (srcDangling && startPointer) start = startPointer;
if (tgtDangling && endPointer) end = endPointer;
// LIVE endpoints stay watcher-driven (exactly as before the fix — committed
// connections behave byte-for-byte). DANGLING endpoints subscribe NO listener
// (it would never fire — there is no socket); their coordinate is the pointer.
let un1: any = null;
let un2: any = null;
if (!srcDangling) un1 = socketWatcher.listen(connection.source, 'output', connection.sourceOutput, (p: any) => {
start = p;
redraw();
});
if (!tgtDangling) un2 = socketWatcher.listen(connection.target, 'input', connection.targetInput, (p: any) => {
end = p;
redraw();
});
// Update only the DANGLING side(s) from a fresh pointer on each subsequent
// render call. For a REAL connection (neither side dangling) this is a no-op,
// so committed connections never have a pointer override and keep behaving
// exactly as before.
const updatePointer = (sp: any, ep: any) => {
let moved = false;
if (srcDangling && sp) {
start = sp;
moved = true;
}
if (tgtDangling && ep) {
end = ep;
moved = true;
}
if (moved) redraw();
};
// Draw once now: a pseudo seeded with an initial pointer (+ its live side
// already replayed) draws immediately; a real connection whose sockets are
// already known also draws (idempotent — same `d` the listeners just set).
redraw();
connEntries.set(id, {
element,
updatePointer,
dispose: () => {
try {
un1 && un1();
} catch (e: any) {}
try {
un2 && un2();
} catch (e: any) {}
}
});
};
// ── unmount cleanup (keyed by the engine element area hands back) ──
const cleanupElement = (element: any) => {
for (const [id, entry] of nodeEntries as any) {
if (entry.element === element) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
nodeEntries.delete(id);
return;
}
}
for (const [id, entry] of connEntries as any) {
if (entry.element === element) {
entry.dispose();
connEntries.delete(id);
return;
}
}
};
// Resolve a single port's TYPE for the validation pipe: look up the live node's
// `type` (via nodeMeta) then the portReg entry keyed `type::side::key`. Returns the
// portType string or null (null on either side ⇒ no type constraint ⇒ allow). DEFINED
// HERE (inside $onMount) — NOT at top level — so its $data.portReg read lowers on React
// to the live `_portRegRef.current` rather than a stale-empty closure snapshot captured
// when this once-only mount effect first ran (the cross-type-reject-didn't-fire bug).
const portTypeOf = (nodeId: any, side: any, key: any) => {
const meta = nodeMeta.get(nodeId);
if (!meta || meta.type == null || key == null) return null;
const entry = portReg()[meta.type + '::' + side + '::' + key];
return entry ? entry.portType : null;
};
// ─── connection-validation gate (D2/D3 — typed-socket validation + override) ──
// Cancels Rete's cancellable `connectioncreate` pre-event when the connection is
// rejected. TWO independent reject paths, both surfacing `connection-rejected`:
// 1. AUTOMATIC typed validation (`:validate-types`, default ON, D3 option a):
// resolve src/tgt port TYPE from the per-TYPE port schema (via each endpoint
// node's `type`); if both are non-null and UNEQUAL → reject. A null on either
// side (untyped port / unknown type) imposes no constraint → allow.
// 2. `canConnect` OVERRIDE (Phase-40 contract, SURVIVES): a consumer custom rule;
// runs IN ADDITION to (after) the automatic check; returning false rejects.
// Cancelling makes editor.addConnection return false WITHOUT pushing the connection
// or emitting `connectioncreated` — no ghost edge, no `connection-created`. Gates
// drag-to-connect, imperative addConnection, and reconcile uniformly. Both predicates
// are PURE (no $data write / engine call) — reads only. The block (return undefined)
// stays UNCONDITIONAL so rejection is enforced on every path; only the EMIT is
// echo-guarded (a programmatic reconcile the rule would reject must not surface as a
// user-facing rejection — mirrors connection-created/connection-removed).
editor.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectioncreate') {
const c = context.data;
// ClassicPreset.Connection fields: { id, source, sourceOutput, target, targetInput }.
// Same shape as serializeConn minus the engine-assigned `id` (never created).
const conn = {
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
};
// 1. AUTOMATIC typed validation (default ON; opt out via :validate-types="false").
if (local.validateTypes !== false) {
const srcType = portTypeOf(c.source, 'output', c.sourceOutput);
const tgtType = portTypeOf(c.target, 'input', c.targetInput);
if (srcType != null && tgtType != null && srcType !== tgtType) {
if (!programmatic) _props.onConnectionRejected?.(conn);
return undefined; // ← CANCEL: type mismatch
}
}
// 2. canConnect OVERRIDE (Phase-40 contract — custom rule, in addition).
if (typeof local.canConnect === 'function' && local.canConnect(conn) === false) {
if (!programmatic) _props.onConnectionRejected?.(conn);
return undefined; // ← CANCEL: Signal.emit halts, addConnection returns false
}
}
return context;
});
// ─── forward engine events (echo-guarded via `programmatic`) ───────────────
editor.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectioncreated') {
// keep engine truth in sync so reconcile diffs correctly — a user-drawn
// connection (auto id) must register here or the next graph pass re-adds it.
connInstances.set(context.data.id, context.data);
if (!programmatic) {
// WRITE-BACK: append the new connection into a fresh graph object (D4).
writeBackConnectionCreated(context.data);
// keep the discrete event too (back-compat).
_props.onConnectionCreated?.(serializeConn(context.data));
}
} else if (context.type === 'connectionremoved') {
connInstances.delete(context.data.id);
connMeta.delete(context.data.id);
if (!programmatic) {
// WRITE-BACK: filter the removed connection out of a fresh graph object (D4).
writeBackConnectionRemoved(context.data.id);
_props.onConnectionRemoved?.({
id: context.data.id
});
}
}
return context;
});
area.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'nodepicked') {
_props.onNodePicked?.({
id: context.data.id
});
// T1.3 — pointer-DOWN: stash the PRE-drag graph snapshot (before any movement). It
// is committed to history on the first `nodetranslated` (only if a drag follows;
// gated on !programmatic + history). A re-pick mid-drag won't overwrite a live one.
if (!programmatic && local.history !== false && !dragGestureActive) {
pendingDragSnapshot = snapshotCurrent();
}
// Win 2: a pick changed the selection — surface @selection-change after the
// engine's awaited select() for THIS pick has flushed the selector entities.
scheduleSelectionEmit();
} else if (context.type === 'pointerup') {
// Win 2: AreaExtensions.selectableNodes UNSELECTS all on a click-like background
// pointerUP (its `twitch < 4` deselect — NOT on pointerdown, verified against
// rete-area-plugin's selectable pipe). Its unselectAll() is async and its pipe
// runs before ours, so recompute AFTER its awaited unselectAll() flushes (the
// microtask + rAF schedule). The dedup makes a no-op when nothing changed (e.g. a
// pointerup that ended a node pick — already surfaced by the nodepicked branch).
scheduleSelectionEmit();
// T1.3 — a pointerup ends any in-progress drag gesture, so the NEXT drag pushes a
// fresh history snapshot (one gesture = one undo step, D-03). Drop any stashed
// pre-drag snapshot that was never committed (a pick with no drag).
dragGestureActive = false;
pendingDragSnapshot = null;
// T1.1: a background pointerup (anywhere not on a connection path) clears the edge
// selection — UNLESS this same gesture just selected an edge (the path's own
// pointerup ran in the same tick and raised `edgeClickGuard`; the guard self-resets
// on the next microtask). Mirrors the node selectable's click-to-deselect.
if (!edgeClickGuard && selectedConnId != null) clearEdgeSelection();
} else if (context.type === 'nodetranslated') {
if (!programmatic) {
const id = context.data.id;
const pos = context.data.position;
const meta = nodeMeta.get(id);
if (meta) {
meta.x = pos.x;
meta.y = pos.y;
}
// T1.3 — commit ONE history snapshot per drag gesture, at its FIRST translate:
// the pre-move snapshot stashed on nodepicked (a drag truly happened now, not just
// a pick). dragGestureActive holds until the drag-ending pointerup resets it, so a
// continuous drag = ONE undo step (D-03).
if (!dragGestureActive) {
dragGestureActive = true;
if (pendingDragSnapshot) {
pushHistorySnapshot(pendingDragSnapshot);
pendingDragSnapshot = null;
}
}
// WRITE-BACK (coalesced): accumulate the latest position for this node and
// flush ONE fresh graph object per animation frame (Pitfall 2 — the drag
// storm). The discrete `node-moved` emit stays per-translate (back-compat).
pendingDragPositions.set(id, {
x: pos.x,
y: pos.y
});
scheduleDragFlush();
_props.onNodeMoved?.({
id,
x: pos.x,
y: pos.y
});
}
// a node moved → its minimap rect moves (works during a programmatic translate too).
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
// T2.8 — the selected node moved → re-track its toolbar overlay (no-op if off).
if (scheduleToolbarTrack) scheduleToolbarTrack();
} else if (context.type === 'translated') {
_props.onTranslated?.({
x: context.data.position.x,
y: context.data.position.y
});
// the viewport window moved → redraw the minimap viewport rect + mask.
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
// T2.8 — a pan shifts the node's screen rect → re-track the toolbar (no-op if off).
if (scheduleToolbarTrack) scheduleToolbarTrack();
} else if (context.type === 'zoomed') {
if (!programmatic) {
const k = area.area.transform.k;
if (k !== zoom()) setZoom(k);
}
// the viewport window resized (zoom) → redraw the minimap viewport rect + mask.
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
// T2.8 — a zoom changes the node's screen rect/size → re-track the toolbar (no-op if off).
if (scheduleToolbarTrack) scheduleToolbarTrack();
} else if (context.type === 'contextmenu') {
// suppress the native browser menu over the canvas; surface a hook instead.
context.data.event.preventDefault();
const ctx = context.data.context;
_props.onContextMenu?.({
id: ctx && ctx.id ? ctx.id : null
});
}
return context;
});
// ─── reconciler off the bound graph, bridged to the top-level $watch ──────────
// Nodes come ONLY from `$props.graph.nodes` (the single source of truth, D1/D2);
// sockets come from each node's TYPE port schema (portReg keyed `type::side::key`).
// A port-schema change ($data.portReg, when a <Port> registers late on Lit) ALSO
// drives this reconcile so a node whose type just gained ports re-renders. An
// imperative $expose addNode (provenance NOT in lastPropNodeIds) survives the reaper.
// Wrapped by reconcileNodes (below) with a re-entrancy guard so two passes never
// race the engine (the Lit "cannot find node" fix).
const reconcileNodesPass = async () => {
if (!editor || !area) return;
const graphNodes = Array.isArray(graph() && graph().nodes) ? graph().nodes : [];
const want = [];
programmatic++;
try {
for (const spec of graphNodes as any) {
if (!spec || spec.id == null) continue;
want.push(spec.id);
nodeMeta.set(spec.id, spec);
let node = nodeInstances.get(spec.id);
if (!node) {
node = buildNode(spec, portReg());
nodeInstances.set(spec.id, node);
await editor.addNode(node);
await area.translate(spec.id, {
x: spec.x || 0,
y: spec.y || 0
});
} else {
// Sync any ports this node's TYPE gained AFTER the node was first built —
// a nested <Port>'s addTypePort can land after reconcileNodes already
// created the node (the node registered before its ports on some targets,
// or a <Port> registered late on Lit). buildNode only runs for NEW nodes,
// so add the missing inputs/outputs onto the live instance here from the
// TYPE schema, then re-render.
let portsAdded = false;
const {
inputs: wantIn,
outputs: wantOut
} = portSchemaForType(spec.type, portReg());
for (const inp of wantIn as any) {
if (!inp || inp.key == null || node.inputs[inp.key]) continue;
node.addInput(inp.key, new ClassicPreset.Input(SOCKET, inp.label, inp.multiple === true));
portsAdded = true;
}
for (const out of wantOut as any) {
if (!out || out.key == null || node.outputs[out.key]) continue;
node.addOutput(out.key, new ClassicPreset.Output(SOCKET, out.label, out.multiple !== false));
portsAdded = true;
}
const view = area.nodeViews.get(spec.id);
if (view && spec.x != null && spec.y != null && (view.position.x !== spec.x || view.position.y !== spec.y)) {
await area.translate(spec.id, {
x: spec.x,
y: spec.y
});
}
if (portsAdded) {
// renderNode's in-place branch deliberately leaves existing sockets
// untouched; to render the NEW sockets, drop this node's render entry so
// area.update takes the fresh-build path (re-runs buildSocketRow + re-
// emits the socket render signals the ConnectionPlugin/watcher need). The
// render-by-type body host is re-projected by the type's bodyRenderer
// (mounts a fresh portal root into the same host — idempotent).
const entry = nodeEntries.get(spec.id);
if (entry) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
nodeEntries.delete(spec.id);
}
}
await area.update('node', spec.id);
// a port change must re-run connections — an edge that was skipped because
// its endpoint port didn't exist yet can now be drawn.
if (portsAdded && reconcileConnections) await reconcileConnections();
}
}
// remove dropped GRAPH-managed nodes (+ their connections) — imperatively added
// nodes (NOT in lastPropNodeIds) survive (the power-user escape hatch).
const tracked = new Set(lastPropNodeIds);
for (const id of tracked as any) {
if (!want.includes(id) && nodeInstances.has(id)) {
for (const c of editor.getConnections() as any) {
if (c.source === id || c.target === id) await editor.removeConnection(c.id);
}
await editor.removeNode(id);
nodeInstances.delete(id);
nodeMeta.delete(id);
}
}
lastPropNodeIds = want;
} finally {
programmatic--;
}
};
// Re-entrancy-guarded entry point. If a pass is already running, mark a re-run and
// return — the in-flight pass loops until no further request is pending. Serializing
// overlapping reconciles is what stops the Lit async-context cascade from racing the
// engine into "cannot find node" (which otherwise aborts the declarative graph build).
reconcileNodes = async () => {
if (reconcileNodesRunning) {
reconcileNodesPending = true;
return;
}
reconcileNodesRunning = true;
try {
do {
reconcileNodesPending = false;
await reconcileNodesPass();
} while (reconcileNodesPending);
} finally {
reconcileNodesRunning = false;
}
};
reconcileConnections = async () => {
if (!editor) return;
// Edges come ONLY from the bound graph's `connections` (the single source of
// truth — declarative <Connection> children are gone). Normalize id-defaulting
// (a connection authored without an id gets a stable derived id) so an edge the
// canvas wrote back (carrying the engine id) and a hand-authored edge dedup.
const graphConns = Array.isArray(graph() && graph().connections) ? graph().connections : [];
const norm = (spec: any) => {
if (!spec || spec.source == null || spec.target == null) return null;
const srcOut = spec.sourceOutput != null ? spec.sourceOutput : 'out';
const tgtIn = spec.targetInput != null ? spec.targetInput : 'in';
const id = spec.id != null ? spec.id : `${spec.source}:${srcOut}->${spec.target}:${tgtIn}`;
// carry the optional per-edge label/style (F3) through to connMeta → renderConnection.
return {
id,
source: spec.source,
sourceOutput: srcOut,
target: spec.target,
targetInput: tgtIn,
label: spec.label,
stroke: spec.stroke,
dashed: spec.dashed,
type: spec.type
};
};
// cheap style signature so a label/style/type change on an EXISTING edge re-renders it.
const edgeStyleSig = (s: any) => s ? String(s.label) + '|' + String(s.stroke) + '|' + String(s.dashed) + '|' + String(s.type) : '';
const merged = graphConns.map(norm).filter(Boolean);
const want = [];
programmatic++;
try {
for (const spec of merged as any) {
if (!spec || spec.id == null) continue;
want.push(spec.id);
if (connInstances.has(spec.id)) {
// existing edge — relabel/restyle in place if its label/style changed (the
// controlled-graph expectation: edit the bound graph → see the change). Drop the
// render entry so area.update takes the fresh-build path (re-applies label/style).
const changed = edgeStyleSig(connMeta.get(spec.id)) !== edgeStyleSig(spec);
connMeta.set(spec.id, spec);
if (changed) {
const entry = connEntries.get(spec.id);
if (entry) {
entry.dispose();
connEntries.delete(spec.id);
}
await area.update('connection', spec.id);
}
continue;
}
const sourceNode = nodeInstances.get(spec.source);
const targetNode = nodeInstances.get(spec.target);
if (!sourceNode || !targetNode) continue;
// DEFENSIVE: the referenced output/input ports must exist on the live node
// instances before addConnection (Rete throws "source node doesn't have
// output with a key out" otherwise, aborting the loop). An edge may reference
// a port the node's TYPE schema has not flushed yet (a <Port> registered
// after the <NodeType>); skip until the ports exist — reconcileNodes re-runs
// reconcileConnections after a port-schema change, so the edge lands later.
if (!sourceNode.outputs || !sourceNode.outputs[spec.sourceOutput]) continue;
if (!targetNode.inputs || !targetNode.inputs[spec.targetInput]) continue;
const conn = new ClassicPreset.Connection(sourceNode, spec.sourceOutput, targetNode, spec.targetInput);
conn.id = spec.id;
connInstances.set(spec.id, conn);
// seed connMeta BEFORE addConnection so renderConnection sees the label/style on
// its first render (the render fires synchronously inside addConnection's pipe).
connMeta.set(spec.id, spec);
await editor.addConnection(conn);
}
// remove dropped GRAPH-managed edges — imperatively added edges survive.
const tracked = new Set(lastPropConnIds);
for (const id of tracked as any) {
if (!want.includes(id) && connInstances.has(id)) {
await editor.removeConnection(id);
connInstances.delete(id);
connMeta.delete(id);
}
}
lastPropConnIds = want;
} finally {
programmatic--;
}
};
// ─── built-in MiniMap (opt-in :minimap, Phase 42) ────────────────────────────
// An absolute light-DOM SVG overlay (bottom-right) showing a scaled map of every
// node + the current viewport window (outside dimmed), PANNABLE (drag recenters via
// setCenter). The host div is COMPONENT-template DOM (carries the [data-rozie-s-*]
// scope attr → plain scoped CSS positions it); its SVG children are built
// IMPERATIVELY with createElementNS (the connection-renderer discipline) so SVG
// namespacing is identical on all 6 (no SVG-in-template cross-target risk) and styled
// with INLINE attributes (the arrowhead-marker lesson — no scoped-CSS / :root rule
// needed for engine-style DOM). Node dims come from the MEASURED engine node-view
// elements (area.nodeViews.get(id).element offsetW/H — target-agnostic, like the
// render pipe) with a default-rect fallback for Lit's unmeasured first paint.
const measureNodeSize = (id: any) => {
const view = area && area.nodeViews ? area.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
const w = el && el.offsetWidth ? el.offsetWidth : MINIMAP_DEFAULT_NODE_W;
const h = el && el.offsetHeight ? el.offsetHeight : MINIMAP_DEFAULT_NODE_H;
return {
w,
h
};
};
const mkMinimapRect = (x: any, y: any, w: any, h: any, cls: any, fill: any, stroke: any, strokeW: any) => {
const r = document.createElementNS(SVGNS, 'rect');
r.setAttribute('class', cls);
r.setAttribute('x', String(x));
r.setAttribute('y', String(y));
r.setAttribute('width', String(Math.max(w, 0)));
r.setAttribute('height', String(Math.max(h, 0)));
if (fill) r.setAttribute('fill', fill);
if (stroke) {
r.setAttribute('stroke', stroke);
r.setAttribute('stroke-width', String(strokeW || 1));
}
return r;
};
// Rebuild the minimap SVG: node rects (selected highlighted) + a dim mask outside the
// viewport (evenodd punch-out) + the viewport window outline. The bounds union the
// node rects AND the viewport window so the viewport indicator stays in-frame even
// when panned past the nodes. Stores `minimapMap` (the px↔graph mapping the pointer-
// pan handlers read). Cheap (a handful of rects) → a full rebuild per frame is fine.
const redrawMinimap = () => {
minimapRedrawRaf = 0;
if (!local.minimap || !minimapSvg || !area || !container) return;
const t = area.area.transform;
const k = t.k || 1;
const cw = container.clientWidth || MINIMAP_W;
const ch = container.clientHeight || MINIMAP_H;
// viewport window in GRAPH coords (screen [0,cw]×[0,ch] → graph).
const vx = -t.x / k,
vy = -t.y / k,
vw = cw / k,
vh = ch / k;
const graphNodes = currentGraph().nodes || [];
const selIds = new Set(selectedNodeIds().map((s: any) => String(s)));
const rects = [];
for (const n of graphNodes as any) {
if (!n || n.id == null) continue;
const view = area.nodeViews.get(n.id);
const gx = view ? view.position.x : n.x || 0;
const gy = view ? view.position.y : n.y || 0;
const sz = measureNodeSize(n.id);
rects.push({
gx,
gy,
gw: sz.w,
gh: sz.h,
selected: selIds.has(String(n.id))
});
}
let minX = vx,
minY = vy,
maxX = vx + vw,
maxY = vy + vh;
for (const r of rects as any) {
if (r.gx < minX) minX = r.gx;
if (r.gy < minY) minY = r.gy;
if (r.gx + r.gw > maxX) maxX = r.gx + r.gw;
if (r.gy + r.gh > maxY) maxY = r.gy + r.gh;
}
const padX = (maxX - minX) * 0.1 || 20;
const padY = (maxY - minY) * 0.1 || 20;
minX -= padX;
minY -= padY;
maxX += padX;
maxY += padY;
const bw = maxX - minX || 1;
const bh = maxY - minY || 1;
const scale = Math.min(MINIMAP_W / bw, MINIMAP_H / bh);
const offX = (MINIMAP_W - bw * scale) / 2;
const offY = (MINIMAP_H - bh * scale) / 2;
minimapMap = {
minX,
minY,
scale,
offX,
offY
};
const toMMx = (gx: any) => (gx - minX) * scale + offX;
const toMMy = (gy: any) => (gy - minY) * scale + offY;
minimapSvg.innerHTML = '';
for (const r of rects as any) {
const fill = r.selected ? '#3b82f6' : '#94a3b8';
minimapSvg.appendChild(mkMinimapRect(toMMx(r.gx), toMMy(r.gy), r.gw * scale, r.gh * scale, 'rozie-flow-minimap__node', fill, null, 0));
}
// dim mask OUTSIDE the viewport: full minimap rect with the viewport rect punched
// out (both subpaths same winding → fill-rule:evenodd leaves the viewport a hole).
const mvx = toMMx(vx),
mvy = toMMy(vy),
mvw = vw * scale,
mvh = vh * scale;
const mask = document.createElementNS(SVGNS, 'path');
mask.setAttribute('class', 'rozie-flow-minimap__mask');
mask.setAttribute('fill-rule', 'evenodd');
mask.setAttribute('fill', 'rgba(15, 23, 42, 0.18)');
mask.setAttribute('d', 'M0 0 H' + MINIMAP_W + ' V' + MINIMAP_H + ' H0 Z ' + 'M' + mvx + ' ' + mvy + ' h' + mvw + ' v' + mvh + ' h' + -mvw + ' Z');
minimapSvg.appendChild(mask);
minimapSvg.appendChild(mkMinimapRect(mvx, mvy, mvw, mvh, 'rozie-flow-minimap__viewport', 'none', '#3b82f6', 1.5));
};
// rAF-coalesced scheduler (bridged to the top-level $watch + the engine pipes). No-op
// when :minimap is off (the bridge stays callable everywhere, cheap).
scheduleMinimapRedraw = () => {
if (!local.minimap || minimapRedrawRaf) return;
if (typeof requestAnimationFrame === 'function') {
minimapRedrawRaf = requestAnimationFrame(redrawMinimap);
} else {
minimapRedrawRaf = 1;
Promise.resolve().then(redrawMinimap);
}
};
// Map a minimap pointer event → graph coords (via the stored minimapMap) → setCenter.
// Pan is a view op → allowed even when readonly, but gated by `pannable` (mirror the
// main-canvas pannable gate). Pointer capture keeps the drag tracking off the box.
const minimapPointerToGraph = (e: any) => {
if (!minimapMap || !minimapHost) return null;
const box = minimapHost.getBoundingClientRect();
const rw = box.width || MINIMAP_W;
const rh = box.height || MINIMAP_H;
const mx = (e.clientX - box.left) * (MINIMAP_W / rw);
const my = (e.clientY - box.top) * (MINIMAP_H / rh);
return {
gx: minimapMap.minX + (mx - minimapMap.offX) / minimapMap.scale,
gy: minimapMap.minY + (my - minimapMap.offY) / minimapMap.scale
};
};
if (local.minimap && minimapElRef) {
minimapHost = minimapElRef;
minimapSvg = document.createElementNS(SVGNS, 'svg');
minimapSvg.setAttribute('class', 'rozie-flow-minimap__svg');
minimapSvg.setAttribute('viewBox', '0 0 ' + MINIMAP_W + ' ' + MINIMAP_H);
minimapSvg.setAttribute('preserveAspectRatio', 'none');
minimapHost.appendChild(minimapSvg);
onMinimapPointerDown = (e: any) => {
if (!local.pannable) return;
const g = minimapPointerToGraph(e);
if (!g) return;
minimapPanning = true;
try {
if (e.target && e.target.setPointerCapture && e.pointerId != null) e.target.setPointerCapture(e.pointerId);
} catch (err: any) {}
e.preventDefault();
e.stopPropagation();
setCenter(g.gx, g.gy, null);
};
onMinimapPointerMove = (e: any) => {
if (!minimapPanning || !local.pannable) return;
const g = minimapPointerToGraph(e);
if (!g) return;
e.preventDefault();
setCenter(g.gx, g.gy, null);
};
onMinimapPointerUp = (e: any) => {
if (!minimapPanning) return;
minimapPanning = false;
try {
if (e.target && e.target.releasePointerCapture && e.pointerId != null) e.target.releasePointerCapture(e.pointerId);
} catch (err: any) {}
};
minimapHost.addEventListener('pointerdown', onMinimapPointerDown);
minimapHost.addEventListener('pointermove', onMinimapPointerMove);
minimapHost.addEventListener('pointerup', onMinimapPointerUp);
}
// ─── T2.8 NodeToolbar (opt-in :node-toolbar) ─────────────────────────────────
// A floating component-template overlay over the SELECTED node. The host div
// (ref="toolbarEl") carries the [data-rozie-s-*] scope attr → PLAIN scoped CSS positions
// it absolutely (NOT the :root engine-DOM escape hatch — it's component DOM, like the
// marquee box + Controls). It is positioned from the engine node-view ELEMENT's rect
// (which the AreaPlugin transforms for pan/zoom/drag) relative to the canvas container, so
// the area transform is honored automatically — we read getBoundingClientRect() and
// subtract the container's rect (the screenToFlowPosition discipline, but the other way).
// Re-tracked on translated/zoomed/nodetranslated (the pipe branches that schedule the
// minimap redraw) + on every selection emit. OPT-IN (default OFF) → existing demos +
// FlowCanvasScreenshot are pixel-identical (the host div is r-if'd off when :node-toolbar
// is false; selecting a node never pops it).
// Resolve the SINGLE selected node id the toolbar should track: the one picked node when
// EXACTLY one is selected, else null (no toolbar over a multi-select or empty selection —
// a per-node action needs an unambiguous target). Read-only.
const singleSelectedNodeId = () => {
const ids = selectedNodeIds();
return ids.length === 1 ? ids[0] : null;
};
// Position the toolbar host over the tracked node's engine element, or hide it. The
// node-view element is already transformed by the AreaPlugin (pan/zoom/drag), so its
// client rect minus the container's client rect gives the toolbar's container-relative
// px — no manual transform math. Placed just ABOVE the node (bottom of the toolbar at the
// node's top edge); clamped so it never goes off the top of the container.
const trackToolbar = () => {
toolbarTrackRaf = 0;
if (!local.nodeToolbar || !toolbarHost || !area || !container) return;
const id = toolbarSelectedId;
if (id == null) {
toolbarHost.style.display = 'none';
return;
}
const view = area.nodeViews ? area.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
const rect = el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null;
if (!rect) {
toolbarHost.style.display = 'none';
return;
}
const cbox = container.getBoundingClientRect();
// container-relative px of the node's top-left + width.
const nx = rect.left - cbox.left;
const ny = rect.top - cbox.top;
const tbH = toolbarHost.offsetHeight || 30;
let top = ny - tbH - 6;
if (top < 2) top = ny + rect.height + 6; // flip below if it would clip the top
toolbarHost.style.left = nx + 'px';
toolbarHost.style.top = top + 'px';
toolbarHost.style.display = 'flex';
};
scheduleToolbarTrack = () => {
if (!local.nodeToolbar || toolbarTrackRaf) return;
if (typeof requestAnimationFrame === 'function') {
toolbarTrackRaf = requestAnimationFrame(trackToolbar);
} else {
toolbarTrackRaf = 1;
Promise.resolve().then(trackToolbar);
}
};
// Recompute the tracked node from the live selection + (re)mount the toolbar content for
// it. Called from the selection emit (a pick/unpick changed the selection). When the
// tracked id changes: if the consumer fills `#toolbar`, (re)render the reactive portal
// with the new node scope; else the default buttons stay put (they read the live tracked
// id at click time, so no re-mount needed). Then reposition.
const syncToolbar = () => {
if (!local.nodeToolbar || !toolbarHost) return;
const id = singleSelectedNodeId();
if (id === toolbarSelectedId && id == null === (toolbarSelectedId == null)) {
// same target — just reposition (e.g. after a drag).
scheduleToolbarTrack();
return;
}
toolbarSelectedId = id;
if ((_props.toolbarSlot ?? _props.slots?.["toolbar"]) && id != null) {
const meta = nodeMeta.get(id) || {
id,
type: undefined,
data: {}
};
const scope = {
node: meta,
emit: toolbarEmit
};
if (toolbarHandle && toolbarHandle.update) {
toolbarHandle.update(scope);
} else {
toolbarHandle = portals.toolbar(toolbarHost, scope);
}
}
scheduleToolbarTrack();
};
syncToolbarSelection = syncToolbar;
// The @node-action emit helper for the toolbar's actions (the EXISTING emit — no new emit,
// T2.8). Carries the tracked node id. Handed to the `#toolbar` slot scope so a consumer
// override can raise its own actions too.
const toolbarEmit = (name: any, detail: any) => {
const id = toolbarSelectedId;
_props.onNodeAction?.({
id,
name,
detail
});
};
if (local.nodeToolbar && toolbarElRef) {
toolbarHost = toolbarElRef;
toolbarHost.style.display = 'none';
if (!(_props.toolbarSlot ?? _props.slots?.["toolbar"])) {
// default chrome: delete + duplicate buttons. Static literal labels (Threat
// T-44-06-1: no node-derived text rendered via innerHTML — these are fixed strings
// set via textContent). Both fire @node-action on the tracked node.
toolbarDeleteBtn = document.createElement('button');
toolbarDeleteBtn.type = 'button';
toolbarDeleteBtn.className = 'rozie-flow-toolbar__btn rozie-flow-toolbar__btn--delete';
toolbarDeleteBtn.setAttribute('data-testid', 'flow-toolbar-delete');
toolbarDeleteBtn.setAttribute('aria-label', 'Delete node');
toolbarDeleteBtn.textContent = 'Delete';
toolbarDuplicateBtn = document.createElement('button');
toolbarDuplicateBtn.type = 'button';
toolbarDuplicateBtn.className = 'rozie-flow-toolbar__btn rozie-flow-toolbar__btn--duplicate';
toolbarDuplicateBtn.setAttribute('data-testid', 'flow-toolbar-duplicate');
toolbarDuplicateBtn.setAttribute('aria-label', 'Duplicate node');
toolbarDuplicateBtn.textContent = 'Duplicate';
onToolbarDelete = (e: any) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const id = toolbarSelectedId;
if (id == null) return;
toolbarEmit('delete', {
id
});
toolbarSelectedId = null;
deleteNode(id);
scheduleToolbarTrack();
};
onToolbarDup = (e: any) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const id = toolbarSelectedId;
if (id == null) return;
const newId = duplicateNode(id);
toolbarEmit('duplicate', {
id,
newId
});
scheduleToolbarTrack();
};
// pointerup (NOT click — Rete swallows clicks during node interaction; the §6a item-7
// discipline) on the COMPONENT-template buttons.
toolbarDeleteBtn.addEventListener('pointerup', onToolbarDelete);
toolbarDuplicateBtn.addEventListener('pointerup', onToolbarDup);
toolbarHost.appendChild(toolbarDeleteBtn);
toolbarHost.appendChild(toolbarDuplicateBtn);
}
}
// ─── T2.4 MARQUEE select (mode:'select') ─────────────────────────────────────
// A Figma-style rubber-band box. RESTORE-PATH resolution (RESEARCH Q2/A8): rete's
// internal `Drag` class is NOT exported, so setDragHandler(null) can't be cleanly
// reversed (re-instantiating Drag is impossible). Instead we leave the default pan Drag
// installed and intercept the EMPTY-canvas pointerdown in the CAPTURE phase on the
// container — the default Drag attaches its own bubble-phase pointerdown listener on the
// SAME container (verified rete-area-plugin@2.1.5: setDragHandler → Drag.initialize(
// this.container)), so a capture listener fires FIRST and stopPropagation() blocks pan
// before it starts. The interception is gated PURELY on the live `$props.mode` flag, so
// switching back to 'pan' restores pan with ZERO engine mutation (the persistent
// mode-guard the research preferred). A node drag is UNTOUCHED in both modes: we only act
// when the pointerdown target is NOT inside a node element (empty canvas).
//
// The box is a COMPONENT-TEMPLATE overlay div (ref="marqueeEl") — it carries the
// [data-rozie-s-*] scope attr so a PLAIN scoped rule styles it (NOT the :root engine-DOM
// escape hatch). On release we hit-test every graph node's rect (graph coords via
// area.nodeViews.get(id).position + measureNodeSize) against the box (converted to graph
// coords through the live transform) and nodeSelectApi.select(id, true) each intersector,
// then scheduleSelectionEmit() (the existing @selection-change path — NO new emit).
// Marquee changes only SELECTION (script-state), never the graph model → no history push.
const nodeAt = (target: any) => {
if (!target || typeof target.closest !== 'function') return null;
return target.closest('.rozie-flow-node');
};
// container-relative px → GRAPH coords (the inverse area transform, like
// screenToFlowPosition but already container-relative). px = transform + graph·k.
const containerPxToGraph = (px: any, py: any) => {
const t = area.area.transform;
const k = t.k || 1;
return {
x: (px - t.x) / k,
y: (py - t.y) / k
};
};
const updateMarqueeBox = () => {
if (!marqueeBox || !marqueeStart || !marqueeCur) return;
const x = Math.min(marqueeStart.x, marqueeCur.x);
const y = Math.min(marqueeStart.y, marqueeCur.y);
const w = Math.abs(marqueeCur.x - marqueeStart.x);
const h = Math.abs(marqueeCur.y - marqueeStart.y);
marqueeBox.style.left = x + 'px';
marqueeBox.style.top = y + 'px';
marqueeBox.style.width = w + 'px';
marqueeBox.style.height = h + 'px';
marqueeBox.style.display = 'block';
};
const finishMarquee = () => {
if (!marqueeActive) return;
marqueeActive = false;
if (marqueeBox) marqueeBox.style.display = 'none';
if (!marqueeStart || !marqueeCur || !nodeSelectApi) {
marqueeStart = null;
marqueeCur = null;
return;
}
// box in graph coords (two opposite corners → min/max).
const a = containerPxToGraph(marqueeStart.x, marqueeStart.y);
const b = containerPxToGraph(marqueeCur.x, marqueeCur.y);
const bx0 = Math.min(a.x, b.x),
by0 = Math.min(a.y, b.y);
const bx1 = Math.max(a.x, b.x),
by1 = Math.max(a.y, b.y);
marqueeStart = null;
marqueeCur = null;
const graphNodes = currentGraph().nodes || [];
let first = true;
for (const n of graphNodes as any) {
if (!n || n.id == null) continue;
const view = area.nodeViews.get(n.id);
const gx = view ? view.position.x : n.x || 0;
const gy = view ? view.position.y : n.y || 0;
const sz = measureNodeSize(n.id);
// a node intersects the box if their rects overlap (AABB), in graph coords.
const overlaps = gx < bx1 && gx + sz.w > bx0 && gy < by1 && gy + sz.h > by0;
if (overlaps) {
// accumulate=true keeps every intersector selected (first one replaces the prior
// selection so an old pick doesn't linger; rest accumulate). select(id, accumulate).
nodeSelectApi.select(n.id, !first);
first = false;
}
}
// surface @selection-change once the engine's awaited select() chain has flushed.
scheduleSelectionEmit();
};
if (local.selectable && !local.readonly && container && typeof container.addEventListener === 'function') {
marqueeBox = marqueeElRef || null;
onCanvasPointerDownCapture = (e: any) => {
// only in select mode, only the EMPTY canvas (not on a node — those still drag), only
// the primary button. A live `$props.mode` read = the persistent mode-guard (restoring
// pan is just this check returning early; no engine mutation).
if (mode() !== 'select') return;
if (e && e.button != null && e.button !== 0) return;
if (nodeAt(e.target)) return;
// BLOCK rete's pan Drag (its bubble-phase pointerdown on the same container) — capture
// phase runs first, so stopPropagation() here pre-empts pan; the marquee owns this drag.
e.stopPropagation();
e.preventDefault();
const box = container.getBoundingClientRect();
marqueeActive = true;
marqueeStart = {
x: e.clientX - box.left,
y: e.clientY - box.top
};
marqueeCur = {
x: marqueeStart.x,
y: marqueeStart.y
};
try {
if (container.setPointerCapture && e.pointerId != null) container.setPointerCapture(e.pointerId);
} catch (err: any) {}
updateMarqueeBox();
};
onMarqueePointerMove = (e: any) => {
if (!marqueeActive) return;
const box = container.getBoundingClientRect();
marqueeCur = {
x: e.clientX - box.left,
y: e.clientY - box.top
};
updateMarqueeBox();
};
onMarqueePointerUp = (e: any) => {
if (!marqueeActive) return;
try {
if (container.releasePointerCapture && e && e.pointerId != null) container.releasePointerCapture(e.pointerId);
} catch (err: any) {}
finishMarquee();
};
container.addEventListener('pointerdown', onCanvasPointerDownCapture, true);
container.addEventListener('pointermove', onMarqueePointerMove);
container.addEventListener('pointerup', onMarqueePointerUp);
}
// ─── initial graph: nodes first, then connections (connections reference live
// node instances), then optional fit. Sequenced via an async IIFE so the
// $onMount-returned teardown stays synchronous. ──────────────────────────────
;
(async () => {
// T1.3 — seed the canvas's own last-written graph from the initial bound value so the
// first gesture's snapshot/base reflects the mounted graph (immune to prop re-bind lag).
lastWrittenGraph = structuredClone(currentGraph());
await reconcileNodes();
await reconcileConnections();
if (typeof zoom() === 'number' && zoom() !== 1) {
programmatic++;
try {
await area.area.zoom(zoom());
} finally {
programmatic--;
}
}
if (local.fitOnMount && editor.getNodes().length) {
programmatic++;
try {
await AreaExtensions.zoomAt(area, editor.getNodes());
} finally {
programmatic--;
}
if (area) {
const k = area.area.transform.k;
if (k !== zoom()) setZoom(k);
}
}
// draw the minimap once the graph + fit have settled (also redrawn on every
// render / pan / zoom / drag / selection / graph change below).
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
})();
})() as unknown;
if (_cleanup) onCleanup(_cleanup as () => void);
onCleanup(() => {
if (onCanvasKeydown && keydownContainer && typeof keydownContainer.removeEventListener === 'function') {
try {
keydownContainer.removeEventListener('keydown', onCanvasKeydown);
} catch (e: any) {}
}
if (dragFlushRaf && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(dragFlushRaf);
} catch (e: any) {}
}
dragFlushRaf = 0;
pendingDragPositions.clear();
// T1.1: drop the edge-selection state + its cached <path> reference on teardown.
clearEdgeSelection();
// MiniMap teardown — remove the pointer-pan listeners + cancel a pending redraw.
if (minimapHost) {
if (onMinimapPointerDown) {
try {
minimapHost.removeEventListener('pointerdown', onMinimapPointerDown);
} catch (e: any) {}
}
if (onMinimapPointerMove) {
try {
minimapHost.removeEventListener('pointermove', onMinimapPointerMove);
} catch (e: any) {}
}
if (onMinimapPointerUp) {
try {
minimapHost.removeEventListener('pointerup', onMinimapPointerUp);
} catch (e: any) {}
}
}
if (minimapRedrawRaf && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(minimapRedrawRaf);
} catch (e: any) {}
}
minimapRedrawRaf = 0;
// T2.8 NodeToolbar teardown — remove the default-button listeners, dispose the optional
// `#toolbar` reactive portal handle, and cancel a pending reposition.
if (toolbarDeleteBtn && onToolbarDelete) {
try {
toolbarDeleteBtn.removeEventListener('pointerup', onToolbarDelete);
} catch (e: any) {}
}
if (toolbarDuplicateBtn && onToolbarDup) {
try {
toolbarDuplicateBtn.removeEventListener('pointerup', onToolbarDup);
} catch (e: any) {}
}
if (toolbarHandle && toolbarHandle.dispose) {
try {
toolbarHandle.dispose();
} catch (e: any) {}
}
toolbarHandle = null;
toolbarSelectedId = null;
if (toolbarTrackRaf && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(toolbarTrackRaf);
} catch (e: any) {}
}
toolbarTrackRaf = 0;
// T2.4 Marquee teardown — remove the capture-phase pointerdown guard + window listeners.
if (keydownContainer) {
if (onCanvasPointerDownCapture) {
try {
keydownContainer.removeEventListener('pointerdown', onCanvasPointerDownCapture, true);
} catch (e: any) {}
}
if (onMarqueePointerMove) {
try {
keydownContainer.removeEventListener('pointermove', onMarqueePointerMove);
} catch (e: any) {}
}
if (onMarqueePointerUp) {
try {
keydownContainer.removeEventListener('pointerup', onMarqueePointerUp);
} catch (e: any) {}
}
}
marqueeActive = false;
marqueeStart = null;
marqueeCur = null;
for (const [, entry] of nodeEntries as any) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
}
nodeEntries.clear();
for (const [, entry] of connEntries as any) entry.dispose();
connEntries.clear();
if (area) area.destroy();
});
});
createEffect(on(() => (() => graph())(), (v) => untrack(() => (() => {
// T1.3 — keep the canvas's own last-written graph in sync with an EXTERNAL (non-
// programmatic) consumer change, so undo/redo's "current" state tracks reality (our own
// write-backs / restores set lastWrittenGraph synchronously under the programmatic guard;
// this only refreshes it for a genuine outside edit).
if (selfWriteInFlight) {
// our own commitGraph write echoing back — lastWrittenGraph is already authoritative.
selfWriteInFlight = false;
} else if (!programmatic) {
const c = structuredClone(currentGraph());
if (c != null) lastWrittenGraph = c;
}
if (reconcileNodes) {
Promise.resolve(reconcileNodes()).then(() => {
if (reconcileConnections) reconcileConnections();
});
}
// graph changed (nodes added/removed/moved) → refresh the minimap node rects.
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
})()), { defer: true }));
createEffect(on(() => (() => portReg())(), (v) => untrack(() => (() => {
if (reconcileNodes) {
Promise.resolve(reconcileNodes()).then(() => {
if (reconcileConnections) reconcileConnections();
});
}
})()), { defer: true }));
createEffect(on(() => (() => typeReg())(), (v) => untrack(() => (() => {
if (reconcileNodes) reconcileNodes();
})()), { defer: true }));
createEffect(on(() => (() => zoom())(), (v) => untrack(() => ((v: any) => {
if (!area || typeof v !== 'number') return;
if (v === area.area.transform.k) return;
programmatic++;
Promise.resolve(area.area.zoom(v)).finally(() => {
programmatic--;
});
})(v)), { defer: true }));
let canvasElRef: HTMLElement | null = null;
let minimapElRef: HTMLElement | null = null;
let marqueeElRef: HTMLElement | null = null;
let toolbarElRef: HTMLElement | null = null;
// ── engine instances — null-lets so typeNeutralize types them `any` (the
// MapLibre `let instance = null` discipline). Rete's NodeEditor / AreaPlugin /
// ConnectionPlugin / DOMSocketPosition carry rich generic Schemes types that the
// loosely-typed .rozie props (any[]) don't satisfy under the strict react/solid/
// lit leaf tsc; routing every engine call through an `any` instance is the
// .rozie-native fix (no lang="ts", no codegen type-aid). These are top-level lets
// referenced from hooks → React auto-hoists each to a useRef. ──
let editor: any = null;
let area: any = null;
let connectionPlugin: any = null;
let socketWatcher: any = null;
let renderScope: any = null;
let selector: any = null;
// T2.6 — the AutoArrangePlugin instance (elkjs-backed). COMPONENT-scope (NOT $onMount-local)
// so the top-level autoArrange() verb sees it (the editor/area discipline). null until $onMount
// wires it; the verb no-ops before mount.
let arrange: any = null;
// Win 1: the Delete/Backspace keydown listener + its host container. COMPONENT-scope
// (NOT $onMount-local) so the $onMount-returned teardown — which the Solid emitter
// hoists into a sibling onCleanup() OUTSIDE the mount IIFE — can still see them to
// removeEventListener (the same component-scope discipline as nodeInstances below).
let keydownContainer: any = null;
let onCanvasKeydown: any = null;
// Phase 42 MiniMap (opt-in :minimap) — the absolute SVG overlay host + its imperative
// SVG layer + the pointer-pan listeners. COMPONENT-scope (NOT $onMount-local) so the
// $onMount-returned teardown — which the Solid emitter hoists into a sibling
// onCleanup() OUTSIDE the mount IIFE — can still removeEventListener them (the same
// keydown / nodeInstances discipline). `minimapMap` is the live minimap-px ↔ graph-
// coord mapping the pointer-pan handlers read; `scheduleMinimapRedraw` is the bridge
// the top-level $watch + the engine pipes call (assigned inside $onMount, like the
// reconcilers). minimapRedrawRaf coalesces the viewport-rect redraw to one per frame
// (the drag-write-back discipline — the viewport rect redraws on every pan/zoom).
let minimapHost: any = null;
let minimapSvg: any = null;
let minimapRedrawRaf = 0;
let minimapMap: any = null;
let minimapPanning = false;
let onMinimapPointerDown: any = null;
let onMinimapPointerMove: any = null;
let onMinimapPointerUp: any = null;
let scheduleMinimapRedraw: any = null;
// T2.4 MARQUEE select (mode:'select') — the programmatic-select handle captured from
// AreaExtensions.selectableNodes ({ select(id, accumulate), unselect(id) }), the rubber-
// band overlay box (component-template DOM, scoped CSS), and the capture-phase pointerdown
// guard + window pointer listeners that draw the box in select mode. COMPONENT-scope (NOT
// $onMount-local) so the Solid-hoisted teardown can removeEventListener them (the keydown /
// minimap discipline). `marqueeBox` is the absolute overlay <div>; `marqueeActive` gates the
// in-progress drag; `marqueeStart`/`marqueeCur` are container-relative px corners.
let nodeSelectApi: any = null;
let marqueeBox: any = null;
let marqueeActive = false;
let marqueeStart: any = null;
let marqueeCur: any = null;
let onCanvasPointerDownCapture: any = null;
let onMarqueePointerMove: any = null;
let onMarqueePointerUp: any = null;
// T2.8 NodeToolbar (opt-in :node-toolbar) — a floating component-template overlay (scoped
// CSS, like the marquee box + Controls) over the SELECTED node, positioned from the engine
// node-view element's rect relative to the canvas container + the area transform. COMPONENT-
// scope (NOT $onMount-local) so the Solid-hoisted teardown sees them. `toolbarHost` is the
// absolute overlay <div> (the $refs.toolbarEl element); `toolbarSelectedId` is the id of the
// node the toolbar currently tracks (the SINGLE selected node — null when nothing or >1 is
// selected, or selection is empty); `toolbarHandle` is the optional `#toolbar` reactive-
// portal handle ({ update, dispose }) when the consumer fills the slot; `scheduleToolbarTrack`
// is the rAF-coalesced reposition bridge (assigned in $onMount, called by the area pipes +
// the selection emit, like scheduleMinimapRedraw); `toolbarTrackRaf` coalesces it to one per
// frame. `toolbarDeleteBtn`/`toolbarDuplicateBtn` are the default buttons (kept so teardown
// can removeEventListener them); their pointerup handlers are `onToolbarDelete`/`onToolbarDup`.
let toolbarHost: any = null;
let toolbarSelectedId: any = null;
let toolbarHandle: any = null;
let scheduleToolbarTrack: any = null;
// component-scope bridge to the $onMount-local syncToolbar (the scheduleMinimapRedraw
// discipline) — called from maybeEmitSelectionChange + the area pipes so a pick/unpick /
// pan / zoom / drag re-tracks the toolbar over the selected node.
let syncToolbarSelection: any = null;
let toolbarTrackRaf = 0;
let toolbarDeleteBtn: any = null;
let toolbarDuplicateBtn: any = null;
let onToolbarDelete: any = null;
let onToolbarDup: any = null;
// MiniMap geometry (px) — MUST match the .rozie-flow-minimap CSS box below.
const MINIMAP_W = 200;
const MINIMAP_H = 150;
// Fallback node-rect dims when a node-view element isn't measurable yet (Lit async
// first paint, REQ-30) — re-measured on the next render (the render pipe re-schedules).
const MINIMAP_DEFAULT_NODE_W = 140;
const MINIMAP_DEFAULT_NODE_H = 52;
const SVGNS = 'http://www.w3.org/2000/svg';
// One Socket shared by every port (Rete sockets gate compatibility by identity;
// a single socket = "anything connects to anything", the common editor default).
const SOCKET = new ClassicPreset.Socket('flow');
// Live engine bookkeeping — COMPONENT-scope (NOT $onMount-local) so the
// $onMount-returned teardown, which the Solid emitter hoists into a sibling
// onCleanup() OUTSIDE the mount IIFE, keeps them in scope (the MapLibre
// markerEntries lesson).
// nodeInstances : id → live ClassicPreset.Node (engine truth)
// nodeMeta : id → the consumer's node spec object (for the slot scope)
// connInstances : id → live ClassicPreset.Connection (engine truth)
// nodeEntries : id → { element, bodyHost, handle, socketDisposers }
// connEntries : id → { element, dispose }
const nodeInstances = new Map();
const nodeMeta = new Map();
const connInstances = new Map();
const nodeEntries = new Map();
const connEntries = new Map();
// connMeta : id → the consumer's connection spec ({ …, label?, stroke?, dashed? }) — the
// connection-side analog of nodeMeta, read by renderConnection for per-edge label/styling (F3).
const connMeta = new Map();
// ids last applied FROM THE BOUND GRAPH, so reconcile removes only graph-managed
// entities — an imperative $expose addNode/addConnection is NOT auto-reaped on the
// next graph change (the power-user escape hatch stays alive). MapLibre reconciles
// every marker because markers are purely prop-driven; a flow editor also accepts
// imperative edits, so it tracks provenance. (Phase 41: nodes/connections now come
// ONLY from the single `graph` model — the per-instance declarative-children
// registries are gone; node TYPE templates + port schemas live in typeReg/portReg.)
let lastPropNodeIds: any = null;
let lastPropConnIds: any = null;
// Re-entrant suppression counter: while > 0 the editor/area event handlers skip
// echoing back into $emit / $model (our own programmatic add/remove/translate/
// zoom must not bounce out as if the user did it — the MapLibre PROGRAMMATIC
// eventData guard, in counter form so batched/nested ops never race).
let programmatic = 0;
// Win 2: the last emitted selection id-set, joined to a stable string, so
// @selection-change fires ONLY on an actual change (a repeated identical pick/unpick
// set does not spam the consumer). `null` until the first emit (so the initial empty
// selection does not emit on mount). COMPONENT-scope so it survives across area events.
let lastSelectionIds: any = null;
// T1.1 — EDGE SELECTION (D-08). The currently-selected CONNECTION id, or null. Lives
// PURELY in component script (the selectedNodeIds echo-safety discipline) — NEVER
// written into $model.graph, so the controlled-graph write-back assertions are
// unaffected (Threat T-44-01-2: no spurious model write). COMPONENT-scope so it
// survives across area events + so the Solid-hoisted teardown can clear it. The
// `.is-selected` class is toggled imperatively on the engine-DOM __path; this id is the
// source of truth the Delete branch reads. `selectedPathEl` caches the live <path>
// element so a background-click clear (and re-select) can drop `.is-selected` without
// re-walking the DOM. `edgeClickGuard` is a one-shot flag the area-background pointerup
// branch checks so an edge click (which fires its own pointerup on the path AND lets the
// area's background pointerup run) does not immediately clear the selection it just made
// — reset on the next microtask, after the gesture settles.
let selectedConnId: any = null;
let selectedPathEl: any = null;
let edgeClickGuard = false;
// T1.3 — UNDO / REDO (D-02 on-by-default, D-03 per-gesture graph-only scope, D-04
// echo-guarded restore). A CAPPED snapshot stack over the BOUND GRAPH only — nodes
// (incl x/y) + connections — and explicitly NOT the viewport (pan/zoom is excluded,
// D-03). One entry is pushed per COMPLETED gesture: a drag = ONE entry (snapshot taken
// on pointer-down, committed on the first translate — never per pointermove frame), a
// connect / disconnect / delete = one each. A push is gated on `!programmatic` so a
// restore-driven write (which runs INSIDE the programmatic guard) never re-enters the
// history (D-04). Pushing clears the redo branch and drops the oldest entry beyond the
// cap (Threat T-44-03-1: bounded memory). Snapshots are deep clones of the consumer's own
// serializable graph JSON (Pattern 7; the `$clone` sigil — a deep, de-proxied copy
// that strips the Vue/Svelte reactivity Proxy that a bare `structuredClone` THROWS
// on) — no external input, so the restore (T-44-03-2 accept)
// cannot loop (it rides the programmatic guard + the existing $watch(graph) reconcile).
// Undo is ALWAYS on for v1; `:history=false` (the `history` prop) is the cheap escape
// hatch that skips every push (the stacks stay empty → undo/redo are no-ops).
// COMPONENT-scope so the stack survives across area events + the Solid-hoisted teardown.
const HISTORY_CAP = 100;
// Two-stack model (simpler + correct than a single cursor): `historyStack` holds
// PRE-gesture snapshots (the states to UNDO back to, newest last); `redoStack` holds
// snapshots an undo popped off (the states to REDO forward to, newest last). A new
// gesture (pushHistory) snapshots the PRE-gesture graph onto historyStack and CLEARS
// redoStack (a fresh edit discards the redo branch). undo() pops historyStack → pushes
// the CURRENT (pre-undo) graph onto redoStack → restores the popped snapshot. redo()
// pops redoStack → pushes the current graph back onto historyStack → restores it.
let historyStack = [];
let redoStack = [];
// One-shot per-drag guard: a drag fires `nodetranslated` (→ flushDragWriteBack) on EVERY
// pointermove frame, so a push-per-flush would record many entries for ONE gesture. We
// snapshot the PRE-drag graph on `nodepicked` (pointer-DOWN, definitively before any
// movement — capturing it on the first `nodetranslated` is too late: the engine has
// already applied the initial delta + may have flushed a write-back, so $props.graph no
// longer holds the start position), stash it in `pendingDragSnapshot`, and COMMIT it to
// the history stack on the FIRST `nodetranslated` of the gesture (a pick WITHOUT a drag
// must not create a history entry). `dragGestureActive` then holds until the drag-ending
// `pointerup` resets it. D-03: a drag = ONE undo step.
let dragGestureActive = false;
let pendingDragSnapshot: any = null;
// T2.5 — RECONNECT coalescing (D-08 reconnectable edges, D-03 one-gesture-one-entry).
// Dragging an existing edge endpoint to a new socket is a SINGLE user gesture, but the
// shipped `Presets.classic.setup()` implements it as `editor.removeConnection(old)` then
// `editor.addConnection(new)` — so the write-back pipe sees a `connectionremoved` followed
// by a `connectioncreated`, which would push TWO history entries (Pitfall 2: two Ctrl+Z to
// undo one drag). The fix is to COALESCE: the ConnectionPlugin emits `connectionpick` when
// the user grabs a socket and `connectiondrop` when they release. While a reconnect is in
// flight (`reconnectInFlight > 0`) we SUPPRESS the per-event history pushes that
// writeBackConnectionRemoved / writeBackConnectionCreated normally do (the graph write-back
// itself STILL runs — the controlled graph stays correct), capturing the PRE-gesture
// snapshot ONCE on connectionpick (`reconnectPreSnapshot`). On `connectiondrop` we push that
// single snapshot (whether the drop landed on a new socket → `created:true` = a real
// reconnect, OR on an empty pane → `created:false` = the edge was removed with no re-add)
// and clear the flag. A plain drag-to-connect from an UNCONNECTED output socket also fires
// connectionpick/drop, but there is no remove in that gesture — the single `connectioncreated`
// write-back's own pushHistory is suppressed and the one coalesced snapshot is pushed on drop
// instead, so the per-gesture count stays exactly one either way. Counter form (not a bool)
// so a re-pick mid-gesture can't desync. COMPONENT-scope (survives across area events).
let reconnectInFlight = 0;
let reconnectPreSnapshot: any = null;
// Set true if a write-back (remove or add) actually ran during the in-flight window, so a
// connectionpick→drop that changed NOTHING (e.g. clicking a socket then releasing on the
// pane with no edge created/removed) does NOT push an empty history entry.
let reconnectDidWriteBack = false;
// One-shot guard for the DEFERRED close (the drop fires BEFORE the trailing remove+add
// writeBacks, so the window must close on a macrotask AFTER they settle — see the
// connectiondrop branch). A re-pick before the deferred close runs cancels it.
let reconnectCloseScheduled = false;
// ─── controlled-graph write-back (D4 — the central NEW capability) ─────────────
// On every drag/connect/disconnect the canvas emits a FRESH top-level
// `{ nodes, connections }` object via `$model.graph` — immutable React-Flow
// applyNodeChanges style (Wave-0-proven 6/6; in-place deep mutation is SILENT on
// React/Solid/Lit/Angular). Echo-guarded by the `programmatic` counter + the
// no-op-diff property: the write-back value already matches engine truth (the node
// is already at x/y; the edge already exists) so the consumer's re-bind →
// $watch(graph) → reconcile is a no-op diff.
//
// DRAG COALESCING (Pitfall 2): `nodetranslated` fires on every pointermove during a
// drag; emitting a fresh graph + full reconcile per frame is a rebuild storm. We
// accumulate the latest position per node (pendingDragPositions) and flush ONE fresh
// graph write per animation frame (dragFlushRaf), plus a final flush so the last
// position always lands. requestAnimationFrame coalesces multiple moves in a frame
// into a single $model.graph emit.
const pendingDragPositions = new Map(); // id → { x, y } (latest during a drag)
let dragFlushRaf = 0;
// The current bound graph — NEVER mutated in place.
function currentGraph() {
return graph() || {
nodes: [],
connections: []
};
}
// T1.3 — deep-clone a graph snapshot. The graph is serializable JSON (nodes/connections of
// primitives), so JSON round-trip is the robust path: it strips framework reactivity
// wrappers — a Vue `reactive()` Proxy / Svelte `$state` proxy that a bare
// `structuredClone` THROWS on ("could not be cloned"), the silent vue/svelte-only
// failure that left the history stack empty. Phase 45 replaced the hand-rolled
// JSON-first clone helper with the `$clone(x)` sigil at every call site below: it
// lowers to `rozieDeepClone(x)` on Vue (Phase 45-07 — a recursive proxy-safe deep
// clone in @rozie/runtime-vue that de-proxies nested INDEPENDENT reactive members,
// not just the top level), `$state.snapshot(x)` on Svelte, and `structuredClone(x)`
// on the other four — a deep, independent, de-proxied copy on all six (and
// `$clone(null)` → `null` on all six, preserving the old `g == null` early-return
// implicitly). The Rete graph is JSON-serializable, so `$clone` never throws here;
// the former null-return fallbacks at the call sites are now dead but harmless.
// T1.3 — the canvas's OWN last-written graph. Every write-back funnels through
// `commitGraph`, which sets `$model.graph` AND records the written value here. undo/redo
// use THIS (not the round-tripped `$props.graph`) as the "current" state to push onto the
// opposite stack — `$props.graph` lags a drag write-back on React/Vue/Svelte (the
// two-way re-bind is async / batched), so reading it at undo time captured an
// INTERMEDIATE drag position. `lastWrittenGraph` is exact + synchronous. Seeded from the
// bound graph in $onMount.
let lastWrittenGraph: any = null;
// Funnel for every component-driven graph write: record the value, then emit it. A deep
// clone is stored so a later consumer mutation of the live bound object can't corrupt the
// recorded state. (Echo-guarding is the CALLER's responsibility — restoreGraph wraps this
// in the programmatic guard.) `selfWriteInFlight` suppresses the resulting $watch(graph)
// tick from clobbering `lastWrittenGraph` with the (possibly still-stale, async) bound
// prop value — the value we just wrote IS the truth.
let selfWriteInFlight = false;
function commitGraph(g: any) {
const c = structuredClone(g);
lastWrittenGraph = c != null ? c : g;
selfWriteInFlight = true;
setGraph(g);
}
// Capture the canvas's current graph state (its own last write, falling back to the bound
// prop before the first write). Always a fresh deep clone.
function snapshotCurrent() {
const src = lastWrittenGraph != null ? lastWrittenGraph : currentGraph();
return structuredClone(src);
}
// The BASE graph a write-back builds its fresh object from: the canvas's own last write if
// present (immune to the async prop re-bind lag), else the bound prop. This keeps a rapid
// gesture sequence (e.g. drag then immediately disconnect) consistent even before the
// consumer's two-way re-bind has propagated the prior write back into `$props.graph`.
function baseGraph() {
return lastWrittenGraph != null ? lastWrittenGraph : currentGraph();
}
// Commit an ALREADY-CAPTURED snapshot onto the undo stack (caps + clears redo). Gated on
// the `history` prop. Used by both the synchronous-commit path (connect/disconnect/delete)
// and the drag gesture (pre-move snapshot taken on pointer-down, committed on first translate).
function pushHistorySnapshot(snap: any) {
if (local.history === false) return;
if (!snap) return;
historyStack.push(snap);
if (historyStack.length > HISTORY_CAP) {
historyStack = historyStack.slice(historyStack.length - HISTORY_CAP);
}
redoStack = [];
}
// Snapshot the canvas's CURRENT graph state + commit it onto the undo stack (the connect /
// disconnect / delete path — called BEFORE the write-back so the snapshot is the
// pre-gesture state). Gated on `!programmatic` (echo-guard) + history. D-03: one per gesture.
function pushHistory() {
if (programmatic) return;
if (local.history === false) return;
pushHistorySnapshot(snapshotCurrent());
}
// T2.5 — close the reconnect coalesce window. Called on a DEFERRED macrotask after a
// connectiondrop, so the trailing connectionremoved + connectioncreated writeBacks (which
// the classic preset fires AFTER the drop) have all run with the window still open
// (suppressing their per-event pushHistory, flagging reconnectDidWriteBack). Pushes the
// SINGLE pre-gesture snapshot iff the gesture actually changed the graph, then resets the
// per-gesture state. Idempotent + gated on the one-shot scheduled flag so a re-pick can
// cancel a pending close.
function closeReconnectGesture() {
if (!reconnectCloseScheduled) return;
reconnectCloseScheduled = false;
if (reconnectInFlight > 0) reconnectInFlight = 0;
if (!programmatic && local.history !== false && reconnectDidWriteBack && reconnectPreSnapshot) {
pushHistorySnapshot(reconnectPreSnapshot);
}
reconnectPreSnapshot = null;
reconnectDidWriteBack = false;
}
// Schedule the deferred close on a macrotask (setTimeout 0) — runs after the synchronous +
// microtask writeBack signals settle. Falls back to a microtask where setTimeout is absent.
function scheduleReconnectClose() {
if (reconnectCloseScheduled) return;
reconnectCloseScheduled = true;
if (typeof setTimeout === 'function') setTimeout(closeReconnectGesture, 0);else Promise.resolve().then(closeReconnectGesture);
}
// T1.3 — restore a captured snapshot by writing a FRESH `{ nodes, connections }` via
// `commitGraph` (→ `$model.graph`), wrapped in the `programmatic` guard so the consumer's
// re-bind → $watch(graph) → reconcile applies it WITHOUT re-entering history (D-04 —
// pushHistory / the write-back helpers all bail while `programmatic` is raised). Recorded
// in `lastWrittenGraph` so a following undo/redo sees the restored state as "current".
// Graph-ONLY (D-03): the viewport transform is untouched.
function restoreGraph(snap: any) {
if (!snap) return;
// Cancel any in-flight drag write-back so a queued frame can't clobber the restore with
// a stale position after the programmatic guard releases.
pendingDragPositions.clear();
if (dragFlushRaf) {
if (typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(dragFlushRaf);
} catch (e: any) {}
}
dragFlushRaf = 0;
}
programmatic++;
try {
const fresh = {
nodes: (snap.nodes || []).map((n: any) => ({
...n
})),
connections: (snap.connections || []).map((c: any) => ({
...c
}))
};
commitGraph(fresh);
} finally {
programmatic--;
}
}
// undo() — pop the newest PRE-gesture snapshot, push the CURRENT graph onto the redo
// stack, and restore the snapshot. No-op when nothing to undo.
function undo() {
if (historyStack.length === 0) return;
const cur = snapshotCurrent();
const snap = historyStack.pop();
if (cur) redoStack.push(cur);
restoreGraph(snap);
}
// redo() — pop the newest redo snapshot, push the CURRENT graph back onto the undo
// stack, and restore it. No-op when nothing to redo.
function redo() {
if (redoStack.length === 0) return;
const cur = snapshotCurrent();
const snap = redoStack.pop();
if (cur) historyStack.push(cur);
restoreGraph(snap);
}
function canUndo() {
return historyStack.length > 0;
}
function canRedo() {
return redoStack.length > 0;
}
// Flush the coalesced drag positions: one fresh graph object with every pending
// node's x/y applied. Echo-guarded. Clears the pending map.
function flushDragWriteBack() {
dragFlushRaf = 0;
if (programmatic) {
pendingDragPositions.clear();
return;
}
if (pendingDragPositions.size === 0) return;
const g = baseGraph();
const nodes = (g.nodes || []).map((n: any) => {
const p = n && n.id != null ? pendingDragPositions.get(n.id) : null;
return p ? {
...n,
x: p.x,
y: p.y
} : n;
});
pendingDragPositions.clear();
commitGraph({
...g,
nodes
});
}
// Schedule a coalesced drag write-back (rAF; falls back to a microtask where rAF is
// unavailable — e.g. a non-DOM test env).
function scheduleDragFlush() {
if (dragFlushRaf) return;
if (typeof requestAnimationFrame === 'function') {
dragFlushRaf = requestAnimationFrame(flushDragWriteBack);
} else {
dragFlushRaf = 1;
Promise.resolve().then(flushDragWriteBack);
}
}
// CONNECT — append a fresh connection into a fresh graph object. Echo-guarded.
function writeBackConnectionCreated(c: any) {
if (programmatic) return;
// T1.3 — one history entry per CONNECT gesture (BEFORE the write so the snapshot is the
// pre-connect state — snapshotCurrent reads lastWrittenGraph, still the pre-connect value).
// T2.5 — SUPPRESS while a reconnect is in flight: the paired remove+add of a reconnect
// (and a plain new-connection drag, which also rides connectionpick/drop) push ONE
// coalesced snapshot on connectiondrop instead (D-03 one-gesture-one-entry).
if (reconnectInFlight) reconnectDidWriteBack = true;else pushHistory();
const g = baseGraph();
const conn = {
id: c.id,
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
};
commitGraph({
...g,
connections: [...(g.connections || []), conn]
});
}
// DISCONNECT — filter the id out into a fresh graph object. Echo-guarded.
function writeBackConnectionRemoved(id: any) {
if (programmatic) return;
// T1.3 — one history entry per DISCONNECT / edge-delete gesture (BEFORE the write).
// T2.5 — SUPPRESS while a reconnect is in flight: the remove half of a reconnect is
// coalesced with its paired add into ONE snapshot pushed on connectiondrop (D-03).
if (reconnectInFlight) reconnectDidWriteBack = true;else pushHistory();
const g = baseGraph();
commitGraph({
...g,
connections: (g.connections || []).filter((e: any) => e && e.id !== id)
});
}
// T1.1 — EDGE SELECTION helpers (D-08). Selection state is kept PURELY in script
// (selectedConnId / selectedPathEl) and surfaced to the consumer via @edge-click /
// @edge-selected — never written into $model.graph (echo-safe like selectedNodeIds).
//
// `clearEdgeSelection` drops `.is-selected` from the live <path> (if still attached) and
// nulls the selection. `selectEdge` is invoked from the per-edge pointerup listener: it
// clears any prior selection, marks THIS path `.is-selected`, records the id + element,
// raises the one-shot `edgeClickGuard` (so the area's own background-pointerup branch
// does not immediately clear what this click just selected — the same pointerup gesture
// fires on the path AND lets the area pipe run), and emits BOTH @edge-click and
// @edge-selected ({ id }). The guard self-resets on the next microtask once the gesture
// has settled.
function clearEdgeSelection() {
if (selectedPathEl && selectedPathEl.classList) {
try {
selectedPathEl.classList.remove('is-selected');
} catch (e: any) {}
}
selectedConnId = null;
selectedPathEl = null;
}
function selectEdge(id: any, pathEl: any) {
if (id == null) return;
clearEdgeSelection();
selectedConnId = id;
selectedPathEl = pathEl;
if (pathEl && pathEl.classList) {
try {
pathEl.classList.add('is-selected');
} catch (e: any) {}
}
edgeClickGuard = true;
Promise.resolve().then(() => {
edgeClickGuard = false;
});
_props.onEdgeClick?.({
id
});
_props.onEdgeSelected?.({
id
});
}
// CASCADING DELETE (the PUBLIC controlled-graph node delete — Win 1). Distinct from
// the engine-only `removeNode` $expose verb: `removeNode` operates directly on the
// editor and is NOT written back to the model (the provenance-tracked imperative
// escape hatch); `deleteNode` is the BLESSED controlled-graph delete — it filters the
// node AND every incident connection out of FRESH arrays and writes ONE fresh
// top-level `{ ...g, nodes, connections }` object via `$model.graph` (the Phase-41
// write-back contract — in-place mutation is silently dropped on React/Solid/Lit/
// Angular). The wrapper's own `$watch(graph)` reconcile then reaps the live engine
// node + edges — we do NOT call editor.removeNode here (a double-remove would race the
// reconcile into Rete's "cannot find node"; the controlled-model filter is the single
// removal path). NOT echo-guarded with `programmatic` — this is a CONSUMER-driven write
// that SHOULD update the bound model (mirrors the demo's per-node ✕ filter). Returns
// true if a node was removed. The id-coerce-to-String mirrors the demo's onRemoveClick.
function deleteNode(id: any) {
if (id == null) return false;
const g = baseGraph();
const sid = String(id);
const nodes = (g.nodes || []).filter((n: any) => n && String(n.id) !== sid);
if (nodes.length === (g.nodes || []).length) return false;
const connections = (g.connections || []).filter((c: any) => c && String(c.source) !== sid && String(c.target) !== sid);
// T1.3 — one history entry per DELETE gesture (node + its incident edges = ONE undo).
pushHistory();
commitGraph({
...g,
nodes,
connections
});
return true;
}
// T2.8 — a fresh unique node id for a duplicated node. Derived from the source id + an
// incrementing suffix, skipping any id already present in the live graph so a repeated
// duplicate never collides (Threat T-44-06-2: a NEW unique id, never a forged/colliding
// one). String ids only (mirrors the graph contract).
function freshNodeId(baseId: any, existing: any) {
const taken = new Set((existing || []).map((n: any) => n && n.id != null ? String(n.id) : ''));
const root = baseId != null ? String(baseId) : 'node';
let i = 1;
let candidate = root + '-copy';
while (taken.has(candidate)) {
i++;
candidate = root + '-copy-' + i;
}
return candidate;
}
// T2.8 — DUPLICATE the given node: clone its spec at a small offset with a NEW unique id
// into a FRESH `{ ...g, nodes:[...g.nodes, clone] }` object (the controlled-graph write-back
// contract — never an in-place push). The clone's `data` is deep-cloned ($clone strips
// any reactivity proxy) so the copy is independent of the source. Connections are NOT cloned
// (a duplicate is an isolated node — the React-Flow default). One history entry per
// duplicate gesture (pushHistory, gated on !programmatic + history). Returns the new id, or
// null if the source isn't found. NOT echo-guarded — a duplicate SHOULD update the model.
function duplicateNode(id: any) {
if (id == null) return null;
const g = baseGraph();
const sid = String(id);
const src = (g.nodes || []).find((n: any) => n && String(n.id) === sid);
if (!src) return null;
const newId = freshNodeId(src.id, g.nodes);
// Phase 45-07 (WR-02/WR-06): `$clone` is now a recursive proxy-safe deep clone
// on every target (Vue's lowering de-proxies nested reactive members via the
// `rozieDeepClone` runtime helper). The historical `$clone({ d: src.data }).d`
// object-literal wrapper — which never actually dodged the old single-toRaw
// throw on a live nested proxy — is no longer needed; clone `src.data` directly.
const clonedData = src.data != null ? structuredClone(src.data) : undefined;
const clone = {
...src,
id: newId,
x: (typeof src.x === 'number' ? src.x : 0) + 28,
y: (typeof src.y === 'number' ? src.y : 0) + 28,
data: clonedData
};
pushHistory();
commitGraph({
...g,
nodes: [...(g.nodes || []), clone]
});
return newId;
}
// Collect the currently-SELECTED node ids from the live selector (Win 1 + Win 2). The
// AreaExtensions.selector() `entities` Map holds the picked entities ({ label, id });
// for selectable nodes each entity's `id` is the node id. Empty when nothing is picked
// or selection is disabled. Read-only — no $data / engine write.
function selectedNodeIds() {
if (!selector || !selector.entities) return [];
const ids = [];
for (const e of selector.entities.values() as any) {
if (e && e.id != null) ids.push(e.id);
}
return ids;
}
// Win 2: surface selection changes to the consumer via @selection-change ({ ids }).
// Computes the current selected-id set, dedupes against the last-emitted set (joined
// string), and emits only on an ACTUAL change. Echo-guarded by `programmatic` so a
// PROGRAMMATIC unselect (clear/deleteNode may unpick) does not surface as a user
// selection. Selection is kept PURELY in the emit — never written into the graph model
// — so the controlled-graph echo-safety (the drag write-back assertions) is unaffected.
// Sorted before joining so the dedup key is order-independent (the selector Map order
// is not guaranteed stable across pick/unpick).
function maybeEmitSelectionChange() {
if (programmatic) return;
const ids = selectedNodeIds();
const key = [...ids].map((x: any) => String(x)).sort().join(' ');
if (key === lastSelectionIds) return;
lastSelectionIds = key;
_props.onSelectionChange?.({
ids
});
// the selected set changed → repaint the minimap (selected nodes are highlighted).
if (scheduleMinimapRedraw) scheduleMinimapRedraw();
// T2.8 — the selection changed → re-track the NodeToolbar (it follows the single
// selected node; hides on multi-select / empty selection). No-op when :node-toolbar off.
if (syncToolbarSelection) syncToolbarSelection();
}
// Schedule the selection recompute AFTER the engine's own async selection update has
// settled. AreaExtensions.selectableNodes does its pick / unselectAll via AWAITED
// area.update() calls, so a bare microtask can run before `selector.entities` reflects
// the new state. A microtask AND an rAF together guarantee we recompute once the engine
// chain has flushed (the dedup collapses the pair to at most one emit). Falls back to a
// double microtask where rAF is unavailable (non-DOM test env).
function scheduleSelectionEmit() {
Promise.resolve().then(maybeEmitSelectionChange);
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(maybeEmitSelectionChange);
} else {
Promise.resolve().then(() => Promise.resolve().then(maybeEmitSelectionChange));
}
}
// The $portals/$emit-capturing reconcilers are built INSIDE $onMount ($portals
// referenced at top level fails the bundled-leaf strict typecheck — the CM/
// TipTap/MapLibre portal discipline) and bridged here so the top-level $watch can
// call them.
let reconcileNodes: any = null;
let reconcileConnections: any = null;
// Re-entrancy guard for reconcileNodes. The declarative-children path can fire the
// node reconcile RE-ENTRANTLY on async-context targets (Lit): a <FlowNode>'s
// $onMount register starts reconcile #1, and its late-context $onUpdate registration
// (REQ-30) — or the registry $watch the register triggers — starts reconcile #2 while
// #1's awaits (editor.addNode / area.translate / area.update) are still pending. Two
// overlapping reconciles racing the same engine throw Rete's "cannot find node" (one
// updates/translates a node-view the other just rebuilt), which aborts the whole graph
// build (only the config-array `cfg` node survives on Lit). This flag serializes them:
// a reconcile requested while one is running sets a "run again" bit and returns; the
// in-flight reconcile re-runs once it finishes, so every registry mutation is folded
// into a fresh non-overlapping pass. The config-array-only path never re-enters (props
// change once per tick), so this is byte-transparent to its behavior.
let reconcileNodesRunning = false;
let reconcileNodesPending = false;
// ── pure helpers (no sigils → safe at top level) ──
function serializeConn(c: any) {
return {
id: c.id,
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
};
}
// Resolve a node TYPE's port schema from the flat per-TYPE portReg — the entries
// whose key starts `type + '::'`. Returns { inputs:[{key,label,multiple,portType}],
// outputs:[…] }. Pure (no $data write) so buildNode / buildSocketRow can call it on
// every run regardless of the order the <NodeType> vs its <Port> children registered.
function portSchemaForType(type: any, portReg: any) {
const inputs = [];
const outputs = [];
if (type == null || !portReg) return {
inputs,
outputs
};
const prefix = type + '::';
for (const k in portReg) {
if (k.indexOf(prefix) !== 0) continue;
const p = portReg[k];
if (!p || p.key == null) continue;
const entry = {
key: p.key,
label: p.label,
multiple: p.multiple,
portType: p.portType
};
if (p.side === 'input') inputs.push(entry);else outputs.push(entry);
}
return {
inputs,
outputs
};
}
// Build a live Rete node from a graph-node spec ({ id, type, x, y, data }). The
// consumer's `id` is assigned onto the node so positions, portal keys, and
// connection source/target ids all align with the author's identifiers (Rete would
// otherwise auto-generate ids). Sockets come from the node's TYPE port schema
// (portReg keyed `type::side::key`) — a type's ports declared ONCE apply to every
// instance (render-by-type). The single shared SOCKET still gates compatibility by
// identity; the per-port `portType` drives typed VALIDATION, not socket identity.
function buildNode(spec: any, portReg: any) {
const label = spec.data && spec.data.label != null ? String(spec.data.label) : '';
const node = new ClassicPreset.Node(label);
node.id = spec.id;
const {
inputs,
outputs
} = portSchemaForType(spec.type, portReg);
for (const inp of inputs as any) {
if (!inp || inp.key == null) continue;
node.addInput(inp.key, new ClassicPreset.Input(SOCKET, inp.label, inp.multiple === true));
}
for (const out of outputs as any) {
if (!out || out.key == null) continue;
node.addOutput(out.key, new ClassicPreset.Output(SOCKET, out.label, out.multiple !== false));
}
return node;
}
// NOTE: portTypeOf (the validation-pipe port-type resolver) is DEFINED INSIDE
// $onMount (next to the editor.addPipe that uses it), NOT here at top level. It reads
// $data.portReg, and a top-level definition lowers on React to a `useCallback` whose
// captured `portReg` is FROZEN at the snapshot when the validation pipe (set up once in
// the mount effect) was created — i.e. the INITIAL empty {} before any <Port> registered.
// A stale-empty portReg makes portTypeOf return null for every port, so the typed-socket
// validation `srcType != null && tgtType != null && srcType !== tgtType` check is SKIPPED
// and a cross-type connection is WRONGLY ALLOWED (the React-only "reject didn't fire" bug
// the advanced VR cell surfaced). Defined inside $onMount, the emitter lowers its
// $data.portReg read to the live `_portRegRef.current` (the same ref the reconcilers use),
// so validation always sees the current schema. The 5 non-React targets read live signals
// so they were correct either way; this is the React stale-closure fix (the MapLibre/PDF
// $watch-reroute lesson, here as a mount-scoped definition). ZERO emitter change.
// ─── per-TYPE registry (Phase 41 controlled-graph — the per-TYPE shift of the
// Phase 37 per-instance $provide/$inject dogfood) ────────────────────────────────
// The 'rete:canvas' registry API CONSUMED BY <NodeType>/<Port> (41-03). CRITICAL
// reactive-write discipline (Pitfall 1): every mutation WHOLE-OBJECT-REPLACES the
// registry so the watched $data.typeReg/$data.portReg reference changes exactly once
// per call — a bare in-place $data.typeReg[type] = spec is silent on React/Solid/
// Angular/Lit. THE CROSS-PLAN CONTRACT (41-03 calls EXACTLY these verbs):
// registerType(type, spec) → type-template registry (<NodeType>)
// unregisterType(type) → drop a type on <NodeType> unmount
// addTypePort(type, side, key, portType, label, multiple) → per-TYPE port schema (<Port>)
// bodyHostFor(nodeId) → the engine `body` host div
// (render-by-type callback target)
// ─── imperative handle (Phase 21 $expose) ────────────────────────────────────
// Collision discipline (ROZ121/ROZ524/Lit-lifecycle):
// - NO `setZoom` — `zoom` is a model prop, so React auto-generates a `setZoom`
// state setter (the MapLibre setCenter/setZoom lesson); the verb is `zoomTo`.
// - NONE equals a Lit reserved lifecycle name (update/render/firstUpdated/
// updated/willUpdate/requestUpdate) — note `clear` and `getNodes` are safe.
// - NONE equals an emitted event name (node-moved/node-picked/connection-*
// /translated/context-menu/node-action) or a prop name.
// addNode/addConnection/removeNode/removeConnection operate on the engine
// directly and are NOT reaped by props reconcile (provenance-tracked).
function getEditor() {
return editor;
}
function getArea() {
return area;
}
async function addNode(spec: any) {
if (!editor || !spec || spec.id == null) return null;
const node = buildNode(spec, portReg());
nodeInstances.set(spec.id, node);
nodeMeta.set(spec.id, spec);
programmatic++;
try {
await editor.addNode(node);
await area.translate(spec.id, {
x: spec.x || 0,
y: spec.y || 0
});
} finally {
programmatic--;
}
return spec.id;
}
async function removeNode(id: any) {
if (!editor || id == null || !nodeInstances.has(id)) return false;
programmatic++;
try {
for (const c of editor.getConnections() as any) {
if (c.source === id || c.target === id) await editor.removeConnection(c.id);
}
await editor.removeNode(id);
} finally {
programmatic--;
}
nodeInstances.delete(id);
nodeMeta.delete(id);
return true;
}
async function addConnection(spec: any) {
if (!editor || !spec || spec.source == null || spec.target == null) return null;
const srcOut = spec.sourceOutput != null ? spec.sourceOutput : 'out';
const tgtIn = spec.targetInput != null ? spec.targetInput : 'in';
const sourceNode = nodeInstances.get(spec.source);
const targetNode = nodeInstances.get(spec.target);
if (!sourceNode || !targetNode) return null;
const conn = new ClassicPreset.Connection(sourceNode, srcOut, targetNode, tgtIn);
if (spec.id != null) conn.id = spec.id;
programmatic++;
try {
await editor.addConnection(conn);
} finally {
programmatic--;
}
connInstances.set(conn.id, conn);
return conn.id;
}
async function removeConnection(id: any) {
if (!editor || id == null) return false;
programmatic++;
try {
await editor.removeConnection(id);
} finally {
programmatic--;
}
connInstances.delete(id);
return true;
}
async function clear() {
if (!editor) return;
programmatic++;
try {
await editor.clear();
} finally {
programmatic--;
}
nodeInstances.clear();
nodeMeta.clear();
connInstances.clear();
connMeta.clear();
lastPropNodeIds = [];
lastPropConnIds = [];
}
async function zoomToFit() {
if (!area || !editor) return;
programmatic++;
try {
await AreaExtensions.zoomAt(area, editor.getNodes());
} finally {
programmatic--;
}
const k = area.area.transform.k;
if (k !== zoom()) setZoom(k);
}
async function zoomTo(k: any) {
if (!area || typeof k !== 'number') return;
programmatic++;
try {
await area.area.zoom(k);
} finally {
programmatic--;
}
if (k !== zoom()) setZoom(k);
}
// ─── viewport API (Phase 42 — the T11 gap + what the pannable minimap needs) ─────
// Both write the AreaPlugin transform via the CONFIRMED Rete v2 area API: with the
// origin omitted `area.area.zoom(k)` leaves x/y unchanged (transform.x += 0·d), and
// `area.area.translate(x, y)` sets the pan ABSOLUTELY (verified against rete-area-
// plugin@2.1.5). Echo-guarded with `programmatic` so the transform write doesn't loop
// back through the zoomed/nodetranslated write-back (the `translated` emit stays
// UNCONDITIONAL, so @translated still surfaces a programmatic recenter — a real
// viewport change the consumer asked for). After, echo `$model.zoom` (mirrors zoomTo).
// Collision discipline: setCenter/setViewport are NOT Lit lifecycle names, NOT emit
// names, NOT prop names, NOT React model-setters (`graph`/`zoom` → setGraph/setZoom),
// and NOT inherited DOM methods (the Embla scrollTo lesson) — clean on all 6.
//
// setViewport({ x, y, k }) — set the raw transform (any field omitted keeps its
// current value).
async function setViewport(vp: any) {
if (!area || !vp || typeof vp !== 'object') return;
const tf = area.area.transform;
const k = typeof vp.k === 'number' ? vp.k : tf.k;
const x = typeof vp.x === 'number' ? vp.x : tf.x;
const y = typeof vp.y === 'number' ? vp.y : tf.y;
programmatic++;
try {
if (k !== area.area.transform.k) await area.area.zoom(k);
await area.area.translate(x, y);
} finally {
programmatic--;
}
if (k !== zoom()) setZoom(k);
}
// setCenter(x, y, opts?) — center the viewport on graph-coords (x, y), optionally
// setting zoom (`opts.zoom`). The transform that puts graph point (x,y) at the canvas
// center is tx = W/2 − x·k, ty = H/2 − y·k (screen = graph·k + transform). W/H are the
// engine container's pixel dims (area.container — public on AreaPlugin, no $refs read).
async function setCenter(x: any, y: any, opts: any) {
if (!area || typeof x !== 'number' || typeof y !== 'number') return;
const k = opts && typeof opts.zoom === 'number' ? opts.zoom : area.area.transform.k;
const el = area.container;
const cw = el && el.clientWidth ? el.clientWidth : 0;
const ch = el && el.clientHeight ? el.clientHeight : 0;
const tx = cw / 2 - x * k;
const ty = ch / 2 - y * k;
programmatic++;
try {
if (k !== area.area.transform.k) await area.area.zoom(k);
await area.area.translate(tx, ty);
} finally {
programmatic--;
}
if (k !== zoom()) setZoom(k);
}
// ─── built-in Controls overlay handlers (Win 4) ──────────────────────────────
// Wired to the in-template zoom in / out / fit buttons (gated r-if="$props.controls").
// They REUSE the zoomTo / zoomToFit verbs (one implementation — no logic duplication),
// clamping the step to [minZoom, maxZoom] so a button never exceeds the restrictor
// bounds. Zoom/fit are view-only, so they stay enabled even when readonly (they do not
// edit the graph). A no-op before the area mounts.
const ZOOM_STEP = 1.2;
function clampZoom(k: any) {
let lo = typeof local.minZoom === 'number' && local.minZoom > 0 ? local.minZoom : 0.01;
let hi = typeof local.maxZoom === 'number' && local.maxZoom > 0 ? local.maxZoom : 100;
if (k < lo) return lo;
if (k > hi) return hi;
return k;
}
function controlZoomIn() {
if (!area) return;
zoomTo(clampZoom(area.area.transform.k * ZOOM_STEP));
}
function controlZoomOut() {
if (!area) return;
zoomTo(clampZoom(area.area.transform.k / ZOOM_STEP));
}
function controlFit() {
zoomToFit();
}
// T2.4 — the gated 4th Controls button toggles the two-way mode (pan ↔ select). Writes
// $model.mode (model:true); the consumer's r-model:mode (or the internal demo state) updates.
function toggleMode() {
setMode(mode() === 'select' ? 'pan' : 'select');
}
function getNodes() {
if (!area) return [];
const out = [];
for (const [id, node] of nodeInstances as any) {
const view = area.nodeViews.get(id);
out.push({
id,
label: node.label,
x: view ? view.position.x : 0,
y: view ? view.position.y : 0
});
}
return out;
}
function getConnections() {
return editor ? editor.getConnections().map(serializeConn) : [];
}
function getTransform() {
return area ? {
x: area.area.transform.x,
y: area.area.transform.y,
k: area.area.transform.k
} : null;
}
// screenToFlowPosition(clientX, clientY) → { x, y } in GRAPH coords (Phase 43 — the
// palette-drop / no-code-builder primitive, the React-Flow `screenToFlowPosition`
// parity). The INVERSE of the area transform: a graph point projects to the screen as
// `screen = containerOrigin + transform.{x,y} + graph·k`, so
// `graph = (client − containerOrigin − transform) / k`. `area.container` is public on
// the AreaPlugin (no $refs read). Returns null before the area mounts. The component
// owns ONLY this projection — the consumer owns the drag/drop (a palette item's
// `draggable` + the canvas `@dragover.prevent`/`@drop`) and writes the new node into the
// bound `graph` at the returned coords, exactly like React Flow (which does not own the
// palette either).
function screenToFlowPosition(clientX: any, clientY: any) {
if (!area || typeof clientX !== 'number' || typeof clientY !== 'number') return null;
const el = area.container;
const rect = el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null;
if (!rect) return null;
const t = area.area.transform;
const k = t.k || 1;
return {
x: (clientX - rect.left - t.x) / k,
y: (clientY - rect.top - t.y) / k
};
}
// T2.6 — autoArrange(opts?) — relayout the graph into a non-overlapping LAYERED arrangement
// (D-08, verb-only, NO auto-trigger — the MapLibre verb-first stance). Runs the
// AutoArrangePlugin (elkjs classic preset), then READS the arranged positions BACK into a
// FRESH `{ nodes, connections }` object written through `$model.graph` (the controlled-graph
// contract — the engine is never the source of truth, mirroring the drag write-back).
//
// PITFALL 3 (Plan 00 / RESEARCH): elkjs needs each node's `width`/`height`; our nodes are
// plain `ClassicPreset.Node` with no dimensions, so without dims the classic preset collapses
// every node to (0,0). We set `node.width`/`node.height` from the MEASURED engine node-view
// element (area.nodeViews.get(id).element offsetW/H — target-agnostic, the measureNodeSize
// discipline) BEFORE layout, falling back to MINIMAP_DEFAULT_NODE_W/H for Lit's unmeasured
// first paint. (measureNodeSize itself is $onMount-local; the verb is top-level, so the same
// measure is inlined here over the component-scope `area` + `nodeInstances`.)
//
// Echo-guarded (programmatic++ around layout AND the write-back) so the engine relayout and
// the resulting $model.graph re-bind → $watch(graph) → reconcile don't re-enter; ONE history
// snapshot is pushed for the whole gesture (D-03, gated on !programmatic + history). The
// optional `opts.options` (elk layout options — direction/spacing) is forwarded to
// arrange.layout() (D-01 discretion — default-only is fine; the arg stays optional).
//
// Collision discipline: `autoArrange` is NOT a Lit lifecycle name (update/render/firstUpdated/
// updated/willUpdate/requestUpdate), NOT an inherited DOM method (the Embla scrollTo lesson),
// NOT an emit (node-*/connection-*/translated/context-menu/selection-change/edge-*/node-action),
// NOT a prop, NOT a React model-setter (graph/zoom → setGraph/setZoom) — clean on all 6.
async function autoArrange(opts: any) {
if (!arrange || !area) return;
// Set elkjs dimensions on every live node instance from its measured node-view element
// (Pitfall 3) — without dims the classic preset stacks all nodes at (0,0).
for (const [id, node] of nodeInstances as any) {
const view = area.nodeViews ? area.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
node.width = el && el.offsetWidth ? el.offsetWidth : MINIMAP_DEFAULT_NODE_W;
node.height = el && el.offsetHeight ? el.offsetHeight : MINIMAP_DEFAULT_NODE_H;
}
// ONE history entry for the arrange gesture, captured BEFORE the write (pushHistory reads
// lastWrittenGraph, still the pre-arrange state). Gated on !programmatic + history.
pushHistory();
programmatic++;
try {
await arrange.layout(opts && opts.options ? {
options: opts.options
} : undefined);
} finally {
programmatic--;
}
// Read the arranged positions back into a FRESH graph object (controlled-graph contract).
// Echo-guarded: commitGraph → $model.graph re-bind must not re-enter the reconcile as a new
// gesture. (The arrange already moved the engine to these coords, so the reconcile is a
// no-op diff; the guard is belt-and-braces + suppresses any history re-entry.)
programmatic++;
try {
const g = baseGraph();
const nodes = (g.nodes || []).map((n: any) => {
const v = n && n.id != null && area.nodeViews ? area.nodeViews.get(n.id) : null;
return v && v.position ? {
...n,
x: v.position.x,
y: v.position.y
} : n;
});
commitGraph({
...g,
nodes
});
} finally {
programmatic--;
}
}
// ─── imperative selection control ────────────────────────────────────────────
// Selection was previously PUSH-ONLY (the `selection-change` emit fires on change,
// but a consumer couldn't READ or DRIVE selection). These reuse the internal
// `selector` / `nodeSelectApi` (AreaExtensions.selector + selectableNodes) already
// wired for the marquee — no new engine state. All no-op when selection is off
// (readonly / !selectable, when `nodeSelectApi` is null). Each schedules the same
// post-settle `selection-change` recompute the marquee uses, so an imperative
// select keeps the consumer's bound state in sync (the zoomTo→$model.zoom echo
// stance). Collision discipline: `selectNode` is NOT bare `select` — `select` is
// an inherited HTMLElement method (Lit shadow, the Embla scrollTo lesson) AND a
// FullCalendar-style emit hazard; getSelectedNodes/clearSelection/selectAll/
// centerOnNode are NOT emits (selection-change/node-*/edge-*), NOT props, NOT
// React model-setters (graph/zoom → setGraph/setZoom), NOT Lit lifecycle.
//
// getSelectedNodes() — the currently-selected nodes as { id, label, x, y } (the
// getNodes() shape, filtered to the live selection). Empty when nothing selected.
function getSelectedNodes() {
const sel = new Set(selectedNodeIds().map((x: any) => String(x)));
return getNodes().filter((n: any) => sel.has(String(n.id)));
}
// selectNode(id, accumulate?) — programmatically select a node (sidebar/search →
// highlight). accumulate=true adds to the current selection; falsy replaces it.
function selectNode(id: any, accumulate: any) {
if (!nodeSelectApi || id == null) return;
nodeSelectApi.select(id, !!accumulate);
scheduleSelectionEmit();
}
// clearSelection() — unselect every selected node (and any selected edge).
function clearSelection() {
if (nodeSelectApi) {
for (const id of selectedNodeIds() as any) nodeSelectApi.unselect(id);
}
clearEdgeSelection();
scheduleSelectionEmit();
}
// selectAll() — select every node (Ctrl+A is not bound; marquee only covers a
// dragged region). Mirrors the marquee's first-replaces / rest-accumulate pattern.
function selectAll() {
if (!nodeSelectApi) return;
let first = true;
for (const n of getNodes() as any) {
nodeSelectApi.select(n.id, !first);
first = false;
}
scheduleSelectionEmit();
}
// centerOnNode(id, opts?) — pan (and optionally zoom via opts.zoom) to center the
// viewport on a node by id. setCenter is coordinate-based; this measures the node
// to compute its center in GRAPH coords (position is the top-left; offsetW/H are
// unscaled graph units), falling back to the minimap default dims pre-measure.
async function centerOnNode(id: any, opts: any) {
if (!area || id == null) return;
const view = area.nodeViews ? area.nodeViews.get(id) : null;
if (!view || !view.position) return;
const el = view.element;
const w = el && el.offsetWidth ? el.offsetWidth : MINIMAP_DEFAULT_NODE_W;
const h = el && el.offsetHeight ? el.offsetHeight : MINIMAP_DEFAULT_NODE_H;
await setCenter(view.position.x + w / 2, view.position.y + h / 2, opts);
}
return (
<__ctx_rete_canvas.Provider value={{
// Register/replace a node TYPE template. `spec` carries an optional
// `bodyRenderer(host, { node })` — the render-by-type projection (mounted per graph
// node of this type into the engine body host, see renderNode). Whole-object replace.
registerType: (type: any, spec: any) => {
if (type != null) setTypeReg({
...typeReg(),
[type]: spec
});
},
// Drop a type on <NodeType> unmount (whole-object replace).
unregisterType: (type: any) => {
const t = {
...typeReg()
};
delete t[type];
setTypeReg(t);
},
// A <Port> registers a port against its TYPE + side. Stored in the flat portReg
// under a UNIQUE per-port key `type::side::key` so registration is order-independent
// AND concurrency-safe: two <Port>s of the same type addTypePort in one React commit,
// and a pure `{ ...portReg, [uniqueKey]: port }` write (functional setState) merges
// both (an array read-modify-write under one type key would clobber). buildNode reads
// the type's portReg entries on every run regardless of mount order. The unique key
// also makes a re-fired addTypePort (late Lit context) idempotent — same key, same value.
// `side` is derived by <Port> from which of output=/input= is set (output⇒'output', input⇒'input');
// `portType` carries the port type that drives validate-types + the typed-port color.
// `position` (F2) is the socket's VISUAL placement (left|right|top|bottom; default by
// side) — drives the render-pipe socket layout + the connection-anchor axis.
addTypePort: (type: any, side: any, key: any, portType: any, label: any, multiple: any, position: any) => {
if (type == null || key == null) return;
const portKey = type + '::' + side + '::' + key;
setPortReg({
...portReg(),
[portKey]: {
type,
side,
key,
portType,
label,
multiple,
position
}
});
},
// Render-by-type callback target. Returns the engine-created body host div for a
// graph node (nodeEntries.get(nodeId).body). The render-by-type projection mounts
// the node's TYPE template `#body` INTO this host via $portals — the Wave-0 A3
// finding (a Lit child cannot relocate its own shadow <slot> across the boundary),
// so the body is projected by the parent reusing the $portals host discipline.
bodyHostFor: (nodeId: any) => {
const entry = nodeEntries.get(nodeId);
return entry ? entry.body : null;
}
}}>
<>
<div class={"rozie-flow-canvas"} ref={(el) => { canvasElRef = el as HTMLElement; }} tabIndex={0} data-rozie-s-cd396d6a="">
{<Show when={local.controls}><div class={"rozie-flow-controls"} data-rozie-s-cd396d6a="">
<button type="button" data-testid="flow-zoom-in" aria-label="Zoom in" class={"rozie-flow-controls__btn"} onClick={controlZoomIn} data-rozie-s-cd396d6a="">+</button>
<button type="button" data-testid="flow-zoom-out" aria-label="Zoom out" class={"rozie-flow-controls__btn"} onClick={controlZoomOut} data-rozie-s-cd396d6a="">−</button>
<button type="button" data-testid="flow-fit" aria-label="Fit view" class={"rozie-flow-controls__btn"} onClick={controlFit} data-rozie-s-cd396d6a="">☐</button>
{<Show when={local.marquee}><button type="button" data-testid="flow-mode" aria-label={rozieAttr(mode() === 'select' ? 'Select mode (click to pan)' : 'Pan mode (click to select)')} class={"rozie-flow-controls__btn" + " " + rozieClass({ 'is-active': mode() === 'select' })} onClick={toggleMode} data-rozie-s-cd396d6a="">{rozieDisplay(mode() === 'select' ? '▢' : '✥')}</button></Show>}</div></Show>}{<Show when={local.minimap}><div class={"rozie-flow-minimap"} ref={(el) => { minimapElRef = el as HTMLElement; }} data-testid="flow-minimap" data-rozie-s-cd396d6a="" /></Show>}<div class={"rozie-flow-marquee"} ref={(el) => { marqueeElRef = el as HTMLElement; }} data-testid="flow-marquee" data-rozie-s-cd396d6a="" />
{<Show when={local.nodeToolbar}><div class={"rozie-flow-toolbar"} ref={(el) => { toolbarElRef = el as HTMLElement; }} data-testid="flow-toolbar" data-rozie-s-cd396d6a="" /></Show>}</div>
{resolved()}
</>
</__ctx_rete_canvas.Provider>
);
}ts
import { LitElement, css, html, nothing, render } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, effect, signal, untracked } from '@lit-labs/preact-signals';
import { adoptDocumentStyles, createLitControllableProperty, injectGlobalStyles, rozieAttr, rozieDisplay } from '@rozie/runtime-lit';
import { ContextProvider, createContext } from '@lit/context';
import { NodeEditor, ClassicPreset, Scope } from 'rete';
import { AreaPlugin, AreaExtensions } from 'rete-area-plugin';
import { ConnectionPlugin, Presets as ConnectionPresets } from 'rete-connection-plugin';
import { getDOMSocketPosition, classicConnectionPath } from 'rete-render-utils';
// T2.6 — auto-layout (D-08, verb-only). The 3 deps (rete-auto-arrange-plugin / elkjs
// @0.8.2 / web-worker) are OPTIONAL leaf peers, installed + bundle-smoked on all 6 in
// Plan 00 (the Vite/Angular-AOT/Lit rollup build resolves elkjs to the SYNCHRONOUS
// elk.bundled.js entry — no web-worker resolution error, no manual fallback switch). Only
// a consumer calling autoArrange() pulls these in.
// T2.6 — auto-layout (D-08, verb-only). The 3 deps (rete-auto-arrange-plugin / elkjs
// @0.8.2 / web-worker) are OPTIONAL leaf peers, installed + bundle-smoked on all 6 in
// Plan 00 (the Vite/Angular-AOT/Lit rollup build resolves elkjs to the SYNCHRONOUS
// elk.bundled.js entry — no web-worker resolution error, no manual fallback switch). Only
// a consumer calling autoArrange() pulls these in.
import { AutoArrangePlugin, Presets as ArrangePresets } from 'rete-auto-arrange-plugin';
// ── engine instances — null-lets so typeNeutralize types them `any` (the
// MapLibre `let instance = null` discipline). Rete's NodeEditor / AreaPlugin /
// ConnectionPlugin / DOMSocketPosition carry rich generic Schemes types that the
// loosely-typed .rozie props (any[]) don't satisfy under the strict react/solid/
// lit leaf tsc; routing every engine call through an `any` instance is the
// .rozie-native fix (no lang="ts", no codegen type-aid). These are top-level lets
// referenced from hooks → React auto-hoists each to a useRef. ──
const __rozieCtx_rete_canvas = createContext(Symbol.for("rozie:rete:canvas"));
interface RozieNodeSlotCtx {
node: unknown;
selected: unknown;
emit: unknown;
}
interface RozieToolbarSlotCtx {
node: unknown;
emit: unknown;
}
@customElement('rozie-flow-canvas')
export default class FlowCanvas extends SignalWatcher(LitElement) {
static styles = css`
.rozie-flow-canvas[data-rozie-s-cd396d6a] {
width: 100%;
height: 100%;
min-height: 360px;
position: relative;
overflow: hidden;
border-radius: 8px;
background:
radial-gradient(circle, rgba(0, 0, 0, 0.08) 1px, transparent 1px) 0 0 / 20px 20px,
#f7f8fa;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.rozie-flow-controls[data-rozie-s-cd396d6a] {
position: absolute;
left: 10px;
bottom: 10px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 2px;
pointer-events: none;
}
.rozie-flow-controls__btn[data-rozie-s-cd396d6a] {
pointer-events: auto;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
font: 600 16px/1 system-ui, sans-serif;
color: #334155;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
cursor: pointer;
user-select: none;
}
.rozie-flow-controls__btn[data-rozie-s-cd396d6a]:hover { background: #f1f5f9; }
.rozie-flow-controls__btn[data-rozie-s-cd396d6a]:active { background: #e2e8f0; }
.rozie-flow-controls__btn.is-active[data-rozie-s-cd396d6a] { background: #dbeafe; color: #1d4ed8; border-color: #3b82f6; }
.rozie-flow-marquee[data-rozie-s-cd396d6a] {
position: absolute;
display: none;
z-index: 9;
pointer-events: none;
background: rgba(59, 130, 246, 0.12);
border: 1px solid #3b82f6;
border-radius: 2px;
}
.rozie-flow-minimap[data-rozie-s-cd396d6a] {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 10;
width: 200px;
height: 150px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
overflow: hidden;
cursor: pointer;
touch-action: none;
}
.rozie-flow-minimap__svg[data-rozie-s-cd396d6a] { display: block; width: 100%; height: 100%; }
.rozie-flow-toolbar[data-rozie-s-cd396d6a] {
position: absolute;
display: none;
z-index: 11;
gap: 4px;
padding: 3px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
pointer-events: auto;
white-space: nowrap;
}
.rozie-flow-toolbar__btn[data-rozie-s-cd396d6a] {
font: 600 12px/1 system-ui, sans-serif;
color: #334155;
background: #f8fafc;
border: 1px solid rgba(0, 0, 0, 0.14);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
user-select: none;
}
.rozie-flow-toolbar__btn[data-rozie-s-cd396d6a]:hover { background: #eef2f7; }
.rozie-flow-toolbar__btn[data-rozie-s-cd396d6a]:active { background: #e2e8f0; }
.rozie-flow-toolbar__btn--delete[data-rozie-s-cd396d6a] { color: #b91c1c; }
.rozie-flow-canvas .rozie-flow-node {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
min-width: 140px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
user-select: none;
cursor: grab;
font: 13px/1.4 system-ui, sans-serif;
}
.rozie-flow-canvas .rozie-flow-node.is-selected {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5), 0 2px 8px rgba(0, 0, 0, 0.15);
}
.rozie-flow-canvas .rozie-flow-node__title {
padding: 0.5rem 0.75rem;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
}
.rozie-flow-canvas .rozie-flow-node__body { min-width: 0; }
.rozie-flow-canvas .rozie-flow-node__col {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 0;
}
.rozie-flow-canvas .rozie-flow-port {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: #6b7280;
}
.rozie-flow-canvas .rozie-flow-port--output { justify-content: flex-end; }
.rozie-flow-canvas .rozie-flow-socket {
width: 12px;
height: 12px;
border-radius: 50%;
background: #94a3b8;
border: 2px solid #ffffff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
cursor: crosshair;
flex: none;
}
.rozie-flow-canvas .rozie-flow-socket--input { margin-left: -6px; }
.rozie-flow-canvas .rozie-flow-socket--output { margin-right: -6px; }
.rozie-flow-canvas .rozie-flow-socket:hover { background: #3b82f6; }
.rozie-flow-canvas .rozie-flow-node--rows {
display: flex;
flex-direction: column;
}
.rozie-flow-canvas .rozie-flow-node__mid {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
}
.rozie-flow-canvas .rozie-flow-node__row {
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
padding: 0 0.5rem;
}
.rozie-flow-canvas .rozie-flow-port--vertical {
flex-direction: column;
align-items: center;
gap: 0.125rem;
font-size: 0.7rem;
}
.rozie-flow-canvas .rozie-flow-socket--top,
.rozie-flow-canvas .rozie-flow-socket--bottom { margin-left: 0; margin-right: 0; }
.rozie-flow-canvas .rozie-flow-socket--top { margin-top: -6px; }
.rozie-flow-canvas .rozie-flow-socket--bottom { margin-bottom: -6px; }
.rozie-flow-canvas .rozie-flow-connection { position: absolute; }
.rozie-flow-canvas .rozie-flow-connection__svg {
/* display:block is LOAD-BEARING, not cosmetic. An <svg> is display:inline by
default, so the 1px-tall connection SVG sits on the connection element's TEXT
BASELINE — which, with the engine container's default line-height, pushes the
whole path DOWN ~14px. That offset is in screen space (the connection element
is the area-transform origin), so EVERY connection endpoint lands ~14px below
its socket — visibly anchoring connectors at the BOTTOM of each node instead
of on the socket. The socket positions reported by getDOMSocketPosition are
already correct (offsetTop/offsetLeft within the node-view); the inline
baseline is the sole cause of the vertical drift. block (or equivalently
line-height:0 / vertical-align:top on the inline box) removes the baseline gap
so the path renders at its true coordinates. Verified: drops the endpoint→
socket vertical offset from ~13.9px to ~0.1px on all 6 targets. */
display: block;
overflow: visible;
width: 1px;
height: 1px;
pointer-events: none;
}
.rozie-flow-canvas .rozie-flow-connection__path {
fill: none;
stroke: #64748b;
stroke-width: 3px;
pointer-events: auto;
}
.rozie-flow-canvas .rozie-flow-connection__path.is-selected {
stroke: #3b82f6;
stroke-width: 4px;
}
.rozie-flow-canvas .rozie-flow-connection__label {
font: 600 11px system-ui, sans-serif;
fill: #334155;
paint-order: stroke;
stroke: #ffffff;
stroke-width: 3px;
stroke-linejoin: round;
pointer-events: none;
user-select: none;
}
`;
/**
* The single source of truth (two-way `r-model`) — `{ nodes: [{ id, type, x, y, data? }], connections: [{ id?, source, sourceOutput?, target, targetInput?, label?, stroke?, dashed? }] }`. A node's `type` selects its `<NodeType>` template (render-by-type + port schema); `data` is the opaque payload handed to that type's `#body` scope. The canvas writes back a FRESH top-level object on every drag (x/y) and connect/disconnect (connections) — immutable applyNodeChanges style. `sourceOutput`/`targetInput` default to `out`/`in`; a missing connection `id` is derived from the endpoints.
* @example
* <FlowCanvas r-model:graph="graph" :validate-types="true" />
*/
@property({ type: Object, attribute: 'graph' }) _graph_attr: any = {
nodes: [],
connections: []
};
private _graphControllable = createLitControllableProperty<any>({ host: this, eventName: 'graph-change', defaultValue: {
nodes: [],
connections: []
}, initialControlledValue: undefined });
/**
* Automatic typed-socket validation (default ON). When `true`, the canvas resolves each endpoint's port type from the per-`<NodeType>` `<Port type>` schema and auto-rejects a type-mismatched connection (firing `connection-rejected`). `canConnect` survives as the optional custom-rule override that runs in addition. Set `false` for pure-`canConnect` (type as metadata only).
*/
@property({ type: Boolean, reflect: true }) validateTypes: boolean = true;
/**
* The viewport zoom level (two-way `r-model`). Scroll/pinch writes the new zoom back through the model (echo-guarded against the wrapper's own programmatic zooms); a consumer write zooms the live area. There is deliberately no `zoom`/`zoomed` emit — a same-named emit collides with the model on Vue and Angular — so the two-way binding is the channel for zoom changes.
*/
@property({ type: Number, attribute: 'zoom' }) _zoom_attr: number = 1;
private _zoomControllable = createLitControllableProperty<number>({ host: this, eventName: 'zoom-change', defaultValue: 1, initialControlledValue: undefined });
/**
* Whether the canvas can be panned by dragging the background (applied at construction). Set `false` to detach the area's drag handler.
*/
@property({ type: Boolean, reflect: true }) pannable: boolean = true;
/**
* Whether the canvas can be zoomed by scroll/pinch (applied at construction). Set `false` to detach the area's zoom handler.
*/
@property({ type: Boolean, reflect: true }) zoomable: boolean = true;
/**
* Whether nodes can be selected (click; ctrl-click to accumulate). Reflected as the `selected` flag in the `<NodeType>` `#body` scope and surfaced to the consumer via the `@selection-change` event.
*/
@property({ type: Boolean, reflect: true }) selectable: boolean = true;
/**
* Read-only viewer mode — no node drag, no connection editing, and no selection. View-only zoom/fit (Controls, the `zoomTo`/`zoomToFit` verbs) stay enabled.
*/
@property({ type: Boolean, reflect: true }) readonly: boolean = false;
/**
* Minimum zoom level — the lower bound of the area's zoom restrictor. `0` disables the bound.
*/
@property({ type: Number, reflect: true }) minZoom: number = 0.1;
/**
* Maximum zoom level — the upper bound of the area's zoom restrictor. `0` disables the bound.
*/
@property({ type: Number, reflect: true }) maxZoom: number = 4;
/**
* Snap-to-grid size in pixels for node dragging. `0` turns snapping off.
*/
@property({ type: Number, reflect: true }) snapGrid: number = 0;
/**
* When selectable, hold Ctrl to add to the current selection instead of replacing it.
*/
@property({ type: Boolean, reflect: true }) accumulateOnCtrl: boolean = true;
/**
* The bezier curvature of connection paths (`classicConnectionPath`).
*/
@property({ type: Number, reflect: true }) curvature: number = 0.3;
/**
* After the initial graph mounts, pan/zoom the viewport to fit all nodes (`AreaExtensions.zoomAt`).
*/
@property({ type: Boolean, reflect: true }) fitOnMount: boolean = true;
/**
* Render the built-in Controls overlay — a zoom in / zoom out / fit-view button cluster (the React Flow `<Controls/>` parity). The buttons drive the same zoom/fit path as the `zoomTo`/`zoomToFit` handle verbs (clamped to `minZoom`/`maxZoom`) and stay enabled in `readonly`. Opt out with `:controls="false"`.
*/
@property({ type: Boolean, reflect: true }) controls: boolean = true;
/**
* Render the built-in MiniMap overlay (opt-in, default OFF — the React Flow `<MiniMap/>` parity) — an absolute SVG panel (bottom-right) showing a scaled map of every node (sized from the measured engine node-view dims) plus the current viewport window (the area outside dimmed). It is pannable: dragging the minimap recenters the main viewport (via `setCenter`). Evaluated at construction, like `pannable`/`zoomable`/`controls` — set it at mount time.
*/
@property({ type: Boolean, reflect: true }) minimap: boolean = false;
/**
* Connection-validation predicate `(conn) => boolean`, receiving the normalized candidate connection `{ source, sourceOutput, target, targetInput }`. Return `false` to reject the connection — no edge is committed, no ghost path is drawn, and `connection-rejected` fires. Runs in addition to the automatic `:validate-types` check (the custom-rule override) and gates all connection paths uniformly (drag-to-connect, imperative `addConnection`, graph reconcile). Absent/`null` imposes no custom rule.
*/
@property({ type: Function }) canConnect: ((...args: unknown[]) => unknown) | null = null;
/**
* Undo/redo, on by default. Every gesture (drag, connect, disconnect, delete) pushes ONE capped (~100) snapshot of the bound graph (nodes incl. x/y + connections; not the viewport), and `undo()`/`redo()` plus Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, and Ctrl/Cmd+Y restore it through the two-way `graph` model (echo-guarded). One gesture = one undo step; a fresh edit after an undo discards the redo branch. Opt out with `:history="false"` (the snapshot stack stays empty and the verbs no-op).
*/
@property({ type: Boolean, reflect: true }) history: boolean = true;
/**
* Two-way interaction mode (`r-model`) — the Figma-style pan ↔ select toggle, `'pan'` (default) or `'select'`. In `'pan'` an empty-canvas drag pans the viewport (unchanged). In `'select'` an empty-canvas drag draws a rubber-band marquee box that multi-selects the intersecting nodes (surfacing `@selection-change`). A node drag still drags the node in both modes — only the empty-canvas drag changes. The canvas writes it back when the built-in mode button toggles (see `marquee`).
*/
@property({ type: String, attribute: 'mode' }) _mode_attr: string = 'pan';
private _modeControllable = createLitControllableProperty<string>({ host: this, eventName: 'mode-change', defaultValue: 'pan', initialControlledValue: undefined });
/**
* Render the 4th Controls button — the pan ↔ select mode toggle (it two-way-writes `mode`). Default OFF so the default Controls overlay keeps its three buttons. The marquee behavior works whenever `mode === 'select'` regardless of this flag (a consumer can drive `mode` directly); this only governs the built-in button.
*/
@property({ type: Boolean, reflect: true }) marquee: boolean = false;
/**
* Render the opt-in NodeToolbar (default OFF) — a floating toolbar over the single selected node (positioned from the engine node-view rect + the area transform, re-tracked on pan/zoom/drag). Default content is Delete (cascading controlled-graph `deleteNode`) + Duplicate (clone the node spec at an offset with a new id into a fresh `graph` object); both fire `@node-action` (`name: 'delete' | 'duplicate'`). Override the content by filling the `#toolbar` reactive slot.
*/
@property({ type: Boolean, reflect: true }) nodeToolbar: boolean = false;
private _typeReg = signal<any>({});
private _portReg = signal<any>({});
@query('[data-rozie-ref="canvasEl"]') private _refCanvasEl!: HTMLElement;
@query('[data-rozie-ref="minimapEl"]') private _refMinimapEl!: HTMLElement;
@query('[data-rozie-ref="marqueeEl"]') private _refMarqueeEl!: HTMLElement;
@query('[data-rozie-ref="toolbarEl"]') private _refToolbarEl!: HTMLElement;
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_1 = true;
private __rozieWatchInitial_2 = true;
private __rozieWatchInitial_3 = true;
private _portalContainers = new Set<HTMLElement>();
private __rozieCtxProvider_rete_canvas = new ContextProvider(this, { context: __rozieCtx_rete_canvas, initialValue: ((__rozieCtxHost) => ({
// Register/replace a node TYPE template. `spec` carries an optional
// `bodyRenderer(host, { node })` — the render-by-type projection (mounted per graph
// node of this type into the engine body host, see renderNode). Whole-object replace.
registerType: (type: any, spec: any) => {
if (type != null) __rozieCtxHost._typeReg.value = {
...__rozieCtxHost._typeReg.value,
[type]: spec
};
},
// Drop a type on <NodeType> unmount (whole-object replace).
unregisterType: (type: any) => {
const t = {
...__rozieCtxHost._typeReg.value
};
delete t[type];
__rozieCtxHost._typeReg.value = t;
},
// A <Port> registers a port against its TYPE + side. Stored in the flat portReg
// under a UNIQUE per-port key `type::side::key` so registration is order-independent
// AND concurrency-safe: two <Port>s of the same type addTypePort in one React commit,
// and a pure `{ ...portReg, [uniqueKey]: port }` write (functional setState) merges
// both (an array read-modify-write under one type key would clobber). buildNode reads
// the type's portReg entries on every run regardless of mount order. The unique key
// also makes a re-fired addTypePort (late Lit context) idempotent — same key, same value.
// `side` is derived by <Port> from which of output=/input= is set (output⇒'output', input⇒'input');
// `portType` carries the port type that drives validate-types + the typed-port color.
// `position` (F2) is the socket's VISUAL placement (left|right|top|bottom; default by
// side) — drives the render-pipe socket layout + the connection-anchor axis.
addTypePort: (type: any, side: any, key: any, portType: any, label: any, multiple: any, position: any) => {
if (type == null || key == null) return;
const portKey = type + '::' + side + '::' + key;
__rozieCtxHost._portReg.value = {
...__rozieCtxHost._portReg.value,
[portKey]: {
type,
side,
key,
portType,
label,
multiple,
position
}
};
},
// Render-by-type callback target. Returns the engine-created body host div for a
// graph node (nodeEntries.get(nodeId).body). The render-by-type projection mounts
// the node's TYPE template `#body` INTO this host via $portals — the Wave-0 A3
// finding (a Lit child cannot relocate its own shadow <slot> across the boundary),
// so the body is projected by the parent reusing the $portals host discipline.
bodyHostFor: (nodeId: any) => {
const entry = __rozieCtxHost.nodeEntries.get(nodeId);
return entry ? entry.body : null;
}
}))(this) });
@state() private _hasSlotNode = false;
@queryAssignedElements({ slot: 'node', flatten: true }) private _slotNodeElements!: Element[];
@property({ attribute: false }) node?: (scope: { node: unknown; selected: unknown; emit: unknown }) => unknown;
@state() private _hasSlotToolbar = false;
@queryAssignedElements({ slot: 'toolbar', flatten: true }) private _slotToolbarElements!: Element[];
@property({ attribute: false }) toolbar?: (scope: { node: unknown; emit: unknown }) => unknown;
@state() private _hasSlotDefault = false;
@queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
private _armListeners(): void {
{
const slotEl = this.shadowRoot?.querySelector('slot[name="node"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotNode = this._slotNodeElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="toolbar"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotToolbar = this._slotToolbarElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot:not([name])');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotDefault = this._slotDefaultElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
}
connectedCallback(): void {
// Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
this._hasSlotNode = Array.from(this.children).some((el) => el.getAttribute('slot') === 'node');
this._hasSlotToolbar = Array.from(this.children).some((el) => el.getAttribute('slot') === 'toolbar');
this._hasSlotDefault = Array.from(this.children).some((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
super.connectedCallback();
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
}
firstUpdated(): void {
adoptDocumentStyles(this);
this._armListeners();
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portals = {
node: (container: HTMLElement, scope: { node: unknown; selected: unknown; emit: unknown }): ReactivePortalHandle => {
const tpl = this.node;
if (typeof tpl !== 'function') return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-node', 'cd396d6a');
const renderScope = (s: { node: unknown; selected: unknown; emit: unknown }): void => {
render(tpl(s), container);
};
renderScope(scope);
this._portalContainers.add(container);
return {
update: (s: { node: unknown; selected: unknown; emit: unknown }): void => renderScope(s),
dispose: (): void => {
render(nothing, container);
this._portalContainers.delete(container);
},
};
},
toolbar: (container: HTMLElement, scope: { node: unknown; emit: unknown }): ReactivePortalHandle => {
const tpl = this.toolbar;
if (typeof tpl !== 'function') return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-toolbar', 'cd396d6a');
const renderScope = (s: { node: unknown; emit: unknown }): void => {
render(tpl(s), container);
};
renderScope(scope);
this._portalContainers.add(container);
return {
update: (s: { node: unknown; emit: unknown }): void => renderScope(s),
dispose: (): void => {
render(nothing, container);
this._portalContainers.delete(container);
},
};
},
};
this._disconnectCleanups.push((() => {
if (this.onCanvasKeydown && this.keydownContainer && typeof this.keydownContainer.removeEventListener === 'function') {
try {
this.keydownContainer.removeEventListener('keydown', this.onCanvasKeydown);
} catch (e: any) {}
}
if (this.dragFlushRaf && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(this.dragFlushRaf);
} catch (e: any) {}
}
this.dragFlushRaf = 0;
this.pendingDragPositions.clear();
// T1.1: drop the edge-selection state + its cached <path> reference on teardown.
this.clearEdgeSelection();
// MiniMap teardown — remove the pointer-pan listeners + cancel a pending redraw.
if (this.minimapHost) {
if (this.onMinimapPointerDown) {
try {
this.minimapHost.removeEventListener('pointerdown', this.onMinimapPointerDown);
} catch (e: any) {}
}
if (this.onMinimapPointerMove) {
try {
this.minimapHost.removeEventListener('pointermove', this.onMinimapPointerMove);
} catch (e: any) {}
}
if (this.onMinimapPointerUp) {
try {
this.minimapHost.removeEventListener('pointerup', this.onMinimapPointerUp);
} catch (e: any) {}
}
}
if (this.minimapRedrawRaf && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(this.minimapRedrawRaf);
} catch (e: any) {}
}
this.minimapRedrawRaf = 0;
// T2.8 NodeToolbar teardown — remove the default-button listeners, dispose the optional
// `#toolbar` reactive portal handle, and cancel a pending reposition.
if (this.toolbarDeleteBtn && this.onToolbarDelete) {
try {
this.toolbarDeleteBtn.removeEventListener('pointerup', this.onToolbarDelete);
} catch (e: any) {}
}
if (this.toolbarDuplicateBtn && this.onToolbarDup) {
try {
this.toolbarDuplicateBtn.removeEventListener('pointerup', this.onToolbarDup);
} catch (e: any) {}
}
if (this.toolbarHandle && this.toolbarHandle.dispose) {
try {
this.toolbarHandle.dispose();
} catch (e: any) {}
}
this.toolbarHandle = null;
this.toolbarSelectedId = null;
if (this.toolbarTrackRaf && typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(this.toolbarTrackRaf);
} catch (e: any) {}
}
this.toolbarTrackRaf = 0;
// T2.4 Marquee teardown — remove the capture-phase pointerdown guard + window listeners.
if (this.keydownContainer) {
if (this.onCanvasPointerDownCapture) {
try {
this.keydownContainer.removeEventListener('pointerdown', this.onCanvasPointerDownCapture, true);
} catch (e: any) {}
}
if (this.onMarqueePointerMove) {
try {
this.keydownContainer.removeEventListener('pointermove', this.onMarqueePointerMove);
} catch (e: any) {}
}
if (this.onMarqueePointerUp) {
try {
this.keydownContainer.removeEventListener('pointerup', this.onMarqueePointerUp);
} catch (e: any) {}
}
}
this.marqueeActive = false;
this.marqueeStart = null;
this.marqueeCur = null;
for (const [, entry] of this.nodeEntries as any) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
}
this.nodeEntries.clear();
for (const [, entry] of this.connEntries as any) entry.dispose();
this.connEntries.clear();
if (this.area) this.area.destroy();
}));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.graph)(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => {
// T1.3 — keep the canvas's own last-written graph in sync with an EXTERNAL (non-
// programmatic) consumer change, so undo/redo's "current" state tracks reality (our own
// write-backs / restores set lastWrittenGraph synchronously under the programmatic guard;
// this only refreshes it for a genuine outside edit).
if (this.selfWriteInFlight) {
// our own commitGraph write echoing back — lastWrittenGraph is already authoritative.
this.selfWriteInFlight = false;
} else if (!this.programmatic) {
const c = structuredClone(this.currentGraph());
if (c != null) this.lastWrittenGraph = c;
}
if (this.reconcileNodes) {
Promise.resolve(this.reconcileNodes()).then(() => {
if (this.reconcileConnections) this.reconcileConnections();
});
}
// graph changed (nodes added/removed/moved) → refresh the minimap node rects.
if (this.scheduleMinimapRedraw) this.scheduleMinimapRedraw();
})(); }); }));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this._portReg.value)(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } (() => {
if (this.reconcileNodes) {
Promise.resolve(this.reconcileNodes()).then(() => {
if (this.reconcileConnections) this.reconcileConnections();
});
}
})(); }); }));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this._typeReg.value)(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } (() => {
if (this.reconcileNodes) this.reconcileNodes();
})(); }); }));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.zoom)(); untracked(() => { if (this.__rozieWatchInitial_3) { this.__rozieWatchInitial_3 = false; return; } ((v: any) => {
if (!this.area || typeof v !== 'number') return;
if (v === this.area.area.transform.k) return;
this.programmatic++;
Promise.resolve(this.area.area.zoom(v)).finally(() => {
this.programmatic--;
});
})(__watchVal); }); }));
this._disconnectCleanups.push(effect(() => { void this._typeReg.value; void this._portReg.value; this.__rozieCtxProvider_rete_canvas.setValue(((__rozieCtxHost) => ({
// Register/replace a node TYPE template. `spec` carries an optional
// `bodyRenderer(host, { node })` — the render-by-type projection (mounted per graph
// node of this type into the engine body host, see renderNode). Whole-object replace.
registerType: (type: any, spec: any) => {
if (type != null) __rozieCtxHost._typeReg.value = {
...__rozieCtxHost._typeReg.value,
[type]: spec
};
},
// Drop a type on <NodeType> unmount (whole-object replace).
unregisterType: (type: any) => {
const t = {
...__rozieCtxHost._typeReg.value
};
delete t[type];
__rozieCtxHost._typeReg.value = t;
},
// A <Port> registers a port against its TYPE + side. Stored in the flat portReg
// under a UNIQUE per-port key `type::side::key` so registration is order-independent
// AND concurrency-safe: two <Port>s of the same type addTypePort in one React commit,
// and a pure `{ ...portReg, [uniqueKey]: port }` write (functional setState) merges
// both (an array read-modify-write under one type key would clobber). buildNode reads
// the type's portReg entries on every run regardless of mount order. The unique key
// also makes a re-fired addTypePort (late Lit context) idempotent — same key, same value.
// `side` is derived by <Port> from which of output=/input= is set (output⇒'output', input⇒'input');
// `portType` carries the port type that drives validate-types + the typed-port color.
// `position` (F2) is the socket's VISUAL placement (left|right|top|bottom; default by
// side) — drives the render-pipe socket layout + the connection-anchor axis.
addTypePort: (type: any, side: any, key: any, portType: any, label: any, multiple: any, position: any) => {
if (type == null || key == null) return;
const portKey = type + '::' + side + '::' + key;
__rozieCtxHost._portReg.value = {
...__rozieCtxHost._portReg.value,
[portKey]: {
type,
side,
key,
portType,
label,
multiple,
position
}
};
},
// Render-by-type callback target. Returns the engine-created body host div for a
// graph node (nodeEntries.get(nodeId).body). The render-by-type projection mounts
// the node's TYPE template `#body` INTO this host via $portals — the Wave-0 A3
// finding (a Lit child cannot relocate its own shadow <slot> across the boundary),
// so the body is projected by the parent reusing the $portals host discipline.
bodyHostFor: (nodeId: any) => {
const entry = __rozieCtxHost.nodeEntries.get(nodeId);
return entry ? entry.body : null;
}
}))(this)); }));
const container = this._refCanvasEl;
this.lastPropNodeIds = [];
this.lastPropConnIds = [];
this.editor = new NodeEditor();
this.area = new AreaPlugin(container);
this.connectionPlugin = new ConnectionPlugin();
this.connectionPlugin.addPreset(ConnectionPresets.classic.setup());
// Resolve a port's VISUAL position (F2) from the per-TYPE port schema (portReg, keyed
// `type::side::key`), defaulting by DIRECTION (input → left, output → right) for exact
// back-compat. DEFINED HERE inside $onMount (NOT top level) so its $data.portReg read
// lowers on React to the live `_portRegRef.current`, not a stale-empty mount-time
// closure (the portTypeOf discipline). Used by both the socket-anchor offset below and
// renderNode's socket layout.
// Resolve a port's VISUAL position (F2) from the per-TYPE port schema (portReg, keyed
// `type::side::key`), defaulting by DIRECTION (input → left, output → right) for exact
// back-compat. DEFINED HERE inside $onMount (NOT top level) so its $data.portReg read
// lowers on React to the live `_portRegRef.current`, not a stale-empty mount-time
// closure (the portTypeOf discipline). Used by both the socket-anchor offset below and
// renderNode's socket layout.
const resolvePortPosition = (type: any, side: any, key: any) => {
const entry = type != null && key != null ? this._portReg.value[type + '::' + side + '::' + key] : null;
const p = entry && entry.position != null ? entry.position : null;
if (p === 'left' || p === 'right' || p === 'top' || p === 'bottom') return p;
return side === 'input' ? 'left' : 'right';
};
// DOM-based socket position watcher — feeds connection-path redraw + the
// ConnectionPlugin's drag-to-connect hit-testing. A CUSTOM `offset` (F2): the rete
// default shifts the anchor 12px OUTWARD on the X axis only (`x + 12·(input?−1:1)`) —
// correct for left/right, wrong for top/bottom. We resolve each socket's visual
// position and shift on the matching axis (±x for left/right — IDENTICAL to the default,
// so the rete-flow-align cell stays green; ±y for top/bottom). The position is looked up
// live via nodeMeta→type→portReg, so it tracks late-registered ports.
// DOM-based socket position watcher — feeds connection-path redraw + the
// ConnectionPlugin's drag-to-connect hit-testing. A CUSTOM `offset` (F2): the rete
// default shifts the anchor 12px OUTWARD on the X axis only (`x + 12·(input?−1:1)`) —
// correct for left/right, wrong for top/bottom. We resolve each socket's visual
// position and shift on the matching axis (±x for left/right — IDENTICAL to the default,
// so the rete-flow-align cell stays green; ±y for top/bottom). The position is looked up
// live via nodeMeta→type→portReg, so it tracks late-registered ports.
const SOCKET_SHIFT = 12;
const socketOffset = (position: any, nodeId: any, side: any, key: any) => {
const meta = this.nodeMeta.get(nodeId);
const p = meta ? resolvePortPosition(meta.type, side, key) : side === 'input' ? 'left' : 'right';
if (p === 'top') return {
x: position.x,
y: position.y - SOCKET_SHIFT
};
if (p === 'bottom') return {
x: position.x,
y: position.y + SOCKET_SHIFT
};
if (p === 'left') return {
x: position.x - SOCKET_SHIFT,
y: position.y
};
return {
x: position.x + SOCKET_SHIFT,
y: position.y
};
};
this.socketWatcher = getDOMSocketPosition({
offset: socketOffset
});
this.editor.use(this.area);
this.area.use(this.connectionPlugin);
// ── T2.5 RECONNECT coalescing pipe (D-08 reconnectable edges, D-03 one-gesture-one-entry) ──
// `connectionpick` / `connectiondrop` are emitted on the ConnectionPlugin's OWN scope (they
// are NOT editor signals like connectioncreated/removed, nor area signals like nodepicked),
// so they must be observed via a pipe attached DIRECTLY to `connectionPlugin` — they do not
// propagate into editor.addPipe / area.addPipe. Grabbing an already-connected input socket
// fires connectionpick, then the classic preset removes the old edge + (on drop over a new
// socket) adds a new one — a remove+add pair that would push TWO history entries (Pitfall 2).
// We open a reconnect-in-flight window on connectionpick (capturing the PRE-gesture snapshot
// ONCE) and close it on connectiondrop (pushing that single snapshot iff the gesture actually
// changed the graph) — so the whole reconnect is ONE undoable step.
// ── T2.5 RECONNECT coalescing pipe (D-08 reconnectable edges, D-03 one-gesture-one-entry) ──
// `connectionpick` / `connectiondrop` are emitted on the ConnectionPlugin's OWN scope (they
// are NOT editor signals like connectioncreated/removed, nor area signals like nodepicked),
// so they must be observed via a pipe attached DIRECTLY to `connectionPlugin` — they do not
// propagate into editor.addPipe / area.addPipe. Grabbing an already-connected input socket
// fires connectionpick, then the classic preset removes the old edge + (on drop over a new
// socket) adds a new one — a remove+add pair that would push TWO history entries (Pitfall 2).
// We open a reconnect-in-flight window on connectionpick (capturing the PRE-gesture snapshot
// ONCE) and close it on connectiondrop (pushing that single snapshot iff the gesture actually
// changed the graph) — so the whole reconnect is ONE undoable step.
this.connectionPlugin.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectionpick') {
// Open the coalesce window + capture the pre-gesture snapshot once. Gated on
// !programmatic + history (a restore-driven engine op must not record history). A
// re-pick while a close is pending cancels the pending close (the gesture continues).
if (!this.programmatic && this.history !== false) {
this.reconnectInFlight++;
this.reconnectPreSnapshot = this.snapshotCurrent();
this.reconnectDidWriteBack = false;
this.reconnectCloseScheduled = false;
}
} else if (context.type === 'connectiondrop') {
// The gesture ended. CRITICAL ORDERING: the classic preset emits `connectiondrop`
// BEFORE the editor's `connectionremoved` / `connectioncreated` signals fire (the
// pseudo-connection is dropped, THEN the real add/remove run — verified in the event
// trace: drop → connectioncreate → connectioncreated → connectionremove →
// connectionremoved). So we must NOT close the window synchronously here, or the
// trailing writeBacks would run with inFlight=0 and each push its own (wrong) history
// entry. Instead DEFER the close to a macrotask (setTimeout 0), which runs after all
// the synchronous + microtask writeBack signals have settled. The window stays open
// across the remove+add (both suppress their per-event push, setting
// reconnectDidWriteBack), then closeReconnectGesture pushes the SINGLE pre-gesture
// snapshot iff the graph actually changed. Re-entrant picks can't desync because the
// close is gated on a one-shot scheduled flag.
this.scheduleReconnectClose();
// ── T2.7 CONNECT-END-ON-PANE (D-07, pure emit) ──
// A drag that STARTED on an output socket and ENDED on empty canvas (no target
// socket, no connection created) surfaces `@connect-end { source, sourceOutput,
// position }` so the consumer can run its OWN node-picker / create-node flow at the
// drop point (the n8n "drag off a port → drop on the pane → pick a node" UX). The
// component owns ONLY this hook — it creates NO node and shows NO picker (D-07,
// consumer-owns-creation, exactly like screenToFlowPosition + the palette drop).
// Detection: `socket == null` (released over the pane, not a socket) && `created ==
// false` (no edge was made) && `initial.side === 'output'` (we only surface OUTPUT-
// started drags — an input-started drag off the pane has no "source output" to seed
// a downstream node from, and the reconnect path already owns input-endpoint drags).
// Position = `area.area.pointer` (the AreaPlugin's live pointer, ALREADY in graph
// coords — the same origin screenToFlowPosition projects into), so no client→graph
// projection is needed; we still fall back to screenToFlowPosition over a raw
// clientX/clientY if a future plugin build stops tracking area.area.pointer. Gated on
// !programmatic so a restore/imperative-driven drop never emits. NO node is created.
const cd = context.data;
if (cd && !cd.socket && cd.created === false && cd.initial && cd.initial.side === 'output' && !this.programmatic) {
let pos: any = null;
const inner = this.area && this.area.area ? this.area.area : null;
if (inner && inner.pointer && typeof inner.pointer.x === 'number' && typeof inner.pointer.y === 'number') {
pos = {
x: inner.pointer.x,
y: inner.pointer.y
};
}
if ((!pos || typeof pos.x !== 'number' || typeof pos.y !== 'number') && cd.initial && cd.initial.element && typeof cd.initial.element.getBoundingClientRect === 'function') {
// Fallback: project the last-known pointer client coords through the shipped
// screenToFlowPosition (graph-coord inverse of the area transform). The drop event
// carries no pointer; use the source socket element's center as a degraded anchor.
const r = cd.initial.element.getBoundingClientRect();
pos = this.screenToFlowPosition(r.left + r.width / 2, r.top + r.height / 2);
}
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
this.dispatchEvent(new CustomEvent("connect-end", {
detail: {
source: cd.initial.nodeId,
sourceOutput: cd.initial.key,
position: {
x: pos.x,
y: pos.y
}
},
bubbles: true,
composed: true
}));
}
}
}
return context;
});
// The socket-position watcher (and, conceptually, our vanilla "render plugin")
// must attach to a CHILD scope of the area — `attach` calls
// `scope.parentScope(BaseAreaPlugin)`, which walks UP one level, so the scope's
// parent must BE the area. Attaching to `area` itself fails ("actual parent is
// not instance of type") because area's parent is the NodeEditor. So we add a
// minimal child Scope and attach the watcher to it. Rete forwards every area
// signal (render/nodetranslated/unmount/…) into this child's signal, so the
// watcher sees socket renders + node moves and recomputes socket positions.
// The socket-position watcher (and, conceptually, our vanilla "render plugin")
// must attach to a CHILD scope of the area — `attach` calls
// `scope.parentScope(BaseAreaPlugin)`, which walks UP one level, so the scope's
// parent must BE the area. Attaching to `area` itself fails ("actual parent is
// not instance of type") because area's parent is the NodeEditor. So we add a
// minimal child Scope and attach the watcher to it. Rete forwards every area
// signal (render/nodetranslated/unmount/…) into this child's signal, so the
// watcher sees socket renders + node moves and recomputes socket positions.
this.renderScope = new Scope('rozie-vanilla-render');
this.area.use(this.renderScope);
this.socketWatcher.attach(this.renderScope);
// ── T2.6 auto-layout (D-08, verb-only) ──
// Wire the AutoArrangePlugin (elkjs classic preset) so the top-level autoArrange() verb
// can run a layered relayout on demand. area.use(arrange) installs it as an area-scope
// plugin; arrange.layout() mutates the engine node positions directly (calls area.translate
// internally). The verb reads the arranged positions BACK into a FRESH $model.graph (the
// controlled-graph contract — the engine is never the source of truth). NO auto-trigger —
// the consumer calls autoArrange() (the MapLibre verb-first stance).
// ── T2.6 auto-layout (D-08, verb-only) ──
// Wire the AutoArrangePlugin (elkjs classic preset) so the top-level autoArrange() verb
// can run a layered relayout on demand. area.use(arrange) installs it as an area-scope
// plugin; arrange.layout() mutates the engine node positions directly (calls area.translate
// internally). The verb reads the arranged positions BACK into a FRESH $model.graph (the
// controlled-graph contract — the engine is never the source of truth). NO auto-trigger —
// the consumer calls autoArrange() (the MapLibre verb-first stance).
this.arrange = new AutoArrangePlugin();
this.arrange.addPreset(ArrangePresets.classic.setup());
this.area.use(this.arrange);
// ── selection (selectableNodes) ──
// Capture the returned handle ({ select(id, accumulate), unselect(id) }) so the T2.4
// marquee can PROGRAMMATICALLY select each intersecting node (select(id, true) =
// accumulate). The handle is null when selection is off (readonly / !selectable), in
// which case the marquee branch no-ops.
// ── selection (selectableNodes) ──
// Capture the returned handle ({ select(id, accumulate), unselect(id) }) so the T2.4
// marquee can PROGRAMMATICALLY select each intersecting node (select(id, true) =
// accumulate). The handle is null when selection is off (readonly / !selectable), in
// which case the marquee branch no-ops.
if (this.selectable && !this.readonly) {
this.selector = AreaExtensions.selector();
this.nodeSelectApi = AreaExtensions.selectableNodes(this.area, this.selector, {
accumulating: this.accumulateOnCtrl ? AreaExtensions.accumulateOnCtrl() : {
active: () => false
}
});
}
// raise the picked node above its siblings.
// raise the picked node above its siblings.
AreaExtensions.simpleNodesOrder(this.area);
// ── zoom clamp (restrictor) ──
// ── zoom clamp (restrictor) ──
const min = typeof this.minZoom === 'number' && this.minZoom > 0 ? this.minZoom : 0;
const max = typeof this.maxZoom === 'number' && this.maxZoom > 0 ? this.maxZoom : 0;
if (min || max) {
AreaExtensions.restrictor(this.area, {
scaling: {
min: min || 0.01,
max: max || 100
}
});
}
// ── snap-to-grid ──
// ── snap-to-grid ──
if (typeof this.snapGrid === 'number' && this.snapGrid > 0) {
AreaExtensions.snapGrid(this.area, {
size: this.snapGrid,
dynamic: true
});
}
// ── interaction toggles ──
// ── interaction toggles ──
if (!this.pannable) this.area.area.setDragHandler(null);
if (!this.zoomable) this.area.area.setZoomHandler(null);
// ── Delete / Backspace key → cascading delete of the selected node(s) (Win 1) ──
// Attached to the engine container ($refs.canvasEl, which carries tabindex="0" in
// the template so it can receive key focus) rather than `document`: the listener
// lives INSIDE the Lit shadow root alongside the canvas, so a canvas-focused key
// reaches it on Lit too (a `:target="document"` listener does not reliably see
// shadow-scoped focus across all 6 — the canvas-element listener is the robust
// cross-target path). Gated on selectable && !readonly. We guard against deleting
// while focus is in a node-body text field (INPUT/TEXTAREA/contenteditable) so
// typing in a node never nukes it. The listener is removed in the teardown.
// ── Delete / Backspace key → cascading delete of the selected node(s) (Win 1) ──
// Attached to the engine container ($refs.canvasEl, which carries tabindex="0" in
// the template so it can receive key focus) rather than `document`: the listener
// lives INSIDE the Lit shadow root alongside the canvas, so a canvas-focused key
// reaches it on Lit too (a `:target="document"` listener does not reliably see
// shadow-scoped focus across all 6 — the canvas-element listener is the robust
// cross-target path). Gated on selectable && !readonly. We guard against deleting
// while focus is in a node-body text field (INPUT/TEXTAREA/contenteditable) so
// typing in a node never nukes it. The listener is removed in the teardown.
if (this.selectable && !this.readonly && container && typeof container.addEventListener === 'function') {
this.onCanvasKeydown = (e: any) => {
if (!e) return;
const t = e.target;
// Focus-guard (verbatim with the Delete branch): never act while focus is in a
// node-body text field (INPUT/TEXTAREA/contenteditable) — Ctrl+Z must reach the
// browser's native text undo there, and Delete must not nuke the node.
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
// ── T1.3 — Undo / Redo keybinds (D-02). Ctrl/Cmd+Z → undo; Ctrl/Cmd+Shift+Z and
// Ctrl/Cmd+Y → redo. Gated on the SAME focus-guard as Delete. preventDefault so the
// browser's page-level undo doesn't also fire. `metaKey` covers macOS Cmd. ──
if ((e.ctrlKey || e.metaKey) && !e.altKey) {
const k = typeof e.key === 'string' ? e.key.toLowerCase() : '';
if (k === 'z' && !e.shiftKey) {
e.preventDefault();
this.undo();
return;
}
if (k === 'z' && e.shiftKey || k === 'y') {
e.preventDefault();
this.redo();
return;
}
}
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
const ids = this.selectedNodeIds();
if (ids.length > 0) {
e.preventDefault();
for (const id of ids as any) this.deleteNode(id);
return;
}
// T1.1 — EDGE DELETE (D-08). No node is picked but an edge is selected → remove
// exactly that edge via the controlled-graph write-back (the disconnect path: a
// fresh `{ ...g, connections: filtered }` object), then clear the selection. The
// wrapper's own $watch(graph) reconcile reaps the live engine connection (the
// single removal path — we do NOT also call editor.removeConnection, which would
// race the reconcile into "cannot find connection", mirroring deleteNode). Node
// delete takes precedence (handled above); this only runs when nothing's picked.
if (this.selectedConnId != null) {
e.preventDefault();
const id = this.selectedConnId;
this.clearEdgeSelection();
this.writeBackConnectionRemoved(id);
}
};
this.keydownContainer = container;
container.addEventListener('keydown', this.onCanvasKeydown);
}
// ─────────────────────────────────────────────────────────────────────────
// THE VANILLA RENDER PIPE. Intercepts the AreaPlugin's render/unmount signals.
// ALWAYS returns context (returning undefined would halt the signal chain and
// break the ConnectionPlugin / socket watcher downstream).
// ─────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────
// THE VANILLA RENDER PIPE. Intercepts the AreaPlugin's render/unmount signals.
// ALWAYS returns context (returning undefined would halt the signal chain and
// break the ConnectionPlugin / socket watcher downstream).
// ─────────────────────────────────────────────────────────────────────────
this.area.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'render') {
const data = context.data;
if (data.type === 'node') renderNode(data.element, data.payload);else if (data.type === 'connection') renderConnection(data.element, data.payload, data.start, data.end);
// data.type === 'socket' (our own re-emitted signals) falls through
// untouched so the ConnectionPlugin + socketWatcher consume them.
} else if (context.type === 'unmount') {
cleanupElement(context.data.element);
}
return context;
});
// ── node renderer ──
// Fills the engine-created nodeView element with: input sockets, the body
// (consumer `node` portal fragment OR default chrome), and output sockets.
// Re-render (area.update('node', id)) reuses the same element → update in place.
// NOTE: the engine-node parameter is `reteNode`, NOT `node` — on Svelte the
// `$slots.node` slot lowers to a top-level `const node`, and a parameter named
// `node` here would SHADOW it, so `if ($slots.node)` would read the (always-
// truthy) engine node and wrongly take the portal branch even when the slot is
// unfilled (dropping the default-chrome title). The cross-target slot-name ==
// local-binding shadow trap.
// ── node renderer ──
// Fills the engine-created nodeView element with: input sockets, the body
// (consumer `node` portal fragment OR default chrome), and output sockets.
// Re-render (area.update('node', id)) reuses the same element → update in place.
// NOTE: the engine-node parameter is `reteNode`, NOT `node` — on Svelte the
// `$slots.node` slot lowers to a top-level `const node`, and a parameter named
// `node` here would SHADOW it, so `if ($slots.node)` would read the (always-
// truthy) engine node and wrongly take the portal branch even when the slot is
// unfilled (dropping the default-chrome title). The cross-target slot-name ==
// local-binding shadow trap.
const renderNode = (element: any, reteNode: any) => {
// a (re)render means node DOM exists / changed → refresh the minimap (its node
// rects measure these elements; coalesced, so calling it on every render is cheap,
// and it covers Lit's measure-after-first-paint).
if (this.scheduleMinimapRedraw) this.scheduleMinimapRedraw();
const id = reteNode.id;
const meta = this.nodeMeta.get(id) || {
id,
type: undefined,
data: {}
};
const existing = this.nodeEntries.get(id);
const selected = reteNode.selected === true;
// default-chrome fallback label (only when a node's type has no #body template).
const chromeLabel = meta.data && meta.data.label != null ? String(meta.data.label) : meta.type != null ? String(meta.type) : '';
if (existing && existing.element === element) {
// in-place update — refresh chrome + reactive portal scope, leave sockets.
existing.box.classList.toggle('is-selected', selected);
if (existing.handle) {
existing.handle.update({
node: meta,
selected,
emit: existing.emit
});
} else if (existing.titleEl) {
existing.titleEl.textContent = chromeLabel;
}
return;
}
// fresh build
element.innerHTML = '';
const box = document.createElement('div');
box.className = 'rozie-flow-node' + (selected ? ' is-selected' : '');
const body = document.createElement('div');
body.className = 'rozie-flow-node__body';
// ── socket layout (F2: position-aware) ───────────────────────────────────────
// Bucket the node's ports by VISUAL position (default input→left, output→right).
// When NO port is top/bottom (every pre-F2 graph), render the EXACT classic
// [inputsCol | body | outputsCol] 3-column structure — byte-identical DOM, so the
// FlowCanvasScreenshot pixel baseline is untouched. A node that declares ANY top/
// bottom port gets the 3-ROW structure (topRow / midRow[left|body|right] / bottomRow).
const socketDisposers = [];
const portEntries = [];
for (const key of Object.keys(reteNode.inputs) as any) portEntries.push({
side: 'input',
key,
position: resolvePortPosition(meta.type, 'input', key)
});
for (const key of Object.keys(reteNode.outputs) as any) portEntries.push({
side: 'output',
key,
position: resolvePortPosition(meta.type, 'output', key)
});
const hasVertical = portEntries.some((p: any) => p.position === 'top' || p.position === 'bottom');
if (!hasVertical) {
// CLASSIC left/right layout — byte-for-byte identical to pre-F2 (pixel-baseline safe).
const inputsCol = document.createElement('div');
inputsCol.className = 'rozie-flow-node__col rozie-flow-node__col--in';
const outputsCol = document.createElement('div');
outputsCol.className = 'rozie-flow-node__col rozie-flow-node__col--out';
box.appendChild(inputsCol);
box.appendChild(body);
box.appendChild(outputsCol);
element.appendChild(box);
for (const p of portEntries as any) {
renderSocketInto(p.position === 'right' ? outputsCol : inputsCol, reteNode, p.side, p.key, p.position, socketDisposers);
}
} else {
// VERTICAL-capable 3-row layout (only when a top/bottom port exists).
box.classList.add('rozie-flow-node--rows');
const topRow = document.createElement('div');
topRow.className = 'rozie-flow-node__row rozie-flow-node__row--top';
const midRow = document.createElement('div');
midRow.className = 'rozie-flow-node__mid';
const leftCol = document.createElement('div');
leftCol.className = 'rozie-flow-node__col rozie-flow-node__col--in';
const rightCol = document.createElement('div');
rightCol.className = 'rozie-flow-node__col rozie-flow-node__col--out';
const bottomRow = document.createElement('div');
bottomRow.className = 'rozie-flow-node__row rozie-flow-node__row--bottom';
midRow.appendChild(leftCol);
midRow.appendChild(body);
midRow.appendChild(rightCol);
box.appendChild(topRow);
box.appendChild(midRow);
box.appendChild(bottomRow);
element.appendChild(box);
for (const p of portEntries as any) {
const zone = p.position === 'top' ? topRow : p.position === 'bottom' ? bottomRow : p.position === 'right' ? rightCol : leftCol;
renderSocketInto(zone, reteNode, p.side, p.key, p.position, socketDisposers);
}
}
// emit per-node event helper handed to the slot scope so a consumer node body
// can raise a custom event carrying its id (e.g. a delete button).
const emit = (name: any, detail: any) => this.dispatchEvent(new CustomEvent("node-action", {
detail: {
id,
name,
detail
},
bubbles: true,
composed: true
}));
const entry = {
element,
box,
body,
handle: null,
bodyHandle: null,
titleEl: null,
bodyMoved: false,
emit,
socketDisposers
};
// ── RENDER-BY-TYPE: select the body by `node.type` ──────────────────────────
// 1) the node's TYPE template (typeReg[type].bodyRenderer) — the primary path
// (41-03 <NodeType><template #body>); 2) the low-level `#node` portal slot
// (consumer switches on node.type itself — escape hatch); 3) default chrome.
const typeSpec = meta.type != null ? this._typeReg.value[meta.type] : null;
if (typeSpec && typeof typeSpec.bodyRenderer === 'function') {
// RENDER-BY-TYPE callback path. The <NodeType> cannot relocate its OWN <slot>
// across the Lit shadow boundary (Wave-0 A3), so the PARENT projects the body
// here from its own render scope: the type's registered bodyRenderer(host, scope)
// mounts the type's `#body` portal INTO the engine `body` div (a FRESH render
// root per node — no framework DOM relocation, the Phase-37 D-04 trap avoided).
// nodeEntries must exist before the callback runs (bodyHostFor reads it), so
// register first. The graph node's `data` flows in as scope → one template per
// type renders every instance of that type.
this.nodeEntries.set(id, entry);
entry.bodyHandle = typeSpec.bodyRenderer(body, {
node: meta,
selected,
emit
});
entry.bodyMoved = true;
return;
}
if (this.node !== undefined) {
// reactive multi-instance portal — one handle per node, re-rendered in
// place on meta change (the MapLibre marker discipline). Low-level escape
// hatch: the consumer switches on node.type inside the single `#node` slot.
entry.handle = portals.node(body, {
node: meta,
selected,
emit
});
} else {
// default chrome: a title bar (the type name / data.label).
const title = document.createElement('div');
title.className = 'rozie-flow-node__title';
title.textContent = chromeLabel;
body.appendChild(title);
entry.titleEl = title;
}
this.nodeEntries.set(id, entry);
};
// Render ONE socket into a zone and, crucially, EMIT its render signal so the
// ConnectionPlugin + position watcher register it. `position` is the socket's visual
// placement (left|right|top|bottom). For left/right the DOM is byte-identical to pre-F2
// (the classic horizontal port row); top/bottom get a vertical port (socket above its
// label) + a `--<position>` socket class so the socket straddles the matching edge.
// Render ONE socket into a zone and, crucially, EMIT its render signal so the
// ConnectionPlugin + position watcher register it. `position` is the socket's visual
// placement (left|right|top|bottom). For left/right the DOM is byte-identical to pre-F2
// (the classic horizontal port row); top/bottom get a vertical port (socket above its
// label) + a `--<position>` socket class so the socket straddles the matching edge.
const renderSocketInto = (zone: any, reteNode: any, side: any, key: any, position: any, socketDisposers: any) => {
const port = (side === 'input' ? reteNode.inputs : reteNode.outputs)[key];
if (!port) return;
const vertical = position === 'top' || position === 'bottom';
const row = document.createElement('div');
row.className = 'rozie-flow-port rozie-flow-port--' + side + (vertical ? ' rozie-flow-port--vertical' : '');
const socketEl = document.createElement('div');
socketEl.className = 'rozie-flow-socket rozie-flow-socket--' + side + (vertical ? ' rozie-flow-socket--' + position : '');
socketEl.setAttribute('data-testid', 'socket');
const label = document.createElement('span');
label.className = 'rozie-flow-port__label';
label.textContent = port.label != null ? String(port.label) : key;
// CLASSIC: inputs socket-first, outputs label-first (byte-identical to pre-F2).
// VERTICAL: socket-first (the socket sits on the edge, label tucked inward).
if (side === 'input' || vertical) {
row.appendChild(socketEl);
row.appendChild(label);
} else {
row.appendChild(label);
row.appendChild(socketEl);
}
zone.appendChild(row);
// LOAD-BEARING: announce the socket to the rest of the area's child plugins.
// 'render' lets the ConnectionPlugin register the socket as a drag anchor.
this.area.emit({
type: 'render',
data: {
type: 'socket',
side,
key,
nodeId: reteNode.id,
element: socketEl,
payload: {
socket: port.socket
}
}
});
// ALSO LOAD-BEARING (the socket-position contract): getDOMSocketPosition measures +
// stores a socket's DOM position ONLY on a 'rendered' socket signal — the render-plugin
// lifecycle's post-mount phase. Our vanilla pipe creates + appends the socket DOM
// synchronously, so we fire 'rendered' right after 'render'. WITHOUT IT the position
// store stays empty, every socketWatcher.listen() callback reads null, and NO
// connection path (committed OR drag preview) is ever drawn.
this.area.emit({
type: 'rendered',
data: {
type: 'socket',
side,
key,
nodeId: reteNode.id,
element: socketEl,
payload: {
socket: port.socket
}
}
});
socketDisposers.push(() => {
this.area.emit({
type: 'unmount',
data: {
element: socketEl
}
});
});
};
// ── hand-written edge-type path generators (T1.2, D-01) ───────────────────────
// `rete-render-utils` ships ONLY `classicConnectionPath` (bezier) + `loopConnectionPath`;
// step/smoothstep/straight do NOT exist in any installed rete package, so they are
// hand-written here matching React-Flow's `step|smoothstep|straight` semantics. Each is a
// PURE `(start, end) → d-string` function over `{x,y}` graph-screen points; the `d` is
// composed from numeric coords + literal SVG commands and written via setAttribute (never
// innerHTML — no injection, T-44-02-2 accept). The default branch stays
// `classicConnectionPath` → byte-identical bezier (pixel-baseline safe).
// straight: a single line, no curvature.
// ── hand-written edge-type path generators (T1.2, D-01) ───────────────────────
// `rete-render-utils` ships ONLY `classicConnectionPath` (bezier) + `loopConnectionPath`;
// step/smoothstep/straight do NOT exist in any installed rete package, so they are
// hand-written here matching React-Flow's `step|smoothstep|straight` semantics. Each is a
// PURE `(start, end) → d-string` function over `{x,y}` graph-screen points; the `d` is
// composed from numeric coords + literal SVG commands and written via setAttribute (never
// innerHTML — no injection, T-44-02-2 accept). The default branch stays
// `classicConnectionPath` → byte-identical bezier (pixel-baseline safe).
// straight: a single line, no curvature.
const straightPath = (s: any, e: any) => `M ${s.x} ${s.y} L ${e.x} ${e.y}`;
// step: orthogonal HV-VH with a mid-X break.
// step: orthogonal HV-VH with a mid-X break.
const stepPath = (s: any, e: any) => {
const mx = (s.x + e.x) / 2;
return `M ${s.x} ${s.y} L ${mx} ${s.y} L ${mx} ${e.y} L ${e.x} ${e.y}`;
};
// smoothstep: step with rounded corners (radius r, clamped to half the shorter leg).
// smoothstep: step with rounded corners (radius r, clamped to half the shorter leg).
const smoothstepPath = (s: any, e: any, r = 8) => {
const mx = (s.x + e.x) / 2;
const dir = e.y >= s.y ? 1 : -1;
const rr = Math.min(r, Math.abs(mx - s.x), Math.abs(e.y - s.y) / 2);
return [`M ${s.x} ${s.y}`, `L ${mx - rr} ${s.y}`, `Q ${mx} ${s.y} ${mx} ${s.y + dir * rr}`, `L ${mx} ${e.y - dir * rr}`, `Q ${mx} ${e.y} ${mx + rr} ${e.y}`, `L ${e.x} ${e.y}`].join(' ');
};
// ── connection renderer ──
// Mounts an <svg><path> and redraws it whenever either endpoint socket moves
// (real connection) OR the dragged pointer moves (user drag-to-connect pseudo).
//
// A USER DRAG renders a *pseudo-connection* (rete-connection-plugin): the render
// signal carries a literal pointer coordinate (`endPointer`/`data.end` when
// dragging FROM an output, `startPointer`/`data.start` when dragging FROM an
// input) alongside a payload with ONE DANGLING endpoint — `target:''`/
// `targetInput:''` (output-side drag) or `source:''`/`sourceOutput:''`
// (input-side drag). The dangling side has no socket to watch, so its coordinate
// MUST come from the pointer; the live side stays watcher-driven. The
// ConnectionPlugin re-emits this render on EVERY pointermove with a fresh pointer
// — so the same pseudo element is re-rendered repeatedly and the dangling
// coordinate must update in place (no SVG rebuild, no listener re-subscribe).
// ── connection renderer ──
// Mounts an <svg><path> and redraws it whenever either endpoint socket moves
// (real connection) OR the dragged pointer moves (user drag-to-connect pseudo).
//
// A USER DRAG renders a *pseudo-connection* (rete-connection-plugin): the render
// signal carries a literal pointer coordinate (`endPointer`/`data.end` when
// dragging FROM an output, `startPointer`/`data.start` when dragging FROM an
// input) alongside a payload with ONE DANGLING endpoint — `target:''`/
// `targetInput:''` (output-side drag) or `source:''`/`sourceOutput:''`
// (input-side drag). The dangling side has no socket to watch, so its coordinate
// MUST come from the pointer; the live side stays watcher-driven. The
// ConnectionPlugin re-emits this render on EVERY pointermove with a fresh pointer
// — so the same pseudo element is re-rendered repeatedly and the dangling
// coordinate must update in place (no SVG rebuild, no listener re-subscribe).
const renderConnection = (element: any, connection: any, startPointer: any, endPointer: any) => {
const id = connection.id;
// A side is dangling when its node id OR its port key is empty/nullish.
const srcDangling = !connection.source || !connection.sourceOutput;
const tgtDangling = !connection.target || !connection.targetInput;
// RE-RENDER of the SAME element (the pseudo on each pointermove): do NOT rebuild
// the SVG or re-subscribe listeners (would leak) — just update the dangling
// side's coordinate and redraw. This replaces the old unconditional early-return
// that froze the preview line. For a REAL connection updatePointer is a no-op,
// so a re-render of a committed edge is byte-for-byte the old early-return.
const prev = this.connEntries.get(id);
if (prev && prev.element === element) {
prev.updatePointer(startPointer, endPointer);
return;
}
element.innerHTML = '';
element.classList.add('rozie-flow-connection');
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'rozie-flow-connection__svg');
// ── direction arrowhead (Win 3) ─────────────────────────────────────────────
// A <defs><marker> in THIS connection's own <svg>, referenced by `marker-end` so
// the triangle sits at the path END (the input socket — the path runs output→input,
// so marker-end points INTO the target). The marker id is UNIQUE per connection
// (`rozie-arrow-<id>`) so two edges' markers never collide on a shared document id
// (url(#id) resolves to the first match otherwise). The def lives in the SAME
// per-edge <svg> inside the SAME shadow root as the path, so url(#id) resolves
// within that root — no cross-root reference (Lit-safe). markerUnits="userSpaceOnUse"
// keeps a constant pixel size under the area zoom transform. Inline fill (#64748b,
// matching the connection stroke) is the cross-target-safe choice — no scoped-CSS /
// :root rule needed for the marker DOM. The marker does NOT change the path `d`
// or the socket geometry (the rete-flow-align cell stays green) — redraw() only
// sets the head's `orient` and a `stroke-dasharray` that visually trims the last
// ARROW_LEN of the stroke so the line meets the head without poking through it.
const markerId = 'rozie-arrow-' + String(id);
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
marker.setAttribute('id', markerId);
// Sized in userSpaceOnUse (constant pixels under zoom). A 12×10 head reads
// clearly at default zoom (the old 6×6 was barely visible). refX=12 sits the
// TIP exactly at the path-end vertex (the socket); refY=5 centers it. `orient`
// is recomputed per-redraw from the path's final-segment tangent, and the
// visible stroke is trimmed back to the arrow base, so the head points along
// the edge's actual approach AND the line meets it cleanly — see redraw().
marker.setAttribute('markerWidth', '13');
marker.setAttribute('markerHeight', '10');
marker.setAttribute('refX', '12');
marker.setAttribute('refY', '5');
marker.setAttribute('orient', 'auto');
marker.setAttribute('markerUnits', 'userSpaceOnUse');
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
arrow.setAttribute('class', 'rozie-flow-connection__arrow');
arrow.setAttribute('d', 'M0,0 L12,5 L0,10 Z');
arrow.setAttribute('fill', '#64748b');
marker.appendChild(arrow);
defs.appendChild(marker);
svg.appendChild(defs);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('class', 'rozie-flow-connection__path');
path.setAttribute('marker-end', 'url(#' + markerId + ')');
svg.appendChild(path);
// ── T1.1 edge-select listener (D-08) ─────────────────────────────────────────
// Attach an IMPERATIVE pointerup listener on the engine-DOM <path> (NOT a template
// `@` — the path is engine-created; NOT click — Rete swallows it; NOT pointerdown —
// Rete stopPropagations it: the Phase-41 connector landmine, playbook §6a item 7).
// Gated on `selectable && !readonly` (mirrors node delete) and ONLY for COMMITTED
// edges — a drag-to-connect pseudo (either side dangling) carries no stable id and
// must not be selectable. `selectEdge` reads the id back off the closure (the
// committed connection.id == the graph connection id — conn.id = spec.id at build),
// so it always matches what `writeBackConnectionRemoved` filters. `.stop` keeps the
// pointerup from reaching the area's pan/background handling beneath the path.
if (this.selectable && !this.readonly && !srcDangling && !tgtDangling) {
path.style.cursor = 'pointer';
path.addEventListener('pointerup', (e: any) => {
if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
this.selectEdge(connection.id, path);
});
}
// ── per-edge label + styling (F3) ────────────────────────────────────────────
// The consumer's connection spec ({ id, source, …, label?, stroke?, dashed? }) is kept
// in connMeta keyed by id (the connection-side analog of nodeMeta). A committed edge
// resolves its label/style here; a drag-preview pseudo (no committed id) has none.
// Styling is applied as INLINE attributes (the arrowhead-marker discipline — engine DOM
// carries no scope attr); a `label` renders an SVG <text> at the path midpoint (white
// halo via paint-order for legibility over the line), repositioned in redraw().
const emeta = this.connMeta.get(connection.id) || null;
if (emeta) {
if (emeta.stroke != null) {
const s = String(emeta.stroke);
path.setAttribute('stroke', s);
arrow.setAttribute('fill', s);
}
if (emeta.dashed === true) path.setAttribute('stroke-dasharray', '7 5');
}
// ── resolved edge type (T1.2) ────────────────────────────────────────────────
// The consumer-supplied `connection.type` selects a path generator. ALLOWLIST it
// (`bezier|step|smoothstep|straight`); any other/absent value falls through to the
// bezier default — no dynamic path-fn lookup keyed on the raw string, no eval
// (T-44-02-1 mitigate). A dangling drag-preview pseudo has no committed connMeta
// entry, so it stays bezier too.
const rawType = emeta && emeta.type != null ? String(emeta.type) : 'bezier';
const edgeType = rawType === 'step' || rawType === 'smoothstep' || rawType === 'straight' ? rawType : 'bezier';
// Arrowhead geometry (redraw): the head is oriented along the path's tangent
// over its LAST `ARROW_LEN` (angled for a descending edge, aligned with where
// the line actually meets the head — unlike the chord, which diverges from the
// bezier's flattened end tangent), and the visible stroke is trimmed back to
// the arrow base on SOLID edges so the line's width can't poke past the
// tapering tip (the "square tip"). Dashed edges keep their pattern untrimmed.
const ARROW_LEN = 12;
const isDashed = !!(emeta && emeta.dashed === true);
let labelEl: any = null;
const edgeLabel = emeta && emeta.label != null ? String(emeta.label) : null;
if (edgeLabel) {
labelEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
labelEl.setAttribute('class', 'rozie-flow-connection__label');
labelEl.setAttribute('text-anchor', 'middle');
labelEl.setAttribute('dominant-baseline', 'middle');
labelEl.textContent = edgeLabel;
svg.appendChild(labelEl);
}
element.appendChild(svg);
let start: any = null;
let end: any = null;
const curvature = typeof this.curvature === 'number' ? this.curvature : 0.3;
const redraw = () => {
if (!start || !end) return;
// branch on the resolved edge type; default (bezier/unknown) stays
// classicConnectionPath UNCHANGED → byte-identical bezier output.
const d = edgeType === 'step' ? stepPath(start, end) : edgeType === 'smoothstep' ? smoothstepPath(start, end) : edgeType === 'straight' ? straightPath(start, end) : classicConnectionPath([start, end], curvature);
path.setAttribute('d', d);
// Orient the head and trim the visible stroke back to the arrow base (solid
// edges) so the line meets the head without poking through the tip.
// getTotalLength/getPointAtLength are SVGGeometryElement methods unavailable
// in a non-rendering env (jsdom) → guard and fall back to orient='auto' / untrimmed.
let pathLen = 0;
try {
pathLen = path.getTotalLength();
} catch (e: any) {
pathLen = 0;
}
if (pathLen > ARROW_LEN + 1) {
// BACKWARD edge (target socket left of the source socket): the classic
// bezier overshoots both control points, looping the curve into tight
// u-turns right at the sockets, so a sampled local tangent is unstable and
// the head curls. Use the path's TRUE end tangent (orient='auto' — the
// horizontal entry into the input) for a stable, standard arrow. FORWARD
// edges keep the final-ARROW_LEN tangent, which follows a descending edge
// AND aligns with where the line meets the head.
if (end.x < start.x) {
marker.setAttribute('orient', 'auto');
} else {
const tip = path.getPointAtLength(pathLen);
const back = path.getPointAtLength(pathLen - ARROW_LEN);
marker.setAttribute('orient', String(Math.atan2(tip.y - back.y, tip.x - back.x) * 180 / Math.PI));
}
if (!isDashed) path.setAttribute('stroke-dasharray', pathLen - ARROW_LEN + ' ' + pathLen);
} else {
marker.setAttribute('orient', 'auto');
if (!isDashed) path.removeAttribute('stroke-dasharray');
}
if (labelEl) {
labelEl.setAttribute('x', String((start.x + end.x) / 2));
labelEl.setAttribute('y', String((start.y + end.y) / 2));
}
};
// Seed the DANGLING side's coordinate from the pointer FIRST — socketWatcher
// .listen() synchronously replays the current socket snapshot on subscribe, so
// seeding before subscribing the live side means redraw() already has the
// dangling coordinate and the preview line draws immediately on the first render.
if (srcDangling && startPointer) start = startPointer;
if (tgtDangling && endPointer) end = endPointer;
// LIVE endpoints stay watcher-driven (exactly as before the fix — committed
// connections behave byte-for-byte). DANGLING endpoints subscribe NO listener
// (it would never fire — there is no socket); their coordinate is the pointer.
let un1: any = null;
let un2: any = null;
if (!srcDangling) un1 = this.socketWatcher.listen(connection.source, 'output', connection.sourceOutput, (p: any) => {
start = p;
redraw();
});
if (!tgtDangling) un2 = this.socketWatcher.listen(connection.target, 'input', connection.targetInput, (p: any) => {
end = p;
redraw();
});
// Update only the DANGLING side(s) from a fresh pointer on each subsequent
// render call. For a REAL connection (neither side dangling) this is a no-op,
// so committed connections never have a pointer override and keep behaving
// exactly as before.
const updatePointer = (sp: any, ep: any) => {
let moved = false;
if (srcDangling && sp) {
start = sp;
moved = true;
}
if (tgtDangling && ep) {
end = ep;
moved = true;
}
if (moved) redraw();
};
// Draw once now: a pseudo seeded with an initial pointer (+ its live side
// already replayed) draws immediately; a real connection whose sockets are
// already known also draws (idempotent — same `d` the listeners just set).
redraw();
this.connEntries.set(id, {
element,
updatePointer,
dispose: () => {
try {
un1 && un1();
} catch (e: any) {}
try {
un2 && un2();
} catch (e: any) {}
}
});
};
// ── unmount cleanup (keyed by the engine element area hands back) ──
// ── unmount cleanup (keyed by the engine element area hands back) ──
const cleanupElement = (element: any) => {
for (const [id, entry] of this.nodeEntries as any) {
if (entry.element === element) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
this.nodeEntries.delete(id);
return;
}
}
for (const [id, entry] of this.connEntries as any) {
if (entry.element === element) {
entry.dispose();
this.connEntries.delete(id);
return;
}
}
};
// Resolve a single port's TYPE for the validation pipe: look up the live node's
// `type` (via nodeMeta) then the portReg entry keyed `type::side::key`. Returns the
// portType string or null (null on either side ⇒ no type constraint ⇒ allow). DEFINED
// HERE (inside $onMount) — NOT at top level — so its $data.portReg read lowers on React
// to the live `_portRegRef.current` rather than a stale-empty closure snapshot captured
// when this once-only mount effect first ran (the cross-type-reject-didn't-fire bug).
// Resolve a single port's TYPE for the validation pipe: look up the live node's
// `type` (via nodeMeta) then the portReg entry keyed `type::side::key`. Returns the
// portType string or null (null on either side ⇒ no type constraint ⇒ allow). DEFINED
// HERE (inside $onMount) — NOT at top level — so its $data.portReg read lowers on React
// to the live `_portRegRef.current` rather than a stale-empty closure snapshot captured
// when this once-only mount effect first ran (the cross-type-reject-didn't-fire bug).
const portTypeOf = (nodeId: any, side: any, key: any) => {
const meta = this.nodeMeta.get(nodeId);
if (!meta || meta.type == null || key == null) return null;
const entry = this._portReg.value[meta.type + '::' + side + '::' + key];
return entry ? entry.portType : null;
};
// ─── connection-validation gate (D2/D3 — typed-socket validation + override) ──
// Cancels Rete's cancellable `connectioncreate` pre-event when the connection is
// rejected. TWO independent reject paths, both surfacing `connection-rejected`:
// 1. AUTOMATIC typed validation (`:validate-types`, default ON, D3 option a):
// resolve src/tgt port TYPE from the per-TYPE port schema (via each endpoint
// node's `type`); if both are non-null and UNEQUAL → reject. A null on either
// side (untyped port / unknown type) imposes no constraint → allow.
// 2. `canConnect` OVERRIDE (Phase-40 contract, SURVIVES): a consumer custom rule;
// runs IN ADDITION to (after) the automatic check; returning false rejects.
// Cancelling makes editor.addConnection return false WITHOUT pushing the connection
// or emitting `connectioncreated` — no ghost edge, no `connection-created`. Gates
// drag-to-connect, imperative addConnection, and reconcile uniformly. Both predicates
// are PURE (no $data write / engine call) — reads only. The block (return undefined)
// stays UNCONDITIONAL so rejection is enforced on every path; only the EMIT is
// echo-guarded (a programmatic reconcile the rule would reject must not surface as a
// user-facing rejection — mirrors connection-created/connection-removed).
// ─── connection-validation gate (D2/D3 — typed-socket validation + override) ──
// Cancels Rete's cancellable `connectioncreate` pre-event when the connection is
// rejected. TWO independent reject paths, both surfacing `connection-rejected`:
// 1. AUTOMATIC typed validation (`:validate-types`, default ON, D3 option a):
// resolve src/tgt port TYPE from the per-TYPE port schema (via each endpoint
// node's `type`); if both are non-null and UNEQUAL → reject. A null on either
// side (untyped port / unknown type) imposes no constraint → allow.
// 2. `canConnect` OVERRIDE (Phase-40 contract, SURVIVES): a consumer custom rule;
// runs IN ADDITION to (after) the automatic check; returning false rejects.
// Cancelling makes editor.addConnection return false WITHOUT pushing the connection
// or emitting `connectioncreated` — no ghost edge, no `connection-created`. Gates
// drag-to-connect, imperative addConnection, and reconcile uniformly. Both predicates
// are PURE (no $data write / engine call) — reads only. The block (return undefined)
// stays UNCONDITIONAL so rejection is enforced on every path; only the EMIT is
// echo-guarded (a programmatic reconcile the rule would reject must not surface as a
// user-facing rejection — mirrors connection-created/connection-removed).
this.editor.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectioncreate') {
const c = context.data;
// ClassicPreset.Connection fields: { id, source, sourceOutput, target, targetInput }.
// Same shape as serializeConn minus the engine-assigned `id` (never created).
const conn = {
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
};
// 1. AUTOMATIC typed validation (default ON; opt out via :validate-types="false").
if (this.validateTypes !== false) {
const srcType = portTypeOf(c.source, 'output', c.sourceOutput);
const tgtType = portTypeOf(c.target, 'input', c.targetInput);
if (srcType != null && tgtType != null && srcType !== tgtType) {
if (!this.programmatic) this.dispatchEvent(new CustomEvent("connection-rejected", {
detail: conn,
bubbles: true,
composed: true
}));
return undefined; // ← CANCEL: type mismatch
}
}
// 2. canConnect OVERRIDE (Phase-40 contract — custom rule, in addition).
if (typeof this.canConnect === 'function' && this.canConnect(conn) === false) {
if (!this.programmatic) this.dispatchEvent(new CustomEvent("connection-rejected", {
detail: conn,
bubbles: true,
composed: true
}));
return undefined; // ← CANCEL: Signal.emit halts, addConnection returns false
}
}
return context;
});
// ─── forward engine events (echo-guarded via `programmatic`) ───────────────
// ─── forward engine events (echo-guarded via `programmatic`) ───────────────
this.editor.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'connectioncreated') {
// keep engine truth in sync so reconcile diffs correctly — a user-drawn
// connection (auto id) must register here or the next graph pass re-adds it.
this.connInstances.set(context.data.id, context.data);
if (!this.programmatic) {
// WRITE-BACK: append the new connection into a fresh graph object (D4).
this.writeBackConnectionCreated(context.data);
// keep the discrete event too (back-compat).
this.dispatchEvent(new CustomEvent("connection-created", {
detail: this.serializeConn(context.data),
bubbles: true,
composed: true
}));
}
} else if (context.type === 'connectionremoved') {
this.connInstances.delete(context.data.id);
this.connMeta.delete(context.data.id);
if (!this.programmatic) {
// WRITE-BACK: filter the removed connection out of a fresh graph object (D4).
this.writeBackConnectionRemoved(context.data.id);
this.dispatchEvent(new CustomEvent("connection-removed", {
detail: {
id: context.data.id
},
bubbles: true,
composed: true
}));
}
}
return context;
});
this.area.addPipe((context: any) => {
if (!context || typeof context !== 'object' || !('type' in context)) return context;
if (context.type === 'nodepicked') {
this.dispatchEvent(new CustomEvent("node-picked", {
detail: {
id: context.data.id
},
bubbles: true,
composed: true
}));
// T1.3 — pointer-DOWN: stash the PRE-drag graph snapshot (before any movement). It
// is committed to history on the first `nodetranslated` (only if a drag follows;
// gated on !programmatic + history). A re-pick mid-drag won't overwrite a live one.
if (!this.programmatic && this.history !== false && !this.dragGestureActive) {
this.pendingDragSnapshot = this.snapshotCurrent();
}
// Win 2: a pick changed the selection — surface @selection-change after the
// engine's awaited select() for THIS pick has flushed the selector entities.
this.scheduleSelectionEmit();
} else if (context.type === 'pointerup') {
// Win 2: AreaExtensions.selectableNodes UNSELECTS all on a click-like background
// pointerUP (its `twitch < 4` deselect — NOT on pointerdown, verified against
// rete-area-plugin's selectable pipe). Its unselectAll() is async and its pipe
// runs before ours, so recompute AFTER its awaited unselectAll() flushes (the
// microtask + rAF schedule). The dedup makes a no-op when nothing changed (e.g. a
// pointerup that ended a node pick — already surfaced by the nodepicked branch).
this.scheduleSelectionEmit();
// T1.3 — a pointerup ends any in-progress drag gesture, so the NEXT drag pushes a
// fresh history snapshot (one gesture = one undo step, D-03). Drop any stashed
// pre-drag snapshot that was never committed (a pick with no drag).
this.dragGestureActive = false;
this.pendingDragSnapshot = null;
// T1.1: a background pointerup (anywhere not on a connection path) clears the edge
// selection — UNLESS this same gesture just selected an edge (the path's own
// pointerup ran in the same tick and raised `edgeClickGuard`; the guard self-resets
// on the next microtask). Mirrors the node selectable's click-to-deselect.
if (!this.edgeClickGuard && this.selectedConnId != null) this.clearEdgeSelection();
} else if (context.type === 'nodetranslated') {
if (!this.programmatic) {
const id = context.data.id;
const pos = context.data.position;
const meta = this.nodeMeta.get(id);
if (meta) {
meta.x = pos.x;
meta.y = pos.y;
}
// T1.3 — commit ONE history snapshot per drag gesture, at its FIRST translate:
// the pre-move snapshot stashed on nodepicked (a drag truly happened now, not just
// a pick). dragGestureActive holds until the drag-ending pointerup resets it, so a
// continuous drag = ONE undo step (D-03).
if (!this.dragGestureActive) {
this.dragGestureActive = true;
if (this.pendingDragSnapshot) {
this.pushHistorySnapshot(this.pendingDragSnapshot);
this.pendingDragSnapshot = null;
}
}
// WRITE-BACK (coalesced): accumulate the latest position for this node and
// flush ONE fresh graph object per animation frame (Pitfall 2 — the drag
// storm). The discrete `node-moved` emit stays per-translate (back-compat).
this.pendingDragPositions.set(id, {
x: pos.x,
y: pos.y
});
this.scheduleDragFlush();
this.dispatchEvent(new CustomEvent("node-moved", {
detail: {
id,
x: pos.x,
y: pos.y
},
bubbles: true,
composed: true
}));
}
// a node moved → its minimap rect moves (works during a programmatic translate too).
if (this.scheduleMinimapRedraw) this.scheduleMinimapRedraw();
// T2.8 — the selected node moved → re-track its toolbar overlay (no-op if off).
if (this.scheduleToolbarTrack) this.scheduleToolbarTrack();
} else if (context.type === 'translated') {
this.dispatchEvent(new CustomEvent("translated", {
detail: {
x: context.data.position.x,
y: context.data.position.y
},
bubbles: true,
composed: true
}));
// the viewport window moved → redraw the minimap viewport rect + mask.
if (this.scheduleMinimapRedraw) this.scheduleMinimapRedraw();
// T2.8 — a pan shifts the node's screen rect → re-track the toolbar (no-op if off).
if (this.scheduleToolbarTrack) this.scheduleToolbarTrack();
} else if (context.type === 'zoomed') {
if (!this.programmatic) {
const k = this.area.area.transform.k;
if (k !== this.zoom) this._zoomControllable.write(k);
}
// the viewport window resized (zoom) → redraw the minimap viewport rect + mask.
if (this.scheduleMinimapRedraw) this.scheduleMinimapRedraw();
// T2.8 — a zoom changes the node's screen rect/size → re-track the toolbar (no-op if off).
if (this.scheduleToolbarTrack) this.scheduleToolbarTrack();
} else if (context.type === 'contextmenu') {
// suppress the native browser menu over the canvas; surface a hook instead.
context.data.event.preventDefault();
const ctx = context.data.context;
this.dispatchEvent(new CustomEvent("context-menu", {
detail: {
id: ctx && ctx.id ? ctx.id : null
},
bubbles: true,
composed: true
}));
}
return context;
});
// ─── reconciler off the bound graph, bridged to the top-level $watch ──────────
// Nodes come ONLY from `$props.graph.nodes` (the single source of truth, D1/D2);
// sockets come from each node's TYPE port schema (portReg keyed `type::side::key`).
// A port-schema change ($data.portReg, when a <Port> registers late on Lit) ALSO
// drives this reconcile so a node whose type just gained ports re-renders. An
// imperative $expose addNode (provenance NOT in lastPropNodeIds) survives the reaper.
// Wrapped by reconcileNodes (below) with a re-entrancy guard so two passes never
// race the engine (the Lit "cannot find node" fix).
// ─── reconciler off the bound graph, bridged to the top-level $watch ──────────
// Nodes come ONLY from `$props.graph.nodes` (the single source of truth, D1/D2);
// sockets come from each node's TYPE port schema (portReg keyed `type::side::key`).
// A port-schema change ($data.portReg, when a <Port> registers late on Lit) ALSO
// drives this reconcile so a node whose type just gained ports re-renders. An
// imperative $expose addNode (provenance NOT in lastPropNodeIds) survives the reaper.
// Wrapped by reconcileNodes (below) with a re-entrancy guard so two passes never
// race the engine (the Lit "cannot find node" fix).
const reconcileNodesPass = async () => {
if (!this.editor || !this.area) return;
const graphNodes = Array.isArray(this.graph && this.graph.nodes) ? this.graph.nodes : [];
const want = [];
this.programmatic++;
try {
for (const spec of graphNodes as any) {
if (!spec || spec.id == null) continue;
want.push(spec.id);
this.nodeMeta.set(spec.id, spec);
let node = this.nodeInstances.get(spec.id);
if (!node) {
node = this.buildNode(spec, this._portReg.value);
this.nodeInstances.set(spec.id, node);
await this.editor.addNode(node);
await this.area.translate(spec.id, {
x: spec.x || 0,
y: spec.y || 0
});
} else {
// Sync any ports this node's TYPE gained AFTER the node was first built —
// a nested <Port>'s addTypePort can land after reconcileNodes already
// created the node (the node registered before its ports on some targets,
// or a <Port> registered late on Lit). buildNode only runs for NEW nodes,
// so add the missing inputs/outputs onto the live instance here from the
// TYPE schema, then re-render.
let portsAdded = false;
const {
inputs: wantIn,
outputs: wantOut
} = this.portSchemaForType(spec.type, this._portReg.value);
for (const inp of wantIn as any) {
if (!inp || inp.key == null || node.inputs[inp.key]) continue;
node.addInput(inp.key, new ClassicPreset.Input(this.SOCKET, inp.label, inp.multiple === true));
portsAdded = true;
}
for (const out of wantOut as any) {
if (!out || out.key == null || node.outputs[out.key]) continue;
node.addOutput(out.key, new ClassicPreset.Output(this.SOCKET, out.label, out.multiple !== false));
portsAdded = true;
}
const view = this.area.nodeViews.get(spec.id);
if (view && spec.x != null && spec.y != null && (view.position.x !== spec.x || view.position.y !== spec.y)) {
await this.area.translate(spec.id, {
x: spec.x,
y: spec.y
});
}
if (portsAdded) {
// renderNode's in-place branch deliberately leaves existing sockets
// untouched; to render the NEW sockets, drop this node's render entry so
// area.update takes the fresh-build path (re-runs buildSocketRow + re-
// emits the socket render signals the ConnectionPlugin/watcher need). The
// render-by-type body host is re-projected by the type's bodyRenderer
// (mounts a fresh portal root into the same host — idempotent).
const entry = this.nodeEntries.get(spec.id);
if (entry) {
if (entry.handle) entry.handle.dispose();
if (entry.bodyHandle && entry.bodyHandle.dispose) {
try {
entry.bodyHandle.dispose();
} catch (e: any) {}
}
for (const d of entry.socketDisposers as any) {
try {
d();
} catch (e: any) {}
}
this.nodeEntries.delete(spec.id);
}
}
await this.area.update('node', spec.id);
// a port change must re-run connections — an edge that was skipped because
// its endpoint port didn't exist yet can now be drawn.
if (portsAdded && this.reconcileConnections) await this.reconcileConnections();
}
}
// remove dropped GRAPH-managed nodes (+ their connections) — imperatively added
// nodes (NOT in lastPropNodeIds) survive (the power-user escape hatch).
const tracked = new Set(this.lastPropNodeIds);
for (const id of tracked as any) {
if (!want.includes(id) && this.nodeInstances.has(id)) {
for (const c of this.editor.getConnections() as any) {
if (c.source === id || c.target === id) await this.editor.removeConnection(c.id);
}
await this.editor.removeNode(id);
this.nodeInstances.delete(id);
this.nodeMeta.delete(id);
}
}
this.lastPropNodeIds = want;
} finally {
this.programmatic--;
}
};
// Re-entrancy-guarded entry point. If a pass is already running, mark a re-run and
// return — the in-flight pass loops until no further request is pending. Serializing
// overlapping reconciles is what stops the Lit async-context cascade from racing the
// engine into "cannot find node" (which otherwise aborts the declarative graph build).
// Re-entrancy-guarded entry point. If a pass is already running, mark a re-run and
// return — the in-flight pass loops until no further request is pending. Serializing
// overlapping reconciles is what stops the Lit async-context cascade from racing the
// engine into "cannot find node" (which otherwise aborts the declarative graph build).
this.reconcileNodes = async () => {
if (this.reconcileNodesRunning) {
this.reconcileNodesPending = true;
return;
}
this.reconcileNodesRunning = true;
try {
do {
this.reconcileNodesPending = false;
await reconcileNodesPass();
} while (this.reconcileNodesPending);
} finally {
this.reconcileNodesRunning = false;
}
};
this.reconcileConnections = async () => {
if (!this.editor) return;
// Edges come ONLY from the bound graph's `connections` (the single source of
// truth — declarative <Connection> children are gone). Normalize id-defaulting
// (a connection authored without an id gets a stable derived id) so an edge the
// canvas wrote back (carrying the engine id) and a hand-authored edge dedup.
const graphConns = Array.isArray(this.graph && this.graph.connections) ? this.graph.connections : [];
const norm = (spec: any) => {
if (!spec || spec.source == null || spec.target == null) return null;
const srcOut = spec.sourceOutput != null ? spec.sourceOutput : 'out';
const tgtIn = spec.targetInput != null ? spec.targetInput : 'in';
const id = spec.id != null ? spec.id : `${spec.source}:${srcOut}->${spec.target}:${tgtIn}`;
// carry the optional per-edge label/style (F3) through to connMeta → renderConnection.
return {
id,
source: spec.source,
sourceOutput: srcOut,
target: spec.target,
targetInput: tgtIn,
label: spec.label,
stroke: spec.stroke,
dashed: spec.dashed,
type: spec.type
};
};
// cheap style signature so a label/style/type change on an EXISTING edge re-renders it.
const edgeStyleSig = (s: any) => s ? String(s.label) + '|' + String(s.stroke) + '|' + String(s.dashed) + '|' + String(s.type) : '';
const merged = graphConns.map(norm).filter(Boolean);
const want = [];
this.programmatic++;
try {
for (const spec of merged as any) {
if (!spec || spec.id == null) continue;
want.push(spec.id);
if (this.connInstances.has(spec.id)) {
// existing edge — relabel/restyle in place if its label/style changed (the
// controlled-graph expectation: edit the bound graph → see the change). Drop the
// render entry so area.update takes the fresh-build path (re-applies label/style).
const changed = edgeStyleSig(this.connMeta.get(spec.id)) !== edgeStyleSig(spec);
this.connMeta.set(spec.id, spec);
if (changed) {
const entry = this.connEntries.get(spec.id);
if (entry) {
entry.dispose();
this.connEntries.delete(spec.id);
}
await this.area.update('connection', spec.id);
}
continue;
}
const sourceNode = this.nodeInstances.get(spec.source);
const targetNode = this.nodeInstances.get(spec.target);
if (!sourceNode || !targetNode) continue;
// DEFENSIVE: the referenced output/input ports must exist on the live node
// instances before addConnection (Rete throws "source node doesn't have
// output with a key out" otherwise, aborting the loop). An edge may reference
// a port the node's TYPE schema has not flushed yet (a <Port> registered
// after the <NodeType>); skip until the ports exist — reconcileNodes re-runs
// reconcileConnections after a port-schema change, so the edge lands later.
if (!sourceNode.outputs || !sourceNode.outputs[spec.sourceOutput]) continue;
if (!targetNode.inputs || !targetNode.inputs[spec.targetInput]) continue;
const conn = new ClassicPreset.Connection(sourceNode, spec.sourceOutput, targetNode, spec.targetInput);
conn.id = spec.id;
this.connInstances.set(spec.id, conn);
// seed connMeta BEFORE addConnection so renderConnection sees the label/style on
// its first render (the render fires synchronously inside addConnection's pipe).
this.connMeta.set(spec.id, spec);
await this.editor.addConnection(conn);
}
// remove dropped GRAPH-managed edges — imperatively added edges survive.
const tracked = new Set(this.lastPropConnIds);
for (const id of tracked as any) {
if (!want.includes(id) && this.connInstances.has(id)) {
await this.editor.removeConnection(id);
this.connInstances.delete(id);
this.connMeta.delete(id);
}
}
this.lastPropConnIds = want;
} finally {
this.programmatic--;
}
};
// ─── built-in MiniMap (opt-in :minimap, Phase 42) ────────────────────────────
// An absolute light-DOM SVG overlay (bottom-right) showing a scaled map of every
// node + the current viewport window (outside dimmed), PANNABLE (drag recenters via
// setCenter). The host div is COMPONENT-template DOM (carries the [data-rozie-s-*]
// scope attr → plain scoped CSS positions it); its SVG children are built
// IMPERATIVELY with createElementNS (the connection-renderer discipline) so SVG
// namespacing is identical on all 6 (no SVG-in-template cross-target risk) and styled
// with INLINE attributes (the arrowhead-marker lesson — no scoped-CSS / :root rule
// needed for engine-style DOM). Node dims come from the MEASURED engine node-view
// elements (area.nodeViews.get(id).element offsetW/H — target-agnostic, like the
// render pipe) with a default-rect fallback for Lit's unmeasured first paint.
// ─── built-in MiniMap (opt-in :minimap, Phase 42) ────────────────────────────
// An absolute light-DOM SVG overlay (bottom-right) showing a scaled map of every
// node + the current viewport window (outside dimmed), PANNABLE (drag recenters via
// setCenter). The host div is COMPONENT-template DOM (carries the [data-rozie-s-*]
// scope attr → plain scoped CSS positions it); its SVG children are built
// IMPERATIVELY with createElementNS (the connection-renderer discipline) so SVG
// namespacing is identical on all 6 (no SVG-in-template cross-target risk) and styled
// with INLINE attributes (the arrowhead-marker lesson — no scoped-CSS / :root rule
// needed for engine-style DOM). Node dims come from the MEASURED engine node-view
// elements (area.nodeViews.get(id).element offsetW/H — target-agnostic, like the
// render pipe) with a default-rect fallback for Lit's unmeasured first paint.
const measureNodeSize = (id: any) => {
const view = this.area && this.area.nodeViews ? this.area.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
const w = el && el.offsetWidth ? el.offsetWidth : this.MINIMAP_DEFAULT_NODE_W;
const h = el && el.offsetHeight ? el.offsetHeight : this.MINIMAP_DEFAULT_NODE_H;
return {
w,
h
};
};
const mkMinimapRect = (x: any, y: any, w: any, h: any, cls: any, fill: any, stroke: any, strokeW: any) => {
const r = document.createElementNS(this.SVGNS, 'rect');
r.setAttribute('class', cls);
r.setAttribute('x', String(x));
r.setAttribute('y', String(y));
r.setAttribute('width', String(Math.max(w, 0)));
r.setAttribute('height', String(Math.max(h, 0)));
if (fill) r.setAttribute('fill', fill);
if (stroke) {
r.setAttribute('stroke', stroke);
r.setAttribute('stroke-width', String(strokeW || 1));
}
return r;
};
// Rebuild the minimap SVG: node rects (selected highlighted) + a dim mask outside the
// viewport (evenodd punch-out) + the viewport window outline. The bounds union the
// node rects AND the viewport window so the viewport indicator stays in-frame even
// when panned past the nodes. Stores `minimapMap` (the px↔graph mapping the pointer-
// pan handlers read). Cheap (a handful of rects) → a full rebuild per frame is fine.
// Rebuild the minimap SVG: node rects (selected highlighted) + a dim mask outside the
// viewport (evenodd punch-out) + the viewport window outline. The bounds union the
// node rects AND the viewport window so the viewport indicator stays in-frame even
// when panned past the nodes. Stores `minimapMap` (the px↔graph mapping the pointer-
// pan handlers read). Cheap (a handful of rects) → a full rebuild per frame is fine.
const redrawMinimap = () => {
this.minimapRedrawRaf = 0;
if (!this.minimap || !this.minimapSvg || !this.area || !container) return;
const t = this.area.area.transform;
const k = t.k || 1;
const cw = container.clientWidth || this.MINIMAP_W;
const ch = container.clientHeight || this.MINIMAP_H;
// viewport window in GRAPH coords (screen [0,cw]×[0,ch] → graph).
const vx = -t.x / k,
vy = -t.y / k,
vw = cw / k,
vh = ch / k;
const graphNodes = this.currentGraph().nodes || [];
const selIds = new Set(this.selectedNodeIds().map((s: any) => String(s)));
const rects = [];
for (const n of graphNodes as any) {
if (!n || n.id == null) continue;
const view = this.area.nodeViews.get(n.id);
const gx = view ? view.position.x : n.x || 0;
const gy = view ? view.position.y : n.y || 0;
const sz = measureNodeSize(n.id);
rects.push({
gx,
gy,
gw: sz.w,
gh: sz.h,
selected: selIds.has(String(n.id))
});
}
let minX = vx,
minY = vy,
maxX = vx + vw,
maxY = vy + vh;
for (const r of rects as any) {
if (r.gx < minX) minX = r.gx;
if (r.gy < minY) minY = r.gy;
if (r.gx + r.gw > maxX) maxX = r.gx + r.gw;
if (r.gy + r.gh > maxY) maxY = r.gy + r.gh;
}
const padX = (maxX - minX) * 0.1 || 20;
const padY = (maxY - minY) * 0.1 || 20;
minX -= padX;
minY -= padY;
maxX += padX;
maxY += padY;
const bw = maxX - minX || 1;
const bh = maxY - minY || 1;
const scale = Math.min(this.MINIMAP_W / bw, this.MINIMAP_H / bh);
const offX = (this.MINIMAP_W - bw * scale) / 2;
const offY = (this.MINIMAP_H - bh * scale) / 2;
this.minimapMap = {
minX,
minY,
scale,
offX,
offY
};
const toMMx = (gx: any) => (gx - minX) * scale + offX;
const toMMy = (gy: any) => (gy - minY) * scale + offY;
this.minimapSvg.innerHTML = '';
for (const r of rects as any) {
const fill = r.selected ? '#3b82f6' : '#94a3b8';
this.minimapSvg.appendChild(mkMinimapRect(toMMx(r.gx), toMMy(r.gy), r.gw * scale, r.gh * scale, 'rozie-flow-minimap__node', fill, null, 0));
}
// dim mask OUTSIDE the viewport: full minimap rect with the viewport rect punched
// out (both subpaths same winding → fill-rule:evenodd leaves the viewport a hole).
const mvx = toMMx(vx),
mvy = toMMy(vy),
mvw = vw * scale,
mvh = vh * scale;
const mask = document.createElementNS(this.SVGNS, 'path');
mask.setAttribute('class', 'rozie-flow-minimap__mask');
mask.setAttribute('fill-rule', 'evenodd');
mask.setAttribute('fill', 'rgba(15, 23, 42, 0.18)');
mask.setAttribute('d', 'M0 0 H' + this.MINIMAP_W + ' V' + this.MINIMAP_H + ' H0 Z ' + 'M' + mvx + ' ' + mvy + ' h' + mvw + ' v' + mvh + ' h' + -mvw + ' Z');
this.minimapSvg.appendChild(mask);
this.minimapSvg.appendChild(mkMinimapRect(mvx, mvy, mvw, mvh, 'rozie-flow-minimap__viewport', 'none', '#3b82f6', 1.5));
};
// rAF-coalesced scheduler (bridged to the top-level $watch + the engine pipes). No-op
// when :minimap is off (the bridge stays callable everywhere, cheap).
// rAF-coalesced scheduler (bridged to the top-level $watch + the engine pipes). No-op
// when :minimap is off (the bridge stays callable everywhere, cheap).
this.scheduleMinimapRedraw = () => {
if (!this.minimap || this.minimapRedrawRaf) return;
if (typeof requestAnimationFrame === 'function') {
this.minimapRedrawRaf = requestAnimationFrame(redrawMinimap);
} else {
this.minimapRedrawRaf = 1;
Promise.resolve().then(redrawMinimap);
}
};
// Map a minimap pointer event → graph coords (via the stored minimapMap) → setCenter.
// Pan is a view op → allowed even when readonly, but gated by `pannable` (mirror the
// main-canvas pannable gate). Pointer capture keeps the drag tracking off the box.
// Map a minimap pointer event → graph coords (via the stored minimapMap) → setCenter.
// Pan is a view op → allowed even when readonly, but gated by `pannable` (mirror the
// main-canvas pannable gate). Pointer capture keeps the drag tracking off the box.
const minimapPointerToGraph = (e: any) => {
if (!this.minimapMap || !this.minimapHost) return null;
const box = this.minimapHost.getBoundingClientRect();
const rw = box.width || this.MINIMAP_W;
const rh = box.height || this.MINIMAP_H;
const mx = (e.clientX - box.left) * (this.MINIMAP_W / rw);
const my = (e.clientY - box.top) * (this.MINIMAP_H / rh);
return {
gx: this.minimapMap.minX + (mx - this.minimapMap.offX) / this.minimapMap.scale,
gy: this.minimapMap.minY + (my - this.minimapMap.offY) / this.minimapMap.scale
};
};
if (this.minimap && this._refMinimapEl) {
this.minimapHost = this._refMinimapEl;
this.minimapSvg = document.createElementNS(this.SVGNS, 'svg');
this.minimapSvg.setAttribute('class', 'rozie-flow-minimap__svg');
this.minimapSvg.setAttribute('viewBox', '0 0 ' + this.MINIMAP_W + ' ' + this.MINIMAP_H);
this.minimapSvg.setAttribute('preserveAspectRatio', 'none');
this.minimapHost.appendChild(this.minimapSvg);
this.onMinimapPointerDown = (e: any) => {
if (!this.pannable) return;
const g = minimapPointerToGraph(e);
if (!g) return;
this.minimapPanning = true;
try {
if (e.target && e.target.setPointerCapture && e.pointerId != null) e.target.setPointerCapture(e.pointerId);
} catch (err: any) {}
e.preventDefault();
e.stopPropagation();
this.setCenter(g.gx, g.gy, null);
};
this.onMinimapPointerMove = (e: any) => {
if (!this.minimapPanning || !this.pannable) return;
const g = minimapPointerToGraph(e);
if (!g) return;
e.preventDefault();
this.setCenter(g.gx, g.gy, null);
};
this.onMinimapPointerUp = (e: any) => {
if (!this.minimapPanning) return;
this.minimapPanning = false;
try {
if (e.target && e.target.releasePointerCapture && e.pointerId != null) e.target.releasePointerCapture(e.pointerId);
} catch (err: any) {}
};
this.minimapHost.addEventListener('pointerdown', this.onMinimapPointerDown);
this.minimapHost.addEventListener('pointermove', this.onMinimapPointerMove);
this.minimapHost.addEventListener('pointerup', this.onMinimapPointerUp);
}
// ─── T2.8 NodeToolbar (opt-in :node-toolbar) ─────────────────────────────────
// A floating component-template overlay over the SELECTED node. The host div
// (ref="toolbarEl") carries the [data-rozie-s-*] scope attr → PLAIN scoped CSS positions
// it absolutely (NOT the :root engine-DOM escape hatch — it's component DOM, like the
// marquee box + Controls). It is positioned from the engine node-view ELEMENT's rect
// (which the AreaPlugin transforms for pan/zoom/drag) relative to the canvas container, so
// the area transform is honored automatically — we read getBoundingClientRect() and
// subtract the container's rect (the screenToFlowPosition discipline, but the other way).
// Re-tracked on translated/zoomed/nodetranslated (the pipe branches that schedule the
// minimap redraw) + on every selection emit. OPT-IN (default OFF) → existing demos +
// FlowCanvasScreenshot are pixel-identical (the host div is r-if'd off when :node-toolbar
// is false; selecting a node never pops it).
// Resolve the SINGLE selected node id the toolbar should track: the one picked node when
// EXACTLY one is selected, else null (no toolbar over a multi-select or empty selection —
// a per-node action needs an unambiguous target). Read-only.
// ─── T2.8 NodeToolbar (opt-in :node-toolbar) ─────────────────────────────────
// A floating component-template overlay over the SELECTED node. The host div
// (ref="toolbarEl") carries the [data-rozie-s-*] scope attr → PLAIN scoped CSS positions
// it absolutely (NOT the :root engine-DOM escape hatch — it's component DOM, like the
// marquee box + Controls). It is positioned from the engine node-view ELEMENT's rect
// (which the AreaPlugin transforms for pan/zoom/drag) relative to the canvas container, so
// the area transform is honored automatically — we read getBoundingClientRect() and
// subtract the container's rect (the screenToFlowPosition discipline, but the other way).
// Re-tracked on translated/zoomed/nodetranslated (the pipe branches that schedule the
// minimap redraw) + on every selection emit. OPT-IN (default OFF) → existing demos +
// FlowCanvasScreenshot are pixel-identical (the host div is r-if'd off when :node-toolbar
// is false; selecting a node never pops it).
// Resolve the SINGLE selected node id the toolbar should track: the one picked node when
// EXACTLY one is selected, else null (no toolbar over a multi-select or empty selection —
// a per-node action needs an unambiguous target). Read-only.
const singleSelectedNodeId = () => {
const ids = this.selectedNodeIds();
return ids.length === 1 ? ids[0] : null;
};
// Position the toolbar host over the tracked node's engine element, or hide it. The
// node-view element is already transformed by the AreaPlugin (pan/zoom/drag), so its
// client rect minus the container's client rect gives the toolbar's container-relative
// px — no manual transform math. Placed just ABOVE the node (bottom of the toolbar at the
// node's top edge); clamped so it never goes off the top of the container.
// Position the toolbar host over the tracked node's engine element, or hide it. The
// node-view element is already transformed by the AreaPlugin (pan/zoom/drag), so its
// client rect minus the container's client rect gives the toolbar's container-relative
// px — no manual transform math. Placed just ABOVE the node (bottom of the toolbar at the
// node's top edge); clamped so it never goes off the top of the container.
const trackToolbar = () => {
this.toolbarTrackRaf = 0;
if (!this.nodeToolbar || !this.toolbarHost || !this.area || !container) return;
const id = this.toolbarSelectedId;
if (id == null) {
this.toolbarHost.style.display = 'none';
return;
}
const view = this.area.nodeViews ? this.area.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
const rect = el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null;
if (!rect) {
this.toolbarHost.style.display = 'none';
return;
}
const cbox = container.getBoundingClientRect();
// container-relative px of the node's top-left + width.
const nx = rect.left - cbox.left;
const ny = rect.top - cbox.top;
const tbH = this.toolbarHost.offsetHeight || 30;
let top = ny - tbH - 6;
if (top < 2) top = ny + rect.height + 6; // flip below if it would clip the top
this.toolbarHost.style.left = nx + 'px';
this.toolbarHost.style.top = top + 'px';
this.toolbarHost.style.display = 'flex';
};
this.scheduleToolbarTrack = () => {
if (!this.nodeToolbar || this.toolbarTrackRaf) return;
if (typeof requestAnimationFrame === 'function') {
this.toolbarTrackRaf = requestAnimationFrame(trackToolbar);
} else {
this.toolbarTrackRaf = 1;
Promise.resolve().then(trackToolbar);
}
};
// Recompute the tracked node from the live selection + (re)mount the toolbar content for
// it. Called from the selection emit (a pick/unpick changed the selection). When the
// tracked id changes: if the consumer fills `#toolbar`, (re)render the reactive portal
// with the new node scope; else the default buttons stay put (they read the live tracked
// id at click time, so no re-mount needed). Then reposition.
// Recompute the tracked node from the live selection + (re)mount the toolbar content for
// it. Called from the selection emit (a pick/unpick changed the selection). When the
// tracked id changes: if the consumer fills `#toolbar`, (re)render the reactive portal
// with the new node scope; else the default buttons stay put (they read the live tracked
// id at click time, so no re-mount needed). Then reposition.
const syncToolbar = () => {
if (!this.nodeToolbar || !this.toolbarHost) return;
const id = singleSelectedNodeId();
if (id === this.toolbarSelectedId && id == null === (this.toolbarSelectedId == null)) {
// same target — just reposition (e.g. after a drag).
this.scheduleToolbarTrack();
return;
}
this.toolbarSelectedId = id;
if (this.toolbar !== undefined && id != null) {
const meta = this.nodeMeta.get(id) || {
id,
type: undefined,
data: {}
};
const scope = {
node: meta,
emit: toolbarEmit
};
if (this.toolbarHandle && this.toolbarHandle.update) {
this.toolbarHandle.update(scope);
} else {
this.toolbarHandle = portals.toolbar(this.toolbarHost, scope);
}
}
this.scheduleToolbarTrack();
};
this.syncToolbarSelection = syncToolbar;
// The @node-action emit helper for the toolbar's actions (the EXISTING emit — no new emit,
// T2.8). Carries the tracked node id. Handed to the `#toolbar` slot scope so a consumer
// override can raise its own actions too.
// The @node-action emit helper for the toolbar's actions (the EXISTING emit — no new emit,
// T2.8). Carries the tracked node id. Handed to the `#toolbar` slot scope so a consumer
// override can raise its own actions too.
const toolbarEmit = (name: any, detail: any) => {
const id = this.toolbarSelectedId;
this.dispatchEvent(new CustomEvent("node-action", {
detail: {
id,
name,
detail
},
bubbles: true,
composed: true
}));
};
if (this.nodeToolbar && this._refToolbarEl) {
this.toolbarHost = this._refToolbarEl;
this.toolbarHost.style.display = 'none';
if (!(this.toolbar !== undefined)) {
// default chrome: delete + duplicate buttons. Static literal labels (Threat
// T-44-06-1: no node-derived text rendered via innerHTML — these are fixed strings
// set via textContent). Both fire @node-action on the tracked node.
this.toolbarDeleteBtn = document.createElement('button');
this.toolbarDeleteBtn.type = 'button';
this.toolbarDeleteBtn.className = 'rozie-flow-toolbar__btn rozie-flow-toolbar__btn--delete';
this.toolbarDeleteBtn.setAttribute('data-testid', 'flow-toolbar-delete');
this.toolbarDeleteBtn.setAttribute('aria-label', 'Delete node');
this.toolbarDeleteBtn.textContent = 'Delete';
this.toolbarDuplicateBtn = document.createElement('button');
this.toolbarDuplicateBtn.type = 'button';
this.toolbarDuplicateBtn.className = 'rozie-flow-toolbar__btn rozie-flow-toolbar__btn--duplicate';
this.toolbarDuplicateBtn.setAttribute('data-testid', 'flow-toolbar-duplicate');
this.toolbarDuplicateBtn.setAttribute('aria-label', 'Duplicate node');
this.toolbarDuplicateBtn.textContent = 'Duplicate';
this.onToolbarDelete = (e: any) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const id = this.toolbarSelectedId;
if (id == null) return;
toolbarEmit('delete', {
id
});
this.toolbarSelectedId = null;
this.deleteNode(id);
this.scheduleToolbarTrack();
};
this.onToolbarDup = (e: any) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const id = this.toolbarSelectedId;
if (id == null) return;
const newId = this.duplicateNode(id);
toolbarEmit('duplicate', {
id,
newId
});
this.scheduleToolbarTrack();
};
// pointerup (NOT click — Rete swallows clicks during node interaction; the §6a item-7
// discipline) on the COMPONENT-template buttons.
this.toolbarDeleteBtn.addEventListener('pointerup', this.onToolbarDelete);
this.toolbarDuplicateBtn.addEventListener('pointerup', this.onToolbarDup);
this.toolbarHost.appendChild(this.toolbarDeleteBtn);
this.toolbarHost.appendChild(this.toolbarDuplicateBtn);
}
}
// ─── T2.4 MARQUEE select (mode:'select') ─────────────────────────────────────
// A Figma-style rubber-band box. RESTORE-PATH resolution (RESEARCH Q2/A8): rete's
// internal `Drag` class is NOT exported, so setDragHandler(null) can't be cleanly
// reversed (re-instantiating Drag is impossible). Instead we leave the default pan Drag
// installed and intercept the EMPTY-canvas pointerdown in the CAPTURE phase on the
// container — the default Drag attaches its own bubble-phase pointerdown listener on the
// SAME container (verified rete-area-plugin@2.1.5: setDragHandler → Drag.initialize(
// this.container)), so a capture listener fires FIRST and stopPropagation() blocks pan
// before it starts. The interception is gated PURELY on the live `$props.mode` flag, so
// switching back to 'pan' restores pan with ZERO engine mutation (the persistent
// mode-guard the research preferred). A node drag is UNTOUCHED in both modes: we only act
// when the pointerdown target is NOT inside a node element (empty canvas).
//
// The box is a COMPONENT-TEMPLATE overlay div (ref="marqueeEl") — it carries the
// [data-rozie-s-*] scope attr so a PLAIN scoped rule styles it (NOT the :root engine-DOM
// escape hatch). On release we hit-test every graph node's rect (graph coords via
// area.nodeViews.get(id).position + measureNodeSize) against the box (converted to graph
// coords through the live transform) and nodeSelectApi.select(id, true) each intersector,
// then scheduleSelectionEmit() (the existing @selection-change path — NO new emit).
// Marquee changes only SELECTION (script-state), never the graph model → no history push.
// ─── T2.4 MARQUEE select (mode:'select') ─────────────────────────────────────
// A Figma-style rubber-band box. RESTORE-PATH resolution (RESEARCH Q2/A8): rete's
// internal `Drag` class is NOT exported, so setDragHandler(null) can't be cleanly
// reversed (re-instantiating Drag is impossible). Instead we leave the default pan Drag
// installed and intercept the EMPTY-canvas pointerdown in the CAPTURE phase on the
// container — the default Drag attaches its own bubble-phase pointerdown listener on the
// SAME container (verified rete-area-plugin@2.1.5: setDragHandler → Drag.initialize(
// this.container)), so a capture listener fires FIRST and stopPropagation() blocks pan
// before it starts. The interception is gated PURELY on the live `$props.mode` flag, so
// switching back to 'pan' restores pan with ZERO engine mutation (the persistent
// mode-guard the research preferred). A node drag is UNTOUCHED in both modes: we only act
// when the pointerdown target is NOT inside a node element (empty canvas).
//
// The box is a COMPONENT-TEMPLATE overlay div (ref="marqueeEl") — it carries the
// [data-rozie-s-*] scope attr so a PLAIN scoped rule styles it (NOT the :root engine-DOM
// escape hatch). On release we hit-test every graph node's rect (graph coords via
// area.nodeViews.get(id).position + measureNodeSize) against the box (converted to graph
// coords through the live transform) and nodeSelectApi.select(id, true) each intersector,
// then scheduleSelectionEmit() (the existing @selection-change path — NO new emit).
// Marquee changes only SELECTION (script-state), never the graph model → no history push.
const nodeAt = (target: any) => {
if (!target || typeof target.closest !== 'function') return null;
return target.closest('.rozie-flow-node');
};
// container-relative px → GRAPH coords (the inverse area transform, like
// screenToFlowPosition but already container-relative). px = transform + graph·k.
// container-relative px → GRAPH coords (the inverse area transform, like
// screenToFlowPosition but already container-relative). px = transform + graph·k.
const containerPxToGraph = (px: any, py: any) => {
const t = this.area.area.transform;
const k = t.k || 1;
return {
x: (px - t.x) / k,
y: (py - t.y) / k
};
};
const updateMarqueeBox = () => {
if (!this.marqueeBox || !this.marqueeStart || !this.marqueeCur) return;
const x = Math.min(this.marqueeStart.x, this.marqueeCur.x);
const y = Math.min(this.marqueeStart.y, this.marqueeCur.y);
const w = Math.abs(this.marqueeCur.x - this.marqueeStart.x);
const h = Math.abs(this.marqueeCur.y - this.marqueeStart.y);
this.marqueeBox.style.left = x + 'px';
this.marqueeBox.style.top = y + 'px';
this.marqueeBox.style.width = w + 'px';
this.marqueeBox.style.height = h + 'px';
this.marqueeBox.style.display = 'block';
};
const finishMarquee = () => {
if (!this.marqueeActive) return;
this.marqueeActive = false;
if (this.marqueeBox) this.marqueeBox.style.display = 'none';
if (!this.marqueeStart || !this.marqueeCur || !this.nodeSelectApi) {
this.marqueeStart = null;
this.marqueeCur = null;
return;
}
// box in graph coords (two opposite corners → min/max).
const a = containerPxToGraph(this.marqueeStart.x, this.marqueeStart.y);
const b = containerPxToGraph(this.marqueeCur.x, this.marqueeCur.y);
const bx0 = Math.min(a.x, b.x),
by0 = Math.min(a.y, b.y);
const bx1 = Math.max(a.x, b.x),
by1 = Math.max(a.y, b.y);
this.marqueeStart = null;
this.marqueeCur = null;
const graphNodes = this.currentGraph().nodes || [];
let first = true;
for (const n of graphNodes as any) {
if (!n || n.id == null) continue;
const view = this.area.nodeViews.get(n.id);
const gx = view ? view.position.x : n.x || 0;
const gy = view ? view.position.y : n.y || 0;
const sz = measureNodeSize(n.id);
// a node intersects the box if their rects overlap (AABB), in graph coords.
const overlaps = gx < bx1 && gx + sz.w > bx0 && gy < by1 && gy + sz.h > by0;
if (overlaps) {
// accumulate=true keeps every intersector selected (first one replaces the prior
// selection so an old pick doesn't linger; rest accumulate). select(id, accumulate).
this.nodeSelectApi.select(n.id, !first);
first = false;
}
}
// surface @selection-change once the engine's awaited select() chain has flushed.
this.scheduleSelectionEmit();
};
if (this.selectable && !this.readonly && container && typeof container.addEventListener === 'function') {
this.marqueeBox = this._refMarqueeEl || null;
this.onCanvasPointerDownCapture = (e: any) => {
// only in select mode, only the EMPTY canvas (not on a node — those still drag), only
// the primary button. A live `$props.mode` read = the persistent mode-guard (restoring
// pan is just this check returning early; no engine mutation).
if (this.mode !== 'select') return;
if (e && e.button != null && e.button !== 0) return;
if (nodeAt(e.target)) return;
// BLOCK rete's pan Drag (its bubble-phase pointerdown on the same container) — capture
// phase runs first, so stopPropagation() here pre-empts pan; the marquee owns this drag.
e.stopPropagation();
e.preventDefault();
const box = container.getBoundingClientRect();
this.marqueeActive = true;
this.marqueeStart = {
x: e.clientX - box.left,
y: e.clientY - box.top
};
this.marqueeCur = {
x: this.marqueeStart.x,
y: this.marqueeStart.y
};
try {
if (container.setPointerCapture && e.pointerId != null) container.setPointerCapture(e.pointerId);
} catch (err: any) {}
updateMarqueeBox();
};
this.onMarqueePointerMove = (e: any) => {
if (!this.marqueeActive) return;
const box = container.getBoundingClientRect();
this.marqueeCur = {
x: e.clientX - box.left,
y: e.clientY - box.top
};
updateMarqueeBox();
};
this.onMarqueePointerUp = (e: any) => {
if (!this.marqueeActive) return;
try {
if (container.releasePointerCapture && e && e.pointerId != null) container.releasePointerCapture(e.pointerId);
} catch (err: any) {}
finishMarquee();
};
container.addEventListener('pointerdown', this.onCanvasPointerDownCapture, true);
container.addEventListener('pointermove', this.onMarqueePointerMove);
container.addEventListener('pointerup', this.onMarqueePointerUp);
}
// ─── initial graph: nodes first, then connections (connections reference live
// node instances), then optional fit. Sequenced via an async IIFE so the
// $onMount-returned teardown stays synchronous. ──────────────────────────────
// ─── initial graph: nodes first, then connections (connections reference live
// node instances), then optional fit. Sequenced via an async IIFE so the
// $onMount-returned teardown stays synchronous. ──────────────────────────────
;
(async () => {
// T1.3 — seed the canvas's own last-written graph from the initial bound value so the
// first gesture's snapshot/base reflects the mounted graph (immune to prop re-bind lag).
this.lastWrittenGraph = structuredClone(this.currentGraph());
await this.reconcileNodes();
await this.reconcileConnections();
if (typeof this.zoom === 'number' && this.zoom !== 1) {
this.programmatic++;
try {
await this.area.area.zoom(this.zoom);
} finally {
this.programmatic--;
}
}
if (this.fitOnMount && this.editor.getNodes().length) {
this.programmatic++;
try {
await AreaExtensions.zoomAt(this.area, this.editor.getNodes());
} finally {
this.programmatic--;
}
if (this.area) {
const k = this.area.area.transform.k;
if (k !== this.zoom) this._zoomControllable.write(k);
}
}
// draw the minimap once the graph + fit have settled (also redrawn on every
// render / pan / zoom / drag / selection / graph change below).
if (this.scheduleMinimapRedraw) this.scheduleMinimapRedraw();
})();
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
for (const container of this._portalContainers) render(nothing, container);
this._portalContainers.clear();
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'graph') this._graphControllable.notifyAttributeChange(value as unknown as any);
if (name === 'zoom') this._zoomControllable.notifyAttributeChange(value === null ? 1 : Number(value));
if (name === 'mode') this._modeControllable.notifyAttributeChange(value as unknown as string);
}
render() {
return html`
<div class="rozie-flow-canvas" tabindex="0" data-rozie-ref="canvasEl" data-rozie-s-cd396d6a>
${this.controls ? html`<div class="rozie-flow-controls" data-rozie-s-cd396d6a>
<button class="rozie-flow-controls__btn" type="button" data-testid="flow-zoom-in" aria-label="Zoom in" @click=${this.controlZoomIn} data-rozie-s-cd396d6a>+</button>
<button class="rozie-flow-controls__btn" type="button" data-testid="flow-zoom-out" aria-label="Zoom out" @click=${this.controlZoomOut} data-rozie-s-cd396d6a>−</button>
<button class="rozie-flow-controls__btn" type="button" data-testid="flow-fit" aria-label="Fit view" @click=${this.controlFit} data-rozie-s-cd396d6a>☐</button>
${this.marquee ? html`<button class="${Object.entries({ "rozie-flow-controls__btn": true, 'is-active': this.mode === 'select' }).filter(([, v]) => v).map(([k]) => k).join(' ')}" type="button" data-testid="flow-mode" aria-label=${rozieAttr(this.mode === 'select' ? 'Select mode (click to pan)' : 'Pan mode (click to select)')} @click=${this.toggleMode} data-rozie-s-cd396d6a>${rozieDisplay(this.mode === 'select' ? '▢' : '✥')}</button>` : nothing}</div>` : nothing}${this.minimap ? html`<div class="rozie-flow-minimap" data-testid="flow-minimap" data-rozie-ref="minimapEl" data-rozie-s-cd396d6a></div>` : nothing}<div class="rozie-flow-marquee" data-testid="flow-marquee" data-rozie-ref="marqueeEl" data-rozie-s-cd396d6a></div>
${this.nodeToolbar ? html`<div class="rozie-flow-toolbar" data-testid="flow-toolbar" data-rozie-ref="toolbarEl" data-rozie-s-cd396d6a></div>` : nothing}</div>
<slot name="node"></slot>
<slot name="toolbar"></slot>
<slot></slot>
`;
}
editor: any = null;
area: any = null;
connectionPlugin: any = null;
socketWatcher: any = null;
renderScope: any = null;
selector: any = null;
arrange: any = null;
keydownContainer: any = null;
onCanvasKeydown: any = null;
minimapHost: any = null;
minimapSvg: any = null;
minimapRedrawRaf = 0;
minimapMap: any = null;
minimapPanning = false;
onMinimapPointerDown: any = null;
onMinimapPointerMove: any = null;
onMinimapPointerUp: any = null;
scheduleMinimapRedraw: any = null;
nodeSelectApi: any = null;
marqueeBox: any = null;
marqueeActive = false;
marqueeStart: any = null;
marqueeCur: any = null;
onCanvasPointerDownCapture: any = null;
onMarqueePointerMove: any = null;
onMarqueePointerUp: any = null;
toolbarHost: any = null;
toolbarSelectedId: any = null;
toolbarHandle: any = null;
scheduleToolbarTrack: any = null;
syncToolbarSelection: any = null;
toolbarTrackRaf = 0;
toolbarDeleteBtn: any = null;
toolbarDuplicateBtn: any = null;
onToolbarDelete: any = null;
onToolbarDup: any = null;
MINIMAP_W = 200;
MINIMAP_H = 150;
MINIMAP_DEFAULT_NODE_W = 140;
MINIMAP_DEFAULT_NODE_H = 52;
SVGNS = 'http://www.w3.org/2000/svg';
SOCKET = new ClassicPreset.Socket('flow');
nodeInstances = new Map();
nodeMeta = new Map();
connInstances = new Map();
nodeEntries = new Map();
connEntries = new Map();
connMeta = new Map();
lastPropNodeIds: any = null;
lastPropConnIds: any = null;
programmatic = 0;
lastSelectionIds: any = null;
selectedConnId: any = null;
selectedPathEl: any = null;
edgeClickGuard = false;
HISTORY_CAP = 100;
historyStack = [];
redoStack = [];
dragGestureActive = false;
pendingDragSnapshot: any = null;
reconnectInFlight = 0;
reconnectPreSnapshot: any = null;
reconnectDidWriteBack = false;
reconnectCloseScheduled = false;
pendingDragPositions = new Map();
dragFlushRaf = 0;
currentGraph = () => this.graph || {
nodes: [],
connections: []
};
lastWrittenGraph: any = null;
selfWriteInFlight = false;
commitGraph = (g: any) => {
const c = structuredClone(g);
this.lastWrittenGraph = c != null ? c : g;
this.selfWriteInFlight = true;
this._graphControllable.write(g);
};
snapshotCurrent = () => {
const src = this.lastWrittenGraph != null ? this.lastWrittenGraph : this.currentGraph();
return structuredClone(src);
};
baseGraph = () => this.lastWrittenGraph != null ? this.lastWrittenGraph : this.currentGraph();
pushHistorySnapshot = (snap: any) => {
if (this.history === false) return;
if (!snap) return;
this.historyStack.push(snap);
if (this.historyStack.length > this.HISTORY_CAP) {
this.historyStack = this.historyStack.slice(this.historyStack.length - this.HISTORY_CAP);
}
this.redoStack = [];
};
pushHistory = () => {
if (this.programmatic) return;
if (this.history === false) return;
this.pushHistorySnapshot(this.snapshotCurrent());
};
closeReconnectGesture = () => {
if (!this.reconnectCloseScheduled) return;
this.reconnectCloseScheduled = false;
if (this.reconnectInFlight > 0) this.reconnectInFlight = 0;
if (!this.programmatic && this.history !== false && this.reconnectDidWriteBack && this.reconnectPreSnapshot) {
this.pushHistorySnapshot(this.reconnectPreSnapshot);
}
this.reconnectPreSnapshot = null;
this.reconnectDidWriteBack = false;
};
scheduleReconnectClose = () => {
if (this.reconnectCloseScheduled) return;
this.reconnectCloseScheduled = true;
if (typeof setTimeout === 'function') setTimeout(this.closeReconnectGesture, 0);else Promise.resolve().then(this.closeReconnectGesture);
};
restoreGraph = (snap: any) => {
if (!snap) return;
// Cancel any in-flight drag write-back so a queued frame can't clobber the restore with
// a stale position after the programmatic guard releases.
this.pendingDragPositions.clear();
if (this.dragFlushRaf) {
if (typeof cancelAnimationFrame === 'function') {
try {
cancelAnimationFrame(this.dragFlushRaf);
} catch (e: any) {}
}
this.dragFlushRaf = 0;
}
this.programmatic++;
try {
const fresh = {
nodes: (snap.nodes || []).map((n: any) => ({
...n
})),
connections: (snap.connections || []).map((c: any) => ({
...c
}))
};
this.commitGraph(fresh);
} finally {
this.programmatic--;
}
};
undo = () => {
if (this.historyStack.length === 0) return;
const cur = this.snapshotCurrent();
const snap = this.historyStack.pop();
if (cur) this.redoStack.push(cur);
this.restoreGraph(snap);
};
redo = () => {
if (this.redoStack.length === 0) return;
const cur = this.snapshotCurrent();
const snap = this.redoStack.pop();
if (cur) this.historyStack.push(cur);
this.restoreGraph(snap);
};
canUndo = () => this.historyStack.length > 0;
canRedo = () => this.redoStack.length > 0;
flushDragWriteBack = () => {
this.dragFlushRaf = 0;
if (this.programmatic) {
this.pendingDragPositions.clear();
return;
}
if (this.pendingDragPositions.size === 0) return;
const g = this.baseGraph();
const nodes = (g.nodes || []).map((n: any) => {
const p = n && n.id != null ? this.pendingDragPositions.get(n.id) : null;
return p ? {
...n,
x: p.x,
y: p.y
} : n;
});
this.pendingDragPositions.clear();
this.commitGraph({
...g,
nodes
});
};
scheduleDragFlush = () => {
if (this.dragFlushRaf) return;
if (typeof requestAnimationFrame === 'function') {
this.dragFlushRaf = requestAnimationFrame(this.flushDragWriteBack);
} else {
this.dragFlushRaf = 1;
Promise.resolve().then(this.flushDragWriteBack);
}
};
writeBackConnectionCreated = (c: any) => {
if (this.programmatic) return;
// T1.3 — one history entry per CONNECT gesture (BEFORE the write so the snapshot is the
// pre-connect state — snapshotCurrent reads lastWrittenGraph, still the pre-connect value).
// T2.5 — SUPPRESS while a reconnect is in flight: the paired remove+add of a reconnect
// (and a plain new-connection drag, which also rides connectionpick/drop) push ONE
// coalesced snapshot on connectiondrop instead (D-03 one-gesture-one-entry).
if (this.reconnectInFlight) this.reconnectDidWriteBack = true;else this.pushHistory();
const g = this.baseGraph();
const conn = {
id: c.id,
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
};
this.commitGraph({
...g,
connections: [...(g.connections || []), conn]
});
};
writeBackConnectionRemoved = (id: any) => {
if (this.programmatic) return;
// T1.3 — one history entry per DISCONNECT / edge-delete gesture (BEFORE the write).
// T2.5 — SUPPRESS while a reconnect is in flight: the remove half of a reconnect is
// coalesced with its paired add into ONE snapshot pushed on connectiondrop (D-03).
if (this.reconnectInFlight) this.reconnectDidWriteBack = true;else this.pushHistory();
const g = this.baseGraph();
this.commitGraph({
...g,
connections: (g.connections || []).filter((e: any) => e && e.id !== id)
});
};
clearEdgeSelection = () => {
if (this.selectedPathEl && this.selectedPathEl.classList) {
try {
this.selectedPathEl.classList.remove('is-selected');
} catch (e: any) {}
}
this.selectedConnId = null;
this.selectedPathEl = null;
};
selectEdge = (id: any, pathEl: any) => {
if (id == null) return;
this.clearEdgeSelection();
this.selectedConnId = id;
this.selectedPathEl = pathEl;
if (pathEl && pathEl.classList) {
try {
pathEl.classList.add('is-selected');
} catch (e: any) {}
}
this.edgeClickGuard = true;
Promise.resolve().then(() => {
this.edgeClickGuard = false;
});
this.dispatchEvent(new CustomEvent("edge-click", {
detail: {
id
},
bubbles: true,
composed: true
}));
this.dispatchEvent(new CustomEvent("edge-selected", {
detail: {
id
},
bubbles: true,
composed: true
}));
};
deleteNode = (id: any) => {
if (id == null) return false;
const g = this.baseGraph();
const sid = String(id);
const nodes = (g.nodes || []).filter((n: any) => n && String(n.id) !== sid);
if (nodes.length === (g.nodes || []).length) return false;
const connections = (g.connections || []).filter((c: any) => c && String(c.source) !== sid && String(c.target) !== sid);
// T1.3 — one history entry per DELETE gesture (node + its incident edges = ONE undo).
this.pushHistory();
this.commitGraph({
...g,
nodes,
connections
});
return true;
};
freshNodeId = (baseId: any, existing: any) => {
const taken = new Set((existing || []).map((n: any) => n && n.id != null ? String(n.id) : ''));
const root = baseId != null ? String(baseId) : 'node';
let i = 1;
let candidate = root + '-copy';
while (taken.has(candidate)) {
i++;
candidate = root + '-copy-' + i;
}
return candidate;
};
duplicateNode = (id: any) => {
if (id == null) return null;
const g = this.baseGraph();
const sid = String(id);
const src = (g.nodes || []).find((n: any) => n && String(n.id) === sid);
if (!src) return null;
const newId = this.freshNodeId(src.id, g.nodes);
// Phase 45-07 (WR-02/WR-06): `$clone` is now a recursive proxy-safe deep clone
// on every target (Vue's lowering de-proxies nested reactive members via the
// `rozieDeepClone` runtime helper). The historical `$clone({ d: src.data }).d`
// object-literal wrapper — which never actually dodged the old single-toRaw
// throw on a live nested proxy — is no longer needed; clone `src.data` directly.
const clonedData = src.data != null ? structuredClone(src.data) : undefined;
const clone = {
...src,
id: newId,
x: (typeof src.x === 'number' ? src.x : 0) + 28,
y: (typeof src.y === 'number' ? src.y : 0) + 28,
data: clonedData
};
this.pushHistory();
this.commitGraph({
...g,
nodes: [...(g.nodes || []), clone]
});
return newId;
};
selectedNodeIds = () => {
if (!this.selector || !this.selector.entities) return [];
const ids = [];
for (const e of this.selector.entities.values() as any) {
if (e && e.id != null) ids.push(e.id);
}
return ids;
};
maybeEmitSelectionChange = () => {
if (this.programmatic) return;
const ids = this.selectedNodeIds();
const key = [...ids].map((x: any) => String(x)).sort().join(' ');
if (key === this.lastSelectionIds) return;
this.lastSelectionIds = key;
this.dispatchEvent(new CustomEvent("selection-change", {
detail: {
ids
},
bubbles: true,
composed: true
}));
// the selected set changed → repaint the minimap (selected nodes are highlighted).
if (this.scheduleMinimapRedraw) this.scheduleMinimapRedraw();
// T2.8 — the selection changed → re-track the NodeToolbar (it follows the single
// selected node; hides on multi-select / empty selection). No-op when :node-toolbar off.
if (this.syncToolbarSelection) this.syncToolbarSelection();
};
scheduleSelectionEmit = () => {
Promise.resolve().then(this.maybeEmitSelectionChange);
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(this.maybeEmitSelectionChange);
} else {
Promise.resolve().then(() => Promise.resolve().then(this.maybeEmitSelectionChange));
}
};
reconcileNodes: any = null;
reconcileConnections: any = null;
reconcileNodesRunning = false;
reconcileNodesPending = false;
serializeConn = (c: any) => ({
id: c.id,
source: c.source,
sourceOutput: c.sourceOutput,
target: c.target,
targetInput: c.targetInput
});
portSchemaForType = (type: any, portReg: any) => {
const inputs = [];
const outputs = [];
if (type == null || !portReg) return {
inputs,
outputs
};
const prefix = type + '::';
for (const k in portReg) {
if (k.indexOf(prefix) !== 0) continue;
const p = portReg[k];
if (!p || p.key == null) continue;
const entry = {
key: p.key,
label: p.label,
multiple: p.multiple,
portType: p.portType
};
if (p.side === 'input') inputs.push(entry);else outputs.push(entry);
}
return {
inputs,
outputs
};
};
buildNode = (spec: any, portReg: any) => {
const label = spec.data && spec.data.label != null ? String(spec.data.label) : '';
const node = new ClassicPreset.Node(label);
node.id = spec.id;
const {
inputs,
outputs
} = this.portSchemaForType(spec.type, portReg);
for (const inp of inputs as any) {
if (!inp || inp.key == null) continue;
node.addInput(inp.key, new ClassicPreset.Input(this.SOCKET, inp.label, inp.multiple === true));
}
for (const out of outputs as any) {
if (!out || out.key == null) continue;
node.addOutput(out.key, new ClassicPreset.Output(this.SOCKET, out.label, out.multiple !== false));
}
return node;
};
getEditor() {
return this.editor;
}
getArea() {
return this.area;
}
async addNode(spec: any) {
if (!this.editor || !spec || spec.id == null) return null;
const node = this.buildNode(spec, this._portReg.value);
this.nodeInstances.set(spec.id, node);
this.nodeMeta.set(spec.id, spec);
this.programmatic++;
try {
await this.editor.addNode(node);
await this.area.translate(spec.id, {
x: spec.x || 0,
y: spec.y || 0
});
} finally {
this.programmatic--;
}
return spec.id;
}
async removeNode(id: any) {
if (!this.editor || id == null || !this.nodeInstances.has(id)) return false;
this.programmatic++;
try {
for (const c of this.editor.getConnections() as any) {
if (c.source === id || c.target === id) await this.editor.removeConnection(c.id);
}
await this.editor.removeNode(id);
} finally {
this.programmatic--;
}
this.nodeInstances.delete(id);
this.nodeMeta.delete(id);
return true;
}
async addConnection(spec: any) {
if (!this.editor || !spec || spec.source == null || spec.target == null) return null;
const srcOut = spec.sourceOutput != null ? spec.sourceOutput : 'out';
const tgtIn = spec.targetInput != null ? spec.targetInput : 'in';
const sourceNode = this.nodeInstances.get(spec.source);
const targetNode = this.nodeInstances.get(spec.target);
if (!sourceNode || !targetNode) return null;
const conn = new ClassicPreset.Connection(sourceNode, srcOut, targetNode, tgtIn);
if (spec.id != null) conn.id = spec.id;
this.programmatic++;
try {
await this.editor.addConnection(conn);
} finally {
this.programmatic--;
}
this.connInstances.set(conn.id, conn);
return conn.id;
}
async removeConnection(id: any) {
if (!this.editor || id == null) return false;
this.programmatic++;
try {
await this.editor.removeConnection(id);
} finally {
this.programmatic--;
}
this.connInstances.delete(id);
return true;
}
async clear() {
if (!this.editor) return;
this.programmatic++;
try {
await this.editor.clear();
} finally {
this.programmatic--;
}
this.nodeInstances.clear();
this.nodeMeta.clear();
this.connInstances.clear();
this.connMeta.clear();
this.lastPropNodeIds = [];
this.lastPropConnIds = [];
}
async zoomToFit() {
if (!this.area || !this.editor) return;
this.programmatic++;
try {
await AreaExtensions.zoomAt(this.area, this.editor.getNodes());
} finally {
this.programmatic--;
}
const k = this.area.area.transform.k;
if (k !== this.zoom) this._zoomControllable.write(k);
}
async zoomTo(k: any) {
if (!this.area || typeof k !== 'number') return;
this.programmatic++;
try {
await this.area.area.zoom(k);
} finally {
this.programmatic--;
}
if (k !== this.zoom) this._zoomControllable.write(k);
}
async setViewport(vp: any) {
if (!this.area || !vp || typeof vp !== 'object') return;
const tf = this.area.area.transform;
const k = typeof vp.k === 'number' ? vp.k : tf.k;
const x = typeof vp.x === 'number' ? vp.x : tf.x;
const y = typeof vp.y === 'number' ? vp.y : tf.y;
this.programmatic++;
try {
if (k !== this.area.area.transform.k) await this.area.area.zoom(k);
await this.area.area.translate(x, y);
} finally {
this.programmatic--;
}
if (k !== this.zoom) this._zoomControllable.write(k);
}
async setCenter(x: any, y: any, opts: any) {
if (!this.area || typeof x !== 'number' || typeof y !== 'number') return;
const k = opts && typeof opts.zoom === 'number' ? opts.zoom : this.area.area.transform.k;
const el = this.area.container;
const cw = el && el.clientWidth ? el.clientWidth : 0;
const ch = el && el.clientHeight ? el.clientHeight : 0;
const tx = cw / 2 - x * k;
const ty = ch / 2 - y * k;
this.programmatic++;
try {
if (k !== this.area.area.transform.k) await this.area.area.zoom(k);
await this.area.area.translate(tx, ty);
} finally {
this.programmatic--;
}
if (k !== this.zoom) this._zoomControllable.write(k);
}
ZOOM_STEP = 1.2;
clampZoom = (k: any) => {
let lo = typeof this.minZoom === 'number' && this.minZoom > 0 ? this.minZoom : 0.01;
let hi = typeof this.maxZoom === 'number' && this.maxZoom > 0 ? this.maxZoom : 100;
if (k < lo) return lo;
if (k > hi) return hi;
return k;
};
controlZoomIn = () => {
if (!this.area) return;
this.zoomTo(this.clampZoom(this.area.area.transform.k * this.ZOOM_STEP));
};
controlZoomOut = () => {
if (!this.area) return;
this.zoomTo(this.clampZoom(this.area.area.transform.k / this.ZOOM_STEP));
};
controlFit = () => {
this.zoomToFit();
};
toggleMode = () => {
this._modeControllable.write(this.mode === 'select' ? 'pan' : 'select');
};
getNodes() {
if (!this.area) return [];
const out = [];
for (const [id, node] of this.nodeInstances as any) {
const view = this.area.nodeViews.get(id);
out.push({
id,
label: node.label,
x: view ? view.position.x : 0,
y: view ? view.position.y : 0
});
}
return out;
}
getConnections() {
return this.editor ? this.editor.getConnections().map(this.serializeConn) : [];
}
getTransform() {
return this.area ? {
x: this.area.area.transform.x,
y: this.area.area.transform.y,
k: this.area.area.transform.k
} : null;
}
screenToFlowPosition(clientX: any, clientY: any) {
if (!this.area || typeof clientX !== 'number' || typeof clientY !== 'number') return null;
const el = this.area.container;
const rect = el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null;
if (!rect) return null;
const t = this.area.area.transform;
const k = t.k || 1;
return {
x: (clientX - rect.left - t.x) / k,
y: (clientY - rect.top - t.y) / k
};
}
async autoArrange(opts: any) {
if (!this.arrange || !this.area) return;
// Set elkjs dimensions on every live node instance from its measured node-view element
// (Pitfall 3) — without dims the classic preset stacks all nodes at (0,0).
for (const [id, node] of this.nodeInstances as any) {
const view = this.area.nodeViews ? this.area.nodeViews.get(id) : null;
const el = view && view.element ? view.element : null;
node.width = el && el.offsetWidth ? el.offsetWidth : this.MINIMAP_DEFAULT_NODE_W;
node.height = el && el.offsetHeight ? el.offsetHeight : this.MINIMAP_DEFAULT_NODE_H;
}
// ONE history entry for the arrange gesture, captured BEFORE the write (pushHistory reads
// lastWrittenGraph, still the pre-arrange state). Gated on !programmatic + history.
this.pushHistory();
this.programmatic++;
try {
await this.arrange.layout(opts && opts.options ? {
options: opts.options
} : undefined);
} finally {
this.programmatic--;
}
// Read the arranged positions back into a FRESH graph object (controlled-graph contract).
// Echo-guarded: commitGraph → $model.graph re-bind must not re-enter the reconcile as a new
// gesture. (The arrange already moved the engine to these coords, so the reconcile is a
// no-op diff; the guard is belt-and-braces + suppresses any history re-entry.)
this.programmatic++;
try {
const g = this.baseGraph();
const nodes = (g.nodes || []).map((n: any) => {
const v = n && n.id != null && this.area.nodeViews ? this.area.nodeViews.get(n.id) : null;
return v && v.position ? {
...n,
x: v.position.x,
y: v.position.y
} : n;
});
this.commitGraph({
...g,
nodes
});
} finally {
this.programmatic--;
}
}
getSelectedNodes() {
const sel = new Set(this.selectedNodeIds().map((x: any) => String(x)));
return this.getNodes().filter((n: any) => sel.has(String(n.id)));
}
selectNode(id: any, accumulate: any) {
if (!this.nodeSelectApi || id == null) return;
this.nodeSelectApi.select(id, !!accumulate);
this.scheduleSelectionEmit();
}
clearSelection() {
if (this.nodeSelectApi) {
for (const id of this.selectedNodeIds() as any) this.nodeSelectApi.unselect(id);
}
this.clearEdgeSelection();
this.scheduleSelectionEmit();
}
selectAll() {
if (!this.nodeSelectApi) return;
let first = true;
for (const n of this.getNodes() as any) {
this.nodeSelectApi.select(n.id, !first);
first = false;
}
this.scheduleSelectionEmit();
}
async centerOnNode(id: any, opts: any) {
if (!this.area || id == null) return;
const view = this.area.nodeViews ? this.area.nodeViews.get(id) : null;
if (!view || !view.position) return;
const el = view.element;
const w = el && el.offsetWidth ? el.offsetWidth : this.MINIMAP_DEFAULT_NODE_W;
const h = el && el.offsetHeight ? el.offsetHeight : this.MINIMAP_DEFAULT_NODE_H;
await this.setCenter(view.position.x + w / 2, view.position.y + h / 2, opts);
}
get graph(): any { return this._graphControllable.read(); }
set graph(v: any) { this._graphControllable.notifyPropertyWrite(v); }
get zoom(): number { return this._zoomControllable.read(); }
set zoom(v: number) { this._zoomControllable.notifyPropertyWrite(v); }
get mode(): string { return this._modeControllable.read(); }
set mode(v: string) { this._modeControllable.notifyPropertyWrite(v); }
}
injectGlobalStyles('rozie-flow-canvas-global', `
.rozie-flow-canvas .rozie-flow-node {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
min-width: 140px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
user-select: none;
cursor: grab;
font: 13px/1.4 system-ui, sans-serif;
}
.rozie-flow-canvas .rozie-flow-node.is-selected {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5), 0 2px 8px rgba(0, 0, 0, 0.15);
}
.rozie-flow-canvas .rozie-flow-node__title {
padding: 0.5rem 0.75rem;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
}
.rozie-flow-canvas .rozie-flow-node__body { min-width: 0; }
.rozie-flow-canvas .rozie-flow-node__col {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 0;
}
.rozie-flow-canvas .rozie-flow-port {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: #6b7280;
}
.rozie-flow-canvas .rozie-flow-port--output { justify-content: flex-end; }
.rozie-flow-canvas .rozie-flow-socket {
width: 12px;
height: 12px;
border-radius: 50%;
background: #94a3b8;
border: 2px solid #ffffff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
cursor: crosshair;
flex: none;
}
.rozie-flow-canvas .rozie-flow-socket--input { margin-left: -6px; }
.rozie-flow-canvas .rozie-flow-socket--output { margin-right: -6px; }
.rozie-flow-canvas .rozie-flow-socket:hover { background: #3b82f6; }
.rozie-flow-canvas .rozie-flow-node--rows {
display: flex;
flex-direction: column;
}
.rozie-flow-canvas .rozie-flow-node__mid {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
}
.rozie-flow-canvas .rozie-flow-node__row {
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
padding: 0 0.5rem;
}
.rozie-flow-canvas .rozie-flow-port--vertical {
flex-direction: column;
align-items: center;
gap: 0.125rem;
font-size: 0.7rem;
}
.rozie-flow-canvas .rozie-flow-socket--top,
.rozie-flow-canvas .rozie-flow-socket--bottom { margin-left: 0; margin-right: 0; }
.rozie-flow-canvas .rozie-flow-socket--top { margin-top: -6px; }
.rozie-flow-canvas .rozie-flow-socket--bottom { margin-bottom: -6px; }
.rozie-flow-canvas .rozie-flow-connection { position: absolute; }
.rozie-flow-canvas .rozie-flow-connection__svg {
/* display:block is LOAD-BEARING, not cosmetic. An <svg> is display:inline by
default, so the 1px-tall connection SVG sits on the connection element's TEXT
BASELINE — which, with the engine container's default line-height, pushes the
whole path DOWN ~14px. That offset is in screen space (the connection element
is the area-transform origin), so EVERY connection endpoint lands ~14px below
its socket — visibly anchoring connectors at the BOTTOM of each node instead
of on the socket. The socket positions reported by getDOMSocketPosition are
already correct (offsetTop/offsetLeft within the node-view); the inline
baseline is the sole cause of the vertical drift. block (or equivalently
line-height:0 / vertical-align:top on the inline box) removes the baseline gap
so the path renders at its true coordinates. Verified: drops the endpoint→
socket vertical offset from ~13.9px to ~0.1px on all 6 targets. */
display: block;
overflow: visible;
width: 1px;
height: 1px;
pointer-events: none;
}
.rozie-flow-canvas .rozie-flow-connection__path {
fill: none;
stroke: #64748b;
stroke-width: 3px;
pointer-events: auto;
}
.rozie-flow-canvas .rozie-flow-connection__path.is-selected {
stroke: #3b82f6;
stroke-width: 4px;
}
.rozie-flow-canvas .rozie-flow-connection__label {
font: 600 11px system-ui, sans-serif;
fill: #334155;
paint-order: stroke;
stroke: #ffffff;
stroke-width: 3px;
stroke-linejoin: round;
pointer-events: none;
user-select: none;
}
`);Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component with model() signals, a Solid component, and a Lit custom element. Same props, same events, same imperative handle, same render-by-type body portal, all from the one source above — and the engine (graph model, pan/zoom/drag, drag-to-connect) is Rete.js on every target.
The companion <NodeType> and <Port> type-template tags compile the same way:
vue
<template>
<div class="rozie-node-type-children" style="display:none"><slot></slot></div>
</template>
<script setup lang="ts">
import { Fragment, h, inject, onBeforeUnmount, onMounted, onUpdated, provide, render, useSlots, watch } from 'vue';
const props = defineProps<{
/**
* The node TYPE id (required). Every graph node whose `type` matches renders this template and uses this type's `<Port>` schema. There is no id/x/y here — this is a render-by-type TEMPLATE, not an instance; instance identity and position live in the bound `graph` model.
* @example
* <NodeType type="source"><template #body="{ node }">{{ node.data.label }}</template></NodeType>
*/
type: string;
}>();
defineSlots<{
body(props: { node: any; selected: any; emit: any }): any;
default(props: { }): any;
}>();
const slots = useSlots();
const canvas = inject('rete:canvas');
// $inject is typed `unknown` (Phase 36 D-4: no rich type synthesis yet), which the
// STRICT BUNDLED-LEAF tsc rejects on `.registerType(...)` (TS2339). The .rozie-native
// fix is the null-let → `any` typeNeutralize idiom: alias the injected API through
// a MODULE-SCOPE `let cv = null` (typeNeutralize types it `any`). Module-scope (not
// hook-local) so the alias is in scope from the Solid teardown — which the Solid
// emitter hoists into a sibling onCleanup() OUTSIDE the mount closure (the MapLibre
// Source/Layer lesson). ZERO emitter change.
let cv: any = null;
cv = canvas;
// The live $portals.body handle ({ dispose }) returned by the parent-invoked
// bodyRenderer callback. Module-scope `any` so the teardown — which the Solid
// emitter hoists into a sibling onCleanup() OUTSIDE the mount closure — can dispose
// it. (A NodeType type-template projects ONE body root per graph node; the canvas
// disposes per-node on node unmount, this is the last-projection handle.)
//
// PER-NODE FIX: a Set of INDEPENDENT handles — ONE PER GRAPH NODE of this type.
// render-by-type calls bodyRenderer once per node a->b->c; the old single-handle
// form disposed the PRIOR node's body on each call, leaving only the LAST node of
// the type rendered (3 nodes, 1 body — the count-only-VR-masking bug). Each call now
// mounts an INDEPENDENT handle and disposes NONE of its siblings; the canvas already
// owns per-node disposal (entry.bodyHandle in nodeEntries, torn down on node unmount).
// Module-scope `any` so the Solid-hoisted teardown can sweep any leftovers. This is
// the controlled-graph analog of FlowCanvas's per-node $portals.node handle map.
// The live $portals.body handle ({ dispose }) returned by the parent-invoked
// bodyRenderer callback. Module-scope `any` so the teardown — which the Solid
// emitter hoists into a sibling onCleanup() OUTSIDE the mount closure — can dispose
// it. (A NodeType type-template projects ONE body root per graph node; the canvas
// disposes per-node on node unmount, this is the last-projection handle.)
//
// PER-NODE FIX: a Set of INDEPENDENT handles — ONE PER GRAPH NODE of this type.
// render-by-type calls bodyRenderer once per node a->b->c; the old single-handle
// form disposed the PRIOR node's body on each call, leaving only the LAST node of
// the type rendered (3 nodes, 1 body — the count-only-VR-masking bug). Each call now
// mounts an INDEPENDENT handle and disposes NONE of its siblings; the canvas already
// owns per-node disposal (entry.bodyHandle in nodeEntries, torn down on node unmount).
// Module-scope `any` so the Solid-hoisted teardown can sweep any leftovers. This is
// the controlled-graph analog of FlowCanvas's per-node $portals.node handle map.
let bodyHandles: any = null;
bodyHandles = new Set();
// The body-mount closure, DEFINED INSIDE $onMount (below) so it captures the
// emitter-synthesized `portals` local — which on React/Angular/Lit is scoped to the
// mount effect body, NOT visible from a spec callback the canvas invokes later (that
// escaped scope is exactly why a bare `$portals.body(...)` in the bodyRenderer
// threw "portals is not defined" on those 3 targets). Stored in a module-scope `any`
// so the spec's bodyRenderer — invoked by the canvas's renderNode from its own
// render scope — can delegate to it. ZERO emitter change (just correct scoping).
// The body-mount closure, DEFINED INSIDE $onMount (below) so it captures the
// emitter-synthesized `portals` local — which on React/Angular/Lit is scoped to the
// mount effect body, NOT visible from a spec callback the canvas invokes later (that
// escaped scope is exactly why a bare `$portals.body(...)` in the bodyRenderer
// threw "portals is not defined" on those 3 targets). Stored in a module-scope `any`
// so the spec's bodyRenderer — invoked by the canvas's renderNode from its own
// render scope — can delegate to it. ZERO emitter change (just correct scoping).
let mountBody: any = null;
// idempotency flag so a reactive late-context registration (Lit async first
// paint, REQ-30) and the $onMount registration never double-register the type.
// idempotency flag so a reactive late-context registration (Lit async first
// paint, REQ-30) and the $onMount registration never double-register the type.
let registered = false;
// the canvas TYPE spec builder — shared by the $onMount register and the late-context
// $onUpdate below. The bodyRenderer render-callback is invoked by the canvas's
// renderNode (per graph node of this type) from the canvas's own render scope with
// the engine `body` host div + the { node, selected, emit } scope; the NodeType then
// mounts its OWN `body` portal slot INTO that host via $portals.body — reusing the
// shipped reactive-portal machinery (6/6 green on the config-array `node` path). NO
// framework DOM is relocated. Returns { dispose } so the canvas can tear the body
// projection down on node unmount / port-resync.
// the canvas TYPE spec builder — shared by the $onMount register and the late-context
// $onUpdate below. The bodyRenderer render-callback is invoked by the canvas's
// renderNode (per graph node of this type) from the canvas's own render scope with
// the engine `body` host div + the { node, selected, emit } scope; the NodeType then
// mounts its OWN `body` portal slot INTO that host via $portals.body — reusing the
// shipped reactive-portal machinery (6/6 green on the config-array `node` path). NO
// framework DOM is relocated. Returns { dispose } so the canvas can tear the body
// projection down on node unmount / port-resync.
const buildSpec = () => ({
type: props.type,
// RENDER-BY-TYPE callback: the canvas hands the engine body host + scope; delegate
// to the mountBody closure (defined inside $onMount so it can see the emitter's
// mount-scoped `portals` local). Until $onMount has run, mountBody is null — but
// the canvas only invokes bodyRenderer AFTER reconcileNodes (post-register,
// post-mount), so mountBody is always set by then. Returns the { dispose } handle.
bodyRenderer: (host: any, scope: any) => {
// try/catch so a per-target portal-render hiccup (e.g. a Lit lit-html "cannot
// find node" when re-rendering into an engine-owned host the area re-created)
// can NEVER abort the canvas's renderNode loop — a thrown bodyRenderer would
// propagate out of area.update/addNode and stop the whole graph from building.
if (host && mountBody) {
try {
return mountBody(host, scope);
} catch (e: any) {}
}
return null;
}
});
provide('rete:nodeType', {
get type() {
return props.type;
},
addPort: (side: any, key: any, portType: any, label: any, multiple: any, position: any) => {
if (cv) cv.addTypePort(props.type, side, key, portType, label, multiple, position);
}
});
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portalContainers = new Set<HTMLElement>();
const portals = {
body: (container: HTMLElement, scope: { node: unknown; selected: unknown; emit: unknown }): ReactivePortalHandle => {
const slotFn = slots.body;
if (!slotFn) return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection. Cascades the @portal
// body { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-body', '372f9492');
const renderScope = (s: unknown): void => {
render(h(Fragment, null, slotFn(s)), container);
};
renderScope(scope);
portalContainers.add(container);
return {
update: (s: unknown): void => renderScope(s),
dispose: (): void => {
render(null, container);
portalContainers.delete(container);
},
};
},
};
onBeforeUnmount(() => {
for (const container of portalContainers) render(null, container);
portalContainers.clear();
});
let _cleanup_0: (() => void) | undefined;
onMounted(() => {
// The body-mount closure — captures the mount-scoped `portals` local. Mounts an
// INDEPENDENT body root PER graph node (the canvas calls this once per node of the
// type), so every instance keeps its OWN #body — it must NOT dispose any sibling's
// handle (the bug: a single shared handle torn down on each call left only the LAST
// node rendered). The returned { dispose } is wrapped to deregister ITSELF from the
// live set when the canvas disposes that node's projection (entry.bodyHandle on node
// unmount / port-resync); a leftover handle is swept by the component teardown below.
mountBody = (host: any, scope: any) => {
if (!host) return null;
const s = scope || {};
const h = portals.body(host, {
node: s.node,
selected: s.selected,
emit: s.emit
});
if (!h) return null;
bodyHandles.add(h);
return {
update: (next: any) => {
if (h && h.update) {
try {
return h.update(next);
} catch (e: any) {}
}
},
dispose: () => {
bodyHandles.delete(h);
if (h && h.dispose) {
try {
h.dispose();
} catch (e: any) {}
}
}
};
};
// register this TYPE's spec INCLUDING the bodyRenderer callback. The canvas's
// renderNode resolves typeReg[node.type].bodyRenderer for every graph node of this
// type and projects the body into the engine host. On Lit the injected canvas may
// still be undefined here (REQ-30 async context); the $onUpdate below performs the
// registration once the value arrives.
if (cv && !registered) {
registered = true;
cv.registerType(props.type, buildSpec());
}
_cleanup_0 = () => {
// sweep any body projections still live at teardown (the canvas normally disposes
// each per node unmount, but a component-level unmount must clean any stragglers).
if (bodyHandles) {
for (const h of bodyHandles as any) {
if (h && h.dispose) {
try {
h.dispose();
} catch (e: any) {}
}
}
bodyHandles.clear();
}
if (cv) cv.unregisterType(props.type);
};
});
onBeforeUnmount(() => { _cleanup_0?.(); });
onUpdated(() => {
if (registered) return;
const live = canvas;
if (live == null) return;
cv = live;
registered = true;
cv.registerType(props.type, buildSpec());
});
watch(() => props.type, () => {
if (cv) cv.registerType(props.type, buildSpec());
});
</script>vue
<template>
<!-- empty template -->
</template>
<script setup lang="ts">
import { inject, onMounted, onUpdated } from 'vue';
const props = withDefaults(
defineProps<{
/**
* Declares an OUTPUT port and names its key — set this (not `input`) so the port direction resolves to `output`. The attribute is `output`, not `out`: `out`/`in` are awkward bare identifiers, so `output`/`input` are used across all six targets.
* @example
* <Port output="num" type="number" />
*/
output?: string;
/**
* Declares an INPUT port and names its key — set this (not `output`) so the port direction resolves to `input`. The attribute is `input`, not `in`: `in` is a JS reserved word that Svelte's mandatory `$props()` destructure rejects, so `input`/`output` are used instead.
*/
input?: string;
/**
* The port TYPE — drives the canvas's typed-socket `:validate-types` (a type-mismatched connection is auto-rejected). It is the typed layer, NOT socket identity (a single shared Socket gates identity). Optional: an untyped port imposes no type constraint and connects to anything.
*/
type?: string;
/**
* Optional socket label shown next to the port (defaults to the port key when omitted).
*/
label?: string;
/**
* Allow multiple connections into/out of this socket. Left undefined by default to preserve the canvas's side asymmetry: outputs default to multi, inputs default to single. To force an explicit multi input, BIND it (`:multiple="true"`) rather than using the bare boolean attribute (a bare `multiple` lands as undefined on Lit).
*/
multiple?: unknown;
/**
* Visual placement of the socket on the node: `left`, `right`, `top`, or `bottom`. Defaults by direction (input → left, output → right). `top`/`bottom` enable vertical flows (decision trees, top-down pipelines) — the canvas lays the socket out on that edge and the connection anchor shifts onto the matching axis.
*/
position?: string;
}>(),
{ output: undefined, input: undefined, type: undefined, label: undefined, multiple: undefined, position: undefined }
);
const injectedType = inject('rete:nodeType');
// $inject is typed `unknown` (Phase 36 D-4), which the STRICT BUNDLED-LEAF tsc
// rejects on `.addPort(...)` (TS2339). The .rozie-native fix is the null-let → `any`
// typeNeutralize idiom: alias through a MODULE-SCOPE `let nt = null` so it is in
// scope from the Solid hoisted onCleanup teardown (the MapLibre Source/Layer
// lesson). ZERO emitter change.
let nt: any = null;
nt = injectedType;
// Derive side + key from which of output=/input= is set. output wins if both are
// (mis)set. `output`/`input` are ordinary identifiers (NOT reserved words) so they
// read normally — no member-access-only workaround needed. null key (neither set) ⇒
// addPort no-ops on the canvas side (key == null guard).
// Derive side + key from which of output=/input= is set. output wins if both are
// (mis)set. `output`/`input` are ordinary identifiers (NOT reserved words) so they
// read normally — no member-access-only workaround needed. null key (neither set) ⇒
// addPort no-ops on the canvas side (key == null guard).
const portSide = () => props.output != null ? 'output' : 'input';
const portKey = () => props.output != null ? props.output : props.input;
// idempotency flag so the $onMount addPort and the late-context $onUpdate path
// (Lit async, REQ-30) never double-add the port. (addTypePort is also idempotent —
// same `type::side::key` key, same value — so this is belt-and-suspenders.)
// idempotency flag so the $onMount addPort and the late-context $onUpdate path
// (Lit async, REQ-30) never double-add the port. (addTypePort is also idempotent —
// same `type::side::key` key, same value — so this is belt-and-suspenders.)
let added = false;
onMounted(() => {
// register this typed port against the enclosing node TYPE's schema; the canvas's
// reconcileNodes builds buildNode with the updated input/output spec for every node
// of that type. On Lit the injected nodeType ctx may still be undefined here (async
// context, REQ-30) — the $onUpdate below adds the port once it resolves.
if (nt && !added) {
added = true;
nt.addPort(portSide(), portKey(), props.type, props.label, props.multiple, props.position);
}
});
onUpdated(() => {
if (added) return;
const live = injectedType;
if (live == null) return;
nt = live;
added = true;
nt.addPort(portSide(), portKey(), props.type, props.label, props.multiple, props.position);
});
</script>Typed-socket connection validation
The canvas knows each <NodeType>'s port types (from the nested <Port type> schema), so it can refuse a type-mismatched connection automatically — a number output should not feed a string input. That is the validateTypes prop (:validate-types, default ON): before any edge is committed the canvas resolves each endpoint's port type from the type templates and rejects a mismatch — no path is drawn, no connection-created fires — and a connection-rejected event reports the attempt so you can surface it. (See the validateTypes prop and the connection-rejected event rows in the API reference.)
For rules the type system can't express, canConnect is the optional custom-rule override — a predicate (conn) => boolean that runs in addition to the automatic typed check. Return false and the edge is rejected just the same (connection-rejected fires). It gates all connection paths uniformly — drag-to-connect, the imperative addConnection() handle, and the graph.connections reconcile — so one predicate enforces your rules everywhere. Set :validate-types="false" to disable the automatic check and treat type as metadata only (pure-canConnect).
The typed data-pipeline demo
examples/demos/FlowCanvasAdvancedDemo.rozie is a small typed data pipeline that puts the feature through its paces. Five nodes across four types — a source (BOTH a number AND a string OUTPUT port, Dan's multi-port ask), a numTx (number → number), a strTx (number → string), and a merge (BOTH a number AND a string INPUT port, both multiple). The port type lives on each <Port type> declaration, so the canvas validates connections itself — no per-edge predicate needed for the common case. Typed ports render with color: number ports blue (#3b82f6), string ports green (#10b981). The whole consumer is one r-model:graph object + a handful of type templates:
html
<FlowCanvas r-model:graph="$data.graph" :validate-types="true"
:can-connect="canConnect" @connection-rejected="onReject">
<!-- source: ONE type, BOTH a number AND a string OUTPUT port -->
<NodeType type="source">
<template #body="{ node }">{{ node.data.label }}</template>
<Port output="num" type="number" label="number" />
<Port output="str" type="string" label="string" />
</NodeType>
<!-- merge: ONE type, BOTH a number AND a string INPUT port (both multiple) -->
<NodeType type="merge">
<template #body="{ node }">Merge</template>
<Port input="num" type="number" label="number" multiple />
<Port input="str" type="string" label="string" multiple />
</NodeType>
</FlowCanvas>Drag a number output onto a string input and the connection is rejected automatically by :validate-types (the canvas knows the port types): no edge appears, the accepted-count stays put, and a live readout shows the attempted endpoints. Drag a number output onto a number input and the edge commits — the canvas writes it back into $data.graph.connections (the connection-count readout reflects it) and @connection-created fires.
The demo also layers a tiny canConnect override on top of the automatic check — a self-loop rule (c.source !== c.target) — proving a consumer rule runs in addition to the typed validation. It is a pure predicate: it must NOT mutate $data or call any engine method (it runs synchronously inside Rete's connectioncreate signal chain, where a write risks engine re-entrancy). The rejected-types readout is written only in the @connection-rejected handler — never inside canConnect:
js
// PURE override — the optional custom rule, layered on the automatic typed check.
const canConnect = (c) => c.source !== c.target;
// SOLE writer of the rejected readout — the @connection-rejected handler.
const onReject = (c) => {
const from = (c.source ?? '?') + ':' + (c.sourceOutput ?? '?');
const to = (c.target ?? '?') + ':' + (c.targetInput ?? '?');
$data.lastRejected = from + ' → ' + to;
};Try it in the playground. The demo is registered as
bundle/FlowCanvasAdvancedDemoin the Rozie playground (pnpm --filter rozie-playground dev) — open the Compare all targets grid to see the same controlled-graph typed pipeline (and its port colors) rendered by all six frameworks side by side.
Passing a function prop across all six targets
canConnect is a function-typed prop — a slightly different pattern from the data-and-events props elsewhere in @rozie-ui. It binds idiomatically on every target; the only one that needs care is Lit, where a function must go through a property binding (a function cannot be serialized to an HTML attribute):
tsx
<FlowCanvas graph={graph} onGraphChange={setGraph} canConnect={canConnect} onConnectionRejected={onReject} />vue
<FlowCanvas v-model:graph="graph" :can-connect="canConnect" @connection-rejected="onReject" />svelte
<FlowCanvas bind:graph canConnect={canConnect} on:connection-rejected={onReject} />html
<rozie-flow-canvas [(graph)]="graph" [canConnect]="canConnect" (connectionRejected)="onReject($event)" />tsx
<FlowCanvas graph={graph} onGraphChange={setGraph} canConnect={canConnect} onConnectionRejected={onReject} />ts
// Function props MUST use a PROPERTY binding (the leading dot) — Lit cannot
// serialize a function to an attribute. The generated element declares the
// prop attribute:false so it is never reflected.
html`<rozie-flow-canvas .graph=${graph} .canConnect=${canConnect}
@connection-rejected=${onReject}></rozie-flow-canvas>`Port colors are styled through the :root {} engine-DOM escape hatch (the node chrome teleports into the engine-created node element, which on Lit lives inside the custom element's shadow root — a plain scoped rule never reaches it). That is the same escape hatch the styling note describes for engine-rendered DOM.
Per-node actions
Each <NodeType>'s #body template carries a ✕ remove button. The demo drives it with a top-level @pointerup handler that reads the node id off a :data-id attribute bind (e.target.closest('[data-id]')) and filters it out of $data.graph.nodes into a fresh graph object assigned back to $data.graph (controlled-model remove — the canvas reconciles the engine node away) — a pattern that works on all six targets including Solid. (@pointerup rather than @click because Rete starts a node-drag on pointer-down and the browser never synthesizes a click from the drag gesture; and @pointerup over @pointerdown because Rete's drag handler stopPropagations pointerdown at the node element, which on Solid's delegated handler blocks it.) Separately, FlowCanvas also offers a built-in node-action emit: a #body fill can call its emit(name, detail) helper to raise @node-action with the node id, which is the idiomatic route for in-node buttons that want a typed event round-trip. This demo's ✕ deliberately takes the DOM-attribute route instead (the slot-scope emit/node are not accessor-rewritten inside an @event body on Solid — the foreign-slot accessor limitation), but node-action remains a first-class capability for consumers who want it.
See also
- FlowCanvas — showcase & API — install, quick starts for all six frameworks, the controlled
r-model:graphbinding, the<NodeType>/<Port>type templates, the events, the two-way zoom binding, and the imperative handle. - Node-flow editor libraries comparison — how
@rozie-ui/retestacks up against React Flow / Vue Flow / Svelte Flow / Foblex (and the Solid / Lit gap it closes).