Skip to content

Instantly share code, notes, and snippets.

@waptik
Created July 12, 2021 16:48
Show Gist options
  • Save waptik/f44b0d3c803fade75456817b1b1df6b4 to your computer and use it in GitHub Desktop.
Save waptik/f44b0d3c803fade75456817b1b1df6b4 to your computer and use it in GitHub Desktop.
A custom extension for tiptap v2 to for image upload
import { Node, nodeInputRule } from "@tiptap/core";
import { mergeAttributes } from "@tiptap/react";
import { uploadImagePlugin, UploadFn } from "./upload_image";
/**
* Tiptap Extension to upload images
* @see https://gist.github.com/slava-vishnyakov/16076dff1a77ddaca93c4bccd4ec4521#gistcomment-3744392
* @since 7th July 2021
*
* 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"]
*/
interface ImageOptions {
inline: boolean;
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
image: {
/**
* Add an image
*/
setImage: (options: { src: string; alt?: string; title?: string }) => ReturnType;
};
}
}
const IMAGE_INPUT_REGEX = /!\[(.+|:?)\]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
export const TipTapCustomImage = (uploadFn: UploadFn) => {
return Node.create<ImageOptions>({
name: "image",
defaultOptions: {
inline: false,
HTMLAttributes: {},
},
inline() {
return this.options.inline;
},
group() {
return this.options.inline ? "inline" : "block";
},
draggable: true,
addAttributes() {
return {
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
};
},
parseHTML: () => [
{
tag: "img[src]",
getAttrs: dom => {
if (typeof dom === "string") return {};
const element = dom as HTMLImageElement;
const obj = {
src: element.getAttribute("src"),
title: element.getAttribute("title"),
alt: element.getAttribute("alt"),
};
return obj;
},
},
],
renderHTML: ({ HTMLAttributes }) => ["img", mergeAttributes(HTMLAttributes)],
addCommands() {
return {
setImage:
attrs =>
({ state, dispatch }) => {
const { selection } = state;
const position = selection.$head ? selection.$head.pos : selection.$to.pos;
const node = this.type.create(attrs);
const transaction = state.tr.insert(position, node);
return dispatch?.(transaction);
},
};
},
addInputRules() {
return [
nodeInputRule(IMAGE_INPUT_REGEX, this.type, match => {
const [, alt, src, title] = match;
return {
src,
alt,
title,
};
}),
];
},
addProseMirrorPlugins() {
return [uploadImagePlugin(uploadFn)];
},
});
};
import { Plugin } from "prosemirror-state";
/**
* function for image drag n drop(for tiptap)
* @see https://gist.github.com/slava-vishnyakov/16076dff1a77ddaca93c4bccd4ec4521#gistcomment-3744392
*/
export type UploadFn = (image: File) => Promise<string>;
export const uploadImagePlugin = (upload: UploadFn) => {
return new Plugin({
props: {
handlePaste(view, event) {
console.log("----onhandlePaste image---");
const items = Array.from(event.clipboardData?.items || []);
const { schema } = view.state;
console.log({ items });
items.forEach(item => {
const image = item.getAsFile();
console.log({ image, item });
if (item.type.indexOf("image") === 0) {
console.log("item is an image");
event.preventDefault();
if (upload && image) {
upload(image).then(src => {
const node = schema.nodes.image.create({
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);
};
if (!image) return;
reader.readAsDataURL(image);
}
});
return false;
},
handleDOMEvents: {
drop(view, event) {
console.log("----handleDom.onDrop----");
const hasFiles = event.dataTransfer?.files?.length;
if (!hasFiles) {
return false;
}
const images = Array.from(event!.dataTransfer!.files).filter(file => /image/i.test(file.type));
if (images.length === 0) {
return false;
}
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);
}
});
return false;
},
},
},
});
};
@waptik
Copy link
Author

waptik commented Jul 30, 2022

Hey guys, i wrote a demo example some time ago and forgot to link it up here. Here' the component in which it's being used https://github.com/waptik/tiptap-react/blob/main/src/components/editor/RichTextEditor.tsx

@waptik
Copy link
Author

waptik commented Jul 30, 2022

Hi

Great work there converting it from Vue.js to React there. I am kinda new in this Tiptap world, how do you use this TipTapCustomImage, do you just pass it into the extension array itself or what is it?

Yes, you pass it to the extensions array. As you can see in https://github.com/waptik/tiptap-react/blob/main/src/components/editor/extensions/index.ts#L37, I conditionally add it to the array depending on the type of editor I'm building. See how it's being used

@jqhoogland
Copy link

In order to get this working, I needed to update the nodeInputRule in index@103:

nodeInputRule({
  find: IMAGE_INPUT_REGEX,
  type: this.type,
  getAttributes: (match) => {
    const [, alt, src, title] = match;
    return {
      src,
      alt,
      title,
    };
  },
}),

@waptik
Copy link
Author

waptik commented Aug 12, 2022

IMAGE_INPUT_REGEX

Ah, good for you then.
You might have been using the latest version of tiptap because the it works with the version i am using

@jqhoogland
Copy link

Just trying to be helpful for future stumblers! Thanks for the gist!

@varun-raj
Copy link

Is there any ways we can add a loader while when the image is getting uploaded and then replace the loader with the actual image once the upload is done? this way we can give more context to the users.

@cesc1989
Copy link

cesc1989 commented Jan 6, 2024

For Tiptap version 2.1.3 I had to made the change commented by @jqhoogland for the nodeInputRule. Otherwise the editor breaks when trying to type.

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