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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Thomas
3
+ Copyright (c) 2026 Thomas Lo
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,18 +1,22 @@
1
1
  # Interactive Markdown (IVM)
2
2
 
3
- **Interactive Markdown** is a small browser **editor + renderer** for plain `.md` files that include live **vector graphics** driven by sliders, timers, and a safe expression language.
3
+ **Interactive Markdown** is a small browser **editor + renderer** for plain `.md` files that include live **vector graphics** driven by sliders, timers, and a safe expression language, plus a **graph editor** stored in the same fence.
4
4
 
5
- Write Markdown as usual. Add an `ivm` fenced block when you want interactive SVG. The format stays AI-friendly: readable text an agent can generate like normal Markdown.
5
+ Write Markdown as usual. Add an `ivm` fenced block when you want interactive SVG or a directed graph you can edit in the preview. The format stays AI-friendly: readable text an agent can generate like normal Markdown.
6
6
 
7
7
  License: **MIT** (see [LICENSE](LICENSE)).
8
8
 
9
9
  ## Features
10
10
 
11
11
  - Split **editor** and **live preview**
12
+ - **Graph editor** tab beside the source: prose renders as formatted text, and sliders, figures, and graphs stay editable blocks
12
13
  - Open / save `.md`, with a **localStorage** draft
13
14
  - IVM widgets: `@slider`, `@timer` (Play/Pause), `@input`, `@toggle`
14
- - Derived values with `@let` / `@calc`
15
+ - Derived values with `@let` (agents should emit `@let`, not `@calc`)
15
16
  - SVG templates with `{{expr}}` bindings
17
+ - Slim `@svg WxH` wrapper for compact markup
18
+ - Visual **graph editor**: `@graph`, `@node`, and `@edge` (drag, connect, rename; edits write back into the fence)
19
+ - Animated SVG: CSS `@keyframes`, SMIL, glow filters, and internal `<use>`
16
20
  - Whitelist expression evaluator (no `eval`)
17
21
  - SVG sanitizer (strips scripts, event handlers, external hrefs)
18
22
 
@@ -37,23 +41,24 @@ npx serve .
37
41
 
38
42
  > ES modules need HTTP — opening `index.html` as a `file://` URL will not work.
39
43
 
44
+ ## Graph editor tab
45
+
46
+ The left pane has two tabs. **Source** is the markdown. **Graph editor** shows the same document with prose rendered as formatted text. Sliders, timers, text fields, toggles, formulas, shapes, and graphs stay as blocks. Select a block to edit its label, range, expression, or shape attributes. A shape attribute can be a number or `{{name}}`. The graph block is the canvas: drag, connect, and rename there. An animated graph marches dashes along each edge and sends a dot along the same path. Each change rewrites the source. The right pane is the rendered preview.
47
+
40
48
  ## IVM dialect (quick reference)
41
49
 
42
- Fence name: **`ivm`**.
50
+ Fence name: **`ivm`**. Long control names are canonical; short aliases are accepted for compact AI generation.
51
+
52
+ ### Canonical exemplar
43
53
 
44
54
  ````markdown
45
55
  ```ivm
46
- @ns optionalSharedName
47
- @slider amp: 5..60 = 30 step=1 "Amplitude"
48
- @timer t: 0..100 = 0 speed=20 loop "Phase"
49
- @input title: "Hello" "Title"
50
- @toggle show: true "Show"
51
- @let y = 40 - amp * sin(t * pi / 50)
52
- @calc a = amp / 2
53
- @svg
54
- <svg viewBox="0 0 200 80" xmlns="http://www.w3.org/2000/svg">
55
- <circle cx="100" cy="{{round(y, 2)}}" r="{{amp}}" fill="#38bdf8"/>
56
- </svg>
56
+ @slider amp: 5..55 = 28
57
+ @timer t: 0..100 = 0 speed=25
58
+ @let y = 40 - amp * sind(t * 3.6)
59
+ @svg 200x80
60
+ <rect width="200" height="80" fill="#0f172a"/>
61
+ <path d="M10 40 Q55 {{y}} 100 40 T190 40" fill="none" stroke="#38bdf8" stroke-width="3"/>
57
62
  ```
