diff --git a/apps/client/src/menus/context_menu.ts b/apps/client/src/menus/context_menu.ts index 9efdac65f..d4e9693d8 100644 --- a/apps/client/src/menus/context_menu.ts +++ b/apps/client/src/menus/context_menu.ts @@ -2,7 +2,7 @@ import { KeyboardActionNames } from "@triliumnext/commons"; import keyboardActionService, { getActionSync } from "../services/keyboard_actions.js"; import note_tooltip from "../services/note_tooltip.js"; import utils from "../services/utils.js"; -import { should } from "vitest"; +import { h, JSX, render } from "preact"; export interface ContextMenuOptions { x: number; @@ -15,6 +15,11 @@ export interface ContextMenuOptions { onHide?: () => void; } +export interface CustomMenuItem { + kind: "custom", + componentFn: () => JSX.Element; +} + export interface MenuSeparatorItem { kind: "separator"; } @@ -51,7 +56,7 @@ export interface MenuCommandItem { columns?: number; } -export type MenuItem = MenuCommandItem | MenuSeparatorItem | MenuHeader; +export type MenuItem = MenuCommandItem | CustomMenuItem | MenuSeparatorItem | MenuHeader; export type MenuHandler = (item: MenuCommandItem, e: JQuery.MouseDownEvent) => void; export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEvent; @@ -202,118 +207,14 @@ class ContextMenu { $group.append($("
").addClass("dropdown-header").text(item.title)); shouldResetGroup = true; } else { - const $icon = $(""); - - if ("uiIcon" in item || "checked" in item) { - const icon = (item.checked ? "bx bx-check" : item.uiIcon); - if (icon) { - $icon.addClass(icon); - } else { - $icon.append(" "); - } + if ("kind" in item && item.kind === "custom") { + // Custom menu item + $group.append(this.createCustomMenuItem(item)); + } else { + // Standard menu item + $group.append(this.createMenuItem(item)); } - const $link = $("") - .append($icon) - .append("   ") // some space between icon and text - .append(item.title); - - if ("badges" in item && item.badges) { - for (let badge of item.badges) { - const badgeElement = $(``).text(badge.title); - - if (badge.className) { - badgeElement.addClass(badge.className); - } - - $link.append(badgeElement); - } - } - - if ("keyboardShortcut" in item && item.keyboardShortcut) { - const shortcuts = getActionSync(item.keyboardShortcut).effectiveShortcuts; - if (shortcuts) { - const allShortcuts: string[] = []; - for (const effectiveShortcut of shortcuts) { - allShortcuts.push(effectiveShortcut.split("+") - .map(key => `${key}`) - .join("+")); - } - - if (allShortcuts.length) { - const container = $("").addClass("keyboard-shortcut"); - container.append($(allShortcuts.join(","))); - $link.append(container); - } - } - } else if ("shortcut" in item && item.shortcut) { - $link.append($("").text(item.shortcut)); - } - - const $item = $("
  • ") - .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; - } - - 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; - }); - - $item.on("mouseup", (e) => { - // Prevent submenu from failing to expand on mobile - if (!this.isMobile || !("items" in item && item.items)) { - e.stopPropagation(); - // Hide the content menu on mouse up to prevent the mouse event from propagating to the elements below. - this.hide(); - 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 = $("
      ").addClass("dropdown-menu"); - const hasColumns = !!item.columns && item.columns > 1; - if (!this.isMobile && hasColumns) { - $subMenu.css("column-count", item.columns!); - } - - this.addItems($subMenu, item.items, hasColumns); - - $item.append($subMenu); - } - - $group.append($item); - // After adding a menu item, if the previous item was a separator or header, // reset the group so that the next item will be appended directly to the parent. if (shouldResetGroup) { @@ -324,6 +225,126 @@ class ContextMenu { } } + private createCustomMenuItem(item: CustomMenuItem) { + const element = document.createElement("li"); + element.classList.add("dropdown-custom-item"); + render(h(item.componentFn, {}), element); + return element; + } + + private createMenuItem(item: MenuCommandItem) { + const $icon = $(""); + + if ("uiIcon" in item || "checked" in item) { + const icon = (item.checked ? "bx bx-check" : item.uiIcon); + if (icon) { + $icon.addClass(icon); + } else { + $icon.append(" "); + } + } + + const $link = $("") + .append($icon) + .append("   ") // some space between icon and text + .append(item.title); + + if ("badges" in item && item.badges) { + for (let badge of item.badges) { + const badgeElement = $(``).text(badge.title); + + if (badge.className) { + badgeElement.addClass(badge.className); + } + + $link.append(badgeElement); + } + } + + if ("keyboardShortcut" in item && item.keyboardShortcut) { + const shortcuts = getActionSync(item.keyboardShortcut).effectiveShortcuts; + if (shortcuts) { + const allShortcuts: string[] = []; + for (const effectiveShortcut of shortcuts) { + allShortcuts.push(effectiveShortcut.split("+") + .map(key => `${key}`) + .join("+")); + } + + if (allShortcuts.length) { + const container = $("").addClass("keyboard-shortcut"); + container.append($(allShortcuts.join(","))); + $link.append(container); + } + } + } else if ("shortcut" in item && item.shortcut) { + $link.append($("").text(item.shortcut)); + } + + const $item = $("
    • ") + .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; + } + + 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; + }); + + $item.on("mouseup", (e) => { + // Prevent submenu from failing to expand on mobile + if (!this.isMobile || !("items" in item && item.items)) { + e.stopPropagation(); + // Hide the content menu on mouse up to prevent the mouse event from propagating to the elements below. + this.hide(); + 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 = $("
        ").addClass("dropdown-menu"); + const hasColumns = !!item.columns && item.columns > 1; + if (!this.isMobile && hasColumns) { + $subMenu.css("column-count", item.columns!); + } + + this.addItems($subMenu, item.items, hasColumns); + + $item.append($subMenu); + } + return $item; + } + async hide() { this.options?.onHide?.(); this.$widget.removeClass("show");