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:

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

Edit this page on GitHub