58
63
  ````
59
64
 
@@ -61,26 +66,78 @@ Fence name: **`ivm`**.
61
66
 
62
67
  | Line | Meaning |
63
68
  |------|---------|
64
- | `@slider name: min..max = default step=S "Label"` | Range input |
65
- | `@timer name: min..max = default speed=N loop` | Animated value + Play/Pause (`noloop` to stop at end) |
66
- | `@input name: "default" "Label"` | Text field |
67
- | `@toggle name: true\|false "Label"` | Checkbox |
68
- | `@let name = expression` | Computed value |
69
- | `@calc name = expression` | Alias of `@let` |
69
+ | `@slider name: min..max = default [step=S] [deg\|rad] ["Label"]` | Range input (`@s` alias) |
70
+ | `@timer name: min..max = default [speed=N] [step=S] [loop\|noloop] [deg\|rad] ["Label"]` | Animated value + Play/Pause (`@t` alias) |
71
+ | `@input name: "default" ["Label"]` | Text field (`@i` alias) |
72
+ | `@toggle name: true\|false ["Label"]` | Checkbox (`@b` alias) |
73
+ | `@let name = expression` | Computed value (`@l` alias) |
74
+ | `@calc name = expression` | Alias of `@let` (compat only; prefer `@let`) |
70
75
  | `@ns name` | Share variable state across IVM blocks with the same name |
71
- | `@svg` | Everything after this line is SVG markup |
76
+ | `@svg` | Everything after this line is full SVG markup |
77
+ | `@svg WxH` | Same, but wrap non-root markup in `<svg viewBox="0 0 W H" xmlns=…>` |
78
+ | `@graph` | Start an editable directed graph. This is the first directive in that fence |
79
+ | `@node id: "Label" x=N y=N kind=K` | Node. `kind` is `action` (default), `input`, `output`, or `decision` |
80
+ | `@edge from -> to` | Directed edge. Add `from=top\|right\|bottom\|left` and `to=top\|right\|bottom\|left` to choose the sides. Defaults are `from=right` and `to=left` |
81
+ | `@animate` | Play marching dashes and traveling dots on the graph canvas. Options: `path=ortho\|straight\|curve`, `quantity=N` (1–12), `direction=forward\|reverse`, `speed=seconds` |
82
+
83
+ ### Defaults (omit when unchanged)
84
+
85
+ - `step` defaults to **1**
86
+ - timer `loop` defaults to **true** (write `noloop` only when needed)
87
+ - timer `speed` defaults to **30**
88
+ - if `"Label"` is omitted, the UI shows the variable **name**
89
+
90
+ ### Short aliases (AI-compact)
91
+
92
+ | Short | Long |
93
+ |-------|------|
94
+ | `@s` | `@slider` |
95
+ | `@t` | `@timer` |
96
+ | `@i` | `@input` |
97
+ | `@b` | `@toggle` |
98
+ | `@l` | `@let` |
99
+
100
+ Humans and docs should use the long forms. Generators may emit short aliases; both parse.
101
+
102
+ ### Graph
103
+
104
+ A fence that starts with `@graph` is a node-and-edge diagram. The preview is the editor: drag a node, drag a port on any side onto another node, double-click a label to rename, and press Delete to remove the selection. `@animate` runs the motion on that same canvas. Path, quantity, direction, and speed are edited there and written back onto the `@animate` line. Each committed edit replaces that fence in the source. Do not mix `@svg` or controls into the same fence.
105
+
106
+ ````markdown
107
+ ```ivm
108
+ @graph
109
+ @node start: "Start" x=40 y=48 kind=input
110
+ @node step: "Step" x=280 y=48
111
+ @edge start -> step
112
+ ```
113
+ ````
72
114
 
73
115
  ### Expressions (`{{…}}`)
74
116
 
75
- Placeholders use **double braces**: `{{expr}}`.
117
+ Placeholders use **double braces** only: `{{expr}}`.
76
118
 
