fl_nodes_v2 1.2.2 copy "fl_nodes_v2: ^1.2.2" to clipboard
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 NodeEditor in a ListenableBuilder on its own controller. It already listens for itself, and rebuilding it hands it a fresh nodeBuilder closure 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 Backspace unhandled 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.dart does this on focus change for the text field and on onChangeStart/onChangeEnd for 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.hasUnmeasuredNodes reports 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. applyLayout takes 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, width or draggable. NodeDefinition.defaultWidth seeds instantiate but is not enforced after.

License #

MIT. See LICENSE.

4
likes
150
points
716
downloads

Documentation

API reference

Publisher

verified publisherconnecting-the-dots.dev

Weekly Downloads

A node graph editor for Flutter: pan, zoom, wire and run graphs, with ordinary Flutter widgets inside every node.

License

MIT (license)

Dependencies

flutter

More

Packages that depend on fl_nodes_v2