---
title: Editing programmatically
description: Change the document from code with handle commands or facade operations, read their results, group writes with transact, and move blocks.
icon: terminal
---

You can change a document from code in two ways: **handle commands**, which behave like user edits, and **facade operations**, which write the document directly. This page shows both, how to read their results, and how to move blocks.

## Commands or operations

| | Handle commands | Facade operations |
| --- | --- | --- |
| Called on | `Block`, `Text`, the editor | `edytor.facade` (or `document.facade`) |
| Addresses | Handles and offsets inside a text segment | Block ids and block offsets |
| Plugin hooks (`onBeforeOperation`, `onAfterOperation`) | Yes: plugins can refuse or replace them | No |
| Normalization (`normalizeContent`, `normalizeChildren`) | Yes | No |
| Refused in a readonly view | Yes | No |
| Result | `edytor.dispatcher.last`, plus a return value | An `OpResult` |
| Needs a view | Yes | No, works headlessly |

Use handle commands for anything a user could have done, so your plugins see it. Use facade operations for imports, migrations, server-side edits and code that must bypass plugins.

## Handle commands

Block commands take a payload object:

```ts
const block = edytor.idToBlock.get('intro')!;

// Insert blocks around it. Both return the new block's handle.
const next = block.insertBlockAfter({ block: { type: 'paragraph', content: [{ text: 'After' }] } });
block.insertBlockBefore({ block: { type: 'heading', data: { level: 'h2' } } });

// Change its kind, data, content or children in one step.
block.setBlock({ value: { type: 'quote' } });
block.setData({ ...block.data, icon: '✦' }); // the same `setBlock` command, for `data`
block.type = 'quote'; // also `setBlock`, in place: a list's item set to a paragraph still shows as an item
// (`convertToKind` takes it out of the list, as Turn into does)

// Structure.
block.nestBlock(); // last child of the previous sibling
block.unNestBlock(); // after its parent; the siblings after it become its children
// (a list's item leaves the list instead, see concepts/blocks#containers)
block.mergeBlockBackward(); // join into the previous block (a list's first item leaves the list instead)
block.addChildBlock({ block: { type: 'paragraph' }, index: 0 });
block.removeBlock(); // children take its place
block.removeBlock({ keepChildren: false }); // the whole subtree

// Split at an offset inside a text segment. Returns the new block.
const text = block.firstText!;
const tail = block.splitBlock({ index: 3, text });
```

Text commands run on a `Text` segment. Offsets are inside the segment:

```ts
const text = block.firstText!;
text.insertText({ value: 'Hello', start: 0, end: 0 });
text.insertText({ value: 'link', start: 5, end: 5, marks: { link: { href: 'https://example.com' } } });
text.deleteText({ direction: 'BACKWARD', length: 1 }); // at the caret
text.markText({ mark: 'bold', start: 0, end: 5, toggle: true });
text.removeMarksFromText({ start: 0, end: 5 });
```

Without `start`/`end`, text commands use the current selection. Without `marks`, inserted text takes the marks the caret would give it (pending marks, then the neighboring text).

Commands on the selection live on the editor and on `richTextOperations`:

```ts
import { richTextOperations } from 'edytor';

edytor.deleteContentWithinSelection({}); // delete the selected range
edytor.deleteBlocks({ blocks: edytor.selection.selectedMembers }); // a selected list with its items

const rich = richTextOperations(edytor);
rich.setMarkAtRange('bold'); // toggle over the selection, or stage at a caret
rich.setLinkAtRange({ href: 'https://example.com' }); // unsafe URLs are dropped
rich.setMarkValueAtRange('color', '#dc2626');
rich.removeAllMarksAtRange();
rich.insertDividerAtSelection();
```

