@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elabs-ai/components-flow",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"@xyflow/react": "^12.11.1",
|
|
33
33
|
"react": "^18.2.0 || ^19.0.0",
|
|
34
34
|
"react-dom": "^18.2.0 || ^19.0.0",
|
|
35
|
-
"@elabs-ai/components-tokens": "4.
|
|
36
|
-
"@elabs-ai/components-ui": "4.
|
|
35
|
+
"@elabs-ai/components-tokens": "4.2.0",
|
|
36
|
+
"@elabs-ai/components-ui": "4.2.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@testing-library/jest-dom": "^6.6.3",
|
|
@@ -52,10 +52,10 @@
|
|
|
52
52
|
"typescript": "^5.7.3",
|
|
53
53
|
"vitest": "^3.0.2",
|
|
54
54
|
"@elabs-ai/components-eslint-config": "0.1.0",
|
|
55
|
-
"@elabs-ai/components-
|
|
56
|
-
"@elabs-ai/components-
|
|
57
|
-
"@elabs-ai/components-
|
|
58
|
-
"@elabs-ai/components-
|
|
55
|
+
"@elabs-ai/components-icons": "4.2.0",
|
|
56
|
+
"@elabs-ai/components-tokens": "4.2.0",
|
|
57
|
+
"@elabs-ai/components-typescript-config": "0.1.0",
|
|
58
|
+
"@elabs-ai/components-ui": "4.2.0"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
61
61
|
"build": "tsup",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// GENERATED by scripts/gen-contract-tests.mjs — do not edit; re-run the generator instead.
|
|
2
|
+
/**
|
|
3
|
+
* Contract probe for InspectorPanel (`packages/flow/src/inspector-panel/inspector-panel.tsx`), derived from its
|
|
4
|
+
* `Default` story (packages/flow/src/inspector-panel/inspector-panel.stories.tsx). See scripts/gen-contract-tests.mjs.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
7
|
+
import { render, cleanup } from "@testing-library/react";
|
|
8
|
+
import { createRef } from "react";
|
|
9
|
+
import * as stories from "../inspector-panel/inspector-panel.stories";
|
|
10
|
+
import knownFailuresJson from "../../../../scripts/check/contract-known-failures.json";
|
|
11
|
+
|
|
12
|
+
afterEach(cleanup);
|
|
13
|
+
|
|
14
|
+
// Every component here has its own prop/ref/element shape; a generated probe
|
|
15
|
+
// stays generic on purpose (loosely typed, not untyped — see
|
|
16
|
+
// scripts/gen-contract-tests.mjs) rather than re-deriving each one.
|
|
17
|
+
const KNOWN_FAILURES: Record<string, string> = knownFailuresJson;
|
|
18
|
+
const meta = stories.default as { component?: unknown; args?: Record<string, unknown> };
|
|
19
|
+
const Default = (stories as { Default?: { args?: Record<string, unknown> } }).Default;
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- probe target; see comment above
|
|
21
|
+
const Component = meta.component as any;
|
|
22
|
+
const args = { ...(meta.args ?? {}), ...(Default?.args ?? {}) };
|
|
23
|
+
const isForwardRefComponent = Component?.["$$typeof"] === Symbol.for("react.forward_ref");
|
|
24
|
+
|
|
25
|
+
/** Wrap a known, tracked failure in `it.fails` so fixing it forces the key's removal. */
|
|
26
|
+
function contractIt(assertion: string, name: string, fn: () => void) {
|
|
27
|
+
const key = `flow-inspectorpanel--default|jsdom|jsdom|${assertion}`;
|
|
28
|
+
const reason = KNOWN_FAILURES[key];
|
|
29
|
+
if (reason) return it.fails(`${name} (known failure: ${reason})`, fn);
|
|
30
|
+
return it(name, fn);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("InspectorPanel contract", () => {
|
|
34
|
+
it.skipIf(!isForwardRefComponent)("forwards a ref to a DOM element", () => {
|
|
35
|
+
const ref = createRef<Element>();
|
|
36
|
+
render(<Component {...args} ref={ref} />);
|
|
37
|
+
expect(ref.current).toBeInstanceOf(Element);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
contractIt("className", "merges a caller className onto the root", () => {
|
|
41
|
+
const { container } = render(<Component {...args} className="contract-probe" />);
|
|
42
|
+
expect(container.querySelector(".contract-probe")).not.toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
contractIt("data-slot", 'exposes data-slot="inspector-panel" on its root', () => {
|
|
46
|
+
const { container } = render(<Component {...args} />);
|
|
47
|
+
expect(container.querySelector('[data-slot="inspector-panel"]')).not.toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// GENERATED by scripts/gen-contract-tests.mjs — do not edit; re-run the generator instead.
|
|
2
|
+
/**
|
|
3
|
+
* Contract probe for Legend (`packages/flow/src/legend/legend.tsx`), derived from its
|
|
4
|
+
* `Default` story (packages/flow/src/legend/legend.stories.tsx). See scripts/gen-contract-tests.mjs.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
7
|
+
import { render, cleanup } from "@testing-library/react";
|
|
8
|
+
import { createRef } from "react";
|
|
9
|
+
import * as stories from "../legend/legend.stories";
|
|
10
|
+
import knownFailuresJson from "../../../../scripts/check/contract-known-failures.json";
|
|
11
|
+
|
|
12
|
+
afterEach(cleanup);
|
|
13
|
+
|
|
14
|
+
// Every component here has its own prop/ref/element shape; a generated probe
|
|
15
|
+
// stays generic on purpose (loosely typed, not untyped — see
|
|
16
|
+
// scripts/gen-contract-tests.mjs) rather than re-deriving each one.
|
|
17
|
+
const KNOWN_FAILURES: Record<string, string> = knownFailuresJson;
|
|
18
|
+
const meta = stories.default as { component?: unknown; args?: Record<string, unknown> };
|
|
19
|
+
const Default = (stories as { Default?: { args?: Record<string, unknown> } }).Default;
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- probe target; see comment above
|
|
21
|
+
const Component = meta.component as any;
|
|
22
|
+
const args = { ...(meta.args ?? {}), ...(Default?.args ?? {}) };
|
|
23
|
+
const isForwardRefComponent = Component?.["$$typeof"] === Symbol.for("react.forward_ref");
|
|
24
|
+
|
|
25
|
+
/** Wrap a known, tracked failure in `it.fails` so fixing it forces the key's removal. */
|
|
26
|
+
function contractIt(assertion: string, name: string, fn: () => void) {
|
|
27
|
+
const key = `flow-legend--default|jsdom|jsdom|${assertion}`;
|
|
28
|
+
const reason = KNOWN_FAILURES[key];
|
|
29
|
+
if (reason) return it.fails(`${name} (known failure: ${reason})`, fn);
|
|
30
|
+
return it(name, fn);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("Legend contract", () => {
|
|
34
|
+
it.skipIf(!isForwardRefComponent)("forwards a ref to a DOM element", () => {
|
|
35
|
+
const ref = createRef<Element>();
|
|
36
|
+
render(<Component {...args} ref={ref} />);
|
|
37
|
+
expect(ref.current).toBeInstanceOf(Element);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
contractIt("className", "merges a caller className onto the root", () => {
|
|
41
|
+
const { container } = render(<Component {...args} className="contract-probe" />);
|
|
42
|
+
expect(container.querySelector(".contract-probe")).not.toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
contractIt("data-slot", 'exposes data-slot="legend" on its root', () => {
|
|
46
|
+
const { container } = render(<Component {...args} />);
|
|
47
|
+
expect(container.querySelector('[data-slot="legend"]')).not.toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
1
|
+
import { useEffect, type ReactNode } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Background,
|
|
4
4
|
ReactFlow,
|
|
5
5
|
ReactFlowProvider,
|
|
6
|
+
useNodesInitialized,
|
|
7
|
+
useReactFlow,
|
|
8
|
+
useStoreApi,
|
|
6
9
|
type AriaLabelConfig,
|
|
7
10
|
type Edge,
|
|
11
|
+
type FitViewOptions,
|
|
8
12
|
type Node,
|
|
9
13
|
type ReactFlowProps,
|
|
10
14
|
} from "@xyflow/react";
|
|
11
15
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
12
16
|
import { HelperLines } from "../helper-lines/helper-lines";
|
|
13
17
|
import { useHelperLines } from "../helper-lines/use-helper-lines";
|
|
18
|
+
import { useMeasuredNodes } from "./use-measured-nodes";
|
|
14
19
|
|
|
15
20
|
export interface CanvasShellProps<
|
|
16
21
|
NodeType extends Node = Node,
|
|
@@ -27,6 +32,26 @@ export interface CanvasShellProps<
|
|
|
27
32
|
* use the exported `useHelperLines` + `<HelperLines>` directly.
|
|
28
33
|
*/
|
|
29
34
|
helperLines?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Re-fit the viewport whenever this value changes.
|
|
37
|
+
*
|
|
38
|
+
* React Flow's own `fitView` prop fits ONCE, as soon as the nodes are first measured,
|
|
39
|
+
* and then never again. A canvas whose positions arrive AFTER that first paint —
|
|
40
|
+
* anything laid out asynchronously, e.g. a debounced `layoutFlow` pass — therefore gets
|
|
41
|
+
* fitted to the pre-layout positions (typically every node still stacked at the origin)
|
|
42
|
+
* and stays there. Measured on the process map: the viewport pinned at React Flow's
|
|
43
|
+
* `maxZoom` of 2 with 2 of 11 nodes on screen, while the layout underneath was correct
|
|
44
|
+
* the whole time.
|
|
45
|
+
*
|
|
46
|
+
* Pass the key the layout is cached on (structure + direction), NOT something that
|
|
47
|
+
* changes on every render and not a metric: a re-fit is a viewport jump, so it must
|
|
48
|
+
* happen when the picture genuinely moved and never while the reader is reading. The
|
|
49
|
+
* fit waits for `useNodesInitialized`, so it measures real node boxes rather than
|
|
50
|
+
* fitting a degenerate bounding box a second time.
|
|
51
|
+
*/
|
|
52
|
+
fitViewKey?: string | number;
|
|
53
|
+
/** Options for the {@link fitViewKey} re-fit. Ignored without one. */
|
|
54
|
+
fitViewKeyOptions?: FitViewOptions;
|
|
30
55
|
/** Overlays rendered inside the flow (ZoomControls, Legend, Panels). */
|
|
31
56
|
children?: ReactNode;
|
|
32
57
|
className?: string;
|
|
@@ -91,23 +116,113 @@ function CanvasShellBase<NodeType extends Node, EdgeType extends Edge>({
|
|
|
91
116
|
children,
|
|
92
117
|
className,
|
|
93
118
|
ariaLabelConfig,
|
|
119
|
+
nodes,
|
|
120
|
+
onNodesChange,
|
|
121
|
+
fitViewKey,
|
|
122
|
+
fitViewKeyOptions,
|
|
94
123
|
...props
|
|
95
124
|
}: CanvasShellInnerProps<NodeType, EdgeType>) {
|
|
125
|
+
const measured = useMeasuredNodes<NodeType>(nodes, onNodesChange);
|
|
96
126
|
return (
|
|
97
127
|
<div className={cn("h-full w-full bg-canvas", className)}>
|
|
98
128
|
<ReactFlow
|
|
99
129
|
fitView
|
|
100
130
|
proOptions={{ hideAttribution: true }}
|
|
101
131
|
ariaLabelConfig={{ ...DEFAULT_ARIA_LABEL_CONFIG, ...ariaLabelConfig }}
|
|
132
|
+
nodes={measured.nodes}
|
|
133
|
+
onNodesChange={measured.onNodesChange}
|
|
102
134
|
{...props}
|
|
103
135
|
>
|
|
104
136
|
{background ? <Background gap={20} size={1} color="var(--canvas-grid)" /> : null}
|
|
137
|
+
{fitViewKey === undefined ? null : (
|
|
138
|
+
<FitViewOnKey fitViewKey={fitViewKey} options={fitViewKeyOptions} />
|
|
139
|
+
)}
|
|
105
140
|
{children}
|
|
106
141
|
</ReactFlow>
|
|
107
142
|
</div>
|
|
108
143
|
);
|
|
109
144
|
}
|
|
110
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Re-fits the viewport when `fitViewKey` changes and the nodes have been measured.
|
|
148
|
+
*
|
|
149
|
+
* Rendered as a CHILD of `<ReactFlow>` because `useReactFlow`/`useNodesInitialized` need
|
|
150
|
+
* the flow context, which only exists below it. It renders nothing.
|
|
151
|
+
*/
|
|
152
|
+
function FitViewOnKey({
|
|
153
|
+
fitViewKey,
|
|
154
|
+
options,
|
|
155
|
+
}: {
|
|
156
|
+
fitViewKey: string | number;
|
|
157
|
+
options?: FitViewOptions;
|
|
158
|
+
}) {
|
|
159
|
+
const { fitView, getNodes, getNodesBounds, setViewport } = useReactFlow();
|
|
160
|
+
const store = useStoreApi();
|
|
161
|
+
const nodesInitialized = useNodesInitialized();
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!nodesInitialized) return;
|
|
164
|
+
void fitView(options).then(() =>
|
|
165
|
+
anchorToStartWhenClamped(store, setViewport, getNodesBounds, getNodes(), options),
|
|
166
|
+
);
|
|
167
|
+
// `options` is deliberately absent: an inline object literal would re-fit on every
|
|
168
|
+
// render, which is a viewport jump under the reader's cursor. The key is the trigger.
|
|
169
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
170
|
+
}, [fitViewKey, nodesInitialized, fitView, getNodes, getNodesBounds, setViewport, store]);
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* After a fit that could NOT show everything, show the BEGINNING rather than the middle.
|
|
176
|
+
*
|
|
177
|
+
* `fitView` always centres. That is right when the content fits — and wrong the moment a
|
|
178
|
+
* `minZoom` floor stops it from shrinking far enough, because centring a graph that
|
|
179
|
+
* overflows puts its first rank off the top of the pane and opens the canvas on the
|
|
180
|
+
* middle of a process nobody has read the start of yet. Every layout this shell is used
|
|
181
|
+
* with runs from an origin (top for `TB`, left for `LR`), so the content's own top-left
|
|
182
|
+
* corner IS the beginning, in both.
|
|
183
|
+
*
|
|
184
|
+
* Applied PER AXIS, and only to an axis that actually overflows: a tall graph in a wide
|
|
185
|
+
* pane is pinned to the top and stays horizontally centred, which is what a reader
|
|
186
|
+
* expects. A canvas whose content fits is left exactly as `fitView` left it.
|
|
187
|
+
*/
|
|
188
|
+
function anchorToStartWhenClamped(
|
|
189
|
+
store: ReturnType<typeof useStoreApi>,
|
|
190
|
+
setViewport: ReturnType<typeof useReactFlow>["setViewport"],
|
|
191
|
+
// The HOOK's `getNodesBounds`, never the top-level export: the standalone one warns in
|
|
192
|
+
// development when it is handed no `nodeLookup` ("Please use `getNodesBounds` from
|
|
193
|
+
// `useReactFlow`…") and measures a parent's own `position` instead of resolving a
|
|
194
|
+
// nested node's absolute one, so a keyed re-fit on a canvas with sub-flows anchors to
|
|
195
|
+
// the wrong corner.
|
|
196
|
+
getNodesBounds: ReturnType<typeof useReactFlow>["getNodesBounds"],
|
|
197
|
+
nodes: Node[],
|
|
198
|
+
options: FitViewOptions | undefined,
|
|
199
|
+
): void {
|
|
200
|
+
if (nodes.length === 0) return;
|
|
201
|
+
const { width, height, transform, panZoom } = store.getState();
|
|
202
|
+
const [x, y, zoom] = transform;
|
|
203
|
+
if (!width || !height || !zoom) return;
|
|
204
|
+
|
|
205
|
+
const bounds = getNodesBounds(nodes);
|
|
206
|
+
// The same fraction-of-the-pane padding `fitView` itself applies, so the anchored
|
|
207
|
+
// corner sits exactly where a fitted one would.
|
|
208
|
+
const padding = typeof options?.padding === "number" ? options.padding : 0.1;
|
|
209
|
+
const padX = width * padding;
|
|
210
|
+
const padY = height * padding;
|
|
211
|
+
|
|
212
|
+
const overflowsX = bounds.width * zoom > width - padX * 2 + 1;
|
|
213
|
+
const overflowsY = bounds.height * zoom > height - padY * 2 + 1;
|
|
214
|
+
if (!overflowsX && !overflowsY) return;
|
|
215
|
+
|
|
216
|
+
const next = {
|
|
217
|
+
x: overflowsX ? padX - bounds.x * zoom : x,
|
|
218
|
+
y: overflowsY ? padY - bounds.y * zoom : y,
|
|
219
|
+
zoom,
|
|
220
|
+
};
|
|
221
|
+
// `setViewport` is a no-op before the pan/zoom instance exists (the very first commit).
|
|
222
|
+
if (!panZoom) return;
|
|
223
|
+
void setViewport(next);
|
|
224
|
+
}
|
|
225
|
+
|
|
111
226
|
function CanvasShellWithHelperLines<NodeType extends Node, EdgeType extends Edge>({
|
|
112
227
|
onNodesChange,
|
|
113
228
|
children,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { Node, NodeChange, OnNodesChange } from "@xyflow/react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Feed React Flow's own measurements back onto the node objects a CONTROLLED canvas
|
|
6
|
+
* renders.
|
|
7
|
+
*
|
|
8
|
+
* React Flow measures every node in the DOM and keeps the result on its INTERNAL node
|
|
9
|
+
* record; the objects the consumer passed in `nodes` are never touched. That is fine for
|
|
10
|
+
* the renderer — edges and the viewport read the internal record — but three shipped
|
|
11
|
+
* surfaces read `node.measured` off the USER object instead, and each of them silently
|
|
12
|
+
* degrades when it is absent:
|
|
13
|
+
*
|
|
14
|
+
* - **`<MiniMap>`** bails out per node in `nodeHasDimensions(internals.userNode)` and
|
|
15
|
+
* renders NOTHING. Measured: a `CanvasShell` with 11 nodes drew 0 minimap rects while
|
|
16
|
+
* the minimap's own `viewBox` (computed from the internal records) was correct — a
|
|
17
|
+
* blank white panel, in the flow package's own `FlowMiniMap` story as much as in a
|
|
18
|
+
* composing package's.
|
|
19
|
+
* - **`layoutFlow`** falls back to `DEFAULT_NODE_WIDTH`/`DEFAULT_NODE_HEIGHT`
|
|
20
|
+
* (172×40) and hands dagre a node half the height of the real card, so ranks are laid
|
|
21
|
+
* out too close together and edge labels collide with the nodes below them.
|
|
22
|
+
* - Any consumer arithmetic over `node.measured` — the same trap, one layer out.
|
|
23
|
+
*
|
|
24
|
+
* React Flow's own examples never hit this because they drive the canvas with
|
|
25
|
+
* `useNodesState`, whose `onNodesChange` applies the `dimensions` change back into the
|
|
26
|
+
* array. A canvas driven from derived data (a discovered process graph, a layout hook)
|
|
27
|
+
* legitimately has no such setter, and gets a half-working canvas with no error. This
|
|
28
|
+
* hook closes that gap centrally so no consumer has to know about it: it remembers the
|
|
29
|
+
* dimensions React Flow reports and merges them into the nodes on the way past.
|
|
30
|
+
*
|
|
31
|
+
* It is deliberately a NO-OP in the two cases where React Flow already does the right
|
|
32
|
+
* thing: an UNCONTROLLED canvas (`defaultNodes`, where `hasDefaultNodes` makes React Flow
|
|
33
|
+
* apply the changes itself) never reaches here because `nodes` is undefined, and a
|
|
34
|
+
* controlled canvas whose consumer already applies dimension changes finds `measured`
|
|
35
|
+
* equal to the cached value and returns the very same array identity.
|
|
36
|
+
*/
|
|
37
|
+
export interface MeasuredNodesResult<NodeType extends Node> {
|
|
38
|
+
/** `nodes`, with React Flow's measured dimensions merged in. Same identity when nothing changed. */
|
|
39
|
+
nodes: NodeType[] | undefined;
|
|
40
|
+
/** The handler to give React Flow — records dimensions, then calls the consumer's own. */
|
|
41
|
+
onNodesChange: OnNodesChange<NodeType> | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useMeasuredNodes<NodeType extends Node>(
|
|
45
|
+
nodes: NodeType[] | undefined,
|
|
46
|
+
onNodesChange: OnNodesChange<NodeType> | undefined,
|
|
47
|
+
): MeasuredNodesResult<NodeType> {
|
|
48
|
+
const measured = useRef(new Map<string, { width: number; height: number }>());
|
|
49
|
+
// A version counter, not the map itself: the map is a ref so the change handler stays
|
|
50
|
+
// stable, and this is what tells the memo below that its contents moved.
|
|
51
|
+
const [version, setVersion] = useState(0);
|
|
52
|
+
|
|
53
|
+
const handleNodesChange = useCallback<OnNodesChange<NodeType>>(
|
|
54
|
+
(changes: NodeChange<NodeType>[]) => {
|
|
55
|
+
let touched = false;
|
|
56
|
+
for (const change of changes) {
|
|
57
|
+
if (change.type !== "dimensions" || !change.dimensions) continue;
|
|
58
|
+
const previous = measured.current.get(change.id);
|
|
59
|
+
if (
|
|
60
|
+
previous?.width === change.dimensions.width &&
|
|
61
|
+
previous.height === change.dimensions.height
|
|
62
|
+
) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
measured.current.set(change.id, { ...change.dimensions });
|
|
66
|
+
touched = true;
|
|
67
|
+
}
|
|
68
|
+
onNodesChange?.(changes);
|
|
69
|
+
if (touched) setVersion((n) => n + 1);
|
|
70
|
+
},
|
|
71
|
+
[onNodesChange],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const mergedNodes = useMemo(() => {
|
|
75
|
+
if (!nodes) return nodes;
|
|
76
|
+
// `version` is read so this recomputes when the ref's contents move; the map itself
|
|
77
|
+
// is intentionally not a dependency (a ref never changes identity).
|
|
78
|
+
void version;
|
|
79
|
+
let changed = false;
|
|
80
|
+
const next = nodes.map((node) => {
|
|
81
|
+
const dimensions = measured.current.get(node.id);
|
|
82
|
+
if (!dimensions) return node;
|
|
83
|
+
if (
|
|
84
|
+
node.measured?.width === dimensions.width &&
|
|
85
|
+
node.measured?.height === dimensions.height
|
|
86
|
+
) {
|
|
87
|
+
return node;
|
|
88
|
+
}
|
|
89
|
+
changed = true;
|
|
90
|
+
return { ...node, measured: dimensions };
|
|
91
|
+
});
|
|
92
|
+
return changed ? next : nodes;
|
|
93
|
+
}, [nodes, version]);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
nodes: mergedNodes,
|
|
97
|
+
// Only take the handler over when there is something to measure INTO. An uncontrolled
|
|
98
|
+
// canvas is left exactly as it was.
|
|
99
|
+
onNodesChange: nodes ? handleNodesChange : onNodesChange,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -2,10 +2,12 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
|
2
2
|
import "@xyflow/react/dist/style.css";
|
|
3
3
|
import { useCallback, useEffect, useRef } from "react";
|
|
4
4
|
import { useEdgesState, useNodesState } from "@xyflow/react";
|
|
5
|
+
import { expect, waitFor } from "storybook/test";
|
|
5
6
|
import { CanvasShell } from "../canvas-shell";
|
|
6
7
|
import { FlowNode, type BrandFlowNode } from "../flow-node";
|
|
7
8
|
import { useFlowLayout } from "../flow-layout";
|
|
8
9
|
import { ZoomControls } from "../zoom-controls";
|
|
10
|
+
import { edgePaths, endpointsOffHandles } from "../testing/edge-anchors";
|
|
9
11
|
import { FlowButtonEdge, type BrandFlowButtonEdge } from "./flow-button-edge";
|
|
10
12
|
|
|
11
13
|
const nodeTypes = { brand: FlowNode };
|
|
@@ -20,6 +22,14 @@ const meta = {
|
|
|
20
22
|
export default meta;
|
|
21
23
|
type Story = StoryObj<typeof meta>;
|
|
22
24
|
|
|
25
|
+
/** Every edge must terminate ON a handle dot — see `testing/edge-anchors`. */
|
|
26
|
+
async function expectAnchoredToHandles(canvasElement: HTMLElement, edgeCount: number) {
|
|
27
|
+
await waitFor(() => {
|
|
28
|
+
expect(edgePaths(canvasElement, "flow-button-edge")).toHaveLength(edgeCount);
|
|
29
|
+
expect(endpointsOffHandles(canvasElement, "flow-button-edge")).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
/** Two nodes joined by a button edge — hover/focus the "+" to see it. */
|
|
24
34
|
export const Default: Story = {
|
|
25
35
|
render: () => {
|
|
@@ -46,6 +56,9 @@ export const Default: Story = {
|
|
|
46
56
|
</div>
|
|
47
57
|
);
|
|
48
58
|
},
|
|
59
|
+
play: async ({ canvasElement }) => {
|
|
60
|
+
await expectAnchoredToHandles(canvasElement, 1);
|
|
61
|
+
},
|
|
49
62
|
};
|
|
50
63
|
|
|
51
64
|
// ---------------------------------------------------------------------------
|
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
BaseEdge,
|
|
3
|
-
EdgeLabelRenderer,
|
|
4
|
-
getBezierPath,
|
|
5
|
-
type Edge,
|
|
6
|
-
type EdgeProps,
|
|
7
|
-
} from "@xyflow/react";
|
|
1
|
+
import { EdgeLabelRenderer, getBezierPath, type Edge, type EdgeProps } from "@xyflow/react";
|
|
8
2
|
import { Plus } from "lucide-react";
|
|
3
|
+
import { FlowEdgePath } from "../flow-edge-path";
|
|
9
4
|
|
|
10
5
|
export interface FlowButtonEdgeData extends Record<string, unknown> {
|
|
11
6
|
/** aria-label for the insert button. @default "Insert node on edge" */
|
|
@@ -51,11 +46,14 @@ export function FlowButtonEdge({
|
|
|
51
46
|
|
|
52
47
|
return (
|
|
53
48
|
<>
|
|
54
|
-
<
|
|
49
|
+
<FlowEdgePath
|
|
55
50
|
id={id}
|
|
56
51
|
path={edgePath}
|
|
57
52
|
markerEnd={markerEnd}
|
|
58
|
-
|
|
53
|
+
data-slot="flow-button-edge"
|
|
54
|
+
stroke="var(--flow-edge)"
|
|
55
|
+
strokeWidth={1.5}
|
|
56
|
+
style={style}
|
|
59
57
|
/>
|
|
60
58
|
<EdgeLabelRenderer>
|
|
61
59
|
<div
|
|
@@ -69,7 +67,7 @@ export function FlowButtonEdge({
|
|
|
69
67
|
type="button"
|
|
70
68
|
aria-label={label}
|
|
71
69
|
onClick={() => data?.onInsert?.()}
|
|
72
|
-
className="flex size-5 items-center justify-center rounded-full border border-flow-group-border bg-flow-node text-flow-node-foreground shadow-sm transition-colors duration-fast ease-standard hover:bg-accent hover:text-accent-foreground focus-
|
|
70
|
+
className="flex size-5 items-center justify-center rounded-full border border-flow-group-border bg-flow-node text-flow-node-foreground shadow-sm transition-colors duration-fast ease-standard hover:bg-accent hover:text-accent-foreground focus-ring"
|
|
73
71
|
>
|
|
74
72
|
<Plus className="size-3" aria-hidden="true" />
|
|
75
73
|
</button>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
2
|
import "@xyflow/react/dist/style.css";
|
|
3
3
|
import { type Edge } from "@xyflow/react";
|
|
4
|
+
import { expect, waitFor } from "storybook/test";
|
|
4
5
|
import { CanvasShell } from "../canvas-shell";
|
|
5
6
|
import { FlowNode, type BrandFlowNode } from "../flow-node";
|
|
7
|
+
import { edgePaths, endpointsOffHandles } from "../testing/edge-anchors";
|
|
6
8
|
import { FlowEdge } from "./flow-edge";
|
|
7
9
|
|
|
8
10
|
const nodeTypes = { brand: FlowNode };
|
|
@@ -17,6 +19,18 @@ const meta = {
|
|
|
17
19
|
export default meta;
|
|
18
20
|
type Story = StoryObj<typeof meta>;
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Every edge must terminate ON a handle dot — see `testing/edge-anchors`. React
|
|
24
|
+
* Flow's native anchors land on the dot's outer rim; the assertion allows
|
|
25
|
+
* anywhere on the dot, and fails on a line that meets a bare stretch of border.
|
|
26
|
+
*/
|
|
27
|
+
async function expectAnchoredToHandles(canvasElement: HTMLElement, edgeCount: number) {
|
|
28
|
+
await waitFor(() => {
|
|
29
|
+
expect(edgePaths(canvasElement, "flow-edge")).toHaveLength(edgeCount);
|
|
30
|
+
expect(endpointsOffHandles(canvasElement, "flow-edge")).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
/** Two nodes connected by a single branded bezier edge. */
|
|
21
35
|
export const Default: Story = {
|
|
22
36
|
render: () => {
|
|
@@ -41,6 +55,9 @@ export const Default: Story = {
|
|
|
41
55
|
</div>
|
|
42
56
|
);
|
|
43
57
|
},
|
|
58
|
+
play: async ({ canvasElement }) => {
|
|
59
|
+
await expectAnchoredToHandles(canvasElement, 1);
|
|
60
|
+
},
|
|
44
61
|
};
|
|
45
62
|
|
|
46
63
|
/** A small pipeline with multiple branded edges. */
|
|
@@ -76,4 +93,7 @@ export const Pipeline: Story = {
|
|
|
76
93
|
</div>
|
|
77
94
|
);
|
|
78
95
|
},
|
|
96
|
+
play: async ({ canvasElement }) => {
|
|
97
|
+
await expectAnchoredToHandles(canvasElement, 2);
|
|
98
|
+
},
|
|
79
99
|
};
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getBezierPath, type EdgeProps } from "@xyflow/react";
|
|
2
|
+
import { FlowEdgePath } from "../flow-edge-path";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Branded bezier edge using the `--flow-edge` token. Register it in
|
|
5
6
|
* `edgeTypes={{ brand: FlowEdge }}` and create edges with `type: "brand"`.
|
|
7
|
+
*
|
|
8
|
+
* Drawn through `FlowEdgePath`, so it carries the shared keyboard focus
|
|
9
|
+
* indicator (#286) — never reach for React Flow's `BaseEdge` directly.
|
|
6
10
|
*/
|
|
7
11
|
export function FlowEdge({
|
|
8
12
|
id,
|
|
@@ -24,11 +28,14 @@ export function FlowEdge({
|
|
|
24
28
|
targetPosition,
|
|
25
29
|
});
|
|
26
30
|
return (
|
|
27
|
-
<
|
|
31
|
+
<FlowEdgePath
|
|
28
32
|
id={id}
|
|
29
33
|
path={edgePath}
|
|
30
34
|
markerEnd={markerEnd}
|
|
31
|
-
|
|
35
|
+
data-slot="flow-edge"
|
|
36
|
+
stroke="var(--flow-edge)"
|
|
37
|
+
strokeWidth={1.5}
|
|
38
|
+
style={style}
|
|
32
39
|
/>
|
|
33
40
|
);
|
|
34
41
|
}
|