---
title: Operations
description: How onBeforeOperation sees every edit as a command and its planned steps, and how to veto, replace or rewrite edits and react after them.
icon: git-branch
---

Every edit, whether typed, pasted, dragged or called from code, runs as a command through one dispatcher. Before anything is written, the dispatcher shows the command to every plugin's `onBeforeOperation`, then shows each step the command plans. Any plugin can refuse it, replace it with its own logic, or rewrite its payload. Use these hooks to enforce rules on the document (protected blocks, schema constraints) or to turn one edit into another (auto-pairing, mentions on `@`).

## The change payload

`onBeforeOperation` receives one object per command and per step:

| Prop | Type | Default | Description |
| - | - | - | - |
| `operation` | `string` | - | The operation name, such as 'insertText' or 'mergeBlockBackward'. Narrows payload. |
| `payload` | `object` | - | The operation's arguments. Its shape depends on operation (table below). |
| `block` | `Block` | - | The block the operation is about. |
| `text?` | `Text` | - | The text segment, on text operations and text steps. |
| `effect?` | `PlanEffect` | - | What the command would do. Present on a command that is one document plan; absent on steps and on text-level operations. |
| `prevent` | `(cb?: () => void) => void` | - | Refuse the whole command, optionally running cb in its place. |

## Commands and steps

A command is shown first, under its own name. If it is one document plan, each planned step is then shown under its documented operation name. A few examples:

| Gesture                                   | Command                        | Steps shown after it                                         |
| ----------------------------------------- | ------------------------------ | ------------------------------------------------------------ |
| Typing                                    | `insertText`                   | none                                                         |
| <kbd>Enter</kbd>                          | `splitBlock`, `insertBlockAfter`, `insertBlockBefore`, `addChildBlock`, `unNestBlock` or `setBlock`, by the block's [role](/docs/customization/hotkeys#enter-and-backspace-by-role) | the steps it plans |
| <kbd>Backspace</kbd> at a block start     | `setBlock`, `unNestBlock` or `mergeBlockBackward`, by the block's [role](/docs/customization/hotkeys#enter-and-backspace-by-role) | the child moves it plans |
| <kbd>Tab</kbd>, <kbd>Shift</kbd> + <kbd>Tab</kbd> | `nestBlock`, `unNestBlock`; over several selected blocks, one `moveBlock` or `moveBlocks` per run of adjacent siblings | the moves it plans |
| Deleting a range across blocks            | `deleteContentWithinSelection` | `deleteContentAtRange`, `removeBlock`, `mergeBlockBackward`  |
| Deleting a block selection                | `deleteBlocks`                 | one `removeBlock` per selected block                         |
| Paste or drop                             | `insertFlow`                   | `splitBlock`, `insertText`, `addChildBlocks`                 |
| Markdown shortcut or slash command        | `setBlock`                     | `deleteContentAtRange` for the removed trigger text          |
| Turn into (menus, <kbd>Mod</kbd> + <kbd>Alt</kbd> + digit) | `setBlock`, or `insertBlockAfter` for a kind inserted after the block; over several blocks, one per block | the moves and inserts that place the kind |
| Formatting                                | `markText`                     | none                                                         |
| Block handle or arrow-move                | `moveBlock` or `moveBlocks`    | none                                                         |
| Duplicate (block menu, <kbd>Mod</kbd> + <kbd>D</kbd>) | `duplicateBlock`   | `addChildBlocks` on the parent                               |

The step names are `addChildBlocks`, `moveBlock`, `moveBlocks`, `removeBlock`, `splitBlock`, `mergeBlockBackward`, `setBlock`, `insertText`, `addInlineBlock` and `deleteContentAtRange`. Work an operation does internally, such as normalization, is part of it and is not shown separately.

A command the document itself refuses is still shown, without steps, so a plugin can replace it.

## Refusing an edit

Call `prevent()` on the command or on any of its steps to refuse the whole command. Nothing is written and no undo step is recorded.

A gesture over several blocks that issues one command per part refuses only the part you veto, as the document refuses a part it cannot apply: Turn into over several blocks (one command per block), <kbd>Tab</kbd> and <kbd>Shift</kbd> + <kbd>Tab</kbd> over several runs of siblings (one per run) or over several lines of a code block (one `insertText` or `deleteContentAtRange` per line), formatting across blocks (one `markText` per block) and the block menu's Duplicate. The other parts apply, together one undo step, and `dispatcher.last` reports `applied` when one did. A block-selection delete, from the keyboard (any <kbd>Backspace</kbd> or <kbd>Delete</kbd> chord) or the block menu, is one command: a veto keeps every block. So is typing or a paste over a block selection: one `insertFlow` replaces the blocks, and a veto keeps them all (`onDeleteSelectedBlocks` runs first, see [Writing plugins](/docs/plugins/writing-plugins)). Your own gesture gets the same rule from `edytor.dispatcher.each(kind, parts, part)`.

