BlogJavaScript

Adding a video embed extension to Tiptap editor in ReactJS

Written by Codemzy on May 18th, 2022

Videos are pretty popular on the web these days. But what if your users want to add them to content? Here's how I added YouTube and Vimeo video embeds to Tiptap editor in ReactJS.

I recently integrated Tiptap editor into a website as a way for users to edit content. Tiptap editor is honestly fantastic, and I fully recommend using it if you need a WYSIWYG editor.

One feature I need is video embeds - a way for users to add videos from YouTube or Vimeo to the content.

Tiptap currently doesn't have support for video embeds, but the good news is, that it's pretty easy to add features to Tiptap. This is one of the reasons why I recommend it.

Ok, let's get started adding videos to Tiptap!

I want the video extension to have a few key features:

  • Videos should be draggable so that you can move them about
  • You should be able to playback a video
  • You can select and delete a video

Working with iframes/embeds brings a few quirks, so a couple of these features were a little trickier than I first thought!

Creating the video extension

First, you need to create a new extension. For the video extension, we will create a node extension (since it's a new node).

Here's how that looks...

import { Node } from '@tiptap/core'

const Video = Node.create({
  name: 'video', // unique name for the Node
  group: 'block', // belongs to the 'block' group of extensions
  selectable: true, // so we can select the video
  draggable: true, // so we can drag the video
  atom: true, // is a single unit

  //...
});

To keep things simple, videos will be represented with their own <video> tag. To do this, we tell Tiptap that this extension applies to the video tag, using parseHTML().

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

  parseHTML() {
    return [
      {
        tag: 'video',
      },
    ]
  },

});

So in the HTML output <video> will trigger the video extension.

<video src="https://www.youtube.com/embed/dQw4w9WgXcQ"></video>

You will also need a src attribute so that you can show the correct video.

You can add attributes with addAttributes(), so let's add a "src" attribute there.

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

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

});

And finally, we need to tell Tiptap what to render. This is the output HTML, which is our new <video> tag!

import { mergeAttributes } from '@tiptap/core'

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

  renderHTML({ HTMLAttributes }) {
      return ['video', mergeAttributes(HTMLAttributes)];
  },
});

Great, we now have a video extension! The extension doesn't do anything yet, because the <video> element doesn't exist in Tiptap. Next, we will fix that, by adding a node view.

I do realise that the video element does exist in HTML, but what we are building here is slightly different. If you also want your HTML to work with the native video element, then you could tweak your extension to either use an embed tag <embed data-type="video" src="... e.g.

parseHTML() {
  return [
    {
      tag: 'embed[data-type="video"]', // alternative option 1
    },
  ]
},

Or you could add an attribute to know the difference between a native video and video embed <video data-type="embed" src="..., e.g.:

parseHTML() {
  return [
    {
      tag: 'video[data-type="embed"]', // alternative option 2
    },
  ]
},

But I don't plan to have video uploads, only embeds (and only video embeds), so I'm happy to repurpose the video element for this extension.

Here's the code so far:

import { Node, mergeAttributes } from '@tiptap/core'

const Video = Node.create({
  name: 'video', // unique name for the Node
  group: 'block', // belongs to the 'block' group of extensions
  selectable: true, // so we can select the video
  draggable: true, // so we can drag the video
  atom: true, // is a single unit

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

  parseHTML() {
    return [
      {
        tag: 'video',
      },
    ]
  },

  renderHTML({ HTMLAttributes }) {
      return ['video', mergeAttributes(HTMLAttributes)];
  },
});

Adding the video custom node view

So far, we have a custom video extension, that handles a video HTML tag.

<video src="https://www.youtube.com/embed/dQw4w9WgXcQ"></video>

But we don't want our browser to display the <video> element because we want to embed an iframe - like the code you get from YouTube.

<iframe width="560" height="315" src="https://www.youtube.com/embed/dQw4w9WgXcQ" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

So how can you go from video to embed with all the extra attributes you might need? The answer in Tiptap is node views.

Node views are amazing to improve the in-editor experience, but can also be used in a read-only instance of Tiptap. They are unrelated to the HTML output by design, so you have full control about the in-editor experience and the output.

- Tiptap Interactive node views

Adding the iframe

Let's start by adding an iframe with the attributes we need for our embed.

const Video = Node.create({
  //...
  addNodeView() {
    return ({ editor, node }) => {
      const iframe = document.createElement('iframe');
      iframe.width = '640';
      iframe.height = '360';
      iframe.frameborder = "0";
      iframe.allowfullscreen = "";
      iframe.src = node.attrs.src;
      return {
        dom: iframe,
      }
    }
  },
});

Ok, that's great and all, but not very responsive. You probably don't want your video to stay at a fixed height and width. Let's fix that with a wrapper div and a little CSS.

Making the embed responsive

You can make your video responsive by wrapping it in a div and giving it an aspect ratio.

If you're using Tailwind CSS, you can use the aspect-ratio plugin and just add the aspect-w-16 aspect-h-9 classes to the wrapping div.

If you're writing your own CSS, you could add a video-container class to the wrapping div, and then add the following CSS:

