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.
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.
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:
Create the Tiptap editor component:
Create a new file calledTiptap.tsx
in thesrc/app/components
directory of your Next.js project.Set up the Tiptap editor in
Tiptap.tsx
:
OpenTiptap.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 beblock, 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 namedparagraph
then it will be represented asparagraph+
, in case of zero or more paragraphs then it will be represented asparagraph*
.
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 havecode
orblockquote
; 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 aheading 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 othercommands
from the editor inaddCommands
, we can use the 'commands' parameter from theaddCommands
.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. HereNodeViewContent
andNodeViewWrapper
are the wrappers provided by TipTap, whereNodeViewContent
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 usingas
props. These view nodes get props of the typeNodeViewProps
.updateAttributes
is one of the props that we are using here fromNodeViewProps
. This prop is used to update the attributes for the node. In our case, we will define an attribute namedurl
.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 ofcontent 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 beimageNode captionNode
Now inside theaddCommands
, we will add a commandcreateCardNode
to insert the content. We will use the command calledinsertContent
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.