```ts title="lockedBlocks.ts" check
import type { Plugin } from 'edytor';

/** Blocks with data.locked cannot be deleted or merged away. */
export const lockedBlocksPlugin: Plugin = (edytor) => ({
	onBeforeOperation: ({ effect, prevent }) => {
		if (!effect) return;
		const leaving = [...effect.removes, ...effect.merges.map(([from]) => from)];
		if (leaving.some((id) => edytor.idToBlock.get(id)?.data.locked)) prevent();
	}
});
```

Because steps are shown too, this catches every path that would remove a locked block: <kbd>Backspace</kbd>, a range deletion, a cut, a block-selection delete.

Typing, <kbd>Enter</kbd> or a paste over a text range runs two commands: `deleteContentWithinSelection` removes the range, then the insertion (`insertText`, `splitBlock`, `insertFlow`) runs at the caret it leaves. Refusing the insertion alone leaves the range deleted; to keep the range, refuse `deleteContentWithinSelection` or one of its steps.

`effect` describes the whole plan:

| Prop | Type | Default | Description |
| - | - | - | - |
| `creates?` | `string[]` | - | Ids of blocks the command creates. |
| `removes?` | `string[]` | - | Ids of blocks that leave the document. |
| `merges?` | `[from: string, into: string][]` | - | Blocks merged into another. |
| `moves?` | `string[]` | - | Blocks given a new place. |
| `meta?` | `string[]` | - | Blocks whose type or data changes. |
| `textRanges?` | `{ block, offset, length }[]` | - | Text ranges written. |

A step's veto refuses the command it belongs to, so check the operation name before you veto a step that many commands share, such as `insertText` or `deleteContentAtRange`.

## Replacing an edit

`prevent(cb)` refuses the command and runs `cb` instead. This replaces a typed `@` with an inline atom, as the [mention reference plugin](/docs/plugins/mention) does:

```ts
onBeforeOperation: ({ operation, payload, block, prevent }) => {
	if (operation === 'insertText' && payload.value === '@') {
		const { yStart, startText } = edytor.selection.state;
		if (!startText) return;
		prevent(() => {
			const after = block.addInlineBlock({
				index: yStart,
				text: startText,
				block: { type: 'mention', data: {} }
			});
			if (after) edytor.selection.setAtTextOffset(after, 0);
		});
	}
}
```

The callback is a command of its own. If another plugin refuses one of the operations it issues, that operation writes nothing and the callback carries on. Each plugin replaces a given command at most once: while your callback runs, your own `prevent(cb)` on the operations it issues is ignored, so the callback can call the operation it replaced without recursing.

## Triggers: one plan

A trigger plugin (`:smile:` → 😄, `@query` → a mention) removes the text that triggered it and writes its replacement. Issued as two operations, a veto of the second leaves the trigger text already gone. Three dispatcher members make the pair one refusable command, as the slash menu and markdown shortcuts do:

- `edytor.dispatcher.lead(plan, body)` runs `body` with `plan`, prepared now, composed into the first operation `body` dispatches: one plan in one transaction. Hooks see the lead's steps with that operation's; a veto of any of them, or a refusal, writes neither. When that operation is itself one plan, the lead's steps on a block whose whole content the operation replaces are dropped. Otherwise the lead applies first and the operation reads the state it leaves. It answers `{ out, taken }`: `body`'s result, and whether an operation took the lead. When `taken` is `false`, nothing of the lead was written: either `body` dispatched nothing (remove the trigger yourself, as the slash menu does for a command that writes later), or the lead was a refusal and `body` did not run.
- `edytor.dispatcher.dispatch(operation, payload, { block, text? }, body, prepare?)` dispatches an operation of your own, under your own name: admission, `onBeforeOperation` for the command and, when `prepare` returns a plan, for each of its steps, then `body(payload, plan)` in one transaction, `dispatcher.last` and `onAfterOperation`. With `prepare`, apply the plan you are given (`facade.apply(plan)`), which a rewritten payload re-prepares. It answers `body`'s result, or `undefined` when the view refuses it (readonly, a read-only document) or a plugin prevents it. When the document refuses the plan, `body` still runs, with that refusal as `plan` (`'writes' in plan` is `false`, and `facade.apply(plan)` writes nothing and answers `refused`), and `dispatcher.last.status` is `refused`.
- `edytor.dispatcher.caret(text, offset, ops?)` is the command's result caret: selected once, recorded on `dispatcher.last.selection`, and shown after the DOM update. With `ops`, the caret is declared before `ops` runs, so it follows the edit, and is written only when `ops` returns a truthy value. A caret inside text the edit deletes does not survive: set it after the edit instead.

`facade.prepare.<op>(…)` returns a `Prepared`: a `Plan`, or the operation's refusal. Both types are exported from `edytor`, and `'writes' in prepared` tells them apart. See [the document API](/docs/reference/document-api).

