mirror of
https://github.com/zadam/trilium.git
synced 2025-12-20 15:19:56 +01:00
chore(nx): move all monorepo-style in subfolder for processing
This commit is contained in:
@@ -1,246 +0,0 @@
|
||||
import keyboardActionService from "../services/keyboard_actions.js";
|
||||
import note_tooltip from "../services/note_tooltip.js";
|
||||
import utils from "../services/utils.js";
|
||||
|
||||
interface ContextMenuOptions<T> {
|
||||
x: number;
|
||||
y: number;
|
||||
orientation?: "left";
|
||||
selectMenuItemHandler: MenuHandler<T>;
|
||||
items: MenuItem<T>[];
|
||||
/** On mobile, if set to `true` then the context menu is shown near the element. If `false` (default), then the context menu is shown at the bottom of the screen. */
|
||||
forcePositionOnMobile?: boolean;
|
||||
}
|
||||
|
||||
interface MenuSeparatorItem {
|
||||
title: "----";
|
||||
}
|
||||
|
||||
export interface MenuCommandItem<T> {
|
||||
title: string;
|
||||
command?: T;
|
||||
type?: string;
|
||||
uiIcon?: string;
|
||||
templateNoteId?: string;
|
||||
enabled?: boolean;
|
||||
handler?: MenuHandler<T>;
|
||||
items?: MenuItem<T>[] | null;
|
||||
shortcut?: string;
|
||||
spellingSuggestion?: string;
|
||||
}
|
||||
|
||||
export type MenuItem<T> = MenuCommandItem<T> | MenuSeparatorItem;
|
||||
export type MenuHandler<T> = (item: MenuCommandItem<T>, e: JQuery.MouseDownEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => void;
|
||||
export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEvent;
|
||||
|
||||
class ContextMenu {
|
||||
private $widget: JQuery<HTMLElement>;
|
||||
private $cover: JQuery<HTMLElement>;
|
||||
private dateContextMenuOpenedMs: number;
|
||||
private options?: ContextMenuOptions<any>;
|
||||
private isMobile: boolean;
|
||||
|
||||
constructor() {
|
||||
this.$widget = $("#context-menu-container");
|
||||
this.$cover = $("#context-menu-cover");
|
||||
this.$widget.addClass("dropend");
|
||||
this.dateContextMenuOpenedMs = 0;
|
||||
this.isMobile = utils.isMobile();
|
||||
|
||||
if (this.isMobile) {
|
||||
this.$cover.on("click", () => this.hide());
|
||||
} else {
|
||||
$(document).on("click", (e) => this.hide());
|
||||
}
|
||||
}
|
||||
|
||||
async show<T>(options: ContextMenuOptions<T>) {
|
||||
this.options = options;
|
||||
|
||||
note_tooltip.dismissAllTooltips();
|
||||
|
||||
if (this.$widget.hasClass("show")) {
|
||||
// The menu is already visible. Hide the menu then open it again
|
||||
// at the new location to re-trigger the opening animation.
|
||||
await this.hide();
|
||||
}
|
||||
|
||||
this.$widget.toggleClass("mobile-bottom-menu", !this.options.forcePositionOnMobile);
|
||||
this.$cover.addClass("show");
|
||||
$("body").addClass("context-menu-shown");
|
||||
|
||||
this.$widget.empty();
|
||||
|
||||
this.addItems(this.$widget, options.items);
|
||||
|
||||
keyboardActionService.updateDisplayedShortcuts(this.$widget);
|
||||
|
||||
this.positionMenu();
|
||||
|
||||
this.dateContextMenuOpenedMs = Date.now();
|
||||
}
|
||||
|
||||
positionMenu() {
|
||||
if (!this.options) {
|
||||
return;
|
||||
}
|
||||
|
||||
// the code below tries to detect when dropdown would overflow from page
|
||||
// in such case we'll position it above click coordinates, so it will fit into the client
|
||||
|
||||
const CONTEXT_MENU_PADDING = 5; // How many pixels to pad the context menu from edge of screen
|
||||
const CONTEXT_MENU_OFFSET = 0; // How many pixels to offset the context menu by relative to cursor, see #3157
|
||||
|
||||
const clientHeight = document.documentElement.clientHeight;
|
||||
const clientWidth = document.documentElement.clientWidth;
|
||||
const contextMenuHeight = this.$widget.outerHeight();
|
||||
const contextMenuWidth = this.$widget.outerWidth();
|
||||
let top, left;
|
||||
|
||||
if (contextMenuHeight && this.options.y + contextMenuHeight - CONTEXT_MENU_OFFSET > clientHeight - CONTEXT_MENU_PADDING) {
|
||||
// Overflow: bottom
|
||||
top = clientHeight - contextMenuHeight - CONTEXT_MENU_PADDING;
|
||||
} else if (this.options.y - CONTEXT_MENU_OFFSET < CONTEXT_MENU_PADDING) {
|
||||
// Overflow: top
|
||||
top = CONTEXT_MENU_PADDING;
|
||||
} else {
|
||||
top = this.options.y - CONTEXT_MENU_OFFSET;
|
||||
}
|
||||
|
||||
if (this.options.orientation === "left" && contextMenuWidth) {
|
||||
if (this.options.x + CONTEXT_MENU_OFFSET > clientWidth - CONTEXT_MENU_PADDING) {
|
||||
// Overflow: right
|
||||
left = clientWidth - contextMenuWidth - CONTEXT_MENU_OFFSET;
|
||||
} else if (this.options.x - contextMenuWidth + CONTEXT_MENU_OFFSET < CONTEXT_MENU_PADDING) {
|
||||
// Overflow: left
|
||||
left = CONTEXT_MENU_PADDING;
|
||||
} else {
|
||||
left = this.options.x - contextMenuWidth + CONTEXT_MENU_OFFSET;
|
||||
}
|
||||
} else {
|
||||
if (contextMenuWidth && this.options.x + contextMenuWidth - CONTEXT_MENU_OFFSET > clientWidth - CONTEXT_MENU_PADDING) {
|
||||
// Overflow: right
|
||||
left = clientWidth - contextMenuWidth - CONTEXT_MENU_PADDING;
|
||||
} else if (this.options.x - CONTEXT_MENU_OFFSET < CONTEXT_MENU_PADDING) {
|
||||
// Overflow: left
|
||||
left = CONTEXT_MENU_PADDING;
|
||||
} else {
|
||||
left = this.options.x - CONTEXT_MENU_OFFSET;
|
||||
}
|
||||
}
|
||||
|
||||
this.$widget
|
||||
.css({
|
||||
display: "block",
|
||||
top: top,
|
||||
left: left
|
||||
})
|
||||
.addClass("show");
|
||||
}
|
||||
|
||||
addItems($parent: JQuery<HTMLElement>, items: MenuItem<any>[]) {
|
||||
for (const item of items) {
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.title === "----") {
|
||||
$parent.append($("<div>").addClass("dropdown-divider"));
|
||||
} else {
|
||||
const $icon = $("<span>");
|
||||
|
||||
if ("uiIcon" in item && item.uiIcon) {
|
||||
$icon.addClass(item.uiIcon);
|
||||
} else {
|
||||
$icon.append(" ");
|
||||
}
|
||||
|
||||
const $link = $("<span>")
|
||||
.append($icon)
|
||||
.append(" ") // some space between icon and text
|
||||
.append(item.title);
|
||||
|
||||
if ("shortcut" in item && item.shortcut) {
|
||||
$link.append($("<kbd>").text(item.shortcut));
|
||||
}
|
||||
|
||||
const $item = $("<li>")
|
||||
.addClass("dropdown-item")
|
||||
.append($link)
|
||||
.on("contextmenu", (e) => false)
|
||||
// important to use mousedown instead of click since the former does not change focus
|
||||
// (especially important for focused text for spell check)
|
||||
.on("mousedown", (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.which !== 1) {
|
||||
// only left click triggers menu items
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isMobile && "items" in item && item.items) {
|
||||
const $item = $(e.target).closest(".dropdown-item");
|
||||
|
||||
$item.toggleClass("submenu-open");
|
||||
$item.find("ul.dropdown-menu").toggleClass("show");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.hide();
|
||||
|
||||
if ("handler" in item && item.handler) {
|
||||
item.handler(item, e);
|
||||
}
|
||||
|
||||
this.options?.selectMenuItemHandler(item, e);
|
||||
|
||||
// it's important to stop the propagation especially for sub-menus, otherwise the event
|
||||
// might be handled again by top-level menu
|
||||
return false;
|
||||
});
|
||||
|
||||
if ("enabled" in item && item.enabled !== undefined && !item.enabled) {
|
||||
$item.addClass("disabled");
|
||||
}
|
||||
|
||||
if ("items" in item && item.items) {
|
||||
$item.addClass("dropdown-submenu");
|
||||
$link.addClass("dropdown-toggle");
|
||||
|
||||
const $subMenu = $("<ul>").addClass("dropdown-menu");
|
||||
|
||||
this.addItems($subMenu, item.items);
|
||||
|
||||
$item.append($subMenu);
|
||||
}
|
||||
|
||||
$parent.append($item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async hide() {
|
||||
// this date checking comes from change in FF66 - https://github.com/zadam/trilium/issues/468
|
||||
// "contextmenu" event also triggers "click" event which depending on the timing can close the just opened context menu
|
||||
// we might filter out right clicks, but then it's better if even right clicks close the context menu
|
||||
if (Date.now() - this.dateContextMenuOpenedMs > 300) {
|
||||
// seems like if we hide the menu immediately, some clicks can get propagated to the underlying component
|
||||
// see https://github.com/zadam/trilium/pull/3805 for details
|
||||
await timeout(100);
|
||||
this.$widget.removeClass("show");
|
||||
this.$cover.removeClass("show");
|
||||
$("body").removeClass("context-menu-shown");
|
||||
this.$widget.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function timeout(ms: number) {
|
||||
return new Promise((accept, reject) => {
|
||||
setTimeout(accept, ms);
|
||||
});
|
||||
}
|
||||
|
||||
const contextMenu = new ContextMenu();
|
||||
|
||||
export default contextMenu;
|
||||
@@ -1,145 +0,0 @@
|
||||
import utils from "../services/utils.js";
|
||||
import options from "../services/options.js";
|
||||
import zoomService from "../components/zoom.js";
|
||||
import contextMenu, { type MenuItem } from "./context_menu.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import type { BrowserWindow } from "electron";
|
||||
import type { CommandNames } from "../components/app_context.js";
|
||||
|
||||
function setupContextMenu() {
|
||||
const electron = utils.dynamicRequire("electron");
|
||||
|
||||
const remote = utils.dynamicRequire("@electron/remote");
|
||||
// FIXME: Remove typecast once Electron is properly integrated.
|
||||
const { webContents } = remote.getCurrentWindow() as BrowserWindow;
|
||||
|
||||
webContents.on("context-menu", (event, params) => {
|
||||
const { editFlags } = params;
|
||||
const hasText = params.selectionText.trim().length > 0;
|
||||
const isMac = process.platform === "darwin";
|
||||
const platformModifier = isMac ? "Meta" : "Ctrl";
|
||||
|
||||
const items: MenuItem<CommandNames>[] = [];
|
||||
|
||||
if (params.misspelledWord) {
|
||||
for (const suggestion of params.dictionarySuggestions) {
|
||||
items.push({
|
||||
title: suggestion,
|
||||
command: "replaceMisspelling",
|
||||
spellingSuggestion: suggestion,
|
||||
uiIcon: "bx bx-empty"
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
title: t("electron_context_menu.add-term-to-dictionary", { term: params.misspelledWord }),
|
||||
uiIcon: "bx bx-plus",
|
||||
handler: () => webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
|
||||
});
|
||||
|
||||
items.push({ title: `----` });
|
||||
}
|
||||
|
||||
if (params.isEditable) {
|
||||
items.push({
|
||||
enabled: editFlags.canCut && hasText,
|
||||
title: t("electron_context_menu.cut"),
|
||||
shortcut: `${platformModifier}+X`,
|
||||
uiIcon: "bx bx-cut",
|
||||
handler: () => webContents.cut()
|
||||
});
|
||||
}
|
||||
|
||||
if (params.isEditable || hasText) {
|
||||
items.push({
|
||||
enabled: editFlags.canCopy && hasText,
|
||||
title: t("electron_context_menu.copy"),
|
||||
shortcut: `${platformModifier}+C`,
|
||||
uiIcon: "bx bx-copy",
|
||||
handler: () => webContents.copy()
|
||||
});
|
||||
}
|
||||
|
||||
if (!["", "javascript:", "about:blank#blocked"].includes(params.linkURL) && params.mediaType === "none") {
|
||||
items.push({
|
||||
title: t("electron_context_menu.copy-link"),
|
||||
uiIcon: "bx bx-copy",
|
||||
handler: () => {
|
||||
electron.clipboard.write({
|
||||
bookmark: params.linkText,
|
||||
text: params.linkURL
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (params.isEditable) {
|
||||
items.push({
|
||||
enabled: editFlags.canPaste,
|
||||
title: t("electron_context_menu.paste"),
|
||||
shortcut: `${platformModifier}+V`,
|
||||
uiIcon: "bx bx-paste",
|
||||
handler: () => webContents.paste()
|
||||
});
|
||||
}
|
||||
|
||||
if (params.isEditable) {
|
||||
items.push({
|
||||
enabled: editFlags.canPaste,
|
||||
title: t("electron_context_menu.paste-as-plain-text"),
|
||||
shortcut: `${platformModifier}+Shift+V`,
|
||||
uiIcon: "bx bx-paste",
|
||||
handler: () => webContents.pasteAndMatchStyle()
|
||||
});
|
||||
}
|
||||
|
||||
if (hasText) {
|
||||
const shortenedSelection = params.selectionText.length > 15 ? `${params.selectionText.substr(0, 13)}…` : params.selectionText;
|
||||
|
||||
// Read the search engine from the options and fallback to DuckDuckGo if the option is not set.
|
||||
const customSearchEngineName = options.get("customSearchEngineName");
|
||||
const customSearchEngineUrl = options.get("customSearchEngineUrl") as string;
|
||||
let searchEngineName;
|
||||
let searchEngineUrl;
|
||||
if (customSearchEngineName && customSearchEngineUrl) {
|
||||
searchEngineName = customSearchEngineName;
|
||||
searchEngineUrl = customSearchEngineUrl;
|
||||
} else {
|
||||
searchEngineName = "DuckDuckGo";
|
||||
searchEngineUrl = "https://duckduckgo.com/?q={keyword}";
|
||||
}
|
||||
|
||||
// Replace the placeholder with the real search keyword.
|
||||
let searchUrl = searchEngineUrl.replace("{keyword}", encodeURIComponent(params.selectionText));
|
||||
|
||||
items.push({ title: "----" });
|
||||
|
||||
items.push({
|
||||
title: t("electron_context_menu.search_online", { term: shortenedSelection, searchEngine: searchEngineName }),
|
||||
uiIcon: "bx bx-search-alt",
|
||||
handler: () => electron.shell.openExternal(searchUrl)
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const zoomLevel = zoomService.getCurrentZoom();
|
||||
|
||||
contextMenu.show({
|
||||
x: params.x / zoomLevel,
|
||||
y: params.y / zoomLevel,
|
||||
items,
|
||||
selectMenuItemHandler: ({ command, spellingSuggestion }) => {
|
||||
if (command === "replaceMisspelling" && spellingSuggestion) {
|
||||
webContents.insertText(spellingSuggestion);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
setupContextMenu
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
import { t } from "../services/i18n.js";
|
||||
import utils from "../services/utils.js";
|
||||
import contextMenu from "./context_menu.js";
|
||||
import imageService from "../services/image.js";
|
||||
|
||||
const PROP_NAME = "imageContextMenuInstalled";
|
||||
|
||||
function setupContextMenu($image: JQuery<HTMLElement>) {
|
||||
if (!utils.isElectron() || $image.prop(PROP_NAME)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$image.prop(PROP_NAME, true);
|
||||
$image.on("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
contextMenu.show({
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
items: [
|
||||
{
|
||||
title: t("image_context_menu.copy_reference_to_clipboard"),
|
||||
command: "copyImageReferenceToClipboard",
|
||||
uiIcon: "bx bx-directions"
|
||||
},
|
||||
{
|
||||
title: t("image_context_menu.copy_image_to_clipboard"),
|
||||
command: "copyImageToClipboard",
|
||||
uiIcon: "bx bx-copy"
|
||||
}
|
||||
],
|
||||
selectMenuItemHandler: async ({ command }) => {
|
||||
if (command === "copyImageReferenceToClipboard") {
|
||||
imageService.copyImageReferenceToClipboard($image);
|
||||
} else if (command === "copyImageToClipboard") {
|
||||
try {
|
||||
const nativeImage = utils.dynamicRequire("electron").nativeImage;
|
||||
const clipboard = utils.dynamicRequire("electron").clipboard;
|
||||
|
||||
const src = $image.attr("src");
|
||||
if (!src) {
|
||||
console.error("Missing src");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(src);
|
||||
const blob = await response.blob();
|
||||
|
||||
clipboard.writeImage(nativeImage.createFromBuffer(Buffer.from(await blob.arrayBuffer())));
|
||||
} catch (error) {
|
||||
console.error("Failed to copy image to clipboard:", error);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unrecognized command '${command}'`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
setupContextMenu
|
||||
};
|
||||
@@ -1,86 +0,0 @@
|
||||
import treeService from "../services/tree.js";
|
||||
import froca from "../services/froca.js";
|
||||
import contextMenu, { type MenuCommandItem, type MenuItem } from "./context_menu.js";
|
||||
import dialogService from "../services/dialog.js";
|
||||
import server from "../services/server.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import type { SelectMenuItemEventListener } from "../components/events.js";
|
||||
import type NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import type { FilteredCommandNames, ContextMenuCommandData } from "../components/app_context.js";
|
||||
|
||||
type LauncherCommandNames = FilteredCommandNames<ContextMenuCommandData>;
|
||||
|
||||
export default class LauncherContextMenu implements SelectMenuItemEventListener<LauncherCommandNames> {
|
||||
private treeWidget: NoteTreeWidget;
|
||||
private node: Fancytree.FancytreeNode;
|
||||
|
||||
constructor(treeWidget: NoteTreeWidget, node: Fancytree.FancytreeNode) {
|
||||
this.treeWidget = treeWidget;
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
async show(e: PointerEvent | JQuery.TouchStartEvent | JQuery.ContextMenuEvent) {
|
||||
contextMenu.show({
|
||||
x: e.pageX ?? 0,
|
||||
y: e.pageY ?? 0,
|
||||
items: await this.getMenuItems(),
|
||||
selectMenuItemHandler: (item, e) => this.selectMenuItemHandler(item)
|
||||
});
|
||||
}
|
||||
|
||||
async getMenuItems(): Promise<MenuItem<LauncherCommandNames>[]> {
|
||||
const note = this.node.data.noteId ? await froca.getNote(this.node.data.noteId) : null;
|
||||
const parentNoteId = this.node.getParent().data.noteId;
|
||||
|
||||
const isVisibleRoot = note?.noteId === "_lbVisibleLaunchers";
|
||||
const isAvailableRoot = note?.noteId === "_lbAvailableLaunchers";
|
||||
const isVisibleItem = parentNoteId === "_lbVisibleLaunchers" || parentNoteId === "_lbMobileVisibleLaunchers";
|
||||
const isAvailableItem = parentNoteId === "_lbAvailableLaunchers" || parentNoteId === "_lbMobileAvailableLaunchers";
|
||||
const isItem = isVisibleItem || isAvailableItem;
|
||||
const canBeDeleted = !note?.noteId.startsWith("_"); // fixed notes can't be deleted
|
||||
const canBeReset = !canBeDeleted && note?.isLaunchBarConfig();
|
||||
|
||||
const items: (MenuItem<LauncherCommandNames> | null)[] = [
|
||||
isVisibleRoot || isAvailableRoot ? { title: t("launcher_context_menu.add-note-launcher"), command: "addNoteLauncher", uiIcon: "bx bx-note" } : null,
|
||||
isVisibleRoot || isAvailableRoot ? { title: t("launcher_context_menu.add-script-launcher"), command: "addScriptLauncher", uiIcon: "bx bx-code-curly" } : null,
|
||||
isVisibleRoot || isAvailableRoot ? { title: t("launcher_context_menu.add-custom-widget"), command: "addWidgetLauncher", uiIcon: "bx bx-customize" } : null,
|
||||
isVisibleRoot || isAvailableRoot ? { title: t("launcher_context_menu.add-spacer"), command: "addSpacerLauncher", uiIcon: "bx bx-dots-horizontal" } : null,
|
||||
isVisibleRoot || isAvailableRoot ? { title: "----" } : null,
|
||||
|
||||
isAvailableItem ? { title: t("launcher_context_menu.move-to-visible-launchers"), command: "moveLauncherToVisible", uiIcon: "bx bx-show", enabled: true } : null,
|
||||
isVisibleItem ? { title: t("launcher_context_menu.move-to-available-launchers"), command: "moveLauncherToAvailable", uiIcon: "bx bx-hide", enabled: true } : null,
|
||||
isVisibleItem || isAvailableItem ? { title: "----" } : null,
|
||||
|
||||
{ title: `${t("launcher_context_menu.duplicate-launcher")}`, command: "duplicateSubtree", uiIcon: "bx bx-outline", enabled: isItem },
|
||||
{ title: `${t("launcher_context_menu.delete")}`, command: "deleteNotes", uiIcon: "bx bx-trash destructive-action-icon", enabled: canBeDeleted },
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
{ title: t("launcher_context_menu.reset"), command: "resetLauncher", uiIcon: "bx bx-reset destructive-action-icon", enabled: canBeReset }
|
||||
];
|
||||
return items.filter((row) => row !== null) as MenuItem<LauncherCommandNames>[];
|
||||
}
|
||||
|
||||
async selectMenuItemHandler({ command }: MenuCommandItem<LauncherCommandNames>) {
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "resetLauncher") {
|
||||
const confirmed = await dialogService.confirm(t("launcher_context_menu.reset_launcher_confirm", { title: this.node.title }));
|
||||
|
||||
if (confirmed) {
|
||||
await server.post(`special-notes/launchers/${this.node.data.noteId}/reset`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.treeWidget.triggerCommand(command, {
|
||||
node: this.node,
|
||||
notePath: treeService.getNotePath(this.node),
|
||||
selectedOrActiveBranchIds: this.treeWidget.getSelectedOrActiveBranchIds(this.node),
|
||||
selectedOrActiveNoteIds: this.treeWidget.getSelectedOrActiveNoteIds(this.node)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { t } from "../services/i18n.js";
|
||||
import contextMenu, { type ContextMenuEvent, type MenuItem } from "./context_menu.js";
|
||||
import appContext, { type CommandNames } from "../components/app_context.js";
|
||||
import type { ViewScope } from "../services/link.js";
|
||||
|
||||
function openContextMenu(notePath: string, e: ContextMenuEvent, viewScope: ViewScope = {}, hoistedNoteId: string | null = null) {
|
||||
contextMenu.show({
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
items: getItems(),
|
||||
selectMenuItemHandler: ({ command }) => handleLinkContextMenuItem(command, notePath, viewScope, hoistedNoteId)
|
||||
});
|
||||
}
|
||||
|
||||
function getItems(): MenuItem<CommandNames>[] {
|
||||
return [
|
||||
{ title: t("link_context_menu.open_note_in_new_tab"), command: "openNoteInNewTab", uiIcon: "bx bx-link-external" },
|
||||
{ title: t("link_context_menu.open_note_in_new_split"), command: "openNoteInNewSplit", uiIcon: "bx bx-dock-right" },
|
||||
{ title: t("link_context_menu.open_note_in_new_window"), command: "openNoteInNewWindow", uiIcon: "bx bx-window-open" }
|
||||
];
|
||||
}
|
||||
|
||||
function handleLinkContextMenuItem(command: string | undefined, notePath: string, viewScope = {}, hoistedNoteId: string | null = null) {
|
||||
if (!hoistedNoteId) {
|
||||
hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId ?? null;
|
||||
}
|
||||
|
||||
if (command === "openNoteInNewTab") {
|
||||
appContext.tabManager.openContextWithNote(notePath, { hoistedNoteId, viewScope });
|
||||
} else if (command === "openNoteInNewSplit") {
|
||||
const subContexts = appContext.tabManager.getActiveContext()?.getSubContexts();
|
||||
|
||||
if (!subContexts) {
|
||||
logError("subContexts is null");
|
||||
return;
|
||||
}
|
||||
|
||||
const { ntxId } = subContexts[subContexts.length - 1];
|
||||
|
||||
appContext.triggerCommand("openNewNoteSplit", { ntxId, notePath, hoistedNoteId, viewScope });
|
||||
} else if (command === "openNoteInNewWindow") {
|
||||
appContext.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope });
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getItems,
|
||||
handleLinkContextMenuItem,
|
||||
openContextMenu
|
||||
};
|
||||
@@ -1,270 +0,0 @@
|
||||
import treeService from "../services/tree.js";
|
||||
import froca from "../services/froca.js";
|
||||
import clipboard from "../services/clipboard.js";
|
||||
import noteCreateService from "../services/note_create.js";
|
||||
import contextMenu, { type MenuCommandItem, type MenuItem } from "./context_menu.js";
|
||||
import appContext, { type ContextMenuCommandData, type FilteredCommandNames } from "../components/app_context.js";
|
||||
import noteTypesService from "../services/note_types.js";
|
||||
import server from "../services/server.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import dialogService from "../services/dialog.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import type NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import type FAttachment from "../entities/fattachment.js";
|
||||
import type { SelectMenuItemEventListener } from "../components/events.js";
|
||||
import utils from "../services/utils.js";
|
||||
|
||||
// TODO: Deduplicate once client/server is well split.
|
||||
interface ConvertToAttachmentResponse {
|
||||
attachment?: FAttachment;
|
||||
}
|
||||
|
||||
// This will include all commands that implement ContextMenuCommandData, but it will not work if it additional options are added via the `|` operator,
|
||||
// so they need to be added manually.
|
||||
export type TreeCommandNames = FilteredCommandNames<ContextMenuCommandData> | "openBulkActionsDialog";
|
||||
|
||||
export default class TreeContextMenu implements SelectMenuItemEventListener<TreeCommandNames> {
|
||||
private treeWidget: NoteTreeWidget;
|
||||
private node: Fancytree.FancytreeNode;
|
||||
|
||||
constructor(treeWidget: NoteTreeWidget, node: Fancytree.FancytreeNode) {
|
||||
this.treeWidget = treeWidget;
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
async show(e: PointerEvent | JQuery.TouchStartEvent | JQuery.ContextMenuEvent) {
|
||||
contextMenu.show({
|
||||
x: e.pageX ?? 0,
|
||||
y: e.pageY ?? 0,
|
||||
items: await this.getMenuItems(),
|
||||
selectMenuItemHandler: (item, e) => this.selectMenuItemHandler(item)
|
||||
});
|
||||
}
|
||||
|
||||
async getMenuItems(): Promise<MenuItem<TreeCommandNames>[]> {
|
||||
const note = this.node.data.noteId ? await froca.getNote(this.node.data.noteId) : null;
|
||||
const branch = froca.getBranch(this.node.data.branchId);
|
||||
const isNotRoot = note?.noteId !== "root";
|
||||
const isHoisted = note?.noteId === appContext.tabManager.getActiveContext()?.hoistedNoteId;
|
||||
const parentNote = isNotRoot && branch ? await froca.getNote(branch.parentNoteId) : null;
|
||||
|
||||
// some actions don't support multi-note, so they are disabled when notes are selected,
|
||||
// the only exception is when the only selected note is the one that was right-clicked, then
|
||||
// it's clear what the user meant to do.
|
||||
const selNodes = this.treeWidget.getSelectedNodes();
|
||||
const noSelectedNotes = selNodes.length === 0 || (selNodes.length === 1 && selNodes[0] === this.node);
|
||||
|
||||
const notSearch = note?.type !== "search";
|
||||
const notOptionsOrHelp = !note?.noteId.startsWith("_options") && !note?.noteId.startsWith("_help");
|
||||
const parentNotSearch = !parentNote || parentNote.type !== "search";
|
||||
const insertNoteAfterEnabled = isNotRoot && !isHoisted && parentNotSearch;
|
||||
|
||||
const items: (MenuItem<TreeCommandNames> | null)[] = [
|
||||
{ title: `${t("tree-context-menu.open-in-a-new-tab")}`, command: "openInTab", uiIcon: "bx bx-link-external", enabled: noSelectedNotes },
|
||||
|
||||
{ title: t("tree-context-menu.open-in-a-new-split"), command: "openNoteInSplit", uiIcon: "bx bx-dock-right", enabled: noSelectedNotes },
|
||||
|
||||
isHoisted
|
||||
? null
|
||||
: {
|
||||
title: `${t("tree-context-menu.hoist-note")} <kbd data-command="toggleNoteHoisting"></kbd>`,
|
||||
command: "toggleNoteHoisting",
|
||||
uiIcon: "bx bxs-chevrons-up",
|
||||
enabled: noSelectedNotes && notSearch
|
||||
},
|
||||
!isHoisted || !isNotRoot
|
||||
? null
|
||||
: { title: `${t("tree-context-menu.unhoist-note")} <kbd data-command="toggleNoteHoisting"></kbd>`, command: "toggleNoteHoisting", uiIcon: "bx bx-door-open" },
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.insert-note-after")}<kbd data-command="createNoteAfter"></kbd>`,
|
||||
command: "insertNoteAfter",
|
||||
uiIcon: "bx bx-plus",
|
||||
items: insertNoteAfterEnabled ? await noteTypesService.getNoteTypeItems("insertNoteAfter") : null,
|
||||
enabled: insertNoteAfterEnabled && noSelectedNotes && notOptionsOrHelp
|
||||
},
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.insert-child-note")}<kbd data-command="createNoteInto"></kbd>`,
|
||||
command: "insertChildNote",
|
||||
uiIcon: "bx bx-plus",
|
||||
items: notSearch ? await noteTypesService.getNoteTypeItems("insertChildNote") : null,
|
||||
enabled: notSearch && noSelectedNotes && notOptionsOrHelp
|
||||
},
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
{ title: t("tree-context-menu.protect-subtree"), command: "protectSubtree", uiIcon: "bx bx-check-shield", enabled: noSelectedNotes },
|
||||
|
||||
{ title: t("tree-context-menu.unprotect-subtree"), command: "unprotectSubtree", uiIcon: "bx bx-shield", enabled: noSelectedNotes },
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
{
|
||||
title: t("tree-context-menu.advanced"),
|
||||
uiIcon: "bx bxs-wrench",
|
||||
enabled: true,
|
||||
items: [
|
||||
{ title: t("tree-context-menu.apply-bulk-actions"), command: "openBulkActionsDialog", uiIcon: "bx bx-list-plus", enabled: true },
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.edit-branch-prefix")} <kbd data-command="editBranchPrefix"></kbd>`,
|
||||
command: "editBranchPrefix",
|
||||
uiIcon: "bx bx-rename",
|
||||
enabled: isNotRoot && parentNotSearch && noSelectedNotes && notOptionsOrHelp
|
||||
},
|
||||
{ title: t("tree-context-menu.convert-to-attachment"), command: "convertNoteToAttachment", uiIcon: "bx bx-paperclip", enabled: isNotRoot && !isHoisted && notOptionsOrHelp },
|
||||
{
|
||||
title: `${t("tree-context-menu.duplicate-subtree")} <kbd data-command="duplicateSubtree">`,
|
||||
command: "duplicateSubtree",
|
||||
uiIcon: "bx bx-outline",
|
||||
enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp
|
||||
},
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
{ title: `${t("tree-context-menu.expand-subtree")} <kbd data-command="expandSubtree"></kbd>`, command: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes },
|
||||
{ title: `${t("tree-context-menu.collapse-subtree")} <kbd data-command="collapseSubtree"></kbd>`, command: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes },
|
||||
{
|
||||
title: `${t("tree-context-menu.sort-by")} <kbd data-command="sortChildNotes"></kbd>`,
|
||||
command: "sortChildNotes",
|
||||
uiIcon: "bx bx-sort-down",
|
||||
enabled: noSelectedNotes && notSearch
|
||||
},
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
{ title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true },
|
||||
{ title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: noSelectedNotes && notOptionsOrHelp }
|
||||
]
|
||||
},
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.cut")} <kbd data-command="cutNotesToClipboard"></kbd>`,
|
||||
command: "cutNotesToClipboard",
|
||||
uiIcon: "bx bx-cut",
|
||||
enabled: isNotRoot && !isHoisted && parentNotSearch
|
||||
},
|
||||
|
||||
{ title: `${t("tree-context-menu.copy-clone")} <kbd data-command="copyNotesToClipboard"></kbd>`, command: "copyNotesToClipboard", uiIcon: "bx bx-copy", enabled: isNotRoot && !isHoisted },
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.paste-into")} <kbd data-command="pasteNotesFromClipboard"></kbd>`,
|
||||
command: "pasteNotesFromClipboard",
|
||||
uiIcon: "bx bx-paste",
|
||||
enabled: !clipboard.isClipboardEmpty() && notSearch && noSelectedNotes
|
||||
},
|
||||
|
||||
{
|
||||
title: t("tree-context-menu.paste-after"),
|
||||
command: "pasteNotesAfterFromClipboard",
|
||||
uiIcon: "bx bx-paste",
|
||||
enabled: !clipboard.isClipboardEmpty() && isNotRoot && !isHoisted && parentNotSearch && noSelectedNotes
|
||||
},
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.move-to")} <kbd data-command="moveNotesTo"></kbd>`,
|
||||
command: "moveNotesTo",
|
||||
uiIcon: "bx bx-transfer",
|
||||
enabled: isNotRoot && !isHoisted && parentNotSearch
|
||||
},
|
||||
|
||||
{ title: `${t("tree-context-menu.clone-to")} <kbd data-command="cloneNotesTo"></kbd>`, command: "cloneNotesTo", uiIcon: "bx bx-duplicate", enabled: isNotRoot && !isHoisted },
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.delete")} <kbd data-command="deleteNotes"></kbd>`,
|
||||
command: "deleteNotes",
|
||||
uiIcon: "bx bx-trash destructive-action-icon",
|
||||
enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp
|
||||
},
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
{ title: t("tree-context-menu.import-into-note"), command: "importIntoNote", uiIcon: "bx bx-import", enabled: notSearch && noSelectedNotes && notOptionsOrHelp },
|
||||
|
||||
{ title: t("tree-context-menu.export"), command: "exportNote", uiIcon: "bx bx-export", enabled: notSearch && noSelectedNotes && notOptionsOrHelp },
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
{
|
||||
title: `${t("tree-context-menu.search-in-subtree")} <kbd data-command="searchInSubtree"></kbd>`,
|
||||
command: "searchInSubtree",
|
||||
uiIcon: "bx bx-search",
|
||||
enabled: notSearch && noSelectedNotes
|
||||
}
|
||||
];
|
||||
return items.filter((row) => row !== null) as MenuItem<TreeCommandNames>[];
|
||||
}
|
||||
|
||||
async selectMenuItemHandler({ command, type, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
|
||||
const notePath = treeService.getNotePath(this.node);
|
||||
|
||||
if (utils.isMobile()) {
|
||||
this.treeWidget.triggerCommand("setActiveScreen", { screen: "detail" });
|
||||
}
|
||||
|
||||
if (command === "openInTab") {
|
||||
appContext.tabManager.openTabWithNoteWithHoisting(notePath);
|
||||
} else if (command === "insertNoteAfter") {
|
||||
const parentNotePath = treeService.getNotePath(this.node.getParent());
|
||||
const isProtected = treeService.getParentProtectedStatus(this.node);
|
||||
|
||||
noteCreateService.createNote(parentNotePath, {
|
||||
target: "after",
|
||||
targetBranchId: this.node.data.branchId,
|
||||
type: type,
|
||||
isProtected: isProtected,
|
||||
templateNoteId: templateNoteId
|
||||
});
|
||||
} else if (command === "insertChildNote") {
|
||||
const parentNotePath = treeService.getNotePath(this.node);
|
||||
|
||||
noteCreateService.createNote(parentNotePath, {
|
||||
type: type,
|
||||
isProtected: this.node.data.isProtected,
|
||||
templateNoteId: templateNoteId
|
||||
});
|
||||
} else if (command === "openNoteInSplit") {
|
||||
const subContexts = appContext.tabManager.getActiveContext()?.getSubContexts();
|
||||
const { ntxId } = subContexts?.[subContexts.length - 1] ?? {};
|
||||
|
||||
this.treeWidget.triggerCommand("openNewNoteSplit", { ntxId, notePath });
|
||||
} else if (command === "convertNoteToAttachment") {
|
||||
if (!(await dialogService.confirm(t("tree-context-menu.convert-to-attachment-confirm")))) {
|
||||
return;
|
||||
}
|
||||
|
||||
let converted = 0;
|
||||
|
||||
for (const noteId of this.treeWidget.getSelectedOrActiveNoteIds(this.node)) {
|
||||
const note = await froca.getNote(noteId);
|
||||
|
||||
if (note?.isEligibleForConversionToAttachment()) {
|
||||
const { attachment } = await server.post<ConvertToAttachmentResponse>(`notes/${note.noteId}/convert-to-attachment`);
|
||||
|
||||
if (attachment) {
|
||||
converted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toastService.showMessage(t("tree-context-menu.converted-to-attachments", { count: converted }));
|
||||
} else if (command === "copyNotePathToClipboard") {
|
||||
navigator.clipboard.writeText("#" + notePath);
|
||||
} else if (command) {
|
||||
this.treeWidget.triggerCommand<TreeCommandNames>(command, {
|
||||
node: this.node,
|
||||
notePath: notePath,
|
||||
noteId: this.node.data.noteId,
|
||||
selectedOrActiveBranchIds: this.treeWidget.getSelectedOrActiveBranchIds(this.node),
|
||||
selectedOrActiveNoteIds: this.treeWidget.getSelectedOrActiveNoteIds(this.node)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user