interactive-markdown 0.1.1 → 0.3.0

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