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 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,23 @@
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 **Cartesian XY plots** and 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
+ - **XY plots**: `@plot`, `@axis`, and `@series` with live slider-driven curves
19
+ - Visual **graph editor**: `@graph`, `@node`, and `@edge` (drag, connect, rename; edits write back into the fence)
20
+ - Animated SVG: CSS `@keyframes`, SMIL, glow filters, and internal `<use>`
16
21
  - Whitelist expression evaluator (no `eval`)
17
22
  - SVG sanitizer (strips scripts, event handlers, external hrefs)
18
23
 
@@ -37,23 +42,24 @@ npx serve .
37
42
 
38
43
  > ES modules need HTTP — opening `index.html` as a `file://` URL will not work.
39
44
 
45
+ ## Graph editor tab
46
+
47
+ 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, XY plots, 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.
48
+
40
49
  ## IVM dialect (quick reference)
41
50
 
42
- Fence name: **`ivm`**.
51
+ Fence name: **`ivm`**. Long control names are canonical; short aliases are accepted for compact AI generation.
52
+
53
+ ### Canonical exemplar
43
54
 
44
55
  ````markdown
45
56
  ```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>
57
+ @slider amp: 5..55 = 28
58
+ @timer t: 0..100 = 0 speed=25
59
+ @let y = 40 - amp * sind(t * 3.6)
60
+ @svg 200x80
61
+ <rect width="200" height="80" fill="#0f172a"/>
62
+ <path d="M10 40 Q55 {{y}} 100 40 T190 40" fill="none" stroke="#38bdf8" stroke-width="3"/>
57
63
  ```
58
64
  ````
59
65
 
@@ -61,26 +67,108 @@ Fence name: **`ivm`**.
61
67
 
62
68
  | Line | Meaning |
