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

Operations

How onBeforeOperation sees every edit as a command and its planned steps, and how to veto, replace or rewrite edits and react after them.

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:

PropType
operationstring

The operation name, such as 'insertText' or 'mergeBlockBackward'. Narrows payload.

Typestring
payloadobject

The operation's arguments. Its shape depends on operation (table below).

Typeobject
blockBlock

The block the operation is about.

TypeBlock
text?Text

The text segment, on text operations and text steps.

TypeText
effect?PlanEffect

What the command would do. Present on a command that is one document plan; absent on steps and on text-level operations.

TypePlanEffect
prevent(cb?: () => void) => void

Refuse the whole command, optionally running cb in its place.

Type(cb?: () => void) => void

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
Enter splitBlock, insertBlockAfter, insertBlockBefore, addChildBlock, unNestBlock or setBlock, by the block’s role the steps it plans
Backspace at a block start setBlock, unNestBlock or mergeBlockBackward, by the block’s role the child moves it plans
Tab, Shift + Tab nestBlock, unNestBlock; over several selected blocks, moveBlocks 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
Formatting markText none
Block handle or arrow-move moveBlock or moveBlocks none
Duplicate (block menu, Mod + D) 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.

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: Backspace, a range deletion, a cut, a block-selection delete.

effect describes the whole plan:

PropType
creates?string[]

Ids of blocks the command creates.

Typestring[]
removes?string[]

Ids of blocks that leave the document.

Typestring[]
merges?[from: string, into: string][]

Blocks merged into another.

Type[from: string, into: string][]
moves?string[]

Blocks given a new place.

Typestring[]
meta?string[]

Blocks whose type or data changes.

Typestring[]
textRanges?{ block, offset, length }[]

Text ranges written.

Type{ block, offset, length }[]

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 does:

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.

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:

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. 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.

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:

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.

Was this page helpful?