Vaishnav Chandurkar

Oct 03, 2024 • 8 min read

Creating a flexible Rich Text Editor with Tiptap

Building a flexible rich text editor, focusing on creating dynamic toolbars and extension sets for different editing needs.

Creating a flexible Rich Text Editor with Tiptap

I’ve recently worked with rich text editors (also known as WYSIWYG editors - "What You See Is What You Get"). They can handle many tasks, but they can also get quite complex. Here are some of the challenges I encountered:

Here's what I ran into:

  1. Different Needs, Different Features: Imagine you're building an editor for a social media app. You might want users to be able to mention their friends. But you probably don't need that feature if you're using the same editor for a writing bio.

  2. Copy-Paste Chaos: I found myself copying and pasting code all over the place to handle these different scenarios.

Let's work on creating a flexible rich text editor. Here’s what we’ll cover:

  1. Building the Core Editor:

    • How to set up the basic Tiptap editor component

    • Understanding the useEditor hook and its configuration

    • Using useImperativeHandle to expose editor methods safely

  2. Creating flexible extension sets for various scenarios

  3. Implementing a dynamic toolbar that adapts to active extensions

Building the Core Editor

I'm using Tiptap, a headless wrapper around ProseMirror for this project. Tiptap comes with some default configurations and friendly APIs. For most use cases, you won't need anything extra. But if you need to, you can create your extensions using Tiptap and ProseMirror APIs.

Let's dive into the implementation of our Rich Text Editor component. We'll focus on how to handle the editor instance and manage data flow.

import {
  EditorContent,
  EditorEvents,
  JSONContent,
  useEditor,
} from "@tiptap/react";
import { forwardRef, useImperativeHandle } from "react";
import Toolbar from "./toolbar/Toolbar";
import { content } from "./utils/content";
import { editorMode, getEditorExtensionsByType } from "./extensions";

interface EditorProps {
  editable?: boolean;
  editorType: editorMode | null;
  enableToolbar: boolean;
  onUpdate?: ((props: EditorEvents["update"]) => void) | undefined;
}

export interface EditorRef {
  getHTML: () => string | undefined;
  getText: () => string | undefined;
  getJSON: () => JSONContent | undefined;
}

const Editor = forwardRef<EditorRef, EditorProps>((props, ref) => {
  const { editable = true, editorType, enableToolbar, onUpdate } = props;

  const editorExtensions = getEditorExtensionsByType(editorType);

  const editor = useEditor({
    extensions: editorExtensions,
    editable,
    content,
    onUpdate,
    editorProps: {
      attributes: {
        class:
          "outline-none px-4 py-2 ring-2 ring-blue-100 rounded-lg prose max-w-4xl",
      },
    },
  });

  useImperativeHandle(
    ref,
    () => ({
      getHTML: () => editor?.getHTML(),
      getText: () => editor?.getText(),
      getJSON: () => editor?.getJSON(),
    }),
    []
  );

  if (!editor) {
    return null;
  }

  return (
    <div>
      {enableToolbar ? <Toolbar editor={editor} /> : null}
      <EditorContent editor={editor} />
    </div>
  );
});

export default Editor;

The function getEditorExtensionsByType to manage the editor's behaviour based on the type of editor mode (editorType). The line below fetches the extensions needed based on the editor type:

const editorExtensions = getEditorExtensionsByType(editorType);

So basically with this, we were able to centralize the extensions, making it easier to configure without hardcoding extensions directly into the editor setup.

Getting data from the editor

How do you get the editor's data, such as HTML, text, or JSON? For this, I’ve used useImperativeHandle:

useImperativeHandle(
  ref,
  () => ({
    getHTML: () => editor?.getHTML(),
    getText: () => editor?.getText(),
    getJSON: () => editor?.getJSON(),
  }),
  []
);

useImperativeHandle allows us to expose methods (like getHTML, getText, and getJSON) from a functional component to the parent component via a ref. This is particularly useful in cases where you want to programmatically access the editor's state from outside the component, such as in form submission or validation scenarios.

To use these exposed methods, you would create a ref in the parent component and pass it to the Editor component. Then, you can call these methods through the ref. Here's an example:

const editorRef = useRef<EditorRef>(null);

// Later in your component
const htmlContent = editorRef.current?.getHTML();
const plainText = editorRef.current?.getText();
const jsonContent = editorRef.current?.getJSON();


Why prefer useImperativeHandle?

Normally, refs are used to directly access DOM elements. However, in this case, I need to expose specific methods that return the editor's content. This hook gives me control over what the parent component can access, without exposing the entire editor instance. It keeps things clean and allows precise control over how we interact with the editor.

When to Use onUpdate?

The onUpdate prop listens for content changes and fires whenever the editor’s state is updated:

const { onUpdate } = props;


The onUpdate callback is useful when you need real-time updates for saving drafts, syncing state, or triggering side effects as the user types or interacts with the editor. For instance, if you want to autosave a document while editing, onUpdate would be ideal.

Creating flexible extension sets for various scenarios

Imagine you're building a website with different types of text input areas:

  1. A simple comment box

  2. A bio section on user profiles

  3. A full-fledged blog post editor

Each of these needs different levels of text editing capabilities. This is where flexible extension sets come in handy.

Let’s look at the code.

import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { Link as TiptapLink } from "@tiptap/extension-link";
import { TextAlign as TipTapTextAlign } from "@tiptap/extension-text-align";
import Underline from "@tiptap/extension-underline";
import StarterKit from "@tiptap/starter-kit";
import SlashCommand from "./SlashCommand";
import Emoji from "./Emoji";

