Concepts
Commands
A command is a function that takes a context and returns a boolean: true if it did something, false if it declined. It never throws · one that does is caught, logged, and its half-built transaction discarded rather than applied.
const toggleBold: Command = (ctx) => ctx.toggleMark('bold') The boolean is the interesting part
A command returning false is not an error. It is the answer to "can this happen here?",
and it is how the schema, the selection and the document shape all get a say without anybody writing
a check.
// In a comment box with no heading in its schema:
editor.commands.toggleHeading(2) // undefined · not a command at all
// In a full editor, with the caret inside a code block:
editor.commands.toggleBold() // false · code blocks accept no marks
// With nothing selected:
editor.commands.askAi('shorten') // false · there is nothing to shorten
A keyboard shortcut that returns false falls through to the browser's own handling
rather than swallowing the key.
Names are checked too
The same inference covers the names you pass to isActive. An editor built
without heading has no toggleHeading — and no node called "heading"
either, so asking about one is a compile error rather than a permanent
false.
const editor = createEditor({ extensions: [document, paragraph, text, bold] })
editor.isActive('bold') // fine
editor.isActive('heading') // Argument of type '"heading"' is not assignable
editor.isActive('bould') // nor is a typo
This is the failure that used to be hardest to see. A missing command is a
TypeError the first time you press the button; a misspelt name in
isActive is a toolbar button that simply never lights up, on a page where everything
else works.
Asking without doing
The boolean above only arrives after the change has happened, which is no use to a button
that wants to be disabled before it is pressed. editor.can is the same command
surface, with the same names and the same arguments, that returns the answer and changes nothing.
bold.disabled = !editor.can.toggleBold()
undo.disabled = !editor.can.undo()
The command builds its transaction as usual and the transaction is thrown away, so asking
costs about what pressing the button costs and leaves the document, the selection and the
undo stack exactly as they were. No change event fires.
Repaint on selectionChange as well as change. Whether bold is
possible depends on where the caret is, so moving it into a code block has to grey the
button out even though nothing was edited.
Types are inferred, not declared
editor.commands is built from the extensions array you passed. Add an extension and
its commands appear, typed, with their argument types intact. Leave it out and calling them is
a compile error rather than a runtime surprise.
const editor = createEditor({ extensions: [document, paragraph, text, bold] })
editor.commands.toggleBold() // fine
editor.commands.toggleItalic() // Property 'toggleItalic' does not exist There is no module augmentation and nothing to declare. This is the one thing here that other editors cannot copy without changing their architecture: Tiptap's commands live in a global interface, so every installed extension's commands appear on every editor whether you passed them or not.
Running several as one
editor.batch((commands) => {
commands.toggleBold()
commands.insert(' and this')
})
One undo step, and all or nothing · if any command returns false the whole batch
rolls back and batch itself returns false. Nothing half-applied
reaches the document.
Making a change its own undo step
Undo groups by time, which is right for typing and wrong for anything deliberate. A command that restores a template, applies a rewrite or accepts a suggestion should not disappear into the sentence somebody was writing a second earlier.
const applyTemplate: Command = (ctx) => {
if (!ctx.replace({ from: 0, to: size }, template)) return false
return ctx.isolateUndo()
} It seals from both sides: nothing merges into it from before, and the next keystroke does not merge into it either.
The core commands
These exist whatever extensions you pass, because the engine provides them:
| Command | What it does |
|---|---|
select(range) | Move the selection · a position or a range |
insert(content, at?) | JSON, an array of nodes, or an HTML string |
replace(range, content) | Swap a span for something else |
remove(range?) | Delete a span, or the selection |
moveBlock(from, to) | What a drag handle does |
focus() | Put the caret back in the editor |
What the context gives you
Inside a command, ctx is the whole surface. The document as JSON, the selection,
and the operations that change them:
ctx.doc // the document, as plain JSON
ctx.selection // { from, to, anchor, head, empty }
ctx.toggleMark('bold') // and addMark, removeMark
ctx.setBlockType('heading', { level: 2 })
ctx.setNodeAttrs('taskItem', { checked: true })
ctx.wrapIn('blockquote') // and lift()
ctx.insert(content, at?) // replace, delete, select, focus
ctx.mark() // a position marker · see position mapping
ctx.isolateUndo() setBlockType only applies to textblocks — nodes that hold text. For a node that
holds blocks, like a checklist item or a table cell, use
setNodeAttrs. Ticking a checkbox with setBlockType silently does nothing,
which is a mistake worth making only once.
Writing a position down
Pos is branded, so arithmetic on one does not typecheck: from + 5
is a plain number, and a plain number is not accepted back. That is
deliberate · a position computed from a stale one is the bug this editor exists to prevent, and
it is invisible at runtime.
It does mean a literal position needs saying out loud. pos and
range are that, and nothing else:
import { pos, range } from '@matrajs/core'
editor.commands.select(pos(0))
editor.commands.replace(range(1, 6), 'goodbye')
Use them for positions you are writing down, never for one you are carrying across an
await · that is ctx.mark(), which maps rather than guesses. See
position mapping.
Writing your own
A command is a plain function on an extension. Nothing registers it, nothing wraps it, and
the name you give it is the name on editor.commands:
import type { Command, ExtensionDef } from '@matrajs/core'
const shout: Command<[times?: number]> = (ctx, times = 1) => {
const { from, to } = ctx.selection
if (from === to) return false // nothing selected · decline
return ctx.insert('!'.repeat(times), to)
}
export const emphasis: ExtensionDef<{ shout: Command<[times?: number]> }> = {
kind: 'extension',
name: 'emphasis',
commands: { shout },
keys: { 'Mod-Alt-!': 'shout' },
} editor.commands.shout(3) now exists and is typed, because the array it came from
says so. See writing an extension for the rest of the shape.
Positions that are not finite integers inside the document return false
rather than throwing — NaN included. It slips past a naive range check, because
both NaN < 0 and NaN > size are false.