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.
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.
Add image extensions to the editor extensions list.
Create a menu that appears when the image block is selected.
Modal to upload, edit, or remove the image.
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.
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
});
}
Add image extension to the extensions list.
import Image from '@tiptap/extension-image';
import { useEditor } from '@tiptap/react'
useEditor({
// other extensions
Image
})
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>
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>
);
}
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];
},
})