Properties
Block, inline block and document properties (data), read like plain objects, written with patchData, synced per property, with undo, hooks and typing.
Every block, every inline block and the document itself carry data: a JSON object of properties, such as a to-do’s checked, an image’s src or a document’s title. You read it like a plain object and write it with the patchData operation (undoable, visible to plugins, refused in a readonly view). The document syncs it property by property, so two people editing different properties of the same block both keep their change.
Read and write
block.data, atom.data and edytor.data read the current value; edytor.patchData(target, patches) writes it, where target is the block’s id, { block, atom } for an inline block, or null for the document:
const read = block.data.title; // read one property
edytor.patchData(block.id, [{ path: ['title'], value: 'Launch plan' }]); // set one property
edytor.patchData(block.id, [{ path: ['meta', 'owner'], value: 'ada' }]); // a nested one
edytor.patchData(block.id, [{ path: ['draft'] }]); // delete one
edytor.patchData(block.id, [{ path: ['tags'], splice: [1, 0, 'q3'] }]); // one item added
edytor.patchData(block.id, [{ path: ['tasks', '0', 'done'], value: true }]); // one property of one item
edytor.patchData(null, [{ path: ['title'], value: 'Notes' }]); // the document's own properties
edytor.patchData({ block: block.id, atom: 'm1' }, [{ path: ['name'], value: 'Ada' }]); // an inline block
edytor.patchData(block.id, [{ path: [], value: { title: 'Fresh' } }]); // replace the whole object
void read;
- A read returns the current value, which you must not mutate: copy it (
{ ...block.data },structuredClone(block.data)) to change a copy or to keep what it was at one moment. - A write writes only what its patches change (the patches below). Values are JSON: a
Datebecomes a string andundefinedremoves the property (see the document model); a value that is not JSON is converted and logged as a warning in development. - Reads in a template are reactive: a snippet that reads
block.data.titlere-renders when anyone changes the block, you, a plugin or a collaborator.edytor.datais reactive too, anywhere in your app. edytor.root.dataisedytor.data.
Bind to inputs
Bind a property through a getter and a setter that writes it. A kind whose snippet edits its own data:
<script module lang="ts">
import type { BlockSnippetPayload, BlockView, Plugin } from 'edytor';
type Card = { title?: string; done?: boolean; tags?: string[] };
export const cardPlugin: Plugin = () => ({
blocks: {
card: {
snippet: card,
presets: [{ label: 'Card', icon: '▤', data: { title: '', done: false, tags: [] } }]
}
}
});
/** Write property `key` of the card: one `patchData`. */
const set = (block: BlockView<Card>, key: keyof Card, value: unknown) =>
block.handle?.edytor.patchData(block.id, [{ path: [key], value }]);
</script>
{#snippet card({ block, content }: BlockSnippetPayload<Card>)}
<div use:block.void>
<input
placeholder="Title"
bind:value={() => block.data.title ?? '', (title) => set(block, 'title', title)}
/>
<input
type="checkbox"
bind:checked={() => block.data.done ?? false, (done) => set(block, 'done', done)}
/>
<button
onclick={() =>
block.handle?.edytor.patchData(block.id, [
{ path: ['tags'], splice: [block.data.tags?.length ?? 0, 0, 'new'] }
])}>Tag</button
>
{block.data.tags?.join(', ')}
</div>
<div>{@render content()}</div>
{/snippet}
- An absent property reads as empty through the getter, so nothing is written when the input mounts; a plain
bind:would write it, an edit (and an undo step) in every editable view that renders it. - A row of an
{#each}over an array writes its own item by id, so its write merges with a collaborator’s edits of the others and stays on its item wherever they move it:document.dataItemIds(block.id, ['tasks'])lists the items’ ids in order, and a patch names an item by its id (['tasks', '~k3f9xq2a', 'done']). A patch naming an item that is gone writes nothing. - A kind’s own controls own their events. Any form control in a kind’s markup (an
<input>of any type: text, date, time, number, range, color, a checkbox; a<textarea>, a<select>, a<button>) and any nested editable element that is not the editor’s own text receives its keys,beforeinput, paste, copy, cut, drops and selection gestures from the browser, whatever the editor’s selection: Backspace, Enter or Tab there never act on selected blocks, a paste never lands in the document, and the editor never moves its caret over a focused control while each change writes the block. Only a file dropped on one is swallowed (the browser would open it in place of the page), and Backspace or Delete on a focused<select>or<button>does nothing, so the browser deletes nothing around it. - A readonly view refuses the writes, so the field would show what was typed until the next render: disable it there (
disabled={block.handle?.edytor.readonly}; a suggestion’s preview has no handle).
Document properties
The document’s own data is edytor.data in a view, document.docData() headless, and the optional data of a JSONDoc:
import { createDocument } from 'edytor';
declare module 'edytor' {
interface EdytorDocData {
title?: string;
tags?: string[];
}
}
const document = createDocument({
value: { data: { title: 'Notes' }, children: [{ type: 'paragraph', content: [{ text: 'Hi' }] }] }
});
document.patchData(null, [{ path: ['tags'], value: ['draft'] }]);
document.docData(); // { title: 'Notes', tags: ['draft'] }
document.toJSON().data; // the same; `data` is absent when the document has none
- A
valuewithdataseeds it with the content. The same seed on two replicas writes the same properties, whatever order its keys come in (a database may reorder them), like the content (see Documents). edytor.value(andonChange’s value) carries it as the root’sdata;JSONDoc.dataround-trips throughtoJSON, the room’sread(),onSaveandonLoad.- Augment
EdytorDocDatato typeedytor.data, as above. Block data is typed per kind withBlockSnippetPayload<T>(block.datais a read-onlyTin the snippet).
Commands, hooks and undo
A property write is the patchData operation: patchData(target, patches), a hook’s payload being [target, patches]. patches is a list, applied in order. path is the list of keys from the root, an array item’s being its index as a string (['tasks', '0', 'done']) or its id (['tasks', '~k3f9xq2a', 'done']; document.dataItemIds(target, path) lists an array’s ids in order). An id is ~ and a lowercase letter, so a key that starts with ~ is written with that ~ as ~0, as in a JSON pointer (['~0abc'] for the key ~abc). A patch is one of:
{ path, value }sets the value there; withoutvalueit deletes the property, or removes the item.{ path, splice: [start, deleteCount, ...items] }removes and inserts items of the array atpath, asArray.prototype.splicedoes.{ path, order }moves the array’s items:orderlists the current indexes in their new order.
Indexes are resolved against the document when the command runs, ids wherever the item is then (a patch naming an item that is gone, removed or with its array deleted or replaced, is refused and writes nothing); the stored write names each item by its id, so it merges with what collaborators did meanwhile. The block is the edited block, the root block for the document.
import type { Plugin } from 'edytor';
// Validation lives in a hook. Only the app sets `locked`: refuse any write that
// would change it, a whole-object replace included.
export const locked: Plugin = () => ({
onBeforeOperation: (change) => {
if (change.operation !== 'patchData') return;
const [target, patches] = change.payload;
if (typeof target !== 'string') return; // an inline block's, or the document's
const was = change.block.data.locked;
const changes = (patch: (typeof patches)[number]) =>
patch.path.length === 0
? (patch.value as { locked?: unknown } | undefined)?.locked !== was
: patch.path[0] === 'locked';
if (patches.some(changes)) change.prevent();
}
});
A patch with an empty path replaces the whole object, so a check must look inside its value, not only at path[0]: a replace that leaves locked out deletes it.
- A readonly view (or a read-only document) refuses every write, and
edytor.dispatcher.lastrecordsrefused. - A vetoed write writes nothing. Edytor has no property schema: validate or normalize in
onBeforeOperation(return a replacement payload to rewrite a patch). - A block created with its properties is not a
patchData: a paste, Duplicate andinsertBlocksshow asinsertBlockswith the new blocks (payload[1],dataincluded), and a split’s second half takes a copy of the first half’s properties. A hook that guards a property checksinsertBlockstoo. - Each write is one undo step. Typing into a property joins one step: a write of the same properties whose new strings each insert or delete one run of characters in the previous ones (
'Pla'→'Plan','Plan'→'Pan') continues the step while it comes within the history’s capture window (history.captureTimeout, 500 ms by default), as typing text does. Any other write is its own step however fast it comes: a checkbox’strue/false, a number, a delete, an object, and a string that is another value rather than an edit of it ('todo'→'done','doing'→'done', a<select>changed twice). So checking and unchecking a to-do, or picking two statuses in a row, is two steps. To make several writes one step, run them inedytor.transact(() => …). - A patch with an empty
pathreplaces the whole object (patchData(id, [{ path: [], value: data }])).setBlock(id, { data })(and Turn into) writes data as apatchDatastep, which hooks also see: one patch per property it names ({ path: ['level'], value: 'h1' }), never a replace. - A change of kind keeps the properties. Turn into, a markdown shortcut, the slash menu, Backspace at the start of a heading or a to-do and
setBlock(id, { type, data })set the properties the new kind’s preset names and keep every other one, as Notion does: a checked red to-do turned into a heading keepscheckedandcolor(a heading ignores them) and gainslevel; turned back into a to-do, the preset’schecked: falseunchecks it. A nested preset property sets its own keys inside an object the block holds there. To drop properties, delete them ({ path: ['checked'] }) or replace the object. - Undo gives back exactly the properties the step changed; a collaborator’s change to another property stays.
How concurrent edits merge
Each leaf of the data (a string, number, boolean, null, an empty object or an empty array) is stored as its own entry, and each array item is its own entry with an id and a position, so:
- Different properties edited at the same time are all kept, at any depth:
titleanddone, ormeta.aandmeta.b. - The same property edited at the same time ends with one of the two values, the same on every replica (last writer wins).
- Arrays merge item by item, like a
Y.Array. Edits inside different items are all kept (tasks[0].titleandtasks[1].done), and so are both items of two concurrent pushes: concurrent inserts at the same place come in the same order on every replica, and what one person inserts right after an item they inserted in the same session stays right after it, even across separate writes (twopushcalls, two clicks on Add): a collaborator’s insert made at that place before seeing them never lands between them. An insert lands between the items it was made between. - A removed item stays removed. When one person removes an item while another edits inside it, the item is gone for both; the edit never brings it back.
- A moved item keeps its edits.
sortandreversemove items: an edit made meanwhile inside one of them follows it to its new place, and an item removed meanwhile stays removed. Two people moving the same item at once leave it at one of the two places. - A whole array assignment keeps what stays.
{ path: ['tasks'], value: next }(or a replace of the object holding the array) keeps every item of the longest run thatnextkeeps equal and in order; between two of them, old and new items pair up by position, each keeping its item and taking the new value; the rest are removed or inserted. So the immutable-update pattern (tasks = [...tasks, task],tasks.map(…),tasks.filter(…)) appends, edits or removes only those items, and a collaborator’s edit inside the others is kept.- Two people reassigning the same array at once each pair their values with the same old items, so where both changed one, the last writer’s value wins, and the items each inserted past them are both kept.
- An array assigned where there was none (or only an empty one) gets items derived from its value: two people assigning the same one at once (a default every open view writes,
options ??= ['A', 'B']) leave one copy, and two different ones are both kept, one after the other.
- A whole assignment costs time linear in the array’s length. The items equal at the start and at the end are kept first; between them, the longest run is exact whenever the old items’ values are all different (rows with their own ids or titles,
sort’s order) or few of them repeat. When many values repeat (thousands oftrue/falseflags, say), and more than 262,144 pairs of equal values would have to be compared, the items between the kept start and end pair up by position instead: each keeps its item and takes the new value at its index. Reassigning a 6,000-item array with one insert, one edit and one removal writes those three items and takes about 0.2 s in Node; set or splice one item (tasks[i].done = true,tasks.splice(i, 1)) when you know which one changed. - An object beats a value. When one person sets
metato5while another setsmeta.b, everyone ends withmetaas an object:{ b: … }. - An atomic path is one value. Key-by-key merging can mix two objects set at the same time: one person sets
linkto{ url: a, title: A }while another sets it to{ url: b, kind: 'video' }, and both end with{ url: b, title: A, kind: 'video' }, a link neither of them wrote. A kind that declaresatomic: ['link'](in its block definition and in the document’ssemantics, so every replica agrees) storeslinkas one value: everyone ends with one of the two links, whole. A write inside it ({ path: ['link', 'title'], value: 'X' }) writes the wholelinktoo, so it can lose to a concurrent write oflinkas a whole. Declare the paths whose parts only make sense together (a link, an embed’s source); leave the rest per key. - A set beats a concurrent delete of the same property, and a replace (of the whole object, or setting an object) removes only the properties its writer saw: a property a collaborator added meanwhile stays.
- Deleting the last property of an object keeps the object, empty.
For text that several people type into at once, use the block’s content, which merges character by character.
Headless
The document API has one operation for data: document.patchData(target, ops), where target is a block id, { block, atom } for an inline block, or null for the document. It takes the same patches as the operation (document.patchData('a', [{ path: ['tasks'], splice: [0, 1] }]) removes the first task). The change report carries a block’s new data in meta, an inline block’s in the block’s content runs, and the document’s in data (see the document API).
Storage and upgrading
Upgrade every client of a document together: a client of an older version reads only the old whole-data attribute, and a client of the previous pre-release reads an array a current client wrote as an object keyed by its items’ ids.
How properties are stored:
- A leaf is an attribute of the block, the inline block or the document, named
d/and its path as a JSON pointer (~as~0,/as~1). - An array is an attribute holding
[]at its path, and its items. An item has a short random id (one derived from the array when the array is assigned whole where there was none), its value under~and its id (d/tasks/~k3f9xq2afor a string,d/tasks/~k3f9xq2a/titlefor a property of an object) and its position ind/tasks/~k3f9xq2a#, a sortable key. A move writes the new position ind/tasks/~k3f9xq2a>, so a concurrent removal still removes the item; a removed item has none of them.
How older documents are read:
- Arrays written by the previous pre-release are one attribute holding the whole array. Edytor reads them unchanged and keeps them while a write only adds, changes or moves items, splitting them into items at the first write that removes one (both peers then write the same items, so concurrent first writes merge as usual).
- Documents written before properties were stored per leaf store a block’s data as one attribute. Edytor reads it unchanged, writes a set property over it, and splits it into leaves at the first write it would otherwise show through (a delete, an object replacing its object, a replace of the whole object).
- Edge case. When two people make such a first write to the same old block or array at the same moment, a property the other one set, or an item the other one removed, can come back.