Document API
Reference for the document facade, its operations and results, order and capability queries, change events and attribution.
document.facade is the one read and write surface of a document: every structural and text edit, and every read of the tree, goes through it. It is the same whether you use it headlessly or from a view. For the document object itself (history, providers, readiness), see Documents; for the tree model, see the document model.
import { createDocument, defaultSemantics } from 'edytor';
const { facade } = createDocument({
value: { children: [{ type: 'paragraph', id: 'p1', content: [{ text: 'hello' }] }] },
semantics: defaultSemantics // the bundled kinds' roles; views add their plugins' own
});
facade.insertText('p1', 5, ' world'); // { status: 'applied', ids: ['p1'] }
facade.insertText('p1', 0, ''); // { status: 'noop', ids: [] }
facade.insertBlock({ parent: null, index: 1 }, { id: 'p2', type: 'paragraph' });
facade.insertBlock({ parent: null, index: 1 }, { id: 'p2', type: 'paragraph' }); // refused: id taken
defaultSemantics, richTextSemantics, codeSemantics, imageSemantics and layoutSemantics are frozen and shared by every document. defaultSemantics holds the other four: the rich text, code, image and columns plugins’ rows. The columns plugin is not a default plugin of <Edytor>, but its layout rows are there so a headless document and the room show a layout as a view with the plugin does; a document holding no columns block is unchanged by them. To add your own kinds, build their config with semanticsOf (kind rows in, { roles, rendersContent, defaultChild } out) and merge it into the bundled one field by field. Spreading the two configs at the top level would keep only your roles, and drop the divider, image and code rules:
import { createDocument, defaultSemantics, semanticsOf } from 'edytor';
const mine = semanticsOf({ embed: { void: true, rendersContent: false } });
export const semantics = {
roles: { ...defaultSemantics.roles, ...mine.roles },
rendersContent: { ...defaultSemantics.rendersContent, ...mine.rendersContent },
defaultChild: { ...defaultSemantics.defaultChild, ...mine.defaultChild }
};
export const document = createDocument({ semantics }); // divider stays void, code an island
Shapes
| Type | Shape |
|---|---|
BlockId |
string, chosen by the caller and never reused. |
Destination |
{ parent: BlockId | null, index: number }. null is the root. |
BlockSpec |
{ id, type, data?, content?: ContentItem[], children?: BlockSpec[] } |
ContentItem |
{ kind: 'text', text, marks? } or { kind: 'inline', id, type, data? } |
InlineSpec |
{ id, type, data? } |
DocPosition |
{ block: BlockId, offset: number } |
RangeView |
{ hidden?(id, removed?): boolean }: the blocks a view hides (the view of deleteRange and replaceRange). |
FlowView |
A RangeView with itemKind?(parent): string | undefined, a list’s flat item kind (the view of insertFlow). |
Offsets count what the block displays: one per UTF-16 text unit and one per inline block. Offsets out of range are clamped.
toBlockSpec(block, { freshIds? }) (from edytor or edytor/crdt/edytor) turns a canonical JSONBlock (from toJSON, blockJSON, a template or an import) into a BlockSpec: it keeps the ids the JSON carries and mints the missing ones; freshIds: true mints every id, so the result can be inserted beside its source.
import { toBlockSpec } from 'edytor/crdt/edytor';
facade.insertBlock({ parent: null, index: 1 }, toBlockSpec(facade.blockJSON('p1'), { freshIds: true }));
Results
Every operation returns an OpResult:
type OpResult = {
status: 'applied' | 'noop' | 'refused';
ids: readonly BlockId[]; // what the op is about; empty unless applied
reason?: string; // e.g. 'id-collision'
};
applied: the operation wrote to the document.noop: nothing to do (inserting'', an empty range, formatting with the value already there,moveBlocks([])). Nothing is written.refused: the operation is not allowed here (a missing or deleted block, an id already taken, a move into an island). Nothing is written.
Block operations
| Operation | ids when applied |
Notes |
|---|---|---|
insertBlock(dest, spec) |
the new block | Refused when dest.parent is missing, deleted or void, or any id in the spec is taken (by a live or deleted block). |
insertBlocks(dest, specs) |
the new roots | All or nothing. |
moveBlock(id, dest) |
the block | Keeps the block’s identity and kind. Refused exactly when canPlace([id], dest.parent) is false, so never directly into a list unless it is an item. |
moveBlocks(ids, dest) |
the blocks, in request order | One step; placed consecutively at dest.index. A list the move leaves with no item is removed (all the move ops do this). |
nestBlock(id, parent) |
the block | Moves it to the end of parent’s children. When parent is a list the block is no item of, it nests under the list’s last item instead (Tab after a list, as in Notion); refused when the list ends with a block that holds no children, such as an image. |
unNestBlock(id) |
the block | Moves it right after its parent; the siblings that followed it become its last children (unless it is void or an island, or they would not fit it). Out of a list, it becomes its new parent’s default child, unless it stays inside an outer list of that kind (a nested list’s item stays a list item), and never takes the items after it: a first item goes before the list, and a middle one splits the list: the list keeps the items after it and a new list of the same kind takes the ones before it. Into a list (a paragraph nested under an item), it becomes a list item. Refused where it would land directly in a container it is no item of (an image, a code block or a heading out of an item into its list, a paragraph out of a column into its columns layout). A list left with no item is removed. |
unNestBlocks(ids) |
the blocks | The same for sibling blocks in document order, as one step: the siblings after the last one become its children (out of a list, they stay a list). The blocks land together, so a sibling between two of them that ids leaves out ends up before them: unNestBlocks(['a', 'c']) on a list a to e reads b a c d e. To keep the order, call it once per run of adjacent siblings, as Shift+Tab and edytor.moveBlocks with direction: 'out' do. |
liftOut(id, kind, { keep?, after? }) |
the placed blocks | Places id where a block of kind fits, as one step: out of every container around it that kind does not fit (a list, and a list holding that list directly), each split around it by the same split unNestBlock makes in one list; a container left with no child is removed. Its own kind keeps a block where it is (a heading shed into a list, turned into a heading, stays). after (new block specs) lands right after it; with keep: true the block stays and only after goes out, the lists split right after the block. It never retypes: compose it with setBlockType or setBlock, as the editor’s Turn into does. Refused where the block may not move. |
wrapInLayout(ids, kind?, columns?) |
the new layout | Wraps sibling blocks in a new layout of columns columns (default one per block, at least two) of the layout kind (else the only kind whose role says layout): the blocks one per column in document order, at the first one’s place, with their children, then each further column holding one empty block of the column’s default child (a paragraph): the block menu’s Turn into → N columns, over N blocks or over one. Refused for no block, fewer than two columns or fewer columns than blocks, blocks of different parents, a column, a layout or a block holding one, where a layout does not fit (list items, a code block’s lines) or would sit in a column or an island, and without a layout kind. |
placeBeside(ids, target, side, kind?) |
the moved blocks | Places ids left or right (side) of target, as a drop on a block’s edge does. The target stands for its list (a list item, at any depth) or its code block (a code line); any other block stands for itself where a layout fits (a toggle’s or a callout’s child is wrapped inside it), else for its outermost block below the root or a column (besideAt). Beside a block directly in a column, a new column holding ids goes beside that column; otherwise the target and a new column holding ids are wrapped in a new layout of the layout kind (else the only kind whose role says layout) at its place. A layout target stands for its first (left) or last (right) slot. The sources are cleaned in the same step: a column the move empties goes, and a layout left with one column is replaced by its blocks. Refused when ids holds the target or an ancestor of it, holds a column, a layout or a block holding one (no layout goes into a column), when a block does not fit a column, inside an island, and, wrapping, without a layout kind. |
splitBlock(id, offset, newId, tail?) |
newId |
The text after offset and the children move to the new sibling. tail is { type, data? }; default (also for an empty type): the source’s kind, or its parent’s default child while a peer’s retype of it is half-delivered. Refused on void blocks and on blocks that render no content. |
mergeBlocks(from, into) |
into |
from’s content joins into, and its children become into’s last children. A list left with no item is removed. |
mergeBackward(id) |
the surviving block | Merges id into the previous block in document order; its children take its place, ranked right after it (in a list, paragraphs as list items; any other kind, such as an image, keeps its kind and data). The first item of a list (a container that renders no content) moves out of it instead, as its new parent’s default child (unless it stays inside an outer list of that kind: a nested list’s first item stays a list item). The first block of a layout’s item (a column) merges into the previous shown line in reading order instead: the previous column’s last line, or, from the first column, the line before the layout; its children stay in its column, a column it empties goes, and a layout left with one column is replaced by its blocks. Refused when no line comes before, or that line takes no merge (canMerge: a void, a code line). |
mergeForward(id) |
id |
Pulls the next block in document order into id. A list passes the merge to its first item, whose children stay in the list (paragraphs as list items, other kinds as they are), and is removed if it is left with no item. A layout passes it to its first column’s first block, and the last line of a column pulls the next column’s first block; an emptied column goes and a one-column layout dissolves. |
deleteBlock(id, { keepChildren? }) |
id |
Children take the block’s place unless keepChildren: false. Refused when the block is not live. |
deleteBlocks(ids) |
the deleted roots | Deletes exactly these blocks; their unselected children take their places (in a list, paragraphs as list items; any other kind, such as an image, keeps its kind and data). A list left with no item is removed too. A deleted column’s blocks go right after its layout; a deleted layout’s blocks (its columns go with it) take its place in reading order, keeping their kinds; a column left with no block goes, and a layout left with one column is replaced by its blocks. |
setBlock(id, { type?, data?, content?, children? }) |
id |
type and data update in place; content and children replace everything (new children need unused ids, or reason: 'id-collision'). Refused with children for a void kind. |
setBlockType(id, type) |
id |
To a void kind, the block’s children move out, right after it. An island (a code block) retyped to another kind keeps its lines as that kind’s default child, or as the document’s default kind where that child shows no text (a column); an island without lines (a table) keeps its children’s kinds. A block set to the document’s default kind directly in a list shows as the list’s item. |
patchData(target, ops) |
the block, or none for the document | Patches the data of a block (target its id), of an inline block ({ block, atom }) or of the document (null). ops are applied in order; path lists object keys from the root, an array item by its index as a string or by its id (~ and a lowercase letter, from dataItemIds), resolved when the op runs; a key that starts with ~ is written ~0… (['~0abc'] for the key ~abc). { path, value? } sets the value at path (objects are made along it; an index past the end pads with null), or deletes it (removes the item) without value; path: [] replaces the whole object. { path, splice: [start, deleteCount?, ...items] } removes and inserts items of the array at path as Array.prototype.splice does; { path, order } moves its items (order: the current indexes in their new order). Only the properties and items it changes are written, so a peer’s concurrent edit of another property or item is kept, and assigning an array keeps the items it keeps equal (the merge rules are in Properties). noop when nothing changes; refused for a missing target, a path that is not an array of strings, a root value that is not an object, an index or id that names no item (an item removed meanwhile, or whose array was deleted or replaced: nothing is written under it), a splice or order where there is no array, splice bounds that are not integers, or an order that is not a permutation. |
setBlockData(id, data) |
id |
Replaces the whole data object: patchData(id, [{ path: [], value: data }]). |
duplicateBlock(id, freshId) |
the copy | Copies the subtree after id; freshId(oldId, kind) names each copied block (kind 'block') and inline atom ('inline'). An atom it returns no id for gets a fresh one; a block it returns no id for refuses the copy. |
The blocks unNestBlock(s), liftOut and mergeBackward move out of a block, and the blocks splitBlock and a multi-line insertFlow into a block create after one, are ranked by where they came from, not at random in the gap: first by side (a split’s pieces, then the blocks leaving the block before the gap, then those leaving the block after it), then by the item’s place in its list, or by the text after the split point. Two peers outdenting, lifting or turning into another kind items of one list, or splitting or pasting lines into one block, or one splitting a paragraph while the other lifts the first item of the list below it, at the same time keep the text in its order whatever their client ids, when each makes one such call between syncs (text edits before its split point aside; one after it is a residual). Not covered, where the order can follow the client ids: insertBlock(s) (the editor’s Enter at a block’s start or end, a kind picked from the + button’s menu), an insertFlow at a block’s start whose first line stands apart (a list, a code block, a divider or an image), or at the end of a view.header block, duplicateBlock, an insertFlow that is whole (a block-selection copy) or replaces blocks (a paste or typing over selected blocks), several structural calls by one replica before it syncs (the editor’s Turn into over several blocks is one liftOut plan per block, and its Shift+Tab one unNestBlocks plan per run of adjacent blocks; unNestBlocks over adjacent siblings is one plan; one call over non-adjacent ones is not covered), and moves (moveBlock(s), nestBlock: a drag, the handle’s Alt+↑/↓/→, Mod+Shift+↑/↓, the block menu’s Move up/down, Tab; the handle’s Alt+← is the outdent, unNestBlocks, which is ranked). The residuals (in the split of a list, a new line beside a block a peer moves, a merge into a block a peer splits, text typed or deleted after one’s own split point) and these cases are listed in Concurrent editing.
Text operations
| Operation | Notes |
|---|---|
insertText(id, offset, text, marks?) |
marks like { bold: true }. |
deleteText(id, offset, length) |
|
formatRange(id, offset, length, marks) |
Sets several marks; a null value removes that mark. |
setMark(id, offset, length, name, value) |
One mark. |
unsetMark(id, offset, length, name) |
|
clearMarks(id, offset, length) |
Removes every mark present in the range. |
insertInline(id, offset, atom) |
atom is an InlineSpec. |
removeInline(id, inlineId) |
|
setInlineData(id, inlineId, data) |
Replaces the atom’s data: patchData({ block: id, atom: inlineId }, [{ path: [], value: data }]). |
Text operations also work inside void blocks, which may hold a caption.
Ranges and flows
| Operation | Notes |
|---|---|
deleteRange(from, to, view?) |
Deletes between two DocPositions across blocks, following the editor’s range-deletion rules. Keeps the first block when nothing else would remain. A list the range only starts before keeps its later items; it goes only when the range empties it. view.hidden(id, removed?) names blocks the view hides, such as a closed toggle’s body: they are not in the range and go only with a block that goes. With removed, it answers whether the block stays hidden once those blocks go (a removed toggle’s body is shown in its place), which picks the caret. The editor passes it; headless, document order decides. |
replaceRange(from, to, view?) |
Like deleteRange, but always keeps the first block, ready for replacement content. |
insertFlow(target, flow, view?) |
Paste-style insertion. target is a DocPosition or { replace: BlockId[] }; flow is { lines, whole? }, where each line is a BlockSpec whose type may be omitted for plain inline content. Several lines split the block, and its children go to the last line, except children view.hidden(id) names (a closed toggle’s body), which stay with the first. The editor passes it; headless, every child goes to the last line. A line whose kind shows no text of its own (a list or a code block) or is a void or an island (a divider, an image) is placed as a block and never joined: the rest of the block, with its children, moves to a new line of the block’s kind after it (a fresh line of the parent’s default kind when there is no rest and no child to carry) (so a server looking for the children under a pasted list’s id does not find them there), and at the block’s start the lines go before it. At the end of a block view.header(id) names (the editor passes it: an open toggle, a callout or quote with nested lines), the block keeps its kind, data and children, and what would follow it (the lines after a joining first line, or every line when the first stands apart) becomes its first children, as Enter opens a first child there; with no text, it takes a joining first line’s text but not its kind (an empty one with no children is replaced by a first line that stands apart, as any empty block). 'closed' (the editor answers it for any closed <details>, such as a closed toggle) keeps its kind the same way, and the lines go after it, a joined line’s children included, so no pasted line lands in the hidden body. Inside a code line, and over selected code lines ({ replace }), the flow is placed as plain code lines. A plain line (a run or a paragraph) placed directly in a list becomes a list item, and so does a line of the kind view.itemKind(listId) names (the editor passes the list’s itemKind: a pasted numbered item in an ordered-list); a line of any other kind (an image, a heading) keeps its kind and data. |
The prepared plan of these operations carries at, the DocPosition where the caret should land.
Prepare, apply, compose
Every operation also exists in two phases. facade.prepare.<op>(…) checks it and returns a plan without writing; facade.apply(plan) writes it in one transaction. Calling facade.<op>(…) is apply(prepare.<op>(…)).
const plan = facade.prepare.splitBlock('p1', 5, 'p1b');
if ('writes' in plan) {
plan.effect; // { creates: ['p1b'], removes: [], merges: [], moves: [], meta: [], textRanges: [...] }
facade.apply(plan); // { status: 'applied', ids: ['p1b'] }
}
// Independent edits as one plan, one transaction, one undo step:
facade.apply(
facade.compose(
facade.prepare.setBlockType('p2', 'heading'),
facade.prepare.setBlockData('p2', { level: 2 })
)
);
A plan is { ids, writes, effect, version, at? }; a refusal is returned as its OpResult. A plan is valid only at the version it was prepared against and in the same synchronous turn: applying it after the document changed throws. compose(...plans) joins plans prepared at the same version whose steps do not depend on each other.
Reads
| Read | Returns |
|---|---|
toJSON() |
The document as JSONDoc ({ data?, children: JSONBlock[] }; data only when the document has properties). |
docData() |
The document’s own data ({} when it has none), including writes made earlier in the current transaction. |
dataItemIds(target, path) |
The ids of the items of the array at path (keys, item indexes or ids) in a block’s (target its id), an inline block’s ({ block, atom }) or the document’s (null) data, in order; [] where there is no array. A patchData path can name an item by its id, which reaches it wherever collaborators move it. |
project() |
The visible tree as { children: ProjectedBlock[] } with ContentItem content. |
blockJSON(id) |
One block’s subtree as JSONBlock. |
childrenIds(parent) |
Visible child ids; null for the root. |
parentOf(id), ancestorsOf(id) |
The display parent (null at the root), and the ancestors, nearest first. |
positionOf(id), pathOf(id) |
{ parent, index }, and the index path from the root; null when not visible. |
blockTypeOf(id), blockDataOf(id), blockText(id) |
Type, data, and plain text (null for a block that is not live). |
contentItems(id), displayLength(id) |
Content items and display length, including writes made earlier in the current transaction. |
runs(id) |
The block’s content runs as of the last commit: frozen arrays, shared between reads. |
hasBlock(id), isVisibleBlock(id) |
Registered at all (even deleted or merged away), and shown in the tree. |
isVoid(id), isIsland(id), isLines(id), islandOf(id), insideIsland(id) |
Structural roles, as the document’s semantics declare them (isLines: an island of lines, such as a code block). Without a view or semantics, no kind is void or an island. |
isLayout(id), isLayoutItem(id) |
Whether a block is a layout (its kind’s role says layout, such as columns), and whether it is one of a layout’s items (a block of its item kind directly in it, a column). See Columns. |
besideAt(id, kind?) |
The block a beside placement at id stands beside, as placeBeside resolves it: a layout or a column for itself, a list item’s list, a code line’s code block, the block itself where a layout of kind (else the only layout kind) fits, else its outermost block below the root or a column. |
listBlockIds() |
Every visible block id, in document order. |
version |
A counter that changes on every write that changed the tree. |
Order and capability
| Query | Returns |
|---|---|
order() |
All visible block ids in document order (a depth-first walk). |
compare(a, b) |
Negative when a comes first. Blocks that are not visible sort last. |
next(id, policy?), previous(id, policy?) |
The neighbor in document order, or null. With { sealed: true }, the walk never enters an island it did not start in. |
canPlace(ids, parent?) |
Whether the blocks may be moved under parent (null for the root), keeping their kinds: false when they would sit directly in a container they are no items of (fits). A block already under parent always fits it, so an image a merge left in a list still moves among its items. Without parent: whether they may move at all. |
fits(parent, kind) |
The container rule: whether a block of kind may sit directly under parent. A container whose default child is a kind of its own (a list’s list-item, a columns layout’s column) holds only those, and containers of them when that kind renders content (a list holds a nested list directly; a layout holds no layout); one whose default child is the document’s (a column) holds any block. |
landingOf(id, kind, after?) |
Where a block of kind at id’s place lands, as liftOut places it: { parent, levels }, the first parent up that kind fits and the containers it leaves on the way, innermost first. With after, for a new block inserted after id (the block’s own kind then does not keep it in place). |
nestParent(ids, parent) |
Where the blocks nest when nested into parent: parent, or the last item of a container they are no items of (what Tab and nestBlock use). When the container ends with a block that holds no children (an image, a code block), that block is the answer and canPlace refuses it: Tab after such a list does nothing, as Tab under that block does. |
canMerge(from, into) |
Whether from’s content may merge into into: both live, neither void, into renders its content (never a list, a row or a code block), same side of an island boundary. |
defaultChild(parentId) |
The block type a new child of parentId gets (null for the root). |
Whether a block type renders its own content is a document-level question: document.rendersContent(type).
Changes
facade.onChange(callback) calls back once per committed transaction that changed the visible document or its data, local or remote, and returns an unsubscribe function. A callback that throws is logged; the other callbacks and the document carry on.
const off = facade.onChange((change) => {
if (!change.local) console.log('remote edit in', [...change.content.keys()]);
});
| Field | Type | Description |
|---|---|---|
added |
Map<BlockId, ProjectedBlock> |
Newly visible blocks, with their subtree. |
removed |
Set<BlockId> |
Blocks no longer visible (deleted or merged away). A block under a deleted parent is not removed: it takes the parent’s place. |
moved |
Set<BlockId> |
Blocks whose parent or position changed. |
meta |
Map<BlockId, { type, data? }> |
Blocks whose type or data changed. An inline block’s data change is a content change of its block. |
content |
Map<BlockId, readonly ContentRun[]> |
Blocks whose visible content changed, with the new runs. |
order |
Map<BlockId | null, readonly BlockId[]> |
Parents whose child list changed, with the new order. |
data |
Record<string, unknown> | undefined |
The document’s data, when the transaction changed it. |
origin, local |
unknown, boolean |
The transaction’s origin, and false for edits that came from a provider. |
version |
number |
Increases with every change event. |
transact(fn, origin?) groups several operations into one transaction and one change event. It is not a rollback: a throw from fn does not undo the writes made before it. That holds for document.transact and the room’s transact too; for all or nothing, apply one prepare.* plan, since a refused plan writes nothing. Prefer document.transact(fn), which also makes it one undo step.
Block handles
facade.block(id) returns a handle over one block, with the same operations bound to that id. Handles are cached per id and safe on missing ids (operations are refused).
const block = facade.block('p1');
block.insertText(0, '> ');
block.items; // ContentItem[]
block.split(2, 'p1-tail');
block.delete(); // children take its place; delete({ keepChildren: false }) removes them too
Members: id, attribution, items, runs, length, childIds(), insertText, deleteText, format, setMark, unsetMark, clearMarks, insertInline, removeInline, setInlineData (a replace), insertChild(index, spec), moveTo({ parent, index }), nestUnder(parent), unNest(), split(offset, newId, tail?), mergeBackward(), mergeForward(), mergeFrom(other), delete(opts?), setType, setData (a replace), set(value), duplicate(freshId) (the same callback as duplicateBlock).
Attribution
Each block records who created it and who changed it, as actor ids (actor.id from createDocument):
document.attribution.block('p3');
// { createdBy: 'user-42', contributors: Set { 'user-42' }, lastChangedBy: 'user-42' }
facade.blockAttribution('p3'); // the same record
document.attribution.actors.get('user-42'); // { name: 'Ada', color: '#7559ee' }
| Field | Description |
|---|---|
createdBy |
The actor who created the block. Absent on blocks from a seeded value. |
lastChangedBy |
The actor of the block’s last content change. Deletes and moves do not change it. |
contributors |
Every actor who edited this block id, including through splits and merges. It describes the block’s history, not who wrote the text visible now: do not present it as “written by”. |
attribution.actorOf(clientID) maps a CRDT client id to its actor, attribution.setProfile({ name, color }) republishes the local actor’s profile, and attribution.history(id) returns earlier versions of a block when the document was created with lineage: { depth }.