```ts title="emoji.ts" check
import type { Plugin } from 'edytor';

const EMOJI: Record<string, string> = { smile: '😄' };

/** Typing the closing `:` of `:smile:` replaces the whole trigger with its emoji. */
export const emojiPlugin: Plugin = (edytor) => ({
	onBeforeOperation: ({ operation, payload, block, prevent }) => {
		if (operation !== 'insertText' || payload.value !== ':') return;
		const { startText: text, yStart } = edytor.selection.state;
		if (!text) return;
		const match = /:(\w+)$/.exec(text.stringContent.slice(0, yStart));
		const emoji = match && EMOJI[match[1]!];
		if (!emoji) return;
		const start = yStart - match[0].length;
		prevent(() => {
			const { dispatcher, facade } = edytor;
			// `text.segStart` maps a segment offset to the block's display offset.
			const trigger = facade.prepare.deleteText(block.id, text.segStart + start, match[0].length);
			dispatcher.lead(trigger, () => text.insertText({ value: emoji, start, end: start }));
			if (dispatcher.last?.status === 'applied') dispatcher.caret(text, start + emoji.length);
		});
	}
});
```

If another plugin refuses the `insertText`, or the `deleteContentAtRange` step that removes `:smile`, nothing is written and no undo step is recorded: the trigger text stays.

## Rewriting the payload

Return a new payload from `onBeforeOperation` to replace the command's arguments. The code plugin auto-pairs brackets this way:

```ts
onBeforeOperation: ({ operation, payload, block }) => {
	if (block.type === 'codeLine' && operation === 'insertText' && payload.value === '(') {
		return { ...payload, value: '()' };
	}
}
```

The replacement is prepared again and shown to every plugin from the start. A Turn into is placed for the kind its replacement names (rewritten to a list's item, it stays in the list; rewritten to a heading, it leaves the list), that kind's `normalizeContent` runs on it, and the caret goes into it. Each plugin rewrites a command at most once. A payload returned for a step is ignored, with a warning in development.

## After an edit

`onAfterOperation` runs once per command, after its transaction, with the original payload. It does not run for steps or for refused commands, and it has no `prevent`.

```ts
onAfterOperation: ({ operation, block }) => {
	if (operation === 'setBlock') console.log(block.id, 'is now', block.type);
}
```

## Reading the result

`edytor.dispatcher.last` holds the result of the last operation:

```ts
block.mergeBlockBackward();
if (edytor.dispatcher.last?.status === 'refused') {
	// A plugin, a readonly editor or the document refused it.
}
```

`status` is `'applied'` (it wrote), `'noop'` (it ran and changed nothing), `'refused'` (a plugin, the readonly state or the document refused it) or `'failed'` (it threw; the error is rethrown and kept in `error`). A refusal is reported, never thrown.

A readonly editor, or a document that has become read-only, refuses every command that would write. An error thrown by a hook is not swallowed: it surfaces to the caller.

## Operation reference

Text operations carry `text`. Block operations run on `block`.

| Operation                      | Payload                                                     |
| ------------------------------ | ----------------------------------------------------------- |
| `insertText`                   | `{ value, start?, end?, marks? }`                           |
| `deleteText`                   | `{ direction: 'BACKWARD' or 'FORWARD', length }`            |
| `splitText`                    | `{ index? }`                                                |
| `setText`                      | `{ value: JSONText[] }`                                     |
| `markText`                     | `{ mark, start?, end?, value?, toggle? }`                   |
| `removeMarksFromText`          | `{ start?, end? }`                                          |
| `splitBlock`                   | `{ index, text }`                                           |
| `insertBlockAfter`, `insertBlockBefore` | `{ block: JSONBlock }`                             |
| `duplicateBlock`               | `{}` (a copy of the block and its subtree after it, under fresh ids) |
| `addChildBlock`                | `{ block: JSONBlock, index }`                               |
| `addChildBlocks`               | `{ blocks: JSONBlock[], index }`                            |
| `removeBlock`                  | `{ keepChildren? }`                                         |
| `deleteBlocks`                 | `{ blocks: Block[] }`                                       |
| `mergeBlockBackward`, `mergeBlockForward` | `{}`                                             |
| `nestBlock`, `unNestBlock`     | `{}`                                                        |
| `setBlock`                     | `{ value: Partial<JSONBlock> }`                             |
| `setInlineData`                | `{ id, data }` (an inline atom's data: `atom.setData`)      |
| `pushContentIntoBlock`         | `{ value: (Text \| InlineBlock)[] }`                        |
| `moveBlock`                    | `{ path: number[] }`                                        |
| `moveBlocks`                   | `{ blocks: Block[], path: number[] }`                       |
| `addInlineBlock`               | `{ index, text, block: JSONInlineBlock }`                   |
| `removeInlineBlock`            | `{ index }`                                                 |
| `deleteContentAtRange`         | `{ start: [part, offset], end: [part, offset] }`            |
| `deleteContentWithinSelection` | `{ replace? }`                                              |
| `insertFlow`                   | `{ flow, target }`                                          |
| `insertDivider`                | `{}`                                                        |
| `suggestText`                  | `{ value }`                                                 |
| `acceptSuggestedText`          | `{}`                                                        |

In `deleteContentAtRange`, `part` is an index into `block.content` and `offset` an offset inside that text.
