/** * HTML plugin for CodeMirror. * * Features: * - Identifies HTML blocks and tags (excluding those inside tables) * - Shows indicator icon at the end * - Click to preview rendered HTML */ 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'; import DOMPurify from 'dompurify'; import { LruCache } from '@/common/utils/lruCache'; interface HTMLBlockInfo { from: number; to: number; content: string; sanitized: string; } // HTML5 official logo const HTML_ICON = ``; /** * LRU cache for DOMPurify sanitize results. */ const sanitizeCache = new LruCache(100); /** * Sanitize HTML content with caching for performance. */ function sanitizeHTML(html: string): string { const cached = sanitizeCache.get(html); if (cached !== undefined) { return cached; } const sanitized = DOMPurify.sanitize(html, { ADD_TAGS: ['img'], ADD_ATTR: ['src', 'alt', 'width', 'height', 'style', 'class', 'loading'], ALLOW_DATA_ATTR: true }); sanitizeCache.set(html, sanitized); return sanitized; } /** * 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; } /** * Check if a node is inside a table. */ function isInsideTable(node: { parent: { type: { name: string }; parent: unknown } | null }): boolean { let current = node.parent; while (current) { const name = current.type.name; if (name === 'Table' || name === 'TableHeader' || name === 'TableRow' || name === 'TableCell') { return true; } current = current.parent as typeof current; } return false; } /** * Extract all HTML blocks from visible ranges. * Excludes HTML inside tables (tables have their own rendering). */ function extractHTMLBlocks(view: EditorView): HTMLBlockInfo[] { const result: HTMLBlockInfo[] = []; for (const { from, to } of view.visibleRanges) { syntaxTree(view.state).iterate({ from, to, enter: (nodeRef) => { const { name, from: f, to: t, node } = nodeRef; // Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag) if (name !== 'HTMLBlock' && name !== 'HTMLTag') return; // Skip HTML inside tables if (isInsideTable(node)) return; const content = view.state.sliceDoc(f, t); const sanitized = sanitizeHTML(content); // Skip empty content after sanitization if (!sanitized.trim()) return; result.push({ from: f, to: t, content, sanitized }); } }); } return result; } /** Effect to toggle tooltip visibility */ const toggleHTMLTooltip = StateEffect.define(); /** Effect to close tooltip */ const closeHTMLTooltip = StateEffect.define(); /** StateField to track active tooltip */ const htmlTooltipState = StateField.define({ create: () => null, update(value, tr) { for (const effect of tr.effects) { if (effect.is(toggleHTMLTooltip)) { // Toggle: if same block, close; otherwise open new if (value && effect.value && value.from === effect.value.from) { return null; } return effect.value; } if (effect.is(closeHTMLTooltip)) { return null; } } // Close tooltip on document changes if (tr.docChanged) { return null; } return value; }, provide: (field) => showTooltip.from(field, (block): Tooltip | null => { if (!block) return null; return { pos: block.to, above: true, create: () => { const dom = document.createElement('div'); dom.className = 'cm-html-tooltip'; dom.innerHTML = block.sanitized; // Prevent clicks inside tooltip from closing it dom.addEventListener('click', (e) => { e.stopPropagation(); }); return { dom }; } }; }) }); /** * Indicator widget shown at the end of HTML blocks. * Clicking toggles the tooltip. */ class HTMLIndicatorWidget extends WidgetType { constructor(readonly info: HTMLBlockInfo) { super(); } toDOM(view: EditorView): HTMLElement { const el = document.createElement('span'); el.className = 'cm-html-indicator'; el.innerHTML = HTML_ICON; el.title = 'Click to preview HTML'; // Click handler to toggle tooltip el.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); view.dispatch({ effects: toggleHTMLTooltip.of(this.info) }); }); return el; } eq(other: HTMLIndicatorWidget): boolean { return this.info.from === other.info.from && this.info.content === other.info.content; } ignoreEvent(): boolean { return false; } } /** * Plugin to manage HTML block decorations. * Optimized with incremental updates when changes don't affect HTML regions. */ class HTMLBlockPlugin { decorations: DecorationSet; blocks: HTMLBlockInfo[] = []; constructor(view: EditorView) { this.blocks = extractHTMLBlocks(view); this.decorations = this.build(); } update(update: ViewUpdate) { // Always rebuild on viewport change if (update.viewportChanged) { this.blocks = extractHTMLBlocks(update.view); this.decorations = this.build(); return; } // For document changes, only rebuild if changes affect HTML regions if (update.docChanged) { const needsRebuild = changesAffectRegions(update.changes, this.blocks); if (needsRebuild) { this.blocks = extractHTMLBlocks(update.view); this.decorations = this.build(); } else { // Just update positions of existing decorations this.decorations = this.decorations.map(update.changes); this.blocks = this.blocks.map(block => ({ ...block, from: update.changes.mapPos(block.from), to: update.changes.mapPos(block.to) })); } } } private build(): DecorationSet { const deco: Range[] = []; for (const block of this.blocks) { deco.push( Decoration.widget({ widget: new HTMLIndicatorWidget(block), side: 1 }).range(block.to) ); } return Decoration.set(deco, true); } } const htmlBlockPlugin = ViewPlugin.fromClass(HTMLBlockPlugin, { 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-html-indicator') || target.closest('.cm-html-tooltip')) { return false; } // Close tooltip if one is open const currentTooltip = view.state.field(htmlTooltipState); if (currentTooltip) { view.dispatch({ effects: closeHTMLTooltip.of(null) }); } return false; } }); const theme = EditorView.baseTheme({ // Indicator icon '.cm-html-indicator': { display: 'inline-flex', alignItems: 'center', marginLeft: '4px', verticalAlign: 'middle', cursor: 'pointer', opacity: '0.5', color: 'var(--cm-html-color, #e44d26)', transition: 'opacity 0.15s', '& svg': { width: '14px', height: '14px' } }, '.cm-html-indicator:hover': { opacity: '1' }, // Tooltip content '.cm-html-tooltip': { padding: '8px 12px', maxWidth: '60vw', maxHeight: '50vh', overflow: 'auto' }, // Images inside tooltip '.cm-html-tooltip img': { maxWidth: '100%', height: 'auto', display: 'block' }, // Style the parent tooltip container '.cm-tooltip:has(.cm-html-tooltip)': { background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #ddd)', borderRadius: '4px', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)' } }); /** * HTML block extension. * * Features: * - Identifies HTML blocks and tags (excluding those inside tables) * - Shows indicator icon at the end * - Click to preview rendered HTML */ export const html = (): Extension => [ htmlBlockPlugin, htmlTooltipState, clickOutsideHandler, theme ];