import React, { useCallback, useEffect, useRef } from 'react' import { mergeAttributes } from '@tiptap/core' import Image from '@tiptap/extension-image' import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' function clamp(value, min, max) { return Math.min(max, Math.max(min, value)) } function parsePixelValue(rawValue) { const normalized = String(rawValue || '').trim() if (!normalized) { return null } const parsed = Number.parseFloat(normalized.replace(/px$/i, '')) return Number.isFinite(parsed) ? Math.round(parsed) : null } function readImageAttrs(element) { const imageElement = element.tagName?.toLowerCase() === 'img' ? element : element.querySelector?.('img') const captionElement = element.querySelector?.('figcaption') return { src: imageElement?.getAttribute('src') || '', alt: imageElement?.getAttribute('alt') || '', title: imageElement?.getAttribute('title') || '', caption: captionElement?.textContent?.trim() || '', width: parsePixelValue( element.getAttribute?.('data-width') || element.getAttribute?.('width') || imageElement?.getAttribute('width') || element.style?.width || '', ), } } function RichImageNodeView({ editor, node, selected, updateAttributes, deleteNode, getPos }) { const imageRef = useRef(null) const cleanupResizeRef = useRef(null) useEffect(() => () => { if (typeof cleanupResizeRef.current === 'function') { cleanupResizeRef.current() } }, []) const selectNode = useCallback(() => { if (!editor || typeof getPos !== 'function') { return } editor.chain().focus().setNodeSelection(getPos()).run() }, [editor, getPos]) const startResize = useCallback((event) => { if (!imageRef.current || event.button !== 0) { return } event.preventDefault() event.stopPropagation() selectNode() const imageElement = imageRef.current const parentWidth = imageElement.parentElement?.getBoundingClientRect().width || imageElement.getBoundingClientRect().width || 0 const startX = event.clientX const startWidth = node.attrs.width || Math.round(imageElement.getBoundingClientRect().width) || 0 const minWidth = 180 const maxWidth = Math.max(minWidth, Math.round(parentWidth || 1280)) const handleMove = (moveEvent) => { const nextWidth = clamp(Math.round(startWidth + (moveEvent.clientX - startX)), minWidth, maxWidth) updateAttributes({ width: nextWidth }) } const handleUp = () => { window.removeEventListener('pointermove', handleMove) window.removeEventListener('pointerup', handleUp) cleanupResizeRef.current = null } cleanupResizeRef.current = handleUp window.addEventListener('pointermove', handleMove) window.addEventListener('pointerup', handleUp) }, [node.attrs.width, selectNode, updateAttributes]) const width = Number.isFinite(Number(node.attrs.width)) && Number(node.attrs.width) > 0 ? Number(node.attrs.width) : null return ( { if (event.target instanceof HTMLElement && event.target.closest('input, textarea, button, select, label')) { return } selectNode() }} >
{node.attrs.alt {selected ? ( ) : null} {selected ? ( ) : null}
{!selected && node.attrs.caption ? (
{node.attrs.caption}
) : null} {selected ? (
) : null}
) } const RichImage = Image.extend({ addOptions() { return { ...this.parent?.(), HTMLAttributes: { class: 'rich-image-node', }, } }, addAttributes() { return { ...this.parent?.(), alt: { default: '', }, title: { default: '', }, caption: { default: '', }, width: { default: null, parseHTML: (element) => parsePixelValue( element.getAttribute?.('data-width') || element.getAttribute?.('width') || element.style?.width || '', ), renderHTML: (attributes) => { const width = Number(attributes.width) if (!Number.isFinite(width) || width <= 0) { return {} } return { 'data-width': String(Math.round(width)), style: `width:${Math.round(width)}px;max-width:100%;`, } }, }, } }, parseHTML() { return [ { tag: 'figure[data-rich-image]', getAttrs: (element) => readImageAttrs(element), }, { tag: 'img[src]', getAttrs: (element) => readImageAttrs(element), }, ] }, renderHTML({ node, HTMLAttributes }) { const { src: _src, alt: _alt, title: _title, caption: _caption, width: _width, 'data-width': _dataWidth, ...figureHTMLAttributes } = HTMLAttributes const figureAttributes = mergeAttributes(this.options.HTMLAttributes, figureHTMLAttributes, { 'data-rich-image': 'true', }) const imageAttributes = { src: node.attrs.src, alt: node.attrs.alt || '', title: node.attrs.title || '', loading: 'lazy', decoding: 'async', } if (Number.isFinite(Number(node.attrs.width)) && Number(node.attrs.width) > 0) { const width = Math.round(Number(node.attrs.width)) imageAttributes.style = `width:${width}px;max-width:100%;` imageAttributes['data-width'] = String(width) } const children = [ ['img', imageAttributes], ] if (node.attrs.caption) { children.push(['figcaption', { class: 'rich-image-node__caption' }, node.attrs.caption]) } return ['figure', figureAttributes, ...children] }, addNodeView() { return ReactNodeViewRenderer(RichImageNodeView) }, }) export default RichImage