Skip to content

FlowCanvas — the cross-framework node-flow editor

FlowCanvas is Rozie's data-bound port of Rete.js v2 — the framework-agnostic visual-programming engine whose core owns the graph model and all pointer interaction (pan, zoom, node drag, drag-to-connect). One .rozie source ships idiomatic React, Vue, Svelte, Angular, Solid, and Lit consumers from a single wrapper.

This fills a genuine cross-framework gap. No other node-flow editor ships all six idiomatically:

Rete.js ships render plugins for React/Vue/Angular/Svelte/Lit (five divergent codebases, no Solid). Rozie replaces all of them with one source and one vanilla render layer — and Solid (plus a far thinner Lit) gets a category-leading node editor for free.

The full source for FlowCanvas.rozie lives in the @rozie-ui/rete package.

The @rozie-ui/rete packages

FlowCanvas ships as six pre-compiled, per-framework packages generated from a single FlowCanvas.rozie source via the package's codegen.mjs doc-automation engine. Consumers install only the one for their framework — no Rozie toolchain, no build-time compile step:

PackageInstallREADME
@rozie-ui/rete-reactnpm i @rozie-ui/rete-reactreact/README
@rozie-ui/rete-vuenpm i @rozie-ui/rete-vuevue/README
@rozie-ui/rete-sveltenpm i @rozie-ui/rete-sveltesvelte/README
@rozie-ui/rete-angularnpm i @rozie-ui/rete-angularangular/README
@rozie-ui/rete-solidnpm i @rozie-ui/rete-solidsolid/README
@rozie-ui/rete-litnpm i @rozie-ui/rete-litlit/README

Each package carries the Rete engine peersrete, rete-area-plugin, rete-connection-plugin, and rete-render-utils (all ^2) — plus its framework peer (react + react-dom, vue, svelte, @angular/core + @angular/common, solid-js, or lit + @lit-labs/preact-signals + @preact/signals-core). Install the engine peers alongside the framework package:

bash
npm i @rozie-ui/rete-react rete rete-area-plugin rete-connection-plugin rete-render-utils

Rete ships no stylesheet — every node, socket, and connection is styled by the component itself (the scoped <style> plus the :root {} engine-DOM escape hatch that reaches the engine-created node/connection DOM). There is no engine CSS to import.

Authoring model

FlowCanvas follows the controlled-graph mental model (the xyflow nodeTypes + controlled-state shape, Vue-natural): the consumer binds one graph object and declares node TYPE templates. The canvas is the middleware — it renders each node by its type, owns drag / zoom / connect / validation, and writes back layout (x/y on drag) and connections (on connect / disconnect) into the bound r-model object so the developer never hand-reconciles.

html
<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.

