How to Create a Custom Extension in TipTap WYSIWYG Editor

How to Create a Custom Extension in TipTap WYSIWYG Editor

Anmol Sehgal Software Engineer - II @ GeekyAnts guides you through how to create a customized editing experience with Next.js project.

·

11 min read

TipTap WYSIWYG editor is a headless content editor in which you can craft a customized editing experience for the user. In TipTap, you can create and customize the interactions and functionality to cater to the user's needs. Under the hood, TipTap uses ProseMirror, a toolkit for building rich-text editors on the web. Before we jump on how to create a custom extension in the TipTap editor, we need to understand ProseMirror.

Who is Using TipTap?

Tiptap is a versatile and widely adopted rich text editor framework, popular among various companies due to its robust features and customizable nature. More than 20,000+ companies are using the TipTap editor ( as per the TipTap website ). Here are some of the companies that are using TipTap editor: GitLab, Statamic, Apostrophe CMS, Productboard, Axios, Staffbase, Beehiiv, Front, ChartHop, Jenni, Chegg, Trainual, UserTesting, Substack, Opal, Nextcloud, Y Combinator, Ahrefs, Storyblok, etc.

What is ProseMirror?

ProseMirror is a toolkit for building rich text editors that handles schema enforcement, state management, and content rendering. It doesn't store its content to inform of HTML but it uses a custom data structure organizing content, specifically designed for documents. The ProseMirror editor builds itself around this document to represent the content.
ProseMirror document is a node that holds a fragment that contains zero or more child nodes. These children can be paragraphs, lists, tables, or other elements. You can learn more about Prosemirror from the official documentation. Now let's look into how to set up TipTap in Next.js.

Set up TipTap with Next.js:

In this section, we will walk through the steps to set up a Next.js project and integrate Tiptap, a rich-text editor. Tiptap is a highly customizable editor based on ProseMirror that allows an excellent user experience in content editing.

  1. Create a new Next.js project:
    Open your terminal and run the following command to create a new Next.js project:

     npx create-next-app@latest my-nextjs-tiptap-project
      cd my-nextjs-tiptap-project
    

    This command creates a new directory called my-nextjs-tiptap-project and initializes a Next.js project inside it.

2. Install Tiptap packages:
Run the following command to install Tiptap and its dependencies:

    npm install @tiptap/core @tiptap/react @tiptap/starter-kit

