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.
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.
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" />
`,
});