2025-01-09 18:36:24 +02:00
import type { CommandNames } from "../components/app_context.js" ;
2025-01-09 18:07:02 +02:00
import keyboardActionService from "../services/keyboard_actions.js" ;
import utils from "../services/utils.js" ;
2024-12-22 17:29:09 +02:00
2024-12-22 19:31:29 +02:00
interface ContextMenuOptions < T extends CommandNames > {
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 ;
2024-12-22 17:29:09 +02:00
}
interface MenuSeparatorItem {
2025-01-09 18:07:02 +02:00
title : "----" ;
2024-12-22 17:29:09 +02:00
}
2024-12-22 19:31:29 +02:00
export interface MenuCommandItem < T extends CommandNames > {
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 ;
uiIcon? : string ;
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 ;
2024-12-22 17:44:50 +02:00
spellingSuggestion? : string ;
2024-12-22 17:29:09 +02:00
}
2024-12-22 19:31:29 +02:00
export type MenuItem < T extends CommandNames > = MenuCommandItem < T > | MenuSeparatorItem ;
export type MenuHandler < T extends CommandNames > = ( item : MenuCommandItem < T > , e : JQuery.MouseDownEvent < HTMLElement , undefined , HTMLElement , HTMLElement > ) = > void ;
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 17:29:09 +02:00
private dateContextMenuOpenedMs : number ;
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" ) ;
this . dateContextMenuOpenedMs = 0 ;
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-01-09 18:07:02 +02:00
$ ( document ) . on ( "click" , ( e ) = > this . hide ( ) ) ;
2024-12-28 10:22:01 +02:00
}
2024-12-22 17:29:09 +02:00
}
2024-12-22 19:31:29 +02:00
async show < T extends CommandNames > ( options : ContextMenuOptions < T > ) {
2024-12-22 17:29:09 +02:00
this . options = options ;
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 ( ) ;
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 ;
}
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" ,
top : top ,
left : left
} )
. addClass ( "show" ) ;
2024-12-22 17:29:09 +02:00
}
2024-12-22 19:31:29 +02:00
addItems ( $parent : JQuery < HTMLElement > , items : MenuItem < any > [ ] ) {
2024-12-22 17:29:09 +02:00
for ( const item of items ) {
if ( ! item ) {
continue ;
}
2025-01-09 18:07:02 +02:00
if ( item . title === "----" ) {
2024-12-22 17:29:09 +02:00
$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 )
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
}
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" ) ;
2024-12-28 10:22:01 +02:00
this . $cover . removeClass ( "show" ) ;
2024-12-28 11:07:44 +02:00
$ ( "body" ) . removeClass ( "context-menu-shown" ) ;
2024-12-28 10:22:01 +02:00
this . $widget . hide ( ) ;
2024-12-22 17:29:09 +02:00
}
}
}
function timeout ( ms : number ) {
return new Promise ( ( accept , reject ) = > {
setTimeout ( accept , ms ) ;
} ) ;
}
const contextMenu = new ContextMenu ( ) ;
export default contextMenu ;