459 lines
14 KiB
TypeScript
Raw Normal View History

import treeService from './tree.js';
2022-08-05 16:44:26 +02:00
import linkContextMenuService from "../menus/link_context_menu.js";
import appContext, { NoteCommandData } from "../components/app_context.js";
2021-04-16 23:01:56 +02:00
import froca from "./froca.js";
import utils from "./utils.js";
2017-11-04 17:07:03 -04:00
2024-12-21 22:37:19 +02:00
function getNotePathFromUrl(url: string) {
2022-11-28 23:39:23 +01:00
const notePathMatch = /#(root[A-Za-z0-9_/]*)$/.exec(url);
2017-11-04 17:07:03 -04:00
return notePathMatch === null ? null : notePathMatch[1];
}
2017-11-04 17:07:03 -04:00
2024-12-21 22:37:19 +02:00
async function getLinkIcon(noteId: string, viewMode: ViewMode | undefined) {
2023-05-29 10:21:34 +02:00
let icon;
2024-12-21 22:37:19 +02:00
if (!viewMode || viewMode === 'default') {
2023-05-29 10:21:34 +02:00
const note = await froca.getNote(noteId);
2024-12-21 22:37:19 +02:00
icon = note?.getIcon();
2023-05-29 10:21:34 +02:00
} else if (viewMode === 'source') {
icon = 'bx bx-code-curly';
} else if (viewMode === 'attachments') {
icon = 'bx bx-file';
}
return icon;
}
2024-12-21 22:37:19 +02:00
type ViewMode = "default" | "source" | "attachments" | string;
export interface ViewScope {
2024-12-21 22:37:19 +02:00
viewMode?: ViewMode;
attachmentId?: string;
readOnlyTemporarilyDisabled?: boolean;
2025-01-07 12:34:10 +02:00
highlightsListPreviousVisible?: boolean;
highlightsListTemporarilyHidden?: boolean;
2024-12-21 22:37:19 +02:00
}
interface CreateLinkOptions {
title?: string;
showTooltip?: boolean;
showNotePath?: boolean;
showNoteIcon?: boolean;
referenceLink?: boolean;
autoConvertToImage?: boolean;
viewScope?: ViewScope;
}
async function createLink(notePath: string, options: CreateLinkOptions = {}) {
2020-01-03 10:48:36 +01:00
if (!notePath || !notePath.trim()) {
logError("Missing note path");
2020-01-03 10:48:36 +01:00
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}`;
}
2019-12-28 21:10:02 +01:00
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;
2019-12-28 21:10:02 +01:00
const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath);
2024-12-21 22:37:19 +02:00
if (!noteId) {
logError("Missing note ID");
return $("<span>").text("[missing note]");
}
2023-05-29 00:19:54 +02:00
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]';
2024-12-21 22:37:19 +02:00
} else if (noteId) {
2023-05-29 00:19:54 +02:00
linkTitle = await treeService.getNoteTitle(noteId, parentNoteId);
}
}
const note = await froca.getNote(noteId);
2024-12-21 22:37:19 +02:00
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()}`)
2024-12-21 22:37:19 +02:00
.attr("alt", linkTitle || "");
}
const $container = $("<span>");
if (showNoteIcon) {
2023-05-29 10:21:34 +02:00
let icon = await getLinkIcon(noteId, viewMode);
2023-05-29 00:19:54 +02:00
if (icon) {
$container
.append($("<span>").addClass(`bx ${icon}`))
.append(" ");
}
}
const hash = calculateHash({
notePath,
2023-05-29 00:19:54 +02:00
viewScope: viewScope
});
const $noteLink = $("<a>", {
href: hash,
2023-05-29 00:19:54 +02:00
text: linkTitle
});
2019-12-28 21:10:02 +01:00
if (!showTooltip) {
2019-10-01 21:41:20 +02:00
$noteLink.addClass("no-tooltip-preview");
}
if (referenceLink) {
$noteLink.addClass("reference-link");
}
$container.append($noteLink);
2019-12-28 21:10:02 +01:00
if (showNotePath) {
2024-12-21 22:37:19 +02:00
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)));
}
}
}
2019-12-28 21:10:02 +01:00
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];
2024-12-21 22:37:19 +02:00
const value = (pair as Record<string, string | undefined>)[name];
2024-12-21 22:37:19 +02:00
return `${encodeURIComponent(name)}=${encodeURIComponent(value || "")}`;
}).join("&");
if (!notePath && !paramStr) {
return "";
}
let hash = `#${notePath}`;
if (paramStr) {
hash += `?${paramStr}`;
}
return hash;
}
2024-12-21 22:37:19 +02:00
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 '#'
const [notePath, paramString] = hash.split("?");
2023-08-02 23:23:31 +02:00
if (!notePath.match(/^[_a-z0-9]{4,}(\/[_a-z0-9]{4,})*$/i)) {
return {};
}
2024-12-21 22:37:19 +02:00
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)) {
2024-12-21 22:37:19 +02:00
(viewScope as any)[name] = value;
} else {
console.warn(`Unrecognized hash parameter '${name}'.`);
}
}
}
return {
notePath,
noteId: treeService.getNoteIdFromUrl(notePath),
ntxId,
hoistedNoteId,
viewScope,
searchString
};
}
function goToLink(evt: MouseEvent | JQuery.ClickEvent) {
2024-12-21 22:37:19 +02:00
const $link = $(evt.target as any).closest("a,.block-link");
const hrefLink = $link.attr('href') || $link.attr('data-href');
2022-07-29 00:32:28 +02:00
return goToLinkExt(evt, hrefLink, $link);
}
function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent, hrefLink: string | undefined, $link: JQuery<HTMLElement>) {
2023-04-11 17:45:51 +02:00
if (hrefLink?.startsWith("data:")) {
2022-07-29 00:32:28 +02:00
return true;
}
evt.preventDefault();
evt.stopPropagation();
if (hrefLink?.startsWith("#fn")) {
return handleFootnote(hrefLink, $link);
}
const {notePath, viewScope} = parseNavigationStateFromUrl(hrefLink);
const ctrlKey = utils.isCtrlKey(evt);
2023-04-03 23:47:24 +02:00
const isLeftClick = evt.which === 1;
const isMiddleClick = evt.which === 2;
const openInNewTab = (isLeftClick && ctrlKey) || isMiddleClick;
const leftClick = evt.which === 1;
const middleClick = evt.which === 2;
if (notePath) {
2023-04-03 23:47:24 +02:00
if (openInNewTab) {
appContext.tabManager.openTabWithNoteWithHoisting(notePath, {viewScope});
} else if (isLeftClick) {
2024-12-21 22:37:19 +02:00
const ntxId = $(evt.target as any).closest("[data-ntx-id]").attr("data-ntx-id");
2021-05-21 22:44:08 +02:00
2021-05-22 12:26:45 +02:00
const noteContext = ntxId
? appContext.tabManager.getNoteContextById(ntxId)
2021-05-22 12:35:41 +02:00
: appContext.tabManager.getActiveContext();
2021-05-21 22:44:08 +02:00
noteContext.setNote(notePath, {viewScope}).then(() => {
2021-05-22 12:35:41 +02:00
if (noteContext !== appContext.tabManager.getActiveContext()) {
appContext.tabManager.activateNoteContext(noteContext.ntxId);
2021-05-21 22:44:08 +02:00
}
});
2019-05-08 19:10:45 +02:00
}
} else if (hrefLink) {
const withinEditLink = $link?.hasClass("ck-link-actions__preview");
const outsideOfCKEditor = !$link || $link.closest("[contenteditable]").length === 0;
if (openInNewTab
|| (withinEditLink && (leftClick || middleClick))
|| (outsideOfCKEditor && (leftClick || middleClick))
2020-09-27 23:02:21 +02:00
) {
if (hrefLink.toLowerCase().startsWith('http') || hrefLink.startsWith("api/")) {
2023-04-11 17:45:51 +02:00
window.open(hrefLink, '_blank');
2025-01-05 13:54:19 +01:00
} else if ((hrefLink.toLowerCase().startsWith('file:') || hrefLink.toLowerCase().startsWith('geo:')) && utils.isElectron()) {
2023-04-11 17:45:51 +02:00
const electron = utils.dynamicRequire('electron');
electron.shell.openPath(hrefLink);
2024-11-14 11:15:38 +08:00
} else {
// Enable protocols supported by CKEditor 5 to be clickable.
2024-11-14 11:15:38 +08:00
// Refer to `allowedProtocols` in https://github.com/TriliumNext/trilium-ckeditor5/blob/main/packages/ckeditor5-build-balloon-block/src/ckeditor.ts.
2024-11-29 18:01:12 +08:00
// And be consistent with `allowedSchemes` in `src\services\html_sanitizer.ts`
const allowedSchemes = [
'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',
2025-01-05 13:54:19 +01:00
'view-source', 'vlc', 'vnc', 'ws', 'wss', 'xmpp', 'jdbc', 'slack', 'tel', 'smb', 'zotero', 'geo'
2024-11-29 18:01:12 +08:00
];
if (allowedSchemes.some(protocol => hrefLink.toLowerCase().startsWith(protocol+':'))){
2024-11-14 11:15:38 +08:00
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) {
2024-12-21 22:37:19 +02:00
const $link = $(e.target as any).closest("a");
const url = $link.attr("href") || $link.attr("data-href");
const { notePath, viewScope } = parseNavigationStateFromUrl(url);
if (!notePath) {
return;
}
e.preventDefault();
2023-04-03 23:47:24 +02:00
linkContextMenuService.openContextMenu(notePath, e, viewScope, null);
}
2024-12-21 22:37:19 +02:00
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");
2023-05-29 10:21:34 +02:00
if (!href) {
console.warn("Empty URL for parsing: " + $el[0].outerHTML);
2023-05-29 00:19:54 +02:00
return;
}
2023-05-29 10:21:34 +02:00
const {noteId, viewScope} = parseNavigationStateFromUrl(href);
2024-12-21 22:37:19 +02:00
if (!noteId) {
console.warn("Missing note ID.");
return;
}
2021-04-16 22:57:37 +02:00
const note = await froca.getNote(noteId, true);
2023-05-29 10:21:34 +02:00
if (note) {
$el.addClass(note.getColorClass());
}
const title = await getReferenceLinkTitle(href);
$el.text(title);
if (note) {
const icon = await getLinkIcon(noteId, viewScope.viewMode);
2024-12-21 22:37:19 +02:00
if (icon) {
$el.prepend($("<span>").addClass(icon));
}
2023-05-29 10:21:34 +02:00
}
}
2024-12-21 22:37:19 +02:00
async function getReferenceLinkTitle(href: string) {
2023-05-29 10:21:34 +02:00
const {noteId, viewScope} = parseNavigationStateFromUrl(href);
if (!noteId) {
return "[missing note]";
}
const note = await froca.getNote(noteId);
if (!note) {
2023-05-29 10:21:34 +02:00
return "[missing note]";
}
2023-05-29 10:21:34 +02:00
if (viewScope?.viewMode === 'attachments' && viewScope?.attachmentId) {
const attachment = await note.getAttachmentById(viewScope.attachmentId);
return attachment ? attachment.title : "[missing attachment]";
} else {
return note.title;
}
2023-05-29 10:21:34 +02:00
}
2024-12-21 22:37:19 +02:00
function getReferenceLinkTitleSync(href: string) {
2023-05-29 10:21:34 +02:00
const {noteId, viewScope} = parseNavigationStateFromUrl(href);
if (!noteId) {
return "[missing note]";
}
2023-05-29 10:21:34 +02:00
const note = froca.getNoteFromCache(noteId);
if (!note) {
return "[missing note]";
}
2022-09-24 22:38:20 +02:00
2023-05-29 10:21:34 +02:00
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;
}
}
2024-12-21 22:37:19 +02:00
// TODO: Check why the event is not supported.
//@ts-ignore
2020-08-19 17:59:55 +02:00
$(document).on('click', "a", goToLink);
2024-12-21 22:37:19 +02:00
// TODO: Check why the event is not supported.
//@ts-ignore
2023-05-05 23:41:11 +02:00
$(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,
2023-05-29 00:19:54 +02:00
createLink,
goToLink,
goToLinkExt,
2023-04-11 17:45:51 +02:00
loadReferenceLinkTitle,
2023-05-29 10:21:34 +02:00
getReferenceLinkTitle,
getReferenceLinkTitleSync,
calculateHash,
parseNavigationStateFromUrl
};