BlogCode

How to upload or disable pasting images in Tiptap

Written by Codemzy on February 27th, 2023

Once you add the Image extension to the Tiptap WYSIWYG editor, users can paste images in from HTML and the clipboard. That may (or may not be) what you want to happen. Here's how I handled image pasting in Tiptap.

This is a kind of follow-up post to Adding drag and drop image uploads to Tiptap. We took care of users uploading images through drag and drop - but what happens if they paste images into the editor content?

Once you add the Image extension in Tiptap, your editor can handle images, or in this case the <img> element.

In the previous blog post, we added a handleDrop function, that took care of uploading images when they were dropped into the editor.

In this post, we will add a handlePaste function, so your editor can also upload images that are pasted in.

Pasting an image file in Tiptap

By default, you can't paste an image file into Tiptap, so we need to write some code to allow this behaviour.

Like the handleDrop function we used for drag and drop images in Tiptap, handlePaste is a function provided to use from ProseMirror (the rich-text editor Tiptap uses under the hood).

handlePaste is "used to override the behaviour of pasting". That sounds like what we need to do here - let's get it set up!

new Editor({
 element: document.querySelector('.editor'),
 extensions: [
  StarterKit,
  Image,
 ],
 editorProps: {
  handlePaste: function(view, event, slice) {
   // we will do something here!
   return false; // not handled use default behaviour
  }
 },
 content: `
  <p>Hello World!</p>
 `,
});

This is the bit we are working on:

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

When we return false from the function, the default behaviour will run. We are not handling anything yet, so that's a good place to start. But we do want to change the behaviour if an image is pasted in, so let's check if the paste event contains an image.

handlePaste: function(view, event, slice) {
  const items = Array.from(event.clipboardData?.items || []);      
  for (const item of items) {
    if (item.type.indexOf("image") === 0) {
      // handle the image upload
      return true; // handled
    }
  }
  return false;
},

If the MIME type starts with "image", we know the pasted file contains image data.

Now the handlePaste function checks if the clipboard contains a file and prevents the default behaviour if it does, and the file is an image.

Handle the image

Now we need to get the image and upload it to the server. Most of the code at this stage will be similar to the handleDrop function from the previous post.

