Skip to content

Instantly share code, notes, and snippets.

@slava-vishnyakov
Last active May 7, 2024 04:49
Show Gist options
  • Save slava-vishnyakov/16076dff1a77ddaca93c4bccd4ec4521 to your computer and use it in GitHub Desktop.
Save slava-vishnyakov/16076dff1a77ddaca93c4bccd4ec4521 to your computer and use it in GitHub Desktop.
How to upload images with TipTap editor
  1. Create a file Image.js from the source below (it is almost a copy of Image.js from tiptap-extensions except that it has a constructor that accepts uploadFunc (function to be called with image being uploaded) and additional logic if(upload) { ... } else { ... previous base64 logic .. } in the new Plugin section.
import {Node, Plugin} from 'tiptap'
import {nodeInputRule} from 'tiptap-commands'

/**
 * Matches following attributes in Markdown-typed image: [, alt, src, title]
 *
 * Example:
 * ![Lorem](image.jpg) -> [, "Lorem", "image.jpg"]
 * ![](image.jpg "Ipsum") -> [, "", "image.jpg", "Ipsum"]
 * ![Lorem](image.jpg "Ipsum") -> [, "Lorem", "image.jpg", "Ipsum"]
 */
const IMAGE_INPUT_REGEX = /!\[(.+|:?)\]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;

export default class Image extends Node {

    constructor(name, parent, uploadFunc = null) {
        super(name, parent);
        this.uploadFunc = uploadFunc;
    }

    get name() {
        return 'image'
    }

    get schema() {
        return {
            inline: true,
            attrs: {
                src: {},
                alt: {
                    default: null,
                },
                title: {
                    default: null,
                },
            },
            group: 'inline',
            draggable: true,
            parseDOM: [
                {
                    tag: 'img[src]',
                    getAttrs: dom => ({
                        src: dom.getAttribute('src'),
                        title: dom.getAttribute('title'),
                        alt: dom.getAttribute('alt'),
                    }),
                },
            ],
            toDOM: node => ['img', node.attrs],
        }
    }

    commands({ type }) {
        return attrs => (state, dispatch) => {
            const { selection } = state;
            const position = selection.$cursor ? selection.$cursor.pos : selection.$to.pos;
            const node = type.create(attrs);
            const transaction = state.tr.insert(position, node);
            dispatch(transaction)
        }
    }

    inputRules({ type }) {
        return [
            nodeInputRule(IMAGE_INPUT_REGEX, type, match => {
                const [, alt, src, title] = match;
                return {
                    src,
                    alt,
                    title,
                }
            }),
        ]
    }

    get plugins() {
        const upload = this.uploadFunc;
        return [
            new Plugin({
                props: {
                    handleDOMEvents: {
                        drop(view, event) {
                            const hasFiles = event.dataTransfer
                                && event.dataTransfer.files
                                && event.dataTransfer.files.length;

                            if (!hasFiles) {
                                return
                            }

                            const images = Array
                                .from(event.dataTransfer.files)
                                .filter(file => (/image/i).test(file.type));

                            if (images.length === 0) {
                                return
                            }

                            event.preventDefault();

                            const { schema } = view.state;
                            const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });

                            images.forEach(async image => {
                                const reader = new FileReader();

                                if(upload) {
                                    const node = schema.nodes.image.create({
                                        src: await upload(image),
                                    });
                                    const transaction = view.state.tr.insert(coordinates.pos, node);
                                    view.dispatch(transaction)
                                } else {
                                    reader.onload = readerEvent => {
                                        const node = schema.nodes.image.create({
                                            src: readerEvent.target.result,
                                        });
                                        const transaction = view.state.tr.insert(coordinates.pos, node);
                                        view.dispatch(transaction)
                                    };
                                    reader.readAsDataURL(image)
                                }
                            })
                        },
                    },
                },
            }),
        ]
    }

}
  1. Import it:
import Image from './Image';

async function upload(file) {
...
}

