@kubex/zinc 1.1.23 → 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 +1158 -74
- package/dist/vscode.html-custom-data.json +33 -11
- package/dist/web-types.json +132 -23
- package/dist/zn.d.ts +864 -0
- package/dist/zn.min.js +874 -463
- 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/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,1171 @@
|
|
|
1
|
+
import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
|
|
2
|
+
import {guard} from 'lit/directives/guard.js';
|
|
3
|
+
import {ifDefined} from 'lit/directives/if-defined.js';
|
|
4
|
+
import {property, state} from 'lit/decorators.js';
|
|
5
|
+
import ZincElement from '../../internal/zinc-element';
|
|
6
|
+
import ZnFlowCanvas from './modules/flow-canvas';
|
|
7
|
+
import ZnFlowStepGroup from './modules/flow-step-group';
|
|
8
|
+
import ZnIcon from '../icon';
|
|
9
|
+
import ZnInput from '../input';
|
|
10
|
+
import ZnNavbar from '../navbar';
|
|
11
|
+
import ZnTabs from '../tabs';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
cardCollides,
|
|
15
|
+
DEFAULT_OUTPUT,
|
|
16
|
+
descendantIds,
|
|
17
|
+
emptyDragImage,
|
|
18
|
+
emptyFlowState,
|
|
19
|
+
firstInputId,
|
|
20
|
+
FLOW_TYPE_MIME,
|
|
21
|
+
type FlowConnection,
|
|
22
|
+
type FlowGroup,
|
|
23
|
+
type FlowNodeInstance,
|
|
24
|
+
type FlowNodeType,
|
|
25
|
+
type FlowPort,
|
|
26
|
+
type FlowState,
|
|
27
|
+
GRID_SIZE,
|
|
28
|
+
loopConnections,
|
|
29
|
+
NEW_OUTPUT_PORT,
|
|
30
|
+
NODE_HEIGHT,
|
|
31
|
+
NODE_WIDTH,
|
|
32
|
+
nodeInputs,
|
|
33
|
+
nodeOutputs,
|
|
34
|
+
portAnchor,
|
|
35
|
+
snapToGrid,
|
|
36
|
+
typeInputs,
|
|
37
|
+
typeOutputs,
|
|
38
|
+
} from './flow.types';
|
|
39
|
+
import {FlowRegistry} from './flow-registry';
|
|
40
|
+
import {HasSlotController} from '../../internal/slot';
|
|
41
|
+
import {LAYOUT_V_GAP, untangledPositions} from './flow-layout';
|
|
42
|
+
|
|
43
|
+
import styles from './flow-builder.scss';
|
|
44
|
+
|
|
45
|
+
const HISTORY_LIMIT = 50;
|
|
46
|
+
const TYPE_MIME = FLOW_TYPE_MIME;
|
|
47
|
+
|
|
48
|
+
const TABS: { group: FlowGroup; label: string }[] = [
|
|
49
|
+
{group: 'entrypoint', label: 'Entrypoint'},
|
|
50
|
+
{group: 'trigger', label: 'Triggers'},
|
|
51
|
+
{group: 'action', label: 'Actions'},
|
|
52
|
+
{group: 'rule', label: 'Rules'},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
interface PickerTarget { kind: 'wire'; connectionId: string }
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @summary A drag-and-drop visual flow builder: steps panel, pan/zoom canvas, and a config inspector.
|
|
59
|
+
* @documentation https://zinc.style/components/flow-builder
|
|
60
|
+
* @status experimental
|
|
61
|
+
* @since 1.0
|
|
62
|
+
*
|
|
63
|
+
* @dependency zn-icon
|
|
64
|
+
* @dependency zn-input
|
|
65
|
+
* @dependency zn-tabs
|
|
66
|
+
* @dependency zn-navbar
|
|
67
|
+
* @dependency zn-flow-canvas
|
|
68
|
+
* @dependency zn-flow-node
|
|
69
|
+
*
|
|
70
|
+
* @event zn-flow-change - Emitted whenever the flow state changes. `event.detail.state` is the new FlowState.
|
|
71
|
+
* @event zn-flow-selection-change - Emitted when the selected node changes. `event.detail.nodeId`.
|
|
72
|
+
* @event zn-flow-connect - Emitted when a connection is created. `event.detail.connection`.
|
|
73
|
+
*
|
|
74
|
+
* @slot - `<zn-flow-step>` type declarations; never displayed, each `group`/`category` routes the
|
|
75
|
+
* step into the right tab and collapsible grouping of the rendered panel.
|
|
76
|
+
* @slot header-left - Actions shown on the left of the header bar (e.g. Close / Undo All Changes).
|
|
77
|
+
* @slot header-right - Actions shown on the right of the header bar (e.g. Apply Changes).
|
|
78
|
+
* @slot sidebar - Extra right-panel content (status, version history), below the configuration errors.
|
|
79
|
+
*
|
|
80
|
+
* @csspart base - The grid wrapper.
|
|
81
|
+
* @csspart header - The full-width header action bar (only rendered when header slots are filled).
|
|
82
|
+
* @csspart steps - The left steps panel.
|
|
83
|
+
* @csspart inspector - The right panel while a node or branch is selected.
|
|
84
|
+
*/
|
|
85
|
+
export default class ZnFlowBuilder extends ZincElement {
|
|
86
|
+
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
87
|
+
|
|
88
|
+
static dependencies = {
|
|
89
|
+
'zn-icon': ZnIcon,
|
|
90
|
+
'zn-input': ZnInput,
|
|
91
|
+
'zn-tabs': ZnTabs,
|
|
92
|
+
'zn-navbar': ZnNavbar,
|
|
93
|
+
'zn-flow-canvas': ZnFlowCanvas,
|
|
94
|
+
'zn-flow-step-group': ZnFlowStepGroup,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Node types to make available, registered into the internal registry. */
|
|
98
|
+
@property({attribute: false}) nodeTypes: FlowNodeType[] = [];
|
|
99
|
+
|
|
100
|
+
@property({reflect: true}) heading = '';
|
|
101
|
+
@property({attribute: 'subheading', reflect: true}) subheading = '';
|
|
102
|
+
|
|
103
|
+
/** Node ids flagged as having configuration errors (drives the red node styling). */
|
|
104
|
+
@property({attribute: false}) errorNodes: string[] = [];
|
|
105
|
+
|
|
106
|
+
/** Optional hint shown beneath each steps-panel tab. */
|
|
107
|
+
@property({attribute: 'entrypoints-hint'}) entrypointsHint = '';
|
|
108
|
+
@property({attribute: 'triggers-hint'}) triggersHint = '';
|
|
109
|
+
@property({attribute: 'actions-hint'}) actionsHint = '';
|
|
110
|
+
@property({attribute: 'rules-hint'}) rulesHint = '';
|
|
111
|
+
|
|
112
|
+
private registry = new FlowRegistry();
|
|
113
|
+
|
|
114
|
+
@state() private _state: FlowState = emptyFlowState();
|
|
115
|
+
@state() private _selectedNodeId: string | null = null;
|
|
116
|
+
/** The output branch open in the branch editor, if any. */
|
|
117
|
+
@state() private _selectedBranch: { nodeId: string; port: string } | null = null;
|
|
118
|
+
@state() private _search = '';
|
|
119
|
+
/** The steps-panel tab currently shown; the search only filters this tab. */
|
|
120
|
+
@state() private _activeGroup: FlowGroup | null = null;
|
|
121
|
+
|
|
122
|
+
private readonly _hasSlot = new HasSlotController(this, 'header-left', 'header-right');
|
|
123
|
+
/** The node being relocated via the MOVE menu action, if any. */
|
|
124
|
+
@state() private _movingNodeId: string | null = null;
|
|
125
|
+
/** The "+" picker popover target (an open output, or a wire to insert into), if open. */
|
|
126
|
+
@state() private _picker: { x: number; y: number; target: PickerTarget } | null = null;
|
|
127
|
+
/** The node type currently being dragged from the steps panel, for the canvas drop preview. */
|
|
128
|
+
@state() private _draggingType: string | null = null;
|
|
129
|
+
|
|
130
|
+
private _history: FlowState[] = [];
|
|
131
|
+
private _redo: FlowState[] = [];
|
|
132
|
+
private _seq = 0;
|
|
133
|
+
private _untangleRaf: number | null = null;
|
|
134
|
+
/** Applies the in-flight untangle's final positions; used when it's cut short. */
|
|
135
|
+
private _untangleSettle: (() => void) | null = null;
|
|
136
|
+
/**
|
|
137
|
+
* Bumped whenever the state is replaced wholesale (undo / redo / setState) so the
|
|
138
|
+
* guarded renderConfig / renderBranchConfig bodies rebuild against the fresh node
|
|
139
|
+
* objects. Value edits don't bump it — the consumer's config DOM stays in place,
|
|
140
|
+
* which is what lets its inputs commit live without losing focus.
|
|
141
|
+
*/
|
|
142
|
+
private _configRevision = 0;
|
|
143
|
+
|
|
144
|
+
private get _listeners(): [string, EventListener][] {
|
|
145
|
+
return [
|
|
146
|
+
['flow-node-select', this._onSelect as EventListener],
|
|
147
|
+
['flow-node-action', this._onNodeAction as EventListener],
|
|
148
|
+
['flow-interaction-start', this._onInteractionStart as EventListener],
|
|
149
|
+
['flow-change-commit', this._commit as EventListener],
|
|
150
|
+
['flow-output-assign', this._onOutputAssign as EventListener],
|
|
151
|
+
['flow-link-assign', this._onLinkAssign as EventListener],
|
|
152
|
+
['flow-output-move-target', this._onOutputMoveTarget as EventListener],
|
|
153
|
+
['flow-wire-pick', this._onWirePick as EventListener],
|
|
154
|
+
['flow-wire-assign', this._onWireAssign as EventListener],
|
|
155
|
+
['flow-branch-pick', this._onBranchPick as EventListener],
|
|
156
|
+
['flow-branch-delete', this._onBranchDelete as EventListener],
|
|
157
|
+
['flow-step-drag', this._onStepDrag as EventListener],
|
|
158
|
+
['flow-step-drag-end', this._onDragEnd as EventListener],
|
|
159
|
+
['flow-undo', this.undo as EventListener],
|
|
160
|
+
['flow-redo', this.redo as EventListener],
|
|
161
|
+
['flow-untangle', this.untangle as EventListener],
|
|
162
|
+
['flow-add-note', this._onAddNote as EventListener],
|
|
163
|
+
['flow-note-change', this._onNoteChange as EventListener],
|
|
164
|
+
['flow-note-delete', this._onNoteDelete as EventListener],
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
connectedCallback() {
|
|
169
|
+
super.connectedCallback();
|
|
170
|
+
this._listeners.forEach(([name, fn]) => this.addEventListener(name, fn));
|
|
171
|
+
document.addEventListener('keydown', this._onKeyDown);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
disconnectedCallback() {
|
|
175
|
+
this._listeners.forEach(([name, fn]) => this.removeEventListener(name, fn));
|
|
176
|
+
document.removeEventListener('keydown', this._onKeyDown);
|
|
177
|
+
this._cancelUntangle();
|
|
178
|
+
super.disconnectedCallback();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private _onKeyDown = (e: KeyboardEvent) => {
|
|
182
|
+
if (e.key === 'Escape' && (this._movingNodeId || this._picker || this._selectedBranch)) {
|
|
183
|
+
this._movingNodeId = null;
|
|
184
|
+
this._picker = null;
|
|
185
|
+
this._selectedBranch = null;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
protected willUpdate(changed: PropertyValues) {
|
|
190
|
+
if (changed.has('nodeTypes') && this.nodeTypes?.length) {
|
|
191
|
+
this.registry.registerAll(this.nodeTypes);
|
|
192
|
+
}
|
|
193
|
+
super.willUpdate(changed);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
protected firstUpdated(changed: PropertyValues) {
|
|
197
|
+
super.firstUpdated(changed);
|
|
198
|
+
this._registerSlottedTypes();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- Slotted steps define node types -------------------------------
|
|
202
|
+
|
|
203
|
+
private static _parsePorts(attr: string | null): FlowPort[] | undefined {
|
|
204
|
+
if (attr === null) return undefined; // omitted → use the default
|
|
205
|
+
const trimmed = attr.trim();
|
|
206
|
+
if (trimmed === '') return []; // present but empty → no ports
|
|
207
|
+
try {
|
|
208
|
+
// A JSON array of port ids ("a") and/or port objects ({id, label}).
|
|
209
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
210
|
+
if (!Array.isArray(parsed)) return undefined;
|
|
211
|
+
return parsed.map(p => (typeof p === 'string' ? {id: p} : (p as FlowPort)));
|
|
212
|
+
} catch {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private _typeFromStep(el: Element): FlowNodeType | null {
|
|
218
|
+
const type = el.getAttribute('type');
|
|
219
|
+
if (!type) return null;
|
|
220
|
+
return {
|
|
221
|
+
type,
|
|
222
|
+
label: el.getAttribute('label') ?? el.textContent?.trim() ?? type,
|
|
223
|
+
group: (el.getAttribute('group') as FlowGroup) ?? 'action',
|
|
224
|
+
category: el.getAttribute('category') ?? undefined,
|
|
225
|
+
icon: el.getAttribute('icon') ?? undefined,
|
|
226
|
+
iconLibrary: el.getAttribute('icon-library') ?? undefined,
|
|
227
|
+
color: el.getAttribute('color') ?? undefined,
|
|
228
|
+
description: el.getAttribute('description') ?? undefined,
|
|
229
|
+
inputs: ZnFlowBuilder._parsePorts(el.getAttribute('inputs')),
|
|
230
|
+
outputs: ZnFlowBuilder._parsePorts(el.getAttribute('outputs')),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Register a FlowNodeType for every slotted <zn-flow-step>. */
|
|
235
|
+
private _registerSlottedTypes = () => {
|
|
236
|
+
let added = false;
|
|
237
|
+
this.querySelectorAll('zn-flow-step').forEach(el => {
|
|
238
|
+
const type = this._typeFromStep(el);
|
|
239
|
+
if (type && !this.registry.has(type.type)) {
|
|
240
|
+
this.registry.register(type);
|
|
241
|
+
added = true;
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
if (added) this.requestUpdate();
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// --- Public API -------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
registerNodeType(type: FlowNodeType): this {
|
|
250
|
+
this.registry.register(type);
|
|
251
|
+
this.requestUpdate();
|
|
252
|
+
return this;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
registerNodeTypes(types: FlowNodeType[]): this {
|
|
256
|
+
this.registry.registerAll(types);
|
|
257
|
+
this.requestUpdate();
|
|
258
|
+
return this;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
getState(): FlowState {
|
|
262
|
+
return this._clone(this._state);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
setState(next: FlowState) {
|
|
266
|
+
this._cancelUntangle();
|
|
267
|
+
this._state = next;
|
|
268
|
+
this._history = [];
|
|
269
|
+
this._redo = [];
|
|
270
|
+
this._selectedNodeId = null;
|
|
271
|
+
this._configRevision++;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
get value(): string {
|
|
275
|
+
return JSON.stringify(this._state);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
set value(json: string) {
|
|
279
|
+
try {
|
|
280
|
+
const parsed = JSON.parse(json) as Partial<FlowState>;
|
|
281
|
+
this.setState({
|
|
282
|
+
nodes: parsed.nodes ?? [],
|
|
283
|
+
connections: parsed.connections ?? [],
|
|
284
|
+
notes: parsed.notes ?? [],
|
|
285
|
+
});
|
|
286
|
+
} catch {
|
|
287
|
+
/* ignore malformed JSON */
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
undo = () => {
|
|
292
|
+
const prev = this._history.pop();
|
|
293
|
+
if (!prev) return;
|
|
294
|
+
this._cancelUntangle();
|
|
295
|
+
this._redo.push(this._clone(this._state));
|
|
296
|
+
this._state = prev;
|
|
297
|
+
this._configRevision++;
|
|
298
|
+
this._syncSelection();
|
|
299
|
+
this._commit();
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
redo = () => {
|
|
303
|
+
const next = this._redo.pop();
|
|
304
|
+
if (!next) return;
|
|
305
|
+
this._cancelUntangle();
|
|
306
|
+
this._history.push(this._clone(this._state));
|
|
307
|
+
this._state = next;
|
|
308
|
+
this._configRevision++;
|
|
309
|
+
this._syncSelection();
|
|
310
|
+
this._commit();
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Auto-arrange the nodes into evenly spaced layers that follow the flow,
|
|
315
|
+
* animating them into place (wires and pills track them since everything is
|
|
316
|
+
* derived from the node coordinates). Undoable as a single step.
|
|
317
|
+
*/
|
|
318
|
+
untangle = () => {
|
|
319
|
+
if (!this._state.nodes.length) return;
|
|
320
|
+
this._cancelUntangle();
|
|
321
|
+
this._pushHistory();
|
|
322
|
+
const positions = untangledPositions(this._state, t => this.registry.get(t));
|
|
323
|
+
const moves = this._state.nodes
|
|
324
|
+
.map(n => ({n, from: {x: n.x, y: n.y}, to: positions.get(n.id)}))
|
|
325
|
+
.filter((m): m is typeof m & { to: { x: number; y: number } } => !!m.to);
|
|
326
|
+
|
|
327
|
+
const settle = () => {
|
|
328
|
+
moves.forEach(({n, to}) => {
|
|
329
|
+
n.x = to.x;
|
|
330
|
+
n.y = to.y;
|
|
331
|
+
});
|
|
332
|
+
};
|
|
333
|
+
const finish = () => {
|
|
334
|
+
this._untangleSettle = null;
|
|
335
|
+
settle();
|
|
336
|
+
this._commit();
|
|
337
|
+
};
|
|
338
|
+
this._untangleSettle = settle;
|
|
339
|
+
|
|
340
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
341
|
+
finish();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const DURATION = 500;
|
|
346
|
+
const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
|
|
347
|
+
const canvas = this.shadowRoot?.querySelector('zn-flow-canvas') as ZnFlowCanvas | null;
|
|
348
|
+
const t0 = performance.now();
|
|
349
|
+
const step = (now: number) => {
|
|
350
|
+
const t = Math.min(1, (now - t0) / DURATION);
|
|
351
|
+
if (t >= 1) {
|
|
352
|
+
this._untangleRaf = null;
|
|
353
|
+
finish();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const e = ease(t);
|
|
357
|
+
moves.forEach(({n, from, to}) => {
|
|
358
|
+
n.x = Math.round(from.x + (to.x - from.x) * e);
|
|
359
|
+
n.y = Math.round(from.y + (to.y - from.y) * e);
|
|
360
|
+
});
|
|
361
|
+
canvas?.requestUpdate();
|
|
362
|
+
this._untangleRaf = requestAnimationFrame(step);
|
|
363
|
+
};
|
|
364
|
+
this._untangleRaf = requestAnimationFrame(step);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
private _cancelUntangle() {
|
|
368
|
+
if (this._untangleRaf !== null) {
|
|
369
|
+
cancelAnimationFrame(this._untangleRaf);
|
|
370
|
+
this._untangleRaf = null;
|
|
371
|
+
}
|
|
372
|
+
// Never leave nodes mid-interpolation (off-grid) — snap them to their targets.
|
|
373
|
+
this._untangleSettle?.();
|
|
374
|
+
this._untangleSettle = null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// --- State helpers ----------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
private _clone(s: FlowState): FlowState {
|
|
380
|
+
return JSON.parse(JSON.stringify(s)) as FlowState;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private _pushHistory() {
|
|
384
|
+
this._history.push(this._clone(this._state));
|
|
385
|
+
if (this._history.length > HISTORY_LIMIT) this._history.shift();
|
|
386
|
+
this._redo = [];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private _commit = () => {
|
|
390
|
+
this._pruneConnections();
|
|
391
|
+
// New array refs so the canvas (which receives nodes/connections/notes by
|
|
392
|
+
// reference) re-renders on structural changes. Live drags mutate the node
|
|
393
|
+
// objects in place and the canvas self-updates; this keeps them in sync.
|
|
394
|
+
this._state = {
|
|
395
|
+
nodes: [...this._state.nodes],
|
|
396
|
+
connections: [...this._state.connections],
|
|
397
|
+
notes: [...this._state.notes],
|
|
398
|
+
};
|
|
399
|
+
this.emit('zn-flow-change', {detail: {state: this.getState()}});
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Drop connections whose endpoints reference ports that no longer exist — e.g.
|
|
404
|
+
* after a user removes a node's output (branch) via its config.
|
|
405
|
+
*/
|
|
406
|
+
private _pruneConnections() {
|
|
407
|
+
const byId = new Map(this._state.nodes.map(n => [n.id, n] as const));
|
|
408
|
+
this._state.connections = this._state.connections.filter(c => {
|
|
409
|
+
const from = byId.get(c.source.node);
|
|
410
|
+
const to = byId.get(c.target.node);
|
|
411
|
+
if (!from || !to) return false;
|
|
412
|
+
const hasSource = nodeOutputs(from, this.registry.get(from.type)).some(p => p.id === c.source.port);
|
|
413
|
+
const hasTarget = nodeInputs(to, this.registry.get(to.type)).some(p => p.id === c.target.port);
|
|
414
|
+
return hasSource && hasTarget;
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private _syncSelection() {
|
|
419
|
+
if (this._selectedNodeId && !this._state.nodes.some(n => n.id === this._selectedNodeId)) {
|
|
420
|
+
this._selectedNodeId = null;
|
|
421
|
+
}
|
|
422
|
+
if (this._selectedBranch && !this._branchSelection()) {
|
|
423
|
+
this._selectedBranch = null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The node + output port of the branch open in the editor, if both still exist. */
|
|
428
|
+
private _branchSelection(): { node: FlowNodeInstance; port: FlowPort } | null {
|
|
429
|
+
if (!this._selectedBranch) return null;
|
|
430
|
+
const node = this._state.nodes.find(n => n.id === this._selectedBranch!.nodeId);
|
|
431
|
+
if (!node) return null;
|
|
432
|
+
const port = nodeOutputs(node, this.registry.get(node.type)).find(p => p.id === this._selectedBranch!.port);
|
|
433
|
+
return port ? {node, port} : null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private _id(prefix: string): string {
|
|
437
|
+
return `${prefix}-${Date.now().toString(36)}-${++this._seq}`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// --- Node operations --------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Snap to the grid and, if another node's footprint (card or branch pills)
|
|
444
|
+
* occupies the spot, walk outward in grid-step rings to the nearest free one.
|
|
445
|
+
*/
|
|
446
|
+
private _freePosition(x: number, y: number, excludeId?: string): { x: number; y: number } {
|
|
447
|
+
const collides = (px: number, py: number) =>
|
|
448
|
+
this._state.nodes.some(n =>
|
|
449
|
+
n.id !== excludeId
|
|
450
|
+
&& cardCollides({x: px, y: py}, n, t => this.registry.get(t), this._state.nodes, this._state.connections)
|
|
451
|
+
);
|
|
452
|
+
x = snapToGrid(x);
|
|
453
|
+
y = snapToGrid(y);
|
|
454
|
+
if (!collides(x, y)) return {x, y};
|
|
455
|
+
const step = GRID_SIZE * 2;
|
|
456
|
+
for (let ring = 1; ring < 40; ring++) {
|
|
457
|
+
for (let dy = -ring; dy <= ring; dy++) {
|
|
458
|
+
for (let dx = -ring; dx <= ring; dx++) {
|
|
459
|
+
if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring) continue;
|
|
460
|
+
if (!collides(x + dx * step, y + dy * step)) {
|
|
461
|
+
return {x: x + dx * step, y: y + dy * step};
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return {x, y};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private _addNode(typeKey: string, x: number, y: number) {
|
|
470
|
+
const type = this.registry.get(typeKey);
|
|
471
|
+
if (!type) return;
|
|
472
|
+
this._pushHistory();
|
|
473
|
+
const pos = this._freePosition(x, y);
|
|
474
|
+
const node: FlowNodeInstance = {
|
|
475
|
+
id: this._id('node'),
|
|
476
|
+
type: typeKey,
|
|
477
|
+
x: pos.x,
|
|
478
|
+
y: pos.y,
|
|
479
|
+
data: {...(type.defaultData ?? {})},
|
|
480
|
+
};
|
|
481
|
+
this._state.nodes.push(node);
|
|
482
|
+
this._select(node.id);
|
|
483
|
+
this._commit();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private _deleteNode(id: string) {
|
|
487
|
+
this._pushHistory();
|
|
488
|
+
this._state.nodes = this._state.nodes.filter(n => n.id !== id);
|
|
489
|
+
this._state.connections = this._state.connections.filter(
|
|
490
|
+
c => c.source.node !== id && c.target.node !== id
|
|
491
|
+
);
|
|
492
|
+
if (this._selectedNodeId === id) this._select(null);
|
|
493
|
+
this._commit();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private _duplicateNode(id: string) {
|
|
497
|
+
const node = this._state.nodes.find(n => n.id === id);
|
|
498
|
+
if (!node) return;
|
|
499
|
+
this._pushHistory();
|
|
500
|
+
const pos = this._freePosition(node.x + GRID_SIZE * 2, node.y + GRID_SIZE * 2);
|
|
501
|
+
const copy: FlowNodeInstance = {
|
|
502
|
+
...this._clone({nodes: [node], connections: [], notes: []}).nodes[0],
|
|
503
|
+
id: this._id('node'),
|
|
504
|
+
x: pos.x,
|
|
505
|
+
y: pos.y,
|
|
506
|
+
};
|
|
507
|
+
this._state.nodes.push(copy);
|
|
508
|
+
this._select(copy.id);
|
|
509
|
+
this._commit();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Canvas position for a node newly placed off a source node's output. */
|
|
513
|
+
private _positionBelowOutput(source: FlowNodeInstance, port: string): { x: number; y: number } {
|
|
514
|
+
const outputs = nodeOutputs(source, this.registry.get(source.type));
|
|
515
|
+
const idx = Math.max(outputs.findIndex(o => o.id === port), 0);
|
|
516
|
+
const anchor = portAnchor(source, 'out', idx, outputs.length);
|
|
517
|
+
// A full layer below the source (same rhythm as untangle) — clears the bus,
|
|
518
|
+
// the branch pill, and leaves wire room, so the child lands in a straight
|
|
519
|
+
// line under the branch instead of being shoved sideways by collision.
|
|
520
|
+
return {x: Math.round(anchor.x - NODE_WIDTH / 2), y: source.y + LAYOUT_V_GAP};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Resolve an output port id on a node: the "new branch" sentinel materialises a
|
|
525
|
+
* fresh, labelled output port (per-instance), so it exists before connecting.
|
|
526
|
+
*/
|
|
527
|
+
private _ensureOutput(node: FlowNodeInstance, port: string): string {
|
|
528
|
+
if (port !== NEW_OUTPUT_PORT) return port;
|
|
529
|
+
const outputs = nodeOutputs(node, this.registry.get(node.type)).map(p => ({...p}));
|
|
530
|
+
const id = this._id('branch');
|
|
531
|
+
outputs.push({id, label: 'Branch'});
|
|
532
|
+
node.outputs = outputs;
|
|
533
|
+
return id;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Every connected output is a configurable branch — default its name so the
|
|
538
|
+
* pill renders, however the connection was made (arrow, drop, or move).
|
|
539
|
+
*/
|
|
540
|
+
private _ensureBranchLabel(node: FlowNodeInstance, port: string) {
|
|
541
|
+
const outputs = nodeOutputs(node, this.registry.get(node.type)).map(p => ({...p}));
|
|
542
|
+
const outPort = outputs.find(p => p.id === port);
|
|
543
|
+
if (outPort && !outPort.label) {
|
|
544
|
+
outPort.label = 'Branch';
|
|
545
|
+
node.outputs = outputs;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Create a node and attach it to an open output slot of an existing node. */
|
|
550
|
+
private _addNodeAtOutput(sourceId: string, port: string, typeKey: string) {
|
|
551
|
+
const source = this._state.nodes.find(n => n.id === sourceId);
|
|
552
|
+
const type = this.registry.get(typeKey);
|
|
553
|
+
if (!source || !type) return;
|
|
554
|
+
const inPort = typeInputs(type)[0]?.id;
|
|
555
|
+
if (!inPort) return; // an entrypoint takes no inputs, so it can't be attached
|
|
556
|
+
this._pushHistory();
|
|
557
|
+
port = this._ensureOutput(source, port);
|
|
558
|
+
this._ensureBranchLabel(source, port);
|
|
559
|
+
const below = this._positionBelowOutput(source, port);
|
|
560
|
+
const pos = this._freePosition(below.x, below.y);
|
|
561
|
+
const node: FlowNodeInstance = {
|
|
562
|
+
id: this._id('node'),
|
|
563
|
+
type: typeKey,
|
|
564
|
+
x: pos.x,
|
|
565
|
+
y: pos.y,
|
|
566
|
+
data: {...(type.defaultData ?? {})},
|
|
567
|
+
};
|
|
568
|
+
const connection: FlowConnection = {
|
|
569
|
+
id: this._id('conn'),
|
|
570
|
+
source: {node: sourceId, port},
|
|
571
|
+
target: {node: node.id, port: inPort},
|
|
572
|
+
};
|
|
573
|
+
this._state.nodes.push(node);
|
|
574
|
+
this._state.connections.push(connection);
|
|
575
|
+
this.emit('zn-flow-connect', {detail: {connection}});
|
|
576
|
+
this._select(node.id);
|
|
577
|
+
this._commit();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Wire an open output to an existing node — targets its first input. Fan-in
|
|
582
|
+
* and loops (a branch pointing back to an earlier step) are both allowed;
|
|
583
|
+
* only wiring a node directly to itself is refused.
|
|
584
|
+
*/
|
|
585
|
+
private _linkNodeAtOutput(sourceId: string, port: string, targetId: string) {
|
|
586
|
+
const source = this._state.nodes.find(n => n.id === sourceId);
|
|
587
|
+
const target = this._state.nodes.find(n => n.id === targetId);
|
|
588
|
+
if (!source || !target || sourceId === targetId) return;
|
|
589
|
+
const inPort = firstInputId(target, this.registry.get(target.type));
|
|
590
|
+
if (inPort === null) return;
|
|
591
|
+
this._pushHistory();
|
|
592
|
+
port = this._ensureOutput(source, port);
|
|
593
|
+
this._ensureBranchLabel(source, port);
|
|
594
|
+
const connection: FlowConnection = {
|
|
595
|
+
id: this._id('conn'),
|
|
596
|
+
source: {node: sourceId, port},
|
|
597
|
+
target: {node: targetId, port: inPort},
|
|
598
|
+
};
|
|
599
|
+
this._state.connections.push(connection);
|
|
600
|
+
this.emit('zn-flow-connect', {detail: {connection}});
|
|
601
|
+
this._select(null);
|
|
602
|
+
this._selectedBranch = {nodeId: sourceId, port};
|
|
603
|
+
this._commit();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Insert a new node in the middle of an existing connection (split the wire). */
|
|
607
|
+
private _insertNodeOnWire(connectionId: string, typeKey: string) {
|
|
608
|
+
const conn = this._state.connections.find(c => c.id === connectionId);
|
|
609
|
+
const type = this.registry.get(typeKey);
|
|
610
|
+
if (!conn || !type) return;
|
|
611
|
+
const from = this._state.nodes.find(n => n.id === conn.source.node);
|
|
612
|
+
const to = this._state.nodes.find(n => n.id === conn.target.node);
|
|
613
|
+
if (!from || !to) return;
|
|
614
|
+
|
|
615
|
+
const inPort = typeInputs(type)[0]?.id;
|
|
616
|
+
if (!inPort) return; // an entrypoint can't be inserted mid-wire
|
|
617
|
+
this._pushHistory();
|
|
618
|
+
// The inserted node rejoins the original target via its own branch — make
|
|
619
|
+
// sure its first output exists and is named so the pill renders.
|
|
620
|
+
const outputs = typeOutputs(type).map(p => ({...p}));
|
|
621
|
+
if (!outputs.length) outputs.push({id: this._id('branch')});
|
|
622
|
+
if (!outputs[0].label) outputs[0].label = 'Branch';
|
|
623
|
+
// Slot in aligned under the source's branch, a full layer down. Push the
|
|
624
|
+
// original target (with its subtree) down first to make the room, so the
|
|
625
|
+
// insert reads as a clean vertical chain rather than a squeeze.
|
|
626
|
+
const below = this._positionBelowOutput(from, conn.source.port);
|
|
627
|
+
const needY = below.y + LAYOUT_V_GAP;
|
|
628
|
+
if (to.y < needY) {
|
|
629
|
+
const delta = needY - to.y;
|
|
630
|
+
const shifted = descendantIds(this._state, to.id);
|
|
631
|
+
shifted.add(to.id);
|
|
632
|
+
this._state.nodes.forEach(n => {
|
|
633
|
+
if (shifted.has(n.id)) n.y += delta;
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
const pos = this._freePosition(below.x, below.y);
|
|
637
|
+
const node: FlowNodeInstance = {
|
|
638
|
+
id: this._id('node'),
|
|
639
|
+
type: typeKey,
|
|
640
|
+
x: pos.x,
|
|
641
|
+
y: pos.y,
|
|
642
|
+
outputs,
|
|
643
|
+
data: {...(type.defaultData ?? {})},
|
|
644
|
+
};
|
|
645
|
+
// Re-wire: source -> new, new -> original target (using the new node's first output).
|
|
646
|
+
const originalTargetPort = conn.target.port;
|
|
647
|
+
conn.target = {node: node.id, port: inPort};
|
|
648
|
+
const downstream: FlowConnection = {
|
|
649
|
+
id: this._id('conn'),
|
|
650
|
+
source: {node: node.id, port: outputs[0].id},
|
|
651
|
+
target: {node: to.id, port: originalTargetPort},
|
|
652
|
+
};
|
|
653
|
+
this._state.nodes.push(node);
|
|
654
|
+
this._state.connections.push(downstream);
|
|
655
|
+
this.emit('zn-flow-connect', {detail: {connection: downstream}});
|
|
656
|
+
this._select(node.id);
|
|
657
|
+
this._commit();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private _select(id: string | null) {
|
|
661
|
+
this._selectedBranch = null;
|
|
662
|
+
if (this._selectedNodeId === id) return;
|
|
663
|
+
this._selectedNodeId = id;
|
|
664
|
+
this.emit('zn-flow-selection-change', {detail: {nodeId: id}});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private _onBranchPick = (e: CustomEvent<{ nodeId: string; port: string }>) => {
|
|
668
|
+
this._select(null);
|
|
669
|
+
this._selectedBranch = {nodeId: e.detail.nodeId, port: e.detail.port};
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
/** Remove an output branch and its wire. Undoable. */
|
|
673
|
+
private _onBranchDelete = (e: CustomEvent<{ nodeId: string; port: string }>) => {
|
|
674
|
+
const node = this._state.nodes.find(n => n.id === e.detail.nodeId);
|
|
675
|
+
if (!node) return;
|
|
676
|
+
this._pushHistory();
|
|
677
|
+
const outputs = nodeOutputs(node, this.registry.get(node.type)).filter(p => p.id !== e.detail.port);
|
|
678
|
+
// Deleting the last branch leaves a plain open output, so the node stays extensible.
|
|
679
|
+
node.outputs = outputs.length ? outputs : [{...DEFAULT_OUTPUT}];
|
|
680
|
+
// Sever the wire explicitly — the fallback output can share the deleted
|
|
681
|
+
// port's id, in which case pruning alone would keep the connection alive.
|
|
682
|
+
this._state.connections = this._state.connections.filter(
|
|
683
|
+
c => !(c.source.node === node.id && c.source.port === e.detail.port)
|
|
684
|
+
);
|
|
685
|
+
if (this._selectedBranch?.nodeId === e.detail.nodeId && this._selectedBranch.port === e.detail.port) {
|
|
686
|
+
this._selectedBranch = null;
|
|
687
|
+
}
|
|
688
|
+
this._commit();
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
// --- Event handlers ---------------------------------------------------------
|
|
692
|
+
|
|
693
|
+
private _onSelect = (e: CustomEvent<{ nodeId: string | null }>) => {
|
|
694
|
+
// A click on empty canvas cancels an in-progress move.
|
|
695
|
+
if (e.detail.nodeId === null && this._movingNodeId) {
|
|
696
|
+
this._movingNodeId = null;
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
this._select(e.detail.nodeId);
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
private _onNodeAction = (e: CustomEvent<{ nodeId: string; action: string }>) => {
|
|
703
|
+
const {nodeId, action} = e.detail;
|
|
704
|
+
if (action === 'delete') this._deleteNode(nodeId);
|
|
705
|
+
else if (action === 'duplicate') this._duplicateNode(nodeId);
|
|
706
|
+
else if (action === 'move') this._movingNodeId = nodeId;
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
private _onInteractionStart = () => {
|
|
710
|
+
this._pushHistory();
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
private _onOutputAssign = (e: CustomEvent<{ nodeId: string; port: string; type: string }>) => {
|
|
714
|
+
this._addNodeAtOutput(e.detail.nodeId, e.detail.port, e.detail.type);
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
/** A stray branch was attached to an existing node (fan-in). */
|
|
718
|
+
private _onLinkAssign = (e: CustomEvent<{ nodeId: string; port: string; targetId: string }>) => {
|
|
719
|
+
this._linkNodeAtOutput(e.detail.nodeId, e.detail.port, e.detail.targetId);
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
private _onWirePick = (e: CustomEvent<{ connectionId: string; clientX: number; clientY: number }>) => {
|
|
723
|
+
this._picker = {
|
|
724
|
+
x: e.detail.clientX,
|
|
725
|
+
y: e.detail.clientY,
|
|
726
|
+
target: {kind: 'wire', connectionId: e.detail.connectionId}
|
|
727
|
+
};
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
private _onWireAssign = (e: CustomEvent<{ connectionId: string; type: string }>) => {
|
|
731
|
+
this._insertNodeOnWire(e.detail.connectionId, e.detail.type);
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
/** Re-attach the node being moved to the chosen open output slot. */
|
|
735
|
+
private _onOutputMoveTarget = (e: CustomEvent<{ nodeId: string; port: string }>) => {
|
|
736
|
+
const movingId = this._movingNodeId;
|
|
737
|
+
this._movingNodeId = null;
|
|
738
|
+
if (!movingId) return;
|
|
739
|
+
const slotOwner = e.detail.nodeId;
|
|
740
|
+
if (slotOwner === movingId) return;
|
|
741
|
+
|
|
742
|
+
const owner = this._state.nodes.find(n => n.id === slotOwner);
|
|
743
|
+
const moving = this._state.nodes.find(n => n.id === movingId);
|
|
744
|
+
if (!owner || !moving) return;
|
|
745
|
+
const inPort = firstInputId(moving, this.registry.get(moving.type));
|
|
746
|
+
if (inPort === null) return;
|
|
747
|
+
|
|
748
|
+
this._pushHistory();
|
|
749
|
+
const outPort = this._ensureOutput(owner, e.detail.port);
|
|
750
|
+
this._ensureBranchLabel(owner, outPort);
|
|
751
|
+
// Detach the moving node's current incoming connections, then attach to the new slot.
|
|
752
|
+
this._state.connections = this._state.connections.filter(c => c.target.node !== movingId);
|
|
753
|
+
const connection: FlowConnection = {
|
|
754
|
+
id: this._id('conn'),
|
|
755
|
+
source: {node: slotOwner, port: outPort},
|
|
756
|
+
target: {node: movingId, port: inPort},
|
|
757
|
+
};
|
|
758
|
+
this._state.connections.push(connection);
|
|
759
|
+
// Reposition the moved node below its new slot so it visually lands there.
|
|
760
|
+
const below = this._positionBelowOutput(owner, outPort);
|
|
761
|
+
const pos = this._freePosition(below.x, below.y, movingId);
|
|
762
|
+
moving.x = pos.x;
|
|
763
|
+
moving.y = pos.y;
|
|
764
|
+
this.emit('zn-flow-connect', {detail: {connection}});
|
|
765
|
+
this._commit();
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
private _onAddNote = () => {
|
|
769
|
+
this._pushHistory();
|
|
770
|
+
this._state.notes.push({id: this._id('note'), x: 80, y: 80, text: ''});
|
|
771
|
+
this._commit();
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
private _onNoteChange = (e: CustomEvent<{ noteId: string; text: string }>) => {
|
|
775
|
+
const note = this._state.notes.find(n => n.id === e.detail.noteId);
|
|
776
|
+
if (!note) return;
|
|
777
|
+
note.text = e.detail.text;
|
|
778
|
+
this._commit();
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
private _onNoteDelete = (e: CustomEvent<{ noteId: string }>) => {
|
|
782
|
+
this._pushHistory();
|
|
783
|
+
this._state.notes = this._state.notes.filter(n => n.id !== e.detail.noteId);
|
|
784
|
+
this._commit();
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
// --- Step drag/drop ------------------------------------------------------
|
|
788
|
+
|
|
789
|
+
private _onDragStart(e: DragEvent, typeKey: string) {
|
|
790
|
+
if (!e.dataTransfer) return;
|
|
791
|
+
// dataTransfer payload isn't readable during `dragover`, so expose the type
|
|
792
|
+
// separately for the canvas to render a meaningful drop preview.
|
|
793
|
+
this._draggingType = typeKey;
|
|
794
|
+
e.dataTransfer.setData(TYPE_MIME, typeKey);
|
|
795
|
+
// 'copyMove' so the drop is accepted by both the canvas (move) and "+" targets (copy).
|
|
796
|
+
e.dataTransfer.effectAllowed = 'copyMove';
|
|
797
|
+
// Hide the native drag image — the canvas renders an in-canvas drop preview instead.
|
|
798
|
+
e.dataTransfer.setDragImage(emptyDragImage(), 0, 0);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private _onDragEnd = () => {
|
|
802
|
+
this._draggingType = null;
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
// Slotted <zn-flow-step>s report their type so the canvas can preview the drop.
|
|
806
|
+
private _onStepDrag = (e: CustomEvent<{ type: string }>) => {
|
|
807
|
+
this._draggingType = e.detail.type;
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
private _onCanvasDragOver = (e: DragEvent) => {
|
|
811
|
+
// Allow the drop; the canvas's own dragover handler decides the cursor
|
|
812
|
+
// (copy over a "+" slot, move elsewhere), so don't override dropEffect here.
|
|
813
|
+
e.preventDefault();
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
private _onCanvasDrop = (e: DragEvent) => {
|
|
817
|
+
const typeKey = e.dataTransfer?.getData(TYPE_MIME);
|
|
818
|
+
if (!typeKey) return;
|
|
819
|
+
e.preventDefault();
|
|
820
|
+
const canvas = this.shadowRoot?.querySelector('zn-flow-canvas') as ZnFlowCanvas | null;
|
|
821
|
+
const pt = canvas?.screenToCanvas(e.clientX, e.clientY) ?? {x: e.clientX, y: e.clientY};
|
|
822
|
+
this._addNode(typeKey, pt.x - NODE_WIDTH / 2, pt.y - NODE_HEIGHT / 2);
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
// --- Rendering --------------------------------------------------------------
|
|
826
|
+
|
|
827
|
+
private _renderSteps() {
|
|
828
|
+
return html`
|
|
829
|
+
<aside part="steps" class="steps">
|
|
830
|
+
<div class="title-block">
|
|
831
|
+
<div class="heading">${this.heading || 'Flow Builder'}</div>
|
|
832
|
+
${this.subheading ? html`
|
|
833
|
+
<div class="subheading">${this.subheading}</div>` : ''}
|
|
834
|
+
</div>
|
|
835
|
+
|
|
836
|
+
${this._renderStepsContent()}
|
|
837
|
+
|
|
838
|
+
<!-- Node types are declared by <zn-flow-step> children (hidden); the steps panel
|
|
839
|
+
above is rendered from them, tabbed by group (groups with no steps get no tab). -->
|
|
840
|
+
<slot class="declarations" @slotchange="${this._registerSlottedTypes}"></slot>
|
|
841
|
+
</aside>
|
|
842
|
+
`;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
private _hintFor(group: FlowGroup): string {
|
|
846
|
+
if (group === 'entrypoint') return this.entrypointsHint;
|
|
847
|
+
if (group === 'trigger') return this.triggersHint;
|
|
848
|
+
if (group === 'action') return this.actionsHint;
|
|
849
|
+
return this.rulesHint;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
private _renderStep(type: FlowNodeType) {
|
|
853
|
+
return html`
|
|
854
|
+
<div
|
|
855
|
+
class="step"
|
|
856
|
+
draggable="true"
|
|
857
|
+
@dragstart="${(e: DragEvent) => this._onDragStart(e, type.type)}"
|
|
858
|
+
@dragend="${this._onDragEnd}"
|
|
859
|
+
>
|
|
860
|
+
<span class="step__icon" style="--node-accent:${type.color ?? 'rgb(var(--zn-color-primary))'}">
|
|
861
|
+
<zn-icon src="${type.icon ?? 'circle'}" library="${ifDefined(type.iconLibrary)}" size="16"></zn-icon>
|
|
862
|
+
</span>
|
|
863
|
+
<span class="step__label">${type.label}</span>
|
|
864
|
+
</div>
|
|
865
|
+
`;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
private _renderStepsContent() {
|
|
869
|
+
// Only groups with registered types get a tab.
|
|
870
|
+
const tabs = TABS.filter(tab => this.registry.byGroup(tab.group).length > 0);
|
|
871
|
+
const activeGroup = tabs.find(t => t.group === this._activeGroup)?.group ?? tabs[0]?.group;
|
|
872
|
+
|
|
873
|
+
return html`
|
|
874
|
+
<div class="steps-content">
|
|
875
|
+
<zn-input
|
|
876
|
+
class="search"
|
|
877
|
+
placeholder="Search by step name"
|
|
878
|
+
clearable
|
|
879
|
+
.value="${this._search}"
|
|
880
|
+
@zn-input="${(e: Event) => (this._search = String((e.target as ZnInput).value ?? ''))}"
|
|
881
|
+
@input="${(e: Event) => (this._search = String((e.target as ZnInput).value ?? ''))}"
|
|
882
|
+
></zn-input>
|
|
883
|
+
|
|
884
|
+
${tabs.length === 0
|
|
885
|
+
? html`<p class="steps-empty">No steps registered.</p>`
|
|
886
|
+
: html`
|
|
887
|
+
<zn-tabs flush>
|
|
888
|
+
<zn-navbar slot="top">
|
|
889
|
+
${tabs.map((tab, i) => html`
|
|
890
|
+
<li tab="${i === 0 ? '' : tab.group}" @click="${() => (this._activeGroup = tab.group)}">
|
|
891
|
+
${tab.label}
|
|
892
|
+
</li>`)}
|
|
893
|
+
</zn-navbar>
|
|
894
|
+
${tabs.map((tab, i) => this._renderStepsPanel(tab.group, i === 0 ? '' : tab.group, tab.group === activeGroup))}
|
|
895
|
+
</zn-tabs>`}
|
|
896
|
+
</div>
|
|
897
|
+
`;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
private _renderStepsPanel(group: FlowGroup, panelId: string, active: boolean) {
|
|
901
|
+
const term = active ? this._search.trim().toLowerCase() : '';
|
|
902
|
+
const categories = this.registry.categories(group);
|
|
903
|
+
const hint = this._hintFor(group);
|
|
904
|
+
|
|
905
|
+
return html`
|
|
906
|
+
<div id="${panelId}" class="steps-panel">
|
|
907
|
+
${hint ? html`<p class="steps-hint">${hint}</p>` : ''}
|
|
908
|
+
<div class="steps-scroll">
|
|
909
|
+
${Array.from(categories.entries()).map(([name, types]) => {
|
|
910
|
+
const items = types.filter(t => !term || t.label.toLowerCase().includes(term));
|
|
911
|
+
if (!items.length) return '';
|
|
912
|
+
const rows = items.map(type => this._renderStep(type));
|
|
913
|
+
return name
|
|
914
|
+
? html`
|
|
915
|
+
<zn-flow-step-group caption="${name}">${rows}</zn-flow-step-group>`
|
|
916
|
+
: html`
|
|
917
|
+
<div class="steps-uncategorized">${rows}</div>`;
|
|
918
|
+
})}
|
|
919
|
+
</div>
|
|
920
|
+
</div>
|
|
921
|
+
`;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// The selected node's configuration, shown in the right panel.
|
|
925
|
+
private _renderInspector(node: FlowNodeInstance) {
|
|
926
|
+
const type = this.registry.get(node.type);
|
|
927
|
+
const renderConfig = type?.renderConfig;
|
|
928
|
+
const update = (data: Record<string, unknown>) => {
|
|
929
|
+
node.data = {...node.data, ...data};
|
|
930
|
+
this._commit();
|
|
931
|
+
};
|
|
932
|
+
// The guard keeps the consumer's config DOM in place across value-only
|
|
933
|
+
// re-renders (so live-typing inputs keep focus); it rebuilds when the node
|
|
934
|
+
// changes, the state is replaced, or a branch is added / removed.
|
|
935
|
+
const configKey = [node.id, this._configRevision, nodeOutputs(node, type).length];
|
|
936
|
+
|
|
937
|
+
return html`
|
|
938
|
+
<aside part="inspector" class="inspector">
|
|
939
|
+
<div class="inspector-head">
|
|
940
|
+
<span
|
|
941
|
+
class="inspector-head__icon"
|
|
942
|
+
style="--node-accent:${type?.color ?? 'rgb(var(--zn-color-primary))'}"
|
|
943
|
+
>
|
|
944
|
+
<zn-icon src="${type?.icon ?? 'circle'}" library="${ifDefined(type?.iconLibrary)}" size="18"></zn-icon>
|
|
945
|
+
</span>
|
|
946
|
+
<div class="inspector-head__text">
|
|
947
|
+
<div class="inspector-head__title">${node.label ?? type?.label ?? node.type}</div>
|
|
948
|
+
<div class="inspector-head__type">${type?.label ?? node.type}</div>
|
|
949
|
+
</div>
|
|
950
|
+
<button class="inspector-close" title="Close" @click="${() => this._select(null)}">
|
|
951
|
+
<zn-icon src="x@lu" size="18"></zn-icon>
|
|
952
|
+
</button>
|
|
953
|
+
</div>
|
|
954
|
+
|
|
955
|
+
<div class="inspector-body">
|
|
956
|
+
${renderConfig
|
|
957
|
+
? guard(configKey, () => renderConfig(node, update))
|
|
958
|
+
: html`
|
|
959
|
+
<zn-input
|
|
960
|
+
label="Label"
|
|
961
|
+
.value="${node.label ?? ''}"
|
|
962
|
+
@input="${(e: Event) => {
|
|
963
|
+
node.label = String((e.target as ZnInput).value ?? '');
|
|
964
|
+
this._commit();
|
|
965
|
+
}}"
|
|
966
|
+
></zn-input>
|
|
967
|
+
<p class="inspector-hint">This step type has no custom configuration.</p>
|
|
968
|
+
`}
|
|
969
|
+
</div>
|
|
970
|
+
</aside>
|
|
971
|
+
`;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** Replace one of the node's output ports (per-instance override), keeping its id. */
|
|
975
|
+
private _updateBranch(node: FlowNodeInstance, portId: string, patch: Partial<FlowPort>) {
|
|
976
|
+
const type = this.registry.get(node.type);
|
|
977
|
+
node.outputs = nodeOutputs(node, type).map(p => (p.id === portId ? {...p, ...patch, id: p.id} : p));
|
|
978
|
+
this._commit();
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// The branch editor: rename an output branch and configure its conditions.
|
|
982
|
+
private _renderBranchEditor(node: FlowNodeInstance, port: FlowPort) {
|
|
983
|
+
const type = this.registry.get(node.type);
|
|
984
|
+
const renderBranchConfig = type?.renderBranchConfig;
|
|
985
|
+
const update = (patch: Partial<FlowPort>) => this._updateBranch(node, port.id, patch);
|
|
986
|
+
// Stable while the branch name is live-typed; rebuilds (with the fresh port
|
|
987
|
+
// object) when the branch, its condition data, or the state changes.
|
|
988
|
+
const configKey = [node.id, port.id, this._configRevision, JSON.stringify(port.data ?? null)];
|
|
989
|
+
// A loop branch's editor matches its amber pill and wire, and says so.
|
|
990
|
+
const conn = this._state.connections.find(c => c.source.node === node.id && c.source.port === port.id);
|
|
991
|
+
const isLoop = !!conn && loopConnections(this._state.nodes, this._state.connections).has(conn);
|
|
992
|
+
const accent = isLoop ? 'rgb(var(--zn-color-warning))' : type?.color ?? 'rgb(var(--zn-color-primary))';
|
|
993
|
+
const loopTarget = isLoop ? this._state.nodes.find(n => n.id === conn!.target.node) : undefined;
|
|
994
|
+
const loopTargetLabel = loopTarget
|
|
995
|
+
? loopTarget.label ?? this.registry.get(loopTarget.type)?.label ?? loopTarget.type
|
|
996
|
+
: '';
|
|
997
|
+
|
|
998
|
+
return html`
|
|
999
|
+
<aside part="inspector" class="inspector">
|
|
1000
|
+
<div class="inspector-head">
|
|
1001
|
+
<span
|
|
1002
|
+
class="inspector-head__icon"
|
|
1003
|
+
style="--node-accent:${accent}"
|
|
1004
|
+
>
|
|
1005
|
+
<zn-icon src="git-branch@lu" size="18"></zn-icon>
|
|
1006
|
+
</span>
|
|
1007
|
+
<div class="inspector-head__text">
|
|
1008
|
+
<div class="inspector-head__title-row">
|
|
1009
|
+
<span class="inspector-head__title">${port.label ?? port.id}</span>
|
|
1010
|
+
${isLoop
|
|
1011
|
+
? html`<span class="inspector-loop-tag"><zn-icon src="repeat@lu" size="12"></zn-icon>Loop</span>`
|
|
1012
|
+
: ''}
|
|
1013
|
+
</div>
|
|
1014
|
+
<div class="inspector-head__type">
|
|
1015
|
+
${isLoop
|
|
1016
|
+
? `Branch of ${node.label ?? type?.label ?? node.type} — loops back to ${loopTargetLabel}`
|
|
1017
|
+
: `Branch of ${node.label ?? type?.label ?? node.type}`}
|
|
1018
|
+
</div>
|
|
1019
|
+
</div>
|
|
1020
|
+
<button class="inspector-close" title="Close" @click="${() => (this._selectedBranch = null)}">
|
|
1021
|
+
<zn-icon src="x@lu" size="18"></zn-icon>
|
|
1022
|
+
</button>
|
|
1023
|
+
</div>
|
|
1024
|
+
|
|
1025
|
+
<div class="inspector-body">
|
|
1026
|
+
<zn-input
|
|
1027
|
+
label="Branch name"
|
|
1028
|
+
.value="${port.label ?? ''}"
|
|
1029
|
+
@zn-input="${(e: Event) => update({label: String((e.target as ZnInput).value ?? '')})}"
|
|
1030
|
+
@input="${(e: Event) => update({label: String((e.target as ZnInput).value ?? '')})}"
|
|
1031
|
+
></zn-input>
|
|
1032
|
+
${renderBranchConfig
|
|
1033
|
+
? guard(configKey, () => renderBranchConfig(node, port, update))
|
|
1034
|
+
: html`<p class="inspector-hint">This step type has no branch conditions.</p>`}
|
|
1035
|
+
</div>
|
|
1036
|
+
</aside>
|
|
1037
|
+
`;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// The right panel: branch editor or node inspector when something is selected,
|
|
1041
|
+
// otherwise the sidebar (status / errors / version history).
|
|
1042
|
+
private _renderRightPanel() {
|
|
1043
|
+
const branch = this._branchSelection();
|
|
1044
|
+
if (branch) return this._renderBranchEditor(branch.node, branch.port);
|
|
1045
|
+
const selected = this._state.nodes.find(n => n.id === this._selectedNodeId);
|
|
1046
|
+
return selected ? this._renderInspector(selected) : this._renderSidebar();
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Full-width action bar above the panels; only shown when actions are slotted.
|
|
1050
|
+
private _renderHeader() {
|
|
1051
|
+
const hasHeader = this._hasSlot.test('header-left') || this._hasSlot.test('header-right');
|
|
1052
|
+
return html`
|
|
1053
|
+
<header part="header" class="header" ?hidden="${!hasHeader}">
|
|
1054
|
+
<div class="header__group">
|
|
1055
|
+
<slot name="header-left"></slot>
|
|
1056
|
+
</div>
|
|
1057
|
+
<div class="header__group">
|
|
1058
|
+
<slot name="header-right"></slot>
|
|
1059
|
+
</div>
|
|
1060
|
+
</header>
|
|
1061
|
+
`;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// The right panel: a slot for version history / status, defaulting to a
|
|
1065
|
+
// configuration-errors summary derived from `errorNodes`.
|
|
1066
|
+
private _renderSidebar() {
|
|
1067
|
+
const errors = this.errorNodes
|
|
1068
|
+
.map(id => this._state.nodes.find(n => n.id === id))
|
|
1069
|
+
.filter((n): n is FlowNodeInstance => !!n);
|
|
1070
|
+
|
|
1071
|
+
return html`
|
|
1072
|
+
<aside part="sidebar" class="sidebar">
|
|
1073
|
+
<div class="sidebar-section">
|
|
1074
|
+
<div class="sidebar-section__head">
|
|
1075
|
+
<span>Configuration Errors</span>
|
|
1076
|
+
<span class="sidebar-count">${errors.length}</span>
|
|
1077
|
+
</div>
|
|
1078
|
+
${errors.length === 0
|
|
1079
|
+
? html`<p class="sidebar-empty">No configuration errors.</p>`
|
|
1080
|
+
: errors.map(n => {
|
|
1081
|
+
const type = this.registry.get(n.type);
|
|
1082
|
+
return html`
|
|
1083
|
+
<button class="sidebar-error" @click="${() => this._select(n.id)}">
|
|
1084
|
+
<zn-icon src="triangle-alert@lu" size="16"></zn-icon>
|
|
1085
|
+
<span>${n.label ?? type?.label ?? n.type}</span>
|
|
1086
|
+
</button>
|
|
1087
|
+
`;
|
|
1088
|
+
})}
|
|
1089
|
+
</div>
|
|
1090
|
+
<slot name="sidebar"></slot>
|
|
1091
|
+
</aside>
|
|
1092
|
+
`;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
private _renderPicker() {
|
|
1096
|
+
if (!this._picker) return '';
|
|
1097
|
+
const {x, y} = this._picker;
|
|
1098
|
+
const groups = TABS.map(tab => ({tab, types: this.registry.byGroup(tab.group)})).filter(g => g.types.length);
|
|
1099
|
+
|
|
1100
|
+
return html`
|
|
1101
|
+
<div class="picker-backdrop" @pointerdown="${() => (this._picker = null)}"></div>
|
|
1102
|
+
<div class="picker" style="left:${x}px;top:${y}px">
|
|
1103
|
+
${groups.length === 0
|
|
1104
|
+
? html`<p class="picker-empty">No steps registered.</p>`
|
|
1105
|
+
: groups.map(
|
|
1106
|
+
g => html`
|
|
1107
|
+
<div class="picker-group">${g.tab.label}</div>
|
|
1108
|
+
${g.types.map(
|
|
1109
|
+
type => html`
|
|
1110
|
+
<button class="picker-item" @click="${() => this._pickType(type.type)}">
|
|
1111
|
+
<span
|
|
1112
|
+
class="picker-item__icon"
|
|
1113
|
+
style="--node-accent:${type.color ?? 'rgb(var(--zn-color-primary))'}"
|
|
1114
|
+
>
|
|
1115
|
+
<zn-icon src="${type.icon ?? 'circle'}" library="${ifDefined(type.iconLibrary)}"
|
|
1116
|
+
size="16"></zn-icon>
|
|
1117
|
+
</span>
|
|
1118
|
+
<span>${type.label}</span>
|
|
1119
|
+
</button>
|
|
1120
|
+
`
|
|
1121
|
+
)}
|
|
1122
|
+
`
|
|
1123
|
+
)}
|
|
1124
|
+
</div>
|
|
1125
|
+
`;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
private _pickType(typeKey: string) {
|
|
1129
|
+
const picker = this._picker;
|
|
1130
|
+
if (!picker) return;
|
|
1131
|
+
this._picker = null;
|
|
1132
|
+
this._insertNodeOnWire(picker.target.connectionId, typeKey);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
render() {
|
|
1136
|
+
return html`
|
|
1137
|
+
<div part="base" class="builder">
|
|
1138
|
+
${this._renderHeader()}
|
|
1139
|
+
${this._renderSteps()}
|
|
1140
|
+
<div
|
|
1141
|
+
class="canvas-cell"
|
|
1142
|
+
@dragover="${this._onCanvasDragOver}"
|
|
1143
|
+
@drop="${this._onCanvasDrop}"
|
|
1144
|
+
>
|
|
1145
|
+
<zn-flow-canvas
|
|
1146
|
+
.nodes="${this._state.nodes}"
|
|
1147
|
+
.connections="${this._state.connections}"
|
|
1148
|
+
.notes="${this._state.notes}"
|
|
1149
|
+
.registry="${this.registry}"
|
|
1150
|
+
.errorNodes="${new Set(this.errorNodes)}"
|
|
1151
|
+
selected-node="${this._selectedNodeId ?? ''}"
|
|
1152
|
+
moving-node="${this._movingNodeId ?? ''}"
|
|
1153
|
+
drag-type="${this._draggingType ?? ''}"
|
|
1154
|
+
selected-branch="${this._selectedBranch ? `${this._selectedBranch.nodeId}:${this._selectedBranch.port}` : ''}"
|
|
1155
|
+
></zn-flow-canvas>
|
|
1156
|
+
${this._movingNodeId
|
|
1157
|
+
? html`
|
|
1158
|
+
<div class="move-banner">
|
|
1159
|
+
<zn-icon src="move@lu" size="16"></zn-icon>
|
|
1160
|
+
<span>Pick a <strong>+</strong> slot to move this step, or press Esc.</span>
|
|
1161
|
+
<button @click="${() => (this._movingNodeId = null)}">Cancel</button>
|
|
1162
|
+
</div>
|
|
1163
|
+
`
|
|
1164
|
+
: ''}
|
|
1165
|
+
${this._renderPicker()}
|
|
1166
|
+
</div>
|
|
1167
|
+
${this._renderRightPanel()}
|
|
1168
|
+
</div>
|
|
1169
|
+
`;
|
|
1170
|
+
}
|
|
1171
|
+
}
|