edododraw 0.1.0 → 0.1.2
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/README.md +1 -1
- package/dist-lib/chunks/{EdodoDraw-CUf224RG.js → EdodoDraw-C3Yd1I9B.js} +3426 -2736
- package/dist-lib/chunks/EdodoDraw-C3Yd1I9B.js.map +1 -0
- package/dist-lib/engine/dsl/ast.d.ts +19 -1
- package/dist-lib/engine/dsl/ast.d.ts.map +1 -1
- package/dist-lib/engine/dsl/compile.d.ts.map +1 -1
- package/dist-lib/engine/dsl/parser.d.ts.map +1 -1
- package/dist-lib/engine/dsl/patch.d.ts +46 -0
- package/dist-lib/engine/dsl/patch.d.ts.map +1 -0
- package/dist-lib/engine/edit/controller.d.ts +73 -0
- package/dist-lib/engine/edit/controller.d.ts.map +1 -0
- package/dist-lib/engine/index.d.ts +5 -0
- package/dist-lib/engine/index.d.ts.map +1 -1
- package/dist-lib/engine/scene/overrides.d.ts +10 -0
- package/dist-lib/engine/scene/overrides.d.ts.map +1 -0
- package/dist-lib/engine/scene/types.d.ts +12 -0
- package/dist-lib/engine/scene/types.d.ts.map +1 -1
- package/dist-lib/index.js +71 -63
- package/dist-lib/lib/EdodoDraw.d.ts +37 -3
- package/dist-lib/lib/EdodoDraw.d.ts.map +1 -1
- package/dist-lib/llms-full.txt +1782 -0
- package/dist-lib/llms.txt +21 -0
- package/dist-lib/react.js +1 -1
- package/package.json +10 -1
- package/dist-lib/chunks/EdodoDraw-CUf224RG.js.map +0 -1
|
@@ -0,0 +1,1782 @@
|
|
|
1
|
+
# EDodoDraw — complete documentation (v0.1.1)
|
|
2
|
+
|
|
3
|
+
> A 100% code/syntax-to-diagram engine with the Excalidraw hand-drawn aesthetic, magic-move camera, scriptable + real-time annotations, animated arrows, and first-class Mermaid import.
|
|
4
|
+
>
|
|
5
|
+
> This ONE file is the full, LLM-consumable documentation for EDodoDraw. It
|
|
6
|
+
> concatenates every guide and every runnable example, generated verbatim
|
|
7
|
+
> from the source repo — nothing is summarised or omitted.
|
|
8
|
+
|
|
9
|
+
- Live site (rendered docs + live demos): https://vivmagarwal.github.io/edododraw/
|
|
10
|
+
- npm package: https://www.npmjs.com/package/edododraw (`npm i edododraw`)
|
|
11
|
+
- Source repo: https://github.com/vivmagarwal/edododraw
|
|
12
|
+
|
|
13
|
+
What EDodoDraw is: a 100% code/syntax-to-diagram engine with the Excalidraw
|
|
14
|
+
hand-drawn look, a magic-move camera, scriptable + real-time annotations,
|
|
15
|
+
animated arrows, and first-class Mermaid import. You write `.edd` source; it
|
|
16
|
+
renders a diagram. Use it via the DSL (see "Language reference") and/or embed
|
|
17
|
+
it in an app via the `EdodoDraw` facade (see "Embed in your app").
|
|
18
|
+
|
|
19
|
+
## Contents
|
|
20
|
+
|
|
21
|
+
1. Language reference
|
|
22
|
+
2. Camera & timeline
|
|
23
|
+
3. Annotations
|
|
24
|
+
4. Import & export
|
|
25
|
+
5. Embed in your app
|
|
26
|
+
6. Extend & make plugins
|
|
27
|
+
7. Architecture
|
|
28
|
+
8. Development standards
|
|
29
|
+
9. Runnable examples (.edd)
|
|
30
|
+
|
|
31
|
+
==============================================================================
|
|
32
|
+
|
|
33
|
+
# ═══ Language reference (docs/DSL_LANGUAGE_GUIDE.md) ═══
|
|
34
|
+
|
|
35
|
+
# EDodoDraw Language Guide (`.edd`)
|
|
36
|
+
|
|
37
|
+
The complete reference for **EDodoDraw code** — the text syntax that compiles 100% to a diagram. Written to be equally readable by humans and generatable by LLMs.
|
|
38
|
+
|
|
39
|
+
> Implementation: `src/engine/dsl/` (lexer → parser → compiler). This guide documents what the compiler **actually accepts today**. Runnable examples live in `examples/`.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 1. Mental model
|
|
44
|
+
|
|
45
|
+
A program has two halves:
|
|
46
|
+
|
|
47
|
+
1. **Structure** — declared once inside `scene { … }`: nodes, edges, groups, styles, layout. Mermaid-friendly (arrow glyphs, `id[Label]` sugar, `:::class`) with clean `{ key: value }` attribute blocks for styling.
|
|
48
|
+
2. **Choreography** (optional) — a `timeline { … }` of ordered `beat`s. Each beat is a keyframe of **camera** (fit/focus/zoom/pan), **annotate** (highlight/underline/point-at/…), and **reveal** (show/hide). The engine "magic-moves" between beats.
|
|
49
|
+
|
|
50
|
+
Minimal program:
|
|
51
|
+
|
|
52
|
+
```edd
|
|
53
|
+
scene {
|
|
54
|
+
a[Start] --> b[Do work] --> c{OK?}
|
|
55
|
+
c -->|yes| d(Done)
|
|
56
|
+
c -->|no| a
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Everything else is optional layering on top.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 2. Top-level statements
|
|
65
|
+
|
|
66
|
+
| Statement | Purpose |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `edd 1.0` | Optional version marker (first line). |
|
|
69
|
+
| `meta { title: "…", background: "#…" }` | Diagram metadata. |
|
|
70
|
+
| `theme name { tokens { $x: #hex, … } }` | Named theme + reusable `$tokens`. |
|
|
71
|
+
| `style .name { … }` / `style .name extends .other { … }` | Reusable style class. |
|
|
72
|
+
| `defaults { node { … } edge { … } }` | Defaults applied to every node/edge. |
|
|
73
|
+
| `scene [name] { … }` | Structure. Multiple `scene` blocks merge. |
|
|
74
|
+
| `annotate ["label"] { … }` | Always-on annotations (outside the timeline). |
|
|
75
|
+
| `timeline [name] { … }` | Choreography (beats). |
|
|
76
|
+
| `overrides { id at (x, y) [size (w, h)] … }` | Pinned positions/sizes (usually machine-written by direct editing — see §11). |
|
|
77
|
+
| `mermaid """ … """` | Import a raw Mermaid diagram (see IMPORT_AND_EXPORT_GUIDE.md). |
|
|
78
|
+
|
|
79
|
+
Comments: `// line`, `%% line` (Mermaid-style), `/* block */` (nestable).
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 3. Nodes — three equivalent forms
|
|
84
|
+
|
|
85
|
+
```edd
|
|
86
|
+
// 1. Mermaid sugar (shape from the bracket kind)
|
|
87
|
+
db[(Postgres)] // cylinder
|
|
88
|
+
q{{Load Balancer}} // hexagon
|
|
89
|
+
ok([Success]) // pill / stadium
|
|
90
|
+
|
|
91
|
+
// 2. Keyword form (explicit shape keyword first) — best for LLMs
|
|
92
|
+
cylinder db "Postgres"
|
|
93
|
+
hexagon lb "Load Balancer"
|
|
94
|
+
|
|
95
|
+
// 3. Explicit form
|
|
96
|
+
node db "Postgres" as cylinder
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Shape sugar table
|
|
100
|
+
|
|
101
|
+
| Sugar | Shape | Sugar | Shape |
|
|
102
|
+
|---|---|---|---|
|
|
103
|
+
| `[x]` | rectangle | `[(x)]` | cylinder |
|
|
104
|
+
| `(x)` | round-rectangle | `((x))` | circle |
|
|
105
|
+
| `([x])` | pill / stadium | `{x}` | diamond |
|
|
106
|
+
| `[[x]]` | rectangle | `{{x}}` | hexagon |
|
|
107
|
+
| `[/x/]` | parallelogram | `[/x\]` | trapezoid |
|
|
108
|
+
|
|
109
|
+
### Full shape keyword list
|
|
110
|
+
|
|
111
|
+
`rect` · `round-rect` · `ellipse` · `circle` · `diamond` (`decision`) · `triangle` · `hexagon` · `parallelogram` · `trapezoid` · `cylinder` (`db`) · `cloud` · `document` · `note` · `actor` · `pill` (`stadium`) · `text` · `star`
|
|
112
|
+
|
|
113
|
+
Unknown shape names resolve through the **shape plugin registry** (`registerShape`) — see DEVELOPMENT_STANDARDS.md.
|
|
114
|
+
|
|
115
|
+
### Node attributes
|
|
116
|
+
|
|
117
|
+
```edd
|
|
118
|
+
rect api "API Service" {
|
|
119
|
+
fill: green // palette name or #hex → soft bg + matching stroke
|
|
120
|
+
stroke: #1971c2
|
|
121
|
+
fillStyle: hachure // hachure | cross-hatch | solid | zigzag | dots | none
|
|
122
|
+
strokeWidth: bold // thin | medium | bold | thick | <number>
|
|
123
|
+
strokeStyle: dashed // solid | dashed | dotted
|
|
124
|
+
roughness: artist // architect(0) | artist(1) | cartoonist(2) | <number>
|
|
125
|
+
roundness: 16 // corner radius (px)
|
|
126
|
+
font: hand // hand | normal | code
|
|
127
|
+
fontSize: 22
|
|
128
|
+
opacity: 0.9 // 0..1 or 0..100
|
|
129
|
+
at: (1180, 40) // absolute world position
|
|
130
|
+
pin: true // exclude from auto-layout (pinned)
|
|
131
|
+
size: (180, 80) // explicit width,height
|
|
132
|
+
tags: [edge, critical] // selectable via .edge / .critical
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Apply style classes with `:::` — `rect api "API" :::card :::critical`.
|
|
137
|
+
|
|
138
|
+
### Named colors
|
|
139
|
+
|
|
140
|
+
Stroke palette: `black gray red pink grape violet blue cyan teal green lime yellow orange brown white`.
|
|
141
|
+
`fill: green` yields the pleasant Excalidraw soft-green box with a matching darker-green outline. `transparent`/`none` → no fill.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 4. Edges
|
|
146
|
+
|
|
147
|
+
```edd
|
|
148
|
+
a --> b // arrow
|
|
149
|
+
a --> b "label" // trailing label
|
|
150
|
+
a -->|mid label| b // Mermaid mid-label
|
|
151
|
+
a -> b -> c // chain → two edges
|
|
152
|
+
a -> b & c // fan-out → a→b and a→c
|
|
153
|
+
a@(1,0.5) -> b.north // anchored endpoints (see §7)
|
|
154
|
+
edge e1: a --> b "named" // give the edge an id
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Glyph table
|
|
158
|
+
|
|
159
|
+
| Glyph | Line | End arrowhead | Notes |
|
|
160
|
+
|---|---|---|---|
|
|
161
|
+
| `--` `---` | solid | none | plain connector |
|
|
162
|
+
| `->` `-->` | solid | arrow | canonical |
|
|
163
|
+
| `<-` `<--` | solid | arrow at start | reversed |
|
|
164
|
+
| `<->` `<-->` | solid | arrows both | bidirectional |
|
|
165
|
+
| `-.->` | dashed | arrow | async |
|
|
166
|
+
| `..>` | dotted | arrow | dependency |
|
|
167
|
+
| `==>` | solid (thick) | arrow | emphasis |
|
|
168
|
+
| `~>` | solid (curved) | arrow | **animated `flow` by default** |
|
|
169
|
+
| `--o` `-o` | solid | circle | association |
|
|
170
|
+
| `--x` `-x` | solid | bar | termination |
|
|
171
|
+
|
|
172
|
+
### Edge attributes
|
|
173
|
+
|
|
174
|
+
```edd
|
|
175
|
+
a -> b {
|
|
176
|
+
color: #1971c2
|
|
177
|
+
strokeWidth: bold
|
|
178
|
+
strokeStyle: dashed
|
|
179
|
+
startArrow: dot // none arrow triangle bar dot circle diamond crow …
|
|
180
|
+
endArrow: triangle
|
|
181
|
+
curve: curved // straight | curved | orthogonal | elbow | bezier | arc
|
|
182
|
+
animate: dash-march // see §5
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Parallel edges between the same pair automatically fan out so they never overlap.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## 5. Animated arrows
|
|
191
|
+
|
|
192
|
+
Set `animate: <kind>` on any edge. EDodoDraw ships far more than Excalidraw:
|
|
193
|
+
|
|
194
|
+
| Kind | Effect |
|
|
195
|
+
|---|---|
|
|
196
|
+
| `flow` | dashes flow along the path |
|
|
197
|
+
| `dash-march` | marching ants |
|
|
198
|
+
| `draw-on` | the line draws itself in (loops) |
|
|
199
|
+
| `comet` | bright head with a fading trail + glow |
|
|
200
|
+
| `gradient-flow` | animated multi-color gradient stroke |
|
|
201
|
+
| `electric` | fast jittered dashes |
|
|
202
|
+
| `pulse` | opacity/width pulse |
|
|
203
|
+
|
|
204
|
+
`glow`→comet, `caravan`→dash-march, `wiggle`→flow are accepted aliases. Speed: `animate: flow { speed: 1.4 }`.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## 6. Layout
|
|
209
|
+
|
|
210
|
+
```edd
|
|
211
|
+
scene {
|
|
212
|
+
layout dag { direction: down, gap: 64, rankGap: 110 }
|
|
213
|
+
…
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
| Kind | Behavior |
|
|
218
|
+
|---|---|
|
|
219
|
+
| `dag` / `tree` / `flow` | layered graph via dagre (respects `direction`) |
|
|
220
|
+
| `grid` | row-major grid |
|
|
221
|
+
| `radial` | hub + ring (`center: id`) |
|
|
222
|
+
| `manual` / `free` | use each node's `at:` position |
|
|
223
|
+
|
|
224
|
+
`direction`: `down`(TB) · `up`(BT) · `right`(LR) · `left`(RL). Nodes with `pin: true` (or `at:` set) are excluded from auto-layout and kept fixed. Groups are clustered so their members stay together.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## 7. Connection anchors (richer than Excalidraw's 4)
|
|
229
|
+
|
|
230
|
+
Attach an edge endpoint to a specific point on a node with `.anchor` or `@(u,v)`:
|
|
231
|
+
|
|
232
|
+
- Compass: `.n .s .e .w .ne .nw .se .sw .c` (aliases `.top .bottom .left .right .center`).
|
|
233
|
+
- Side fraction: `.top:0.3` (30% along the top edge), also `.right:0.75`, `.bottom:0.5`, `.left:0.2`.
|
|
234
|
+
- Normalized: `@(0.5, 1.0)` — u,v in 0..1 of the node box.
|
|
235
|
+
- Angle: `.angle:45` — degrees from the center (0 = east, clockwise).
|
|
236
|
+
|
|
237
|
+
```edd
|
|
238
|
+
gw.s -> api.n // bottom of gw → top of api
|
|
239
|
+
api@(1, 0.5) -> db.top:0.3 // right-middle of api → 30% along db's top edge
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Omit the anchor for automatic border attachment aimed at the other endpoint.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## 8. Groups
|
|
247
|
+
|
|
248
|
+
```edd
|
|
249
|
+
scene {
|
|
250
|
+
group services "Application Services" {
|
|
251
|
+
round-rect auth "Auth"
|
|
252
|
+
round-rect api "API"
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Draws a labelled dashed frame around its members. A group is addressable as one target (e.g. `camera focus services`, `spotlight services`).
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## 9. Annotations
|
|
262
|
+
|
|
263
|
+
Annotate elements in an always-on `annotate { … }` block or inside a timeline beat.
|
|
264
|
+
|
|
265
|
+
| Command | Example |
|
|
266
|
+
|---|---|
|
|
267
|
+
| `highlight` | `highlight cdn { color: yellow }` |
|
|
268
|
+
| `underline` | `underline api { color: #2563eb, style: wavy }` |
|
|
269
|
+
| `strike` | `strike legacy { color: red }` |
|
|
270
|
+
| `box` (over a set) | `box [gateway, api, db] "critical path" { color: red }` |
|
|
271
|
+
| `circle-mark` | `circle-mark queue "retry here"` |
|
|
272
|
+
| `point-at` | `point-at gateway "single entry" { from: ne, color: #2563eb }` |
|
|
273
|
+
| `callout` | `callout db "source of truth" { placement: bottom }` |
|
|
274
|
+
| `spotlight` | `spotlight services { dim: 0.72 }` |
|
|
275
|
+
| `note-marker` | `note-marker db "1"` |
|
|
276
|
+
|
|
277
|
+
`from` / `placement` take a cardinal (`n s e w ne …`). Annotations anchor to their target and track the camera. See ANNOTATIONS_GUIDE.md for the real-time editor and round-trip.
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## 10. Timeline (magic-move presentation)
|
|
282
|
+
|
|
283
|
+
```edd
|
|
284
|
+
timeline story {
|
|
285
|
+
beat overview "The whole system" {
|
|
286
|
+
camera fit-all over 800ms
|
|
287
|
+
reveal { show all with fade-in }
|
|
288
|
+
narrate: "End-to-end request path."
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
beat data "Where state lives" {
|
|
292
|
+
camera focus [db, cache, queue] zoom 1.7 ease ease-in-out
|
|
293
|
+
annotate {
|
|
294
|
+
callout db "primary of record" { placement: right }
|
|
295
|
+
spotlight [db, cache, queue] { dim: 0.7 }
|
|
296
|
+
}
|
|
297
|
+
narrate: "All writes funnel through Postgres."
|
|
298
|
+
hold: 2s
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Camera operations (inline or block form)
|
|
304
|
+
|
|
305
|
+
| Op | Inline | Block |
|
|
306
|
+
|---|---|---|
|
|
307
|
+
| Frame all | `camera fit-all` | `camera { }` |
|
|
308
|
+
| Focus target(s) | `camera focus [a,b] zoom 1.6` | `camera { focus: [a,b], zoom: 1.6 }` |
|
|
309
|
+
| Absolute zoom | `camera zoom 1.3` | `camera { zoom: 1.3 }` |
|
|
310
|
+
| Pan | `camera pan (x, y)` | `camera { pan: (x,y) }` |
|
|
311
|
+
| Reset | `camera reset` | — |
|
|
312
|
+
|
|
313
|
+
Shared modifiers: `zoom N` · `over <ms|s>` · `ease <easing>` · `pad N`.
|
|
314
|
+
Easings: `linear ease ease-in ease-out ease-in-out back-out anticipate spring` — plus `magic` (an alias for the tuned spring). `spring(…)` is accepted and animates with the tuned spring; `cubic-bezier(…)` is accepted syntax but currently falls back to the default `ease-in-out` (parameters for both are reserved for a future release).
|
|
315
|
+
|
|
316
|
+
### Reveal / hide
|
|
317
|
+
|
|
318
|
+
`reveal { show all }`, `reveal { hide legacy }`, `reveal { show .critical with pop }`. Visibility is **sticky** across beats until changed. Beat annotations are cleared at the start of each beat (unless in the always-on `annotate` block).
|
|
319
|
+
|
|
320
|
+
### Beat properties
|
|
321
|
+
|
|
322
|
+
`narrate: "caption"` (speaker note shown under the diagram), `hold: 2s` (dwell before auto-advance during Play).
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## 11. Overrides — where direct edits live
|
|
327
|
+
|
|
328
|
+
The layout engine positions nodes automatically. When you **drag, resize, add, rename, restyle, or delete elements directly on the canvas** (in the playground, or via the editor API — see INTEGRATION_GUIDE.md), those changes are written straight back into the source so the code stays the single source of truth. Geometry lands in an `overrides { … }` block:
|
|
329
|
+
|
|
330
|
+
```edd
|
|
331
|
+
scene {
|
|
332
|
+
layout dag { direction: down }
|
|
333
|
+
rect a "A"
|
|
334
|
+
rect b "B"
|
|
335
|
+
a --> b
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
overrides {
|
|
339
|
+
a at (120, 40) // pin a node's top-left corner (world units)
|
|
340
|
+
b at (120, 220) size (160, 90) // …and optionally its width/height
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
- Each entry is `id at (x, y)` with an optional `size (w, h)`. Coordinates are the node's top-left in world units; `id` may be any node — declared, auto-created from an edge, or Mermaid-imported.
|
|
345
|
+
- A pinned node keeps its coordinates; the layout still routes edges and places any *un-pinned* nodes around it.
|
|
346
|
+
- The block is **machine-managed**: dragging a node rewrites its entry, and the engine freezes the other nodes' current positions into the block at the same time so the layout doesn't reshuffle under you. You can hand-edit or delete entries freely — remove the whole block to hand control back to auto-layout.
|
|
347
|
+
- Restyle/rename/add/delete edits patch the node's declaration and edges in place (e.g. a fill swatch upserts `{ fill: green }`); only position/size use `overrides`.
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## 12. Diagnostics
|
|
352
|
+
|
|
353
|
+
The compiler recovers from errors and reports many at once, each with `line:col`, a stable code (`E-EDGE-NOOP`, `E-STMT`, `W-UNKNOWN-KEY`, …), and a hint. A bad statement never blanks the diagram — valid statements still render.
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## 13. LLM authoring tips
|
|
358
|
+
|
|
359
|
+
- Prefer the **keyword node form** (`cylinder db "…"`) and the **block form** for camera/annotations — they're unambiguous.
|
|
360
|
+
- One statement per line. Attributes are `{ key: value, … }` (commas or newlines both separate).
|
|
361
|
+
- Declare structure once in `scene`; reference ids everywhere else. Forward references are fine.
|
|
362
|
+
- Colors: use palette names (`green`, `blue`) for the Excalidraw look, or `#hex`.
|
|
363
|
+
- To animate a connector, add `animate: flow` (or use the `~>` glyph).
|
|
364
|
+
- To present, add a `timeline` of `beat`s with `camera focus …` — that's the differentiator.
|
|
365
|
+
|
|
366
|
+
See `examples/*.edd` for complete, runnable programs.
|
|
367
|
+
|
|
368
|
+
==============================================================================
|
|
369
|
+
|
|
370
|
+
# ═══ Camera & timeline (docs/CAMERA_AND_TIMELINE_GUIDE.md) ═══
|
|
371
|
+
|
|
372
|
+
# Camera & Timeline Guide
|
|
373
|
+
|
|
374
|
+
How EDodoDraw does "magic-move" — smooth, scriptable camera moves and step-by-step presentations.
|
|
375
|
+
|
|
376
|
+
## Architecture
|
|
377
|
+
|
|
378
|
+
The camera is a `{ cx, cy, zoom }` transform (world point centered in the viewport). It is applied as the SVG `transform` on the world `<g>`:
|
|
379
|
+
|
|
380
|
+
```
|
|
381
|
+
translate(vw/2, vh/2) · scale(zoom) · translate(-cx, -cy)
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
- **Math** — `src/engine/camera/fit.ts` (`cameraForBBox`: frame any bbox with padding).
|
|
385
|
+
- **Easing** — `src/engine/camera/easing.ts` (`linear ease ease-in ease-out ease-in-out back-out anticipate spring`; `spring` is a tuned overshoot for magic-move).
|
|
386
|
+
- **Controller** — `src/engine/camera/controller.ts` drives interruptible, retargetable rAF tweens. Position eases linearly; **zoom interpolates in log space** so fast/slow zooms feel natural. Duration auto-scales with travel distance + zoom ratio when not specified.
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
const controller = new CameraController(renderer);
|
|
390
|
+
await controller.fitAll(scene, { padding: 80 });
|
|
391
|
+
await controller.focus(scene, ["db", "cache"], { zoom: 1.7, easing: "spring" });
|
|
392
|
+
controller.zoomBy(1.2, screenPoint); // wheel zoom around cursor
|
|
393
|
+
controller.panByScreen(dx, dy); // drag pan
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Mid-flight `animateTo` retargets smoothly from the current interpolated state — no snap.
|
|
397
|
+
|
|
398
|
+
## Interaction (built into the canvas)
|
|
399
|
+
|
|
400
|
+
- **Wheel** → zoom around the cursor.
|
|
401
|
+
- **Drag** → pan.
|
|
402
|
+
- Any user gesture pauses the timeline player.
|
|
403
|
+
|
|
404
|
+
## Timeline player
|
|
405
|
+
|
|
406
|
+
`src/engine/timeline/player.ts` plays `scene.steps` (compiled from a `timeline { beat … }` block — see [DSL_LANGUAGE_GUIDE §10](DSL_LANGUAGE_GUIDE.md)). Each beat diffs against the running state:
|
|
407
|
+
|
|
408
|
+
| Channel | Rule |
|
|
409
|
+
|---|---|
|
|
410
|
+
| Camera | **Sticky** — a beat with no `camera` keeps the current one. |
|
|
411
|
+
| Visibility (`show`/`hide`) | **Sticky** until changed. |
|
|
412
|
+
| Annotations | **Beat-scoped** — replaced each beat (always-on `annotate` block persists underneath). |
|
|
413
|
+
|
|
414
|
+
API: `player.load(scene)`, `play()`, `pause()`, `next()`, `prev()`, `restart()`, `goto(i)`. It emits `PlayerState { index, total, caption, stepName, playing }` for the UI. Auto-advance uses each beat's `hold:` (default 3.2s).
|
|
415
|
+
|
|
416
|
+
```edd
|
|
417
|
+
timeline story {
|
|
418
|
+
beat overview "All" { camera fit-all over 800ms; narrate: "the system" }
|
|
419
|
+
beat focus "Detail" { camera focus [db, queue] zoom 1.7 ease spring; hold: 2s
|
|
420
|
+
annotate { spotlight [db, queue] { dim: 0.7 } } }
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
In the app, the bottom **player bar** shows Fit / Restart / Prev / Play / Next, the step indicator, and the caption. `⤢ Fit` reframes the whole diagram at any time.
|
|
425
|
+
|
|
426
|
+
==============================================================================
|
|
427
|
+
|
|
428
|
+
# ═══ Annotations (docs/ANNOTATIONS_GUIDE.md) ═══
|
|
429
|
+
|
|
430
|
+
# Annotations Guide
|
|
431
|
+
|
|
432
|
+
Annotations are the layer of **highlights, underlines, arrows, callouts, spotlights** on top of a diagram — authored in code **or** drawn live, unified under one model (`Annotation` in `src/engine/scene/types.ts`).
|
|
433
|
+
|
|
434
|
+
## One model, two sources
|
|
435
|
+
|
|
436
|
+
- **Scripted** — from a top-level `annotate { … }` block (always-on) or inside a timeline `beat` (beat-scoped). Compiled into `scene.annotations` / `step.annotations`.
|
|
437
|
+
- **Live** — drawn interactively by the user. Kept by `LiveAnnotationController` in a separate `live` layer.
|
|
438
|
+
|
|
439
|
+
Both are the same `Annotation` record and both render through `AnnotationLayer` (`src/engine/annotate/layer.ts`), which draws hand-drawn (rough.js) marks in **world space** so they track the camera and their anchored element.
|
|
440
|
+
|
|
441
|
+
### Anchoring
|
|
442
|
+
|
|
443
|
+
`Annotation.target` is a ref to a node/edge/group id (tracks that element), a set (`options.members`), or an absolute world point. When the camera moves or layout changes, the annotation follows because it lives in the transformed world layer and re-resolves its target bbox on render.
|
|
444
|
+
|
|
445
|
+
## Kinds
|
|
446
|
+
|
|
447
|
+
`highlight` (marker) · `underline` (solid/double/wavy) · `strike` · `box` (over a set, labelled) · `circle-mark` · `point-at` (hand-drawn pointer + label) · `callout` (leader + bubble) · `spotlight` (dims everything but the target via an SVG mask) · `note-marker` · `connector` (free arrow) · `sticky` (note). See the table in [DSL_LANGUAGE_GUIDE §9](DSL_LANGUAGE_GUIDE.md).
|
|
448
|
+
|
|
449
|
+
## Real-time editing
|
|
450
|
+
|
|
451
|
+
The floating toolbar (left of the canvas) selects a tool; `LiveAnnotationController` (`src/engine/annotate/interact.ts`) handles pointer events:
|
|
452
|
+
|
|
453
|
+
| Tool | Gesture | Result |
|
|
454
|
+
|---|---|---|
|
|
455
|
+
| Select | click a mark → select; drag → move; Delete → remove | edit existing |
|
|
456
|
+
| Highlight / Underline / Box / Circle | click an element | anchored annotation, spring reveal |
|
|
457
|
+
| Arrow | drag; endpoints snap to elements | `point-at` / free connector |
|
|
458
|
+
| Text | click | sticky note (inline text input) |
|
|
459
|
+
|
|
460
|
+
Undo/redo (`⌘Z` / `⇧⌘Z`) is a snapshot stack. All interaction is smooth and reversible; nothing mutates the source until you commit.
|
|
461
|
+
|
|
462
|
+
## Round-trip: commit to code
|
|
463
|
+
|
|
464
|
+
The **⤓ code** button serializes live annotations back into an `annotate "live" { … }` block and appends it to the editor, then clears the live layer — so what you drew becomes part of the program and re-renders as scripted:
|
|
465
|
+
|
|
466
|
+
```edd
|
|
467
|
+
annotate "live" {
|
|
468
|
+
highlight idea { color: yellow }
|
|
469
|
+
underline vibe { color: #1971c2 }
|
|
470
|
+
circle-mark edit { color: #e8590c }
|
|
471
|
+
point-at hello { from: s, color: #1971c2 }
|
|
472
|
+
}
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Element-anchored annotations serialize exactly; free-form marks (a floating arrow with no element under either end) are emitted as a comment noting they aren't representable by id. See `LiveAnnotationController.commitToCode` and `tests/annotations.test.ts` for the round-trip contract.
|
|
476
|
+
|
|
477
|
+
## Animated arrows
|
|
478
|
+
|
|
479
|
+
The connector animations (`flow`, `dash-march`, `draw-on`, `comet`, `gradient-flow`, `electric`, `pulse`) are a CSS overlay path over the hand-drawn stroke — see `animationOverlay` in `src/engine/render/edges.ts` and the keyframes in `src/engine/render/theme.css.ts`. Set on any edge with `animate:` or the `~>` glyph.
|
|
480
|
+
|
|
481
|
+
==============================================================================
|
|
482
|
+
|
|
483
|
+
# ═══ Import & export (docs/IMPORT_AND_EXPORT_GUIDE.md) ═══
|
|
484
|
+
|
|
485
|
+
# Import & Export Guide
|
|
486
|
+
|
|
487
|
+
## Mermaid import
|
|
488
|
+
|
|
489
|
+
EDodoDraw imports raw Mermaid and re-renders it in the hand-drawn style, **preserving node ids** so you can choreograph and annotate the result.
|
|
490
|
+
|
|
491
|
+
```edd
|
|
492
|
+
mermaid """
|
|
493
|
+
flowchart LR
|
|
494
|
+
app[App Servers] --> otel[OTel Collector]
|
|
495
|
+
otel --> prom[Prometheus]
|
|
496
|
+
prom --> grafana[Grafana]
|
|
497
|
+
"""
|
|
498
|
+
|
|
499
|
+
timeline walkthrough {
|
|
500
|
+
beat collect "Collection" {
|
|
501
|
+
camera focus [app, otel] zoom 1.7 // references the mermaid ids
|
|
502
|
+
annotate { point-at otel "single ingestion point" { from: s } }
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### How it works
|
|
508
|
+
|
|
509
|
+
`src/engine/import/mermaid.ts` wraps `@excalidraw/mermaid-to-excalidraw`. Mermaid rendering needs the browser, so import is **async and app-side**:
|
|
510
|
+
|
|
511
|
+
1. `extractMermaidBlocks(source)` pulls the `mermaid """ … """` bodies (pure string fn — unit-testable).
|
|
512
|
+
2. `convertMermaid(body)` runs the Mermaid → Excalidraw-skeleton converter and maps each element to Scene IR: vertices → pinned nodes (mermaid id preserved), arrows → edges bound by `start.id`/`end.id`.
|
|
513
|
+
3. `injectMermaid(scene, fragment)` merges the fragment into the compiled scene, so `id:::class` re-tags and timeline/annotate references resolve against the imported nodes.
|
|
514
|
+
|
|
515
|
+
Supported: flowcharts (and other mermaid graph types the converter emits as elements). The synchronous DSL compiler stays DOM-free; only the app awaits conversion.
|
|
516
|
+
|
|
517
|
+
## Export
|
|
518
|
+
|
|
519
|
+
Toolbar (top-right): **SVG · PNG · JSON · Copy**. Implemented in `src/engine/export.ts`.
|
|
520
|
+
|
|
521
|
+
| Format | Notes |
|
|
522
|
+
|---|---|
|
|
523
|
+
| **SVG** | Standalone, framed to the content bbox (independent of the current camera). The hand-drawn font is **embedded as base64**, so the file renders correctly anywhere. |
|
|
524
|
+
| **PNG** | The SVG rasterized to a 2× canvas. |
|
|
525
|
+
| **JSON** | The full `Scene` IR — round-trippable, inspectable. |
|
|
526
|
+
| **Copy** | The `.edd` source to the clipboard. |
|
|
527
|
+
|
|
528
|
+
```ts
|
|
529
|
+
await downloadSVG(renderer, scene);
|
|
530
|
+
await downloadPNG(renderer, scene);
|
|
531
|
+
downloadJSON(scene);
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Screen-space overlays (player bar, selection outlines, hidden elements) are stripped from exports; the background is the scene/theme background.
|
|
535
|
+
|
|
536
|
+
==============================================================================
|
|
537
|
+
|
|
538
|
+
# ═══ Embed in your app (docs/INTEGRATION_GUIDE.md) ═══
|
|
539
|
+
|
|
540
|
+
# Integrating EDodoDraw
|
|
541
|
+
|
|
542
|
+
How to embed the `edododraw` engine in your own app — vanilla JS, any framework,
|
|
543
|
+
React, or a server. Everything here is checked against the published API in
|
|
544
|
+
`src/lib/` and `src/engine/`.
|
|
545
|
+
|
|
546
|
+
> Want to _extend_ the engine (new shapes, animations, layouts)? See
|
|
547
|
+
> [EXTENDING_GUIDE.md](EXTENDING_GUIDE.md). For the diagram source language, see
|
|
548
|
+
> [DSL_LANGUAGE_GUIDE.md](DSL_LANGUAGE_GUIDE.md).
|
|
549
|
+
|
|
550
|
+
---
|
|
551
|
+
|
|
552
|
+
## 1. Install
|
|
553
|
+
|
|
554
|
+
```bash
|
|
555
|
+
npm i edododraw
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
- **Runtime dependencies install automatically:** `roughjs` (hand-drawn strokes)
|
|
559
|
+
and `@dagrejs/dagre` (auto-layout).
|
|
560
|
+
- **Mermaid import** (`@excalidraw/mermaid-to-excalidraw`) ships as an
|
|
561
|
+
_optionalDependency_: it's installed by default, but install won't fail if it's
|
|
562
|
+
unavailable, and it's **lazy-loaded** only when a `mermaid` block is actually
|
|
563
|
+
rendered. See [§6](#6-mermaid-note).
|
|
564
|
+
- **React is optional.** It's a _peer_ dependency needed only for the
|
|
565
|
+
`edododraw/react` entry. For the core `edododraw` entry you don't need React at
|
|
566
|
+
all.
|
|
567
|
+
|
|
568
|
+
The package is **ESM-only** (`"type": "module"`, `exports` expose `import` only)
|
|
569
|
+
and targets **Node ≥ 18**. Two entry points:
|
|
570
|
+
|
|
571
|
+
| Import | What you get |
|
|
572
|
+
|---|---|
|
|
573
|
+
| `edododraw` | The core: `EdodoDraw` facade + the whole engine (`compileEdd`, `SvgRenderer`, `registerShape`, Scene IR types, …). |
|
|
574
|
+
| `edododraw/react` | `EdodoDrawView` React component (thin wrapper over the facade). |
|
|
575
|
+
|
|
576
|
+
---
|
|
577
|
+
|
|
578
|
+
## 2. Quick start (vanilla / any framework)
|
|
579
|
+
|
|
580
|
+
Give the engine a sized container element and hand it EDodoDraw source. The
|
|
581
|
+
`EdodoDraw` facade mounts an `<svg>`, compiles + renders, and exposes camera,
|
|
582
|
+
timeline, live annotations, and export behind one object.
|
|
583
|
+
|
|
584
|
+
```html
|
|
585
|
+
<!doctype html>
|
|
586
|
+
<div id="diagram" style="width: 100%; height: 500px;"></div>
|
|
587
|
+
<script type="module">
|
|
588
|
+
import { EdodoDraw } from "edododraw";
|
|
589
|
+
|
|
590
|
+
const el = document.getElementById("diagram");
|
|
591
|
+
const edd = new EdodoDraw(el, { interactive: true });
|
|
592
|
+
|
|
593
|
+
await edd.render(`
|
|
594
|
+
scene {
|
|
595
|
+
a[Client] --> b[API] --> c[(Database)]
|
|
596
|
+
}
|
|
597
|
+
timeline {
|
|
598
|
+
beat intro "Overview" { camera fit-all, reveal { show a } }
|
|
599
|
+
beat call "Trace the call" { camera focus b zoom 1.4, reveal { show b, c } }
|
|
600
|
+
}
|
|
601
|
+
`);
|
|
602
|
+
|
|
603
|
+
edd.play(); // drives the timeline (magic-move). No-op if the source has no timeline.
|
|
604
|
+
</script>
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
The **container must have a height** — the facade sets `position` and
|
|
608
|
+
`overflow: hidden` for you but never a size, and `autoFit` needs a measurable
|
|
609
|
+
viewport. A `0px`-tall div renders nothing.
|
|
610
|
+
|
|
611
|
+
`render()` is async (a `mermaid` block may need the lazy runtime) and safe to call
|
|
612
|
+
on every keystroke — stale runs are discarded. It resolves to
|
|
613
|
+
`{ scene, diagnostics }`.
|
|
614
|
+
|
|
615
|
+
---
|
|
616
|
+
|
|
617
|
+
## 3. `EdodoDraw` API reference
|
|
618
|
+
|
|
619
|
+
```ts
|
|
620
|
+
import { EdodoDraw } from "edododraw";
|
|
621
|
+
new EdodoDraw(container: HTMLElement, options?: EdodoDrawOptions)
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
### Options (`EdodoDrawOptions`)
|
|
625
|
+
|
|
626
|
+
| Option | Type | Default | Effect |
|
|
627
|
+
|---|---|---|---|
|
|
628
|
+
| `interactive` | `boolean` | `true` | Enable pan (drag), zoom (wheel), and live-annotation pointer/keyboard handling. Set `false` for a static embed. |
|
|
629
|
+
| `grid` | `boolean` | `true` | Draw a dotted grid that pans/zooms with the camera (a CSS background on the container). |
|
|
630
|
+
| `autoFit` | `boolean` | `true` | Fit the whole diagram into view after each `render()`. |
|
|
631
|
+
| `padding` | `number` | `80` | Screen-px padding used when fitting. |
|
|
632
|
+
|
|
633
|
+
### Render / scene
|
|
634
|
+
|
|
635
|
+
| Method | Signature | Notes |
|
|
636
|
+
|---|---|---|
|
|
637
|
+
| `render` | `(source: string) => Promise<RenderResult>` | Compile + render. `RenderResult = { scene: Scene; diagnostics: Diagnostic[] }`. |
|
|
638
|
+
| `getScene` | `() => Scene` | The current rendered scene (Scene IR). |
|
|
639
|
+
| `getSource` | `() => string` | The last source string passed to `render`. |
|
|
640
|
+
|
|
641
|
+
### Camera
|
|
642
|
+
|
|
643
|
+
| Method | Signature | Notes |
|
|
644
|
+
|---|---|---|
|
|
645
|
+
| `fit` | `(animate?: boolean) => void` | Fit the whole diagram (default `animate = true`). |
|
|
646
|
+
| `focus` | `(ids: string[], opts?: { zoom?: number; padding?: number }) => Promise<void>` | Animate to frame the given node/group/edge ids. |
|
|
647
|
+
| `zoomBy` | `(factor: number) => void` | Multiply zoom around the viewport centre (e.g. `1.2` in, `0.8` out). |
|
|
648
|
+
| `reset` | `() => void` | Animate back to fit-all. |
|
|
649
|
+
| `camera` | `get camera(): CameraController` | The underlying controller for advanced moves (`animateTo`, `panByScreen`, …). |
|
|
650
|
+
|
|
651
|
+
### Timeline (magic-move presentation)
|
|
652
|
+
|
|
653
|
+
Only meaningful when the source contains a `timeline { … }` — otherwise these
|
|
654
|
+
no-op. State changes emit via `on("state", …)`.
|
|
655
|
+
|
|
656
|
+
| Method | Signature | Notes |
|
|
657
|
+
|---|---|---|
|
|
658
|
+
| `play` | `() => void` | Auto-advance through beats. No-op if the scene has no steps. |
|
|
659
|
+
| `pause` | `() => void` | Stop auto-advance. |
|
|
660
|
+
| `next` / `prev` | `() => void` | Step forward / back one beat. |
|
|
661
|
+
| `goto` | `(i: number) => void` | Jump to beat index `i` (0-based). |
|
|
662
|
+
| `restart` | `() => void` | Go to the first beat. |
|
|
663
|
+
| `timeline` | `get timeline(): TimelinePlayer` | The underlying player (`hasTimeline`, current index, …). |
|
|
664
|
+
|
|
665
|
+
### Tools — direct editing & annotation
|
|
666
|
+
|
|
667
|
+
`setTool` selects the active pointer tool and switches the engine between its two
|
|
668
|
+
interactive **modes**. All of this requires `interactive: true`.
|
|
669
|
+
|
|
670
|
+
| Method | Signature | Notes |
|
|
671
|
+
|---|---|---|
|
|
672
|
+
| `setTool` | `(tool: string) => void` | **Edit tools** (mode `edit`): `"select"`, `"hand"`, `"rect"`, `"ellipse"`, `"diamond"`, `"text"`, `"arrow"`. **Annotate tools** (mode `annotate`): `"highlight"`, `"underline"`, `"mark-box"`, `"mark-circle"`, `"point"`, `"note"`. |
|
|
673
|
+
|
|
674
|
+
**Direct editing (canvas → code).** In `edit` mode, pointer gestures manipulate the
|
|
675
|
+
diagram and every change is patched back into the source (emitted as the `"edit"`
|
|
676
|
+
event — see below), keeping the code the single source of truth:
|
|
677
|
+
|
|
678
|
+
- **select / move** — click a node to select (8-handle box); drag to move.
|
|
679
|
+
- **resize** — drag a handle. Move/resize write an `overrides { … }` block (see DSL_LANGUAGE_GUIDE §11).
|
|
680
|
+
- **rename** — double-click a node, type, `Enter`.
|
|
681
|
+
- **add** — pick `rect`/`ellipse`/`diamond`/`text` and drag on empty canvas; `arrow` drags between two nodes to connect them.
|
|
682
|
+
- **delete** — `Delete`/`Backspace` on the selection.
|
|
683
|
+
|
|
684
|
+
| Method | Signature | Notes |
|
|
685
|
+
|---|---|---|
|
|
686
|
+
| `applyStyle` | `(id, { fill?, stroke?, shape? }) => void` | Upsert style attrs on a node's declaration (e.g. `{ fill: green }`). |
|
|
687
|
+
| `renameSelected` | `() => void` | Open the rename field for the current selection. |
|
|
688
|
+
| `deleteSelected` | `() => void` | Delete the selection (node decl + its edges + its override). |
|
|
689
|
+
| `fitNext` | `() => void` | Fit the whole diagram on the next render (e.g. after loading a new document). |
|
|
690
|
+
| `editor` | `get editor(): EditController` | The underlying edit controller (`clearSelection`, `getTool`, …). |
|
|
691
|
+
|
|
692
|
+
### Live annotations
|
|
693
|
+
|
|
694
|
+
| Method | Signature | Notes |
|
|
695
|
+
|---|---|---|
|
|
696
|
+
| `undo` / `redo` | `() => void` | Undo/redo live annotation edits. |
|
|
697
|
+
| `clearAnnotations` | `() => void` | Remove all live annotations. |
|
|
698
|
+
| `annotationsToCode` | `() => string` | Serialize live annotations back to an `annotate { … }` DSL block. |
|
|
699
|
+
| `annotator` | `get annotator(): LiveAnnotationController` | The underlying controller. |
|
|
700
|
+
|
|
701
|
+
### Export
|
|
702
|
+
|
|
703
|
+
| Method | Signature | Notes |
|
|
704
|
+
|---|---|---|
|
|
705
|
+
| `toSVG` | `() => Promise<string>` | Standalone SVG string with the hand-drawn font **embedded** (renders offline). |
|
|
706
|
+
| `toPNG` | `() => Promise<Blob>` | Rasterised PNG (2× by default). |
|
|
707
|
+
| `toJSON` | `() => Scene` | The Scene IR (the same object `getScene` returns). |
|
|
708
|
+
| `downloadSVG` / `downloadPNG` | `() => Promise<void>` | Trigger a browser download. |
|
|
709
|
+
| `downloadJSON` | `() => void` | Download the Scene IR as JSON. |
|
|
710
|
+
|
|
711
|
+
### Lifecycle
|
|
712
|
+
|
|
713
|
+
| Method | Signature | Notes |
|
|
714
|
+
|---|---|---|
|
|
715
|
+
| `resize` | `() => void` | Re-measure the container and re-apply the camera. Called automatically on container resize when `interactive: true` (via `ResizeObserver`); call it yourself otherwise. |
|
|
716
|
+
| `destroy` | `() => void` | Remove listeners, the SVG, and the timeline; clears event subscribers. Always call on teardown. |
|
|
717
|
+
|
|
718
|
+
### Events — `on(event, cb): () => void`
|
|
719
|
+
|
|
720
|
+
`on` returns an **unsubscribe** function. Six events:
|
|
721
|
+
|
|
722
|
+
| Event | Payload | Fires when |
|
|
723
|
+
|---|---|---|
|
|
724
|
+
| `"render"` | `RenderResult` `{ scene, diagnostics }` | After each successful `render()`. |
|
|
725
|
+
| `"diagnostics"` | `Diagnostic[]` | After each `render()`, with compile diagnostics. |
|
|
726
|
+
| `"state"` | `PlayerState` `{ index, total, caption, playing, stepName }` | Timeline position/playing changes. |
|
|
727
|
+
| `"live"` | `LiveState` `{ tool, count, canUndo, canRedo, selected }` | Live-annotation tool/selection/undo state changes. |
|
|
728
|
+
| `"edit"` | `source: string` | A direct-manipulation edit patched the source. Re-render with this new source (and mirror it into your code editor). |
|
|
729
|
+
| `"editstate"` | `EditState` `{ tool, selected }` | Edit-mode tool or node selection changes (drive a property panel / toolbar highlight). |
|
|
730
|
+
|
|
731
|
+
A `Diagnostic` has `{ severity: "error"|"warning"|"info", code, message, line, col,
|
|
732
|
+
start, end, expected?, found?, hint? }` (see `src/engine/dsl/diagnostics.ts`).
|
|
733
|
+
|
|
734
|
+
```ts
|
|
735
|
+
const off = edd.on("diagnostics", (diags) => {
|
|
736
|
+
const errors = diags.filter((d) => d.severity === "error");
|
|
737
|
+
if (errors.length) console.warn(errors.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
|
738
|
+
});
|
|
739
|
+
// later: off(); // unsubscribe
|
|
740
|
+
edd.on("state", (s) => console.log(`beat ${s.index + 1}/${s.total} — ${s.stepName}`));
|
|
741
|
+
|
|
742
|
+
// Direct-edit round-trip: the diagram is editable, and edits flow back to code.
|
|
743
|
+
let source = "scene { rect a \"A\"\n a --> b }";
|
|
744
|
+
edd.on("edit", (next) => {
|
|
745
|
+
source = next; // keep your source of truth in sync…
|
|
746
|
+
void edd.render(next); // …and re-render (also mirror `next` into your code editor)
|
|
747
|
+
});
|
|
748
|
+
edd.setTool("select"); // enable move/resize/rename/delete on the canvas
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
---
|
|
752
|
+
|
|
753
|
+
## 4. React
|
|
754
|
+
|
|
755
|
+
`edododraw/react` exports `EdodoDrawView`, a thin wrapper that owns an `EdodoDraw`
|
|
756
|
+
instance for the lifetime of the component.
|
|
757
|
+
|
|
758
|
+
```tsx
|
|
759
|
+
import { EdodoDrawView } from "edododraw/react";
|
|
760
|
+
import type { EdodoDraw } from "edododraw/react";
|
|
761
|
+
|
|
762
|
+
export function Diagram() {
|
|
763
|
+
return (
|
|
764
|
+
<div style={{ height: 500 }}>
|
|
765
|
+
<EdodoDrawView
|
|
766
|
+
source={`scene { a[Hello] --> b[World] }`}
|
|
767
|
+
interactive
|
|
768
|
+
grid={false}
|
|
769
|
+
style={{ borderRadius: 8 }}
|
|
770
|
+
onReady={(edd: EdodoDraw) => edd.on("state", (s) => console.log(s.stepName))}
|
|
771
|
+
onDiagnostics={(diags) => console.log(diags)}
|
|
772
|
+
onState={(state) => console.log(state.index)}
|
|
773
|
+
/>
|
|
774
|
+
</div>
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
### Props (`EdodoDrawViewProps`)
|
|
780
|
+
|
|
781
|
+
`EdodoDrawViewProps` extends `EdodoDrawOptions` (so `interactive`, `grid`,
|
|
782
|
+
`autoFit`, `padding` are all valid props), plus:
|
|
783
|
+
|
|
784
|
+
| Prop | Type | Notes |
|
|
785
|
+
|---|---|---|
|
|
786
|
+
| `source` | `string` (required) | EDodoDraw source. Re-renders whenever it changes. |
|
|
787
|
+
| `className` | `string` | On the host `<div>`. |
|
|
788
|
+
| `style` | `CSSProperties` | Merged onto the host (which defaults to `width/height: 100%`). |
|
|
789
|
+
| `onReady` | `(edd: EdodoDraw) => void` | Called **once** after mount with the imperative instance — use it to subscribe to events or grab a ref for camera/export. |
|
|
790
|
+
| `onDiagnostics` | `(diags: Diagnostic[]) => void` | Wired to the `"diagnostics"` event. |
|
|
791
|
+
| `onState` | `(state: PlayerState) => void` | Wired to the `"state"` event. |
|
|
792
|
+
|
|
793
|
+
The host `<div>` fills its parent (`width/height: 100%`), so wrap it in a **sized**
|
|
794
|
+
element. Note the `EdodoDrawOptions` props (`interactive`, `grid`, …) are read
|
|
795
|
+
**once at mount**; only `source` is reactive. To change options at runtime, remount
|
|
796
|
+
(e.g. via a React `key`).
|
|
797
|
+
|
|
798
|
+
---
|
|
799
|
+
|
|
800
|
+
## 5. Low-level engine usage (SSR / Node-safe)
|
|
801
|
+
|
|
802
|
+
The DSL compiler is **synchronous and DOM-free**, so you can compile and validate a
|
|
803
|
+
diagram anywhere — Node, an edge function, a build step — without a browser:
|
|
804
|
+
|
|
805
|
+
```ts
|
|
806
|
+
import { compileEdd } from "edododraw";
|
|
807
|
+
|
|
808
|
+
const { scene, diagnostics, report } = compileEdd(`scene { a -> b -> c }`);
|
|
809
|
+
// scene : the Scene IR (nodes/edges/steps/…), JSON-serializable
|
|
810
|
+
// diagnostics : a DiagnosticBag — diagnostics.items is Diagnostic[], .hasErrors, .errors
|
|
811
|
+
// report : string[] — human-readable, source-annotated diagnostic blocks
|
|
812
|
+
|
|
813
|
+
if (diagnostics.hasErrors) throw new Error(report.join("\n\n"));
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
> `compileEdd` returns `diagnostics` as a **`DiagnosticBag`** (use `.items`,
|
|
817
|
+
> `.hasErrors`, `.errors`). This differs from the facade's `render()`, whose
|
|
818
|
+
> `diagnostics` is already a flat `Diagnostic[]`.
|
|
819
|
+
|
|
820
|
+
The full engine is re-exported from `edododraw` for building your own rendering
|
|
821
|
+
pipeline instead of using the `EdodoDraw` facade:
|
|
822
|
+
|
|
823
|
+
```ts
|
|
824
|
+
import {
|
|
825
|
+
compileEdd, applyLayout,
|
|
826
|
+
SvgRenderer, CameraController, TimelinePlayer,
|
|
827
|
+
AnnotationLayer, LiveAnnotationController,
|
|
828
|
+
cameraForBBox, sceneBBox,
|
|
829
|
+
exportSVGString, exportPNGBlob,
|
|
830
|
+
registerShape, ensureEngineStyles,
|
|
831
|
+
} from "edododraw";
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
For example, a minimal custom composition (browser): compile → new `SvgRenderer`
|
|
835
|
+
→ `renderer.render(scene)` → drive a `CameraController` yourself. The `EdodoDraw`
|
|
836
|
+
facade in `src/lib/EdodoDraw.ts` is the reference for how these compose.
|
|
837
|
+
|
|
838
|
+
---
|
|
839
|
+
|
|
840
|
+
## 6. Mermaid note
|
|
841
|
+
|
|
842
|
+
You can embed raw Mermaid inside EDodoDraw source:
|
|
843
|
+
|
|
844
|
+
```edd
|
|
845
|
+
scene {
|
|
846
|
+
mermaid """
|
|
847
|
+
flowchart LR
|
|
848
|
+
A[Start] --> B{Choice}
|
|
849
|
+
B -->|yes| C[Do it]
|
|
850
|
+
"""
|
|
851
|
+
}
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
- The Mermaid engine (`@excalidraw/mermaid-to-excalidraw`) is an **optional
|
|
855
|
+
dependency, lazy-loaded** via dynamic `import()` on the **first** `mermaid` block
|
|
856
|
+
rendered — so apps that never use Mermaid don't pay for it (it's heavy).
|
|
857
|
+
- The facade's `render()` extracts `mermaid """ … """` blocks, converts each, and
|
|
858
|
+
injects the resulting nodes/edges into the scene.
|
|
859
|
+
- **If the package isn't installed**, the lazy import fails; the facade catches it
|
|
860
|
+
and reports a diagnostic (code `M-PARSE`) for that block. The rest of the diagram
|
|
861
|
+
still renders — `import { EdodoDraw } from "edododraw"` stays fully functional.
|
|
862
|
+
Install it explicitly if you need Mermaid: `npm i @excalidraw/mermaid-to-excalidraw`.
|
|
863
|
+
- Mermaid conversion runs in the **browser only** (it renders to a hidden SVG). The
|
|
864
|
+
pure `compileEdd` path stays synchronous and ignores Mermaid runtime.
|
|
865
|
+
|
|
866
|
+
Helpers `convertMermaid`, `extractMermaidBlocks`, and `injectMermaid` are also
|
|
867
|
+
exported from `edododraw` if you want to drive the import yourself. See
|
|
868
|
+
[IMPORT_AND_EXPORT_GUIDE.md](IMPORT_AND_EXPORT_GUIDE.md).
|
|
869
|
+
|
|
870
|
+
---
|
|
871
|
+
|
|
872
|
+
## 7. SSR / bundler notes
|
|
873
|
+
|
|
874
|
+
- **The facade needs a DOM.** `new EdodoDraw(el)` and its methods use browser globals
|
|
875
|
+
— `document`, `window`, `getComputedStyle`, `ResizeObserver`, `requestAnimationFrame`
|
|
876
|
+
/ `cancelAnimationFrame` (camera animation), and `XMLSerializer` (SVG export).
|
|
877
|
+
Construct and use it in the browser only. In React, create it inside `useEffect`
|
|
878
|
+
(which is exactly what `EdodoDrawView` does), never during render or on the server.
|
|
879
|
+
- **`compileEdd` is DOM-free.** Use it for server-side validation, computing a scene
|
|
880
|
+
ahead of time, or CI checks — no browser or jsdom required.
|
|
881
|
+
- **Fonts are self-contained.** The hand-drawn font (Excalifont/Virgil) is embedded
|
|
882
|
+
as a base64 `@font-face` in the engine's injected CSS and in every exported SVG —
|
|
883
|
+
**no external font file or CDN is needed**. The live canvas injects this CSS once
|
|
884
|
+
per document via `ensureEngineStyles()` (the facade calls it automatically on
|
|
885
|
+
mount). The UI/code fonts (Nunito, Cascadia) are best-effort from `/fonts` with
|
|
886
|
+
system fallbacks and only matter inside the playground app.
|
|
887
|
+
- **ESM only.** There is no CommonJS build; use `import` (or dynamic `import()`),
|
|
888
|
+
not `require`. Any modern bundler (Vite, webpack 5, esbuild, Rollup, Next.js)
|
|
889
|
+
resolves the `edododraw` / `edododraw/react` subpath exports directly.
|
|
890
|
+
- **Tree-shaking + built-in shapes.** Built-in plugin shapes (e.g. `star`) are
|
|
891
|
+
registered from the renderer on `mount()` (via `registerBuiltinShapes()`), so they
|
|
892
|
+
survive a tree-shaking library build and are always available once you render.
|
|
893
|
+
`package.json` also lists the `builtins` files under `sideEffects` for the eager
|
|
894
|
+
registration path. Your own `registerShape(...)` calls run whenever their module is
|
|
895
|
+
imported — import that module before you render.
|
|
896
|
+
|
|
897
|
+
---
|
|
898
|
+
|
|
899
|
+
## 8. Troubleshooting
|
|
900
|
+
|
|
901
|
+
| Symptom | Likely cause & fix |
|
|
902
|
+
|---|---|
|
|
903
|
+
| Blank canvas, nothing renders | Container has no height. Give it an explicit size (`height: 500px`); the facade sets position/overflow but never a size. |
|
|
904
|
+
| `document is not defined` / crash on the server | The facade needs a DOM. Only construct `EdodoDraw` in the browser; use `compileEdd` for SSR/Node. |
|
|
905
|
+
| `edd.play()` does nothing | The source has no `timeline { … }` — the player no-ops without steps. Add beats, or drive the camera directly with `focus`/`fit`. |
|
|
906
|
+
| Mermaid block shows an `M-PARSE` diagnostic | `@excalidraw/mermaid-to-excalidraw` isn't installed. Run `npm i @excalidraw/mermaid-to-excalidraw`. |
|
|
907
|
+
| Wheel/scroll is hijacked by the diagram | `interactive: true` binds wheel-zoom with `preventDefault`. Use `interactive: false` for a static embed. |
|
|
908
|
+
| React: changing `interactive`/`grid` prop has no effect | Options are read once at mount; only `source` is reactive. Remount (e.g. change the component `key`). |
|
|
909
|
+
| Live-annotation tools do nothing | Live tools require `interactive: true`. |
|
|
910
|
+
| Camera/timeline "jumps" instead of animating | You called an immediate variant (`fit(false)`), or `autoFit` refit after a `render()`. Use `focus(...)`/`fit(true)` for animated moves. |
|
|
911
|
+
| Old instance leaks / duplicate SVGs after re-mount | Call `edd.destroy()` on teardown (the React wrapper does this for you). |
|
|
912
|
+
|
|
913
|
+
---
|
|
914
|
+
|
|
915
|
+
See also: [ARCHITECTURE.md](ARCHITECTURE.md) ·
|
|
916
|
+
[DSL_LANGUAGE_GUIDE.md](DSL_LANGUAGE_GUIDE.md) ·
|
|
917
|
+
[CAMERA_AND_TIMELINE_GUIDE.md](CAMERA_AND_TIMELINE_GUIDE.md) ·
|
|
918
|
+
[ANNOTATIONS_GUIDE.md](ANNOTATIONS_GUIDE.md) ·
|
|
919
|
+
[IMPORT_AND_EXPORT_GUIDE.md](IMPORT_AND_EXPORT_GUIDE.md) ·
|
|
920
|
+
[EXTENDING_GUIDE.md](EXTENDING_GUIDE.md)
|
|
921
|
+
|
|
922
|
+
==============================================================================
|
|
923
|
+
|
|
924
|
+
# ═══ Extend & make plugins (docs/EXTENDING_GUIDE.md) ═══
|
|
925
|
+
|
|
926
|
+
# Extending EDodoDraw
|
|
927
|
+
|
|
928
|
+
How to add new capabilities to the engine: custom **shapes**, **animated arrows**,
|
|
929
|
+
**annotation kinds**, **layouts**, and **DSL constructs**. This guide is for people
|
|
930
|
+
working _inside_ the repository (or forking it) — every example is copy-paste
|
|
931
|
+
accurate against the current source.
|
|
932
|
+
|
|
933
|
+
> New to the codebase? Read [ARCHITECTURE.md](ARCHITECTURE.md) first, then
|
|
934
|
+
> [DEVELOPMENT_STANDARDS.md](DEVELOPMENT_STANDARDS.md). To _embed_ EDodoDraw in an
|
|
935
|
+
> app (rather than extend it), see [INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md).
|
|
936
|
+
|
|
937
|
+
---
|
|
938
|
+
|
|
939
|
+
## 1. The extension philosophy
|
|
940
|
+
|
|
941
|
+
EDodoDraw is designed to be extended without rewriting the core. Two ideas make
|
|
942
|
+
that possible:
|
|
943
|
+
|
|
944
|
+
- **The Scene IR is the contract.** Every producer (the DSL compiler, the Mermaid
|
|
945
|
+
importer, a programmatic caller) emits a `Scene`; the renderer and controllers
|
|
946
|
+
only consume a `Scene`. See `src/engine/scene/types.ts`. If your extension can be
|
|
947
|
+
expressed as data on the `Scene`, everything downstream already works.
|
|
948
|
+
- **Open unions + registries.** The presentation-level enums are _open_ string
|
|
949
|
+
unions: `ShapeKind`, `ArrowheadKind`, `ArrowAnimationKind`, `AnnotationKind`,
|
|
950
|
+
and `LayoutKind` all end in `| (string & {})` (except `LayoutKind`, whose
|
|
951
|
+
dispatch has a grid fallback). That means the parser and compiler already accept
|
|
952
|
+
names they've never seen — an unknown value flows through the pipeline untouched
|
|
953
|
+
and is _resolved at the last moment_ by a renderer switch or a registry lookup.
|
|
954
|
+
|
|
955
|
+
The practical consequence: **most extensions need no grammar change.** You add a
|
|
956
|
+
name and teach one switch (or one registry) how to draw it.
|
|
957
|
+
|
|
958
|
+
There are two flavours of extension:
|
|
959
|
+
|
|
960
|
+
| Flavour | Mechanism | Needs a repo edit? | Example |
|
|
961
|
+
|---|---|---|---|
|
|
962
|
+
| **Registry** (runtime) | `registerShape(name, fn)` | No — callable from any app | Custom shapes |
|
|
963
|
+
| **Switch** (compile-time) | add a `case` to a `switch` | Yes — you edit engine source | Animations, annotations, layouts |
|
|
964
|
+
|
|
965
|
+
Shapes are the only fully-runtime seam today; the rest are small, well-isolated
|
|
966
|
+
`switch` additions.
|
|
967
|
+
|
|
968
|
+
---
|
|
969
|
+
|
|
970
|
+
## 2. Add a custom shape
|
|
971
|
+
|
|
972
|
+
Shapes resolve through a **plugin registry** (`src/engine/plugins/registry.ts`).
|
|
973
|
+
You register a function under a name; the renderer calls it whenever a node's
|
|
974
|
+
`shape` matches. No grammar change is needed — `mapShape()` in
|
|
975
|
+
`src/engine/dsl/lower.ts` returns any unknown shape name as-is, and
|
|
976
|
+
`renderShapeBody()` (`src/engine/render/shapes.ts`) falls back to the registry in
|
|
977
|
+
its `default:` case:
|
|
978
|
+
|
|
979
|
+
```ts
|
|
980
|
+
// src/engine/render/shapes.ts (default branch of renderShapeBody)
|
|
981
|
+
default: {
|
|
982
|
+
const plugin = getShapePlugin(shape);
|
|
983
|
+
if (plugin) return plugin(rc, rect, style);
|
|
984
|
+
// unknown shape -> rounded rectangle so nothing silently disappears
|
|
985
|
+
}
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
### The plugin contract
|
|
989
|
+
|
|
990
|
+
```ts
|
|
991
|
+
export type ShapePluginFn = (rc: RoughSVG, rect: ShapeRect, style: NodeStyle) => SVGGElement;
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
- **`rc`** — a rough.js SVG generator (`ReturnType<typeof rough.svg>`). Use
|
|
995
|
+
`rc.polygon`, `rc.path`, `rc.ellipse`, `rc.rectangle`, `rc.circle`, `rc.line`,
|
|
996
|
+
`rc.linearPath`, `rc.curve` — each returns an `SVGGElement` (or `SVGPathElement`)
|
|
997
|
+
you append.
|
|
998
|
+
- **`rect`** — the node box as **top-left `x`/`y` plus `w`/`h`** (`ShapeRect`), in
|
|
999
|
+
world coordinates. There is no separate transform: draw directly at these
|
|
1000
|
+
absolute coordinates. The centre is `(rect.x + rect.w/2, rect.y + rect.h/2)`.
|
|
1001
|
+
- **`style`** — the node's resolved `NodeStyle` (stroke, fill, `fillStyle`,
|
|
1002
|
+
`strokeWidth`, `roughness`, `seed`, `fontSize`, …).
|
|
1003
|
+
- **Return** a single `<g>` (`SVGGElement`). **Do not draw the label** — the
|
|
1004
|
+
renderer overlays text itself after calling you.
|
|
1005
|
+
|
|
1006
|
+
`nodeRoughOptions(style, filled)` (exported from `src/engine/render/shapes.ts`)
|
|
1007
|
+
maps a `NodeStyle` onto rough.js `Options` (stroke/fill/roughness/seed, dashes,
|
|
1008
|
+
hachure gap). Pass `filled = true` for closed bodies so the node's fill and
|
|
1009
|
+
`fillStyle` are honoured; `false` for stroke-only accents. Using it keeps the
|
|
1010
|
+
deterministic `seed` (so re-renders don't re-jitter) and the hand-drawn look
|
|
1011
|
+
consistent with the built-ins.
|
|
1012
|
+
|
|
1013
|
+
### Worked example — a "gauge" shape (add to the builtins)
|
|
1014
|
+
|
|
1015
|
+
The canonical place to register in-repo shapes is
|
|
1016
|
+
`src/engine/plugins/builtins.ts` (the `star` example already lives there). Add your
|
|
1017
|
+
shape inside the exported `registerBuiltinShapes()` function — the renderer calls it
|
|
1018
|
+
on `mount()` (and it is called eagerly on import), so built-ins survive a
|
|
1019
|
+
tree-shaking library build and are always available once you render. Add:
|
|
1020
|
+
|
|
1021
|
+
```ts
|
|
1022
|
+
// src/engine/plugins/builtins.ts — add this call INSIDE registerBuiltinShapes()
|
|
1023
|
+
// (so it survives the tree-shaking library build, like the star above).
|
|
1024
|
+
// `nodeRoughOptions` and `registerShape` are already imported in that file.
|
|
1025
|
+
|
|
1026
|
+
// A half-circle gauge with a needle — great for "health"/"load" nodes.
|
|
1027
|
+
registerShape("gauge", (rc, rect, style) => {
|
|
1028
|
+
const g = document.createElementNS("http://www.w3.org/2000/svg", "g") as SVGGElement;
|
|
1029
|
+
const cx = rect.x + rect.w / 2;
|
|
1030
|
+
const cy = rect.y + rect.h * 0.72; // dial sits low in the box
|
|
1031
|
+
const r = Math.min(rect.w, rect.h * 1.4) / 2;
|
|
1032
|
+
|
|
1033
|
+
// The dial arc (semicircle), stroke-only.
|
|
1034
|
+
const arc = `M${cx - r},${cy} A${r},${r} 0 0 1 ${cx + r},${cy}`;
|
|
1035
|
+
g.appendChild(rc.path(arc, nodeRoughOptions(style, false)));
|
|
1036
|
+
|
|
1037
|
+
// Filled body under the arc so fill/fillStyle read through.
|
|
1038
|
+
const body = `M${cx - r},${cy} A${r},${r} 0 0 1 ${cx + r},${cy} Z`;
|
|
1039
|
+
g.appendChild(rc.path(body, nodeRoughOptions(style, true)));
|
|
1040
|
+
|
|
1041
|
+
// The needle, pointing ~70% of the way across the dial.
|
|
1042
|
+
const angle = Math.PI - Math.PI * 0.7;
|
|
1043
|
+
g.appendChild(rc.line(cx, cy, cx + Math.cos(angle) * r * 0.9, cy - Math.sin(angle) * r * 0.9, {
|
|
1044
|
+
stroke: style.stroke,
|
|
1045
|
+
strokeWidth: style.strokeWidth + 0.6,
|
|
1046
|
+
roughness: Math.min(style.roughness, 1),
|
|
1047
|
+
seed: style.seed,
|
|
1048
|
+
}));
|
|
1049
|
+
return g;
|
|
1050
|
+
});
|
|
1051
|
+
```
|
|
1052
|
+
|
|
1053
|
+
### Using it from the DSL
|
|
1054
|
+
|
|
1055
|
+
No grammar change — three equivalent ways to reach a registered shape:
|
|
1056
|
+
|
|
1057
|
+
```edd
|
|
1058
|
+
scene {
|
|
1059
|
+
g1[CPU] { shape: gauge } // via the `shape:` attribute
|
|
1060
|
+
node g2 "Memory" as gauge // via the explicit `as <name>` form
|
|
1061
|
+
}
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
Both compile to a node whose `shape` is `"gauge"`, which the renderer resolves via
|
|
1065
|
+
`getShapePlugin("gauge")`. (Verified: `g[Gauge] { shape: gauge }` compiles to a
|
|
1066
|
+
node with `shape === "gauge"`, and `node x as gauge` likewise.)
|
|
1067
|
+
|
|
1068
|
+
To also expose a **short keyword** (so `gauge g1 "CPU"` works as a shape-led node),
|
|
1069
|
+
add the word to two internal tables:
|
|
1070
|
+
|
|
1071
|
+
1. `SHAPE_KEYWORDS` in `src/engine/dsl/tokens.ts` — so the parser treats
|
|
1072
|
+
`gauge <id>` as a keyword-led node (see `parseKeywordNode` in `parser.ts`).
|
|
1073
|
+
2. `SHAPE_MAP` in `src/engine/dsl/lower.ts` — map `gauge: "gauge"` (only needed if
|
|
1074
|
+
the surface word differs from the registered name; identical names pass through
|
|
1075
|
+
`mapShape` unchanged).
|
|
1076
|
+
|
|
1077
|
+
### Registering from _outside_ the repo (runtime)
|
|
1078
|
+
|
|
1079
|
+
`registerShape` is part of the public package export, so an embedding app can add a
|
|
1080
|
+
shape at runtime with no fork:
|
|
1081
|
+
|
|
1082
|
+
```ts
|
|
1083
|
+
import { registerShape, EdodoDraw } from "edododraw";
|
|
1084
|
+
|
|
1085
|
+
// nodeRoughOptions is engine-internal (not re-exported), so build rough options
|
|
1086
|
+
// inline from the NodeStyle you're handed:
|
|
1087
|
+
registerShape("gauge", (rc, rect, style) => {
|
|
1088
|
+
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
1089
|
+
const opts = {
|
|
1090
|
+
stroke: style.stroke,
|
|
1091
|
+
strokeWidth: style.strokeWidth,
|
|
1092
|
+
roughness: style.roughness,
|
|
1093
|
+
seed: style.seed,
|
|
1094
|
+
fill: style.fill ?? undefined,
|
|
1095
|
+
fillStyle: style.fillStyle,
|
|
1096
|
+
};
|
|
1097
|
+
const cx = rect.x + rect.w / 2, cy = rect.y + rect.h / 2, r = Math.min(rect.w, rect.h) / 2;
|
|
1098
|
+
g.appendChild(rc.circle(cx, cy, r * 2, opts));
|
|
1099
|
+
return g;
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
const edd = new EdodoDraw(document.getElementById("app")!);
|
|
1103
|
+
await edd.render(`scene { s[Sensor] { shape: gauge } }`);
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
Register **before** you call `render()` so the plugin is present when the scene
|
|
1107
|
+
paints.
|
|
1108
|
+
|
|
1109
|
+
---
|
|
1110
|
+
|
|
1111
|
+
## 3. Add an animated arrow
|
|
1112
|
+
|
|
1113
|
+
An edge draws a hand-drawn base stroke, arrowheads, and (optionally) a clean
|
|
1114
|
+
**overlay `<path>`** that carries a CSS animation along the exact same centerline.
|
|
1115
|
+
Adding a new animation kind is **exactly three edits** plus (optionally) widening
|
|
1116
|
+
the type union. We'll add a kind called `surge` (a fast, short dash sweep).
|
|
1117
|
+
|
|
1118
|
+
**Edit 1 — the overlay case** in `animationOverlay()`
|
|
1119
|
+
(`src/engine/render/edges.ts`). This configures the SVG path for the new kind; the
|
|
1120
|
+
element already has class `edd-anim edd-anim-surge`, and `--edd-len` / `--edd-speed`
|
|
1121
|
+
CSS variables are set for you:
|
|
1122
|
+
|
|
1123
|
+
```ts
|
|
1124
|
+
// src/engine/render/edges.ts — inside animationOverlay's switch (kind)
|
|
1125
|
+
case "surge": {
|
|
1126
|
+
p.setAttribute("stroke", stroke);
|
|
1127
|
+
p.setAttribute("stroke-width", String(sw * 1.5));
|
|
1128
|
+
p.setAttribute("stroke-linecap", "round");
|
|
1129
|
+
p.setAttribute("stroke-dasharray", "4 14");
|
|
1130
|
+
style.animationDuration = `${0.7 / speed}s`;
|
|
1131
|
+
break;
|
|
1132
|
+
}
|
|
1133
|
+
```
|
|
1134
|
+
|
|
1135
|
+
**Edit 2 — the keyframe + class** in `src/engine/render/theme.css.ts` (the `CSS`
|
|
1136
|
+
string). The class name must be `edd-anim-<kind>` to match the attribute the
|
|
1137
|
+
overlay sets:
|
|
1138
|
+
|
|
1139
|
+
```css
|
|
1140
|
+
@keyframes edd-surge {
|
|
1141
|
+
to { stroke-dashoffset: -18; }
|
|
1142
|
+
}
|
|
1143
|
+
.edd-anim-surge {
|
|
1144
|
+
animation-name: edd-surge;
|
|
1145
|
+
animation-timing-function: ease-in;
|
|
1146
|
+
animation-iteration-count: infinite;
|
|
1147
|
+
}
|
|
1148
|
+
```
|
|
1149
|
+
|
|
1150
|
+
`.edd-anim-surge` is already covered by the blanket `.edd-anim` selector in the
|
|
1151
|
+
`prefers-reduced-motion` block at the end of the CSS, so motion-sensitive users
|
|
1152
|
+
are handled automatically — no extra edit needed there.
|
|
1153
|
+
|
|
1154
|
+
**Edit 3 — accept the name in the compiler.** In `mapAnimation()`
|
|
1155
|
+
(`src/engine/dsl/lower.ts`), add `surge` to the `known` set so the DSL keeps it
|
|
1156
|
+
instead of falling back to `flow`:
|
|
1157
|
+
|
|
1158
|
+
```ts
|
|
1159
|
+
const known = new Set([
|
|
1160
|
+
"none", "flow", "dash-march", "draw-on", "pulse", "comet",
|
|
1161
|
+
"gradient-flow", "electric", "glow", "caravan", "wiggle",
|
|
1162
|
+
"surge", // <-- new
|
|
1163
|
+
]);
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
**Optional — widen the type.** Add `| "surge"` to the `ArrowAnimationKind` union in
|
|
1167
|
+
`src/engine/scene/types.ts`. The union is open (`| (string & {})`), so this is only
|
|
1168
|
+
for editor autocomplete and exhaustiveness, not correctness.
|
|
1169
|
+
|
|
1170
|
+
### Using it
|
|
1171
|
+
|
|
1172
|
+
```edd
|
|
1173
|
+
scene {
|
|
1174
|
+
a[Client] ~> b[Server] { animate: surge }
|
|
1175
|
+
a -> b { animation: surge { speed: 1.6 } }
|
|
1176
|
+
}
|
|
1177
|
+
```
|
|
1178
|
+
|
|
1179
|
+
Both `animate:` and `animation:` are accepted (see `buildEdgeStyle` in
|
|
1180
|
+
`compile.ts`); the `{ speed: N }` styled form sets `animationSpeed`, which your
|
|
1181
|
+
`animationDuration` divides by. The `~>` glyph is a convenient default that already
|
|
1182
|
+
sets `animation: flow` + curved routing — override with the trailing block.
|
|
1183
|
+
|
|
1184
|
+
---
|
|
1185
|
+
|
|
1186
|
+
## 4. Add an annotation kind
|
|
1187
|
+
|
|
1188
|
+
Annotations (scripted _and_ live) are `Annotation` records rendered by
|
|
1189
|
+
`AnnotationLayer.draw()` (`src/engine/annotate/layer.ts`). The `kind` field is an
|
|
1190
|
+
open union, so adding one is a single `case` plus a private draw method. We'll add
|
|
1191
|
+
`cross` (a big hand-drawn X over an element — distinct from `strike`, which is one
|
|
1192
|
+
horizontal line).
|
|
1193
|
+
|
|
1194
|
+
**Edit 1 — the `draw` switch** in `AnnotationLayer` (`layer.ts`). `box` is the
|
|
1195
|
+
target's world bounding box (already resolved for you from `an.target`):
|
|
1196
|
+
|
|
1197
|
+
```ts
|
|
1198
|
+
// src/engine/annotate/layer.ts — inside draw()'s switch (an.kind)
|
|
1199
|
+
case "cross":
|
|
1200
|
+
if (box) this.drawCross(g, box, an);
|
|
1201
|
+
break;
|
|
1202
|
+
```
|
|
1203
|
+
|
|
1204
|
+
**Edit 2 — the draw method** (same file), using rough.js with a stable `seed` so it
|
|
1205
|
+
doesn't re-jitter:
|
|
1206
|
+
|
|
1207
|
+
```ts
|
|
1208
|
+
private drawCross(g: SVGGElement, box: BBox, an: Annotation): void {
|
|
1209
|
+
const color = an.color || "#e03131";
|
|
1210
|
+
const opts = { stroke: color, strokeWidth: 3, roughness: 1.6, seed: 33 };
|
|
1211
|
+
g.appendChild(this.rc.line(box.minX, box.minY, box.maxX, box.maxY, opts));
|
|
1212
|
+
g.appendChild(this.rc.line(box.minX, box.maxY, box.maxX, box.minY, opts));
|
|
1213
|
+
if (an.text) this.label(g, an.text, { x: box.minX, y: box.minY - 8 }, color, "start");
|
|
1214
|
+
}
|
|
1215
|
+
```
|
|
1216
|
+
|
|
1217
|
+
That's enough to use it from a scripted `annotate { … }` block — `parseAnnotationCmd`
|
|
1218
|
+
reads any leading ident as the `kind`, so no parser change is required:
|
|
1219
|
+
|
|
1220
|
+
```edd
|
|
1221
|
+
scene { a[Deprecated] }
|
|
1222
|
+
annotate {
|
|
1223
|
+
cross a { color: red }
|
|
1224
|
+
}
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
> Note: to use a **bare** annotation command inside a `timeline` beat or a
|
|
1228
|
+
> `stagger { … }` block (e.g. `beat { cross a }`), also add `"cross"` to the
|
|
1229
|
+
> `ANNOT_KINDS` set at the bottom of `src/engine/dsl/parser.ts` — that set gates
|
|
1230
|
+
> which idents are recognised as annotation verbs in beat context. Top-level
|
|
1231
|
+
> `annotate { … }` blocks don't consult it.
|
|
1232
|
+
|
|
1233
|
+
### Optional — a live tool
|
|
1234
|
+
|
|
1235
|
+
To let users draw it interactively, teach `LiveAnnotationController`
|
|
1236
|
+
(`src/engine/annotate/interact.ts`):
|
|
1237
|
+
|
|
1238
|
+
1. **Widen the `Tool` union:** `export type Tool = "select" | … | "cross";`
|
|
1239
|
+
2. **Add a default** to the `DEFAULTS` map — the element-anchored tool path in
|
|
1240
|
+
`pointerDown()` reads it (click an element → annotation created):
|
|
1241
|
+
```ts
|
|
1242
|
+
const DEFAULTS = {
|
|
1243
|
+
// …existing…
|
|
1244
|
+
cross: { kind: "cross", color: "#e03131", options: {} },
|
|
1245
|
+
};
|
|
1246
|
+
```
|
|
1247
|
+
3. **(Optional) commit-to-code:** add a `case "cross"` to `serialize()` so
|
|
1248
|
+
`annotationsToCode()` round-trips it back to DSL.
|
|
1249
|
+
4. **Add a toolbar button** in the playground: `src/app/App.tsx`, the `TOOLS`
|
|
1250
|
+
array — `{ tool: "cross", icon: "✕", label: "Cross out element" }`.
|
|
1251
|
+
|
|
1252
|
+
The live layer already handles selection, undo/redo, and camera tracking generically,
|
|
1253
|
+
so those come for free.
|
|
1254
|
+
|
|
1255
|
+
---
|
|
1256
|
+
|
|
1257
|
+
## 5. Add a layout
|
|
1258
|
+
|
|
1259
|
+
Auto-layout assigns world positions to non-pinned nodes based on
|
|
1260
|
+
`scene.meta.layout` (a `LayoutKind`). See `src/engine/layout/index.ts`. Adding a
|
|
1261
|
+
mode is three edits; we'll add `column` (a single vertical stack).
|
|
1262
|
+
|
|
1263
|
+
**Edit 1 — the algorithm.** New file `src/engine/layout/column.ts`, mutating each
|
|
1264
|
+
node's top-left `x`/`y` in place (mirror the shape of `grid.ts`/`radial.ts`):
|
|
1265
|
+
|
|
1266
|
+
```ts
|
|
1267
|
+
// src/engine/layout/column.ts
|
|
1268
|
+
import type { SceneNode } from "../scene/types.js";
|
|
1269
|
+
|
|
1270
|
+
/** Stack nodes in one centred vertical column, top to bottom. */
|
|
1271
|
+
export function layoutColumn(nodes: SceneNode[], gap: number): void {
|
|
1272
|
+
let y = 0;
|
|
1273
|
+
for (const n of nodes) {
|
|
1274
|
+
n.x = -n.w / 2; // centre each node on x = 0
|
|
1275
|
+
n.y = y;
|
|
1276
|
+
y += n.h + gap;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
```
|
|
1280
|
+
|
|
1281
|
+
**Edit 2 — the dispatch** in `runLayout()` (`layout/index.ts`). Any unknown kind
|
|
1282
|
+
already falls back to grid, so this just makes `column` explicit:
|
|
1283
|
+
|
|
1284
|
+
```ts
|
|
1285
|
+
import { layoutColumn } from "./column.js";
|
|
1286
|
+
// …
|
|
1287
|
+
switch (kind) {
|
|
1288
|
+
// …existing cases…
|
|
1289
|
+
case "column":
|
|
1290
|
+
layoutColumn(movable, gridGap(scene));
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
```
|
|
1294
|
+
|
|
1295
|
+
**Edit 3 — the type + DSL mapping.**
|
|
1296
|
+
|
|
1297
|
+
- Add `"column"` to the `LayoutKind` union in `src/engine/scene/types.ts`.
|
|
1298
|
+
- Map the DSL keyword in `resolveLayoutKind()` (`src/engine/dsl/compile.ts`) — this
|
|
1299
|
+
is where `layout <word>` is translated to a `LayoutKind`:
|
|
1300
|
+
```ts
|
|
1301
|
+
const map: Record<string, LayoutKind> = {
|
|
1302
|
+
dag: "dag", tree: "dag", flow: "dag", force: "dag",
|
|
1303
|
+
grid: "grid", radial: "radial", free: "manual", manual: "manual",
|
|
1304
|
+
column: "column", // <-- new
|
|
1305
|
+
};
|
|
1306
|
+
```
|
|
1307
|
+
|
|
1308
|
+
### How a layout is chosen
|
|
1309
|
+
|
|
1310
|
+
`resolveLayoutKind()` runs at compile time: an explicit `layout <kind>` maps through
|
|
1311
|
+
the table above; with no `layout` statement, the compiler defaults to `"dag"` when
|
|
1312
|
+
any node is unpinned, else `"manual"`. `applyLayout(scene)` then reads
|
|
1313
|
+
`scene.meta.layout` and dispatches. Cross-cutting rules that come for free:
|
|
1314
|
+
`pinned` nodes are never moved, the function never throws (falls back to grid), and
|
|
1315
|
+
the final result is normalised so the bounding box starts near `(40, 40)`.
|
|
1316
|
+
|
|
1317
|
+
### Using it
|
|
1318
|
+
|
|
1319
|
+
```edd
|
|
1320
|
+
scene {
|
|
1321
|
+
layout column
|
|
1322
|
+
a[One] --> b[Two] --> c[Three]
|
|
1323
|
+
}
|
|
1324
|
+
```
|
|
1325
|
+
|
|
1326
|
+
---
|
|
1327
|
+
|
|
1328
|
+
## 6. Add a DSL construct
|
|
1329
|
+
|
|
1330
|
+
The DSL is a hand-written recursive-descent parser (`src/engine/dsl/parser.ts`)
|
|
1331
|
+
lowered to Scene IR by the compiler (`src/engine/dsl/compile.ts`). Grammar changes
|
|
1332
|
+
touch three files — know where each concern lives:
|
|
1333
|
+
|
|
1334
|
+
| File | Responsibility | Where to hook in |
|
|
1335
|
+
|---|---|---|
|
|
1336
|
+
| `dsl/ast.ts` | AST node types | Add your statement's interface + to the relevant union (`TopStmt`, `SceneStmt`, `BeatItem`, …). |
|
|
1337
|
+
| `dsl/parser.ts` | tokens → AST | Add a keyword case to `parseTop()` (top level) or `parseSceneStmt()` (inside `scene { }`); write a `parseX()` that returns your AST node. |
|
|
1338
|
+
| `dsl/compile.ts` | AST → Scene IR | Handle your node in `compileProgram`'s top-level loop or in `walk()` (scene statements), writing onto the `Scene`. |
|
|
1339
|
+
| `dsl/lower.ts` | enum/glyph → IR value tables | Add any surface-word → IR-value mapping here (e.g. new shape/animation/routing names). |
|
|
1340
|
+
|
|
1341
|
+
Concretely:
|
|
1342
|
+
|
|
1343
|
+
- **A new node/edge attribute** (e.g. `blur: 4`) is the smallest change: no parser
|
|
1344
|
+
edit at all. Attributes are parsed generically by `parseAttrEntry`, so you only add
|
|
1345
|
+
a `case "blur":` to `buildNodeStyle` (or `buildEdgeStyle`) in `compile.ts` and a
|
|
1346
|
+
field to `NodeStyle`/`EdgeStyle` in `scene/types.ts` (then honour it in the
|
|
1347
|
+
renderer). This is the recommended path for most "new knob" requests.
|
|
1348
|
+
- **A new top-level keyword** (e.g. `legend { … }`): add `case "legend": return
|
|
1349
|
+
this.parseLegend();` to `parseTop()`, define `parseLegend()` (use
|
|
1350
|
+
`parseAttrBlock()` / `skipBlock()` helpers), add the AST type, and consume it in
|
|
1351
|
+
`compileProgram`'s statement loop. Note `parseTop` already _tolerates_ unknown
|
|
1352
|
+
top-level keywords like `plugin`/`define` by skipping their block — a good template
|
|
1353
|
+
for "parse but ignore for now."
|
|
1354
|
+
- **A new scene-level statement** (e.g. `ruler …`): add a `case` to
|
|
1355
|
+
`parseSceneStmt()`'s keyword switch and handle it in `walk()`.
|
|
1356
|
+
|
|
1357
|
+
Keep the parser **permissive and recoverable**: report problems via
|
|
1358
|
+
`this.error(code, msg, token, …)` (which pushes a `Diagnostic`) and call
|
|
1359
|
+
`recoverStmt()` / `skipBlock()` rather than throwing. The compiler accumulates
|
|
1360
|
+
`Diagnostic`s and always returns a best-effort `Scene` — see
|
|
1361
|
+
[DEVELOPMENT_STANDARDS.md](DEVELOPMENT_STANDARDS.md) ("Diagnostics, not
|
|
1362
|
+
exceptions").
|
|
1363
|
+
|
|
1364
|
+
For the full user-facing grammar, see
|
|
1365
|
+
[DSL_LANGUAGE_GUIDE.md](DSL_LANGUAGE_GUIDE.md).
|
|
1366
|
+
|
|
1367
|
+
---
|
|
1368
|
+
|
|
1369
|
+
## 7. Testing your extension
|
|
1370
|
+
|
|
1371
|
+
Follow the two-stage rule from [DEVELOPMENT_STANDARDS.md](DEVELOPMENT_STANDARDS.md).
|
|
1372
|
+
|
|
1373
|
+
**Stage 1 — code-level (fast, Node/jsdom).** The compiler is synchronous and
|
|
1374
|
+
DOM-free, so most extensions are unit-testable without a browser. Add a spec under
|
|
1375
|
+
`tests/` (vitest) and run:
|
|
1376
|
+
|
|
1377
|
+
```bash
|
|
1378
|
+
npm test # vitest run
|
|
1379
|
+
npm run typecheck # tsc -b --noEmit
|
|
1380
|
+
```
|
|
1381
|
+
|
|
1382
|
+
A shape/animation/annotation test typically compiles a snippet and asserts on the
|
|
1383
|
+
resulting `Scene`:
|
|
1384
|
+
|
|
1385
|
+
```ts
|
|
1386
|
+
import { describe, it, expect } from "vitest";
|
|
1387
|
+
import { compileEdd } from "../src/engine/dsl/index.js";
|
|
1388
|
+
|
|
1389
|
+
describe("gauge shape", () => {
|
|
1390
|
+
it("passes an unknown shape name through unchanged", () => {
|
|
1391
|
+
const { scene } = compileEdd(`scene { g[CPU] { shape: gauge } }`);
|
|
1392
|
+
expect(scene.nodes[0].shape).toBe("gauge");
|
|
1393
|
+
});
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
describe("surge animation", () => {
|
|
1397
|
+
it("keeps a registered animation kind", () => {
|
|
1398
|
+
const { scene } = compileEdd(`scene { a -> b { animate: surge } }`);
|
|
1399
|
+
expect(scene.edges[0].style.animation).toBe("surge");
|
|
1400
|
+
});
|
|
1401
|
+
});
|
|
1402
|
+
```
|
|
1403
|
+
|
|
1404
|
+
Existing suites to mirror: `tests/parser.test.ts`, `tests/lowering.test.ts`,
|
|
1405
|
+
`tests/layout.test.ts`, `tests/annotations.test.ts`.
|
|
1406
|
+
|
|
1407
|
+
**Stage 2 — end-user visual check.** A green unit test does not prove the
|
|
1408
|
+
hand-drawn render is correct. Run the app and drive it with `playwright-cli`, then
|
|
1409
|
+
**read the screenshots**:
|
|
1410
|
+
|
|
1411
|
+
```bash
|
|
1412
|
+
npm run dev # dev server (see DEVELOPMENT_STANDARDS.md)
|
|
1413
|
+
scripts/qa/smoke.sh # loads every example, screenshots, fails on console errors
|
|
1414
|
+
```
|
|
1415
|
+
|
|
1416
|
+
Add an example that exercises your extension (an `.edd` file wired into the
|
|
1417
|
+
playground's example list) so the smoke test covers it, and confirm no
|
|
1418
|
+
per-element render warnings appear in the console — the renderer wraps each
|
|
1419
|
+
node/edge so one bad element can't blank the diagram, but it _will_ log a warning
|
|
1420
|
+
you should catch.
|
|
1421
|
+
|
|
1422
|
+
==============================================================================
|
|
1423
|
+
|
|
1424
|
+
# ═══ Architecture (docs/ARCHITECTURE.md) ═══
|
|
1425
|
+
|
|
1426
|
+
# EDodoDraw Architecture
|
|
1427
|
+
|
|
1428
|
+
EDodoDraw is a **100% code-to-diagram engine** with the Excalidraw hand-drawn aesthetic, a magic-move camera, and scriptable + real-time annotations. It is built from the ground up (no Excalidraw runtime) on three reused primitives: **rough.js** (hand-drawn strokes), **dagre** (auto-layout), and **@excalidraw/mermaid-to-excalidraw** (Mermaid import). The hand-drawn font is the OFL-licensed **Virgil/Excalifont**.
|
|
1429
|
+
|
|
1430
|
+
## The pipeline
|
|
1431
|
+
|
|
1432
|
+
```mermaid
|
|
1433
|
+
flowchart LR
|
|
1434
|
+
src["EDodoDraw code (.edd)"] --> lex[Lexer]
|
|
1435
|
+
lex --> parse[Parser → AST]
|
|
1436
|
+
parse --> compile[Compiler]
|
|
1437
|
+
compile --> scene["Scene IR"]
|
|
1438
|
+
merm["mermaid ' … '"] -. import .-> scene
|
|
1439
|
+
scene --> layout["Layout (dagre/grid/radial)"]
|
|
1440
|
+
layout --> render["SVG Renderer (rough.js)"]
|
|
1441
|
+
render --> cam[Camera controller]
|
|
1442
|
+
render --> anno[Annotation layer]
|
|
1443
|
+
render --> tl[Timeline player]
|
|
1444
|
+
```
|
|
1445
|
+
|
|
1446
|
+
Everything flows through the **Scene IR** (`src/engine/scene/types.ts`) — a flat, serializable model of nodes, edges, groups, annotations, and timeline steps. The DSL, the Mermaid importer, and any programmatic caller all produce a `Scene`; the renderer and controllers only consume one. This is the single contract that keeps the system decoupled.
|
|
1447
|
+
|
|
1448
|
+
## Modules
|
|
1449
|
+
|
|
1450
|
+
| Area | Path | Responsibility | Guide |
|
|
1451
|
+
|---|---|---|---|
|
|
1452
|
+
| Scene IR | `src/engine/scene/` | Types, palette, factories, anchors, queries | — |
|
|
1453
|
+
| DSL | `src/engine/dsl/` | `lexer → parser → ast → compile` + diagnostics | [DSL_LANGUAGE_GUIDE](DSL_LANGUAGE_GUIDE.md) |
|
|
1454
|
+
| Layout | `src/engine/layout/` | dagre/grid/radial → node positions | — |
|
|
1455
|
+
| Renderer | `src/engine/render/` | SVG + rough.js, shapes, edges, arrowheads, fonts | — |
|
|
1456
|
+
| Camera | `src/engine/camera/` | fit math, easing, animated controller | [CAMERA_AND_TIMELINE_GUIDE](CAMERA_AND_TIMELINE_GUIDE.md) |
|
|
1457
|
+
| Timeline | `src/engine/timeline/` | beat player (magic-move) | [CAMERA_AND_TIMELINE_GUIDE](CAMERA_AND_TIMELINE_GUIDE.md) |
|
|
1458
|
+
| Annotations | `src/engine/annotate/` | render layer + live interactive editor | [ANNOTATIONS_GUIDE](ANNOTATIONS_GUIDE.md) |
|
|
1459
|
+
| Import/Export | `src/engine/import/`, `src/engine/export.ts` | Mermaid in; SVG/PNG/JSON out | [IMPORT_AND_EXPORT_GUIDE](IMPORT_AND_EXPORT_GUIDE.md) |
|
|
1460
|
+
| Plugins | `src/engine/plugins/` | shape registry (extension seam) | [DEVELOPMENT_STANDARDS](DEVELOPMENT_STANDARDS.md) |
|
|
1461
|
+
| App | `src/app/` | React playground: editor, canvas, toolbar, player | [DEVELOPMENT_STANDARDS](DEVELOPMENT_STANDARDS.md) |
|
|
1462
|
+
|
|
1463
|
+
`src/engine/index.ts` is the public barrel; the app imports only from `@engine/*`.
|
|
1464
|
+
|
|
1465
|
+
## Key decisions
|
|
1466
|
+
|
|
1467
|
+
- **SVG, not Canvas.** The world layer is one `<g>` whose `transform` attribute is the camera. This makes the magic-move camera a single attribute update per frame, GPU-friendly, and lets annotations/animated arrows be DOM elements with CSS animations. Trade-off: not ideal for tens of thousands of elements — fine for diagrams.
|
|
1468
|
+
- **Imperative renderer outside React.** `SvgRenderer` owns the SVG DOM directly (like Excalidraw owns its canvas). React only mounts the container and drives high-level state. Full re-render on scene change; camera/animation mutate transforms only.
|
|
1469
|
+
- **Deterministic hand-drawn strokes.** Every element gets a stable rough.js `seed` (hash of its id) so re-rendering never re-jitters strokes.
|
|
1470
|
+
- **Open unions + registries.** `ShapeKind`, `ArrowAnimationKind`, and `AnnotationKind` are open string unions; unknown values resolve through registries (`src/engine/plugins/`). New shapes/animations need no grammar or core change.
|
|
1471
|
+
- **Sync compiler, async Mermaid.** The DSL compiler is synchronous and DOM-free (unit-testable in Node). Mermaid rendering needs the browser, so the app awaits `convertMermaid` and injects the fragment — the compiler itself stays pure.
|
|
1472
|
+
|
|
1473
|
+
## Rendering layers (bottom → top)
|
|
1474
|
+
|
|
1475
|
+
`bg (screen)` · `grid` · `groups` · `edges` · `nodes` · `annotations` (scripted) · `live` (interactive) — the last two sit in the camera-transformed world layer so annotations track the diagram. See `src/engine/render/svgRenderer.ts`.
|
|
1476
|
+
|
|
1477
|
+
## Testing
|
|
1478
|
+
|
|
1479
|
+
- Unit tests (vitest, jsdom): `tests/` — lexer, parser, compiler, lowering, layout, camera math, anchors, annotations round-trip. Run `npm test`.
|
|
1480
|
+
- Visual smoke: `scripts/qa/smoke.sh` drives every example with `playwright-cli` and fails on console errors. Two-stage testing per `docs`-referenced FE guide: code-level first, end-user browser interaction last.
|
|
1481
|
+
|
|
1482
|
+
==============================================================================
|
|
1483
|
+
|
|
1484
|
+
# ═══ Development standards (docs/DEVELOPMENT_STANDARDS.md) ═══
|
|
1485
|
+
|
|
1486
|
+
# Development Standards
|
|
1487
|
+
|
|
1488
|
+
## Setup
|
|
1489
|
+
|
|
1490
|
+
```bash
|
|
1491
|
+
npm install
|
|
1492
|
+
npm run dev # http://localhost:5273
|
|
1493
|
+
npm test # vitest
|
|
1494
|
+
npm run typecheck # tsc -b --noEmit
|
|
1495
|
+
npm run build # tsc + vite build → dist/
|
|
1496
|
+
```
|
|
1497
|
+
|
|
1498
|
+
Node ≥ 18 (matches `engines`). The `reference/` directory (Excalidraw + mermaid-to-excalidraw clones, studied during design) is gitignored and excluded from Vite (`vite.config.ts → optimizeDeps.entries` + `server.watch.ignored`).
|
|
1499
|
+
|
|
1500
|
+
## Project structure
|
|
1501
|
+
|
|
1502
|
+
```
|
|
1503
|
+
src/
|
|
1504
|
+
engine/ framework-agnostic engine (no React)
|
|
1505
|
+
scene/ Scene IR: types, palette, defaults, anchors, query
|
|
1506
|
+
dsl/ lexer, tokens, ast, parser, lower, compile, diagnostics
|
|
1507
|
+
layout/ dagre / grid / radial → node positions
|
|
1508
|
+
render/ svgRenderer, shapes, edges, theme.css, fonts
|
|
1509
|
+
camera/ fit math, easing, controller
|
|
1510
|
+
timeline/ beat player
|
|
1511
|
+
annotate/ layer (render) + interact (live editor)
|
|
1512
|
+
import/ mermaid adapter
|
|
1513
|
+
plugins/ registry + builtins
|
|
1514
|
+
export.ts SVG / PNG / JSON
|
|
1515
|
+
index.ts public barrel (import from "@engine/…")
|
|
1516
|
+
app/ React playground (App, CanvasView, examples)
|
|
1517
|
+
examples/ *.edd sample programs (imported ?raw)
|
|
1518
|
+
docs/ this documentation
|
|
1519
|
+
tests/ vitest suites
|
|
1520
|
+
scripts/qa/ playwright-cli smoke test
|
|
1521
|
+
public/fonts/ OFL hand-drawn fonts (Virgil, Excalifont)
|
|
1522
|
+
```
|
|
1523
|
+
|
|
1524
|
+
The engine is pure TypeScript with no React dependency; the app is a thin shell over it. Import engine code via the `@engine/*` alias with `.js` extensions (bundler resolution maps to `.ts`).
|
|
1525
|
+
|
|
1526
|
+
## Conventions
|
|
1527
|
+
|
|
1528
|
+
- **Scene IR is the contract.** Anything that produces diagrams emits a `Scene`; anything that draws consumes one. Don't reach around it.
|
|
1529
|
+
- **Deterministic seeds.** Element factories (`makeNode`/`makeEdge`) assign a rough.js seed from the id hash. Preserve that when adding element kinds.
|
|
1530
|
+
- **Never throw in the lexer/renderer per-element.** The lexer is permissive; the renderer wraps each node/edge so one bad element can't blank the diagram.
|
|
1531
|
+
- **Diagnostics, not exceptions.** The compiler accumulates `Diagnostic`s and recovers at statement/block boundaries.
|
|
1532
|
+
|
|
1533
|
+
## Extending the engine
|
|
1534
|
+
|
|
1535
|
+
### Add a shape
|
|
1536
|
+
|
|
1537
|
+
Register a renderer — no grammar change needed (the DSL already accepts any shape name):
|
|
1538
|
+
|
|
1539
|
+
```ts
|
|
1540
|
+
import { registerShape } from "@engine/index.js";
|
|
1541
|
+
import { nodeRoughOptions } from "@engine/render/shapes.js";
|
|
1542
|
+
|
|
1543
|
+
registerShape("hexstar", (rc, rect, style) => {
|
|
1544
|
+
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
1545
|
+
g.appendChild(rc.polygon(myPoints(rect), nodeRoughOptions(style, true)));
|
|
1546
|
+
return g;
|
|
1547
|
+
});
|
|
1548
|
+
```
|
|
1549
|
+
|
|
1550
|
+
Then `shape: hexstar` (or a keyword mapped in `src/engine/dsl/lower.ts → SHAPE_MAP`) works. See `src/engine/plugins/builtins.ts` for the built-in `star`.
|
|
1551
|
+
|
|
1552
|
+
### Add an animated arrow
|
|
1553
|
+
|
|
1554
|
+
Add a `case` in `animationOverlay` (`src/engine/render/edges.ts`) and a keyframe in `src/engine/render/theme.css.ts`, then list it in `mapAnimation` (`src/engine/dsl/lower.ts`).
|
|
1555
|
+
|
|
1556
|
+
### Add an annotation kind
|
|
1557
|
+
|
|
1558
|
+
Add a `case` in `AnnotationLayer.draw` (`src/engine/annotate/layer.ts`) and, for live editing, a tool in `LiveAnnotationController` (`src/engine/annotate/interact.ts`) + the toolbar in `src/app/App.tsx`.
|
|
1559
|
+
|
|
1560
|
+
### Add a DSL construct
|
|
1561
|
+
|
|
1562
|
+
Grammar-level changes live in `src/engine/dsl/parser.ts` (recursive descent, keyword-dispatched) and are lowered to Scene IR in `src/engine/dsl/compile.ts`. Add tests in `tests/parser.test.ts`.
|
|
1563
|
+
|
|
1564
|
+
## Testing standard
|
|
1565
|
+
|
|
1566
|
+
Two stages (mandatory for anything user-facing):
|
|
1567
|
+
|
|
1568
|
+
1. **Code-level** — `npm test` (vitest) + `npm run typecheck`.
|
|
1569
|
+
2. **End-user** — drive the running app with `playwright-cli` (`scripts/qa/smoke.sh`), screenshot every state, and **read every screenshot**. A route returning the right JSON does not prove the hand-drawn render is correct.
|
|
1570
|
+
|
|
1571
|
+
==============================================================================
|
|
1572
|
+
|
|
1573
|
+
# ▸ Runnable examples (examples/*.edd)
|
|
1574
|
+
|
|
1575
|
+
Each is a complete EDodoDraw program. Paste any into the playground
|
|
1576
|
+
(https://vivmagarwal.github.io/edododraw/#/playground) or compile it with `compileEdd(source)`.
|
|
1577
|
+
|
|
1578
|
+
## examples/animated-arrows.edd
|
|
1579
|
+
|
|
1580
|
+
```edd
|
|
1581
|
+
edd 1.0
|
|
1582
|
+
meta { title: "Animated Arrow Gallery" }
|
|
1583
|
+
|
|
1584
|
+
// EDodoDraw ships far more animated connectors than Excalidraw.
|
|
1585
|
+
scene {
|
|
1586
|
+
layout dag { direction: right, gap: 120 }
|
|
1587
|
+
|
|
1588
|
+
circle a "A" { fill: blue }
|
|
1589
|
+
circle b "B" { fill: green }
|
|
1590
|
+
|
|
1591
|
+
a -> b "flow" { animate: flow }
|
|
1592
|
+
a -> b "dash-march" { animate: dash-march }
|
|
1593
|
+
a -> b "draw-on" { animate: draw-on }
|
|
1594
|
+
a -> b "comet" { animate: comet, color: red }
|
|
1595
|
+
a -> b "gradient-flow" { animate: gradient-flow }
|
|
1596
|
+
a -> b "electric" { animate: electric, color: violet }
|
|
1597
|
+
a -> b "pulse" { animate: pulse }
|
|
1598
|
+
}
|
|
1599
|
+
```
|
|
1600
|
+
|
|
1601
|
+
## examples/architecture.edd
|
|
1602
|
+
|
|
1603
|
+
```edd
|
|
1604
|
+
edd 1.0
|
|
1605
|
+
meta { title: "WebShop Reference Architecture" }
|
|
1606
|
+
|
|
1607
|
+
theme light {
|
|
1608
|
+
tokens { $accent: #2563eb, $muted: #9ca3af, $danger: #e03131 }
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
style .card { shape: round-rect, fill: blue }
|
|
1612
|
+
style .critical { stroke: $danger, strokeWidth: bold }
|
|
1613
|
+
|
|
1614
|
+
defaults {
|
|
1615
|
+
edge { endArrow: arrow }
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
scene {
|
|
1619
|
+
use theme light
|
|
1620
|
+
layout dag { direction: down, gap: 64, rankGap: 110 }
|
|
1621
|
+
|
|
1622
|
+
actor client "User"
|
|
1623
|
+
cloud cdn "CDN / Edge" { fill: cyan }
|
|
1624
|
+
hexagon gw "API Gateway" { fill: violet, strokeWidth: bold }
|
|
1625
|
+
|
|
1626
|
+
group services "Application Services" {
|
|
1627
|
+
round-rect auth "Auth Service" :::card
|
|
1628
|
+
round-rect api "API Service" :::card :::critical
|
|
1629
|
+
subroutine worker "Async Worker"
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
group data "Data Layer" {
|
|
1633
|
+
cylinder db "Postgres" { fill: teal }
|
|
1634
|
+
cylinder cache "Redis" { fill: red }
|
|
1635
|
+
parallelogram queue "Message Queue" { fill: orange }
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
note legacy "Legacy Billing\n(deprecate)" { at: (760, 40), pin: true, strokeStyle: dashed }
|
|
1639
|
+
|
|
1640
|
+
// Arrowhead + animation variety
|
|
1641
|
+
client -> cdn "https"
|
|
1642
|
+
cdn ==> gw "cache miss" { color: $accent }
|
|
1643
|
+
gw -> auth "authZ" { startArrow: dot }
|
|
1644
|
+
gw -> api "REST" { animate: dash-march }
|
|
1645
|
+
api ~> worker "enqueue" { animate: flow }
|
|
1646
|
+
api -> db "write" { endArrow: triangle, animate: draw-on }
|
|
1647
|
+
api -.-> cache "read-through"
|
|
1648
|
+
worker -> queue "publish" { animate: comet, color: $accent }
|
|
1649
|
+
queue -> db "persist"
|
|
1650
|
+
api -x legacy "deprecated" { color: $muted, strokeStyle: dashed }
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
annotate "persistent" {
|
|
1654
|
+
callout db "source of truth" { placement: bottom }
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
timeline story {
|
|
1658
|
+
beat overview "The whole system" {
|
|
1659
|
+
camera fit-all over 800ms
|
|
1660
|
+
reveal { show all with fade-in }
|
|
1661
|
+
narrate: "End-to-end request path."
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
beat ingress "Traffic enters at the edge" {
|
|
1665
|
+
camera focus [client, cdn, gw] zoom 1.5 ease spring
|
|
1666
|
+
annotate {
|
|
1667
|
+
point-at gw "single entry point" { from: ne, color: $accent }
|
|
1668
|
+
highlight cdn { color: yellow }
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
beat services "Inside the app tier" {
|
|
1673
|
+
camera focus services zoom 1.6
|
|
1674
|
+
annotate {
|
|
1675
|
+
spotlight services { dim: 0.72 }
|
|
1676
|
+
underline api "the hot path" { color: $accent }
|
|
1677
|
+
}
|
|
1678
|
+
narrate: "Auth fronts the API; the worker drains async jobs."
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
beat data "Where state lives" {
|
|
1682
|
+
camera focus data zoom 1.7 ease ease-in-out
|
|
1683
|
+
annotate {
|
|
1684
|
+
callout db "primary of record" { placement: right }
|
|
1685
|
+
circle-mark queue "retry here"
|
|
1686
|
+
}
|
|
1687
|
+
narrate: "All writes funnel through Postgres; Redis is read-through."
|
|
1688
|
+
hold: 2s
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
```
|
|
1692
|
+
|
|
1693
|
+
## examples/flowchart.edd
|
|
1694
|
+
|
|
1695
|
+
```edd
|
|
1696
|
+
edd 1.0
|
|
1697
|
+
meta { title: "Signup Flow" }
|
|
1698
|
+
|
|
1699
|
+
scene {
|
|
1700
|
+
layout dag { direction: down, gap: 60 }
|
|
1701
|
+
|
|
1702
|
+
start([Start])
|
|
1703
|
+
form[Signup Form]
|
|
1704
|
+
valid{Valid?}
|
|
1705
|
+
ok(Create Account):::good
|
|
1706
|
+
err(Show Errors):::bad
|
|
1707
|
+
|
|
1708
|
+
start --> form
|
|
1709
|
+
form --> valid
|
|
1710
|
+
valid -->|yes| ok
|
|
1711
|
+
valid -->|no| err
|
|
1712
|
+
err --> form
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
style .good { stroke: green, fill: green }
|
|
1716
|
+
style .bad { stroke: red, fill: red }
|
|
1717
|
+
```
|
|
1718
|
+
|
|
1719
|
+
## examples/mermaid-import.edd
|
|
1720
|
+
|
|
1721
|
+
```edd
|
|
1722
|
+
edd 1.0
|
|
1723
|
+
meta { title: "Imported from Mermaid" }
|
|
1724
|
+
|
|
1725
|
+
// Paste raw Mermaid — EDodoDraw imports it, keeps the node ids,
|
|
1726
|
+
// and lets you choreograph a walkthrough on top.
|
|
1727
|
+
mermaid """
|
|
1728
|
+
flowchart LR
|
|
1729
|
+
app[App Servers] --> otel[OTel Collector]
|
|
1730
|
+
otel --> prom[Prometheus]
|
|
1731
|
+
otel --> loki[Loki]
|
|
1732
|
+
prom --> grafana[Grafana]
|
|
1733
|
+
loki --> grafana
|
|
1734
|
+
prom --> alertmgr[Alertmanager]
|
|
1735
|
+
"""
|
|
1736
|
+
|
|
1737
|
+
timeline walkthrough {
|
|
1738
|
+
beat all "Pipeline overview" {
|
|
1739
|
+
camera fit-all over 700ms
|
|
1740
|
+
narrate: "Telemetry flows left to right into Grafana."
|
|
1741
|
+
}
|
|
1742
|
+
beat collect "Collection" {
|
|
1743
|
+
camera focus [app, otel] zoom 1.7
|
|
1744
|
+
annotate {
|
|
1745
|
+
highlight otel { color: yellow }
|
|
1746
|
+
point-at otel "single ingestion point" { from: s }
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
beat visualize "Dashboards & alerts" {
|
|
1750
|
+
camera focus [grafana, alertmgr] zoom 1.6
|
|
1751
|
+
annotate {
|
|
1752
|
+
circle-mark grafana "the pane of glass"
|
|
1753
|
+
callout alertmgr "pages on-call" { placement: top }
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
```
|
|
1758
|
+
|
|
1759
|
+
## examples/welcome.edd
|
|
1760
|
+
|
|
1761
|
+
```edd
|
|
1762
|
+
edd 1.0
|
|
1763
|
+
meta { title: "Welcome to EDodoDraw" }
|
|
1764
|
+
|
|
1765
|
+
// Everything you see is generated from THIS code.
|
|
1766
|
+
// Edit anything on the left and the diagram updates live.
|
|
1767
|
+
|
|
1768
|
+
scene {
|
|
1769
|
+
layout dag { direction: down, gap: 72 }
|
|
1770
|
+
|
|
1771
|
+
ellipse hello "Hello 👋" { fill: yellow }
|
|
1772
|
+
rect idea "Diagrams as CODE" { fill: blue }
|
|
1773
|
+
round-rect vibe "…with the Excalidraw vibe" { fill: green }
|
|
1774
|
+
diamond edit "Try editing me!" { fill: violet }
|
|
1775
|
+
|
|
1776
|
+
hello --> idea "type"
|
|
1777
|
+
idea --> vibe "render"
|
|
1778
|
+
vibe --> edit "enjoy"
|
|
1779
|
+
edit ~> hello "loop" // ~> == a flowing animated arrow
|
|
1780
|
+
}
|
|
1781
|
+
```
|
|
1782
|
+
|