Files
plainleaf/client/codemirror/frontmatter.ts
T

312 lines
9.9 KiB
TypeScript

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), <scheme>:// 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);
});
}