@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/FILES.md
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
# Saving, loading and exporting
|
|
2
|
+
|
|
3
|
+
How a document gets out of the editor and back in again. By the end you will
|
|
4
|
+
know what a snapshot contains and how to restore one, how to read and write
|
|
5
|
+
`.tldr` files (including ones from the reference implementation), what runs when
|
|
6
|
+
an old file meets a new build, and how to produce a PNG or an SVG of a page or a
|
|
7
|
+
selection.
|
|
8
|
+
|
|
9
|
+
Everything here is public API from `@mocanvas/mocanvas`. Read
|
|
10
|
+
[ARCHITECTURE.md](ARCHITECTURE.md) for the layering and
|
|
11
|
+
[CUSTOM_SHAPES.md](CUSTOM_SHAPES.md) for writing a shape; this is the document
|
|
12
|
+
where your shapes have to survive being written to disk.
|
|
13
|
+
|
|
14
|
+
Contents:
|
|
15
|
+
|
|
16
|
+
1. [Snapshots](#1-snapshots)
|
|
17
|
+
2. [`.tldr` files](#2-tldr-files)
|
|
18
|
+
3. [Migrations](#3-migrations)
|
|
19
|
+
4. [Image export](#4-image-export)
|
|
20
|
+
5. [The clipboard and printing](#5-the-clipboard-and-printing)
|
|
21
|
+
6. [Assets](#6-assets)
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 1. Snapshots
|
|
26
|
+
|
|
27
|
+
A snapshot is the store's records as plain JSON, plus the schema version they
|
|
28
|
+
were written under:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
interface StoreSnapshot<R> {
|
|
32
|
+
store: Record<string, R> // records keyed by id
|
|
33
|
+
schema: { schemaVersion: 2; sequences: { [sequenceId: string]: number } }
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The schema half is not decoration: it says how far each migration sequence had
|
|
38
|
+
run when the records were saved, which is the only thing that lets a later build
|
|
39
|
+
know what to do with them (§3).
|
|
40
|
+
|
|
41
|
+
### Record scopes
|
|
42
|
+
|
|
43
|
+
Every record type declares a scope. A snapshot carries one of them, or all:
|
|
44
|
+
|
|
45
|
+
| Scope | Record types | What it is |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| `document` | `document`, `page`, `shape`, `binding`, `asset`, plus any custom record types you registered (the comment types, opted into via `records:`, are document-scoped too) | The board. Shared by everybody; what a file holds. |
|
|
48
|
+
| `session` | `instance`, `camera`, `instance_page_state`, `pointer` | One person's viewpoint: current page, camera, selection. |
|
|
49
|
+
| `presence` | `instance_presence` | Other people's cursors. Never persisted. |
|
|
50
|
+
|
|
51
|
+
`editor.store.getStoreSnapshot()` defaults to `document`, which is almost always
|
|
52
|
+
what you want to save. Pass `"all"` for everything.
|
|
53
|
+
|
|
54
|
+
### Taking and restoring one
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import type { Editor, TLEditorSnapshot } from "@mocanvas/mocanvas"
|
|
58
|
+
|
|
59
|
+
const snapshot: TLEditorSnapshot = editor.getSnapshot()
|
|
60
|
+
editor.loadSnapshot(snapshot)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`getSnapshot()` returns both halves — `{ document, session }` — because
|
|
64
|
+
restoring a document without a session drops the reader at the origin of page
|
|
65
|
+
one, and restoring a session against a different document points at shapes that
|
|
66
|
+
are not there. They travel together and load separately. `loadSnapshot` also
|
|
67
|
+
accepts a bare document snapshot (`EditorStoreSnapshot`).
|
|
68
|
+
|
|
69
|
+
Loading is **not undoable** — it replaces the document, so there is nothing
|
|
70
|
+
coherent to go back to — and the history is cleared rather than left holding
|
|
71
|
+
steps against records that no longer exist. The session half is applied
|
|
72
|
+
defensively: a page that is gone falls back to the first page, selected shapes
|
|
73
|
+
that are gone are dropped, and a session from a version this build does not know
|
|
74
|
+
(`CURRENT_SESSION_SCHEMA_VERSION`) is ignored entirely rather than half-applied.
|
|
75
|
+
Pass `{ forceOverwriteSessionState: true }` to land on the first page whatever
|
|
76
|
+
the session says. `getSessionStateSnapshot(editor)` gets that half on its own,
|
|
77
|
+
for an app that persists the document on a server and the viewpoint in
|
|
78
|
+
`localStorage`.
|
|
79
|
+
|
|
80
|
+
### Opening one in the component
|
|
81
|
+
|
|
82
|
+
`<Mocanvas />` takes a `store`, not a `snapshot`, so either build the store with
|
|
83
|
+
the document already in it or load it once the editor exists:
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
import { Mocanvas, createStore, defaultShapeUtils, defaultBindingUtils } from "@mocanvas/mocanvas"
|
|
87
|
+
|
|
88
|
+
const store = createStore({ shapeUtils: defaultShapeUtils, bindingUtils: defaultBindingUtils, snapshot })
|
|
89
|
+
|
|
90
|
+
<Mocanvas store={store} />
|
|
91
|
+
<Mocanvas onMount={(editor) => editor.loadSnapshot(snapshot)} />
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Build the store once (in a `useMemo` or outside the component) rather than on
|
|
95
|
+
every render. `createStore` turns its own page seeding off when `snapshot` or
|
|
96
|
+
`initialData` is supplied, so you do not get an empty default page beside the
|
|
97
|
+
loaded ones.
|
|
98
|
+
|
|
99
|
+
To draw a snapshot without mounting an editor — a thumbnail grid, a document
|
|
100
|
+
listing — use `<TldrawImage snapshot={…} />`. It builds a throwaway headless
|
|
101
|
+
editor, renders through the same SVG exporter as §4, and disposes of it, so a
|
|
102
|
+
thumbnail and a downloaded file agree.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 2. `.tldr` files
|
|
107
|
+
|
|
108
|
+
A `.tldr` file is a snapshot in an envelope:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"tldrawFileFormatVersion": 1,
|
|
113
|
+
"schema": { "schemaVersion": 2, "sequences": { "com.tldraw.shape.geo": 12 } },
|
|
114
|
+
"records": [ { "id": "page:abc", "typeName": "page", "…": "…" } ]
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The writer pretty-prints and sorts every object's keys recursively, so the same
|
|
119
|
+
document always produces byte-identical text — which is what makes a file
|
|
120
|
+
diffable and a content hash of it meaningful.
|
|
121
|
+
|
|
122
|
+
### Writing
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { serializeMocanvasFile, serializeTldrawJsonBlob, downloadBlob, TLDRAW_FILE_EXTENSION } from "@mocanvas/mocanvas"
|
|
126
|
+
import type { Editor } from "@mocanvas/mocanvas"
|
|
127
|
+
|
|
128
|
+
const text = serializeMocanvasFile(editor) // synchronous, returns JSON text
|
|
129
|
+
|
|
130
|
+
async function saveToDisk(editor: Editor, name: string) {
|
|
131
|
+
const blob = await serializeTldrawJsonBlob(editor)
|
|
132
|
+
downloadBlob(blob, `${name}${TLDRAW_FILE_EXTENSION}`)
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`serializeTldrawJson` and `serializeTldrawJsonBlob` are asynchronous aliases of
|
|
137
|
+
`serializeMocanvasFile` — nothing in them awaits today, but the signature is
|
|
138
|
+
asynchronous by contract so an app that resolves assets before saving (§6) can
|
|
139
|
+
slot in without every call site changing. Only `document`-scope records are
|
|
140
|
+
written: a `.tldr` is the board, not your camera.
|
|
141
|
+
|
|
142
|
+
One inconsistency worth knowing: `TLDRAW_FILE_EXTENSION` is `".tldr"`, but the
|
|
143
|
+
built-in **File → Save a copy** menu item names its download `.mocanvas`. The
|
|
144
|
+
contents are the same JSON either way.
|
|
145
|
+
|
|
146
|
+
### Reading
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { loadMocanvasFile } from "@mocanvas/mocanvas"
|
|
150
|
+
|
|
151
|
+
const result = loadMocanvasFile(editor, await file.text())
|
|
152
|
+
if (!result.ok) console.error("could not read the file", result.error)
|
|
153
|
+
else if (result.warnings.length) console.warn(result.warnings.join("\n"))
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`loadMocanvasFile` takes the JSON text or already-parsed JSON. Before the records
|
|
157
|
+
reach the store they go through `normalizeLoadedRecords`, which turns what
|
|
158
|
+
different generations of the format spell differently into the one form the
|
|
159
|
+
shape utils expect — a label that arrived as a plain string becomes a rich-text
|
|
160
|
+
document, a freehand stroke packed as one base64 blob becomes an array of
|
|
161
|
+
points. It never throws: whatever it cannot make sense of comes back in
|
|
162
|
+
`warnings`, which names shapes whose props were repaired, segments that had to
|
|
163
|
+
be dropped, and shape types with no registered util. After loading it clears the
|
|
164
|
+
history, points the instance at a page that exists, and calls `zoomToFit`.
|
|
165
|
+
|
|
166
|
+
To inspect a file without an editor — a document picker, a server validating an
|
|
167
|
+
upload, a migration script — use `parseTldrawJsonFile`:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { parseTldrawJsonFile } from "@mocanvas/mocanvas"
|
|
171
|
+
|
|
172
|
+
const parsed = parseTldrawJsonFile({ json: text })
|
|
173
|
+
if (parsed.ok) {
|
|
174
|
+
console.log(parsed.value.records.length, "records")
|
|
175
|
+
} else {
|
|
176
|
+
switch (parsed.error.type) {
|
|
177
|
+
case "v1File": // the pre-envelope format; convertible, not loadable
|
|
178
|
+
case "invalidRecords": // records missing, duplicated or not record-shaped
|
|
179
|
+
case "fileFormatVersionTooNew": // envelope version above 1
|
|
180
|
+
case "notATldrawFile": // not JSON, or no envelope at all
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Two honest caveats about that union. `fileFormatVersionTooNew` reports
|
|
186
|
+
`version: NaN` — the discriminant is right, the number is not carried through.
|
|
187
|
+
And `migrationFailed` is declared in `TldrawFileParseError` but the parser never
|
|
188
|
+
returns it, because parsing does not migrate: migration happens when the
|
|
189
|
+
snapshot goes into a store, and a failure there **throws**. Wrap
|
|
190
|
+
`loadMocanvasFile` in a `try` when the files are untrusted.
|
|
191
|
+
|
|
192
|
+
### A file from the reference implementation
|
|
193
|
+
|
|
194
|
+
It loads. The fixture in this repository (`apps/bench/public/compare.tldr`) is a
|
|
195
|
+
real tldraw file, loaded by the test suite on every run. The mechanism is the
|
|
196
|
+
sequence-id rule below: a migration sequence the file records but this schema
|
|
197
|
+
does not know is ignored with a `console.warn`, and those props load as written.
|
|
198
|
+
That works only because mocanvas's own built-in shape migrations live under
|
|
199
|
+
`com.mocanvas.shape.*`. Registering them under `com.tldraw.shape.geo` at version
|
|
200
|
+
1 — the naive port — made every real file fail with *"data comes from a newer
|
|
201
|
+
version"*, because the reference implementation's `geo` sequence was already
|
|
202
|
+
at 12.
|
|
203
|
+
|
|
204
|
+
The corollary matters to you: **your own custom shape types keep the
|
|
205
|
+
`com.tldraw.shape.<type>` prefix**, which is what `createShapePropsMigrationIds`
|
|
206
|
+
gives them. Nothing else claims those names, and documents in the wild already
|
|
207
|
+
record their progress under them.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## 3. Migrations
|
|
212
|
+
|
|
213
|
+
### What runs when
|
|
214
|
+
|
|
215
|
+
Every load — `store.loadStoreSnapshot`, and therefore `editor.loadSnapshot` and
|
|
216
|
+
`loadMocanvasFile` — does the same three things: compare the snapshot's
|
|
217
|
+
`schema.sequences` against the schema this build assembled from its shape,
|
|
218
|
+
binding, asset and custom-record utils; apply every migration each sequence has
|
|
219
|
+
gained since, in order; validate the records on the way in.
|
|
220
|
+
`store.migrateSnapshot(snapshot)` does the first two without loading. Either way
|
|
221
|
+
a failure raises rather than returning half-migrated data, so a caller can fail
|
|
222
|
+
closed.
|
|
223
|
+
|
|
224
|
+
Three outcomes are worth naming:
|
|
225
|
+
|
|
226
|
+
- **A sequence the persisted schema does not mention.** Migrations run from
|
|
227
|
+
version 0 if the sequence is `retroactive` (the default), and are skipped
|
|
228
|
+
otherwise. Use `retroactive: false` for a sequence added to a type that
|
|
229
|
+
already existed, where older data was already in the right shape.
|
|
230
|
+
- **A sequence in the file this schema does not know.** Ignored, with a console
|
|
231
|
+
warning. Those records load as written.
|
|
232
|
+
- **A sequence at a higher version than this build knows.** A hard error. There
|
|
233
|
+
is no sound way to run a migration backwards that you do not have.
|
|
234
|
+
|
|
235
|
+
A pre-sequence schema (`schemaVersion: 1`, one version number per record type)
|
|
236
|
+
is *recognised* and reported, never guessed at: there is no sound mapping from
|
|
237
|
+
per-type versions to sequence versions, and guessing would re-apply migrations
|
|
238
|
+
that had already run.
|
|
239
|
+
|
|
240
|
+
### What "unknown props are kept" means
|
|
241
|
+
|
|
242
|
+
Two escape hatches, both deliberate:
|
|
243
|
+
|
|
244
|
+
- **A record type this schema does not know** passes through validation
|
|
245
|
+
untouched. It is still checked for being record-shaped — an object with a
|
|
246
|
+
string `id` and a string `typeName` — and nothing else.
|
|
247
|
+
- **Props a known type does not declare are kept, not rejected.** Shape and
|
|
248
|
+
binding record types are built with `unknownProps: "keep"`. The declared props
|
|
249
|
+
are still validated; the rest ride along.
|
|
250
|
+
|
|
251
|
+
The reason is the round trip. A `.tldr` from a newer generation of the format
|
|
252
|
+
legitimately carries props this build has never heard of — `binding.props.snap`
|
|
253
|
+
is in the fixture here — and dropping or rejecting them would silently lose the
|
|
254
|
+
user's data on the next save. Open such a file in an old build, move one shape,
|
|
255
|
+
save, and the newer props are still in it. This is the *opposite* of the default
|
|
256
|
+
for a record your app is *making*: `createShapeValidator` on its own rejects
|
|
257
|
+
undeclared props, because there an undeclared prop is a typo or a forgotten
|
|
258
|
+
migration.
|
|
259
|
+
|
|
260
|
+
A shape whose *type* has no registered util keeps every prop it arrived with,
|
|
261
|
+
and its envelope fields (`x`, `y`, `index`, `parentId`…) are still validated —
|
|
262
|
+
those have been true of every shape record there has ever been. It will not
|
|
263
|
+
render, and `loadMocanvasFile` says so in `warnings`, but it saves back out
|
|
264
|
+
intact.
|
|
265
|
+
|
|
266
|
+
### Changing a shape's props
|
|
267
|
+
|
|
268
|
+
When you add, remove or reinterpret a prop, you owe the format a migration.
|
|
269
|
+
Without one, a board saved before the change loads with the prop missing and
|
|
270
|
+
every consumer reading `shape.props.whatever` gets `undefined`.
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
import {
|
|
274
|
+
createShapePropsMigrationIds,
|
|
275
|
+
createShapePropsMigrationSequence,
|
|
276
|
+
ShapeUtil,
|
|
277
|
+
type MigratableProps,
|
|
278
|
+
} from "@mocanvas/mocanvas"
|
|
279
|
+
|
|
280
|
+
const versions = createShapePropsMigrationIds("callout", { AddTailStyle: 1 })
|
|
281
|
+
|
|
282
|
+
const calloutMigrations = createShapePropsMigrationSequence({
|
|
283
|
+
sequence: [
|
|
284
|
+
{
|
|
285
|
+
id: versions.AddTailStyle, // "com.tldraw.shape.callout/1"
|
|
286
|
+
up(props: MigratableProps) {
|
|
287
|
+
props["tailStyle"] = "straight"
|
|
288
|
+
},
|
|
289
|
+
down(props: MigratableProps) {
|
|
290
|
+
delete props["tailStyle"]
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
export class CalloutShapeUtil extends ShapeUtil<CalloutShape> {
|
|
297
|
+
static override type = "callout" as const
|
|
298
|
+
static override props = calloutShapeProps
|
|
299
|
+
static override migrations = calloutMigrations
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The helpers check what they can where the sequence is written, rather than when
|
|
304
|
+
somebody's document fails to open: ids in one sequence must be numbered
|
|
305
|
+
`1, 2, 3, …` in order and all belong to the same type. `up` may mutate the props
|
|
306
|
+
in place or return a replacement, but must not change the record's id — the
|
|
307
|
+
store checks that afterwards and refuses the snapshot if it happened. `down` is
|
|
308
|
+
optional; the only thing that runs it is a downward record migration
|
|
309
|
+
(`store.schema.migratePersistedRecord(record, persistedSchema, "down")`), so
|
|
310
|
+
omit it unless you need that.
|
|
311
|
+
|
|
312
|
+
### The worked example: the note shape
|
|
313
|
+
|
|
314
|
+
`packages/mocanvas/src/shapes/shape-migrations.ts` holds the whole built-in set,
|
|
315
|
+
and the note shape shows what a prop change really costs.
|
|
316
|
+
|
|
317
|
+
Version 1 is the cheap kind: backfill the props that post-date the shape
|
|
318
|
+
(`fontSizeAdjustment`, `growY`, `url`, `scale`) with what `getDefaultProps`
|
|
319
|
+
would give them, and derive `richText` from a legacy plain `text` label. Every
|
|
320
|
+
built-in has one, and every one declares a version 1 even with nothing to
|
|
321
|
+
backfill — the group shape's is a no-op whose only job is to name the sequence,
|
|
322
|
+
because an empty sequence carries no id and would fall back to
|
|
323
|
+
`com.tldraw.shape.group`, the claim the prefix rule forbids.
|
|
324
|
+
|
|
325
|
+
Version 2 is the expensive kind: it changes what `growY` *means*. `growY` is how
|
|
326
|
+
much taller than its square a note had to be for the text to fit, and it used to
|
|
327
|
+
be measured against a font, width and padding already multiplied by `scale`,
|
|
328
|
+
then added to an already-scaled box:
|
|
329
|
+
|
|
330
|
+
```
|
|
331
|
+
was: height = 200 * scale + growY
|
|
332
|
+
now: height = (200 + growY) * scale
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Both are self-consistent, which is why nothing caught it: they agree whenever
|
|
336
|
+
`scale` is 1 or `growY` is 0, so every note the library grew itself was correct.
|
|
337
|
+
They disagree about every note arriving from *outside* — a `.tldr`, or an app
|
|
338
|
+
writing records directly — where `growY` is in unscaled units, and such a note
|
|
339
|
+
came out `growY * (scale - 1)` too short. The fix is one line, `growY / scale`,
|
|
340
|
+
run once on load; the cost is everything round it — a second version, a `down`
|
|
341
|
+
that multiplies back, defensive readers so a broken `scale` cannot divide by
|
|
342
|
+
zero, and this section. Changing what a prop means is a breaking change you pay
|
|
343
|
+
for forever.
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## 4. Image export
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
import { exportToBlob, downloadBlob } from "@mocanvas/mocanvas"
|
|
351
|
+
|
|
352
|
+
const blob = await exportToBlob(editor, {
|
|
353
|
+
format: "png", // "svg" | "png" | "jpeg" | "webp"
|
|
354
|
+
padding: 32, // page units around the bounds, default 32
|
|
355
|
+
background: true, // paint the page colour, default false
|
|
356
|
+
scale: 2, // multiplies the output width/height, default 1
|
|
357
|
+
darkMode: false,
|
|
358
|
+
quality: 0.9, // lossy formats only
|
|
359
|
+
pixelRatio: 2, // raster only, defaults to window.devicePixelRatio
|
|
360
|
+
})
|
|
361
|
+
downloadBlob(blob, "drawing.png")
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Add `ids: [...]` to draw specific shapes. `exportAs(editor, format, ids?, opts?)`
|
|
365
|
+
is the same thing plus the download, naming the file after the current page
|
|
366
|
+
unless you pass `opts.name`; it is what the **Export as** menu runs.
|
|
367
|
+
|
|
368
|
+
### What gets drawn
|
|
369
|
+
|
|
370
|
+
One rule, shared by every entry point:
|
|
371
|
+
|
|
372
|
+
- Pass `ids` and you get those shapes **and all of their descendants**.
|
|
373
|
+
- Pass nothing and you get the selection, with its descendants.
|
|
374
|
+
- Pass nothing with nothing selected and you get the whole current page.
|
|
375
|
+
|
|
376
|
+
`getExportShapes(editor, ids?)` and `getExportBounds(editor, shapes)` are
|
|
377
|
+
exported if you want the bounds before exporting — to show the user the output
|
|
378
|
+
size, say. Shapes are drawn in page order, frames clip their children with a
|
|
379
|
+
`clipPath`, and `ShapeUtil.toBackgroundSvg` output is collected separately and
|
|
380
|
+
emitted before *every* shape, so a backdrop sits behind the whole drawing rather
|
|
381
|
+
than only behind its own shape.
|
|
382
|
+
|
|
383
|
+
`getSvgString(editor, ids?, opts?)` returns `{ svg, width, height }`, or
|
|
384
|
+
`undefined` when there is nothing to export. The size is the bounds times
|
|
385
|
+
`scale`, rounded up, in CSS pixels; raster output is that times `pixelRatio`.
|
|
386
|
+
Backgrounds are `SVG_LIGHT_BACKGROUND` / `SVG_DARK_BACKGROUND`, and JPEG paints
|
|
387
|
+
one regardless, because JPEG has no alpha and transparent areas would otherwise
|
|
388
|
+
come out black.
|
|
389
|
+
|
|
390
|
+
### The SVG is a renderer, not a screenshot
|
|
391
|
+
|
|
392
|
+
It is **not** a capture of the WebGL canvas. It is a second renderer that walks
|
|
393
|
+
the same shapes and draws them from the same geometry and the same style, into
|
|
394
|
+
standalone SVG markup with no external references. That is why `format: "svg"`
|
|
395
|
+
works in Node with no DOM; the raster formats need a browser and say so with a
|
|
396
|
+
clear error rather than a reference error.
|
|
397
|
+
|
|
398
|
+
Each shape resolves in three steps, highest first:
|
|
399
|
+
|
|
400
|
+
1. `ShapeUtil.toSvg(shape, ctx)` — the util's own markup, in shape-local
|
|
401
|
+
coordinates. The exporter wraps it in a `<g>` carrying the page transform and
|
|
402
|
+
opacity.
|
|
403
|
+
2. `shapeSvgRenderers` — the built-in registry, covering `geo`, `draw`, `line`,
|
|
404
|
+
`arrow`, `text`, `note`, `frame`, `bookmark`, `embed` and `video`.
|
|
405
|
+
`registerShapeSvgRenderer(type, renderer)` adds or replaces an entry.
|
|
406
|
+
3. `geometryFallbackSvg` — the shape's geometry outline drawn with its render
|
|
407
|
+
style, or a hairline when it has none.
|
|
408
|
+
|
|
409
|
+
So **a custom shape appears in an export exactly when its `ShapeUtil` implements
|
|
410
|
+
`toSvg`** (or you registered a renderer for its type). Without either it still
|
|
411
|
+
appears — as its outline, which is recognisable and never right.
|
|
412
|
+
|
|
413
|
+
```ts
|
|
414
|
+
import { ShapeUtil, type ShapeSvgContext } from "@mocanvas/mocanvas"
|
|
415
|
+
|
|
416
|
+
class CalloutShapeUtil extends ShapeUtil<CalloutShape> {
|
|
417
|
+
override toSvg(shape: CalloutShape, ctx: ShapeSvgContext) {
|
|
418
|
+
const fill = ctx.darkMode ? "#1b1d22" : "#ffffff"
|
|
419
|
+
return `<rect width="${shape.props.w}" height="${shape.props.h}" fill="${fill}"/>`
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
`toSvg` may return a markup string — inserted verbatim, so **you** must escape
|
|
425
|
+
anything the document supplied — or a React node, which the exporter serializes.
|
|
426
|
+
Return `undefined` to fall through to the next step.
|
|
427
|
+
|
|
428
|
+
Two limits to be plain about. There is **no renderer for the `image` shape**, so
|
|
429
|
+
an image exports as its geometry outline; if you need image bytes in exports,
|
|
430
|
+
implement `toSvg` on your own image util (see §6 for why that is harder than it
|
|
431
|
+
looks). And `bookmark`, `embed` and `video` deliberately draw placeholder cards
|
|
432
|
+
rather than the remote media: an export has to stand on its own, and a remote
|
|
433
|
+
`<image href>` would either fail to load or leak a request when the file is
|
|
434
|
+
opened elsewhere.
|
|
435
|
+
|
|
436
|
+
### On the `Editor`
|
|
437
|
+
|
|
438
|
+
`Editor.getSvgString`, `Editor.toImage`, `Editor.getSvgElement` and
|
|
439
|
+
`Editor.toImageDataUrl` are the same functionality as methods, installed through
|
|
440
|
+
a registration seam: the implementations live in `@mocanvas/mocanvas`, which
|
|
441
|
+
depends on `@mocanvas/editor` and not the other way round, so they **throw** in
|
|
442
|
+
an app importing only the editor package. `getSvgElement` returns a live
|
|
443
|
+
`SVGSVGElement` and needs a DOM; `toImageDataUrl` returns a `data:` URL, about a
|
|
444
|
+
third larger than the blob, which is why `toImage` stays the default.
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## 5. The clipboard and printing
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
import {
|
|
452
|
+
copySelectionToClipboard,
|
|
453
|
+
cutSelectionToClipboard,
|
|
454
|
+
pasteFromClipboard,
|
|
455
|
+
copyAs,
|
|
456
|
+
Vec,
|
|
457
|
+
} from "@mocanvas/mocanvas"
|
|
458
|
+
|
|
459
|
+
await copySelectionToClipboard(editor)
|
|
460
|
+
await pasteFromClipboard(editor) // at the viewport centre
|
|
461
|
+
await pasteFromClipboard(editor, new Vec(0, 0)) // at a page point
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Internal copy and paste move JSON text tagged `application/mocanvas`
|
|
465
|
+
(`MOCANVAS_CLIPBOARD_TYPE`), produced by `editor.getContentFromCurrentPage` and
|
|
466
|
+
consumed by `editor.putContentOntoCurrentPage`. Both go through the async
|
|
467
|
+
Clipboard API and both fall back to an in-page buffer, so copy and paste still
|
|
468
|
+
work in a browser that has not granted clipboard permission — within the one
|
|
469
|
+
tab, which is where it matters most. `getClipboardTextForShapes` gives you that
|
|
470
|
+
text without touching the clipboard.
|
|
471
|
+
|
|
472
|
+
`copyAs(editor, format, ids?, opts?)` puts a *rendered* copy on the clipboard for
|
|
473
|
+
another application: `"json"` is the internal format above, and `"svg"`,
|
|
474
|
+
`"png"`, `"jpeg"` and `"webp"` go through `exportToBlob`. `copyBlobToClipboard`
|
|
475
|
+
writes images as a `ClipboardItem`; SVG, which clipboards do not accept as an
|
|
476
|
+
image, is written as text.
|
|
477
|
+
|
|
478
|
+
### Printing
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
import { canPrint, printSelection } from "@mocanvas/mocanvas"
|
|
482
|
+
|
|
483
|
+
if (canPrint(editor)) printSelection(editor)
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
`printSelection` prints the selection if there is one, otherwise the page. It is
|
|
487
|
+
deliberately **not** `window.print()`: the editor is a component on somebody's
|
|
488
|
+
page, and the host window's print view is that whole page — headers, navigation,
|
|
489
|
+
the article the canvas sits in, and a canvas element a print stylesheet cannot
|
|
490
|
+
usefully lay out. What the user asked to print is the drawing, so the drawing is
|
|
491
|
+
what is rendered: the same SVG the exporter produces, alone in a hidden
|
|
492
|
+
same-origin iframe that is printed and then thrown away (removed on
|
|
493
|
+
`afterprint`, and on a 60-second timer, since not every browser fires it).
|
|
494
|
+
|
|
495
|
+
It uses the instance's `exportBackground` setting and always renders light, and
|
|
496
|
+
returns `false` when there is nothing to print or no DOM to print in — the same
|
|
497
|
+
condition `canPrint` reports, which the built-in menu item disables itself on.
|
|
498
|
+
|
|
499
|
+
---
|
|
500
|
+
|
|
501
|
+
## 6. Assets
|
|
502
|
+
|
|
503
|
+
Image and video bytes do not live in the records. An `asset` record is
|
|
504
|
+
`document`-scoped metadata — dimensions, name, MIME type, and a `props.src`
|
|
505
|
+
pointing at the bytes — and a shape references it by `props.assetId`. Where the
|
|
506
|
+
bytes actually are is the `AssetStore`'s business:
|
|
507
|
+
|
|
508
|
+
```ts
|
|
509
|
+
interface AssetStore {
|
|
510
|
+
upload(asset: Asset, file: File, abortSignal?: AbortSignal): Promise<{ src: string; meta?: JsonObject }>
|
|
511
|
+
resolve?(asset: Asset, context: AssetContext): Promise<string | null> | string | null
|
|
512
|
+
remove?(assetIds: AssetId[]): Promise<void>
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Only `upload` is required. It is called once per file, before the asset record
|
|
517
|
+
is written, so the `src` it returns is what every collaborator sees — throw from
|
|
518
|
+
it to reject the file and no shape is created. Without `resolve` the editor
|
|
519
|
+
falls back to the asset's own `props.src`; without `remove`, deleting an asset
|
|
520
|
+
record leaves the bytes where they are. `resolve` receives an `AssetContext` —
|
|
521
|
+
camera zoom times device pixel ratio, a stepped version of the same so a
|
|
522
|
+
drag-resize does not re-request every frame, the network's effective type, and
|
|
523
|
+
`shouldResolveToOriginal`, set for copy, paste and export, where a downscaled
|
|
524
|
+
variant would lose data. A store with one URL per asset can ignore all of it.
|
|
525
|
+
|
|
526
|
+
Pass yours as `<Mocanvas assets={myAssetStore} />` or
|
|
527
|
+
`createStore({ …, assets: myAssetStore })`. It hangs off the *store* rather than the editor, as `store.props.assets`,
|
|
528
|
+
because assets are document-scoped: a snapshot loaded into a store, exported
|
|
529
|
+
from it or synced out of it all need the same uploader, and some of that happens
|
|
530
|
+
with no editor mounted. It is always present — an app supplying none gets
|
|
531
|
+
`createInMemoryAssetStore()` — so `store.props.assets.upload` is never a null
|
|
532
|
+
check at a call site.
|
|
533
|
+
|
|
534
|
+
Both built-in stores inline the bytes as base64 `data:` URLs:
|
|
535
|
+
`createInMemoryAssetStore()` (the default; it keeps a map, so `remove` works)
|
|
536
|
+
and `inlineBase64AssetStore` (no `resolve`, no `remove` — the `src` *is* the
|
|
537
|
+
data). Inlining needs no infrastructure and the bytes travel inside the
|
|
538
|
+
document: exactly right for a prototype or a test, exactly wrong for anything
|
|
539
|
+
real, where a few photographs produce a document too large to sync.
|
|
540
|
+
`dataUrlToFile`, `fileToDataUrl` and `fileToBase64DataUrl` convert between the
|
|
541
|
+
two forms — a paste handler needs the first, so a data URL on the clipboard
|
|
542
|
+
becomes a `File` and takes the same upload path a dropped one does.
|
|
543
|
+
|
|
544
|
+
### What this means for files and exports
|
|
545
|
+
|
|
546
|
+
A `.tldr` carries asset *records*, not bytes — unless the `src` is a data URL,
|
|
547
|
+
in which case it carries both and is correspondingly enormous. A file saved from
|
|
548
|
+
an app whose asset store hands out CDN URLs only opens correctly where those URLs
|
|
549
|
+
resolve. For portable files, resolve assets to data URLs before serializing; the
|
|
550
|
+
asynchronous signature of `serializeTldrawJson` exists for exactly that.
|
|
551
|
+
|
|
552
|
+
For image export the answer is blunter: as §4 says there is no SVG renderer for
|
|
553
|
+
the `image` shape at all, so image bytes are in no export by any route today. A
|
|
554
|
+
custom `toSvg` that embeds a data URL is the way to change that.
|
package/MIGRATION.md
CHANGED
|
@@ -694,7 +694,7 @@ aspirational.
|
|
|
694
694
|
|
|
695
695
|
| Gap | Status |
|
|
696
696
|
| --- | ------ |
|
|
697
|
-
| `ShapeUtil.toSvg`, `ShapeUtil.toBackgroundSvg` |
|
|
697
|
+
| `ShapeUtil.toSvg`, `ShapeUtil.toBackgroundSvg` | Both are `ShapeUtil` members, and a util's own implementation takes precedence over the registry. `registerShapeSvgRenderer(type, renderer)` is the other way in, for a shape whose util you do not own; a shape with neither falls back to `geometryFallbackSvg`, which draws its outline. See [FILES.md](FILES.md) §4. |
|
|
698
698
|
| Sync protocol | Wire compatibility with tldraw's own sync protocol is not planned. `@mocanvas/sync` is a working transport of its own — presence records, `store.mergeRemoteChanges`, and a relay — but it does not speak tldraw's wire format. |
|
|
699
699
|
| `image` shape on the GPU | The texture path exists in the engine (`StyleWords.texture` + `uploadTexture`) but the `image` shape still draws an `<img>` in the DOM overlay. |
|
|
700
700
|
| `FrameShapeUtil` default size | mocanvas creates a frame at **160×90**; tldraw creates one at 320×180. Same aspect, half the size. It only bites code that creates a frame *programmatically* without passing `w`/`h` — drawing one with the tool sizes it from the drag either way. Pass explicit dimensions if the size matters to you. Aligning the default is a behaviour change and is not being made in a patch release. |
|