---
title: "Building custom extensions in Tiptap"
description: "A comprehensive guide on building custom extensions in Tiptap."
canonical_url: "https://www.bigbinary.com/blog/building-custom-extensions-in-tiptap"
markdown_url: "https://www.bigbinary.com/blog/building-custom-extensions-in-tiptap.md"
---

# Building custom extensions in Tiptap

A comprehensive guide on building custom extensions in Tiptap.

- Author: Gaagul C Gigi
- Published: August 6, 2024
- Categories: React, JavaScript

[neetoEditor](https://neeto-editor.neeto.com) is a rich text editor used across
[neeto](https://neeto.com) 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?

<div style="width:100%;max-width:600px;margin:auto;">
  <img width="100" alt="embed-extension" src="/blog/images/images_used_in_blog/2024/building-custom-extensions-in-tiptap/embed-youtube-video.gif">
</div>

<br />

<br />

The Embed extension enables embedding of videos from YouTube, Vimeo, Loom and
[NeetoRecord](https://neeto.com/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.

```jsx
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](https://tiptap.dev/docs/editor/guide/custom-extensions#attributes)
store extra information about a node and are rendered as HTML attributes by
default. They are parsed from the content during initialization.

```javascript
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](https://Video.dev/docs/editor/guide/custom-extensions#render-html)
function controls how an extension is rendered to HTML.

```javascript
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:

```jsx
<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](https://tiptap.dev/docs/editor/guide/custom-extensions#parse-html)
function loads the editor document from HTML by receiving an HTML DOM element as
input and returning an object with attributes and their values.

```javascript
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](https://tiptap.dev/docs/editor/api/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.

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

This is how a command can be executed:

```javascript
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](https://tiptap.dev/docs/editor/guide/node-views/react).

This is how your node extension could look like:

```jsx
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:

```jsx
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:

```javascript
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)
            );
          }
        },
      }),
    ];
  },
});
```

## Links

- [Human page](https://www.bigbinary.com/blog/building-custom-extensions-in-tiptap)
