import type { EditorState } from "@codemirror/state"; import { foldedRanges, foldEffect, syntaxTree, unfoldEffect, } from "@codemirror/language"; import { Decoration, type EditorView, WidgetType } from "@codemirror/view"; import { decoratorStateField, isCursorInRange, LinkWidget } from "./util.ts"; import type { Client } from "../client.ts"; import { frontmatterMailtoRegex, frontmatterQuotesRegex, frontmatterUrlRegex, frontmatterWikiLinkRegex, } from "../markdown_parser/constants.ts"; import { processWikiLink, type WikiLinkMatch } from "./wiki_link_processor.ts"; class FrontmatterMarkerWidget extends WidgetType { constructor( private readonly from: number, private readonly to: number, private readonly folded: boolean, ) { super(); } toDOM(view: EditorView): HTMLElement { const marker = document.createElement("span"); marker.className = "sb-frontmatter-marker"; marker.role = "button"; marker.tabIndex = 0; marker.title = this.folded ? "Unfold frontmatter" : "Fold frontmatter"; marker.setAttribute( "aria-label", this.folded ? "Unfold frontmatter" : "Fold frontmatter", ); marker.textContent = `${this.folded ? "◂" : "▾"} frontmatter`; const toggleFold = (event: Event) => { event.preventDefault(); event.stopPropagation(); view.dispatch({ effects: (this.folded ? unfoldEffect : foldEffect).of({ from: this.from, to: this.to, }), }); view.focus(); }; marker.addEventListener("click", toggleFold); marker.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { toggleFold(event); } }); return marker; } override eq(other: WidgetType): boolean { return ( other instanceof FrontmatterMarkerWidget && other.from === this.from && other.to === this.to && other.folded === this.folded ); } } function hasExactFoldedRange( state: EditorState, from: number, to: number, ): boolean { const folded = foldedRanges(state).iter(); while (folded.value) { if (folded.from === from && folded.to === to) { return true; } folded.next(); } return false; } export function shouldRenderFrontmatterLivePreview({ state, client, from, to, }: { state: EditorState; client: Client; from: number; to: number; }): boolean { return ( !client.ui.viewState.uiOptions.markdownSyntaxRendering && !isCursorInRange(state, [from, to]) ); } export function frontmatterPlugin(client: Client) { return decoratorStateField((state: EditorState) => { const widgets: any[] = []; const foldRanges = foldedRanges(state); const shortWikiLinks = client.config.get("shortWikiLinks", true); syntaxTree(state).iterate({ enter(node) { if (node.name === "FrontMatterMarker") { const parent = node.node.parent!; const frontmatterFolded = hasExactFoldedRange( state, parent.from, parent.to, ); const folded = foldRanges.iter(); let shouldShowFrontmatterBanner = false; while (folded.value) { // Check if cursor is in the folded range if (isCursorInRange(state, [folded.from, folded.to])) { // console.log("Cursor is in folded area, "); shouldShowFrontmatterBanner = true; break; } folded.next(); } if (!isCursorInRange(state, [parent.from, parent.to])) { widgets.push( Decoration.line({ class: "sb-line-frontmatter-outside", }).range(node.from), ); shouldShowFrontmatterBanner = true; } if (shouldShowFrontmatterBanner && parent.from === node.from) { // Only put this on the first line of the frontmatter widgets.push( Decoration.widget({ widget: new FrontmatterMarkerWidget( parent.from, parent.to, frontmatterFolded, ), }).range(node.from), ); } } // Render links inside frontmatter code as clickable anchors (external and wiki links) if (node.name === "FrontMatterCode") { const oFrom = node.from; const oTo = node.to; const otext = state.sliceDoc(oFrom, oTo); let oMatch: RegExpExecArray | null; while ((oMatch = frontmatterQuotesRegex.exec(otext)) !== null) { const from = oFrom + (oMatch.index ?? 0); const to = from + oMatch[0].length; const text = state.sliceDoc(from, to); // 1) External links: http(s), :// URLs frontmatterUrlRegex.lastIndex = 0; let match: RegExpExecArray | null; while ((match = frontmatterUrlRegex.exec(text)) !== null) { const mFrom = from + (match.index ?? 0); const mTo = mFrom + match[0].length; const url = match[1]; if ( !shouldRenderFrontmatterLivePreview({ state, client, from: mFrom, to: mTo, }) ) { continue; } widgets.push( Decoration.replace({ widget: new LinkWidget({ text: url, title: `Open ${url}`, href: url, cssClass: "sb-external-link", from: mFrom, callback: (e) => { if (e.altKey) { // Move cursor into the link client.editorView.dispatch({ selection: { anchor: mFrom }, }); client.focus(); return; } try { // Open http(s) links in a new window/tab, open // alternate schemes in the same page, as they'll // bounce to another application. if (/^https?:\/\//i.test(url)) { globalThis.open(url, "_blank"); } else { globalThis.open(url, "_self"); } } catch (err) { console.error("Failed to open external link", err); } }, }), }).range(mFrom, mTo), ); } // 2) Internal links: WikiLinks [[...]] (make navigable) frontmatterWikiLinkRegex.lastIndex = 0; let wMatch: RegExpExecArray | null; while ((wMatch = frontmatterWikiLinkRegex.exec(text)) !== null) { if (!wMatch || !wMatch.groups) { return; } const mFrom = from + (wMatch.index ?? 0); const mTo = mFrom + wMatch[0].length; const wikiLinkMatch: WikiLinkMatch = { leadingTrivia: wMatch.groups.leadingTrivia, stringRef: wMatch.groups.stringRef, alias: wMatch.groups.alias, trailingTrivia: wMatch.groups.trailingTrivia, }; const decorations = processWikiLink({ from: mFrom, to: mTo, match: wikiLinkMatch, matchFrom: mFrom, matchTo: mTo, client, shortWikiLinks, state, callback: (e, ref) => { if (e.altKey) { // Move cursor into the link's content client.editorView.dispatch({ selection: { anchor: mFrom + wikiLinkMatch.leadingTrivia.length, }, }); client.focus(); return; } void client.navigate(ref, false, e.ctrlKey || e.metaKey); }, }); widgets.push(...decorations); } // 3) mailto:... links frontmatterMailtoRegex.lastIndex = 0; let mMatch: RegExpExecArray | null; while ((mMatch = frontmatterMailtoRegex.exec(text)) !== null) { const mFrom = from + (mMatch.index ?? 0); const mTo = mFrom + mMatch[0].length; const url = mMatch[1]; const address = url.slice(7); if ( !shouldRenderFrontmatterLivePreview({ state, client, from: mFrom, to: mTo, }) ) { continue; } widgets.push( Decoration.replace({ widget: new LinkWidget({ text: url, title: `Mail ${address}`, href: url, cssClass: "sb-external-link", from: mFrom, callback: (e) => { if (e.altKey) { // Move cursor into the link client.editorView.dispatch({ selection: { anchor: mFrom }, }); client.focus(); return; } try { globalThis.open(url, "_self"); } catch (err) { console.error("Failed to open external link", err); } }, }), }).range(mFrom, mTo), ); } } } }, }); return Decoration.set(widgets, true); }); }