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#
{
"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": {}
}
typepicks the shape's ShapeUtil.propsare that shape's own fields.parentIdis the page, or a frame that contains the shape.indexis a fractional index: z-order that never needs renumbering, and never conflicts when two people reorder at once.metais 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:
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 |
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.
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:
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.