interactive-markdown 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,633 @@
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
+
9
+ /** @typedef {'rect'|'circle'|'ellipse'|'line'|'path'|'text'} ShapeType */
10
+ /** @typedef {{ type: ShapeType, attrs: Record<string, string> }} Shape */
11
+ /** @typedef {{ nodes: {id:string,label:string,x:number,y:number,kind:string}[], edges: {from:string,to:string}[] }} GraphModel */
12
+
13
+ /**
14
+ * @typedef {object} BlockBase
15
+ * @property {string} id
16
+ */
17
+
18
+ let seq = 1;
19
+ function nid() {
20
+ return "b" + seq++;
21
+ }
22
+
23
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
24
+ const SIMPLE_TAGS = new Set(["svg", "g", "rect", "circle", "ellipse", "line", "path", "text"]);
25
+ const SHAPE_TAGS = new Set(["rect", "circle", "ellipse", "line", "path", "text"]);
26
+
27
+ const CONTROL_PREFIX = {
28
+ slider: "s",
29
+ timer: "t",
30
+ input: "in",
31
+ toggle: "c",
32
+ let: "y",
33
+ };
34
+
35
+ /**
36
+ * @param {string} markdown
37
+ * @returns {{ blocks: object[] }}
38
+ */
39
+ export function parseDocument(markdown) {
40
+ const segments = splitIvmFences(markdown);
41
+ /** @type {object[]} */
42
+ const blocks = [];
43
+ for (const seg of segments) {
44
+ if (seg.type === "ivm") blocks.push(blockFromIvm(seg.content));
45
+ else blocks.push(...parseProse(seg.content));
46
+ }
47
+ return { blocks };
48
+ }
49
+
50
+ /**
51
+ * @returns {{ blocks: object[] }}
52
+ */
53
+ export function blankDocument() {
54
+ return { blocks: [] };
55
+ }
56
+
57
+ /**
58
+ * @param {object[]} blocks
59
+ * @returns {string}
60
+ */
61
+ export function serializeDocument(blocks) {
62
+ const parts = [];
63
+ for (const block of blocks || []) {
64
+ const text = serializeBlock(block).replace(/\s+$/, "");
65
+ if (text) parts.push(text);
66
+ }
67
+ if (!parts.length) return "\n";
68
+ return parts.join("\n\n") + "\n";
69
+ }
70
+
71
+ /**
72
+ * Fence body for one figure or graph. Used by the live preview.
73
+ * @param {object} block
74
+ * @returns {string}
75
+ */
76
+ export function serializeFigure(block) {
77
+ if (!block) return "\n";
78
+ if (block.type === "graph") return serializeGraph(block.model || { nodes: [], edges: [] });
79
+ if (block.type !== "figure") return "\n";
80
+ const lines = [];
81
+ if (block.namespace) lines.push(`@ns ${block.namespace}`);
82
+ for (const control of block.controls || []) {
83
+ const line = serializeControl(control);
84
+ if (line) lines.push(line);
85
+ }
86
+ const svg = svgMarkup(block);
87
+ if (svg) {
88
+ lines.push("@svg");
89
+ lines.push(svg);
90
+ }
91
+ return lines.join("\n").replace(/\s*$/, "\n");
92
+ }
93
+
94
+ /**
95
+ * @param {{ blocks?: object[] }} doc
96
+ * @param {string} feature
97
+ * @param {{ at?: number, figureId?: string, shapeType?: string }} [options]
98
+ * @returns {{ doc: { blocks: object[] }, focusId: string }}
99
+ */
100
+ export function insertFeature(doc, feature, options = {}) {
101
+ const blocks = (doc?.blocks || []).map(cloneBlock);
102
+ const at = options.at;
103
+ /** @type {string} */
104
+ let focusId = "";
105
+
106
+ const place = (block) => {
107
+ const index = at == null ? blocks.length : Math.max(0, Math.min(blocks.length, at));
108
+ blocks.splice(index, 0, block);
109
+ focusId = block.id;
110
+ return block;
111
+ };
112
+
113
+ if (feature === "heading") {
114
+ place({ id: nid(), type: "heading", level: 2, text: "Heading" });
115
+ } else if (feature === "paragraph") {
116
+ place({ id: nid(), type: "paragraph", text: "" });
117
+ } else if (feature === "bullets") {
118
+ place({ id: nid(), type: "bullets", items: ["Item"] });
119
+ } else if (feature === "graph" || feature === "animated") {
120
+ place({
121
+ id: nid(),
122
+ type: "graph",
123
+ model: { nodes: [], edges: [], animated: true },
124
+ });
125
+ } else if (feature === "shape") {
126
+ const shape = defaultShape(options.shapeType || "rect");
127
+ const existing = findFigure(blocks, options.figureId);
128
+ if (existing && existing.shapes && !existing.customSvg) {
129
+ existing.shapes.push(shape);
130
+ focusId = existing.id;
131
+ } else {
132
+ const fig = emptyFigure();
133
+ fig.shapes = [shape];
134
+ place(fig);
135
+ }
136
+ } else if (feature === "slider" || feature === "timer" || feature === "input" || feature === "toggle" || feature === "formula") {
137
+ const kind = feature === "formula" ? "let" : feature;
138
+ const control = defaultControl(kind, nextControlName(blocks, CONTROL_PREFIX[kind]));
139
+ const existing = findFigure(blocks, options.figureId);
140
+ if (existing) {
141
+ existing.controls.push(control);
142
+ focusId = existing.id;
143
+ } else {
144
+ const fig = emptyFigure();
145
+ fig.controls.push(control);
146
+ place(fig);
147
+ }
148
+ }
149
+
150
+ return { doc: { blocks }, focusId };
151
+ }
152
+
153
+ /**
154
+ * @param {{ blocks?: object[] }} doc
155
+ * @param {string} id
156
+ * @returns {{ blocks: object[] }}
157
+ */
158
+ export function removeBlock(doc, id) {
159
+ return { blocks: (doc?.blocks || []).filter((b) => b.id !== id).map(cloneBlock) };
160
+ }
161
+
162
+ /**
163
+ * @param {{ blocks?: object[] }} doc
164
+ * @param {string} id
165
+ * @param {number} delta
166
+ * @returns {{ blocks: object[] }}
167
+ */
168
+ export function moveBlock(doc, id, delta) {
169
+ const blocks = (doc?.blocks || []).map(cloneBlock);
170
+ const i = blocks.findIndex((b) => b.id === id);
171
+ const j = i + delta;
172
+ if (i < 0 || j < 0 || j >= blocks.length) return { blocks };
173
+ const [item] = blocks.splice(i, 1);
174
+ blocks.splice(j, 0, item);
175
+ return { blocks };
176
+ }
177
+
178
+ /**
179
+ * @param {{ blocks?: object[] }} doc
180
+ * @param {string} id
181
+ * @param {object} patch
182
+ * @returns {{ blocks: object[] }}
183
+ */
184
+ export function updateBlock(doc, id, patch) {
185
+ return {
186
+ blocks: (doc?.blocks || []).map((b) => (b.id === id ? cloneBlock({ ...b, ...patch }) : cloneBlock(b))),
187
+ };
188
+ }
189
+
190
+ /**
191
+ * @param {{ blocks?: object[] }} doc
192
+ * @param {string} blockId
193
+ * @param {number} index
194
+ * @param {object} patch
195
+ * @returns {{ blocks: object[] }}
196
+ */
197
+ export function updateControl(doc, blockId, index, patch) {
198
+ return {
199
+ blocks: (doc?.blocks || []).map((b) => {
200
+ const copy = cloneBlock(b);
201
+ if (copy.id === blockId && copy.type === "figure" && copy.controls[index]) {
202
+ copy.controls[index] = cloneControl({ ...copy.controls[index], ...patch });
203
+ }
204
+ return copy;
205
+ }),
206
+ };
207
+ }
208
+
209
+ /**
210
+ * @param {{ blocks?: object[] }} doc
211
+ * @param {string} blockId
212
+ * @param {number} index
213
+ * @returns {{ blocks: object[] }}
214
+ */
215
+ export function removeControl(doc, blockId, index) {
216
+ return {
217
+ blocks: (doc?.blocks || []).map((b) => {
218
+ const copy = cloneBlock(b);
219
+ if (copy.id === blockId && copy.type === "figure") {
220
+ copy.controls = copy.controls.filter((_, i) => i !== index);
221
+ }
222
+ return copy;
223
+ }),
224
+ };
225
+ }
226
+
227
+ /**
228
+ * @param {{ blocks?: object[] }} doc
229
+ * @param {string} blockId
230
+ * @param {number} index
231
+ * @param {Record<string, string>} attrs
232
+ * @returns {{ blocks: object[] }}
233
+ */
234
+ export function updateShape(doc, blockId, index, attrs) {
235
+ return {
236
+ blocks: (doc?.blocks || []).map((b) => {
237
+ const copy = cloneBlock(b);
238
+ if (copy.id === blockId && copy.type === "figure" && copy.shapes && copy.shapes[index]) {
239
+ copy.shapes[index] = {
240
+ type: copy.shapes[index].type,
241
+ attrs: { ...copy.shapes[index].attrs, ...attrs },
242
+ };
243
+ }
244
+ return copy;
245
+ }),
246
+ };
247
+ }
248
+
249
+ /**
250
+ * @param {{ blocks?: object[] }} doc
251
+ * @param {string} blockId
252
+ * @param {number} index
253
+ * @returns {{ blocks: object[] }}
254
+ */
255
+ export function removeShape(doc, blockId, index) {
256
+ return {
257
+ blocks: (doc?.blocks || []).map((b) => {
258
+ const copy = cloneBlock(b);
259
+ if (copy.id === blockId && copy.type === "figure" && copy.shapes) {
260
+ copy.shapes = copy.shapes.filter((_, i) => i !== index);
261
+ }
262
+ return copy;
263
+ }),
264
+ };
265
+ }
266
+
267
+ export { NAME_RE };
268
+
269
+ function emptyFigure() {
270
+ return {
271
+ id: nid(),
272
+ type: "figure",
273
+ namespace: null,
274
+ controls: [],
275
+ viewBox: "0 0 200 120",
276
+ shapes: [],
277
+ customSvg: null,
278
+ };
279
+ }
280
+
281
+ /**
282
+ * @param {object[]} blocks
283
+ * @param {string} [id]
284
+ */
285
+ function findFigure(blocks, id) {
286
+ if (!id) return null;
287
+ const block = blocks.find((b) => b.id === id && b.type === "figure");
288
+ return block || null;
289
+ }
290
+
291
+ /**
292
+ * @param {object[]} blocks
293
+ * @param {string} prefix
294
+ */
295
+ function nextControlName(blocks, prefix) {
296
+ const used = new Set();
297
+ for (const block of blocks) {
298
+ if (block.type !== "figure") continue;
299
+ for (const control of block.controls || []) {
300
+ if (control.name) used.add(control.name);
301
+ }
302
+ }
303
+ let i = 1;
304
+ while (used.has(prefix + i)) i++;
305
+ return prefix + i;
306
+ }
307
+
308
+ /**
309
+ * @param {string} kind
310
+ * @param {string} name
311
+ */
312
+ function defaultControl(kind, name) {
313
+ if (kind === "slider") {
314
+ return { kind, name, range: [0, 100], step: 1, default: 50, label: "Slider" };
315
+ }
316
+ if (kind === "timer") {
317
+ return { kind, name, range: [0, 100], step: 1, default: 0, speed: 20, loop: true, label: "Timer" };
318
+ }
319
+ if (kind === "input") return { kind, name, default: "", label: "Text" };
320
+ if (kind === "toggle") return { kind, name, default: false, label: "Toggle" };
321
+ return { kind: "let", name, expression: "0" };
322
+ }
323
+
324
+ /**
325
+ * @param {string} type
326
+ * @returns {Shape}
327
+ */
328
+ function defaultShape(type) {
329
+ if (type === "circle") return { type, attrs: { cx: "40", cy: "40", r: "16", fill: "#38bdf8" } };
330
+ if (type === "ellipse") return { type, attrs: { cx: "40", cy: "40", rx: "24", ry: "14", fill: "#38bdf8" } };
331
+ if (type === "line") {
332
+ return { type, attrs: { x1: "8", y1: "40", x2: "120", y2: "40", stroke: "#38bdf8", "stroke-width": "2" } };
333
+ }
334
+ if (type === "path") {
335
+ return {
336
+ type,
337
+ attrs: { d: "M 8 40 Q 40 8 72 40", fill: "none", stroke: "#38bdf8", "stroke-width": "2" },
338
+ };
339
+ }
340
+ if (type === "text") {
341
+ return { type, attrs: { x: "8", y: "24", fill: "#e5e7eb", "font-size": "14", text: "Label" } };
342
+ }
343
+ return { type: "rect", attrs: { x: "8", y: "8", width: "80", height: "40", fill: "#1e293b" } };
344
+ }
345
+
346
+ /**
347
+ * @param {string} content
348
+ */
349
+ function parseProse(content) {
350
+ const lines = String(content).replace(/\r\n/g, "\n").split("\n");
351
+ /** @type {object[]} */
352
+ const blocks = [];
353
+ let i = 0;
354
+ while (i < lines.length) {
355
+ if (!lines[i].trim()) {
356
+ i++;
357
+ continue;
358
+ }
359
+ if (lines[i].startsWith("```")) {
360
+ const start = i;
361
+ i++;
362
+ while (i < lines.length && !lines[i].startsWith("```")) i++;
363
+ if (i < lines.length) i++;
364
+ blocks.push({ id: nid(), type: "markdown", text: lines.slice(start, i).join("\n") });
365
+ continue;
366
+ }
367
+ const heading = lines[i].match(/^(#{1,3})\s+(.*)$/);
368
+ if (heading) {
369
+ blocks.push({ id: nid(), type: "heading", level: heading[1].length, text: heading[2] });
370
+ i++;
371
+ continue;
372
+ }
373
+ if (/^\s*[-*]\s+/.test(lines[i])) {
374
+ const items = [];
375
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
376
+ items.push(lines[i].replace(/^\s*[-*]\s+/, ""));
377
+ i++;
378
+ }
379
+ blocks.push({ id: nid(), type: "bullets", items });
380
+ continue;
381
+ }
382
+ const start = i;
383
+ i++;
384
+ while (
385
+ i < lines.length &&
386
+ lines[i].trim() &&
387
+ !lines[i].startsWith("```") &&
388
+ !/^(#{1,3})\s+/.test(lines[i]) &&
389
+ !/^\s*[-*]\s+/.test(lines[i])
390
+ ) {
391
+ i++;
392
+ }
393
+ blocks.push({ id: nid(), type: "paragraph", text: lines.slice(start, i).join("\n").trim() });
394
+ }
395
+ return blocks;
396
+ }
397
+
398
+ /**
399
+ * @param {string} body
400
+ */
401
+ function blockFromIvm(body) {
402
+ const parsed = parseIvm(body);
403
+ if (parsed.graph) {
404
+ return {
405
+ id: nid(),
406
+ type: "graph",
407
+ model: copyGraphModel(parsed.graph),
408
+ };
409
+ }
410
+ const raw = parsed.templateSvg;
411
+ const template = wrapSlimSvg(raw, parsed.svgWidth, parsed.svgHeight);
412
+ const shapes = template ? tryParseShapes(template) : [];
413
+ const custom = shapes == null;
414
+ const viewBox =
415
+ (template && readViewBox(template)) ||
416
+ (parsed.svgWidth != null && parsed.svgHeight != null
417
+ ? `0 0 ${parsed.svgWidth} ${parsed.svgHeight}`
418
+ : "0 0 200 120");
419
+ return {
420
+ id: nid(),
421
+ type: "figure",
422
+ namespace: parsed.namespace,
423
+ controls: parsed.controls.map(cloneControl),
424
+ viewBox,
425
+ shapes: custom ? null : shapes,
426
+ customSvg: custom ? raw : null,
427
+ };
428
+ }
429
+
430
+ /**
431
+ * If @svg WxH left non-root markup, wrap it so shape parsing sees an <svg> root.
432
+ * @param {string|null} markup
433
+ * @param {number|null} width
434
+ * @param {number|null} height
435
+ */
436
+ function wrapSlimSvg(markup, width, height) {
437
+ if (markup == null) return null;
438
+ const src = String(markup);
439
+ const trimmed = src.trim();
440
+ if (!trimmed) return src;
441
+ if (/^<svg\b/i.test(trimmed)) return src;
442
+ if (width == null || height == null || !Number.isFinite(width) || !Number.isFinite(height)) {
443
+ return src;
444
+ }
445
+ return (
446
+ `<svg viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">\n` +
447
+ src.replace(/^\n+/, "").replace(/\n+$/, "\n") +
448
+ `</svg>`
449
+ );
450
+ }
451
+
452
+ /**
453
+ * @param {string} svg
454
+ * @returns {Shape[] | null}
455
+ */
456
+ function tryParseShapes(svg) {
457
+ const Parser = globalThis.DOMParser;
458
+ if (!Parser) return null;
459
+ let doc;
460
+ try {
461
+ doc = new Parser().parseFromString(svg, "image/svg+xml");
462
+ } catch (_) {
463
+ return null;
464
+ }
465
+ const root = doc.documentElement;
466
+ const rootName = String(root?.localName || root?.tagName || "").toLowerCase();
467
+ if (!root || rootName !== "svg") return null;
468
+ if (typeof doc.querySelector === "function" && doc.querySelector("parsererror")) return null;
469
+ /** @type {Shape[]} */
470
+ const shapes = [];
471
+ const walk = (el) => {
472
+ const tag = String(el.localName || el.tagName || "").toLowerCase();
473
+ if (!SIMPLE_TAGS.has(tag)) return false;
474
+ if (tag === "svg" || tag === "g") {
475
+ if (tag === "g" && el.attributes && el.attributes.length > 0) return false;
476
+ for (const child of el.children || []) {
477
+ if (!walk(child)) return false;
478
+ }
479
+ return true;
480
+ }
481
+ if (!SHAPE_TAGS.has(tag)) return false;
482
+ if (tag === "text" && el.children && el.children.length > 0) return false;
483
+ /** @type {Record<string, string>} */
484
+ const attrs = {};
485
+ if (el.attributes) {
486
+ for (const attr of el.attributes) attrs[attr.name] = attr.value;
487
+ }
488
+ if (tag === "text") attrs.text = String(el.textContent || "").trim();
489
+ shapes.push({ type: /** @type {ShapeType} */ (tag), attrs });
490
+ return true;
491
+ };
492
+ if (!walk(root)) return null;
493
+ return shapes;
494
+ }
495
+
496
+ /**
497
+ * @param {string} svg
498
+ */
499
+ function readViewBox(svg) {
500
+ const match = String(svg).match(/viewBox\s*=\s*"([^"]*)"/i) || String(svg).match(/viewBox\s*=\s*'([^']*)'/i);
501
+ return match ? match[1] : "";
502
+ }
503
+
504
+ /**
505
+ * @param {object} block
506
+ */
507
+ function serializeBlock(block) {
508
+ if (!block) return "";
509
+ if (block.type === "heading") {
510
+ const level = Math.min(3, Math.max(1, Number(block.level) || 1));
511
+ return `${"#".repeat(level)} ${block.text ?? ""}`;
512
+ }
513
+ if (block.type === "paragraph") return String(block.text ?? "");
514
+ if (block.type === "bullets") {
515
+ return (block.items || []).map((item) => `- ${item}`).join("\n");
516
+ }
517
+ if (block.type === "markdown") return String(block.text ?? "");
518
+ if (block.type === "graph") {
519
+ return "```ivm\n" + serializeFigure(block).replace(/\s*$/, "\n") + "```";
520
+ }
521
+ if (block.type === "figure") {
522
+ return "```ivm\n" + serializeFigure(block).replace(/\s*$/, "\n") + "```";
523
+ }
524
+ return "";
525
+ }
526
+
527
+ /**
528
+ * @param {object} block
529
+ */
530
+ function svgMarkup(block) {
531
+ if (block.customSvg) return String(block.customSvg).replace(/\s+$/, "");
532
+ if (!block.shapes || !block.shapes.length) return "";
533
+ const viewBox = block.viewBox || "0 0 200 120";
534
+ const body = block.shapes.map(serializeShape).join("\n ");
535
+ return `<svg viewBox="${escapeAttr(viewBox)}" width="100%" xmlns="http://www.w3.org/2000/svg">\n ${body}\n</svg>`;
536
+ }
537
+
538
+ /**
539
+ * @param {Shape} shape
540
+ */
541
+ function serializeShape(shape) {
542
+ const attrs = Object.entries(shape.attrs || {})
543
+ .filter(([key, value]) => key !== "text" && value !== "")
544
+ .map(([key, value]) => `${key}="${escapeAttr(value)}"`)
545
+ .join(" ");
546
+ if (shape.type === "text") {
547
+ return `<text${attrs ? " " + attrs : ""}>${escapeText(shape.attrs?.text ?? "")}</text>`;
548
+ }
549
+ return `<${shape.type}${attrs ? " " + attrs : ""}/>`;
550
+ }
551
+
552
+ /**
553
+ * @param {object} control
554
+ */
555
+ function serializeControl(control) {
556
+ if (!control || !control.name) return "";
557
+ if (control.kind === "let") return `@let ${control.name} = ${control.expression ?? "0"}`;
558
+ if (control.kind === "slider" || control.kind === "timer") {
559
+ const range = control.range || [0, 100];
560
+ let line = `@${control.kind} ${control.name}: ${fmtNum(range[0])}..${fmtNum(range[1])} = ${fmtNum(control.default)}`;
561
+ if (control.kind === "timer") line += ` speed=${fmtNum(control.speed ?? 20)}`;
562
+ line += ` step=${fmtNum(control.step ?? 1)}`;
563
+ if (control.kind === "timer") line += control.loop === false ? " noloop" : " loop";
564
+ if (control.label) line += " " + quoteGraphLabel(control.label);
565
+ return line;
566
+ }
567
+ if (control.kind === "input") {
568
+ let line = `@input ${control.name}: ${quoteGraphLabel(control.default ?? "")}`;
569
+ if (control.label) line += " " + quoteGraphLabel(control.label);
570
+ return line;
571
+ }
572
+ if (control.kind === "toggle") {
573
+ let line = `@toggle ${control.name}: ${control.default ? "true" : "false"}`;
574
+ if (control.label) line += " " + quoteGraphLabel(control.label);
575
+ return line;
576
+ }
577
+ return "";
578
+ }
579
+
580
+ /**
581
+ * @param {{ nodes?: object[], edges?: object[], animated?: boolean, motion?: object } | null | undefined} model
582
+ */
583
+ function copyGraphModel(model) {
584
+ const next = {
585
+ nodes: (model?.nodes || []).map((n) => ({ ...n })),
586
+ edges: (model?.edges || []).map((e) => ({ ...e })),
587
+ };
588
+ if (model?.animated) next.animated = true;
589
+ if (model?.motion) next.motion = { ...model.motion };
590
+ return next;
591
+ }
592
+
593
+ function fmtNum(n) {
594
+ const value = Number(n);
595
+ return Number.isFinite(value) ? String(value) : "0";
596
+ }
597
+
598
+ function escapeAttr(value) {
599
+ return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
600
+ }
601
+
602
+ function escapeText(value) {
603
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;");
604
+ }
605
+
606
+ /**
607
+ * @param {object} control
608
+ */
609
+ function cloneControl(control) {
610
+ return {
611
+ ...control,
612
+ range: control.range ? [control.range[0], control.range[1]] : control.range,
613
+ };
614
+ }
615
+
616
+ /**
617
+ * @param {object} block
618
+ */
619
+ function cloneBlock(block) {
620
+ if (!block) return block;
621
+ if (block.type === "bullets") return { ...block, items: (block.items || []).slice() };
622
+ if (block.type === "graph") {
623
+ return { ...block, model: copyGraphModel(block.model) };
624
+ }
625
+ if (block.type === "figure") {
626
+ return {
627
+ ...block,
628
+ controls: (block.controls || []).map(cloneControl),
629
+ shapes: block.shapes ? block.shapes.map((shape) => ({ type: shape.type, attrs: { ...shape.attrs } })) : null,
630
+ };
631
+ }
632
+ return { ...block };
633
+ }
package/src/expr.js CHANGED
@@ -41,6 +41,12 @@ const FUNCTIONS = Object.freeze({
41
41
  lerp: (a, b, t) => a + (b - a) * t,
42
42
  deg: (r) => (r * 180) / Math.PI,
43
43
  rad: (d) => (d * Math.PI) / 180,
44
+ sind: (d) => Math.sin((d * Math.PI) / 180),
45
+ cosd: (d) => Math.cos((d * Math.PI) / 180),
46
+ tand: (d) => Math.tan((d * Math.PI) / 180),
47
+ asind: (x) => (Math.asin(x) * 180) / Math.PI,
48
+ acosd: (x) => (Math.acos(x) * 180) / Math.PI,
49
+ atand: (x) => (Math.atan(x) * 180) / Math.PI,
44
50
  });
45
51
 
46
52
  function tokenize(input) {