BlogJavaScript

Adding drag and drop image uploads to Tiptap

Written by Codemzy on January 27th, 2023

Need to be able to drag and drop image files into your Tiptap WYSIWYG editor? Tiptap is highly customisable, so let's add some drag-and-drop magic to the Image extension.

If you don't already know, Tiptap is a headless WYSIWYG editor that uses ProseMirror under the hood. I love how easy it is to build/add just the features you need and create a totally custom editing experience for your users.

Although Tiptap comes with an image extension, it doesn't have drag-and-drop functionality out of the box (at the time of writing). But like every other part of Tiptap, it's fairly straightforward to add this extra feature - and that's what we will do in this blog post!

Here's what we will build:

See the Pen Tiptap Drag and Drop Images by Codemzy (@codemzy) on CodePen.

Add the image extension

First things first, you're going to need Tiptap installed, and you will also need to use the Image extension.

I'm going to assume you already have Tiptap installed, but here's the bare bones of it.

import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';

new Editor({
  element: document.querySelector('.editor'),
  extensions: [
    StarterKit,
    Image
  ],
  content: '<p>Hello World!</p>',
});
<div class="editor"></div>

There are instructions in the Tiptap documentation for ReactJS (my framework of choice), Vue, and other options.

I've created a Tiptap CodePen if you want to follow along without the setup.

Add the Dropcursor extension

I'm going to highly recommend the Tiptap Dropcursor extension if you have the image extension installed.

Without it, you can still move your images around within the editor, but you can't necessarily see where they will end up!

If you have the StarterKit extension installed like in the code example above, you already have the Dropcursor extension. But if you didn't use the Starterkit extension, you can install the Dropcursor extension now.

Image drag in Tiptap

OK, now you have the image extension, and you can display images in your editor.

import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';

new Editor({
  element: document.querySelector('.editor'),
  extensions: [
    StarterKit,
    Image
  ],
  content: `
    <p>Hello World!</p>
    <img src="https://source.unsplash.com/8xznAGy4HcY/800x400" />
  `,
});

And you can drag and drop images around in the editor, this all works out of the box, thanks to Tiptap.

dragging image in tiptap gif

But if you're reading this post, I'm guessing you want to drag and drop images from outside of the editor (e.g. an image file on the desktop) into the editor.

Image file drag and drop in Tiptap

Under the hood, Tiptap uses ProseMirror. ProseMirror has a handleDrop function that's "called when something is dropped on the editor.". This sounds exactly like what we need for our drag-and-drop images.

We can access ProseMirror props through editorProps in Tiptap - let's get that set up before we write the function.

new Editor({
  element: document.querySelector('.editor'),
  extensions: [
    StarterKit,
    Image,
  ],
  editorProps: {
    handleDrop: function(view, event, slice, moved) {
      // we will do something here!
      return false; // not handled use default behaviour
    }
  },
  content: `
    <p>Hello World!</p>
    <img src="https://source.unsplash.com/8xznAGy4HcY/800x400" />
  `,
});

This is the bit we are working on:

handleDrop: function(view, event, slice, moved) {
  // we will do something here!
  return false; // not handled use default behaviour
}

We'll start by just returning false from the handleDrop function. Since this function covers all drop events, we only want to change the behaviour if a new image is dragged in from outside the editor (since the existing functionality for dragging an image around inside the editor works great).

Let's update the function to handle any new files dragged into the editor, by checking that the drop event isn't moving something and that it is transferring a new file.

handleDrop: function(view, event, slice, moved) {
  if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { // if dropping external files
    // handle the image upload
    return true; // handled
  }
  return false; // not handled use default behaviour
}

Now our drop function checks for a new file and prevents the default behaviour if the user is dropping a file. But we are not taking any old files, images only, please!

Handle the image

Now you need to handle the image, here's the function that I use, but you might want to make some changes. For example, I'm only allowing png and jpeg files under 10MB. They also must be under 5000px in width and height. You can adjust these settings depending on the file size and dimensions you want to allow.

handleDrop: function(view, event, slice, moved) {
  if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { // if dropping external files
    let file = event.dataTransfer.files[0]; // the dropped file
    let filesize = ((file.size/1024)/1024).toFixed(4); // get the filesize in MB
    if ((file.type === "image/jpeg" || file.type === "image/png") && filesize < 10) { // check valid image type under 10MB
      // check the dimensions
      let _URL = window.URL || window.webkitURL;
      let img = new Image(); /* global Image */
      img.src = _URL.createObjectURL(file);
      img.onload = function () {
        if (this.width > 5000 || this.height > 5000) {
          window.alert("Your images need to be less than 5000 pixels in height and width."); // display alert
        } else {
          // valid image so upload to server
          // uploadImage will be your function to upload the image to the server or s3 bucket somewhere
          uploadImage(file).then(function(response) { // response is the image url for where it has been saved
            // do something with the response
          }).catch(function(error) {
            if (error) {
              window.alert("There was a problem uploading your image, please try again.");
            }
          });
        }
      };
    } else {
      window.alert("Images need to be in jpg or png format and less than 10mb in size.");
    }
    return true; // handled
  }
  return false; // not handled use default behaviour
}

Sending the image to your server

You might have noticed a warning on the Tiptap page for the Image extension.

This extension does only the rendering of images. It doesn’t upload images to your server, that’s a whole different story.

