Editing programmatically
Change the document from code with handle commands or facade operations, read their results, group writes with transact, and move blocks.
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:
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:
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:
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.
Results
Every command sets edytor.dispatcher.last:
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 Tab 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). |
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:
type OpResult = {
status: 'applied' | 'noop' | 'refused';
ids: readonly string[]; // what it created, moved or targeted (empty unless applied)
reason?: string; // e.g. 'id-collision'
};
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):
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.
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.
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:
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). |
const moved = edytor.moveBlocks({ blocks: [...edytor.selection.selectedBlocks], direction: 'down' });
- A group moved by
directionmust be siblings, and keeps its document order. Withtargetandposition, the blocks land in the order you pass them. - With
inorout, siblings that have another block between them move as separate runs of adjacent siblings, as Tab and Shift+Tab do, so the text keeps its order:[a, c]out of a listatoeleavesbbetween them. A run that cannot move stays where it is, and the result lists only the blocks that moved;canMoveBlocksistruewhen at least one run can move. Withupordownthe group moves together. moveBlocksreturns the blocks it moved, or[]when the move was refused (dispatcher.last.statusis then'refused', also for a movecanMoveBlocksrejects).- A closed toggle the blocks land in, or that takes blocks as children (
out), opens, so a moved block is never hidden. The block commandsnestBlock,unNestBlock,moveBlockandmoveBlocksopen it too. canMoveBlocksanswers whether the move is structurally allowed. It isfalsefor 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). Directionoutanswers as Shift+Tab 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, Alt+arrows on a focused handle and Mod+↑/↓ (arrowMovePlugin) perform. The types are exported as BlockMoveRequest, BlockMovePosition and BlockMoveDirection.