@elabs-ai/components-flow 4.1.0 → 5.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.
- package/README.md +2 -1
- package/dist/canvas-shell/canvas-shell.js +13 -0
- package/dist/canvas-shell/canvas-shell.js.map +1 -0
- package/dist/canvas-shell/clamped-fit-offset.js +8 -0
- package/dist/canvas-shell/clamped-fit-offset.js.map +1 -0
- package/dist/canvas-shell/index.js +14 -0
- package/dist/canvas-shell/index.js.map +1 -0
- package/dist/canvas-shell/use-measured-nodes.js +8 -0
- package/dist/canvas-shell/use-measured-nodes.js.map +1 -0
- package/dist/chunk-3BTU2VWZ.js +42 -0
- package/dist/chunk-3BTU2VWZ.js.map +1 -0
- package/dist/chunk-3LK6OOXE.js +2 -0
- package/dist/chunk-3LK6OOXE.js.map +1 -0
- package/dist/chunk-3M3FQAD4.js +105 -0
- package/dist/chunk-3M3FQAD4.js.map +1 -0
- package/dist/chunk-3Y5TCGC3.js +2 -0
- package/dist/chunk-3Y5TCGC3.js.map +1 -0
- package/dist/chunk-4VYHZM3S.js +108 -0
- package/dist/chunk-4VYHZM3S.js.map +1 -0
- package/dist/chunk-5TVVM4GR.js +45 -0
- package/dist/chunk-5TVVM4GR.js.map +1 -0
- package/dist/chunk-5XT73MQQ.js +2 -0
- package/dist/chunk-5XT73MQQ.js.map +1 -0
- package/dist/chunk-6LQD5Y5R.js +24 -0
- package/dist/chunk-6LQD5Y5R.js.map +1 -0
- package/dist/chunk-6SR26QY2.js +18 -0
- package/dist/chunk-6SR26QY2.js.map +1 -0
- package/dist/chunk-75JJK6BH.js +157 -0
- package/dist/chunk-75JJK6BH.js.map +1 -0
- package/dist/chunk-A66JBW4X.js +2 -0
- package/dist/chunk-A66JBW4X.js.map +1 -0
- package/dist/chunk-A7AFM3LI.js +72 -0
- package/dist/chunk-A7AFM3LI.js.map +1 -0
- package/dist/chunk-ADHLQF56.js +2 -0
- package/dist/chunk-ADHLQF56.js.map +1 -0
- package/dist/chunk-ATO4LXJF.js +75 -0
- package/dist/chunk-ATO4LXJF.js.map +1 -0
- package/dist/chunk-ATXVXTMZ.js +227 -0
- package/dist/chunk-ATXVXTMZ.js.map +1 -0
- package/dist/chunk-B6IUPZU7.js +2 -0
- package/dist/chunk-B6IUPZU7.js.map +1 -0
- package/dist/chunk-BJ7UMCD3.js +53 -0
- package/dist/chunk-BJ7UMCD3.js.map +1 -0
- package/dist/chunk-BWGYV4JZ.js +2 -0
- package/dist/chunk-BWGYV4JZ.js.map +1 -0
- package/dist/chunk-CWNO63LQ.js +109 -0
- package/dist/chunk-CWNO63LQ.js.map +1 -0
- package/dist/chunk-DHPUXKCZ.js +76 -0
- package/dist/chunk-DHPUXKCZ.js.map +1 -0
- package/dist/chunk-DNX7G3KA.js +185 -0
- package/dist/chunk-DNX7G3KA.js.map +1 -0
- package/dist/chunk-DNZRAJAQ.js +41 -0
- package/dist/chunk-DNZRAJAQ.js.map +1 -0
- package/dist/chunk-ELCLRVD4.js +2 -0
- package/dist/chunk-ELCLRVD4.js.map +1 -0
- package/dist/chunk-ELOADZSX.js +2 -0
- package/dist/chunk-ELOADZSX.js.map +1 -0
- package/dist/chunk-EUNV2MR5.js +21 -0
- package/dist/chunk-EUNV2MR5.js.map +1 -0
- package/dist/chunk-FAFQ5KLB.js +43 -0
- package/dist/chunk-FAFQ5KLB.js.map +1 -0
- package/dist/chunk-FIRO6Y3R.js +128 -0
- package/dist/chunk-FIRO6Y3R.js.map +1 -0
- package/dist/chunk-GE73WQUK.js +2 -0
- package/dist/chunk-GE73WQUK.js.map +1 -0
- package/dist/chunk-GIARAOU6.js +39 -0
- package/dist/chunk-GIARAOU6.js.map +1 -0
- package/dist/chunk-HTLTCCFA.js +73 -0
- package/dist/chunk-HTLTCCFA.js.map +1 -0
- package/dist/chunk-I53W3CFN.js +56 -0
- package/dist/chunk-I53W3CFN.js.map +1 -0
- package/dist/chunk-IJVFPUXO.js +2 -0
- package/dist/chunk-IJVFPUXO.js.map +1 -0
- package/dist/chunk-JUPMOISE.js +51 -0
- package/dist/chunk-JUPMOISE.js.map +1 -0
- package/dist/chunk-K5RPSVO6.js +167 -0
- package/dist/chunk-K5RPSVO6.js.map +1 -0
- package/dist/chunk-K6U27YJ6.js +78 -0
- package/dist/chunk-K6U27YJ6.js.map +1 -0
- package/dist/chunk-KDVMGMAK.js +2 -0
- package/dist/chunk-KDVMGMAK.js.map +1 -0
- package/dist/chunk-LQOZCOK3.js +74 -0
- package/dist/chunk-LQOZCOK3.js.map +1 -0
- package/dist/chunk-NAQWNX2A.js +102 -0
- package/dist/chunk-NAQWNX2A.js.map +1 -0
- package/dist/chunk-NVJVWGUS.js +152 -0
- package/dist/chunk-NVJVWGUS.js.map +1 -0
- package/dist/chunk-O2F3R3PV.js +2 -0
- package/dist/chunk-O2F3R3PV.js.map +1 -0
- package/dist/chunk-OMFQHMA6.js +2 -0
- package/dist/chunk-OMFQHMA6.js.map +1 -0
- package/dist/chunk-PE6JXVOX.js +2 -0
- package/dist/chunk-PE6JXVOX.js.map +1 -0
- package/dist/chunk-ROJEIIA4.js +52 -0
- package/dist/chunk-ROJEIIA4.js.map +1 -0
- package/dist/chunk-ROYAQKJA.js +9 -0
- package/dist/chunk-ROYAQKJA.js.map +1 -0
- package/dist/chunk-SHCZK4DO.js +45 -0
- package/dist/chunk-SHCZK4DO.js.map +1 -0
- package/dist/chunk-SXXJV6YJ.js +79 -0
- package/dist/chunk-SXXJV6YJ.js.map +1 -0
- package/dist/chunk-T7S7NJFD.js +287 -0
- package/dist/chunk-T7S7NJFD.js.map +1 -0
- package/dist/chunk-TEZ3OXAY.js +63 -0
- package/dist/chunk-TEZ3OXAY.js.map +1 -0
- package/dist/chunk-TGWPM2CG.js +199 -0
- package/dist/chunk-TGWPM2CG.js.map +1 -0
- package/dist/chunk-TSB6RE7L.js +2 -0
- package/dist/chunk-TSB6RE7L.js.map +1 -0
- package/dist/chunk-UPV2MT4O.js +2 -0
- package/dist/chunk-UPV2MT4O.js.map +1 -0
- package/dist/chunk-UZJZY4A5.js +2 -0
- package/dist/chunk-UZJZY4A5.js.map +1 -0
- package/dist/chunk-W3TQMUGX.js +2 -0
- package/dist/chunk-W3TQMUGX.js.map +1 -0
- package/dist/chunk-WN46RIOC.js +29 -0
- package/dist/chunk-WN46RIOC.js.map +1 -0
- package/dist/chunk-XZFE2YYH.js +70 -0
- package/dist/chunk-XZFE2YYH.js.map +1 -0
- package/dist/chunk-YG3KXTZW.js +2 -0
- package/dist/chunk-YG3KXTZW.js.map +1 -0
- package/dist/chunk-ZATVF42E.js +65 -0
- package/dist/chunk-ZATVF42E.js.map +1 -0
- package/dist/chunk-ZXZTLQIX.js +19 -0
- package/dist/chunk-ZXZTLQIX.js.map +1 -0
- package/dist/flow-button-edge/flow-button-edge.js +10 -0
- package/dist/flow-button-edge/flow-button-edge.js.map +1 -0
- package/dist/flow-button-edge/index.js +11 -0
- package/dist/flow-button-edge/index.js.map +1 -0
- package/dist/flow-edge/flow-edge.js +10 -0
- package/dist/flow-edge/flow-edge.js.map +1 -0
- package/dist/flow-edge/index.js +11 -0
- package/dist/flow-edge/index.js.map +1 -0
- package/dist/flow-edge-path/flow-edge-path.js +12 -0
- package/dist/flow-edge-path/flow-edge-path.js.map +1 -0
- package/dist/flow-edge-path/index.js +13 -0
- package/dist/flow-edge-path/index.js.map +1 -0
- package/dist/flow-edge-tokens/edge-path-sampler.js +10 -0
- package/dist/flow-edge-tokens/edge-path-sampler.js.map +1 -0
- package/dist/flow-edge-tokens/flow-edge-tokens.js +12 -0
- package/dist/flow-edge-tokens/flow-edge-tokens.js.map +1 -0
- package/dist/flow-edge-tokens/index.js +17 -0
- package/dist/flow-edge-tokens/index.js.map +1 -0
- package/dist/flow-floating-edge/floating-edge-geometry.js +8 -0
- package/dist/flow-floating-edge/floating-edge-geometry.js.map +1 -0
- package/dist/flow-floating-edge/flow-floating-edge.js +11 -0
- package/dist/flow-floating-edge/flow-floating-edge.js.map +1 -0
- package/dist/flow-floating-edge/index.js +15 -0
- package/dist/flow-floating-edge/index.js.map +1 -0
- package/dist/flow-group-node/flow-group-node.js +12 -0
- package/dist/flow-group-node/flow-group-node.js.map +1 -0
- package/dist/flow-group-node/index.js +13 -0
- package/dist/flow-group-node/index.js.map +1 -0
- package/dist/flow-handle/flow-handle-anchor.js +8 -0
- package/dist/flow-handle/flow-handle-anchor.js.map +1 -0
- package/dist/flow-handle/index.js +9 -0
- package/dist/flow-handle/index.js.map +1 -0
- package/dist/flow-layout/backbone.js +8 -0
- package/dist/flow-layout/backbone.js.map +1 -0
- package/dist/flow-layout/flow-layout.js +10 -0
- package/dist/flow-layout/flow-layout.js.map +1 -0
- package/dist/flow-layout/index.js +21 -0
- package/dist/flow-layout/index.js.map +1 -0
- package/dist/flow-layout/layout-flow-elk.js +9 -0
- package/dist/flow-layout/layout-flow-elk.js.map +1 -0
- package/dist/flow-layout/layout-graph.js +9 -0
- package/dist/flow-layout/layout-graph.js.map +1 -0
- package/dist/flow-layout/use-auto-layout.js +10 -0
- package/dist/flow-layout/use-auto-layout.js.map +1 -0
- package/dist/flow-layout/use-flow-layout.js +9 -0
- package/dist/flow-layout/use-flow-layout.js.map +1 -0
- package/dist/flow-mini-map/flow-mini-map.js +8 -0
- package/dist/flow-mini-map/flow-mini-map.js.map +1 -0
- package/dist/flow-mini-map/index.js +9 -0
- package/dist/flow-mini-map/index.js.map +1 -0
- package/dist/flow-node/flow-node.js +11 -0
- package/dist/flow-node/flow-node.js.map +1 -0
- package/dist/flow-node/index.js +12 -0
- package/dist/flow-node/index.js.map +1 -0
- package/dist/flow-placeholder-node/flow-placeholder-node.js +9 -0
- package/dist/flow-placeholder-node/flow-placeholder-node.js.map +1 -0
- package/dist/flow-placeholder-node/index.js +10 -0
- package/dist/flow-placeholder-node/index.js.map +1 -0
- package/dist/flow-self-loop-edge/flow-self-loop-edge.js +20 -0
- package/dist/flow-self-loop-edge/flow-self-loop-edge.js.map +1 -0
- package/dist/flow-self-loop-edge/index.js +28 -0
- package/dist/flow-self-loop-edge/index.js.map +1 -0
- package/dist/flow-self-loop-edge/self-loop-geometry.js +12 -0
- package/dist/flow-self-loop-edge/self-loop-geometry.js.map +1 -0
- package/dist/flow-smart-edge/flow-smart-edge.js +11 -0
- package/dist/flow-smart-edge/flow-smart-edge.js.map +1 -0
- package/dist/flow-smart-edge/index.js +25 -0
- package/dist/flow-smart-edge/index.js.map +1 -0
- package/dist/flow-smart-edge/smart-edge-geometry.js +18 -0
- package/dist/flow-smart-edge/smart-edge-geometry.js.map +1 -0
- package/dist/flow-weighted-edge/back-edge-geometry.js +8 -0
- package/dist/flow-weighted-edge/back-edge-geometry.js.map +1 -0
- package/dist/flow-weighted-edge/edge-aria.js +10 -0
- package/dist/flow-weighted-edge/edge-aria.js.map +1 -0
- package/dist/flow-weighted-edge/edge-label-pill.js +8 -0
- package/dist/flow-weighted-edge/edge-label-pill.js.map +1 -0
- package/dist/flow-weighted-edge/flow-weighted-edge.js +16 -0
- package/dist/flow-weighted-edge/flow-weighted-edge.js.map +1 -0
- package/dist/flow-weighted-edge/index.js +34 -0
- package/dist/flow-weighted-edge/index.js.map +1 -0
- package/dist/flow-weighted-edge/weight-scale.js +10 -0
- package/dist/flow-weighted-edge/weight-scale.js.map +1 -0
- package/dist/helper-lines/get-helper-lines.js +8 -0
- package/dist/helper-lines/get-helper-lines.js.map +1 -0
- package/dist/helper-lines/helper-lines.js +9 -0
- package/dist/helper-lines/helper-lines.js.map +1 -0
- package/dist/helper-lines/index.js +17 -0
- package/dist/helper-lines/index.js.map +1 -0
- package/dist/helper-lines/use-helper-lines.js +10 -0
- package/dist/helper-lines/use-helper-lines.js.map +1 -0
- package/dist/index.d.ts +241 -2
- package/dist/index.js +167 -2306
- package/dist/index.js.map +1 -1
- package/dist/inspector-panel/index.js +9 -0
- package/dist/inspector-panel/index.js.map +1 -0
- package/dist/inspector-panel/inspector-panel.js +8 -0
- package/dist/inspector-panel/inspector-panel.js.map +1 -0
- package/dist/legend/index.js +10 -0
- package/dist/legend/index.js.map +1 -0
- package/dist/legend/legend.js +9 -0
- package/dist/legend/legend.js.map +1 -0
- package/dist/use-flow-groups/group-operations.js +20 -0
- package/dist/use-flow-groups/group-operations.js.map +1 -0
- package/dist/use-flow-groups/index.js +25 -0
- package/dist/use-flow-groups/index.js.map +1 -0
- package/dist/use-flow-groups/use-flow-groups.js +9 -0
- package/dist/use-flow-groups/use-flow-groups.js.map +1 -0
- package/dist/zoom-controls/index.js +9 -0
- package/dist/zoom-controls/index.js.map +1 -0
- package/dist/zoom-controls/zoom-controls.js +8 -0
- package/dist/zoom-controls/zoom-controls.js.map +1 -0
- package/package.json +14 -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.stories.tsx +1 -0
- package/src/canvas-shell/canvas-shell.tsx +51 -4
- package/src/canvas-shell/clamped-fit-offset.test.ts +25 -0
- package/src/canvas-shell/clamped-fit-offset.ts +30 -0
- package/src/custom-nodes/custom-nodes.stories.tsx +718 -0
- package/src/flow-edge-tokens/edge-path-sampler.test.ts +108 -0
- package/src/flow-edge-tokens/edge-path-sampler.ts +362 -0
- package/src/flow-edge-tokens/flow-edge-tokens.stories.tsx +312 -0
- package/src/flow-edge-tokens/flow-edge-tokens.test.tsx +115 -0
- package/src/flow-edge-tokens/flow-edge-tokens.tsx +94 -0
- package/src/flow-edge-tokens/index.ts +12 -0
- package/src/flow-layout/backbone.test.ts +88 -0
- package/src/flow-layout/backbone.ts +58 -0
- package/src/flow-layout/layout-flow-elk.stories.tsx +154 -0
- package/src/flow-layout/layout-flow-elk.test.ts +152 -0
- package/src/flow-layout/layout-flow-elk.ts +399 -0
- package/src/flow-mini-map/flow-mini-map.stories.tsx +20 -0
- package/src/flow-node/flow-node.stories.tsx +40 -1
- package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +38 -0
- package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +7 -0
- package/src/flow-weighted-edge/edge-label-pill.test.tsx +20 -0
- package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +1 -0
- package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +40 -0
- package/src/flow-weighted-edge/flow-weighted-edge.tsx +7 -0
- package/src/index.ts +13 -0
- package/src/inspector-panel/inspector-panel.tsx +9 -6
- package/src/legend/legend.test.tsx +11 -0
- package/src/legend/legend.tsx +15 -7
- package/src/templates-flow-workspace.stories.tsx +7 -2
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import type { Edge, Node, XYPosition } from "@xyflow/react";
|
|
2
|
+
import type { FlowElkBackbone } from "./backbone";
|
|
3
|
+
import {
|
|
4
|
+
HANDLE_BY_DIRECTION,
|
|
5
|
+
layoutFlow,
|
|
6
|
+
type FlowLayoutDirection,
|
|
7
|
+
type FlowLayoutResult,
|
|
8
|
+
} from "./flow-layout";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* elkjs layout adapter — RM-067.
|
|
12
|
+
*
|
|
13
|
+
* dagre has no self-loop layout, no edge routing and weak compound nodes. ELK (Eclipse
|
|
14
|
+
* Layout Kernel, EPL-2.0) has all three, and it is large — so it is an OPTIONAL peer of this
|
|
15
|
+
* package, reached ONLY through `import()` inside {@link layoutFlowElk}'s body. A consumer
|
|
16
|
+
* that never calls it never downloads it, and a consumer that calls it without installing
|
|
17
|
+
* elkjs gets `layoutFlow`'s dagre answer plus a development-only console warning, never a
|
|
18
|
+
* crash.
|
|
19
|
+
*
|
|
20
|
+
* The default engine runs inline on the calling thread (elkjs's own in-thread worker
|
|
21
|
+
* stand-in). The package deliberately ships NO worker entry: a `new Worker(new URL(…,
|
|
22
|
+
* import.meta.url))` pointing at a file the tsup build does not emit breaks some consumer
|
|
23
|
+
* bundlers at build time. A host that wants the layout off the main thread supplies
|
|
24
|
+
* `loadEngine` with its own worker, e.g.
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* loadEngine: async () => {
|
|
28
|
+
* const { default: ELK } = await import("elkjs/lib/elk-api.js");
|
|
29
|
+
* return new ELK({ workerUrl: new URL("elkjs/lib/elk-worker.min.js", import.meta.url).href });
|
|
30
|
+
* }
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* No remote origin is involved either way.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** Options for {@link layoutFlowElk}. */
|
|
37
|
+
export interface FlowLayoutElkOptions {
|
|
38
|
+
/** Layout direction. @default "TB" */
|
|
39
|
+
direction?: FlowLayoutDirection;
|
|
40
|
+
/** How ELK routes edges. @default "splines" */
|
|
41
|
+
edgeRouting?: "orthogonal" | "splines";
|
|
42
|
+
/** Route back-edges as ELK feedback edges (around the graph, not through it). @default true */
|
|
43
|
+
feedbackEdges?: boolean;
|
|
44
|
+
/** Gap between nodes in the same layer. @default 48 */
|
|
45
|
+
nodeSpacing?: number;
|
|
46
|
+
/** Gap between layers. @default 72 */
|
|
47
|
+
rankSpacing?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Compound nodes. Each `id` names a node already in `nodes` (typically a `FlowGroupNode`);
|
|
50
|
+
* its `children` are laid out inside it and come back with `parentId` set, positioned
|
|
51
|
+
* relative to the group, and the group comes back sized to fit them.
|
|
52
|
+
*/
|
|
53
|
+
groups?: { id: string; children: string[] }[];
|
|
54
|
+
/** A pinned straight path — see `pinBackbone`. */
|
|
55
|
+
backbone?: FlowElkBackbone;
|
|
56
|
+
/**
|
|
57
|
+
* Supply the engine yourself: a host with its own worker URL, or a test. Rejecting (for
|
|
58
|
+
* example because elkjs is not installed) falls back to dagre exactly like the default
|
|
59
|
+
* loader does.
|
|
60
|
+
*/
|
|
61
|
+
loadEngine?: () => Promise<FlowElkEngine>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A graph in ELK's JSON format — the minimal shape this adapter reads and writes. */
|
|
65
|
+
export interface FlowElkGraph {
|
|
66
|
+
id: string;
|
|
67
|
+
x?: number;
|
|
68
|
+
y?: number;
|
|
69
|
+
width?: number;
|
|
70
|
+
height?: number;
|
|
71
|
+
layoutOptions?: Record<string, string>;
|
|
72
|
+
children?: FlowElkGraph[];
|
|
73
|
+
edges?: {
|
|
74
|
+
id: string;
|
|
75
|
+
sources: string[];
|
|
76
|
+
targets: string[];
|
|
77
|
+
layoutOptions?: Record<string, string>;
|
|
78
|
+
}[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A running ELK engine. `new ELK()` from elkjs satisfies it. */
|
|
82
|
+
export interface FlowElkEngine {
|
|
83
|
+
layout(graph: FlowElkGraph): Promise<FlowElkGraph>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What {@link layoutFlowElk} answers — `layoutFlow`'s result plus which engine produced it. */
|
|
87
|
+
export interface FlowLayoutElkResult<
|
|
88
|
+
NodeType extends Node = Node,
|
|
89
|
+
EdgeType extends Edge = Edge,
|
|
90
|
+
> extends FlowLayoutResult<NodeType, EdgeType> {
|
|
91
|
+
/** `"elk"`, or `"dagre"` when elkjs could not be loaded or failed and dagre answered. */
|
|
92
|
+
engine: "elk" | "dagre";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ELK_DIRECTION: Record<FlowLayoutDirection, string> = {
|
|
96
|
+
TB: "DOWN",
|
|
97
|
+
BT: "UP",
|
|
98
|
+
LR: "RIGHT",
|
|
99
|
+
RL: "LEFT",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Room above a group's children for its header, matching `groupNodes`' own offset. */
|
|
103
|
+
const GROUP_PADDING = "[top=60,left=16,bottom=16,right=16]";
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Straightness priority for backbone edges, against the default `0` of every other edge —
|
|
107
|
+
* enough for network-simplex placement to favour them in every tie it breaks.
|
|
108
|
+
*/
|
|
109
|
+
const BACKBONE_STRAIGHTNESS = "100";
|
|
110
|
+
|
|
111
|
+
const DEFAULT_NODE_WIDTH = 172;
|
|
112
|
+
const DEFAULT_NODE_HEIGHT = 40;
|
|
113
|
+
|
|
114
|
+
function nodeSize(node: Node): { width: number; height: number } {
|
|
115
|
+
return {
|
|
116
|
+
width: node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH,
|
|
117
|
+
height: node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** CommonJS interop: elkjs ships CJS, which bundlers and Node wrap in `default` differently. */
|
|
122
|
+
function interopDefault<T>(mod: unknown): T {
|
|
123
|
+
let value = mod as { default?: unknown };
|
|
124
|
+
while (value && typeof value === "object" && "default" in value && value.default) {
|
|
125
|
+
value = value.default as { default?: unknown };
|
|
126
|
+
}
|
|
127
|
+
return value as T;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type ElkConstructor = new (args: { workerFactory: (url?: string) => unknown }) => FlowElkEngine;
|
|
131
|
+
|
|
132
|
+
type FakeWorkerConstructor = new (url?: string) => unknown;
|
|
133
|
+
|
|
134
|
+
/** The default engine: elkjs run inline on the calling thread (see the module docblock). */
|
|
135
|
+
async function loadDefaultEngine(): Promise<FlowElkEngine> {
|
|
136
|
+
const ELK = interopDefault<ElkConstructor>(await import("elkjs/lib/elk-api.js"));
|
|
137
|
+
// `elk-worker.min.js` exports `{ default: FakeWorker, Worker: FakeWorker }` outside a worker.
|
|
138
|
+
// @ts-expect-error -- elkjs ships no declaration for the minified worker; the shape is narrowed below.
|
|
139
|
+
const mod = (await import("elkjs/lib/elk-worker.min.js")) as {
|
|
140
|
+
Worker?: FakeWorkerConstructor;
|
|
141
|
+
default?: FakeWorkerConstructor | { Worker?: FakeWorkerConstructor };
|
|
142
|
+
};
|
|
143
|
+
const FakeWorker =
|
|
144
|
+
mod.Worker ?? (typeof mod.default === "function" ? mod.default : mod.default?.Worker);
|
|
145
|
+
if (FakeWorker === undefined) throw new Error("elkjs inline worker not found");
|
|
146
|
+
return new ELK({ workerFactory: (url) => new FakeWorker(url) });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function warnFallback(error: unknown): void {
|
|
150
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV === "production") return;
|
|
151
|
+
console.warn(
|
|
152
|
+
"[@elabs-ai/components-flow] layoutFlowElk could not run elkjs (is the optional peer " +
|
|
153
|
+
"`elkjs` installed?) — falling back to the dagre layout.",
|
|
154
|
+
error,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Build ELK's hierarchical graph, nesting grouped nodes under their group. */
|
|
159
|
+
function toElkGraph(nodes: Node[], edges: Edge[], options: FlowLayoutElkOptions): FlowElkGraph {
|
|
160
|
+
const {
|
|
161
|
+
direction = "TB",
|
|
162
|
+
edgeRouting = "splines",
|
|
163
|
+
feedbackEdges = true,
|
|
164
|
+
nodeSpacing = 48,
|
|
165
|
+
rankSpacing = 72,
|
|
166
|
+
groups = [],
|
|
167
|
+
backbone,
|
|
168
|
+
} = options;
|
|
169
|
+
|
|
170
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
171
|
+
const straight = new Set(backbone?.edgeIds ?? []);
|
|
172
|
+
const parentOf = new Map<string, string>();
|
|
173
|
+
const groupIds = new Set<string>();
|
|
174
|
+
for (const group of groups) {
|
|
175
|
+
if (!ids.has(group.id)) continue;
|
|
176
|
+
groupIds.add(group.id);
|
|
177
|
+
for (const child of group.children) {
|
|
178
|
+
if (ids.has(child) && child !== group.id) parentOf.set(child, group.id);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const elkNodes = new Map<string, FlowElkGraph>();
|
|
183
|
+
for (const node of nodes) {
|
|
184
|
+
const isGroup = groupIds.has(node.id);
|
|
185
|
+
elkNodes.set(node.id, {
|
|
186
|
+
id: node.id,
|
|
187
|
+
// A group is sized by ELK from its children; a leaf keeps its measured size.
|
|
188
|
+
...(isGroup ? { layoutOptions: { "elk.padding": GROUP_PADDING } } : nodeSize(node)),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const root: FlowElkGraph = {
|
|
193
|
+
id: "__flow_elk_root__",
|
|
194
|
+
layoutOptions: {
|
|
195
|
+
"elk.algorithm": "layered",
|
|
196
|
+
"elk.direction": ELK_DIRECTION[direction],
|
|
197
|
+
"elk.edgeRouting": edgeRouting === "orthogonal" ? "ORTHOGONAL" : "SPLINES",
|
|
198
|
+
"elk.spacing.nodeNode": String(nodeSpacing),
|
|
199
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": String(rankSpacing),
|
|
200
|
+
"elk.layered.feedbackEdges": String(feedbackEdges),
|
|
201
|
+
// Break cycles by input order, as a reader would: the edge pointing back to an
|
|
202
|
+
// EARLIER node is the reversed one — the same edge dagre reports.
|
|
203
|
+
"elk.layered.cycleBreaking.strategy": "MODEL_ORDER",
|
|
204
|
+
"elk.hierarchyHandling": groupIds.size > 0 ? "INCLUDE_CHILDREN" : "INHERIT",
|
|
205
|
+
...(backbone && backbone.nodeIds.length > 0
|
|
206
|
+
? { "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX" }
|
|
207
|
+
: {}),
|
|
208
|
+
},
|
|
209
|
+
children: [],
|
|
210
|
+
edges: edges
|
|
211
|
+
.filter((edge) => ids.has(edge.source) && ids.has(edge.target))
|
|
212
|
+
.map((edge) => ({
|
|
213
|
+
id: edge.id,
|
|
214
|
+
sources: [edge.source],
|
|
215
|
+
targets: [edge.target],
|
|
216
|
+
...(straight.has(edge.id)
|
|
217
|
+
? { layoutOptions: { "elk.layered.priority.straightness": BACKBONE_STRAIGHTNESS } }
|
|
218
|
+
: {}),
|
|
219
|
+
})),
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
for (const node of nodes) {
|
|
223
|
+
const elkNode = elkNodes.get(node.id)!;
|
|
224
|
+
const parent = resolveParent(node.id, parentOf);
|
|
225
|
+
const container = parent === undefined ? root : elkNodes.get(parent)!;
|
|
226
|
+
(container.children ??= []).push(elkNode);
|
|
227
|
+
}
|
|
228
|
+
return root;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** A node's group, ignoring any parent chain that would loop back onto itself. */
|
|
232
|
+
function resolveParent(id: string, parentOf: Map<string, string>): string | undefined {
|
|
233
|
+
const parent = parentOf.get(id);
|
|
234
|
+
if (parent === undefined) return undefined;
|
|
235
|
+
const seen = new Set([id]);
|
|
236
|
+
let cursor: string | undefined = parent;
|
|
237
|
+
while (cursor !== undefined) {
|
|
238
|
+
if (seen.has(cursor)) return undefined;
|
|
239
|
+
seen.add(cursor);
|
|
240
|
+
cursor = parentOf.get(cursor);
|
|
241
|
+
}
|
|
242
|
+
return parent;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
interface PlacedNode {
|
|
246
|
+
relative: XYPosition;
|
|
247
|
+
absolute: XYPosition;
|
|
248
|
+
width?: number;
|
|
249
|
+
height?: number;
|
|
250
|
+
parentId?: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function collectPlacements(
|
|
254
|
+
graph: FlowElkGraph,
|
|
255
|
+
origin: XYPosition,
|
|
256
|
+
parentId: string | undefined,
|
|
257
|
+
out: Map<string, PlacedNode>,
|
|
258
|
+
): void {
|
|
259
|
+
for (const child of graph.children ?? []) {
|
|
260
|
+
const relative = { x: child.x ?? 0, y: child.y ?? 0 };
|
|
261
|
+
const absolute = { x: origin.x + relative.x, y: origin.y + relative.y };
|
|
262
|
+
out.set(child.id, {
|
|
263
|
+
relative,
|
|
264
|
+
absolute,
|
|
265
|
+
width: child.width,
|
|
266
|
+
height: child.height,
|
|
267
|
+
...(parentId === undefined ? {} : { parentId }),
|
|
268
|
+
});
|
|
269
|
+
if (child.children && child.children.length > 0) {
|
|
270
|
+
collectPlacements(child, absolute, child.id, out);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Put the backbone's nodes on one line by translating each of their LAYERS along the cross
|
|
277
|
+
* axis. ELK's straightness priority gets close but does not guarantee a line; shifting a
|
|
278
|
+
* whole layer does, and cannot overlap two nodes of that layer. Only top-level nodes are
|
|
279
|
+
* moved — a node inside a group is positioned relative to it.
|
|
280
|
+
*/
|
|
281
|
+
function straightenBackbone(
|
|
282
|
+
placements: Map<string, PlacedNode>,
|
|
283
|
+
nodeIds: readonly string[],
|
|
284
|
+
direction: FlowLayoutDirection,
|
|
285
|
+
): void {
|
|
286
|
+
const along = direction === "LR" || direction === "RL" ? "x" : "y";
|
|
287
|
+
const cross = along === "x" ? "y" : "x";
|
|
288
|
+
const extent = (p: PlacedNode, axis: "x" | "y") => (axis === "x" ? p.width : p.height) ?? 0;
|
|
289
|
+
const centerOf = (p: PlacedNode) => p.absolute[cross] + extent(p, cross) / 2;
|
|
290
|
+
|
|
291
|
+
const pinned = nodeIds
|
|
292
|
+
.map((id) => placements.get(id))
|
|
293
|
+
.filter((p): p is PlacedNode => p !== undefined && p.parentId === undefined);
|
|
294
|
+
const first = pinned[0];
|
|
295
|
+
if (first === undefined) return;
|
|
296
|
+
const line = centerOf(first);
|
|
297
|
+
|
|
298
|
+
const topLevel = [...placements.values()].filter((p) => p.parentId === undefined);
|
|
299
|
+
const moved = new Set<PlacedNode>();
|
|
300
|
+
for (const anchor of pinned) {
|
|
301
|
+
const delta = line - centerOf(anchor);
|
|
302
|
+
if (delta === 0) continue;
|
|
303
|
+
const start = anchor.absolute[along];
|
|
304
|
+
const end = start + extent(anchor, along);
|
|
305
|
+
for (const p of topLevel) {
|
|
306
|
+
if (moved.has(p)) continue;
|
|
307
|
+
const pStart = p.absolute[along];
|
|
308
|
+
if (pStart >= end || pStart + extent(p, along) <= start) continue;
|
|
309
|
+
p.absolute = { ...p.absolute, [cross]: p.absolute[cross] + delta };
|
|
310
|
+
p.relative = { ...p.relative, [cross]: p.relative[cross] + delta };
|
|
311
|
+
moved.add(p);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Lay a graph out with ELK's layered algorithm — `layoutFlow`'s async sibling.
|
|
318
|
+
*
|
|
319
|
+
* Same result shape as `layoutFlow` (`nodes`, `edges`, `backEdges`, `selfLoops`), so a
|
|
320
|
+
* caller can swap engines without a different consumption path; `engine` says which one
|
|
321
|
+
* actually answered. Self-loops are laid out by ELK (it routes them around their node) and
|
|
322
|
+
* still reported in `selfLoops`; `backEdges` are the edges whose source sits at or after
|
|
323
|
+
* their target along the layout direction.
|
|
324
|
+
*
|
|
325
|
+
* elkjs is imported lazily inside this function. If it is not installed, or ELK throws,
|
|
326
|
+
* the result is `layoutFlow`'s dagre layout with `engine: "dagre"`.
|
|
327
|
+
*/
|
|
328
|
+
export async function layoutFlowElk<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
|
|
329
|
+
nodes: NodeType[],
|
|
330
|
+
edges: EdgeType[],
|
|
331
|
+
options: FlowLayoutElkOptions = {},
|
|
332
|
+
): Promise<FlowLayoutElkResult<NodeType, EdgeType>> {
|
|
333
|
+
const direction = options.direction ?? "TB";
|
|
334
|
+
|
|
335
|
+
let laidOut: FlowElkGraph;
|
|
336
|
+
try {
|
|
337
|
+
const engine = await (options.loadEngine ?? loadDefaultEngine)();
|
|
338
|
+
laidOut = await engine.layout(toElkGraph(nodes, edges, options));
|
|
339
|
+
} catch (error) {
|
|
340
|
+
warnFallback(error);
|
|
341
|
+
return {
|
|
342
|
+
...layoutFlow(nodes, edges, {
|
|
343
|
+
direction,
|
|
344
|
+
...(options.nodeSpacing === undefined ? {} : { nodeSpacing: options.nodeSpacing }),
|
|
345
|
+
...(options.rankSpacing === undefined ? {} : { rankSpacing: options.rankSpacing }),
|
|
346
|
+
}),
|
|
347
|
+
engine: "dagre",
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const placements = new Map<string, PlacedNode>();
|
|
352
|
+
collectPlacements(laidOut, { x: 0, y: 0 }, undefined, placements);
|
|
353
|
+
if (options.backbone) straightenBackbone(placements, options.backbone.nodeIds, direction);
|
|
354
|
+
|
|
355
|
+
const handles = HANDLE_BY_DIRECTION[direction];
|
|
356
|
+
const groupIds = new Set((options.groups ?? []).map((group) => group.id));
|
|
357
|
+
const layoutedNodes = nodes.map((node) => {
|
|
358
|
+
const placed = placements.get(node.id);
|
|
359
|
+
if (!placed) return node;
|
|
360
|
+
const next: NodeType = {
|
|
361
|
+
...node,
|
|
362
|
+
sourcePosition: handles.source,
|
|
363
|
+
targetPosition: handles.target,
|
|
364
|
+
position: placed.relative,
|
|
365
|
+
};
|
|
366
|
+
if (placed.parentId !== undefined) {
|
|
367
|
+
next.parentId = placed.parentId;
|
|
368
|
+
next.extent = "parent";
|
|
369
|
+
}
|
|
370
|
+
if (groupIds.has(node.id) && placed.width !== undefined && placed.height !== undefined) {
|
|
371
|
+
next.width = placed.width;
|
|
372
|
+
next.height = placed.height;
|
|
373
|
+
}
|
|
374
|
+
return next;
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const selfLoops: string[] = [];
|
|
378
|
+
const backEdges: string[] = [];
|
|
379
|
+
const axis = direction === "LR" || direction === "RL" ? "x" : "y";
|
|
380
|
+
const sign = direction === "BT" || direction === "RL" ? -1 : 1;
|
|
381
|
+
const center = (id: string): number | undefined => {
|
|
382
|
+
const placed = placements.get(id);
|
|
383
|
+
if (!placed) return undefined;
|
|
384
|
+
const size = axis === "x" ? (placed.width ?? 0) : (placed.height ?? 0);
|
|
385
|
+
return sign * (placed.absolute[axis] + size / 2);
|
|
386
|
+
};
|
|
387
|
+
for (const edge of edges) {
|
|
388
|
+
if (edge.source === edge.target) {
|
|
389
|
+
selfLoops.push(edge.id);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const source = center(edge.source);
|
|
393
|
+
const target = center(edge.target);
|
|
394
|
+
if (source === undefined || target === undefined) continue;
|
|
395
|
+
if (source >= target) backEdges.push(edge.id);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return { nodes: layoutedNodes, edges, backEdges, selfLoops, engine: "elk" };
|
|
399
|
+
}
|
|
@@ -135,6 +135,26 @@ export const Default: Story = {
|
|
|
135
135
|
return (hi! + 0.05) / (lo! + 0.05);
|
|
136
136
|
};
|
|
137
137
|
|
|
138
|
+
// Measure the SETTLED paint, not the frame the theme switched in. The preview's
|
|
139
|
+
// `ThemeBoundary` writes `data-theme` in an effect, after the canvas has already
|
|
140
|
+
// rendered once under the `:root` (light) fallback. The storybook Vitest project runs
|
|
141
|
+
// with `reducedMotion: "reduce"`, and the tokens' reduced-motion backstop clamps
|
|
142
|
+
// `transition-duration` to 0.01ms on EVERY element — whose `transition-property`
|
|
143
|
+
// defaults to `all` — so the light→dark swap becomes a one-frame colour transition.
|
|
144
|
+
// Read synchronously, `getComputedStyle` still returns the transition's START value
|
|
145
|
+
// (the light panel's white) while the node rects, mounted after the switch, are
|
|
146
|
+
// already dark: a false 2.47:1 in the dark theme only. Awaiting the running
|
|
147
|
+
// transitions on the panel and its rects reads what the browser actually paints.
|
|
148
|
+
// Transitions keep starting after the first batch settles: a child's inherited
|
|
149
|
+
// colour starts its own once the parent's has ended, and a superseded one rejects
|
|
150
|
+
// `finished` while its replacement is still running. So keep waiting
|
|
151
|
+
// until none is left; awaiting only the first batch still read the white panel
|
|
152
|
+
// once on CI.
|
|
153
|
+
for (;;) {
|
|
154
|
+
const running = minimap.getAnimations({ subtree: true });
|
|
155
|
+
if (running.length === 0) break;
|
|
156
|
+
await Promise.allSettled(running.map((animation) => animation.finished));
|
|
157
|
+
}
|
|
138
158
|
const panelBackground = getComputedStyle(minimap).backgroundColor;
|
|
139
159
|
|
|
140
160
|
// The whole point of this helper is that a blank minimap must FAIL the check — lock
|
|
@@ -204,6 +204,43 @@ export const FocusIndicator: Story = {
|
|
|
204
204
|
return (hi! + 0.05) / (lo! + 0.05);
|
|
205
205
|
};
|
|
206
206
|
|
|
207
|
+
// Since ADR 0027 Amendment 2 (commit 779c040b), the shared `focus-ring*`
|
|
208
|
+
// composite always paints its ring as three `box-shadow` layers: a
|
|
209
|
+
// `var(--background)`-coloured "gap" segment and an inset echo segment
|
|
210
|
+
// that both have zero offset/blur/spread by construction at this node's
|
|
211
|
+
// default geometry (they exist for OTHER call sites with a non-zero
|
|
212
|
+
// `--focus-ring-offset`), plus the one visible ring segment in between.
|
|
213
|
+
// A zero-geometry layer paints nothing, so comparing the raw `boxShadow`
|
|
214
|
+
// string byte-for-byte (its old shape, before Amendment 2, had no such
|
|
215
|
+
// padding layers) now reports a difference where nothing actually
|
|
216
|
+
// renders differently. Splitting into layers and keeping only the ones
|
|
217
|
+
// with a non-zero length lets the assertion below compare what is
|
|
218
|
+
// genuinely PAINTED — the resting selection ring plus the node's
|
|
219
|
+
// elevation shadow — while staying just as strict: drop the ring
|
|
220
|
+
// entirely, or change its colour/width, and the visible layers stop
|
|
221
|
+
// matching.
|
|
222
|
+
const splitShadowLayers = (shadowValue: string): string[] => {
|
|
223
|
+
const layers: string[] = [];
|
|
224
|
+
let depth = 0;
|
|
225
|
+
let current = "";
|
|
226
|
+
for (const char of shadowValue) {
|
|
227
|
+
if (char === "(") depth += 1;
|
|
228
|
+
if (char === ")") depth -= 1;
|
|
229
|
+
if (char === "," && depth === 0) {
|
|
230
|
+
layers.push(current.trim());
|
|
231
|
+
current = "";
|
|
232
|
+
} else {
|
|
233
|
+
current += char;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (current.trim()) layers.push(current.trim());
|
|
237
|
+
return layers;
|
|
238
|
+
};
|
|
239
|
+
const isVisibleShadowLayer = (layer: string): boolean =>
|
|
240
|
+
[...layer.matchAll(/(-?[\d.]+)px/g)].some((match) => Number(match[1]) !== 0);
|
|
241
|
+
const visibleShadow = (shadowValue: string): string =>
|
|
242
|
+
splitShadowLayers(shadowValue).filter(isVisibleShadowLayer).join(", ");
|
|
243
|
+
|
|
207
244
|
let plainWrapper!: HTMLElement;
|
|
208
245
|
let plainDiv!: HTMLElement;
|
|
209
246
|
let chosenWrapper!: HTMLElement;
|
|
@@ -283,7 +320,9 @@ export const FocusIndicator: Story = {
|
|
|
283
320
|
await waitFor(() => {
|
|
284
321
|
expect(getComputedStyle(chosenDiv).outlineStyle).toBe("solid");
|
|
285
322
|
});
|
|
286
|
-
await expect(getComputedStyle(chosenDiv).boxShadow).toBe(
|
|
323
|
+
await expect(visibleShadow(getComputedStyle(chosenDiv).boxShadow)).toBe(
|
|
324
|
+
visibleShadow(restingChosenShadow),
|
|
325
|
+
);
|
|
287
326
|
|
|
288
327
|
// Blur restores the resting state.
|
|
289
328
|
await userEvent.tab();
|
|
@@ -194,3 +194,41 @@ describe("FlowSelfLoopEdge", () => {
|
|
|
194
194
|
expect(pill.parentElement!.style.transform).toContain(`translate(${labelX}px, ${labelY}px)`);
|
|
195
195
|
});
|
|
196
196
|
});
|
|
197
|
+
|
|
198
|
+
// RM-065 prerequisite — `data.tokens` draws `FlowEdgeTokens` on the loop's own arc.
|
|
199
|
+
describe("FlowSelfLoopEdge data.tokens", () => {
|
|
200
|
+
it("renders exactly the same markup with an empty list as with the field absent", () => {
|
|
201
|
+
internalNodeBox.current = measuredNode();
|
|
202
|
+
const absent = render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
|
|
203
|
+
const absentHtml = absent.container.innerHTML;
|
|
204
|
+
expect(absent.container.querySelector('[data-slot="flow-edge-tokens"]')).toBeNull();
|
|
205
|
+
absent.unmount();
|
|
206
|
+
const empty = render(<FlowSelfLoopEdge {...makeEdgeProps({ data: { tokens: [] } })} />);
|
|
207
|
+
expect(empty.container.innerHTML).toBe(absentHtml);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("starts and ends its tokens on the arc's own endpoints, outside the named graphic", () => {
|
|
211
|
+
internalNodeBox.current = measuredNode();
|
|
212
|
+
const { container } = render(
|
|
213
|
+
<FlowSelfLoopEdge
|
|
214
|
+
{...makeEdgeProps({
|
|
215
|
+
data: {
|
|
216
|
+
tokens: [
|
|
217
|
+
{ id: "start", progress: 0 },
|
|
218
|
+
{ id: "end", progress: 1 },
|
|
219
|
+
],
|
|
220
|
+
},
|
|
221
|
+
})}
|
|
222
|
+
/>,
|
|
223
|
+
);
|
|
224
|
+
const [start, end] = Array.from(
|
|
225
|
+
container.querySelectorAll<SVGCircleElement>('[data-slot="flow-edge-tokens-token"]'),
|
|
226
|
+
);
|
|
227
|
+
const arc = fixtureArc().path;
|
|
228
|
+
const [sx, sy] = arc.slice(2).split(" ")[0]!.split(",").map(Number);
|
|
229
|
+
expect(start!.style.transform).toBe(`translate(${sx}px, ${sy}px)`);
|
|
230
|
+
const [ex, ey] = arc.trim().split(" ").at(-1)!.split(",").map(Number);
|
|
231
|
+
expect(end!.style.transform).toBe(`translate(${ex}px, ${ey}px)`);
|
|
232
|
+
expect(screen.getByRole("img").querySelector('[data-slot="flow-edge-tokens"]')).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
2
|
import { useEdges, useInternalNode, type Edge, type EdgeProps } from "@xyflow/react";
|
|
3
3
|
import { FlowEdgePath } from "../flow-edge-path";
|
|
4
|
+
import { FlowEdgeTokens, type FlowEdgeToken } from "../flow-edge-tokens";
|
|
4
5
|
import {
|
|
5
6
|
computeEdgeWeightScale,
|
|
6
7
|
DEFAULT_EDGE_WIDTH_RANGE,
|
|
@@ -37,6 +38,11 @@ export interface FlowSelfLoopEdgeData extends Record<string, unknown> {
|
|
|
37
38
|
* own root button from outside this component, without a new semantic prop here.
|
|
38
39
|
*/
|
|
39
40
|
labelProps?: Omit<EdgeLabelPillProps, "label" | "secondaryLabel" | "x" | "y" | "selected">;
|
|
41
|
+
/**
|
|
42
|
+
* Markers travelling along the loop's computed arc (see `FlowEdgeTokens`). The parent
|
|
43
|
+
* drives each token's `progress`; omitted or empty renders exactly the edge without them.
|
|
44
|
+
*/
|
|
45
|
+
tokens?: readonly FlowEdgeToken[];
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
export type BrandFlowSelfLoopEdge = Edge<FlowSelfLoopEdgeData, "self-loop">;
|
|
@@ -159,6 +165,7 @@ export function FlowSelfLoopEdge({
|
|
|
159
165
|
style={{ fill: "none", ...style }}
|
|
160
166
|
/>
|
|
161
167
|
</g>
|
|
168
|
+
{data?.tokens?.length ? <FlowEdgeTokens path={path} tokens={data.tokens} /> : null}
|
|
162
169
|
<EdgeLabelPill
|
|
163
170
|
label={data?.label}
|
|
164
171
|
secondaryLabel={data?.secondaryLabel}
|
|
@@ -62,4 +62,24 @@ describe("EdgeLabelPill", () => {
|
|
|
62
62
|
render(<EdgeLabelPill x={0} y={0} label="128×" data-selection="excluded" />);
|
|
63
63
|
expect(screen.getByRole("button")).toHaveAttribute("data-selection", "excluded");
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
// #354 — a caller-supplied `aria-label` (the seam #351 built) must win over the pill's
|
|
67
|
+
// own computed `[label, secondaryLabel].join(" · ")` string, since a composing package
|
|
68
|
+
// (e.g. `@elabs-ai/components-process`'s `ProcessTransitionEdge`) uses it to replace a
|
|
69
|
+
// bare printed number with a full, disambiguating sentence naming both endpoints.
|
|
70
|
+
it("lets a caller-supplied aria-label win over the computed label · secondaryLabel name", () => {
|
|
71
|
+
render(
|
|
72
|
+
<EdgeLabelPill
|
|
73
|
+
x={0}
|
|
74
|
+
y={0}
|
|
75
|
+
label="213"
|
|
76
|
+
aria-label="Transition from Check Credit to Approve Order, Transitions 213"
|
|
77
|
+
/>,
|
|
78
|
+
);
|
|
79
|
+
expect(
|
|
80
|
+
screen.getByRole("button", {
|
|
81
|
+
name: "Transition from Check Credit to Approve Order, Transitions 213",
|
|
82
|
+
}),
|
|
83
|
+
).toBeInTheDocument();
|
|
84
|
+
});
|
|
65
85
|
});
|
|
@@ -406,6 +406,7 @@ export const TenEdgesMixed: Story = {
|
|
|
406
406
|
* add" for the full trap. Do not "simplify" `nameOf` away.
|
|
407
407
|
*/
|
|
408
408
|
export const NamingContractEdges: Story = {
|
|
409
|
+
name: "Edge names come from node titles, never ids",
|
|
409
410
|
render: function NamingContractEdgesStory() {
|
|
410
411
|
const nodes: BrandFlowNode[] = [
|
|
411
412
|
{ id: "n1", type: "brand", position: { x: 0, y: 0 }, data: { kind: "Step", title: "Start" } },
|
|
@@ -403,3 +403,43 @@ describe("edge focus contour contrast (#286)", () => {
|
|
|
403
403
|
},
|
|
404
404
|
);
|
|
405
405
|
});
|
|
406
|
+
|
|
407
|
+
// RM-065 prerequisite — `data.tokens` draws `FlowEdgeTokens` on the edge's own path.
|
|
408
|
+
describe("FlowWeightedEdge data.tokens", () => {
|
|
409
|
+
it("renders exactly the same markup with an empty list as with the field absent", () => {
|
|
410
|
+
edgesBox.current = [{ id: "test-edge", data: { weight: 4, label: "4×" } }];
|
|
411
|
+
const absent = render(
|
|
412
|
+
<FlowWeightedEdge {...makeEdgeProps({ data: { weight: 4, label: "4×" } })} />,
|
|
413
|
+
);
|
|
414
|
+
const absentHtml = absent.container.innerHTML;
|
|
415
|
+
expect(absent.container.querySelector('[data-slot="flow-edge-tokens"]')).toBeNull();
|
|
416
|
+
absent.unmount();
|
|
417
|
+
const empty = render(
|
|
418
|
+
<FlowWeightedEdge {...makeEdgeProps({ data: { weight: 4, label: "4×", tokens: [] } })} />,
|
|
419
|
+
);
|
|
420
|
+
expect(empty.container.innerHTML).toBe(absentHtml);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("draws one token per entry along the SAME path the edge draws", () => {
|
|
424
|
+
edgesBox.current = [{ id: "test-edge", data: {} }];
|
|
425
|
+
// The mocked getSmoothStepPath returns a straight `M0,0 L100,100`.
|
|
426
|
+
const { container } = render(
|
|
427
|
+
<FlowWeightedEdge
|
|
428
|
+
{...makeEdgeProps({
|
|
429
|
+
data: {
|
|
430
|
+
path: "smoothstep",
|
|
431
|
+
tokens: [
|
|
432
|
+
{ id: "c1", progress: 0 },
|
|
433
|
+
{ id: "c2", progress: 0.5 },
|
|
434
|
+
],
|
|
435
|
+
},
|
|
436
|
+
})}
|
|
437
|
+
/>,
|
|
438
|
+
);
|
|
439
|
+
const tokens = container.querySelectorAll<SVGCircleElement>(
|
|
440
|
+
'[data-slot="flow-edge-tokens-token"]',
|
|
441
|
+
);
|
|
442
|
+
expect(tokens).toHaveLength(2);
|
|
443
|
+
expect(tokens[1]!.style.transform).toBe("translate(50px, 50px)");
|
|
444
|
+
});
|
|
445
|
+
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "@xyflow/react";
|
|
12
12
|
import { resolveTokenColor } from "@elabs-ai/components-tokens";
|
|
13
13
|
import { FlowEdgePath } from "../flow-edge-path";
|
|
14
|
+
import { FlowEdgeTokens, type FlowEdgeToken } from "../flow-edge-tokens";
|
|
14
15
|
import { EdgeLabelPill, type EdgeLabelPillProps } from "./edge-label-pill";
|
|
15
16
|
import { backEdgeDetour, type BackEdgeNodeRect } from "./back-edge-geometry";
|
|
16
17
|
import {
|
|
@@ -63,6 +64,11 @@ export interface FlowWeightedEdgeData extends Record<string, unknown> {
|
|
|
63
64
|
* own root button from outside this component, without a new semantic prop here.
|
|
64
65
|
*/
|
|
65
66
|
labelProps?: Omit<EdgeLabelPillProps, "label" | "secondaryLabel" | "x" | "y" | "selected">;
|
|
67
|
+
/**
|
|
68
|
+
* Markers travelling along this edge's computed path (see `FlowEdgeTokens`). The parent
|
|
69
|
+
* drives each token's `progress`; omitted or empty renders exactly the edge without them.
|
|
70
|
+
*/
|
|
71
|
+
tokens?: readonly FlowEdgeToken[];
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
export type BrandFlowWeightedEdge = Edge<FlowWeightedEdgeData, "weighted">;
|
|
@@ -295,6 +301,7 @@ export function FlowWeightedEdge({
|
|
|
295
301
|
) : (
|
|
296
302
|
edge
|
|
297
303
|
)}
|
|
304
|
+
{data?.tokens?.length ? <FlowEdgeTokens path={edgePath} tokens={data.tokens} /> : null}
|
|
298
305
|
<EdgeLabelPill
|
|
299
306
|
label={data?.label}
|
|
300
307
|
secondaryLabel={data?.secondaryLabel}
|
package/src/index.ts
CHANGED
|
@@ -44,3 +44,16 @@ export * from "./flow-weighted-edge";
|
|
|
44
44
|
|
|
45
45
|
// FlowSelfLoopEdge — RM-044
|
|
46
46
|
export * from "./flow-self-loop-edge";
|
|
47
|
+
|
|
48
|
+
// FlowEdgeTokens — RM-065 prerequisite
|
|
49
|
+
export * from "./flow-edge-tokens";
|
|
50
|
+
|
|
51
|
+
// elkjs adapter — RM-067
|
|
52
|
+
export {
|
|
53
|
+
layoutFlowElk,
|
|
54
|
+
type FlowElkEngine,
|
|
55
|
+
type FlowElkGraph,
|
|
56
|
+
type FlowLayoutElkOptions,
|
|
57
|
+
type FlowLayoutElkResult,
|
|
58
|
+
} from "./flow-layout/layout-flow-elk";
|
|
59
|
+
export { pinBackbone, type FlowElkBackbone } from "./flow-layout/backbone";
|