interactive-markdown 0.1.1 → 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,1117 @@
1
+ /**
2
+ * Basic directed-graph editor.
3
+ * The model is plain data ({ nodes, edges }) so it can be stored in an ```ivm @graph fence.
4
+ * Gestures: drag a node, drag a side port onto another node, double-click to add or rename.
5
+ */
6
+
7
+ export const NODE_W = 168;
8
+ export const NODE_H = 56;
9
+
10
+ export const GRAPH_KINDS = Object.freeze(["action", "input", "output", "decision"]);
11
+ export const GRAPH_SIDES = Object.freeze(["top", "right", "bottom", "left"]);
12
+ export const GRAPH_PATHS = Object.freeze(["ortho", "straight", "curve"]);
13
+
14
+ const KIND_SET = new Set(GRAPH_KINDS);
15
+ const SIDE_SET = new Set(GRAPH_SIDES);
16
+ const DEFAULT_SPEED = 2.8;
17
+
18
+ /**
19
+ * @typedef {"left"|"right"|"top"|"bottom"} GraphSide
20
+ * @typedef {"ortho"|"straight"|"curve"} GraphPath
21
+ * @typedef {"forward"|"reverse"} GraphDirection
22
+ * @typedef {{ id: string, label: string, x: number, y: number, kind: string }} GraphNode
23
+ * @typedef {{ from: string, to: string, fromSide?: GraphSide, toSide?: GraphSide }} GraphEdge
24
+ * @typedef {{ path: GraphPath, quantity: number, direction: GraphDirection, speed: number }} GraphMotion
25
+ * @typedef {{ nodes: GraphNode[], edges: GraphEdge[], animated?: boolean, motion?: GraphMotion }} GraphModel
26
+ * @typedef {{ type: 'node', id: string } | { type: 'edge', index: number }} GraphSelection
27
+ */
28
+
29
+ /** @param {unknown} kind */
30
+ export function normalizeKind(kind) {
31
+ const k = String(kind || "action").toLowerCase();
32
+ return KIND_SET.has(k) ? k : "action";
33
+ }
34
+
35
+ /**
36
+ * @param {unknown} side
37
+ * @param {GraphSide} fallback
38
+ * @returns {GraphSide}
39
+ */
40
+ export function normalizeSide(side, fallback) {
41
+ const s = String(side || fallback).toLowerCase();
42
+ return SIDE_SET.has(s) ? /** @type {GraphSide} */ (s) : fallback;
43
+ }
44
+
45
+ /**
46
+ * @param {Partial<GraphMotion> | null | undefined} motion
47
+ * @returns {GraphMotion}
48
+ */
49
+ export function normalizeMotion(motion) {
50
+ const path = String(motion?.path || "ortho").toLowerCase();
51
+ const direction = String(motion?.direction || "forward").toLowerCase();
52
+ let quantity = Math.round(Number(motion?.quantity ?? 1));
53
+ if (!Number.isFinite(quantity)) quantity = 1;
54
+ quantity = Math.min(12, Math.max(1, quantity));
55
+ let speed = Number(motion?.speed ?? DEFAULT_SPEED);
56
+ if (!Number.isFinite(speed)) speed = DEFAULT_SPEED;
57
+ speed = Math.min(30, Math.max(0.2, Math.round(speed * 10) / 10));
58
+ return {
59
+ path: path === "straight" || path === "curve" ? /** @type {GraphPath} */ (path) : "ortho",
60
+ quantity,
61
+ direction: direction === "reverse" ? "reverse" : "forward",
62
+ speed,
63
+ };
64
+ }
65
+
66
+ function clamp(n, lo, hi) {
67
+ return Math.min(hi, Math.max(lo, n));
68
+ }
69
+
70
+ function snap(n) {
71
+ return Math.round(n / 8) * 8;
72
+ }
73
+
74
+ /** @param {GraphNode[]} nodes */
75
+ export function nextNodeId(nodes) {
76
+ const used = new Set(nodes.map((n) => n.id));
77
+ let i = 1;
78
+ while (used.has("n" + i)) i++;
79
+ return "n" + i;
80
+ }
81
+
82
+ /**
83
+ * @param {GraphNode[]} nodes
84
+ * @returns {{ x: number, y: number }}
85
+ */
86
+ export function nextNodePosition(nodes) {
87
+ for (let i = 0; i < 300; i++) {
88
+ const x = 32 + (i % 3) * 208;
89
+ const y = 32 + Math.floor(i / 3) * 104;
90
+ const blocked = nodes.some(
91
+ (n) => Math.abs(n.x - x) < NODE_W + 16 && Math.abs(n.y - y) < NODE_H + 16
92
+ );
93
+ if (!blocked) return { x, y };
94
+ }
95
+ return { x: 32, y: 32 + nodes.length * 104 };
96
+ }
97
+
98
+ /**
99
+ * @param {string} label
100
+ * @returns {string}
101
+ */
102
+ export function quoteGraphLabel(label) {
103
+ return '"' + String(label ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
104
+ }
105
+
106
+ /**
107
+ * @param {GraphModel} model
108
+ * @returns {string}
109
+ */
110
+ export function serializeGraph(model) {
111
+ const lines = ["@graph"];
112
+ if (model.animated) lines.push(serializeAnimate(model.motion));
113
+ for (const n of model.nodes) {
114
+ const kind = n.kind && n.kind !== "action" ? ` kind=${n.kind}` : "";
115
+ lines.push(
116
+ `@node ${n.id}: ${quoteGraphLabel(n.label)} x=${Math.round(n.x)} y=${Math.round(n.y)}${kind}`
117
+ );
118
+ }
119
+ for (const e of model.edges) lines.push(serializeEdge(e));
120
+ return lines.join("\n") + "\n";
121
+ }
122
+
123
+ /**
124
+ * @param {Partial<GraphMotion> | null | undefined} motion
125
+ */
126
+ function serializeAnimate(motion) {
127
+ const next = normalizeMotion(motion);
128
+ return `@animate path=${next.path} quantity=${next.quantity} direction=${next.direction} speed=${next.speed}`;
129
+ }
130
+
131
+ /**
132
+ * @param {GraphEdge} edge
133
+ */
134
+ function serializeEdge(edge) {
135
+ let line = `@edge ${edge.from} -> ${edge.to}`;
136
+ const fromSide = normalizeSide(edge.fromSide, "right");
137
+ const toSide = normalizeSide(edge.toSide, "left");
138
+ if (fromSide !== "right") line += ` from=${fromSide}`;
139
+ if (toSide !== "left") line += ` to=${toSide}`;
140
+ return line;
141
+ }
142
+
143
+ /**
144
+ * @param {GraphModel} model
145
+ * @param {{ x?: number, y?: number, label?: string, kind?: string }} [partial]
146
+ * @returns {GraphModel}
147
+ */
148
+ export function addNode(model, partial = {}) {
149
+ const pos = nextNodePosition(model.nodes);
150
+ const node = {
151
+ id: nextNodeId(model.nodes),
152
+ label: partial.label?.trim() ? partial.label.trim() : "Node",
153
+ x: partial.x == null ? pos.x : snap(clamp(partial.x, 0, 4000)),
154
+ y: partial.y == null ? pos.y : snap(clamp(partial.y, 0, 4000)),
155
+ kind: normalizeKind(partial.kind),
156
+ };
157
+ return carry(model, { nodes: model.nodes.concat(node), edges: model.edges.map((e) => ({ ...e })) });
158
+ }
159
+
160
+ /**
161
+ * @param {GraphModel} model
162
+ * @param {string} id
163
+ * @param {number} x
164
+ * @param {number} y
165
+ * @returns {GraphModel}
166
+ */
167
+ export function moveNode(model, id, x, y) {
168
+ return carry(model, {
169
+ nodes: model.nodes.map((n) =>
170
+ n.id === id ? { ...n, x: snap(clamp(x, 0, 4000)), y: snap(clamp(y, 0, 4000)) } : { ...n }
171
+ ),
172
+ edges: model.edges.map((e) => ({ ...e })),
173
+ });
174
+ }
175
+
176
+ /**
177
+ * @param {GraphModel} model
178
+ * @param {string} id
179
+ * @param {string} label
180
+ * @returns {GraphModel}
181
+ */
182
+ export function renameNode(model, id, label) {
183
+ const next = String(label ?? "").trim();
184
+ return carry(model, {
185
+ nodes: model.nodes.map((n) => (n.id === id ? { ...n, label: next || n.label } : { ...n })),
186
+ edges: model.edges.map((e) => ({ ...e })),
187
+ });
188
+ }
189
+
190
+ /**
191
+ * @param {GraphModel} model
192
+ * @param {string} id
193
+ * @param {string} kind
194
+ * @returns {GraphModel}
195
+ */
196
+ export function setNodeKind(model, id, kind) {
197
+ const k = normalizeKind(kind);
198
+ return carry(model, {
199
+ nodes: model.nodes.map((n) => (n.id === id ? { ...n, kind: k } : { ...n })),
200
+ edges: model.edges.map((e) => ({ ...e })),
201
+ });
202
+ }
203
+
204
+ /**
205
+ * @param {GraphModel} model
206
+ * @param {string} from
207
+ * @param {string} to
208
+ * @param {{ fromSide?: GraphSide, toSide?: GraphSide }} [sides]
209
+ * @returns {{ model: GraphModel, ok: boolean, reason?: string }}
210
+ */
211
+ export function addEdge(model, from, to, sides = {}) {
212
+ if (!from || !to || from === to) return { model, ok: false, reason: "self" };
213
+ if (!model.nodes.some((n) => n.id === from) || !model.nodes.some((n) => n.id === to)) {
214
+ return { model, ok: false, reason: "missing" };
215
+ }
216
+ if (model.edges.some((e) => e.from === from && e.to === to)) {
217
+ return { model, ok: false, reason: "duplicate" };
218
+ }
219
+ /** @type {GraphEdge} */
220
+ const edge = { from, to };
221
+ const fromSide = normalizeSide(sides.fromSide, "right");
222
+ const toSide = normalizeSide(sides.toSide, "left");
223
+ if (fromSide !== "right") edge.fromSide = fromSide;
224
+ if (toSide !== "left") edge.toSide = toSide;
225
+ return {
226
+ model: carry(model, {
227
+ nodes: model.nodes.map((n) => ({ ...n })),
228
+ edges: model.edges.concat(edge),
229
+ }),
230
+ ok: true,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * @param {GraphModel} model
236
+ * @param {Partial<GraphMotion>} patch
237
+ * @returns {GraphModel}
238
+ */
239
+ export function setMotion(model, patch) {
240
+ const motion = normalizeMotion({ ...normalizeMotion(model.motion), ...patch });
241
+ const next = carry(model, {
242
+ nodes: model.nodes.map((n) => ({ ...n })),
243
+ edges: model.edges.map((e) => ({ ...e })),
244
+ });
245
+ next.animated = true;
246
+ next.motion = motion;
247
+ return next;
248
+ }
249
+
250
+ /**
251
+ * @param {GraphModel} model
252
+ * @param {GraphSelection | null} selection
253
+ * @returns {GraphModel}
254
+ */
255
+ export function removeSelection(model, selection) {
256
+ if (!selection) return model;
257
+ if (selection.type === "node") {
258
+ return carry(model, {
259
+ nodes: model.nodes.filter((n) => n.id !== selection.id),
260
+ edges: model.edges.filter((e) => e.from !== selection.id && e.to !== selection.id),
261
+ });
262
+ }
263
+ return carry(model, {
264
+ nodes: model.nodes.map((n) => ({ ...n })),
265
+ edges: model.edges.filter((_, i) => i !== selection.index),
266
+ });
267
+ }
268
+
269
+ function cssEscape(value) {
270
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(value);
271
+ return String(value).replace(/[^a-zA-Z0-9_-]/g, (ch) => "\\" + ch);
272
+ }
273
+
274
+ function cloneModel(model) {
275
+ return carry(model, {
276
+ nodes: model.nodes.map((n) => ({ ...n })),
277
+ edges: model.edges.map((e) => ({ ...e })),
278
+ });
279
+ }
280
+
281
+ /** @param {GraphModel} model @param {GraphModel} next */
282
+ function carry(model, next) {
283
+ if (model && model.animated) next.animated = true;
284
+ if (next.motion == null && model && model.motion) next.motion = { ...model.motion };
285
+ return next;
286
+ }
287
+
288
+ function approachX(to) {
289
+ return to.x - 22;
290
+ }
291
+
292
+ function edgeTip(to) {
293
+ const y = to.y + NODE_H / 2;
294
+ return `M ${approachX(to)} ${y} H ${to.x - 6}`;
295
+ }
296
+
297
+ function edgePath(from, to) {
298
+ const x1 = from.x + NODE_W;
299
+ const y1 = from.y + NODE_H / 2;
300
+ const x2 = approachX(to);
301
+ const y2 = to.y + NODE_H / 2;
302
+ const overlapsX = from.x < to.x + NODE_W - 8 && to.x < from.x + NODE_W - 8;
303
+ if (Math.abs(y1 - y2) < 6 && x2 >= x1) return `M ${x1} ${y1} H ${x2}`;
304
+ if (overlapsX && Math.abs(y2 - y1) > NODE_H) {
305
+ const gap = y2 > y1 ? from.y + NODE_H + 16 : from.y - 16;
306
+ const xOut = x1 + 10;
307
+ return `M ${x1} ${y1} H ${xOut} V ${gap} H ${x2} V ${y2}`;
308
+ }
309
+ if (x2 >= x1 - 4) {
310
+ const rail = Math.min(x1 + 12, x2);
311
+ return `M ${x1} ${y1} H ${rail} V ${y2} H ${x2}`;
312
+ }
313
+ const yOut =
314
+ y2 >= y1
315
+ ? Math.max(from.y + NODE_H, to.y + NODE_H) + 24
316
+ : Math.min(from.y, to.y) - 24;
317
+ return `M ${x1} ${y1} H ${x1 + 18} V ${yOut} H ${x2} V ${y2}`;
318
+ }
319
+
320
+ /**
321
+ * @param {GraphNode} node
322
+ * @param {GraphSide} side
323
+ */
324
+ function sideAnchor(node, side) {
325
+ if (side === "left") return { x: node.x, y: node.y + NODE_H / 2, dx: -1, dy: 0 };
326
+ if (side === "top") return { x: node.x + NODE_W / 2, y: node.y, dx: 0, dy: -1 };
327
+ if (side === "bottom") return { x: node.x + NODE_W / 2, y: node.y + NODE_H, dx: 0, dy: 1 };
328
+ return { x: node.x + NODE_W, y: node.y + NODE_H / 2, dx: 1, dy: 0 };
329
+ }
330
+
331
+ /**
332
+ * @param {GraphNode} node
333
+ * @param {{ x: number, y: number }} point
334
+ * @returns {GraphSide}
335
+ */
336
+ function nearestSide(node, point) {
337
+ const nx = (point.x - (node.x + NODE_W / 2)) / (NODE_W / 2);
338
+ const ny = (point.y - (node.y + NODE_H / 2)) / (NODE_H / 2);
339
+ if (Math.abs(nx) >= Math.abs(ny)) return nx >= 0 ? "right" : "left";
340
+ return ny >= 0 ? "bottom" : "top";
341
+ }
342
+
343
+ /**
344
+ * @param {number} value
345
+ */
346
+ function px(value) {
347
+ return String(Math.round(value));
348
+ }
349
+
350
+ /**
351
+ * @param {GraphNode} from
352
+ * @param {GraphNode} to
353
+ * @param {GraphEdge | undefined} edge
354
+ * @param {GraphPath} style
355
+ */
356
+ function edgeRoute(from, to, edge, style) {
357
+ const fromSide = normalizeSide(edge?.fromSide, "right");
358
+ const toSide = normalizeSide(edge?.toSide, "left");
359
+ if (style === "ortho" && fromSide === "right" && toSide === "left") {
360
+ return { d: edgePath(from, to), tip: edgeTip(to) };
361
+ }
362
+ return sideRoute(from, to, fromSide, toSide, style);
363
+ }
364
+
365
+ /**
366
+ * @param {GraphNode} from
367
+ * @param {GraphNode} to
368
+ * @param {GraphSide} fromSide
369
+ * @param {GraphSide} toSide
370
+ * @param {GraphPath} style
371
+ */
372
+ function sideRoute(from, to, fromSide, toSide, style) {
373
+ const a = sideAnchor(from, fromSide);
374
+ const b = sideAnchor(to, toSide);
375
+ const leave = { x: a.x + a.dx * 18, y: a.y + a.dy * 18 };
376
+ const approach = { x: b.x + b.dx * 18, y: b.y + b.dy * 18 };
377
+ const tipEnd = { x: b.x + b.dx * 6, y: b.y + b.dy * 6 };
378
+ const tip = `M ${px(approach.x)} ${px(approach.y)} L ${px(tipEnd.x)} ${px(tipEnd.y)}`;
379
+ if (style === "straight") {
380
+ return { d: `M ${px(a.x)} ${px(a.y)} L ${px(approach.x)} ${px(approach.y)}`, tip };
381
+ }
382
+ if (style === "curve") {
383
+ const c1 = { x: a.x + a.dx * 64, y: a.y + a.dy * 64 };
384
+ const c2 = { x: b.x + b.dx * 64, y: b.y + b.dy * 64 };
385
+ return {
386
+ d: `M ${px(a.x)} ${px(a.y)} C ${px(c1.x)} ${px(c1.y)} ${px(c2.x)} ${px(c2.y)} ${px(approach.x)} ${px(approach.y)}`,
387
+ tip,
388
+ };
389
+ }
390
+ /** @type {{ x: number, y: number }[]} */
391
+ const pts = [
392
+ { x: a.x, y: a.y },
393
+ leave,
394
+ ];
395
+ if (Math.abs(approach.x - leave.x) >= 1 && Math.abs(approach.y - leave.y) >= 1) {
396
+ if (Math.abs(a.dx) >= Math.abs(a.dy)) pts.push({ x: approach.x, y: leave.y });
397
+ else pts.push({ x: leave.x, y: approach.y });
398
+ }
399
+ pts.push(approach);
400
+ let d = "";
401
+ /** @type {{ x: number, y: number } | null} */
402
+ let prev = null;
403
+ for (const p of pts) {
404
+ if (prev && Math.abs(prev.x - p.x) < 0.5 && Math.abs(prev.y - p.y) < 0.5) continue;
405
+ d += d ? ` L ${px(p.x)} ${px(p.y)}` : `M ${px(p.x)} ${px(p.y)}`;
406
+ prev = p;
407
+ }
408
+ return { d, tip };
409
+ }
410
+
411
+ let mountSeq = 0;
412
+
413
+ /**
414
+ * Mount an interactive graph.
415
+ * @param {HTMLElement} container
416
+ * @param {GraphModel} initial
417
+ * @param {{ onChange?: (model: GraphModel) => void }} [options]
418
+ * @returns {{ destroy: () => void, getModel: () => GraphModel }}
419
+ */
420
+ export function mountGraphEditor(container, initial, options = {}) {
421
+ const doc = container.ownerDocument || globalThis.document;
422
+ const markerId = "im-graph-arrow-" + ++mountSeq;
423
+ /** @type {GraphModel} */
424
+ let model = cloneModel(initial);
425
+ /** @type {GraphSelection | null} */
426
+ let selection = null;
427
+ let pendingKind = "action";
428
+ let flashTimer = 0;
429
+ /** @type {{ id: string, ox: number, oy: number, pointerId: number, x0: number, y0: number, moved: boolean } | null} */
430
+ let dragging = null;
431
+ /** @type {{ id: string, at: number } | null} */
432
+ let labelTap = null;
433
+ /** @type {{ fromId: string, fromSide: GraphSide, pointerId: number } | null} */
434
+ let connecting = null;
435
+ let dead = false;
436
+
437
+ container.innerHTML = "";
438
+ const root = doc.createElement("div");
439
+ root.className = "im-graph";
440
+
441
+ const bar = doc.createElement("div");
442
+ bar.className = "im-graph-bar";
443
+
444
+ const addBtn = doc.createElement("button");
445
+ addBtn.type = "button";
446
+ addBtn.dataset.action = "add";
447
+ addBtn.textContent = "Add node";
448
+
449
+ const kindLabel = doc.createElement("label");
450
+ kindLabel.className = "im-graph-kind-label";
451
+ kindLabel.textContent = "Kind";
452
+ const kindSelect = doc.createElement("select");
453
+ kindSelect.dataset.action = "kind";
454
+ kindSelect.setAttribute("aria-label", "Node kind");
455
+ for (const kind of GRAPH_KINDS) {
456
+ const opt = doc.createElement("option");
457
+ opt.value = kind;
458
+ opt.textContent = kind;
459
+ kindSelect.appendChild(opt);
460
+ }
461
+ kindLabel.appendChild(kindSelect);
462
+
463
+ const deleteBtn = doc.createElement("button");
464
+ deleteBtn.type = "button";
465
+ deleteBtn.dataset.action = "delete";
466
+ deleteBtn.textContent = "Delete";
467
+
468
+ const countEl = doc.createElement("span");
469
+ countEl.className = "im-graph-count";
470
+
471
+ bar.append(addBtn, kindLabel, deleteBtn, countEl);
472
+
473
+ const motionBar = doc.createElement("div");
474
+ motionBar.className = "im-graph-motion-bar";
475
+ motionBar.hidden = true;
476
+
477
+ const pathCtl = motionSelect("Path", "path", [
478
+ ["ortho", "Orthogonal"],
479
+ ["straight", "Straight"],
480
+ ["curve", "Curve"],
481
+ ]);
482
+ const qtyCtl = motionNumber("Quantity", "quantity", 1, 12, 1);
483
+ const dirCtl = motionSelect("Direction", "direction", [
484
+ ["forward", "Forward"],
485
+ ["reverse", "Reverse"],
486
+ ]);
487
+ const speedCtl = motionNumber("Seconds", "speed", 0.2, 30, 0.1);
488
+ motionBar.append(pathCtl.wrap, qtyCtl.wrap, dirCtl.wrap, speedCtl.wrap);
489
+
490
+ const help = doc.createElement("p");
491
+ help.className = "im-graph-help";
492
+ help.textContent =
493
+ "Drag a node to move it. Drag a dot on the top, right, bottom, or left onto another node. Double-click a label to rename, or the canvas to add a node. Delete removes the selection.";
494
+
495
+ const canvas = doc.createElement("div");
496
+ canvas.className = "im-graph-canvas";
497
+ canvas.tabIndex = 0;
498
+ canvas.setAttribute("aria-label", "Graph canvas");
499
+
500
+ const stage = doc.createElement("div");
501
+ stage.className = "im-graph-stage";
502
+
503
+ const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
504
+ svg.setAttribute("class", "im-graph-edges");
505
+ const defs = doc.createElementNS("http://www.w3.org/2000/svg", "defs");
506
+ const marker = doc.createElementNS("http://www.w3.org/2000/svg", "marker");
507
+ marker.setAttribute("id", markerId);
508
+ marker.setAttribute("viewBox", "0 0 10 10");
509
+ marker.setAttribute("refX", "8");
510
+ marker.setAttribute("refY", "5");
511
+ marker.setAttribute("markerWidth", "7");
512
+ marker.setAttribute("markerHeight", "7");
513
+ marker.setAttribute("orient", "auto");
514
+ const markerPath = doc.createElementNS("http://www.w3.org/2000/svg", "path");
515
+ markerPath.setAttribute("d", "M1.2 1.4 L8.4 5 L1.2 8.6");
516
+ markerPath.setAttribute("fill", "none");
517
+ markerPath.setAttribute("stroke", "#b7c6f0");
518
+ markerPath.setAttribute("stroke-width", "1.5");
519
+ markerPath.setAttribute("stroke-linecap", "round");
520
+ markerPath.setAttribute("stroke-linejoin", "round");
521
+ marker.appendChild(markerPath);
522
+ defs.appendChild(marker);
523
+ svg.appendChild(defs);
524
+ const edgeLayer = doc.createElementNS("http://www.w3.org/2000/svg", "g");
525
+ svg.appendChild(edgeLayer);
526
+ const tempPath = doc.createElementNS("http://www.w3.org/2000/svg", "path");
527
+ tempPath.setAttribute("class", "im-graph-edge im-graph-edge-temp");
528
+ tempPath.setAttribute("fill", "none");
529
+ svg.appendChild(tempPath);
530
+
531
+ const empty = doc.createElement("div");
532
+ empty.className = "im-graph-empty";
533
+ empty.textContent = "This graph is empty. Add a node, or double-click the canvas.";
534
+
535
+ stage.append(svg, empty);
536
+ canvas.appendChild(stage);
537
+ root.append(bar, motionBar, help, canvas);
538
+ container.appendChild(root);
539
+
540
+ function commit() {
541
+ if (dead) return;
542
+ options.onChange?.(cloneModel(model));
543
+ }
544
+
545
+ function flash(msg) {
546
+ countEl.textContent = msg;
547
+ clearTimeout(flashTimer);
548
+ flashTimer = setTimeout(syncChrome, 1400);
549
+ }
550
+
551
+ /**
552
+ * @param {string} label
553
+ * @param {string} name
554
+ * @param {[string, string][]} choices
555
+ */
556
+ function motionSelect(label, name, choices) {
557
+ const wrap = doc.createElement("label");
558
+ wrap.className = "im-graph-kind-label";
559
+ wrap.appendChild(doc.createTextNode(label));
560
+ const select = doc.createElement("select");
561
+ select.dataset.motion = name;
562
+ select.setAttribute("aria-label", label);
563
+ for (const [value, text] of choices) {
564
+ const opt = doc.createElement("option");
565
+ opt.value = value;
566
+ opt.textContent = text;
567
+ select.appendChild(opt);
568
+ }
569
+ wrap.appendChild(select);
570
+ return { wrap, select };
571
+ }
572
+
573
+ /**
574
+ * @param {string} label
575
+ * @param {string} name
576
+ * @param {number} min
577
+ * @param {number} max
578
+ * @param {number} step
579
+ */
580
+ function motionNumber(label, name, min, max, step) {
581
+ const wrap = doc.createElement("label");
582
+ wrap.className = "im-graph-kind-label";
583
+ wrap.appendChild(doc.createTextNode(label));
584
+ const input = doc.createElement("input");
585
+ input.type = "number";
586
+ input.dataset.motion = name;
587
+ input.min = String(min);
588
+ input.max = String(max);
589
+ input.step = String(step);
590
+ input.setAttribute("aria-label", label);
591
+ wrap.appendChild(input);
592
+ return { wrap, input };
593
+ }
594
+
595
+ /**
596
+ * @param {HTMLSelectElement} select
597
+ * @param {string} value
598
+ */
599
+ function setSelect(select, value) {
600
+ for (const opt of select.querySelectorAll("option")) {
601
+ const match = (opt.getAttribute("value") || "") === value;
602
+ if (match) opt.setAttribute("selected", "");
603
+ else opt.removeAttribute("selected");
604
+ try {
605
+ opt.selected = match;
606
+ } catch (_) {
607
+ /* linkedom option.selected can be locked */
608
+ }
609
+ }
610
+ }
611
+
612
+ /**
613
+ * @param {HTMLSelectElement} select
614
+ */
615
+ function readSelect(select) {
616
+ const options = [...select.querySelectorAll("option")];
617
+ const live = options.find((opt) => opt.selected);
618
+ if (live) return live.getAttribute("value") || live.value || "";
619
+ const marked = options.find((opt) => opt.hasAttribute("selected"));
620
+ return marked ? marked.getAttribute("value") || marked.value || "" : "";
621
+ }
622
+
623
+ function setKindSelect(kind) {
624
+ const next = normalizeKind(kind);
625
+ for (const opt of kindSelect.querySelectorAll("option")) {
626
+ if (opt.getAttribute("value") === next) opt.setAttribute("selected", "");
627
+ else opt.removeAttribute("selected");
628
+ }
629
+ }
630
+
631
+ function syncChrome() {
632
+ const node = selection?.type === "node" ? model.nodes.find((n) => n.id === selection.id) : null;
633
+ setKindSelect(node ? node.kind : pendingKind);
634
+ deleteBtn.disabled = !selection;
635
+ countEl.textContent = `${model.nodes.length} node${model.nodes.length === 1 ? "" : "s"} · ${model.edges.length} edge${model.edges.length === 1 ? "" : "s"}`;
636
+ motionBar.hidden = !model.animated;
637
+ if (model.animated) {
638
+ const motion = normalizeMotion(model.motion);
639
+ setSelect(pathCtl.select, motion.path);
640
+ setSelect(dirCtl.select, motion.direction);
641
+ if (doc.activeElement !== qtyCtl.input) qtyCtl.input.value = String(motion.quantity);
642
+ if (doc.activeElement !== speedCtl.input) speedCtl.input.value = String(motion.speed);
643
+ }
644
+ }
645
+
646
+ function contentSize() {
647
+ let w = 640;
648
+ let h = 300;
649
+ for (const n of model.nodes) {
650
+ w = Math.max(w, n.x + NODE_W + 40);
651
+ h = Math.max(h, n.y + NODE_H + 48);
652
+ }
653
+ return { w, h };
654
+ }
655
+
656
+ function paintEdges() {
657
+ while (edgeLayer.firstChild) edgeLayer.removeChild(edgeLayer.firstChild);
658
+ const motion = model.animated ? normalizeMotion(model.motion) : null;
659
+ model.edges.forEach((e, index) => {
660
+ const from = model.nodes.find((n) => n.id === e.from);
661
+ const to = model.nodes.find((n) => n.id === e.to);
662
+ if (!from || !to) return;
663
+ const route = edgeRoute(from, to, e, motion ? motion.path : "ortho");
664
+ const selected = selection?.type === "edge" && selection.index === index;
665
+ const hit = doc.createElementNS("http://www.w3.org/2000/svg", "path");
666
+ hit.setAttribute("class", "im-graph-edge-hit");
667
+ hit.setAttribute("d", `${route.d} ${route.tip}`);
668
+ hit.dataset.edge = String(index);
669
+ const vis = doc.createElementNS("http://www.w3.org/2000/svg", "path");
670
+ vis.setAttribute(
671
+ "class",
672
+ "im-graph-edge" +
673
+ (motion ? " is-animated" : "") +
674
+ (motion?.direction === "reverse" ? " is-reverse" : "") +
675
+ (selected ? " is-selected" : "")
676
+ );
677
+ vis.setAttribute("d", route.d);
678
+ if (motion) vis.style.animationDuration = Math.max(0.35, motion.speed / 2) + "s";
679
+ const head = doc.createElementNS("http://www.w3.org/2000/svg", "path");
680
+ head.setAttribute("class", "im-graph-edge" + (selected ? " is-selected" : ""));
681
+ head.setAttribute("d", route.tip);
682
+ head.setAttribute("marker-end", `url(#${markerId})`);
683
+ edgeLayer.append(hit, vis, head);
684
+ if (motion) {
685
+ for (const dot of travelingDots(route.d, motion)) edgeLayer.appendChild(dot);
686
+ }
687
+ });
688
+ }
689
+
690
+ /**
691
+ * @param {string} path
692
+ * @param {GraphMotion} motion
693
+ */
694
+ function travelingDots(path, motion) {
695
+ /** @type {SVGCircleElement[]} */
696
+ const dots = [];
697
+ for (let i = 0; i < motion.quantity; i++) {
698
+ const dot = doc.createElementNS("http://www.w3.org/2000/svg", "circle");
699
+ dot.setAttribute("class", "im-graph-motion");
700
+ dot.setAttribute("r", "4.5");
701
+ dot.setAttribute("fill", "#f472b6");
702
+ const anim = doc.createElementNS("http://www.w3.org/2000/svg", "animateMotion");
703
+ anim.setAttribute("dur", motion.speed + "s");
704
+ anim.setAttribute("repeatCount", "indefinite");
705
+ anim.setAttribute("path", path);
706
+ if (motion.direction === "reverse") {
707
+ anim.setAttribute("keyPoints", "1;0");
708
+ anim.setAttribute("keyTimes", "0;1");
709
+ anim.setAttribute("calcMode", "linear");
710
+ }
711
+ if (i > 0) anim.setAttribute("begin", `${(-motion.speed * i) / motion.quantity}s`);
712
+ dot.appendChild(anim);
713
+ dots.push(dot);
714
+ }
715
+ return dots;
716
+ }
717
+
718
+ function renderNode(node) {
719
+ const el = doc.createElement("div");
720
+ el.className =
721
+ "im-graph-node im-graph-kind-" +
722
+ node.kind +
723
+ (selection?.type === "node" && selection.id === node.id ? " is-selected" : "");
724
+ el.dataset.id = node.id;
725
+ el.style.transform = `translate(${node.x}px, ${node.y}px)`;
726
+ el.setAttribute("role", "group");
727
+ el.setAttribute("aria-label", node.label);
728
+
729
+ if (node.kind === "decision") {
730
+ const diamond = doc.createElement("span");
731
+ diamond.className = "im-graph-diamond";
732
+ diamond.setAttribute("aria-hidden", "true");
733
+ el.appendChild(diamond);
734
+ }
735
+
736
+ const label = doc.createElement("div");
737
+ label.className = "im-graph-label";
738
+ label.textContent = node.label;
739
+ label.title = "Double-click to rename";
740
+ el.appendChild(label);
741
+
742
+ for (const side of GRAPH_SIDES) {
743
+ const port = doc.createElement("button");
744
+ port.type = "button";
745
+ port.className = "im-graph-port im-graph-port-" + side;
746
+ port.dataset.side = side;
747
+ port.setAttribute("aria-label", `Connect from the ${side} of ${node.label}`);
748
+ el.appendChild(port);
749
+ }
750
+ return el;
751
+ }
752
+
753
+ function paint() {
754
+ const size = contentSize();
755
+ stage.style.width = size.w + "px";
756
+ stage.style.height = size.h + "px";
757
+ stage.classList.toggle("is-animated", !!model.animated);
758
+ empty.hidden = model.nodes.length > 0;
759
+ for (const el of stage.querySelectorAll(".im-graph-node")) el.remove();
760
+ for (const n of model.nodes) stage.appendChild(renderNode(n));
761
+ paintEdges();
762
+ syncChrome();
763
+ }
764
+
765
+ function focusCanvas() {
766
+ try {
767
+ canvas.focus?.({ preventScroll: true });
768
+ } catch (_) {
769
+ try {
770
+ canvas.focus?.();
771
+ } catch (_) {
772
+ /* ignore */
773
+ }
774
+ }
775
+ }
776
+
777
+ function markSelection() {
778
+ for (const el of stage.querySelectorAll(".im-graph-node")) {
779
+ el.classList.toggle("is-selected", selection?.type === "node" && el.dataset.id === selection.id);
780
+ }
781
+ for (const el of stage.querySelectorAll(".im-graph-edge.is-selected")) el.classList.remove("is-selected");
782
+ syncChrome();
783
+ }
784
+
785
+ function selectNode(id) {
786
+ selection = { type: "node", id };
787
+ paint();
788
+ focusCanvas();
789
+ }
790
+
791
+ function apply(next, opts = {}) {
792
+ model = next;
793
+ if (opts.selection !== undefined) selection = opts.selection;
794
+ if (selection?.type === "node" && !model.nodes.some((n) => n.id === selection.id)) selection = null;
795
+ if (selection?.type === "edge" && (selection.index < 0 || selection.index >= model.edges.length)) {
796
+ selection = null;
797
+ }
798
+ paint();
799
+ if (opts.commit !== false) commit();
800
+ }
801
+
802
+ addBtn.addEventListener("click", () => {
803
+ const before = model.nodes.length;
804
+ model = addNode(model, { kind: pendingKind });
805
+ const created = model.nodes[model.nodes.length - 1];
806
+ selection = created ? { type: "node", id: created.id } : null;
807
+ if (model.nodes.length === before) return;
808
+ paint();
809
+ commit();
810
+ });
811
+
812
+ deleteBtn.addEventListener("click", () => {
813
+ if (!selection) return;
814
+ apply(removeSelection(model, selection), { selection: null });
815
+ });
816
+
817
+ pathCtl.select.addEventListener("change", () => {
818
+ apply(setMotion(model, { path: /** @type {GraphPath} */ (readSelect(pathCtl.select) || "ortho") }));
819
+ });
820
+ dirCtl.select.addEventListener("change", () => {
821
+ const direction = readSelect(dirCtl.select) === "reverse" ? "reverse" : "forward";
822
+ apply(setMotion(model, { direction }));
823
+ });
824
+ qtyCtl.input.addEventListener("change", () => {
825
+ apply(setMotion(model, { quantity: Number(qtyCtl.input.value) }));
826
+ });
827
+ speedCtl.input.addEventListener("change", () => {
828
+ apply(setMotion(model, { speed: Number(speedCtl.input.value) }));
829
+ });
830
+
831
+ kindSelect.addEventListener("change", () => {
832
+ const kind = normalizeKind(kindSelect.value);
833
+ if (selection?.type === "node") {
834
+ pendingKind = kind;
835
+ apply(setNodeKind(model, selection.id, kind));
836
+ return;
837
+ }
838
+ pendingKind = kind;
839
+ });
840
+
841
+ canvas.addEventListener("keydown", (e) => {
842
+ if (e.key !== "Delete" && e.key !== "Backspace") return;
843
+ const t = /** @type {HTMLElement} */ (e.target);
844
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT")) return;
845
+ if (!selection) return;
846
+ e.preventDefault();
847
+ apply(removeSelection(model, selection), { selection: null });
848
+ });
849
+
850
+ function pointInStage(e) {
851
+ const rect = stage.getBoundingClientRect();
852
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
853
+ }
854
+
855
+ function beginRename(id) {
856
+ const node = model.nodes.find((n) => n.id === id);
857
+ const el = stage.querySelector(`.im-graph-node[data-id="${cssEscape(id)}"]`);
858
+ const label = el?.querySelector(".im-graph-label");
859
+ if (!node || !el || !label) return;
860
+ const input = doc.createElement("input");
861
+ input.type = "text";
862
+ input.className = "im-graph-rename";
863
+ input.value = node.label;
864
+ input.setAttribute("aria-label", "Node label");
865
+ label.replaceWith(input);
866
+ const focusInput = () => {
867
+ try {
868
+ input.focus({ preventScroll: true });
869
+ input.select?.();
870
+ } catch (_) {
871
+ try {
872
+ input.focus();
873
+ } catch (_) {
874
+ /* ignore */
875
+ }
876
+ }
877
+ };
878
+ focusInput();
879
+ requestAnimationFrame(focusInput);
880
+ let done = false;
881
+ let touched = false;
882
+ let inside = true;
883
+ const onDocDown = (ev) => {
884
+ if (!input.contains(ev.target)) inside = false;
885
+ };
886
+ doc.addEventListener("pointerdown", onDocDown, true);
887
+ const finish = (save) => {
888
+ if (done) return;
889
+ done = true;
890
+ doc.removeEventListener("pointerdown", onDocDown, true);
891
+ if (save) model = renameNode(model, id, input.value);
892
+ selection = { type: "node", id };
893
+ paint();
894
+ if (save) commit();
895
+ };
896
+ input.addEventListener("keydown", (e) => {
897
+ if (e.key === "Enter") {
898
+ e.preventDefault();
899
+ finish(true);
900
+ } else if (e.key === "Escape") {
901
+ e.preventDefault();
902
+ finish(false);
903
+ }
904
+ e.stopPropagation();
905
+ });
906
+ input.addEventListener("input", () => {
907
+ touched = true;
908
+ });
909
+ input.addEventListener("pointerdown", (e) => e.stopPropagation());
910
+ input.addEventListener("click", (e) => e.stopPropagation());
911
+ input.addEventListener("blur", () => {
912
+ if (done) return;
913
+ if (!touched && inside) {
914
+ setTimeout(focusInput, 0);
915
+ return;
916
+ }
917
+ finish(true);
918
+ });
919
+ }
920
+
921
+ stage.addEventListener("pointerdown", (e) => {
922
+ if (dead) return;
923
+ const target = /** @type {Element} */ (e.target);
924
+ if (target.closest?.(".im-graph-rename")) return;
925
+
926
+ const port = target.closest?.(".im-graph-port");
927
+ if (port) {
928
+ e.preventDefault();
929
+ e.stopPropagation();
930
+ const id = port.closest(".im-graph-node")?.dataset.id;
931
+ if (!id) return;
932
+ connecting = {
933
+ fromId: id,
934
+ fromSide: normalizeSide(port.dataset.side, "right"),
935
+ pointerId: e.pointerId,
936
+ };
937
+ selection = { type: "node", id };
938
+ paint();
939
+ try {
940
+ stage.setPointerCapture(e.pointerId);
941
+ } catch (_) {
942
+ /* ignore */
943
+ }
944
+ return;
945
+ }
946
+
947
+ const nodeEl = target.closest?.(".im-graph-node");
948
+ if (!nodeEl) return;
949
+ const id = nodeEl.dataset.id;
950
+ if (!id) return;
951
+ const node = model.nodes.find((n) => n.id === id);
952
+ if (!node) return;
953
+ const onLabel = !!target.closest?.(".im-graph-label");
954
+ const now = Date.now();
955
+ if (onLabel && labelTap && labelTap.id === id && now - labelTap.at < 700) {
956
+ labelTap = null;
957
+ dragging = null;
958
+ e.preventDefault();
959
+ e.stopPropagation();
960
+ try {
961
+ if (stage.hasPointerCapture?.(e.pointerId)) stage.releasePointerCapture(e.pointerId);
962
+ } catch (_) {
963
+ /* ignore */
964
+ }
965
+ beginRename(id);
966
+ return;
967
+ }
968
+ labelTap = onLabel ? { id, at: now } : null;
969
+ const p = pointInStage(e);
970
+ dragging = {
971
+ id,
972
+ ox: p.x - node.x,
973
+ oy: p.y - node.y,
974
+ pointerId: e.pointerId,
975
+ x0: node.x,
976
+ y0: node.y,
977
+ moved: false,
978
+ };
979
+ selection = { type: "node", id };
980
+ markSelection();
981
+ focusCanvas();
982
+ try {
983
+ stage.setPointerCapture(e.pointerId);
984
+ } catch (_) {
985
+ /* ignore */
986
+ }
987
+ });
988
+
989
+ stage.addEventListener("pointermove", (e) => {
990
+ if (dragging && e.pointerId === dragging.pointerId) {
991
+ const node = model.nodes.find((n) => n.id === dragging.id);
992
+ const el = stage.querySelector(`.im-graph-node[data-id="${cssEscape(dragging.id)}"]`);
993
+ if (!node || !el) return;
994
+ const p = pointInStage(e);
995
+ const nx = snap(clamp(p.x - dragging.ox, 0, 4000));
996
+ const ny = snap(clamp(p.y - dragging.oy, 0, 4000));
997
+ if (!dragging.moved && nx === dragging.x0 && ny === dragging.y0) return;
998
+ dragging.moved = true;
999
+ node.x = nx;
1000
+ node.y = ny;
1001
+ el.style.transform = `translate(${node.x}px, ${node.y}px)`;
1002
+ const size = contentSize();
1003
+ stage.style.width = size.w + "px";
1004
+ stage.style.height = size.h + "px";
1005
+ paintEdges();
1006
+ return;
1007
+ }
1008
+ if (connecting && e.pointerId === connecting.pointerId) {
1009
+ const from = model.nodes.find((n) => n.id === connecting.fromId);
1010
+ if (!from) return;
1011
+ const p = pointInStage(e);
1012
+ const anchor = sideAnchor(from, connecting.fromSide || "right");
1013
+ tempPath.setAttribute("d", `M ${anchor.x} ${anchor.y} L ${p.x} ${p.y}`);
1014
+ }
1015
+ });
1016
+
1017
+ stage.addEventListener("pointerup", (e) => {
1018
+ if (dragging && e.pointerId === dragging.pointerId) {
1019
+ const moved = dragging.moved;
1020
+ const id = dragging.id;
1021
+ dragging = null;
1022
+ if (!moved) return;
1023
+ const node = model.nodes.find((n) => n.id === id);
1024
+ if (node) model = moveNode(model, id, node.x, node.y);
1025
+ paint();
1026
+ commit();
1027
+ return;
1028
+ }
1029
+ if (connecting && e.pointerId === connecting.pointerId) {
1030
+ const fromId = connecting.fromId;
1031
+ const fromSide = connecting.fromSide;
1032
+ connecting = null;
1033
+ tempPath.setAttribute("d", "");
1034
+ const hit = doc.elementFromPoint?.(e.clientX, e.clientY);
1035
+ const toId = hit?.closest?.(".im-graph-node")?.dataset.id;
1036
+ if (!toId) {
1037
+ paint();
1038
+ return;
1039
+ }
1040
+ const toNode = model.nodes.find((n) => n.id === toId);
1041
+ const portSide = hit?.closest?.(".im-graph-port")?.dataset.side;
1042
+ const toSide = portSide
1043
+ ? normalizeSide(portSide, "left")
1044
+ : toNode
1045
+ ? nearestSide(toNode, pointInStage(e))
1046
+ : "left";
1047
+ const result = addEdge(model, fromId, toId, { fromSide, toSide });
1048
+ if (!result.ok) {
1049
+ paint();
1050
+ if (result.reason === "duplicate") flash("Those nodes are already connected");
1051
+ else if (result.reason === "self") flash("A node cannot connect to itself");
1052
+ return;
1053
+ }
1054
+ model = result.model;
1055
+ selection = { type: "edge", index: model.edges.length - 1 };
1056
+ paint();
1057
+ commit();
1058
+ }
1059
+ });
1060
+
1061
+ stage.addEventListener("pointercancel", () => {
1062
+ dragging = null;
1063
+ labelTap = null;
1064
+ connecting = null;
1065
+ tempPath.setAttribute("d", "");
1066
+ paint();
1067
+ });
1068
+
1069
+ stage.addEventListener("click", (e) => {
1070
+ const target = /** @type {Element} */ (e.target);
1071
+ const edge = target.closest?.("[data-edge]");
1072
+ if (edge) {
1073
+ selection = { type: "edge", index: Number(edge.dataset.edge) };
1074
+ paint();
1075
+ focusCanvas();
1076
+ return;
1077
+ }
1078
+ if (!target.closest?.(".im-graph-node")) {
1079
+ selection = null;
1080
+ paint();
1081
+ }
1082
+ });
1083
+
1084
+ stage.addEventListener("dblclick", (e) => {
1085
+ const target = /** @type {Element} */ (e.target);
1086
+ const nodeEl = target.closest?.(".im-graph-node");
1087
+ if (nodeEl?.dataset.id && target.closest?.(".im-graph-label")) {
1088
+ e.preventDefault();
1089
+ beginRename(nodeEl.dataset.id);
1090
+ return;
1091
+ }
1092
+ if (nodeEl) return;
1093
+ const p = pointInStage(e);
1094
+ model = addNode(model, {
1095
+ x: p.x - NODE_W / 2,
1096
+ y: p.y - NODE_H / 2,
1097
+ kind: pendingKind,
1098
+ });
1099
+ const created = model.nodes[model.nodes.length - 1];
1100
+ selection = created ? { type: "node", id: created.id } : null;
1101
+ paint();
1102
+ commit();
1103
+ });
1104
+
1105
+ paint();
1106
+
1107
+ return {
1108
+ destroy() {
1109
+ dead = true;
1110
+ clearTimeout(flashTimer);
1111
+ container.innerHTML = "";
1112
+ },
1113
+ getModel() {
1114
+ return cloneModel(model);
1115
+ },
1116
+ };
1117
+ }