@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,1086 @@
|
|
|
1
|
+
import {type CSSResultGroup, html, type PropertyValues, svg, unsafeCSS} from 'lit';
|
|
2
|
+
import {ifDefined} from 'lit/directives/if-defined.js';
|
|
3
|
+
import {property, state} from 'lit/decorators.js';
|
|
4
|
+
import {repeat} from 'lit/directives/repeat.js';
|
|
5
|
+
import ZincElement from '../../../../internal/zinc-element';
|
|
6
|
+
import ZnButton from '../../../button';
|
|
7
|
+
import ZnFlowNode from '../flow-node';
|
|
8
|
+
import ZnIcon from '../../../icon';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
branchDropXs,
|
|
12
|
+
BUS_OFFSET,
|
|
13
|
+
firstInputId,
|
|
14
|
+
FLOW_TYPE_MIME,
|
|
15
|
+
type FlowConnection,
|
|
16
|
+
type FlowNodeInstance,
|
|
17
|
+
type FlowNote,
|
|
18
|
+
GRID_SIZE,
|
|
19
|
+
loopConnections,
|
|
20
|
+
NEW_OUTPUT_PORT,
|
|
21
|
+
NODE_HEIGHT,
|
|
22
|
+
NODE_WIDTH,
|
|
23
|
+
nodeInputs,
|
|
24
|
+
nodeOutputs,
|
|
25
|
+
nodesCollide,
|
|
26
|
+
NOTE_HEIGHT,
|
|
27
|
+
NOTE_MIN_HEIGHT,
|
|
28
|
+
NOTE_MIN_WIDTH,
|
|
29
|
+
NOTE_WIDTH,
|
|
30
|
+
PILL_DROP,
|
|
31
|
+
pillSize,
|
|
32
|
+
portAnchor,
|
|
33
|
+
snapToGrid,
|
|
34
|
+
} from '../../flow.types';
|
|
35
|
+
import type {FlowRegistry} from '../../flow-registry';
|
|
36
|
+
|
|
37
|
+
import styles from './flow-canvas.scss';
|
|
38
|
+
|
|
39
|
+
const ZOOM_MIN = 0.25;
|
|
40
|
+
const ZOOM_MAX = 2;
|
|
41
|
+
const ZOOM_STEP = 0.15;
|
|
42
|
+
// Gap from the bus (or a branch's label pill) down to its "+" add-point.
|
|
43
|
+
const ADD_POINT_OFFSET = 40;
|
|
44
|
+
// Wire routing: a short stub leaving an exit, the vertical run-in above a target
|
|
45
|
+
// input, and how far a detour clears a node card when routing around it. All
|
|
46
|
+
// whole grid units so wire runs sit on grid lines.
|
|
47
|
+
const WIRE_STUB = 20;
|
|
48
|
+
const WIRE_APPROACH = 20;
|
|
49
|
+
const WIRE_DETOUR = 40;
|
|
50
|
+
// Vertical spacing between the horizontal runs of wires heading to different
|
|
51
|
+
// targets, so unrelated wires never share a line (which reads as a join).
|
|
52
|
+
const WIRE_LANE_STEP = 20;
|
|
53
|
+
const TYPE_MIME = FLOW_TYPE_MIME;
|
|
54
|
+
|
|
55
|
+
type DragState =
|
|
56
|
+
| { kind: 'pan'; ox: number; oy: number; px: number; py: number }
|
|
57
|
+
| { kind: 'node'; id: string; offX: number; offY: number }
|
|
58
|
+
| { kind: 'note'; id: string; offX: number; offY: number }
|
|
59
|
+
| { kind: 'note-resize'; id: string };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @summary The pannable, zoomable surface that renders flow nodes and the connections between them.
|
|
63
|
+
* @documentation https://zinc.style/components/flow-canvas
|
|
64
|
+
* @status experimental
|
|
65
|
+
* @since 1.0
|
|
66
|
+
*
|
|
67
|
+
* @dependency zn-button
|
|
68
|
+
* @dependency zn-icon
|
|
69
|
+
* @dependency zn-flow-node
|
|
70
|
+
*
|
|
71
|
+
* @event flow-interaction-start - A drag (node/note move or resize) has begun; the builder snapshots for undo.
|
|
72
|
+
* @event flow-change-commit - A drag or operation finished and the state should be persisted/emitted.
|
|
73
|
+
* @event flow-output-assign - A step was dropped on an open output's "+".
|
|
74
|
+
* @event flow-output-move-target - An open output's "+" was chosen as the destination while moving a node.
|
|
75
|
+
* @event flow-link-assign - A stray branch (started from an output's "+") was attached to an existing node.
|
|
76
|
+
* @event flow-wire-pick - An existing wire's "+" was clicked; the builder opens the step picker to insert.
|
|
77
|
+
* @event flow-wire-assign - A step was dropped on a wire's "+" to insert a step.
|
|
78
|
+
* @event flow-branch-pick - A branch pill was clicked; the builder opens the branch editor.
|
|
79
|
+
* @event flow-branch-delete - A branch pill's delete button was clicked; the builder removes the branch.
|
|
80
|
+
* @event flow-undo - The undo toolbar button was pressed.
|
|
81
|
+
* @event flow-redo - The redo toolbar button was pressed.
|
|
82
|
+
* @event flow-add-note - The add-note toolbar button was pressed.
|
|
83
|
+
* @event flow-untangle - The untangle toolbar button was pressed; the builder auto-arranges the nodes.
|
|
84
|
+
* @event flow-note-change - A note's text was edited.
|
|
85
|
+
* @event flow-note-delete - A note was removed.
|
|
86
|
+
*
|
|
87
|
+
* @csspart base - The canvas viewport.
|
|
88
|
+
* @csspart toolbar - The floating toolbar.
|
|
89
|
+
*/
|
|
90
|
+
export default class ZnFlowCanvas extends ZincElement {
|
|
91
|
+
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
92
|
+
|
|
93
|
+
static dependencies = {
|
|
94
|
+
'zn-button': ZnButton,
|
|
95
|
+
'zn-icon': ZnIcon,
|
|
96
|
+
'zn-flow-node': ZnFlowNode,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
@property({attribute: false}) nodes: FlowNodeInstance[] = [];
|
|
100
|
+
@property({attribute: false}) connections: FlowConnection[] = [];
|
|
101
|
+
@property({attribute: false}) notes: FlowNote[] = [];
|
|
102
|
+
@property({attribute: false}) registry: FlowRegistry;
|
|
103
|
+
@property({attribute: 'selected-node'}) selectedNodeId: string | null = null;
|
|
104
|
+
@property({attribute: false}) errorNodes: Set<string> = new Set();
|
|
105
|
+
/** When set, the canvas is in "move" mode: open "+" slots act as drop targets for this node. */
|
|
106
|
+
@property({attribute: 'moving-node'}) movingNodeId: string | null = null;
|
|
107
|
+
/** The node type being dragged from the steps panel, used to render the drop preview. */
|
|
108
|
+
@property({attribute: 'drag-type'}) dragType: string | null = null;
|
|
109
|
+
/** The branch being edited, as `nodeId:portId` — highlights its pill. */
|
|
110
|
+
@property({attribute: 'selected-branch'}) selectedBranch: string | null = null;
|
|
111
|
+
|
|
112
|
+
@state() private zoom = 1;
|
|
113
|
+
@state() private panX = 0;
|
|
114
|
+
@state() private panY = 0;
|
|
115
|
+
@state() private drag: DragState | null = null;
|
|
116
|
+
/** Canvas-space top-left where a step, if dropped now, would be placed. */
|
|
117
|
+
@state() private _dropGhost: { x: number; y: number } | null = null;
|
|
118
|
+
/** The stray branch being drawn from an output port until it attaches or cancels. */
|
|
119
|
+
@state() private _linking: { nodeId: string; port: string } | null = null;
|
|
120
|
+
@state() private _linkPos: { x: number; y: number } | null = null;
|
|
121
|
+
/** The valid node under the cursor while linking — the preview snaps to its input. */
|
|
122
|
+
@state() private _linkTarget: string | null = null;
|
|
123
|
+
|
|
124
|
+
private _dragMoved = false;
|
|
125
|
+
/** Centre the flow when it first arrives; any earlier user interaction opts out. */
|
|
126
|
+
private _viewInitialised = false;
|
|
127
|
+
|
|
128
|
+
connectedCallback() {
|
|
129
|
+
super.connectedCallback();
|
|
130
|
+
this.addEventListener('flow-node-grab', this._onNodeGrab as EventListener);
|
|
131
|
+
this.addEventListener('flow-node-select', this._onNodeSelect as EventListener);
|
|
132
|
+
this.addEventListener('flow-port-click', this._onPortClick as EventListener);
|
|
133
|
+
// Wheel must be non-passive to keep the page from scrolling underneath.
|
|
134
|
+
this.addEventListener('wheel', this._onWheel, {passive: false});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
disconnectedCallback() {
|
|
138
|
+
this.removeEventListener('flow-node-grab', this._onNodeGrab as EventListener);
|
|
139
|
+
this.removeEventListener('flow-node-select', this._onNodeSelect as EventListener);
|
|
140
|
+
this.removeEventListener('flow-port-click', this._onPortClick as EventListener);
|
|
141
|
+
this.removeEventListener('wheel', this._onWheel);
|
|
142
|
+
this._cancelLink();
|
|
143
|
+
this._teardownWindow();
|
|
144
|
+
super.disconnectedCallback();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Wheel navigation: scroll pans vertically, side-scroll (or Shift+scroll)
|
|
149
|
+
* pans horizontally, and Ctrl/Cmd+scroll — including trackpad pinch — zooms
|
|
150
|
+
* toward the cursor.
|
|
151
|
+
*/
|
|
152
|
+
private _onWheel = (e: WheelEvent) => {
|
|
153
|
+
// Let a note's textarea scroll its own content.
|
|
154
|
+
if (e.composedPath().some(el => el instanceof HTMLTextAreaElement)) return;
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
this._viewInitialised = true;
|
|
157
|
+
|
|
158
|
+
// Normalise line/page deltas (Firefox) to pixels.
|
|
159
|
+
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 100 : 1;
|
|
160
|
+
const dX = e.deltaX * unit;
|
|
161
|
+
const dY = e.deltaY * unit;
|
|
162
|
+
|
|
163
|
+
if (e.ctrlKey || e.metaKey) {
|
|
164
|
+
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, this.zoom * Math.exp(-dY * 0.0015)));
|
|
165
|
+
if (next === this.zoom) return;
|
|
166
|
+
// Keep the canvas point under the cursor stationary while zooming.
|
|
167
|
+
const rect = this.getBoundingClientRect();
|
|
168
|
+
const px = e.clientX - rect.left;
|
|
169
|
+
const py = e.clientY - rect.top;
|
|
170
|
+
const cx = (px - this.panX) / this.zoom;
|
|
171
|
+
const cy = (py - this.panY) / this.zoom;
|
|
172
|
+
this.zoom = +next.toFixed(3);
|
|
173
|
+
this.panX = Math.round(px - cx * this.zoom);
|
|
174
|
+
this.panY = Math.round(py - cy * this.zoom);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Shift converts a plain vertical scroll into a horizontal pan.
|
|
179
|
+
const horizontal = e.shiftKey && !dX ? dY : dX;
|
|
180
|
+
const vertical = e.shiftKey && !dX ? 0 : dY;
|
|
181
|
+
this.panX -= Math.round(horizontal);
|
|
182
|
+
this.panY -= Math.round(vertical);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
protected updated(changed: PropertyValues) {
|
|
186
|
+
super.updated(changed);
|
|
187
|
+
this.style.setProperty('--flow-zoom', String(this.zoom));
|
|
188
|
+
// The step drag ended (builder cleared drag-type) — drop the preview.
|
|
189
|
+
if (changed.has('dragType') && !this.dragType) this._dropGhost = null;
|
|
190
|
+
// First load with content: centre the flow (once the canvas has a size).
|
|
191
|
+
if (!this._viewInitialised && this.nodes.length) {
|
|
192
|
+
const rect = this.getBoundingClientRect();
|
|
193
|
+
if (rect.width && rect.height) {
|
|
194
|
+
this._viewInitialised = true;
|
|
195
|
+
this._resetView();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private _emit(name: string, detail?: Record<string, unknown>) {
|
|
201
|
+
this.dispatchEvent(new CustomEvent(name, {bubbles: true, composed: true, detail}));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Convert a client (screen) coordinate to canvas space, accounting for pan/zoom. */
|
|
205
|
+
screenToCanvas(clientX: number, clientY: number): { x: number; y: number } {
|
|
206
|
+
const rect = this.getBoundingClientRect();
|
|
207
|
+
return {
|
|
208
|
+
x: (clientX - rect.left - this.panX) / this.zoom,
|
|
209
|
+
y: (clientY - rect.top - this.panY) / this.zoom,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private _typeFor(node: FlowNodeInstance) {
|
|
214
|
+
return this.registry?.get(node.type);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- Drag lifecycle ---------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
private _setupWindow(cursor = '') {
|
|
220
|
+
window.addEventListener('pointermove', this._onPointerMove);
|
|
221
|
+
window.addEventListener('pointerup', this._onPointerUp);
|
|
222
|
+
// Force the cursor for the whole drag — pointer capture means the pointer can
|
|
223
|
+
// leave the element, and body-level override also crosses shadow boundaries.
|
|
224
|
+
if (cursor) document.body.style.cursor = cursor;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private _teardownWindow() {
|
|
228
|
+
window.removeEventListener('pointermove', this._onPointerMove);
|
|
229
|
+
window.removeEventListener('pointerup', this._onPointerUp);
|
|
230
|
+
document.body.style.cursor = '';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private _onBackgroundPointerDown = (e: PointerEvent) => {
|
|
234
|
+
// Middle-drag pans from anywhere — nodes included — without touching
|
|
235
|
+
// selection or an in-flight branch.
|
|
236
|
+
if (e.button === 1) {
|
|
237
|
+
e.preventDefault(); // suppress the browser's middle-click autoscroll
|
|
238
|
+
this._viewInitialised = true;
|
|
239
|
+
this.drag = {kind: 'pan', ox: e.clientX, oy: e.clientY, px: this.panX, py: this.panY};
|
|
240
|
+
this._setupWindow('grabbing');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (e.button !== 0) return;
|
|
244
|
+
this._viewInitialised = true;
|
|
245
|
+
if (this._linking) {
|
|
246
|
+
// Clicking while snapped onto a target attaches; anywhere else cancels.
|
|
247
|
+
const link = this._linking;
|
|
248
|
+
const target = this._linkTarget;
|
|
249
|
+
this._cancelLink();
|
|
250
|
+
if (target) {
|
|
251
|
+
this._emit('flow-link-assign', {nodeId: link.nodeId, port: link.port, targetId: target});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
this._emit('flow-node-select', {nodeId: null});
|
|
256
|
+
this.drag = {kind: 'pan', ox: e.clientX, oy: e.clientY, px: this.panX, py: this.panY};
|
|
257
|
+
this._setupWindow('grabbing');
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
private _onNodeGrab = (e: CustomEvent<{ nodeId: string; clientX: number; clientY: number }>) => {
|
|
261
|
+
// While a branch is in progress, clicking a node attaches it instead of dragging.
|
|
262
|
+
if (this._linking) {
|
|
263
|
+
const link = this._linking;
|
|
264
|
+
this._cancelLink();
|
|
265
|
+
if (link.nodeId !== e.detail.nodeId) {
|
|
266
|
+
this._emit('flow-link-assign', {nodeId: link.nodeId, port: link.port, targetId: e.detail.nodeId});
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const node = this.nodes.find(n => n.id === e.detail.nodeId);
|
|
271
|
+
if (!node) return;
|
|
272
|
+
const p = this.screenToCanvas(e.detail.clientX, e.detail.clientY);
|
|
273
|
+
this.drag = {kind: 'node', id: node.id, offX: p.x - node.x, offY: p.y - node.y};
|
|
274
|
+
this._dragMoved = false;
|
|
275
|
+
this._setupWindow('grabbing');
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/** While linking, a node click attaches the branch — swallow the selection. */
|
|
279
|
+
private _onNodeSelect = (e: CustomEvent<{ nodeId: string | null }>) => {
|
|
280
|
+
if (this._linking && e.detail.nodeId) e.stopPropagation();
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// --- Stray-branch linking -----------------------------------------------------
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* A node's output stem port was clicked. If a branch is already in flight from
|
|
287
|
+
* another node, attach it here; otherwise start one — from the node's first
|
|
288
|
+
* open output if it has one, else as a brand-new branch (materialised by the
|
|
289
|
+
* builder on attach).
|
|
290
|
+
*/
|
|
291
|
+
private _onPortClick = (e: CustomEvent<{ nodeId: string }>) => {
|
|
292
|
+
const {nodeId} = e.detail;
|
|
293
|
+
if (this._linking && this._linking.nodeId !== nodeId) {
|
|
294
|
+
const link = this._linking;
|
|
295
|
+
this._cancelLink();
|
|
296
|
+
this._emit('flow-link-assign', {nodeId: link.nodeId, port: link.port, targetId: nodeId});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const node = this.nodes.find(n => n.id === nodeId);
|
|
300
|
+
if (!node) return;
|
|
301
|
+
const open = nodeOutputs(node, this._typeFor(node)).find(
|
|
302
|
+
p => !this.connections.some(c => c.source.node === nodeId && c.source.port === p.id)
|
|
303
|
+
);
|
|
304
|
+
this._startLink(nodeId, open?.id ?? NEW_OUTPUT_PORT);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
private _startLink(nodeId: string, port: string) {
|
|
308
|
+
this._linking = {nodeId, port};
|
|
309
|
+
this._linkPos = null;
|
|
310
|
+
window.addEventListener('pointermove', this._onLinkPointerMove);
|
|
311
|
+
window.addEventListener('keydown', this._onLinkKeyDown);
|
|
312
|
+
window.addEventListener('blur', this._cancelLink);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private _onLinkPointerMove = (e: PointerEvent) => {
|
|
316
|
+
const p = this.screenToCanvas(e.clientX, e.clientY);
|
|
317
|
+
this._linkPos = p;
|
|
318
|
+
const linking = this._linking;
|
|
319
|
+
if (!linking) return;
|
|
320
|
+
// Snap onto a valid target under the cursor: any node that takes inputs
|
|
321
|
+
// (loops back to earlier steps are allowed; only the source itself isn't).
|
|
322
|
+
const M = 12;
|
|
323
|
+
const target = this.nodes.find(n =>
|
|
324
|
+
n.id !== linking.nodeId
|
|
325
|
+
&& p.x >= n.x - M && p.x <= n.x + NODE_WIDTH + M
|
|
326
|
+
&& p.y >= n.y - M && p.y <= n.y + NODE_HEIGHT + M
|
|
327
|
+
&& nodeInputs(n, this._typeFor(n)).length > 0
|
|
328
|
+
);
|
|
329
|
+
this._linkTarget = target?.id ?? null;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
private _onLinkKeyDown = (e: KeyboardEvent) => {
|
|
333
|
+
if (e.key === 'Escape') this._cancelLink();
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
private _cancelLink = () => {
|
|
337
|
+
if (!this._linking) return;
|
|
338
|
+
this._linking = null;
|
|
339
|
+
this._linkPos = null;
|
|
340
|
+
this._linkTarget = null;
|
|
341
|
+
window.removeEventListener('pointermove', this._onLinkPointerMove);
|
|
342
|
+
window.removeEventListener('keydown', this._onLinkKeyDown);
|
|
343
|
+
window.removeEventListener('blur', this._cancelLink);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
private _onPointerMove = (e: PointerEvent) => {
|
|
347
|
+
if (!this.drag) return;
|
|
348
|
+
if (this.drag.kind === 'pan') {
|
|
349
|
+
this.panX = this.drag.px + (e.clientX - this.drag.ox);
|
|
350
|
+
this.panY = this.drag.py + (e.clientY - this.drag.oy);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const p = this.screenToCanvas(e.clientX, e.clientY);
|
|
354
|
+
if (this.drag.kind === 'node') {
|
|
355
|
+
const node = this.nodes.find(n => n.id === (this.drag as { id: string }).id);
|
|
356
|
+
if (node) {
|
|
357
|
+
// Snap to the grid, and refuse positions where any part of the node's
|
|
358
|
+
// footprint (card or branch pills) would overlap another's — falling
|
|
359
|
+
// back to one axis at a time so the node slides along edges.
|
|
360
|
+
const desired = {x: snapToGrid(p.x - this.drag.offX), y: snapToGrid(p.y - this.drag.offY)};
|
|
361
|
+
const others = this.nodes.filter(o => o.id !== node.id);
|
|
362
|
+
const fits = (pos: { x: number; y: number }) => {
|
|
363
|
+
const moved = {...node, ...pos};
|
|
364
|
+
return !others.some(o =>
|
|
365
|
+
nodesCollide(moved, o, t => this.registry?.get(t), this.nodes, this.connections));
|
|
366
|
+
};
|
|
367
|
+
const next = fits(desired) ? desired
|
|
368
|
+
: fits({x: desired.x, y: node.y}) ? {x: desired.x, y: node.y}
|
|
369
|
+
: fits({x: node.x, y: desired.y}) ? {x: node.x, y: desired.y}
|
|
370
|
+
: null;
|
|
371
|
+
if (next && (next.x !== node.x || next.y !== node.y)) {
|
|
372
|
+
if (!this._dragMoved) this._beginMove();
|
|
373
|
+
node.x = next.x;
|
|
374
|
+
node.y = next.y;
|
|
375
|
+
this.requestUpdate();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} else if (this.drag.kind === 'note') {
|
|
379
|
+
const note = this.notes.find(n => n.id === (this.drag as { id: string }).id);
|
|
380
|
+
if (note) {
|
|
381
|
+
if (!this._dragMoved) this._beginMove();
|
|
382
|
+
note.x = snapToGrid(p.x - this.drag.offX);
|
|
383
|
+
note.y = snapToGrid(p.y - this.drag.offY);
|
|
384
|
+
this.requestUpdate();
|
|
385
|
+
}
|
|
386
|
+
} else if (this.drag.kind === 'note-resize') {
|
|
387
|
+
const note = this.notes.find(n => n.id === (this.drag as { id: string }).id);
|
|
388
|
+
if (note) {
|
|
389
|
+
if (!this._dragMoved) this._beginMove();
|
|
390
|
+
note.width = Math.max(NOTE_MIN_WIDTH, snapToGrid(p.x - note.x));
|
|
391
|
+
note.height = Math.max(NOTE_MIN_HEIGHT, snapToGrid(p.y - note.y));
|
|
392
|
+
this.requestUpdate();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
private _onPointerUp = () => {
|
|
398
|
+
const drag = this.drag;
|
|
399
|
+
this.drag = null;
|
|
400
|
+
this._teardownWindow();
|
|
401
|
+
if (!drag) return;
|
|
402
|
+
|
|
403
|
+
if ((drag.kind === 'node' || drag.kind === 'note' || drag.kind === 'note-resize') && this._dragMoved) {
|
|
404
|
+
this._emit('flow-change-commit');
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
private _beginMove() {
|
|
409
|
+
this._dragMoved = true;
|
|
410
|
+
this._emit('flow-interaction-start');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// --- Output fork geometry ---------------------------------------------------
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* A node's outputs all leave from a single bottom-centre stem and fan out along a
|
|
417
|
+
* shared horizontal bus — one branch per output. Each branch drops on the source's
|
|
418
|
+
* own side (so fan-in branches from different nodes never stack their pills), then
|
|
419
|
+
* routes to its connected child or ends in a "+" add-point (open).
|
|
420
|
+
*/
|
|
421
|
+
private _outputLayout(node: FlowNodeInstance) {
|
|
422
|
+
const outputs = nodeOutputs(node, this._typeFor(node));
|
|
423
|
+
const cx = node.x + NODE_WIDTH / 2;
|
|
424
|
+
const by = node.y + NODE_HEIGHT;
|
|
425
|
+
const busY = by + BUS_OFFSET;
|
|
426
|
+
// Drop positions prefer a straight line above each branch's child.
|
|
427
|
+
const dropXs = branchDropXs(node, t => this.registry?.get(t), this.nodes, this.connections);
|
|
428
|
+
const branches = outputs.map((port, i) => {
|
|
429
|
+
const conn = this.connections.find(c => c.source.node === node.id && c.source.port === port.id);
|
|
430
|
+
const child = conn ? this.nodes.find(n => n.id === conn.target.node) : undefined;
|
|
431
|
+
const x = dropXs[i];
|
|
432
|
+
// Labelled outputs show their branch pill on the drop; the wire continues
|
|
433
|
+
// from below it. Long names wrap, so the pill height (and the exit where
|
|
434
|
+
// the +/child wire begins) grows with the text.
|
|
435
|
+
const pillTop = port.label ? busY + PILL_DROP : null;
|
|
436
|
+
const pillH = port.label ? pillSize(port.label).h : 0;
|
|
437
|
+
const exitY = pillTop === null ? busY : pillTop + pillH;
|
|
438
|
+
return {port, conn, child, x, pillTop, pillH, exitY};
|
|
439
|
+
});
|
|
440
|
+
return {cx, by, busY, branches};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Canvas-space anchor of a node's input port for an incoming connection. */
|
|
444
|
+
private _inputAnchor(child: FlowNodeInstance, targetPort: string) {
|
|
445
|
+
const inputs = nodeInputs(child, this._typeFor(child));
|
|
446
|
+
const idx = Math.max(inputs.findIndex(p => p.id === targetPort), 0);
|
|
447
|
+
return {x: portAnchor(child, 'in', idx, inputs.length).x, y: child.y};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Per-connection nudges for elbow horizontals. An elbow runs at the exact
|
|
452
|
+
* midpoint of its gap (equal drop and approach) unless wires to *different*
|
|
453
|
+
* targets would share the same line — those read as a merge, so each
|
|
454
|
+
* conflicting target gets its own grid-step offset. Wires fanning in to the
|
|
455
|
+
* same input keep sharing a line: their join is real.
|
|
456
|
+
*/
|
|
457
|
+
private _elbowMidOffsets(): Map<string, number> {
|
|
458
|
+
const groups = new Map<number, Map<string, string[]>>(); // baseMid -> target -> conn ids
|
|
459
|
+
for (const node of this.nodes) {
|
|
460
|
+
const {branches} = this._outputLayout(node);
|
|
461
|
+
for (const b of branches) {
|
|
462
|
+
if (!b.conn || !b.child) continue;
|
|
463
|
+
const {x: tx, y: ty} = this._inputAnchor(b.child, b.conn.target.port);
|
|
464
|
+
if (tx === b.x || ty - WIRE_APPROACH < b.exitY) continue; // straight / detour
|
|
465
|
+
const baseMid = snapToGrid((b.exitY + ty) / 2);
|
|
466
|
+
const target = `${b.conn.target.node}:${b.conn.target.port}`;
|
|
467
|
+
const group = groups.get(baseMid) ?? new Map<string, string[]>();
|
|
468
|
+
groups.set(baseMid, group);
|
|
469
|
+
group.set(target, [...(group.get(target) ?? []), b.conn.id]);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const offsets = new Map<string, number>();
|
|
473
|
+
for (const group of groups.values()) {
|
|
474
|
+
if (group.size < 2) continue; // no conflict — perfectly symmetric elbows
|
|
475
|
+
const targets = [...group.keys()].sort();
|
|
476
|
+
targets.forEach((target, i) => {
|
|
477
|
+
const offset = (i - Math.floor((targets.length - 1) / 2)) * WIRE_LANE_STEP;
|
|
478
|
+
group.get(target)!.forEach(id => offsets.set(id, offset));
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
return offsets;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Whether an orthogonal segment passes through any node card (with margin). */
|
|
485
|
+
private _segmentBlocked(a: { x: number; y: number }, b: { x: number; y: number }, ignore: Set<string>): boolean {
|
|
486
|
+
const M = 8;
|
|
487
|
+
const minX = Math.min(a.x, b.x);
|
|
488
|
+
const maxX = Math.max(a.x, b.x);
|
|
489
|
+
const minY = Math.min(a.y, b.y);
|
|
490
|
+
const maxY = Math.max(a.y, b.y);
|
|
491
|
+
return this.nodes.some(n =>
|
|
492
|
+
!ignore.has(n.id)
|
|
493
|
+
&& minX < n.x + NODE_WIDTH + M && maxX > n.x - M
|
|
494
|
+
&& minY < n.y + NODE_HEIGHT + M && maxY > n.y - M
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private _routeClear(points: { x: number; y: number }[], ignore: Set<string>): boolean {
|
|
499
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
500
|
+
if (this._segmentBlocked(points[i], points[i + 1], ignore)) return false;
|
|
501
|
+
}
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Orthogonal waypoints from a branch exit to a child's input. The wire always
|
|
507
|
+
* enters the input from above (arrow pointing down), and never passes through
|
|
508
|
+
* a node card: each candidate route is checked against every card, scanning
|
|
509
|
+
* alternative lanes / side-steps / detours until one is clear.
|
|
510
|
+
*/
|
|
511
|
+
private _routePoints(
|
|
512
|
+
from: { x: number; y: number },
|
|
513
|
+
child: FlowNodeInstance,
|
|
514
|
+
targetPort: string,
|
|
515
|
+
midOffset = 0,
|
|
516
|
+
sourceId?: string
|
|
517
|
+
) {
|
|
518
|
+
const {x: tx, y: ty} = this._inputAnchor(child, targetPort);
|
|
519
|
+
const ignore = new Set(sourceId ? [child.id, sourceId] : [child.id]);
|
|
520
|
+
|
|
521
|
+
if (ty - WIRE_APPROACH >= from.y) {
|
|
522
|
+
// The exact midpoint, not grid-snapped: both endpoints sit on the grid,
|
|
523
|
+
// so the midpoint is always a half-tile multiple — snapping it to full
|
|
524
|
+
// tiles made the two verticals grow alternately as a node was dragged.
|
|
525
|
+
const base = Math.round((from.y + ty) / 2) + midOffset;
|
|
526
|
+
// The horizontal run stays within the middle half of the gap, so the
|
|
527
|
+
// drop and the final approach grow in proportion to the distance —
|
|
528
|
+
// lane offsets can't shove the run right up against either end.
|
|
529
|
+
const span = ty - from.y;
|
|
530
|
+
const lo = Math.min(ty - 12, Math.ceil((from.y + span / 4) / GRID_SIZE) * GRID_SIZE);
|
|
531
|
+
const hi = Math.max(lo, Math.floor((ty - span / 4) / GRID_SIZE) * GRID_SIZE);
|
|
532
|
+
const clampY = (v: number) => Math.max(lo, Math.min(hi, v));
|
|
533
|
+
let fallback: { x: number; y: number }[] | null = null;
|
|
534
|
+
|
|
535
|
+
if (tx === from.x) {
|
|
536
|
+
const straight = [from, {x: tx, y: ty}];
|
|
537
|
+
if (this._routeClear(straight, ignore)) return straight;
|
|
538
|
+
fallback = straight;
|
|
539
|
+
} else {
|
|
540
|
+
// Elbow: try the lane midY first, then scan outward for a clear line.
|
|
541
|
+
for (let step = 0; step <= 20; step++) {
|
|
542
|
+
for (const cand of step === 0 ? [base] : [base + step * GRID_SIZE, base - step * GRID_SIZE]) {
|
|
543
|
+
const my = clampY(cand);
|
|
544
|
+
if (my !== cand && step > 0) continue; // out of range
|
|
545
|
+
const route = [from, {x: from.x, y: my}, {x: tx, y: my}, {x: tx, y: ty}];
|
|
546
|
+
fallback ??= route;
|
|
547
|
+
if (this._routeClear(route, ignore)) return route;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// No simple route is clear (e.g. a card sits right under the exit) —
|
|
553
|
+
// side-step: drop to m1, jog sideways to sx, descend, and approach the
|
|
554
|
+
// input from above. Scan both the drop height and the jog distance.
|
|
555
|
+
const m2 = ty - WIRE_APPROACH;
|
|
556
|
+
for (let mStep = 0; mStep <= 12; mStep++) {
|
|
557
|
+
for (const mCand of mStep === 0 ? [base] : [base + mStep * GRID_SIZE, base - mStep * GRID_SIZE]) {
|
|
558
|
+
const m1 = clampY(mCand);
|
|
559
|
+
if (m1 !== mCand && mStep > 0) continue;
|
|
560
|
+
for (let sStep = 1; sStep <= 14; sStep++) {
|
|
561
|
+
for (const dir of [1, -1]) {
|
|
562
|
+
const sx = from.x + dir * sStep * GRID_SIZE;
|
|
563
|
+
const route = [
|
|
564
|
+
from,
|
|
565
|
+
{x: from.x, y: m1},
|
|
566
|
+
{x: sx, y: m1},
|
|
567
|
+
{x: sx, y: m2},
|
|
568
|
+
{x: tx, y: m2},
|
|
569
|
+
{x: tx, y: ty},
|
|
570
|
+
];
|
|
571
|
+
if (this._routeClear(route, ignore)) return route;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return fallback!;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Child is beside/above: stub down, clear the card on its nearer side, rise
|
|
580
|
+
// above the input, then approach it from the top — widening the detour until
|
|
581
|
+
// nothing is crossed.
|
|
582
|
+
const stubY = from.y + WIRE_STUB;
|
|
583
|
+
const overY = ty - WIRE_APPROACH;
|
|
584
|
+
const rightSide = from.x >= child.x + NODE_WIDTH / 2;
|
|
585
|
+
const baseDetour = rightSide ? child.x + NODE_WIDTH + WIRE_DETOUR : child.x - WIRE_DETOUR;
|
|
586
|
+
let fallback: { x: number; y: number }[] | null = null;
|
|
587
|
+
for (let step = 0; step <= 20; step++) {
|
|
588
|
+
const dx = baseDetour + (rightSide ? 1 : -1) * step * GRID_SIZE;
|
|
589
|
+
const route = [from, {x: from.x, y: stubY}, {x: dx, y: stubY}, {x: dx, y: overY}, {x: tx, y: overY}, {x: tx, y: ty}];
|
|
590
|
+
fallback ??= route;
|
|
591
|
+
if (this._routeClear(route, ignore)) return route;
|
|
592
|
+
}
|
|
593
|
+
return fallback!;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private static _pathFrom(points: { x: number; y: number }[]): string {
|
|
597
|
+
return points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Open output slots ("+" add-points) across all nodes — only rendered while
|
|
602
|
+
* they're meaningful targets (dragging a step in, or moving a node). Idle
|
|
603
|
+
* canvases show no stray stubs; branches start from the node's output port.
|
|
604
|
+
*/
|
|
605
|
+
private _addPoints() {
|
|
606
|
+
if (!this.movingNodeId && !this.dragType) return [];
|
|
607
|
+
const points: { nodeId: string; port: string; px: number; py: number }[] = [];
|
|
608
|
+
for (const node of this.nodes) {
|
|
609
|
+
const {branches} = this._outputLayout(node);
|
|
610
|
+
branches.forEach(b => {
|
|
611
|
+
if (b.conn) return;
|
|
612
|
+
points.push({nodeId: node.id, port: b.port.id, px: b.x, py: b.exitY + ADD_POINT_OFFSET});
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
return points;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Midpoint "+" insert-points on each connected branch (hidden while moving). */
|
|
619
|
+
private _wirePoints() {
|
|
620
|
+
if (this.movingNodeId) return [];
|
|
621
|
+
const midOffsets = this._elbowMidOffsets();
|
|
622
|
+
const points: { connectionId: string; px: number; py: number }[] = [];
|
|
623
|
+
for (const node of this.nodes) {
|
|
624
|
+
const {branches} = this._outputLayout(node);
|
|
625
|
+
branches.forEach(b => {
|
|
626
|
+
if (!b.conn || !b.child) return;
|
|
627
|
+
// Sit the "+" on the route's longest segment so it stays on the wire
|
|
628
|
+
// whatever shape the routing took.
|
|
629
|
+
const offset = midOffsets.get(b.conn.id) ?? 0;
|
|
630
|
+
const pts = this._routePoints({x: b.x, y: b.exitY}, b.child, b.conn.target.port, offset, node.id);
|
|
631
|
+
let seg = 0;
|
|
632
|
+
let best = -1;
|
|
633
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
634
|
+
const len = Math.abs(pts[i + 1].x - pts[i].x) + Math.abs(pts[i + 1].y - pts[i].y);
|
|
635
|
+
if (len > best) {
|
|
636
|
+
best = len;
|
|
637
|
+
seg = i;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
points.push({
|
|
641
|
+
connectionId: b.conn.id,
|
|
642
|
+
px: (pts[seg].x + pts[seg + 1].x) / 2,
|
|
643
|
+
py: (pts[seg].y + pts[seg + 1].y) / 2,
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
return points;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Convert a canvas-space point to screen coordinates (for anchoring popovers). */
|
|
651
|
+
private _screenFromCanvas(cx: number, cy: number) {
|
|
652
|
+
const rect = this.getBoundingClientRect();
|
|
653
|
+
return {clientX: rect.left + this.panX + cx * this.zoom, clientY: rect.top + this.panY + cy * this.zoom};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private _onAddClick(e: Event, nodeId: string, port: string) {
|
|
657
|
+
e.stopPropagation();
|
|
658
|
+
if (this.movingNodeId) {
|
|
659
|
+
this._emit('flow-output-move-target', {nodeId, port});
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
// Start a stray branch from this output; it follows the cursor until it
|
|
663
|
+
// attaches to a node or is cancelled (empty canvas click / Escape / blur).
|
|
664
|
+
this._startLink(nodeId, port);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private _onWireAddClick(e: Event, connectionId: string, px: number, py: number) {
|
|
668
|
+
e.stopPropagation();
|
|
669
|
+
this._emit('flow-wire-pick', {connectionId, ...this._screenFromCanvas(px, py)});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// --- Step drop preview ---------------------------------------------------
|
|
673
|
+
|
|
674
|
+
private _onViewportDragOver = (e: DragEvent) => {
|
|
675
|
+
if (!e.dataTransfer?.types.includes(TYPE_MIME)) return;
|
|
676
|
+
this._viewInitialised = true;
|
|
677
|
+
e.preventDefault();
|
|
678
|
+
// The preview follows the cursor everywhere; the cursor only shows the
|
|
679
|
+
// add/"+" (copy) affordance when actually over a "+" slot, otherwise move.
|
|
680
|
+
const overAddPoint = e.composedPath().some(el => el instanceof Element && el.classList.contains('add-point'));
|
|
681
|
+
e.dataTransfer.dropEffect = overAddPoint ? 'copy' : 'move';
|
|
682
|
+
const p = this.screenToCanvas(e.clientX, e.clientY);
|
|
683
|
+
this._dropGhost = {x: snapToGrid(p.x - NODE_WIDTH / 2), y: snapToGrid(p.y - NODE_HEIGHT / 2)};
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
private _onViewportDragLeave = (e: DragEvent) => {
|
|
687
|
+
const r = this.getBoundingClientRect();
|
|
688
|
+
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) {
|
|
689
|
+
this._dropGhost = null;
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
private _clearDropGhost = () => {
|
|
694
|
+
this._dropGhost = null;
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
// A drop target must preventDefault on its own dragover to accept the drop.
|
|
698
|
+
// No stopPropagation, so the viewport handler still tracks the preview.
|
|
699
|
+
private _onAddPointDragOver = (e: DragEvent) => {
|
|
700
|
+
if (!e.dataTransfer?.types.includes(TYPE_MIME)) return;
|
|
701
|
+
e.preventDefault();
|
|
702
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
private _onAddDrop(e: DragEvent, nodeId: string, port: string) {
|
|
706
|
+
const type = e.dataTransfer?.getData(TYPE_MIME);
|
|
707
|
+
if (!type) return;
|
|
708
|
+
e.preventDefault();
|
|
709
|
+
e.stopPropagation();
|
|
710
|
+
this._emit('flow-output-assign', {nodeId, port, type});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private _onWireDrop(e: DragEvent, connectionId: string) {
|
|
714
|
+
const type = e.dataTransfer?.getData(TYPE_MIME);
|
|
715
|
+
if (!type) return;
|
|
716
|
+
e.preventDefault();
|
|
717
|
+
e.stopPropagation();
|
|
718
|
+
this._emit('flow-wire-assign', {connectionId, type});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// --- Toolbar ----------------------------------------------------------------
|
|
722
|
+
|
|
723
|
+
private _zoomBy(delta: number) {
|
|
724
|
+
this._viewInitialised = true;
|
|
725
|
+
this.zoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, +(this.zoom + delta).toFixed(2)));
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Canvas-space bounding box of the whole flow: nodes, branch drops, and notes. */
|
|
729
|
+
private _contentBounds(): { x: number; y: number; width: number; height: number } | null {
|
|
730
|
+
if (!this.nodes.length && !this.notes.length) return null;
|
|
731
|
+
let minX = Infinity;
|
|
732
|
+
let minY = Infinity;
|
|
733
|
+
let maxX = -Infinity;
|
|
734
|
+
let maxY = -Infinity;
|
|
735
|
+
for (const node of this.nodes) {
|
|
736
|
+
minX = Math.min(minX, node.x);
|
|
737
|
+
maxX = Math.max(maxX, node.x + NODE_WIDTH);
|
|
738
|
+
minY = Math.min(minY, node.y);
|
|
739
|
+
maxY = Math.max(maxY, node.y + NODE_HEIGHT);
|
|
740
|
+
for (const b of this._outputLayout(node).branches) {
|
|
741
|
+
minX = Math.min(minX, b.x - 70); // half a typical pill
|
|
742
|
+
maxX = Math.max(maxX, b.x + 70);
|
|
743
|
+
maxY = Math.max(maxY, b.exitY + ADD_POINT_OFFSET + 14);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
for (const note of this.notes) {
|
|
747
|
+
minX = Math.min(minX, note.x);
|
|
748
|
+
maxX = Math.max(maxX, note.x + (note.width ?? NOTE_WIDTH));
|
|
749
|
+
minY = Math.min(minY, note.y);
|
|
750
|
+
maxY = Math.max(maxY, note.y + (note.height ?? NOTE_HEIGHT));
|
|
751
|
+
}
|
|
752
|
+
return {x: minX, y: minY, width: maxX - minX, height: maxY - minY};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Reset zoom and centre the flow in the viewport, zooming out to fit if needed. */
|
|
756
|
+
private _resetView() {
|
|
757
|
+
const rect = this.getBoundingClientRect();
|
|
758
|
+
const bounds = this._contentBounds();
|
|
759
|
+
if (!bounds || !rect.width || !rect.height) {
|
|
760
|
+
this.zoom = 1;
|
|
761
|
+
this.panX = 0;
|
|
762
|
+
this.panY = 0;
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const PAD = 60;
|
|
766
|
+
const fit = Math.min(
|
|
767
|
+
1,
|
|
768
|
+
(rect.width - PAD * 2) / bounds.width,
|
|
769
|
+
(rect.height - PAD * 2) / bounds.height
|
|
770
|
+
);
|
|
771
|
+
this.zoom = Math.max(ZOOM_MIN, +fit.toFixed(2));
|
|
772
|
+
this.panX = Math.round((rect.width - bounds.width * this.zoom) / 2 - bounds.x * this.zoom);
|
|
773
|
+
this.panY = Math.round((rect.height - bounds.height * this.zoom) / 2 - bounds.y * this.zoom);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// --- Rendering --------------------------------------------------------------
|
|
777
|
+
|
|
778
|
+
private _viewportCursorClass(): string {
|
|
779
|
+
if (!this.drag) return '';
|
|
780
|
+
if (this.drag.kind === 'note-resize') return 'resizing';
|
|
781
|
+
return 'grabbing';
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
private _renderConnections() {
|
|
785
|
+
const transform = `translate(${this.panX} ${this.panY}) scale(${this.zoom})`;
|
|
786
|
+
const midOffsets = this._elbowMidOffsets();
|
|
787
|
+
const loops = loopConnections(this.nodes, this.connections);
|
|
788
|
+
const paths = [];
|
|
789
|
+
|
|
790
|
+
// Open (unconnected, unlabelled) branches draw nothing while idle — their
|
|
791
|
+
// stubs and "+" targets only appear mid-move/drag, since branches are
|
|
792
|
+
// started from the node's output port.
|
|
793
|
+
const showOpen = !!(this.movingNodeId || this.dragType);
|
|
794
|
+
|
|
795
|
+
for (const node of this.nodes) {
|
|
796
|
+
const {cx, by, busY, branches} = this._outputLayout(node);
|
|
797
|
+
const active = branches.filter(b => (b.conn && b.child) || b.pillTop !== null || showOpen);
|
|
798
|
+
if (!active.length) continue;
|
|
799
|
+
|
|
800
|
+
// Stem from the node's bottom-centre down to the bus.
|
|
801
|
+
paths.push(svg`<path class="wire" d="M ${cx} ${by} L ${cx} ${busY}"></path>`);
|
|
802
|
+
|
|
803
|
+
// Horizontal bus spanning the visible branch endpoints.
|
|
804
|
+
const xs = [cx, ...active.map(b => b.x)];
|
|
805
|
+
const minX = Math.min(...xs);
|
|
806
|
+
const maxX = Math.max(...xs);
|
|
807
|
+
if (maxX - minX > 0.5) {
|
|
808
|
+
paths.push(svg`<path class="wire" d="M ${minX} ${busY} L ${maxX} ${busY}"></path>`);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// One branch per output. Labelled outputs drop from the bus into a pill
|
|
812
|
+
// (arrowhead), then continue from the pill to the child / open "+".
|
|
813
|
+
active.forEach(b => {
|
|
814
|
+
if (b.pillTop !== null) {
|
|
815
|
+
paths.push(svg`<path class="wire" d="M ${b.x} ${busY} L ${b.x} ${b.pillTop}" marker-end="url(#flow-arrow)"></path>`);
|
|
816
|
+
}
|
|
817
|
+
if (b.conn && b.child) {
|
|
818
|
+
const offset = midOffsets.get(b.conn.id) ?? 0;
|
|
819
|
+
const pts = this._routePoints({x: b.x, y: b.exitY}, b.child, b.conn.target.port, offset, node.id);
|
|
820
|
+
const loop = loops.has(b.conn);
|
|
821
|
+
paths.push(svg`<path
|
|
822
|
+
class="wire ${loop ? 'wire--loop' : ''}"
|
|
823
|
+
d="${ZnFlowCanvas._pathFrom(pts)}"
|
|
824
|
+
marker-end="url(#${loop ? 'flow-arrow-loop' : 'flow-arrow'})"
|
|
825
|
+
></path>`);
|
|
826
|
+
} else if (showOpen) {
|
|
827
|
+
// Stub down to the contextual "+" target (only shown mid-move/drag).
|
|
828
|
+
paths.push(svg`<path class="wire wire--stub" d="M ${b.x} ${b.exitY} L ${b.x} ${b.exitY + ADD_POINT_OFFSET - 11}"></path>`);
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// The in-progress branch follows the cursor from the node's bottom port,
|
|
834
|
+
// snapping onto the hovered target's input until it attaches or cancels.
|
|
835
|
+
if (this._linking && this._linkPos) {
|
|
836
|
+
const src = this.nodes.find(n => n.id === this._linking!.nodeId);
|
|
837
|
+
if (src) {
|
|
838
|
+
const from = {x: src.x + NODE_WIDTH / 2, y: src.y + NODE_HEIGHT};
|
|
839
|
+
const target = this._linkTarget ? this.nodes.find(n => n.id === this._linkTarget) : undefined;
|
|
840
|
+
const end = target
|
|
841
|
+
? this._inputAnchor(target, firstInputId(target, this._typeFor(target)) ?? '')
|
|
842
|
+
: this._linkPos;
|
|
843
|
+
paths.push(svg`<path class="wire wire--preview" d="M ${from.x} ${from.y} L ${end.x} ${end.y}" marker-end="url(#flow-arrow)"></path>`);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return html`
|
|
848
|
+
<svg class="connections" part="connections">
|
|
849
|
+
<defs>
|
|
850
|
+
<marker id="flow-arrow" markerWidth="10" markerHeight="10" refX="7" refY="3" orient="auto"
|
|
851
|
+
markerUnits="userSpaceOnUse">
|
|
852
|
+
<path d="M0,0 L7,3 L0,6 Z"></path>
|
|
853
|
+
</marker>
|
|
854
|
+
<marker id="flow-arrow-loop" markerWidth="10" markerHeight="10" refX="7" refY="3" orient="auto"
|
|
855
|
+
markerUnits="userSpaceOnUse">
|
|
856
|
+
<path d="M0,0 L7,3 L0,6 Z"></path>
|
|
857
|
+
</marker>
|
|
858
|
+
</defs>
|
|
859
|
+
<g transform="${transform}">${paths}</g>
|
|
860
|
+
</svg>
|
|
861
|
+
`;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Output labels (branch names) render as a clickable pill on their branch;
|
|
866
|
+
* hovering slides out a delete button that removes the branch (and its wire).
|
|
867
|
+
* Keyed by node+port so deleting one never hands its DOM (with its hovered,
|
|
868
|
+
* visible delete button) to a different pill — which flickered on screen.
|
|
869
|
+
*/
|
|
870
|
+
private _renderBranchPills() {
|
|
871
|
+
const loops = loopConnections(this.nodes, this.connections);
|
|
872
|
+
const items: {
|
|
873
|
+
key: string; nodeId: string; portId: string; label: string; x: number; top: number; height: number; loop: boolean;
|
|
874
|
+
}[] = [];
|
|
875
|
+
for (const node of this.nodes) {
|
|
876
|
+
const {branches} = this._outputLayout(node);
|
|
877
|
+
for (const b of branches) {
|
|
878
|
+
if (b.pillTop === null) continue;
|
|
879
|
+
items.push({
|
|
880
|
+
key: `${node.id}:${b.port.id}`,
|
|
881
|
+
nodeId: node.id,
|
|
882
|
+
portId: b.port.id,
|
|
883
|
+
label: b.port.label ?? '',
|
|
884
|
+
x: b.x,
|
|
885
|
+
top: b.pillTop,
|
|
886
|
+
height: b.pillH,
|
|
887
|
+
loop: !!b.conn && loops.has(b.conn),
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return repeat(
|
|
892
|
+
items,
|
|
893
|
+
i => i.key,
|
|
894
|
+
i => html`
|
|
895
|
+
<div
|
|
896
|
+
class="branch-pill-wrap"
|
|
897
|
+
style="left:${i.x}px;top:${i.top}px;height:${i.height}px"
|
|
898
|
+
>
|
|
899
|
+
<button
|
|
900
|
+
class="branch-pill ${i.key === this.selectedBranch ? 'branch-pill--selected' : ''} ${i.loop ? 'branch-pill--loop' : ''}"
|
|
901
|
+
title="Configure branch"
|
|
902
|
+
@pointerdown="${(e: PointerEvent) => e.button === 0 && e.stopPropagation()}"
|
|
903
|
+
@click="${(e: Event) => {
|
|
904
|
+
e.stopPropagation();
|
|
905
|
+
this._emit('flow-branch-pick', {nodeId: i.nodeId, port: i.portId});
|
|
906
|
+
}}"
|
|
907
|
+
>${i.label}</button>
|
|
908
|
+
<button
|
|
909
|
+
class="branch-pill-delete"
|
|
910
|
+
title="Delete branch"
|
|
911
|
+
@pointerdown="${(e: PointerEvent) => e.button === 0 && e.stopPropagation()}"
|
|
912
|
+
@click="${(e: Event) => {
|
|
913
|
+
e.stopPropagation();
|
|
914
|
+
this._emit('flow-branch-delete', {nodeId: i.nodeId, port: i.portId});
|
|
915
|
+
}}"
|
|
916
|
+
>
|
|
917
|
+
<zn-icon src="x@lu" size="14"></zn-icon>
|
|
918
|
+
</button>
|
|
919
|
+
</div>
|
|
920
|
+
`
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
private _renderAddPoints() {
|
|
925
|
+
const moving = !!this.movingNodeId;
|
|
926
|
+
return this._addPoints().map(
|
|
927
|
+
p => html`
|
|
928
|
+
<button
|
|
929
|
+
class="add-point ${moving ? 'add-point--target' : ''}"
|
|
930
|
+
style="transform:translate(${p.px}px, ${p.py}px)"
|
|
931
|
+
title="${moving ? 'Move here' : 'Start a branch'}"
|
|
932
|
+
@pointerdown="${(e: PointerEvent) => e.button === 0 && e.stopPropagation()}"
|
|
933
|
+
@click="${(e: Event) => this._onAddClick(e, p.nodeId, p.port)}"
|
|
934
|
+
@dragover="${this._onAddPointDragOver}"
|
|
935
|
+
@drop="${(e: DragEvent) => this._onAddDrop(e, p.nodeId, p.port)}"
|
|
936
|
+
>
|
|
937
|
+
<zn-icon src="plus@lu" size="16"></zn-icon>
|
|
938
|
+
</button>
|
|
939
|
+
`
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
private _renderWireAddPoints() {
|
|
944
|
+
return this._wirePoints().map(
|
|
945
|
+
p => html`
|
|
946
|
+
<button
|
|
947
|
+
class="add-point add-point--wire"
|
|
948
|
+
style="transform:translate(${p.px}px, ${p.py}px)"
|
|
949
|
+
title="Insert a step here"
|
|
950
|
+
@pointerdown="${(e: PointerEvent) => e.button === 0 && e.stopPropagation()}"
|
|
951
|
+
@click="${(e: Event) => this._onWireAddClick(e, p.connectionId, p.px, p.py)}"
|
|
952
|
+
@dragover="${this._onAddPointDragOver}"
|
|
953
|
+
@drop="${(e: DragEvent) => this._onWireDrop(e, p.connectionId)}"
|
|
954
|
+
>
|
|
955
|
+
<zn-icon src="plus@lu" size="14"></zn-icon>
|
|
956
|
+
</button>
|
|
957
|
+
`
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
private _renderDropGhost() {
|
|
962
|
+
if (!this._dropGhost) return '';
|
|
963
|
+
const type = this.dragType ? this.registry?.get(this.dragType) : undefined;
|
|
964
|
+
return html`
|
|
965
|
+
<div
|
|
966
|
+
class="drop-ghost"
|
|
967
|
+
style="transform:translate(${this._dropGhost.x}px, ${this._dropGhost.y}px);width:${NODE_WIDTH}px;height:${NODE_HEIGHT}px"
|
|
968
|
+
>
|
|
969
|
+
<div class="drop-ghost__icon" style="--node-accent:${type?.color ?? 'rgb(var(--zn-color-primary))'}">
|
|
970
|
+
<zn-icon src="${type?.icon ?? 'circle'}" library="${ifDefined(type?.iconLibrary)}" size="20"></zn-icon>
|
|
971
|
+
</div>
|
|
972
|
+
<span class="drop-ghost__label">${type?.label ?? 'New step'}</span>
|
|
973
|
+
</div>
|
|
974
|
+
`;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
private _startNoteGrab(e: PointerEvent, note: FlowNote) {
|
|
978
|
+
if (e.button !== 0) return;
|
|
979
|
+
e.stopPropagation();
|
|
980
|
+
const p = this.screenToCanvas(e.clientX, e.clientY);
|
|
981
|
+
this.drag = {kind: 'note', id: note.id, offX: p.x - note.x, offY: p.y - note.y};
|
|
982
|
+
this._dragMoved = false;
|
|
983
|
+
this._setupWindow('grabbing');
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
private _startNoteResize(e: PointerEvent, note: FlowNote) {
|
|
987
|
+
if (e.button !== 0) return;
|
|
988
|
+
e.stopPropagation();
|
|
989
|
+
e.preventDefault();
|
|
990
|
+
this.drag = {kind: 'note-resize', id: note.id};
|
|
991
|
+
this._dragMoved = false;
|
|
992
|
+
this._setupWindow('nwse-resize');
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
private _renderNotes() {
|
|
996
|
+
return repeat(
|
|
997
|
+
this.notes,
|
|
998
|
+
n => n.id,
|
|
999
|
+
note => html`
|
|
1000
|
+
<div
|
|
1001
|
+
class="note"
|
|
1002
|
+
style="left:${note.x}px;top:${note.y}px;width:${note.width ?? NOTE_WIDTH}px;height:${note.height ?? NOTE_HEIGHT}px"
|
|
1003
|
+
>
|
|
1004
|
+
<div class="note__bar" @pointerdown="${(e: PointerEvent) => this._startNoteGrab(e, note)}">
|
|
1005
|
+
<button
|
|
1006
|
+
class="note__close"
|
|
1007
|
+
@click="${() => this._emit('flow-note-delete', {noteId: note.id})}"
|
|
1008
|
+
>×
|
|
1009
|
+
</button>
|
|
1010
|
+
</div>
|
|
1011
|
+
<textarea
|
|
1012
|
+
class="note__text"
|
|
1013
|
+
.value="${note.text}"
|
|
1014
|
+
placeholder="Add a note…"
|
|
1015
|
+
@pointerdown="${(e: Event) => e.stopPropagation()}"
|
|
1016
|
+
@change="${(e: Event) => this._emit('flow-note-change', {
|
|
1017
|
+
noteId: note.id,
|
|
1018
|
+
text: (e.target as HTMLTextAreaElement).value
|
|
1019
|
+
})}"
|
|
1020
|
+
></textarea>
|
|
1021
|
+
<span class="note__resize" @pointerdown="${(e: PointerEvent) => this._startNoteResize(e, note)}"></span>
|
|
1022
|
+
</div>
|
|
1023
|
+
`
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
render() {
|
|
1028
|
+
const transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`;
|
|
1029
|
+
return html`
|
|
1030
|
+
<div
|
|
1031
|
+
part="base"
|
|
1032
|
+
class="viewport ${this._viewportCursorClass()} ${this.movingNodeId ? 'moving' : ''} ${this.dragType ? 'step-dragging' : ''} ${this._linking ? 'linking' : ''}"
|
|
1033
|
+
@pointerdown="${this._onBackgroundPointerDown}"
|
|
1034
|
+
@dragover="${this._onViewportDragOver}"
|
|
1035
|
+
@dragleave="${this._onViewportDragLeave}"
|
|
1036
|
+
@drop="${this._clearDropGhost}"
|
|
1037
|
+
>
|
|
1038
|
+
${this._renderConnections()}
|
|
1039
|
+
|
|
1040
|
+
<div class="layer" style="transform:${transform}">
|
|
1041
|
+
${repeat(
|
|
1042
|
+
this.nodes,
|
|
1043
|
+
n => n.id,
|
|
1044
|
+
node => html`
|
|
1045
|
+
<div class="node-pos" style="transform:translate(${node.x}px, ${node.y}px)">
|
|
1046
|
+
<zn-flow-node
|
|
1047
|
+
.node="${node}"
|
|
1048
|
+
.type="${this._typeFor(node)}"
|
|
1049
|
+
?selected="${node.id === this.selectedNodeId}"
|
|
1050
|
+
?error="${this.errorNodes.has(node.id)}"
|
|
1051
|
+
?dragging="${this.drag?.kind === 'node' && this.drag.id === node.id}"
|
|
1052
|
+
?link-target="${node.id === this._linkTarget}"
|
|
1053
|
+
></zn-flow-node>
|
|
1054
|
+
</div>
|
|
1055
|
+
`
|
|
1056
|
+
)}
|
|
1057
|
+
${this._renderDropGhost()}
|
|
1058
|
+
${this._renderBranchPills()}
|
|
1059
|
+
${this._renderAddPoints()}
|
|
1060
|
+
${this._renderWireAddPoints()}
|
|
1061
|
+
${this._renderNotes()}
|
|
1062
|
+
</div>
|
|
1063
|
+
|
|
1064
|
+
<div part="toolbar" class="toolbar toolbar--history" @pointerdown="${(e: Event) => e.stopPropagation()}">
|
|
1065
|
+
<zn-button icon="undo@lu" icon-size="18" icon-button="small" plain
|
|
1066
|
+
@click="${() => this._emit('flow-undo')}"></zn-button>
|
|
1067
|
+
<zn-button icon="redo@lu" icon-size="18" icon-button="small" plain
|
|
1068
|
+
@click="${() => this._emit('flow-redo')}"></zn-button>
|
|
1069
|
+
<zn-button icon="sticky-note@lu" icon-size="18" icon-button="small" plain
|
|
1070
|
+
@click="${() => this._emit('flow-add-note')}"></zn-button>
|
|
1071
|
+
<zn-button icon="network@lu" icon-size="18" icon-button="small" plain title="Untangle"
|
|
1072
|
+
@click="${() => this._emit('flow-untangle')}"></zn-button>
|
|
1073
|
+
</div>
|
|
1074
|
+
|
|
1075
|
+
<div part="toolbar" class="toolbar toolbar--zoom" @pointerdown="${(e: Event) => e.stopPropagation()}">
|
|
1076
|
+
<zn-button icon="plus@lu" icon-size="18" icon-button="small" plain
|
|
1077
|
+
@click="${() => this._zoomBy(ZOOM_STEP)}"></zn-button>
|
|
1078
|
+
<zn-button icon="locate-fixed@lu" icon-size="18" icon-button="small" plain
|
|
1079
|
+
@click="${() => this._resetView()}"></zn-button>
|
|
1080
|
+
<zn-button icon="minus@lu" icon-size="18" icon-button="small" plain
|
|
1081
|
+
@click="${() => this._zoomBy(-ZOOM_STEP)}"></zn-button>
|
|
1082
|
+
</div>
|
|
1083
|
+
</div>
|
|
1084
|
+
`;
|
|
1085
|
+
}
|
|
1086
|
+
}
|