@elabs-ai/components-flow 4.0.0 → 4.1.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.
Files changed (66) hide show
  1. package/README.md +8 -8
  2. package/dist/index.d.ts +734 -20
  3. package/dist/index.js +976 -178
  4. package/dist/index.js.map +1 -1
  5. package/package.json +6 -6
  6. package/src/canvas-shell/canvas-shell.tsx +116 -1
  7. package/src/canvas-shell/use-measured-nodes.ts +101 -0
  8. package/src/flow-button-edge/flow-button-edge.stories.tsx +13 -0
  9. package/src/flow-button-edge/flow-button-edge.tsx +8 -10
  10. package/src/flow-edge/flow-edge.stories.tsx +20 -0
  11. package/src/flow-edge/flow-edge.tsx +10 -3
  12. package/src/flow-edge-path/flow-edge-path.tsx +149 -0
  13. package/src/flow-edge-path/index.ts +1 -0
  14. package/src/flow-edge-path/no-raw-base-edge.test.ts +45 -0
  15. package/src/flow-floating-edge/flow-floating-edge.tsx +7 -3
  16. package/src/flow-group-node/flow-group-node.stories.tsx +1 -1
  17. package/src/flow-group-node/flow-group-node.tsx +24 -8
  18. package/src/flow-handle/flow-handle-anchor.test.tsx +97 -0
  19. package/src/flow-handle/flow-handle-anchor.ts +36 -0
  20. package/src/flow-handle/index.ts +1 -0
  21. package/src/flow-layout/flow-layout.stories.tsx +2 -2
  22. package/src/flow-layout/flow-layout.test.tsx +91 -0
  23. package/src/flow-layout/flow-layout.ts +77 -1
  24. package/src/flow-layout/layout-graph.test.ts +83 -2
  25. package/src/flow-layout/layout-graph.ts +23 -15
  26. package/src/flow-mini-map/flow-mini-map.stories.tsx +86 -0
  27. package/src/flow-node/flow-node.stories.tsx +151 -0
  28. package/src/flow-node/flow-node.tsx +56 -1
  29. package/src/flow-placeholder-node/flow-placeholder-node.tsx +5 -2
  30. package/src/flow-self-loop-edge/flow-self-loop-edge.stories.tsx +275 -0
  31. package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +196 -0
  32. package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +172 -0
  33. package/src/flow-self-loop-edge/index.ts +13 -0
  34. package/src/flow-self-loop-edge/self-loop-geometry.test.ts +128 -0
  35. package/src/flow-self-loop-edge/self-loop-geometry.ts +165 -0
  36. package/src/flow-smart-edge/flow-smart-edge.stories.tsx +55 -8
  37. package/src/flow-smart-edge/flow-smart-edge.tsx +125 -35
  38. package/src/flow-smart-edge/index.ts +5 -1
  39. package/src/flow-smart-edge/smart-edge-geometry.test.ts +88 -47
  40. package/src/flow-smart-edge/smart-edge-geometry.ts +69 -33
  41. package/src/flow-weighted-edge/back-edge-geometry.test.ts +54 -0
  42. package/src/flow-weighted-edge/back-edge-geometry.ts +60 -0
  43. package/src/flow-weighted-edge/edge-aria.test.ts +108 -0
  44. package/src/flow-weighted-edge/edge-aria.ts +117 -0
  45. package/src/flow-weighted-edge/edge-label-pill.test.tsx +65 -0
  46. package/src/flow-weighted-edge/edge-label-pill.tsx +82 -0
  47. package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +691 -0
  48. package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +405 -0
  49. package/src/flow-weighted-edge/flow-weighted-edge.tsx +308 -0
  50. package/src/flow-weighted-edge/index.ts +18 -0
  51. package/src/flow-weighted-edge/weight-scale.test.ts +92 -0
  52. package/src/flow-weighted-edge/weight-scale.ts +86 -0
  53. package/src/index.ts +9 -0
  54. package/src/inspector-panel/inspector-panel.stories.tsx +1 -1
  55. package/src/inspector-panel/inspector-panel.test.tsx +20 -0
  56. package/src/inspector-panel/inspector-panel.tsx +13 -3
  57. package/src/legend/index.ts +7 -1
  58. package/src/legend/legend.stories.tsx +126 -0
  59. package/src/legend/legend.test.tsx +169 -0
  60. package/src/legend/legend.tsx +214 -3
  61. package/src/templates-flow-workspace.stories.tsx +1 -1
  62. package/src/testing/canvas-framing.test.ts +107 -0
  63. package/src/testing/canvas-framing.ts +396 -0
  64. package/src/testing/edge-anchors.ts +107 -0
  65. package/src/testing/index.ts +36 -0
  66. package/src/zoom-controls/zoom-controls.tsx +1 -1