77
119
  Allowed operators include `+ - * / % ^ **`, comparisons, `&& || !`, and ternary `? :`.
78
120
 
79
- Functions include: `abs`, `sqrt`, `pow`, `exp`, `ln`, `log`, `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `round`, `floor`, `ceil`, `trunc`, `sign`, `min`, `max`, `clamp`, `mod`, `lerp`, `deg`, `rad`.
121
+ Functions include: `abs`, `sqrt`, `pow`, `exp`, `ln`, `log`, `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `sind`, `cosd`, `tand`, `asind`, `acosd`, `atand`, `round`, `floor`, `ceil`, `trunc`, `sign`, `min`, `max`, `clamp`, `mod`, `lerp`, `deg`, `rad`.
80
122
 
81
123
  Constants: `pi`, `e`, `tau`.
82
124
 
83
- Angles for trig functions are in **radians** (use `deg` / `rad` helpers as needed).
125
+ - `sin` / `cos` / `tan` take **radians**
126
+ - `sind` / `cosd` / `tand` take **degrees** (prefer these when a control range is in degrees)
127
+ - Optional `deg` / `rad` flag on a range line is stored as metadata (`units`) for authors/tools; trig still uses the helpers above
128
+
129
+ ### Animated SVG
130
+
131
+ Everything after `@svg` can be a self-contained animated figure:
132
+
133
+ - `<style>` with `@keyframes` (crossfades, pulses, marching dashes)
134
+ - SMIL (`<animate>`, `<animateTransform>`, `<animateMotion>`)
135
+ - Filters (`feGaussianBlur`, …) and paint servers such as `url(#id)`
136
+ - `<use href="#symbol-id">` for icons defined in the same SVG
137
+
138
+ Scripts, event handlers, and remote URLs are still removed. The figure is mounted in a shadow root so its CSS cannot restyle the editor. The SVG is rewritten only when the substituted markup changes, so those animations keep playing while sliders sit still.
139
+
140
+ A block with only `@svg` (no controls) renders as a full-width figure.
84
141
 
85
142
  ### Fallback
86
143
 
@@ -92,18 +149,24 @@ Load from the toolbar **Examples…** menu, or open the files directly:
92
149
 
93
150
  - [`examples/amplitude.md`](examples/amplitude.md) — wave amplitude + phase timer
94
151
  - [`examples/circular-motion.md`](examples/circular-motion.md) — point on a circle with spin animation
152
+ - [`examples/agent-loop.md`](examples/agent-loop.md) — agent loop drawn as an animated graph
153
+ - [`examples/graph.md`](examples/graph.md) — directed graph you can edit in the preview
95
154
 
96
155
  ## Project layout
97
156
 
98
157
  ```
99
158
  index.html Editor shell
100
159
  src/
160
+ index.js Library entry
101
161
  app.js UI wiring
162
+ document.js Block model (parse and serialize)
163
+ document-editor.js Preview block editor
102
164
  markdown.js MD + IVM pipeline
103
165
  parse-ivm.js Fence / control parser
104
166
  expr.js Safe expression evaluator
105
167
  sanitize-svg.js SVG sanitizer
106
168
  render.js Controls + SVG binding
169
+ graph-editor.js Graph model and canvas editor
107
170
  styles/app.css
108
171
  examples/
109
172
  test/ Node smoke tests
@@ -115,7 +178,7 @@ test/ Node smoke tests
115
178
  npm test
116
179
  ```
117
180
 
118
- Smoke tests cover the expression engine, IVM parser, SVG sanitizer, renderer, and Markdown pipeline (`linkedom` is used where a DOM is required).
181
+ Smoke tests cover the expression engine, IVM parser, SVG sanitizer, renderer, Markdown pipeline, graph editor, and preview document builder (`linkedom` is used where a DOM is required).
119
182
 
120
183
  ## License
121
184
 
