@mocanvas/editor 4.8.3 → 4.8.6
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/AGENTS.md +476 -0
- package/CUSTOM_SHAPES.md +7 -2
- package/FILES.md +554 -0
- package/MIGRATION.md +1 -1
- package/SYNC.md +490 -0
- package/TOOLS.md +655 -0
- package/package.json +8 -4
package/AGENTS.md
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
# Driving the editor from an agent
|
|
2
|
+
|
|
3
|
+
mocanvas ships no model, no prompt and no agent. It ships a document that exists
|
|
4
|
+
without a browser, and one `Editor` API that a person's toolbar and a program's
|
|
5
|
+
code both call. This guide is for somebody pointing a model at that API: how to
|
|
6
|
+
build an editor with no screen, write to it, read the document back, and where
|
|
7
|
+
the sharp edges are. Everything here is public API from `@mocanvas/mocanvas` /
|
|
8
|
+
`@mocanvas/editor`.
|
|
9
|
+
|
|
10
|
+
Contents:
|
|
11
|
+
|
|
12
|
+
1. [The shape of the thing](#1-the-shape-of-the-thing)
|
|
13
|
+
2. [A document with no browser](#2-a-document-with-no-browser)
|
|
14
|
+
3. [Writing](#3-writing)
|
|
15
|
+
4. [Reading back](#4-reading-back)
|
|
16
|
+
5. [Keeping an agent's writes out of a person's undo stack](#5-keeping-an-agents-writes-out-of-a-persons-undo-stack)
|
|
17
|
+
6. [Reproducible runs](#6-reproducible-runs)
|
|
18
|
+
7. [Validation on the way in](#7-validation-on-the-way-in)
|
|
19
|
+
8. [A worked example: a plan, drawn](#8-a-worked-example-a-plan-drawn)
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 1. The shape of the thing
|
|
24
|
+
|
|
25
|
+
There is no agent API. An agent calls `editor.createShapes(...)`, and so does the
|
|
26
|
+
toolbar; an agent calls `editor.markHistoryStoppingPoint("style")`, and so does
|
|
27
|
+
the style panel — that exact call is in `packages/mocanvas/src/ui/StylePanel.tsx`.
|
|
28
|
+
"Convert to embed", "fit frame to content" and "auto size" in
|
|
29
|
+
`packages/mocanvas/src/ui/menu-items.tsx` are each a `markHistoryStoppingPoint`
|
|
30
|
+
followed by ordinary editor calls.
|
|
31
|
+
|
|
32
|
+
Three things follow. **There is no second surface to keep in sync** — a method an
|
|
33
|
+
agent can reach is one the UI already exercises, under the same tests. **Nothing
|
|
34
|
+
an agent does is outside the document**: writes go through `editor.run`, which
|
|
35
|
+
opens one store operation, so a sync layer sees an agent's work the way it sees a
|
|
36
|
+
person's. And **nothing an agent does is un-undoable by a person** — an agent's
|
|
37
|
+
writes are recorded steps by default, so Cmd+Z takes them back. §5 covers turning
|
|
38
|
+
that off, and what turning it off does not promise. The UI layer is described in
|
|
39
|
+
[UI.md](UI.md); none of it is required.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 2. A document with no browser
|
|
44
|
+
|
|
45
|
+
`createStore()` builds a store with the editor's record types and seeds the two
|
|
46
|
+
records every document has — the document itself and page 1 — so a store no
|
|
47
|
+
editor has touched is already a readable document:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { createStore, defaultShapeUtils, defaultBindingUtils } from "@mocanvas/mocanvas"
|
|
51
|
+
|
|
52
|
+
const store = createStore({ shapeUtils: defaultShapeUtils, bindingUtils: defaultBindingUtils })
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Pass the same util lists you will give the editor: their `static migrations` are
|
|
56
|
+
collected into the schema, so a board persisted before a prop existed is backfilled
|
|
57
|
+
on load. Pass `seed: false` when you will supply your own page afterwards, or that
|
|
58
|
+
store ends up with two pages at the same index — and equal indices have no defined
|
|
59
|
+
order.
|
|
60
|
+
|
|
61
|
+
The WebAssembly engine loads once per realm: `await loadEngine()` in a browser,
|
|
62
|
+
which needs no bundler configuration, and `loadEngineSync(bytes)` in Node — what
|
|
63
|
+
this repository's own tests use.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { readFileSync } from "node:fs"
|
|
67
|
+
import { fileURLToPath } from "node:url"
|
|
68
|
+
import { loadEngineSync } from "@mocanvas/mocanvas"
|
|
69
|
+
|
|
70
|
+
const wasmPath = fileURLToPath(import.meta.resolve("@mocanvas/wasm/pkg/mocanvas_bg.wasm"))
|
|
71
|
+
const engine = loadEngineSync(readFileSync(wasmPath))
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`await loadEngine()` does work in Node, but not by the route you want: it fetches
|
|
75
|
+
`new URL("../pkg/mocanvas_bg.wasm", import.meta.url)`, Node's `fetch` cannot read a
|
|
76
|
+
`file:` URL, and the loader falls back to a base64 copy embedded in the package
|
|
77
|
+
with a one-time warning.
|
|
78
|
+
|
|
79
|
+
### The editor, with no element
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { Editor, defaultShapeUtils, defaultBindingUtils, defaultTools } from "@mocanvas/mocanvas"
|
|
83
|
+
|
|
84
|
+
const editor = new Editor({
|
|
85
|
+
store,
|
|
86
|
+
shapeUtils: defaultShapeUtils,
|
|
87
|
+
bindingUtils: defaultBindingUtils,
|
|
88
|
+
tools: defaultTools,
|
|
89
|
+
engine,
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**No `getContainer`.** It became optional in 4.8.3, and optional is the point: an
|
|
94
|
+
editor with no screen has no element to give, and requiring one made every headless
|
|
95
|
+
caller begin by inventing a fake DOM node (`getContainer: () => ({}) as HTMLElement`).
|
|
96
|
+
Nothing inside ever needed it — `Editor`'s private `safeContainer()` has always
|
|
97
|
+
treated a missing or throwing container as "no container". It was the type
|
|
98
|
+
insisting, not the runtime.
|
|
99
|
+
|
|
100
|
+
Ask for the element anyway and `editor.getContainer()` — and `editor.container`,
|
|
101
|
+
which calls it — **throws**, saying this editor was built without `getContainer` so
|
|
102
|
+
it has no container, which is expected for a headless editor: a stated condition
|
|
103
|
+
rather than a `TypeError` two layers down. Everything that merely *looks* for one
|
|
104
|
+
copes. `getContainerDocument()` and `getContainerWindow()` return `undefined`, and
|
|
105
|
+
`focus()` and `blur()` are DOM no-ops that still record the state on the instance
|
|
106
|
+
record, so `getIsFocused()` stays answerable.
|
|
107
|
+
|
|
108
|
+
Two things are genuinely different without a DOM. **Text is estimated, not
|
|
109
|
+
measured** — `TextMeasure` falls back to `estimateTextSize` where no `document`
|
|
110
|
+
exists, so label sizes and any auto-grown height (`growY`) are close but not
|
|
111
|
+
identical to a browser's. And **the viewport is 0×0 until you say otherwise**: call
|
|
112
|
+
`editor.updateViewportScreenBounds({ x: 0, y: 0, w: 1600, h: 1000 })` before
|
|
113
|
+
`zoomToFit()`, or the move is deferred and a warning says so. Importing
|
|
114
|
+
`@mocanvas/mocanvas` in plain Node works; the JS entry stopped importing the
|
|
115
|
+
stylesheet after 4.0.2. Call `editor.dispose()` when the run is over.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 3. Writing
|
|
120
|
+
|
|
121
|
+
`createShapes`, `updateShapes` and `deleteShapes` are the three verbs. Each takes an
|
|
122
|
+
array and returns the editor, so they chain.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { createShapeId, type GeoShape } from "@mocanvas/mocanvas"
|
|
126
|
+
|
|
127
|
+
const id = createShapeId("summary")
|
|
128
|
+
|
|
129
|
+
editor.createShapes<GeoShape>([
|
|
130
|
+
{ id, type: "geo", x: 0, y: 0, props: { geo: "rectangle", w: 240, h: 120 } },
|
|
131
|
+
])
|
|
132
|
+
editor.updateShapes<GeoShape>([{ id, type: "geo", x: 40, props: { w: 300 } }])
|
|
133
|
+
editor.deleteShapes([id])
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- **Props you leave out come from `getDefaultProps()`**, which keeps a model's
|
|
137
|
+
output short.
|
|
138
|
+
- **Style props you leave out come from the *instance*.** `createShapes` fills any
|
|
139
|
+
unset style prop from `stylesForNextShape` — the colour and size the last
|
|
140
|
+
interaction left behind. Right for a person, surprising for a program: if the
|
|
141
|
+
same input should give the same document, set style props explicitly.
|
|
142
|
+
- **`updateShapes` merges `props` and `meta` and replaces everything else.** A
|
|
143
|
+
partial naming a shape that is not there is skipped rather than throwing, as is a
|
|
144
|
+
`null` or `undefined` entry.
|
|
145
|
+
- **`deleteShapes` takes descendants with the parent**, skips locked shapes, and
|
|
146
|
+
clears the selection, hover and editing state of anything it removes.
|
|
147
|
+
|
|
148
|
+
### One gesture, one `run`, one mark
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
editor.markHistoryStoppingPoint("lay out the diagram")
|
|
152
|
+
editor.run(() => {
|
|
153
|
+
editor.createShapes(boxes)
|
|
154
|
+
editor.createShapes(arrows)
|
|
155
|
+
editor.updateShapes(adjustments)
|
|
156
|
+
})
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`editor.run(fn)` opens an operation on the *store*, not merely a signal transaction:
|
|
160
|
+
one history entry, one notification, however many writes happen inside. Before 4.8.1
|
|
161
|
+
it was a `transact` alone, so two `updateShapes` calls in one `run` reached a
|
|
162
|
+
listener as two notifications, the first carrying a half-applied state the document
|
|
163
|
+
was never meant to be in — and a sync binding published that state to its peers.
|
|
164
|
+
|
|
165
|
+
`markHistoryStoppingPoint(name?)` puts a mark in the history and returns its id.
|
|
166
|
+
**One gesture should be one mark.** The unit a person expects Cmd+Z to take back is
|
|
167
|
+
the thing they asked for — "draw the plan", not "the fourteenth arrow of the plan" —
|
|
168
|
+
and the mark defines that unit. `editor.mark(name)` is an alias;
|
|
169
|
+
`editor.bailToMark(id)` abandons a half-finished gesture using the returned id.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## 4. Reading back
|
|
174
|
+
|
|
175
|
+
The half an agent needs and rarely gets: asking what is on the page, and where,
|
|
176
|
+
without anything having been drawn. All of it works headless, because geometry and
|
|
177
|
+
hit-testing live in the engine rather than in the renderer.
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
editor.getCurrentPageShapes() // Shape[] — store order
|
|
181
|
+
editor.getCurrentPageShapesSorted() // draw order, depth-first through children
|
|
182
|
+
editor.getCurrentPageShapeIds() // Set<ShapeId>
|
|
183
|
+
editor.getShape<GeoShape>(id) // one shape, or undefined
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`getShape` takes an id or a shape and returns the discriminated `Shape` union by
|
|
187
|
+
default, so `if (shape.type === "geo")` narrows `shape.props` at the call site. Pass
|
|
188
|
+
a type argument for a specific shape, or `UnknownShape` for a type nothing
|
|
189
|
+
registered.
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
editor.getShapePageBounds(id) // Box | undefined — one shape, in page space
|
|
193
|
+
editor.getShapeGeometryBounds(id) // Box | undefined — the same box, shape-local
|
|
194
|
+
editor.getCurrentPageBounds() // Box | undefined — the union, undefined when empty
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`getCurrentPageBounds()` is **geometry, not ink**: the union of
|
|
198
|
+
`getShapePageBounds()`, deliberately excluding the half-stroke pad the engine keeps
|
|
199
|
+
for culling. The pad is per-shape, so including it would inflate the box and shift
|
|
200
|
+
its centre whenever the outermost shapes carried different stroke widths — making
|
|
201
|
+
"centre this on the page" depend on how thickly it was drawn. `Box` gives you
|
|
202
|
+
`center`, `minX`/`maxX`/`minY`/`maxY`, `corners` and `containsPoint`.
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
editor.getShapeAtPoint({ x: 420, y: 260 }) // UnknownShape | undefined, topmost
|
|
206
|
+
editor.getShapesAtPoint({ x: 420, y: 260 }) // UnknownShape[], topmost first
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Both call `flushEngine()` first, so a shape created a line earlier is already in the
|
|
210
|
+
spatial index: **no frame has to be rendered for a hit test to be correct**, and none
|
|
211
|
+
is. The default answers "the interior of a filled shape, or the outline of any
|
|
212
|
+
shape", which is what a pointer would hit. `{ hitInside: true }` also counts the
|
|
213
|
+
interior of a *hollow* shape; that case goes the geometry route rather than the
|
|
214
|
+
engine's index, which is a page scan, so it is a deliberate opt-in. Tolerance comes
|
|
215
|
+
from `editor.getHitTestMargin()`, divided by the current zoom — headless with no
|
|
216
|
+
camera moved, that zoom is 1; pass `{ margin }` to fix it yourself if a run should
|
|
217
|
+
not depend on the configured default. `editor.getPointInShapeSpace(shape, point)`
|
|
218
|
+
converts a page point into a shape's local frame, which is what you want before
|
|
219
|
+
comparing against `w` and `h`.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## 5. Keeping an agent's writes out of a person's undo stack
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
editor.run(() => {
|
|
227
|
+
editor.updateShapes(layoutCorrections)
|
|
228
|
+
}, { history: "ignore" })
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`history: "ignore"` means the writes inside the callback are **not a step in the undo
|
|
232
|
+
stack**. Use it for work the user did not ask for and would not think of as their own
|
|
233
|
+
edit: a derived layout pass, a label re-flow, an annotation an agent maintains in the
|
|
234
|
+
background. Without it, a person's next Cmd+Z takes back your housekeeping instead of
|
|
235
|
+
their last edit.
|
|
236
|
+
|
|
237
|
+
The nesting matters. The history manager is a store *listener*: it reads its ignore
|
|
238
|
+
counter when the notification arrives, which — now that `run` is one operation — is
|
|
239
|
+
after the callback has returned. So the ignore goes outside the operation, not inside
|
|
240
|
+
it:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
store.atomic(() => history.ignore(fn)) // 4.8.1 — wrong way round
|
|
244
|
+
history.ignore(() => store.atomic(fn)) // 4.8.2 onwards
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
4.8.1 had it inside, and ignored writes went into the undo stack after all. If you are
|
|
248
|
+
on 4.8.1, upgrade straight past it.
|
|
249
|
+
|
|
250
|
+
### What it does not promise
|
|
251
|
+
|
|
252
|
+
This is the part that bites in production. **An ignored write to a record the user
|
|
253
|
+
then undoes is still reverted.**
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
editor.markHistoryStoppingPoint()
|
|
257
|
+
editor.updateShapes([{ id: S, type: "geo", x: 100 }])
|
|
258
|
+
|
|
259
|
+
editor.run(() => editor.updateShapes([{ id: S, type: "geo", y: 250 }]), { history: "ignore" })
|
|
260
|
+
|
|
261
|
+
editor.undo() // S.x back to 0 — and S.y back to 0 as well
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Undo restores **whole records, not fields**. Putting `S` back as it was at the mark
|
|
265
|
+
puts back every field of `S`, the ignored one included. `history: "ignore"` promises
|
|
266
|
+
your write is not a *step*; it cannot promise that some other step will not overwrite
|
|
267
|
+
it. This is not a regression and not a mocanvas quirk — it behaves the same on 4.8.0
|
|
268
|
+
and earlier, and the same in the reference implementation this API is shaped after. A
|
|
269
|
+
test in this repository asserts it, so the next person to meet it can tell which of
|
|
270
|
+
the two problems they have.
|
|
271
|
+
|
|
272
|
+
The practical consequence: **if an agent's output must survive a user's undo, put it
|
|
273
|
+
in records the user is not editing.** A separate shape, or an app record type of your
|
|
274
|
+
own, is safe; a field tucked into a shape the user is dragging is not. The same option
|
|
275
|
+
also takes `"record"` (the default) and `"record-preserveRedoStack"`, which records
|
|
276
|
+
the step without discarding a redo stack the user might still want.
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## 6. Reproducible runs
|
|
281
|
+
|
|
282
|
+
Two runs of the same agent over the same input should produce the same document, or
|
|
283
|
+
you cannot diff them, snapshot them in a test, or tell a model's change from a
|
|
284
|
+
library's. Two things stand in the way; both are controllable.
|
|
285
|
+
|
|
286
|
+
**Ids.** `createShapeId()` with no argument generates a random unique part;
|
|
287
|
+
`createShapeId("box-1")` returns `shape:box-1` verbatim. Name your ids from your input
|
|
288
|
+
and the same plan yields the same records. `createBindingId` is the same.
|
|
289
|
+
|
|
290
|
+
**Index-key jitter.** A record's `index` is a fractional index — an order key that
|
|
291
|
+
sorts lexicographically and between which new keys can always be generated. Plain
|
|
292
|
+
fractional indexing is a pure function of its two neighbours, so two clients inserting
|
|
293
|
+
into the same gap generate the *same* key every single time; `index` is one register to
|
|
294
|
+
a last-writer-wins merge, so one of the two shapes is merged away. mocanvas appends six
|
|
295
|
+
random digits from a 61-character alphabet, so the keys differ while staying in the
|
|
296
|
+
same gap. That is what makes concurrent insertion safe — and it makes every generated
|
|
297
|
+
key unpredictable, which is exactly wrong for a run you want to compare. So jitter is
|
|
298
|
+
off under a test runner. The resolution order, in `packages/store/src/indexKey.ts`:
|
|
299
|
+
|
|
300
|
+
1. An explicit `setIndexJitterEnabled(true | false)` wins for the rest of the process;
|
|
301
|
+
`null` restores the default.
|
|
302
|
+
2. Otherwise `MOCANVAS_INDEX_JITTER`, if set. `"0"`, `"false"` and `"off"` mean off;
|
|
303
|
+
anything else means on.
|
|
304
|
+
3. Otherwise `NODE_ENV === "test"` turns it off.
|
|
305
|
+
4. Otherwise it is on.
|
|
306
|
+
|
|
307
|
+
The environment variable is the one to reach for in a runner that does not set
|
|
308
|
+
`NODE_ENV`, or that sets it to `test` when you did not want that. Both helpers come
|
|
309
|
+
from `@mocanvas/store`; `@mocanvas/editor` re-exports the index-key *generators* but
|
|
310
|
+
not these two. It is process-wide rather than per-call because those generators are
|
|
311
|
+
free functions reached from everywhere, `Editor` included.
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
import { setIndexJitterEnabled } from "@mocanvas/store"
|
|
315
|
+
|
|
316
|
+
setIndexJitterEnabled(false) // process-wide; pass null to restore the default
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
With named ids and jitter off, `serializeMocanvasFile(editor)` returns a string you can
|
|
320
|
+
compare byte for byte between runs, and `store.getStoreSnapshot()` returns the records
|
|
321
|
+
and the schema version for a structural assertion.
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## 7. Validation on the way in
|
|
326
|
+
|
|
327
|
+
Records are checked against their type as they enter the store, so a malformed shape
|
|
328
|
+
from a model throws at `put` — the moment it is written — rather than mounting and
|
|
329
|
+
failing somewhere in geometry or rendering. `Store.put` calls `schema.validateRecord`
|
|
330
|
+
for every record, before any `before*` side effect and before the record is frozen and
|
|
331
|
+
written. The message names the path:
|
|
332
|
+
|
|
333
|
+
```
|
|
334
|
+
At shape.props.w: Expected number, got undefined
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
An unregistered shape type is caught earlier still: `createShapes` asks
|
|
338
|
+
`getShapeUtil(partial.type)` first, which throws `No ShapeUtil registered for type
|
|
339
|
+
"flowchart"` without touching the store. What each shape is validated against is its
|
|
340
|
+
util's `static props` map — the same map [CUSTOM_SHAPES.md](CUSTOM_SHAPES.md) shows you
|
|
341
|
+
writing, which is why validation covers your own shapes and not only the built-in ones.
|
|
342
|
+
|
|
343
|
+
Failures throw by default. `StoreSchema.create` accepts an `onValidationFailure`
|
|
344
|
+
handler that can repair a record and return it instead — how a `.tldr` file from an
|
|
345
|
+
older version gets loaded rather than rejected — but `createStore` does not plumb that
|
|
346
|
+
option through, so a store built the ordinary way throws. For an agent that is usually
|
|
347
|
+
right: a model that produced `w: "180"` should be told, not silently corrected.
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## 8. A worked example: a plan, drawn
|
|
352
|
+
|
|
353
|
+
A plan is data — boxes and arrows with stable names. Turning it into a document is one
|
|
354
|
+
function, and nothing in it is agent-specific: the same code behind a toolbar button
|
|
355
|
+
would be the same code.
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
import { createShapeId, toRichText, type ArrowShape, type Editor, type GeoShape } from "@mocanvas/mocanvas"
|
|
359
|
+
|
|
360
|
+
export interface Plan {
|
|
361
|
+
boxes: { name: string; label: string; x: number; y: number; w?: number; h?: number }[]
|
|
362
|
+
arrows: { from: string; to: string; label?: string }[]
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function drawPlan(editor: Editor, plan: Plan): void {
|
|
366
|
+
editor.markHistoryStoppingPoint("draw plan")
|
|
367
|
+
|
|
368
|
+
editor.run(() => {
|
|
369
|
+
editor.createShapes<GeoShape>(
|
|
370
|
+
plan.boxes.map((box) => ({
|
|
371
|
+
id: createShapeId(box.name),
|
|
372
|
+
type: "geo",
|
|
373
|
+
x: box.x,
|
|
374
|
+
y: box.y,
|
|
375
|
+
// Styles are explicit: anything left out would come from
|
|
376
|
+
// `stylesForNextShape`, whatever the last interaction left there.
|
|
377
|
+
props: {
|
|
378
|
+
geo: "rectangle",
|
|
379
|
+
w: box.w ?? 180,
|
|
380
|
+
h: box.h ?? 90,
|
|
381
|
+
richText: toRichText(box.label),
|
|
382
|
+
color: "black",
|
|
383
|
+
fill: "none",
|
|
384
|
+
dash: "draw",
|
|
385
|
+
size: "m",
|
|
386
|
+
},
|
|
387
|
+
})),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
for (const edge of plan.arrows) {
|
|
391
|
+
const fromId = createShapeId(edge.from)
|
|
392
|
+
const toId = createShapeId(edge.to)
|
|
393
|
+
const from = editor.getShapePageBounds(fromId)
|
|
394
|
+
const to = editor.getShapePageBounds(toId)
|
|
395
|
+
if (!from || !to) continue
|
|
396
|
+
|
|
397
|
+
const arrowId = createShapeId(`${edge.from}->${edge.to}`)
|
|
398
|
+
|
|
399
|
+
// The arrow sits at the page origin, so its local space and page space are the
|
|
400
|
+
// same frame and the two centres go straight in. These are the *static*
|
|
401
|
+
// terminals; the bindings below take over while they exist.
|
|
402
|
+
editor.createShape<ArrowShape>({
|
|
403
|
+
id: arrowId,
|
|
404
|
+
type: "arrow",
|
|
405
|
+
x: 0,
|
|
406
|
+
y: 0,
|
|
407
|
+
props: {
|
|
408
|
+
kind: "arc",
|
|
409
|
+
start: { x: from.center.x, y: from.center.y },
|
|
410
|
+
end: { x: to.center.x, y: to.center.y },
|
|
411
|
+
...(edge.label ? { richText: toRichText(edge.label) } : {}),
|
|
412
|
+
},
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
editor.createBindings([
|
|
416
|
+
{ type: "arrow", fromId: arrowId, toId: fromId, props: { terminal: "start" } },
|
|
417
|
+
{ type: "arrow", fromId: arrowId, toId: toId, props: { terminal: "end" } },
|
|
418
|
+
])
|
|
419
|
+
}
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
### Why the bindings, and why the arrow still ends on the edge
|
|
425
|
+
|
|
426
|
+
The two points above are box *centres*, not edge points — and the drawn arrow runs edge
|
|
427
|
+
to edge anyway. That is the binding doing it, not arithmetic you were spared writing.
|
|
428
|
+
`ArrowShapeUtil.getGeometry` resolves its terminals through
|
|
429
|
+
`getArrowTerminalsInArrowSpace` on every read: a bound terminal starts at the anchor
|
|
430
|
+
inside the bound shape's bounds (the default `normalizedAnchor` is `{ x: 0.5, y: 0.5 }`,
|
|
431
|
+
the centre) and is pulled back to where the arrow body first crosses that shape's
|
|
432
|
+
**outline** on its way from the opposite terminal, then back a little further, by
|
|
433
|
+
`ARROW_TERMINAL_GAP_STROKES` (2.7) times the stroke width. An arrowhead resting on a
|
|
434
|
+
border reads as part of that border; a small gap reads as pointing at it.
|
|
435
|
+
|
|
436
|
+
So the geometry is right from the moment the binding exists, before anything is rendered
|
|
437
|
+
— `editor.getShapePageBounds(arrowId)` already reflects it. The static `props.start` /
|
|
438
|
+
`props.end` are a fallback: `ArrowBindingUtil` rewrites them when the bound shape
|
|
439
|
+
changes, and freezes the terminal at its current position if the bound shape is deleted
|
|
440
|
+
or the binding is isolated. Give them sensible values, as above, so an isolated arrow
|
|
441
|
+
does not snap to the origin. The consequence you actually wanted is that moving a box
|
|
442
|
+
afterwards moves the arrows with it, whether the move came from your agent or from
|
|
443
|
+
someone dragging it.
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
drawPlan(editor, {
|
|
447
|
+
boxes: [
|
|
448
|
+
{ name: "ingest", label: "Ingest", x: 0, y: 0 },
|
|
449
|
+
{ name: "parse", label: "Parse", x: 320, y: 0 },
|
|
450
|
+
{ name: "store", label: "Store", x: 640, y: 0 },
|
|
451
|
+
],
|
|
452
|
+
arrows: [{ from: "ingest", to: "parse" }, { from: "parse", to: "store", label: "validated" }],
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
editor.updateViewportScreenBounds({ x: 0, y: 0, w: 1600, h: 1000 })
|
|
456
|
+
editor.zoomToFit()
|
|
457
|
+
|
|
458
|
+
editor.getCurrentPageBounds() // Box over the three shapes
|
|
459
|
+
editor.getShapeAtPoint({ x: 90, y: 45 })?.id // shape:ingest
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Undo takes the whole plan back in one step, because the whole plan was one `run` after
|
|
463
|
+
one mark. That is the property worth protecting as the agent grows: one gesture, one
|
|
464
|
+
mark, whatever the model decided to do inside it.
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
## Where to go next
|
|
469
|
+
|
|
470
|
+
- [CUSTOM_SHAPES.md](CUSTOM_SHAPES.md) — a shape of your own, and the `static props`
|
|
471
|
+
map that validates it.
|
|
472
|
+
- [UI.md](UI.md) — the default interface, for the half a person drives.
|
|
473
|
+
- [ARCHITECTURE.md](ARCHITECTURE.md) — how the store, the command stream and the engine
|
|
474
|
+
fit together.
|
|
475
|
+
- [COMPAT.md](COMPAT.md) — what exists against the tldraw 5.4 API model, and what
|
|
476
|
+
deliberately does not.
|
package/CUSTOM_SHAPES.md
CHANGED
|
@@ -685,8 +685,13 @@ export function App() {
|
|
|
685
685
|
}
|
|
686
686
|
```
|
|
687
687
|
|
|
688
|
-
The default toolbar
|
|
689
|
-
|
|
688
|
+
The default toolbar will grow a button for your tool: register it in the UI
|
|
689
|
+
tool list with `overrides.tools` and the toolbar collects any id outside its own
|
|
690
|
+
layout into an extras group after a divider. [TOOLS.md](TOOLS.md) §10 covers
|
|
691
|
+
that, along with the keyboard shortcut.
|
|
692
|
+
|
|
693
|
+
You can also skip the built-in chrome entirely and drive the tool from your own,
|
|
694
|
+
rendered as `children` of `<Mocanvas>`:
|
|
690
695
|
|
|
691
696
|
```tsx
|
|
692
697
|
<Mocanvas shapeUtils={[CalloutShapeUtil]} tools={[CalloutTool]}>
|