const Link = TiptapLink.extend({ inclusive: false }).configure({
  autolink: true,
  openOnClick: false,
  HTMLAttributes: {
    rel: "noopener noreferrer",
    target: "_blank",
  },
});

const TextAlign = TipTapTextAlign.configure({
  alignments: ["left", "right", "center"],
  types: ["heading", "paragraph"],
});

const minimalExtensionSet = [StarterKit, Link];

const fullFeaturedExtensionSet = [
  StarterKit.configure({
    heading: {
      levels: [1, 2, 3],
    },
  }),
  TaskItem,
  TaskList,
  Underline,
  SlashCommand,
  Emoji,
  Link,
  TextAlign,
];

const defaultExtensionSet = [StarterKit, Link, TaskItem, TaskList];

export enum editorMode {
  minimal = "minimal",
  fullFeatured = "fullFeatured",
}

export const getEditorExtensionsByType = (editorType: editorMode | null) => {
  switch (editorType) {
    case editorMode.minimal:
      return minimalExtensionSet;

    case editorMode.fullFeatured:
      return fullFeaturedExtensionSet;

    default:
      return defaultExtensionSet;
  }
};

Some extensions need specific configurations. For instance, we want links to open in new tabs and disable the default openOnClick behaviour while editing

const Link = TiptapLink.extend({ inclusive: false }).configure({
  autolink: true,
  openOnClick: false,
  HTMLAttributes: {
    rel: "noopener noreferrer",
    target: "_blank",
  },
});

For text alignment, we specify the elements that can be aligned (headings and paragraphs) and allow certain alignments:

const TextAlign = TipTapTextAlign.configure({
  alignments: ["left", "right", "center"],
  types: ["heading", "paragraph"],
});

Instead of using a fixed set of extensions across all editor instances, we can create different sets for various use cases. This makes the editor more flexible, based on the features we need. By organizing the extensions into sets and using a function like getEditorExtensionsByType, you can easily adjust your editor's capabilities.

Implementing a dynamic toolbar that adapts to active extensions

Implementation of a dynamic toolbar for a rich text editor, specifically designed to adapt based on the active extensions. This approach ensures that the toolbar only displays controls for features that are currently available in the editor.

The key part of this implementation is the extensionMap, which serves as a lookup to define which buttons I want to display on the UI. Each entry in extensionMap corresponds to an extension, such as bold, italic, heading, etc. If the extension is active in the editor, its respective toolbar button will be shown.

const extensionMap: ExtensionMap = {
  bold: "bold",
  italic: "italic",
  strike: "strike",
  link: "link",
  code: "code",
  codeBlock: "codeBlock",
  bulletList: "bulletList",
  orderedList: "orderedList",
  horizontalRule: "horizontalRule",
  blockquote: "blockquote",
  heading: "heading",
  paragraph: "paragraph",
  taskList: "taskList",
  underline: "underline",
  textAlign: "textAlign",
};
const Toolbar = (props: Props) => {
  const { editor } = props;
  const activeExtensions = editor?.extensionManager?.extensions?..reduce((prev, curr) => {
    if (extensionMap[curr.name]) {
      prev[curr.name] = curr;
    }
    return prev;
  }, {});

  return activeExtensions;
};

  return (
    <div className="sticky top-10 bg-white z-50 mb-20">
      <div className="border flex-wrap rounded-lg p-1 flex items-center gap-2 ...">
        {activeExtensions[extensionMap.heading] ? <Heading editor={editor} /> : null}
        {activeExtensions[extensionMap.bold] ? <Bold editor={editor} /> : null}
        // ... other controls
      </div>
    </div>
  );
};

Here’s a breakdown of how the Toolbar component works:

  1. Retrieving Active Extensions: The toolbar relies on the editor's extensionManager to retrieve the active extensions. By reducing over the available extensions and checking them against the extensionMap, it creates a set of active tools that should be shown in the UI.

    const activeExtensions = editor?.extensionManager?.extensions?.reduce((prev, curr) => {
        if (extensionMap[curr.name]) {
            prev[curr.name] = curr;
        }
        return prev;
    }, {});
  2. Displaying Controls: Once the active extensions are determined, the toolbar renders the appropriate controls based on what is enabled. For example, if the heading and bold extensions are active, the Heading and Bold components will be displayed in the toolbar.

    <div className="border flex-wrap rounded-lg p-1 flex items-center gap-2 ...">
        {activeExtensions[extensionMap.heading] ? <Heading editor={editor} /> : null}
        {activeExtensions[extensionMap.bold] ? <Bold editor={editor} /> : null}
        // ... other controls
    </div>
  3. Customizing the Toolbar: The extensionMap object is fully customizable. This allows to define exactly which extensions I want to control through the toolbar and keep the UI adaptable, whether working with a simple text editor or adding more complex functionality like lists, code blocks, or alignment options.

    export const extensionMap: ExtensionMap = {
        bold: "bold",
        italic: "italic",
        strike: "strike",
        link: "link",
        code: "code",
        // ... other extensions
    };

By designing the toolbar this way, I ensure it’s dynamic and automatically adapts based on the editor’s configuration, showing only the relevant controls. This flexibility keeps the interface efficient and user-friendly, particularly when dealing with a variety of content types in different contexts.

For the complete implementation and to see the editor in action, check out the following links:

Feel free to explore the code, contribute, or use it as a reference for your projects!

Join Vaishnav on Peerlist!

Join amazing folks like Vaishnav and thousands of other designers and developers.

Create Profile

Join with Vaishnav’s personal invite link.