interactive-markdown 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +89 -26
- package/examples/agent-loop.md +26 -0
- package/examples/amplitude.md +11 -14
- package/examples/circular-motion.md +10 -14
- package/examples/graph.md +18 -0
- package/index.html +7 -1
- package/package.json +10 -4
- package/src/app.js +85 -8
- package/src/document-editor.js +956 -0
- package/src/document.js +633 -0
- package/src/expr.js +6 -0
- package/src/graph-editor.js +1117 -0
- package/src/markdown.js +10 -2
- package/src/parse-ivm.js +306 -67
- package/src/render.js +98 -6
- package/src/sanitize-svg.js +240 -9
- package/styles/app.css +647 -1
package/LICENSE
CHANGED
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`
|
|
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
|
-
@
|
|
47
|
-
@
|
|
48
|
-
@
|
|
49
|
-
@
|
|
50
|
-
|
|
51
|
-
|
|
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 (`
|
|
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
|
|
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
|
-
|
|
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,
|
|
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.
|
package/examples/amplitude.md
CHANGED
|
@@ -1,25 +1,22 @@
|
|
|
1
1
|
# Wave amplitude
|
|
2
2
|
|
|
3
|
-
Drag **
|
|
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
|
|
7
|
-
@timer t: 0..100 = 0 speed=25
|
|
8
|
-
@let y = 40 - amp *
|
|
9
|
-
@let y2 = 40 - (amp * 0.55) *
|
|
10
|
-
@svg
|
|
11
|
-
<
|
|
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="
|
|
15
|
-
|
|
16
|
-
<
|
|
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
|
|
7
|
-
@slider angle: 0..360 = 45
|
|
8
|
-
@timer spin: 0..360 = 0 speed=40
|
|
9
|
-
@
|
|
10
|
-
@let x = round(100 + radius *
|
|
11
|
-
@let y = round(100 + radius *
|
|
12
|
-
@svg
|
|
13
|
-
<
|
|
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 **
|
|
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-
|
|
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.
|
|
4
|
-
"description": "Interactive Markdown (IVM): plain .md with live SVG
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
88
|
+
let previewOk = false;
|
|
89
|
+
try {
|
|
90
|
+
renderPreview();
|
|
91
|
+
previewOk = true;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
showPreviewError(err);
|
|
31
94
|
}
|
|
32
95
|
try {
|
|
33
|
-
|
|
34
|
-
setStatus(dirty ? "Edited · preview updated" : "Preview updated");
|
|
96
|
+
refreshGraphEditor();
|
|
35
97
|
} catch (err) {
|
|
36
|
-
|
|
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
|
|
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 () => {
|