@kahitsan/ksui 0.23.0 → 0.25.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/package.json +1 -1
- package/src/components/composite/ComboBox.tsx +1 -1
- package/src/components/composite/FlowGraph.tsx +382 -124
- package/src/components/composite/SearchableSelect.tsx +1 -1
- package/src/index.ts +9 -0
- package/src/utils/flow-builder.test.ts +44 -0
- package/src/utils/flow-builder.ts +121 -0
- package/src/utils/flow-spec.test.ts +53 -0
- package/src/utils/flow-spec.ts +124 -0
- package/src/utils/graph.ts +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -205,7 +205,7 @@ function SingleComboBox<T>(props: ComboBoxSingleProps<T>): JSX.Element {
|
|
|
205
205
|
<div
|
|
206
206
|
ref={popupRef}
|
|
207
207
|
data-testid={tid("popup")}
|
|
208
|
-
class="z-[
|
|
208
|
+
class="z-[10000] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
|
|
209
209
|
style={eng.popupStyle()}
|
|
210
210
|
>
|
|
211
211
|
<div class="px-2 py-2 border-b border-zinc-800 flex items-center gap-2">
|
|
@@ -1,27 +1,66 @@
|
|
|
1
|
-
// FlowGraph
|
|
2
|
-
//
|
|
3
|
-
// *draws* it
|
|
4
|
-
//
|
|
5
|
-
// with `animated` the
|
|
1
|
+
// FlowGraph — a blueprint/automation-tool renderer for a declarative node graph
|
|
2
|
+
// (the projection of a FlowDefinition; see utils/flow-spec). Where FlowRunner
|
|
3
|
+
// *executes* a flow, FlowGraph *draws* it as connectable node cards — the way a
|
|
4
|
+
// game-engine node editor or n8n shows behavior. With `interactive` it's a
|
|
5
|
+
// pan/zoom canvas; with `animated` the connectors flow toward the arrowhead.
|
|
6
6
|
//
|
|
7
|
-
// Composite
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
7
|
+
// Composite: composes the pure graph model (utils/graph) with SVG edges + HTML
|
|
8
|
+
// node cards (foreignObject) + interaction. Domain-free; self-contained CSS
|
|
9
|
+
// (ksui-fg-* classes + CSS custom props); no Tailwind, no host-brand classes;
|
|
10
|
+
// no graph/canvas library — pan/zoom is a group transform, layout is the pure
|
|
11
|
+
// layoutGraph. Connectors are drawn BEHIND the opaque cards so an edge never
|
|
12
|
+
// visibly crosses a node block.
|
|
13
13
|
|
|
14
14
|
import type { Component, JSX } from "solid-js";
|
|
15
|
-
import {
|
|
15
|
+
import { Dynamic } from "solid-js/web";
|
|
16
|
+
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
|
17
|
+
import Database from "lucide-solid/icons/database";
|
|
18
|
+
import MousePointerClick from "lucide-solid/icons/mouse-pointer-click";
|
|
19
|
+
import AppWindow from "lucide-solid/icons/app-window";
|
|
20
|
+
import DownloadCloud from "lucide-solid/icons/download-cloud";
|
|
21
|
+
import ArrowLeftRight from "lucide-solid/icons/arrow-left-right";
|
|
22
|
+
import Calculator from "lucide-solid/icons/calculator";
|
|
23
|
+
import GitBranch from "lucide-solid/icons/git-branch";
|
|
24
|
+
import Save from "lucide-solid/icons/save";
|
|
25
|
+
import Radio from "lucide-solid/icons/radio";
|
|
26
|
+
import Sparkles from "lucide-solid/icons/sparkles";
|
|
27
|
+
import CircleCheck from "lucide-solid/icons/circle-check-big";
|
|
28
|
+
import Circle from "lucide-solid/icons/circle";
|
|
16
29
|
import {
|
|
17
|
-
DEFAULT_METRICS,
|
|
18
30
|
layoutGraph,
|
|
31
|
+
type GraphDirection,
|
|
19
32
|
type GraphEdge,
|
|
20
33
|
type GraphLayout,
|
|
34
|
+
type GraphMetrics,
|
|
21
35
|
type GraphNode,
|
|
22
36
|
type PositionedNode,
|
|
23
37
|
} from "../../utils/graph";
|
|
24
38
|
|
|
39
|
+
type IconComp = Component<{ size?: number; class?: string }>;
|
|
40
|
+
|
|
41
|
+
// Blueprint node-kind → icon. Unknown kinds fall back to a plain dot.
|
|
42
|
+
const KIND_ICON: Record<string, IconComp> = {
|
|
43
|
+
data: Database,
|
|
44
|
+
trigger: MousePointerClick,
|
|
45
|
+
modal: AppWindow,
|
|
46
|
+
load: DownloadCloud,
|
|
47
|
+
call: ArrowLeftRight,
|
|
48
|
+
compute: Calculator,
|
|
49
|
+
condition: GitBranch,
|
|
50
|
+
commit: Save,
|
|
51
|
+
emit: Radio,
|
|
52
|
+
effect: Sparkles,
|
|
53
|
+
terminal: CircleCheck,
|
|
54
|
+
};
|
|
55
|
+
const iconFor = (kind?: string): IconComp => KIND_ICON[kind ?? ""] ?? Circle;
|
|
56
|
+
|
|
57
|
+
// Gaps are at least half the larger node dimension (max(190,60)/2 ≈ 95) on both
|
|
58
|
+
// axes, so nodes never crowd a neighbour from any side.
|
|
59
|
+
const METRICS: GraphMetrics = { nodeW: 190, nodeH: 60, gapX: 100, gapY: 100, pad: 28 };
|
|
60
|
+
const { nodeW, nodeH, pad } = METRICS;
|
|
61
|
+
const ZOOM_MIN = 0.3;
|
|
62
|
+
const ZOOM_MAX = 3;
|
|
63
|
+
|
|
25
64
|
const STYLE_ID = "ksui-flow-graph-style";
|
|
26
65
|
|
|
27
66
|
function ensureStyle(): void {
|
|
@@ -30,39 +69,47 @@ function ensureStyle(): void {
|
|
|
30
69
|
const style = document.createElement("style");
|
|
31
70
|
style.id = STYLE_ID;
|
|
32
71
|
style.textContent = `
|
|
33
|
-
.ksui-fg-wrap{width:100%;overflow:auto;position:relative;}
|
|
34
|
-
.ksui-fg-
|
|
72
|
+
.ksui-fg-wrap{width:100%;overflow:auto;position:relative;user-select:none;-webkit-user-select:none;}
|
|
73
|
+
.ksui-fg-card,.ksui-fg-title,.ksui-fg-kind,.ksui-fg-elabel{user-select:none;-webkit-user-select:none;}
|
|
74
|
+
.ksui-fg-wrap.interactive{overflow:hidden;border:1px solid var(--ksui-fg-node-border,rgba(255,255,255,0.12));border-radius:10px;background:var(--ksui-fg-canvas,#101014);background-image:radial-gradient(var(--ksui-fg-dot,rgba(255,255,255,0.05)) 1px,transparent 1px);background-size:20px 20px;cursor:grab;touch-action:none;}
|
|
35
75
|
.ksui-fg-wrap.interactive.grabbing{cursor:grabbing;}
|
|
76
|
+
.ksui-fg-wrap.scroll{overflow:auto;border:1px solid var(--ksui-fg-node-border,rgba(255,255,255,0.12));border-radius:10px;background:var(--ksui-fg-canvas,#101014);background-image:radial-gradient(var(--ksui-fg-dot,rgba(255,255,255,0.05)) 1px,transparent 1px);background-size:20px 20px;}
|
|
36
77
|
.ksui-fg-svg{display:block;max-width:100%;height:auto;font-family:inherit;}
|
|
78
|
+
.ksui-fg-wrap.scroll .ksui-fg-svg{margin:0 auto;}
|
|
37
79
|
.ksui-fg-wrap.interactive .ksui-fg-svg{max-width:none;width:100%;height:100%;}
|
|
38
|
-
.ksui-fg-edge{fill:none;stroke:var(--ksui-fg-edge,rgba(255,255,255,0.
|
|
39
|
-
.ksui-fg-edge.dashed{stroke-dasharray:
|
|
80
|
+
.ksui-fg-edge{fill:none;stroke:var(--ksui-fg-edge,rgba(255,255,255,0.28));stroke-width:1.75;}
|
|
81
|
+
.ksui-fg-edge.dashed{stroke-dasharray:5 4;}
|
|
40
82
|
.ksui-fg-edge.primary{stroke:var(--ksui-fg-primary,#c9a961);}
|
|
41
|
-
.ksui-fg-edge.info{stroke:#
|
|
42
|
-
.ksui-fg-edge.success{stroke:#
|
|
43
|
-
.ksui-fg-edge.danger{stroke:#
|
|
44
|
-
.ksui-fg-edge.muted{stroke:rgba(255,255,255,0.
|
|
45
|
-
.ksui-fg-edge.flow{stroke-dasharray:
|
|
46
|
-
@keyframes ksui-fg-march{to{stroke-dashoffset:-
|
|
83
|
+
.ksui-fg-edge.info{stroke:#5b9bf0;}
|
|
84
|
+
.ksui-fg-edge.success{stroke:#43c478;}
|
|
85
|
+
.ksui-fg-edge.danger{stroke:#ef6a6a;}
|
|
86
|
+
.ksui-fg-edge.muted{stroke:rgba(255,255,255,0.2);}
|
|
87
|
+
.ksui-fg-edge.flow{stroke-dasharray:6 5;animation:ksui-fg-march .8s linear infinite;}
|
|
88
|
+
@keyframes ksui-fg-march{to{stroke-dashoffset:-11;}}
|
|
47
89
|
@media (prefers-reduced-motion:reduce){.ksui-fg-edge.flow{animation:none;}}
|
|
48
|
-
.ksui-fg-
|
|
49
|
-
.ksui-fg-elabel-bg
|
|
50
|
-
.ksui-fg-
|
|
51
|
-
.ksui-fg-
|
|
52
|
-
.ksui-fg-
|
|
53
|
-
.ksui-fg-
|
|
54
|
-
.ksui-fg-node.
|
|
55
|
-
.ksui-fg-node.
|
|
56
|
-
.ksui-fg-node
|
|
57
|
-
.ksui-fg-node
|
|
58
|
-
.ksui-fg-
|
|
59
|
-
.ksui-fg-
|
|
60
|
-
.ksui-fg-
|
|
61
|
-
.ksui-fg-
|
|
90
|
+
.ksui-fg-handle{fill:var(--ksui-fg-canvas,#101014);stroke:var(--ksui-fg-edge,rgba(255,255,255,0.4));stroke-width:1.5;}
|
|
91
|
+
.ksui-fg-node,.ksui-fg-edge,.ksui-fg-elabel,.ksui-fg-elabel-bg,.ksui-fg-handle{transition:opacity .12s ease;}
|
|
92
|
+
.ksui-fg-dim{opacity:0.14;}
|
|
93
|
+
.ksui-fg-elabel{fill:var(--ksui-fg-muted,rgba(255,255,255,0.78));font-size:9.5px;}
|
|
94
|
+
.ksui-fg-elabel-bg{fill:var(--ksui-fg-canvas,#101014);opacity:0.92;}
|
|
95
|
+
.ksui-fg-card{box-sizing:border-box;height:100%;display:flex;align-items:center;gap:9px;padding:0 11px;border-radius:9px;background:var(--ksui-fg-card,#1c1c22);border:1px solid var(--ksui-fg-node-border,rgba(255,255,255,0.14));border-left-width:3px;overflow:hidden;}
|
|
96
|
+
.ksui-fg-node.clickable .ksui-fg-card{cursor:pointer;}
|
|
97
|
+
.ksui-fg-node.clickable .ksui-fg-card:hover{background:#23232b;}
|
|
98
|
+
.ksui-fg-node:focus{outline:none;}
|
|
99
|
+
.ksui-fg-node:focus-visible .ksui-fg-card{border-color:var(--ksui-fg-primary,#c9a961);}
|
|
100
|
+
.ksui-fg-chip{flex:none;width:30px;height:30px;border-radius:7px;display:flex;align-items:center;justify-content:center;color:#fff;}
|
|
101
|
+
.ksui-fg-txt{min-width:0;display:flex;flex-direction:column;line-height:1.15;}
|
|
102
|
+
.ksui-fg-title{font-size:12px;font-weight:600;color:var(--ksui-fg-fg,#ececef);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
103
|
+
.ksui-fg-kind{font-size:9px;text-transform:uppercase;letter-spacing:0.06em;color:var(--ksui-fg-muted,rgba(255,255,255,0.45));white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
104
|
+
.ksui-fg-card.primary{border-left-color:#c9a961;} .ksui-fg-card.primary .ksui-fg-chip{background:#c9a961;color:#18181b;}
|
|
105
|
+
.ksui-fg-card.info{border-left-color:#5b9bf0;} .ksui-fg-card.info .ksui-fg-chip{background:#3f6fb0;}
|
|
106
|
+
.ksui-fg-card.success{border-left-color:#43c478;} .ksui-fg-card.success .ksui-fg-chip{background:#2f8e57;}
|
|
107
|
+
.ksui-fg-card.danger{border-left-color:#ef6a6a;} .ksui-fg-card.danger .ksui-fg-chip{background:#b04545;}
|
|
108
|
+
.ksui-fg-card.muted{border-left-color:rgba(255,255,255,0.3);} .ksui-fg-card.muted .ksui-fg-chip{background:#3a3a42;}
|
|
62
109
|
.ksui-fg-empty{padding:1.75rem 1rem;text-align:center;font-size:0.82rem;color:var(--ksui-fg-muted,rgba(255,255,255,0.55));}
|
|
63
110
|
.ksui-fg-controls{position:absolute;right:8px;bottom:8px;display:flex;gap:4px;z-index:1;}
|
|
64
|
-
.ksui-fg-ctrl{width:24px;height:24px;display:grid;place-items:center;border-radius:5px;border:1px solid var(--ksui-fg-node-border,rgba(255,255,255,0.18));background:var(--ksui-fg-
|
|
65
|
-
.ksui-fg-ctrl:hover{background
|
|
111
|
+
.ksui-fg-ctrl{width:24px;height:24px;display:grid;place-items:center;border-radius:5px;border:1px solid var(--ksui-fg-node-border,rgba(255,255,255,0.18));background:var(--ksui-fg-card,#1c1c22);color:var(--ksui-fg-fg,#ececef);font-size:14px;line-height:1;cursor:pointer;user-select:none;}
|
|
112
|
+
.ksui-fg-ctrl:hover{background:#23232b;}
|
|
66
113
|
.ksui-fg-hint{position:absolute;left:8px;bottom:8px;font-size:9.5px;color:var(--ksui-fg-muted,rgba(255,255,255,0.4));pointer-events:none;}
|
|
67
114
|
`;
|
|
68
115
|
document.head.appendChild(style);
|
|
@@ -77,99 +124,256 @@ export interface FlowGraphProps {
|
|
|
77
124
|
emptyLabel?: string;
|
|
78
125
|
/** Accessible description of the whole graph (the svg's aria-label). */
|
|
79
126
|
ariaLabel?: string;
|
|
80
|
-
/** When supplied,
|
|
127
|
+
/** When supplied, node cards become buttons that fire this with the node id. */
|
|
81
128
|
onNodeSelect?: (id: string) => void;
|
|
82
|
-
/** Turn the graph into a pan (drag) + zoom (wheel/buttons) canvas.
|
|
129
|
+
/** Turn the graph into a pan (drag) + zoom (wheel/buttons) canvas. Ignored for
|
|
130
|
+
* vertical direction, which scrolls instead. */
|
|
83
131
|
interactive?: boolean;
|
|
84
|
-
/** Animate
|
|
132
|
+
/** Animate connectors as marching dashes flowing toward the arrowhead. */
|
|
85
133
|
animated?: boolean;
|
|
86
|
-
/**
|
|
134
|
+
/** "horizontal" (default) flows left→right; "vertical" flows top→bottom and
|
|
135
|
+
* renders in a scrollable panel (scroll to follow the flow). */
|
|
136
|
+
direction?: GraphDirection;
|
|
137
|
+
/** Viewport height in px — canvas height (horizontal) or max scroll height
|
|
138
|
+
* (vertical). Default 360. */
|
|
87
139
|
height?: number;
|
|
88
140
|
testId?: string;
|
|
89
141
|
}
|
|
90
142
|
|
|
91
|
-
/**
|
|
143
|
+
/** Trim a label to fit the card (CSS ellipsis also guards, but keep SVG sane). */
|
|
92
144
|
function clip(text: string, max: number): string {
|
|
93
145
|
return text.length > max ? text.slice(0, max - 1) + "…" : text;
|
|
94
146
|
}
|
|
95
147
|
|
|
96
|
-
const { nodeW, nodeH } = DEFAULT_METRICS;
|
|
97
|
-
const ZOOM_MIN = 0.3;
|
|
98
|
-
const ZOOM_MAX = 3;
|
|
99
|
-
|
|
100
148
|
export const FlowGraph: Component<FlowGraphProps> = (props) => {
|
|
101
149
|
ensureStyle();
|
|
102
150
|
const tid = (s: string) => (props.testId ? `${props.testId}-${s}` : undefined);
|
|
103
151
|
|
|
104
|
-
const
|
|
152
|
+
const vertical = () => props.direction === "vertical";
|
|
153
|
+
// Pan/zoom canvas applies whenever interactive — in BOTH directions. Direction
|
|
154
|
+
// only changes the layout axis + how connectors leave/enter the cards.
|
|
155
|
+
const canvas = () => !!props.interactive;
|
|
156
|
+
const laid = createMemo(() =>
|
|
157
|
+
layoutGraph(props.nodes, props.edges, props.layout ?? "layered", METRICS, props.direction),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Node ports: a node shows an INPUT handle only when something feeds it (so a
|
|
161
|
+
// trigger/root has none), and one OUTPUT handle per outgoing edge — a 2-branch
|
|
162
|
+
// condition therefore has two output handles, each driving a distinct edge.
|
|
163
|
+
const ports = createMemo(() => {
|
|
164
|
+
const incoming = new Set<string>();
|
|
165
|
+
const outBySource = new Map<string, GraphEdge[]>();
|
|
166
|
+
for (const e of props.edges) {
|
|
167
|
+
incoming.add(e.to);
|
|
168
|
+
const arr = outBySource.get(e.from) ?? [];
|
|
169
|
+
arr.push(e);
|
|
170
|
+
outBySource.set(e.from, arr);
|
|
171
|
+
}
|
|
172
|
+
return { incoming, outBySource };
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Order each node's outgoing edges by their TARGET's cross-axis position, so
|
|
176
|
+
// output handle 0 (leftmost/topmost) drives the edge whose target sits
|
|
177
|
+
// leftmost — branches then fan out without crossing (a condition's yes/no
|
|
178
|
+
// reach the correct sides). Falls back to declared order when positions tie.
|
|
179
|
+
const outRank = createMemo(() => {
|
|
180
|
+
const rank = new Map<GraphEdge, number>();
|
|
181
|
+
const cross = (id: string) => {
|
|
182
|
+
const p = laid().byId.get(id);
|
|
183
|
+
return p ? (vertical() ? p.x : p.y) : 0;
|
|
184
|
+
};
|
|
185
|
+
for (const es of ports().outBySource.values()) {
|
|
186
|
+
[...es]
|
|
187
|
+
.map((e, i) => ({ e, i }))
|
|
188
|
+
.sort((a, b) => cross(a.e.to) - cross(b.e.to) || a.i - b.i)
|
|
189
|
+
.forEach(({ e }, idx) => rank.set(e, idx));
|
|
190
|
+
}
|
|
191
|
+
return rank;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// The position of a node's i-th output handle (of n), spread along the leaving
|
|
195
|
+
// edge (bottom for vertical, right for horizontal). A single output centers.
|
|
196
|
+
const outHandle = (src: PositionedNode, i: number, n: number): { x: number; y: number } => {
|
|
197
|
+
const f = (i + 1) / (n + 1);
|
|
198
|
+
return vertical()
|
|
199
|
+
? { x: src.x + nodeW * f, y: src.y + nodeH }
|
|
200
|
+
: { x: src.x + nodeW, y: src.y + nodeH * f };
|
|
201
|
+
};
|
|
202
|
+
const inHandle = (dst: PositionedNode): { x: number; y: number } =>
|
|
203
|
+
vertical()
|
|
204
|
+
? { x: dst.x + nodeW / 2, y: dst.y }
|
|
205
|
+
: { x: dst.x, y: dst.y + nodeH / 2 };
|
|
206
|
+
|
|
207
|
+
// ── Node drag (move a node; edges follow) ──────────────────────────────────
|
|
208
|
+
const [offsets, setOffsets] = createSignal<Record<string, { dx: number; dy: number }>>({});
|
|
209
|
+
// The drawn position of a node = its laid-out slot + any drag offset.
|
|
210
|
+
const pos = (n: PositionedNode): PositionedNode => {
|
|
211
|
+
const o = offsets()[n.id];
|
|
212
|
+
return o ? { ...n, x: n.x + o.dx, y: n.y + o.dy } : n;
|
|
213
|
+
};
|
|
214
|
+
const drawn = (id: string): PositionedNode | undefined => {
|
|
215
|
+
const n = laid().byId.get(id);
|
|
216
|
+
return n ? pos(n) : undefined;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// ── Highlight (hover/click dims everything not connected) ──────────────────
|
|
220
|
+
const [hover, setHover] = createSignal<string | null>(null);
|
|
221
|
+
const [pinned, setPinned] = createSignal<string | null>(null);
|
|
222
|
+
const active = () => pinned() ?? hover();
|
|
223
|
+
const lit = createMemo<Set<string> | null>(() => {
|
|
224
|
+
const a = active();
|
|
225
|
+
if (!a) return null;
|
|
226
|
+
const s = new Set<string>([a]);
|
|
227
|
+
for (const e of props.edges) {
|
|
228
|
+
if (e.from === a) s.add(e.to);
|
|
229
|
+
if (e.to === a) s.add(e.from);
|
|
230
|
+
}
|
|
231
|
+
return s;
|
|
232
|
+
});
|
|
233
|
+
const nodeDim = (id: string) => lit() !== null && !lit()!.has(id);
|
|
234
|
+
const edgeDim = (e: GraphEdge) =>
|
|
235
|
+
lit() !== null && !(lit()!.has(e.from) && lit()!.has(e.to) && (e.from === active() || e.to === active()));
|
|
105
236
|
|
|
106
|
-
// Pan/zoom view transform (interactive mode only).
|
|
107
237
|
const [view, setView] = createSignal({ x: 0, y: 0, k: 1 });
|
|
108
238
|
const [grabbing, setGrabbing] = createSignal(false);
|
|
109
239
|
let svgRef: SVGSVGElement | undefined;
|
|
110
240
|
let dragging = false;
|
|
111
241
|
let lastX = 0;
|
|
112
242
|
let lastY = 0;
|
|
113
|
-
let moved = false;
|
|
243
|
+
let moved = false;
|
|
114
244
|
|
|
115
|
-
|
|
245
|
+
// Fit the whole graph into the viewport (zoom-to-fit), centered, from the
|
|
246
|
+
// ACTUAL drawn bounding box (including any drag offsets) so the reset control
|
|
247
|
+
// always frames everything regardless of pan/zoom/drag state.
|
|
248
|
+
const fitView = () => {
|
|
249
|
+
if (!svgRef) return;
|
|
250
|
+
const rect = svgRef.getBoundingClientRect();
|
|
251
|
+
const vw = rect.width;
|
|
252
|
+
const vh = rect.height;
|
|
253
|
+
const ns = laid().nodes.map(pos);
|
|
254
|
+
if (!vw || !vh || ns.length === 0) return;
|
|
255
|
+
const minX = Math.min(...ns.map((n) => n.x)) - pad;
|
|
256
|
+
const minY = Math.min(...ns.map((n) => n.y)) - pad;
|
|
257
|
+
const maxX = Math.max(...ns.map((n) => n.x)) + nodeW + pad;
|
|
258
|
+
const maxY = Math.max(...ns.map((n) => n.y)) + nodeH + pad;
|
|
259
|
+
const gw = Math.max(1, maxX - minX);
|
|
260
|
+
const gh = Math.max(1, maxY - minY);
|
|
261
|
+
const k = Math.min(vw / gw, vh / gh, 1.4);
|
|
262
|
+
setView({ x: (vw - gw * k) / 2 - minX * k, y: (vh - gh * k) / 2 - minY * k, k });
|
|
263
|
+
};
|
|
264
|
+
// Default placement: NOT zoom-to-fit (keep 1:1), just pan so the graph's top is
|
|
265
|
+
// centered horizontally and visible — otherwise a centered layout can sit
|
|
266
|
+
// off-screen. Re-runs whenever the node SET changes (e.g. the flow selector
|
|
267
|
+
// switches flows), clearing prior drags, so a freshly-chosen flow is framed
|
|
268
|
+
// instead of leaving the view on the previous flow's now-empty region.
|
|
269
|
+
const nodeSig = createMemo(() => props.nodes.map((n) => n.id).join("|"));
|
|
270
|
+
let lastSig: string | null = null;
|
|
271
|
+
createEffect(() => {
|
|
272
|
+
const sig = nodeSig();
|
|
273
|
+
if (canvas() && sig !== lastSig && laid().nodes.length > 0 && svgRef) {
|
|
274
|
+
lastSig = sig;
|
|
275
|
+
setOffsets({});
|
|
276
|
+
requestAnimationFrame(() => {
|
|
277
|
+
if (!svgRef) return;
|
|
278
|
+
const vw = svgRef.clientWidth || 0;
|
|
279
|
+
const ns = laid().nodes;
|
|
280
|
+
if (!vw || ns.length === 0) return;
|
|
281
|
+
const minX = Math.min(...ns.map((n) => n.x));
|
|
282
|
+
const maxX = Math.max(...ns.map((n) => n.x)) + nodeW;
|
|
283
|
+
const minY = Math.min(...ns.map((n) => n.y));
|
|
284
|
+
// Center horizontally, align the content's actual top to a small margin.
|
|
285
|
+
setView({ x: vw / 2 - (minX + maxX) / 2, y: pad - minY, k: 1 });
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
});
|
|
116
289
|
|
|
290
|
+
const clampK = (k: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, k));
|
|
117
291
|
const zoomBy = (factor: number, cx?: number, cy?: number) => {
|
|
118
292
|
const v = view();
|
|
119
293
|
const k = clampK(v.k * factor);
|
|
120
|
-
// Keep the point (cx,cy) under the cursor fixed while zooming.
|
|
121
294
|
const px = cx ?? (svgRef?.clientWidth ?? 0) / 2;
|
|
122
295
|
const py = cy ?? (svgRef?.clientHeight ?? 0) / 2;
|
|
123
296
|
setView({ x: px - (px - v.x) * (k / v.k), y: py - (py - v.y) * (k / v.k), k });
|
|
124
297
|
};
|
|
125
|
-
|
|
126
298
|
const onWheel = (e: WheelEvent) => {
|
|
127
|
-
if (!
|
|
299
|
+
if (!canvas()) return;
|
|
128
300
|
e.preventDefault();
|
|
129
301
|
const rect = svgRef?.getBoundingClientRect();
|
|
130
302
|
zoomBy(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX - (rect?.left ?? 0), e.clientY - (rect?.top ?? 0));
|
|
131
303
|
};
|
|
132
|
-
|
|
304
|
+
// A press that started on a node card (recorded by the card's handler) becomes
|
|
305
|
+
// a node-drag; a press on empty canvas pans. svgRef captures the pointer so the
|
|
306
|
+
// gesture continues even when it leaves the element.
|
|
307
|
+
let pendingNode: string | null = null;
|
|
308
|
+
let nodeDrag: string | null = null;
|
|
133
309
|
const onPointerDown = (e: PointerEvent) => {
|
|
134
|
-
if (!
|
|
135
|
-
dragging = true;
|
|
310
|
+
if (!canvas()) return;
|
|
136
311
|
moved = false;
|
|
137
|
-
setGrabbing(true);
|
|
138
312
|
lastX = e.clientX;
|
|
139
313
|
lastY = e.clientY;
|
|
140
314
|
svgRef?.setPointerCapture(e.pointerId);
|
|
315
|
+
if (pendingNode) {
|
|
316
|
+
nodeDrag = pendingNode;
|
|
317
|
+
pendingNode = null;
|
|
318
|
+
} else {
|
|
319
|
+
dragging = true;
|
|
320
|
+
setGrabbing(true);
|
|
321
|
+
}
|
|
141
322
|
};
|
|
142
323
|
const onPointerMove = (e: PointerEvent) => {
|
|
143
|
-
if (!dragging) return;
|
|
324
|
+
if (!dragging && !nodeDrag) return;
|
|
144
325
|
const dx = e.clientX - lastX;
|
|
145
326
|
const dy = e.clientY - lastY;
|
|
146
327
|
if (Math.abs(dx) + Math.abs(dy) > 2) moved = true;
|
|
147
328
|
lastX = e.clientX;
|
|
148
329
|
lastY = e.clientY;
|
|
149
|
-
|
|
330
|
+
if (nodeDrag) {
|
|
331
|
+
const id = nodeDrag;
|
|
332
|
+
const k = view().k || 1;
|
|
333
|
+
setOffsets((o) => {
|
|
334
|
+
const cur = o[id] ?? { dx: 0, dy: 0 };
|
|
335
|
+
return { ...o, [id]: { dx: cur.dx + dx / k, dy: cur.dy + dy / k } };
|
|
336
|
+
});
|
|
337
|
+
} else {
|
|
338
|
+
setView((v) => ({ ...v, x: v.x + dx, y: v.y + dy }));
|
|
339
|
+
}
|
|
150
340
|
};
|
|
151
341
|
const endDrag = (e: PointerEvent) => {
|
|
342
|
+
// A press on a node that didn't move = a click → toggle its pinned highlight.
|
|
343
|
+
// A press on empty canvas that didn't move = a click-away → clear the focus.
|
|
344
|
+
if (nodeDrag && !moved) setPinned((p) => (p === nodeDrag ? null : nodeDrag));
|
|
345
|
+
else if (dragging && !moved) setPinned(null);
|
|
152
346
|
dragging = false;
|
|
347
|
+
nodeDrag = null;
|
|
348
|
+
pendingNode = null;
|
|
153
349
|
setGrabbing(false);
|
|
154
350
|
try {
|
|
155
351
|
svgRef?.releasePointerCapture(e.pointerId);
|
|
156
352
|
} catch {
|
|
157
|
-
/*
|
|
353
|
+
/* already released */
|
|
158
354
|
}
|
|
159
355
|
};
|
|
160
356
|
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
357
|
+
// Bezier from a source output handle to a target input handle. Control points
|
|
358
|
+
// pulled along the flow axis (down for vertical, right for horizontal) so the
|
|
359
|
+
// curve bows through the gaps rather than cutting across rows.
|
|
360
|
+
// Control-point reach is capped so a long edge (e.g. a back-edge looping up)
|
|
361
|
+
// curves gently instead of ballooning its handles far past the endpoints.
|
|
362
|
+
const edgePath = (a: { x: number; y: number }, b: { x: number; y: number }): string => {
|
|
363
|
+
if (vertical()) {
|
|
364
|
+
const dy = Math.min(110, Math.max(34, Math.abs(b.y - a.y) * 0.5));
|
|
365
|
+
return `M ${a.x} ${a.y} C ${a.x} ${a.y + dy}, ${b.x} ${b.y - dy}, ${b.x} ${b.y}`;
|
|
366
|
+
}
|
|
367
|
+
const dx = Math.min(140, Math.max(46, Math.abs(b.x - a.x) * 0.5));
|
|
368
|
+
return `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`;
|
|
169
369
|
};
|
|
370
|
+
const mid = (a: { x: number; y: number }, b: { x: number; y: number }) => ({
|
|
371
|
+
x: (a.x + b.x) / 2,
|
|
372
|
+
y: (a.y + b.y) / 2,
|
|
373
|
+
});
|
|
170
374
|
|
|
171
375
|
const selectNode = (id: string) => {
|
|
172
|
-
if (moved) return;
|
|
376
|
+
if (moved) return;
|
|
173
377
|
props.onNodeSelect?.(id);
|
|
174
378
|
};
|
|
175
379
|
const activate = (e: KeyboardEvent, id: string) => {
|
|
@@ -178,15 +382,20 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
|
|
|
178
382
|
props.onNodeSelect?.(id);
|
|
179
383
|
}
|
|
180
384
|
};
|
|
181
|
-
|
|
182
385
|
const groupTransform = () =>
|
|
183
|
-
|
|
386
|
+
canvas() ? `translate(${view().x} ${view().y}) scale(${view().k})` : undefined;
|
|
184
387
|
|
|
185
388
|
return (
|
|
186
389
|
<div
|
|
187
390
|
class="ksui-fg-wrap"
|
|
188
|
-
classList={{ interactive:
|
|
189
|
-
style={
|
|
391
|
+
classList={{ interactive: canvas(), scroll: vertical(), grabbing: grabbing() }}
|
|
392
|
+
style={
|
|
393
|
+
canvas()
|
|
394
|
+
? { height: `${props.height ?? 360}px` }
|
|
395
|
+
: vertical()
|
|
396
|
+
? { "max-height": `${props.height ?? 360}px` }
|
|
397
|
+
: undefined
|
|
398
|
+
}
|
|
190
399
|
data-testid={tid("root")}
|
|
191
400
|
>
|
|
192
401
|
<Show
|
|
@@ -200,11 +409,11 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
|
|
|
200
409
|
<svg
|
|
201
410
|
ref={svgRef}
|
|
202
411
|
class="ksui-fg-svg"
|
|
203
|
-
viewBox={
|
|
204
|
-
width={
|
|
205
|
-
height={
|
|
412
|
+
viewBox={canvas() ? undefined : `0 0 ${laid().width} ${laid().height}`}
|
|
413
|
+
width={canvas() ? "100%" : laid().width}
|
|
414
|
+
height={canvas() ? "100%" : laid().height}
|
|
206
415
|
role="img"
|
|
207
|
-
aria-label={props.ariaLabel ?? "
|
|
416
|
+
aria-label={props.ariaLabel ?? "Flow graph"}
|
|
208
417
|
data-testid={tid("svg")}
|
|
209
418
|
onWheel={onWheel}
|
|
210
419
|
onPointerDown={onPointerDown}
|
|
@@ -222,43 +431,48 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
|
|
|
222
431
|
markerHeight="6"
|
|
223
432
|
orient="auto-start-reverse"
|
|
224
433
|
>
|
|
225
|
-
<path d="M0 0 L8 4 L0 8 z" fill="var(--ksui-fg-edge,rgba(255,255,255,0.
|
|
434
|
+
<path d="M0 0 L8 4 L0 8 z" fill="var(--ksui-fg-edge,rgba(255,255,255,0.45))" />
|
|
226
435
|
</marker>
|
|
227
436
|
</defs>
|
|
228
437
|
|
|
229
438
|
<g transform={groupTransform()}>
|
|
230
|
-
{/*
|
|
439
|
+
{/* 1) Connectors — drawn first so the opaque cards paint over them. */}
|
|
231
440
|
<For each={props.edges}>
|
|
232
441
|
{(e) => {
|
|
233
|
-
const s = () =>
|
|
234
|
-
const t = () =>
|
|
442
|
+
const s = () => drawn(e.from);
|
|
443
|
+
const t = () => drawn(e.to);
|
|
235
444
|
return (
|
|
236
445
|
<Show when={s() && t()}>
|
|
237
446
|
{(() => {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
447
|
+
// Reactive endpoints so the connector follows a dragged node;
|
|
448
|
+
// the output-handle slot is ranked by target position to avoid
|
|
449
|
+
// crossings.
|
|
450
|
+
const sibs = ports().outBySource.get(e.from) ?? [];
|
|
451
|
+
const a = () =>
|
|
452
|
+
outHandle(s() as PositionedNode, outRank().get(e) ?? 0, sibs.length || 1);
|
|
453
|
+
const b = () => inHandle(t() as PositionedNode);
|
|
454
|
+
const m = () => mid(a(), b());
|
|
455
|
+
const lbl = () => clip(e.label ?? "", 18);
|
|
242
456
|
return (
|
|
243
|
-
<g>
|
|
457
|
+
<g classList={{ "ksui-fg-dim": edgeDim(e) }}>
|
|
244
458
|
<path
|
|
245
459
|
class={`ksui-fg-edge ${e.accent ?? ""} ${e.dashed ? "dashed" : ""} ${
|
|
246
460
|
props.animated ? "flow" : ""
|
|
247
461
|
}`}
|
|
248
|
-
d={edgePath(
|
|
462
|
+
d={edgePath(a(), b())}
|
|
249
463
|
marker-end="url(#ksui-fg-arrow)"
|
|
250
464
|
/>
|
|
251
465
|
<Show when={e.label}>
|
|
252
466
|
<rect
|
|
253
467
|
class="ksui-fg-elabel-bg"
|
|
254
|
-
x={
|
|
255
|
-
y={
|
|
256
|
-
width={
|
|
468
|
+
x={m().x - lbl().length * 2.7 - 3}
|
|
469
|
+
y={m().y - 7}
|
|
470
|
+
width={lbl().length * 5.4 + 6}
|
|
257
471
|
height={12}
|
|
258
|
-
rx={
|
|
472
|
+
rx={3}
|
|
259
473
|
/>
|
|
260
|
-
<text class="ksui-fg-elabel" x={
|
|
261
|
-
{
|
|
474
|
+
<text class="ksui-fg-elabel" x={m().x} y={m().y + 2} text-anchor="middle">
|
|
475
|
+
{lbl()}
|
|
262
476
|
</text>
|
|
263
477
|
</Show>
|
|
264
478
|
</g>
|
|
@@ -269,30 +483,79 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
|
|
|
269
483
|
}}
|
|
270
484
|
</For>
|
|
271
485
|
|
|
272
|
-
{/*
|
|
486
|
+
{/* 2) Node cards (opaque HTML via foreignObject — the blueprint look). */}
|
|
273
487
|
<For each={laid().nodes}>
|
|
274
|
-
{(
|
|
275
|
-
const
|
|
488
|
+
{(base) => {
|
|
489
|
+
const n = () => pos(base);
|
|
490
|
+
const clickable = () => typeof props.onNodeSelect === "function";
|
|
276
491
|
return (
|
|
277
|
-
<
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
492
|
+
<foreignObject
|
|
493
|
+
x={n().x}
|
|
494
|
+
y={n().y}
|
|
495
|
+
width={nodeW}
|
|
496
|
+
height={nodeH}
|
|
497
|
+
class="ksui-fg-node"
|
|
498
|
+
classList={{ clickable: clickable(), "ksui-fg-dim": nodeDim(base.id) }}
|
|
499
|
+
data-testid={tid(`node-${base.id}`)}
|
|
500
|
+
role={clickable() ? "button" : undefined}
|
|
501
|
+
tabindex={clickable() ? 0 : undefined}
|
|
502
|
+
aria-label={base.sublabel ? `${base.label} — ${base.sublabel}` : base.label}
|
|
503
|
+
onClick={clickable() ? () => selectNode(base.id) : undefined}
|
|
504
|
+
onKeyDown={clickable() ? (ev) => activate(ev, base.id) : undefined}
|
|
505
|
+
onPointerDown={canvas() ? () => (pendingNode = base.id) : undefined}
|
|
506
|
+
onPointerEnter={canvas() ? () => setHover(base.id) : undefined}
|
|
507
|
+
onPointerLeave={canvas() ? () => setHover(null) : undefined}
|
|
286
508
|
>
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
509
|
+
<div
|
|
510
|
+
// @ts-expect-error xmlns switches the foreignObject child back to HTML ns
|
|
511
|
+
xmlns="http://www.w3.org/1999/xhtml"
|
|
512
|
+
class={`ksui-fg-card ${base.accent ?? "muted"}`}
|
|
513
|
+
style={canvas() ? { cursor: "grab" } : { "pointer-events": "none" }}
|
|
514
|
+
>
|
|
515
|
+
<span class="ksui-fg-chip">
|
|
516
|
+
<Dynamic component={iconFor(base.kind)} size={15} />
|
|
517
|
+
</span>
|
|
518
|
+
<span class="ksui-fg-txt">
|
|
519
|
+
<span class="ksui-fg-title">{base.label}</span>
|
|
520
|
+
<Show when={base.sublabel}>
|
|
521
|
+
<span class="ksui-fg-kind">{base.sublabel}</span>
|
|
522
|
+
</Show>
|
|
523
|
+
</span>
|
|
524
|
+
</div>
|
|
525
|
+
</foreignObject>
|
|
526
|
+
);
|
|
527
|
+
}}
|
|
528
|
+
</For>
|
|
529
|
+
|
|
530
|
+
{/* 3) I/O port handles, over everything. INPUT only when fed (a
|
|
531
|
+
trigger/root has none); one OUTPUT per outgoing edge (a 2-branch
|
|
532
|
+
condition shows two). */}
|
|
533
|
+
<For each={laid().nodes}>
|
|
534
|
+
{(base) => {
|
|
535
|
+
const n = () => pos(base);
|
|
536
|
+
const outs = () => ports().outBySource.get(base.id) ?? [];
|
|
537
|
+
const hasIn = () => ports().incoming.has(base.id);
|
|
538
|
+
return (
|
|
539
|
+
<g classList={{ "ksui-fg-dim": nodeDim(base.id) }}>
|
|
540
|
+
{/* cx/cy are accessor-driven so handles follow a dragged node. */}
|
|
541
|
+
<Show when={hasIn()}>
|
|
542
|
+
<circle
|
|
543
|
+
class="ksui-fg-handle in"
|
|
544
|
+
cx={inHandle(n()).x}
|
|
545
|
+
cy={inHandle(n()).y}
|
|
546
|
+
r={3.5}
|
|
547
|
+
/>
|
|
295
548
|
</Show>
|
|
549
|
+
<For each={outs()}>
|
|
550
|
+
{(_e, i) => (
|
|
551
|
+
<circle
|
|
552
|
+
class="ksui-fg-handle out"
|
|
553
|
+
cx={outHandle(n(), i(), outs().length).x}
|
|
554
|
+
cy={outHandle(n(), i(), outs().length).y}
|
|
555
|
+
r={3.5}
|
|
556
|
+
/>
|
|
557
|
+
)}
|
|
558
|
+
</For>
|
|
296
559
|
</g>
|
|
297
560
|
);
|
|
298
561
|
}}
|
|
@@ -300,14 +563,9 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
|
|
|
300
563
|
</g>
|
|
301
564
|
</svg>
|
|
302
565
|
|
|
303
|
-
<Show when={
|
|
566
|
+
<Show when={canvas()}>
|
|
304
567
|
<div class="ksui-fg-controls" data-testid={tid("controls")}>
|
|
305
|
-
<button
|
|
306
|
-
type="button"
|
|
307
|
-
class="ksui-fg-ctrl"
|
|
308
|
-
aria-label="Zoom in"
|
|
309
|
-
onClick={() => zoomBy(1.2)}
|
|
310
|
-
>
|
|
568
|
+
<button type="button" class="ksui-fg-ctrl" aria-label="Zoom in" onClick={() => zoomBy(1.2)}>
|
|
311
569
|
+
|
|
312
570
|
</button>
|
|
313
571
|
<button
|
|
@@ -321,9 +579,9 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
|
|
|
321
579
|
<button
|
|
322
580
|
type="button"
|
|
323
581
|
class="ksui-fg-ctrl"
|
|
324
|
-
aria-label="
|
|
582
|
+
aria-label="Fit to view"
|
|
325
583
|
data-testid={tid("reset")}
|
|
326
|
-
onClick={
|
|
584
|
+
onClick={fitView}
|
|
327
585
|
>
|
|
328
586
|
⤢
|
|
329
587
|
</button>
|
|
@@ -169,7 +169,7 @@ export default function SearchableSelect(props: SearchableSelectProps): JSX.Elem
|
|
|
169
169
|
<Portal>
|
|
170
170
|
<div
|
|
171
171
|
ref={popupRef}
|
|
172
|
-
class="z-[
|
|
172
|
+
class="z-[10000] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
|
|
173
173
|
style={popupStyle()}
|
|
174
174
|
>
|
|
175
175
|
<div class="px-2 py-1.5 border-b border-zinc-800">
|
package/src/index.ts
CHANGED
|
@@ -239,6 +239,15 @@ export type {
|
|
|
239
239
|
FlowInput,
|
|
240
240
|
} from "./utils/flow";
|
|
241
241
|
|
|
242
|
+
// Flow-spec (pure): the node-based PROGRAM model — defineFlow + node/edge
|
|
243
|
+
// builders producing a serializable FlowDefinition (the source of truth an
|
|
244
|
+
// author writes in code), plus flowToGraph which lowers it to the graph the
|
|
245
|
+
// FlowGraph canvas draws. This is the "authored in SDK code → parsed to a
|
|
246
|
+
// diagram" seam.
|
|
247
|
+
export { defineFlow, node, edge, flowToGraph } from "./utils/flow-spec";
|
|
248
|
+
export { buildFlow, FlowSteps } from "./utils/flow-builder";
|
|
249
|
+
export type { FlowDefinition, FlowNodeDef, FlowNodeKind, FlowPort } from "./utils/flow-spec";
|
|
250
|
+
|
|
242
251
|
// FlowGraph model (pure): the node/edge types + the dependency-free layout the
|
|
243
252
|
// renderer uses. Exported so hosts can type their graph data and, if needed,
|
|
244
253
|
// pre-compute layout off the DOM.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// flow-builder: node-step authoring → FlowDefinition. Tests linear chaining and
|
|
2
|
+
// the condition fork (both arms captured).
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { buildFlow } from "./flow-builder";
|
|
5
|
+
import { flowToGraph } from "./flow-spec";
|
|
6
|
+
|
|
7
|
+
describe("buildFlow", () => {
|
|
8
|
+
it("chains linear steps into a connected path", () => {
|
|
9
|
+
const def = buildFlow("item.create", "Add Item", (f) => {
|
|
10
|
+
f.trigger("Add Item button").modal("Item form").commit("Create", "POST /api/items");
|
|
11
|
+
});
|
|
12
|
+
expect(def.nodes.map((n) => n.kind)).toEqual(["trigger", "modal", "commit"]);
|
|
13
|
+
const { edges } = flowToGraph(def);
|
|
14
|
+
// trigger → modal → commit
|
|
15
|
+
expect(edges).toHaveLength(2);
|
|
16
|
+
expect(edges[0].from).toBe(def.nodes[0].id);
|
|
17
|
+
expect(edges[0].to).toBe(def.nodes[1].id);
|
|
18
|
+
expect(edges[1].to).toBe(def.nodes[2].id);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("forks a condition into two labelled branches, both rendered", () => {
|
|
22
|
+
const def = buildFlow("cart.checkout", "Checkout", (f) => {
|
|
23
|
+
f.trigger("Checkout button")
|
|
24
|
+
.modal("Coupon entry")
|
|
25
|
+
.condition(
|
|
26
|
+
"Coupon valid?",
|
|
27
|
+
(yes) => yes.call("pricing:validate").compute("Apply discount").commit("Place order"),
|
|
28
|
+
(no) => no.commit("Place order"),
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
const cond = def.nodes.find((n) => n.kind === "condition")!;
|
|
32
|
+
// exactly two outgoing branches, to DIFFERENT nodes, each labelled
|
|
33
|
+
expect(cond.out).toHaveLength(2);
|
|
34
|
+
expect(cond.out![0].to).not.toBe(cond.out![1].to);
|
|
35
|
+
expect(cond.out!.map((p) => p.label)).toEqual(["yes", "no"]);
|
|
36
|
+
// the "yes" arm carries the validate→compute→commit chain
|
|
37
|
+
expect(def.nodes.some((n) => n.kind === "call" && n.detail === "pricing:validate")).toBe(true);
|
|
38
|
+
expect(def.nodes.filter((n) => n.kind === "commit")).toHaveLength(2);
|
|
39
|
+
// every edge resolves (defineFlow would have thrown otherwise)
|
|
40
|
+
const { nodes, edges } = flowToGraph(def);
|
|
41
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
42
|
+
expect(edges.every((e) => ids.has(e.from) && ids.has(e.to))).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Node-step authoring DSL — a fluent builder for a flow/node-graph definition.
|
|
2
|
+
// An author wraps steps in calls (`f.trigger(...)`, `f.condition(label, onYes,
|
|
3
|
+
// onNo)`, `f.call(...)`, …); each call appends a node wired from the previous
|
|
4
|
+
// step, and a condition forks into two labelled branches. The whole thing lowers
|
|
5
|
+
// to a `FlowDefinition` the FlowGraph renders.
|
|
6
|
+
//
|
|
7
|
+
// Authoring with steps — instead of hand-building node/edge data — is what keeps
|
|
8
|
+
// the diagram parseable and in lockstep with the code that declares it: the same
|
|
9
|
+
// call tree that (in a step-running runtime) drives the behaviour is what gets
|
|
10
|
+
// rendered. This is optional, additive sugar over `defineFlow`; consumers can
|
|
11
|
+
// still build a `FlowDefinition` directly with `node`/`edge` if they prefer.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
defineFlow,
|
|
15
|
+
type FlowDefinition,
|
|
16
|
+
type FlowNodeDef,
|
|
17
|
+
type FlowNodeKind,
|
|
18
|
+
} from "./flow-spec";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A node-step recorder. Linear steps chain (`f.trigger(...).load(...).commit(...)`);
|
|
22
|
+
* `condition` forks into two labelled branches that recurse with the SAME node
|
|
23
|
+
* list, so the full tree — both arms, not just the one a runtime would take — is
|
|
24
|
+
* captured for the diagram. Branch builders share `nodes` (and thus the running
|
|
25
|
+
* id sequence via `nodes.length`), so ids never collide across arms.
|
|
26
|
+
*/
|
|
27
|
+
export class FlowSteps {
|
|
28
|
+
constructor(
|
|
29
|
+
readonly prefix: string,
|
|
30
|
+
readonly nodes: FlowNodeDef[] = [],
|
|
31
|
+
private tail: string | null = null,
|
|
32
|
+
private pendingLabel?: string,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
private step(kind: FlowNodeKind, label: string, detail?: string): this {
|
|
36
|
+
const id = `${this.prefix}_${kind}_${this.nodes.length}`;
|
|
37
|
+
this.nodes.push({ id, kind, label, ...(detail ? { detail } : {}) });
|
|
38
|
+
if (this.tail) {
|
|
39
|
+
const prev = this.nodes.find((n) => n.id === this.tail);
|
|
40
|
+
if (prev) {
|
|
41
|
+
prev.out = prev.out ?? [];
|
|
42
|
+
const bl = this.pendingLabel;
|
|
43
|
+
prev.out.push(bl ? { id: bl, to: id, label: bl } : { id: "out", to: id });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
this.tail = id;
|
|
47
|
+
this.pendingLabel = undefined; // a fork label applies only to the first step of the arm
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A UI event that starts/continues the flow (a button, a selection). */
|
|
52
|
+
trigger(label: string): this {
|
|
53
|
+
return this.step("trigger", label);
|
|
54
|
+
}
|
|
55
|
+
/** A data source / list the screen shows. */
|
|
56
|
+
data(label: string, detail?: string): this {
|
|
57
|
+
return this.step("data", label, detail);
|
|
58
|
+
}
|
|
59
|
+
/** A fetch/load into the current screen. */
|
|
60
|
+
load(label: string, detail?: string): this {
|
|
61
|
+
return this.step("load", label, detail);
|
|
62
|
+
}
|
|
63
|
+
/** Opens an overlay / form. */
|
|
64
|
+
modal(label: string): this {
|
|
65
|
+
return this.step("modal", label);
|
|
66
|
+
}
|
|
67
|
+
/** A call out to another service/capability; `target` is its identifier. */
|
|
68
|
+
call(target: string, label?: string): this {
|
|
69
|
+
return this.step("call", label ?? target, target);
|
|
70
|
+
}
|
|
71
|
+
/** A pure computation (apply a discount, total a cart). */
|
|
72
|
+
compute(label: string): this {
|
|
73
|
+
return this.step("compute", label);
|
|
74
|
+
}
|
|
75
|
+
/** A write / command. */
|
|
76
|
+
commit(label: string, detail?: string): this {
|
|
77
|
+
return this.step("commit", label, detail);
|
|
78
|
+
}
|
|
79
|
+
/** Emits a domain event. */
|
|
80
|
+
emit(event: string): this {
|
|
81
|
+
return this.step("emit", event);
|
|
82
|
+
}
|
|
83
|
+
/** A UI effect — refresh / toast / navigate / close. */
|
|
84
|
+
effect(label: string): this {
|
|
85
|
+
return this.step("effect", label);
|
|
86
|
+
}
|
|
87
|
+
/** An end state. */
|
|
88
|
+
terminal(label: string): this {
|
|
89
|
+
return this.step("terminal", label);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A two-way branch. `onYes`/`onNo` each receive a builder rooted at the
|
|
94
|
+
* condition so both arms render; `labels` annotates the two out-edges
|
|
95
|
+
* (default "yes"/"no").
|
|
96
|
+
*/
|
|
97
|
+
condition(
|
|
98
|
+
label: string,
|
|
99
|
+
onYes: (yes: FlowSteps) => void,
|
|
100
|
+
onNo: (no: FlowSteps) => void,
|
|
101
|
+
labels: readonly [string, string] = ["yes", "no"],
|
|
102
|
+
): this {
|
|
103
|
+
this.step("condition", label);
|
|
104
|
+
const cond = this.tail as string;
|
|
105
|
+
onYes(new FlowSteps(this.prefix, this.nodes, cond, labels[0]));
|
|
106
|
+
onNo(new FlowSteps(this.prefix, this.nodes, cond, labels[1]));
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Author one flow from node steps. The returned definition is identity-checked
|
|
112
|
+
* (`defineFlow` throws on a dangling edge) and renders on the FlowGraph canvas. */
|
|
113
|
+
export function buildFlow(
|
|
114
|
+
id: string,
|
|
115
|
+
title: string,
|
|
116
|
+
build: (f: FlowSteps) => void,
|
|
117
|
+
): FlowDefinition {
|
|
118
|
+
const f = new FlowSteps(id.replace(/[^a-zA-Z0-9]+/g, "_"));
|
|
119
|
+
build(f);
|
|
120
|
+
return defineFlow({ id, title, nodes: f.nodes });
|
|
121
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// flow-spec: the node-based program model + its lowering to the graph the canvas
|
|
2
|
+
// draws. Tests the parse step (flowToGraph) and the dangling-edge guard.
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { defineFlow, edge, node, flowToGraph } from "./flow-spec";
|
|
5
|
+
|
|
6
|
+
describe("defineFlow", () => {
|
|
7
|
+
it("returns the definition unchanged when edges are valid", () => {
|
|
8
|
+
const def = defineFlow({
|
|
9
|
+
id: "demo",
|
|
10
|
+
title: "Demo",
|
|
11
|
+
nodes: [
|
|
12
|
+
node("a", "trigger", "Start", { out: [edge("b")] }),
|
|
13
|
+
node("b", "terminal", "Done"),
|
|
14
|
+
],
|
|
15
|
+
});
|
|
16
|
+
expect(def.nodes).toHaveLength(2);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("throws on an edge to an unknown node", () => {
|
|
20
|
+
expect(() =>
|
|
21
|
+
defineFlow({
|
|
22
|
+
id: "bad",
|
|
23
|
+
title: "Bad",
|
|
24
|
+
nodes: [node("a", "trigger", "Start", { out: [edge("ghost")] })],
|
|
25
|
+
}),
|
|
26
|
+
).toThrow(/unknown node "ghost"/);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("flowToGraph", () => {
|
|
31
|
+
it("lowers nodes (kind + detail) and edges (branch labels) to the graph model", () => {
|
|
32
|
+
const { nodes, edges } = flowToGraph({
|
|
33
|
+
id: "checkout",
|
|
34
|
+
title: "Checkout",
|
|
35
|
+
nodes: [
|
|
36
|
+
node("pick", "trigger", "Pick voucher", { out: [edge("validate", "voucher chosen")] }),
|
|
37
|
+
node("validate", "call", "Validate", { detail: "vouchers.validate", out: [edge("done")] }),
|
|
38
|
+
node("done", "effect", "Refresh"),
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
expect(nodes.map((n) => n.kind)).toEqual(["trigger", "call", "effect"]);
|
|
42
|
+
// detail becomes the sublabel; kind falls back when no detail
|
|
43
|
+
expect(nodes[1].sublabel).toBe("vouchers.validate");
|
|
44
|
+
expect(nodes[0].sublabel).toBe("trigger");
|
|
45
|
+
// a named branch is dashed + labeled; the default "out" is solid + unlabeled
|
|
46
|
+
const branch = edges.find((e) => e.from === "pick")!;
|
|
47
|
+
expect(branch.label).toBe("voucher chosen");
|
|
48
|
+
expect(branch.dashed).toBe(true);
|
|
49
|
+
const plain = edges.find((e) => e.from === "validate")!;
|
|
50
|
+
expect(plain.dashed).toBe(false);
|
|
51
|
+
expect(plain.label).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Node-based program model — a plugin's behavior as a graph of connectable
|
|
2
|
+
// nodes (game-engine / n8n style). This is the SOURCE OF TRUTH a viewer renders
|
|
3
|
+
// and (in future) an editor edits; the diagram is just its projection. Pure +
|
|
4
|
+
// serializable: no DOM, no solid, no app/domain assumptions. `flowToGraph`
|
|
5
|
+
// lowers a FlowDefinition into the generic GraphNode/GraphEdge the FlowGraph
|
|
6
|
+
// canvas draws — that lowering IS the "automatically visualized" step.
|
|
7
|
+
|
|
8
|
+
import type { GraphAccent, GraphEdge, GraphNode } from "./graph";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The kinds of node a behavior graph is built from. Spans the WHOLE plugin —
|
|
12
|
+
* data sources, UI triggers, modals, loads, peer calls, computations, branches,
|
|
13
|
+
* writes and events — so a non-technical reader sees the real behavior, not an
|
|
14
|
+
* RPC list.
|
|
15
|
+
*/
|
|
16
|
+
export type FlowNodeKind =
|
|
17
|
+
| "data" // a data source / list (e.g. "list availments")
|
|
18
|
+
| "trigger" // a UI event that starts or continues the graph (button, selection)
|
|
19
|
+
| "modal" // opens an overlay / screen
|
|
20
|
+
| "load" // fetches/loads data into the current screen
|
|
21
|
+
| "call" // a cross-plugin capability call
|
|
22
|
+
| "compute" // a pure computation (e.g. apply a discount)
|
|
23
|
+
| "condition" // a branch
|
|
24
|
+
| "commit" // a mutation / write (a command)
|
|
25
|
+
| "emit" // emits a domain event
|
|
26
|
+
| "effect" // a UI effect (refresh / toast / navigate)
|
|
27
|
+
| "terminal"; // an end state
|
|
28
|
+
|
|
29
|
+
/** One outgoing connection from a node. `id` is the branch name ("out" = the
|
|
30
|
+
* single default output); `label` annotates the edge ("voucher chosen"). */
|
|
31
|
+
export interface FlowPort {
|
|
32
|
+
id: string;
|
|
33
|
+
to: string;
|
|
34
|
+
label?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FlowNodeDef {
|
|
38
|
+
id: string;
|
|
39
|
+
kind: FlowNodeKind;
|
|
40
|
+
label: string;
|
|
41
|
+
/** Secondary line: a `peer.method` for a call, the computed field for a
|
|
42
|
+
* compute, the data source for a load — what makes the node concrete. */
|
|
43
|
+
detail?: string;
|
|
44
|
+
/** Outgoing connections (control/data flow). Omitted/empty = a leaf. */
|
|
45
|
+
out?: FlowPort[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FlowDefinition {
|
|
49
|
+
id: string;
|
|
50
|
+
title: string;
|
|
51
|
+
/** Entry node id; defaults to the first node when omitted. */
|
|
52
|
+
entry?: string;
|
|
53
|
+
nodes: FlowNodeDef[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Author a connection. `edge("charge")` or `edge("charge", "voucher chosen")`. */
|
|
57
|
+
export function edge(to: string, label?: string): FlowPort {
|
|
58
|
+
return label === undefined ? { id: "out", to } : { id: label, to, label };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Author one node. Sugar over the literal so authored graphs read top-down. */
|
|
62
|
+
export function node(
|
|
63
|
+
id: string,
|
|
64
|
+
kind: FlowNodeKind,
|
|
65
|
+
label: string,
|
|
66
|
+
opts?: { detail?: string; out?: FlowPort[] },
|
|
67
|
+
): FlowNodeDef {
|
|
68
|
+
return { id, kind, label, detail: opts?.detail, out: opts?.out };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Identity + light validation: the authored graph IS the definition. Throws on
|
|
72
|
+
* a dangling edge so an author catches a typo'd target id immediately. */
|
|
73
|
+
export function defineFlow(def: FlowDefinition): FlowDefinition {
|
|
74
|
+
const ids = new Set(def.nodes.map((n) => n.id));
|
|
75
|
+
for (const n of def.nodes) {
|
|
76
|
+
for (const p of n.out ?? []) {
|
|
77
|
+
if (!ids.has(p.to)) {
|
|
78
|
+
throw new Error(`flow "${def.id}": node "${n.id}" connects to unknown node "${p.to}"`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return def;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Visual accent per node kind — keeps the palette consistent across consumers. */
|
|
86
|
+
const KIND_ACCENT: Record<FlowNodeKind, GraphAccent> = {
|
|
87
|
+
data: "info",
|
|
88
|
+
trigger: "primary",
|
|
89
|
+
modal: "info",
|
|
90
|
+
load: "muted",
|
|
91
|
+
call: "success",
|
|
92
|
+
compute: "primary",
|
|
93
|
+
condition: "danger",
|
|
94
|
+
commit: "success",
|
|
95
|
+
emit: "info",
|
|
96
|
+
effect: "muted",
|
|
97
|
+
terminal: "muted",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Lower a FlowDefinition to the generic graph model the FlowGraph canvas draws.
|
|
102
|
+
* Node `kind` flows through (the renderer maps it to an icon); `detail` becomes
|
|
103
|
+
* the sublabel; branch ports become labeled edges. This is the parse step: one
|
|
104
|
+
* declarative source → one diagram, no hand-drawing.
|
|
105
|
+
*/
|
|
106
|
+
export function flowToGraph(def: FlowDefinition): { nodes: GraphNode[]; edges: GraphEdge[] } {
|
|
107
|
+
const nodes: GraphNode[] = def.nodes.map((n) => ({
|
|
108
|
+
id: n.id,
|
|
109
|
+
label: n.label,
|
|
110
|
+
sublabel: n.detail ?? n.kind,
|
|
111
|
+
kind: n.kind,
|
|
112
|
+
accent: KIND_ACCENT[n.kind],
|
|
113
|
+
}));
|
|
114
|
+
const edges: GraphEdge[] = def.nodes.flatMap((n) =>
|
|
115
|
+
(n.out ?? []).map((p) => ({
|
|
116
|
+
from: n.id,
|
|
117
|
+
to: p.to,
|
|
118
|
+
label: p.label,
|
|
119
|
+
// A named (non-default) branch reads as conditional — draw it dashed.
|
|
120
|
+
dashed: p.id !== "out",
|
|
121
|
+
})),
|
|
122
|
+
);
|
|
123
|
+
return { nodes, edges };
|
|
124
|
+
}
|
package/src/utils/graph.ts
CHANGED
|
Binary file
|