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
JavaScript
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