@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.
Files changed (48) hide show
  1. package/custom-elements-manifest.config.js +2 -4
  2. package/dist/custom-elements.json +1172 -78
  3. package/dist/vscode.html-custom-data.json +34 -13
  4. package/dist/web-types.json +135 -26
  5. package/dist/zn.d.ts +867 -1
  6. package/dist/zn.min.js +893 -481
  7. package/docs/_includes/default.njk +1 -0
  8. package/docs/_includes/full-page.njk +38 -0
  9. package/docs/pages/components/flow-builder-demo.njk +194 -0
  10. package/docs/pages/components/flow-builder-troubleshooter-demo.njk +282 -0
  11. package/docs/pages/components/flow-builder.md +377 -0
  12. package/package.json +1 -1
  13. package/src/components/button/button.component.ts +1 -2
  14. package/src/components/button/button.scss +6 -16
  15. package/src/components/collapsible/collapsible.component.ts +11 -6
  16. package/src/components/data-table/data-table.component.ts +12 -12
  17. package/src/components/flow-builder/flow-builder.component.ts +1171 -0
  18. package/src/components/flow-builder/flow-builder.scss +489 -0
  19. package/src/components/flow-builder/flow-builder.test.ts +59 -0
  20. package/src/components/flow-builder/flow-layout.ts +139 -0
  21. package/src/components/flow-builder/flow-registry.ts +52 -0
  22. package/src/components/flow-builder/flow.types.ts +407 -0
  23. package/src/components/flow-builder/index.ts +14 -0
  24. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +1086 -0
  25. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.scss +371 -0
  26. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.test.ts +39 -0
  27. package/src/components/flow-builder/modules/flow-canvas/index.ts +12 -0
  28. package/src/components/flow-builder/modules/flow-node/flow-node.component.ts +174 -0
  29. package/src/components/flow-builder/modules/flow-node/flow-node.scss +180 -0
  30. package/src/components/flow-builder/modules/flow-node/flow-node.test.ts +50 -0
  31. package/src/components/flow-builder/modules/flow-node/index.ts +12 -0
  32. package/src/components/flow-builder/modules/flow-step/flow-step.component.ts +88 -0
  33. package/src/components/flow-builder/modules/flow-step/flow-step.scss +52 -0
  34. package/src/components/flow-builder/modules/flow-step/flow-step.test.ts +21 -0
  35. package/src/components/flow-builder/modules/flow-step/index.ts +12 -0
  36. package/src/components/flow-builder/modules/flow-step-group/flow-step-group.component.ts +35 -0
  37. package/src/components/flow-builder/modules/flow-step-group/flow-step-group.scss +28 -0
  38. package/src/components/flow-builder/modules/flow-step-group/flow-step-group.test.ts +20 -0
  39. package/src/components/flow-builder/modules/flow-step-group/index.ts +12 -0
  40. package/src/components/flow-builder/modules/flow-steps/flow-steps.component.ts +88 -0
  41. package/src/components/flow-builder/modules/flow-steps/flow-steps.scss +24 -0
  42. package/src/components/flow-builder/modules/flow-steps/flow-steps.test.ts +17 -0
  43. package/src/components/flow-builder/modules/flow-steps/index.ts +12 -0
  44. package/src/events/events.ts +3 -0
  45. package/src/events/zn-flow-change.ts +9 -0
  46. package/src/events/zn-flow-connect.ts +9 -0
  47. package/src/events/zn-flow-selection-change.ts +7 -0
  48. package/src/zinc.ts +4 -0
