import { findChildrenInRange, mergeAttributes, Node, nodeInputRule, Tracker } from '@tiptap/core' export interface FigureOptions { HTMLAttributes: Record } declare module '@tiptap/core' { interface Commands { figure: { /** * Add a figure element */ setFigure: (options: { src: string; alt?: string; title?: string; caption?: string }) => ReturnType /** * Converts an image to a figure */ imageToFigure: () => ReturnType /** * Converts a figure to an image */ figureToImage: () => ReturnType } } } export const inputRegex = /!\[(.+|:?)]\((\S+)(?:\s+["'](\S+)["'])?\)/ export const Figure = Node.create({ name: 'figure', addOptions() { return { HTMLAttributes: {} } }, group: 'block', content: 'inline*', draggable: true, isolating: true, addAttributes() { return { src: { default: null, parseHTML: (element) => element.querySelector('img')?.getAttribute('src') }, alt: { default: null, parseHTML: (element) => element.querySelector('img')?.getAttribute('alt') }, title: { default: null, parseHTML: (element) => element.querySelector('img')?.getAttribute('title') } } }, parseHTML() { return [ { tag: 'figure', contentElement: (dom: HTMLElement) => dom.querySelector('figcaption') ?? document.createElement('figcaption') } ] }, renderHTML({ HTMLAttributes }) { return [ 'figure', this.options.HTMLAttributes, ['img', mergeAttributes(HTMLAttributes, { draggable: false, contenteditable: false })], ['figcaption', 0] ] }, addCommands() { return { setFigure: ({ caption, ...attrs }) => ({ chain }) => { return ( chain() .insertContent({ type: this.name, attrs, content: caption ? [{ type: 'text', text: caption }] : [] }) // set cursor at end of caption field .command(({ tr, commands }) => { const { doc, selection } = tr const position = doc.resolve(selection.to - 2).end() return commands.setTextSelection(position) }) .run() ) }, imageToFigure: () => // eslint-disable-next-line unicorn/consistent-function-scoping ({ tr, commands }) => { const { doc, selection } = tr const { from, to } = selection const images = findChildrenInRange(doc, { from, to }, (node) => node.type.name === 'image') if (images.length === 0) { return false } const tracker = new Tracker(tr) return commands.forEach( // eslint-disable-next-line unicorn/no-array-callback-reference images, // eslint-disable-next-line unicorn/no-array-method-this-argument ({ node, pos }) => { // eslint-disable-next-line unicorn/no-array-callback-reference const mapResult = tracker.map(pos) if (mapResult.deleted) { return false } const range = { from: mapResult.position, to: mapResult.position + node.nodeSize } return commands.insertContentAt(range, { type: this.name, attrs: { src: node.attrs.src }, content: [{ type: 'text', text: node.attrs.src }] }) } ) }, figureToImage: () => // eslint-disable-next-line unicorn/consistent-function-scoping ({ tr, commands }) => { const { doc, selection } = tr const { from, to } = selection const figures = findChildrenInRange(doc, { from, to }, (node) => node.type.name === this.name) if (figures.length === 0) { return false } const tracker = new Tracker(tr) return commands.forEach( // eslint-disable-next-line unicorn/no-array-callback-reference figures, // eslint-disable-next-line unicorn/no-array-method-this-argument ({ node, pos }) => { // eslint-disable-next-line unicorn/no-array-callback-reference const mapResult = tracker.map(pos) if (mapResult.deleted) { return false } const range = { from: mapResult.position, to: mapResult.position + node.nodeSize } return commands.insertContentAt(range, { type: 'image', attrs: { src: node.attrs.src } }) } ) } } }, addInputRules() { return [ nodeInputRule({ find: inputRegex, type: this.type, getAttributes: (match) => { const [, src, alt, title] = match return { src, alt, title } } }) ] } })