Skip to content
Edytor
Esc
↑↓navigate↵open⌘Jpreview
On this page

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 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 Tab and Shift+Tab 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). Direction out answers 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.

Was this page helpful?