Now, let's create a Tiptap editor component:

  1. Create the Tiptap editor component:
    Create a new file called Tiptap.tsx in the src/app/components directory of your Next.js project.

  2. Set up the Tiptap editor inTiptap.tsx:
    Open Tiptap.tsx and add the following code to initialize and configure the Tiptap editor:

     import { EditorContent, useEditor } from "@tiptap/react";
      import StarterKit from "@tiptap/starter-kit";
      import Placeholder from "@tiptap/extension-placeholder";
      import Button from "../Button";
      import styles from "./tiptap.module.css";
      // import your custom extension
    
      // define your extension array
      const extensions = [
        StarterKit,
        CardNode,
        Placeholder.configure({
          emptyEditorClass: styles.emptyEditorClass,
          placeholder: "Please add your content here",
        }),
      ];
    
      const Tiptap = (): JSX.Element => {
        const editor = useEditor({ extensions });
        return (
          <div>
            <EditorContent editor={editor} className={styles.tiptap} />
          </div>
        );
      };
    
      export default Tiptap;
    

    You can read more about the setup from TipTap official docs.

    Here is the folder structure we are following for the website :

    Creating our CardNode Extension:

    To create a custom node, you need to define the scheme for the node using which editor will validate the document content. Before we jump onto creating our node, let's understand some schema terminology and function:

    name : represents an identifier unique to each node type and is used to distinguish it from other node types within the same schema.

    group : defines a category or set that a node type belongs to, which can be referred to in the content expressions for the schema. It can be block, list, or inline.

    content : the value we assign to the content field is known as "Content Expression". They determine which sequences of child nodes are permissible for this node type. consider an example where we want one or more than one node named paragraph then it will be represented as paragraph+, in case of zero or more paragraphs then it will be represented as paragraph*.
    We can also create a combination out of these sequences. For example, we have a node where we want to choose between the nodes. In that case, we can use a pipe operator. Let's have code or blockquote; the expression would be (code|blockquote) . If we want a particular sequence, consider an example where we want a heading and then a list. It can be represented as a heading list.

    parseHTML : tries to load the editor document from HTML.

    renderHTML : function allows you to control how a node is converted to HTML. You provide it with an attributes object containing all the local attributes, global attributes, and configured CSS classes. So the return value is an array for this function where the first value should be a tag, if the second element of an array is an object, it implies the set of attributes, and if any other element, it will be considered as children.
    addCommands : functions that will allow a user to perform a certain editing action by interacting with others. To use any other commands from the editor in addCommands, we can use the 'commands' parameter from the addCommands.

    addAttributes: Using attributes, we can store extra information about the node. The addAttributes function helps us create and define these attributes.

    addNodeView : a function that helps you render the node view on the editor and to render a sophisticated interface.

    You can also go through the official documentation by the TipTap to understand more about schema and extensions.

    Now, let's look into how to create a Card Node. The Card Node will contain two elements: the image and the caption. So, we will create two separate nodes: an image Node and a caption node.
    In the ImageNode, we will have a placeholder button on click, of which an alert box will open, and we can add an image link. Let's create a component which will handle the rendering of this Image Node:
    ImageNode.view.tsx

     import Button from "@/app/Components/Button";
     import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
    
     import styles from "./ImageNode.module.css";
     import Image from "next/image";
     import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
     import { faArrowsRotate } from "@fortawesome/free-solid-svg-icons";
    
     const ImageNodeView = ({ node, updateAttributes }: NodeViewProps) => {
       const handleUrlUpdate = () => {
         const url = prompt("Please enter image url", node.attrs.url || "");
         if (url) {
           updateAttributes({ url });
         }
       };
       return (
         <NodeViewWrapper className={styles.imageNodeWrapper}>
           <div className={styles.imageContainer}>
             {!node.attrs.url && (
               <div contentEditable={false}>
                 <Button onClick={handleUrlUpdate}>Set an image</Button>
               </div>
             )}
             {node.attrs.url && (
               <Image
                 src={node.attrs.url}
                 alt={""}
                 layout="fill"
                 style={{ objectFit: "contain" }}
               />
             )}
             {node.attrs.url && (
               <div className={styles.refreshAction} onClick={handleUrlUpdate}>
                 <FontAwesomeIcon icon={faArrowsRotate} />
               </div>
             )}
           </div>
         </NodeViewWrapper>
       );
     };
    
     export default ImageNodeView;
    

    The component will have a placeholder button initially, on click of which we will open a prompt where we will paste the url for image, and the image will be rendered, and the placeholder button will hide. Also, there will be a button to update the image link again, which will only be visible if the URL is available at the bottom right corner of the image. Here NodeViewContent and NodeViewWrapper are the wrappers provided by TipTap, where NodeViewContent is responsible for adding editable content to your node view. NodeViewWrapper is used as a wrapper on the component, which will help you render the react component in the editor view.

    By default, these wrappers are rendered as div, but they can be changed by using as props. These view nodes get props of the type NodeViewProps. updateAttributes is one of the props that we are using here from NodeViewProps. This prop is used to update the attributes for the node. In our case, we will define an attribute named url.

    Now, let's integrate this view with the ImageNode node. Our node may contain an image or not, so the content expression is defined with *.

     import { Node, mergeAttributes } from "@tiptap/core";
     import { ReactNodeViewRenderer } from "@tiptap/react";
     import ImageNodeView from "./ImageNode.view";
    
     const ImageNode = Node.create({
       name: "imageNode",
       group: "block",
       content: "inline*",
    
       parseHTML() {
         return [
           {
             tag: "imageNode",
           },
         ];
       },
       addAttributes() {
         return {
           url: {
             default: null,
           },
         };
       },
       renderHTML({ HTMLAttributes }) {
         return ["div", mergeAttributes(HTMLAttributes)];
       },
       addNodeView() {
         return ReactNodeViewRenderer(ImageNodeView);
       },
     });
    
     export default ImageNode;
    

    We use a URL attribute to store the URL information and render the user's image. There is a function named ReactNodeViewRenderer. TipTap provides this function and helps us render the react component in the editor. It passes a few very helpful props to your custom React component.

    We will do something similar to create CaptionNode. Let's define the view for the CaptionNode.
    CaptionNode.view.tsx

     import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
     import styles from "./CaptionNode.module.css";
    
     const CaptionNode = () => {
       return (
         <NodeViewWrapper>
           <NodeViewContent
             as={"p"}
             className={styles.captionNode}
           ></NodeViewContent>
         </NodeViewWrapper>
       );
     };
    
     export default CaptionNode;
    

    This NodeViewContent will be rendered as an editable paragraph, and we can write the text below the image. Now, let's integrate the view with our CaptionNode.

    CaptionNode.ts

     import { Node, mergeAttributes } from "@tiptap/core";
     import { ReactNodeViewRenderer } from "@tiptap/react";
     import CaptionNodeView from "./CaptionNode.view";
    
     const CaptionNode = Node.create({
       name: "captionNode",
       group: "block",
       content: "inline*",
       isolating: true,
    
       parseHTML() {
         return [
           {
             tag: "captionNode",
           },
         ];
       },
       renderHTML({ HTMLAttributes }) {
         return ["p", mergeAttributes(HTMLAttributes)];
       },
       addNodeView() {
         return ReactNodeViewRenderer(CaptionNodeView);
       },
     });
    
     export default CaptionNode;
    

    We are done with our child nodes for our CardNode. So, we will create a component that renders our child node and provides a button for the user to delete the Node at the top right corner, which will be visible while we hover on the view. We will use the deleteNode function that we get as a prop.

    CardNode.view.tsx

     import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
     import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
     import { faTrash } from "@fortawesome/free-solid-svg-icons";
     import styles from "./CardNode.module.css";
    
     const CardNodeView = ({ deleteNode }: NodeViewProps) => {
       return (
         <NodeViewWrapper className={styles.cardWrapper}>
           <div
             className={styles.deleteNode}
             onClick={() => {
               deleteNode();
             }}
           >
             <FontAwesomeIcon icon={faTrash} color="rgba(255, 255, 255, 0.65)" />
           </div>
    
           <NodeViewContent
             style={{ display: "flex", flexDirection: "column", gap: "4px" }}
           ></NodeViewContent>
         </NodeViewWrapper>
       );
     };
    
     export default CardNodeView;
    

    Now, let's define a type for the command to create the Card node. The core package has a type called Commands, which is required wherever we write commands. As you can see in the below example, we added the createCardNode command.

    Now let's define the schema for the CardNodeNode. So in our case, we have nodes that are part of the block group. So the group will be block. In the case of content expression, we want the order of rendering the nodes to be image, then caption, and we want only one block each of that child node so that the expression will be imageNode captionNode
    Now inside the addCommands, we will add a command createCardNode to insert the content. We will use the command called insertContent which is provided by the TipTap. It adds the passed value to the document. The value will be an object representation of the structure of how we want to render our data. So in our case, we want to render a type called CardNode content as ImageNode and CaptionNode. So the object will look like this.

     {
        type: this.name,
        content: [{ type: "imageNode" }, { type: "captionNode" }],
     }
    

    Here is what our code for CardNode will look like.

     import { Node, mergeAttributes } from "@tiptap/core";
     import { ReactNodeViewRenderer } from "@tiptap/react";
     import CardNodeView from "./CardNode.view";
    
     declare module "@tiptap/core" {
       interface Commands<ReturnType> {
         CardNode: {
           /**
            * create a card node
            */
           createCardNode: () => ReturnType;
         };
       }
     }
    
     const CardNode = Node.create({
       name: "cardNode",
       group: "block",
       content: "imageNode captionNode",
       isolating: true,
       parseHTML() {
         return [
           {
             tag: "cardNode",
           },
         ];
       },
       renderHTML({ HTMLAttributes }) {
         return ["div", mergeAttributes(HTMLAttributes), 0];
       },
       addCommands() {
         return {
           createCardNode:
             () =>
             ({ commands }) => {
               return commands.insertContent({
                 type: this.name,
                 content: [{ type: "imageNode" }, { type: "captionNode" }],
               });
             },
         };
       },
       addNodeView() {
         return ReactNodeViewRenderer(CardNodeView);
       },
     });
    
     export default CardNode;
    

    Now let's add our Nodes in the Tiptap editor file and an option for adding cards in the editor view.

     import CardNode from "@/app/extensions/CardNode/CardNode";
     import { EditorContent, useEditor } from "@tiptap/react";
     import StarterKit from "@tiptap/starter-kit";
     import Placeholder from "@tiptap/extension-placeholder";
     import Button from "../Button";
     import styles from "./tiptap.module.css";
     import ImageNode from "@/app/extensions/ImageNode/ImageNode";
     import CaptionNode from "@/app/extensions/CaptionNode/CaptionNode";
    
     // define your extension array
     const extensions = [
       StarterKit,
       CardNode,
       Placeholder.configure({
         emptyEditorClass: styles.emptyEditorClass,
         placeholder: "Please add you content here",
       }),
       ImageNode,
       CaptionNode,
     ];
    
     const Tiptap = (): JSX.Element => {
       const editor = useEditor({ extensions });
       return (
         <div style={{ width: "786px" }}>
           <div
             style={{
               backgroundColor: "#6a5acd",
               borderRadius: "4px",
             }}
           >
             <span>
               <Button
                 onClick={() => {
                   editor?.chain().focus().setHeading({ level: 1 }).run();
                 }}
               >
                 h1
               </Button>
               <Button
                 onClick={() => {
                   editor?.chain().focus().setHeading({ level: 2 }).run();
                 }}
               >
                 h2
               </Button>
               <Button
                 onClick={() => {
                   editor?.chain().focus().setHeading({ level: 3 }).run();
                 }}
               >
                 h3
               </Button>
               <Button
                 onClick={() => {
                   editor?.chain().focus().setHeading({ level: 4 }).run();
                 }}
               >
                 h4
               </Button>
               <Button
                 onClick={() => {
                   editor?.chain().focus().createImageNode().run();
                 }}
               >
                 Card
               </Button>
             </span>
           </div>
           <EditorContent editor={editor} className={styles.tiptap} />
         </div>
       );
     };
    
     export default Tiptap;
    

    This is how the output should look:

    https://drive.google.com/file/d/1neQUVkcSOLtcqaAov73OgCEvqLUOcXk7/view?usp=drive_link

    Conclusion

    Creating custom extensions in the TipTap WYSIWYG editor allows you to tailor the editing experience to your specific needs. By understanding the underlying ProseMirror schema and leveraging TipTap's flexible API, you can build powerful and interactive content editing tools. In this article, we walked through setting up TipTap with Next.js and creating a custom CardNode extension. This extension includes an image and a caption, demonstrating how to handle complex node structures and interactions.

    For a complete implementation and to learn more about this project, visit the GitHub repository.