@mocanvas/editor 4.8.2 → 4.8.5

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/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` | Not `ShapeUtil` members. Custom shapes contribute to SVG export through `registerShapeSvgRenderer(type, renderer)`; without one they fall back to `geometryFallbackSvg`. |
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. |