Skip to content

feat: grid-aligned connection handles that scale with node size and zoom#775

Draft
FelixTJDietrich wants to merge 12 commits into
mainfrom
ivory-salmon
Draft

feat: grid-aligned connection handles that scale with node size and zoom#775
FelixTJDietrich wants to merge 12 commits into
mainfrom
ivory-salmon

Conversation

@FelixTJDietrich

@FelixTJDietrich FelixTJDietrich commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Summary

When a node grows, five connection points per side stops being enough, and the old points sat in a cosmetic 20–80 % band rather than at the places people aim for. This PR reworks node connection handles so the available connection points scale with the node and the zoom level, and so the centre and true corners of every side are always easy to hit.

It replaces the legacy fixed 9-slot / staged-arc model with a single, pure anchor model: every endpoint is stored as a compact side:ratio value (e.g. r:0.500), and all connection geometry — the visible handles, drop/reconnect snapping, the drag-time grid overlay — is derived from that one source of truth. The change is a net ~1,600 lines deleted while adding a small, well-tested lib/nodes/handles/ module.

What you get as a user:

  • More points that scale with size — each side always offers its centre and corners; long sides also get quarter points.
  • A magnetic grid while connecting — drag a connection toward a node and a 5 px grid appears on the targeted side, snapping the endpoint exactly onto the canvas grid. Density adapts to zoom (finest 5 px when zoomed in, coarser when zoomed out) so points never overlap.
  • Centre & corners first — snapping prioritises centre > corner > quarter > grid, so the obvious targets are the easiest to land on.
  • Existing diagrams keep working — saved v2/v3/v4 diagrams are migrated automatically on import; no edge is ever dropped.

Release note

Connecting edges is now easier and more precise: every node side offers its centre and corners (plus quarter points on longer sides), the available points scale with node size and zoom, and a magnetic grid appears while you drag a connection so you can snap an edge exactly onto the canvas grid. Existing diagrams keep all their connections.

Implementation notes

New module — library/lib/nodes/handles/

  • anchorModel.ts — pure, no React/DOM. Owns the side:ratio encoding, key-handle sets (keyHandlesForSide), grid quantization (quantizeRatio), the zoom level-of-detail (effectiveStepPx, visibleKeyRatios, snap radius), and snapToAnchor (priority centre > corner > quarter > grid). Drives canvas, snapping, ghost overlay, and any headless export from one place.
  • nodeHandleConfig.ts — per-node-type config (key / center / none, ellipse shape, excludeCorners) keyed by node-type string. Used by both rendering and drop resolution, so they can't drift apart. This also generalises the existing NSEW restriction for circles/diamonds/interfaces.
  • ConnectHandles.tsx — renders the (≤ 5/side) visible key handles plus visibility:hidden "addressable anchor" handles for any saved edge that references a non-key ratio. This is the safety net: React Flow resolves an edge endpoint from a measured DOM handle, so every persisted anchor must have one — otherwise the edge silently disappears.
  • GridGhostLayer.tsx — the magnetic grid + snap dot shown during a drag (useConnection + ViewportPortal), mounted once in App.tsx.
  • migrateLegacyHandle.ts — maps every legacy named handle to a side:ratio anchor; chained into EdgeTransformer, the single import chokepoint for all diagram versions.

Design decisions (and trade-offs)

  • Kept DOM-backed handles + React Flow's native reconnect, rather than moving to floating edges. Verified against @xyflow/system: getEdgePosition resolves endpoints from measured DOM handles, so floating edges would mean rewriting all 13 edge types and replacing native reconnect — a separate, larger project. Deferred deliberately.
  • 3-decimal ratios (not 2). A 2-decimal ratio drifts up to a full 5 px grid cell on nodes wider than ~200 px (common class boxes) and makes re-saves non-idempotent; 3 decimals round-trip exactly to ~5,000 px. There's a regression test pinning this.
  • True corners (0/1) replace the old 20 %/80 % band — this is the "easy to hit the corner" ask, and it means corner-anchored edges shift slightly outward (see the heads-up below).

Server image export. Headless SVG/PNG/PDF export seeds React Flow's handle bounds from node.handles (jsdom can't measure the DOM). The server hard-coded those with the old id scheme, so once edges migrated to side:ratio anchors the exported edges (and their labels) dropped. The server now builds its render handles from the library's anchor model via a new buildServerRenderHandles export — one handle per anchor each edge references — so the export matches the canvas. This is covered by the existing server svg-export integration suite (caught the regression; now green) and a new unit test, and ships with its own @tumaet/server changeset.

