@elabs-ai/components-flow 4.0.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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/index.d.ts +634 -0
  4. package/dist/index.js +1577 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +68 -0
  7. package/src/canvas-shell/canvas-shell.stories.tsx +77 -0
  8. package/src/canvas-shell/canvas-shell.test.tsx +106 -0
  9. package/src/canvas-shell/canvas-shell.tsx +128 -0
  10. package/src/canvas-shell/index.ts +1 -0
  11. package/src/flow-button-edge/flow-button-edge.stories.tsx +151 -0
  12. package/src/flow-button-edge/flow-button-edge.test.tsx +99 -0
  13. package/src/flow-button-edge/flow-button-edge.tsx +80 -0
  14. package/src/flow-button-edge/index.ts +5 -0
  15. package/src/flow-edge/flow-edge.stories.tsx +79 -0
  16. package/src/flow-edge/flow-edge.test.tsx +76 -0
  17. package/src/flow-edge/flow-edge.tsx +34 -0
  18. package/src/flow-edge/index.ts +1 -0
  19. package/src/flow-floating-edge/floating-edge-geometry.test.ts +100 -0
  20. package/src/flow-floating-edge/floating-edge-geometry.ts +128 -0
  21. package/src/flow-floating-edge/flow-floating-edge.stories.tsx +81 -0
  22. package/src/flow-floating-edge/flow-floating-edge.tsx +82 -0
  23. package/src/flow-floating-edge/index.ts +6 -0
  24. package/src/flow-group-node/flow-group-node.stories.tsx +283 -0
  25. package/src/flow-group-node/flow-group-node.test.tsx +100 -0
  26. package/src/flow-group-node/flow-group-node.tsx +134 -0
  27. package/src/flow-group-node/index.ts +6 -0
  28. package/src/flow-layout/flow-layout.stories.tsx +215 -0
  29. package/src/flow-layout/flow-layout.test.tsx +133 -0
  30. package/src/flow-layout/flow-layout.ts +104 -0
  31. package/src/flow-layout/index.ts +14 -0
  32. package/src/flow-layout/layout-graph.test.ts +257 -0
  33. package/src/flow-layout/layout-graph.ts +302 -0
  34. package/src/flow-layout/use-auto-layout.ts +26 -0
  35. package/src/flow-layout/use-flow-layout.ts +60 -0
  36. package/src/flow-mini-map/flow-mini-map.stories.tsx +74 -0
  37. package/src/flow-mini-map/flow-mini-map.test.tsx +56 -0
  38. package/src/flow-mini-map/flow-mini-map.tsx +29 -0
  39. package/src/flow-mini-map/index.ts +1 -0
  40. package/src/flow-node/flow-node.stories.tsx +143 -0
  41. package/src/flow-node/flow-node.test.tsx +156 -0
  42. package/src/flow-node/flow-node.tsx +190 -0
  43. package/src/flow-node/index.ts +8 -0
  44. package/src/flow-placeholder-node/flow-placeholder-node.stories.tsx +260 -0
  45. package/src/flow-placeholder-node/flow-placeholder-node.test.tsx +101 -0
  46. package/src/flow-placeholder-node/flow-placeholder-node.tsx +49 -0
  47. package/src/flow-placeholder-node/index.ts +5 -0
  48. package/src/flow-smart-edge/flow-smart-edge.stories.tsx +75 -0
  49. package/src/flow-smart-edge/flow-smart-edge.tsx +75 -0
  50. package/src/flow-smart-edge/index.ts +9 -0
  51. package/src/flow-smart-edge/smart-edge-geometry.test.ts +120 -0
  52. package/src/flow-smart-edge/smart-edge-geometry.ts +116 -0
  53. package/src/helper-lines/get-helper-lines.ts +98 -0
  54. package/src/helper-lines/helper-lines.stories.tsx +107 -0
  55. package/src/helper-lines/helper-lines.test.tsx +78 -0
  56. package/src/helper-lines/helper-lines.tsx +67 -0
  57. package/src/helper-lines/index.ts +7 -0
  58. package/src/helper-lines/use-helper-lines.ts +109 -0
  59. package/src/index.ts +37 -0
  60. package/src/inspector-panel/index.ts +1 -0
  61. package/src/inspector-panel/inspector-panel.stories.tsx +161 -0
  62. package/src/inspector-panel/inspector-panel.test.tsx +82 -0
  63. package/src/inspector-panel/inspector-panel.tsx +129 -0
  64. package/src/legend/index.ts +1 -0
  65. package/src/legend/legend.stories.tsx +56 -0
  66. package/src/legend/legend.test.tsx +46 -0
  67. package/src/legend/legend.tsx +39 -0
  68. package/src/templates-flow-workspace.stories.tsx +169 -0
  69. package/src/use-flow-groups/group-operations.test.ts +318 -0
  70. package/src/use-flow-groups/group-operations.ts +349 -0
  71. package/src/use-flow-groups/index.ts +13 -0
  72. package/src/use-flow-groups/use-flow-groups.ts +113 -0
  73. package/src/zoom-controls/index.ts +1 -0
  74. package/src/zoom-controls/zoom-controls.stories.tsx +68 -0
  75. package/src/zoom-controls/zoom-controls.test.tsx +80 -0
  76. package/src/zoom-controls/zoom-controls.tsx +78 -0
