import { ViewPlugin, EditorView, Decoration, DecorationSet, MatchDecorator, WidgetType, ViewUpdate, } from '@codemirror/view'; import { Extension, Range } from '@codemirror/state'; import * as runtime from "@wailsio/runtime"; const pathStr = ``; const defaultRegexp = /\b(([a-zA-Z][\w+\-.]*):\/\/[^\s/$.?#].[^\s]*)\b/gi; 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; } } function hyperLinkDecorations(view: EditorView, anchor?: HyperLinkExtensionOptions['anchor']) { const widgets: Array> = []; const doc = view.state.doc.toString(); let match; while ((match = defaultRegexp.exec(doc)) !== null) { const from = match.index; const to = from + match[0].length; const linkMark = Decoration.mark({ class: 'cm-hyper-link-text' }); widgets.push(linkMark.range(from, to)); const widget = Decoration.widget({ widget: new HyperLinkIcon({ at: to, url: match[0], anchor, }), side: 1, }); widgets.push(widget.range(to)); } return Decoration.set(widgets); } const linkDecorator = ( regexp?: RegExp, matchData?: Record, matchFn?: (str: string, input: string, from: number, to: number) => string, anchor?: HyperLinkExtensionOptions['anchor'], ) => new MatchDecorator({ regexp: regexp || defaultRegexp, decorate: (add, from, to, match, _view) => { const url = match[0]; let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url; if (matchData && matchData[url]) { urlStr = matchData[url]; } const start = to, end = to; const linkIcon = new HyperLinkIcon({ at: start, url: urlStr, anchor }); add(from, to, Decoration.mark({ class: 'cm-hyper-link-text cm-hyper-link-underline' })); add(start, end, Decoration.widget({ widget: linkIcon, side: 1 })); }, }); export type HyperLinkExtensionOptions = { regexp?: RegExp; match?: Record; handle?: (value: string, input: string, from: number, to: number) => string; anchor?: (dom: HTMLAnchorElement) => HTMLAnchorElement; showIcon?: boolean; }; export function hyperLinkExtension({ regexp, match, handle, anchor, showIcon = true }: HyperLinkExtensionOptions = {}) { return ViewPlugin.fromClass( class HyperLinkView { decorator?: MatchDecorator; decorations: DecorationSet; constructor(view: EditorView) { if (regexp) { this.decorator = linkDecorator(regexp, match, handle, anchor); this.decorations = this.decorator.createDeco(view); } else { this.decorations = hyperLinkDecorations(view, anchor); } } update(update: ViewUpdate) { if (update.docChanged || update.viewportChanged) { if (regexp && this.decorator) { this.decorations = this.decorator.updateDeco(update, this.decorations); } else { this.decorations = hyperLinkDecorations(update.view, anchor); } } } }, { 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: '14px', height: '14px', }, '.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 ];