2025-05-16 21:25:10 +08:00

491 lines
16 KiB
TypeScript

import treeService from "./tree.js";
import linkContextMenuService from "../menus/link_context_menu.js";
import appContext, { type NoteCommandData } from "../components/app_context.js";
import froca from "./froca.js";
import utils from "./utils.js";
// Be consistent with `allowedSchemes` in `src\services\html_sanitizer.ts`
// TODO: Deduplicate with server once we can.
export const ALLOWED_PROTOCOLS = [
'http', 'https', 'ftp', 'ftps', 'mailto', 'data', 'evernote', 'file', 'facetime', 'gemini', 'git',
'gopher', 'imap', 'irc', 'irc6', 'jabber', 'jar', 'lastfm', 'ldap', 'ldaps', 'magnet', 'message',
'mumble', 'nfs', 'onenote', 'pop', 'rmi', 's3', 'sftp', 'skype', 'sms', 'spotify', 'steam', 'svn', 'udp',
'view-source', 'vlc', 'vnc', 'ws', 'wss', 'xmpp', 'jdbc', 'slack', 'tel', 'smb', 'zotero', 'geo',
'mid'
];
function getNotePathFromUrl(url: string) {
const notePathMatch = /#(root[A-Za-z0-9_/]*)$/.exec(url);
return notePathMatch === null ? null : notePathMatch[1];
}
async function getLinkIcon(noteId: string, viewMode: ViewMode | undefined) {
let icon;
if (!viewMode || viewMode === "default") {
const note = await froca.getNote(noteId);
icon = note?.getIcon();
} else if (viewMode === "source") {
icon = "bx bx-code-curly";
} else if (viewMode === "attachments") {
icon = "bx bx-file";
}
return icon;
}
// TODO: Remove `string` once all the view modes have been mapped.
type ViewMode = "default" | "source" | "attachments" | "contextual-help" | string;
export interface ViewScope {
/**
* - "source", when viewing the source code of a note.
* - "attachments", when viewing the attachments of a note.
* - "contextual-help", if the current view represents a help window that was opened to the side of the main content.
* - "default", otherwise.
*/
viewMode?: ViewMode;
attachmentId?: string;
readOnlyTemporarilyDisabled?: boolean;
highlightsListPreviousVisible?: boolean;
highlightsListTemporarilyHidden?: boolean;
tocTemporarilyHidden?: boolean;
/*
* The reason for adding tocPreviousVisible is to record whether the previous state of the toc is hidden or displayed,
* and then let it be displayed/hidden at the initial time. If there is no such value,
* when the right panel needs to display highlighttext but not toc, every time the note content is changed,
* toc will appear and then close immediately, because getToc(html) function will consume time
*/
tocPreviousVisible?: boolean;
tocCollapsedHeadings?: Set<string>;
}
interface CreateLinkOptions {
title?: string;
showTooltip?: boolean;
showNotePath?: boolean;
showNoteIcon?: boolean;
referenceLink?: boolean;
autoConvertToImage?: boolean;
viewScope?: ViewScope;
}
async function createLink(notePath: string | undefined, options: CreateLinkOptions = {}) {
if (!notePath || !notePath.trim()) {
logError("Missing note path");
return $("<span>").text("[missing note]");
}
if (!notePath.startsWith("root")) {
// all note paths should start with "root/" (except for "root" itself)
// used, e.g., to find internal links
notePath = `root/${notePath}`;
}
const showTooltip = options.showTooltip === undefined ? true : options.showTooltip;
const showNotePath = options.showNotePath === undefined ? false : options.showNotePath;
const showNoteIcon = options.showNoteIcon === undefined ? false : options.showNoteIcon;
const referenceLink = options.referenceLink === undefined ? false : options.referenceLink;
const autoConvertToImage = options.autoConvertToImage === undefined ? false : options.autoConvertToImage;
const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath);
if (!noteId) {
logError("Missing note ID");
return $("<span>").text("[missing note]");
}
const viewScope = options.viewScope || {};
const viewMode = viewScope.viewMode || "default";
let linkTitle = options.title;
if (!linkTitle) {
if (viewMode === "attachments" && viewScope.attachmentId) {
const attachment = await froca.getAttachment(viewScope.attachmentId);
linkTitle = attachment ? attachment.title : "[missing attachment]";
} else if (noteId) {
linkTitle = await treeService.getNoteTitle(noteId, parentNoteId);
}
}
const note = await froca.getNote(noteId);
if (autoConvertToImage && note?.type && ["image", "canvas", "mermaid"].includes(note.type) && viewMode === "default") {
const encodedTitle = encodeURIComponent(linkTitle || "");
return $("<img>")
.attr("src", `api/images/${noteId}/${encodedTitle}?${Math.random()}`)
.attr("alt", linkTitle || "");
}
const $container = $("<span>");
if (showNoteIcon) {
let icon = await getLinkIcon(noteId, viewMode);
if (icon) {
$container.append($("<span>").addClass(`bx ${icon}`)).append(" ");
}
}
const hash = calculateHash({
notePath,
viewScope: viewScope
});
const $noteLink = $("<a>", {
href: hash,
text: linkTitle
});
if (!showTooltip) {
$noteLink.addClass("no-tooltip-preview");
}
if (referenceLink) {
$noteLink.addClass("reference-link");
}
$container.append($noteLink);
if (showNotePath) {
const resolvedPathSegments = (await treeService.resolveNotePathToSegments(notePath)) || [];
resolvedPathSegments.pop(); // Remove last element
const resolvedPath = resolvedPathSegments.join("/");
const pathSegments = await treeService.getNotePathTitleComponents(resolvedPath);
if (pathSegments) {
if (pathSegments.length) {
$container.append($("<small>").append(treeService.formatNotePath(pathSegments)));
}
}
}
return $container;
}
function calculateHash({ notePath, ntxId, hoistedNoteId, viewScope = {} }: NoteCommandData) {
notePath = notePath || "";
const params = [
ntxId ? { ntxId: ntxId } : null,
hoistedNoteId && hoistedNoteId !== "root" ? { hoistedNoteId: hoistedNoteId } : null,
viewScope.viewMode && viewScope.viewMode !== "default" ? { viewMode: viewScope.viewMode } : null,
viewScope.attachmentId ? { attachmentId: viewScope.attachmentId } : null
].filter((p) => !!p);
const paramStr = params
.map((pair) => {
const name = Object.keys(pair)[0];
const value = (pair as Record<string, string | undefined>)[name];
return `${encodeURIComponent(name)}=${encodeURIComponent(value || "")}`;
})
.join("&");
if (!notePath && !paramStr) {
return "";
}
let hash = `#${notePath}`;
if (paramStr) {
hash += `?${paramStr}`;
}
return hash;
}
export function parseNavigationStateFromUrl(url: string | undefined) {
if (!url) {
return {};
}
const hashIdx = url.indexOf("#");
if (hashIdx === -1) {
return {};
}
const hash = url.substr(hashIdx + 1); // strip also the initial '#'
let [notePath, paramString] = hash.split("?");
const viewScope: ViewScope = {
viewMode: "default"
};
let ntxId = null;
let hoistedNoteId = null;
let searchString = null;
if (paramString) {
for (const pair of paramString.split("&")) {
let [name, value] = pair.split("=");
name = decodeURIComponent(name);
value = decodeURIComponent(value);
if (name === "ntxId") {
ntxId = value;
} else if (name === "hoistedNoteId") {
hoistedNoteId = value;
} else if (name === "searchString") {
searchString = value; // supports triggering search from URL, e.g. #?searchString=blabla
} else if (["viewMode", "attachmentId"].includes(name)) {
(viewScope as any)[name] = value;
} else {
console.warn(`Unrecognized hash parameter '${name}'.`);
}
}
}
if (searchString) {
return { searchString }
}
if (!notePath.match(/^[_a-z0-9]{4,}(\/[_a-z0-9]{4,})*$/i)) {
return {};
}
return {
notePath,
noteId: treeService.getNoteIdFromUrl(notePath),
ntxId,
hoistedNoteId,
viewScope,
searchString
};
}
function goToLink(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent) {
const $link = $(evt.target as any).closest("a,.block-link");
const hrefLink = $link.attr("href") || $link.attr("data-href");
return goToLinkExt(evt, hrefLink, $link);
}
function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement>, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
if (hrefLink?.startsWith("data:")) {
return true;
}
evt.preventDefault();
evt.stopPropagation();
if (hrefLink?.startsWith("#fn") && $link) {
return handleFootnote(hrefLink, $link);
}
const { notePath, viewScope } = parseNavigationStateFromUrl(hrefLink);
const ctrlKey = utils.isCtrlKey(evt);
const shiftKey = evt.shiftKey;
const isLeftClick = "which" in evt && evt.which === 1;
const isMiddleClick = "which" in evt && evt.which === 2;
const targetIsBlank = ($link?.attr("target") === "_blank");
const openInNewTab = (isLeftClick && ctrlKey) || isMiddleClick || targetIsBlank;
const activate = (isLeftClick && ctrlKey && shiftKey) || (isMiddleClick && shiftKey);
const openInNewWindow = isLeftClick && evt.shiftKey && !ctrlKey;
if (notePath) {
if (openInNewWindow) {
appContext.triggerCommand("openInWindow", { notePath, viewScope });
} else if (openInNewTab) {
appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
activate: activate ? true : targetIsBlank,
viewScope
});
} else if (isLeftClick) {
const ntxId = $(evt.target as any)
.closest("[data-ntx-id]")
.attr("data-ntx-id");
const noteContext = ntxId ? appContext.tabManager.getNoteContextById(ntxId) : appContext.tabManager.getActiveContext();
if (noteContext) {
noteContext.setNote(notePath, { viewScope }).then(() => {
if (noteContext !== appContext.tabManager.getActiveContext()) {
appContext.tabManager.activateNoteContext(noteContext.ntxId);
}
});
} else {
appContext.tabManager.openContextWithNote(notePath, { viewScope, activate: true });
}
}
} else if (hrefLink) {
const withinEditLink = $link?.hasClass("ck-link-actions__preview");
const outsideOfCKEditor = !$link || $link.closest("[contenteditable]").length === 0;
if (openInNewTab || (withinEditLink && (isLeftClick || isMiddleClick)) || (outsideOfCKEditor && (isLeftClick || isMiddleClick))) {
if (hrefLink.toLowerCase().startsWith("http") || hrefLink.startsWith("api/")) {
window.open(hrefLink, "_blank");
} else if ((hrefLink.toLowerCase().startsWith("file:") || hrefLink.toLowerCase().startsWith("geo:")) && utils.isElectron()) {
const electron = utils.dynamicRequire("electron");
electron.shell.openPath(hrefLink);
} else {
// Enable protocols supported by CKEditor 5 to be clickable.
if (ALLOWED_PROTOCOLS.some((protocol) => hrefLink.toLowerCase().startsWith(protocol + ":"))) {
window.open(hrefLink, "_blank");
}
}
}
}
return true;
}
/**
* Scrolls to either the footnote (if clicking on a reference such as `[1]`), or to the reference of a footnote (if clicking on the footnote `^` arrow).
*
* @param hrefLink the URL of the link that was clicked (it should be in the form of `#fn` or `#fnref`).
* @param $link the element of the link that was clicked.
* @returns whether the event should be consumed or not.
*/
function handleFootnote(hrefLink: string, $link: JQuery<HTMLElement>) {
const el = $link.closest(".ck-content").find(hrefLink)[0];
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
}
return true;
}
function linkContextMenu(e: PointerEvent) {
const $link = $(e.target as any).closest("a");
const url = $link.attr("href") || $link.attr("data-href");
if ($link.attr("data-no-context-menu")) {
return;
}
const { notePath, viewScope } = parseNavigationStateFromUrl(url);
if (!notePath) {
return;
}
e.preventDefault();
linkContextMenuService.openContextMenu(notePath, e, viewScope, null);
}
async function loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string | null | undefined = null) {
const $link = $el[0].tagName === "A" ? $el : $el.find("a");
href = href || $link.attr("href");
if (!href) {
console.warn("Empty URL for parsing: " + $el[0].outerHTML);
return;
}
const { noteId, viewScope } = parseNavigationStateFromUrl(href);
if (!noteId) {
console.warn("Missing note ID.");
return;
}
const note = await froca.getNote(noteId, true);
if (note) {
$el.addClass(note.getColorClass());
}
const title = await getReferenceLinkTitle(href);
$el.text(title);
if (note) {
const icon = await getLinkIcon(noteId, viewScope.viewMode);
if (icon) {
$el.prepend($("<span>").addClass(icon));
}
}
}
async function getReferenceLinkTitle(href: string) {
const { noteId, viewScope } = parseNavigationStateFromUrl(href);
if (!noteId) {
return "[missing note]";
}
const note = await froca.getNote(noteId);
if (!note) {
return "[missing note]";
}
if (viewScope?.viewMode === "attachments" && viewScope?.attachmentId) {
const attachment = await note.getAttachmentById(viewScope.attachmentId);
return attachment ? attachment.title : "[missing attachment]";
} else {
return note.title;
}
}
function getReferenceLinkTitleSync(href: string) {
const { noteId, viewScope } = parseNavigationStateFromUrl(href);
if (!noteId) {
return "[missing note]";
}
const note = froca.getNoteFromCache(noteId);
if (!note) {
return "[missing note]";
}
if (viewScope?.viewMode === "attachments" && viewScope?.attachmentId) {
if (!note.attachments) {
return "[loading title...]";
}
const attachment = note.attachments.find((att) => att.attachmentId === viewScope.attachmentId);
return attachment ? attachment.title : "[missing attachment]";
} else {
return note.title;
}
}
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("click", "a", goToLink);
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("auxclick", "a", goToLink); // to handle the middle button
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("contextmenu", "a", linkContextMenu);
$(document).on("dblclick", "a", (e) => {
e.preventDefault();
e.stopPropagation();
const $link = $(e.target).closest("a");
const address = $link.attr("href");
if (address && address.startsWith("http")) {
window.open(address, "_blank");
}
});
$(document).on("mousedown", "a", (e) => {
if (e.which === 2) {
// prevent paste on middle click
// https://github.com/zadam/trilium/issues/2995
// https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event#preventing_default_actions
e.preventDefault();
return false;
}
});
export default {
getNotePathFromUrl,
createLink,
goToLink,
goToLinkExt,
loadReferenceLinkTitle,
getReferenceLinkTitle,
getReferenceLinkTitleSync,
calculateHash,
parseNavigationStateFromUrl
};