@@ -0,0 +1,26 @@
1
+ # Agent loop
2
+
3
+ One pass through an agent: a trigger starts the loop, then think, act, and observe. Done either returns the output or sends the run back to think. Memory feeds think, and act can call tools. The dots travel those edges.
4
+
5
+ ```ivm
6
+ @graph
7
+ @animate path=ortho quantity=3 direction=forward speed=2.2
8
+ @node trigger: "Trigger" x=16 y=16 kind=input
9
+ @node output: "Output" x=688 y=16 kind=output
10
+ @node think: "Think" x=16 y=148
11
+ @node act: "Act" x=248 y=148
12
+ @node observe: "Observe" x=480 y=148
13
+ @node done: "Done?" x=480 y=292 kind=decision
14
+ @node memory: "Memory" x=16 y=436
15
+ @node tools: "Tools" x=248 y=436
16
+ @edge trigger -> think from=bottom to=top
17
+ @edge think -> act
18
+ @edge act -> observe
19
+ @edge observe -> done from=bottom to=top
20
+ @edge done -> output from=right to=bottom
21
+ @edge done -> think from=bottom to=left
22
+ @edge memory -> think from=top to=bottom
23
+ @edge act -> tools from=bottom to=top
24
+ ```
25
+
26
+ Drag any side dot to reconnect a node. On the canvas, change the path, quantity, direction, or seconds and the source updates.
@@ -1,25 +1,22 @@
1
1
  # Wave amplitude
2
2
 
3
- Drag **Amplitude** or press **Play** on the phase timer. The path and marker update from `{{expr}}` bindings.
3
+ Drag **amp** or press **Play** on the phase timer. The path and marker update from `{{expr}}` bindings.
4
4
 
5
5
  ```ivm
6
- @slider amp: 5..55 = 28 step=1 "Amplitude"
7
- @timer t: 0..100 = 0 speed=25 loop "Phase"
8
- @let y = 40 - amp * sin(t * pi / 50)
9
- @let y2 = 40 - (amp * 0.55) * sin(t * pi / 50 + 1.2)
10
- @svg
11
- <svg viewBox="0 0 200 80" width="100%" xmlns="http://www.w3.org/2000/svg">
12
- <rect x="0" y="0" width="200" height="80" rx="4" fill="#0f172a"/>
6
+ @slider amp: 5..55 = 28
7
+ @timer t: 0..100 = 0 speed=25
8
+ @let y = 40 - amp * sind(t * 3.6)
9
+ @let y2 = 40 - (amp * 0.55) * sind(t * 3.6 + 68.75)
10
+ @svg 200x80
11
+ <rect width="200" height="80" rx="4" fill="#0f172a"/>
13
12
  <line x1="10" y1="40" x2="190" y2="40" stroke="#334155" stroke-width="1"/>
14
- <path d="M 10 40 Q 55 {{round(y,2)}} 100 40 T 190 40"
15
- fill="none" stroke="#38bdf8" stroke-width="3"/>
16
- <path d="M 10 40 Q 55 {{round(y2,2)}} 100 40 T 190 40"
17
- fill="none" stroke="#a78bfa" stroke-width="2" opacity="0.85"/>
18
- <circle cx="100" cy="{{round(y,2)}}" r="5" fill="#f472b6"/>
19
- </svg>
13
+ <path d="M10 40 Q55 {{y}} 100 40 T190 40" fill="none" stroke="#38bdf8" stroke-width="3"/>
14
+ <path d="M10 40 Q55 {{y2}} 100 40 T190 40" fill="none" stroke="#a78bfa" stroke-width="2" opacity="0.85"/>
15
+ <circle cx="100" cy="{{y}}" r="5" fill="#f472b6"/>
20
16
  ```
21
17
 
22
18
  ## Tips
23
19
 
24
20
  - `@timer` uses **Play / Pause** in the widget chrome.
25
21
  - `@let` values are recomputed whenever a slider or timer changes.
22
+ - Prefer `sind` / `cosd` when angles are in degrees.
@@ -3,23 +3,19 @@
3
3
  Adjust **radius** and **angle**, or animate with the timer. The moving point sits on the circle at angle `a`.
4
4
 
