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.
- package/LICENSE +1 -1
- package/README.md +122 -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/examples/normal-density.md +19 -0
- package/index.html +8 -1
- package/package.json +13 -5
- package/src/app.js +85 -8
- package/src/document-editor.js +1062 -0
- package/src/document.js +771 -0
- package/src/expr.js +6 -0
- package/src/graph-editor.js +1117 -0
- package/src/index.js +1 -0
- package/src/markdown.js +10 -2
- package/src/parse-ivm.js +488 -67
- package/src/plot.js +401 -0
- package/src/render.js +116 -6
- package/src/sanitize-svg.js +240 -9
- package/styles/app.css +659 -1
package/src/document.js
ADDED
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document model for the preview block editor.
|
|
3
|
+
* Markdown stays the source of truth: parseDocument reads it, serializeDocument writes it back.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { parseIvm, splitIvmFences } from "./parse-ivm.js";
|
|
7
|
+
import { quoteGraphLabel, serializeGraph } from "./graph-editor.js";
|
|
8
|
+
import { normalizePlot, serializePlotLines } from "./plot.js";
|
|
9
|
+
|
|
10
|
+
/** @typedef {'rect'|'circle'|'ellipse'|'line'|'path'|'text'} ShapeType */
|
|
11
|
+
/** @typedef {{ type: ShapeType, attrs: Record<string, string> }} Shape */
|
|
12
|
+
/** @typedef {{ nodes: {id:string,label:string,x:number,y:number,kind:string}[], edges: {from:string,to:string}[] }} GraphModel */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} BlockBase
|
|
16
|
+
* @property {string} id
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
let seq = 1;
|
|
20
|
+
function nid() {
|
|
21
|
+
return "b" + seq++;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
25
|
+
const SIMPLE_TAGS = new Set(["svg", "g", "rect", "circle", "ellipse", "line", "path", "text"]);
|
|
26
|
+
const SHAPE_TAGS = new Set(["rect", "circle", "ellipse", "line", "path", "text"]);
|
|
27
|
+
|
|
28
|
+
const CONTROL_PREFIX = {
|
|
29
|
+
slider: "s",
|
|
30
|
+
timer: "t",
|
|
31
|
+
input: "in",
|
|
32
|
+
toggle: "c",
|
|
33
|
+
let: "y",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} markdown
|
|
38
|
+
* @returns {{ blocks: object[] }}
|
|
39
|
+
*/
|
|
40
|
+
export function parseDocument(markdown) {
|
|
41
|
+
const segments = splitIvmFences(markdown);
|
|
42
|
+
/** @type {object[]} */
|
|
43
|
+
const blocks = [];
|
|
44
|
+
for (const seg of segments) {
|
|
45
|
+
if (seg.type === "ivm") blocks.push(blockFromIvm(seg.content));
|
|
46
|
+
else blocks.push(...parseProse(seg.content));
|
|
47
|
+
}
|
|
48
|
+
return { blocks };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @returns {{ blocks: object[] }}
|
|
53
|
+
*/
|
|
54
|
+
export function blankDocument() {
|
|
55
|
+
return { blocks: [] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {object[]} blocks
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function serializeDocument(blocks) {
|
|
63
|
+
const parts = [];
|
|
64
|
+
for (const block of blocks || []) {
|
|
65
|
+
const text = serializeBlock(block).replace(/\s+$/, "");
|
|
66
|
+
if (text) parts.push(text);
|
|
67
|
+
}
|
|
68
|
+
if (!parts.length) return "\n";
|
|
69
|
+
return parts.join("\n\n") + "\n";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fence body for one figure or graph. Used by the live preview.
|
|
74
|
+
* @param {object} block
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function serializeFigure(block) {
|
|
78
|
+
if (!block) return "\n";
|
|
79
|
+
if (block.type === "graph") return serializeGraph(block.model || { nodes: [], edges: [] });
|
|
80
|
+
if (block.type === "plot") {
|
|
81
|
+
const rebuilt = [];
|
|
82
|
+
if (block.namespace) rebuilt.push(`@ns ${block.namespace}`);
|
|
83
|
+
const plotLines = serializePlotLines(normalizePlot(block.plot || {}));
|
|
84
|
+
const preamble = [];
|
|
85
|
+
const series = [];
|
|
86
|
+
let sawSeries = false;
|
|
87
|
+
for (const line of plotLines) {
|
|
88
|
+
if (/^@series\b/i.test(line)) sawSeries = true;
|
|
89
|
+
if (sawSeries) series.push(line);
|
|
90
|
+
else preamble.push(line);
|
|
91
|
+
}
|
|
92
|
+
rebuilt.push(...preamble);
|
|
93
|
+
for (const control of block.controls || []) {
|
|
94
|
+
const line = serializeControl(control);
|
|
95
|
+
if (line) rebuilt.push(line);
|
|
96
|
+
}
|
|
97
|
+
rebuilt.push(...series);
|
|
98
|
+
return rebuilt.join("\n").replace(/\s*$/, "\n");
|
|
99
|
+
}
|
|
100
|
+
if (block.type !== "figure") return "\n";
|
|
101
|
+
const lines = [];
|
|
102
|
+
if (block.namespace) lines.push(`@ns ${block.namespace}`);
|
|
103
|
+
for (const control of block.controls || []) {
|
|
104
|
+
const line = serializeControl(control);
|
|
105
|
+
if (line) lines.push(line);
|
|
106
|
+
}
|
|
107
|
+
const svg = svgMarkup(block);
|
|
108
|
+
if (svg) {
|
|
109
|
+
lines.push("@svg");
|
|
110
|
+
lines.push(svg);
|
|
111
|
+
}
|
|
112
|
+
return lines.join("\n").replace(/\s*$/, "\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {{ blocks?: object[] }} doc
|
|
117
|
+
* @param {string} feature
|
|
118
|
+
* @param {{ at?: number, figureId?: string, shapeType?: string }} [options]
|
|
119
|
+
* @returns {{ doc: { blocks: object[] }, focusId: string }}
|
|
120
|
+
*/
|
|
121
|
+
export function insertFeature(doc, feature, options = {}) {
|
|
122
|
+
const blocks = (doc?.blocks || []).map(cloneBlock);
|
|
123
|
+
const at = options.at;
|
|
124
|
+
/** @type {string} */
|
|
125
|
+
let focusId = "";
|
|
126
|
+
|
|
127
|
+
const place = (block) => {
|
|
128
|
+
const index = at == null ? blocks.length : Math.max(0, Math.min(blocks.length, at));
|
|
129
|
+
blocks.splice(index, 0, block);
|
|
130
|
+
focusId = block.id;
|
|
131
|
+
return block;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
if (feature === "heading") {
|
|
135
|
+
place({ id: nid(), type: "heading", level: 2, text: "Heading" });
|
|
136
|
+
} else if (feature === "paragraph") {
|
|
137
|
+
place({ id: nid(), type: "paragraph", text: "" });
|
|
138
|
+
} else if (feature === "bullets") {
|
|
139
|
+
place({ id: nid(), type: "bullets", items: ["Item"] });
|
|
140
|
+
} else if (feature === "graph" || feature === "animated") {
|
|
141
|
+
place({
|
|
142
|
+
id: nid(),
|
|
143
|
+
type: "graph",
|
|
144
|
+
model: { nodes: [], edges: [], animated: true },
|
|
145
|
+
});
|
|
146
|
+
} else if (feature === "shape") {
|
|
147
|
+
const shape = defaultShape(options.shapeType || "rect");
|
|
148
|
+
const existing = findFigure(blocks, options.figureId);
|
|
149
|
+
if (existing && existing.shapes && !existing.customSvg) {
|
|
150
|
+
existing.shapes.push(shape);
|
|
151
|
+
focusId = existing.id;
|
|
152
|
+
} else {
|
|
153
|
+
const fig = emptyFigure();
|
|
154
|
+
fig.shapes = [shape];
|
|
155
|
+
place(fig);
|
|
156
|
+
}
|
|
157
|
+
} else if (feature === "plot") {
|
|
158
|
+
place(emptyPlot());
|
|
159
|
+
} else if (feature === "slider" || feature === "timer" || feature === "input" || feature === "toggle" || feature === "formula") {
|
|
160
|
+
const kind = feature === "formula" ? "let" : feature;
|
|
161
|
+
const control = defaultControl(kind, nextControlName(blocks, CONTROL_PREFIX[kind]));
|
|
162
|
+
const existing = findFigure(blocks, options.figureId);
|
|
163
|
+
if (existing) {
|
|
164
|
+
existing.controls.push(control);
|
|
165
|
+
focusId = existing.id;
|
|
166
|
+
} else {
|
|
167
|
+
const fig = emptyFigure();
|
|
168
|
+
fig.controls.push(control);
|
|
169
|
+
place(fig);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { doc: { blocks }, focusId };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {{ blocks?: object[] }} doc
|
|
178
|
+
* @param {string} id
|
|
179
|
+
* @returns {{ blocks: object[] }}
|
|
180
|
+
*/
|
|
181
|
+
export function removeBlock(doc, id) {
|
|
182
|
+
return { blocks: (doc?.blocks || []).filter((b) => b.id !== id).map(cloneBlock) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {{ blocks?: object[] }} doc
|
|
187
|
+
* @param {string} id
|
|
188
|
+
* @param {number} delta
|
|
189
|
+
* @returns {{ blocks: object[] }}
|
|
190
|
+
*/
|
|
191
|
+
export function moveBlock(doc, id, delta) {
|
|
192
|
+
const blocks = (doc?.blocks || []).map(cloneBlock);
|
|
193
|
+
const i = blocks.findIndex((b) => b.id === id);
|
|
194
|
+
const j = i + delta;
|
|
195
|
+
if (i < 0 || j < 0 || j >= blocks.length) return { blocks };
|
|
196
|
+
const [item] = blocks.splice(i, 1);
|
|
197
|
+
blocks.splice(j, 0, item);
|
|
198
|
+
return { blocks };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {{ blocks?: object[] }} doc
|
|
203
|
+
* @param {string} id
|
|
204
|
+
* @param {object} patch
|
|
205
|
+
* @returns {{ blocks: object[] }}
|
|
206
|
+
*/
|
|
207
|
+
export function updateBlock(doc, id, patch) {
|
|
208
|
+
return {
|
|
209
|
+
blocks: (doc?.blocks || []).map((b) => (b.id === id ? cloneBlock({ ...b, ...patch }) : cloneBlock(b))),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {{ blocks?: object[] }} doc
|
|
215
|
+
* @param {string} blockId
|
|
216
|
+
* @param {number} index
|
|
217
|
+
* @param {object} patch
|
|
218
|
+
* @returns {{ blocks: object[] }}
|
|
219
|
+
*/
|
|
220
|
+
export function updateControl(doc, blockId, index, patch) {
|
|
221
|
+
return {
|
|
222
|
+
blocks: (doc?.blocks || []).map((b) => {
|
|
223
|
+
const copy = cloneBlock(b);
|
|
224
|
+
if (copy.id === blockId && (copy.type === "figure" || copy.type === "plot") && copy.controls[index]) {
|
|
225
|
+
copy.controls[index] = cloneControl({ ...copy.controls[index], ...patch });
|
|
226
|
+
}
|
|
227
|
+
return copy;
|
|
228
|
+
}),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param {{ blocks?: object[] }} doc
|
|
234
|
+
* @param {string} blockId
|
|
235
|
+
* @param {number} index
|
|
236
|
+
* @returns {{ blocks: object[] }}
|
|
237
|
+
*/
|
|
238
|
+
export function removeControl(doc, blockId, index) {
|
|
239
|
+
return {
|
|
240
|
+
blocks: (doc?.blocks || []).map((b) => {
|
|
241
|
+
const copy = cloneBlock(b);
|
|
242
|
+
if (copy.id === blockId && (copy.type === "figure" || copy.type === "plot")) {
|
|
243
|
+
copy.controls = copy.controls.filter((_, i) => i !== index);
|
|
244
|
+
}
|
|
245
|
+
return copy;
|
|
246
|
+
}),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @param {{ blocks?: object[] }} doc
|
|
252
|
+
* @param {string} blockId
|
|
253
|
+
* @param {number} index
|
|
254
|
+
* @param {Record<string, string>} attrs
|
|
255
|
+
* @returns {{ blocks: object[] }}
|
|
256
|
+
*/
|
|
257
|
+
export function updateShape(doc, blockId, index, attrs) {
|
|
258
|
+
return {
|
|
259
|
+
blocks: (doc?.blocks || []).map((b) => {
|
|
260
|
+
const copy = cloneBlock(b);
|
|
261
|
+
if (copy.id === blockId && copy.type === "figure" && copy.shapes && copy.shapes[index]) {
|
|
262
|
+
copy.shapes[index] = {
|
|
263
|
+
type: copy.shapes[index].type,
|
|
264
|
+
attrs: { ...copy.shapes[index].attrs, ...attrs },
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
return copy;
|
|
268
|
+
}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @param {{ blocks?: object[] }} doc
|
|
274
|
+
* @param {string} blockId
|
|
275
|
+
* @param {number} index
|
|
276
|
+
* @returns {{ blocks: object[] }}
|
|
277
|
+
*/
|
|
278
|
+
export function removeShape(doc, blockId, index) {
|
|
279
|
+
return {
|
|
280
|
+
blocks: (doc?.blocks || []).map((b) => {
|
|
281
|
+
const copy = cloneBlock(b);
|
|
282
|
+
if (copy.id === blockId && copy.type === "figure" && copy.shapes) {
|
|
283
|
+
copy.shapes = copy.shapes.filter((_, i) => i !== index);
|
|
284
|
+
}
|
|
285
|
+
return copy;
|
|
286
|
+
}),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* @param {{ blocks?: object[] }} doc
|
|
293
|
+
* @param {string} blockId
|
|
294
|
+
* @param {"x"|"y"} which
|
|
295
|
+
* @param {object} patch
|
|
296
|
+
* @returns {{ blocks: object[] }}
|
|
297
|
+
*/
|
|
298
|
+
export function updatePlotAxis(doc, blockId, which, patch) {
|
|
299
|
+
return {
|
|
300
|
+
blocks: (doc?.blocks || []).map((b) => {
|
|
301
|
+
const copy = cloneBlock(b);
|
|
302
|
+
if (copy.id === blockId && copy.type === "plot" && copy.plot) {
|
|
303
|
+
const axis = { ...(copy.plot[which] || {}), ...patch };
|
|
304
|
+
copy.plot = { ...copy.plot, [which]: axis };
|
|
305
|
+
}
|
|
306
|
+
return copy;
|
|
307
|
+
}),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {{ blocks?: object[] }} doc
|
|
313
|
+
* @param {string} blockId
|
|
314
|
+
* @param {number} index
|
|
315
|
+
* @param {object} patch
|
|
316
|
+
* @returns {{ blocks: object[] }}
|
|
317
|
+
*/
|
|
318
|
+
export function updatePlotSeries(doc, blockId, index, patch) {
|
|
319
|
+
return {
|
|
320
|
+
blocks: (doc?.blocks || []).map((b) => {
|
|
321
|
+
const copy = cloneBlock(b);
|
|
322
|
+
if (copy.id === blockId && copy.type === "plot" && copy.plot?.series?.[index]) {
|
|
323
|
+
copy.plot.series[index] = { ...copy.plot.series[index], ...patch };
|
|
324
|
+
}
|
|
325
|
+
return copy;
|
|
326
|
+
}),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* @param {{ blocks?: object[] }} doc
|
|
332
|
+
* @param {string} blockId
|
|
333
|
+
* @param {object} patch
|
|
334
|
+
* @returns {{ blocks: object[] }}
|
|
335
|
+
*/
|
|
336
|
+
export function updatePlotMeta(doc, blockId, patch) {
|
|
337
|
+
return {
|
|
338
|
+
blocks: (doc?.blocks || []).map((b) => {
|
|
339
|
+
const copy = cloneBlock(b);
|
|
340
|
+
if (copy.id === blockId && copy.type === "plot" && copy.plot) {
|
|
341
|
+
copy.plot = { ...copy.plot, ...patch };
|
|
342
|
+
}
|
|
343
|
+
return copy;
|
|
344
|
+
}),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export { NAME_RE };
|
|
349
|
+
|
|
350
|
+
function emptyFigure() {
|
|
351
|
+
return {
|
|
352
|
+
id: nid(),
|
|
353
|
+
type: "figure",
|
|
354
|
+
namespace: null,
|
|
355
|
+
controls: [],
|
|
356
|
+
viewBox: "0 0 200 120",
|
|
357
|
+
shapes: [],
|
|
358
|
+
customSvg: null,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function emptyPlot() {
|
|
363
|
+
return {
|
|
364
|
+
id: nid(),
|
|
365
|
+
type: "plot",
|
|
366
|
+
namespace: null,
|
|
367
|
+
controls: [
|
|
368
|
+
{ kind: "slider", name: "mu", range: [-5, 5], step: 0.1, default: 0, label: "μ (mean)" },
|
|
369
|
+
{ kind: "slider", name: "sigma", range: [0.5, 4], step: 0.1, default: 1.5, label: "σ (std dev)" },
|
|
370
|
+
],
|
|
371
|
+
plot: {
|
|
372
|
+
width: 480,
|
|
373
|
+
height: 320,
|
|
374
|
+
grid: true,
|
|
375
|
+
x: { min: -10, max: 10, step: 2, label: "x" },
|
|
376
|
+
y: { min: 0, max: 0.8, step: 0.2, label: "density" },
|
|
377
|
+
series: [
|
|
378
|
+
{
|
|
379
|
+
id: "density",
|
|
380
|
+
expression: "(1/(sigma*sqrt(2*pi))) * exp(-0.5*((x-mu)/sigma)^2)",
|
|
381
|
+
stroke: "#6d28d9",
|
|
382
|
+
fill: "#7c3aed",
|
|
383
|
+
fillOpacity: 0.25,
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* @param {object | null | undefined} plot
|
|
392
|
+
*/
|
|
393
|
+
function copyPlotModel(plot) {
|
|
394
|
+
const model = normalizePlot(plot || {});
|
|
395
|
+
return {
|
|
396
|
+
width: model.width,
|
|
397
|
+
height: model.height,
|
|
398
|
+
grid: model.grid,
|
|
399
|
+
samples: model.samples,
|
|
400
|
+
x: { ...model.x },
|
|
401
|
+
y: { ...model.y },
|
|
402
|
+
series: model.series.map((s) => ({ ...s })),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* @param {object[]} blocks
|
|
408
|
+
* @param {string} [id]
|
|
409
|
+
*/
|
|
410
|
+
function findFigure(blocks, id) {
|
|
411
|
+
if (!id) return null;
|
|
412
|
+
const block = blocks.find((b) => b.id === id && (b.type === "figure" || b.type === "plot"));
|
|
413
|
+
return block || null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* @param {object[]} blocks
|
|
418
|
+
* @param {string} prefix
|
|
419
|
+
*/
|
|
420
|
+
function nextControlName(blocks, prefix) {
|
|
421
|
+
const used = new Set();
|
|
422
|
+
for (const block of blocks) {
|
|
423
|
+
if (block.type !== "figure" && block.type !== "plot") continue;
|
|
424
|
+
for (const control of block.controls || []) {
|
|
425
|
+
if (control.name) used.add(control.name);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
let i = 1;
|
|
429
|
+
while (used.has(prefix + i)) i++;
|
|
430
|
+
return prefix + i;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* @param {string} kind
|
|
435
|
+
* @param {string} name
|
|
436
|
+
*/
|
|
437
|
+
function defaultControl(kind, name) {
|
|
438
|
+
if (kind === "slider") {
|
|
439
|
+
return { kind, name, range: [0, 100], step: 1, default: 50, label: "Slider" };
|
|
440
|
+
}
|
|
441
|
+
if (kind === "timer") {
|
|
442
|
+
return { kind, name, range: [0, 100], step: 1, default: 0, speed: 20, loop: true, label: "Timer" };
|
|
443
|
+
}
|
|
444
|
+
if (kind === "input") return { kind, name, default: "", label: "Text" };
|
|
445
|
+
if (kind === "toggle") return { kind, name, default: false, label: "Toggle" };
|
|
446
|
+
return { kind: "let", name, expression: "0" };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* @param {string} type
|
|
451
|
+
* @returns {Shape}
|
|
452
|
+
*/
|
|
453
|
+
function defaultShape(type) {
|
|
454
|
+
if (type === "circle") return { type, attrs: { cx: "40", cy: "40", r: "16", fill: "#38bdf8" } };
|
|
455
|
+
if (type === "ellipse") return { type, attrs: { cx: "40", cy: "40", rx: "24", ry: "14", fill: "#38bdf8" } };
|
|
456
|
+
if (type === "line") {
|
|
457
|
+
return { type, attrs: { x1: "8", y1: "40", x2: "120", y2: "40", stroke: "#38bdf8", "stroke-width": "2" } };
|
|
458
|
+
}
|
|
459
|
+
if (type === "path") {
|
|
460
|
+
return {
|
|
461
|
+
type,
|
|
462
|
+
attrs: { d: "M 8 40 Q 40 8 72 40", fill: "none", stroke: "#38bdf8", "stroke-width": "2" },
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
if (type === "text") {
|
|
466
|
+
return { type, attrs: { x: "8", y: "24", fill: "#e5e7eb", "font-size": "14", text: "Label" } };
|
|
467
|
+
}
|
|
468
|
+
return { type: "rect", attrs: { x: "8", y: "8", width: "80", height: "40", fill: "#1e293b" } };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* @param {string} content
|
|
473
|
+
*/
|
|
474
|
+
function parseProse(content) {
|
|
475
|
+
const lines = String(content).replace(/\r\n/g, "\n").split("\n");
|
|
476
|
+
/** @type {object[]} */
|
|
477
|
+
const blocks = [];
|
|
478
|
+
let i = 0;
|
|
479
|
+
while (i < lines.length) {
|
|
480
|
+
if (!lines[i].trim()) {
|
|
481
|
+
i++;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (lines[i].startsWith("```")) {
|
|
485
|
+
const start = i;
|
|
486
|
+
i++;
|
|
487
|
+
while (i < lines.length && !lines[i].startsWith("```")) i++;
|
|
488
|
+
if (i < lines.length) i++;
|
|
489
|
+
blocks.push({ id: nid(), type: "markdown", text: lines.slice(start, i).join("\n") });
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
const heading = lines[i].match(/^(#{1,3})\s+(.*)$/);
|
|
493
|
+
if (heading) {
|
|
494
|
+
blocks.push({ id: nid(), type: "heading", level: heading[1].length, text: heading[2] });
|
|
495
|
+
i++;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (/^\s*[-*]\s+/.test(lines[i])) {
|
|
499
|
+
const items = [];
|
|
500
|
+
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
|
501
|
+
items.push(lines[i].replace(/^\s*[-*]\s+/, ""));
|
|
502
|
+
i++;
|
|
503
|
+
}
|
|
504
|
+
blocks.push({ id: nid(), type: "bullets", items });
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const start = i;
|
|
508
|
+
i++;
|
|
509
|
+
while (
|
|
510
|
+
i < lines.length &&
|
|
511
|
+
lines[i].trim() &&
|
|
512
|
+
!lines[i].startsWith("```") &&
|
|
513
|
+
!/^(#{1,3})\s+/.test(lines[i]) &&
|
|
514
|
+
!/^\s*[-*]\s+/.test(lines[i])
|
|
515
|
+
) {
|
|
516
|
+
i++;
|
|
517
|
+
}
|
|
518
|
+
blocks.push({ id: nid(), type: "paragraph", text: lines.slice(start, i).join("\n").trim() });
|
|
519
|
+
}
|
|
520
|
+
return blocks;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* @param {string} body
|
|
525
|
+
*/
|
|
526
|
+
function blockFromIvm(body) {
|
|
527
|
+
const parsed = parseIvm(body);
|
|
528
|
+
if (parsed.graph) {
|
|
529
|
+
return {
|
|
530
|
+
id: nid(),
|
|
531
|
+
type: "graph",
|
|
532
|
+
model: copyGraphModel(parsed.graph),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
if (parsed.plot) {
|
|
536
|
+
return {
|
|
537
|
+
id: nid(),
|
|
538
|
+
type: "plot",
|
|
539
|
+
namespace: parsed.namespace,
|
|
540
|
+
controls: parsed.controls.map(cloneControl),
|
|
541
|
+
plot: copyPlotModel(parsed.plot),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
const raw = parsed.templateSvg;
|
|
545
|
+
const template = wrapSlimSvg(raw, parsed.svgWidth, parsed.svgHeight);
|
|
546
|
+
const shapes = template ? tryParseShapes(template) : [];
|
|
547
|
+
const custom = shapes == null;
|
|
548
|
+
const viewBox =
|
|
549
|
+
(template && readViewBox(template)) ||
|
|
550
|
+
(parsed.svgWidth != null && parsed.svgHeight != null
|
|
551
|
+
? `0 0 ${parsed.svgWidth} ${parsed.svgHeight}`
|
|
552
|
+
: "0 0 200 120");
|
|
553
|
+
return {
|
|
554
|
+
id: nid(),
|
|
555
|
+
type: "figure",
|
|
556
|
+
namespace: parsed.namespace,
|
|
557
|
+
controls: parsed.controls.map(cloneControl),
|
|
558
|
+
viewBox,
|
|
559
|
+
shapes: custom ? null : shapes,
|
|
560
|
+
customSvg: custom ? raw : null,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* If @svg WxH left non-root markup, wrap it so shape parsing sees an <svg> root.
|
|
566
|
+
* @param {string|null} markup
|
|
567
|
+
* @param {number|null} width
|
|
568
|
+
* @param {number|null} height
|
|
569
|
+
*/
|
|
570
|
+
function wrapSlimSvg(markup, width, height) {
|
|
571
|
+
if (markup == null) return null;
|
|
572
|
+
const src = String(markup);
|
|
573
|
+
const trimmed = src.trim();
|
|
574
|
+
if (!trimmed) return src;
|
|
575
|
+
if (/^<svg\b/i.test(trimmed)) return src;
|
|
576
|
+
if (width == null || height == null || !Number.isFinite(width) || !Number.isFinite(height)) {
|
|
577
|
+
return src;
|
|
578
|
+
}
|
|
579
|
+
return (
|
|
580
|
+
`<svg viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">\n` +
|
|
581
|
+
src.replace(/^\n+/, "").replace(/\n+$/, "\n") +
|
|
582
|
+
`</svg>`
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* @param {string} svg
|
|
588
|
+
* @returns {Shape[] | null}
|
|
589
|
+
*/
|
|
590
|
+
function tryParseShapes(svg) {
|
|
591
|
+
const Parser = globalThis.DOMParser;
|
|
592
|
+
if (!Parser) return null;
|
|
593
|
+
let doc;
|
|
594
|
+
try {
|
|
595
|
+
doc = new Parser().parseFromString(svg, "image/svg+xml");
|
|
596
|
+
} catch (_) {
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
const root = doc.documentElement;
|
|
600
|
+
const rootName = String(root?.localName || root?.tagName || "").toLowerCase();
|
|
601
|
+
if (!root || rootName !== "svg") return null;
|
|
602
|
+
if (typeof doc.querySelector === "function" && doc.querySelector("parsererror")) return null;
|
|
603
|
+
/** @type {Shape[]} */
|
|
604
|
+
const shapes = [];
|
|
605
|
+
const walk = (el) => {
|
|
606
|
+
const tag = String(el.localName || el.tagName || "").toLowerCase();
|
|
607
|
+
if (!SIMPLE_TAGS.has(tag)) return false;
|
|
608
|
+
if (tag === "svg" || tag === "g") {
|
|
609
|
+
if (tag === "g" && el.attributes && el.attributes.length > 0) return false;
|
|
610
|
+
for (const child of el.children || []) {
|
|
611
|
+
if (!walk(child)) return false;
|
|
612
|
+
}
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
if (!SHAPE_TAGS.has(tag)) return false;
|
|
616
|
+
if (tag === "text" && el.children && el.children.length > 0) return false;
|
|
617
|
+
/** @type {Record<string, string>} */
|
|
618
|
+
const attrs = {};
|
|
619
|
+
if (el.attributes) {
|
|
620
|
+
for (const attr of el.attributes) attrs[attr.name] = attr.value;
|
|
621
|
+
}
|
|
622
|
+
if (tag === "text") attrs.text = String(el.textContent || "").trim();
|
|
623
|
+
shapes.push({ type: /** @type {ShapeType} */ (tag), attrs });
|
|
624
|
+
return true;
|
|
625
|
+
};
|
|
626
|
+
if (!walk(root)) return null;
|
|
627
|
+
return shapes;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* @param {string} svg
|
|
632
|
+
*/
|
|
633
|
+
function readViewBox(svg) {
|
|
634
|
+
const match = String(svg).match(/viewBox\s*=\s*"([^"]*)"/i) || String(svg).match(/viewBox\s*=\s*'([^']*)'/i);
|
|
635
|
+
return match ? match[1] : "";
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* @param {object} block
|
|
640
|
+
*/
|
|
641
|
+
function serializeBlock(block) {
|
|
642
|
+
if (!block) return "";
|
|
643
|
+
if (block.type === "heading") {
|
|
644
|
+
const level = Math.min(3, Math.max(1, Number(block.level) || 1));
|
|
645
|
+
return `${"#".repeat(level)} ${block.text ?? ""}`;
|
|
646
|
+
}
|
|
647
|
+
if (block.type === "paragraph") return String(block.text ?? "");
|
|
648
|
+
if (block.type === "bullets") {
|
|
649
|
+
return (block.items || []).map((item) => `- ${item}`).join("\n");
|
|
650
|
+
}
|
|
651
|
+
if (block.type === "markdown") return String(block.text ?? "");
|
|
652
|
+
if (block.type === "graph" || block.type === "figure" || block.type === "plot") {
|
|
653
|
+
return "```ivm\n" + serializeFigure(block).replace(/\s*$/, "\n") + "```";
|
|
654
|
+
}
|
|
655
|
+
return "";
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* @param {object} block
|
|
660
|
+
*/
|
|
661
|
+
function svgMarkup(block) {
|
|
662
|
+
if (block.customSvg) return String(block.customSvg).replace(/\s+$/, "");
|
|
663
|
+
if (!block.shapes || !block.shapes.length) return "";
|
|
664
|
+
const viewBox = block.viewBox || "0 0 200 120";
|
|
665
|
+
const body = block.shapes.map(serializeShape).join("\n ");
|
|
666
|
+
return `<svg viewBox="${escapeAttr(viewBox)}" width="100%" xmlns="http://www.w3.org/2000/svg">\n ${body}\n</svg>`;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* @param {Shape} shape
|
|
671
|
+
*/
|
|
672
|
+
function serializeShape(shape) {
|
|
673
|
+
const attrs = Object.entries(shape.attrs || {})
|
|
674
|
+
.filter(([key, value]) => key !== "text" && value !== "")
|
|
675
|
+
.map(([key, value]) => `${key}="${escapeAttr(value)}"`)
|
|
676
|
+
.join(" ");
|
|
677
|
+
if (shape.type === "text") {
|
|
678
|
+
return `<text${attrs ? " " + attrs : ""}>${escapeText(shape.attrs?.text ?? "")}</text>`;
|
|
679
|
+
}
|
|
680
|
+
return `<${shape.type}${attrs ? " " + attrs : ""}/>`;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* @param {object} control
|
|
685
|
+
*/
|
|
686
|
+
function serializeControl(control) {
|
|
687
|
+
if (!control || !control.name) return "";
|
|
688
|
+
if (control.kind === "let") return `@let ${control.name} = ${control.expression ?? "0"}`;
|
|
689
|
+
if (control.kind === "slider" || control.kind === "timer") {
|
|
690
|
+
const range = control.range || [0, 100];
|
|
691
|
+
let line = `@${control.kind} ${control.name}: ${fmtNum(range[0])}..${fmtNum(range[1])} = ${fmtNum(control.default)}`;
|
|
692
|
+
if (control.kind === "timer") line += ` speed=${fmtNum(control.speed ?? 20)}`;
|
|
693
|
+
line += ` step=${fmtNum(control.step ?? 1)}`;
|
|
694
|
+
if (control.kind === "timer") line += control.loop === false ? " noloop" : " loop";
|
|
695
|
+
if (control.label) line += " " + quoteGraphLabel(control.label);
|
|
696
|
+
return line;
|
|
697
|
+
}
|
|
698
|
+
if (control.kind === "input") {
|
|
699
|
+
let line = `@input ${control.name}: ${quoteGraphLabel(control.default ?? "")}`;
|
|
700
|
+
if (control.label) line += " " + quoteGraphLabel(control.label);
|
|
701
|
+
return line;
|
|
702
|
+
}
|
|
703
|
+
if (control.kind === "toggle") {
|
|
704
|
+
let line = `@toggle ${control.name}: ${control.default ? "true" : "false"}`;
|
|
705
|
+
if (control.label) line += " " + quoteGraphLabel(control.label);
|
|
706
|
+
return line;
|
|
707
|
+
}
|
|
708
|
+
return "";
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* @param {{ nodes?: object[], edges?: object[], animated?: boolean, motion?: object } | null | undefined} model
|
|
713
|
+
*/
|
|
714
|
+
function copyGraphModel(model) {
|
|
715
|
+
const next = {
|
|
716
|
+
nodes: (model?.nodes || []).map((n) => ({ ...n })),
|
|
717
|
+
edges: (model?.edges || []).map((e) => ({ ...e })),
|
|
718
|
+
};
|
|
719
|
+
if (model?.animated) next.animated = true;
|
|
720
|
+
if (model?.motion) next.motion = { ...model.motion };
|
|
721
|
+
return next;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function fmtNum(n) {
|
|
725
|
+
const value = Number(n);
|
|
726
|
+
return Number.isFinite(value) ? String(value) : "0";
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function escapeAttr(value) {
|
|
730
|
+
return String(value).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function escapeText(value) {
|
|
734
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* @param {object} control
|
|
739
|
+
*/
|
|
740
|
+
function cloneControl(control) {
|
|
741
|
+
return {
|
|
742
|
+
...control,
|
|
743
|
+
range: control.range ? [control.range[0], control.range[1]] : control.range,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* @param {object} block
|
|
749
|
+
*/
|
|
750
|
+
function cloneBlock(block) {
|
|
751
|
+
if (!block) return block;
|
|
752
|
+
if (block.type === "bullets") return { ...block, items: (block.items || []).slice() };
|
|
753
|
+
if (block.type === "graph") {
|
|
754
|
+
return { ...block, model: copyGraphModel(block.model) };
|
|
755
|
+
}
|
|
756
|
+
if (block.type === "plot") {
|
|
757
|
+
return {
|
|
758
|
+
...block,
|
|
759
|
+
controls: (block.controls || []).map(cloneControl),
|
|
760
|
+
plot: copyPlotModel(block.plot),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
if (block.type === "figure") {
|
|
764
|
+
return {
|
|
765
|
+
...block,
|
|
766
|
+
controls: (block.controls || []).map(cloneControl),
|
|
767
|
+
shapes: block.shapes ? block.shapes.map((shape) => ({ type: shape.type, attrs: { ...shape.attrs } })) : null,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
return { ...block };
|
|
771
|
+
}
|