new Editor({
  extensions: [
    ...
    new Image(null, null, upload),
    ...
  1. Implement the upload function:
async function upload(file) {
  let formData = new FormData();
  formData.append('file', file);
  const headers = {'Content-Type': 'multipart/form-data'};
  const response = await axios.post('/upload', formData, {headers: headers} );
  return response.data.src;
},

This POSTs using axios to /upload and expects a JSON back of this form:

{"src": "https://yoursite.com/images/uploadedimage.jpg"}
  1. Implement server-side logic for /upload

  2. If you want to support pasting of images, modify Image.js starting at props:, ending at handleDOMEvents (re-factor common parts if you want to)

props: {
    handlePaste(view, event, slice) {
        const items = (event.clipboardData  || event.originalEvent.clipboardData).items;
        for (const item of items) {
            if (item.type.indexOf("image") === 0) {
                event.preventDefault();
                const { schema } = view.state;

                const image = item.getAsFile();

                if(upload) {
                    upload(image).then(src => {
                        const node = schema.nodes.image.create({
                            src: src,
                        });
                        const transaction = view.state.tr.replaceSelectionWith(node);
                        view.dispatch(transaction)
                    });

                } else {
                    const reader = new FileReader();
                    reader.onload = readerEvent => {
                        const node = schema.nodes.image.create({
                            src: readerEvent.target.result,
                        });
                        const transaction = view.state.tr.replaceSelectionWith(node);
                        view.dispatch(transaction)
                    };
                    reader.readAsDataURL(image)
                }

            }
        }
        return false;
    },
    handleDOMEvents: {
@xiangshu233
Copy link

@dan-cooke That's great, but I would like to ask if there is a vue version, thanks

@r614
Copy link

r614 commented Apr 1, 2024

@dan-cooke thanks for the snippet, happy to help with the extension if needed

@optimbro
Copy link

optimbro commented Apr 9, 2024

Heres a full extension that has the following features

  1. Uses data url immediately to render the image while uploading for faster UX
  2. Uploads the image to your API in the background via custom react component
  3. Swaps the data-url image for your uploaded URL once its ready

I will be refining this and adding a tonne of features to it like resizing and cropping, will release as open source when its ready

import { useFilesControllerUpload } from '@templi/sdk';
import { mergeAttributes, Node } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import { useEffect, useState } from 'react';

export interface ImageOptions {
  HTMLAttributes: Record<string, any>;
}

const blobToBase64 = async (blob: Blob): Promise<string> => {
  return await new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.readAsDataURL(blob);
    reader.onload = () => resolve(reader.result);
    reader.onerror = (error) => reject(error);
  });
};
declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    image: {
      /**
       * Add an image
       */
      setImage: (options: {
        src?: string;
        alt?: string;
        title?: string;
      }) => ReturnType;
    };
  }
}

type ImageComponentProps = {
  node: {
    attrs: {
      src: string;
    };
  };
};
function ImageComponent(props: ImageComponentProps) {
  const { node } = props;

  // swap this for your API file upload code
  const { mutateAsync: upload, submittedAt } = useFilesControllerUpload();

  const [src, setSrc] = useState<string | undefined>(node?.attrs?.src);

  useEffect(() => {
    if (node.attrs.src.startsWith('data:') && !submittedAt) {
      async function uploadImage() {
        const formData = new FormData();
        const base64 = node.attrs.src.split(',')[1];
        const file = window.atob(base64);

        formData.set(
          'file',
          new Blob([file], { type: 'image/png' }),
          'image.png'
        );

        const uploadedFile = await upload({
          body: formData,
          headers: {
            'Content-Type': 'multipart/form-data',
          },
        });

        if (!uploadedFile) return;
        setSrc(uploadedFile.url);
      }

      uploadImage();
    }
  }, [node.attrs.src]);

  return (
    <NodeViewWrapper className="w-full">
      <img src={src} className="w-full" />
    </NodeViewWrapper>
  );
}

export const Image = Node.create<ImageOptions>({
  name: 'image',

  addOptions() {
    return {
      inline: false,
      allowBase64: false,
      HTMLAttributes: {},
    };
  },

  inline: false,
  group: 'block',

  draggable: true,

  addAttributes() {
    return {
      src: {
        default: null,
      },
      alt: {
        default: null,
      },
      title: {
        default: null,
      },
    };
  },

  parseHTML() {
    return [
      {
        tag: 'img[src]:not([src^="data:"])',
      },
    ];
  },

  renderHTML({ HTMLAttributes }) {
    return [
      'img',
      mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
    ];
  },

  addNodeView() {
    return ReactNodeViewRenderer(ImageComponent);
  },

  addCommands() {
    return {
      setImage:
        (options) =>
          ({ commands }) => {
            return commands.insertContent({
              type: this.name,
              attrs: options,
            });
          },
    };
  },

  addProseMirrorPlugins() {
    return [
      new Plugin({
        key: new PluginKey('imageDrop'),
        props: {
          handleDOMEvents: {
            drop: async (view, event) => {
              if (event?.dataTransfer?.files) {
                const files = event.dataTransfer.files;
                const file = files.item(0);

                if (file && file.type.includes('image')) {
                  const dataUrl = await blobToBase64(file);
                  return this.editor.chain().setImage({ src: dataUrl }).run();
                }
              }
              return false;
            },
          },
        },
      }),
    ];
  },
});

thank you so much man!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment