@kubex/zinc 1.1.23 → 1.1.25
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 +1333 -249
- package/dist/vscode.html-custom-data.json +59 -37
- package/dist/web-types.json +190 -81
- package/dist/zn.d.ts +864 -0
- package/dist/zn.min.js +875 -464
- 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 +1110 -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,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
|
+
}
|