/** * Image plugin for CodeMirror. * * Features: * - Identifies markdown images * - Shows indicator icon at the end * - Click to preview image */ import { syntaxTree } from '@codemirror/language'; import { Extension, Range, StateField, StateEffect, ChangeSet } from '@codemirror/state'; import { DecorationSet, Decoration, WidgetType, EditorView, ViewPlugin, ViewUpdate, showTooltip, Tooltip } from '@codemirror/view'; interface ImageInfo { src: string; from: number; to: number; alt: string; } const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif|apng|tiff?)(\?.*)?$/i; const IMAGE_ALT_RE = /(?:!\[)(.*?)(?:\])/; const ICON = ``; function isImageUrl(url: string): boolean { return IMAGE_EXT_RE.test(url) || url.startsWith('data:image/'); } /** * Check if document changes affect any of the given regions. */ function changesAffectRegions(changes: ChangeSet, regions: { from: number; to: number }[]): boolean { if (regions.length === 0) return true; let affected = false; changes.iterChanges((fromA, toA) => { if (affected) return; for (const region of regions) { if (fromA <= region.to && toA >= region.from) { affected = true; return; } } }); return affected; } function extractImages(view: EditorView): ImageInfo[] { const result: ImageInfo[] = []; for (const { from, to } of view.visibleRanges) { syntaxTree(view.state).iterate({ from, to, enter: ({ name, node, from: f, to: t }) => { if (name !== 'Image') return; const urlNode = node.getChild('URL'); if (!urlNode) return; const src = view.state.sliceDoc(urlNode.from, urlNode.to); if (!isImageUrl(src)) return; const text = view.state.sliceDoc(f, t); const alt = text.match(IMAGE_ALT_RE)?.[1] ?? ''; result.push({ src, from: f, to: t, alt }); } }); } return result; } /** Effect to toggle tooltip visibility */ const toggleImageTooltip = StateEffect.define(); /** Effect to close tooltip */ const closeImageTooltip = StateEffect.define(); /** StateField to track active tooltip */ const imageTooltipState = StateField.define({ create: () => null, update(value, tr) { for (const effect of tr.effects) { if (effect.is(toggleImageTooltip)) { // Toggle: if same image, close; otherwise open new if (value && effect.value && value.from === effect.value.from) { return null; } return effect.value; } if (effect.is(closeImageTooltip)) { return null; } } // Close tooltip on document changes if (tr.docChanged) { return null; } return value; }, provide: (field) => showTooltip.from(field, (img): Tooltip | null => { if (!img) return null; return { pos: img.to, above: true, create: () => { const dom = document.createElement('div'); dom.className = 'cm-image-tooltip cm-image-loading'; const spinner = document.createElement('span'); spinner.className = 'cm-image-spinner'; const imgEl = document.createElement('img'); imgEl.src = img.src; imgEl.alt = img.alt; imgEl.onload = () => { dom.classList.remove('cm-image-loading'); }; imgEl.onerror = () => { spinner.remove(); imgEl.remove(); dom.textContent = 'Failed to load image'; dom.classList.remove('cm-image-loading'); dom.classList.add('cm-image-tooltip-error'); }; dom.append(spinner, imgEl); // Prevent clicks inside tooltip from closing it dom.addEventListener('click', (e) => { e.stopPropagation(); }); return { dom }; } }; }) }); /** * Indicator widget shown at the end of images. * Clicking toggles the tooltip. */ class IndicatorWidget extends WidgetType { constructor(readonly info: ImageInfo) { super(); } toDOM(view: EditorView): HTMLElement { const el = document.createElement('span'); el.className = 'cm-image-indicator'; el.innerHTML = ICON; el.title = 'Click to preview image'; // Click handler to toggle tooltip el.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); view.dispatch({ effects: toggleImageTooltip.of(this.info) }); }); return el; } eq(other: IndicatorWidget): boolean { return this.info.from === other.info.from && this.info.src === other.info.src; } ignoreEvent(): boolean { return false; } } /** * Plugin to manage image decorations. * Optimized with incremental updates when changes don't affect image regions. */ class ImagePlugin { decorations: DecorationSet; images: ImageInfo[] = []; constructor(view: EditorView) { this.images = extractImages(view); this.decorations = this.build(); } update(update: ViewUpdate) { // Always rebuild on viewport change if (update.viewportChanged) { this.images = extractImages(update.view); this.decorations = this.build(); return; } // For document changes, only rebuild if changes affect image regions if (update.docChanged) { const needsRebuild = changesAffectRegions(update.changes, this.images); if (needsRebuild) { this.images = extractImages(update.view); this.decorations = this.build(); } else { // Just update positions of existing decorations this.decorations = this.decorations.map(update.changes); this.images = this.images.map(img => ({ ...img, from: update.changes.mapPos(img.from), to: update.changes.mapPos(img.to) })); } } } private build(): DecorationSet { const deco: Range[] = []; for (const img of this.images) { deco.push(Decoration.widget({ widget: new IndicatorWidget(img), side: 1 }).range(img.to)); } return Decoration.set(deco, true); } } const imagePlugin = ViewPlugin.fromClass(ImagePlugin, { decorations: (v) => v.decorations }); /** * Close tooltip when clicking outside. */ const clickOutsideHandler = EditorView.domEventHandlers({ click(event, view) { const target = event.target as HTMLElement; // Don't close if clicking on indicator or inside tooltip if (target.closest('.cm-image-indicator') || target.closest('.cm-image-tooltip')) { return false; } // Close tooltip if one is open const currentTooltip = view.state.field(imageTooltipState); if (currentTooltip) { view.dispatch({ effects: closeImageTooltip.of(null) }); } return false; } }); const theme = EditorView.baseTheme({ '.cm-image-indicator': { display: 'inline-flex', alignItems: 'center', marginLeft: '4px', verticalAlign: 'middle', cursor: 'pointer', opacity: '0.5', color: 'var(--cm-link-color, #1a73e8)', transition: 'opacity 0.15s', '& svg': { width: '14px', height: '14px' } }, '.cm-image-indicator:hover': { opacity: '1' }, '.cm-image-tooltip': { position: 'relative', background: ` linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%) `, backgroundColor: '#fff', backgroundSize: '12px 12px', backgroundPosition: '0 0, 0 6px, 6px -6px, -6px 0px', border: '1px solid var(--border-color)', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', '& img': { display: 'block', maxWidth: '60vw', maxHeight: '50vh', opacity: '1', transition: 'opacity 0.15s ease-out' } }, '.cm-image-loading': { minWidth: '48px', minHeight: '48px', '& img': { opacity: '0' } }, '.cm-image-spinner': { position: 'absolute', top: '50%', left: '50%', width: '16px', height: '16px', marginTop: '-8px', marginLeft: '-8px', border: '2px solid #ccc', borderTopColor: '#666', borderRadius: '50%', animation: 'cm-spin 0.5s linear infinite' }, '.cm-image-tooltip:not(.cm-image-loading) .cm-image-spinner': { display: 'none' }, '@keyframes cm-spin': { to: { transform: 'rotate(360deg)' } }, '.cm-image-tooltip-error': { padding: '16px 24px', fontSize: '12px', color: 'red' } }); export const image = (): Extension => [ imagePlugin, imageTooltipState, clickOutsideHandler, theme ];