@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.
- package/README.md +8 -8
- package/dist/index.d.ts +734 -20
- package/dist/index.js +976 -178
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- 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 +86 -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 +13 -3
- package/src/legend/index.ts +7 -1
- package/src/legend/legend.stories.tsx +126 -0
- package/src/legend/legend.test.tsx +169 -0
- package/src/legend/legend.tsx +214 -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
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
|
9
|
+
import { FlowEdgePath } from "../flow-edge-path";
|
|
10
|
+
import type { FlowHandleSide, FlowNodeData } from "../flow-node";
|
|
9
11
|
import {
|
|
12
|
+
pickClosestAnchors,
|
|
10
13
|
pickClosestHandles,
|
|
11
|
-
|
|
14
|
+
positionToSide,
|
|
12
15
|
sideToPosition,
|
|
13
|
-
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `
|
|
31
|
-
*
|
|
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({
|
|
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
|
|
39
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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:
|
|
60
|
-
sourceY:
|
|
61
|
-
sourcePosition
|
|
62
|
-
targetX:
|
|
63
|
-
targetY:
|
|
64
|
-
targetPosition
|
|
146
|
+
sourceX: sx,
|
|
147
|
+
sourceY: sy,
|
|
148
|
+
sourcePosition,
|
|
149
|
+
targetX: tx,
|
|
150
|
+
targetY: ty,
|
|
151
|
+
targetPosition,
|
|
65
152
|
});
|
|
66
153
|
|
|
67
154
|
return (
|
|
68
|
-
<
|
|
155
|
+
<FlowEdgePath
|
|
69
156
|
id={id}
|
|
70
157
|
path={edgePath}
|
|
71
158
|
markerEnd={markerEnd}
|
|
72
|
-
|
|
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
|
|
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
|
-
|
|
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("
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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("
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
expect(
|
|
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
|
-
/**
|
|
24
|
-
export
|
|
25
|
-
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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,
|