5
5
  ```ivm
6
- @slider radius: 10..80 = 50 step=1 "Radius"
7
- @slider angle: 0..360 = 45 step=1 "Angle (°)"
8
- @timer spin: 0..360 = 0 speed=40 loop "Spin"
9
- @calc a = angle + spin
10
- @let x = round(100 + radius * cos(a * pi / 180), 2)
11
- @let y = round(100 + radius * sin(a * pi / 180), 2)
12
- @svg
13
- <svg viewBox="0 0 200 200" width="100%" xmlns="http://www.w3.org/2000/svg">
14
- <rect x="0" y="0" width="200" height="200" rx="6" fill="#0f172a"/>
6
+ @slider radius: 10..80 = 50
7
+ @slider angle: 0..360 = 45 deg
8
+ @timer spin: 0..360 = 0 speed=40 deg
9
+ @let a = angle + spin
10
+ @let x = round(100 + radius * cosd(a), 2)
11
+ @let y = round(100 + radius * sind(a), 2)
12
+ @svg 200x200
13
+ <rect width="200" height="200" rx="6" fill="#0f172a"/>
15
14
  <circle cx="100" cy="100" r="{{radius}}" fill="none" stroke="#475569" stroke-width="2"/>
16
15
  <line x1="100" y1="100" x2="{{x}}" y2="{{y}}" stroke="#38bdf8" stroke-width="2"/>
17
16
  <circle cx="100" cy="100" r="3" fill="#94a3b8"/>
18
17
  <circle cx="{{x}}" cy="{{y}}" r="7" fill="#f472b6"/>
19
- <text x="8" y="18" fill="#94a3b8" font-size="10" font-family="ui-monospace, monospace">
20
- a={{round(a,1)}}°
21
- </text>
22
- </svg>
18
+ <text x="8" y="18" fill="#94a3b8" font-size="10" font-family="ui-monospace, monospace">a={{round(a,1)}}°</text>
23
19
  ```
24
20
 
25
- Press **Play** on **Spin** to sweep the point around the circle while keeping your base angle offset.
21
+ Press **Play** on **spin** to sweep the point around the circle while keeping your base angle offset.
@@ -0,0 +1,18 @@
1
+ # A small graph
2
+
3
+ This fence is a directed graph. Drag a node to move it, and drag a dot on its top, right, bottom, or left onto another node to connect them. The source on the left updates when you let go.
4
+
5
+ ```ivm
6
+ @graph
7
+ @node trigger: "Trigger" x=24 y=40 kind=input
8
+ @node think: "Think" x=220 y=40
9
+ @node act: "Act" x=220 y=188
10
+ @node done: "Done?" x=430 y=40 kind=decision
11
+ @node output: "Output" x=430 y=188 kind=output
12
+ @edge trigger -> think
13
+ @edge think -> done
14
+ @edge think -> act
15
+ @edge act -> output
16
+ ```
17
+
18
+ Double-click a label to rename it. Choose a kind, then add a node, or change the kind of the node you have selected. Delete removes the selected node or edge.
package/index.html CHANGED
@@ -21,6 +21,8 @@
21
21
  <option value="">Examples…</option>
22
22
  <option value="amplitude.md">amplitude.md</option>
23
23
  <option value="circular-motion.md">circular-motion.md</option>
24
+ <option value="agent-loop.md">agent-loop.md</option>
25
+ <option value="graph.md">graph.md</option>
24
26
  </select>
25
27
  <input id="file-input" type="file" accept=".md,text/markdown,text/plain" />
26
28
  </div>
@@ -29,8 +31,12 @@
29
31
 
30
32
  <main class="layout">
31
33
  <section class="pane" aria-label="Editor">
32
- <div class="pane-title">Source (.md)</div>
34
+ <div class="pane-tabs" role="tablist" aria-label="Editor view">
35
+ <button type="button" id="tab-source" role="tab" aria-selected="true" aria-controls="editor">Source</button>
36
+ <button type="button" id="tab-graph" role="tab" aria-selected="false" aria-controls="graph-editor">Graph editor</button>
37
+ </div>
33
38
  <textarea id="editor" spellcheck="false" aria-label="Markdown source"></textarea>
39
+ <div id="graph-editor" hidden></div>
34
40
  </section>
35
41
  <section class="pane" aria-label="Preview">
