The graph renderer could display diagrams. It couldn’t edit them. That’s the gap this work closes — an editing infrastructure built on two ideas: grammar-driven policies and anywhere-drag connection UX.

The EditPolicy SPI

The core question for any diagram editor: what’s allowed? Can this node connect to that one? What types can be inserted on an existing edge? What happens when you delete a node that sits between two others?

I wanted these answers to come from the grammar, not from hardcoded logic. Every stencil already declares its connection rules — inbound max, outbound max, allowed types. defaultEditPolicy() reads those declarations and derives the full editing policy automatically. A consumer registers five stencils with grammar rules and gets a complete editor without writing validation code.

The policy is an SPI. Override canConnect if your domain has constraints the grammar can’t express. Override getDeleteStrategy if deleting a filter node should auto-join its predecessor to its successor instead of disconnecting. The default handles the common case; the SPI handles everything else.

Operations are data, not methods. GraphEdit is a discriminated union — addNode, removeNode, addEdge, splitEdge, compound. You can log them, replay them, compose them. applyGraphEdit() is a pure function that takes a graph and an edit and returns a new graph. Undo is just applying the inverse. Compound edits let “insert a node on this edge” decompose into removeEdge + addNode + addEdge + addEdge in a single atomic operation.

The Handle Problem

React Flow’s connection model assumes small circular handles at specific positions on each node. You drag from a handle, drop on a handle. For an auto-layout editor where ELK positions everything, handle locations are meaningless. What I wanted: drag from anywhere on a node to start a connection, drop anywhere on a target to complete it.

The solution is invisible full-node handles. Each node gets two: a source handle covering 100% of the node at z-index 2, and a target handle at z-index 1 underneath it. When a drag starts, a CSS class toggles pointer-events: none on all source handles — the target handles underneath become the drop targets. The node IS the handle, in both directions.

That wasn’t enough. React Flow’s onConnectEnd callback gives you an event whose target is always the pane overlay — never the actual node under the cursor. The pane sits above everything and intercepts all pointer events during a connection drag. We had to use document.elementsFromPoint() to see through the overlay stack and find the real target. And even that failed initially — the connection line SVG and edge interaction layers also blocked the hit test. Those needed pointer-events: none too, but only during the drag, applied via the same CSS class toggle.

Setting connectionRadius to zero was the final piece. React Flow’s default radius-based matching creates false positives — it connects to nodes that are “close enough.” With full-node handles and element-based hit detection, the rule is simple: if the pointer is over the node, it connects. If it isn’t, it doesn’t.

The Type Picker

Dropping a connection line on empty space fires a graph:connect:end-on-empty event. The companion script catches it, queries getCreatableTypes() from the edit policy, and shows a picker at the drop location. Pick “Transform” and the node appears, already connected to the source. Click an existing edge and getInsertableTypes() drives the picker — the selected type splits the edge with a compound edit.

The picker is SPI-driven end to end. The stencil implementor’s grammar defines what appears. The five-node pipeline domain — source, transform, filter, join, sink — demonstrates this: a source can connect to transforms, filters, and joins, but never to another source or directly to a sink. The picker shows exactly what’s valid for each context.

What This Opens Up

Any consumer building a visual editor — workflow designers, data pipeline builders, case modelling tools — gets grammar-driven editing, full-node drag UX, type pickers, and auto-join deletion out of the box. The example gallery has three working showcases: static rendering, interactive editing with mutation logging, and a property palette wired to node selection with rich form controls (colour pickers, sliders, date pickers, tag inputs).

The palette component itself is generic — <pages-diagram-palette> is the next piece, giving consumers a draggable stencil tray alongside the canvas. The editing infrastructure is the foundation; the palette is the last UX primitive before domain-specific editors can be assembled purely from configuration.


<
Previous Post
When the review says you're in the wrong repo
>
Next Post
Packaging the Evidence Trail