[]previous and next page

Records and the store

What a CanvasFlow document is made of, how changes are described, and how to hook into them.

A CanvasFlow document is a flat collection of records. A record is a plain JSON object with an id and a typeName. There are five kinds:

typeName What it is Example id
document The document itself: name, grid size document
page A page, with its name, order and background page:Kf3…
shape Anything you see on a page shape:Q9x…
binding A link from one shape to another, like an arrow end attached to a box binding:7aZ…
asset An image, video, file, or a data-driven shape spec asset:Lm2…

Because everything is plain JSON, a document is easy to save, send, diff, inspect and migrate.

A shape record#

json
{
  "id": "shape:Q9xW2kLmA7c3PzT1",
  "typeName": "shape",
  "type": "geo",
  "pageId": "page:Kf3aZ8",
  "parentId": "page:Kf3aZ8",
  "index": "a1",
  "x": 120,
  "y": 80,
  "rotation": 0,
  "opacity": 1,
  "locked": false,
  "hidden": false,
  "groupIds": [],
  "props": { "geo": "rectangle", "w": 200, "h": 120, "color": "blue", "fill": "semi", "text": "Hello" },
  "meta": {}
}
  • type picks the shape's ShapeUtil. props are that shape's own fields.
  • parentId is the page, or a frame that contains the shape.
  • index is a fractional index: z-order that never needs renumbering, and never conflicts when two people reorder at once.
  • meta is yours. Put app data there; CanvasFlow never reads it.

Records are immutable#

You never edit a record in place. Every update stores a new object, which is why everything downstream can be fast and simple: the renderer re-draws a shape only when its record changed identity, and moving a shape doesn't even re-render its contents, because its props object stayed the same.

In practice you rarely touch the store directly. editor.createShape, updateShape and friends do it for you, inside a transaction, with history.

Diffs#

Every change is reported as a diff:

ts
interface RecordsDiff {
  added: Record<string, AnyRecord>
  updated: Record<string, [from: AnyRecord, to: AnyRecord]>
  removed: Record<string, AnyRecord>
}

Diffs are what history inverts for undo, what autosave writes, and what collaboration sends. Each change also carries its source:

Source Meaning
user This person did it (a tool, an action, your code)
remote It arrived from a collaborator
system Loading, migrations, side effects
ts
editor.on('change', ({ diff, source }) => {
  if (source !== 'user') return
  for (const [id, [before, after]] of Object.entries(diff.updated)) {
    // react to what this person changed
  }
})

Side effects#

Side effects let you keep rules true no matter who makes the change. They run inside the same transaction, so the result is one undo step and one sync message.

ts
const off = editor.store.registerSideEffects('shape', {
  beforeCreate: (shape) => ({ ...shape, meta: { ...shape.meta, createdAt: Date.now() } }),
  beforeDelete: (shape) => (shape.meta.protected ? false : undefined), // false vetoes the delete
  afterChange: (prev, next) => {
    // keep something else in sync
  },
})

The handlers are beforeCreate, afterCreate, beforeChange, afterChange, beforeDelete and afterDelete. "Before" handlers return the record to store (or false from beforeDelete to keep it).

Snapshots#

A snapshot is every record plus a schema version. It's what you save and load:

ts
const snapshot = editor.getSnapshot() // { schemaVersion, records }
localStorage.setItem('board', JSON.stringify(snapshot))

editor.loadSnapshot(JSON.parse(localStorage.getItem('board')!))

Older snapshots are migrated on load, and shapes of a type this app doesn't know are kept and drawn as placeholders, so opening a document never loses anyone's data. Register your own migration with registerMigration when you change the shape of your own props.