Skip to content

Commit 4dc6616

Browse files
fix(library): reserve an endpoint zone so no bend handle ever starves a grip
My previous fix only covered lone straight edges; a short PINNED S-jog (diagram 79) still starved both endpoint grips to 0px, because a bend handle on its terminal stub AND one on its 5px inner jog sat right on top of the endpoints. The root cause is universal: nothing kept bend handles clear of the endpoints, so on any short or crowded edge a handle lands inside an endpoint's grip zone and the reconnect target (drawn on top) caps the grip to nothing. Generalise "endpoints first" to every segment: each endpoint reserves a run along the edge (ENDPOINT_HANDLE_RESERVE_PX), and ANY bend handle — terminal, inner or lone — whose position falls within that reserve of either endpoint is withheld. The endpoints then own their ends; every handle that survives sits clear in the middle at a usable size. This replaces the narrower lone-segment rule. A short terminal stub (~30px) no longer exposes a bend handle — its handle only gave a ~7px grip anyway — so the endpoint grip is legible instead. Diagram 79 grips: 0px → visible; a long edge still keeps its centre bend handle. Updated the bend-handle unit tests to the endpoint-reserve behaviour and the consecutive-bend e2e to grab a reshaping handle (short stubs no longer expose one); added the pinned-jog e2e case. 1519 unit, handle + fresh-bend + interaction e2e green, gate 23/23. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 873abee commit 4dc6616

6 files changed

Lines changed: 223 additions & 54 deletions

File tree

