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 and imageSemantics are frozen and shared by every document. 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. |
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); refused when it would land directly in another container it is no item of (a paragraph in a columns layout). |
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. |
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. |
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. |
setBlockData(id, data) |
id |
Replaces the whole data object. |
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) |
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. 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 ({ children: JSONBlock[] }). |
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), islandOf(id), insideIsland(id) |
Structural roles, as the document’s semantics declare them. Without a view or semantics, no kind is void or an island. |
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; 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, 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. |
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. |
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, insertChild(index, spec), moveTo({ parent, index }), nestUnder(parent), unNest(), split(offset, newId, tail?), mergeBackward(), mergeForward(), mergeFrom(other), delete(opts?), setType, setData, 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 }.