import { syntaxTree } from '@codemirror/language'; import { Extension, Range } from '@codemirror/state'; import { DecorationSet, Decoration, WidgetType, EditorView, ViewPlugin, ViewUpdate, hoverTooltip, 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/'); } 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; } class IndicatorWidget extends WidgetType { constructor(readonly info: ImageInfo) { super(); } toDOM(): HTMLElement { const el = document.createElement('span'); el.className = 'cm-image-indicator'; el.innerHTML = ICON; return el; } eq(other: IndicatorWidget): boolean { return this.info.from === other.info.from && this.info.src === other.info.src; } } class ImagePlugin { decorations: DecorationSet; images: ImageInfo[] = []; constructor(view: EditorView) { this.images = extractImages(view); this.decorations = this.build(); } update(update: ViewUpdate) { if (update.docChanged || update.viewportChanged) { this.images = extractImages(update.view); this.decorations = this.build(); } } 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); } getImageAt(pos: number): ImageInfo | null { for (const img of this.images) { if (pos >= img.to && pos <= img.to + 1) { return img; } } return null; } } const imagePlugin = ViewPlugin.fromClass(ImagePlugin, { decorations: (v) => v.decorations }); const imageHoverTooltip = hoverTooltip( (view, pos): Tooltip | null => { const plugin = view.plugin(imagePlugin); if (!plugin) return null; const img = plugin.getImageAt(pos); if (!img) return null; return { pos: img.to, above: true, arrow: true, create: () => { const dom = document.createElement('div'); dom.className = 'cm-image-tooltip'; const imgEl = document.createElement('img'); imgEl.src = img.src; imgEl.alt = img.alt; imgEl.onerror = () => { imgEl.remove(); dom.textContent = 'Failed to load image'; dom.classList.add('cm-image-tooltip-error'); }; dom.append(imgEl); return { dom }; } }; }, { hoverTime: 300 } ); 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': { background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', '& img': { display: 'block', maxWidth: '60vw', maxHeight: '50vh' } }, '.cm-image-tooltip-error': { padding: '16px 24px', fontSize: '12px', color: 'var(--text-muted)' }, '.cm-tooltip-arrow:before': { borderTopColor: 'var(--border-color) !important', borderBottomColor: 'var(--border-color) !important' }, '.cm-tooltip-arrow:after': { borderTopColor: 'var(--bg-secondary) !important', borderBottomColor: 'var(--bg-secondary) !important' } }); export const image = (): Extension => [imagePlugin, imageHoverTooltip, theme];