CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-tiptap--core

Headless rich text editor built on ProseMirror with extensible architecture for building custom editors

94

1.00x
Overview
Eval results
Files

extension-system.mddocs/

Extension System

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.

Capabilities

Extension Base Class

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;
      }
    };
  }
});

Node Class

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,
        });
      },
    };
  },
});

Mark Class

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 });

Node and Mark Views

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--core

docs

command-system.md

document-helpers.md

editor-core.md

extension-system.md

index.md

rule-systems.md

utilities.md

tile.json