-
-
Save waptik/f44b0d3c803fade75456817b1b1df6b4 to your computer and use it in GitHub Desktop.
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; | |
}, | |
}, | |
}, | |
}); | |
}; |
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?
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
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
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,
};
},
}),
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
Just trying to be helpful for future stumblers! Thanks for the gist!
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.
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.
Hi
This worked great for adding the setImage command in the same custom image extension. I was figuring out to add a loader while the image is uploading and maybe some way to resize images. I found a package react-cropper for that but not sure how to use it with the
custom extension. https://codesandbox.io/s/wonderful-pine-i7fs3?file=/src/Demo.tsx