feat: grid-aligned connection handles that scale with node size and zoom#775
Draft
FelixTJDietrich wants to merge 12 commits into
Draft
feat: grid-aligned connection handles that scale with node size and zoom#775FelixTJDietrich wants to merge 12 commits into
FelixTJDietrich wants to merge 12 commits into
Conversation
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>
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
added a commit
that referenced
this pull request
Jun 20, 2026
FelixTJDietrich
added a commit
that referenced
this pull request
Jun 20, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:ratiovalue (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-testedlib/nodes/handles/module.What you get as a user:
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 theside:ratioencoding, key-handle sets (keyHandlesForSide), grid quantization (quantizeRatio), the zoom level-of-detail (effectiveStepPx,visibleKeyRatios, snap radius), andsnapToAnchor(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,ellipseshape,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 plusvisibility: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 inApp.tsx.migrateLegacyHandle.ts— maps every legacy named handle to aside:ratioanchor; chained intoEdgeTransformer, the single import chokepoint for all diagram versions.Design decisions (and trade-offs)
@xyflow/system:getEdgePositionresolves 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.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 toside:ratioanchors the exported edges (and their labels) dropped. The server now builds its render handles from the library's anchor model via a newbuildServerRenderHandlesexport — one handle per anchor each edge references — so the export matches the canvas. This is covered by the existing serversvg-exportintegration suite (caught the regression; now green) and a new unit test, and ships with its own@tumaet/serverchangeset.Loading a model into the editor. Loading a saved diagram straight into the editor (
new ApollonEditor({ model }),editor.model = …, or the standalone/localpage) bypassedimportDiagram, so its edges kept legacy handle ids while nodes now renderside:ratioanchors — React Flow then dropped the edges. Migration now runs in the store's bulk-ingestion path (normalizeEdgeForStore, used bysetNodesAndEdges), 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):
In the editor:
Screenshots / screencasts
Connection points scale with node size — every side always offers its centre and corners; longer sides also gain quarter points.
Zoom-aware level of detail — zooming out thins points (quarters → corners → centre) so they never overlap; the centre always stays.
Magnetic grid while connecting — dragging a connection reveals the 5px grid on the targeted side and snaps the edge onto it.
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
feat) matches the kind of changeanchorModel+migrateLegacyHandleunit suites, updatededgeUtils/EdgeTransformer/legacyIosMigration, newnode-anchor-resolutione2eNote
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-checkwas already failing onmain: 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 separatestyle:commit that reformats them (line-wrapping only, no behaviour change). Happy to split it out if you'd prefer it as its own PR.