Files
voidraft/frontend/src/views/editor/extensions/markdown/plugins/link.ts

112 lines
3.0 KiB
TypeScript

/**
* Link handler with underline and clickable icon.
*/
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
import * as runtime from "@wailsio/runtime";
const BLACKLISTED_LINK_PARENTS = new Set(['Image', 'LinkReference']);
/** Link text decoration with underline */
const linkTextDecoration = Decoration.mark({ class: 'cm-md-link-text' });
/** Link icon widget - clickable to open URL */
class LinkIconWidget extends WidgetType {
constructor(readonly url: string) { super(); }
eq(other: LinkIconWidget) { return this.url === other.url; }
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-md-link-icon';
span.textContent = '🔗';
span.title = this.url;
span.onmousedown = (e) => {
e.preventDefault();
e.stopPropagation();
runtime.Browser.OpenURL(this.url);
};
return span;
}
ignoreEvent(e: Event) { return e.type === 'mousedown'; }
}
/**
* Handle URL node (within Link).
*/
export function handleURL(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
ranges: RangeTuple[]
): void {
const parent = node.parent;
if (!parent || BLACKLISTED_LINK_PARENTS.has(parent.name)) return;
if (ctx.seen.has(parent.from)) return;
ctx.seen.add(parent.from);
ranges.push([parent.from, parent.to]);
if (checkRangeOverlap([parent.from, parent.to], ctx.selRange)) return;
// Get link text node (content between first [ and ])
const linkText = parent.getChild('LinkLabel');
const marks = parent.getChildren('LinkMark');
const linkTitle = parent.getChild('LinkTitle');
const closeBracket = marks.find(m => ctx.view.state.sliceDoc(m.from, m.to) === ']');
if (closeBracket && nf < closeBracket.from) return;
// Get URL for the icon
const url = ctx.view.state.sliceDoc(nf, nt);
// Add underline decoration to link text
if (linkText) {
ctx.items.push({ from: linkText.from, to: linkText.to, deco: linkTextDecoration });
}
// Hide markdown syntax marks
for (const m of marks) {
ctx.items.push({ from: m.from, to: m.to, deco: invisibleDecoration });
}
// Hide URL
ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration });
// Hide link title if present
if (linkTitle) {
ctx.items.push({ from: linkTitle.from, to: linkTitle.to, deco: invisibleDecoration });
}
// Add clickable icon widget after link text (at close bracket position)
if (closeBracket) {
ctx.items.push({
from: closeBracket.from,
to: closeBracket.from,
deco: Decoration.widget({ widget: new LinkIconWidget(url), side: 1 }),
priority: 1
});
}
}
/**
* Theme for markdown links.
*/
export const linkTheme = EditorView.baseTheme({
'.cm-md-link-text': {
color: 'var(--cm-link-color, #0969da)',
textDecoration: 'underline',
textUnderlineOffset: '2px',
cursor: 'text'
},
'.cm-md-link-icon': {
cursor: 'pointer',
marginLeft: '0.2em',
opacity: '0.7',
transition: 'opacity 0.15s ease',
'&:hover': {
opacity: '1'
}
}
});