import { Node as PMNode } from 'prosemirror-model'; import { EditorView, NodeView } from 'prosemirror-view'; import { NodeSelection } from 'prosemirror-state'; const MIN_WIDTH = 40; /** * Attaches four corner drag-handles to a wrapper and returns a cleanup * function. Dragging previews by mutating the target's style; on release the * final width is committed through onCommit (one transaction). */ export function attachResizeHandles( wrapper: HTMLElement, img: HTMLElement, onCommit: (width: number) => void, minWidth: number = MIN_WIDTH ): void { for (const corner of ['nw', 'ne', 'sw', 'se']) { const handle = document.createElement('span'); handle.className = `nile-wysiwyg__resize-handle nile-wysiwyg__resize-handle--${corner}`; handle.dataset.resizeHandle = corner; handle.contentEditable = 'false'; handle.addEventListener('mousedown', event => { event.preventDefault(); event.stopPropagation(); const startX = event.clientX; const startWidth = img.getBoundingClientRect().width; const sign = corner.includes('w') ? -1 : 1; const maxWidth = wrapper.closest('.ProseMirror')?.getBoundingClientRect().width ?? 2000; let width = startWidth; const onMove = (move: MouseEvent): void => { width = Math.max( minWidth, Math.min(maxWidth, startWidth + sign * (move.clientX - startX)) ); img.style.width = `${Math.round(width)}px`; }; const onUp = (): void => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); wrapper.classList.remove('nile-wysiwyg__resizing'); onCommit(Math.round(width)); }; // While dragging, iframes inside the wrapper must not swallow mouse // events (they are separate documents); CSS keys off this class. wrapper.classList.add('nile-wysiwyg__resizing'); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }); wrapper.appendChild(handle); } } export function isResizeEvent(event: Event): boolean { return ( event.target instanceof HTMLElement && !!event.target.dataset.resizeHandle ); } /** Inline image with drag-resize handles shown while selected. */ export class ImageView implements NodeView { dom: HTMLSpanElement; private img: HTMLImageElement; constructor( private node: PMNode, view: EditorView, getPos: () => number | undefined ) { this.dom = document.createElement('span'); this.dom.className = 'nile-wysiwyg__image-resizer'; this.img = document.createElement('img'); this.applyAttrs(node); this.dom.appendChild(this.img); attachResizeHandles(this.dom, this.img, width => { const pos = getPos(); if (pos === undefined || !view.editable) return; const current = view.state.doc.nodeAt(pos); if (!current) return; view.dispatch( view.state.tr.setNodeMarkup(pos, undefined, { ...current.attrs, width }) ); }); } private applyAttrs(node: PMNode): void { const { src, alt, title, width } = node.attrs; this.img.src = src; if (alt) this.img.alt = alt; else this.img.removeAttribute('alt'); if (title) this.img.title = title; else this.img.removeAttribute('title'); this.img.style.width = width ? `${width}px` : ''; } update(node: PMNode): boolean { if (node.type !== this.node.type) return false; this.node = node; this.applyAttrs(node); return true; } selectNode(): void { this.dom.classList.add('nile-wysiwyg__image-resizer--selected'); } deselectNode(): void { this.dom.classList.remove('nile-wysiwyg__image-resizer--selected'); } stopEvent(event: Event): boolean { return isResizeEvent(event); } ignoreMutation(): boolean { return true; } } /** * Captioned figure: non-editable image (with resize handles) above an * editable figcaption (the node's content). Clicking the image selects the * whole figure. */ export class FigureView implements NodeView { dom: HTMLElement; contentDOM: HTMLElement; private img: HTMLImageElement; private wrapper: HTMLSpanElement; constructor( private node: PMNode, view: EditorView, getPos: () => number | undefined ) { this.dom = document.createElement('figure'); this.dom.className = 'nile-wysiwyg__figure'; this.wrapper = document.createElement('span'); this.wrapper.className = 'nile-wysiwyg__image-resizer'; this.wrapper.contentEditable = 'false'; this.img = document.createElement('img'); this.wrapper.appendChild(this.img); this.contentDOM = document.createElement('figcaption'); this.contentDOM.className = 'nile-wysiwyg__figcaption'; this.dom.append(this.wrapper, this.contentDOM); this.applyAttrs(node); // Clicking the image selects the figure node (instead of dropping the // cursor into the caption). this.img.addEventListener('mousedown', event => { event.preventDefault(); const pos = getPos(); if (pos === undefined) return; view.dispatch( view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)) ); view.focus(); }); attachResizeHandles(this.wrapper, this.img, width => { const pos = getPos(); if (pos === undefined || !view.editable) return; const current = view.state.doc.nodeAt(pos); if (!current) return; view.dispatch( view.state.tr.setNodeMarkup(pos, undefined, { ...current.attrs, width }) ); }); } private applyAttrs(node: PMNode): void { const { src, alt, title, width, align } = node.attrs; this.img.src = src; if (alt) this.img.alt = alt; else this.img.removeAttribute('alt'); if (title) this.img.title = title; else this.img.removeAttribute('title'); this.dom.style.width = width ? `${width}px` : ''; if (align) this.dom.setAttribute('data-align', align); else this.dom.removeAttribute('data-align'); this.dom.classList.toggle( 'nile-wysiwyg__figure--empty-caption', node.content.size === 0 ); } update(node: PMNode): boolean { if (node.type !== this.node.type) return false; this.node = node; this.applyAttrs(node); return true; } selectNode(): void { this.dom.classList.add('nile-wysiwyg__figure--selected'); this.wrapper.classList.add('nile-wysiwyg__image-resizer--selected'); } deselectNode(): void { this.dom.classList.remove('nile-wysiwyg__figure--selected'); this.wrapper.classList.remove('nile-wysiwyg__image-resizer--selected'); } stopEvent(event: Event): boolean { return isResizeEvent(event); } ignoreMutation(mutation: MutationRecord): boolean { // Caption edits must flow through; image/handle mutations must not. return !this.contentDOM.contains(mutation.target); } }