Headless rich text editor built on ProseMirror with extensible architecture for building custom editors
94
The command system in @tiptap/core provides a powerful and flexible way to execute editor actions. Commands can be executed individually, chained together for complex operations, or validated before execution.
The CommandManager class handles command execution, chaining, and validation within the editor context.
/**
* Manages command execution and validation for the editor
*/
class CommandManager {
/**
* Create a new CommandManager instance
* @param props - Configuration including editor and state
*/
constructor(props: {
editor: Editor;
state: EditorState
});
/** Direct access to individual commands */
readonly commands: SingleCommands;
/**
* Create a command chain for sequential execution
* @returns ChainedCommands instance for chaining operations
*/
chain(): ChainedCommands;
/**
* Create command validation interface
* @returns CanCommands instance for testing executability
*/
can(): CanCommands;
/**
* Create a custom command chain with specific transaction and dispatch behavior
* @param startTr - Optional starting transaction
* @param shouldDispatch - Whether commands should be dispatched immediately
* @returns ChainedCommands instance
*/
createChain(
startTr?: Transaction,
shouldDispatch?: boolean
): ChainedCommands;
/**
* Create a custom command validation instance
* @param startTr - Optional starting transaction
* @returns CanCommands instance
*/
createCan(startTr?: Transaction): CanCommands;
/**
* Build command properties for command execution
* @param tr - Transaction to build props for
* @param shouldDispatch - Whether to include dispatch function
* @returns CommandProps object
*/
buildProps(
tr: Transaction,
shouldDispatch?: boolean
): CommandProps;
}Interface for executing individual commands that return boolean success values.
/**
* Interface for executing individual commands
*/
interface SingleCommands {
[commandName: string]: (attributes?: Record<string, any>) => boolean;
// Core commands available by default
insertContent(value: Content, options?: InsertContentOptions): boolean;
deleteSelection(): boolean;
deleteRange(range: { from: number; to: number }): boolean;
enter(): boolean;
focus(position?: FocusPosition, options?: { scrollIntoView?: boolean }): boolean;
blur(): boolean;
selectAll(): boolean;
selectTextblockStart(): boolean;
selectTextblockEnd(): boolean;
selectNodeForward(): boolean;
selectNodeBackward(): boolean;
selectParentNode(): boolean;
// Mark commands
setMark(typeOrName: string | MarkType, attributes?: Record<string, any>): boolean;
toggleMark(typeOrName: string | MarkType, attributes?: Record<string, any>): boolean;
unsetMark(typeOrName: string | MarkType, options?: { extendEmptyMarkRange?: boolean }): boolean;
// Node commands
setNode(typeOrName: string | NodeType, attributes?: Record<string, any>): boolean;
toggleNode(typeOrName: string | NodeType, toggleTypeOrName?: string | NodeType, attributes?: Record<string, any>): boolean;
wrapIn(typeOrName: string | NodeType, attributes?: Record<string, any>): boolean;
lift(typeOrName?: string | NodeType, attributes?: Record<string, any>): boolean;
liftEmptyBlock(): boolean;
splitBlock(options?: { keepMarks?: boolean }): boolean;
joinBackward(): boolean;
joinForward(): boolean;
// Custom commands added by extensions
[extensionCommand: string]: (attributes?: Record<string, any>) => boolean;
}
interface InsertContentOptions {
parseOptions?: ParseOptions;
updateSelection?: boolean;
}
type Content =
| string
| JSONContent
| JSONContent[]
| ProseMirrorNode
| ProseMirrorNode[]
| ProseMirrorFragment;Usage Examples:
// Execute individual commands
const success = editor.commands.insertContent('Hello World!');
if (editor.commands.focus()) {
editor.commands.selectAll();
}
// Mark commands
editor.commands.setMark('bold');
editor.commands.toggleMark('italic');
editor.commands.unsetMark('link');
// Node commands
editor.commands.setNode('heading', { level: 1 });
editor.commands.wrapIn('blockquote');
editor.commands.lift('listItem');
// Insert complex content
editor.commands.insertContent({
type: 'paragraph',
content: [
{
type: 'text',
text: 'Bold text',
marks: [{ type: 'bold' }]
}
]
});
// Delete and select commands
editor.commands.deleteSelection();
editor.commands.selectTextblockStart();
editor.commands.selectParentNode();Interface for chaining multiple commands together for sequential execution.
/**
* Interface for chaining commands together
*/
interface ChainedCommands {
[commandName: string]: (attributes?: Record<string, any>) => ChainedCommands;
/**
* Execute the entire command chain
* @returns Whether all commands in the chain succeeded
*/
run(): boolean;
// All single commands are available for chaining
insertContent(value: Content, options?: InsertContentOptions): ChainedCommands;
deleteSelection(): ChainedCommands;
deleteRange(range: { from: number; to: number }): ChainedCommands;
enter(): ChainedCommands;
focus(position?: FocusPosition, options?: { scrollIntoView?: boolean }): ChainedCommands;
blur(): ChainedCommands;
selectAll(): ChainedCommands;
setMark(typeOrName: string | MarkType, attributes?: Record<string, any>): ChainedCommands;
toggleMark(typeOrName: string | MarkType, attributes?: Record<string, any>): ChainedCommands;
unsetMark(typeOrName: string | MarkType, options?: { extendEmptyMarkRange?: boolean }): ChainedCommands;
setNode(typeOrName: string | NodeType, attributes?: Record<string, any>): ChainedCommands;
toggleNode(typeOrName: string | NodeType, toggleTypeOrName?: string | NodeType, attributes?: Record<string, any>): ChainedCommands;
wrapIn(typeOrName: string | NodeType, attributes?: Record<string, any>): ChainedCommands;
lift(typeOrName?: string | NodeType, attributes?: Record<string, any>): ChainedCommands;
// Custom commands added by extensions
[extensionCommand: string]: (attributes?: Record<string, any>) => ChainedCommands;
}Usage Examples:
// Basic command chaining
editor
.chain()
.focus()
.selectAll()
.deleteSelection()
.insertContent('New content')
.run();
// Complex formatting chain
editor
.chain()
.focus()
.toggleMark('bold')
.toggleMark('italic')
.insertContent('Bold and italic text')
.setMark('link', { href: 'https://example.com' })
.insertContent(' with a link')
.run();
// Node manipulation chain
editor
.chain()
.focus()
.selectAll()
.wrapIn('blockquote')
.setNode('heading', { level: 2 })
.insertContent('Quoted heading')
.run();
// Conditional chaining
const success = editor
.chain()
.focus()
.deleteSelection() // Only if something is selected
.insertContent('Replacement text')
.run();
if (success) {
console.log('Chain executed successfully');
}
// Chain with custom commands (from extensions)
editor
.chain()
.focus()
.setFontSize(16)
.setTextAlign('center')
.insertTable({ rows: 3, cols: 3 })
.run();Interface for testing whether commands can be executed without actually running them.
/**
* Interface for validating command executability
*/
interface CanCommands {
[commandName: string]: (attributes?: Record<string, any>) => boolean;
// Core command validation
insertContent(value: Content, options?: InsertContentOptions): boolean;
deleteSelection(): boolean;
deleteRange(range: { from: number; to: number }): boolean;
enter(): boolean;
focus(position?: FocusPosition): boolean;
blur(): boolean;
selectAll(): boolean;
setMark(typeOrName: string | MarkType, attributes?: Record<string, any>): boolean;
toggleMark(typeOrName: string | MarkType, attributes?: Record<string, any>): boolean;
unsetMark(typeOrName: string | MarkType): boolean;
setNode(typeOrName: string | NodeType, attributes?: Record<string, any>): boolean;
toggleNode(typeOrName: string | NodeType, toggleTypeOrName?: string | NodeType): boolean;
wrapIn(typeOrName: string | NodeType, attributes?: Record<string, any>): boolean;
lift(typeOrName?: string | NodeType): boolean;
// Custom commands added by extensions
[extensionCommand: string]: (attributes?: Record<string, any>) => boolean;
}Usage Examples:
// Check if commands can be executed
if (editor.can().toggleMark('bold')) {
editor.commands.toggleMark('bold');
}
if (editor.can().wrapIn('blockquote')) {
editor.commands.wrapIn('blockquote');
}
// Conditional UI updates
function BoldButton() {
const canToggleBold = editor.can().toggleMark('bold');
const isBold = editor.isActive('bold');
return (
<button
disabled={!canToggleBold}
className={isBold ? 'active' : ''}
onClick={() => editor.commands.toggleMark('bold')}
>
Bold
</button>
);
}
// Check complex operations
const canInsertTable = editor.can().insertTable?.({ rows: 3, cols: 3 });
const canSetHeading = editor.can().setNode('heading', { level: 1 });
// Multiple validations
const formattingActions = [
{ name: 'bold', can: editor.can().toggleMark('bold') },
{ name: 'italic', can: editor.can().toggleMark('italic') },
{ name: 'code', can: editor.can().toggleMark('code') },
{ name: 'link', can: editor.can().setMark('link', { href: '' }) }
];
const availableActions = formattingActions.filter(action => action.can);The properties passed to command functions when they are executed.
/**
* Properties passed to command functions during execution
*/
interface CommandProps {
/** The editor instance */
editor: Editor;
/** Current transaction (may be modified by commands) */
tr: Transaction;
/** Access to all single commands */
commands: SingleCommands;
/** Access to command validation */
can: CanCommands;
/** Create a new command chain */
chain: () => ChainedCommands;
/** Current editor state */
state: EditorState;
/** ProseMirror editor view */
view: EditorView;
/** Dispatch function (undefined in dry-run mode) */
dispatch: ((tr: Transaction) => void) | undefined;
}
/**
* Command function signature
*/
type CommandFunction = (props: CommandProps) => boolean;How to create custom commands in extensions.
/**
* Commands configuration for extensions
*/
interface Commands {
[commandName: string]: (...args: any[]) => CommandFunction;
}Usage Examples:
import { Extension } from '@tiptap/core';
// Extension with custom commands
const CustomCommands = Extension.create({
name: 'customCommands',
addCommands() {
return {
// Simple command
insertDate: () => ({ commands }) => {
const date = new Date().toLocaleDateString();
return commands.insertContent(date);
},
// Command with parameters
insertHeading: (level: number, text: string) => ({ commands, chain }) => {
return chain()
.setNode('heading', { level })
.insertContent(text)
.run();
},
// Complex command using transaction
duplicateLine: () => ({ tr, state, dispatch }) => {
const { from, to } = state.selection;
const line = state.doc.textBetween(from, to);
if (!line) return false;
tr.insertText(`\n${line}`, to);
if (dispatch) {
dispatch(tr);
}
return true;
},
// Command that checks state
toggleHighlight: (color: string = 'yellow') => ({ commands, editor }) => {
const isActive = editor.isActive('highlight', { color });
if (isActive) {
return commands.unsetMark('highlight');
}
return commands.setMark('highlight', { color });
},
// Command with validation
wrapInCallout: (type: string = 'info') => ({ commands, can }) => {
// Only wrap if we can and aren't already in a callout
if (!can().wrapIn('callout') || editor.isActive('callout')) {
return false;
}
return commands.wrapIn('callout', { type });
}
};
}
});
// Using custom commands
editor.commands.insertDate();
editor.commands.insertHeading(1, 'Chapter Title');
editor.commands.duplicateLine();
// Chain custom commands
editor
.chain()
.focus()
.insertHeading(2, 'Section')
.insertDate()
.toggleHighlight('blue')
.run();
// Validate custom commands
if (editor.can().wrapInCallout('warning')) {
editor.commands.wrapInCallout('warning');
}Understanding how commands are processed and executed.
/**
* Command execution involves several phases:
* 1. Command function is called with CommandProps
* 2. Command modifies the transaction (tr)
* 3. Command returns boolean success value
* 4. Transaction is dispatched (if dispatch is provided)
* 5. Editor state is updated
*/
// Example command implementation
const exampleCommand = (text: string) => ({ tr, dispatch, state }) => {
// 1. Validate the command can run
if (!text || text.trim().length === 0) {
return false;
}
// 2. Modify the transaction
const { from } = state.selection;
tr.insertText(text, from);
// 3. Dispatch if available (not in validation mode)
if (dispatch) {
dispatch(tr);
}
// 4. Return success
return true;
};
// Command chaining execution
// Each command in a chain operates on the same transaction
// The transaction is only dispatched when .run() is called
editor
.chain()
.command1() // Modifies tr
.command2() // Modifies same tr
.command3() // Modifies same tr
.run(); // Dispatches tr with all modificationsInstall with Tessl CLI
npx tessl i tessl/npm-tiptap--coredocs
evals
scenario-1
scenario-2
scenario-3
scenario-4
scenario-5
scenario-6
scenario-7
scenario-8
scenario-9
scenario-10