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