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
];