2025-01-09 18:07:02 +02:00
import keyboardActionService 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" ;
2024-12-22 17:29:09 +02:00
2025-03-20 19:54:09 +02:00
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
}
interface MenuSeparatorItem {
2025-01-09 18:07:02 +02:00
title : "----" ;
2024-12-22 17:29:09 +02:00
}
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 ;
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
}
2025-03-20 19:54:09 +02:00
export type MenuItem < T > = MenuCommandItem < T > | MenuSeparatorItem ;
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-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
}
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" ,
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
}
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
} ) ;
if ( ! this . isMobile ) {
$item . on ( "mouseup" , ( e ) = > {
2025-05-06 20:40:13 +08:00
e . stopPropagation ( ) ;
// Hide the content menu on mouse up to prevent the mouse event from propagating to the elements below.
this . hide ( ) ;
return false ;
2024-12-22 17:29:09 +02:00
} ) ;
2025-06-05 19:14:50 +03: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" ) ;
this . addItems ( $subMenu , item . items ) ;
$item . append ( $subMenu ) ;
}
$parent . append ( $item ) ;
}
}
}
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 ;