Loading a model into the editor. Loading a saved diagram straight into the editor (new ApollonEditor({ model }), editor.model = …, or the standalone /local page) bypassed importDiagram, so its edges kept legacy handle ids while nodes now render side:ratio anchors — React Flow then dropped the edges. Migration now runs in the store's bulk-ingestion path (normalizeEdgeForStore, used by setNodesAndEdges), so every load path keeps its edges. Verified by a new store unit test; this was the cause of the initial e2e failures.

Self-review: I ran an adversarial review of the implementation and fixed one real bug it surfaced (the 5 px round-trip drift above) plus two minor parse/centre edge cases, then locked them with tests.

Important

Visual-regression baselines must be refreshed in this PR. Because corner anchors moved from the old 20 %/80 % band to the true corners, edge endpoints shift slightly across all diagram types and templates. Per the per-PR baseline policy, the rendering PR refreshes its own baselines — I could not regenerate them in my environment (it needs the Playwright Docker image), so this is the main outstanding task before merge.

Steps for testing

Library unit + build (all green locally):

cd library
pnpm exec vitest run     # 1020 passing
pnpm run build
pnpm run lint            # 0 errors

In the editor:

  1. Add a class node and widen it — confirm the top/bottom sides show corners, centre and quarter points; left/right show corners + centre.
  2. Zoom out — connection points thin out from quarters → corners → centre so they never overlap; the centre always stays. Zoom back in to get them back.
  3. Start dragging a connection toward a node — a 5 px magnetic grid appears on the nearest side with a snap dot; release to drop the edge exactly on a grid point.
  4. Connect to the centre and to a corner of a side — both should be effortless targets.
  5. Open an existing/older diagram (v2/v3/v4) — every edge still connects; nothing is dropped.
  6. Circles / diamonds / interfaces (BPMN events, flowchart decision, activity initial/final, Petri net) still connect only at side centres.

Screenshots / screencasts

Connection points scale with node size — every side always offers its centre and corners; longer sides also gain quarter points.

Connection points scale with node size

Zoom-aware level of detail — zooming out thins points (quarters → corners → centre) so they never overlap; the centre always stays.

Zoom-aware level of detail

Magnetic grid while connecting — dragging a connection reveals the 5px grid on the targeted side and snaps the edge onto it.

Magnetic grid while connecting

Note

These are figures rendered from the actual anchor-model geometry (keyHandlesForSide, visibleKeyRatios, effectiveStepPx, anchorPoint) — so handle counts, positions and zoom thinning are exactly what the code produces — styled to match the editor (each node corner is a single handle). They are not live app captures; an interactive screencast can still be added by a maintainer running the editor locally.

Checklist

Note

The standalone Playwright e2e specs could not be executed in my environment; they are included and will run in CI. Local verification was via the library's unit suite (1020 tests), tsc, build, and lint.


Note

Heads-up — one unrelated commit. lint-and-format-check was already failing on main: two communication-edge-label files from #645 (EdgeMultipleLabels.tsx, messageLayout.test.ts) landed mis-formatted. Since that check blocks CI here, this PR includes a separate style: commit that reformats them (line-wrapping only, no behaviour change). Happy to split it out if you'd prefer it as its own PR.

