| // Copyright 2012 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| // NOTE: This file depends on ui.js (or the autogenerated ui.m.js module |
| // version). These files and all files that depend on them are deprecated, and |
| // should only be used by legacy UIs that have not yet been updated to new |
| // patterns. Use Web Components in any new code. |
| |
| // clang-format off |
| import {assertInstanceof} from 'chrome://resources/js/assert_ts.js'; |
| import {EventTracker} from 'chrome://resources/js/event_tracker.js'; |
| |
| import {dispatchPropertyChange} from './cr_deprecated.js'; |
| import {Menu} from './menu.js'; |
| import {MenuItem} from './menu_item.js'; |
| import {HideType} from './menu_button.js'; |
| import {positionPopupAtPoint} from './position_util.js'; |
| import {decorate} from './ui.js'; |
| // clang-format on |
| |
| |
| /** |
| * Handles context menus. |
| * @implements {EventListener} |
| */ |
| class ContextMenuHandler extends EventTarget { |
| constructor() { |
| super(); |
| /** @private {!EventTracker} */ |
| this.showingEvents_ = new EventTracker(); |
| |
| /** |
| * The menu that we are currently showing. |
| * @private {?Menu} |
| */ |
| this.menu_ = null; |
| |
| /** @private {?number} */ |
| this.hideTimestamp_ = null; |
| |
| /** @private {boolean} */ |
| this.keyIsDown_ = false; |
| } |
| |
| get menu() { |
| return this.menu_; |
| } |
| |
| /** |
| * Shows a menu as a context menu. |
| * @param {!Event} e The event triggering the show (usually a contextmenu |
| * event). |
| * @param {!Menu} menu The menu to show. |
| */ |
| showMenu(e, menu) { |
| assertInstanceof(e.currentTarget, Node); |
| menu.updateCommands(e.currentTarget); |
| if (!menu.hasVisibleItems()) { |
| return; |
| } |
| |
| this.menu_ = menu; |
| menu.classList.remove('hide-delayed'); |
| menu.show({x: e.screenX, y: e.screenY}); |
| menu.contextElement = e.currentTarget; |
| |
| // When the menu is shown we steal a lot of events. |
| const doc = menu.ownerDocument; |
| const win = /** @type {!Window} */ (doc.defaultView); |
| this.showingEvents_.add(doc, 'keydown', this, true); |
| this.showingEvents_.add(doc, 'mousedown', this, true); |
| this.showingEvents_.add(doc, 'touchstart', this, true); |
| this.showingEvents_.add(doc, 'focus', this); |
| this.showingEvents_.add(win, 'popstate', this); |
| this.showingEvents_.add(win, 'resize', this); |
| this.showingEvents_.add(win, 'blur', this); |
| this.showingEvents_.add(menu, 'contextmenu', this); |
| this.showingEvents_.add(menu, 'activate', this); |
| this.positionMenu_(e, menu); |
| |
| const ev = new Event('show'); |
| ev.element = menu.contextElement; |
| ev.menu = menu; |
| this.dispatchEvent(ev); |
| } |
| |
| /** |
| * Hide the currently shown menu. |
| * @param {HideType=} opt_hideType Type of hide. |
| * default: HideType.INSTANT. |
| */ |
| hideMenu(opt_hideType) { |
| const menu = this.menu; |
| if (!menu) { |
| return; |
| } |
| |
| if (opt_hideType === HideType.DELAYED) { |
| menu.classList.add('hide-delayed'); |
| } else { |
| menu.classList.remove('hide-delayed'); |
| } |
| menu.hide(); |
| const originalContextElement = menu.contextElement; |
| menu.contextElement = null; |
| this.showingEvents_.removeAll(); |
| menu.selectedIndex = -1; |
| this.menu_ = null; |
| |
| // On windows we might hide the menu in a right mouse button up and if |
| // that is the case we wait some short period before we allow the menu |
| // to be shown again. |
| this.hideTimestamp_ = 0; |
| // <if expr="is_win"> |
| this.hideTimestamp_ = Date.now(); |
| // </if> |
| |
| const ev = new Event('hide'); |
| ev.element = originalContextElement; |
| ev.menu = menu; |
| this.dispatchEvent(ev); |
| } |
| |
| /** |
| * Positions the menu |
| * @param {!Event} e The event object triggering the showing. |
| * @param {!Menu} menu The menu to position. |
| * @private |
| */ |
| positionMenu_(e, menu) { |
| // TODO(arv): Handle scrolled documents when needed. |
| |
| const element = e.currentTarget; |
| let x; |
| let y; |
| // When the user presses the context menu key (on the keyboard) we need |
| // to detect this. |
| if (this.keyIsDown_) { |
| const rect = element.getRectForContextMenu ? |
| element.getRectForContextMenu() : |
| element.getBoundingClientRect(); |
| const offset = Math.min(rect.width, rect.height) / 2; |
| x = rect.left + offset; |
| y = rect.top + offset; |
| } else { |
| x = e.clientX; |
| y = e.clientY; |
| } |
| |
| positionPopupAtPoint(x, y, menu); |
| } |
| |
| /** |
| * Handles event callbacks. |
| * @param {!Event} e The event object. |
| */ |
| handleEvent(e) { |
| // Keep track of keydown state so that we can use that to determine the |
| // reason for the contextmenu event. |
| switch (e.type) { |
| case 'keydown': |
| this.keyIsDown_ = !e.ctrlKey && !e.altKey && |
| // context menu key or Shift-F10 |
| (e.keyCode === 93 && !e.shiftKey || e.key === 'F10' && e.shiftKey); |
| break; |
| |
| case 'keyup': |
| this.keyIsDown_ = false; |
| break; |
| } |
| |
| // Context menu is handled even when we have no menu. |
| if (e.type !== 'contextmenu' && !this.menu) { |
| return; |
| } |
| |
| switch (e.type) { |
| case 'mousedown': |
| if (!this.menu.contains(e.target)) { |
| this.hideMenu(); |
| // <if expr="is_linux or is_macosx or chromeos_lacros"> |
| if (e.button === 0 /* Left button */) { |
| // Emulate Mac and Linux, which swallow native 'mousedown' events |
| // that close menus. |
| e.preventDefault(); |
| e.stopPropagation(); |
| } |
| // </if> |
| } else { |
| e.preventDefault(); |
| } |
| break; |
| |
| case 'touchstart': |
| if (!this.menu.contains(e.target)) { |
| this.hideMenu(); |
| } |
| break; |
| |
| case 'keydown': |
| if (e.key === 'Escape') { |
| this.hideMenu(); |
| e.stopPropagation(); |
| e.preventDefault(); |
| |
| // If the menu is visible we let it handle all the keyboard events. |
| } else if (this.menu) { |
| this.menu.handleKeyDown(e); |
| e.preventDefault(); |
| e.stopPropagation(); |
| } |
| break; |
| |
| case 'activate': |
| const hideDelayed = e.target instanceof MenuItem && e.target.checkable; |
| this.hideMenu(hideDelayed ? HideType.DELAYED : HideType.INSTANT); |
| break; |
| |
| case 'focus': |
| if (!this.menu.contains(e.target)) { |
| this.hideMenu(); |
| } |
| break; |
| |
| case 'blur': |
| this.hideMenu(); |
| break; |
| |
| case 'popstate': |
| case 'resize': |
| this.hideMenu(); |
| break; |
| |
| case 'contextmenu': |
| if ((!this.menu || !this.menu.contains(e.target)) && |
| (!this.hideTimestamp_ || Date.now() - this.hideTimestamp_ > 50)) { |
| this.showMenu(e, e.currentTarget.contextMenu); |
| } |
| e.preventDefault(); |
| // Don't allow elements further up in the DOM to show their menus. |
| e.stopPropagation(); |
| break; |
| } |
| } |
| |
| /** |
| * Adds a contextMenu property to an element or element class. |
| * @param {!Element|!Function} elementOrClass The element or class to add |
| * the contextMenu property to. |
| */ |
| addContextMenuProperty(elementOrClass) { |
| const target = typeof elementOrClass === 'function' ? |
| elementOrClass.prototype : |
| elementOrClass; |
| |
| // eslint-disable-next-line no-restricted-properties |
| target.__defineGetter__('contextMenu', function() { |
| return this.contextMenu_; |
| }); |
| // eslint-disable-next-line no-restricted-properties |
| target.__defineSetter__('contextMenu', function(menu) { |
| const oldContextMenu = this.contextMenu; |
| |
| if (typeof menu === 'string' && menu[0] === '#') { |
| menu = this.ownerDocument.getElementById(menu.slice(1)); |
| decorate(menu, Menu); |
| } |
| |
| if (menu === oldContextMenu) { |
| return; |
| } |
| |
| if (oldContextMenu && !menu) { |
| this.removeEventListener('contextmenu', contextMenuHandler); |
| this.removeEventListener('keydown', contextMenuHandler); |
| this.removeEventListener('keyup', contextMenuHandler); |
| } |
| if (menu && !oldContextMenu) { |
| this.addEventListener('contextmenu', contextMenuHandler); |
| this.addEventListener('keydown', contextMenuHandler); |
| this.addEventListener('keyup', contextMenuHandler); |
| } |
| |
| this.contextMenu_ = menu; |
| |
| if (menu && menu.id) { |
| this.setAttribute('contextmenu', '#' + menu.id); |
| } |
| |
| dispatchPropertyChange(this, 'contextMenu', menu, oldContextMenu); |
| }); |
| |
| if (!target.getRectForContextMenu) { |
| /** |
| * @return {!ClientRect} The rect to use for positioning the context |
| * menu when the context menu is not opened using a mouse position. |
| */ |
| target.getRectForContextMenu = function() { |
| return this.getBoundingClientRect(); |
| }; |
| } |
| } |
| |
| /** |
| * Sets the given contextMenu to the given element. A contextMenu property |
| * would be added if necessary. |
| * @param {!Element} element The element or class to set the contextMenu to. |
| * @param {!Menu} contextMenu The contextMenu property to be set. |
| */ |
| setContextMenu(element, contextMenu) { |
| if (!element.contextMenu) { |
| this.addContextMenuProperty(element); |
| } |
| element.contextMenu = contextMenu; |
| } |
| } |
| |
| /** |
| * The singleton context menu handler. |
| * @type {!ContextMenuHandler} |
| */ |
| export const contextMenuHandler = new ContextMenuHandler(); |