To change the block kind, see [`convertToKind`](/docs/concepts/blocks#kinds-and-presets).

### Results

Every command sets `edytor.dispatcher.last`:

```ts
block.mergeBlockBackward();
const { operation, status } = edytor.dispatcher.last!;
// status: 'applied' | 'noop' | 'refused' | 'failed'
```

| Status | Meaning |
| --- | --- |
| `applied` | The command changed the document. A gesture that issues one command per part (a <kbd>Tab</kbd> over several sibling groups, Turn into over several blocks) is `applied` when any part applied: a part the document refuses or a plugin vetoes is skipped and the others apply ([Refusing an edit](/docs/plugins/operations#refusing-an-edit)). |
| `noop` | It ran and changed nothing. |
| `refused` | The view is readonly, the document is read-only, the document refused it (for example merging into a void block), or a plugin prevented it. Nothing was written and no undo step was recorded. |
| `failed` | It threw. The error is in `last.error` and is rethrown. |

Commands that create blocks also return handles: `insertBlockAfter`, `splitBlock` and `mergeBlockBackward` return a `Block`, `null` when the document refuses the command, and `undefined` when it never ran (a readonly view, or a plugin prevented it). Test the result for a falsy value, or read `dispatcher.last.status`.

## Facade operations

`edytor.facade` addresses blocks by id and uses block offsets, where an inline block counts as one character. Every operation returns an `OpResult`:

```ts
type OpResult = {
	status: 'applied' | 'noop' | 'refused';
	ids: readonly string[]; // what it created, moved or targeted (empty unless applied)
	reason?: string; // e.g. 'id-collision'
};
```

```ts
const { facade } = edytor;
const id = 'intro';

facade.insertText(id, 0, 'Hello ', { bold: true });
facade.deleteText(id, 0, 6);
facade.formatRange(id, 0, 5, { italic: true, bold: null }); // null removes a mark
facade.insertBlock(
	{ parent: null, index: 0 }, // null parent = the root
	{ id: 'title', type: 'heading', data: { level: 'h1' }, content: [{ kind: 'text', text: 'Title' }] }
);
facade.moveBlock('title', { parent: null, index: 2 });
facade.setBlockType(id, 'quote');
facade.deleteBlock(id); // { keepChildren: false } removes the subtree
```

Facade inserts take a `BlockSpec`, not a `JSONBlock`: an `id` is required and content items carry a `kind` (`{ kind: 'text', text, marks? }` or `{ kind: 'inline', id, type, data? }`). `toBlockSpec(json)` converts canonical JSON, minting the ids it leaves out (`{ freshIds: true }` mints all of them, for a copy):

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

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

`block.model` is a block's node on the facade: `block.model.setData(data)` writes `data` directly, with no plugin hooks and regardless of `readonly`. An empty operation (`insertText(id, 0, '')`) is `noop`; an unknown or deleted target is `refused`.

The full list (`splitBlock`, `mergeBlocks`, `deleteBlocks`, `deleteRange`, `insertFlow`, `insertInline`, `duplicateBlock`, the `prepare`/`apply` two-phase form, …) is in [the document API reference](/docs/reference/document-api).

## Grouping with `transact`

`edytor.transact(fn)` runs several writes as one transaction: collaborators receive them as one update, normalization runs once at the end, and they are never split across undo steps. It is not a rollback: a throw from `fn` keeps the writes made before it, and normalization still runs on them before the error reaches you; a normalizer that fails then is logged, so the error you get is `fn`'s.

```ts
edytor.transact(() => {
	const heading = edytor.idToBlock.get('title')!;
	heading.setBlock({ value: { data: { level: 'h2' } } });
	edytor.facade.insertText('intro', 0, 'Updated: ');
});
```

Nested `transact` calls join the outer one. Without a view, use `document.transact(fn)`.

## Moving blocks

`edytor.moveBlocks` moves blocks with their identity, children and content, as one undo step. Describe the destination relative to a target block:

```ts
const request = { blocks: [source], target, position: 'after' as const };
if (edytor.canMoveBlocks(request)) edytor.moveBlocks(request);
```

`position` is `'before'`, `'after'` or `'inside'` (as the target's last child).

Or describe one relative step with `direction`:

| Direction | Result |
| --- | --- |
| `up` | Before the previous sibling. From the first child, before the parent. |
| `down` | After the next sibling, never inside its children. From the last child, after the parent. |
| `in` | Last child of the previous sibling; of its last item when it is a list the blocks are no items of (a `position: 'inside'` drop too). Refused when that list ends with a block that holds no children, such as an image, as under that block. |
| `out` | After the parent. The siblings after the last moved block become its children (an outdent). An item of a list container leaves the list instead: before it, after it, or between its two halves, without the items after it ([Containers](/docs/concepts/blocks#containers)). |

```ts
const moved = edytor.moveBlocks({ blocks: [...edytor.selection.selectedBlocks], direction: 'down' });
```

- A group moved by `direction` must be siblings, and keeps its document order. With `target` and `position`, the blocks land in the order you pass them.
- With `in` or `out`, siblings that have another block between them move as separate runs of adjacent siblings, as <kbd>Tab</kbd> and <kbd>Shift+Tab</kbd> do, so the text keeps its order: `[a, c]` out of a list `a` to `e` leaves `b` between them. A run that cannot move stays where it is, and the result lists only the blocks that moved; `canMoveBlocks` is `true` when at least one run can move. With `up` or `down` the group moves together.
- `moveBlocks` returns the blocks it moved, or `[]` when the move was refused (`dispatcher.last.status` is then `'refused'`, also for a move `canMoveBlocks` rejects).
- A closed toggle the blocks land in, or that takes blocks as children (`out`), opens, so a moved block is never hidden. The block commands `nestBlock`, `unNestBlock`, `moveBlock` and `moveBlocks` open it too.
- `canMoveBlocks` answers whether the move is structurally allowed. It is `false` for a destination inside a void block or an island, for blocks that sit inside an island, for a destination inside the moved blocks' own subtree, and directly inside a list for a block that is not a list item (a move keeps kinds; see [Containers](/docs/concepts/blocks#containers)). Direction `out` answers as <kbd>Shift+Tab</kbd> does: a paragraph under a list item outdents into the list as a list item, and an image, a code block or a heading there is refused. A plugin can still refuse the command.

These are the same moves block handles, <kbd>Alt</kbd>+arrows on a focused handle and <kbd>Mod</kbd>+<kbd>↑</kbd>/<kbd>↓</kbd> (`arrowMovePlugin`) perform. The types are exported as `BlockMoveRequest`, `BlockMovePosition` and `BlockMoveDirection`.
