153 lines
4.4 KiB
TypeScript
153 lines
4.4 KiB
TypeScript
import type { EditorState } from "@codemirror/state";
|
|
import { Decoration } from "@codemirror/view";
|
|
import type { Client } from "../client.ts";
|
|
import {
|
|
fileName,
|
|
isBuiltinPath,
|
|
} from "@silverbulletmd/silverbullet/lib/resolve";
|
|
import {
|
|
encodePageURI,
|
|
encodeRef,
|
|
getNameFromPath,
|
|
parseToRef,
|
|
} from "@silverbulletmd/silverbullet/lib/ref";
|
|
import { isCursorInRange, LinkWidget } from "./util.ts";
|
|
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
|
|
|
|
// Building a `path -> PageMeta` lookup requires calling `parseToRef` (two
|
|
// regexes) on every page in the space. Doing that per rendered wiki link, on
|
|
// every editor update, is O(links * pages) and makes typing on link-heavy
|
|
// pages in large spaces painfully slow. Memoize the map and only rebuild it
|
|
// when the `allPages` array identity changes (i.e. when the page list is
|
|
// actually replaced).
|
|
let pageByPathCache: { pages: PageMeta[]; map: Map<string, PageMeta> } | null =
|
|
null;
|
|
|
|
function pageByPath(allPages: PageMeta[]): Map<string, PageMeta> {
|
|
if (pageByPathCache?.pages === allPages) {
|
|
return pageByPathCache.map;
|
|
}
|
|
const map = new Map<string, PageMeta>();
|
|
for (const p of allPages) {
|
|
const path = parseToRef(p.ref)?.path;
|
|
if (path !== undefined && !map.has(path)) {
|
|
map.set(path, p);
|
|
}
|
|
}
|
|
pageByPathCache = { pages: allPages, map };
|
|
return map;
|
|
}
|
|
|
|
export interface WikiLinkMatch {
|
|
leadingTrivia: string;
|
|
stringRef: string;
|
|
alias?: string;
|
|
trailingTrivia: string;
|
|
}
|
|
|
|
export interface WikiLinkProcessorOptions {
|
|
from: number;
|
|
to: number;
|
|
match: WikiLinkMatch;
|
|
matchFrom: number;
|
|
matchTo: number;
|
|
client: Client;
|
|
state: EditorState;
|
|
shortWikiLinks: boolean;
|
|
callback: (e: MouseEvent, ref: any) => void;
|
|
}
|
|
|
|
export function processWikiLink(options: WikiLinkProcessorOptions): any[] {
|
|
const { from, to, match, matchFrom, matchTo, client, state, callback } =
|
|
options;
|
|
const widgets: any[] = [];
|
|
|
|
const { leadingTrivia, stringRef, alias, trailingTrivia } = match;
|
|
const ref = parseToRef(stringRef);
|
|
|
|
let linkStatus: "file-missing" | "default" | "invalid" = "default";
|
|
|
|
if (!ref) {
|
|
linkStatus = "invalid";
|
|
} else if (ref.path === "" || isBuiltinPath(ref.path)) {
|
|
linkStatus = "default";
|
|
} else if (client.clientSystem.allKnownFiles.has(ref.path)) {
|
|
linkStatus = "default";
|
|
} else if (client.fullSyncCompleted || client.clientSystem.knownFilesLoaded) {
|
|
linkStatus = "file-missing";
|
|
}
|
|
|
|
let css = {
|
|
"file-missing": "sb-wiki-link-missing",
|
|
invalid: "sb-wiki-link-invalid",
|
|
default: "",
|
|
}[linkStatus];
|
|
|
|
const renderingSyntax = client.ui.viewState.uiOptions.markdownSyntaxRendering;
|
|
|
|
if (isCursorInRange(state, [from, to]) || renderingSyntax) {
|
|
// Only attach a CSS class, then get out
|
|
if (linkStatus !== "default") {
|
|
widgets.push(
|
|
Decoration.mark({
|
|
class: css,
|
|
}).range(from + leadingTrivia.length, to - trailingTrivia.length),
|
|
);
|
|
}
|
|
return widgets;
|
|
}
|
|
|
|
const cleanedPath = ref ? getNameFromPath(ref.path) : stringRef;
|
|
const helpText = {
|
|
default: `Navigate to ${cleanedPath}`,
|
|
"file-missing": `Create ${cleanedPath}`,
|
|
invalid: `Cannot create invalid file ${cleanedPath}`,
|
|
}[linkStatus];
|
|
|
|
let linkText = alias || stringRef;
|
|
|
|
// The `&& ref` is only there to make typescript happy
|
|
if (linkStatus === "default" && ref) {
|
|
const meta = pageByPath(client.ui.viewState.allPages).get(ref.path);
|
|
|
|
const renderedRef = structuredClone(ref);
|
|
|
|
// We don't want to render the meta
|
|
renderedRef.meta = false;
|
|
// We also don't want to rendered the prefix of the path
|
|
renderedRef.path = options.shortWikiLinks
|
|
? fileName(renderedRef.path)
|
|
: renderedRef.path;
|
|
|
|
const prefix =
|
|
ref.details?.type === "position" || ref.details?.type === "linecolumn"
|
|
? ""
|
|
: (meta?.pageDecoration?.prefix ?? "");
|
|
|
|
linkText = alias || prefix + encodeRef(renderedRef);
|
|
|
|
if (meta?.pageDecoration?.cssClasses) {
|
|
css +=
|
|
" sb-decorated-object " +
|
|
meta.pageDecoration.cssClasses
|
|
.join(" ")
|
|
.replaceAll(/[^a-zA-Z0-9-_ ]/g, "");
|
|
}
|
|
}
|
|
|
|
widgets.push(
|
|
Decoration.replace({
|
|
widget: new LinkWidget({
|
|
text: linkText,
|
|
title: helpText,
|
|
href: ref ? encodePageURI(encodeRef(ref)) : undefined,
|
|
cssClass: `sb-wiki-link ${css}`,
|
|
from: matchFrom,
|
|
callback: (e) => callback(e, ref),
|
|
}),
|
|
}).range(matchFrom, matchTo),
|
|
);
|
|
|
|
return widgets;
|
|
}
|