1

Add image extension

Configure image extension with your styling. The imageClass is used for styling the placeholder image.

//extensions.ts
import { UploadImagesPlugin } from "novel/plugins";

const tiptapImage = TiptapImage.extend({
    addProseMirrorPlugins() {
        return [
            UploadImagesPlugin({
                imageClass: cx("opacity-40 rounded-lg border border-stone-200"),
            }),
        ];
    },
    }).configure({
    allowBase64: true,
    HTMLAttributes: {
        class: cx("rounded-lg border border-muted"),
    },
});

export const defaultExtensions = [
    tiptapImage,
    //other extensions
];

//editor.tsx
const Editor = () => {
    return <EditorContent extensions={defaultExtensions} />
}

2

Create upload function

onUpload should return a Promise<string> validateFn is triggered before an image is uploaded. It should return a boolean value.

image-upload.ts
import { createImageUpload } from "novel/plugins";
import { toast } from "sonner";

const onUpload = async (file: File) => {
    const promise = fetch("/api/upload", {
        method: "POST",
        headers: {
        "content-type": file?.type || "application/octet-stream",
        "x-vercel-filename": file?.name || "image.png",
        },
        body: file,
    });

    //This should return a src of the uploaded image
    return promise;
};

export const uploadFn = createImageUpload({
    onUpload,
    validateFn: (file) => {
        if (!file.type.includes("image/")) {
            toast.error("File type not supported.");
            return false;
        } else if (file.size / 1024 / 1024 > 20) {
            toast.error("File size too big (max 20MB).");
            return false;
        }
        return true;
    },
});

3

Configure events callbacks

This is required to handle image paste and drop events in the editor.

editor.tsx
import { handleImageDrop, handleImagePaste } from "novel/plugins";
import { uploadFn } from "./image-upload";

...
<EditorContent
        editorProps={{
            handlePaste: (view, event) => handleImagePaste(view, event, uploadFn),
            handleDrop: (view, event, _slice, moved) =>  handleImageDrop(view, event, moved, uploadFn),
            ...
        }}
/>
...
4

Update slash-command suggestionsItems

import { ImageIcon } from "lucide-react";
import { createSuggestionItems } from "novel/extensions";
import { uploadFn } from "./image-upload";

export const suggestionItems = createSuggestionItems([
    ...,
    {
        title: "Image",
        description: "Upload an image from your computer.",
        searchTerms: ["photo", "picture", "media"],
        icon: <ImageIcon size={18} />,
        command: ({ editor, range }) => {
            editor.chain().focus().deleteRange(range).run();
            // upload image
            const input = document.createElement("input");
            input.type = "file";
            input.accept = "image/*";
            input.onchange = async () => {
                if (input.files?.length) {
                const file = input.files[0];
                const pos = editor.view.state.selection.from;
                uploadFn(file, editor.view, pos);
                }
            };
            input.click();
        },
    }
 ])