library/lib/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,13 @@ export const EDGES = Object.freeze({
300300
* (on-top) target never abuts or buries the handle. Small — just enough to separate
301301
* them cleanly at the handle's rounded corner. */
302302
ENDPOINT_HANDLE_CLEARANCE_PX: 4,
303+
/** Run along the edge each ENDPOINT reserves for its own grip + reconnect target. A
304+
* bend handle landing within this of either endpoint is withheld, so the endpoints —
305+
* the primary, universal edge interaction — are never starved by a handle crowding
306+
* their end. Sized so the grip clears the nearest kept handle at a usable length: the
307+
* grip needs ~14px of run, and a handle eats its rendered half (≥9) plus the clearance
308+
* (4), so ~27px keeps the grip legible; rounded to a grid multiple. */
309+
ENDPOINT_HANDLE_RESERVE_PX: 30,
303310
/** Grid step a dragged bend snaps to; matches the canvas grid so bends line
304311
* up with grid-snapped node handles. */
305312
BEND_SNAP_GRID_PX: CANVAS.SNAP_TO_GRID_PX,

library/lib/utils/geometry/bendHandles.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,23 @@ export function getBendableSegments(
125125
if (collapsed.length < 2) return []
126126

127127
const lastSegment = collapsed.length - 2
128+
// Total routed length and the running distance to each segment's start, so a handle can
129+
// be measured by how far ALONG THE EDGE it sits from either endpoint.
130+
const segLen = (a: IPoint, b: IPoint) =>
131+
Math.abs(b.x - a.x) + Math.abs(b.y - a.y)
132+
let totalLength = 0
133+
for (let i = 0; i <= lastSegment; i++)
134+
totalLength += segLen(collapsed[i], collapsed[i + 1])
135+
128136
const handles: BendHandle[] = []
137+
let distToSegStart = 0
129138
for (let i = 0; i <= lastSegment; i++) {
130139
const start = collapsed[i]
131140
const end = collapsed[i + 1]
132141
const rawLength = Math.abs(end.x - start.x) + Math.abs(end.y - start.y)
133142
if (rawLength <= 0) continue
143+
const segStartDist = distToSegStart
144+
distToSegStart += rawLength
134145

135146
// Reserve the near-endpoint portion of a TERMINAL segment for that endpoint's
136147
// reconnect target, which is drawn on top and would swallow a handle placed
@@ -150,22 +161,28 @@ export function getBendableSegments(
150161
if (isTerminal && !isLoneSegment && rawLength < terminalBendFloorPx()) {
151162
continue
152163
}
153-
// A LONE segment has an endpoint at BOTH ends, each of which owns its half for
154-
// reconnecting and repositioning — the primary edge interactions, present on every
155-
// edge. A centred bend handle between them starves both to nothing on a short edge
156-
// (the reconnect targets, drawn on top, cap against it). So the endpoints win: the
157-
// lone handle is withheld unless the region left BETWEEN the two endpoint reserves is
158-
// big enough for a usable, non-overlapping handle. A longer lone edge keeps its
159-
// handle in the clear middle; a short one is fully owned by its two grips.
160-
if (isLoneSegment && bendRegion < EDGES.BEND_HANDLE_MIN_SCREEN_LENGTH_PX) {
161-
continue
162-
}
163164

164165
const fitsPastSafeArea = bendRegion > 0
165166
const centreFromStart = fitsPastSafeArea
166167
? reserveStart + bendRegion / 2
167168
: rawLength / 2
168169

170+
// ENDPOINTS FIRST. The two endpoint handles — reconnect to another node, reposition
171+
// along the side — are the primary interaction on every edge. A bend handle placed
172+
// within an endpoint's reserve (measured ALONG THE EDGE) starves that endpoint's grip
173+
// to nothing, because the reconnect target, drawn on top, caps against it. So any
174+
// handle — terminal OR inner — that would land inside either endpoint's reserve is
175+
// withheld and the endpoint owns that run. A short edge is thus all grips, no bend
176+
// handles; a longer one keeps every handle that sits clear in the middle.
177+
const distFromSource = centreFromStart + segStartDist
178+
const distFromTarget = totalLength - distFromSource
179+
if (
180+
Math.min(distFromSource, distFromTarget) <
181+
EDGES.ENDPOINT_HANDLE_RESERVE_PX
182+
) {
183+
continue
184+
}
185+
169186
const t = centreFromStart / rawLength
170187
handles.push({
171188
segmentIndex: i,

library/tests/unit/bendHandles.test.ts

Lines changed: 34 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,9 @@ describe("bend handle utilities", () => {
9595
expect(longHandle.position).toEqual({ x: 60, y: 0 })
9696

9797
// Multi-bend route with SHORT terminal segments (10px source stub, 12px target
98-
// stub): both terminals are pinned to a port and cannot bend cleanly at that
99-
// length, so they get no handle — the endpoint reconnect target owns them. Only
100-
// the two INNER segments keep a handle (inner segments bend cleanly at any length).
98+
// stub): both terminals — AND the 8px inner segment tucked against the source — sit
99+
// inside an endpoint's reserve, so they are withheld and the endpoints own those
100+
// runs. Only the long middle inner segment, clear of both ends, keeps a handle.
101101
const shortTerminals = [
102102
{ x: 0, y: 0 },
103103
{ x: 10, y: 0 },
@@ -109,7 +109,7 @@ describe("bend handle utilities", () => {
109109
shortTerminals,
110110
EDGES.BEND_HANDLE_SAFE_AREA_PX
111111
)
112-
expect(shortHandles.map((h) => h.kind)).toEqual(["inner", "inner"])
112+
expect(shortHandles.map((h) => h.kind)).toEqual(["inner"])
113113

114114
// The SAME route with LONG terminal segments (60px each) keeps all four handles:
115115
// a terminal long enough to bend without cramping its stub is bendable.
@@ -127,49 +127,46 @@ describe("bend handle utilities", () => {
127127
).toEqual(["source-terminal", "inner", "inner", "target-terminal"])
128128
})
129129

130-
it("gates a terminal handle on the segment's full length, not its reserved bend region (regression)", () => {
130+
it("withholds a bend handle inside an endpoint's reserve so the grip is never starved", () => {
131131
const safeArea = EDGES.BEND_HANDLE_SAFE_AREA_PX
132-
// The user's repro: a 30px target terminal stub. Its reserved bend region is only
133-
// 30 - min(safeArea, 30/2) = 15px, but `computeTerminalJogCoordinate` bends the
134-
// FULL 30px segment cleanly, so the handle must be shown. Gating on the reserved
135-
// region (15 < 20) wrongly withheld it — which also uncapped the endpoint target so
136-
// it swallowed the neighbouring inner handle.
137-
const repro = [
132+
const reserve = EDGES.ENDPOINT_HANDLE_RESERVE_PX
133+
// A SHORT terminal (30px) puts its handle ~15px from the endpoint — inside the
134+
// reserve — where it starves that endpoint's grip. The endpoint owns the stub
135+
// instead; the two LONG middle segments keep their handles, clear of both ends.
136+
const shortTargetStub = [
138137
{ x: 440, y: -265 },
139-
{ x: 440, y: -405 }, // seg0 V 140px → source-terminal
140-
{ x: 495, y: -405 }, // seg1 H 55px → inner
141-
{ x: 495, y: -345 }, // seg2 V 60px → inner
142-
{ x: 525, y: -345 }, // seg3 H 30px → target-terminal (was wrongly withheld)
138+
{ x: 440, y: -405 }, // seg0 V 140px → source-terminal (handle ~45px from source)
139+
{ x: 495, y: -405 }, // seg1 H 55px → inner
140+
{ x: 495, y: -345 }, // seg2 V 60px → inner
141+
{ x: 525, y: -345 }, // seg3 H 30px → target-terminal, ~15px in → withheld
143142
]
144-
expect(getBendableSegments(repro, safeArea).map((h) => h.kind)).toEqual([
145-
"source-terminal",
146-
"inner",
147-
"inner",
148-
"target-terminal",
149-
])
143+
expect(
144+
getBendableSegments(shortTargetStub, safeArea).map((h) => h.kind)
145+
).toEqual(["source-terminal", "inner", "inner"])
150146

151-
// Boundary: MIN_STUB_LENGTH + armFloor. A terminal AT the floor (20px) bends
152-
// cleanly and keeps its handle; one below it (15px) degenerates and is withheld.
153-
const atFloor = [
147+
// A terminal LONG enough to seat its handle clear of the reserve keeps it: a 140px
148+
// source stub's handle sits well past the reserve from the source.
149+
const longStubs = [
154150
{ x: 0, y: 0 },
155-
{ x: 20, y: 0 }, // 20px source terminal — bendable
156-
{ x: 20, y: 200 }, // long inner
157-
{ x: 300, y: 200 }, // 280px target terminal — bendable
151+
{ x: 140, y: 0 }, // 140px source terminal — handle clear of the reserve
152+
{ x: 140, y: 200 }, // long inner
153+
{ x: 340, y: 200 }, // 200px target terminal — handle clear of the reserve
158154
]
159-
expect(getBendableSegments(atFloor, safeArea).map((h) => h.kind)).toEqual([
155+
const kept = getBendableSegments(longStubs, safeArea)
156+
expect(kept.map((h) => h.kind)).toEqual([
160157
"source-terminal",
161158
"inner",
162159
"target-terminal",
163160
])
164-
const belowFloor = [
165-
{ x: 0, y: 0 },
166-
{ x: 15, y: 0 }, // 15px source terminal — spike, withheld
167-
{ x: 15, y: 200 },
168-
{ x: 300, y: 200 },
169-
]
170-
expect(
171-
getBendableSegments(belowFloor, safeArea).map((h) => h.kind)
172-
).toEqual(["inner", "target-terminal"])
161+
// Every kept handle sits at least `reserve` along this monotonic edge from either end
162+
// (Manhattan distance from the source corner equals path distance here).
163+
const total = 140 + 200 + 200
164+
for (const h of kept) {
165+
const fromSource = h.position.x + h.position.y
166+
expect(Math.min(fromSource, total - fromSource)).toBeGreaterThanOrEqual(
167+
reserve
168+
)
169+
}
173170
})
174171

175172
it("reports the room each handle has, so the renderer can size it", () => {

standalone/webapp/tests/e2e/edge-fresh-bend.spec.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,24 @@ async function drawStraightEdge(page: Page): Promise<Locator> {
8686
* auto-selects, so its handle is already present — dragging it is the user's
8787
* "grab the middle and drag" gesture with no separate select-click. Using the
8888
* handle locator (not the path bbox) stays robust once the edge is bent. */
89+
/** The centre of a HORIZONTAL bend handle (wider than tall) on the edge — one a
90+
* vertical drag actually reshapes. After the edge bends, `.first()` may be a vertical
91+
* stub handle a vertical drag cannot move. */
92+
async function pickHorizontalBendHandle(
93+
page: Page,
94+
edge: Locator
95+
): Promise<{ cx: number; cy: number }> {
96+
const handles = edge.locator(".edge-bend-handle")
97+
const count = await handles.count()
98+
for (let i = 0; i < count; i++) {
99+
const box = await handles.nth(i).boundingBox()
100+
if (box && box.width > box.height) {
101+
return { cx: box.x + box.width / 2, cy: box.y + box.height / 2 }
102+
}
103+
}
104+
throw new Error("no horizontal bend handle to drag")
105+
}
106+
89107
async function dragFirstHandle(
90108
page: Page,
91109
edge: Locator,
@@ -202,13 +220,20 @@ test.describe("Fresh-edge first bend — drawn edge", () => {
202220
}) => {
203221
const edge = await drawStraightEdge(page)
204222
const seen: string[] = []
205-
// Each drag RESHAPES the bend (all pull the same way, by different amounts).
206-
// We deliberately do not drag a bend flat here: once the edge is bent,
207-
// `.first()` is the source-TERMINAL handle, and pulling it back across the
208-
// straight line legitimately returns a shallow edge to its auto-route
209-
// (data.points = []) — a separate gesture, not a snap-back to regress.
210-
for (const dy of [-40, -30, -50]) {
211-
await dragFirstHandle(page, edge, dy)
223+
// The first drag bends the (horizontal) straight edge via its centre handle. Each
224+
// FURTHER drag RESHAPES the bend by pulling the top HORIZONTAL segment (a vertical
225+
// drag on it moves the lane); we grab a horizontal handle rather than `.first()`,
226+
// which after bending is a vertical stub handle a vertical drag cannot move. The
227+
// short terminal stubs no longer expose a handle at all — the endpoints own them.
228+
await dragFirstHandle(page, edge, -40)
229+
seen.push(JSON.stringify(await persistedPoints(page)))
230+
for (const dy of [-30, -50]) {
231+
const handle = await pickHorizontalBendHandle(page, edge)
232+
await page.mouse.move(handle.cx, handle.cy)
233+
await page.mouse.down()
234+
await page.mouse.move(handle.cx, handle.cy + dy, { steps: 12 })
235+
await page.mouse.up()
236+
await page.waitForTimeout(350)
212237
const points = await persistedPoints(page)
213238
expect(
214239
isStraightOrEmpty(points),

standalone/webapp/tests/e2e/short-edge-endpoint-handles.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,38 @@ test("a short straight edge keeps usable endpoint grips (not starved by a bend h
5353
expect(Math.min(hit!.w, hit!.h)).toBeGreaterThanOrEqual(12)
5454
}
5555
})
56+
57+
test("a short pinned S-jog edge also keeps usable endpoint grips (no bend handle crowds them)", async ({
58+
page,
59+
}) => {
60+
const fx = JSON.parse(
61+
fs.readFileSync(
62+
path.join(__d, "..", "fixtures", "short-edge-pinned-jog.json"),
63+
"utf-8"
64+
)
65+
)
66+
await openFixtureInLocalEditor(page, fx)
67+
await waitForCanvasReady(page)
68+
await page.waitForTimeout(300)
69+
await page.locator(".react-flow__edge").first().click({ force: true })
70+
await page.waitForTimeout(300)
71+
72+
const info = await page.evaluate(() => {
73+
const m = (sel: string) => {
74+
const el = document.querySelector(sel)
75+
if (!el) return null
76+
const r = el.getBoundingClientRect()
77+
return { w: Math.round(r.width), h: Math.round(r.height) }
78+
}
79+
return {
80+
bendHandles: document.querySelectorAll(".edge-bend-handle").length,
81+
sg: m(".edge-endpoint-grip--source"),
82+
tg: m(".edge-endpoint-grip--target"),
83+
}
84+
})
85+
// No bend handle crowds this short edge; both grips are visible and usable (was 0px).
86+
for (const grip of [info.sg, info.tg]) {
87+
expect(grip).not.toBeNull()
88+
expect(Math.max(grip!.w, grip!.h)).toBeGreaterThanOrEqual(10)
89+
}
90+
})
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
{
2+
"id": "e243b057-3904-4787-9718-2324379be5b4",
3+
"version": "4.1.0",
4+
"title": "",
5+
"type": "ClassDiagram",
6+
"nodes": [
7+
{
8+
"id": "b717899c-aa84-40ba-9cc1-786d722f15c0",
9+
"width": 160,
10+
"height": 100,
11+
"type": "class",
12+
"position": {
13+
"x": 520,
14+
"y": 290
15+
},
16+
"data": {
17+
"name": "Class",
18+
"methods": [
19+
{
20+
"id": "d0fed8cd-8305-4e3c-8b73-958a007fbd5b",
21+
"name": "+ method()"
22+
}
23+
],
24+
"attributes": [
25+
{
26+
"id": "232bc354-1126-413d-96f0-6e8d4a220e37",
27+
"name": "+ attribute: Type"
28+
}
29+
]
30+
},
31+
"measured": {
32+
"width": 160,
33+
"height": 100
34+
}
35+
},
36+
{
37+
"id": "3ece7475-efe8-4097-b9af-2184ac36246e",
38+
"width": 160,
39+
"height": 100,
40+
"type": "class",
41+
"position": {
42+
"x": 520,
43+
"y": 425
44+
},
45+
"data": {
46+
"name": "Class",
47+
"methods": [
48+
{
49+
"id": "44056373-0e97-4003-9b8b-9db813aaaa65",
50+
"name": "+ method()"
51+
}
52+
],
53+
"attributes": [
54+
{
55+
"id": "ecce6c5d-4ede-4aad-a6d1-65cd1c3a2bfe",
56+
"name": "+ attribute: Type"
57+
}
58+
]
59+
},
60+
"measured": {
61+
"width": 160,
62+
"height": 100
63+
}
64+
}
65+
],
66+
"edges": [
67+
{
68+
"id": "1b015227-3e36-4ad2-915e-febe1df31335",
69+
"source": "b717899c-aa84-40ba-9cc1-786d722f15c0",
70+
"target": "3ece7475-efe8-4097-b9af-2184ac36246e",
71+
"type": "ClassUnidirectional",
72+
"sourceHandle": "bottom",
73+
"targetHandle": "top-between-mid-left-center",
74+
"data": {
75+
"sourceAnchor": {
76+
"side": "bottom",
77+
"ratio": 0.46875
78+
},
79+
"targetAnchor": {
80+
"side": "top",
81+
"ratio": 0.5
82+
},
83+
"points": []
84+
}
85+
}
86+
],
87+
"assessments": {}
88+
}

0 commit comments

Comments
 (0)