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.

1import { Node } from "@tiptap/core";
2
3export default Node.create({
4  name: "embed", // A unique identifier for the Node
5  group: "block", // Belongs to the "block" group of extensions
6
7  //...
8});

Attributes

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

1const Embed = Node.create({
2  //...
3
4  addAttributes() {
5    return {
6      src: { default: null },
7
8      title: { default: null },
9
10      frameBorder: { default: "0" },
11
12      allow: {
13        default:
14          "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
15      },
16
17      allowfullscreen: { default: "allowfullscreen" },
18
19      figheight: {
20        default: 281,
21        parseHTML: element => element.getAttribute("figheight"),
22      },
23
24      figwidth: {
25        default: 500,
26        parseHTML: element => element.getAttribute("figwidth"),
27      },
28    };
29  },
30});

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.

1import { Node, mergeAttributes } from "@tiptap/core";
2
3const Embed = Node.create({
4  //...
5  renderHTML({ HTMLAttributes, node }) {
6    const { figheight, figwidth } = node.attrs;
7
8    return [
9      "div",
10      {
11        class: `neeto-editor__video-wrapper neeto-editor__video--${align}`,
12      },
13      [
14        "div",
15        {
16          class: "neeto-editor__video-iframe",
17          style: `width: ${figwidth}px; height: ${figheight}px;`,
18        },
19        [
20          "iframe",
21          mergeAttributes(this.options.HTMLAttributes, {
22            ...HTMLAttributes,
23          }),
24        ],
25      ],
26    ];
27  },
28});

This renders the following HTML content:

1<div class="neeto-editor__video-wrapper neeto-editor__video--center">
2  <div class="neeto-editor__video-iframe" style="width: 281px;height: 500px">
3    <iframe
4      src="<src of the embed>"
5      title="<title of the embed>"
6      frameborder="0"
7      allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
8      allowfullscreen="allowfullscreen"
9      figheight="281"
10      figwidth="500"
11      align="center"
12    ></iframe>
13  </div>
14</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.

1const Embed = Node.create({
2  //...
3  parseHTML() {
4    return [{ tag: "iframe[src]" }];
5  },
6});

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.

1const Embed = Node.create({
2  //...
3  addCommands() {
4    return {
5      setExternalVideo:
6        options =>
7        ({ commands }) =>
8          commands.insertContent({ type: this.name, attrs: options }),
9    };
10  },
11});

This is how a command can be executed:

1editor
2  .setExternalVideo({ src: "https://www.youtube.com/embed/3sQv3Xh3Gt4" })
3  .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:

1import { Node } from "@tiptap/core";
2import { ReactNodeViewRenderer } from "@tiptap/react";
3import Component from "./Component.jsx";
4
5export default Node.create({
6  // configuration …
7
8  addNodeView() {
9    return ReactNodeViewRenderer(Component);
10  },
11});

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

This is how our Embed component looks like:

1import React from "react";
2
3import { NodeViewWrapper } from "@tiptap/react";
4import { mergeRight } from "ramda";
5import { Resizable } from "re-resizable";
6
7import Menu from "../Image/Menu";
8
9const EmbedComponent = ({
10  node,
11  editor,
12  getPos,
13  updateAttributes,
14  deleteNode,
15}) => {
16  const { figheight, figwidth, align } = node.attrs;
17  const { view } = editor;
18  let height = figheight;
19  let width = figwidth;
20
21  const handleResize = (_event, _direction, ref) => {
22    height = ref.offsetHeight;
23    width = ref.offsetWidth;
24    view.dispatch(
25      view.state.tr.setNodeMarkup(
26        getPos(),
27        undefined,
28        mergeRight(node.attrs, {
29          figheight: height,
30          figwidth: width,
31          height,
32          width,
33        })
34      )
35    );
36    editor.commands.focus();
37  };
38
39  return (
40    <NodeViewWrapper
41      className={`neeto-editor__video-wrapper neeto-editor__video--${align}`}
42    >
43      <Resizable
44        lockAspectRatio
45        className="neeto-editor__video-iframe"
46        size={{ height, width }}
47        onResizeStop={handleResize}
48      >
49        <Menu {...{ align, deleteNode, editor, updateAttributes }} /> // Menu
50        component to handle alignment and delete
51        <iframe {...node.attrs} />
52      </Resizable>
53    </NodeViewWrapper>
54  );
55};
56
57export 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:

1import { Node, mergeAttributes, PasteRule } from "@tiptap/core";
2import { ReactNodeViewRenderer } from "@tiptap/react";
3import { TextSelection } from "prosemirror-state";
4
5import { COMBINED_REGEX } from "common/constants";
6
7import EmbedComponent from "./EmbedComponent";
8import { validateUrl } from "./utils";
9
10export default Node.create({
11   name: "embed"
12
13  addOptions() {
14    return { inline: false, HTMLAttributes: {} };
15  },
16
17  inline() {
18    return this.options.inline;
19  },
20
21  group() {
22    return this.options.inline ? "inline" : "block";
23  },
24
25  addAttributes() {
26    return {
27      src: { default: null },
28
29      title: { default: null },
30
31      frameBorder: { default: "0" },
32
33      allow: {
34        default:
35          "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
36      },
37
38      allowfullscreen: { default: "allowfullscreen" },
39
40      figheight: {
41        default: 281,
42        parseHTML: element => element.getAttribute("figheight"),
43      },
44
45      figwidth: {
46        default: 500,
47        parseHTML: element => element.getAttribute("figwidth"),
48      },
49
50      align: {
51        default: "center",
52        parseHTML: element => element.getAttribute("align"),
53      },
54    };
55  },
56
57  parseHTML() {
58    return [{ tag: "iframe[src]" }];
59  },
60
61  renderHTML({ HTMLAttributes, node }) {
62    const { align, figheight, figwidth } = node.attrs;
63
64    return [
65      "div",
66      {
67        class: `neeto-editor__video-wrapper neeto-editor__video--${align}`,
68      },
69      [
70        "div",
71        {
72          class: "neeto-editor__video-iframe",
73          style: `width: ${figwidth}px; height: ${figheight}px;`,
74        },
75        [
76          "iframe",
77          mergeAttributes(this.options.HTMLAttributes, {
78            ...HTMLAttributes,
79          }),
80        ],
81      ],
82    ];
83  },
84
85  addNodeView() {
86    return ReactNodeViewRenderer(EmbedComponent);
87  },
88
89  addCommands() {
90    return {
91      setExternalVideo:
92        options =>
93        ({ commands }) =>
94          commands.insertContent({ type: this.name, attrs: options }),
95    };
96  },
97
98  addPasteRules() {
99    return [
100      new PasteRule({
101        find: COMBINED_REGEX,
102        handler: ({ state, range, match }) => {
103          state.tr.delete(range.from, range.to);
104          state.tr.setSelection(
105            TextSelection.create(state.doc, range.from + 1)
106          );
107
108          const validatedUrl = validateUrl(match[0]);
109          if (validatedUrl) {
110            const node = state.schema.nodes["embed"].create({
111              src: validatedUrl,
112            });
113            state.tr.insert(range.from, node);
114            state.tr.insert(
115              range.from + node.nodeSize + 1,
116              state.schema.nodes.paragraph.create()
117            );
118
119            state.tr.setSelection(
120              TextSelection.create(state.tr.doc, range.from + node.nodeSize + 1)
121            );
122          }
123        },
124      }),
125    ];
126  },
127});

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.