Uploading pasted images in ProseMirror

For Hyvor Blogs, I made the opinionated decision to upload all pasted images to the user’s blog media in order to reduce dependence on external servers. This makes sure all images will work fine even if the original image is no longer accessible.

Hyvor Blog’s editor is based on ProseMirror. I’ll explain how to write a simple ProseMirror plugin to upload images automatically when the user pastes images in the Editor. I am going to use Prosemirror’s handlePaste property (it’s like a hook) to know when content is pasted and then asynchronously upload images and later replace the URLs.

First, I assume you already have a image node with attr src in your Prosemirror schema.

Add a new plugin to your editor.

1const view = new EditorView(document.querySelector("#editor"), {
2 state: EditorState.create({
3 schema: Your_Schema,
4 plugins: [
5 // your_plugins
6 getImagePastingPlugin()
7 ]
8 })

Write the function that creates the plugin.

1function getImagePastingPlugin() {
2 return new Plugin({
3 props: {
4 handlePaste: (view, e, slice) => {
5 const imageUrls : string[] = []
6 slice.content.descendants((node) => {
8 if (node.type.name === 'image') {
9 imageUrls.push(node.attrs.src)
10 }
12 })
13 setTimeout(() => {
14 uploadAndReplaceImages(imageUrls, view);
15 }, 100);
16 }
17 }
18 })

When something is pasted to the Editor, the callback is called. So, we can use slice to get images in the pasted content. slice.content.descendants function loops through all nested nodes. Once we have the imageUrls , we can upload all and replace them. Note that ProseMirror automatically converts pasted content into your schema. You do not have to worry about pasting HTML content or even a raw image. The handlePaste callback is called once all the parsing is done.

The 100ms delay is to give ProseMirror some time to update the view in the DOM.

Let’s write the function to upload and replace image URLs.

1async function uploadAndReplaceImages(imageUrls: string[], view: EditorView) {
2 for (const url of imageUrls) {
3 const fetched = await fetch(url)
4 const blob = fetched.blob();
6 const newUrl = await uploadImage(blob)
8 replaceImage(url, newUrl, view);
9 }

Here, we use the Fetch API to load the image as a blob. This works for both HTTP URLs and base64 encoded images. uploadImage(blob: Blob) : string is an async function that you should implement depending on how your backend works. It takes a Blob and returns a string URL of the uploaded image.

The final step is to replace the old URL with a new URL:

1function replaceImage(currentUrl: string, newUrl: string, view: EditorView) {
3 view.state.doc.descendants((node, pos) => {
4 if (
5 node.type.name === 'image' &&
6 node.attrs.src === currentUrl
7 ) {
8 const tr = view.state.tr.setNodeAttribute(pos, 'src', newUrl);
9 view.dispatch(tr);
10 }
11 });

We again use the descendants method in the ProseMirror document to loop through all nodes. When we find an image with the old URL, we update it with a new one using the tr.setNodeAttribute method. Finally, dispatch the tr.

One thing: Sometimes, some servers may not allow CORS requests. For this reason, we have an endpoint to upload using an URL so the request is made from our servers. The code looks something like this:

1async function uploadAndReplaceImages(imageUrls: string[], view: EditorView) {
3 for (const url of imageUrls) {
4 let newUrl;
6 if (url.startsWith('data:image')) {
7 const fetched = await fetch(url)
8 const blob = fetched.blob();
9 newUrl = await uploadImage(blob)
10 } else {
11 newUrl = await uploadImageFromUrl(url);
12 }
14 replaceImage(url, newUrl, view);
15 }

Feel free to comment below if you have any questions or ideas to improve this.