2025-09-18 07:47:10 +03:00
import { KeyboardActionNames } from "@triliumnext/commons" ;
import keyboardActionService , { getActionSync } from "../services/keyboard_actions.js" ;
2025-01-22 22:24:42 +02:00
import note_tooltip from "../services/note_tooltip.js" ;
2025-01-09 18:07:02 +02:00
import utils from "../services/utils.js" ;
2025-09-20 03:25:16 +03:00
import { should } from "vitest" ;
2024-12-22 17:29:09 +02:00
2025-06-25 13:52:53 +03:00
export interface ContextMenuOptions < T > {
2024-12-22 17:29:09 +02:00
x : number ;
y : number ;
orientation ? : "left" ;
2024-12-22 19:31:29 +02:00
selectMenuItemHandler : MenuHandler < T > ;
items : MenuItem < T > [ ] ;
2025-01-18 00:04:06 +02:00
/** 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 ;
2025-05-06 14:55:17 +08:00
onHide ? : ( ) = > void ;
2024-12-22 17:29:09 +02:00
}
2025-09-20 01:08:36 +03:00
export interface MenuSeparatorItem {
2025-09-20 00:34:25 +03:00
kind : "separator" ;
2024-12-22 17:29:09 +02:00
}
2025-09-20 01:08:36 +03:00
export interface MenuHeader {
2025-09-20 00:18:56 +03:00
title : string ;
kind : "header" ;
}
2025-07-04 11:46:10 +03:00
export interface MenuItemBadge {
2025-07-03 23:27:02 +03:00
title : string ;
className? : string ;
}
2025-03-20 19:54:09 +02:00
export interface MenuCommandItem < T > {
2024-12-22 17:29:09 +02:00
title : string ;
2024-12-22 19:31:29 +02:00
command? : T ;
2024-12-22 17:44:50 +02:00
type ? : string ;
2025-07-19 14:17:48 +03:00
/ * *
* 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 ` .
* /
2024-12-22 17:44:50 +02:00
uiIcon? : string ;
2025-07-03 23:27:02 +03:00
badges? : MenuItemBadge [ ] ;
2024-12-22 17:29:09 +02:00
templateNoteId? : string ;
enabled? : boolean ;
2024-12-22 19:31:29 +02:00
handler? : MenuHandler < T > ;
items? : MenuItem < T > [ ] | null ;
2024-12-22 17:29:09 +02:00
shortcut? : string ;
2025-09-18 07:47:10 +03:00
keyboardShortcut? : KeyboardActionNames ;
2024-12-22 17:44:50 +02:00
spellingSuggestion? : string ;
2025-06-25 13:52:53 +03:00
checked? : boolean ;
2025-07-09 18:37:09 +03:00
columns? : number ;
2024-12-22 17:29:09 +02:00
}
2025-09-20 00:18:56 +03:00
export type MenuItem < T > = MenuCommandItem < T > | MenuSeparatorItem | MenuHeader ;
2025-03-20 19:54:09 +02:00
export type MenuHandler < T > = ( item : MenuCommandItem < T > , e : JQuery.MouseDownEvent < HTMLElement , undefined , HTMLElement , HTMLElement > ) = > void ;
2025-01-22 19:33:53 +02:00
export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery . ContextMenuEvent ;
2024-12-22 17:29:09 +02:00
class ContextMenu {
2024-12-28 10:22:01 +02:00
private $widget : JQuery < HTMLElement > ;
private $cover : JQuery < HTMLElement > ;
2024-12-22 19:31:29 +02:00
private options? : ContextMenuOptions < any > ;
2024-12-28 09:50:19 +02:00
private isMobile : boolean ;
2024-12-22 17:29:09 +02:00
constructor ( ) {
this . $widget = $ ( "#context-menu-container" ) ;
2024-12-28 10:22:01 +02:00
this . $cover = $ ( "#context-menu-cover" ) ;
2024-12-22 17:29:09 +02:00
this . $widget . addClass ( "dropend" ) ;
2024-12-28 09:50:19 +02:00
this . isMobile = utils . isMobile ( ) ;
2024-12-22 17:29:09 +02:00
2024-12-28 10:22:01 +02:00
if ( this . isMobile ) {
2024-12-28 10:35:10 +02:00
this . $cover . on ( "click" , ( ) = > this . hide ( ) ) ;
2024-12-28 10:22:01 +02:00
} else {
2025-09-07 22:57:27 +03:00
$ ( document ) . on ( "click" , ( e ) = > this . hide ( ) ) ;
2024-12-28 10:22:01 +02:00
}
2024-12-22 17:29:09 +02:00
}
2025-03-20 19:54:09 +02:00
async show < T > ( options : ContextMenuOptions < T > ) {
2024-12-22 17:29:09 +02:00
this . options = options ;
2025-01-22 22:24:42 +02:00
note_tooltip . dismissAllTooltips ( ) ;
2024-12-22 17:29:09 +02:00
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 ( ) ;
}
2025-01-18 00:04:06 +02:00
this . $widget . toggleClass ( "mobile-bottom-menu" , ! this . options . forcePositionOnMobile ) ;
2024-12-28 10:22:01 +02:00
this . $cover . addClass ( "show" ) ;
2024-12-28 11:07:44 +02:00
$ ( "body" ) . addClass ( "context-menu-shown" ) ;
2024-12-28 10:22:01 +02:00
2024-12-22 17:29:09 +02:00
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 ;
}
2025-01-09 18:07:02 +02:00
if ( this . options . orientation === "left" && contextMenuWidth ) {
2024-12-22 17:29:09 +02:00
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 ;
}
}
2025-01-09 18:07:02 +02:00
this . $widget
. css ( {
display : "block" ,
2025-10-08 22:26:43 +03:00
top ,
left
2025-01-09 18:07:02 +02:00
} )
. addClass ( "show" ) ;
2024-12-22 17:29:09 +02:00
}
2025-09-20 03:09:56 +03:00
addItems ( $parent : JQuery < HTMLElement > , items : MenuItem < any > [ ] , multicolumn = false ) {
2025-09-20 04:02:46 +03:00
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
2025-09-20 02:59:41 +03:00
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 ] ;
2024-12-22 17:29:09 +02:00
if ( ! item ) {
continue ;
}
2025-09-20 04:02:46 +03:00
// 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.
2025-09-20 02:59:41 +03:00
const nextItem = ( index < items . length - 1 ) ? items [ index + 1 ] : null ;
2025-09-20 04:02:46 +03:00
if ( multicolumn && nextItem && "kind" in nextItem && nextItem . kind === "separator" ) {
if ( ! shouldResetGroup ) {
shouldStartNewGroup = true ;
} else {
shouldResetGroup = true ; // Continue the current group
2025-09-20 02:59:41 +03:00
}
}
2025-09-20 04:02:46 +03:00
// Create a new group to avoid column breaks before and after the seaparator / header.
2025-10-08 17:50:54 +03:00
// This is a workaround for Firefox not supporting break-before / break-after: avoid
2025-09-20 04:02:46 +03:00
// for columns.
if ( shouldStartNewGroup ) {
$group = $ ( "<div class='dropdown-no-break'>" ) ;
$parent . append ( $group ) ;
shouldStartNewGroup = false ;
}
2025-09-20 00:34:25 +03:00
if ( "kind" in item && item . kind === "separator" ) {
2025-09-20 02:59:41 +03:00
$group . append ( $ ( "<div>" ) . addClass ( "dropdown-divider" ) ) ;
2025-09-20 04:02:46 +03:00
shouldResetGroup = true ; // End the group after the next item
2025-09-20 00:18:56 +03:00
} else if ( "kind" in item && item . kind === "header" ) {
2025-09-20 02:59:41 +03:00
$group . append ( $ ( "<h6>" ) . addClass ( "dropdown-header" ) . text ( item . title ) ) ;
shouldResetGroup = true ;
2024-12-22 17:29:09 +02:00
} else {
const $icon = $ ( "<span>" ) ;
2025-06-25 13:52:53 +03:00
if ( "uiIcon" in item || "checked" in item ) {
const icon = ( item . checked ? "bx bx-check" : item . uiIcon ) ;
if ( icon ) {
$icon . addClass ( icon ) ;
} else {
$icon . append ( " " ) ;
}
2024-12-22 17:29:09 +02:00
}
const $link = $ ( "<span>" )
. append ( $icon )
. append ( " " ) // some space between icon and text
. append ( item . title ) ;
2025-07-03 23:27:02 +03:00
if ( "badges" in item && item . badges ) {
for ( let badge of item . badges ) {
const badgeElement = $ ( ` <span class="badge"> ` ) . text ( badge . title ) ;
if ( badge . className ) {
badgeElement . addClass ( badge . className ) ;
}
$link . append ( badgeElement ) ;
}
}
2025-09-18 07:47:10 +03:00
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 = > ` <kbd> ${ key } </kbd> ` )
. join ( "+" ) ) ;
}
if ( allShortcuts . length ) {
const container = $ ( "<span>" ) . addClass ( "keyboard-shortcut" ) ;
container . append ( $ ( allShortcuts . join ( "," ) ) ) ;
$link . append ( container ) ;
}
}
} else if ( "shortcut" in item && item . shortcut ) {
2024-12-22 17:29:09 +02:00
$link . append ( $ ( "<kbd>" ) . text ( item . shortcut ) ) ;
}
const $item = $ ( "<li>" )
. addClass ( "dropdown-item" )
. append ( $link )
2025-01-09 18:07:02 +02:00
. on ( "contextmenu" , ( e ) = > false )
2024-12-22 17:29:09 +02:00
// important to use mousedown instead of click since the former does not change focus
// (especially important for focused text for spell check)
2025-01-09 18:07:02 +02:00
. on ( "mousedown" , ( e ) = > {
2024-12-22 17:29:09 +02:00
e . stopPropagation ( ) ;
2025-01-09 18:07:02 +02:00
if ( e . which !== 1 ) {
// only left click triggers menu items
2024-12-22 17:29:09 +02:00
return false ;
2024-12-28 09:50:19 +02:00
}
if ( this . isMobile && "items" in item && item . items ) {
2025-01-09 18:07:02 +02:00
const $item = $ ( e . target ) . closest ( ".dropdown-item" ) ;
2024-12-28 10:39:45 +02:00
$item . toggleClass ( "submenu-open" ) ;
2025-01-09 18:07:02 +02:00
$item . find ( "ul.dropdown-menu" ) . toggleClass ( "show" ) ;
2024-12-28 09:50:19 +02:00
return false ;
2024-12-22 17:29:09 +02:00
}
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 ;
2025-06-05 19:14:50 +03:00
} ) ;
2025-06-16 17:21:44 +08:00
$item . on ( "mouseup" , ( e ) = > {
// Prevent submenu from failing to expand on mobile
if ( ! this . isMobile || ! ( "items" in item && item . items ) ) {
2025-09-07 22:57:27 +03:00
e . stopPropagation ( ) ;
2025-05-06 20:40:13 +08:00
// Hide the content menu on mouse up to prevent the mouse event from propagating to the elements below.
2025-09-07 22:57:27 +03:00
this . hide ( ) ;
2025-05-06 20:40:13 +08:00
return false ;
2025-06-16 17:21:44 +08:00
}
} ) ;
2024-12-22 17:29:09 +02:00
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" ) ;
2025-09-20 03:09:56 +03:00
const hasColumns = ! ! item . columns && item . columns > 1 ;
if ( ! this . isMobile && hasColumns ) {
$subMenu . css ( "column-count" , item . columns ! ) ;
2025-07-09 18:37:09 +03:00
}
2024-12-22 17:29:09 +02:00
2025-09-20 03:09:56 +03:00
this . addItems ( $subMenu , item . items , hasColumns ) ;
2024-12-22 17:29:09 +02:00
$item . append ( $subMenu ) ;
}
2025-09-20 02:59:41 +03:00
$group . append ( $item ) ;
2025-10-08 17:50:54 +03:00
2025-09-20 02:59:41 +03:00
// 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 ) {
$group = $parent ;
shouldResetGroup = false ;
} ;
2024-12-22 17:29:09 +02:00
}
}
}
async hide() {
2025-05-06 14:55:17 +08:00
this . options ? . onHide ? . ( ) ;
this . $widget . removeClass ( "show" ) ;
this . $cover . removeClass ( "show" ) ;
$ ( "body" ) . removeClass ( "context-menu-shown" ) ;
this . $widget . hide ( ) ;
2024-12-22 17:29:09 +02:00
}
}
const contextMenu = new ContextMenu ( ) ;
export default contextMenu ;