fl_nodes_v2 1.2.2
fl_nodes_v2: ^1.2.2 copied to clipboard
A node graph editor for Flutter: pan, zoom, wire and run graphs, with ordinary Flutter widgets inside every node.
fl_nodes_v2 #
A node graph editor for Flutter. The package supplies the canvas — geometry, wiring, selection, navigation, undo, execution — and lets your app decide what a node looks like.
It is the spiritual successor to fl_nodes: the same ideas, rebuilt so that a node body is an ordinary widget. fl_nodes isolated rebuilds with a custom multi-child render object, which bought node count at the price of the node itself — a render object painting its own content cannot host a text field, a platform view, or anything needing the framework's own machinery. Here the isolation is done at the element level instead, so nodes stay widgets and text fields, sliders, dropdowns and forms all work inside one.
Everything around the nodes — connections, port handles, the grid, the selection overlays — is painted.
Try it in the browser —
the example below, built for the web from master on every push — or run it:
cd example && flutter run
Install #
dependencies:
fl_nodes_v2: ^1.2.0
import 'package:fl_nodes_v2/fl_nodes_v2.dart';
Quickstart #
Two nodes and a wire between them:
final controller = NodeEditorController(
graph: NodeGraph(
nodes: <GraphNode>[
GraphNode(
id: 'start',
type: 'scene',
position: const Offset(0, 0),
data: <String, Object?>{'title': 'Opening'},
ports: const <NodePort>[NodePort.output(id: 'next')],
),
GraphNode(
id: 'end',
type: 'scene',
position: const Offset(320, 40),
ports: const <NodePort>[NodePort.input(id: 'in')],
),
],
connections: const <NodeConnection>[
NodeConnection(
id: 'c1',
from: PortRef('start', 'next'),
to: PortRef('end', 'in'),
),
],
),
);
NodeEditor(
controller: controller,
theme: NodeEditorTheme.dark(),
nodeBuilder: (context, node, state) => MyCard(node: node, state: state),
);
nodeBuilder is the extension point. It receives the node and a
NodeRenderState (isSelected, isHovered, isDragging,
isConnectionTarget, and isWired(portId) for whether a port has a wire on
it, so a body can show an editor for a value only while nothing is supplying
it) and returns any widget.
One rule worth knowing up front. Do not wrap
NodeEditorin aListenableBuilderon its own controller. It already listens for itself, and rebuilding it hands it a freshnodeBuilderclosure every frame — which is the one input it cannot compare, so every node rebuilds. Wrap the parts that read controller state, not the editor.
Used in production #
fl_nodes_v2 is the canvas of Ripple Effect, a desktop application for writing interactive stories as graphs, built with Flutter and Rust for Linux, macOS and Windows. Every board an author writes is one editor over forty node definitions — passages, choices, conditions, loops, dice, Python scripts — and the app leans on most of what this README describes: custom node bodies, connection rules, undo and the clipboard, comments, groups, the minimap, snapping and localisation. Two more views are built on it read-only: an overview of how boards hand the story to each other, and a canvas that traces every route between two passages across boards. The node reference on its documentation site is generated from those definitions' descriptions.
Building an editor #
The pieces in the order a new editor meets them.
The model #
Four value types, all immutable:
NodeGraph |
nodes and connections, edited copy-on-write |
GraphNode |
id, type, position, width, optional height, ports, a free-form data map, and a metadata map beside it |
NodePort |
id, direction, kind, optional dataType, label, anchor, maxConnections |
NodeConnection |
a PortRef at each end, plus type, label, color, data, and the waypoints it is routed through |
type and data are yours. The package never interprets them; your
nodeBuilder switches on type and reads data. metadata is for what your
user attaches to a node — notes, tags — kept apart from data because a
definition shapes data and may prune a key it stopped declaring; the editor
never reads it, and controller.setNodeMetadata is the one way to write it.
The names are the file's. A node's type is the key a document stores and
the one a definition claims; a port's kind is data or control, and its
dataType is the tag wiring compares — three words, three different
questions, each spelled the way the JSON spells it, so a script or another
language reading a saved graph meets the same vocabulary as the Dart. The node
is a GraphNode rather than a Node because dart:html and package:web
already have one of those.
The controller #
NodeEditorController owns the graph and every edit to it — addNode,
updateNode, removeNodes, moveNodes, applyLayout, connect,
removeConnections, replaceGraph, nextId. Everything else is a named
subsystem:
controller.history |
undo, redo, canUndo, transactions |
controller.selection |
nodeIds, connectionIds, selectNodes, selectAll, deleteSelected |
controller.camera |
viewport, panBy, zoomBy, setScale, fitToContent, centerOn, centerOnNode |
controller.layout |
sizeOf, nodeAt, portAt, nodesIn, boundsOf, onMeasured |
controller.clipboard |
copy, cut, paste, duplicate |
controller.project |
the open document: save, load, open, reset, isDirty |
controller.runner |
run, cancel, stateOf, onEvent — idle until asked; see Execution |
controller.emphasis |
a focus: value, clear, revision |
There is no compatibility layer: controller.undo() does not exist, only
controller.history.undo().
Two hooks, both null by default, tell a host what the controller is doing.
guard is asked before every edit and abandons it by returning false —
synchronously, since the graph cannot sit half-changed while a dialog is open;
a host that needs to ask refuses, asks, and re-issues. onEdit is told after
every edit that landed, with a GraphEdit naming the kind and the ids it
touched. Undo and redo pass through neither: they restore a graph the hooks
already saw on the way in.
applyLayout is the other end of an arrangement the package does not ship:
hand it a GraphLayout and it places whatever the algorithm answers as one
edit and one undo step. It ignores draggable, on purpose — that flag is about
a pointer, and a read-only canvas is exactly where an automatic layout is
wanted. controller.layout.onMeasured fires once, when every node has a real
extent, which is where a layout that depends on them should run.
controller.emphasis lifts a set of nodes and connections clear of a scrim
washed over everything else — every route between two nodes, everything a
value reaches — without editing the graph to say so. It is not an edit, so it
is never guarded, never reported and never undone.
Node bodies #
Node bodies are ordinary widgets, so text fields, checkboxes, sliders, images,
scrollables and popup menus all work inside a node — the demo's Form node is
built from exactly those. Interactive children sit deeper in the tree than the
node's drag recogniser and win the gesture arena against it, and scroll events
go through GestureBinding.pointerSignalResolver, so a list inside a node
scrolls instead of zooming the canvas.
Two things when wiring a form into the graph:
- Canvas shortcuts are gated on focus. They fire only while the canvas
itself holds primary focus, never while something inside a node does — a text
field leaves
Backspaceunhandled once it has nothing left to delete, and that event would otherwise bubble up and delete the node being edited. - Wrap continuous edits in a transaction. Write on every keystroke so the
canvas stays in sync, but bracket the session with
beginTransaction()/commitTransaction()so it collapses into one undo step rather than one per character.example/lib/form_node_body.dartdoes this on focus change for the text field and ononChangeStart/onChangeEndfor the slider.
A body holding its own controllers should be a StatefulWidget and adopt model
changes in didUpdateWidget when it is not focused, so undo and redo are
reflected without stealing the caret.
Sizing #
Nodes declare a width. height is optional:
- Declared — the node is exactly that tall. Use this when port positions must line up with rows inside the card, since the row offsets are then known up front.
- Omitted — the node sizes to its content and the measured height is
reported back, so connection endpoints follow. Measurement costs one extra
frame;
controller.layout.hasUnmeasuredNodesreports when extents are provisional.
A definition with resizable: true gets a grip in the node's bottom-right
corner, bounded by minWidth, maxWidth and maxHeight; with snapping on it
is the dragged edge that is pulled onto a grid line, before those limits are
applied. The width dragged is
the node's own; the height is a floor under whatever the content needs, and
dragging back to the natural height clears it. The ports do not move: an anchor
is a fraction of the declared height, so a wire lands on the same row however
tall the card is made.
Ports #
Ports sit on a node edge, spread evenly along their side by default. anchor
pins one to an exact spot instead, normalised to the node's bounds — which is
how the demo gives each branch of a condition its own output:
NodePort.output(id: 'branch_0', anchor: Offset(1, rowCentre / cardHeight))
Handles are painted, not built, and NodeEditorLayout.portAt answers which
one a point is over. Drawing and hit testing read the same geometry, so a handle
and the curve landing on it cannot disagree. Below
NodeEditorTheme.portMinScale (0.25) handles are neither drawn nor hittable —
one rule for both, because a dot too small to see is also too small to aim at.
The trade is that a host cannot supply its own handle widget; style them through
the theme and NodePort.color.
A handle's shape is the theme's too, one per kind:
controlPortShape and dataPortShape pick from PortShape.circle,
triangle and diamond, so a control pin and a data pin can be told apart
without tracing a wire. The default pairs a triangle with a dot; set both to
circle for the row of identical dots this package drew before 0.5.0.
NodeEditor.portTooltip labels the handle the pointer rests on. It is asked
of the host rather than worked out here, because a dataType is a tag the
host chose and only the host knows what it is called out loud:
portTooltip: (node, port) => port.dataType ?? 'anything',
Returning null or an empty string says nothing for that port, so you can label the ones worth labelling and leave the rest alone.
Node types #
A NodeDefinition describes one type: what the Create menu shows for it, the
fields a node of that type stores, and the ports it has. Register them and the
controller keeps every node of that type in step with its definition.
final printNode = NodeDefinition(
type: 'print',
label: 'Print', // also its opt-in to the Create menu
icon: Icons.print,
category: 'Debug',
description: 'Writes its text to the log.',
fields: const <FieldFamily>[
StaticFieldFamily(
id: 'text',
fields: <NodeField>[NodeField(key: 'text', defaultValue: 'Hello!')],
),
],
ports: const <PortFamily>[
StaticPortFamily(
id: 'flow',
ports: <NodePort>[
NodePort.input(id: 'in', kind: PortKind.control),
NodePort.output(id: 'out', kind: PortKind.control),
],
),
],
);
final definitions = NodeDefinitionRegistry(<NodeDefinition>[printNode]);
final controller = NodeEditorController(definitions: definitions);
controller.addNode(
definitions.instantiate('print', id: 'p1', position: Offset.zero),
);
Definitions are optional. Without a registry the controller behaves exactly as
it would otherwise, and a node whose type no definition claims is never
touched. A node reads what its definition stores with
node.field<String>('text'), which answers null rather than throwing when a
saved file holds something else.
label, icon, category, description and defaultWidth are presentation
only and take no part in resolution — they are what the editor's Create menu
lists and its Description item shows.
Ports that depend on a node's fields or its wiring are dynamic families.
Connection rules #
By default the editor refuses same-direction pairs, self-connections,
duplicates, anything over a port's maxConnections, and any pair whose kinds or
types disagree. A connectionValidator sees every pair that is one output and
one input on two nodes, as a ConnectionCheck — both PortRefs, both
NodePorts, the graph — and its answer is final. To add a rule, and it in:
NodeEditorController(
connectionValidator: (check) =>
check.allowedByDefault && check.toPort.label != 'locked',
);
allowedByDefault is the package's own verdict on duplicates, capacity and
portsCompatible. A validator that does not read it replaces those checks,
which is occasionally what you want and is now something you can see. It is a
settable field, and a pasted wire is asked the same question as a drawn one —
one that is refused stays behind, and its nodes still arrive.
Kinds and types. A port is PortKind.data or PortKind.control. Data
carries a value; control carries the flow of execution. They never join.
NodePort.output(id: 'then', kind: PortKind.control),
NodePort.input(id: 'name', dataType: 'string'),
NodePort.input(id: 'anything'), // untyped: a wildcard
dataType is a tag you choose, not a Dart Type — ports are serialised and
Type.toString() is not stable under obfuscation. A null on either end matches
anything, so adding a type to one side of an existing graph never invalidates a
wire on its own. It is a wiring constraint only: nothing compares a runtime
value against it, because a string cannot.
Everything defaults to PortKind.data with no type, so a graph that mentions
neither behaves as though they did not exist.
Direction is shown by markers spaced along the curve rather than by a head
at the receiving port, each taking the curve's own slope.
NodeEditorTheme.connectionArrowSpacing (140) is the gap aimed for, rounded to
fit and clamped to 1..connectionArrowMaxCount (4).
Shape is one choice for the whole canvas, NodeEditorTheme.connectionStyle:
curved (the default) is a bezier leaving and arriving along the port normals;
orthogonal is axis-aligned legs with rounded corners, a Z between facing
ports and a lane round the back when the target is behind. Waypoints mean the
same under both — points the wire passes through — so switching moves no data;
in right angles each one is a corner, and a dragged handle snaps onto the row or
column of its neighbour (waypointAlignSnap, 6 px) — on whichever axes claim
one, with the grid taking the rest when snapping is on. Neither style routes
around cards: the waypoints are what a wire is taken around a card with, and a
straight leg under a card shows it where a swoop does not — which is why
curved is the default.
Undo and the clipboard #
Every mutation records a snapshot; multi-frame gestures wrap themselves in a transaction so a drag is one step.
controller.history.beginTransaction();
// ...many moveNodes calls...
controller.history.commitTransaction();
copy takes the selected nodes and the wires between them — a connection to
a node left behind is not part of what was copied, and reattaching it on paste
would silently rewire the document. paste gives fresh ids, runs one resolution
pass, lands as one undo step, and returns what actually survived:
controller.clipboard.copy();
final pasted = controller.clipboard.paste(); // the ids that survived
controller.clipboard.duplicate(); // buffer untouched
Give the controller a NodeGraphCodec and every copy is also written to the
system clipboard as JSON, which is what carries a selection between windows:
NodeEditorController(definitions: definitions, codec: NodeGraphCodec(definitions: definitions));
await controller.clipboard.pasteFromSystem(); // falls back to the buffer
Without a codec the package never touches flutter/services.
Customising #
How it looks, and what it offers beyond nodes and wires.
Theming #
NodeEditorTheme.dark() / .light(), or build one field by field. It covers
colours, the grid, connection width, curvature and style, port radius and
portMinScale, selection and marquee styling, scale limits, hit tolerances and
the direction markers. gridSpacing doubles as the step a snapped move lands
on — see Snapping below. Omit theme and the editor picks dark or light from
the ambient Theme brightness.
Context menus #
Right-click a node, a port, a wire or the canvas. Menus are built from
MenuAnchor, so they inherit your app's MenuTheme.
| Target | Entries |
|---|---|
| Node | Cut, Copy, Delete, Group, Description |
| Port | Cut links |
| Wire | Go to source, Go to destination, Delete |
| Group | Cut, Copy, Delete with contents, Disband, Rename, Colour ▸ |
| Canvas | Center view, Reset zoom, Paste, Create ▸, Add comment, Project ▸ |
Create ▸ lists every definition that declares a label, grouped by category.
Description shows a definition's description, read-only. Open and Save are
disabled until controller.project has a source and a sink.
Entries are data, so a host filters the defaults rather than rebuilding them:
NodeEditor(
contextMenus: NodeEditorMenus(
createOnDrop: true,
build: (request, defaults) => <NodeMenuEntry>[
...defaults,
if (request.target case NodeMenuNodeTarget(:final node))
NodeMenuEntry(label: 'Run from here', onSelected: () => run(node.id)),
],
),
);
Every built-in entry carries a NodeMenuEntryId. Match on it, not on the
label, to keep, drop or relabel one of the defaults: the label is in whatever
language the host speaks (see Localisation), and copyWith keeps the id.
build: (request, defaults) => <NodeMenuEntry>[
for (final entry in defaults)
if (entry.id != NodeMenuEntryId.disband) entry,
],
contextMenus: null turns them off. Supplying onNodeSecondaryTap,
onPortSecondaryTap, onConnectionSecondaryTap or onCanvasSecondaryTap takes
that one target over, so a host with its own menu keeps it and does not get two.
createOnDrop (off by default) makes a wire dropped on empty canvas offer
Create ▸ at that point and wire up what it makes, in one undo step. It replaces
onConnectionDropped rather than joining it.
Localisation #
The words the editor draws — its menus, its three dialogs, the minimap's bar
and gear, an empty comment's hint — come from NodeEditorLocalizations, the
same pattern as Flutter's MaterialLocalizations. With no delegate in scope
it is DefaultNodeEditorLocalizations, the English above. To translate it,
extend the default and hand Flutter a delegate for it, beside your own:
class GermanEditorLocalizations extends DefaultNodeEditorLocalizations {
const GermanEditorLocalizations();
@override
String get cut => 'Ausschneiden';
@override
String cutLinks(int count) =>
count == 1 ? 'Verbindung trennen' : 'Verbindungen trennen';
}
class GermanEditorDelegate
extends LocalizationsDelegate<NodeEditorLocalizations> {
const GermanEditorDelegate();
@override
bool isSupported(Locale locale) => locale.languageCode == 'de';
@override
Future<NodeEditorLocalizations> load(Locale locale) =>
SynchronousFuture(const GermanEditorLocalizations());
@override
bool shouldReload(GermanEditorDelegate old) => false;
}
// MaterialApp(localizationsDelegates: [GermanEditorDelegate(), ...], …)
Extend, don't implement. A later minor version may add a string; a
subclass of the default shows it in English until you translate it, where an
implements would stop compiling.
A definition's label, category and description are yours, and never
pass through it. MinimapConfig.title and sizePresets and
EditableLinkLabel.editorTitle are yours if you set them, and kept in every
locale; leave them out and they come from the localizations
(minimapTitle, minimapSizePresetLabel, linkLabelTitle).
NodeGroup.defaultName is not a word but a value written into documents, so
it stays as it is in every locale.
Comments #
A comment is a note the app user writes on the canvas: a text field in a grey slab, with a ring of padding wide enough to grab.
final id = controller.addComment(
position: scenePosition,
text: 'this branch is deliberate',
);
It is an ordinary GraphNode of a reserved type, not a model of its own. That
is the whole design — paint order, selection, dragging, the marquee, cut and
paste, undo and the document format are things a node already has and a note
needs unchanged. So a note floats above the nodes when you click it, sweeps
into a marquee, copies, pastes and saves without a single case for it anywhere.
The editor draws them itself and never hands one to your nodeBuilder, so you
neither have to know the type exists nor can be surprised by one arriving. They
are deliberately unthemed and look the same in a light app and a dark one: a
note is the user's own annotation, not part of the graph's visual language.
NodeComment is the seam. NodeComment.isComment(node) and
NodeComment.textOf(node) read one; graph.comments and graph.contentNodes
partition graph.nodes for the places that care — running the graph, counting
it, exporting it. Notes carry no ports, so nothing can be wired to one and the
runner leaves them alone.
Typing folds into one undo step per run. controller.setCommentText records
only the first change of a run; endCommentEdit closes it, and so does any
other edit, so Ctrl+Z can never step back past something that happened while
the caret was elsewhere.
Groups #
A group is a named frame drawn behind a set of nodes. Select some and press
Ctrl+G.
controller.selection.selectNodes(<String>['a', 'b']);
final id = controller.groupSelection();
A group owns no geometry. Its rectangle is the bounding box of whatever it
holds plus NodeGroup.padding, recomputed as those nodes move — which is why
this is its own model rather than a node of a reserved type the way a comment
is. Membership is explicit, not geometric: dragging a node over a frame does
not put it in.
The frame paints immediately below the lowest of its own members, and no lower — under the nodes it holds without sinking beneath whatever else is stacked under them. It takes no pointer events at all. The space inside a frame is still canvas: clicks there sweep a marquee, and clicking a node does what it always did. Everything a group can be asked to do goes through its handle, in the frame's top-left.
| Action | |
|---|---|
Ctrl+G on nodes |
Frame them |
Ctrl+G on a group plus ungrouped nodes |
Widen that frame |
| Drag the handle | Move every member, in one undo step — the frame and its contents come to the front |
| Click the handle | Select the group — not its nodes |
| Double-click the handle | Rename |
| Handle dropdown | Recolour from NodeGroup.palette, or back to neutral |
| Right-click the handle | Cut, Copy, Delete with contents, Disband, Rename, Colour |
Del on a selected group |
Delete the frame and its contents |
groupSelection() returns null rather than guessing when the selection cannot
be framed: nothing selected, more than one group in it, or a node that already
belongs to a different group. Moving a node between groups is deliberately not
offered — disband the old frame first, or one keystroke would rewrite a group
the user was not looking at.
selection.groupIds is separate from selection.nodeIds, and
selection.nodeIdsWithGroups is what "act on the selection" means once a frame
can be in it.
Minimap #
An optional panel over the canvas showing the whole document small, with everything outside the current view washed grey. Off by default:
NodeEditor(
controller: controller,
nodeBuilder: buildCard,
minimap: const MinimapConfig(),
)
It is a readout and never moves the camera. The drag belongs to the panel instead, so it can be pushed off whatever you are working on — a minimap you cannot move is a minimap sitting on top of your graph. That also makes it cheap: it never touches the controller, so nothing it does rebuilds a node.
| Action | |
|---|---|
| Drag the action bar | Move the panel |
| Drag the corner grip | Resize it |
| Gear | Zoom cap, size, what is drawn, idle opacity |
| ✕ | Fold to the bar; the same button unfolds it |
The map fits the whole document, capped by MinimapController.maxScale (0.2)
so a three-node graph does not render as three enormous slabs. The cap only
ever binds downward, so it can never push content off the map.
Nodes take their group's colour, notes the note grey and selected nodes the theme's selection colour. The package has no per-node colour by design, so a host that colours by its own node vocabulary supplies one:
MinimapConfig(
nodeColor: (node) => switch (node.type) {
'trigger' => const Color(0xFF5BC48A),
'output' => const Color(0xFFB57BD8),
_ => null, // fall back to the group, or to the neutral
},
)
The panel's placement, size, folded state and settings live on a
MinimapController. The editor makes one when you supply none; own one to
persist where the panel was left.
Snapping #
controller.snapToGrid = true;
A node's top-left corner then lands on the grid the canvas draws — a drag, an
arrow-key nudge, the corner grip and a dragged waypoint all take it, and each
node in a selection rounds its own corner. The step is not a number of its
own: it is NodeEditorTheme.gridSpacing, which the editor hands the
controller, so what a card lands on is a line you can see. snapStep is that
value resolved, and 0 whenever nothing snaps.
It is on the controller rather than the theme so that turning it on does not
mean handing NodeEditor a new theme, which is the rebuild the callout near
the top of this file warns about. Three things it deliberately leaves alone:
Shift and an arrow place a node exactly, ignoring the grid; an orthogonal
waypoint lined up with its neighbour's row stays lined up, since a corner with
no jog beats a corner on a line; and applyLayout never snaps, because an
arrangement is a computed picture rather than something a pointer expressed. GridSnap.offset is the rounding, public, so a host that
places a node itself can reach the same answer:
controller.applyLayout(
(graph, sizeOf) => myLayout(graph, sizeOf)
.map((id, at) => MapEntry(id, GridSnap.offset(at, 24))),
);
Snapping is independent of showGrid: the lattice is a fact about the scene,
not about what is painted.
Advanced #
None of this is needed to draw and edit a graph.
Dynamic ports and fields #
A definition is not a template stamped out once, as a prototype would be — it is a reduction rule. Given what a node's fields say and how it is wired right now, it returns the ports, fields and height that node should have, and the controller rewrites the node to match. Ports become derived state rather than something the document authors by hand.
That is what lets ports appear on demand: one input per placeholder in a format string, one more exit each time the last free one is wired.
final formatNode = NodeDefinition(
type: 'format',
fields: const <FieldFamily>[
StaticFieldFamily(
id: 'text',
fields: <NodeField>[NodeField(key: 'format', defaultValue: 'Hello, {0}!')],
),
],
ports: <PortFamily>[
DynamicPortFamily(
id: 'args',
build: (context) => <NodePort>[
for (final slot in slotsIn(context.fieldOr<String>('format', '')))
NodePort.input(id: 'arg_$slot', label: '{$slot}'),
],
),
],
);
Families are the unit of ownership, so a node can have a fixed input and a variadic output with only the second re-deriving:
StaticPortFamily |
A constant list, still owned — re-materialised every pass, so renaming a label in the definition reaches nodes that already exist. |
DynamicPortFamily |
Rebuilt from the node's fields and links on every pass. |
| foreign | A port carrying no family. Yours; never rewritten or removed. |
If a generated port has the same id as a hand-authored one, the generated port adopts it — that is how you point a definition at a document whose ports were written by hand.
A builder must settle: given its own output it must return the same thing
again. Resolution runs passes until the node stops changing, bounded by
maxPasses.
Execution #
A definition's onExecute is what its node does; controller.runner walks the
graph and calls them.
NodeDefinition(
type: 'greet',
onExecute: (context) async {
final name = context.input<String>('name') ?? context.fieldOr('name', '');
context.emit('greeting', 'Hello, $name');
context.flow('then');
},
);
final run = await controller.runner.run();
if (!run.succeeded) report(run.error, run.failedNodeId);
Running never touches the document. No node moves, the revision does not change, nothing lands in undo and the project does not become dirty. Values live in the run, keyed by the output port that produced them — which is also why a run is immune to edits made while it is in flight: it works from a snapshot.
run() throws StateError if one is already going; cancel() stops it. An
exception from an executor is not rethrown — it comes back as run.error
with run.failedNodeId, because a result you can inspect beats an error thrown
out of an async subsystem.
Control flow. Execution starts at every node with a control output and
nothing wired into its control input, in node-id order — or at the ids you pass
as from. A node with no executor hands the flow on through its single control
output; with more than one it stops and says so rather than guessing which
branch an if/else meant. Branches run depth first.
Control flow is a pulse: two branches converging on a node run it twice.
There is no implicit join, because a barrier waiting for every incoming edge
deadlocks on the arm a condition never fires. A node that wants to wait counts
tokens in context.state, which persists across that node's turns within one
run — the same place a loop keeps its counter. maxSteps bounds the run.
Data flow. A node declaring no control ports at all is a pure data node: evaluated when something asks for its output, not when the flow arrives.
context.input<String>('name') // null when nothing is wired
context.hasInput('name') // null is a legal value; absence is not
context.inputs('name') // every wire, in connection id order
Its result is reused until one of its own inputs is rewritten, so a value
recomputed inside a loop is recomputed and a constant is not. A node that is not
a function of its inputs — random(), now() — sets pure: false.
GraphRun carries trace, runCounts, per-node states, values keyed by
PortRef, log and diagnostics — the things that would otherwise be silent:
a node that ran twice, a data input with several wires, a read from a producer
that had not run, a graph with no entry point.
Watching a run as it happens. runner.onEvent is handed a GraphRunEvent
for every step — RunStarted, NodeStarted, NodeFinished, MemoHit,
DiagnosticRaised, LogEmitted, RunFinished — a sealed hierarchy, so a
switch is exhaustive. An executor says something into the same stream with
context.log(message). Values on the wires are withheld unless
runner.tracePayloads is on, because a trace is the thing that gets written
to a file and a value was produced by a node body the host did not write. The
package keeps no log of its own: GraphRunRecorder is a fixture, and where the
events go is the host's decision.
Serialisation #
The package does no I/O. NodeGraphCodec converts a GraphDocument to and from
a Map<String, Object?> made only of JSON values.
const codec = NodeGraphCodec();
final json = codec.encode(GraphDocument(
graph: controller.graph,
viewport: controller.camera.viewport,
));
final document = codec.decode(json); // throws here, or not at all
Decode never touches a controller. It returns a document or throws, so a
malformed file cannot leave an editor blank. Failures carry a path —
nodes[3].ports[1].anchor, not type 'String' is not a subtype of type 'num'.
For an app where the editor is the document, controller.project holds the
codec, the metadata and the two callbacks that decide where documents live:
controller.project
..meta = const <String, Object?>{'title': 'Lead routing'}
..appVersion = 'my-app/1.0.0'
..sink = (json) => writeFile(json) // Future<bool>
..source = () => readFile(); // Future<Map<String, Object?>?>
await controller.project.save();
final document = await controller.project.load();
load decodes before it replaces anything, so an unreadable document leaves the
open one on screen. save returns what the sink reported: false is a save the
user backed out of, not a failure. isDirty compares graph identity, so undoing
back to the saved state reports clean again.
Non-JSON values need a PayloadCodec, registered with a tag so both directions
dispatch on the same recorded string. Documents carry an integer version; one
newer than the build is refused rather than parsed hopefully, and older ones are
lifted by pure Map-to-Map migrations.
A document carries a second version, and it is the host's:
const codec = NodeGraphCodec(
schemaVersion: 3,
schemaMigrations: <int, GraphDocumentMigration>{
1: (document) => …, // version 1 of *your* data, as version 2
2: (document) => …,
},
);
version governs the envelope — nodes, connections, groups, ports, the shape
this package owns. schema governs what you mean by a node's type and by
the keys in its data, which the package carries and never interprets. So a
field you rename or two you fold into one is a schema bump, and this package
cutting a format version does not oblige you to write a migration for it. Both
gates run on the way in, the format's first; a missing schema reads as 1, and
leaving schemaVersion null means a schema key is carried through untouched
rather than gated.
Interaction #
| Gesture | Result |
|---|---|
| Drag canvas | Sweep a selection rectangle, updating live |
| Shift / ctrl + drag canvas | Sweep, adding to the current selection |
| Middle-drag, space + drag, touch drag, trackpad swipe | Pan |
| Scroll (pans on the web), pinch, ctrl / cmd + scroll on the web | Zoom about the pointer |
| Drag node | Move it — selecting it first is not required |
| Drag a selected node | Move the whole selection |
| Drag port → port | Create a connection |
| Drag port → node body | Connect to that node's first compatible port |
| Drag port → empty canvas | onConnectionDropped, or Create ▸ with createOnDrop |
| Hold a drag against the edge | Scroll the canvas that way — edgeScroll, off with null |
| Click connection | Select it |
| Double-click connection | Add a waypoint there; drag it to route the wire, double-click it to remove |
| Drag a comment's padding | Move it — the text field takes any press on itself |
| Drag a group's handle | Move every node in it |
Ctrl+G |
Frame the selection, or widen the frame in it |
| Right-click anything | Its context menu |
Del, Ctrl+Z/Ctrl+Shift+Z, Ctrl+A, Esc, arrows |
Delete, undo/redo, select all, cancel, nudge |
Shift + arrows |
Nudge one unit, exactly — ignoring the grid |
Ctrl+C/Ctrl+X/Ctrl+V/Ctrl+D |
Copy, cut, paste, duplicate |
canvasDragBehavior: CanvasDragBehavior.pan swaps the first two rows — canvas
drag pans and shift opts into the rubber band. Navigation is never taken over
by selection either way: a touch drag always pans, so the rubber band is a
pointer-device affordance only.
On the web a trackpad reaches Flutter as wheel events that cannot be told
from a mouse wheel, so there every scroll pans and zoom is a pinch or ctrl /
cmd + scroll. Add html, body { overscroll-behavior: none; } to
the host page's index.html, or a sideways swipe navigates the browser back.
A click selects, a drag moves. Pressing a node and dragging moves it straight away, selecting it on the way. The secondary button never drives a drag — it opens menus and nothing else.
Layers #
Each layer is usable on its own, so graph logic and layout can be unit tested without pumping a widget.
| Layer | Types | Depends on Flutter? |
|---|---|---|
| Model | NodeGraph, GraphNode, NodePort, NodeConnection, NodeGroup, PortRef |
geometry only |
| Geometry | ViewportTransform, NodeGeometry, ConnectionPath, ConnectionRouter, MinimapProjection |
geometry only |
| Definition | NodeDefinition, PortFamily, FieldFamily, NodeDefinitionRegistry |
geometry only |
| Serialisation | NodeGraphCodec, GraphDocument, PayloadCodecs |
geometry only |
| Controller | NodeEditorController and its subsystems, SpatialHashGrid, MinimapController |
ChangeNotifier |
| View | NodeEditor, NodeView, ConnectionLayout, painters, NodeEditorTheme, MinimapConfig |
yes |
Example #
example/ is a workflow editor covering every feature: seven node types, a form
node built from real Flutter inputs, derived ports, link captions, an inspector
panel, comments, groups, the minimap, JSON save and load, execution, and stress
graphs up to 5000 nodes. It is live on GitHub
Pages, rebuilt from
master by .github/workflows/demo.yml.
cd example && flutter run
Not included yet #
- An arrangement of its own.
applyLayouttakes one from the host and places it; which picture a graph should make is a question about what the nodes mean. - Resizing a comment. It sizes itself to its text; only a node whose definition opts in can be resized by hand.
- Resolution cannot change a node's
position,widthordraggable.NodeDefinition.defaultWidthseedsinstantiatebut is not enforced after.
License #
MIT. See LICENSE.