mirror of
https://github.com/zadam/trilium.git
synced 2025-12-05 06:54:23 +01:00
before there was polysemy in word url that is now resolved by making link hypernym to url and path.
506 lines
17 KiB
TypeScript
506 lines
17 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";
|
|
import { ALLOWED_PROTOCOLS } from "@triliumnext/commons";
|
|
import { openInCurrentNoteContext } from "../components/note_context.js";
|
|
|
|
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;
|
|
}
|
|
|
|
export type ViewMode = "default" | "source" | "attachments" | "contextual-help";
|
|
|
|
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;
|
|
/**
|
|
* If true, it indicates that the note in the view should be opened in read-only mode (for supported note types such as text or code).
|
|
*
|
|
* The reason why we store this information here is that a note can become read-only as the user types content in it, and we wouldn't want
|
|
* to immediately enter read-only mode.
|
|
*/
|
|
isReadOnly?: 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) {
|
|
let pathSegments: string[];
|
|
if (notePath == "root") {
|
|
pathSegments = ["⌂"];
|
|
} else {
|
|
const resolvedPathSegments = (await treeService.resolveNotePathToSegments(notePath)) || [];
|
|
resolvedPathSegments.pop(); // Remove last element
|
|
|
|
const resolvedPath = resolvedPathSegments.join("/");
|
|
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 {};
|
|
}
|
|
|
|
url = url.trim();
|
|
const hashIdx = url.indexOf("#");
|
|
if (hashIdx === -1) {
|
|
return {};
|
|
}
|
|
|
|
// Exclude external links that contain #
|
|
if (hashIdx !== 0 && !url.includes("/#root") && !url.includes("/#?searchString") && !url.includes("/?extraWindow")) {
|
|
return {};
|
|
}
|
|
|
|
const hash = url.substr(hashIdx + 1); // strip also the initial '#'
|
|
let [notePath, paramString] = hash.split("?");
|
|
|
|
const viewScope: ViewScope = {
|
|
viewMode: "default"
|
|
};
|
|
let ntxId: string | null = null;
|
|
let hoistedNoteId: string | null = null;
|
|
let searchString: string | null = null;
|
|
let openInPopup = false;
|
|
|
|
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 if (name === "popup") {
|
|
openInPopup = true;
|
|
} 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.getNoteIdFromLink(notePath),
|
|
ntxId,
|
|
hoistedNoteId,
|
|
viewScope,
|
|
searchString,
|
|
openInPopup
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Handles navigation to a link, which can be an internal note path (e.g., `#root/1234`) or an external URL (e.g., `https://example.com`).
|
|
*
|
|
* @param evt the event that triggered the link navigation, or `null` if the link was clicked programmatically. Used to determine if the link should be opened in a new tab/window, based on the button presses.
|
|
* @param hrefLink the link to navigate to, which can be a note path (e.g., `#root/1234`) or an external URL with any supported protocol (e.g., `https://example.com`).
|
|
* @param $link the jQuery element of the link that was clicked, used to determine if the link is an anchor link (e.g., `#fn1` or `#fnref1`) and to handle it accordingly.
|
|
* @returns `true` if the link was handled (i.e., the element was found and scrolled to), or a falsy value otherwise.
|
|
*/
|
|
export function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
|
|
if (hrefLink?.startsWith("data:")) {
|
|
return true;
|
|
}
|
|
|
|
evt?.preventDefault();
|
|
evt?.stopPropagation();
|
|
|
|
if (hrefLink && hrefLink.startsWith("#") && !hrefLink.startsWith("#root/") && $link) {
|
|
if (handleAnchor(hrefLink, $link)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
const { notePath, viewScope, openInPopup } = parseNavigationStateFromUrl(hrefLink);
|
|
|
|
const ctrlKey = evt && utils.isCtrlKey(evt);
|
|
const shiftKey = evt?.shiftKey;
|
|
const isLeftClick = !evt || ("which" in evt && evt.which === 1);
|
|
// Right click is handled separately.
|
|
const isMiddleClick = evt && "which" in evt && evt.which === 2;
|
|
const targetIsBlank = ($link?.attr("target") === "_blank");
|
|
const isDoubleClick = isLeftClick && evt?.type === "dblclick";
|
|
const openInNewTab = (isLeftClick && ctrlKey) || isDoubleClick || isMiddleClick || targetIsBlank;
|
|
const activate = (isLeftClick && ctrlKey && shiftKey) || (isMiddleClick && shiftKey);
|
|
const openInNewWindow = isLeftClick && evt?.shiftKey && !ctrlKey;
|
|
|
|
if (notePath) {
|
|
if (isLeftClick && openInPopup) {
|
|
appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath });
|
|
} else if (openInNewWindow) {
|
|
appContext.triggerCommand("openInWindow", { notePath, viewScope });
|
|
} else if (openInNewTab) {
|
|
appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
|
|
activate: activate ? true : targetIsBlank,
|
|
viewScope
|
|
});
|
|
} else if (isLeftClick) {
|
|
openInCurrentNoteContext(evt, notePath, viewScope);
|
|
}
|
|
} else if (hrefLink) {
|
|
const withinEditLink = $link?.hasClass("ck-link-actions__preview");
|
|
const outsideOfCKEditor = !$link || $link.closest("[contenteditable]").length === 0;
|
|
|
|
if (openInNewTab || openInNewWindow || (isLeftClick && (withinEditLink || outsideOfCKEditor))) {
|
|
if (hrefLink.toLowerCase().startsWith("http") || hrefLink.startsWith("api/")) {
|
|
window.open(hrefLink, "_blank");
|
|
} else {
|
|
// Enable protocols supported by CKEditor 5 to be clickable.
|
|
if (ALLOWED_PROTOCOLS.some((protocol) => hrefLink.toLowerCase().startsWith(protocol + ":"))) {
|
|
if ( utils.isElectron()) {
|
|
const electron = utils.dynamicRequire("electron");
|
|
electron.shell.openExternal(hrefLink);
|
|
} else {
|
|
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),
|
|
* or CKEditor bookmarks.
|
|
*
|
|
* @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 `true` if the link was handled (i.e., the element was found and scrolled to), `false` otherwise.
|
|
*/
|
|
function handleAnchor(hrefLink: string, $link: JQuery<HTMLElement>) {
|
|
const el = $link.closest(".ck-content").find(hrefLink)[0];
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
}
|
|
return !!el;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (utils.isCtrlKey(e) && e.button === 2) {
|
|
appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath });
|
|
e.preventDefault();
|
|
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;
|
|
}
|
|
}
|
|
|
|
if (glob.device !== "print") {
|
|
// 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);
|
|
// TODO: Check why the event is not supported.
|
|
//@ts-ignore
|
|
$(document).on("dblclick", "a", goToLink);
|
|
|
|
$(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
|
|
};
|