@elabs-ai/components-flow 4.0.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/dist/index.d.ts +738 -20
- package/dist/index.js +985 -182
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/__contract__/inspector-panel.contract.test.tsx +49 -0
- package/src/__contract__/legend.contract.test.tsx +49 -0
- package/src/canvas-shell/canvas-shell.tsx +116 -1
- package/src/canvas-shell/use-measured-nodes.ts +101 -0
- package/src/flow-button-edge/flow-button-edge.stories.tsx +13 -0
- package/src/flow-button-edge/flow-button-edge.tsx +8 -10
- package/src/flow-edge/flow-edge.stories.tsx +20 -0
- package/src/flow-edge/flow-edge.tsx +10 -3
- package/src/flow-edge-path/flow-edge-path.tsx +149 -0
- package/src/flow-edge-path/index.ts +1 -0
- package/src/flow-edge-path/no-raw-base-edge.test.ts +45 -0
- package/src/flow-floating-edge/flow-floating-edge.tsx +7 -3
- package/src/flow-group-node/flow-group-node.stories.tsx +1 -1
- package/src/flow-group-node/flow-group-node.tsx +24 -8
- package/src/flow-handle/flow-handle-anchor.test.tsx +97 -0
- package/src/flow-handle/flow-handle-anchor.ts +36 -0
- package/src/flow-handle/index.ts +1 -0
- package/src/flow-layout/flow-layout.stories.tsx +2 -2
- package/src/flow-layout/flow-layout.test.tsx +91 -0
- package/src/flow-layout/flow-layout.ts +77 -1
- package/src/flow-layout/layout-graph.test.ts +83 -2
- package/src/flow-layout/layout-graph.ts +23 -15
- package/src/flow-mini-map/flow-mini-map.stories.tsx +103 -0
- package/src/flow-node/flow-node.stories.tsx +151 -0
- package/src/flow-node/flow-node.tsx +56 -1
- package/src/flow-placeholder-node/flow-placeholder-node.tsx +5 -2
- package/src/flow-self-loop-edge/flow-self-loop-edge.stories.tsx +275 -0
- package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +196 -0
- package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +172 -0
- package/src/flow-self-loop-edge/index.ts +13 -0
- package/src/flow-self-loop-edge/self-loop-geometry.test.ts +128 -0
- package/src/flow-self-loop-edge/self-loop-geometry.ts +165 -0
- package/src/flow-smart-edge/flow-smart-edge.stories.tsx +55 -8
- package/src/flow-smart-edge/flow-smart-edge.tsx +125 -35
- package/src/flow-smart-edge/index.ts +5 -1
- package/src/flow-smart-edge/smart-edge-geometry.test.ts +88 -47
- package/src/flow-smart-edge/smart-edge-geometry.ts +69 -33
- package/src/flow-weighted-edge/back-edge-geometry.test.ts +54 -0
- package/src/flow-weighted-edge/back-edge-geometry.ts +60 -0
- package/src/flow-weighted-edge/edge-aria.test.ts +108 -0
- package/src/flow-weighted-edge/edge-aria.ts +117 -0
- package/src/flow-weighted-edge/edge-label-pill.test.tsx +65 -0
- package/src/flow-weighted-edge/edge-label-pill.tsx +82 -0
- package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +691 -0
- package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +405 -0
- package/src/flow-weighted-edge/flow-weighted-edge.tsx +308 -0
- package/src/flow-weighted-edge/index.ts +18 -0
- package/src/flow-weighted-edge/weight-scale.test.ts +92 -0
- package/src/flow-weighted-edge/weight-scale.ts +86 -0
- package/src/index.ts +9 -0
- package/src/inspector-panel/inspector-panel.stories.tsx +1 -1
- package/src/inspector-panel/inspector-panel.test.tsx +20 -0
- package/src/inspector-panel/inspector-panel.tsx +22 -9
- package/src/legend/index.ts +7 -1
- package/src/legend/legend.stories.tsx +126 -0
- package/src/legend/legend.test.tsx +180 -0
- package/src/legend/legend.tsx +222 -3
- package/src/templates-flow-workspace.stories.tsx +1 -1
- package/src/testing/canvas-framing.test.ts +107 -0
- package/src/testing/canvas-framing.ts +396 -0
- package/src/testing/edge-anchors.ts +107 -0
- package/src/testing/index.ts +36 -0
- package/src/zoom-controls/zoom-controls.tsx +1 -1
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Story-test helpers for the invariants a CANVAS owes its reader, as distinct
|
|
3
|
+
* from the per-edge invariant in `./edge-anchors`.
|
|
4
|
+
*
|
|
5
|
+
* All four are things a green typecheck cannot see and jsdom cannot measure —
|
|
6
|
+
* they are properties of the rendered, laid-out, zoomed picture:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Everything is in frame.** A canvas that opens with half its graph below
|
|
9
|
+
* the fold has failed before the reader touches it. Measured on the process
|
|
10
|
+
* map: `fitView` fired once, before the layout hook had positions, and then
|
|
11
|
+
* never again — 5 of 11 activities opened off-screen.
|
|
12
|
+
* 2. **Connectors sit on the card.** React Flow lays a `<Handle>` out against
|
|
13
|
+
* the nearest positioned ancestor, which is NOT necessarily the painted card:
|
|
14
|
+
* a sibling row rendered beside the card pushes every dot off it by that
|
|
15
|
+
* row's own height (measured: 24 px). The wrapper still looks correct, so
|
|
16
|
+
* this must be measured against the card's own box.
|
|
17
|
+
* 3. **Labels do not cover nodes.** Edge label pills are portalled into
|
|
18
|
+
* `.react-flow__edgelabel-renderer`, so they contribute nothing to dagre's
|
|
19
|
+
* routing and nothing to React Flow's fitted bounds — a rank gap tuned for
|
|
20
|
+
* bare arrows is exactly one pill too narrow, and nothing in the layout
|
|
21
|
+
* math notices.
|
|
22
|
+
* 4. **The overview thumbnail is drawn.** `<MiniMap>` bails out per node in
|
|
23
|
+
* `nodeHasDimensions(internals.userNode)`, so a controlled canvas whose
|
|
24
|
+
* consumer never writes measurements back renders a blank white panel with
|
|
25
|
+
* a correct viewBox — no error, no warning, nothing to typecheck.
|
|
26
|
+
*
|
|
27
|
+
* Every helper returns report LINES rather than a boolean, so a failing
|
|
28
|
+
* assertion prints the offending distances: the numbers are what say whether an
|
|
29
|
+
* anchor drifted a pixel or half a node.
|
|
30
|
+
*
|
|
31
|
+
* Not part of the published surface — see `./index`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The zoom factor React Flow has applied to the viewport, or `1` if it is not rendered. */
|
|
35
|
+
export function viewportZoom(canvasElement: HTMLElement): number {
|
|
36
|
+
const viewport = canvasElement.querySelector<HTMLElement>(".react-flow__viewport");
|
|
37
|
+
if (!viewport) return 1;
|
|
38
|
+
const { a } = new DOMMatrixReadOnly(getComputedStyle(viewport).transform);
|
|
39
|
+
return Number.isFinite(a) && a > 0 ? a : 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One line per node that is not COMPLETELY inside the canvas pane.
|
|
44
|
+
*
|
|
45
|
+
* `tolerance` is a sub-pixel allowance: `fitView`'s padding maths and the
|
|
46
|
+
* browser's own rounding routinely leave an edge a fraction of a pixel proud of
|
|
47
|
+
* the pane, which no reader can see.
|
|
48
|
+
*/
|
|
49
|
+
export function nodesOutsidePane(canvasElement: HTMLElement, tolerance = 1): string[] {
|
|
50
|
+
const pane = canvasElement.querySelector<HTMLElement>(".react-flow__pane");
|
|
51
|
+
if (!pane) return ["no .react-flow__pane rendered"];
|
|
52
|
+
const bounds = pane.getBoundingClientRect();
|
|
53
|
+
const misses: string[] = [];
|
|
54
|
+
for (const node of canvasElement.querySelectorAll<HTMLElement>(".react-flow__node")) {
|
|
55
|
+
const rect = node.getBoundingClientRect();
|
|
56
|
+
const overflow = Math.max(
|
|
57
|
+
bounds.left - rect.left,
|
|
58
|
+
bounds.top - rect.top,
|
|
59
|
+
rect.right - bounds.right,
|
|
60
|
+
rect.bottom - bounds.bottom,
|
|
61
|
+
);
|
|
62
|
+
if (overflow > tolerance) {
|
|
63
|
+
const id = node.getAttribute("data-id") ?? "?";
|
|
64
|
+
misses.push(`node "${id}" is ${overflow.toFixed(1)}px outside the pane`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return misses;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One line per handle dot whose centre sits off the border of its own node's
|
|
72
|
+
* painted card, in FLOW px (screen distance divided by the zoom) so the number
|
|
73
|
+
* means the same at any zoom.
|
|
74
|
+
*
|
|
75
|
+
* `cardSelector` selects the painted card inside each `.react-flow__node`
|
|
76
|
+
* wrapper — `[data-slot="flow-node"]` for a canvas built on `FlowNode`. A node
|
|
77
|
+
* whose wrapper contains no such card is skipped rather than reported: a canvas
|
|
78
|
+
* may legitimately mix node types.
|
|
79
|
+
*/
|
|
80
|
+
export function handlesOffCard(
|
|
81
|
+
canvasElement: HTMLElement,
|
|
82
|
+
cardSelector = '[data-slot="flow-node"]',
|
|
83
|
+
tolerance = 1,
|
|
84
|
+
): string[] {
|
|
85
|
+
const zoom = viewportZoom(canvasElement);
|
|
86
|
+
const misses: string[] = [];
|
|
87
|
+
for (const node of canvasElement.querySelectorAll<HTMLElement>(".react-flow__node")) {
|
|
88
|
+
const card = node.querySelector<HTMLElement>(cardSelector);
|
|
89
|
+
if (!card) continue;
|
|
90
|
+
const box = card.getBoundingClientRect();
|
|
91
|
+
for (const handle of node.querySelectorAll<HTMLElement>(".react-flow__handle")) {
|
|
92
|
+
const rect = handle.getBoundingClientRect();
|
|
93
|
+
const x = rect.left + rect.width / 2;
|
|
94
|
+
const y = rect.top + rect.height / 2;
|
|
95
|
+
// Distance from the point to the card RECTANGLE (0 when the point is inside it).
|
|
96
|
+
const dx = Math.max(box.left - x, 0, x - box.right);
|
|
97
|
+
const dy = Math.max(box.top - y, 0, y - box.bottom);
|
|
98
|
+
const gap = Math.hypot(dx, dy) / zoom;
|
|
99
|
+
if (gap > tolerance) {
|
|
100
|
+
const id = node.getAttribute("data-id") ?? "?";
|
|
101
|
+
const side = handle.getAttribute("data-handlepos") ?? "?";
|
|
102
|
+
misses.push(`handle ${side} on "${id}" is ${gap.toFixed(1)}px off its card`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return misses;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* One line per portalled edge label that overlaps a node card.
|
|
111
|
+
*
|
|
112
|
+
* Rectangle overlap, not centre distance: a pill that clips a card's corner by
|
|
113
|
+
* two pixels is still a number printed on top of a name, which is the thing a
|
|
114
|
+
* reader complains about.
|
|
115
|
+
*/
|
|
116
|
+
export function labelsOverNodes(
|
|
117
|
+
canvasElement: HTMLElement,
|
|
118
|
+
cardSelector = '[data-slot="flow-node"]',
|
|
119
|
+
): string[] {
|
|
120
|
+
const labels = [
|
|
121
|
+
...canvasElement.querySelectorAll<HTMLElement>(".react-flow__edgelabel-renderer > *"),
|
|
122
|
+
];
|
|
123
|
+
const cards = [...canvasElement.querySelectorAll<HTMLElement>(cardSelector)];
|
|
124
|
+
const hits: string[] = [];
|
|
125
|
+
for (const label of labels) {
|
|
126
|
+
const a = label.getBoundingClientRect();
|
|
127
|
+
if (a.width === 0 || a.height === 0) continue;
|
|
128
|
+
for (const card of cards) {
|
|
129
|
+
const b = card.getBoundingClientRect();
|
|
130
|
+
if (a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom) {
|
|
131
|
+
const id = card.closest(".react-flow__node")?.getAttribute("data-id") ?? "?";
|
|
132
|
+
hits.push(`label "${(label.textContent ?? "").trim()}" covers node "${id}"`);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return hits;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The framing invariant for a canvas that is allowed to open ZOOMED IN.
|
|
142
|
+
*
|
|
143
|
+
* `nodesOutsidePane` alone says "everything fits", which is the right bar only while the
|
|
144
|
+
* whole graph is meant to fit. A canvas with a legibility floor on its opening fit gives
|
|
145
|
+
* that up on purpose: a process wider than the pane opens readable and partial rather
|
|
146
|
+
* than complete and unreadable. The bar that replaces it is a disjunction —
|
|
147
|
+
*
|
|
148
|
+
* 1. everything is in frame; **or**
|
|
149
|
+
* 2. the fit was CLAMPED by the floor (the zoom is sitting on it), and the beginning of
|
|
150
|
+
* the process — the first node along each overflowing axis — is fully in frame.
|
|
151
|
+
*
|
|
152
|
+
* Both halves are falsifiable, which is the point of writing it this way. Lose the fit
|
|
153
|
+
* and nodes go out of frame at a zoom BELOW the floor, failing (1) and (2). Lose the
|
|
154
|
+
* anchoring and the clamped fit centres again, so the first node drops off the top or the
|
|
155
|
+
* start edge, failing (2). "Partial" never becomes an excuse for "wrong".
|
|
156
|
+
*
|
|
157
|
+
* Pass `legibleZoom: 0` (the default) to get the plain everything-fits bar back.
|
|
158
|
+
*/
|
|
159
|
+
export function framingMisses(
|
|
160
|
+
canvasElement: HTMLElement,
|
|
161
|
+
{ legibleZoom = 0, tolerance = 1 }: { legibleZoom?: number; tolerance?: number } = {},
|
|
162
|
+
): string[] {
|
|
163
|
+
const outside = nodesOutsidePane(canvasElement, tolerance);
|
|
164
|
+
if (outside.length === 0) return [];
|
|
165
|
+
if (legibleZoom <= 0) return outside;
|
|
166
|
+
|
|
167
|
+
const zoom = viewportZoom(canvasElement);
|
|
168
|
+
if (zoom < legibleZoom - 1e-3) {
|
|
169
|
+
return [
|
|
170
|
+
`${outside.length} node(s) out of frame at zoom ${zoom.toFixed(3)}, below the legible ` +
|
|
171
|
+
`floor of ${legibleZoom} — the fit was not clamped, it simply missed`,
|
|
172
|
+
...outside,
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const pane = canvasElement.querySelector<HTMLElement>(".react-flow__pane");
|
|
177
|
+
if (!pane) return ["no .react-flow__pane rendered"];
|
|
178
|
+
const bounds = pane.getBoundingClientRect();
|
|
179
|
+
const nodes = [...canvasElement.querySelectorAll<HTMLElement>(".react-flow__node")].map(
|
|
180
|
+
(element) => ({
|
|
181
|
+
id: element.getAttribute("data-id") ?? "?",
|
|
182
|
+
rect: element.getBoundingClientRect(),
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
if (nodes.length === 0) return ["no nodes rendered"];
|
|
186
|
+
|
|
187
|
+
const spanX =
|
|
188
|
+
Math.max(...nodes.map((n) => n.rect.right)) - Math.min(...nodes.map((n) => n.rect.left));
|
|
189
|
+
const spanY =
|
|
190
|
+
Math.max(...nodes.map((n) => n.rect.bottom)) - Math.min(...nodes.map((n) => n.rect.top));
|
|
191
|
+
const inFrame = (r: DOMRect) =>
|
|
192
|
+
r.left >= bounds.left - tolerance &&
|
|
193
|
+
r.top >= bounds.top - tolerance &&
|
|
194
|
+
r.right <= bounds.right + tolerance &&
|
|
195
|
+
r.bottom <= bounds.bottom + tolerance;
|
|
196
|
+
|
|
197
|
+
const misses: string[] = [];
|
|
198
|
+
// Per axis, and only where the content actually outruns the pane: a tall graph in a
|
|
199
|
+
// wide pane is pinned to the top and stays horizontally centred, so demanding a
|
|
200
|
+
// left-edge anchor there would fail a canvas that is framed exactly right.
|
|
201
|
+
if (spanX > bounds.width + tolerance) {
|
|
202
|
+
const first = nodes.reduce((a, b) => (b.rect.left < a.rect.left ? b : a));
|
|
203
|
+
if (!inFrame(first.rect)) {
|
|
204
|
+
misses.push(
|
|
205
|
+
`the graph starts at node "${first.id}", which the clamped fit left out of frame`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (spanY > bounds.height + tolerance) {
|
|
210
|
+
const first = nodes.reduce((a, b) => (b.rect.top < a.rect.top ? b : a));
|
|
211
|
+
if (!inFrame(first.rect)) {
|
|
212
|
+
misses.push(
|
|
213
|
+
`the graph starts at node "${first.id}", which the clamped fit left out of frame`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return misses;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** How many node rectangles the overview thumbnail has actually drawn. */
|
|
221
|
+
export function miniMapNodeCount(canvasElement: HTMLElement): number {
|
|
222
|
+
const miniMap = canvasElement.querySelector(".react-flow__minimap");
|
|
223
|
+
if (!miniMap) return -1;
|
|
224
|
+
return miniMap.querySelectorAll(".react-flow__minimap-node").length;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** One node's laid-out position, read from React Flow's own inline transform. */
|
|
228
|
+
export interface NodePlacement {
|
|
229
|
+
id: string;
|
|
230
|
+
x: number;
|
|
231
|
+
y: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Every node's laid-out position, parsed out of `style.transform`.
|
|
236
|
+
*
|
|
237
|
+
* The inline transform, not `getBoundingClientRect()`: React Flow writes
|
|
238
|
+
* `node.position` there un-animated, so it reads the final coordinate on the
|
|
239
|
+
* first commit that has it — whatever frame a CSS entry transition happens to
|
|
240
|
+
* be on, and whichever way the viewport has since been panned.
|
|
241
|
+
*/
|
|
242
|
+
export function nodePlacements(canvasElement: HTMLElement): NodePlacement[] {
|
|
243
|
+
const out: NodePlacement[] = [];
|
|
244
|
+
for (const node of canvasElement.querySelectorAll<HTMLElement>(".react-flow__node")) {
|
|
245
|
+
const match = /translate\(\s*(-?[\d.]+)px\s*,\s*(-?[\d.]+)px\s*\)/.exec(node.style.transform);
|
|
246
|
+
if (!match) continue;
|
|
247
|
+
out.push({ id: node.getAttribute("data-id") ?? "?", x: Number(match[1]), y: Number(match[2]) });
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Node placements plus the viewport zoom, as one comparable string.
|
|
254
|
+
*
|
|
255
|
+
* Carries BOTH coordinate systems on purpose. `nodePlacements` reads the inline
|
|
256
|
+
* `transform` React Flow writes, which jumps to its final value the instant the layout
|
|
257
|
+
* runs — so a canvas that ANIMATES its nodes into place looks settled to it while the
|
|
258
|
+
* cards are still sliding, and every measurement taken then is of a picture the reader
|
|
259
|
+
* never sees. The rendered rect is what actually moves during the transition, so
|
|
260
|
+
* including it is what makes `waitForSettledCanvas` wait for the animation as well as
|
|
261
|
+
* for the layout. Rounded to 0.1 px: sub-pixel jitter is not motion.
|
|
262
|
+
*/
|
|
263
|
+
export function canvasSignature(canvasElement: HTMLElement): string {
|
|
264
|
+
const placements = nodePlacements(canvasElement)
|
|
265
|
+
.map((p) => `${p.id}@${p.x},${p.y}`)
|
|
266
|
+
.join("|");
|
|
267
|
+
const rendered = [...canvasElement.querySelectorAll<HTMLElement>(".react-flow__node")]
|
|
268
|
+
.map((node) => {
|
|
269
|
+
const { left, top, width, height } = node.getBoundingClientRect();
|
|
270
|
+
const at = (n: number) => n.toFixed(1);
|
|
271
|
+
return `${node.getAttribute("data-id") ?? "?"}@${at(left)},${at(top)},${at(width)}x${at(height)}`;
|
|
272
|
+
})
|
|
273
|
+
.join("|");
|
|
274
|
+
return `${viewportZoom(canvasElement).toFixed(4)} ${placements} ${rendered}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Every animation or CSS transition still RUNNING inside the canvas that is
|
|
279
|
+
* going to stop on its own.
|
|
280
|
+
*
|
|
281
|
+
* Infinite ones are skipped on purpose: a shimmer or a pulsing skeleton never
|
|
282
|
+
* reaches `finished`, and waiting for it would turn "settled" into "timed out".
|
|
283
|
+
* What this is for is the finite kind that MOVES the picture — above all the
|
|
284
|
+
* node-position transition a canvas puts on React Flow's own node elements
|
|
285
|
+
* (`PROCESS_MAP_NODE_MOTION_CLASS` is one), which is exactly the thing a
|
|
286
|
+
* measurement must not land in the middle of.
|
|
287
|
+
*/
|
|
288
|
+
function unfinishedAnimations(canvasElement: HTMLElement): Animation[] {
|
|
289
|
+
return canvasElement.getAnimations({ subtree: true }).filter((animation) => {
|
|
290
|
+
if (animation.playState !== "running") return false;
|
|
291
|
+
const iterations = animation.effect?.getTiming().iterations ?? 1;
|
|
292
|
+
return Number.isFinite(iterations);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Resolve after the browser has PRODUCED a frame, or `false` once `deadline` passes.
|
|
298
|
+
*
|
|
299
|
+
* A bare `setTimeout` is not a frame: timers fire several times per frame, and a
|
|
300
|
+
* loaded machine can go many timer ticks without the renderer advancing anything.
|
|
301
|
+
* The pair is deliberate — the timer paces the poll, the frame is what guarantees
|
|
302
|
+
* the picture had a chance to change between two reads.
|
|
303
|
+
*/
|
|
304
|
+
async function nextFrame(interval: number, deadline: number): Promise<boolean> {
|
|
305
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
306
|
+
return new Promise<boolean>((resolve) => {
|
|
307
|
+
const timer = setTimeout(() => resolve(false), Math.max(0, deadline - Date.now()));
|
|
308
|
+
requestAnimationFrame(() => {
|
|
309
|
+
clearTimeout(timer);
|
|
310
|
+
resolve(true);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Resolve once the canvas has finished laying itself out AND finished moving, or throw.
|
|
317
|
+
*
|
|
318
|
+
* **Every framing assertion needs this, and none of them can detect its
|
|
319
|
+
* absence.** A canvas that has mounted but not yet run its layout has all its
|
|
320
|
+
* nodes stacked on the model's mount position — which is fully inside the pane,
|
|
321
|
+
* has every handle on its own card, and has every edge terminating on a dot,
|
|
322
|
+
* because there is nowhere else for any of it to be. So a play function that
|
|
323
|
+
* measures too early does not fail; it PASSES, on a picture the reader never
|
|
324
|
+
* sees. That is not hypothetical: it is how this helper came to exist.
|
|
325
|
+
*
|
|
326
|
+
* Three conditions, and the first is what excludes that trap:
|
|
327
|
+
*
|
|
328
|
+
* 1. **Distinct positions.** A layout pass gives every node its own coordinate;
|
|
329
|
+
* before it runs they share one, so a duplicate means it has not landed.
|
|
330
|
+
* 2. **Nothing is still animating.** A canvas that animates node deltas moves its
|
|
331
|
+
* node elements for the whole of `duration-base` AFTER the coordinates are
|
|
332
|
+
* final, while React Flow draws every edge at the FINAL coordinate straight
|
|
333
|
+
* away — so mid-flight, edges genuinely end in mid-air, by as much as 142 px
|
|
334
|
+
* (measured on the process map). Any finite animation still running is
|
|
335
|
+
* awaited, rather than inferred from a rect that happens to look still.
|
|
336
|
+
* 3. **Stable across two consecutive polls, one produced FRAME apart** —
|
|
337
|
+
* positions AND zoom, so a mid-relayout frame or an in-flight re-fit is not
|
|
338
|
+
* mistaken for the result.
|
|
339
|
+
*
|
|
340
|
+
* The frame in (3) is the load-bearing word, and the reason this helper was
|
|
341
|
+
* rewritten. A CSS transition advances on the frame clock: two reads taken inside
|
|
342
|
+
* ONE frame return the identical rect no matter how fast the picture is actually
|
|
343
|
+
* moving. Polling on a bare timer, a machine slow enough to render at 5 fps
|
|
344
|
+
* therefore gets two identical polls mid-animation and declares a moving canvas
|
|
345
|
+
* settled — which is how CI, and only CI, saw `endpointsOffHandles` report nine
|
|
346
|
+
* edges hanging off their dots on a canvas that is correct a fifth of a second
|
|
347
|
+
* later. Pacing the poll with `requestAnimationFrame` means a starved renderer
|
|
348
|
+
* makes this helper WAIT rather than lie; the assertions it guards are unchanged,
|
|
349
|
+
* and still fail if the picture settles wrong.
|
|
350
|
+
*
|
|
351
|
+
* A settled canvas satisfies all three on the second poll, so the cost is one
|
|
352
|
+
* `interval` plus one frame, not the timeout.
|
|
353
|
+
*/
|
|
354
|
+
export async function waitForSettledCanvas(
|
|
355
|
+
canvasElement: HTMLElement,
|
|
356
|
+
{ timeout = 15_000, interval = 100 }: { timeout?: number; interval?: number } = {},
|
|
357
|
+
): Promise<void> {
|
|
358
|
+
const deadline = Date.now() + timeout;
|
|
359
|
+
let previous: string | null = null;
|
|
360
|
+
let reason = "no nodes rendered";
|
|
361
|
+
for (;;) {
|
|
362
|
+
const placements = nodePlacements(canvasElement);
|
|
363
|
+
const signature = canvasSignature(canvasElement);
|
|
364
|
+
const distinct = new Set(placements.map((p) => `${p.x},${p.y}`)).size;
|
|
365
|
+
const animating = unfinishedAnimations(canvasElement);
|
|
366
|
+
if (placements.length === 0) {
|
|
367
|
+
reason = "no nodes rendered";
|
|
368
|
+
} else if (distinct !== placements.length) {
|
|
369
|
+
reason = `${placements.length - distinct} node(s) share a position — layout has not run`;
|
|
370
|
+
} else if (animating.length > 0) {
|
|
371
|
+
reason = `${animating.length} animation(s) still running`;
|
|
372
|
+
} else if (signature !== previous) {
|
|
373
|
+
reason = "positions or zoom still changing";
|
|
374
|
+
} else {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// A running animation is waited OUT, not polled around: `finished` resolves on the
|
|
378
|
+
// frame the movement actually ends, and rejects only when it is cancelled, which is
|
|
379
|
+
// itself a change worth re-reading after.
|
|
380
|
+
if (animating.length > 0) {
|
|
381
|
+
await Promise.race([
|
|
382
|
+
Promise.allSettled(animating.map((animation) => animation.finished)),
|
|
383
|
+
new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))),
|
|
384
|
+
]);
|
|
385
|
+
}
|
|
386
|
+
previous = signature;
|
|
387
|
+
if (Date.now() >= deadline) {
|
|
388
|
+
throw new Error(`canvas did not settle within ${timeout}ms: ${reason}`);
|
|
389
|
+
}
|
|
390
|
+
if (!(await nextFrame(interval, deadline))) {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`canvas did not settle within ${timeout}ms: the renderer produced no frame (${reason})`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Story-test helpers for the invariant every brand edge owes its canvas: an
|
|
3
|
+
* edge path must **terminate on a handle dot**, not on a bare stretch of node
|
|
4
|
+
* border.
|
|
5
|
+
*
|
|
6
|
+
* Why this lives in a real browser and not in a unit test: React Flow derives
|
|
7
|
+
* edge endpoints from `handleBounds`, which it fills in from measured DOM boxes.
|
|
8
|
+
* jsdom measures nothing, so `handleBounds` is empty there and any assertion
|
|
9
|
+
* about where an edge lands is vacuous. These helpers therefore read both sides
|
|
10
|
+
* in SCREEN coordinates — the handle via `getBoundingClientRect()`, the path via
|
|
11
|
+
* its own `getScreenCTM()` so the viewport's pan/zoom transform is accounted for
|
|
12
|
+
* — and compare them.
|
|
13
|
+
*
|
|
14
|
+
* Not exported from the package barrel: this is test scaffolding for
|
|
15
|
+
* `*.stories.tsx` play functions, not part of the public surface.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A point in screen (viewport) coordinates. */
|
|
19
|
+
export interface ScreenPoint {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A rendered handle dot: its screen-space centre and radius. */
|
|
25
|
+
export interface HandleDot extends ScreenPoint {
|
|
26
|
+
/** Half the dot's larger rendered dimension, in screen px. */
|
|
27
|
+
radius: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every handle dot React Flow has painted inside `canvasElement`. */
|
|
31
|
+
export function handleDots(canvasElement: HTMLElement): HandleDot[] {
|
|
32
|
+
return Array.from(canvasElement.querySelectorAll<HTMLElement>(".react-flow__handle")).map(
|
|
33
|
+
(handle) => {
|
|
34
|
+
const rect = handle.getBoundingClientRect();
|
|
35
|
+
return {
|
|
36
|
+
x: rect.left + rect.width / 2,
|
|
37
|
+
y: rect.top + rect.height / 2,
|
|
38
|
+
radius: Math.max(rect.width, rect.height) / 2,
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The screen-space start and end points of an edge path element. */
|
|
45
|
+
export function pathEndpoints(path: SVGPathElement): [ScreenPoint, ScreenPoint] {
|
|
46
|
+
const ctm = path.getScreenCTM();
|
|
47
|
+
const svg = path.ownerSVGElement;
|
|
48
|
+
if (!ctm || !svg) throw new Error("edge path is not rendered inside a positioned <svg>");
|
|
49
|
+
const at = (length: number): ScreenPoint => {
|
|
50
|
+
const local = path.getPointAtLength(length);
|
|
51
|
+
const point = svg.createSVGPoint();
|
|
52
|
+
point.x = local.x;
|
|
53
|
+
point.y = local.y;
|
|
54
|
+
const screen = point.matrixTransform(ctm);
|
|
55
|
+
return { x: screen.x, y: screen.y };
|
|
56
|
+
};
|
|
57
|
+
return [at(0), at(path.getTotalLength())];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Edge paths of one brand edge type, selected by the `data-slot` it carries. */
|
|
61
|
+
export function edgePaths(canvasElement: HTMLElement, slot: string): SVGPathElement[] {
|
|
62
|
+
return Array.from(canvasElement.querySelectorAll<SVGPathElement>(`path[data-slot="${slot}"]`));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** How far an endpoint sits OUTSIDE the nearest dot, in screen px (0 = on or within it). */
|
|
66
|
+
function overshootOfNearestDot(point: ScreenPoint, dots: HandleDot[]): number {
|
|
67
|
+
let worst = Infinity;
|
|
68
|
+
for (const dot of dots) {
|
|
69
|
+
const gap = Math.hypot(point.x - dot.x, point.y - dot.y) - dot.radius;
|
|
70
|
+
if (gap < worst) worst = gap;
|
|
71
|
+
}
|
|
72
|
+
return worst;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One report line per endpoint that misses every handle dot, naming the edge
|
|
77
|
+
* and by how much. An empty array is the passing state, and a failing
|
|
78
|
+
* assertion prints the offending distances rather than just a boolean — the
|
|
79
|
+
* numbers are what tell you whether an anchor drifted a pixel or half a node.
|
|
80
|
+
*
|
|
81
|
+
* `tolerance` is a sub-pixel rounding allowance on top of the dot's own radius,
|
|
82
|
+
* because an endpoint landing anywhere ON the dot reads as connected: React
|
|
83
|
+
* Flow's native anchors sit on the dot's outer rim, `FlowSmartEdge`'s on its
|
|
84
|
+
* centre.
|
|
85
|
+
*/
|
|
86
|
+
export function endpointsOffHandles(
|
|
87
|
+
canvasElement: HTMLElement,
|
|
88
|
+
slot: string,
|
|
89
|
+
tolerance = 1,
|
|
90
|
+
): string[] {
|
|
91
|
+
const dots = handleDots(canvasElement);
|
|
92
|
+
if (!dots.length) return [`${slot}: no handle dots rendered`];
|
|
93
|
+
const misses: string[] = [];
|
|
94
|
+
for (const [index, path] of edgePaths(canvasElement, slot).entries()) {
|
|
95
|
+
const [start, end] = pathEndpoints(path);
|
|
96
|
+
for (const [label, point] of [
|
|
97
|
+
["start", start],
|
|
98
|
+
["end", end],
|
|
99
|
+
] as const) {
|
|
100
|
+
const overshoot = overshootOfNearestDot(point, dots);
|
|
101
|
+
if (overshoot > tolerance) {
|
|
102
|
+
misses.push(`${slot}[${index}].${label} is ${overshoot.toFixed(1)}px off the nearest dot`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return misses;
|
|
107
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-only measurement helpers for `*.stories.tsx` play functions.
|
|
3
|
+
*
|
|
4
|
+
* Reachable across the workspace as `@elabs-ai/components-flow/test`, because
|
|
5
|
+
* the composing packages owe the SAME canvas invariants and re-deriving this
|
|
6
|
+
* DOM arithmetic per package is how two canvases end up disagreeing about what
|
|
7
|
+
* "connected" means.
|
|
8
|
+
*
|
|
9
|
+
* **Deliberately source-only.** The `./test` key exists in `package.json`'s
|
|
10
|
+
* `exports` but NOT in `publishConfig.exports` and NOT as a `tsup` entry, so it
|
|
11
|
+
* resolves for workspace consumers and does not exist in a published tarball:
|
|
12
|
+
* this is test scaffolding, not public API, and it must not turn up in a
|
|
13
|
+
* consumer's autocomplete. The `/test` suffix is also what keeps it out of
|
|
14
|
+
* `brand-ui.manifest.json` (`readSubpathBarrels` skips any subpath ending in
|
|
15
|
+
* `/test`), so no agent can mistake a measurement helper for a component.
|
|
16
|
+
*/
|
|
17
|
+
export {
|
|
18
|
+
edgePaths,
|
|
19
|
+
endpointsOffHandles,
|
|
20
|
+
handleDots,
|
|
21
|
+
pathEndpoints,
|
|
22
|
+
type HandleDot,
|
|
23
|
+
type ScreenPoint,
|
|
24
|
+
} from "./edge-anchors";
|
|
25
|
+
export {
|
|
26
|
+
canvasSignature,
|
|
27
|
+
framingMisses,
|
|
28
|
+
handlesOffCard,
|
|
29
|
+
labelsOverNodes,
|
|
30
|
+
miniMapNodeCount,
|
|
31
|
+
nodePlacements,
|
|
32
|
+
nodesOutsidePane,
|
|
33
|
+
viewportZoom,
|
|
34
|
+
waitForSettledCanvas,
|
|
35
|
+
type NodePlacement,
|
|
36
|
+
} from "./canvas-framing";
|
|
@@ -20,7 +20,7 @@ function ControlButton({
|
|
|
20
20
|
type="button"
|
|
21
21
|
aria-label={label}
|
|
22
22
|
onClick={onClick}
|
|
23
|
-
className="flex size-8 items-center justify-center text-foreground transition-colors duration-fast hover:bg-surface-muted focus-
|
|
23
|
+
className="flex size-8 items-center justify-center text-foreground transition-colors duration-fast hover:bg-surface-muted focus-ring [&_svg]:size-4"
|
|
24
24
|
>
|
|
25
25
|
{children}
|
|
26
26
|
</button>
|