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; } 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 $("").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 $("").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 $("") .attr("src", `api/images/${noteId}/${encodedTitle}?${Math.random()}`) .attr("alt", linkTitle || ""); } const $container = $(""); if (showNoteIcon) { let icon = await getLinkIcon(noteId, viewMode); if (icon) { $container.append($("").addClass(`bx ${icon}`)).append(" "); } } const hash = calculateHash({ notePath, viewScope: viewScope }); const $noteLink = $("", { 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($("").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)[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, hrefLink: string | undefined, $link?: JQuery | 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) { 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, 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($("").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 };