handlePaste: function(view, event, slice) {
  const items = Array.from(event.clipboardData?.items || []);      
  for (const item of items) {
    if (item.type.indexOf("image") === 0) {
      let filesize = ((item.size/1024)/1024).toFixed(4); // get the filesize in MB
      if (filesize < 10) { // check image under 10MB
        // check the dimensions
        let _URL = window.URL || window.webkitURL;
        let img = new Image(); /* global Image */
        img.src = _URL.createObjectURL(item);
        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
},

The uploadImage function will probably be an API call to your server to send the file. 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 pasted image in the editor

Now you have uploaded the image, you should get a response with the URL to the saved file. It's time to display it in the editor.

The code is slightly different to when we were dragging and dropping our images - instead of inserting at coordinates, we will replaceSelectionWith.

handlePaste: function(view, event, slice) {
  const items = Array.from(event.clipboardData?.items || []);      
  for (const item of items) {
    if (item.type.indexOf("image") === 0) {
      let filesize = ((item.size/1024)/1024).toFixed(4); // get the filesize in MB
      if (filesize < 10) { // check image under 10MB
        // check the dimensions
        let _URL = window.URL || window.webkitURL;
        let img = new Image(); /* global Image */
        img.src = _URL.createObjectURL(item);
        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 pasted
              const node = schema.nodes.image.create({ src: response }); // creates the image element
              const transaction = view.state.tr.replaceSelectionWith(node); // places it in the correct position
              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 less than 10mb in size.");
      }
      return true; // handled
    }
  }
  return false; // not handled use default behaviour
},

You might want to read the section on preventing a delay between response and display in the drag and drop blog post, especially if you want to use a loading spinner during upload. If you end up using both handleDrop and handlePaste for uploading images, it's a great idea to refactor these two functions to use a helper function containing all the shared code.

Pasting <img> elements in HTML

Now users can upload images by copying and pasting them into the editor. But there is another way images can end up inside the editor, and that's when you paste some HTML.

With the Image extension installed, if you paste some HTML in the editor that contains <img> elements, those elements remain, and the image displays.

And that might be what you want.

However, that wasn't what I wanted, for a couple of reasons.

  • Those images are hosted somewhere else (unlike the images we have uploaded with handleDrop and handlePaste
  • Users might think those images are uploaded and saved to the content
  • Since the images are hosted somewhere else, they could get deleted from the other place, and then we would have broken <img>'s in our content

For these reasons, I decided that I only wanted images saved in the content that has been uploaded to my servers - which is what happens now when an actual image file is pasted using the handlePaste function we created above.

If you're happy with users pasting in images with other content copied from HTML, you don't need to do anything else, as that is the default setting in Tiptap.

Preventing pasting images from HTML

For the reasons given above, I decided it was best for me to prevent pasting <img> from HTML, and only allow direct uploads.

And to do this, we can use the transformPastedHTML function in editorProps. You won't find this function in the Tiptap documentation, because it's another ProseMirror function.

With this function you can - and you probably guessed this - make changes to the pasted HTML. And in this case, we can remove <img> tags, with the power of regex.

editorProps: {
  handlePaste: function(view, event, slice) {
   // ...
  },
  transformPastedHTML(html) {
    return html.replace(/<img.*?>/g, ""); // remove any images copied any pasted as HTML
  },
},

Ta-Daa! Goodbye third-party hosted images, we control all the images around here thank you very much!

You might want to make one further tweak here - because if a user copies some existing content within the editor, and then pastes it, the images will be removed. But you don't necessarily want that - because those images have already been uploaded to our storage and we are happy to have them.

So let's swap our regex from /<img.*?>/g to /<img.*?src="(?<imgSrc>.*?)".*?>/g so we can grab the image src, and then use a regex replacer function to decide if the image is allowed or not.

editorProps: {
  handlePaste: function(view, event, slice) {
   // ...
  },
  transformPastedHTML(html) {
    return html.replace(/<img.*?src="(?<imgSrc>.*?)".*?>/gm, function(match, imgSrc) {
      if (imgSrc.startsWith('https://images.your-image-hosting.com')) { // your saved images
        return match; // keep the img
      }
      return ""; // replace it
    });
  },
},

Now we really are just getting rid of the images we don't want.

If you don't understand the regex above, you can read my blog post on regex groups with .replace() in Javascript for more information.

The finished code

Here's the finished code to:

  • Upload pasted images and display them
  • Prevent pasting images from HTML
new Editor({
 element: document.querySelector('.editor'),
 extensions: [
  StarterKit,
  Image,
 ],
 editorProps: {
  handlePaste: function(view, event, slice) {
    const items = Array.from(event.clipboardData?.items || []);      
    for (const item of items) {
      if (item.type.indexOf("image") === 0) {
        let filesize = ((item.size/1024)/1024).toFixed(4); // get the filesize in MB
        if (filesize < 10) { // check image under 10MB
          // check the dimensions
          let _URL = window.URL || window.webkitURL;
          let img = new Image(); /* global Image */
          img.src = _URL.createObjectURL(item);
          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 pasted
                const node = schema.nodes.image.create({ src: response }); // creates the image element
                const transaction = view.state.tr.replaceSelectionWith(node); // places it in the correct position
                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 less than 10mb in size.");
        }
        return true; // handled
      }
    }
    return false; // not handled use default behaviour
  },
  transformPastedHTML(html) {
    return html.replace(/<img.*?src="(?<imgSrc>.*?)".*?>/g, function(match, imgSrc) {
      if (imgSrc.startsWith('https://images.your-image-hosting.com')) { // your saved images
        return match; // keep the img
      }
      return ""; // replace it
    });
  },
 },
 content: `
  <p>Hello World!</p>
 `,
});