63
69
  |------|---------|
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` |
70
+ | `@slider name: min..max = default [step=S] [deg\|rad] ["Label"]` | Range input (`@s` alias) |
71
+ | `@timer name: min..max = default [speed=N] [step=S] [loop\|noloop] [deg\|rad] ["Label"]` | Animated value + Play/Pause (`@t` alias) |
72
+ | `@input name: "default" ["Label"]` | Text field (`@i` alias) |
73
+ | `@toggle name: true\|false ["Label"]` | Checkbox (`@b` alias) |
74
+ | `@let name = expression` | Computed value (`@l` alias) |
75
+ | `@calc name = expression` | Alias of `@let` (compat only; prefer `@let`) |
70
76
  | `@ns name` | Share variable state across IVM blocks with the same name |
71
- | `@svg` | Everything after this line is SVG markup |
77
+ | `@svg` | Everything after this line is full SVG markup |
78
+ | `@svg WxH` | Same, but wrap non-root markup in `<svg viewBox="0 0 W H" xmlns=…>` |
79
+ | `@plot [WxH] [grid\|nogrid]` | Cartesian XY plot (mutually exclusive with `@svg` / `@graph`) |
80
+ | `@axis x\|y: min..max [step=S] ["Label"]` | Plot axis range and label |
81
+ | `@series [id] …` / `y = expr` | Plot series (free var `x`) |
82
+ | `@grid on\|off` | Plot grid toggle |
83
+ | `@graph` | Start an editable directed graph. This is the first directive in that fence |
84
+ | `@node id: "Label" x=N y=N kind=K` | Node. `kind` is `action` (default), `input`, `output`, or `decision` |
85
+ | `@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` |
86
+ | `@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` |
87
+
88
+ ### Defaults (omit when unchanged)
89
+
90
+ - `step` defaults to **1**
91
+ - timer `loop` defaults to **true** (write `noloop` only when needed)
92
+ - timer `speed` defaults to **30**
93
+ - if `"Label"` is omitted, the UI shows the variable **name**
94
+
95
+ ### Short aliases (AI-compact)
96
+
97
+ | Short | Long |
98
+ |-------|------|
99
+ | `@s` | `@slider` |
100
+ | `@t` | `@timer` |
101
+ | `@i` | `@input` |
102
+ | `@b` | `@toggle` |
103
+ | `@l` | `@let` |
104
+
105
+ Humans and docs should use the long forms. Generators may emit short aliases; both parse.
106
+
107
+ ### Graph
108
+
109
+ 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.
110
+
111
+ ````markdown
112
+ ```ivm
113
+ @graph
114
+ @node start: "Start" x=40 y=48 kind=input
115
+ @node step: "Step" x=280 y=48
116
+ @edge start -> step
117
+ ```
118
+ ````
119
+
120
+
121
+ ### XY plot
122
+
123
+ A fence that starts with `@plot` draws a Cartesian chart. Axes, grid, and series are declared in the dialect; the renderer builds the SVG. Sliders and `@let` work the same as in figure blocks. Free variable `x` is sampled across the X axis when evaluating each series. Do not mix `@svg` or `@graph` into the same fence.
124
+
125
+ ````markdown
126
+ ```ivm
127
+ @plot
128
+ @axis x: -10..10 step=2 "x"
129
+ @axis y: 0..0.8 step=0.2 "density"
130
+ @slider mu: -5..5 = 0 step=0.1 "μ (mean)"
131
+ @slider sigma: 0.5..4 = 1.5 step=0.1 "σ (std dev)"
132
+ @series density stroke=#6d28d9 fill=#7c3aed fillOpacity=0.25
133
+ y = (1/(sigma*sqrt(2*pi))) * exp(-0.5*((x-mu)/sigma)^2)
134
+ ```
135
+ ````
136
+
137
+ | Line | Meaning |
138
+ |------|---------|
139
+ | `@plot [WxH] [grid\|nogrid]` | Start an XY plot (default size 480×320, grid on) |
140
+ | `@axis x\|y: min..max [step=S] ["Label"]` | Axis range, tick step, and title |
141
+ | `@series [id] [stroke=#…] [fill=#…] [fillOpacity=N] [width=N]` | Curve style; next line (or same line) `y = expr` |
142
+ | `@grid on\|off` | Toggle the background grid |
143
+
144
+ Defaults: grid **on**, fill opacity **0.25**, stroke `#6d28d9`, fill `#7c3aed`.
72
145
 
73
146
  ### Expressions (`{{…}}`)
74
147
 
75
- Placeholders use **double braces**: `{{expr}}`.
148
+ Placeholders use **double braces** only: `{{expr}}`.
76
149
 
77
150
  Allowed operators include `+ - * / % ^ **`, comparisons, `&& || !`, and ternary `? :`.
78
151
 
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`.
152
+ 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
153
 
81
154
  Constants: `pi`, `e`, `tau`.
82
155
 
83
- Angles for trig functions are in **radians** (use `deg` / `rad` helpers as needed).
156
+ - `sin` / `cos` / `tan` take **radians**
157
+ - `sind` / `cosd` / `tand` take **degrees** (prefer these when a control range is in degrees)
158
+ - Optional `deg` / `rad` flag on a range line is stored as metadata (`units`) for authors/tools; trig still uses the helpers above
159
+
160
+ ### Animated SVG
161
+
162
+ Everything after `@svg` can be a self-contained animated figure:
163
+
164
+ - `<style>` with `@keyframes` (crossfades, pulses, marching dashes)
165
+ - SMIL (`<animate>`, `<animateTransform>`, `<animateMotion>`)
166
+ - Filters (`feGaussianBlur`, …) and paint servers such as `url(#id)`
167
+ - `<use href="#symbol-id">` for icons defined in the same SVG
168
+
169
+ 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.
170
+
171
+ A block with only `@svg` (no controls) renders as a full-width figure.
84
172
 
85
173
  ### Fallback
86
174
 
@@ -92,18 +180,26 @@ Load from the toolbar **Examples…** menu, or open the files directly:
92
180
 
93
181
  - [`examples/amplitude.md`](examples/amplitude.md) — wave amplitude + phase timer
94
182
  - [`examples/circular-motion.md`](examples/circular-motion.md) — point on a circle with spin animation
183
+ - [`examples/agent-loop.md`](examples/agent-loop.md) — agent loop drawn as an animated graph
184
+ - [`examples/graph.md`](examples/graph.md) — directed graph you can edit in the preview
185
+ - [`examples/normal-density.md`](examples/normal-density.md) — Gaussian density XY plot with μ/σ sliders
95
186
 
96
187
  ## Project layout
97
188
 
98
189
  ```
99
190
  index.html Editor shell
100
191
  src/
192
+ index.js Library entry
101
193
  app.js UI wiring
194
+ document.js Block model (parse and serialize)
195
+ document-editor.js Preview block editor
102
196
  markdown.js MD + IVM pipeline
103
197
  parse-ivm.js Fence / control parser
104
198
  expr.js Safe expression evaluator
105
199
  sanitize-svg.js SVG sanitizer
106
200
  render.js Controls + SVG binding
201
+ graph-editor.js Graph model and canvas editor
202
+ plot.js Cartesian XY plot SVG builder
107
203
  styles/app.css
108
204
  examples/
109
205
  test/ Node smoke tests
@@ -115,7 +211,7 @@ test/ Node smoke tests
115
211
  npm test
116
212
  ```
117
213
 
118
- Smoke tests cover the expression engine, IVM parser, SVG sanitizer, renderer, and Markdown pipeline (`linkedom` is used where a DOM is required).
214
+ Smoke tests cover the expression engine, IVM parser, SVG sanitizer, renderer, Markdown pipeline, graph editor, XY plots, and preview document builder (`linkedom` is used where a DOM is required).
119
215
 
120
216
  ## License
121
217
 
@@ -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.
@@ -0,0 +1,19 @@
1
+ # Normal density
2
+
3
+ A Cartesian **XY plot** of the Gaussian PDF. Drag **μ (mean)** and **σ (std dev)** — the curve recomputes live.
4
+
5
+ ```ivm
6
+ @plot
7
+ @axis x: -10..10 step=2 "x"
8
+ @axis y: 0..0.8 step=0.2 "density"
9
+ @slider mu: -5..5 = 0 step=0.1 "μ (mean)"
10
+ @slider sigma: 0.5..4 = 1.5 step=0.1 "σ (std dev)"
11
+ @series density stroke=#6d28d9 fill=#7c3aed fillOpacity=0.25
12
+ y = (1/(sigma*sqrt(2*pi))) * exp(-0.5*((x-mu)/sigma)^2)
13
+ ```
14
+
15
+ ## Tips
16
+
17
+ - `@plot` builds axes, grid, and series — no hand-written SVG required.
18
+ - Series expressions use free variable `x` plus any `@slider` / `@let` names.
19
+ - Omit `nogrid` to keep the light gray grid (default on).
package/index.html CHANGED
@@ -21,6 +21,9 @@
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>
26
+ <option value="normal-density.md">normal-density.md</option>
24
27
  </select>
25
28
  <input id="file-input" type="file" accept=".md,text/markdown,text/plain" />
26
29
  </div>
@@ -29,8 +32,12 @@
29
32
 
30
33
  <main class="layout">
31
34
  <section class="pane" aria-label="Editor">
32
- <div class="pane-title">Source (.md)</div>
35
+ <div class="pane-tabs" role="tablist" aria-label="Editor view">
36
+ <button type="button" id="tab-source" role="tab" aria-selected="true" aria-controls="editor">Source</button>
37
+ <button type="button" id="tab-graph" role="tab" aria-selected="false" aria-controls="graph-editor">Graph editor</button>
38
+ </div>
33
39
  <textarea id="editor" spellcheck="false" aria-label="Markdown source"></textarea>
40
+ <div id="graph-editor" hidden></div>
34
41
  </section>
35
42
  <section class="pane" aria-label="Preview">
36
43
  <div class="pane-title">Preview</div>
package/package.json CHANGED
@@ -1,16 +1,23 @@
1
1
  {
2
2
  "name": "interactive-markdown",
3
- "version": "0.1.1",
4
- "description": "Interactive Markdown (IVM): plain .md with live SVG sliders and timers",
3
+ "version": "0.3.0",
4
+ "description": "Interactive Markdown (IVM): plain .md with live SVG, XY plots, 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-plot.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",
20
+ "test:plot": "node test/smoke-plot.mjs"
14
21
  },
15
22
  "repository": {
16
23
  "type": "git",
@@ -43,7 +50,8 @@
43
50
  "./parse-ivm": "./src/parse-ivm.js",
44
51
  "./sanitize-svg": "./src/sanitize-svg.js",
45
52
  "./render": "./src/render.js",
46
- "./markdown": "./src/markdown.js"
53
+ "./markdown": "./src/markdown.js",
54
+ "./plot": "./src/plot.js"
47
55
  },
48
56
  "files": [
49
57
  "src",
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 () => {