@@ -0,0 +1,371 @@
1
+ @use "../../../../wc";
2
+
3
+ :host {
4
+ display: block;
5
+ position: relative;
6
+ width: 100%;
7
+ height: 100%;
8
+ overflow: hidden;
9
+ }
10
+
11
+ .viewport {
12
+ position: absolute;
13
+ inset: 0;
14
+ overflow: hidden;
15
+ background-color: rgb(var(--zn-color-body));
16
+ background-image: radial-gradient(rgb(var(--zn-color-border)) 1px, transparent 1px);
17
+ background-size: 20px 20px; // matches GRID_SIZE — positions snap to these dots
18
+ cursor: grab;
19
+ touch-action: none;
20
+
21
+ // During a move/pan drag, force grabbing across the canvas and its notes.
22
+ // (The dragged node card is handled inside its own shadow via [dragging].)
23
+ &.grabbing,
24
+ &.grabbing * {
25
+ cursor: grabbing !important;
26
+ }
27
+
28
+ &.connecting,
29
+ &.connecting * {
30
+ cursor: crosshair !important;
31
+ }
32
+
33
+ &.resizing,
34
+ &.resizing * {
35
+ cursor: nwse-resize !important;
36
+ }
37
+ }
38
+
39
+ .connections {
40
+ position: absolute;
41
+ inset: 0;
42
+ width: 100%;
43
+ height: 100%;
44
+ pointer-events: none;
45
+ overflow: visible;
46
+
47
+ .wire {
48
+ fill: none;
49
+ stroke: rgb(var(--zn-color-primary));
50
+ stroke-width: 2;
51
+ }
52
+
53
+ .wire--stub {
54
+ stroke: rgb(var(--zn-color-border-active));
55
+ stroke-dasharray: 4 4;
56
+ }
57
+
58
+ // The stray branch following the cursor while picking a node to attach to.
59
+ .wire--preview {
60
+ stroke-dasharray: 6 4;
61
+ }
62
+
63
+ // A wire that loops back to an earlier step — solid amber, so return paths
64
+ // stand out from the forward flow.
65
+ .wire--loop {
66
+ stroke: rgb(var(--zn-color-warning));
67
+ }
68
+
69
+ marker path {
70
+ fill: rgb(var(--zn-color-primary));
71
+ }
72
+
73
+ marker#flow-arrow-loop path {
74
+ fill: rgb(var(--zn-color-warning));
75
+ }
76
+ }
77
+
78
+ // While a stray branch is live, everything is either a target (a node) or a cancel.
79
+ .viewport.linking {
80
+ cursor: crosshair;
81
+ }
82
+
83
+ .add-point {
84
+ position: absolute;
85
+ top: 0;
86
+ left: 0;
87
+ display: flex;
88
+ align-items: center;
89
+ justify-content: center;
90
+ width: 24px;
91
+ height: 24px;
92
+ margin: -12px; // centre on the transformed point
93
+ padding: 0;
94
+ border-radius: 50%;
95
+ color: rgb(var(--zn-color-primary));
96
+ background: rgb(var(--zn-panel));
97
+ border: 1px dashed rgb(var(--zn-color-border-active));
98
+ cursor: pointer;
99
+ transition: background 0.12s ease, color 0.12s ease;
100
+
101
+ &:hover {
102
+ color: rgb(var(--zn-panel));
103
+ background: rgb(var(--zn-color-primary));
104
+ border-style: solid;
105
+ }
106
+
107
+ &--target {
108
+ color: rgb(var(--zn-panel));
109
+ background: rgb(var(--zn-color-primary));
110
+ border-style: solid;
111
+ box-shadow: 0 0 0 4px color-mix(in srgb, rgb(var(--zn-color-primary)) 25%, transparent);
112
+ }
113
+
114
+ &--wire {
115
+ width: 20px;
116
+ height: 20px;
117
+ margin: -10px;
118
+ }
119
+ }
120
+
121
+ // While dragging a step, emphasise every "+" so the drop targets are obvious.
122
+ .viewport.step-dragging .add-point {
123
+ color: rgb(var(--zn-panel));
124
+ background: rgb(var(--zn-color-primary));
125
+ border-style: solid;
126
+ box-shadow: 0 0 0 4px color-mix(in srgb, rgb(var(--zn-color-primary)) 22%, transparent);
127
+ }
128
+
129
+ .layer {
130
+ position: absolute;
131
+ top: 0;
132
+ left: 0;
133
+ transform-origin: 0 0;
134
+ width: 0;
135
+ height: 0;
136
+ }
137
+
138
+ .node-pos {
139
+ position: absolute;
140
+ top: 0;
141
+ left: 0;
142
+ }
143
+
144
+ // Branch names rendered as a clickable pill on their branch; the wire drops
145
+ // into the pill's top and continues from its bottom. Clicking opens the editor;
146
+ // hovering slides out a delete button to the right.
147
+ .branch-pill-wrap {
148
+ position: absolute;
149
+ top: 0;
150
+ left: 0;
151
+ transform: translateX(-50%);
152
+ display: flex;
153
+ align-items: stretch;
154
+
155
+ // Invisible bridge so the cursor can travel from the pill to the delete
156
+ // button without the hover (and the button) disappearing.
157
+ &::after {
158
+ content: '';
159
+ position: absolute;
160
+ left: 100%;
161
+ top: 0;
162
+ width: 40px;
163
+ height: 100%;
164
+ }
165
+
166
+ // The reveal is slightly delayed so pills that shift under a stationary
167
+ // cursor (e.g. re-spreading after a sibling branch is deleted) don't flash
168
+ // their delete button; hiding stays immediate.
169
+ &:hover .branch-pill-delete {
170
+ opacity: 1;
171
+ transform: translate(0, -50%);
172
+ pointer-events: auto;
173
+ transition-delay: 0.15s;
174
+ }
175
+ }
176
+
177
+ .branch-pill {
178
+ display: flex;
179
+ align-items: center;
180
+ justify-content: center;
181
+ // Size to the text, hard-wrapping at PILL_MAX_WIDTH — width: max-content
182
+ // stops overflow-wrap: anywhere collapsing the shrink-wrapped pill to a
183
+ // single character column. Height is intrinsic: fixed padding around
184
+ // 20px lines (one line = 40px tall, matching PILL_HEIGHT).
185
+ width: max-content;
186
+ max-width: 240px;
187
+ box-sizing: border-box;
188
+ text-align: center;
189
+ overflow-wrap: anywhere;
190
+ overflow: hidden;
191
+ padding: 0 18px;
192
+ border-radius: 10px; // matches the node cards
193
+ font: inherit;
194
+ font-size: 0.8125rem;
195
+ font-weight: 600;
196
+ // After the font shorthand, which would reset it — matches PILL_LINE_HEIGHT
197
+ // so wrapped lines track the estimated pill height.
198
+ line-height: 20px;
199
+ color: rgb(var(--zn-text));
200
+ background: color-mix(in srgb, rgb(var(--zn-color-primary)) 8%, rgb(var(--zn-panel)));
201
+ border: 1px solid color-mix(in srgb, rgb(var(--zn-color-primary)) 45%, transparent);
202
+ cursor: pointer;
203
+ transition: border-color 0.12s ease, box-shadow 0.12s ease;
204
+
205
+ &:hover {
206
+ border-color: rgb(var(--zn-color-primary));
207
+ box-shadow: 0 1px 3px rgba(var(--zn-shadow), 0.4);
208
+ }
209
+
210
+ &--selected {
211
+ border-color: rgb(var(--zn-color-primary));
212
+ background: color-mix(in srgb, rgb(var(--zn-color-primary)) 16%, rgb(var(--zn-panel)));
213
+ }
214
+
215
+ // A loop branch — amber to match its return wire.
216
+ &--loop {
217
+ background: color-mix(in srgb, rgb(var(--zn-color-warning)) 10%, rgb(var(--zn-panel)));
218
+ border-color: color-mix(in srgb, rgb(var(--zn-color-warning)) 55%, transparent);
219
+
220
+ &:hover {
221
+ border-color: rgb(var(--zn-color-warning));
222
+ }
223
+
224
+ &.branch-pill--selected {
225
+ border-color: rgb(var(--zn-color-warning));
226
+ background: color-mix(in srgb, rgb(var(--zn-color-warning)) 18%, rgb(var(--zn-panel)));
227
+ }
228
+ }
229
+ }
230
+
231
+ // Slides out from behind the pill on hover; deletes the branch and its wire.
232
+ // Sits above the wrap's ::after hover bridge, which would otherwise swallow
233
+ // clicks (and the pointer cursor).
234
+ .branch-pill-delete {
235
+ position: absolute;
236
+ z-index: 1;
237
+ left: calc(100% + 8px);
238
+ top: 50%;
239
+ transform: translate(-10px, -50%);
240
+ display: flex;
241
+ align-items: center;
242
+ justify-content: center;
243
+ width: 24px;
244
+ height: 24px;
245
+ padding: 0;
246
+ border: none;
247
+ border-radius: 50%;
248
+ background: rgb(var(--zn-color-error));
249
+ color: white;
250
+ cursor: pointer;
251
+ opacity: 0;
252
+ pointer-events: none;
253
+ transition: opacity 0.15s ease, transform 0.15s ease;
254
+
255
+ &:hover {
256
+ filter: brightness(1.1);
257
+ }
258
+ }
259
+
260
+ // Placeholder shown while dragging a step over the canvas — previews
261
+ // the step that will be created, so you can see what and where it lands.
262
+ .drop-ghost {
263
+ position: absolute;
264
+ top: 0;
265
+ left: 0;
266
+ display: flex;
267
+ align-items: center;
268
+ gap: 10px;
269
+ padding: 0 12px;
270
+ border: 2px dashed rgb(var(--zn-color-primary));
271
+ border-radius: 10px;
272
+ background: color-mix(in srgb, rgb(var(--zn-color-primary)) 10%, rgb(var(--zn-panel)));
273
+ opacity: 0.9;
274
+ pointer-events: none;
275
+
276
+ &__icon {
277
+ --node-accent: rgb(var(--zn-color-primary));
278
+ display: flex;
279
+ align-items: center;
280
+ justify-content: center;
281
+ flex: 0 0 auto;
282
+ width: 36px;
283
+ height: 36px;
284
+ border-radius: 8px;
285
+ color: var(--node-accent);
286
+ background: color-mix(in srgb, var(--node-accent) 16%, rgb(var(--zn-panel)));
287
+ }
288
+
289
+ &__label {
290
+ font-size: 0.8125rem;
291
+ font-weight: 600;
292
+ color: rgb(var(--zn-text));
293
+ white-space: nowrap;
294
+ overflow: hidden;
295
+ text-overflow: ellipsis;
296
+ }
297
+ }
298
+
299
+ .toolbar {
300
+ position: absolute;
301
+ top: 16px;
302
+ display: flex;
303
+ align-items: center;
304
+ gap: 2px;
305
+ padding: 3px;
306
+ background: rgb(var(--zn-panel));
307
+ border: 1px solid rgb(var(--zn-border-color));
308
+ border-radius: 8px;
309
+ box-shadow: 0 1px 3px rgba(var(--zn-shadow), 0.4);
310
+ z-index: 2;
311
+
312
+ &--history {
313
+ left: 16px;
314
+ }
315
+
316
+ &--zoom {
317
+ right: 16px;
318
+ }
319
+ }
320
+
321
+ .note {
322
+ position: absolute;
323
+ display: flex;
324
+ flex-direction: column;
325
+ background: #fef9c3;
326
+ border: 1px solid #fde68a;
327
+ border-radius: 4px;
328
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
329
+ }
330
+
331
+ .note__resize {
332
+ position: absolute;
333
+ right: 0;
334
+ bottom: 0;
335
+ width: 16px;
336
+ height: 16px;
337
+ cursor: nwse-resize;
338
+ // Visual corner grip.
339
+ background: linear-gradient(135deg, transparent 0 50%, #d9c64a 50% 60%, transparent 60% 70%, #d9c64a 70% 80%, transparent 80%);
340
+ }
341
+
342
+ .note__bar {
343
+ display: flex;
344
+ justify-content: flex-end;
345
+ height: 20px;
346
+ cursor: grab;
347
+ }
348
+
349
+ .note__close {
350
+ width: 20px;
351
+ height: 20px;
352
+ padding: 0;
353
+ font-size: 14px;
354
+ line-height: 1;
355
+ color: #92400e;
356
+ background: transparent;
357
+ cursor: pointer;
358
+ }
359
+
360
+ .note__text {
361
+ flex: 1;
362
+ margin: 0;
363
+ padding: 0 10px 10px;
364
+ resize: none;
365
+ border: none;
366
+ outline: none;
367
+ background: transparent;
368
+ font: inherit;
369
+ font-size: 0.8125rem;
370
+ color: #713f12;
371
+ }
@@ -0,0 +1,39 @@
1
+ import '../../../../../dist/zn.min.js';
2
+ import {expect, fixture, html} from '@open-wc/testing';
3
+ import {FlowRegistry} from '../../flow-registry';
4
+ import type {FlowNodeInstance} from '../../flow.types';
5
+ import type ZnFlowCanvas from './flow-canvas.component';
6
+
7
+ describe('<zn-flow-canvas>', () => {
8
+ it('should render a component', async () => {
9
+ const el = await fixture<ZnFlowCanvas>(html`
10
+ <zn-flow-canvas></zn-flow-canvas>`);
11
+ expect(el).to.exist;
12
+ expect(el.shadowRoot?.querySelector('.toolbar--zoom')).to.exist;
13
+ });
14
+
15
+ it('should render one flow node per node in the model', async () => {
16
+ const registry = new FlowRegistry();
17
+ registry.register({type: 'a', label: 'A', group: 'action'});
18
+ const nodes: FlowNodeInstance[] = [
19
+ {id: 'n1', type: 'a', x: 0, y: 0, data: {}},
20
+ {id: 'n2', type: 'a', x: 100, y: 100, data: {}},
21
+ ];
22
+ const el = await fixture<ZnFlowCanvas>(html`
23
+ <zn-flow-canvas></zn-flow-canvas>`);
24
+ el.registry = registry;
25
+ el.nodes = nodes;
26
+ await el.updateComplete;
27
+
28
+ expect(el.shadowRoot?.querySelectorAll('zn-flow-node')?.length).to.equal(2);
29
+ });
30
+
31
+ it('should map screen coordinates to canvas space at the default transform', async () => {
32
+ const el = await fixture<ZnFlowCanvas>(html`
33
+ <zn-flow-canvas></zn-flow-canvas>`);
34
+ const rect = el.getBoundingClientRect();
35
+ const pt = el.screenToCanvas(rect.left + 50, rect.top + 80);
36
+ expect(pt.x).to.be.closeTo(50, 0.5);
37
+ expect(pt.y).to.be.closeTo(80, 0.5);
38
+ });
39
+ });
@@ -0,0 +1,12 @@
1
+ import ZnFlowCanvas from './flow-canvas.component';
2
+
3
+ export * from './flow-canvas.component';
4
+ export default ZnFlowCanvas;
5
+
6
+ ZnFlowCanvas.define('zn-flow-canvas');
7
+
8
+ declare global {
9
+ interface HTMLElementTagNameMap {
10
+ 'zn-flow-canvas': ZnFlowCanvas;
11
+ }
12
+ }
@@ -0,0 +1,174 @@
1
+ import {classMap} from 'lit/directives/class-map.js';
2
+ import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
3
+ import {ifDefined} from 'lit/directives/if-defined.js';
4
+ import {property} from 'lit/decorators.js';
5
+ import ZincElement from '../../../../internal/zinc-element';
6
+ import ZnButton from '../../../button';
7
+ import ZnDropdown from '../../../dropdown';
8
+ import ZnIcon from '../../../icon';
9
+ import ZnMenu from '../../../menu';
10
+ import ZnMenuItem from '../../../menu-item';
11
+
12
+ import {
13
+ type FlowNodeInstance,
14
+ type FlowNodeType,
15
+ type FlowPort,
16
+ NODE_HEIGHT,
17
+ NODE_WIDTH,
18
+ nodeInputs,
19
+ nodeOutputs,
20
+ } from '../../flow.types';
21
+
22
+ import styles from './flow-node.scss';
23
+
24
+ /**
25
+ * @summary A single node tile on the flow canvas, with input/output ports and a context menu.
26
+ * @documentation https://zinc.style/components/flow-node
27
+ * @status experimental
28
+ * @since 1.0
29
+ *
30
+ * @dependency zn-icon
31
+ * @dependency zn-button
32
+ * @dependency zn-dropdown
33
+ * @dependency zn-menu
34
+ * @dependency zn-menu-item
35
+ *
36
+ * @event flow-node-select - Emitted when the node body is clicked.
37
+ * @event flow-node-grab - Emitted on pointerdown of the node body to begin a move (handled by the canvas).
38
+ * @event flow-node-action - Emitted when a context-menu action (delete/duplicate/move) is chosen.
39
+ * @event flow-port-click - An output port was clicked; the canvas starts (or attaches) a branch.
40
+ *
41
+ * @csspart base - The node card wrapper.
42
+ */
43
+ export default class ZnFlowNode extends ZincElement {
44
+ static styles: CSSResultGroup = unsafeCSS(styles);
45
+
46
+ static dependencies = {
47
+ 'zn-icon': ZnIcon,
48
+ 'zn-button': ZnButton,
49
+ 'zn-dropdown': ZnDropdown,
50
+ 'zn-menu': ZnMenu,
51
+ 'zn-menu-item': ZnMenuItem,
52
+ };
53
+
54
+ @property({attribute: false}) node: FlowNodeInstance;
55
+ @property({attribute: false}) type: FlowNodeType;
56
+
57
+ @property({type: Boolean, reflect: true}) selected = false;
58
+ @property({type: Boolean, reflect: true}) error = false;
59
+ @property({type: Boolean, reflect: true}) dragging = false;
60
+ /** A stray branch is snapped onto this node — highlight it as the drop target. */
61
+ @property({type: Boolean, reflect: true, attribute: 'link-target'}) linkTarget = false;
62
+
63
+ protected updated(changed: PropertyValues) {
64
+ super.updated(changed);
65
+ // Position is owned by the canvas (a transformed wrapper) so it updates live
66
+ // during a drag; the node owns only its size and accent colour.
67
+ this.style.width = `${NODE_WIDTH}px`;
68
+ this.style.height = `${NODE_HEIGHT}px`;
69
+ this.style.setProperty('--node-accent', this.type?.color ?? 'rgb(var(--zn-color-primary))');
70
+ }
71
+
72
+ private get nodeTitle(): string {
73
+ return this.node?.label ?? this.type?.label ?? this.node?.type ?? 'Node';
74
+ }
75
+
76
+ private get nodeSubtitle(): string {
77
+ const sub = this.node?.data?.['subtitle'] ?? this.node?.data?.['description'];
78
+ return typeof sub === 'string' ? sub : (this.type?.description ?? '');
79
+ }
80
+
81
+ private _emit(name: string, detail: Record<string, unknown>) {
82
+ this.dispatchEvent(new CustomEvent(name, {bubbles: true, composed: true, detail}));
83
+ }
84
+
85
+ private _onBodyPointerDown = (e: PointerEvent) => {
86
+ if (e.button !== 0) return;
87
+ const target = e.target as HTMLElement;
88
+ if (target.closest('.port') || target.closest('zn-dropdown')) return;
89
+ e.stopPropagation();
90
+ this._emit('flow-node-select', {nodeId: this.node.id});
91
+ this._emit('flow-node-grab', {nodeId: this.node.id, clientX: e.clientX, clientY: e.clientY});
92
+ };
93
+
94
+ private _action(action: 'delete' | 'duplicate' | 'move') {
95
+ this._emit('flow-node-action', {nodeId: this.node.id, action});
96
+ }
97
+
98
+ // Input ports are visual anchors only; the output stem port (rendered in
99
+ // `render`) spawns a branch on click.
100
+ private _renderPorts(ports: FlowPort[], side: 'in' | 'out') {
101
+ const count = ports.length;
102
+ return html`
103
+ <div class="ports ports--${side}">
104
+ ${ports.map((port, i) => {
105
+ const left = `${(100 * (i + 1)) / (count + 1)}%`;
106
+ return html`
107
+ <div class="port-wrap" style="left:${left}">
108
+ ${side === 'out' && port.label
109
+ ? html`<span class="port-label">${port.label}</span>`
110
+ : ''}
111
+ <span class="port port--${side}" data-node="${this.node.id}" data-port="${port.id}"></span>
112
+ </div>
113
+ `;
114
+ })}
115
+ </div>
116
+ `;
117
+ }
118
+
119
+ render() {
120
+ if (!this.node) return html``;
121
+
122
+ const inputs = nodeInputs(this.node, this.type);
123
+ const outputs = nodeOutputs(this.node, this.type);
124
+
125
+ return html`
126
+ <div
127
+ part="base"
128
+ class="${classMap({card: true, selected: this.selected, error: this.error, 'link-target': this.linkTarget})}"
129
+ @pointerdown="${this._onBodyPointerDown}"
130
+ >
131
+ ${inputs.length ? this._renderPorts(inputs, 'in') : ''}
132
+
133
+ <div class="body">
134
+ <div class="icon-tile">
135
+ ${this.type?.icon
136
+ ? html`
137
+ <zn-icon src="${this.type.icon}" library="${ifDefined(this.type.iconLibrary)}" size="20"></zn-icon>`
138
+ : html`
139
+ <zn-icon src="circle" library="lucide" size="20"></zn-icon>`}
140
+ </div>
141
+ <div class="text">
142
+ <span class="title">${this.nodeTitle}</span>
143
+ ${this.nodeSubtitle ? html`<span class="subtitle">${this.nodeSubtitle}</span>` : ''}
144
+ </div>
145
+ <zn-dropdown class="menu">
146
+ <zn-button slot="trigger" icon="ellipsis@lu" icon-size="20" icon-button="small" plain></zn-button>
147
+ <zn-menu>
148
+ <zn-menu-item class="danger" @click="${() => this._action('delete')}">Delete</zn-menu-item>
149
+ <zn-menu-item @click="${() => this._action('move')}">Move</zn-menu-item>
150
+ <zn-menu-item @click="${() => this._action('duplicate')}">Duplicate</zn-menu-item>
151
+ </zn-menu>
152
+ </zn-dropdown>
153
+ </div>
154
+
155
+ ${outputs.length
156
+ ? html`
157
+ <div class="ports ports--out">
158
+ <div class="port-wrap" style="left:50%">
159
+ <span
160
+ class="port port--out"
161
+ title="Start a branch"
162
+ @pointerdown="${(e: PointerEvent) => e.button === 0 && e.stopPropagation()}"
163
+ @click="${(e: Event) => {
164
+ e.stopPropagation();
165
+ this._emit('flow-port-click', {nodeId: this.node.id});
166
+ }}"
167
+ ></span>
168
+ </div>
169
+ </div>`
170
+ : ''}
171
+ </div>
172
+ `;
173
+ }
174
+ }