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"; export interface ContextMenuOptions { x: number; y: number; orientation?: "left"; selectMenuItemHandler: MenuHandler; items: MenuItem[]; /** 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; onHide?: () => void; } export interface MenuSeparatorItem { kind: "separator"; } export interface MenuHeader { title: string; kind: "header"; } export interface MenuItemBadge { title: string; className?: string; } export interface MenuCommandItem { title: string; command?: T; type?: string; /** * The icon to display in the menu item. * * If not set, no icon is displayed and the item will appear shifted slightly to the left if there are other items with icons. To avoid this, use `bx bx-empty`. */ uiIcon?: string; badges?: MenuItemBadge[]; templateNoteId?: string; enabled?: boolean; handler?: MenuHandler; items?: MenuItem[] | null; shortcut?: string; keyboardShortcut?: KeyboardActionNames; spellingSuggestion?: string; checked?: boolean; columns?: number; } export type MenuItem = MenuCommandItem | MenuSeparatorItem | MenuHeader; export type MenuHandler = (item: MenuCommandItem, e: JQuery.MouseDownEvent) => void; export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEvent; class ContextMenu { private $widget: JQuery; private $cover: JQuery; private options?: ContextMenuOptions; private isMobile: boolean; constructor() { this.$widget = $("#context-menu-container"); this.$cover = $("#context-menu-cover"); this.$widget.addClass("dropend"); this.isMobile = utils.isMobile(); if (this.isMobile) { this.$cover.on("click", () => this.hide()); } else { $(document).on("click", (e) => this.hide()); } } async show(options: ContextMenuOptions) { 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(); } 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, left }) .addClass("show"); } addItems($parent: JQuery, items: MenuItem[], multicolumn = false) { let $group = $parent; // The current group or parent element to which items are being appended let shouldStartNewGroup = false; // If true, the next item will start a new group let shouldResetGroup = false; // If true, the next item will be the last one from the group for (let index = 0; index < items.length; index++) { const item = items[index]; if (!item) { continue; } // If the current item is a header, start a new group. This group will contain the // header and the next item that follows the header. if ("kind" in item && item.kind === "header") { if (multicolumn && !shouldResetGroup) { shouldStartNewGroup = true; } } // If the next item is a separator, start a new group. This group will contain the // current item, the separator, and the next item after the separator. const nextItem = (index < items.length - 1) ? items[index + 1] : null; if (multicolumn && nextItem && "kind" in nextItem && nextItem.kind === "separator") { if (!shouldResetGroup) { shouldStartNewGroup = true; } else { shouldResetGroup = true; // Continue the current group } } // Create a new group to avoid column breaks before and after the seaparator / header. // This is a workaround for Firefox not supporting break-before / break-after: avoid // for columns. if (shouldStartNewGroup) { $group = $("