mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 19:49:01 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			497 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			497 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 {};
 | |
|     }
 | |
| 
 | |
|     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")) { 
 | |
|         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;
 | |
| 
 | |
|     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
 | |
| };
 | 