- Tiptap Image

If you are letting people drag and drop images into the editor, you will need to save them somewhere so that you can display them. This might be an s3 bucket or some other object storage.

The uploadImage function will probably be an API call to your server to handle this. If you use axios it might look something like this.

function uploadImage(file) {
  const data = new FormData();
  data.append('file', file);
  return axios.post('/documents/image/upload', data);
};

I have a blog post for handling images on the server with code examples. For this post, I will keep the focus on the Tiptap side of the code.

Displaying the uploaded image in the editor

Now you have saved the image somewhere (hopefully), you should get a response with the URL to the saved file. Now you can display it back in the editor.

In the code below, I've added some code that creates the image element from the image URL that gets returned from the server and places it in the editor where it was dropped.

handleDrop: function(view, event, slice, moved) {
  if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { // if dropping external files
    let file = event.dataTransfer.files[0]; // the dropped file
    let filesize = ((file.size/1024)/1024).toFixed(4); // get the filesize in MB
    if ((file.type === "image/jpeg" || file.type === "image/png") && filesize < 10) { // check valid image type under 10MB
      // check the dimensions
      let _URL = window.URL || window.webkitURL;
      let img = new Image(); /* global Image */
      img.src = _URL.createObjectURL(file);
      img.onload = function () {
        if (this.width > 5000 || this.height > 5000) {
          window.alert("Your images need to be less than 5000 pixels in height and width."); // display alert
        } else {
          // valid image so upload to server
          // uploadImage will be your function to upload the image to the server or s3 bucket somewhere
          uploadImage(file).then(function(response) { // response is the image url for where it has been saved
            // place the now uploaded image in the editor where it was dropped
            const { schema } = view.state;
            const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
            const node = schema.nodes.image.create({ src: response }); // creates the image element
            const transaction = view.state.tr.insert(coordinates.pos, node); // places it in the correct position
            return view.dispatch(transaction);
          }).catch(function(error) {
            if (error) {
              window.alert("There was a problem uploading your image, please try again.");
            }
          });
        }
      };
    } else {
      window.alert("Images need to be in jpg or png format and less than 10mb in size.");
    }
    return true; // handled
  }
  return false; // not handled use default behaviour
}

Preventing a delay between response and display

You might find, especially if you display a loading spinner* while the image uploads, that there is a delay between your response and when the image is displayed in the editor. That's because the browser might take a moment to get the image from the URL.

*For example, if you set a loading state to true when a file is dropped, and then set it to false when the response arrives. We've not added a loading spinner in this tutorial, but maybe that's something for another time!

To get around that you can pre-load the image before responding.

uploadImage(file).then(function(response) { // response is the image url for where it has been saved
  // e.g. setLoading(true);
  // pre-load the image before responding so loading indicators can stay
  // and swaps out smoothly when the image is ready
  let image = new Image();
  image.src = response;
  image.onload = function() {
    // e.g. setLoading(false);
    // place the now uploaded image in the editor where it was dropped
    const { schema } = view.state;
    const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
    const node = schema.nodes.image.create({ src: response }); // creates the image element
    const transaction = view.state.tr.insert(coordinates.pos, node); // places it in the correct position
    return view.dispatch(transaction);
  }
}).catch(function(error) {
  if (error) {
    window.alert("There was a problem uploading your image, please try again.");
  }
});

The finished code

We made it! Here's the final code.

import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';

new Editor({
  element: document.querySelector('.editor'),
  extensions: [
    StarterKit,
    Image,
  ],
  editorProps: {
    handleDrop: function(view, event, slice, moved) {
      if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { // if dropping external files
        let file = event.dataTransfer.files[0]; // the dropped file
        let filesize = ((file.size/1024)/1024).toFixed(4); // get the filesize in MB
        if ((file.type === "image/jpeg" || file.type === "image/png") && filesize < 10) { // check valid image type under 10MB
          // check the dimensions
          let _URL = window.URL || window.webkitURL;
          let img = new Image(); /* global Image */
          img.src = _URL.createObjectURL(file);
          img.onload = function () {
            if (this.width > 5000 || this.height > 5000) {
              window.alert("Your images need to be less than 5000 pixels in height and width."); // display alert
            } else {
              // valid image so upload to server
              // uploadImage will be your function to upload the image to the server or s3 bucket somewhere
              uploadImage(file).then(function(response) { // response is the image url for where it has been saved
                // pre-load the image before responding so loading indicators can stay
                // and swaps out smoothly when image is ready
                let image = new Image();
                image.src = response;
                image.onload = function() {
                  // place the now uploaded image in the editor where it was dropped
                  const { schema } = view.state;
                  const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
                  const node = schema.nodes.image.create({ src: response }); // creates the image element
                  const transaction = view.state.tr.insert(coordinates.pos, node); // places it in the correct position
                  return view.dispatch(transaction);
                }
              }).catch(function(error) {
                if (error) {
                  window.alert("There was a problem uploading your image, please try again.");
                }
              });
            }
          };
        } else {
          window.alert("Images need to be in jpg or png format and less than 10mb in size.");
        }
        return true; // handled
      }
      return false; // not handled use default behaviour
    }
  },
  content: `
    <p>Hello World!</p>
    <img src="https://source.unsplash.com/8xznAGy4HcY/800x400" />
  `,
});