How to Extend Tiptap Rich Text Editor with Custom Extensions in Plane

You extend Tiptap in Plane by creating a standard Tiptap extension (Mark, Node, or Extension), exporting it from the editor package, and registering it in the extensionRegistry array within rich-text-extensions.tsx, where the isEnabled predicate controls visibility.

Plane's rich-text editor is built on Tiptap v2 and uses a centralized extension registry to manage optional features. The codebase in the makeplane/plane repository provides a structured way to add custom marks, nodes, and commands without modifying core editor logic.

Understanding Plane's Extension Architecture

Plane's editor architecture separates core functionality from optional extensions through a registry pattern. The main editor component in packages/editor/src/core/components/editors/rich-text/editor.tsx initializes the Tiptap instance using a getExtensions callback that aggregates extensions from multiple sources.

Optional extensions are managed by RichTextEditorAdditionalExtensions in packages/editor/src/ce/extensions/rich-text-extensions.tsx. This utility reads from an extensionRegistry array that determines which extensions to load based on isEnabled predicates and instantiates them via getExtension factories.

This design allows extensions to be conditionally enabled or disabled through props like disabledExtensions and flaggedExtensions, making it possible to toggle features per workspace or user preference.

Step-by-Step Guide to Extending Tiptap

Step 1: Create a Custom Tiptap Extension

Create your extension file following Tiptap v2 conventions. Plane supports standard Mark, Node, and Extension types.

// packages/editor/src/core/extensions/custom-highlight.ts
import { Mark, mergeAttributes } from "@tiptap/core";

export const Highlight = Mark.create({
  name: "highlight",

  addOptions() {
    return { HTMLAttributes: { class: "bg-yellow-200" } };
  },

  parseHTML() {
    return [{ tag: "mark" }];
  },

  renderHTML({ HTMLAttributes }) {
    return ["mark", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
  },

  addCommands() {
    return {
      toggleHighlight:
        () =>
        ({ commands }) => {
          return commands.toggleMark(this.name);
        },
    };
  },
});

Step 2: Export the Extension from the Package Index

Update the barrel file to make your extension available to the registry.

// packages/editor/src/core/extensions/index.ts
export * from "./custom-highlight";

Now the extension can be imported elsewhere using import { Highlight } from "@/extensions" (the @ alias points to packages/editor/src).

Step 3: Register in the Extension Registry

Add a new entry to extensionRegistry in rich-text-extensions.tsx. The isEnabled function checks against disabled extensions, while getExtension returns the extension class.

// packages/editor/src/ce/extensions/rich-text-extensions.tsx
import { Highlight } from "@/extensions/custom-highlight";

const extensionRegistry: TRichTextEditorAdditionalExtensionsRegistry[] = [
  // Existing entries...
  {
    isEnabled: (disabledExtensions) => !disabledExtensions.includes("highlight"),
    getExtension: () => Highlight,
  },
];

Step 4: Implement UI Controls

Access your custom commands through the editor context. Plane's useEditorContext hook provides access to the Tiptap editor instance.

// Example: a toolbar button inside a custom editor UI
import { useEditorContext } from "@/hooks";

export function HighlightButton() {
  const { editor } = useEditorContext();

  return (
    <button
      onClick={() => editor?.chain().focus().toggleHighlight().run()}
      className="p-2"
    >
      Highlight
    </button>
  );
}

When the button is clicked, the toggleHighlight command defined in the extension runs, wrapping the selection in a <mark> element styled by the CSS class defined in addOptions.

Step 5: Conditionally Disable Extensions

Control extension availability by passing the extension name to the disabledExtensions prop.

<RichTextEditorWithRef
  disabledExtensions={["highlight"]}   // hides the Highlight extension
  {...otherProps}
/>

Key Implementation Files

Summary

  • Plane uses an extensionRegistry pattern in packages/editor/src/ce/extensions/rich-text-extensions.tsx to manage optional Tiptap extensions.
  • Custom extensions must implement standard Tiptap v2 APIs (Mark, Node, or Extension) and be exported from packages/editor/src/core/extensions/index.ts.
  • Register extensions with isEnabled and getExtension properties to control initialization logic.
  • Control visibility via the disabledExtensions prop on the editor component.
  • UI commands integrate through the useEditorContext hook and standard Tiptap command chains like editor.chain().focus().toggleHighlight().run().

Frequently Asked Questions

What is the extension registry in Plane?

The extension registry is an array of configuration objects defined in packages/editor/src/ce/extensions/rich-text-extensions.tsx. Each entry contains an isEnabled function that determines if the extension should load based on current props, and a getExtension factory that returns the Tiptap extension instance. This pattern decouples extension availability from the core editor initialization in editor.tsx.

How do I disable a custom extension for specific users?

Pass the extension name to the disabledExtensions array prop on the RichTextEditorWithRef component. The isEnabled predicate in the registry checks against this array, preventing the extension from being included in the editor's extension list for that specific instance.

Can I add custom nodes as well as marks?

Yes. Plane's registry supports all Tiptap v2 extension types including Node, Mark, and plain Extension. The registration process remains identical regardless of the extension type—create the extension file, export it from the index, and add it to the extensionRegistry with appropriate isEnabled logic.

Where should I store custom extension files?

Store custom extensions in packages/editor/src/core/extensions/ or a subdirectory thereof. Import them into packages/editor/src/core/extensions/index.ts to maintain the package's export structure, then reference them in packages/editor/src/ce/extensions/rich-text-extensions.tsx using the @/extensions alias.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →