---
title: Document API
description: Reference for the document facade, its operations and results, order and capability queries, change events and attribution.
icon: file-code
---

`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](/docs/collaboration/documents); for the tree model, see [the document model](/docs/concepts/document-model).

```ts title="results.ts" check
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:

```ts title="src/lib/semantics.ts" check
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.

```ts
import { toBlockSpec } from 'edytor/crdt/edytor';

facade.insertBlock({ parent: null, index: 1 }, toBlockSpec(facade.blockJSON('p1'), { freshIds: true }));
```

## Results

Every operation returns an `OpResult`:

```ts
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 <kbd>Shift+Tab</kbd> 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 `replace`s 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](/docs/collaboration/concurrent-editing#which-races-keep-the-text-order).

## 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 `DocPosition`s 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>(…))`.

```ts
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`](/docs/collaboration/documents#create-a-document) 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.

```ts
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`](/docs/server/extending#edit-on-the-server) 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`).

```ts
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`):

```ts
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 }`.