Rework node connection handles into a grid-aligned, zoom-aware anchor model.
Connection points now scale with node size (corners + side centre, plus
quarter points on long sides), always make the centre and corners easy to
hit, and reveal a magnetic 5px grid on the targeted side during a connection
drag (density adapts to zoom). Endpoints persist as compact `side:ratio`
anchors; v2/v3/v4 diagrams are migrated automatically on import. The legacy
fixed 9-slot / [0.2,0.8] arc model is removed (net ~1600 LOC deleted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FelixTJDietrich and others added 9 commits June 20, 2026 19:23
EdgeMultipleLabels.tsx and messageLayout.test.ts (from #645) landed
mis-formatted on main, failing the repo-wide format:check. They are
unrelated to this PR's changes but the failing check blocks CI here, so
the formatting is corrected (line-wrapping only, no behaviour change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Headless SVG/PNG/PDF export seeds React Flow's handle bounds from
node.handles (jsdom can't measure). The server hard-coded those handles
with the legacy id scheme, so after edges migrated to side:ratio anchors
their handles no longer matched and every edge (and its labels) dropped
from the exported image.

Add `buildServerRenderHandles` to the library — derived from the same
connection-anchor model the editor renders from — and have the server use
it, including a handle for every anchor an edge references. Server export
now matches the canvas exactly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A diagram loaded straight into the editor (new ApollonEditor({ model }),
editor.model = …, or the standalone /local page) bypassed importDiagram,
so its edges kept legacy handle ids while nodes now render side:ratio
anchors — React Flow then dropped every edge onto a missing handle.

Migrate edge handles in the store's bulk-ingestion path
(normalizeEdgeForStore, used by setNodesAndEdges) so every load path keeps
its edges, not just importDiagram. Idempotent for canonical anchors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…wing edges

The draw/short-edge e2e helpers grabbed data-handleid="right"; connection
handles are now side:ratio anchors, so the right-centre handle is
"r:0.500". Updates the two helpers; unblocks the edge-draw e2e specs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applies a strict multi-agent review of the branch:
- dead code: drop the unused width/height props from DefaultNodeWrapper (and
  all 52 call sites); delete the prod-unused nearestSide helper; unexport the
  three internal-only anchorModel symbols.
- consistency: replace findClosestHandle's useFourHandles boolean with a
  variant ("key"|"center") passed straight through; dedupe safeZoom (export
  from anchorModel, reuse in GridGhostLayer); use @/ import in useHandleFinder;
  derive the server's default edge handles via the new public formatAnchor
  instead of hardcoded "r:0.500"/"l:0.500".
- comment theatre: remove banner-divider blocks and editorializing in
  anchorModel/GridGhostLayer.
- test theatre: drop the SIDE_TO_POSITION tautology and redundant border-
  position test; rename the mislabeled "2 decimals" test to "3 decimals".

No behaviour change; lib unit suite + server SVG-export integration green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bend specs assert on edges whose legacy handles migrated to new
positions: the "fresh straight edge" fixture's target became off-centre
(l:0.625) so the edge was no longer straight, and the bidirectional
"L-shape" edge moved onto a true corner (b:0.000) where the first-bend
stub validation rejects the drag. Point both at side centres so the
fixtures match what the bend tests describe (a straight fresh edge / a
clean diagonal L). The bend logic itself is unchanged on this branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Loop-2 review fixes (bridge to A+):
- findClosestHandle now forwards `sides` to snapToAnchor, matching what
  GridGhostLayer and ConnectHandles already honor. Without it a node that
  restricts its connectable sides could resolve a drop to a side it never
  renders (latent edge-drop / ghost-vs-commit divergence). Wired through
  useConnect + useHandleFinder from the node config.
- add an end-to-end test that an edge saved with legacy handle ids still
  exports with its path (import → migrate → SSR handles → render).
- unexport FindClosestHandleParams (no external consumers); collapse the
  findClosestHandle test to its delegation contract (geometry is covered in
  anchorModel.test.ts) and add a `sides`-forwarding case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…etry

b:0.500 fixed one bend spec but broke two others on the shared
edge-bidirectional-dog-imovable edge (each calibrated to its shape).
Legacy "bottom-left" rendered at the old [0.2,0.8] band edge ≈ 0.2 of the
side, so pin the target to b:0.200 — true-corner 0.0 made the first bend
degenerate, 0.5 reshaped the L. Keeps all three bend specs' geometry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ide)

True-corner anchors (ratio 0/1) are the same point on two sides, so every
corner drew two overlapping arcs (e.g. t:0.000 and l:0.000), which looked
doubled/cluttered. Corners now belong to the top/bottom sides only; left
and right no longer emit ratio 0/1 (key handles, snap candidates, and SSR
handles alike). Every corner stays connectable via one clean handle, and a
corner drop resolves to the horizontal side. Matches Apollon's original
corner ownership.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FelixTJDietrich and others added 2 commits June 20, 2026 23:30
Connectors are no longer placed at the node corners (ratio 0/1): a handle
straddling a corner reads as broken and overlapped the adjacent side. Key
handles are now the side centre plus quarter points on long sides; the
magnetic grid and drop/reconnect snapping likewise never resolve to 0/1.
Removes the now-moot corner machinery (excludeCorners, sideOwnsCorners,
the "corner" anchor kind). Saved edges that referenced a corner still
resolve via a hidden addressable handle, so nothing is dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-removal: snap priority is center > quarter > grid (no corner tier),
the ratio doc notes 0/1 are never offered, and the quarter-threshold note
reflects centre-only vs three-point sides.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@FelixTJDietrich FelixTJDietrich marked this pull request as draft June 21, 2026 20:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant