TipTap Editor images upload

Implement image upload with a placeholder and drag-and-drop functionality. The user creates a placeholder block with the ability to drag an image or click on it. After the event is triggered, a loader appears, and as a result, the user sees the image, which can be resized.

Steps

1. API requests for image upload, delete

I have a Node.js server with an S3 bucket set up and wired to multipart middleware for Express.
I'll need to write React Query hooks to communicate with the server.

2. Add image extension

Add image extensions to the editor extensions list.

Image extension docs

3. Selection menu

Create a menu that appears when the image block is selected.

4. Upload modal

Modal to upload, edit, or remove the image.

5. Sync data with server

I'll need to make sure that when a user uploads an image, it's being uploaded to the server, and when they remove an image or note, it's being deleted from there.

6. Error handling

7. Compress Images


1. API requests for image upload, delete

First of all, we need to have some way to upload the image to the server. I use React Query for fetching the data.

import { API_URL } from '@/constants';
import { useMutation } from '@tanstack/react-query';
import axios from 'axios';

const uploadFile = (file: File) => {
  const formData = new FormData();
  formData.append('file', file);

  return axios
    .post(`${API_URL}/file/upload`, formData, {
      withCredentials: true,
      headers: {
        'Content-Type': 'multipart/form-data',
      },
    })
    .catch((err) => {
      // some error handling here, for example, sentry
      return err?.response;
    });
};

export function useUploadFile() {
  return useMutation({
    mutationFn: uploadFile,
	// you can also invalidate all needed queries on success
  });
}

2. Image Extension

Add image extension to the extensions list.

import Image from '@tiptap/extension-image';
import { useEditor } from '@tiptap/react'

useEditor({
	// other extensions
	Image
})

3. Selection menu

Image blocks will have their own menu, which will show up when they are selected. I use Bubble Menu, but it can be any element that is interactive when editor.isActive("image") is true.

import { BubbleMenu } from '@tiptap/react';

// other code

<BubbleMenu
   editor={editor}
   // Show menu when editor is editable and image is selected
   shouldShow={(params) => {
	   const isEditable = params.editor.isEditable
       return isEditable && editor.isActive('image');
     }
   }
   >
      <button
        // onClick={} Here we will trigger modal to edit image
      >
        Edit image
      </button>
  </div>
</BubbleMenu>

4. Upload modal

Simple upload image modal with the file input.

interface IProps {
  onClose: () => void;
  editor: Editor;
}

export function ImageUploadModal({ onClose, editor }: IProps) {
  const { mutateAsync: uploadImageAsync, isPending } = useUploadFile();

  const onUpload = useCallback(
    async (e: ChangeEvent<HTMLInputElement>) => {
      	const file = e.target.files?.[0];
      	if (!file) return;

	  	// upload image to server
      	const { data } = await uploadImageAsync(file);
	  	// get url
      	const url = data.url;
	  	// right now image block is in selection, set url for the image
      	editor.chain().focus().setImage({ src: url }).run();
	  	// close the window
      	onClose();
      }
    },
    [editor, onClose, uploadImageAsync]
  );

  return (
    <Modal onClose={onClose} title="Upload image">
      {isPending ? (
        <Loader />
      ) : (
        <input
          type="file"
          accept="image/*"
          multiple={false}
          onChange={onUpload}
        />
      )}
    </Modal>
  );
}

7. Compress images

Users don't usually compress images themselves, and people rarely check the image size, so we need to handle it.

I decided to rely on IMGIX, which helped me reduce image sizes by 90%. However, I don't want to be vendor-locked and save their URLs in the database. It's much easier to do URL conversion on the frontend by simply replacing my domain with theirs and adding some compression parameters.

Let's extend image extension config.

Image.extend({
  renderHTML({ HTMLAttributes }) {
    // get image src
    const src = HTMLAttributes.src;
	// replace domain to imgix
    const urlWithReplacedDomain = src.replace(
      'files.jotnoted.com',
      'jotnoted.imgix.net'
    );
	// I noticed errors when using compress with webp
    const compressParams = urlWithReplacedDomain.includes('webp')
      ? ''
      : '?auto=compress';
	// final url
    HTMLAttributes.src = `${urlWithReplacedDomain}${compressParams}`;
	// render image with new src
    return ['img', HTMLAttributes];
  },
})

Results