import { ViewPlugin, EditorView, Decoration, DecorationSet, WidgetType, ViewUpdate, } from '@codemirror/view'; import { Extension, ChangeSet } from '@codemirror/state'; import { syntaxTree } from '@codemirror/language'; import * as runtime from "@wailsio/runtime"; const pathStr = ``; const defaultRegexp = /\b(([a-zA-Z][\w+\-.]*):\/\/[^\s/$.?#].[^\s]*)\b/g; /** Stored hyperlink info for incremental updates */ interface HyperLinkInfo { url: string; from: number; to: number; } /** * Check if document changes affect any of the given link regions. */ function changesAffectLinks(changes: ChangeSet, links: HyperLinkInfo[]): boolean { if (links.length === 0) return true; let affected = false; changes.iterChanges((fromA, toA) => { if (affected) return; for (const link of links) { // Check if change overlaps with link region (with some buffer for insertions) if (fromA <= link.to && toA >= link.from) { affected = true; return; } } }); return affected; } // Markdown link parent nodes that should be excluded from hyperlink decoration const MARKDOWN_LINK_PARENTS = new Set(['Link', 'Image', 'URL']); /** * Check if a position is inside a markdown link syntax node. * This prevents hyperlink decorations from conflicting with markdown rendering. */ function isInMarkdownLink(view: EditorView, from: number, to: number): boolean { const tree = syntaxTree(view.state); let inLink = false; tree.iterate({ from, to, enter: (node) => { if (MARKDOWN_LINK_PARENTS.has(node.name)) { inLink = true; return false; // Stop iteration } } }); return inLink; } /** * Extract hyperlinks from visible ranges only. * This is the key optimization - we only scan what's visible. */ function extractVisibleLinks(view: EditorView): HyperLinkInfo[] { const result: HyperLinkInfo[] = []; const seen = new Set(); // Dedupe by position key for (const { from, to } of view.visibleRanges) { // Get the text for this visible range const rangeText = view.state.sliceDoc(from, to); // Reset regex lastIndex for each range const regex = new RegExp(defaultRegexp.source, 'gi'); let match; while ((match = regex.exec(rangeText)) !== null) { const linkFrom = from + match.index; const linkTo = linkFrom + match[0].length; const key = `${linkFrom}:${linkTo}`; // Skip duplicates if (seen.has(key)) continue; seen.add(key); // Skip URLs inside markdown link syntax if (isInMarkdownLink(view, linkFrom, linkTo)) continue; result.push({ url: match[0], from: linkFrom, to: linkTo }); } } return result; } export interface HyperLinkState { at: number; url: string; anchor: HyperLinkExtensionOptions['anchor']; } class HyperLinkIcon extends WidgetType { private readonly state: HyperLinkState; constructor(state: HyperLinkState) { super(); this.state = state; } eq(other: HyperLinkIcon) { return this.state.url === other.state.url && this.state.at === other.state.at; } toDOM() { const wrapper = document.createElement('a'); wrapper.href = this.state.url; wrapper.innerHTML = pathStr; wrapper.className = 'cm-hyper-link-icon cm-hyper-link-underline'; wrapper.title = this.state.url; wrapper.setAttribute('data-url', this.state.url); wrapper.onclick = (e) => { e.preventDefault(); runtime.Browser.OpenURL(this.state.url); return false; }; const anchor = this.state.anchor && this.state.anchor(wrapper); return anchor || wrapper; } } /** * Build decorations from extracted link info. */ function buildDecorations(links: HyperLinkInfo[], anchor?: HyperLinkExtensionOptions['anchor']): DecorationSet { const decorations: ReturnType[] = []; for (const link of links) { // Add text decoration decorations.push(Decoration.mark({ class: 'cm-hyper-link-text' }).range(link.from, link.to)); // Add icon widget decorations.push(Decoration.widget({ widget: new HyperLinkIcon({ at: link.to, url: link.url, anchor, }), side: 1, }).range(link.to)); } return Decoration.set(decorations, true); } export type HyperLinkExtensionOptions = { /** Custom anchor element transformer */ anchor?: (dom: HTMLAnchorElement) => HTMLAnchorElement; }; /** * Optimized hyperlink extension with visible-range-only scanning. * * Performance optimizations: * 1. Only scans visible ranges (not the entire document) * 2. Incremental updates: maps positions when changes don't affect links * 3. Caches link info to avoid redundant re-extraction */ export function hyperLinkExtension({ anchor }: HyperLinkExtensionOptions = {}) { return ViewPlugin.fromClass( class HyperLinkView { decorations: DecorationSet; links: HyperLinkInfo[] = []; constructor(view: EditorView) { this.links = extractVisibleLinks(view); this.decorations = buildDecorations(this.links, anchor); } update(update: ViewUpdate) { // Always rebuild on viewport change (new content visible) if (update.viewportChanged) { this.links = extractVisibleLinks(update.view); this.decorations = buildDecorations(this.links, anchor); return; } // For document changes, check if they affect link regions if (update.docChanged) { const needsRebuild = changesAffectLinks(update.changes, this.links); if (needsRebuild) { // Changes affect links, full rebuild this.links = extractVisibleLinks(update.view); this.decorations = buildDecorations(this.links, anchor); } else { // Just update positions of existing decorations this.decorations = this.decorations.map(update.changes); this.links = this.links.map(link => ({ ...link, from: update.changes.mapPos(link.from), to: update.changes.mapPos(link.to) })); } } } }, { decorations: (v) => v.decorations, }, ); } export const hyperLinkStyle = EditorView.baseTheme({ '.cm-hyper-link-text': { color: '#0969da', cursor: 'text', transition: 'color 0.2s ease', textDecoration: 'underline', textDecorationColor: '#0969da', textDecorationThickness: '1px', textUnderlineOffset: '2px', '&:hover': { color: '#0550ae', } }, '.cm-hyper-link-underline': { textDecoration: 'underline', textDecorationColor: '#0969da', textDecorationThickness: '1px', textUnderlineOffset: '2px', }, '.cm-hyper-link-icon': { display: 'inline-block', verticalAlign: 'middle', marginLeft: '0.2ch', color: '#656d76', opacity: 0.7, transition: 'opacity 0.2s ease, color 0.2s ease', cursor: 'pointer', '&:hover': { opacity: 1, color: '#0969da', } }, '.cm-hyper-link-icon svg': { display: 'block', width: 'inherit', height: 'inherit', }, '.cm-editor.cm-focused .cm-hyper-link-text': { color: '#58a6ff', '&:hover': { color: '#79c0ff', } }, '.cm-editor.cm-focused .cm-hyper-link-underline': { textDecorationColor: '#58a6ff', }, '.cm-editor.cm-focused .cm-hyper-link-icon': { color: '#8b949e', '&:hover': { color: '#58a6ff', } } }); export const hyperLinkClickHandler = EditorView.domEventHandlers({ click: (event) => { const target = event.target as HTMLElement | null; const iconElement = target?.closest?.('.cm-hyper-link-icon') as (HTMLElement | null); if (iconElement && iconElement.hasAttribute('data-url')) { const url = iconElement.getAttribute('data-url'); if (url) { runtime.Browser.OpenURL(url); event.preventDefault(); return true; } } return false; } }); export const hyperLink: Extension = [ hyperLinkExtension(), hyperLinkStyle, hyperLinkClickHandler ];