@@ -0,0 +1,75 @@
1
+ import {
2
+ BaseEdge,
3
+ getBezierPath,
4
+ useInternalNode,
5
+ type EdgeProps,
6
+ type InternalNode,
7
+ } from "@xyflow/react";
8
+ import type { FlowNodeData } from "../flow-node";
9
+ import {
10
+ pickClosestHandles,
11
+ rectCenter,
12
+ sideToPosition,
13
+ slideAnchor,
14
+ type NodeRect,
15
+ } from "./smart-edge-geometry";
16
+
17
+ function toRect(node: InternalNode): NodeRect {
18
+ return {
19
+ x: node.internals.positionAbsolute.x,
20
+ y: node.internals.positionAbsolute.y,
21
+ width: node.measured.width ?? 0,
22
+ height: node.measured.height ?? 0,
23
+ };
24
+ }
25
+
26
+ /**
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
+ */
33
+ export function FlowSmartEdge({ id, source, target, markerEnd, style }: EdgeProps) {
34
+ const sourceNode = useInternalNode(source);
35
+ const targetNode = useInternalNode(target);
36
+ if (!sourceNode || !targetNode) return null;
37
+
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 ?? [],
50
+ );
51
+
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));
57
+
58
+ 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],
65
+ });
66
+
67
+ return (
68
+ <BaseEdge
69
+ id={id}
70
+ path={edgePath}
71
+ markerEnd={markerEnd}
72
+ style={{ stroke: "var(--flow-edge)", strokeWidth: 1.5, ...style }}
73
+ />
74
+ );
75
+ }
@@ -0,0 +1,9 @@
1
+ export { FlowSmartEdge } from "./flow-smart-edge";
2
+ export {
3
+ HANDLE_SIDES,
4
+ sideToPosition,
5
+ handlePoint,
6
+ pickClosestHandles,
7
+ type NodeRect,
8
+ type ClosestHandles,
9
+ } from "./smart-edge-geometry";
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ // smart-edge-geometry imports the `Position` enum (a value) from @xyflow/react.
4
+ // Mock it to the Position constants so the pure picker runs in jsdom.
5
+ vi.mock("@xyflow/react", () => ({
6
+ Position: { Left: "left", Top: "top", Right: "right", Bottom: "bottom" },
7
+ }));
8
+
9
+ import {
10
+ handlePoint,
11
+ pickClosestHandles,
12
+ rectCenter,
13
+ sideToPosition,
14
+ slideAnchor,
15
+ HANDLE_SIDES,
16
+ type NodeRect,
17
+ } from "./smart-edge-geometry";
18
+
19
+ const rect = (x: number, y: number, width = 100, height = 60): NodeRect => ({
20
+ x,
21
+ y,
22
+ width,
23
+ height,
24
+ });
25
+
26
+ describe("handlePoint", () => {
27
+ it("returns the midpoint of each side", () => {
28
+ const r = rect(0, 0, 100, 60);
29
+ expect(handlePoint(r, "top")).toEqual({ x: 50, y: 0 });
30
+ expect(handlePoint(r, "bottom")).toEqual({ x: 50, y: 60 });
31
+ expect(handlePoint(r, "left")).toEqual({ x: 0, y: 30 });
32
+ expect(handlePoint(r, "right")).toEqual({ x: 100, y: 30 });
33
+ });
34
+ });
35
+
36
+ describe("sideToPosition", () => {
37
+ it("maps every side to the matching Position value", () => {
38
+ expect(sideToPosition.top).toBe("top");
39
+ expect(sideToPosition.right).toBe("right");
40
+ expect(sideToPosition.bottom).toBe("bottom");
41
+ expect(sideToPosition.left).toBe("left");
42
+ });
43
+ });
44
+
45
+ describe("pickClosestHandles", () => {
46
+ 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"]);
50
+
51
+ expect(picked.sourceSide).toBe("right");
52
+ expect(picked.targetSide).toBe("left");
53
+ expect(picked.sx).toBeCloseTo(100);
54
+ expect(picked.tx).toBeCloseTo(300);
55
+ });
56
+
57
+ it("target below → source uses bottom, target uses top", () => {
58
+ const picked = pickClosestHandles(rect(0, 0), ["top", "bottom"], rect(0, 300), [
59
+ "top",
60
+ "bottom",
61
+ ]);
62
+ expect(picked.sourceSide).toBe("bottom");
63
+ expect(picked.targetSide).toBe("top");
64
+ });
65
+
66
+ it("target up-and-to-the-left flips to the near corner sides", () => {
67
+ // Target sits above-left of the source.
68
+ const picked = pickClosestHandles(rect(300, 300), HANDLE_SIDES, rect(0, 0), HANDLE_SIDES);
69
+ expect(["top", "left"]).toContain(picked.sourceSide);
70
+ expect(["bottom", "right"]).toContain(picked.targetSide);
71
+ });
72
+
73
+ it("falls back to all four sides when a side list is empty", () => {
74
+ const picked = pickClosestHandles(rect(0, 0), [], rect(300, 0), []);
75
+ expect(picked.sourceSide).toBe("right");
76
+ expect(picked.targetSide).toBe("left");
77
+ });
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
+
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);
119
+ });
120
+ });
@@ -0,0 +1,116 @@
1
+ import { Position } from "@xyflow/react";
2
+ import type { FlowHandleSide } from "../flow-node";
3
+
4
+ /** Axis-aligned node rectangle in absolute flow coordinates. */
5
+ export interface NodeRect {
6
+ x: number;
7
+ y: number;
8
+ width: number;
9
+ height: number;
10
+ }
11
+
12
+ /** All four candidate handle sides, in a stable order. */
13
+ export const HANDLE_SIDES: FlowHandleSide[] = ["top", "right", "bottom", "left"];
14
+
15
+ /** Maps a handle side to the React Flow `Position` used for bezier control. */
16
+ export const sideToPosition: Record<FlowHandleSide, Position> = {
17
+ top: Position.Top,
18
+ right: Position.Right,
19
+ bottom: Position.Bottom,
20
+ left: Position.Left,
21
+ };
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 };
26
+ }
27
+
28
+ /**
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.
36
+ */
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) };
56
+ }
57
+ }
58
+
59
+ /** Absolute coordinate of a handle on the given side (the side's midpoint). */
60
+ export function handlePoint(rect: NodeRect, side: FlowHandleSide): { x: number; y: number } {
61
+ switch (side) {
62
+ case "top":
63
+ return { x: rect.x + rect.width / 2, y: rect.y };
64
+ case "bottom":
65
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height };
66
+ case "left":
67
+ return { x: rect.x, y: rect.y + rect.height / 2 };
68
+ case "right":
69
+ return { x: rect.x + rect.width, y: rect.y + rect.height / 2 };
70
+ }
71
+ }
72
+
73
+ /** The chosen source/target handle pair plus their absolute anchor points. */
74
+ export interface ClosestHandles {
75
+ sourceSide: FlowHandleSide;
76
+ targetSide: FlowHandleSide;
77
+ sx: number;
78
+ sy: number;
79
+ tx: number;
80
+ ty: number;
81
+ }
82
+
83
+ /**
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.
87
+ */
88
+ export function pickClosestHandles(
89
+ source: NodeRect,
90
+ sourceSides: FlowHandleSide[],
91
+ target: NodeRect,
92
+ targetSides: FlowHandleSide[],
93
+ ): ClosestHandles {
94
+ const srcSides = sourceSides.length ? sourceSides : HANDLE_SIDES;
95
+ const tgtSides = targetSides.length ? targetSides : HANDLE_SIDES;
96
+
97
+ let best: ClosestHandles | undefined;
98
+ let bestDist = Infinity;
99
+
100
+ for (const s of srcSides) {
101
+ const sp = handlePoint(source, s);
102
+ for (const t of tgtSides) {
103
+ const tp = handlePoint(target, t);
104
+ const dx = tp.x - sp.x;
105
+ const dy = tp.y - sp.y;
106
+ const dist = dx * dx + dy * dy;
107
+ if (dist < bestDist) {
108
+ bestDist = dist;
109
+ best = { sourceSide: s, targetSide: t, sx: sp.x, sy: sp.y, tx: tp.x, ty: tp.y };
110
+ }
111
+ }
112
+ }
113
+
114
+ // srcSides/tgtSides are non-empty (fallback above), so best is always set.
115
+ return best as ClosestHandles;
116
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Pure alignment-guide geometry for the helper-lines feature.
3
+ *
4
+ * Implemented fresh from the documented "helper lines" behaviour (the React Flow
5
+ * Pro example is paid — only the concept is reused): while a single node drags,
6
+ * compare its left / horizontal-center / right against every other node's
7
+ * left / center / right (and top / middle / bottom on the vertical axis). When a
8
+ * pair falls within `threshold` flow-coordinate pixels, record the guide's
9
+ * absolute coordinate and the snapped top-left position for the dragged node.
10
+ *
11
+ * Everything here is a pure function of plain rectangles so it is unit-testable
12
+ * without a live React Flow instance.
13
+ */
14
+
15
+ /** A node's bounding box in absolute flow coordinates (top-left origin). */
16
+ export interface HelperLineRect {
17
+ /** Left edge (top-left x) in flow coordinates. */
18
+ x: number;
19
+ /** Top edge (top-left y) in flow coordinates. */
20
+ y: number;
21
+ width: number;
22
+ height: number;
23
+ }
24
+
25
+ export interface HelperLinesResult {
26
+ /** New top-left x for the dragged node once snapped to the vertical guide. */
27
+ snapX?: number;
28
+ /** New top-left y for the dragged node once snapped to the horizontal guide. */
29
+ snapY?: number;
30
+ /** Absolute flow-x of the active vertical guide line, if any. */
31
+ vertical?: number;
32
+ /** Absolute flow-y of the active horizontal guide line, if any. */
33
+ horizontal?: number;
34
+ }
35
+
36
+ /**
37
+ * Fractional anchor offsets along an axis: 0 = start edge (left/top),
38
+ * 0.5 = center/middle, 1 = end edge (right/bottom). Comparing all three anchors
39
+ * of the dragged node against all three of every other node yields
40
+ * edge-to-edge, edge-to-center and center-to-center alignment.
41
+ */
42
+ const ANCHORS = [0, 0.5, 1] as const;
43
+
44
+ /**
45
+ * Compute the closest vertical + horizontal alignment guides (and the snapped
46
+ * position) for a dragged rectangle against the other rectangles.
47
+ *
48
+ * @param dragged the dragged node's proposed bounding box (flow coordinates)
49
+ * @param others every other node's bounding box
50
+ * @param threshold max distance (flow px) at which alignment engages; a pair at
51
+ * exactly `threshold` does NOT match (strict boundary)
52
+ */
53
+ export function getHelperLines(
54
+ dragged: HelperLineRect,
55
+ others: Iterable<HelperLineRect>,
56
+ threshold = 5,
57
+ ): HelperLinesResult {
58
+ const result: HelperLinesResult = {};
59
+ // Track the closest match on each axis; initialising to `threshold` makes the
60
+ // boundary strict (a distance equal to threshold never wins).
61
+ let bestVerticalDist = threshold;
62
+ let bestHorizontalDist = threshold;
63
+
64
+ // Each anchor as { a: fractional offset, pos: absolute coordinate }.
65
+ const draggedX = ANCHORS.map((a) => ({ a, pos: dragged.x + dragged.width * a }));
66
+ const draggedY = ANCHORS.map((a) => ({ a, pos: dragged.y + dragged.height * a }));
67
+
68
+ for (const other of others) {
69
+ const otherX = ANCHORS.map((a) => other.x + other.width * a);
70
+ const otherY = ANCHORS.map((a) => other.y + other.height * a);
71
+
72
+ // Vertical guide (align on the X axis).
73
+ for (const d of draggedX) {
74
+ for (const oPos of otherX) {
75
+ const dx = Math.abs(d.pos - oPos);
76
+ if (dx < bestVerticalDist) {
77
+ bestVerticalDist = dx;
78
+ result.vertical = oPos;
79
+ result.snapX = oPos - dragged.width * d.a;
80
+ }
81
+ }
82
+ }
83
+
84
+ // Horizontal guide (align on the Y axis).
85
+ for (const d of draggedY) {
86
+ for (const oPos of otherY) {
87
+ const dy = Math.abs(d.pos - oPos);
88
+ if (dy < bestHorizontalDist) {
89
+ bestHorizontalDist = dy;
90
+ result.horizontal = oPos;
91
+ result.snapY = oPos - dragged.height * d.a;
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ return result;
98
+ }
@@ -0,0 +1,107 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import "@xyflow/react/dist/style.css";
4
+ import { ReactFlowProvider, useNodesState, type Edge } from "@xyflow/react";
5
+ import { CanvasShell } from "../canvas-shell";
6
+ import { FlowNode, type BrandFlowNode } from "../flow-node";
7
+ import { ZoomControls } from "../zoom-controls";
8
+ import { HelperLines } from "./helper-lines";
9
+ import { useHelperLines } from "./use-helper-lines";
10
+
11
+ const nodeTypes = { brand: FlowNode };
12
+
13
+ const initialNodes: BrandFlowNode[] = [
14
+ {
15
+ id: "1",
16
+ type: "brand",
17
+ position: { x: 40, y: 40 },
18
+ data: { kind: "Source", title: "Postgres", subtitle: "orders", tone: "accent" },
19
+ },
20
+ {
21
+ id: "2",
22
+ type: "brand",
23
+ position: { x: 320, y: 200 },
24
+ data: { kind: "Transform", title: "Clean & join", tone: "default" },
25
+ },
26
+ {
27
+ id: "3",
28
+ type: "brand",
29
+ position: { x: 120, y: 360 },
30
+ data: { kind: "Output", title: "Dashboard", tone: "success" },
31
+ },
32
+ ];
33
+ const initialEdges: Edge[] = [];
34
+
35
+ const meta = {
36
+ title: "Flow/HelperLines",
37
+ component: HelperLines,
38
+ tags: ["autodocs"],
39
+ parameters: { layout: "fullscreen" },
40
+ } satisfies Meta<typeof HelperLines>;
41
+ export default meta;
42
+ type Story = StoryObj<typeof meta>;
43
+
44
+ /**
45
+ * Drag a node so an edge or center aligns with another node — a guide appears
46
+ * and the node snaps onto it. Wired via the `CanvasShell helperLines` prop.
47
+ */
48
+ function CanvasShellDemo() {
49
+ const [nodes, , onNodesChange] = useNodesState(initialNodes);
50
+ const [edges] = useState(initialEdges);
51
+ return (
52
+ <div className="h-[600px]">
53
+ <CanvasShell
54
+ helperLines
55
+ nodes={nodes}
56
+ edges={edges}
57
+ onNodesChange={onNodesChange}
58
+ nodeTypes={nodeTypes}
59
+ >
60
+ <ZoomControls />
61
+ </CanvasShell>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ export const Default: Story = {
67
+ name: "CanvasShell helperLines",
68
+ render: () => <CanvasShellDemo />,
69
+ };
70
+
71
+ /**
72
+ * The composable path: wire `useHelperLines` around your own `onNodesChange`
73
+ * and place `<HelperLines>` inside the canvas. Use this for full control (e.g.
74
+ * a custom threshold, or when you already manage the flow store yourself).
75
+ */
76
+ function ComposableInner() {
77
+ const [nodes, , onNodesChange] = useNodesState(initialNodes);
78
+ const [edges] = useState(initialEdges);
79
+ const {
80
+ onNodesChange: handleNodesChange,
81
+ helperLineHorizontal,
82
+ helperLineVertical,
83
+ } = useHelperLines(onNodesChange);
84
+
85
+ return (
86
+ <div className="h-[600px]">
87
+ <CanvasShell
88
+ nodes={nodes}
89
+ edges={edges}
90
+ onNodesChange={handleNodesChange}
91
+ nodeTypes={nodeTypes}
92
+ >
93
+ <ZoomControls />
94
+ <HelperLines horizontal={helperLineHorizontal} vertical={helperLineVertical} />
95
+ </CanvasShell>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ export const Composable: Story = {
101
+ name: "Composable (useHelperLines + HelperLines)",
102
+ render: () => (
103
+ <ReactFlowProvider>
104
+ <ComposableInner />
105
+ </ReactFlowProvider>
106
+ ),
107
+ };
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getHelperLines, type HelperLineRect } from "./get-helper-lines";
3
+
4
+ // A fixed 100×40 box; the "other" node the dragged node aligns against.
5
+ const other: HelperLineRect = { x: 100, y: 100, width: 100, height: 40 };
6
+
7
+ describe("getHelperLines", () => {
8
+ it("snaps X when left edges are within threshold (left-to-left)", () => {
9
+ // dragged left at 103, other left at 100 → within 5px.
10
+ const dragged: HelperLineRect = { x: 103, y: 300, width: 100, height: 40 };
11
+ const { snapX, vertical } = getHelperLines(dragged, [other]);
12
+ expect(vertical).toBe(100);
13
+ expect(snapX).toBe(100); // left aligns → top-left x == other.x
14
+ });
15
+
16
+ it("snaps X when horizontal centers align (center-to-center)", () => {
17
+ // Narrower box so only its center — not its edges — lands near another anchor.
18
+ // other centerX = 150; dragged (width 60) center at 150 (x=120), edges far.
19
+ const dragged: HelperLineRect = { x: 120, y: 300, width: 60, height: 40 };
20
+ const { snapX, vertical } = getHelperLines(dragged, [other]);
21
+ expect(vertical).toBe(150);
22
+ expect(snapX).toBe(120); // centerX 150 → x = 150 - width/2 = 120
23
+ });
24
+
25
+ it("snaps Y when top edges align (top-to-top)", () => {
26
+ // dragged top at 102, other top at 100 → within 5px.
27
+ const dragged: HelperLineRect = { x: 400, y: 102, width: 100, height: 40 };
28
+ const { snapY, horizontal } = getHelperLines(dragged, [other]);
29
+ expect(horizontal).toBe(100);
30
+ expect(snapY).toBe(100);
31
+ });
32
+
33
+ it("snaps X to the right edge (right-to-right)", () => {
34
+ // Wider box (120) at x=80 → right=200 matches other.right=200; left/center far.
35
+ const dragged: HelperLineRect = { x: 80, y: 300, width: 120, height: 40 };
36
+ const { snapX, vertical } = getHelperLines(dragged, [other], 5);
37
+ expect(vertical).toBe(200); // guide at other's right edge
38
+ expect(snapX).toBe(80); // right 200 → x = 200 - width = 80
39
+ expect((snapX ?? 0) + 120).toBe(vertical); // dragged right lands on the guide
40
+ });
41
+
42
+ it("does not snap when no anchor is within threshold", () => {
43
+ const dragged: HelperLineRect = { x: 500, y: 500, width: 100, height: 40 };
44
+ const result = getHelperLines(dragged, [other]);
45
+ expect(result.snapX).toBeUndefined();
46
+ expect(result.snapY).toBeUndefined();
47
+ expect(result.vertical).toBeUndefined();
48
+ expect(result.horizontal).toBeUndefined();
49
+ });
50
+
51
+ it("treats the threshold as a strict boundary (distance == threshold does not match)", () => {
52
+ // dragged left exactly 5px from other left with threshold 5 → no match.
53
+ const dragged: HelperLineRect = { x: 105, y: 500, width: 100, height: 40 };
54
+ const result = getHelperLines(dragged, [other], 5);
55
+ expect(result.vertical).toBeUndefined();
56
+ expect(result.snapX).toBeUndefined();
57
+
58
+ // One pixel closer (distance 4 < 5) → matches.
59
+ const closer: HelperLineRect = { x: 104, y: 500, width: 100, height: 40 };
60
+ const matched = getHelperLines(closer, [other], 5);
61
+ expect(matched.vertical).toBe(100);
62
+ expect(matched.snapX).toBe(100);
63
+ });
64
+
65
+ it("picks the closest node when several are near", () => {
66
+ const near: HelperLineRect = { x: 101, y: 300, width: 100, height: 40 }; // dist 1
67
+ const far: HelperLineRect = { x: 96, y: 300, width: 100, height: 40 }; // dist 4
68
+ const dragged: HelperLineRect = { x: 100, y: 300, width: 100, height: 40 };
69
+ const { vertical, snapX } = getHelperLines(dragged, [far, near]);
70
+ expect(vertical).toBe(101); // the closer node's left wins
71
+ expect(snapX).toBe(101);
72
+ });
73
+
74
+ it("returns nothing when there are no other nodes", () => {
75
+ const dragged: HelperLineRect = { x: 100, y: 100, width: 100, height: 40 };
76
+ expect(getHelperLines(dragged, [])).toEqual({});
77
+ });
78
+ });
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type SVGAttributes } from "react";
4
+ import { useViewport } from "@xyflow/react";
5
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
6
+
7
+ export interface HelperLinesProps extends SVGAttributes<SVGSVGElement> {
8
+ /** Absolute flow-y of the horizontal guide (from `useHelperLines`). */
9
+ horizontal?: number;
10
+ /** Absolute flow-x of the vertical guide (from `useHelperLines`). */
11
+ vertical?: number;
12
+ }
13
+
14
+ /**
15
+ * Decorative alignment-guide overlay. Draws a full-width horizontal line at
16
+ * `horizontal` and/or a full-height vertical line at `vertical`, coloured with
17
+ * the `--flow-helper-line` token. It reads the live viewport transform
18
+ * (zoom + pan) so the guides sit on the real alignment position when the canvas
19
+ * is zoomed or panned.
20
+ *
21
+ * Render as a child of `<CanvasShell>` / `<ReactFlow>` (it must be inside the
22
+ * flow context to read the viewport). Purely decorative — hidden from AT.
23
+ */
24
+ export const HelperLines = forwardRef<SVGSVGElement, HelperLinesProps>(function HelperLines(
25
+ { horizontal, vertical, className, ...props },
26
+ ref,
27
+ ) {
28
+ const { x, y, zoom } = useViewport();
29
+
30
+ if (horizontal == null && vertical == null) return null;
31
+
32
+ // flow coordinate → pane pixel: coord * zoom + pan offset.
33
+ const screenY = horizontal == null ? null : y + horizontal * zoom;
34
+ const screenX = vertical == null ? null : x + vertical * zoom;
35
+
36
+ return (
37
+ <svg
38
+ ref={ref}
39
+ aria-hidden="true"
40
+ width="100%"
41
+ height="100%"
42
+ className={cn("pointer-events-none absolute inset-0 size-full", className)}
43
+ {...props}
44
+ >
45
+ {screenY != null ? (
46
+ <line
47
+ x1={0}
48
+ x2="100%"
49
+ y1={screenY}
50
+ y2={screenY}
51
+ stroke="var(--flow-helper-line, var(--primary))"
52
+ strokeWidth={1}
53
+ />
54
+ ) : null}
55
+ {screenX != null ? (
56
+ <line
57
+ x1={screenX}
58
+ x2={screenX}
59
+ y1={0}
60
+ y2="100%"
61
+ stroke="var(--flow-helper-line, var(--primary))"
62
+ strokeWidth={1}
63
+ />
64
+ ) : null}
65
+ </svg>
66
+ );
67
+ });
@@ -0,0 +1,7 @@
1
+ export { getHelperLines, type HelperLineRect, type HelperLinesResult } from "./get-helper-lines";
2
+ export {
3
+ useHelperLines,
4
+ type UseHelperLinesOptions,
5
+ type UseHelperLinesResult,
6
+ } from "./use-helper-lines";
7
+ export { HelperLines, type HelperLinesProps } from "./helper-lines";