36
42
  <div class="pane-title">Preview</div>
package/package.json CHANGED
@@ -1,16 +1,22 @@
1
1
  {
2
2
  "name": "interactive-markdown",
3
- "version": "0.1.0",
4
- "description": "Interactive Markdown (IVM): plain .md with live SVG sliders and timers",
3
+ "version": "0.2.0",
4
+ "description": "Interactive Markdown (IVM): plain .md with live SVG, a preview block builder, and an editable graph",
5
5
  "main": "./src/index.js",
6
+ "directories": {
7
+ "example": "examples",
8
+ "test": "test"
9
+ },
6
10
  "scripts": {
7
11
  "start": "serve .",
8
- "test": "node test/smoke-expr.mjs && node test/smoke-parse-ivm.mjs && node test/smoke-sanitize-svg.mjs && node test/smoke-render.mjs && node test/smoke-markdown.mjs",
12
+ "test": "node test/smoke-expr.mjs && node test/smoke-parse-ivm.mjs && node test/smoke-sanitize-svg.mjs && node test/smoke-render.mjs && node test/smoke-markdown.mjs && node test/smoke-graph.mjs && node test/smoke-document.mjs",
9
13
  "test:expr": "node test/smoke-expr.mjs",
10
14
  "test:parse": "node test/smoke-parse-ivm.mjs",
11
15
  "test:sanitize": "node test/smoke-sanitize-svg.mjs",
12
16
  "test:render": "node test/smoke-render.mjs",
13
- "test:markdown": "node test/smoke-markdown.mjs"
17
+ "test:markdown": "node test/smoke-markdown.mjs",
18
+ "test:graph": "node test/smoke-graph.mjs",
19
+ "test:document": "node test/smoke-document.mjs"
14
20
  },
15
21
  "repository": {
16
22
  "type": "git",
package/src/app.js CHANGED
@@ -2,11 +2,14 @@
2
2
  * Interactive Markdown — editor + live preview shell.
3
3
  */
4
4
 
5
+ import { mountDocumentEditor } from "./document-editor.js";
5
6
  import { renderDocument } from "./markdown.js";
7
+ import { replaceIvmFence } from "./parse-ivm.js";
6
8
 
7
9
  const STORAGE_KEY = "interactive-markdown-draft-v1";
8
10
 
9
11
  const editor = document.getElementById("editor");
12
+ const graphEditorEl = document.getElementById("graph-editor");
10
13
  const preview = document.getElementById("preview");
11
14
  const statusEl = document.getElementById("status");
12
15
  const btnOpen = document.getElementById("btn-open");
@@ -14,9 +17,13 @@ const btnSave = document.getElementById("btn-save");
14
17
  const btnClear = document.getElementById("btn-clear");
15
18
  const fileInput = document.getElementById("file-input");
16
19
  const exampleSelect = document.getElementById("example-select");
20
+ const tabSource = document.getElementById("tab-source");
21
+ const tabGraph = document.getElementById("tab-graph");
17
22
 
18
23
  /** @type {{ destroy: () => void } | null} */
19
- let currentMount = null;
24
+ let previewMount = null;
25
+ /** @type {{ destroy: () => void } | null} */
26
+ let graphMount = null;
20
27
  let debounceTimer = 0;
21
28
  let dirty = false;
22
29
 
@@ -24,19 +31,85 @@ function setStatus(msg) {
24
31
  if (statusEl) statusEl.textContent = msg;
25
32
  }
26
33
 
34
+ function persistDraft() {
35
+ dirty = true;
36
+ try {
37
+ localStorage.setItem(STORAGE_KEY, editor.value);
38
+ } catch (_) {
39
+ /* ignore quota */
40
+ }
41
+ }
42
+
43
+ function showPreviewError(err) {
44
+ preview.innerHTML = `<div class="im-errors">${escapeHtml(err.message || String(err))}</div>`;
45
+ setStatus("Render error");
46
+ console.error(err);
47
+ }
48
+
49
+ function renderPreview() {
50
+ if (previewMount) {
51
+ previewMount.destroy();
52
+ previewMount = null;
53
+ }
54
+ previewMount = renderDocument(preview, editor.value, {
55
+ onGraphChange(fenceIndex, body) {
56
+ editor.value = replaceIvmFence(editor.value, fenceIndex, body);
57
+ persistDraft();
58
+ setStatus("Graph updated");
59
+ refreshGraphEditor();
60
+ },
61
+ });
62
+ }
63
+
64
+ function refreshGraphEditor() {
65
+ if (!graphEditorEl) return;
66
+ if (graphMount) {
67
+ graphMount.destroy();
68
+ graphMount = null;
69
+ }
70
+ graphMount = mountDocumentEditor(graphEditorEl, editor.value, {
71
+ onChange(markdown) {
72
+ editor.value = markdown;
73
+ persistDraft();
74
+ setStatus("Document updated");
75
+ try {
76
+ renderPreview();
77
+ } catch (err) {
78
+ showPreviewError(err);
79
+ }
80
+ },
81
+ onStatus(msg) {
82
+ setStatus(msg);
83
+ },
84
+ });
85
+ }
86
+
27
87
  function renderNow() {
28
- if (currentMount) {
29
- currentMount.destroy();
30
- currentMount = null;
88
+ let previewOk = false;
89
+ try {
90
+ renderPreview();
91
+ previewOk = true;
92
+ } catch (err) {
93
+ showPreviewError(err);
31
94
  }
32
95
  try {
33
- currentMount = renderDocument(preview, editor.value);
34
- setStatus(dirty ? "Edited · preview updated" : "Preview updated");
96
+ refreshGraphEditor();
35
97
  } catch (err) {
36
- preview.innerHTML = `<div class="im-errors">${escapeHtml(err.message || String(err))}</div>`;
98
+ if (graphEditorEl) {
99
+ graphEditorEl.innerHTML = `<div class="im-errors">${escapeHtml(err.message || String(err))}</div>`;
100
+ }
37
101
  setStatus("Render error");
38
102
  console.error(err);
39
103
  }
104
+ if (previewOk) setStatus(dirty ? "Edited · preview updated" : "Preview updated");
105
+ }
106
+
107
+ function showEditorTab(name) {
108
+ const source = name !== "graph";
109
+ if (editor) editor.hidden = !source;
110
+ if (graphEditorEl) graphEditorEl.hidden = source;
111
+ tabSource?.setAttribute("aria-selected", source ? "true" : "false");
112
+ tabGraph?.setAttribute("aria-selected", source ? "false" : "true");
40
113
  }
41
114
 
42
115
  function scheduleRender() {
@@ -90,7 +163,7 @@ async function loadExample(name) {
90
163
 
91
164
  const DEFAULT_DOC = `# Interactive Markdown
92
165
 
93
- Edit the left pane. The preview updates as you type.
166
+ Edit the source, or open the Graph editor tab. The preview on the right updates as you go.
94
167
 
95
168
  Use an \`\`\`ivm\`\`\` fence for sliders, timers, and live SVG.
96
169
 
@@ -113,6 +186,7 @@ Use an \`\`\`ivm\`\`\` fence for sliders, timers, and live SVG.
113
186
  - \`@timer name: min..max = default speed=N loop\`
114
187
  - \`@let name = expression\` (or \`@calc\`)
115
188
  - \`@svg\` then SVG with \`{{expr}}\` placeholders
189
+ - \`@graph\` with \`@node\` and \`@edge\` for a visual graph editor
116
190
 
117
191
  Open a \`.md\` file, or save your draft from the toolbar.
118
192
  `;
@@ -131,6 +205,9 @@ function init() {
131
205
 
132
206
  editor.value = start;
133
207
  editor.addEventListener("input", scheduleRender);
208
+ tabSource?.addEventListener("click", () => showEditorTab("source"));
209
+ tabGraph?.addEventListener("click", () => showEditorTab("graph"));
210
+ showEditorTab("source");
134
211
 
135
212
  btnOpen?.addEventListener("click", () => fileInput?.click());
136
213
  fileInput?.addEventListener("change", async () => {