@syware/jlib 0.1.3 → 0.3.0

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 (50) hide show
  1. package/README.md +3 -17
  2. package/dist/all.js +5 -4
  3. package/dist/atoms/jl-icon.css_raw.js +1 -1
  4. package/dist/atoms/jl-icon.d.ts +7 -1
  5. package/dist/atoms/jl-icon.js +4 -1
  6. package/dist/atoms/jl-input.d.ts +1 -0
  7. package/dist/atoms/jl-input.js +2 -0
  8. package/dist/atoms/jl-progress-ring.js +1 -6
  9. package/dist/atoms/jl-select.d.ts +6 -0
  10. package/dist/atoms/jl-select.js +18 -1
  11. package/dist/atoms/jl-switch.d.ts +12 -1
  12. package/dist/atoms/jl-switch.js +11 -1
  13. package/dist/index.d.ts +4 -2
  14. package/dist/index.js +4 -3
  15. package/dist/lib/calendar.d.ts +15 -1
  16. package/dist/lib/calendar.js +72 -1
  17. package/dist/lib/dom.d.ts +2 -0
  18. package/dist/lib/dom.js +8 -1
  19. package/dist/lib/flow.d.ts +70 -0
  20. package/dist/lib/flow.js +353 -0
  21. package/dist/lib/options.d.ts +5 -0
  22. package/dist/lib/options.js +13 -1
  23. package/dist/molecules/jl-accordion-item.css_raw.js +1 -1
  24. package/dist/molecules/jl-accordion-item.d.ts +6 -1
  25. package/dist/molecules/jl-accordion-item.js +21 -6
  26. package/dist/molecules/jl-alert.d.ts +1 -0
  27. package/dist/molecules/jl-alert.js +3 -1
  28. package/dist/molecules/jl-date-picker.d.ts +10 -4
  29. package/dist/molecules/jl-date-picker.js +21 -8
  30. package/dist/molecules/jl-search.d.ts +3 -1
  31. package/dist/molecules/jl-search.js +8 -6
  32. package/dist/molecules/jl-segmented.css_raw.js +1 -1
  33. package/dist/molecules/jl-segmented.js +14 -7
  34. package/dist/molecules/jl-stat.css_raw.js +1 -1
  35. package/dist/molecules/jl-stat.d.ts +6 -0
  36. package/dist/molecules/jl-stat.js +6 -0
  37. package/dist/molecules/jl-steps.css_raw.js +1 -1
  38. package/dist/molecules/jl-timeline.css_raw.js +1 -1
  39. package/dist/molecules/jl-timeline.d.ts +2 -1
  40. package/dist/molecules/jl-timeline.js +18 -16
  41. package/dist/molecules/jl-toast.css_raw.js +1 -1
  42. package/dist/molecules/jl-toast.d.ts +1 -0
  43. package/dist/molecules/jl-toast.js +3 -1
  44. package/dist/organisms/jl-flow.css_raw.js +3 -0
  45. package/dist/organisms/jl-flow.d.ts +109 -0
  46. package/dist/organisms/jl-flow.js +755 -0
  47. package/dist/types/calendar.types.d.ts +2 -0
  48. package/dist/types/jl-flow.types.d.ts +94 -0
  49. package/dist/types/jl-segmented.types.d.ts +2 -0
  50. package/package.json +1 -1
