Headless rich text editor built on ProseMirror with extensible architecture for building custom editors
94
The extension system is the core of @tiptap/core's modularity, allowing you to add functionality through Extensions, Nodes, and Marks. Extensions handle non-content features, Nodes represent document structure, and Marks represent text formatting.
Extensions add functionality that doesn't directly represent document content, such as commands, keyboard shortcuts, or plugins.
/**
* Base class for creating editor extensions
*/
class Extension<Options = any, Storage = any> {
/**
* Create a new extension
* @param config - Extension configuration
* @returns Extension instance
*/
static create<O = any, S = any>(
config?: Partial<ExtensionConfig<O, S>>
): Extension<O, S>;
/**
* Configure the extension with new options
* @param options - Options to merge with defaults
* @returns New extension instance with updated options
*/
configure(options?: Partial<Options>): Extension<Options, Storage>;
/**
* Extend the extension with additional configuration
* @param extendedConfig - Additional configuration to apply
* @returns New extended extension instance
*/
extend<ExtendedOptions = Options, ExtendedStorage = Storage>(
extendedConfig?: Partial<ExtensionConfig<ExtendedOptions, ExtendedStorage>>
): Extension<ExtendedOptions, ExtendedStorage>;
}
interface ExtensionConfig<Options = any, Storage = any> {
/** Unique name for the extension */
name: string;
/** Default options for the extension */
defaultOptions?: Options;
/** Priority for loading order (higher loads later) */
priority?: number;
/** Initialize storage for sharing data between extensions */
addStorage?(): Storage;
/** Add commands to the editor */
addCommands?(): Commands;
/** Add keyboard shortcuts */
addKeymap?(): Record<string, any>;
/** Add input rules for text transformation */
addInputRules?(): InputRule[];
/** Add paste rules for paste transformation */
addPasteRules?(): PasteRule[];
/** Add global attributes to all nodes */
addGlobalAttributes?(): GlobalAttributes[];
/** Add custom node view renderer */
addNodeView?(): NodeViewRenderer;
/** Add ProseMirror plugins */
addProseMirrorPlugins?(): Plugin[];
/** Called when extension is created */
onCreate?(this: { options: Options; storage: Storage }): void;
/** Called when editor content is updated */
onUpdate?(this: { options: Options; storage: Storage }): void;
/** Called before editor is destroyed */
onDestroy?(this: { options: Options; storage: Storage }): void;
/** Called when selection changes */
onSelectionUpdate?(this: { options: Options; storage: Storage }): void;
/** Called on every transaction */
onTransaction?(this: { options: Options; storage: Storage }): void;
/** Called when editor gains focus */
onFocus?(this: { options: Options; storage: Storage }): void;
/** Called when editor loses focus */
onBlur?(this: { options: Options; storage: Storage }): void;
}Usage Examples:
import { Extension } from '@tiptap/core';
// Simple extension with commands
const CustomExtension = Extension.create({
name: 'customExtension',
addCommands() {
return {
customCommand: (text: string) => ({ commands }) => {
return commands.insertContent(text);
}
};
},
addKeymap() {
return {
'Mod-k': () => this.editor.commands.customCommand('Shortcut pressed!'),
};
}
});
// Extension with options and storage
const CounterExtension = Extension.create<{ step: number }, { count: number }>({
name: 'counter',
defaultOptions: {
step: 1,
},
addStorage() {
return {
count: 0,
};
},
addCommands() {
return {
increment: () => ({ editor }) => {
this.storage.count += this.options.step;
return true;
},
getCount: () => () => {
return this.storage.count;
}
};
}
});
// Configure extension
const customCounter = CounterExtension.configure({ step: 5 });
// Extend extension
const AdvancedCounter = CounterExtension.extend({
name: 'advancedCounter',
addCommands() {
return {
...this.parent?.(),
reset: () => ({ editor }) => {
this.storage.count = 0;
return true;
}
};
}
});Nodes represent structural elements in the document like paragraphs, headings, lists, and custom block or inline elements.
/**
* Base class for creating document nodes
*/
class Node<Options = any, Storage = any> {
/**
* Create a new node extension
* @param config - Node configuration
* @returns Node extension instance
*/
static create<O = any, S = any>(
config?: Partial<NodeConfig<O, S>>
): Node<O, S>;
/**
* Configure the node with new options
* @param options - Options to merge with defaults
* @returns New node instance with updated options
*/
configure(options?: Partial<Options>): Node<Options, Storage>;
/**
* Extend the node with additional configuration
* @param extendedConfig - Additional configuration to apply
* @returns New extended node instance
*/
extend<ExtendedOptions = Options, ExtendedStorage = Storage>(
extendedConfig?: Partial<NodeConfig<ExtendedOptions, ExtendedStorage>>
): Node<ExtendedOptions, ExtendedStorage>;
}
interface NodeConfig<Options = any, Storage = any> extends ExtensionConfig<Options, Storage> {
/** Content expression defining allowed child content */
content?: string | ((this: { options: Options }) => string);
/** Marks that can be applied to this node */
marks?: string | ((this: { options: Options }) => string);
/** Node group (e.g., 'block', 'inline') */
group?: string | ((this: { options: Options }) => string);
/** Whether this is an inline node */
inline?: boolean | ((this: { options: Options }) => boolean);
/** Whether this node is atomic (cannot be directly edited) */
atom?: boolean | ((this: { options: Options }) => boolean);
/** Whether this node can be selected */
selectable?: boolean | ((this: { options: Options }) => boolean);
/** Whether this node can be dragged */
draggable?: boolean | ((this: { options: Options }) => boolean);
/** Defines how to parse HTML into this node */
parseHTML?(): HTMLParseRule[];
/** Defines how to render this node as HTML */
renderHTML?(props: { node: ProseMirrorNode; HTMLAttributes: Record<string, any> }): DOMOutputSpec;
/** Defines how to render this node as text */
renderText?(props: { node: ProseMirrorNode }): string;
/** Add custom node view */
addNodeView?(): NodeViewRenderer;
/** Define node attributes */
addAttributes?(): Record<string, Attribute>;
}
interface HTMLParseRule {
tag?: string;
node?: string;
mark?: string;
style?: string;
priority?: number;
consuming?: boolean;
context?: string;
getAttrs?: (node: HTMLElement) => Record<string, any> | null | false;
}
interface Attribute {
default?: any;
rendered?: boolean;
renderHTML?: (attributes: Record<string, any>) => Record<string, any> | null;
parseHTML?: (element: HTMLElement) => any;
keepOnSplit?: boolean;
isRequired?: boolean;
}Usage Examples:
import { Node } from '@tiptap/core';
// Simple custom node
const CalloutNode = Node.create({
name: 'callout',
group: 'block',
content: 'block+',
addAttributes() {
return {
type: {
default: 'info',
parseHTML: element => element.getAttribute('data-type'),
renderHTML: attributes => ({
'data-type': attributes.type,
}),
},
};
},
parseHTML() {
return [
{
tag: 'div[data-callout]',
getAttrs: node => ({ type: node.getAttribute('data-type') }),
},
];
},
renderHTML({ node, HTMLAttributes }) {
return [
'div',
{
'data-callout': '',
'data-type': node.attrs.type,
...HTMLAttributes,
},
0, // Content goes here
];
},
addCommands() {
return {
setCallout: (type: string) => ({ commands }) => {
return commands.wrapIn(this.name, { type });
},
};
},
});
// Inline node example
const MentionNode = Node.create({
name: 'mention',
group: 'inline',
inline: true,
selectable: false,
atom: true,
addAttributes() {
return {
id: {
default: null,
parseHTML: element => element.getAttribute('data-id'),
renderHTML: attributes => ({
'data-id': attributes.id,
}),
},
label: {
default: null,
parseHTML: element => element.getAttribute('data-label'),
renderHTML: attributes => ({
'data-label': attributes.label,
}),
},
};
},
parseHTML() {
return [
{
tag: 'span[data-mention]',
},
];
},
renderHTML({ node, HTMLAttributes }) {
return [
'span',
{
'data-mention': '',
'data-id': node.attrs.id,
'data-label': node.attrs.label,
...HTMLAttributes,
},
`@${node.attrs.label}`,
];
},
renderText({ node }) {
return `@${node.attrs.label}`;
},
addCommands() {
return {
insertMention: (options: { id: string; label: string }) => ({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});Marks represent text formatting that can be applied to ranges of text, such as bold, italic, links, or custom formatting.
/**
* Base class for creating text marks
*/
class Mark<Options = any, Storage = any> {
/**
* Create a new mark extension
* @param config - Mark configuration
* @returns Mark extension instance
*/
static create<O = any, S = any>(
config?: Partial<MarkConfig<O, S>>
): Mark<O, S>;
/**
* Configure the mark with new options
* @param options - Options to merge with defaults
* @returns New mark instance with updated options
*/
configure(options?: Partial<Options>): Mark<Options, Storage>;
/**
* Extend the mark with additional configuration
* @param extendedConfig - Additional configuration to apply
* @returns New extended mark instance
*/
extend<ExtendedOptions = Options, ExtendedStorage = Storage>(
extendedConfig?: Partial<MarkConfig<ExtendedOptions, ExtendedStorage>>
): Mark<ExtendedOptions, ExtendedStorage>;
/**
* Handle mark exit behavior (for marks like links)
* @param options - Exit options
* @returns Whether the exit was handled
*/
static handleExit(options: {
editor: Editor;
mark: ProseMirrorMark
}): boolean;
}
interface MarkConfig<Options = any, Storage = any> extends ExtensionConfig<Options, Storage> {
/** Whether the mark is inclusive (extends to typed text) */
inclusive?: boolean | ((this: { options: Options }) => boolean);
/** Marks that this mark excludes */
excludes?: string | ((this: { options: Options }) => string);
/** Mark group */
group?: string | ((this: { options: Options }) => string);
/** Whether mark can span across different nodes */
spanning?: boolean | ((this: { options: Options }) => boolean);
/** Whether this is a code mark (excludes other formatting) */
code?: boolean | ((this: { options: Options }) => boolean);
/** Defines how to parse HTML into this mark */
parseHTML?(): HTMLParseRule[];
/** Defines how to render this mark as HTML */
renderHTML?(props: {
mark: ProseMirrorMark;
HTMLAttributes: Record<string, any>
}): DOMOutputSpec;
/** Add custom mark view */
addMarkView?(): MarkViewRenderer;
/** Define mark attributes */
addAttributes?(): Record<string, Attribute>;
/** Handle exit behavior when typing at mark boundary */
onExit?(): boolean;
}Usage Examples:
import { Mark } from '@tiptap/core';
// Simple formatting mark
const HighlightMark = Mark.create({
name: 'highlight',
addAttributes() {
return {
color: {
default: 'yellow',
parseHTML: element => element.getAttribute('data-color'),
renderHTML: attributes => ({
'data-color': attributes.color,
}),
},
};
},
parseHTML() {
return [
{
tag: 'mark',
},
{
style: 'background-color',
getAttrs: value => ({ color: value }),
},
];
},
renderHTML({ mark, HTMLAttributes }) {
return [
'mark',
{
style: `background-color: ${mark.attrs.color}`,
...HTMLAttributes,
},
0,
];
},
addCommands() {
return {
setHighlight: (color: string = 'yellow') => ({ commands }) => {
return commands.setMark(this.name, { color });
},
toggleHighlight: (color: string = 'yellow') => ({ commands }) => {
return commands.toggleMark(this.name, { color });
},
unsetHighlight: () => ({ commands }) => {
return commands.unsetMark(this.name);
},
};
},
});
// Link mark with exit handling
const LinkMark = Mark.create({
name: 'link',
inclusive: false,
addAttributes() {
return {
href: {
default: null,
},
target: {
default: null,
},
rel: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: 'a[href]',
getAttrs: node => ({
href: node.getAttribute('href'),
target: node.getAttribute('target'),
rel: node.getAttribute('rel'),
}),
},
];
},
renderHTML({ mark, HTMLAttributes }) {
return [
'a',
{
href: mark.attrs.href,
target: mark.attrs.target,
rel: mark.attrs.rel,
...HTMLAttributes,
},
0,
];
},
addCommands() {
return {
setLink: (attributes: { href: string; target?: string; rel?: string }) => ({ commands }) => {
return commands.setMark(this.name, attributes);
},
toggleLink: (attributes: { href: string; target?: string; rel?: string }) => ({ commands }) => {
return commands.toggleMark(this.name, attributes);
},
unsetLink: () => ({ commands }) => {
return commands.unsetMark(this.name);
},
};
},
// Handle exit when typing at end of link
onExit() {
return this.editor.commands.unsetMark(this.name);
},
});
// Use static handleExit for complex exit behavior
Mark.handleExit({ editor, mark: linkMark });Custom rendering for nodes and marks using framework components or custom DOM manipulation.
/**
* Node view renderer function type
*/
type NodeViewRenderer = (props: {
editor: Editor;
node: ProseMirrorNode;
getPos: () => number;
HTMLAttributes: Record<string, any>;
decorations: readonly Decoration[];
extension: Node;
}) => NodeView;
/**
* Mark view renderer function type
*/
type MarkViewRenderer = (props: {
editor: Editor;
mark: ProseMirrorMark;
HTMLAttributes: Record<string, any>;
extension: Mark;
}) => MarkView;
interface NodeView {
dom: HTMLElement;
contentDOM?: HTMLElement | null;
update?(node: ProseMirrorNode, decorations: readonly Decoration[]): boolean;
selectNode?(): void;
deselectNode?(): void;
setSelection?(anchor: number, head: number, root: Document | ShadowRoot): void;
stopEvent?(event: Event): boolean;
ignoreMutation?(record: MutationRecord): boolean | void;
destroy?(): void;
}
interface MarkView {
dom: HTMLElement;
contentDOM?: HTMLElement | null;
update?(mark: ProseMirrorMark): boolean;
destroy?(): void;
}Usage Examples:
// Custom node view
const CustomParagraphNode = Node.create({
name: 'customParagraph',
group: 'block',
content: 'inline*',
addNodeView() {
return ({ node, getPos, editor }) => {
const dom = document.createElement('div');
const contentDOM = document.createElement('p');
dom.className = 'custom-paragraph-wrapper';
contentDOM.className = 'custom-paragraph-content';
// Add custom controls
const controls = document.createElement('div');
controls.className = 'paragraph-controls';
controls.innerHTML = '<button>Edit</button>';
dom.appendChild(controls);
dom.appendChild(contentDOM);
return {
dom,
contentDOM,
update: (newNode) => {
return newNode.type === node.type;
},
selectNode: () => {
dom.classList.add('ProseMirror-selectednode');
},
deselectNode: () => {
dom.classList.remove('ProseMirror-selectednode');
}
};
};
}
});
// Custom mark view
const CustomEmphasisMark = Mark.create({
name: 'customEmphasis',
addMarkView() {
return ({ mark, HTMLAttributes }) => {
const dom = document.createElement('em');
const contentDOM = document.createElement('span');
dom.className = 'custom-emphasis';
contentDOM.className = 'emphasis-content';
dom.appendChild(contentDOM);
return {
dom,
contentDOM,
update: (newMark) => {
return newMark.type === mark.type;
}
};
};
}
});Install 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