UNPKG

tiptap-extension-resize-image

Version:

A tiptap image resizing extension for React, Vue, Next, and VanillaJS. Additionally, it can align the image position.

1,030 lines (1,014 loc) 43.5 kB
import Image from '@tiptap/extension-image'; import { mergeAttributes, Node, isNodeEmpty } from '@tiptap/core'; import { NodeSelection, Plugin, PluginKey } from '@tiptap/pm/state'; import { DOMSerializer } from '@tiptap/pm/model'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; const CONSTANTS = { MOBILE_BREAKPOINT: 768, ICON_SIZE: '24px', CONTROLLER_HEIGHT: '25px', DOT_SIZE: { MOBILE: 16, DESKTOP: 9, }, DOT_POSITION: { MOBILE: '-8px', DESKTOP: '-4px', }, COLORS: { BORDER: '#6C6C6C', BACKGROUND: 'rgba(255, 255, 255, 1)', }, // Inlined Material Symbols Outlined alignment icons (20px) as base64 data URLs. // Avoids runtime fetch from fonts.gstatic.com so the extension works under // strict CSP (img-src 'self'), in offline/blocked networks, and without // leaking the user's IP/Referer to a third-party CDN on every editor session. ICONS: { LEFT: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgLTk2MCA5NjAgOTYwIiB3aWR0aD0iMjAiPjxwYXRoIGQ9Ik0xNDQtMTQ0di03Mmg2NzJ2NzJIMTQ0Wm0wLTE1MHYtNzJoNDgwdjcySDE0NFptMC0xNTB2LTcyaDY3MnY3MkgxNDRabTAtMTUwdi03Mmg0ODB2NzJIMTQ0Wm0wLTE1MHYtNzJoNjcydjcySDE0NFoiLz48L3N2Zz4=', CENTER: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgLTk2MCA5NjAgOTYwIiB3aWR0aD0iMjAiPjxwYXRoIGQ9Ik0xNDQtMTQ0di03Mmg2NzJ2NzJIMTQ0Wm0xNDQtMTUwdi03MmgzODR2NzJIMjg4Wk0xNDQtNDQ0di03Mmg2NzJ2NzJIMTQ0Wm0xNDQtMTUwdi03MmgzODR2NzJIMjg4Wk0xNDQtNzQ0di03Mmg2NzJ2NzJIMTQ0WiIvPjwvc3ZnPg==', RIGHT: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgLTk2MCA5NjAgOTYwIiB3aWR0aD0iMjAiPjxwYXRoIGQ9Ik0xNDQtNzQ0di03Mmg2NzJ2NzJIMTQ0Wm0xOTIgMTUwdi03Mmg0ODB2NzJIMzM2Wk0xNDQtNDQ0di03Mmg2NzJ2NzJIMTQ0Wm0xOTIgMTUwdi03Mmg0ODB2NzJIMzM2Wk0xNDQtMTQ0di03Mmg2NzJ2NzJIMTQ0WiIvPjwvc3ZnPg==', }, }; const utils = { isMobile() { return document.documentElement.clientWidth < CONSTANTS.MOBILE_BREAKPOINT; }, getDotPosition() { return utils.isMobile() ? CONSTANTS.DOT_POSITION.MOBILE : CONSTANTS.DOT_POSITION.DESKTOP; }, getDotSize() { return utils.isMobile() ? CONSTANTS.DOT_SIZE.MOBILE : CONSTANTS.DOT_SIZE.DESKTOP; }, clearContainerBorder(container) { const containerStyle = container.getAttribute('style'); const newStyle = containerStyle === null || containerStyle === void 0 ? void 0 : containerStyle.replace('border: 1px dashed #6C6C6C;', '').replace('border: 1px dashed rgb(108, 108, 108)', ''); container.setAttribute('style', newStyle); }, removeResizeElements(container) { container .querySelectorAll('[data-resize-image-ui]') .forEach((element) => element.remove()); }, }; class StyleManager { static getContainerStyle(inline, width) { const baseStyle = `width: ${width || '100%'}; height: auto; cursor: pointer;`; const inlineStyle = inline ? 'display: inline-block;' : ''; return `${baseStyle} ${inlineStyle}`; } static getWrapperStyle(inline) { return inline ? 'display: inline-block; float: left; padding-right: 8px;' : 'display: flex; margin: 0;'; } static getPositionControllerStyle(inline) { const width = inline ? '66px' : '100px'; return ` position: absolute; top: 0%; left: 50%; width: ${width}; height: ${CONSTANTS.CONTROLLER_HEIGHT}; z-index: 999; background-color: ${CONSTANTS.COLORS.BACKGROUND}; border-radius: 3px; border: 1px solid ${CONSTANTS.COLORS.BORDER}; cursor: pointer; transform: translate(-50%, -50%); display: flex; justify-content: space-between; align-items: center; padding: 0 6px; ` .replace(/\s+/g, ' ') .trim(); } static getDotStyle(index) { const dotPosition = utils.getDotPosition(); const dotSize = utils.getDotSize(); const positions = [ `top: ${dotPosition}; left: ${dotPosition}; cursor: nwse-resize;`, `top: ${dotPosition}; right: ${dotPosition}; cursor: nesw-resize;`, `bottom: ${dotPosition}; left: ${dotPosition}; cursor: nesw-resize;`, `bottom: ${dotPosition}; right: ${dotPosition}; cursor: nwse-resize;`, ]; return ` position: absolute; width: ${dotSize}px; height: ${dotSize}px; border: 1.5px solid ${CONSTANTS.COLORS.BORDER}; border-radius: 50%; ${positions[index]} ` .replace(/\s+/g, ' ') .trim(); } } /** * Whitelist-based CSS style sanitizer. * * The image node persists its layout state in the `containerStyle` and * `wrapperStyle` attributes as raw CSS text. Without sanitization a crafted * document could embed arbitrary CSS (e.g. `position: fixed; top: 0; ...`, * `background: url(...)`, expressions, etc.) that would survive load and be * re-applied on every render, enabling UI redress, data exfiltration through * CSS, and tracking via remote URLs. * * This sanitizer accepts only the small set of properties the extension * actually uses for sizing/positioning and validates each value against * conservative patterns. Unknown properties, function calls (url(), * expression(), calc(), var(), attr(), ...), and unsafe characters are * stripped silently. */ const DIMENSION_TOKEN = /^-?\d+(?:\.\d+)?(?:px|em|rem|%|vw|vh)$|^auto$|^0$/i; const isDimensionValue = (value) => { // Accept single tokens or shorthand sequences like "0 auto" / "10px 0 0 5px" return value .trim() .split(/\s+/) .every((token) => DIMENSION_TOKEN.test(token)); }; const isOneOf = (allowed) => (value) => allowed.includes(value.trim().toLowerCase()); const ALLOWED_PROPERTIES = { width: isDimensionValue, height: isDimensionValue, 'min-width': isDimensionValue, 'max-width': isDimensionValue, 'min-height': isDimensionValue, 'max-height': isDimensionValue, display: isOneOf(['block', 'inline-block', 'inline', 'flex', 'none', 'contents']), float: isOneOf(['left', 'right', 'none']), 'text-align': isOneOf(['left', 'center', 'right', 'justify']), margin: isDimensionValue, 'margin-top': isDimensionValue, 'margin-right': isDimensionValue, 'margin-bottom': isDimensionValue, 'margin-left': isDimensionValue, padding: isDimensionValue, 'padding-top': isDimensionValue, 'padding-right': isDimensionValue, 'padding-bottom': isDimensionValue, 'padding-left': isDimensionValue, cursor: isOneOf([ 'pointer', 'default', 'auto', 'move', 'text', 'nwse-resize', 'nesw-resize', 'ew-resize', 'ns-resize', ]), }; // Reject any value containing characters that enable CSS escapes, function // calls, or rule injection. This is checked before per-property validation // as a coarse first pass. const UNSAFE_VALUE = /[<>{}\\\(\)`@]|\/\*|\*\//; /** * Returns a sanitized CSS declaration string containing only whitelisted * properties and validated values. Order is preserved; the output is * normalized as `prop: value;` declarations separated by single spaces. */ function sanitizeStyle(input) { if (!input) return ''; const declarations = []; const seen = new Set(); for (const rawDecl of input.split(';')) { const colon = rawDecl.indexOf(':'); if (colon === -1) continue; const property = rawDecl.slice(0, colon).trim().toLowerCase(); const value = rawDecl.slice(colon + 1).trim(); if (!property || !value) continue; if (seen.has(property)) continue; if (UNSAFE_VALUE.test(value)) continue; // CSS hacks like `!important` would not be useful here and could be // abused to override sibling rules; strip them entirely. if (/!important/i.test(value)) continue; const validate = ALLOWED_PROPERTIES[property]; if (!validate || !validate(value)) continue; declarations.push(`${property}: ${value};`); seen.add(property); } return declarations.join(' '); } class AttributeParser { static parseImageAttributes(nodeAttrs, imgElement) { Object.entries(nodeAttrs).forEach(([key, value]) => { if (value === undefined || value === null || key === 'wrapperStyle') return; if (key === 'containerStyle') { const width = value.match(/width:\s*([0-9.]+)px/); if (width) { imgElement.setAttribute('width', width[1]); } return; } imgElement.setAttribute(key, value); }); } static extractWidthFromStyle(style) { const width = style.match(/width:\s*([0-9.]+)px/); return width ? width[1] : null; } } /** * Clamps a width value to the given resize limits. * Always enforces a minimum of 0 to prevent invalid negative CSS values. */ function clampWidth(width, limits) { const { minWidth, maxWidth } = limits; const absoluteMin = minWidth !== undefined ? Math.max(0, minWidth) : 0; let clampedWidth = Math.max(absoluteMin, width); if (maxWidth !== undefined && clampedWidth > maxWidth) { clampedWidth = maxWidth; } return clampedWidth; } /** * Shared `document` click dispatcher. * * Every editable ImageNodeView used to attach its own `click` listener to * `document` so it could detect clicks outside the image and dismiss the * resize UI. With many images on a page this scaled to N listeners and made * every click in the document fan out to N handlers. * * This module keeps a single `document` listener regardless of how many * NodeViews subscribe. Subscribers are dispatched in insertion order; one * subscriber throwing does not stop the rest. */ const subscribers = new Set(); let attached = false; let attachedDoc = null; const dispatch = (event) => { // Snapshot to allow a subscriber to unsubscribe itself during dispatch // without skipping siblings. const snapshot = Array.from(subscribers); for (const listener of snapshot) { try { listener(event); } catch (_a) { // Swallow per-subscriber errors so a buggy NodeView cannot block the // rest of the page from receiving outside-click handling. } } }; const ensureAttached = () => { if (attached) return; if (typeof document === 'undefined') return; attachedDoc = document; attachedDoc.addEventListener('click', dispatch); attached = true; }; const detachIfEmpty = () => { if (!attached) return; if (subscribers.size > 0) return; attachedDoc === null || attachedDoc === void 0 ? void 0 : attachedDoc.removeEventListener('click', dispatch); attached = false; attachedDoc = null; }; /** * Registers a callback that will be invoked for every `click` event on * `document`. Returns an idempotent `unsubscribe` function that the caller * MUST invoke (typically from a NodeView `destroy` hook) to avoid leaks. */ function subscribeDocumentClick(listener) { subscribers.add(listener); ensureAttached(); let active = true; return () => { if (!active) return; active = false; subscribers.delete(listener); detachIfEmpty(); }; } class PositionController { constructor(elements, inline, dispatchNodeView) { this.elements = elements; this.inline = inline; this.dispatchNodeView = dispatchNodeView; } createControllerIcon(src) { const controller = document.createElement('img'); controller.setAttribute('src', src); controller.setAttribute('style', `width: ${CONSTANTS.ICON_SIZE}; height: ${CONSTANTS.ICON_SIZE}; cursor: pointer;`); controller.addEventListener('mouseover', (e) => { e.target.style.opacity = '0.6'; }); controller.addEventListener('mouseout', (e) => { e.target.style.opacity = '1'; }); return controller; } // Setting individual style properties via the CSSStyleDeclaration API lets // the browser validate each value, and avoids the unbounded cssText growth // (and CSS injection surface) that arose from concatenating style strings. handleLeftClick() { if (!this.inline) { this.elements.container.style.margin = '0 auto 0 0'; } else { this.applyInlineFloat('left'); } this.dispatchNodeView(); } handleCenterClick() { this.elements.container.style.margin = '0 auto'; this.dispatchNodeView(); } handleRightClick() { if (!this.inline) { this.elements.container.style.margin = '0 0 0 auto'; } else { this.applyInlineFloat('right'); } this.dispatchNodeView(); } applyInlineFloat(side) { for (const el of [this.elements.wrapper, this.elements.container]) { el.style.display = 'inline-block'; el.style.float = side; el.style.paddingLeft = side === 'right' ? '8px' : ''; el.style.paddingRight = side === 'left' ? '8px' : ''; } } createPositionControls() { const controller = document.createElement('div'); controller.dataset.resizeImageUi = 'position-controller'; controller.setAttribute('style', StyleManager.getPositionControllerStyle(this.inline)); const leftController = this.createControllerIcon(CONSTANTS.ICONS.LEFT); leftController.addEventListener('click', () => this.handleLeftClick()); controller.appendChild(leftController); if (!this.inline) { const centerController = this.createControllerIcon(CONSTANTS.ICONS.CENTER); centerController.addEventListener('click', () => this.handleCenterClick()); controller.appendChild(centerController); } const rightController = this.createControllerIcon(CONSTANTS.ICONS.RIGHT); rightController.addEventListener('click', () => this.handleRightClick()); controller.appendChild(rightController); this.elements.container.appendChild(controller); return this; } } class ResizeController { constructor(elements, dispatchNodeView, resizeLimits = {}) { this.state = { isResizing: false, startX: 0, startWidth: 0, }; // High-frequency pointer events (especially 1000Hz mice) can fire many // times per frame. Coalesce updates through requestAnimationFrame so the // browser only paints once per frame and we avoid forcing layout in the // input handler. this.pendingWidth = null; this.rafId = null; this.handleMouseMove = (e, index) => { if (!this.state.isResizing) return; const deltaX = index % 2 === 0 ? -(e.clientX - this.state.startX) : e.clientX - this.state.startX; const newWidth = clampWidth(this.state.startWidth + deltaX, this.resizeLimits); this.scheduleWidthUpdate(newWidth); }; this.handleMouseUp = () => { if (this.state.isResizing) { this.state.isResizing = false; } // Make sure the final pending width is applied even if no frame ticked // between the last move and release; otherwise the persisted value would // miss the user's last drag delta. this.cancelScheduledWidth(); this.flushWidth(); this.dispatchNodeView(); }; this.handleTouchMove = (e, index) => { if (!this.state.isResizing) return; const deltaX = index % 2 === 0 ? -(e.touches[0].clientX - this.state.startX) : e.touches[0].clientX - this.state.startX; const newWidth = clampWidth(this.state.startWidth + deltaX, this.resizeLimits); this.scheduleWidthUpdate(newWidth); }; this.handleTouchEnd = () => { if (this.state.isResizing) { this.state.isResizing = false; } this.cancelScheduledWidth(); this.flushWidth(); this.dispatchNodeView(); }; this.elements = elements; this.dispatchNodeView = dispatchNodeView; this.resizeLimits = resizeLimits; } scheduleWidthUpdate(width) { this.pendingWidth = width; if (this.rafId !== null) return; this.rafId = requestAnimationFrame(() => { this.rafId = null; this.flushWidth(); }); } flushWidth() { if (this.pendingWidth === null) return; const width = this.pendingWidth; this.pendingWidth = null; this.elements.container.style.width = `${width}px`; this.elements.img.style.width = `${width}px`; } cancelScheduledWidth() { if (this.rafId !== null) { cancelAnimationFrame(this.rafId); this.rafId = null; } } createResizeHandle(index) { const dot = document.createElement('div'); dot.dataset.resizeImageUi = 'resize-handle'; dot.setAttribute('style', StyleManager.getDotStyle(index)); dot.addEventListener('mousedown', (e) => { e.preventDefault(); this.state.isResizing = true; this.state.startX = e.clientX; this.state.startWidth = this.elements.container.offsetWidth; const onMouseMove = (e) => this.handleMouseMove(e, index); const onMouseUp = () => { this.handleMouseUp(); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); dot.addEventListener('touchstart', (e) => { e.cancelable && e.preventDefault(); this.state.isResizing = true; this.state.startX = e.touches[0].clientX; this.state.startWidth = this.elements.container.offsetWidth; const onTouchMove = (e) => this.handleTouchMove(e, index); const onTouchEnd = () => { this.handleTouchEnd(); document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); document.removeEventListener('touchcancel', onTouchEnd); }; document.addEventListener('touchmove', onTouchMove); document.addEventListener('touchend', onTouchEnd); document.addEventListener('touchcancel', onTouchEnd); }, { passive: false }); return dot; } } class ImageNodeView { constructor(context, inline, resizeLimits = {}) { this.unsubscribeDocumentClick = null; this.handleContainerClick = () => { const isMobile = utils.isMobile(); const editorDom = this.context.view.dom; isMobile && (editorDom === null || editorDom === void 0 ? void 0 : editorDom.blur()); this.removeResizeElements(); this.createPositionController(); // Always re-apply the sanitized container style first so any rules left // over from a previous interaction are cleared, then add the transient // selection border via direct property assignment (not cssText) so it // can never be persisted into the node attrs. const sanitized = sanitizeStyle(this.context.node.attrs.containerStyle); this.elements.container.setAttribute('style', sanitized); this.elements.container.style.position = 'relative'; this.elements.container.style.border = `1px dashed ${CONSTANTS.COLORS.BORDER}`; this.applyResizeLimits(); this.createResizeHandler(); }; this.handleDocumentClick = (e) => { const target = e.target; const isClickInside = !!target && (this.elements.container.contains(target) || !!target.closest('[data-resize-image-ui]')); if (!isClickInside) { this.clearContainerBorder(); this.removeResizeElements(); } }; this.clearContainerBorder = () => { utils.clearContainerBorder(this.elements.container); }; this.dispatchNodeView = () => { var _a; const { view, getPos } = this.context; if (typeof getPos === 'function') { this.clearContainerBorder(); const containerStyle = sanitizeStyle(this.elements.container.style.cssText); const wrapperStyle = sanitizeStyle(this.elements.wrapper.style.cssText); const newAttrs = Object.assign(Object.assign({}, this.context.node.attrs), { width: (_a = AttributeParser.extractWidthFromStyle(containerStyle)) !== null && _a !== void 0 ? _a : this.context.node.attrs.width, containerStyle, wrapperStyle }); view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, newAttrs)); } }; this.removeResizeElements = () => { utils.removeResizeElements(this.elements.container); }; this.destroy = () => { var _a; this.elements.container.removeEventListener('click', this.handleContainerClick); (_a = this.unsubscribeDocumentClick) === null || _a === void 0 ? void 0 : _a.call(this); this.unsubscribeDocumentClick = null; this.removeResizeElements(); }; /** * Reuses the existing DOM when ProseMirror replaces the underlying node * (e.g. on resize/alignment commits or external attr updates). Without * this hook ProseMirror would destroy and recreate the NodeView on every * setNodeMarkup, causing the <img> to reload, listeners to be re-bound, * and any open resize UI to vanish mid-interaction. */ this.update = (node) => { if (node.type !== this.context.node.type) return false; const prev = this.context.node; this.context.node = node; if (prev.attrs.wrapperStyle !== node.attrs.wrapperStyle) { this.elements.wrapper.setAttribute('style', sanitizeStyle(node.attrs.wrapperStyle)); } if (prev.attrs.containerStyle !== node.attrs.containerStyle) { this.elements.container.setAttribute('style', sanitizeStyle(node.attrs.containerStyle)); } const imgAttrChanged = prev.attrs.src !== node.attrs.src || prev.attrs.alt !== node.attrs.alt || prev.attrs.title !== node.attrs.title; if (imgAttrChanged || prev.attrs.containerStyle !== node.attrs.containerStyle) { this.setupImageAttributes(); } this.applyResizeLimits(); return true; }; this.context = context; this.inline = inline; this.resizeLimits = resizeLimits; this.elements = this.createElements(); } createElements() { return { wrapper: document.createElement('div'), container: document.createElement('div'), img: document.createElement('img'), }; } setupImageAttributes() { AttributeParser.parseImageAttributes(this.context.node.attrs, this.elements.img); } setupDOMStructure() { const { wrapperStyle, containerStyle } = this.context.node.attrs; this.elements.wrapper.setAttribute('style', sanitizeStyle(wrapperStyle)); this.elements.wrapper.appendChild(this.elements.container); this.elements.container.setAttribute('style', sanitizeStyle(containerStyle)); this.elements.container.appendChild(this.elements.img); } /** * Applies min/max width limits to the container and image. * Enforces configured limits on initial render and when container style is re-applied. */ applyResizeLimits() { let widthStr = AttributeParser.extractWidthFromStyle(this.elements.container.style.cssText); if (widthStr === null) { const maxWidth = this.resizeLimits.maxWidth; if (!maxWidth) return; widthStr = maxWidth.toString(); } const width = Number(widthStr); if (Number.isNaN(width)) return; const clamped = clampWidth(width, this.resizeLimits); const clampedPx = `${clamped}px`; this.elements.container.style.width = clampedPx; this.elements.img.style.width = clampedPx; this.elements.img.setAttribute('width', String(clamped)); } createPositionController() { const positionController = new PositionController(this.elements, this.inline, this.dispatchNodeView); positionController.createPositionControls(); } createResizeHandler() { const resizeHandler = new ResizeController(this.elements, this.dispatchNodeView, this.resizeLimits); Array.from({ length: 4 }, (_, index) => { const dot = resizeHandler.createResizeHandle(index); this.elements.container.appendChild(dot); }); } setupContainerClick() { this.elements.container.addEventListener('click', this.handleContainerClick); } setupContentClick() { this.unsubscribeDocumentClick = subscribeDocumentClick(this.handleDocumentClick); } initialize() { this.setupDOMStructure(); this.setupImageAttributes(); this.applyResizeLimits(); const { editable } = this.context.editor.options; if (!editable) return { dom: this.elements.container }; this.setupContainerClick(); this.setupContentClick(); return { dom: this.elements.wrapper, update: this.update, destroy: this.destroy, }; } } const imageResizeConfig = { name: 'imageResize', addOptions() { var _a; return Object.assign(Object.assign({}, (_a = this.parent) === null || _a === void 0 ? void 0 : _a.call(this)), { inline: false, minWidth: undefined, maxWidth: undefined }); }, addAttributes() { var _a; const inline = this.options.inline; return Object.assign(Object.assign({}, (_a = this.parent) === null || _a === void 0 ? void 0 : _a.call(this)), { containerStyle: { default: null, parseHTML: (element) => { const containerStyle = element.getAttribute('containerstyle'); if (containerStyle) { return sanitizeStyle(containerStyle); } const width = element.getAttribute('width'); return width ? StyleManager.getContainerStyle(inline, `${width}px`) : sanitizeStyle(element.style.cssText); }, }, wrapperStyle: { default: StyleManager.getWrapperStyle(inline), parseHTML: (element) => { const wrapperStyle = element.getAttribute('wrapperstyle'); return wrapperStyle ? sanitizeStyle(wrapperStyle) : StyleManager.getWrapperStyle(inline); }, } }); }, parseHTML() { // Exclude img inside figure to prevent conflict with Figure node parsing return [{ tag: 'img[src]:not(figure img)' }]; }, addNodeView() { return ({ node, editor, getPos }) => { const { inline, minWidth, maxWidth } = this.options; const context = { node, editor, view: editor.view, getPos: typeof getPos === 'function' ? getPos : undefined, }; const resizeLimits = { minWidth, maxWidth }; const nodeView = new ImageNodeView(context, inline, resizeLimits); return nodeView.initialize(); }; }, }; const ImageResize = Image.extend(imageResizeConfig); class FigureNodeView extends ImageNodeView { constructor() { super(...arguments); // Overrides dispatchNodeView to preserve NodeSelection after setNodeMarkup this.dispatchNodeView = () => { var _a; const { view, getPos } = this.context; if (typeof getPos === 'function') { this.clearContainerBorder(); const containerStyle = sanitizeStyle(this.elements.container.style.cssText); const wrapperStyle = sanitizeStyle(this.elements.wrapper.style.cssText); const newAttrs = Object.assign(Object.assign({}, this.context.node.attrs), { width: (_a = AttributeParser.extractWidthFromStyle(containerStyle)) !== null && _a !== void 0 ? _a : this.context.node.attrs.width, containerStyle, wrapperStyle }); // setNodeMarkup causes the current NodeSelection to fall back to TextSelection inside the figcaption // Manually recreate NodeSelection at the same position to preserve it const tr = view.state.tr.setNodeMarkup(getPos(), null, newAttrs); const { selection } = view.state; const newSelection = selection instanceof NodeSelection ? NodeSelection.create(tr.doc, selection.from) : selection.map(tr.doc, tr.mapping); view.dispatch(tr.setSelection(newSelection)); } }; this.handleNodeSelection = (event) => { const target = event.target; if (!this.elements.img.contains(target) && target !== this.elements.img) return; const { view, getPos } = this.context; if (typeof getPos === 'function') { const pos = getPos(); view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos))); } }; this.handleDragStart = (event) => { if (!event.dataTransfer) return; const { view, getPos } = this.context; if (typeof getPos !== 'function') return; const pos = getPos(); const node = view.state.doc.nodeAt(pos); if (!node) return; const serializer = DOMSerializer.fromSchema(view.state.schema); const dom = serializer.serializeNode(node); const wrapper = document.createElement('div'); wrapper.appendChild(dom); event.dataTransfer.clearData(); event.dataTransfer.setData('text/html', wrapper.innerHTML); event.dataTransfer.effectAllowed = 'move'; }; // Updates the DOM when the node's attrs change. // Returns false if the node type has changed to trigger a full re-render this.update = (node) => { if (node.type !== this.context.node.type) return false; this.context.node = node; this.elements.wrapper.setAttribute('style', sanitizeStyle(node.attrs.wrapperStyle)); this.elements.container.setAttribute('style', sanitizeStyle(node.attrs.containerStyle)); this.setupImageAttributes(); this.elements.img.setAttribute('style', 'cursor: pointer'); this.applyResizeLimits(); return true; }; } // Sets NodeSelection to the figure node when the image area is clicked setupNodeSelection() { this.elements.container.addEventListener('click', this.handleNodeSelection); } // Overrides the default drag behavior to serialize the figure node // based on renderHTML, preventing Controller from being copied setupDragStart() { this.elements.wrapper.addEventListener('dragstart', this.handleDragStart); } initializeFigure() { this.elements.wrapper = document.createElement('figure'); this.setupDOMStructure(); this.setupImageAttributes(); this.elements.img.setAttribute('style', 'cursor: pointer'); this.applyResizeLimits(); // contentDOM for figcaption, ProseMirror manages the figcaption content directly const figcaption = document.createElement('div'); figcaption.setAttribute('style', 'display: contents;'); this.elements.container.appendChild(figcaption); const { editable } = this.context.editor.options; if (!editable) return { dom: this.elements.wrapper, contentDOM: figcaption }; this.setupContainerClick(); this.setupContentClick(); this.setupNodeSelection(); this.setupDragStart(); return { dom: this.elements.wrapper, contentDOM: figcaption, update: this.update, stopEvent: (event) => { // Allow dragstart to be handled by ProseMirror to ensure view.dragging is set correctly if (event.type === 'dragstart') return false; // Only allow events inside figcaption to be handled by PM return !figcaption.contains(event.target); }, ignoreMutation: (mutation) => { // Allow ProseMirror to handle selection changes for cursor movement if (mutation.type === 'selection') return false; // Ignore mutations outside figcaption to prevent NodeView re-rendering return !figcaption.contains(mutation.target); }, destroy: () => { this.destroy(); this.elements.container.removeEventListener('click', this.handleNodeSelection); this.elements.wrapper.removeEventListener('dragstart', this.handleDragStart); }, }; } } /** * Locates the figure node enclosing the current selection in O(depth) by * walking up the resolved selection's parent chain. Also handles the case * where the figure itself is the active NodeSelection. Replaces the prior * full-document `nodesBetween` scan, which was O(N) and ran on every * removeCaption / toggleCaption call. */ function findEnclosingFigure(state) { const { selection } = state; if (selection instanceof NodeSelection && selection.node.type.name === 'figure') { return { node: selection.node, pos: selection.from }; } const $pos = selection.$from; for (let depth = $pos.depth; depth > 0; depth--) { const node = $pos.node(depth); if (node.type.name === 'figure') { return { node, pos: $pos.before(depth) }; } } return null; } const Figure = ImageResize.extend({ name: 'figure', group: 'block', content: 'figcaption', draggable: true, isolating: true, addOptions() { return { allowBase64: false, HTMLAttributes: {}, minWidth: undefined, maxWidth: undefined, }; }, addAttributes() { return { src: { default: null, parseHTML: (element) => { var _a; return (_a = element.querySelector('img')) === null || _a === void 0 ? void 0 : _a.getAttribute('src'); }, }, alt: { default: null, parseHTML: (element) => { var _a; return (_a = element.querySelector('img')) === null || _a === void 0 ? void 0 : _a.getAttribute('alt'); }, }, title: { default: null, parseHTML: (element) => { var _a; return (_a = element.querySelector('img')) === null || _a === void 0 ? void 0 : _a.getAttribute('title'); }, }, containerStyle: { default: null, parseHTML: (element) => { const img = element.querySelector('img'); const containerStyle = img === null || img === void 0 ? void 0 : img.getAttribute('containerstyle'); if (containerStyle) return sanitizeStyle(containerStyle); const width = img === null || img === void 0 ? void 0 : img.getAttribute('width'); return width ? StyleManager.getContainerStyle(false, `${width}px`) : sanitizeStyle(img === null || img === void 0 ? void 0 : img.style.cssText); }, }, wrapperStyle: { default: StyleManager.getWrapperStyle(false), parseHTML: (element) => { const wrapperStyle = element.getAttribute('wrapperstyle'); return wrapperStyle ? sanitizeStyle(wrapperStyle) : StyleManager.getWrapperStyle(false); }, }, }; }, parseHTML() { // Only recognize figure elements that contain both img and figcaption // to prevent conflict with ImageResize node parsing return [{ tag: 'figure:has(img):has(figcaption)' }]; }, renderHTML({ HTMLAttributes }) { // Wrap hole(0) in a div because hole must be the only child of its parent node return [ 'figure', {}, ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)], ['div', { 'data-figcaption': 'true' }, 0], ]; }, addNodeView() { return ({ node, editor, getPos }) => { const { minWidth, maxWidth } = this.options; const resizeLimits = { minWidth, maxWidth }; const context = { node, editor, view: editor.view, getPos: typeof getPos === 'function' ? getPos : undefined, }; const nodeView = new FigureNodeView(context, false, resizeLimits); return nodeView.initializeFigure(); }; }, addCommands() { return { /** * Converts an imageResize node to a figure node with a figcaption. * Moves the cursor inside the figcaption after insertion. */ addCaption: (caption = '') => ({ chain, state }) => { const { selection, schema } = state; const { from } = selection; const node = state.doc.nodeAt(from); if (!node || node.type.name !== 'imageResize') return false; // Figure is a block-level node and cannot wrap an inline image without // breaking its parent (e.g. paragraph). Reject the caption insertion // when the source image is inline. if (node.isInline) return false; const figcaptionNode = schema.nodes.figcaption.create({}, caption ? schema.text(caption) : null); const figureNode = schema.nodes.figure.create(node.attrs, [figcaptionNode]); return chain() .insertContentAt({ from, to: from + node.nodeSize }, figureNode.toJSON(), { updateSelection: false, }) .command(({ commands }) => { // from + 1: figcaption start, from + 2: inside figcaption (cursor position) return commands.setTextSelection(from + 2); }) .focus() .run(); }, /** * Converts a figure node back to an imageResize node. * Preserves the original attrs (src, width, containerStyle, etc.) */ removeCaption: () => ({ state, dispatch }) => { const found = findEnclosingFigure(state); if (!found) return false; if (dispatch) { const imageNode = state.schema.nodes.imageResize.create(found.node.attrs); dispatch(state.tr.replaceWith(found.pos, found.pos + found.node.nodeSize, imageNode)); } return true; }, /** * Toggles between imageResize and figure node. * Adds caption if current node is imageResize, removes if figure. */ toggleCaption: (caption = '') => ({ state, commands }) => { const { from } = state.selection; const node = state.doc.nodeAt(from); if ((node === null || node === void 0 ? void 0 : node.type.name) === 'imageResize') { return commands.addCaption(caption); } return findEnclosingFigure(state) ? commands.removeCaption() : false; }, }; }, }); const Figcaption = Node.create({ name: 'figcaption', content: 'inline*', addOptions() { return { placeholder: 'Write a caption...', }; }, addAttributes() { return { placeholder: { default: this.options.placeholder, parseHTML: (element) => element.getAttribute('data-placeholder'), renderHTML: (attributes) => ({ 'data-placeholder': attributes.placeholder, }), }, }; }, parseHTML() { return [{ tag: 'figcaption' }]; }, renderHTML({ HTMLAttributes }) { return ['figcaption', mergeAttributes(HTMLAttributes), 0]; }, addProseMirrorPlugins() { return [ new Plugin({ key: new PluginKey('figcaption-placeholder'), props: { decorations: ({ doc }) => { const decorations = []; doc.descendants((node, pos) => { if (node.type.name !== 'figcaption') return; if (!isNodeEmpty(node)) return; // Add 'is-empty' class and 'data-placeholder' attribute to empty figcaption nodes. // This allows CSS ::before pseudo-element to display the placeholder text. decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: 'is-empty', 'data-placeholder': node.attrs.placeholder, })); }); return DecorationSet.create(doc, decorations); }, }, }), ]; }, }); export { Figcaption, Figure, ImageResize, ImageResize as default, imageResizeConfig }; //# sourceMappingURL=index.js.map