import type { ResizableNodeViewDirection, ResizableNodeViewOptions } from '@tiptap/core' import { getRenderedAttributes, mergeAttributes, Node, nodeInputRule, ResizableNodeView, } from '@tiptap/core' export interface ImageOptions { /** * Controls if the image node should be inline or not. * @default false * @example true */ inline: boolean /** * Controls if base64 images are allowed. Enable this if you want to allow * base64 image urls in the `src` attribute. * @default false * @example true */ allowBase64: boolean /** * HTML attributes to add to the image element. * @default {} * @example { class: 'foo' } */ HTMLAttributes: Record /** * Controls if the image should be resizable and how the resize is configured. * @default false * @example { directions: { top: true, right: true, bottom: true, left: true, topLeft: true, topRight: true, bottomLeft: true, bottomRight: true }, minWidth: 100, minHeight: 100 } */ resize: | { enabled: boolean directions?: ResizableNodeViewDirection[] minWidth?: number minHeight?: number alwaysPreserveAspectRatio?: boolean } | false } export interface SetImageOptions { src: string alt?: string title?: string width?: number height?: number } declare module '@tiptap/core' { interface Commands { image: { /** * Add an image * @param options The image attributes * @example * editor * .commands * .setImage({ src: 'https://tiptap.dev/logo.png', alt: 'tiptap', title: 'tiptap logo' }) */ setImage: (options: SetImageOptions) => ReturnType } } } /** * Matches an image to a ![image](src "title") on input. */ export const inputRegex = /(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/ /** * This extension allows you to insert images. * @see https://www.tiptap.dev/api/nodes/image */ export const Image = Node.create({ name: 'image', addOptions() { return { inline: false, allowBase64: false, HTMLAttributes: {}, resize: false, } }, 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, }, width: { default: null, }, height: { default: null, }, } }, parseHTML() { return [ { tag: this.options.allowBase64 ? 'img[src]' : 'img[src]:not([src^="data:"])', }, ] }, renderHTML({ HTMLAttributes }) { return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)] }, parseMarkdown: (token, helpers) => { return helpers.createNode('image', { src: token.href, title: token.title, alt: token.text, }) }, renderMarkdown: node => { const src = node.attrs?.src ?? '' const alt = node.attrs?.alt ?? '' const title = node.attrs?.title ?? '' return title ? `![${alt}](${src} "${title}")` : `![${alt}](${src})` }, addNodeView() { if (!this.options.resize || !this.options.resize.enabled || typeof document === 'undefined') { return null } const { directions, minWidth, minHeight, alwaysPreserveAspectRatio } = this.options.resize const resizeManagedAttributes = new Set(['src', 'width', 'height']) return ({ node, getPos, HTMLAttributes, editor }) => { const el = document.createElement('img') el.draggable = false const mergedAttributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes) Object.entries(mergedAttributes).forEach(([key, value]) => { if (value != null) { switch (key) { case 'src': case 'width': case 'height': break default: el.setAttribute(key, value) break } } }) if (mergedAttributes.src !== null) { el.src = mergedAttributes.src } let previousHTMLAttributes = { ...HTMLAttributes } const syncImageSource = (src: unknown) => { if (typeof src === 'string' && src !== '') { if (el.getAttribute('src') !== src) { el.src = src } return } if (el.hasAttribute('src')) { el.removeAttribute('src') } if (el.src !== '') { el.src = '' } } syncImageSource(HTMLAttributes.src) const onUpdate: ResizableNodeViewOptions['onUpdate'] = ( updatedNode: Parameters[0], ) => { if (updatedNode.type !== node.type) { return false } const extensionAttributes = editor.extensionManager.attributes.filter( attribute => attribute.type === updatedNode.type.name, ) const newHTMLAttributes = getRenderedAttributes(updatedNode, extensionAttributes) // Remove attributes that were previously rendered but are no longer present Object.keys(previousHTMLAttributes).forEach(key => { if (!resizeManagedAttributes.has(key) && !(key in newHTMLAttributes)) { el.removeAttribute(key) } }) Object.entries(newHTMLAttributes).forEach(([key, value]) => { if (resizeManagedAttributes.has(key)) { return } if (value != null) { el.setAttribute(key, value) } else { el.removeAttribute(key) } }) syncImageSource(newHTMLAttributes.src) previousHTMLAttributes = newHTMLAttributes return true } const nodeView = new ResizableNodeView({ element: el, editor, node, getPos, onResize: (width, height) => { el.style.width = `${width}px` el.style.height = `${height}px` }, onCommit: (width, height) => { const pos = getPos() if (pos === undefined) { return } this.editor .chain() .setNodeSelection(pos) .updateAttributes(this.name, { width, height, }) .run() }, onUpdate, options: { directions, min: { width: minWidth, height: minHeight, }, preserveAspectRatio: alwaysPreserveAspectRatio === true, }, }) const dom = nodeView.dom as HTMLElement // when image is loaded, show the node view to get the correct dimensions const showNodeView = () => { dom.style.visibility = '' dom.style.pointerEvents = '' } dom.style.visibility = 'hidden' dom.style.pointerEvents = 'none' if (el.complete && el.naturalWidth > 0) { // A cached image is already complete before this node view mounts, so // `load` never fires again and the image would stay hidden forever. showNodeView() } else { el.onload = showNodeView // Reveal on error too, so a broken src renders as an inspectable broken // image instead of an invisible node. el.onerror = showNodeView } return nodeView } }, addCommands() { return { setImage: options => ({ commands }) => { return commands.insertContent({ type: this.name, attrs: options, }) }, } }, addInputRules() { return [ nodeInputRule({ find: inputRegex, type: this.type, getAttributes: match => { const [, , alt, src, title] = match return { src, alt, title } }, }), ] }, })