@@ -0,0 +1,755 @@
1
+ import { BASE_STYLES } from "../styles/shared.js";
2
+ import { JlElement } from "../base/jl-element.js";
3
+ import { jsonOf } from "../lib/attributes.js";
4
+ import { registerIcons } from "../lib/icons.js";
5
+ import { setOrRemove, svgElement } from "../lib/dom.js";
6
+ import { JlIcon } from "../atoms/jl-icon.js";
7
+ import { FLOW_NODE, FLOW_ZOOM, centeredAt, connect, edgeEnds, edgeMidpoint, edgePath, fitView, isEdgeTaken, isPortFree, layoutFlow, moveNode, nodeAt, nodeHeight, outputAnchor, portX, portsOf, positionAfter, removeEdge, removeNode, revealIn, snap, toWorld, zoomAt } from "../lib/flow.js";
8
+ import jl_flow_default from "./jl-flow.css_raw.js";
9
+ import { css } from "@jadis/core";
10
+ import { Check, CircleAlert, Hourglass, LoaderCircle, Maximize, Minus, Plus, WandSparkles, X } from "lucide";
11
+ import { Fragment, jsx, jsxs } from "@jadis/core/jsx-runtime";
12
+ // src/organisms/jl-flow.tsx
13
+ registerIcons({
14
+ check: Check,
15
+ "circle-alert": CircleAlert,
16
+ hourglass: Hourglass,
17
+ "loader-circle": LoaderCircle,
18
+ maximize: Maximize,
19
+ minus: Minus,
20
+ plus: Plus,
21
+ "wand-sparkles": WandSparkles,
22
+ x: X
23
+ });
24
+ var EMPTY = {
25
+ edges: [],
26
+ nodes: []
27
+ };
28
+ var IDLE = { kind: "idle" };
29
+ var DEFAULT_LABEL = "Flow";
30
+ var DOT_SPACING = 20;
31
+ var WHEEL_ZOOM_SPEED = .0018;
32
+ var CLICK_TOLERANCE = 4;
33
+ var NUDGE = 8;
34
+ var LONG_NUDGE = 40;
35
+ var PERCENT = 100;
36
+ var ICON_SIZE = 16;
37
+ var ADD_ICON_SIZE = 12;
38
+ var CONTROL_ICON_SIZE = 14;
39
+ var STATUS_ICON_SIZE = 12;
40
+ var PORT_GAP = 6;
41
+ var ID_LENGTH = 8;
42
+ var DELETE_KEYS = /* @__PURE__ */ new Set(["Delete", "Backspace"]);
43
+ var ARROWS = /* @__PURE__ */ new Map([
44
+ ["ArrowDown", {
45
+ x: 0,
46
+ y: 1
47
+ }],
48
+ ["ArrowLeft", {
49
+ x: -1,
50
+ y: 0
51
+ }],
52
+ ["ArrowRight", {
53
+ x: 1,
54
+ y: 0
55
+ }],
56
+ ["ArrowUp", {
57
+ x: 0,
58
+ y: -1
59
+ }]
60
+ ]);
61
+ var STATUS_ICONS = {
62
+ done: "check",
63
+ failed: "x",
64
+ running: "loader-circle",
65
+ waiting: "hourglass"
66
+ };
67
+ var STATUS_LABELS = {
68
+ done: "Done",
69
+ failed: "Failed",
70
+ running: "Running",
71
+ waiting: "Waiting"
72
+ };
73
+ /** What screen readers hear after a node's label: how it did in the run, and what to fix. */
74
+ var noteOf = (node) => [node.status ? STATUS_LABELS[node.status] : "", node.problem ?? ""].filter((part) => part !== "").join(". ");
75
+ var markOf = (element, name) => element?.closest(`[${name}]`)?.getAttribute(name) ?? void 0;
76
+ var isControl = (event) => event.composedPath().some((element) => element instanceof Element && element.hasAttribute("data-control"));
77
+ function targetOf(event) {
78
+ const element = event.target instanceof Element ? event.target : null;
79
+ return {
80
+ add: markOf(element, "data-add"),
81
+ control: isControl(event),
82
+ edge: markOf(element, "data-edge"),
83
+ node: markOf(element, "data-node"),
84
+ port: markOf(element, "data-port")
85
+ };
86
+ }
87
+ var distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
88
+ var newId = (prefix) => `${prefix}_${crypto.randomUUID().replaceAll("-", "").slice(0, ID_LENGTH)}`;
89
+ var addLabel = (node, port) => port.label ? `Add after ${node.label}, ${port.label}` : `Add after ${node.label}`;
90
+ /**
91
+ * A flow of nodes joined by edges, to edit: people move the nodes, draw an edge from a node's exit to another node,
92
+ * press + on a free exit to ask for the next node, and move and zoom the view. Every edit goes out as a new graph
93
+ * (`change`); the page adds nodes itself, when `add` or `drop` asks. Nodes can show how a run went (`status`,
94
+ * `taken`) and what to fix (`problem`).
95
+ */
96
+ var JlFlow = class extends JlElement {
97
+ static selector = "jl-flow";
98
+ events = this.useEvents();
99
+ graph = this.useChange(EMPTY, (graph) => this.show(graph), {
100
+ attribute: "graph",
101
+ parse: jsonOf(EMPTY)
102
+ });
103
+ /** The id of the selected node; the view moves to show it. */
104
+ selected = this.useChange("", (id) => {
105
+ this.renderSelection();
106
+ this.reveal(id);
107
+ }, { attribute: "selected" });
108
+ /** View only: nodes can be selected, not moved, joined or removed. */
109
+ readonly = this.useChange(false, (value) => {
110
+ this.reflect("readonly", value);
111
+ this.render();
112
+ }, { attribute: "readonly" });
113
+ /**
114
+ * The drag-and-drop type the canvas takes (`application/x-step`): something dragged in carrying it asks for a node
115
+ * where it is dropped, with `drop`.
116
+ */
117
+ dropType = this.useChange("", () => void 0, { attribute: "drop-type" });
118
+ /** The canvas's name for screen readers. */
119
+ label = this.useChange("", (value) => this._refs.viewport.setAttribute("aria-label", value || DEFAULT_LABEL), { attribute: "label" });
120
+ _state = {
121
+ edge: "",
122
+ fitted: false,
123
+ gesture: IDLE,
124
+ graph: EMPTY,
125
+ viewport: {
126
+ x: 0,
127
+ y: 0,
128
+ zoom: 1
129
+ }
130
+ };
131
+ _nodes = /* @__PURE__ */ new Map();
132
+ _svg = {
133
+ draft: svgElement("path", { class: "edge draft" }),
134
+ root: svgElement("svg", {
135
+ class: "edges",
136
+ height: "1",
137
+ width: "1"
138
+ })
139
+ };
140
+ _refs = this.useRefs((ref) => ({
141
+ empty: ref(".empty"),
142
+ fit: ref(".fit"),
143
+ nodes: ref(".nodes"),
144
+ removeEdge: ref(".remove-edge"),
145
+ tidy: ref(".tidy"),
146
+ viewport: ref(".viewport"),
147
+ world: ref(".world"),
148
+ zoomIn: ref(".zoom-in"),
149
+ zoomLabel: ref(".zoom-label"),
150
+ zoomOut: ref(".zoom-out")
151
+ }));
152
+ /** Moves and zooms the view to show every node, never larger than life. */
153
+ fit() {
154
+ this.whenRendered(() => {
155
+ const { width, height } = this._refs.viewport.getBoundingClientRect();
156
+ if (width === 0) return;
157
+ this._state.fitted = true;
158
+ this.setViewport(fitView(this._state.graph.nodes, width, height));
159
+ });
160
+ }
161
+ /** Moves the view, when needed, so that a node is in sight: one just added, or moved with the keys. */
162
+ reveal(id) {
163
+ this.whenRendered(() => {
164
+ const node = this.nodeOf(id);
165
+ const { width, height } = this._refs.viewport.getBoundingClientRect();
166
+ if (!node || width === 0) return;
167
+ this.setViewport(revealIn(this._state.viewport, node, width, height));
168
+ });
169
+ }
170
+ /** Where a node added after an exit goes, as + puts it: a row below, under that exit. */
171
+ positionAfter(from, port) {
172
+ return positionAfter(this._state.graph, from, port);
173
+ }
174
+ /** Where a node added without a place goes: in the middle of the view (its top left corner). */
175
+ centerPosition() {
176
+ const { width, height } = this._refs.viewport.getBoundingClientRect();
177
+ return centeredAt(toWorld(this._state.viewport, {
178
+ x: width / 2,
179
+ y: height / 2
180
+ }));
181
+ }
182
+ /** Lays the nodes out again, in rows from the starts down (`change` gives the new positions), and shows them all. */
183
+ layout() {
184
+ this.whenRendered(() => {
185
+ this.commit(layoutFlow(this._state.graph));
186
+ this.fit();
187
+ });
188
+ }
189
+ templateHtml() {
190
+ return /* @__PURE__ */ jsxs("div", {
191
+ class: "viewport",
192
+ role: "application",
193
+ tabIndex: 0,
194
+ attrs: { part: "base" },
195
+ children: [
196
+ /* @__PURE__ */ jsxs("div", {
197
+ class: "world",
198
+ children: [
199
+ this._svg.root,
200
+ /* @__PURE__ */ jsx("div", { class: "nodes" }),
201
+ /* @__PURE__ */ jsx("button", {
202
+ type: "button",
203
+ class: "remove-edge",
204
+ "data-control": "",
205
+ hidden: true,
206
+ "aria-label": "Remove this edge",
207
+ children: /* @__PURE__ */ jsx(JlIcon, {
208
+ name: "x",
209
+ size: ADD_ICON_SIZE
210
+ })
211
+ })
212
+ ]
213
+ }),
214
+ /* @__PURE__ */ jsx("div", {
215
+ class: "empty",
216
+ "data-control": "",
217
+ hidden: true,
218
+ children: /* @__PURE__ */ jsx("slot", {
219
+ name: "empty",
220
+ children: "Nothing here yet."
221
+ })
222
+ }),
223
+ /* @__PURE__ */ jsxs("div", {
224
+ class: "controls",
225
+ "data-control": "",
226
+ children: [
227
+ /* @__PURE__ */ jsx("button", {
228
+ type: "button",
229
+ class: "control zoom-out",
230
+ title: "Zoom out",
231
+ "aria-label": "Zoom out",
232
+ children: /* @__PURE__ */ jsx(JlIcon, {
233
+ name: "minus",
234
+ size: CONTROL_ICON_SIZE
235
+ })
236
+ }),
237
+ /* @__PURE__ */ jsx("span", { class: "zoom-label" }),
238
+ /* @__PURE__ */ jsx("button", {
239
+ type: "button",
240
+ class: "control zoom-in",
241
+ title: "Zoom in",
242
+ "aria-label": "Zoom in",
243
+ children: /* @__PURE__ */ jsx(JlIcon, {
244
+ name: "plus",
245
+ size: CONTROL_ICON_SIZE
246
+ })
247
+ }),
248
+ /* @__PURE__ */ jsx("button", {
249
+ type: "button",
250
+ class: "control fit",
251
+ title: "Show every node",
252
+ "aria-label": "Show every node",
253
+ children: /* @__PURE__ */ jsx(JlIcon, {
254
+ name: "maximize",
255
+ size: CONTROL_ICON_SIZE
256
+ })
257
+ }),
258
+ /* @__PURE__ */ jsx("button", {
259
+ type: "button",
260
+ class: "control tidy",
261
+ title: "Tidy up the layout",
262
+ "aria-label": "Tidy up the layout",
263
+ children: /* @__PURE__ */ jsx(JlIcon, {
264
+ name: "wand-sparkles",
265
+ size: CONTROL_ICON_SIZE
266
+ })
267
+ })
268
+ ]
269
+ })
270
+ ]
271
+ });
272
+ }
273
+ templateCss() {
274
+ return css`${BASE_STYLES}${jl_flow_default}`;
275
+ }
276
+ onConnect() {
277
+ const viewport = this._refs.viewport;
278
+ this.on(viewport, "pointerdown", (event) => this.pointerDown(event));
279
+ this.on(viewport, "pointermove", (event) => this.pointerMove(event));
280
+ this.on(viewport, "pointerup", (event) => this.pointerUp(event));
281
+ this.on(viewport, "pointercancel", (event) => this.pointerUp(event));
282
+ this.on(viewport, "wheel", (event) => this.wheel(event));
283
+ this.on(viewport, "keydown", (event) => this.key(event));
284
+ this.on(viewport, "click", (event) => this.pressAdd(event));
285
+ this.on(viewport, "dragover", (event) => this.dragOver(event));
286
+ this.on(viewport, "drop", (event) => this.dropOn(event));
287
+ this.on(viewport, "focusin", (event) => {
288
+ const node = targetOf(event).node;
289
+ if (node !== void 0) this.select(node);
290
+ });
291
+ this.on(this._refs.removeEdge, "click", () => this.removeSelection());
292
+ this.on(this._refs.zoomIn, "click", () => this.zoomBy(FLOW_ZOOM.step));
293
+ this.on(this._refs.zoomOut, "click", () => this.zoomBy(1 / FLOW_ZOOM.step));
294
+ this.on(this._refs.fit, "click", () => this.fit());
295
+ this.on(this._refs.tidy, "click", () => this.layout());
296
+ this.applyViewport();
297
+ }
298
+ show(graph) {
299
+ this._state.graph = graph;
300
+ this.render();
301
+ if (!this._state.fitted && graph.nodes.length > 0) requestAnimationFrame(() => this.fit());
302
+ }
303
+ /** An edit: drawn, kept, and sent to the page. */
304
+ commit(graph) {
305
+ this.graph.set(graph);
306
+ this.events.emit("change", graph);
307
+ }
308
+ select(id) {
309
+ if (this._state.edge !== "") {
310
+ this._state.edge = "";
311
+ this.renderEdges();
312
+ }
313
+ if (id === this.selected.get()) return;
314
+ this.selected.set(id);
315
+ this.events.emit("select", id);
316
+ }
317
+ selectEdge(id) {
318
+ this.select("");
319
+ this._state.edge = id;
320
+ this.renderEdges();
321
+ }
322
+ removeSelection() {
323
+ const { edge, graph } = this._state;
324
+ const node = this.selected.get();
325
+ if (edge !== "") {
326
+ this._state.edge = "";
327
+ this.commit(removeEdge(graph, edge));
328
+ return;
329
+ }
330
+ if (node === "") return;
331
+ this.select("");
332
+ this.commit(removeNode(graph, node));
333
+ }
334
+ nodeOf(id) {
335
+ return this._state.graph.nodes.find((node) => node.id === id);
336
+ }
337
+ pointerDown(event) {
338
+ const target = targetOf(event);
339
+ if (event.button !== 0 || target.control || target.add !== void 0) return;
340
+ if (target.node !== void 0) this.select(target.node);
341
+ this._state.gesture = this.gestureFor(target, event);
342
+ this._refs.viewport.setPointerCapture(event.pointerId);
343
+ }
344
+ gestureFor(target, event) {
345
+ const start = {
346
+ x: event.clientX,
347
+ y: event.clientY
348
+ };
349
+ const node = target.node === void 0 ? void 0 : this.nodeOf(target.node);
350
+ if (node && target.port !== void 0 && !this.readonly.get()) return {
351
+ kind: "connect",
352
+ node: node.id,
353
+ port: target.port,
354
+ start
355
+ };
356
+ if (node) {
357
+ const world = this.worldPoint(event);
358
+ const offset = {
359
+ x: world.x - node.position.x,
360
+ y: world.y - node.position.y
361
+ };
362
+ return {
363
+ kind: "drag",
364
+ moved: false,
365
+ node: node.id,
366
+ offset,
367
+ start
368
+ };
369
+ }
370
+ return {
371
+ edge: target.edge ?? "",
372
+ kind: "pan",
373
+ moved: false,
374
+ origin: this._state.viewport,
375
+ start
376
+ };
377
+ }
378
+ pointerMove(event) {
379
+ const gesture = this._state.gesture;
380
+ const screen = {
381
+ x: event.clientX,
382
+ y: event.clientY
383
+ };
384
+ if (gesture.kind === "pan") {
385
+ this._state.gesture = {
386
+ ...gesture,
387
+ moved: gesture.moved || distance(screen, gesture.start) > CLICK_TOLERANCE
388
+ };
389
+ this.setViewport({
390
+ ...gesture.origin,
391
+ x: gesture.origin.x + screen.x - gesture.start.x,
392
+ y: gesture.origin.y + screen.y - gesture.start.y
393
+ });
394
+ return;
395
+ }
396
+ if (gesture.kind === "drag") {
397
+ this.drag(gesture, screen, event);
398
+ return;
399
+ }
400
+ if (gesture.kind === "connect") this.drawDraft(gesture, event);
401
+ }
402
+ drag(gesture, screen, event) {
403
+ const moved = gesture.moved || distance(screen, gesture.start) > CLICK_TOLERANCE;
404
+ if (!moved || this.readonly.get()) return;
405
+ this._state.gesture = {
406
+ ...gesture,
407
+ moved
408
+ };
409
+ const world = this.worldPoint(event);
410
+ const position = snap({
411
+ x: world.x - gesture.offset.x,
412
+ y: world.y - gesture.offset.y
413
+ });
414
+ this._state.graph = moveNode(this._state.graph, gesture.node, position);
415
+ const element = this._nodes.get(gesture.node);
416
+ if (element) this.place(element, position);
417
+ this.renderEdges();
418
+ }
419
+ drawDraft(gesture, event) {
420
+ const node = this.nodeOf(gesture.node);
421
+ const from = node ? outputAnchor(node, gesture.port) : void 0;
422
+ if (from) this._svg.draft.setAttribute("d", edgePath(from, this.worldPoint(event)));
423
+ }
424
+ pointerUp(event) {
425
+ const gesture = this._state.gesture;
426
+ this._state.gesture = IDLE;
427
+ if (gesture.kind === "connect") {
428
+ this._svg.draft.removeAttribute("d");
429
+ this.connectAt(gesture, event);
430
+ return;
431
+ }
432
+ if (gesture.kind === "drag" && gesture.moved) {
433
+ this.commit(this._state.graph);
434
+ return;
435
+ }
436
+ if (gesture.kind === "pan" && !gesture.moved) this.selectEdge(gesture.edge);
437
+ }
438
+ /** An edge dropped on a node joins it; dropped on empty space, it asks for a node there. */
439
+ connectAt(gesture, event) {
440
+ if (distance({
441
+ x: event.clientX,
442
+ y: event.clientY
443
+ }, gesture.start) <= CLICK_TOLERANCE) return;
444
+ const at = this.worldPoint(event);
445
+ const target = nodeAt(this._state.graph, at, gesture.node);
446
+ if (!target) {
447
+ const position = snap({
448
+ x: at.x - FLOW_NODE.width / 2,
449
+ y: at.y
450
+ });
451
+ this.events.emit("add", {
452
+ from: gesture.node,
453
+ port: gesture.port,
454
+ position
455
+ });
456
+ return;
457
+ }
458
+ const joined = connect(this._state.graph, {
459
+ from: gesture.node,
460
+ id: newId("e"),
461
+ port: gesture.port,
462
+ to: target.id
463
+ });
464
+ if (joined) this.commit(joined);
465
+ }
466
+ pressAdd(event) {
467
+ const { add, node } = targetOf(event);
468
+ if (add === void 0 || node === void 0 || this.readonly.get()) return;
469
+ this.events.emit("add", {
470
+ from: node,
471
+ port: add,
472
+ position: positionAfter(this._state.graph, node, add)
473
+ });
474
+ }
475
+ /** Whether a drag carries what the canvas takes: then it may be dropped here. */
476
+ carriesDrop(event) {
477
+ const type = this.dropType.get();
478
+ return type !== "" && !this.readonly.get() && (event.dataTransfer?.types.includes(type) ?? false);
479
+ }
480
+ dragOver(event) {
481
+ if (this.carriesDrop(event)) event.preventDefault();
482
+ }
483
+ dropOn(event) {
484
+ if (!this.carriesDrop(event)) return;
485
+ event.preventDefault();
486
+ event.stopPropagation();
487
+ const data = event.dataTransfer?.getData(this.dropType.get()) ?? "";
488
+ this.events.emit("drop", {
489
+ data,
490
+ position: centeredAt(this.worldPoint(event))
491
+ });
492
+ }
493
+ key(event) {
494
+ const target = targetOf(event);
495
+ if (target.control || target.add !== void 0) return;
496
+ if (event.key === "Escape") {
497
+ this.select("");
498
+ return;
499
+ }
500
+ if (this.readonly.get()) return;
501
+ if (DELETE_KEYS.has(event.key)) {
502
+ event.preventDefault();
503
+ this.removeSelection();
504
+ return;
505
+ }
506
+ this.nudge(event);
507
+ }
508
+ /** The arrows move the selected node; with Shift, further. */
509
+ nudge(event) {
510
+ const arrow = ARROWS.get(event.key);
511
+ const node = this.nodeOf(this.selected.get());
512
+ if (!arrow || !node) return;
513
+ event.preventDefault();
514
+ const step = event.shiftKey ? LONG_NUDGE : NUDGE;
515
+ const position = {
516
+ x: node.position.x + arrow.x * step,
517
+ y: node.position.y + arrow.y * step
518
+ };
519
+ this.commit(moveNode(this._state.graph, node.id, position));
520
+ this.reveal(node.id);
521
+ }
522
+ /** Two fingers or the wheel move the view; with Ctrl or ⌘ (a pinch on a trackpad), they zoom. */
523
+ wheel(event) {
524
+ event.preventDefault();
525
+ const viewport = this._state.viewport;
526
+ if (event.ctrlKey || event.metaKey) {
527
+ this.setViewport(zoomAt(viewport, this.screenPoint(event), Math.exp(-event.deltaY * WHEEL_ZOOM_SPEED)));
528
+ return;
529
+ }
530
+ this.setViewport({
531
+ ...viewport,
532
+ x: viewport.x - event.deltaX,
533
+ y: viewport.y - event.deltaY
534
+ });
535
+ }
536
+ zoomBy(factor) {
537
+ const { width, height } = this._refs.viewport.getBoundingClientRect();
538
+ this.setViewport(zoomAt(this._state.viewport, {
539
+ x: width / 2,
540
+ y: height / 2
541
+ }, factor));
542
+ }
543
+ setViewport(viewport) {
544
+ this._state.viewport = viewport;
545
+ this.applyViewport();
546
+ }
547
+ applyViewport() {
548
+ const { x, y, zoom } = this._state.viewport;
549
+ const viewport = this._refs.viewport;
550
+ this._refs.world.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`;
551
+ viewport.style.backgroundSize = `${DOT_SPACING * zoom}px ${DOT_SPACING * zoom}px`;
552
+ viewport.style.backgroundPosition = `${x}px ${y}px`;
553
+ this._refs.zoomLabel.textContent = `${Math.round(zoom * PERCENT)}%`;
554
+ }
555
+ screenPoint(event) {
556
+ const rect = this._refs.viewport.getBoundingClientRect();
557
+ return {
558
+ x: event.clientX - rect.left,
559
+ y: event.clientY - rect.top
560
+ };
561
+ }
562
+ worldPoint(event) {
563
+ return toWorld(this._state.viewport, this.screenPoint(event));
564
+ }
565
+ render() {
566
+ this._refs.empty.hidden = this._state.graph.nodes.length > 0;
567
+ this.renderNodes();
568
+ this.renderEdges();
569
+ }
570
+ /** The nodes' elements stay from one drawing to the next: the focused one keeps the focus. */
571
+ renderNodes() {
572
+ const nodes = this._state.graph.nodes;
573
+ const ids = new Set(nodes.map((node) => node.id));
574
+ for (const [id, element] of this._nodes) if (!ids.has(id)) {
575
+ element.remove();
576
+ this._nodes.delete(id);
577
+ }
578
+ for (const node of nodes) this.drawNode(this._nodes.get(node.id) ?? this.createNode(node.id), node);
579
+ this.renderSelection();
580
+ }
581
+ createNode(id) {
582
+ const element = /* @__PURE__ */ jsx("div", {
583
+ class: "node",
584
+ "data-node": id,
585
+ tabIndex: 0,
586
+ role: "group",
587
+ "aria-roledescription": "node",
588
+ attrs: { part: "node" }
589
+ });
590
+ this._nodes.set(id, element);
591
+ this._refs.nodes.append(element);
592
+ return element;
593
+ }
594
+ drawNode(element, node) {
595
+ const ports = portsOf(node);
596
+ element.setAttribute("aria-label", node.label);
597
+ setOrRemove(element, "aria-description", noteOf(node));
598
+ element.part.toggle("start", node.start === true);
599
+ setOrRemove(element, "data-tone", node.tone ?? "");
600
+ setOrRemove(element, "data-status", node.status ?? "");
601
+ element.style.height = `${nodeHeight(node)}px`;
602
+ this.paintIcon(element, node.color ?? "");
603
+ element.replaceChildren(/* @__PURE__ */ jsxs(Fragment, { children: [
604
+ node.start === true ? null : /* @__PURE__ */ jsx("span", { class: "input" }),
605
+ /* @__PURE__ */ jsxs("span", {
606
+ class: "head",
607
+ children: [
608
+ node.icon ? /* @__PURE__ */ jsx("span", {
609
+ class: "icon",
610
+ attrs: { part: "icon" },
611
+ children: /* @__PURE__ */ jsx(JlIcon, {
612
+ name: node.icon,
613
+ size: ICON_SIZE
614
+ })
615
+ }) : null,
616
+ /* @__PURE__ */ jsxs("span", {
617
+ class: "text",
618
+ children: [/* @__PURE__ */ jsx("span", {
619
+ class: "label",
620
+ attrs: { part: "label" },
621
+ children: node.label
622
+ }), node.description ? /* @__PURE__ */ jsx("span", {
623
+ class: "description",
624
+ attrs: { part: "description" },
625
+ children: node.description
626
+ }) : null]
627
+ }),
628
+ node.problem ? /* @__PURE__ */ jsx("span", {
629
+ class: "problem",
630
+ title: node.problem,
631
+ attrs: { part: "problem" },
632
+ children: /* @__PURE__ */ jsx(JlIcon, {
633
+ name: "circle-alert",
634
+ size: ICON_SIZE
635
+ })
636
+ }) : null,
637
+ node.status ? /* @__PURE__ */ jsx("span", {
638
+ class: "status",
639
+ title: STATUS_LABELS[node.status],
640
+ attrs: { part: "status" },
641
+ children: /* @__PURE__ */ jsx(JlIcon, {
642
+ name: STATUS_ICONS[node.status],
643
+ size: STATUS_ICON_SIZE
644
+ })
645
+ }) : null
646
+ ]
647
+ }),
648
+ node.detail ? /* @__PURE__ */ jsx("span", {
649
+ class: "detail",
650
+ title: node.detail,
651
+ attrs: { part: "detail" },
652
+ children: node.detail
653
+ }) : null,
654
+ /* @__PURE__ */ jsx("span", {
655
+ class: "ports",
656
+ children: ports.map((port, index) => this.portElement(node, port, index, ports.length))
657
+ })
658
+ ] }));
659
+ this.place(element, node.position);
660
+ }
661
+ /** A colour of the page's own for the icon wins over the tone's. */
662
+ paintIcon(element, color) {
663
+ if (color === "") {
664
+ element.style.removeProperty("--tone");
665
+ return;
666
+ }
667
+ element.style.setProperty("--tone", color);
668
+ }
669
+ portElement(node, port, index, count) {
670
+ const free = !this.readonly.get() && isPortFree(this._state.graph, node, port.id);
671
+ const element = /* @__PURE__ */ jsxs("span", {
672
+ class: "port",
673
+ "data-port": port.id,
674
+ title: "Drag to another node to connect",
675
+ attrs: { part: "port" },
676
+ children: [
677
+ /* @__PURE__ */ jsx("span", { class: "dot" }),
678
+ port.label ? /* @__PURE__ */ jsx("span", {
679
+ class: "port-label",
680
+ children: port.label
681
+ }) : null,
682
+ free ? /* @__PURE__ */ jsx("button", {
683
+ type: "button",
684
+ class: "add",
685
+ "data-add": port.id,
686
+ "aria-label": addLabel(node, port),
687
+ children: /* @__PURE__ */ jsx(JlIcon, {
688
+ name: "plus",
689
+ size: ADD_ICON_SIZE
690
+ })
691
+ }) : null
692
+ ]
693
+ });
694
+ element.style.left = `${portX(index, count)}px`;
695
+ element.style.maxWidth = `${FLOW_NODE.width / count - PORT_GAP}px`;
696
+ setOrRemove(element, "data-tone", port.tone ?? "");
697
+ element.toggleAttribute("data-taken", node.taken === port.id);
698
+ return element;
699
+ }
700
+ place(element, position) {
701
+ element.style.transform = `translate(${position.x}px, ${position.y}px)`;
702
+ }
703
+ renderSelection() {
704
+ const selected = this.selected.get();
705
+ for (const [id, element] of this._nodes) {
706
+ element.classList.toggle("selected", id === selected);
707
+ element.part.toggle("selected", id === selected);
708
+ setOrRemove(element, "aria-current", id === selected ? "true" : "");
709
+ }
710
+ }
711
+ renderEdges() {
712
+ const { edge: selected, graph } = this._state;
713
+ const groups = graph.edges.flatMap((edge) => {
714
+ const ends = edgeEnds(graph, edge);
715
+ return ends ? [this.edgeGroup(edge.id, ends, edge.id === selected, isEdgeTaken(graph, edge))] : [];
716
+ });
717
+ this._svg.root.replaceChildren(...groups, this._svg.draft);
718
+ this.renderEdgeTool();
719
+ }
720
+ /**
721
+ * An edge, with a wide invisible twin that catches the pointer: a thin line is still easy to press. One a run took
722
+ * stands out.
723
+ */
724
+ edgeGroup(id, ends, selected, taken) {
725
+ const d = edgePath(ends.from, ends.to);
726
+ const states = [selected ? "selected" : "", taken ? "taken" : ""].filter((state) => state !== "");
727
+ const group = svgElement("g", {
728
+ class: ["edge-group", ...states].join(" "),
729
+ "data-edge": id
730
+ });
731
+ group.append(svgElement("path", {
732
+ class: "edge-hit",
733
+ d
734
+ }), svgElement("path", {
735
+ class: "edge",
736
+ d,
737
+ "data-tone": ends.tone,
738
+ part: ["edge", ...states].join(" ")
739
+ }));
740
+ return group;
741
+ }
742
+ /** The button that removes the selected edge, in its middle. */
743
+ renderEdgeTool() {
744
+ const { edge: selected, graph } = this._state;
745
+ const edge = graph.edges.find((candidate) => candidate.id === selected);
746
+ const ends = edge ? edgeEnds(graph, edge) : void 0;
747
+ const button = this._refs.removeEdge;
748
+ button.hidden = ends === void 0 || this.readonly.get();
749
+ if (!ends) return;
750
+ const middle = edgeMidpoint(ends.from, ends.to);
751
+ button.style.transform = `translate(${middle.x}px, ${middle.y}px) translate(-50%, -50%)`;
752
+ }
753
+ };
754
+ JlFlow.register();
755
+ export { JlFlow };