Node TYPE templates

  • <NodeType type="…"> — declares a node TYPE once: its visible body (a named #body slot, scoped { node, selected, emit }) plus its port schema (nested <Port> children). Every graph node whose type matches renders this template (render-by-type) and uses its ports. A <NodeType> carries no id/x/y — instance identity and position live in the bound graph, not on the tag.
  • <Port output="KEY" type="T" [multiple] [position]> / <Port input="KEY" type="T" [multiple] [position]> — declares one typed directional port on its enclosing <NodeType>. The direction is derived from which attribute is set (output ⇒ output port, input ⇒ input port), the key is its value, and type drives :validate-types (a type-mismatched connection is auto-rejected). Optional label / multiple. position="left|right|top|bottom" places the socket on that edge (default input → left, output → right); top/bottom enable vertical flows (decision trees, top-down pipelines) — the connection anchor tracks the chosen edge. Nests inside its <NodeType> and auto-binds via injected context (no type to wire by hand). (The attrs are input/output, not in/outin is a JS reserved word that Svelte's $props() destructure rejects.)

Why the node body is a named #body slot, not bare children. A node body has to teleport into the node element the Rete engine creates — it does not render in the normal component tree. Rozie mounts it through a portal, which gives it a fresh render-root inside the engine-owned host. A portal render-root has no tree ancestor, so context-consuming children placed inside it would not resolve their $inject on five of six targets (context is tree-scoped on React/Vue/Svelte/Solid/Lit). Separating the teleported body (<template #body>) from the context-consuming <Port> children (which stay in the normal child position) is therefore the correct cross-framework shape — so the body must be the #body slot, not a bare default-slot child.

The authoring shape dogfoods Rozie's own cross-component context primitive ($provide / $inject): <FlowCanvas> provides a per-TYPE registry, <NodeType> provides a nested per-type sub-context, and <Port> injects it.

Quick start

The zoom level is two-way bound (bind with r-model / v-model / bind: / [(…)] / onZoomChange). Note there is deliberately no zoom event — a same-named emit would collide with the model on Vue and Angular; the two-way binding carries the value, and @translated reports panning.

React

tsx
import { useState } from 'react';
import { FlowCanvas, NodeType, Port } from '@rozie-ui/rete-react';

export function Demo() {
  const [graph, setGraph] = useState({
    nodes: [
      { id: 'a', type: 'source', x: 0, y: 0, data: { label: 'Source' } },
      { id: 'b', type: 'merge', x: 280, y: 60, data: { label: 'Merge' } },
    ],
    connections: [{ source: 'a', sourceOutput: 'num', target: 'b', targetInput: 'num' }],
  });
  return (
    <div style={{ height: 400 }}>
      <FlowCanvas graph={graph} onGraphChange={setGraph} validateTypes>
        <NodeType type="source">
          {({ node }) => <div>{node.data.label}</div>}
          <Port output="num" type="number" />
        </NodeType>
        <NodeType type="merge">
          {({ node }) => <div>{node.data.label}</div>}
          <Port input="num" type="number" multiple />
        </NodeType>
      </FlowCanvas>
    </div>
  );
}

Vue

vue
<script setup lang="ts">
import { ref } from 'vue';
import FlowCanvas, { NodeType, Port } from '@rozie-ui/rete-vue';

const graph = ref({
  nodes: [
    { id: 'a', type: 'source', x: 0, y: 0, data: { label: 'Source' } },
    { id: 'b', type: 'merge', x: 280, y: 60, data: { label: 'Merge' } },
  ],
  connections: [{ source: 'a', sourceOutput: 'num', target: 'b', targetInput: 'num' }],
});
</script>

<template>
  <div style="height: 400px">
    <FlowCanvas v-model:graph="graph" :validate-types="true" @connection-rejected="onReject">
      <NodeType type="source">
        <template #body="{ node }">{{ node.data.label }}</template>
        <Port output="num" type="number" />
      </NodeType>
      <NodeType type="merge">
        <template #body="{ node }">{{ node.data.label }}</template>
        <Port input="num" type="number" multiple />
      </NodeType>
    </FlowCanvas>
  </div>
</template>

Custom node bodies — the #body template

Each <NodeType>'s #body is a reactive portal template: one portal handle mounts per graph node of that type, re-rendered in place as the node's data or selection changes. The scope receives { node, selected, emit }node is the graph node (with its data), selected tracks engine selection, and emit(name, detail) raises a @node-action carrying the node id (e.g. a delete button inside a node). When a node's type has no template, it renders default chrome (a title bar) plus its sockets.

vue
<FlowCanvas v-model:graph="graph">
  <NodeType type="card">
    <template #body="{ node, selected }">
      <MyNodeCard :title="node.data.label" :payload="node.data" :active="selected" />
    </template>
    <Port output="out" type="any" />
  </NodeType>
</FlowCanvas>

The sockets (connection anchors) come from each type's <Port> schema and are rendered by the engine layer — drag from an output socket to an input socket to connect.

Props

graph and zoom are two-way (bind with r-model / v-model / bind: / [(…)] / onGraphChange / onZoomChange). The single bound graph object is the source of truth; dragging a node writes its new x/y back into a fresh graph, and drawing / removing a connection writes a fresh connections array — reconciled into the live engine on change, no remount.

NameTypeDefaultTwo-way (model)Description
graphObject{…}The single source of truth — { nodes: [{ id, type, x, y, data? }], connections: [{ id?, source, sourceOutput?, target, targetInput?, label?, stroke?, dashed? }] }. type selects the node's <NodeType> template (render-by-type + its <Port> schema); data is the opaque payload handed to the type's #body scope. A connection may carry an optional label (rendered at the edge midpoint), stroke (CSS color), and dashed (Boolean) — per-edge label / styling for conditional & labeled edges (editing them on the bound graph re-renders the edge). Two-way: 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.
validateTypesBooleantrueAutomatic 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 (runs in addition). Set false for pure-canConnect (type as metadata only).
zoomNumber1The viewport zoom level. Two-way: 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.
pannableBooleantrueWhether the canvas can be panned (drag the background). Disabling detaches the area's drag handler.
zoomableBooleantrueWhether the canvas can be zoomed (scroll / pinch). Disabling detaches the area's zoom handler.
selectableBooleantrueWhether 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.
readonlyBooleanfalseRead-only viewer mode — no node drag, no connection editing, no selection.
minZoomNumber0.1Minimum zoom level (the lower bound of the area's zoom restrictor). 0 disables the bound.
maxZoomNumber4Maximum zoom level (the upper bound of the area's zoom restrictor). 0 disables the bound.
snapGridNumber0Snap-to-grid size in pixels for node dragging. 0 turns snapping off.
accumulateOnCtrlBooleantrueWhen selectable, hold Ctrl to add to the current selection instead of replacing it.
curvatureNumber0.3The bezier curvature of connection paths (classicConnectionPath).
fitOnMountBooleantrueAfter the initial graph mounts, pan/zoom the viewport to fit all nodes (AreaExtensions.zoomAt).
controlsBooleantrueRender the built-in Controls overlay — a zoom in / zoom out / fit-view button cluster over the canvas (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 (zoom/fit are view-only). Opt out with :controls="false".
minimapBooleanfalseRender the built-in MiniMap overlay — 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). Pannable: drag the minimap to recenter the main viewport (via setCenter). Opt-in (default OFF) — the React Flow <MiniMap/> parity. Evaluated at construction (like pannable / zoomable / controls); set it at mount time.
canConnectFunctionnullConnection-validation predicate (conn: { source, sourceOutput, target, targetInput }) => boolean. Return false to REJECT a 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). Gates ALL connection paths uniformly (drag-to-connect, imperative addConnection, graph reconcile). Absent / null imposes no custom rule.
historyBooleantrueUndo / 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() + Ctrl/Cmd+Z · Ctrl/Cmd+Shift+Z · 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; the verbs no-op).
modeString"pan"Two-way interaction mode — the Figma-style pan ↔ select toggle. 'pan' (default) PANS the viewport on an empty-canvas drag (UNCHANGED). 'select' draws a rubber-band marquee box on an empty-canvas drag that multi-selects the intersecting nodes (surfacing selection-change). A node drag still drags the node in BOTH modes. Bind with r-model:mode; the canvas writes it back when the built-in mode button (see marquee) toggles.
marqueeBooleanfalseRender the 4th Controls button — the pan ↔ select mode toggle (two-way-writes mode). Default OFF so the default Controls overlay keeps its 3 buttons (the FlowCanvasScreenshot pixel baseline is byte-identical). The marquee BEHAVIOR works whenever mode === 'select' regardless of this flag (a consumer can drive mode directly); this only governs the built-in button.
nodeToolbarBooleanfalseRender the opt-in NodeToolbar — 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 = 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'

Events

EventPayloadDescription
node-moved{ id, x, y }A node finished a user drag to a new position.
node-picked{ id }A node was picked (pointer-down).
selection-change{ ids }The set of selected node ids changed — fired on pick / re-pick / deselect (background click clears it). Deduped (only on an actual change) and echo-guarded against the wrapper's own programmatic unselects. The #1 hook for an inspector panel. Selection is surfaced purely via this event — it is not written into the bound graph.
edge-click{ id }A committed connection's path was clicked. Fired only when selectable && !readonly. The raw click intent — pair with edge-selected (which both fire on the same gesture).
edge-selected{ id }The selected edge changed to id (the edge analogue of selection-change). Edge selection is kept purely in the wrapper and surfaced via this event — not written into the bound graph. The hook for an edge inspector / "delete this edge" UI.
node-action{ id, name, detail }A <NodeType> #body fill called its emit(name, detail) helper (e.g. an in-node button), or a default NodeToolbar button fired (`name: 'delete'
connection-created{ id, source, sourceOutput, target, targetInput }A user drew a new connection (not fired for programmatic / props-driven adds).
connection-removed{ id }A connection was removed (not fired for programmatic / props-driven removes).
connection-rejected{ source, sourceOutput, target, targetInput }A connection was rejected by canConnect (no edge committed). Not fired for programmatic / props-driven adds.
connect-end{ source, sourceOutput, position }A connection drag started at an output socket and ended on empty canvas (no target socket, no edge created). position is { x, y } in graph coordinates. A pure signal — the canvas creates no node and shows no menu; the consumer owns what happens next (a "create node here" picker, a quick-add menu). The React Flow onConnectEnd parity.
translated{ x, y }The viewport was panned.
context-menu{ id }Right-click on the canvas (id is the node id, or null for the background). The native browser menu is suppressed.

Imperative handle

Beyond props, FlowCanvas exposes imperative methods via $expose. Grab a handle with your framework's native ref mechanism (useRef / template ref / bind:this / @ViewChild / Solid ref callback / the Lit element itself):

MethodDescription
getEditor()The underlying Rete NodeEditor (the graph-model escape hatch).
getArea()The underlying Rete AreaPlugin (viewport transform, node views).
addNode(spec)Imperatively add a node. NOT reaped by the graph reconcile.
removeNode(id)Remove a node and its connections directly on the engine — the imperative escape hatch, NOT written back to the bound graph. (Use deleteNode for the controlled-graph delete.)
deleteNode(id)Cascading controlled-graph delete: removes the node and its incident connections, writing a fresh graph object back through the two-way model (the $watch(graph) reconcile reaps the live engine node/edges). The blessed delete — matches the Delete / Backspace key. Returns whether a node was removed.
addConnection(spec)Imperatively add a connection. NOT reaped by the graph reconcile.
removeConnection(id)Remove a connection by id.
clear()Remove every node and connection.
zoomToFit()Pan/zoom to fit all nodes.
zoomTo(k)Set the zoom level (echoes into the zoom model).
setCenter(x, y, opts?)Center the viewport on graph coordinates (x, y); opts.zoom optionally sets the zoom. Echoes the level into the zoom model and fires translated. Powers the pannable MiniMap.
setViewport({ x, y, k })Set the raw viewport transform (any field omitted keeps its current value). Echoes k into the zoom model and fires translated.
screenToFlowPosition(clientX, clientY)Project a screen/client coordinate to graph coordinates { x, y } (or null before mount). The palette drag-drop primitive — on a canvas @drop, call it with the event's client coords and push a fresh node into the bound graph at the result. The consumer owns the drag/drop; the canvas owns the projection.
getNodes()Serialized snapshot [{ id, label, x, y }] with live positions.
getConnections()Serialized snapshot [{ id, source, sourceOutput, target, targetInput }].
getTransform()The viewport transform { x, y, k }.
undo()Undo the most recent graph edit (drag / connect / disconnect / delete), restoring the previous snapshot through the graph model (echo-guarded; graph-only, not the viewport). One gesture = one step. No-op when there's nothing to undo. Also Ctrl/Cmd+Z. Opt out with :history="false".
redo()Re-apply the edit most recently undone. A fresh edit after an undo discards the redo branch. No-op when there's nothing to redo. Also Ctrl/Cmd+Shift+Z and Ctrl/Cmd+Y.
canUndo()Whether there is an edit to undo → boolean.
canRedo()Whether there is an edit to redo → boolean.
autoArrange(opts?)Relayout the graph into a non-overlapping layered arrangement (elkjs-backed), then read the arranged node positions back through the two-way graph model (echo-guarded, one undoable gesture). Verb-only — never auto-triggered. await-able; opts.options forwards elk layout options (direction / spacing). No-op before mount.

The method is zoomTo, not setZoomzoom is a model prop, so React auto-generates a setZoom state setter that a setZoom verb would collide with (the same collision discipline as the rest of @rozie-ui).

Editing the graph

FlowCanvas is an editor, not just a viewer. Selection, deletion, undo/redo, edge styling, reconnection, marquee selection, a per-node toolbar, and auto-layout all ship in the box, and every edit flows through the same controlled-graph contract as drag and connect: the canvas writes a fresh graph object back through the two-way model, and the consumer never hand-reconciles. The full bundle is on by default behind the existing gates — :readonly="true" turns the whole canvas into a static viewer (no selection, no delete, no editing), and the individual opt-outs / opt-ins below let you trim it to taste.

Selecting and deleting edges

Clicking a committed connection's path selects it (the edge gets an .is-selected class you can style through the :root {} engine-DOM hatch) and fires @edge-click + @edge-selected with { id }. With an edge selected, Delete / Backspace removes it — written back through the bound graph as a fresh connections array. Node deletion takes precedence: if a node is selected, the key deletes the node (and its incident edges) first. Edge selection is gated selectable && !readonly and, like node selection, is surfaced purely via events — it is never written into graph.

html
<FlowCanvas r-model:graph="$data.graph" @edge-selected="onEdgeSelected" />

Edge types — step / smoothstep / straight

Each connection carries an optional type on the bound graph — 'bezier' (default), 'step', 'smoothstep', or 'straight' — selecting the path shape, matching React Flow's edge types. It is a per-edge property, so a single graph can mix orthogonal routing for some edges and curves for others; editing connection.type on the bound graph re-renders just that edge in place (the same restyle path as label / stroke / dashed). An unknown value falls back to the unchanged bezier.

js
$data.graph = {
  nodes: [/* … */],
  connections: [
    { source: 'a', sourceOutput: 'out', target: 'b', targetInput: 'in', type: 'smoothstep' },
  ],
}

Undo / redo

On by default (history prop). Every gesture — drag, connect, disconnect, delete, reconnect, auto-arrange — pushes one capped (~100) snapshot of the bound graph (nodes incl. x/y + connections; not the viewport). Ctrl/Cmd+Z undoes, Ctrl/Cmd+Shift+Z / Ctrl/Cmd+Y redo, and the undo() / redo() / canUndo() / canRedo() handle verbs drive the same stack from your own toolbar. One gesture = one step; a fresh edit after an undo discards the redo branch. Restores are echo-guarded through the graph model. Opt out with :history="false" (the stack stays empty; the verbs no-op).

Snapshotting note (cross-framework): the wrapper clones graph snapshots JSON-first — a bare structuredClone() throws on Vue's reactive() and Svelte's $state proxies, so it is never used on the bound graph. If you keep your own history outside the component, clone the same way.

Marquee selection and the pan ↔ select mode

The two-way mode prop is a Figma-style toggle. 'pan' (default) pans the viewport on an empty-canvas drag — unchanged. 'select' draws a rubber-band marquee box on an empty-canvas drag and multi-selects the intersecting nodes (surfacing @selection-change); a node drag still drags the node in both modes. Bind it with r-model:mode and drive it from your own UI, or set :marquee="true" to render a built-in 4th Controls button that toggles the mode for you. marquee defaults OFF so the default Controls overlay keeps its three buttons (the screenshot baseline is byte-identical); the marquee behavior works whenever mode === 'select', independent of the button.

html
<FlowCanvas r-model:graph="$data.graph" r-model:mode="$data.mode" :marquee="true" />

Reconnectable edges

Dragging an existing edge's endpoint onto a different compatible socket rewrites that connection rather than dropping it — the edge count is unchanged and it counts as one undoable gesture (the internal remove + add are coalesced into a single history snapshot). Reconnection is on whenever !readonly and honors the same :validate-types / canConnect rules as drawing a fresh edge.

Node toolbar

Set :node-toolbar="true" to float a small toolbar over the single selected node (positioned from the engine node-view rect + the area transform, re-tracked on pan / zoom / drag). The default content is Delete (cascading controlled-graph deleteNode) and Duplicate (clone the node spec at an offset with a new id into a fresh graph); both fire @node-action with name: 'delete' | 'duplicate'. Fill the #toolbar reactive slot (scope { node, emit }) to replace the buttons with your own. Default OFF, so existing canvases are pixel-identical — selecting a node pops nothing until you opt in.

html
<FlowCanvas r-model:graph="$data.graph" :node-toolbar="true">
  <template #toolbar="{ node, emit }">
    <button @click="emit('rename', { id: node.id })">Rename</button>
    <button @click="emit('delete')">✕</button>
  </template>
</FlowCanvas>

Auto-layout

The autoArrange(opts?) handle verb relayouts the whole graph into a non-overlapping layered arrangement (elkjs-backed) and writes the arranged positions back through the two-way graph model as one undoable gesture. It is verb-only and never auto-triggered — nothing reflows unless you call it (e.g. from a "Tidy up" button). It is await-able, and opts.options forwards elk layout options (direction, spacing). The three layout packages (rete-auto-arrange-plugin, elkjs, web-worker) are optional peers — only consumers who call autoArrange() need them installed.

js
await $refs.flow.autoArrange()
// or with options:
await $refs.flow.autoArrange({ options: { 'elk.direction': 'RIGHT' } })

Connect-end-on-pane (quick-add menus)

When a connection drag starts at an output socket and ends on empty canvas (no target socket), FlowCanvas fires @connect-end with { source, sourceOutput, position }position in graph coordinates. This is a pure signal, the React Flow onConnectEnd parity: the canvas creates no node and shows no menu. The consumer decides what happens — pop a node picker at position, quick-add a default node, or ignore the drop. Because position is already in graph space, a node you push into graph at that point lands exactly where the drag ended.

js
const onConnectEnd = ({ source, sourceOutput, position }) => {
  // open your own "create node" menu at `position`, then write the new node
  // (and an edge from source/sourceOutput) back into $data.graph.
}

Palette drag-drop (screenToFlowPosition)

Dropping a node from a sidebar palette onto the canvas — the bread-and-butter no-code-builder interaction — works like React Flow: you own the drag/drop, the canvas owns the projection. Grab the canvas handle, mark a palette item draggable, and on the canvas @drop translate the pointer to graph coordinates and append a node into the bound graph:

html
<!-- palette item -->
<div draggable="true">+ New node</div>

<!-- canvas wrapper owns dragover/drop -->
<div @dragover.prevent @drop.prevent="onDrop">
  <FlowCanvas ref="flow" r-model:graph="$data.graph">
    <NodeType type="task"><template #body="{ node }">{{ node.data.label }}</template></NodeType>
  </FlowCanvas>
</div>
js
const onDrop = (e) => {
  // name the local anything BUT `flow` — `const flow = $refs.flow` self-shadows the ref.
  const canvas = $refs.flow
  const pos = canvas?.screenToFlowPosition(e.clientX, e.clientY)
  if (!pos) return
  // controlled-graph write-back: a FRESH graph object (in-place mutation is dropped on 4/6).
  $data.graph = { ...$data.graph, nodes: [...$data.graph.nodes,
    { id: crypto.randomUUID(), type: 'task', x: pos.x, y: pos.y, data: { label: 'New' } }] }
}

screenToFlowPosition(clientX, clientY) inverts the viewport transform (pan + zoom), so a node placed at the result renders exactly under the drop point regardless of how the canvas is panned or zoomed.

Angular consumers: reach the handle with a native @ViewChild(FlowCanvas) query (this.flow.screenToFlowPosition(...)). Rozie's $refs to a child component resolves to the host element on Angular (a documented parity edge), so the in-template $refs.flow path above is for the other five targets.

Pre-v1.0 — internal monorepo.