Building custom extensions in Tiptap

Gaagul C Gigi

Gaagul C Gigi

August 6, 2024

Building custom extensions in Tiptap

neetoEditor is a rich text editor used across neeto products. It is built on Tiptap, an open-source headless content editor, and offers a seamless and customizable solution for rich text editing.

The decision to use Tiptap as the foundational framework for neetoEditor is based on its flexibility. Tiptap simplifies the complex Prosemirror syntax into simple JavaScript classes. In this blog post, we'll walk you through the process of building an Embed extension using Tiptap.

What is the Embed extension?

embed-extension


The Embed extension enables embedding of videos from YouTube, Vimeo, Loom and NeetoRecord.

Implementation

If you think of the document as a tree, every content type in Tiptap is a Node. Examples of nodes include paragraphs, headings, and code blocks. Here, we are creating a new "embed" node.

import { Node } from "@tiptap/core";

export default Node.create({
  name: "embed", // A unique identifier for the Node
  group: "block", // Belongs to the "block" group of extensions

  //...
});

Attributes

Attributes store extra information about a node and are rendered as HTML attributes by default. They are parsed from the content during initialization.

const Embed = Node.create({
  //...

  addAttributes() {
    return {
      src: { default: null },

      title: { default: null },

      frameBorder: { default: "0" },

      allow: {
        default:
          "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
      },

      allowfullscreen: { default: "allowfullscreen" },

      figheight: {
        default: 281,
        parseHTML: element => element.getAttribute("figheight"),
      },

      figwidth: {
        default: 500,
        parseHTML: element => element.getAttribute("figwidth"),
      },
    };
  },
});

These attributes customize the video embed behavior:

  • src: Specify the URL of the video you want to embed.
  • title: Add additional information about the video (optional).
  • frameBorder: Set to "0" for seamless integration (default).
  • allow: Define various permissions for optimal video experience (default value provided).
  • allowfullscreen: Enable fullscreen mode (default).
  • figheight & figwidth: Control the video frame's size.

Render HTML

The renderHTML function controls how an extension is rendered to HTML.

import { Node, mergeAttributes } from "@tiptap/core";

const Embed = Node.create({
  //...
  renderHTML({ HTMLAttributes, node }) {
    const { figheight, figwidth } = node.attrs;

    return [
      "div",
      {
        class: `neeto-editor__video-wrapper neeto-editor__video--${align}`,
      },
      [
        "div",
        {
          class: "neeto-editor__video-iframe",
          style: `width: ${figwidth}px; height: ${figheight}px;`,
        },
        [
          "iframe",
          mergeAttributes(this.options.HTMLAttributes, {
            ...HTMLAttributes,
          }),
        ],
      ],
    ];
  },
});

This renders the following HTML content:

<div class="neeto-editor__video-wrapper neeto-editor__video--center">
  <div class="neeto-editor__video-iframe" style="width: 281px;height: 500px">
    <iframe
      src="<src of the embed>"
      title="<title of the embed>"
      frameborder="0"
      allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
      allowfullscreen="allowfullscreen"
      figheight="281"
      figwidth="500"
      align="center"
    ></iframe>
  </div>
</div>

Parse HTML

The parseHTML function loads the editor document from HTML by receiving an HTML DOM element as input and returning an object with attributes and their values.

const Embed = Node.create({
  //...
  parseHTML() {
    return [{ tag: "iframe[src]" }];
  },
});

This ensures that whenever Tiptap encounters a <iframe> tag with an src attribute, our custom "embed" Node renders our custom UI.

Commands

Commands help us to easily modify or alter a selection programmatically. For our embed extension, let's write a command to insert the embed node.

const Embed = Node.create({
  //...
  addCommands() {
    return {
      setExternalVideo:
        options =>
        ({ commands }) =>
          commands.insertContent({ type: this.name, attrs: options }),
    };
  },
});

This is how a command can be executed:

editor
  .setExternalVideo({ src: "https://www.youtube.com/embed/3sQv3Xh3Gt4" })
  .run();

NodeView

Node views in TipTap enable customization for interactive nodes in your editor.

You can learn more about Node views with React here.

This is how your node extension could look like:

import { Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import Component from "./Component.jsx";

export default Node.create({
  // configuration …

  addNodeView() {
    return ReactNodeViewRenderer(Component);
  },
});

Note: The ReactNodeViewRenderer passes a few very helpful props to your custom React component.

This is how our Embed component looks like:

import React from "react";

import { NodeViewWrapper } from "@tiptap/react";
import { mergeRight } from "ramda";
import { Resizable } from "re-resizable";

import Menu from "../Image/Menu";

const EmbedComponent = ({
  node,
  editor,
  getPos,
  updateAttributes,
  deleteNode,
}) => {
  const { figheight, figwidth, align } = node.attrs;
  const { view } = editor;
  let height = figheight;
  let width = figwidth;

  const handleResize = (_event, _direction, ref) => {
    height = ref.offsetHeight;
    width = ref.offsetWidth;
    view.dispatch(
      view.state.tr.setNodeMarkup(
        getPos(),
        undefined,
        mergeRight(node.attrs, {
          figheight: height,
          figwidth: width,
          height,
          width,
        })
      )
    );
    editor.commands.focus();
  };

  return (
    <NodeViewWrapper
      className={`neeto-editor__video-wrapper neeto-editor__video--${align}`}
    >
      <Resizable
        lockAspectRatio
        className="neeto-editor__video-iframe"
        size={{ height, width }}
        onResizeStop={handleResize}
      >
        <Menu {...{ align, deleteNode, editor, updateAttributes }} /> // Menu
        component to handle alignment and delete
        <iframe {...node.attrs} />
      </Resizable>
    </NodeViewWrapper>
  );
};

export default EmbedComponent;

The NodeViewWrapper component is a wrapper for the custom component provided by TipTap. The Resizable component is used to resize the embed node.

Putting it all together

Here's the final output of the Embed extension in neetoEditor:

import { Node, mergeAttributes, PasteRule } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { TextSelection } from "prosemirror-state";

import { COMBINED_REGEX } from "common/constants";

import EmbedComponent from "./EmbedComponent";
import { validateUrl } from "./utils";

export default Node.create({
   name: "embed"

  addOptions() {
    return { inline: false, HTMLAttributes: {} };
  },

  inline() {
    return this.options.inline;
  },

  group() {
    return this.options.inline ? "inline" : "block";
  },

  addAttributes() {
    return {
      src: { default: null },

      title: { default: null },

      frameBorder: { default: "0" },

      allow: {
        default:
          "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
      },

      allowfullscreen: { default: "allowfullscreen" },

      figheight: {
        default: 281,
        parseHTML: element => element.getAttribute("figheight"),
      },

      figwidth: {
        default: 500,
        parseHTML: element => element.getAttribute("figwidth"),
      },

      align: {
        default: "center",
        parseHTML: element => element.getAttribute("align"),
      },
    };
  },

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

  renderHTML({ HTMLAttributes, node }) {
    const { align, figheight, figwidth } = node.attrs;

    return [
      "div",
      {
        class: `neeto-editor__video-wrapper neeto-editor__video--${align}`,
      },
      [
        "div",
        {
          class: "neeto-editor__video-iframe",
          style: `width: ${figwidth}px; height: ${figheight}px;`,
        },
        [
          "iframe",
          mergeAttributes(this.options.HTMLAttributes, {
            ...HTMLAttributes,
          }),
        ],
      ],
    ];
  },

  addNodeView() {
    return ReactNodeViewRenderer(EmbedComponent);
  },

  addCommands() {
    return {
      setExternalVideo:
        options =>
        ({ commands }) =>
          commands.insertContent({ type: this.name, attrs: options }),
    };
  },

  addPasteRules() {
    return [
      new PasteRule({
        find: COMBINED_REGEX,
        handler: ({ state, range, match }) => {
          state.tr.delete(range.from, range.to);
          state.tr.setSelection(
            TextSelection.create(state.doc, range.from + 1)
          );

          const validatedUrl = validateUrl(match[0]);
          if (validatedUrl) {
            const node = state.schema.nodes["embed"].create({
              src: validatedUrl,
            });
            state.tr.insert(range.from, node);
            state.tr.insert(
              range.from + node.nodeSize + 1,
              state.schema.nodes.paragraph.create()
            );

            state.tr.setSelection(
              TextSelection.create(state.tr.doc, range.from + node.nodeSize + 1)
            );
          }
        },
      }),
    ];
  },
});

If this blog was helpful, check out our full blog archive.

Stay up to date with our blogs.

Subscribe to receive email notifications for new blog posts.