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.
package/src/markdown.js CHANGED
@@ -23,7 +23,7 @@ function resolveMarked(override) {
23
23
  * Render full Interactive Markdown into a preview element.
24
24
  * @param {HTMLElement} previewEl
25
25
  * @param {string} source
26
- * @param {{ marked?: { parse: (md: string) => string } }} [options]
26
+ * @param {{ marked?: { parse: (md: string) => string }, onGraphChange?: (fenceIndex: number, body: string) => void }} [options]
27
27
  * @returns {{ destroy: () => void }}
28
28
  */
29
29
  export function renderDocument(previewEl, source, options = {}) {
@@ -40,6 +40,7 @@ export function renderDocument(previewEl, source, options = {}) {
40
40
  marked.setOptions({ gfm: true, breaks: false });
41
41
  }
42
42
 
43
+ let ivmIndex = 0;
43
44
  for (const seg of segments) {
44
45
  if (seg.type === "text") {
45
46
  if (!seg.content.trim()) continue;
@@ -48,10 +49,17 @@ export function renderDocument(previewEl, source, options = {}) {
48
49
  wrap.innerHTML = marked.parse(seg.content);
49
50
  previewEl.appendChild(wrap);
50
51
  } else if (seg.type === "ivm") {
52
+ const fenceIndex = ivmIndex++;
51
53
  const host = doc.createElement("div");
52
54
  host.className = "im-ivm-host";
53
55
  previewEl.appendChild(host);
54
- mounts.push(mountIvm(host, seg.content));
56
+ mounts.push(
57
+ mountIvm(host, seg.content, {
58
+ onGraphChange(body) {
59
+ options.onGraphChange?.(fenceIndex, body);
60
+ },
61
+ })
62
+ );
55
63
  }
56
64
  }
57
65
 
package/src/parse-ivm.js CHANGED
@@ -2,16 +2,23 @@
2
2
  * Parse ```ivm fence body into controls + SVG template.
3
3
  * Interactive Markdown (IVM) dialect.
4
4
  *
5
- * Line forms:
5
+ * Line forms (long names are canonical; short aliases also parse):
6
6
  * @ns name
7
- * @slider name: min..max = default step=S "Label"
8
- * @timer name: min..max = default speed=N step=S loop|noloop "Label"
9
- * @input name: "default" "Label"
10
- * @toggle name: true|false "Label"
11
- * @let name = expression
12
- * @calc name = expression (alias of @let)
13
- * @svg
14
- * <svg>...</svg> (remainder of fence; use {{expr}} in attrs/text)
7
+ * @slider | @s name: min..max = default [step=S] [deg|rad] ["Label"]
8
+ * @timer | @t name: min..max = default [speed=N] [step=S] [loop|noloop] [deg|rad] ["Label"]
9
+ * @input | @i name: "default" ["Label"]
10
+ * @toggle | @b name: true|false ["Label"]
11
+ * @let | @l name = expression
12
+ * @calc name = expression (alias of @let; prefer @let in docs/generators)
13
+ * @svg [WxH]
14
+ * …markup… (remainder of fence; use {{expr}} in attrs/text)
15
+ * @graph
16
+ * @animate [path=ortho|straight|curve] [quantity=N] [direction=forward|reverse] [speed=seconds]
17
+ * @node id: "Label" x=N y=N [kind=action|input|output|decision]
18
+ * @edge from -> to [from=right|left|top|bottom] [to=left|right|top|bottom]
19
+ *
20
+ * Defaults: step=1, timer loop=true, speed=30. Omitted "Label" → UI shows name.
21
+ * `@svg 200x80` stores width/height so the renderer can wrap non-root markup.
15
22
  */
16
23
 
17
24
  /**
@@ -26,15 +33,103 @@
26
33
  * loop?: boolean,
27
34
  * label?: string,
28
35
  * expression?: string,
36
+ * units?: 'deg'|'rad',
29
37
  * }} Control
38
+ * @typedef {"left"|"right"|"top"|"bottom"} GraphSide
39
+ * @typedef {{ id: string, label: string, x: number, y: number, kind: string }} GraphNode
40
+ * @typedef {{ from: string, to: string, fromSide?: GraphSide, toSide?: GraphSide }} GraphEdge
41
+ * @typedef {{ path: string, quantity: number, direction: string, speed: number }} GraphMotion
42
+ * @typedef {{ nodes: GraphNode[], edges: GraphEdge[], animated?: boolean, motion?: GraphMotion }} GraphModel
30
43
  * @typedef {{
31
44
  * namespace: string|null,
32
45
  * controls: Control[],
33
46
  * templateSvg: string|null,
47
+ * svgWidth: number|null,
48
+ * svgHeight: number|null,
49
+ * graph: GraphModel|null,
34
50
  * errors: string[],
35
51
  * }} IvmBlock
36
52
  */
37
53
 
54
+ const NODE_KINDS = new Set(["action", "input", "output", "decision"]);
55
+
56
+ const KIND_ALIASES = Object.freeze({
57
+ s: "slider",
58
+ slider: "slider",
59
+ t: "timer",
60
+ timer: "timer",
61
+ i: "input",
62
+ input: "input",
63
+ b: "toggle",
64
+ toggle: "toggle",
65
+ l: "let",
66
+ let: "let",
67
+ calc: "let",
68
+ });
69
+
70
+ /**
71
+ * @param {string} line
72
+ * @param {string[]} errors
73
+ * @returns {GraphMotion}
74
+ */
75
+ function readAnimate(line, errors) {
76
+ /** @type {GraphMotion} */
77
+ const motion = { path: "ortho", quantity: 1, direction: "forward", speed: 2.8 };
78
+ const rest = line.replace(/^@animate\b/i, "").trim();
79
+ if (!rest) return motion;
80
+ for (const part of rest.split(/\s+/)) {
81
+ const flag = part.match(/^(path|quantity|direction|speed)=(\S+)$/i);
82
+ if (!flag) {
83
+ errors.push(`@animate: unknown ${part}`);
84
+ continue;
85
+ }
86
+ const key = flag[1].toLowerCase();
87
+ const raw = flag[2];
88
+ const value = raw.toLowerCase();
89
+ if (key === "path") {
90
+ if (value !== "ortho" && value !== "straight" && value !== "curve") {
91
+ errors.push(`@animate: unknown path ${raw}`);
92
+ } else motion.path = value;
93
+ } else if (key === "quantity") {
94
+ const n = Number(value);
95
+ if (!Number.isInteger(n) || n < 1 || n > 12) errors.push("@animate: quantity must be 1..12");
96
+ else motion.quantity = n;
97
+ } else if (key === "direction") {
98
+ if (value !== "forward" && value !== "reverse") errors.push(`@animate: unknown direction ${raw}`);
99
+ else motion.direction = value;
100
+ } else if (key === "speed") {
101
+ const n = Number(raw);
102
+ if (!Number.isFinite(n) || n < 0.2 || n > 30) errors.push("@animate: speed must be seconds from 0.2 to 30");
103
+ else motion.speed = Math.round(n * 10) / 10;
104
+ }
105
+ }
106
+ return motion;
107
+ }
108
+
109
+ /**
110
+ * Read a leading "quoted" or 'quoted' string. Supports backslash escapes.
111
+ * @param {string} s
112
+ * @returns {{ value: string, rest: string } | null}
113
+ */
114
+ function parseQuotedPrefix(s) {
115
+ const str = String(s).trimStart();
116
+ const quote = str[0];
117
+ if (quote !== '"' && quote !== "'") return null;
118
+ let value = "";
119
+ let i = 1;
120
+ while (i < str.length) {
121
+ if (str[i] === "\\" && i + 1 < str.length) {
122
+ value += str[i + 1];
123
+ i += 2;
124
+ continue;
125
+ }
126
+ if (str[i] === quote) return { value, rest: str.slice(i + 1).trim() };
127
+ value += str[i];
128
+ i++;
129
+ }
130
+ return null;
131
+ }
132
+
38
133
  function unquote(s) {
39
134
  if (s == null) return s;
40
135
  const t = String(s).trim();
@@ -60,6 +155,8 @@ function parseFlags(rest) {
60
155
  }
61
156
  if (/\bnoloop\b/i.test(rest)) out.loop = "false";
62
157
  else if (/\bloop\b/i.test(rest) && out.loop === undefined) out.loop = "true";
158
+ if (/\bdeg\b/i.test(rest)) out.units = "deg";
159
+ else if (/\brad\b/i.test(rest)) out.units = "rad";
63
160
  return out;
64
161
  }
65
162
 
@@ -81,6 +178,12 @@ export function parseIvm(body) {
81
178
  const controls = [];
82
179
  let namespace = null;
83
180
  let templateSvg = null;
181
+ /** @type {number|null} */
182
+ let svgWidth = null;
183
+ /** @type {number|null} */
184
+ let svgHeight = null;
185
+ /** @type {GraphModel | null} */
186
+ let graph = null;
84
187
 
85
188
  const lines = String(body).replace(/\r\n/g, "\n").split("\n");
86
189
  let i = 0;
@@ -93,11 +196,148 @@ export function parseIvm(body) {
93
196
  continue;
94
197
  }
95
198
 
96
- // @svg — remainder of fence is SVG markup
199
+ // @graph — following @node / @edge lines are an editable graph
200
+ if (/^@graph$/i.test(line)) {
201
+ if (graph) errors.push("Duplicate @graph");
202
+ else graph = { nodes: [], edges: [] };
203
+ i++;
204
+ continue;
205
+ }
206
+ if (/^@graph\b/i.test(line)) {
207
+ errors.push("@graph takes no arguments");
208
+ i++;
209
+ continue;
210
+ }
211
+
212
+ // @node id: "Label" x=N y=N kind=action
213
+ {
214
+ const m = line.match(/^@node\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([\s\S]+)$/i);
215
+ if (m) {
216
+ const id = m[1];
217
+ if (!graph) {
218
+ errors.push(`@node ${id}: only valid after @graph`);
219
+ i++;
220
+ continue;
221
+ }
222
+ const quoted = parseQuotedPrefix(m[2]);
223
+ if (!quoted) {
224
+ errors.push(`@node ${id}: expected "Label" x=N y=N`);
225
+ i++;
226
+ continue;
227
+ }
228
+ if (graph.nodes.some((n) => n.id === id)) {
229
+ errors.push(`@node ${id}: duplicate id`);
230
+ i++;
231
+ continue;
232
+ }
233
+ const flags = parseFlags(quoted.rest);
234
+ const x = flags.x !== undefined ? Number(flags.x) : 48;
235
+ const y = flags.y !== undefined ? Number(flags.y) : 48;
236
+ const rawKind = flags.kind !== undefined ? String(flags.kind).toLowerCase() : "action";
237
+ if (!NODE_KINDS.has(rawKind)) {
238
+ errors.push(`@node ${id}: unknown kind ${flags.kind}`);
239
+ }
240
+ graph.nodes.push({
241
+ id,
242
+ label: quoted.value,
243
+ x: Number.isFinite(x) ? x : 48,
244
+ y: Number.isFinite(y) ? y : 48,
245
+ kind: NODE_KINDS.has(rawKind) ? rawKind : "action",
246
+ });
247
+ i++;
248
+ continue;
249
+ }
250
+ }
251
+
252
+ // @edge from -> to [from=side] [to=side]
253
+ {
254
+ const m = line.match(
255
+ /^@edge\s+([A-Za-z_][A-Za-z0-9_]*)\s*->\s*([A-Za-z_][A-Za-z0-9_]*)(?:\s+([^\s].*))?$/i
256
+ );
257
+ if (m) {
258
+ const from = m[1];
259
+ const to = m[2];
260
+ if (!graph) {
261
+ errors.push(`@edge ${from} -> ${to}: only valid after @graph`);
262
+ i++;
263
+ continue;
264
+ }
265
+ if (from === to) {
266
+ errors.push(`@edge ${from} -> ${to}: a node cannot connect to itself`);
267
+ i++;
268
+ continue;
269
+ }
270
+ if (!graph.nodes.some((n) => n.id === from) || !graph.nodes.some((n) => n.id === to)) {
271
+ errors.push(`@edge ${from} -> ${to}: unknown node`);
272
+ i++;
273
+ continue;
274
+ }
275
+ if (graph.edges.some((e) => e.from === from && e.to === to)) {
276
+ errors.push(`@edge ${from} -> ${to}: duplicate edge`);
277
+ i++;
278
+ continue;
279
+ }
280
+ /** @type {GraphEdge} */
281
+ const edge = { from, to };
282
+ const tail = (m[3] || "").trim();
283
+ let sideError = false;
284
+ if (tail) {
285
+ for (const part of tail.split(/\s+/)) {
286
+ const flag = part.match(/^(from|to)=(left|right|top|bottom)$/i);
287
+ if (!flag) {
288
+ errors.push(`@edge ${from} -> ${to}: unknown ${part}`);
289
+ sideError = true;
290
+ continue;
291
+ }
292
+ const side = /** @type {GraphSide} */ (flag[2].toLowerCase());
293
+ if (flag[1].toLowerCase() === "from") edge.fromSide = side;
294
+ else edge.toSide = side;
295
+ }
296
+ }
297
+ if (edge.fromSide === "right") delete edge.fromSide;
298
+ if (edge.toSide === "left") delete edge.toSide;
299
+ if (!sideError) graph.edges.push(edge);
300
+ i++;
301
+ continue;
302
+ }
303
+ }
304
+
305
+ if (/^@animate\b/i.test(line)) {
306
+ if (!graph) errors.push("@animate is only valid after @graph");
307
+ else if (graph.animated) errors.push("Duplicate @animate");
308
+ else {
309
+ graph.animated = true;
310
+ graph.motion = readAnimate(line, errors);
311
+ }
312
+ i++;
313
+ continue;
314
+ }
315
+
316
+ if (graph) {
317
+ errors.push(`Not allowed inside @graph: ${line}`);
318
+ i++;
319
+ continue;
320
+ }
321
+
322
+ // @svg [WxH] — remainder of fence is SVG markup
97
323
  if (/^@svg\b/i.test(line)) {
98
- const after = line.replace(/^@svg\b/i, "");
324
+ const after = line.replace(/^@svg\b/i, "").trim();
325
+ const size = after.match(/^(\d+(?:\.\d+)?)\s*[xX×]\s*(\d+(?:\.\d+)?)\s*$/);
326
+ if (size) {
327
+ svgWidth = Number(size[1]);
328
+ svgHeight = Number(size[2]);
329
+ } else if (after) {
330
+ // Inline markup on the same line (legacy): treat as template start
331
+ const parts = [after];
332
+ i++;
333
+ while (i < lines.length) {
334
+ parts.push(lines[i]);
335
+ i++;
336
+ }
337
+ templateSvg = parts.join("\n").replace(/^\n+/, "").replace(/\n+$/, "\n");
338
+ break;
339
+ }
99
340
  const parts = [];
100
- if (after.trim()) parts.push(after.trim());
101
341
  i++;
102
342
  while (i < lines.length) {
103
343
  parts.push(lines[i]);
@@ -117,27 +357,28 @@ export function parseIvm(body) {
117
357
  }
118
358
  }
119
359
 
120
- // @let / @calc name = expression
360
+ // @let / @l / @calc name = expression
121
361
  {
122
- const m = line.match(/^@(?:let|calc)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/i);
362
+ const m = line.match(/^@(let|l|calc)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/i);
123
363
  if (m) {
124
- controls.push({ kind: "let", name: m[1], expression: m[2].trim() });
364
+ controls.push({ kind: "let", name: m[2], expression: m[3].trim() });
125
365
  i++;
126
366
  continue;
127
367
  }
128
368
  }
129
369
 
130
- // @slider name: min..max = default step=S "Label"
370
+ // @slider|@s|@timer|@t name: min..max = default …
131
371
  {
132
- const m = line.match(/^@slider\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/i);
372
+ const m = line.match(/^@(slider|s|timer|t)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/i);
133
373
  if (m) {
134
- const name = m[1];
135
- const [rest, label] = takeLabel(m[2]);
374
+ const kind = KIND_ALIASES[m[1].toLowerCase()];
375
+ const name = m[2];
376
+ const [rest, label] = takeLabel(m[3]);
136
377
  const rm = rest.match(
137
378
  /^(-?[0-9]*\.?[0-9]+)\.\.(-?[0-9]*\.?[0-9]+)\s*(?:=\s*(-?[0-9]*\.?[0-9]+))?\s*(.*)$/
138
379
  );
139
380
  if (!rm) {
140
- errors.push(`@slider ${name}: expected min..max [= default]`);
381
+ errors.push(`@${kind} ${name}: expected min..max [= default]`);
141
382
  i++;
142
383
  continue;
143
384
  }
@@ -145,77 +386,57 @@ export function parseIvm(body) {
145
386
  const def = rm[3] !== undefined ? Number(rm[3]) : range[0];
146
387
  const flags = parseFlags(rm[4] || "");
147
388
  const step = flags.step !== undefined ? Number(flags.step) : 1;
148
- controls.push({
149
- kind: "slider",
389
+ /** @type {Control} */
390
+ const ctrl = {
391
+ kind,
150
392
  name,
151
393
  range,
152
394
  step: Number.isFinite(step) ? step : 1,
153
395
  default: Number.isFinite(def) ? def : range[0],
154
- label: label || flags.label,
155
- });
396
+ label: label || flags.label || undefined,
397
+ };
398
+ if (flags.units === "deg" || flags.units === "rad") {
399
+ ctrl.units = /** @type {'deg'|'rad'} */ (flags.units);
400
+ }
401
+ if (kind === "timer") {
402
+ const speed = flags.speed !== undefined ? Number(flags.speed) : 30;
403
+ ctrl.speed = Number.isFinite(speed) && speed > 0 ? speed : 30;
404
+ ctrl.loop = flags.loop === undefined ? true : parseBool(flags.loop, true);
405
+ }
406
+ controls.push(ctrl);
156
407
  i++;
157
408
  continue;
158
409
  }
159
410
  }
160
411
 
161
- // @timer name: min..max = default speed=N step=S loop|noloop "Label"
412
+ // @input|@i name: "default" "Label"
162
413
  {
163
- const m = line.match(/^@timer\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/i);
414
+ const m = line.match(/^@(input|i)\s+([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*(.+)$/i);
164
415
  if (m) {
165
- const name = m[1];
166
- const [rest, label] = takeLabel(m[2]);
167
- const rm = rest.match(
168
- /^(-?[0-9]*\.?[0-9]+)\.\.(-?[0-9]*\.?[0-9]+)\s*(?:=\s*(-?[0-9]*\.?[0-9]+))?\s*(.*)$/
169
- );
170
- if (!rm) {
171
- errors.push(`@timer ${name}: expected min..max [= default]`);
172
- i++;
173
- continue;
174
- }
175
- const range = /** @type {[number, number]} */ ([Number(rm[1]), Number(rm[2])]);
176
- const def = rm[3] !== undefined ? Number(rm[3]) : range[0];
177
- const flags = parseFlags(rm[4] || "");
178
- const step = flags.step !== undefined ? Number(flags.step) : 1;
179
- const speed = flags.speed !== undefined ? Number(flags.speed) : 30;
180
- const loop = flags.loop === undefined ? true : parseBool(flags.loop, true);
416
+ const name = m[2];
417
+ const [rest, label] = takeLabel(m[3]);
181
418
  controls.push({
182
- kind: "timer",
419
+ kind: "input",
183
420
  name,
184
- range,
185
- step: Number.isFinite(step) ? step : 1,
186
- default: Number.isFinite(def) ? def : range[0],
187
- speed: Number.isFinite(speed) && speed > 0 ? speed : 30,
188
- loop,
189
- label: label || flags.label,
421
+ default: unquote(rest) ?? "",
422
+ label: label || undefined,
190
423
  });
191
424
  i++;
192
425
  continue;
193
426
  }
194
427
  }
195
428
 
196
- // @input name: "default" "Label"
429
+ // @toggle|@b name: true|false "Label"
197
430
  {
198
- const m = line.match(/^@input\s+([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*(.+)$/i);
431
+ const m = line.match(/^@(toggle|b)\s+([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*(.+)$/i);
199
432
  if (m) {
200
- const name = m[1];
201
- const [rest, label] = takeLabel(m[2]);
202
- controls.push({ kind: "input", name, default: unquote(rest) ?? "", label });
203
- i++;
204
- continue;
205
- }
206
- }
207
-
208
- // @toggle name: true|false "Label"
209
- {
210
- const m = line.match(/^@toggle\s+([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*(.+)$/i);
211
- if (m) {
212
- const name = m[1];
213
- const [rest, label] = takeLabel(m[2]);
433
+ const name = m[2];
434
+ const [rest, label] = takeLabel(m[3]);
214
435
  controls.push({
215
436
  kind: "toggle",
216
437
  name,
217
438
  default: parseBool(rest, false),
218
- label,
439
+ label: label || undefined,
219
440
  });
220
441
  i++;
221
442
  continue;
@@ -226,7 +447,25 @@ export function parseIvm(body) {
226
447
  i++;
227
448
  }
228
449
 
229
- return { namespace, controls, templateSvg, errors };
450
+ return { namespace, controls, templateSvg, svgWidth, svgHeight, graph, errors };
451
+ }
452
+
453
+ /**
454
+ * Replace the Nth ```ivm fence (0-based) with a new interior.
455
+ * Indexes are computed from the current source, so earlier edits stay consistent.
456
+ * @param {string} source
457
+ * @param {number} fenceIndex
458
+ * @param {string} body
459
+ * @returns {string}
460
+ */
461
+ export function replaceIvmFence(source, fenceIndex, body) {
462
+ const src = String(source).replace(/\r\n/g, "\n");
463
+ const ivm = splitIvmFences(src).filter((s) => s.type === "ivm");
464
+ const seg = ivm[fenceIndex];
465
+ if (!seg) return src;
466
+ const interior = String(body).replace(/\s*$/, "\n");
467
+ const next = "```ivm\n" + interior + "```";
468
+ return src.slice(0, seg.start) + next + src.slice(seg.end);
230
469
  }
231
470
 
232
471
  /**
package/src/render.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { evaluate, substitute } from "./expr.js";
6
6
  import { parseIvm } from "./parse-ivm.js";
7
7
  import { sanitizeSvg } from "./sanitize-svg.js";
8
+ import { mountGraphEditor, serializeGraph } from "./graph-editor.js";
8
9
 
9
10
  /** @type {Map<string, Record<string, unknown>>} */
10
11
  const namespaces = new Map();
@@ -28,9 +29,10 @@ export function resetNamespaces() {
28
29
  * Mount one ```ivm fence body into a container element.
29
30
  * @param {HTMLElement} container
30
31
  * @param {string} fenceBody
32
+ * @param {{ onGraphChange?: (body: string) => void }} [options]
31
33
  * @returns {{ destroy: () => void, getState: () => Record<string, unknown> }}
32
34
  */
33
- export function mountIvm(container, fenceBody) {
35
+ export function mountIvm(container, fenceBody, options = {}) {
34
36
  const parsed = parseIvm(fenceBody);
35
37
  const doc = container.ownerDocument || globalThis.document;
36
38
  container.classList.add("im-ivm");
@@ -43,6 +45,26 @@ export function mountIvm(container, fenceBody) {
43
45
  container.appendChild(err);
44
46
  }
45
47
 
48
+ if (parsed.graph) {
49
+ container.classList.add("im-ivm-graph");
50
+ const graphHost = doc.createElement("div");
51
+ container.appendChild(graphHost);
52
+ const mounted = mountGraphEditor(graphHost, parsed.graph, {
53
+ onChange(model) {
54
+ options.onGraphChange?.(serializeGraph(model));
55
+ },
56
+ });
57
+ return {
58
+ destroy() {
59
+ mounted.destroy();
60
+ container.innerHTML = "";
61
+ },
62
+ getState() {
63
+ return { graph: mounted.getModel() };
64
+ },
65
+ };
66
+ }
67
+
46
68
  const nsState = getNamespaceState(parsed.namespace);
47
69
  /** @type {Record<string, unknown>} */
48
70
  const local = Object.create(null);
@@ -58,13 +80,14 @@ export function mountIvm(container, fenceBody) {
58
80
  if (parsed.namespace) nsState[c.name] = val;
59
81
  }
60
82
 
83
+ const hasControls = parsed.controls.some((c) => c.kind !== "let" && c.name != null);
84
+ if (!hasControls) container.classList.add("im-ivm-static");
85
+
61
86
  const controlsEl = doc.createElement("div");
62
87
  controlsEl.className = "im-controls";
63
- container.appendChild(controlsEl);
64
88
 
65
89
  const svgHost = doc.createElement("div");
66
90
  svgHost.className = "im-svg-host";
67
- container.appendChild(svgHost);
68
91
 
69
92
  /** @type {Array<() => void>} */
70
93
  const cleanups = [];
@@ -92,6 +115,67 @@ export function mountIvm(container, fenceBody) {
92
115
  return env;
93
116
  }
94
117
 
118
+ function wrapSvgTemplate(markup) {
119
+ const src = String(markup ?? "").trim();
120
+ if (!src) return src;
121
+ const w = parsed.svgWidth;
122
+ const h = parsed.svgHeight;
123
+ if (w == null || h == null || !Number.isFinite(w) || !Number.isFinite(h)) {
124
+ return src;
125
+ }
126
+ // Already a root <svg> — leave author markup alone
127
+ if (/^<svg\b/i.test(src)) return src;
128
+ return (
129
+ `<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">\n` +
130
+ src +
131
+ `\n</svg>`
132
+ );
133
+ }
134
+
135
+ /** Skip DOM writes when the substituted source is unchanged so CSS and SMIL keep running. */
136
+ let lastFilled = null;
137
+
138
+ /**
139
+ * Mount markup in a shadow root so <style> in the figure cannot restyle the editor.
140
+ * transform on the host makes position:fixed a local containing block.
141
+ * @param {string} markup
142
+ */
143
+ function paintSvg(markup) {
144
+ let shadow = svgHost.shadowRoot;
145
+ if (!shadow && typeof svgHost.attachShadow === "function") {
146
+ try {
147
+ shadow = svgHost.attachShadow({ mode: "open" });
148
+ } catch (_) {
149
+ shadow = null;
150
+ }
151
+ }
152
+ const target = shadow || svgHost;
153
+ const doc = svgHost.ownerDocument || globalThis.document;
154
+ target.innerHTML = "";
155
+ if (!markup) return;
156
+
157
+ // Scope figure CSS to this widget. transform makes position:fixed local.
158
+ if (shadow && doc?.createElement) {
159
+ const frame = doc.createElement("style");
160
+ frame.textContent =
161
+ ":host{display:block;overflow:hidden;transform:translate(0)}" +
162
+ "svg{display:block;max-width:100%;height:auto;margin:0 auto}" +
163
+ "@media (prefers-reduced-motion:reduce){:host *{animation:none!important}}";
164
+ target.appendChild(frame);
165
+ }
166
+
167
+ // Parse as XML and adopt the node. innerHTML would lowercase camelCase
168
+ // SVG tags (radialGradient, feGaussianBlur, animateMotion) and drop them.
169
+ const Parser = globalThis.DOMParser;
170
+ if (!Parser) return;
171
+ const parsed = new Parser().parseFromString(markup, "image/svg+xml");
172
+ const root = parsed.documentElement;
173
+ const rootName = String(root?.localName || root?.tagName || "").toLowerCase();
174
+ if (!root || rootName === "parsererror" || rootName !== "svg") return;
175
+ const node = typeof doc.importNode === "function" ? doc.importNode(root, true) : root;
176
+ target.appendChild(node);
177
+ }
178
+
95
179
  function rebind() {
96
180
  const env = buildEnv();
97
181
  for (const [name, el] of valueLabels) {
@@ -100,11 +184,16 @@ export function mountIvm(container, fenceBody) {
100
184
  typeof v === "number" ? String(Math.round(v * 1000) / 1000) : String(v ?? "");
101
185
  }
102
186
  if (!parsed.templateSvg) {
103
- svgHost.innerHTML = "";
187
+ if (lastFilled !== "") {
188
+ lastFilled = "";
189
+ paintSvg("");
190
+ }
104
191
  return;
105
192
  }
106
- const filled = substitute(parsed.templateSvg, env);
107
- svgHost.innerHTML = sanitizeSvg(filled);
193
+ const filled = substitute(wrapSvgTemplate(parsed.templateSvg), env);
194
+ if (filled === lastFilled) return;
195
+ lastFilled = filled;
196
+ paintSvg(sanitizeSvg(filled));
108
197
  }
109
198
 
110
199
  function setVar(name, value) {
@@ -239,6 +328,9 @@ export function mountIvm(container, fenceBody) {
239
328
  }
240
329
  }
241
330
 
331
+ if (hasControls) container.appendChild(controlsEl);
332
+ container.appendChild(svgHost);
333
+
242
334
  rebind();
243
335
 
244
336
  return {