@kubex/zinc 1.1.22 → 1.1.24
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/custom-elements-manifest.config.js +2 -4
- package/dist/custom-elements.json +1172 -78
- package/dist/vscode.html-custom-data.json +34 -13
- package/dist/web-types.json +135 -26
- package/dist/zn.d.ts +867 -1
- package/dist/zn.min.js +893 -481
- package/docs/_includes/default.njk +1 -0
- package/docs/_includes/full-page.njk +38 -0
- package/docs/pages/components/flow-builder-demo.njk +194 -0
- package/docs/pages/components/flow-builder-troubleshooter-demo.njk +282 -0
- package/docs/pages/components/flow-builder.md +377 -0
- package/package.json +1 -1
- package/src/components/button/button.component.ts +1 -2
- package/src/components/button/button.scss +6 -16
- package/src/components/collapsible/collapsible.component.ts +11 -6
- package/src/components/data-table/data-table.component.ts +12 -12
- package/src/components/flow-builder/flow-builder.component.ts +1171 -0
- package/src/components/flow-builder/flow-builder.scss +489 -0
- package/src/components/flow-builder/flow-builder.test.ts +59 -0
- package/src/components/flow-builder/flow-layout.ts +139 -0
- package/src/components/flow-builder/flow-registry.ts +52 -0
- package/src/components/flow-builder/flow.types.ts +407 -0
- package/src/components/flow-builder/index.ts +14 -0
- package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +1086 -0
- package/src/components/flow-builder/modules/flow-canvas/flow-canvas.scss +371 -0
- package/src/components/flow-builder/modules/flow-canvas/flow-canvas.test.ts +39 -0
- package/src/components/flow-builder/modules/flow-canvas/index.ts +12 -0
- package/src/components/flow-builder/modules/flow-node/flow-node.component.ts +174 -0
- package/src/components/flow-builder/modules/flow-node/flow-node.scss +180 -0
- package/src/components/flow-builder/modules/flow-node/flow-node.test.ts +50 -0
- package/src/components/flow-builder/modules/flow-node/index.ts +12 -0
- package/src/components/flow-builder/modules/flow-step/flow-step.component.ts +88 -0
- package/src/components/flow-builder/modules/flow-step/flow-step.scss +52 -0
- package/src/components/flow-builder/modules/flow-step/flow-step.test.ts +21 -0
- package/src/components/flow-builder/modules/flow-step/index.ts +12 -0
- package/src/components/flow-builder/modules/flow-step-group/flow-step-group.component.ts +35 -0
- package/src/components/flow-builder/modules/flow-step-group/flow-step-group.scss +28 -0
- package/src/components/flow-builder/modules/flow-step-group/flow-step-group.test.ts +20 -0
- package/src/components/flow-builder/modules/flow-step-group/index.ts +12 -0
- package/src/components/flow-builder/modules/flow-steps/flow-steps.component.ts +88 -0
- package/src/components/flow-builder/modules/flow-steps/flow-steps.scss +24 -0
- package/src/components/flow-builder/modules/flow-steps/flow-steps.test.ts +17 -0
- package/src/components/flow-builder/modules/flow-steps/index.ts +12 -0
- package/src/events/events.ts +3 -0
- package/src/events/zn-flow-change.ts +9 -0
- package/src/events/zn-flow-connect.ts +9 -0
- package/src/events/zn-flow-selection-change.ts +7 -0
- package/src/zinc.ts +4 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type {FlowGroup, FlowNodeType} from './flow.types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Holds the set of {@link FlowNodeType}s available to a flow builder. This is
|
|
5
|
+
* the modular extension point: register custom node types here and the steps panel
|
|
6
|
+
* and inspector pick them up automatically.
|
|
7
|
+
*/
|
|
8
|
+
export class FlowRegistry {
|
|
9
|
+
private types = new Map<string, FlowNodeType>();
|
|
10
|
+
|
|
11
|
+
register(type: FlowNodeType): this {
|
|
12
|
+
this.types.set(type.type, type);
|
|
13
|
+
return this;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
registerAll(types: FlowNodeType[]): this {
|
|
17
|
+
types.forEach(t => this.register(t));
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get(type: string): FlowNodeType | undefined {
|
|
22
|
+
return this.types.get(type);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
has(type: string): boolean {
|
|
26
|
+
return this.types.has(type);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
all(): FlowNodeType[] {
|
|
30
|
+
return Array.from(this.types.values());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
byGroup(group: FlowGroup): FlowNodeType[] {
|
|
34
|
+
return this.all().filter(t => t.group === group);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Map of category name -> types, preserving insertion order, for one group. */
|
|
38
|
+
categories(group: FlowGroup): Map<string, FlowNodeType[]> {
|
|
39
|
+
const out = new Map<string, FlowNodeType[]>();
|
|
40
|
+
for (const type of this.byGroup(group)) {
|
|
41
|
+
const key = type.category ?? '';
|
|
42
|
+
const bucket = out.get(key) ?? [];
|
|
43
|
+
bucket.push(type);
|
|
44
|
+
out.set(key, bucket);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
clear(): void {
|
|
50
|
+
this.types.clear();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import type {TemplateResult} from 'lit';
|
|
2
|
+
|
|
3
|
+
/** Which steps-panel tab a node type appears under. */
|
|
4
|
+
export type FlowGroup = 'entrypoint' | 'trigger' | 'action' | 'rule';
|
|
5
|
+
|
|
6
|
+
/** A single connection point on a node (input or output). */
|
|
7
|
+
export interface FlowPort {
|
|
8
|
+
id: string;
|
|
9
|
+
/** Branch name shown on the wire pill (outputs). */
|
|
10
|
+
label?: string;
|
|
11
|
+
/** Branch configuration (filters / conditions for taking this path), edited via the branch editor. */
|
|
12
|
+
data?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Describes a kind of node that can be placed on the canvas. Consumers register
|
|
17
|
+
* these with the builder to extend it — the steps panel and inspector are driven
|
|
18
|
+
* entirely by the registered types, so no library changes are needed to add a
|
|
19
|
+
* new custom component.
|
|
20
|
+
*/
|
|
21
|
+
export interface FlowNodeType {
|
|
22
|
+
/** Unique key, persisted on every placed node. */
|
|
23
|
+
type: string;
|
|
24
|
+
label: string;
|
|
25
|
+
/** Steps-panel tab the type is listed under. */
|
|
26
|
+
group: FlowGroup;
|
|
27
|
+
/** Collapsible category within the tab (e.g. "Contacts"). */
|
|
28
|
+
category?: string;
|
|
29
|
+
/** zn-icon `src` (e.g. "mail" or "mail@lu"). */
|
|
30
|
+
icon?: string;
|
|
31
|
+
/** zn-icon `library`, when not encoded in `icon`. */
|
|
32
|
+
iconLibrary?: string;
|
|
33
|
+
/** Accent color for the icon tile / ports — any CSS color. */
|
|
34
|
+
color?: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
/** Input ports. Defaults to a single unlabelled input. Pass `[]` for a trigger. */
|
|
37
|
+
inputs?: FlowPort[];
|
|
38
|
+
/** Output ports. Defaults to a single unlabelled output. e.g. TRUE/FALSE for a split. */
|
|
39
|
+
outputs?: FlowPort[];
|
|
40
|
+
/** Initial `data` for a freshly placed node. */
|
|
41
|
+
defaultData?: Record<string, unknown>;
|
|
42
|
+
/** Renders the inspector body for a selected node of this type. */
|
|
43
|
+
renderConfig?: (node: FlowNodeInstance, update: (data: Record<string, unknown>) => void) => TemplateResult;
|
|
44
|
+
/** Renders the branch editor body (filters / conditions) for one of this type's output branches. */
|
|
45
|
+
renderBranchConfig?: (
|
|
46
|
+
node: FlowNodeInstance,
|
|
47
|
+
port: FlowPort,
|
|
48
|
+
update: (patch: Partial<FlowPort>) => void
|
|
49
|
+
) => TemplateResult;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A node placed on the canvas. */
|
|
53
|
+
export interface FlowNodeInstance {
|
|
54
|
+
id: string;
|
|
55
|
+
type: string;
|
|
56
|
+
x: number;
|
|
57
|
+
y: number;
|
|
58
|
+
/** Overrides the type label when set. */
|
|
59
|
+
label?: string;
|
|
60
|
+
/** Per-instance input ports; overrides the type's when set (user-configurable). */
|
|
61
|
+
inputs?: FlowPort[];
|
|
62
|
+
/** Per-instance output ports; overrides the type's when set (user-configurable). */
|
|
63
|
+
outputs?: FlowPort[];
|
|
64
|
+
data: Record<string, unknown>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface FlowEndpoint {
|
|
68
|
+
node: string;
|
|
69
|
+
port: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface FlowConnection {
|
|
73
|
+
id: string;
|
|
74
|
+
source: FlowEndpoint;
|
|
75
|
+
target: FlowEndpoint;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface FlowNote {
|
|
79
|
+
id: string;
|
|
80
|
+
x: number;
|
|
81
|
+
y: number;
|
|
82
|
+
text: string;
|
|
83
|
+
width?: number;
|
|
84
|
+
height?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The complete serialisable state of a flow. */
|
|
88
|
+
export interface FlowState {
|
|
89
|
+
nodes: FlowNodeInstance[];
|
|
90
|
+
connections: FlowConnection[];
|
|
91
|
+
notes: FlowNote[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const DEFAULT_INPUT: FlowPort = {id: 'in'};
|
|
95
|
+
export const DEFAULT_OUTPUT: FlowPort = {id: 'out'};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Sentinel port id for the extra "+" a fully-connected node always offers.
|
|
99
|
+
* Using it (attaching a stray branch, dropping a step, or moving a node onto it)
|
|
100
|
+
* materialises a real output port on the node first.
|
|
101
|
+
*/
|
|
102
|
+
export const NEW_OUTPUT_PORT = '__new__';
|
|
103
|
+
|
|
104
|
+
/** Drag-and-drop MIME used to carry a node type id from the steps panel to the canvas. */
|
|
105
|
+
export const FLOW_TYPE_MIME = 'application/x-zn-flow-type';
|
|
106
|
+
|
|
107
|
+
let _emptyDragImage: HTMLImageElement | undefined;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A cached 1×1 transparent image. Pass it to `dataTransfer.setDragImage()` to
|
|
111
|
+
* suppress the browser's default drag image — the builder renders its own
|
|
112
|
+
* in-canvas drop preview instead.
|
|
113
|
+
*/
|
|
114
|
+
export function emptyDragImage(): HTMLImageElement {
|
|
115
|
+
if (!_emptyDragImage) {
|
|
116
|
+
_emptyDragImage = new Image();
|
|
117
|
+
_emptyDragImage.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
118
|
+
}
|
|
119
|
+
return _emptyDragImage;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Fixed node geometry. Cards are a uniform size so the canvas can compute exact
|
|
124
|
+
* port coordinates from a node's position alone — no DOM measurement, which
|
|
125
|
+
* keeps connection rendering and hit-testing reliable under pan/zoom.
|
|
126
|
+
*/
|
|
127
|
+
// Node cards and the branch geometry hanging off them are whole grid units
|
|
128
|
+
// (GRID_SIZE multiples), so ports, buses, and pills always land on the grid.
|
|
129
|
+
export const NODE_WIDTH = 240;
|
|
130
|
+
export const NODE_HEIGHT = 60;
|
|
131
|
+
|
|
132
|
+
/** Horizontal spacing between the branches of a multi-output node. */
|
|
133
|
+
export const BRANCH_SPREAD = 200;
|
|
134
|
+
|
|
135
|
+
// Branch geometry below a node: outputs fork from a stem onto a horizontal bus,
|
|
136
|
+
// and labelled outputs drop from it into a name pill.
|
|
137
|
+
export const BUS_OFFSET = 40;
|
|
138
|
+
export const PILL_DROP = 40;
|
|
139
|
+
export const PILL_HEIGHT = 40;
|
|
140
|
+
export const PILL_MAX_WIDTH = 240;
|
|
141
|
+
/** Extra pill height per wrapped line (matches the pill's CSS line-height). */
|
|
142
|
+
export const PILL_LINE_HEIGHT = 20;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Estimated pill box for a branch name: sizes to the text up to the max width,
|
|
146
|
+
* then hard-wraps — the height grows a grid unit per extra line. The pill DOM
|
|
147
|
+
* sizes itself from its content (fixed padding); this estimate drives the wire
|
|
148
|
+
* geometry and collision footprints, so it uses the same ~7.2px/char metric.
|
|
149
|
+
*/
|
|
150
|
+
export function pillSize(label: string): { w: number; h: number } {
|
|
151
|
+
const textWidth = label.length * 7.2;
|
|
152
|
+
const lines = Math.max(1, Math.ceil(textWidth / (PILL_MAX_WIDTH - 36)));
|
|
153
|
+
return {
|
|
154
|
+
w: Math.min(PILL_MAX_WIDTH, Math.round(36 + textWidth)),
|
|
155
|
+
h: PILL_HEIGHT + (lines - 1) * PILL_LINE_HEIGHT,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The grid everything on the canvas snaps to (matches the dotted background). */
|
|
160
|
+
export const GRID_SIZE = 20;
|
|
161
|
+
|
|
162
|
+
export function snapToGrid(v: number): number {
|
|
163
|
+
return Math.round(v / GRID_SIZE) * GRID_SIZE;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Clearance margins per obstacle kind — two cards keep 2 × CARD_MARGIN apart,
|
|
167
|
+
// while pills allow tighter packing.
|
|
168
|
+
const CARD_MARGIN = 10;
|
|
169
|
+
const PILL_MARGIN = 4;
|
|
170
|
+
|
|
171
|
+
interface Rect {
|
|
172
|
+
x: number;
|
|
173
|
+
y: number;
|
|
174
|
+
w: number;
|
|
175
|
+
h: number;
|
|
176
|
+
/** Clearance this rect claims around itself. */
|
|
177
|
+
m: number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function rectsOverlap(a: Rect, b: Rect): boolean {
|
|
181
|
+
const gap = a.m + b.m;
|
|
182
|
+
return a.x < b.x + b.w + gap && b.x < a.x + a.w + gap
|
|
183
|
+
&& a.y < b.y + b.h + gap && b.y < a.y + a.h + gap;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A function resolving a node type key to its registered type. */
|
|
187
|
+
export type FlowTypeOf = (type: string) => FlowNodeType | undefined;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Canvas x for each of a node's branch drops. A connected branch pulls straight
|
|
191
|
+
* above its child's input — a bend-free wire — when it's the only wire into that
|
|
192
|
+
* input, the child is within the branch's natural fan range, and the position
|
|
193
|
+
* keeps clear of sibling branches; otherwise it fans out source-side.
|
|
194
|
+
*/
|
|
195
|
+
export function branchDropXs(
|
|
196
|
+
node: FlowNodeInstance,
|
|
197
|
+
typeOf: FlowTypeOf,
|
|
198
|
+
nodes: FlowNodeInstance[],
|
|
199
|
+
connections: FlowConnection[]
|
|
200
|
+
): number[] {
|
|
201
|
+
const outputs = nodeOutputs(node, typeOf(node.type));
|
|
202
|
+
const cx = node.x + NODE_WIDTH / 2;
|
|
203
|
+
const natural = (i: number) =>
|
|
204
|
+
Math.round(cx + (outputs.length === 1 ? 0 : (i - (outputs.length - 1) / 2) * BRANCH_SPREAD));
|
|
205
|
+
const MIN_SEP = 170;
|
|
206
|
+
|
|
207
|
+
const xs = outputs.map((port, i) => {
|
|
208
|
+
const spread = natural(i);
|
|
209
|
+
const conn = connections.find(c => c.source.node === node.id && c.source.port === port.id);
|
|
210
|
+
const child = conn ? nodes.find(n => n.id === conn.target.node) : undefined;
|
|
211
|
+
if (!conn || !child) return {x: spread, aligned: false};
|
|
212
|
+
// Fan-in wires converge on the target — keep their pills on the source side.
|
|
213
|
+
const fanIn = connections.some(
|
|
214
|
+
c => c !== conn && c.target.node === conn.target.node && c.target.port === conn.target.port
|
|
215
|
+
);
|
|
216
|
+
if (fanIn) return {x: spread, aligned: false};
|
|
217
|
+
const inputs = nodeInputs(child, typeOf(child.type));
|
|
218
|
+
const idx = Math.max(inputs.findIndex(p => p.id === conn.target.port), 0);
|
|
219
|
+
const anchor = portAnchor(child, 'in', idx, inputs.length).x;
|
|
220
|
+
if (Math.abs(anchor - spread) > BRANCH_SPREAD) return {x: spread, aligned: false};
|
|
221
|
+
return {x: anchor, aligned: true};
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Aligned branches yield back to their fan position rather than crowding siblings.
|
|
225
|
+
for (let i = 0; i < xs.length; i++) {
|
|
226
|
+
if (xs[i].aligned && xs.some((o, j) => j !== i && Math.abs(o.x - xs[i].x) < MIN_SEP)) {
|
|
227
|
+
xs[i] = {x: natural(i), aligned: false};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return xs.map(o => o.x);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The rects a node occupies on the canvas: its card plus each branch-name pill,
|
|
235
|
+
* at the exact positions the canvas draws them.
|
|
236
|
+
*/
|
|
237
|
+
export function nodeObstacles(
|
|
238
|
+
node: FlowNodeInstance,
|
|
239
|
+
typeOf: FlowTypeOf,
|
|
240
|
+
nodes: FlowNodeInstance[],
|
|
241
|
+
connections: FlowConnection[]
|
|
242
|
+
): Rect[] {
|
|
243
|
+
const rects: Rect[] = [{x: node.x, y: node.y, w: NODE_WIDTH, h: NODE_HEIGHT, m: CARD_MARGIN}];
|
|
244
|
+
const ports = nodeOutputs(node, typeOf(node.type));
|
|
245
|
+
const xs = branchDropXs(node, typeOf, nodes, connections);
|
|
246
|
+
ports.forEach((port, i) => {
|
|
247
|
+
if (!port.label) return;
|
|
248
|
+
const size = pillSize(port.label);
|
|
249
|
+
rects.push({
|
|
250
|
+
x: xs[i] - size.w / 2,
|
|
251
|
+
y: node.y + NODE_HEIGHT + BUS_OFFSET + PILL_DROP,
|
|
252
|
+
w: size.w,
|
|
253
|
+
h: size.h,
|
|
254
|
+
m: PILL_MARGIN,
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
return rects;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Whether two nodes' footprints (cards and branch pills) would overlap. */
|
|
261
|
+
export function nodesCollide(
|
|
262
|
+
a: FlowNodeInstance,
|
|
263
|
+
b: FlowNodeInstance,
|
|
264
|
+
typeOf: FlowTypeOf,
|
|
265
|
+
nodes: FlowNodeInstance[],
|
|
266
|
+
connections: FlowConnection[]
|
|
267
|
+
): boolean {
|
|
268
|
+
const rectsA = nodeObstacles(a, typeOf, nodes, connections);
|
|
269
|
+
return nodeObstacles(b, typeOf, nodes, connections).some(rb => rectsA.some(ra => rectsOverlap(ra, rb)));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Whether a bare card placed at `pos` would hit any of `node`'s footprint. */
|
|
273
|
+
export function cardCollides(
|
|
274
|
+
pos: { x: number; y: number },
|
|
275
|
+
node: FlowNodeInstance,
|
|
276
|
+
typeOf: FlowTypeOf,
|
|
277
|
+
nodes: FlowNodeInstance[],
|
|
278
|
+
connections: FlowConnection[]
|
|
279
|
+
): boolean {
|
|
280
|
+
const card: Rect = {x: pos.x, y: pos.y, w: NODE_WIDTH, h: NODE_HEIGHT, m: CARD_MARGIN};
|
|
281
|
+
return nodeObstacles(node, typeOf, nodes, connections).some(r => rectsOverlap(card, r));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export const NOTE_WIDTH = 200;
|
|
285
|
+
export const NOTE_HEIGHT = 120;
|
|
286
|
+
export const NOTE_MIN_WIDTH = 120;
|
|
287
|
+
export const NOTE_MIN_HEIGHT = 80;
|
|
288
|
+
|
|
289
|
+
/** Canvas-space coordinate of a port anchor on a node of the given size. */
|
|
290
|
+
export function portAnchor(
|
|
291
|
+
node: Pick<FlowNodeInstance, 'x' | 'y'>,
|
|
292
|
+
side: 'in' | 'out',
|
|
293
|
+
index: number,
|
|
294
|
+
count: number
|
|
295
|
+
): { x: number; y: number } {
|
|
296
|
+
return {
|
|
297
|
+
x: node.x + (NODE_WIDTH * (index + 1)) / (count + 1),
|
|
298
|
+
y: node.y + (side === 'in' ? 0 : NODE_HEIGHT),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function emptyFlowState(): FlowState {
|
|
303
|
+
return {nodes: [], connections: [], notes: []};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Resolve a type's inputs, falling back to a single default input. */
|
|
307
|
+
export function typeInputs(type: FlowNodeType | undefined): FlowPort[] {
|
|
308
|
+
if (!type) return [DEFAULT_INPUT];
|
|
309
|
+
return type.inputs ?? [DEFAULT_INPUT];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Resolve a type's outputs, falling back to a single default output. */
|
|
313
|
+
export function typeOutputs(type: FlowNodeType | undefined): FlowPort[] {
|
|
314
|
+
if (!type) return [DEFAULT_OUTPUT];
|
|
315
|
+
return type.outputs ?? [DEFAULT_OUTPUT];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** A node's effective inputs — its per-instance override, else the type's. */
|
|
319
|
+
export function nodeInputs(node: FlowNodeInstance, type: FlowNodeType | undefined): FlowPort[] {
|
|
320
|
+
return node.inputs ?? typeInputs(type);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** A node's effective outputs — its per-instance override, else the type's. */
|
|
324
|
+
export function nodeOutputs(node: FlowNodeInstance, type: FlowNodeType | undefined): FlowPort[] {
|
|
325
|
+
return node.outputs ?? typeOutputs(type);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** A node's first input id, or null when it accepts no inputs (an entrypoint). */
|
|
329
|
+
export function firstInputId(node: FlowNodeInstance, type: FlowNodeType | undefined): string | null {
|
|
330
|
+
return nodeInputs(node, type)[0]?.id ?? null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// --- Graph helpers -----------------------------------------------------------
|
|
334
|
+
// Every output port holds at most one downstream connection, but a node may
|
|
335
|
+
// receive any number of incoming connections (fan-in), and branches may loop
|
|
336
|
+
// back to earlier steps — the flow is a general directed graph. Traversals
|
|
337
|
+
// guard against revisiting, so cycles are safe.
|
|
338
|
+
|
|
339
|
+
/** The connection occupying a given output port, if any. */
|
|
340
|
+
export function connectionAt(state: FlowState, nodeId: string, port: string): FlowConnection | undefined {
|
|
341
|
+
return state.connections.find(c => c.source.node === nodeId && c.source.port === port);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Whether an output port has no downstream connection (shows a "+"). */
|
|
345
|
+
export function isOpenOutput(state: FlowState, nodeId: string, port: string): boolean {
|
|
346
|
+
return !connectionAt(state, nodeId, port);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** All node ids reachable downstream from a node (excluding the node itself). */
|
|
350
|
+
export function descendantIds(state: FlowState, nodeId: string): Set<string> {
|
|
351
|
+
const seen = new Set<string>();
|
|
352
|
+
const walk = (id: string) => {
|
|
353
|
+
for (const c of state.connections) {
|
|
354
|
+
if (c.source.node === id && !seen.has(c.target.node)) {
|
|
355
|
+
seen.add(c.target.node);
|
|
356
|
+
walk(c.target.node);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
walk(nodeId);
|
|
361
|
+
return seen;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Whether connecting `sourceNode`'s output to `targetNode` would create a cycle —
|
|
366
|
+
* i.e. the target is the source itself or already downstream of it. The builder
|
|
367
|
+
* allows loops, but consumers can use this to validate flows that must stay acyclic.
|
|
368
|
+
*/
|
|
369
|
+
export function wouldCreateCycle(state: FlowState, sourceNode: string, targetNode: string): boolean {
|
|
370
|
+
return sourceNode === targetNode || descendantIds(state, targetNode).has(sourceNode);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The connections that close loops: each cycle's back-edge in a DFS forest
|
|
375
|
+
* grown from the roots (every cycle contains exactly one such edge). Used to
|
|
376
|
+
* render loop wires distinctly and to keep the untangle layering acyclic.
|
|
377
|
+
*/
|
|
378
|
+
export function loopConnections(
|
|
379
|
+
nodes: FlowNodeInstance[],
|
|
380
|
+
connections: FlowConnection[]
|
|
381
|
+
): Set<FlowConnection> {
|
|
382
|
+
const ids = new Set(nodes.map(n => n.id));
|
|
383
|
+
const outgoing = new Map<string, FlowConnection[]>(nodes.map(n => [n.id, []]));
|
|
384
|
+
const hasIncoming = new Set<string>();
|
|
385
|
+
for (const c of connections) {
|
|
386
|
+
if (!ids.has(c.source.node) || !ids.has(c.target.node)) continue;
|
|
387
|
+
outgoing.get(c.source.node)!.push(c);
|
|
388
|
+
hasIncoming.add(c.target.node);
|
|
389
|
+
}
|
|
390
|
+
const back = new Set<FlowConnection>();
|
|
391
|
+
const visited = new Set<string>();
|
|
392
|
+
const onStack = new Set<string>();
|
|
393
|
+
const dfs = (id: string) => {
|
|
394
|
+
visited.add(id);
|
|
395
|
+
onStack.add(id);
|
|
396
|
+
for (const c of outgoing.get(id) ?? []) {
|
|
397
|
+
if (onStack.has(c.target.node)) back.add(c);
|
|
398
|
+
else if (!visited.has(c.target.node)) dfs(c.target.node);
|
|
399
|
+
}
|
|
400
|
+
onStack.delete(id);
|
|
401
|
+
};
|
|
402
|
+
nodes.filter(n => !hasIncoming.has(n.id)).forEach(n => dfs(n.id));
|
|
403
|
+
nodes.forEach(n => {
|
|
404
|
+
if (!visited.has(n.id)) dfs(n.id); // components with no root (pure cycles)
|
|
405
|
+
});
|
|
406
|
+
return back;
|
|
407
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import ZnFlowBuilder from './flow-builder.component';
|
|
2
|
+
|
|
3
|
+
export * from './flow-builder.component';
|
|
4
|
+
export * from './flow.types';
|
|
5
|
+
export * from './flow-registry';
|
|
6
|
+
export default ZnFlowBuilder;
|
|
7
|
+
|
|
8
|
+
ZnFlowBuilder.define('zn-flow-builder');
|
|
9
|
+
|
|
10
|
+
declare global {
|
|
11
|
+
interface HTMLElementTagNameMap {
|
|
12
|
+
'zn-flow-builder': ZnFlowBuilder;
|
|
13
|
+
}
|
|
14
|
+
}
|