interactive-markdown 0.1.0 → 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,956 @@
1
+ /**
2
+ * Preview block editor. Edits commit markdown through onChange and do not remount the editor.
3
+ */
4
+
5
+ import {
6
+ insertFeature,
7
+ moveBlock,
8
+ parseDocument,
9
+ removeBlock,
10
+ removeControl,
11
+ removeShape,
12
+ serializeDocument,
13
+ serializeFigure,
14
+ updateBlock,
15
+ updateControl,
16
+ updateShape,
17
+ NAME_RE,
18
+ } from "./document.js";
19
+ import { mountGraphEditor } from "./graph-editor.js";
20
+ import { mountIvm, resetNamespaces } from "./render.js";
21
+
22
+ const SHAPE_FIELDS = {
23
+ rect: ["x", "y", "width", "height", "rx", "fill", "stroke", "stroke-width", "opacity"],
24
+ circle: ["cx", "cy", "r", "fill", "stroke", "stroke-width"],
25
+ ellipse: ["cx", "cy", "rx", "ry", "fill", "stroke", "stroke-width"],
26
+ line: ["x1", "y1", "x2", "y2", "stroke", "stroke-width"],
27
+ path: ["d", "fill", "stroke", "stroke-width", "opacity"],
28
+ text: ["x", "y", "fill", "font-size", "text"],
29
+ };
30
+
31
+ const ADD_GROUPS = [
32
+ {
33
+ label: "Markdown",
34
+ items: [
35
+ ["heading", "Heading"],
36
+ ["paragraph", "Paragraph"],
37
+ ["bullets", "Bullets"],
38
+ ],
39
+ },
40
+ {
41
+ label: "Interactive",
42
+ items: [
43
+ ["slider", "Slider"],
44
+ ["timer", "Timer"],
45
+ ["input", "Text field"],
46
+ ["toggle", "Toggle"],
47
+ ["formula", "Formula"],
48
+ ["shape", "Shape: rect", "rect"],
49
+ ["shape", "Shape: circle", "circle"],
50
+ ["shape", "Shape: line", "line"],
51
+ ["shape", "Shape: path", "path"],
52
+ ["shape", "Shape: text", "text"],
53
+ ["graph", "Graph"],
54
+ ],
55
+ },
56
+ ];
57
+
58
+ /**
59
+ * @param {HTMLElement} container
60
+ * @param {string} source
61
+ * @param {{ onChange?: (markdown: string) => void, onStatus?: (message: string) => void, marked?: { parse: (markdown: string) => string } }} [options]
62
+ * @returns {{ destroy: () => void }}
63
+ */
64
+ export function mountDocumentEditor(container, source, options = {}) {
65
+ const docEl = container.ownerDocument || globalThis.document;
66
+ let doc = parseDocument(source);
67
+ /** @type {string | null} */
68
+ let selectedId = null;
69
+ /** @type {Map<string, { destroy: () => void }>} */
70
+ const mounts = new Map();
71
+ let dead = false;
72
+ /** @type {{ pointerId: number, x: number, y: number, feature: string, shape: string, label: string, active: boolean, at?: number, figureId: string | null } | null} */
73
+ let drag = null;
74
+ let suppressClick = false;
75
+
76
+ container.innerHTML = "";
77
+ const root = docEl.createElement("div");
78
+ root.className = "im-doc";
79
+
80
+ const bar = docEl.createElement("div");
81
+ bar.className = "im-doc-bar";
82
+ const addWrap = docEl.createElement("div");
83
+ addWrap.className = "im-doc-add";
84
+ const addToggle = docEl.createElement("button");
85
+ addToggle.type = "button";
86
+ addToggle.className = "im-doc-add-toggle";
87
+ addToggle.dataset.menuToggle = "add";
88
+ addToggle.setAttribute("aria-haspopup", "menu");
89
+ addToggle.setAttribute("aria-expanded", "false");
90
+ addToggle.textContent = "Add";
91
+ const menu = docEl.createElement("div");
92
+ menu.className = "im-doc-menu";
93
+ menu.hidden = true;
94
+ menu.setAttribute("role", "menu");
95
+ for (const group of ADD_GROUPS) {
96
+ const section = docEl.createElement("div");
97
+ section.className = "im-doc-menu-group";
98
+ const label = docEl.createElement("div");
99
+ label.className = "im-doc-menu-label";
100
+ label.textContent = group.label;
101
+ section.appendChild(label);
102
+ for (const [feature, itemLabel, shape] of group.items) {
103
+ const button = docEl.createElement("button");
104
+ button.type = "button";
105
+ button.dataset.add = feature;
106
+ if (shape) button.dataset.shape = shape;
107
+ button.textContent = itemLabel;
108
+ section.appendChild(button);
109
+ }
110
+ menu.appendChild(section);
111
+ }
112
+ addWrap.append(addToggle, menu);
113
+ bar.appendChild(addWrap);
114
+
115
+ const closeMenu = () => {
116
+ menu.hidden = true;
117
+ addToggle.setAttribute("aria-expanded", "false");
118
+ };
119
+ const onDocPointer = (event) => {
120
+ if (menu.hidden || addWrap.contains(event.target)) return;
121
+ closeMenu();
122
+ };
123
+ docEl.addEventListener("pointerdown", onDocPointer);
124
+
125
+ const empty = docEl.createElement("p");
126
+ empty.className = "im-doc-empty";
127
+ empty.textContent = "Drag from Add to place a heading, a control, a shape, or a graph.";
128
+
129
+ const blocksEl = docEl.createElement("div");
130
+ blocksEl.className = "im-doc-blocks";
131
+
132
+ root.append(bar, empty, blocksEl);
133
+ const ghost = docEl.createElement("div");
134
+ ghost.className = "im-doc-drag-ghost";
135
+ ghost.hidden = true;
136
+ const guide = docEl.createElement("div");
137
+ guide.className = "im-doc-snap";
138
+ guide.hidden = true;
139
+ root.append(ghost, guide);
140
+ container.appendChild(root);
141
+
142
+ root.addEventListener("click", onClick);
143
+ root.addEventListener("input", onField);
144
+ root.addEventListener("change", onField);
145
+ root.addEventListener("focusout", onBlur);
146
+ menu.addEventListener("pointerdown", onMenuPointerDown);
147
+
148
+ paint();
149
+
150
+ return {
151
+ destroy() {
152
+ dead = true;
153
+ endDragListeners();
154
+ docEl.removeEventListener("pointerdown", onDocPointer);
155
+ destroyMounts();
156
+ root.remove();
157
+ },
158
+ };
159
+
160
+ function commit() {
161
+ if (dead) return;
162
+ options.onChange?.(serializeDocument(doc.blocks));
163
+ }
164
+
165
+ function destroyMounts() {
166
+ for (const mounted of mounts.values()) mounted.destroy();
167
+ mounts.clear();
168
+ }
169
+
170
+ function paint() {
171
+ destroyMounts();
172
+ resetNamespaces();
173
+ blocksEl.innerHTML = "";
174
+ empty.hidden = doc.blocks.length > 0;
175
+ /** @type {object[]} */
176
+ let prose = [];
177
+ let proseStart = 0;
178
+ let index = 0;
179
+ const flush = () => {
180
+ if (!prose.length) return;
181
+ const el = renderProse(prose);
182
+ el.dataset.at = String(proseStart);
183
+ el.dataset.atEnd = String(index);
184
+ blocksEl.appendChild(el);
185
+ prose = [];
186
+ };
187
+ for (const block of doc.blocks) {
188
+ if (block.type === "figure" || block.type === "graph") {
189
+ flush();
190
+ const el = renderBlock(block);
191
+ el.dataset.at = String(index);
192
+ el.dataset.atEnd = String(index + 1);
193
+ if (block.type === "figure") el.dataset.figureId = block.id;
194
+ blocksEl.appendChild(el);
195
+ index++;
196
+ } else {
197
+ if (!prose.length) proseStart = index;
198
+ prose.push(block);
199
+ index++;
200
+ }
201
+ }
202
+ flush();
203
+ }
204
+
205
+ /**
206
+ * Headings, paragraphs, and lists render as HTML. They are not blocks.
207
+ * @param {object[]} blocks
208
+ */
209
+ function renderProse(blocks) {
210
+ const wrap = docEl.createElement("div");
211
+ wrap.className = "im-md im-doc-prose";
212
+ const markdown = serializeDocument(blocks).trim();
213
+ const parser = options.marked || globalThis.marked;
214
+ if (parser && typeof parser.parse === "function") {
215
+ try {
216
+ wrap.innerHTML = parser.parse(markdown);
217
+ return wrap;
218
+ } catch (_) {
219
+ /* fall through to plain text */
220
+ }
221
+ }
222
+ wrap.textContent = markdown;
223
+ return wrap;
224
+ }
225
+
226
+ /**
227
+ * @param {object} block
228
+ */
229
+ function renderBlock(block) {
230
+ const el = docEl.createElement("section");
231
+ el.className = "im-doc-block" + (block.id === selectedId ? " is-selected" : "");
232
+ el.dataset.id = block.id;
233
+
234
+ const row = docEl.createElement("div");
235
+ row.className = "im-doc-block-bar";
236
+ const index = doc.blocks.findIndex((b) => b.id === block.id);
237
+ row.append(
238
+ actionButton("select", kindLabel(block), null),
239
+ actionButton("move", "Up", { delta: "-1", disabled: index <= 0 }),
240
+ actionButton("move", "Down", { delta: "1", disabled: index < 0 || index >= doc.blocks.length - 1 }),
241
+ actionButton("remove-block", "Remove", null)
242
+ );
243
+ if (block.type === "graph" && !block.model?.animated) {
244
+ row.append(actionButton("add-animation", "Add animation", null));
245
+ }
246
+ el.appendChild(row);
247
+
248
+ if (block.id === selectedId) el.appendChild(renderInspector(block));
249
+
250
+ const live = docEl.createElement("div");
251
+ live.className = "im-doc-live";
252
+ const host = docEl.createElement("div");
253
+ host.className = "im-doc-live-host";
254
+ live.appendChild(host);
255
+ el.appendChild(live);
256
+ mountLive(block, host);
257
+ return el;
258
+ }
259
+
260
+ /**
261
+ * @param {string} action
262
+ * @param {string} label
263
+ * @param {{ delta?: string, disabled?: boolean } | null} extra
264
+ */
265
+ function actionButton(action, label, extra) {
266
+ const button = docEl.createElement("button");
267
+ button.type = "button";
268
+ button.dataset.action = action;
269
+ button.textContent = label;
270
+ if (action === "select") button.className = "im-doc-kind";
271
+ if (extra?.delta) button.dataset.delta = extra.delta;
272
+ if (extra?.disabled) button.disabled = true;
273
+ return button;
274
+ }
275
+
276
+ /**
277
+ * @param {object} block
278
+ */
279
+ function renderInspector(block) {
280
+ const box = docEl.createElement("div");
281
+ box.className = "im-doc-inspector";
282
+ if (block.type === "heading") {
283
+ const level = docEl.createElement("select");
284
+ level.dataset.field = "level";
285
+ for (const n of [1, 2, 3]) {
286
+ const option = docEl.createElement("option");
287
+ option.value = String(n);
288
+ option.textContent = "H" + n;
289
+ if (Number(block.level) === n) option.setAttribute("selected", "");
290
+ level.appendChild(option);
291
+ }
292
+ const text = input("text", block.text || "");
293
+ text.dataset.field = "text";
294
+ box.append(field("Level", level), field("Text", text));
295
+ return box;
296
+ }
297
+ if (block.type === "paragraph" || block.type === "markdown") {
298
+ const area = textarea(block.text || "", 4);
299
+ area.dataset.field = "text";
300
+ box.appendChild(field(block.type === "markdown" ? "Markdown" : "Text", area));
301
+ return box;
302
+ }
303
+ if (block.type === "bullets") {
304
+ const area = textarea((block.items || []).join("\n"), 4);
305
+ area.dataset.field = "items";
306
+ box.appendChild(field("One item per line", area));
307
+ return box;
308
+ }
309
+ if (block.type === "graph") {
310
+ if (block.model?.animated) {
311
+ const note = docEl.createElement("p");
312
+ note.className = "im-doc-help";
313
+ note.textContent = "Dashes and dots run on this canvas. Path, quantity, direction, and seconds are on the canvas.";
314
+ const remove = docEl.createElement("button");
315
+ remove.type = "button";
316
+ remove.dataset.action = "remove-animation";
317
+ remove.textContent = "Remove animation";
318
+ box.append(note, remove);
319
+ }
320
+ return box;
321
+ }
322
+ if (block.type === "figure") {
323
+ const ns = input("text", block.namespace || "");
324
+ ns.dataset.field = "namespace";
325
+ box.appendChild(field("Shared name", ns));
326
+ if (!block.customSvg) {
327
+ const view = input("text", block.viewBox || "");
328
+ view.dataset.field = "viewBox";
329
+ box.appendChild(field("viewBox", view));
330
+ } else {
331
+ const note = docEl.createElement("p");
332
+ note.className = "im-doc-help";
333
+ note.textContent = "Finished drawing. The preview plays it as stored. Controls added here can drive {{expr}} inside it.";
334
+ box.appendChild(note);
335
+ }
336
+ (block.controls || []).forEach((control, index) => {
337
+ box.appendChild(renderControl(control, index));
338
+ });
339
+ if (block.shapes) {
340
+ block.shapes.forEach((shape, index) => {
341
+ box.appendChild(renderShape(shape, index));
342
+ });
343
+ }
344
+ }
345
+ return box;
346
+ }
347
+
348
+ /**
349
+ * @param {object} control
350
+ * @param {number} index
351
+ */
352
+ function renderControl(control, index) {
353
+ const group = docEl.createElement("fieldset");
354
+ group.className = "im-doc-sub";
355
+ const legend = docEl.createElement("legend");
356
+ legend.textContent = controlTitle(control);
357
+ const remove = docEl.createElement("button");
358
+ remove.type = "button";
359
+ remove.dataset.action = "remove-control";
360
+ remove.dataset.index = String(index);
361
+ remove.textContent = "Remove";
362
+ group.append(legend, remove);
363
+
364
+ const name = input("text", control.name || "");
365
+ tagControl(name, index, "name");
366
+ group.appendChild(field("Name", name));
367
+
368
+ if (control.kind === "let") {
369
+ const expr = input("text", control.expression || "");
370
+ tagControl(expr, index, "expression");
371
+ group.appendChild(field("Expression", expr));
372
+ return group;
373
+ }
374
+
375
+ const label = input("text", control.label || "");
376
+ tagControl(label, index, "label");
377
+ group.appendChild(field("Label", label));
378
+
379
+ if (control.kind === "input") {
380
+ const value = input("text", control.default ?? "");
381
+ tagControl(value, index, "default");
382
+ group.appendChild(field("Default", value));
383
+ return group;
384
+ }
385
+ if (control.kind === "toggle") {
386
+ const value = input("checkbox", "");
387
+ value.checked = !!control.default;
388
+ tagControl(value, index, "default");
389
+ group.appendChild(field("Default on", value));
390
+ return group;
391
+ }
392
+
393
+ const range = control.range || [0, 100];
394
+ for (const [key, value, title] of [
395
+ ["min", range[0], "Min"],
396
+ ["max", range[1], "Max"],
397
+ ["default", control.default, "Default"],
398
+ ["step", control.step, "Step"],
399
+ ]) {
400
+ const num = input("number", value ?? "");
401
+ tagControl(num, index, key);
402
+ group.appendChild(field(title, num));
403
+ }
404
+ if (control.kind === "timer") {
405
+ const speed = input("number", control.speed ?? 20);
406
+ tagControl(speed, index, "speed");
407
+ const loop = input("checkbox", "");
408
+ loop.checked = control.loop !== false;
409
+ tagControl(loop, index, "loop");
410
+ group.append(field("Speed", speed), field("Loop", loop));
411
+ }
412
+ return group;
413
+ }
414
+
415
+ /**
416
+ * @param {object} shape
417
+ * @param {number} index
418
+ */
419
+ function renderShape(shape, index) {
420
+ const group = docEl.createElement("fieldset");
421
+ group.className = "im-doc-sub";
422
+ const legend = docEl.createElement("legend");
423
+ legend.textContent = shape.type;
424
+ const remove = docEl.createElement("button");
425
+ remove.type = "button";
426
+ remove.dataset.action = "remove-shape";
427
+ remove.dataset.index = String(index);
428
+ remove.textContent = "Remove";
429
+ group.append(legend, remove);
430
+ const known = SHAPE_FIELDS[shape.type] || ["fill"];
431
+ const extras = Object.keys(shape.attrs || {}).filter((key) => key !== "text" && !known.includes(key));
432
+ if (shape.type === "text" && !known.includes("text")) extras.push("text");
433
+ for (const key of known.concat(extras)) {
434
+ const value = input("text", shape.attrs?.[key] ?? "");
435
+ value.dataset.shape = String(index);
436
+ value.dataset.attr = key;
437
+ group.appendChild(field(key === "text" ? "Text" : key, value));
438
+ }
439
+ return group;
440
+ }
441
+
442
+ /**
443
+ * @param {HTMLInputElement} el
444
+ * @param {number} index
445
+ * @param {string} fieldName
446
+ */
447
+ function tagControl(el, index, fieldName) {
448
+ el.dataset.control = String(index);
449
+ el.dataset.field = fieldName;
450
+ }
451
+
452
+ /**
453
+ * @param {string} label
454
+ * @param {HTMLElement} control
455
+ */
456
+ function field(label, control) {
457
+ const wrap = docEl.createElement("label");
458
+ wrap.className = "im-doc-field";
459
+ const span = docEl.createElement("span");
460
+ span.textContent = label;
461
+ wrap.append(span, control);
462
+ return wrap;
463
+ }
464
+
465
+ /**
466
+ * @param {string} type
467
+ * @param {string|number} value
468
+ */
469
+ function input(type, value) {
470
+ const el = docEl.createElement("input");
471
+ el.type = type;
472
+ if (type !== "checkbox") el.value = String(value ?? "");
473
+ if (type === "number") el.step = "any";
474
+ return el;
475
+ }
476
+
477
+ /**
478
+ * @param {string} value
479
+ * @param {number} rows
480
+ */
481
+ function textarea(value, rows) {
482
+ const el = docEl.createElement("textarea");
483
+ el.rows = rows;
484
+ el.value = value;
485
+ return el;
486
+ }
487
+
488
+ /**
489
+ * @param {object} block
490
+ * @param {HTMLElement} host
491
+ */
492
+ function mountLive(block, host) {
493
+ if (block.type === "graph") {
494
+ mounts.set(
495
+ block.id,
496
+ mountGraphEditor(host, block.model || { nodes: [], edges: [] }, {
497
+ onChange(model) {
498
+ doc = { blocks: updateBlock(doc, block.id, { model }).blocks };
499
+ commit();
500
+ },
501
+ })
502
+ );
503
+ return;
504
+ }
505
+ mounts.set(block.id, mountIvm(host, serializeFigure(block)));
506
+ }
507
+
508
+ /**
509
+ * @param {string} id
510
+ */
511
+ function refreshFigure(id) {
512
+ const block = doc.blocks.find((b) => b.id === id);
513
+ const host = blocksEl.querySelector(`.im-doc-block[data-id="${id}"] .im-doc-live-host`);
514
+ if (!block || block.type !== "figure" || !host) return;
515
+ const prev = mounts.get(id);
516
+ if (prev) {
517
+ prev.destroy();
518
+ mounts.delete(id);
519
+ }
520
+ host.innerHTML = "";
521
+ mounts.set(id, mountIvm(host, serializeFigure(block)));
522
+ }
523
+
524
+ /**
525
+ * @param {PointerEvent} event
526
+ */
527
+ function onMenuPointerDown(event) {
528
+ const target = event.target;
529
+ const button = target && target.closest ? target.closest("[data-add]") : null;
530
+ if (!button || !menu.contains(button) || event.button !== 0) return;
531
+ drag = {
532
+ pointerId: event.pointerId,
533
+ x: event.clientX,
534
+ y: event.clientY,
535
+ feature: button.dataset.add || "",
536
+ shape: button.dataset.shape || "",
537
+ label: button.textContent || "",
538
+ active: false,
539
+ figureId: null,
540
+ };
541
+ docEl.addEventListener("pointermove", onMenuPointerMove);
542
+ docEl.addEventListener("pointerup", onMenuPointerUp);
543
+ docEl.addEventListener("pointercancel", onMenuPointerUp);
544
+ }
545
+
546
+ /**
547
+ * @param {PointerEvent} event
548
+ */
549
+ function onMenuPointerMove(event) {
550
+ if (!drag || event.pointerId !== drag.pointerId) return;
551
+ const dx = event.clientX - drag.x;
552
+ const dy = event.clientY - drag.y;
553
+ if (!drag.active) {
554
+ if (dx * dx + dy * dy < 36) return;
555
+ drag.active = true;
556
+ suppressClick = true;
557
+ closeMenu();
558
+ ghost.textContent = drag.label;
559
+ ghost.hidden = false;
560
+ root.classList.add("is-dragging");
561
+ }
562
+ ghost.style.left = event.clientX + 12 + "px";
563
+ ghost.style.top = event.clientY + 12 + "px";
564
+ updateSnap(event.clientX, event.clientY);
565
+ }
566
+
567
+ /**
568
+ * @param {PointerEvent} event
569
+ */
570
+ function onMenuPointerUp(event) {
571
+ if (!drag || event.pointerId !== drag.pointerId) return;
572
+ const active = drag.active;
573
+ const feature = drag.feature;
574
+ const shape = drag.shape;
575
+ const at = drag.at;
576
+ const figureId = drag.figureId;
577
+ clearDrag();
578
+ if (!active) return;
579
+ addFeature(feature, shape || undefined, { at, figureId });
580
+ }
581
+
582
+ function endDragListeners() {
583
+ docEl.removeEventListener("pointermove", onMenuPointerMove);
584
+ docEl.removeEventListener("pointerup", onMenuPointerUp);
585
+ docEl.removeEventListener("pointercancel", onMenuPointerUp);
586
+ }
587
+
588
+ function clearDrag() {
589
+ drag = null;
590
+ ghost.hidden = true;
591
+ guide.hidden = true;
592
+ root.classList.remove("is-dragging");
593
+ for (const el of blocksEl.querySelectorAll(".is-snap-target")) el.classList.remove("is-snap-target");
594
+ endDragListeners();
595
+ }
596
+
597
+ /**
598
+ * @param {number} x
599
+ * @param {number} y
600
+ */
601
+ function updateSnap(x, y) {
602
+ if (!drag) return;
603
+ for (const el of blocksEl.querySelectorAll(".is-snap-target")) el.classList.remove("is-snap-target");
604
+ const figureId = figureUnder(x, y, drag.feature);
605
+ if (figureId) {
606
+ drag.figureId = figureId;
607
+ drag.at = undefined;
608
+ guide.hidden = true;
609
+ blocksEl.querySelector(`[data-figure-id="${figureId}"]`)?.classList.add("is-snap-target");
610
+ return;
611
+ }
612
+ const slot = nearestGap(y);
613
+ drag.figureId = null;
614
+ drag.at = slot.at;
615
+ const area = blocksEl.getBoundingClientRect();
616
+ guide.hidden = false;
617
+ guide.style.left = area.left + "px";
618
+ guide.style.width = Math.max(area.width, 40) + "px";
619
+ guide.style.top = slot.y + "px";
620
+ }
621
+
622
+ /**
623
+ * @param {number} x
624
+ * @param {number} y
625
+ * @param {string} feature
626
+ */
627
+ function figureUnder(x, y, feature) {
628
+ const attachable =
629
+ feature === "slider" ||
630
+ feature === "timer" ||
631
+ feature === "input" ||
632
+ feature === "toggle" ||
633
+ feature === "formula" ||
634
+ feature === "shape";
635
+ if (!attachable || typeof docEl.elementFromPoint !== "function") return null;
636
+ const hit = docEl.elementFromPoint(x, y);
637
+ const blockEl = hit && hit.closest ? hit.closest(".im-doc-block[data-figure-id]") : null;
638
+ if (!blockEl || !blocksEl.contains(blockEl)) return null;
639
+ const rect = blockEl.getBoundingClientRect();
640
+ const margin = Math.min(18, rect.height * 0.2);
641
+ if (y < rect.top + margin || y > rect.bottom - margin) return null;
642
+ return blockEl.dataset.figureId || null;
643
+ }
644
+
645
+ /**
646
+ * @param {number} clientY
647
+ */
648
+ function nearestGap(clientY) {
649
+ const children = [...blocksEl.querySelectorAll(":scope > .im-doc-prose, :scope > .im-doc-block")];
650
+ /** @type {{ at: number, y: number }[]} */
651
+ const slots = [];
652
+ if (!children.length) {
653
+ const rect = (empty.hidden ? blocksEl : empty).getBoundingClientRect();
654
+ slots.push({ at: doc.blocks.length, y: rect.top + 8 });
655
+ } else {
656
+ for (const child of children) {
657
+ const rect = child.getBoundingClientRect();
658
+ slots.push({ at: Number(child.dataset.at), y: rect.top });
659
+ }
660
+ const last = children[children.length - 1];
661
+ const lastRect = last.getBoundingClientRect();
662
+ slots.push({ at: Number(last.dataset.atEnd), y: lastRect.bottom });
663
+ }
664
+ let best = slots[0];
665
+ let bestDist = Infinity;
666
+ for (const slot of slots) {
667
+ const dist = Math.abs(clientY - slot.y);
668
+ if (dist < bestDist) {
669
+ best = slot;
670
+ bestDist = dist;
671
+ }
672
+ }
673
+ return best;
674
+ }
675
+
676
+ /**
677
+ * @param {MouseEvent} event
678
+ */
679
+ function onClick(event) {
680
+ if (suppressClick) {
681
+ suppressClick = false;
682
+ event.preventDefault();
683
+ return;
684
+ }
685
+ const target = event.target;
686
+ if (!target || !target.closest) return;
687
+ const add = target.closest("[data-add]");
688
+ if (add && !add.closest(".im-graph")) {
689
+ event.preventDefault();
690
+ addFeature(add.dataset.add || "", add.dataset.shape);
691
+ closeMenu();
692
+ return;
693
+ }
694
+ if (target.closest("[data-menu-toggle]")) {
695
+ event.preventDefault();
696
+ const open = menu.hidden;
697
+ menu.hidden = !open;
698
+ addToggle.setAttribute("aria-expanded", open ? "true" : "false");
699
+ return;
700
+ }
701
+ const actionEl = target.closest("[data-action]");
702
+ if (actionEl && !actionEl.closest(".im-graph")) {
703
+ const action = actionEl.dataset.action || "";
704
+ if (action === "select" || action === "move" || action === "remove-block" || action === "remove-control" || action === "remove-shape" || action === "add-animation" || action === "remove-animation") {
705
+ event.preventDefault();
706
+ runAction(actionEl);
707
+ }
708
+ return;
709
+ }
710
+ if (target.closest("button, input, textarea, select, a, label, .im-doc-live")) return;
711
+ const blockEl = target.closest(".im-doc-block");
712
+ if (!blockEl || blockEl.dataset.id === selectedId) return;
713
+ selectedId = blockEl.dataset.id || null;
714
+ paint();
715
+ }
716
+
717
+ /**
718
+ * @param {string} feature
719
+ * @param {string} [shapeType]
720
+ * @param {{ at?: number, figureId?: string | null }} [placement]
721
+ */
722
+ function addFeature(feature, shapeType, placement) {
723
+ if (feature === "graph" || feature === "animated") {
724
+ const at = placement && !placement.figureId ? placement.at : undefined;
725
+ const result = insertFeature(doc, "graph", { at });
726
+ doc = result.doc;
727
+ selectedId = result.focusId || selectedId;
728
+ paint();
729
+ commit();
730
+ return;
731
+ }
732
+ if (feature === "shape" && !shapeType) shapeType = "rect";
733
+ /** @type {string | undefined} */
734
+ let figureId;
735
+ /** @type {number | undefined} */
736
+ let at;
737
+ if (placement) {
738
+ figureId = placement.figureId || undefined;
739
+ at = figureId ? undefined : placement.at;
740
+ } else {
741
+ const selected = doc.blocks.find((b) => b.id === selectedId);
742
+ figureId = selected && selected.type === "figure" ? selected.id : undefined;
743
+ }
744
+ const result = insertFeature(doc, feature, { figureId, shapeType, at });
745
+ doc = result.doc;
746
+ selectedId = result.focusId || selectedId;
747
+ paint();
748
+ commit();
749
+ }
750
+
751
+ /**
752
+ * @param {string} id
753
+ * @param {boolean} on
754
+ */
755
+ function setGraphAnimated(id, on) {
756
+ const block = doc.blocks.find((b) => b.id === id);
757
+ if (!block || block.type !== "graph") return;
758
+ const model = {
759
+ nodes: (block.model?.nodes || []).map((n) => ({ ...n })),
760
+ edges: (block.model?.edges || []).map((e) => ({ ...e })),
761
+ };
762
+ if (block.model?.motion) model.motion = { ...block.model.motion };
763
+ if (on) model.animated = true;
764
+ doc = { blocks: updateBlock(doc, id, { model }).blocks };
765
+ paint();
766
+ commit();
767
+ }
768
+
769
+ /**
770
+ * @param {HTMLElement} actionEl
771
+ */
772
+ function runAction(actionEl) {
773
+ const blockEl = actionEl.closest(".im-doc-block");
774
+ const id = blockEl?.dataset.id;
775
+ if (!id) return;
776
+ const action = actionEl.dataset.action;
777
+ if (action === "select") {
778
+ selectedId = id;
779
+ paint();
780
+ return;
781
+ }
782
+ if (action === "move") {
783
+ doc = { blocks: moveBlock(doc, id, Number(actionEl.dataset.delta)).blocks };
784
+ paint();
785
+ commit();
786
+ return;
787
+ }
788
+ if (action === "remove-block") {
789
+ doc = { blocks: removeBlock(doc, id).blocks };
790
+ if (selectedId === id) selectedId = null;
791
+ paint();
792
+ commit();
793
+ return;
794
+ }
795
+ if (action === "add-animation") {
796
+ setGraphAnimated(id, true);
797
+ return;
798
+ }
799
+ if (action === "remove-animation") {
800
+ setGraphAnimated(id, false);
801
+ return;
802
+ }
803
+ const index = Number(actionEl.dataset.index);
804
+ if (action === "remove-control") doc = { blocks: removeControl(doc, id, index).blocks };
805
+ if (action === "remove-shape") doc = { blocks: removeShape(doc, id, index).blocks };
806
+ paint();
807
+ commit();
808
+ }
809
+
810
+ /**
811
+ * @param {Event} event
812
+ */
813
+ function onField(event) {
814
+ const el = event.target;
815
+ if (!el || !el.closest || !el.dataset || !el.closest(".im-doc-inspector")) return;
816
+ const blockEl = el.closest(".im-doc-block");
817
+ const id = blockEl?.dataset.id;
818
+ const block = doc.blocks.find((b) => b.id === id);
819
+ if (!block) return;
820
+
821
+ if (el.dataset.shape != null && el.dataset.attr) {
822
+ doc = { blocks: updateShape(doc, id, Number(el.dataset.shape), { [el.dataset.attr]: el.value }).blocks };
823
+ refreshFigure(id);
824
+ commit();
825
+ return;
826
+ }
827
+
828
+ const fieldName = el.dataset.field;
829
+ if (!fieldName) return;
830
+
831
+ if (el.dataset.control != null) {
832
+ const index = Number(el.dataset.control);
833
+ const control = block.controls?.[index];
834
+ if (!control) return;
835
+ const patch = patchFromControl(control, fieldName, el);
836
+ if (!patch) return;
837
+ doc = { blocks: updateControl(doc, id, index, patch).blocks };
838
+ refreshFigure(id);
839
+ commit();
840
+ return;
841
+ }
842
+
843
+ if ((fieldName === "text" && (block.type === "heading" || block.type === "paragraph" || block.type === "markdown"))) {
844
+ doc = { blocks: updateBlock(doc, id, { text: el.value }).blocks };
845
+ commit();
846
+ return;
847
+ }
848
+ if (fieldName === "level") {
849
+ const level = Number(el.value);
850
+ if (level >= 1 && level <= 3) doc = { blocks: updateBlock(doc, id, { level }).blocks };
851
+ commit();
852
+ return;
853
+ }
854
+ if (fieldName === "items") {
855
+ doc = { blocks: updateBlock(doc, id, { items: el.value.split("\n") }).blocks };
856
+ commit();
857
+ return;
858
+ }
859
+ if (fieldName === "namespace") {
860
+ const name = el.value.trim();
861
+ if (name && !NAME_RE.test(name)) return;
862
+ doc = { blocks: updateBlock(doc, id, { namespace: name || null }).blocks };
863
+ refreshFigure(id);
864
+ commit();
865
+ return;
866
+ }
867
+ if (fieldName === "viewBox") {
868
+ doc = { blocks: updateBlock(doc, id, { viewBox: el.value }).blocks };
869
+ refreshFigure(id);
870
+ commit();
871
+ }
872
+ }
873
+
874
+ /**
875
+ * @param {FocusEvent} event
876
+ */
877
+ function onBlur(event) {
878
+ const el = event.target;
879
+ if (!el?.dataset?.field || !el.closest?.(".im-doc-inspector")) return;
880
+ const block = doc.blocks.find((b) => b.id === el.closest(".im-doc-block")?.dataset.id);
881
+ if (!block) return;
882
+ if (el.dataset.field === "namespace") {
883
+ el.value = block.namespace || "";
884
+ return;
885
+ }
886
+ if (el.dataset.control == null) return;
887
+ const control = block.controls?.[Number(el.dataset.control)];
888
+ if (!control) return;
889
+ const fieldName = el.dataset.field;
890
+ if (fieldName === "name") el.value = control.name || "";
891
+ if (fieldName === "min") el.value = String(control.range?.[0] ?? "");
892
+ if (fieldName === "max") el.value = String(control.range?.[1] ?? "");
893
+ if (fieldName === "step") el.value = String(control.step ?? "");
894
+ if (fieldName === "speed") el.value = String(control.speed ?? "");
895
+ if (fieldName === "default" && el.type === "number") el.value = String(control.default ?? "");
896
+ }
897
+ }
898
+
899
+ /**
900
+ * @param {object} control
901
+ * @param {string} fieldName
902
+ * @param {HTMLInputElement} el
903
+ */
904
+ function patchFromControl(control, fieldName, el) {
905
+ if (fieldName === "name") {
906
+ const value = el.value.trim();
907
+ if (!NAME_RE.test(value)) return null;
908
+ return { name: value };
909
+ }
910
+ if (fieldName === "label") return { label: el.value };
911
+ if (fieldName === "expression") return { expression: el.value };
912
+ if (fieldName === "loop") return { loop: !!el.checked };
913
+ if (fieldName === "default" && el.type === "checkbox") return { default: !!el.checked };
914
+ if (fieldName === "default" && control.kind === "input") return { default: el.value };
915
+ if (fieldName === "min" || fieldName === "max") {
916
+ const n = Number(el.value);
917
+ if (!Number.isFinite(n)) return null;
918
+ const range = control.range ? [control.range[0], control.range[1]] : [0, 100];
919
+ if (fieldName === "min") range[0] = n;
920
+ else range[1] = n;
921
+ return { range };
922
+ }
923
+ if (fieldName === "default" || fieldName === "step" || fieldName === "speed") {
924
+ const n = Number(el.value);
925
+ if (!Number.isFinite(n)) return null;
926
+ if (fieldName === "speed" && n <= 0) return null;
927
+ return { [fieldName]: n };
928
+ }
929
+ return null;
930
+ }
931
+
932
+ /**
933
+ * @param {object} block
934
+ */
935
+ function kindLabel(block) {
936
+ if (block.type === "heading") return "Heading";
937
+ if (block.type === "paragraph") return "Paragraph";
938
+ if (block.type === "bullets") return "Bullets";
939
+ if (block.type === "markdown") return "Markdown";
940
+ if (block.type === "graph") return "Graph";
941
+ if (block.type === "figure" && block.customSvg) return "Animated figure";
942
+ if (block.type === "figure") return "Figure";
943
+ return "Block";
944
+ }
945
+
946
+ /**
947
+ * @param {object} control
948
+ */
949
+ function controlTitle(control) {
950
+ if (control.kind === "slider") return "Slider";
951
+ if (control.kind === "timer") return "Timer";
952
+ if (control.kind === "input") return "Text field";
953
+ if (control.kind === "toggle") return "Toggle";
954
+ if (control.kind === "let") return "Formula";
955
+ return "Control";
956
+ }