.video-container {
  position: relative;
  overflow: hidden;
  padding-bottom: 56.25%;
}

.video-container>iframe {
  position: absolute;
  top: 0px;
  right: 0px;
  bottom: 0px;
  left: 0px;
  width: 100%;
  height: 100%;
}

Let's add that wrapping div with its fancy CSS to our node view.

const Video = Node.create({
  //...
  addNodeView() {
    return ({ editor, node }) => {
      const div = document.createElement('div');
      div.className = 'aspect-w-16 aspect-h-9'; // or 'video-container' or some other class if you are adding your own css
      const iframe = document.createElement('iframe');
      iframe.width = '640';
      iframe.height = '360';
      iframe.frameborder = "0";
      iframe.allowfullscreen = "";
      iframe.src = node.attrs.src;
      div.append(iframe);
      return {
        dom: div,
      }
    }
  },
});

Adding a video

Now the video extension would work (in theory), but users need a way to add a video.

As Tiptap is headless, it's up to you how you want to build the user interface. In this example, I'll use a simple prompt. You might want to use a modal or some other UI for your interface.

In this section, I'm going to assume you already have Tiptap up and running, and you are adding this extension to your existing set-up. I will just native prompt and alert for the UI, so you can wire it up to your current use.

I'm also using ReactJS for my Tiptap installation. So you might need to tweak things if you are using Vue.

You can start by adding a button to your toolbar e.g.

<button onClick={setVideo} className={editor.isActive('video') ? 'is-active' : ''}>Video</button>

Now run a function that takes an input, and adds the video element to the editor content.

const setVideo= React.useCallback(() => {
  const videoSrc = editor.getAttributes('video').src;
  const video = window.prompt('Video URL', videoSrc)

  // cancelled
  if (video === null) {
    return;
  }

  // empty
  if (video === '') {
    editor.isActive('video') ? editor.commands.deleteSelection() : false;
    return;
  }

  // update video
  // validate url is from youtube or vimeo
  if (!input.match(/youtube|vimeo/)) {
      return alert("Sorry, your video must be hosted on YouTube or Vimeo.");
  }
  let srcCheck = input.match(/src="(?<src>.+?)"/); // get the src value from embed code if all pasted in
  let src = srcCheck ? srcCheck.groups.src : input; // use src or if just url in input use that
  // check youtube url is correct
  if (input.match(/youtube/) && !src.match(/^https:\/\/www\.youtube\.com\/embed\//)) {
      return alert("Sorry, your YouTube embed URL should start with https://www.youtube.com/embed/ to work.");
  }
  // check vimeo url is correct
  if (input.match(/vimeo/) && !src.match(/^https:\/\/player\.vimeo\.com\/video\//)) {
      return alert("Sorry, your Vimeo embed URL should start with https://player.vimeo.com/video/ to work.");
  }
  if (video) {
      editor.commands.updateAttributes('video', { src: src }); // update the current video src
  } else {
      editor.chain().focus().insertContent(`<video src="${src}"></video>`).run(); // add a new video element
  }

}, [editor]);

I've added some validation to check if the embed is coming from YouTube or Vimeo. You could add extra video hosting services here too.

Tiptap video playback

Now you can add a video, but the video isn't draggable. If you want to move it within your content, you can't. That's because if you click a video, the mouse event is handled by the iframe.

To get around this, you can apply some CSS to disable mouse events on the iframe, and since we already declared the video extension to be draggable, it will be!

.pointer-events-none {
  pointer-events: none;
}

But now we have the opposite problem. The video is draggable, but playback no longer works.

I found that having both playback and a draggable video was tricky. But I figured, you only need to drag a video in edit mode. So to get around this issue, I made the video draggable in edit mode and playable in read-only mode.

if (editor.isEditable) {
  iframe.className = 'pointer-events-none';
};

Video embed extension code

Here's the final code for the video embed extension. If you're not using Tailwind CSS, don't forget to add the custom CSS discussed in this post.

import { Node, mergeAttributes } from '@tiptap/core'

const Video = Node.create({
  name: 'video', // unique name for the Node
  group: 'block', // belongs to the 'block' group of extensions
  selectable: true, // so we can select the video
  draggable: true, // so we can drag the video
  atom: true, // is a single unit

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

  parseHTML() {
    return [
      {
        tag: 'video',
      },
    ]
  },

  renderHTML({ HTMLAttributes }) {
      return ['video', mergeAttributes(HTMLAttributes)];
  },

  addNodeView() {
    return ({ editor, node }) => {
      const div = document.createElement('div');
      div.className = 'aspect-w-16 aspect-h-9' + (editor.isEditable ? ' cursor-pointer' : '');
      const iframe = document.createElement('iframe');
      if (editor.isEditable) {
        iframe.className = 'pointer-events-none';
      }
      iframe.width = '640';
      iframe.height = '360';
      iframe.frameborder = "0";
      iframe.allowfullscreen = "";
      iframe.src = node.attrs.src;
      div.append(iframe);
      return {
        dom: div,
      }
    }
  },
});