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:
operationstring
The operation name, such as 'insertText' or 'mergeBlockBackward'. Narrows payload.
stringpayloadobject
The operation's arguments. Its shape depends on operation (table below).
objectblockBlock
The block the operation is about.
Blocktext?Text
The text segment, on text operations and text steps.
Texteffect?PlanEffect
What the command would do. Present on a command that is one document plan; absent on steps and on text-level operations.
PlanEffectprevent(cb?: () => void) => void
Refuse the whole command, optionally running cb in its place.
(cb?: () => void) => voidCommands 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:
creates?string[]
Ids of blocks the command creates.
string[]removes?string[]
Ids of blocks that leave the document.
string[]merges?[from: string, into: string][]
Blocks merged into another.
[from: string, into: string][]moves?string[]
Blocks given a new place.
string[]meta?string[]
Blocks whose type or data changes.
string[]textRanges?{ block, offset, length }[]
Text ranges written.
{ 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)runsbodywithplan, prepared now, composed into the first operationbodydispatches: 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. Whentakenisfalse, nothing of the lead was written: eitherbodydispatched nothing (remove the trigger yourself, as the slash menu does for a command that writes later), or the lead was a refusal andbodydid not run.edytor.dispatcher.dispatch(operation, payload, { block, text? }, body, prepare?)dispatches an operation of your own, under your own name: admission,onBeforeOperationfor the command and, whenpreparereturns a plan, for each of its steps, thenbody(payload, plan)in one transaction,dispatcher.lastandonAfterOperation. Withprepare, apply the plan you are given (facade.apply(plan)), which a rewritten payload re-prepares. It answersbody’s result, orundefinedwhen the view refuses it (readonly, a read-only document) or a plugin prevents it. When the document refuses the plan,bodystill runs, with that refusal asplan('writes' in planisfalse, andfacade.apply(plan)writes nothing and answersrefused), anddispatcher.last.statusisrefused.edytor.dispatcher.caret(text, offset, ops?)is the command’s result caret: selected once, recorded ondispatcher.last.selection, and shown after the DOM update. Withops, the caret is declared beforeopsruns, so it follows the edit, and is written only whenopsreturns 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.