@@ -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
- import { FlowNode, type BrandFlowNode } from "../flow-node";
6
+ import { FLOW_ALL_SIDE_HANDLES, FlowNode, type BrandFlowNode } from "../flow-node";
7
+ import { edgePaths, endpointsOffHandles } from "../testing/edge-anchors";
6
8
  import { FlowSmartEdge } from "./flow-smart-edge";
7
9
 
8
10
  const nodeTypes = { brand: FlowNode };
@@ -17,6 +19,19 @@ const meta = {
17
19
  export default meta;
18
20
  type Story = StoryObj<typeof meta>;
19
21
 
22
+ /**
23
+ * Every smart edge must terminate ON a handle dot. Before the anchoring fix the
24
+ * shipped anchors missed the nearest dot by ~22px on all-side nodes and ~124px
25
+ * on nodes carrying only the default top/bottom handles — the latter because a
26
+ * left/right side with no handle at all could be chosen.
27
+ */
28
+ async function expectEdgesAnchoredToHandles(canvasElement: HTMLElement, edgeCount: number) {
29
+ await waitFor(() => {
30
+ expect(edgePaths(canvasElement, "flow-smart-edge")).toHaveLength(edgeCount);
31
+ expect(endpointsOffHandles(canvasElement, "flow-smart-edge")).toEqual([]);
32
+ });
33
+ }
34
+
20
35
  /**
21
36
  * Multi-side nodes (handles on all four sides) connected by `FlowSmartEdge`.
22
37
  * Drag a node around another and the edge re-picks the closest source/target
@@ -24,10 +39,6 @@ type Story = StoryObj<typeof meta>;
24
39
  */
25
40
  export const Default: Story = {
26
41
  render: () => {
27
- const allSides = {
28
- source: ["top", "right", "bottom", "left"],
29
- target: ["top", "right", "bottom", "left"],
30
- } as const;
31
42
  const nodes: BrandFlowNode[] = [
32
43
  {
33
44
  id: "a",
@@ -37,7 +48,7 @@ export const Default: Story = {
37
48
  kind: "Source",
38
49
  title: "Ingest",
39
50
  tone: "accent",
40
- handles: { source: [...allSides.source], target: [...allSides.target] },
51
+ handles: FLOW_ALL_SIDE_HANDLES,
41
52
  },
42
53
  },
43
54
  {
@@ -47,7 +58,7 @@ export const Default: Story = {
47
58
  data: {
48
59
  kind: "Process",
49
60
  title: "Transform",
50
- handles: { source: [...allSides.source], target: [...allSides.target] },
61
+ handles: FLOW_ALL_SIDE_HANDLES,
51
62
  },
52
63
  },
53
64
  {
@@ -58,7 +69,7 @@ export const Default: Story = {
58
69
  kind: "Output",
59
70
  title: "Publish",
60
71
  tone: "success",
61
- handles: { source: [...allSides.source], target: [...allSides.target] },
72
+ handles: FLOW_ALL_SIDE_HANDLES,
62
73
  },
63
74
  },
64
75
  ];
@@ -72,4 +83,40 @@ export const Default: Story = {
72
83
  </div>
73
84
  );
74
85
  },
86
+ play: async ({ canvasElement }) => {
87
+ await expectEdgesAnchoredToHandles(canvasElement, 2);
88
+ },
89
+ };
90
+
91
+ /**
92
+ * Nodes with **no** `data.handles` config — they render `FlowNode`'s default
93
+ * single top target + bottom source. A smart edge must anchor to those two real
94
+ * dots; it must not invent a left/right anchor on a side that has no handle.
95
+ */
96
+ export const DefaultNodeHandles: Story = {
97
+ render: () => {
98
+ const nodes: BrandFlowNode[] = [
99
+ {
100
+ id: "a",
101
+ type: "brand",
102
+ position: { x: 60, y: 40 },
103
+ data: { kind: "Source", title: "Ingest" },
104
+ },
105
+ {
106
+ id: "b",
107
+ type: "brand",
108
+ position: { x: 360, y: 260 },
109
+ data: { kind: "Output", title: "Publish", tone: "success" },
110
+ },
111
+ ];
112
+ const edges: Edge[] = [{ id: "a-b", source: "a", target: "b", type: "smart" }];
113
+ return (
114
+ <div className="h-[420px]">
115
+ <CanvasShell nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} />
116
+ </div>
117
+ );
118
+ },
119
+ play: async ({ canvasElement }) => {
120
+ await expectEdgesAnchoredToHandles(canvasElement, 1);
121
+ },
75
122
  };
@@ -1,19 +1,24 @@
1
1
  import {
2
- BaseEdge,
3
2
  getBezierPath,
4
3
  useInternalNode,
5
4
  type EdgeProps,
6
5
  type InternalNode,
6
+ type Node,
7
+ type Position,
7
8
  } from "@xyflow/react";
8
- import type { FlowNodeData } from "../flow-node";
9
+ import { FlowEdgePath } from "../flow-edge-path";
10
+ import type { FlowHandleSide, FlowNodeData } from "../flow-node";
9
11
  import {
12
+ pickClosestAnchors,
10
13
  pickClosestHandles,
11
- rectCenter,
14
+ positionToSide,
12
15
  sideToPosition,
13
- slideAnchor,
16
+ type HandleAnchor,
14
17
  type NodeRect,
15
18
  } from "./smart-edge-geometry";
16
19
 
20
+ type HandleKind = "source" | "target";
21
+
17
22
  function toRect(node: InternalNode): NodeRect {
18
23
  return {
19
24
  x: node.internals.positionAbsolute.x,
@@ -24,52 +29,137 @@ function toRect(node: InternalNode): NodeRect {
24
29
  }
25
30
 
26
31
  /**
27
- * Branded edge that picks the closest source/target handle pair (from a node's
28
- * multi-side `FlowNodeHandles` config) and routes a bezier between them. Anchors
29
- * are recomputed every render, so they flip as nodes are dragged. Register it in
30
- * `edgeTypes={{ smart: FlowSmartEdge }}` and give the connected nodes a
31
- * `data.handles` config. Uses the `--flow-edge` token, matching `FlowEdge`.
32
+ * The candidate anchors for one end of the edge, read from React Flow's
33
+ * **measured** handle bounds the DOM truth about where each dot was painted.
34
+ *
35
+ * `handleBounds` entries are node-relative, so the node's absolute position is
36
+ * added back; the dot's centre is the entry's own box centre, which is why this
37
+ * tracks any handle size or offset the node's CSS chooses instead of assuming
38
+ * the dot sits on the side's midpoint.
39
+ *
40
+ * `handleId` pins the result to one handle when the edge names one
41
+ * (`sourceHandle`/`targetHandle`), so an explicitly wired edge is never
42
+ * re-routed to a different dot.
43
+ */
44
+ function anchorsOf(
45
+ node: InternalNode,
46
+ kind: HandleKind,
47
+ handleId: string | null | undefined,
48
+ ): HandleAnchor[] {
49
+ const bounds = node.internals.handleBounds?.[kind] ?? [];
50
+ const origin = node.internals.positionAbsolute;
51
+ return bounds
52
+ .filter((handle) => (handleId ? handle.id === handleId : true))
53
+ .map((handle) => ({
54
+ id: handle.id ?? null,
55
+ x: origin.x + handle.x + handle.width / 2,
56
+ y: origin.y + handle.y + handle.height / 2,
57
+ side: positionToSide[handle.position],
58
+ }));
59
+ }
60
+
61
+ /**
62
+ * The sides a `FlowNode` genuinely renders a handle on, for the pre-measurement
63
+ * fallback only: its `data.handles` config when it has one, otherwise the
64
+ * single default handle React Flow's layout direction placed
65
+ * (`targetPosition`/`sourcePosition`, defaulting to top-in / bottom-out).
66
+ */
67
+ function declaredSides(node: InternalNode, kind: HandleKind): FlowHandleSide[] {
68
+ const userNode = node.internals.userNode as Node<Record<string, unknown>>;
69
+ const data = userNode.data as FlowNodeData | undefined;
70
+ const configured = data?.handles?.[kind];
71
+ if (configured?.length) return [...configured];
72
+ const position = (kind === "target" ? userNode.targetPosition : userNode.sourcePosition) as
73
+ | Position
74
+ | undefined;
75
+ if (position) return [positionToSide[position]];
76
+ return [kind === "target" ? "top" : "bottom"];
77
+ }
78
+
79
+ /**
80
+ * Branded edge that picks the closest source/target **handle** pair and routes a
81
+ * bezier between them. Anchors are recomputed every render, so they flip as
82
+ * nodes are dragged. Register it in `edgeTypes={{ smart: FlowSmartEdge }}`; give
83
+ * the connected nodes a `data.handles` config (e.g. `FLOW_ALL_SIDE_HANDLES`) to
84
+ * offer it more than the default two anchors. Uses the `--flow-edge` token,
85
+ * matching `FlowEdge`.
86
+ *
87
+ * ## The path terminates ON the handle dot
88
+ *
89
+ * The endpoints come from React Flow's **measured `handleBounds`** — the centre
90
+ * of the rendered dot — not from a point derived from the node's rectangle.
91
+ * That is a correctness property, not a refinement: an earlier version slid each
92
+ * anchor along the chosen side toward the other node (to fan out edges sharing a
93
+ * side) and picked sides from a four-way fallback list, so a line could meet the
94
+ * node up to half a side away from any dot — measured at ~22px on multi-side
95
+ * nodes and ~124px on nodes carrying only the default top/bottom handles, where
96
+ * a left/right side with no handle at all could be chosen. Two edges leaving the
97
+ * same handle now leave from the same point and diverge, exactly as React Flow's
98
+ * own edges do.
32
99
  */
33
- export function FlowSmartEdge({ id, source, target, markerEnd, style }: EdgeProps) {
100
+ export function FlowSmartEdge({
101
+ id,
102
+ source,
103
+ target,
104
+ sourceHandleId,
105
+ targetHandleId,
106
+ markerEnd,
107
+ style,
108
+ }: EdgeProps) {
34
109
  const sourceNode = useInternalNode(source);
35
110
  const targetNode = useInternalNode(target);
36
111
  if (!sourceNode || !targetNode) return null;
37
112
 
38
- const sourceData = sourceNode.internals.userNode.data as FlowNodeData | undefined;
39
- const targetData = targetNode.internals.userNode.data as FlowNodeData | undefined;
40
-
41
- const sourceRect = toRect(sourceNode);
42
- const targetRect = toRect(targetNode);
43
-
44
- // Pick which side of each node the edge exits/enters (the facing handle side).
45
- const { sourceSide, targetSide } = pickClosestHandles(
46
- sourceRect,
47
- sourceData?.handles?.source ?? [],
48
- targetRect,
49
- targetData?.handles?.target ?? [],
113
+ const picked = pickClosestAnchors(
114
+ anchorsOf(sourceNode, "source", sourceHandleId),
115
+ anchorsOf(targetNode, "target", targetHandleId),
50
116
  );
51
117
 
52
- // Then slide each anchor along its chosen side toward the OTHER node, so
53
- // edges sharing a side fan out to distinct, target-facing points instead of
54
- // stacking on the side midpoint.
55
- const sourceAnchor = slideAnchor(sourceRect, sourceSide, rectCenter(targetRect));
56
- const targetAnchor = slideAnchor(targetRect, targetSide, rectCenter(sourceRect));
118
+ let sx: number;
119
+ let sy: number;
120
+ let tx: number;
121
+ let ty: number;
122
+ let sourcePosition: Position;
123
+ let targetPosition: Position;
124
+
125
+ if (picked) {
126
+ ({ x: sx, y: sy } = picked.source);
127
+ ({ x: tx, y: ty } = picked.target);
128
+ sourcePosition = sideToPosition[picked.source.side];
129
+ targetPosition = sideToPosition[picked.target.side];
130
+ } else {
131
+ // Handles not measured yet (first render). Fall back to the side midpoints
132
+ // of the sides the nodes actually declare, so the edge is drawn sanely for
133
+ // the frame or two before React Flow reports real handle bounds.
134
+ const fallback = pickClosestHandles(
135
+ toRect(sourceNode),
136
+ declaredSides(sourceNode, "source"),
137
+ toRect(targetNode),
138
+ declaredSides(targetNode, "target"),
139
+ );
140
+ ({ sx, sy, tx, ty } = fallback);
141
+ sourcePosition = sideToPosition[fallback.sourceSide];
142
+ targetPosition = sideToPosition[fallback.targetSide];
143
+ }
57
144
 
58
145
  const [edgePath] = getBezierPath({
59
- sourceX: sourceAnchor.x,
60
- sourceY: sourceAnchor.y,
61
- sourcePosition: sideToPosition[sourceSide],
62
- targetX: targetAnchor.x,
63
- targetY: targetAnchor.y,
64
- targetPosition: sideToPosition[targetSide],
146
+ sourceX: sx,
147
+ sourceY: sy,
148
+ sourcePosition,
149
+ targetX: tx,
150
+ targetY: ty,
151
+ targetPosition,
65
152
  });
66
153
 
67
154
  return (
68
- <BaseEdge
155
+ <FlowEdgePath
69
156
  id={id}
70
157
  path={edgePath}
71
158
  markerEnd={markerEnd}
72
- style={{ stroke: "var(--flow-edge)", strokeWidth: 1.5, ...style }}
159
+ data-slot="flow-smart-edge"
160
+ stroke="var(--flow-edge)"
161
+ strokeWidth={1.5}
162
+ style={style}
73
163
  />
74
164
  );
75
165
  }
@@ -2,8 +2,12 @@ export { FlowSmartEdge } from "./flow-smart-edge";
2
2
  export {
3
3
  HANDLE_SIDES,
4
4
  sideToPosition,
5
+ positionToSide,
5
6
  handlePoint,
7
+ pickClosestAnchors,
6
8
  pickClosestHandles,
7
- type NodeRect,
9
+ type ClosestAnchors,
8
10
  type ClosestHandles,
11
+ type HandleAnchor,
12
+ type NodeRect,
9
13
  } from "./smart-edge-geometry";
@@ -8,11 +8,12 @@ vi.mock("@xyflow/react", () => ({
8
8
 
9
9
  import {
10
10
  handlePoint,
11
+ pickClosestAnchors,
11
12
  pickClosestHandles,
12
- rectCenter,
13
+ positionToSide,
13
14
  sideToPosition,
14
- slideAnchor,
15
15
  HANDLE_SIDES,
16
+ type HandleAnchor,
16
17
  type NodeRect,
17
18
  } from "./smart-edge-geometry";
18
19
 
@@ -23,6 +24,13 @@ const rect = (x: number, y: number, width = 100, height = 60): NodeRect => ({
23
24
  height,
24
25
  });
25
26
 
27
+ const anchor = (
28
+ id: HandleAnchor["id"],
29
+ x: number,
30
+ y: number,
31
+ side: HandleAnchor["side"],
32
+ ): HandleAnchor => ({ id, x, y, side });
33
+
26
34
  describe("handlePoint", () => {
27
35
  it("returns the midpoint of each side", () => {
28
36
  const r = rect(0, 0, 100, 60);
@@ -33,20 +41,81 @@ describe("handlePoint", () => {
33
41
  });
34
42
  });
35
43
 
36
- describe("sideToPosition", () => {
44
+ describe("sideToPosition / positionToSide", () => {
37
45
  it("maps every side to the matching Position value", () => {
38
46
  expect(sideToPosition.top).toBe("top");
39
47
  expect(sideToPosition.right).toBe("right");
40
48
  expect(sideToPosition.bottom).toBe("bottom");
41
49
  expect(sideToPosition.left).toBe("left");
42
50
  });
51
+
52
+ it("round-trips every side", () => {
53
+ for (const side of HANDLE_SIDES) {
54
+ expect(positionToSide[sideToPosition[side]]).toBe(side);
55
+ }
56
+ });
43
57
  });
44
58
 
45
- describe("pickClosestHandles", () => {
59
+ describe("pickClosestAnchors", () => {
60
+ // A node at (0,0,100,60) with all-side handles, and one 300px to its right.
61
+ const leftNode: HandleAnchor[] = [
62
+ anchor("top", 50, 0, "top"),
63
+ anchor("right", 100, 30, "right"),
64
+ anchor("bottom", 50, 60, "bottom"),
65
+ anchor("left", 0, 30, "left"),
66
+ ];
67
+ const rightNode: HandleAnchor[] = [
68
+ anchor("top", 350, 0, "top"),
69
+ anchor("right", 400, 30, "right"),
70
+ anchor("bottom", 350, 60, "bottom"),
71
+ anchor("left", 300, 30, "left"),
72
+ ];
73
+
74
+ it("returns the closest facing pair", () => {
75
+ const picked = pickClosestAnchors(leftNode, rightNode);
76
+ expect(picked?.source.id).toBe("right");
77
+ expect(picked?.target.id).toBe("left");
78
+ });
79
+
80
+ it("returns the anchor coordinates VERBATIM — never a derived point", () => {
81
+ // This is the whole contract: the edge terminates exactly where the dot was
82
+ // measured, so no arithmetic may sit between the two.
83
+ const picked = pickClosestAnchors(leftNode, rightNode);
84
+ expect(picked?.source).toEqual(anchor("right", 100, 30, "right"));
85
+ expect(picked?.target).toEqual(anchor("left", 300, 30, "left"));
86
+ });
87
+
88
+ it("picks a stacked pair when the other node is below", () => {
89
+ const below = rightNode.map((a) => ({ ...a, x: a.x - 300, y: a.y + 300 }));
90
+ const picked = pickClosestAnchors(leftNode, below);
91
+ expect(picked?.source.id).toBe("bottom");
92
+ expect(picked?.target.id).toBe("top");
93
+ });
94
+
95
+ it("uses the only handle a node has, whatever side it faces", () => {
96
+ // A default FlowNode: one bottom source, one top target. It must never be
97
+ // routed to a left/right anchor it does not render.
98
+ const picked = pickClosestAnchors(
99
+ [anchor(null, 50, 60, "bottom")],
100
+ [anchor(null, 350, 0, "top")],
101
+ );
102
+ expect(picked?.source.side).toBe("bottom");
103
+ expect(picked?.target.side).toBe("top");
104
+ expect(picked?.source).toEqual({ id: null, x: 50, y: 60, side: "bottom" });
105
+ });
106
+
107
+ it("returns undefined when either end has no measured handle", () => {
108
+ expect(pickClosestAnchors([], rightNode)).toBeUndefined();
109
+ expect(pickClosestAnchors(leftNode, [])).toBeUndefined();
110
+ });
111
+ });
112
+
113
+ describe("pickClosestHandles (pre-measurement fallback)", () => {
46
114
  it("target to the right → source uses its right handle, target its left", () => {
47
- const source = rect(0, 0);
48
- const target = rect(300, 0);
49
- const picked = pickClosestHandles(source, ["right", "left"], target, ["left", "right"]);
115
+ const picked = pickClosestHandles(rect(0, 0), ["right", "left"], rect(300, 0), [
116
+ "left",
117
+ "right",
118
+ ]);
50
119
 
51
120
  expect(picked.sourceSide).toBe("right");
52
121
  expect(picked.targetSide).toBe("left");
@@ -70,51 +139,23 @@ describe("pickClosestHandles", () => {
70
139
  expect(["bottom", "right"]).toContain(picked.targetSide);
71
140
  });
72
141
 
142
+ it("honours a single declared side instead of widening to all four", () => {
143
+ // A default FlowNode declares bottom-out / top-in only. Even with the other
144
+ // node straight to the right, the anchor stays on a side that has a handle.
145
+ const picked = pickClosestHandles(rect(0, 0), ["bottom"], rect(300, 0), ["top"]);
146
+ expect(picked.sourceSide).toBe("bottom");
147
+ expect(picked.targetSide).toBe("top");
148
+ });
149
+
73
150
  it("falls back to all four sides when a side list is empty", () => {
74
151
  const picked = pickClosestHandles(rect(0, 0), [], rect(300, 0), []);
75
152
  expect(picked.sourceSide).toBe("right");
76
153
  expect(picked.targetSide).toBe("left");
77
154
  });
78
- });
79
-
80
- describe("rectCenter", () => {
81
- it("returns the node's centre", () => {
82
- expect(rectCenter(rect(0, 0, 100, 60))).toEqual({ x: 50, y: 30 });
83
- });
84
- });
85
-
86
- describe("slideAnchor", () => {
87
- const r = rect(0, 0, 100, 60); // usable right/left span [12, 48], top/bottom span [12, 88]
88
-
89
- it("stays on the chosen side's axis", () => {
90
- expect(slideAnchor(r, "right", { x: 999, y: 30 }).x).toBe(100);
91
- expect(slideAnchor(r, "left", { x: -999, y: 30 }).x).toBe(0);
92
- expect(slideAnchor(r, "top", { x: 30, y: -999 }).y).toBe(0);
93
- expect(slideAnchor(r, "bottom", { x: 30, y: 999 }).y).toBe(60);
94
- });
95
-
96
- it("slides toward the target and clamps within the inset", () => {
97
- // Target far above → clamp to the top of the usable range (inset 12).
98
- expect(slideAnchor(r, "right", { x: 100, y: -500 }).y).toBe(12);
99
- // Target far below → clamp to the bottom of the usable range.
100
- expect(slideAnchor(r, "right", { x: 100, y: 500 }).y).toBe(48);
101
- // Target within range → follows it.
102
- expect(slideAnchor(r, "right", { x: 100, y: 30 }).y).toBe(30);
103
- });
104
-
105
- it("fans two edges on the same side to distinct anchors", () => {
106
- // The Ingest→Transform (up) vs Ingest→Publish (down) case, both leaving right.
107
- const up = slideAnchor(r, "right", { x: 400, y: -200 });
108
- const down = slideAnchor(r, "right", { x: 400, y: 200 });
109
- expect(up.y).toBeLessThan(down.y);
110
- expect(up.y).not.toBe(down.y);
111
- });
112
155
 
113
- it("never inverts the range on a tiny node", () => {
114
- const tiny = rect(0, 0, 10, 10); // inset (12) capped to width/2 = 5
115
- const p = slideAnchor(tiny, "right", { x: 100, y: -100 });
116
- expect(p.x).toBe(10);
117
- expect(p.y).toBeGreaterThanOrEqual(0);
118
- expect(p.y).toBeLessThanOrEqual(10);
156
+ it("anchors on side midpoints, never a slid point", () => {
157
+ const picked = pickClosestHandles(rect(0, 0), ["right"], rect(300, -500), ["left"]);
158
+ // The target is far above; the anchor stays on the side's midpoint.
159
+ expect(picked.sy).toBe(30);
119
160
  });
120
161
  });
@@ -20,40 +20,71 @@ export const sideToPosition: Record<FlowHandleSide, Position> = {
20
20
  left: Position.Left,
21
21
  };
22
22
 
23
- /** Center point of a node rectangle. */
24
- export function rectCenter(rect: NodeRect): { x: number; y: number } {
25
- return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
23
+ /** Maps a React Flow `Position` back to the handle side it names. */
24
+ export const positionToSide: Record<Position, FlowHandleSide> = {
25
+ [Position.Top]: "top",
26
+ [Position.Right]: "right",
27
+ [Position.Bottom]: "bottom",
28
+ [Position.Left]: "left",
29
+ };
30
+
31
+ /**
32
+ * One candidate connection point: the **measured centre of a rendered handle
33
+ * dot**, in absolute flow coordinates, plus the side it sits on.
34
+ *
35
+ * This is the unit `FlowSmartEdge` routes between. Anchoring on a measured
36
+ * handle — rather than on a point derived from the node's rectangle — is what
37
+ * guarantees the drawn path terminates exactly on the dot the user sees,
38
+ * whatever the handle's size, offset or CSS. See {@link pickClosestAnchors}.
39
+ */
40
+ export interface HandleAnchor {
41
+ /** The handle's `id`, when it has one (`FlowNode` uses the side name). */
42
+ id: string | null;
43
+ /** Absolute x of the handle dot's centre. */
44
+ x: number;
45
+ /** Absolute y of the handle dot's centre. */
46
+ y: number;
47
+ /** The node side the handle sits on — the bezier's control direction. */
48
+ side: FlowHandleSide;
49
+ }
50
+
51
+ /** The chosen source/target anchor pair. */
52
+ export interface ClosestAnchors {
53
+ source: HandleAnchor;
54
+ target: HandleAnchor;
26
55
  }
27
56
 
28
57
  /**
29
- * The connection point on `side` of `rect`, slid along that side toward
30
- * `toward` and clamped `inset` px from the corners. This is what stops two
31
- * edges that both leave the same side (e.g. both targets sit to the right)
32
- * from piling onto the side's midpoint: each edge's anchor slides toward its
33
- * own target, so an up-going edge exits the upper part of the side and a
34
- * down-going edge the lower part each visibly meeting the node on the side
35
- * that faces its target instead of stacking in the middle.
58
+ * Picks the source/target pair of **rendered handles** with the shortest
59
+ * straight-line distance between them.
60
+ *
61
+ * Returns `undefined` when either side has no candidates, so the caller can
62
+ * fall back to rectangle geometry for a node whose handles have not been
63
+ * measured yet (React Flow populates `handleBounds` on its first measurement
64
+ * pass; before that there is nothing to anchor to).
36
65
  */
37
- export function slideAnchor(
38
- rect: NodeRect,
39
- side: FlowHandleSide,
40
- toward: { x: number; y: number },
41
- inset = 12,
42
- ): { x: number; y: number } {
43
- const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
44
- // Never let the inset invert the usable range on a small node.
45
- const ix = Math.min(inset, rect.width / 2);
46
- const iy = Math.min(inset, rect.height / 2);
47
- switch (side) {
48
- case "top":
49
- return { x: clamp(toward.x, rect.x + ix, rect.x + rect.width - ix), y: rect.y };
50
- case "bottom":
51
- return { x: clamp(toward.x, rect.x + ix, rect.x + rect.width - ix), y: rect.y + rect.height };
52
- case "left":
53
- return { x: rect.x, y: clamp(toward.y, rect.y + iy, rect.y + rect.height - iy) };
54
- case "right":
55
- return { x: rect.x + rect.width, y: clamp(toward.y, rect.y + iy, rect.y + rect.height - iy) };
66
+ export function pickClosestAnchors(
67
+ sources: HandleAnchor[],
68
+ targets: HandleAnchor[],
69
+ ): ClosestAnchors | undefined {
70
+ if (!sources.length || !targets.length) return undefined;
71
+
72
+ let best: ClosestAnchors | undefined;
73
+ let bestDist = Infinity;
74
+
75
+ for (const source of sources) {
76
+ for (const target of targets) {
77
+ const dx = target.x - source.x;
78
+ const dy = target.y - source.y;
79
+ const dist = dx * dx + dy * dy;
80
+ if (dist < bestDist) {
81
+ bestDist = dist;
82
+ best = { source, target };
83
+ }
84
+ }
56
85
  }
86
+
87
+ return best;
57
88
  }
58
89
 
59
90
  /** Absolute coordinate of a handle on the given side (the side's midpoint). */
@@ -70,7 +101,7 @@ export function handlePoint(rect: NodeRect, side: FlowHandleSide): { x: number;
70
101
  }
71
102
  }
72
103
 
73
- /** The chosen source/target handle pair plus their absolute anchor points. */
104
+ /** The side pair picked by {@link pickClosestHandles}, with both anchor points. */
74
105
  export interface ClosestHandles {
75
106
  sourceSide: FlowHandleSide;
76
107
  targetSide: FlowHandleSide;
@@ -81,9 +112,14 @@ export interface ClosestHandles {
81
112
  }
82
113
 
83
114
  /**
84
- * Picks the source/target handle pair (one handle per node) with the shortest
85
- * straight-line distance between their anchor points. Empty side lists fall
86
- * back to all four sides so an edge always resolves to a pair.
115
+ * Rectangle-only fallback: picks the closest pair of **side midpoints** from the
116
+ * candidate sides of each node.
117
+ *
118
+ * `FlowSmartEdge` prefers {@link pickClosestAnchors} (measured handle centres)
119
+ * and only reaches for this before React Flow has measured the nodes. Pass the
120
+ * sides each node genuinely renders a handle on — passing sides that carry no
121
+ * handle produces an anchor floating on a bare border, which is the defect this
122
+ * module exists to avoid.
87
123
  */
88
124
  export function pickClosestHandles(
89
125
  source: NodeRect,