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/LICENSE +1 -1
- package/README.md +89 -26
- package/examples/agent-loop.md +26 -0
- package/examples/amplitude.md +11 -14
- package/examples/circular-motion.md +10 -14
- package/examples/graph.md +18 -0
- package/index.html +7 -1
- package/package.json +10 -4
- package/src/app.js +85 -8
- package/src/document-editor.js +956 -0
- package/src/document.js +633 -0
- package/src/expr.js +6 -0
- package/src/graph-editor.js +1117 -0
- package/src/markdown.js +10 -2
- package/src/parse-ivm.js +306 -67
- package/src/render.js +98 -6
- package/src/sanitize-svg.js +240 -9
- package/styles/app.css +647 -1
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(
|
|
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
|
|
13
|
-
* @svg
|
|
14
|
-
*
|
|
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
|
-
// @
|
|
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(/^@(
|
|
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[
|
|
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
|
|
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
|
|
135
|
-
const
|
|
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(
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
// @
|
|
412
|
+
// @input|@i name: "default" "Label"
|
|
162
413
|
{
|
|
163
|
-
const m = line.match(/^@
|
|
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[
|
|
166
|
-
const [rest, label] = takeLabel(m[
|
|
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: "
|
|
419
|
+
kind: "input",
|
|
183
420
|
name,
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
// @
|
|
429
|
+
// @toggle|@b name: true|false "Label"
|
|
197
430
|
{
|
|
198
|
-
const m = line.match(/^@
|
|
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[
|
|
201
|
-
const [rest, label] = takeLabel(m[
|
|
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
|
-
|
|
187
|
+
if (lastFilled !== "") {
|
|
188
|
+
lastFilled = "";
|
|
189
|
+
paintSvg("");
|
|
190
|
+
}
|
|
104
191
|
return;
|
|
105
192
|
}
|
|
106
|
-
const filled = substitute(parsed.templateSvg, env);
|
|
107
|
-
|
|
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 {
|