chromium/ui/file_manager/file_manager/foreground/js/ui/context_menu_handler.ts

// 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.

import {dispatchPropertyChange} from 'chrome://resources/ash/common/cr_deprecated.js';
import {assertInstanceof} from 'chrome://resources/js/assert.js';

import {crInjectTypeAndInit} from '../../../common/js/cr_ui.js';
import {type CustomEventMap, FilesEventTarget} from '../../../common/js/files_event_target.js';

import {Menu} from './menu.js';
import {MenuItem} from './menu_item.js';
import {HideType} from './multi_menu_button.js';
import {positionPopupAtPoint} from './position_util.js';

interface ContextMenuEventDetail {
  element: HTMLElement;
  menu: Menu;
}

export type HideEvent = CustomEvent<ContextMenuEventDetail>;

export type ShowEvent = CustomEvent<ContextMenuEventDetail>;

interface ContextMenuHandlerEventMap extends CustomEventMap {
  'show': ShowEvent;
  'hide': HideEvent;
}

/**
 * Handles context menus.
 */
class ContextMenuHandler extends FilesEventTarget<ContextMenuHandlerEventMap> {
  private abortController_: AbortController|null = null;

  private menu_: Menu|null = null;
  private hideTimestamp_: number|null = null;
  private keyIsDown_ = false;
  private resizeObserver_: ResizeObserver|null = null;

  get menu() {
    return this.menu_;
  }

  isMenuEvent(e: KeyboardEvent) {
    switch (e.key) {
      case 'ArrowDown':
      case 'ArrowUp':
      case 'ArrowLeft':
      case 'ArrowRight':
      case 'Enter':
      case ' ':
      case 'Escape':
      case 'Tab':
        return true;
    }
    return false;
  }

  private getMenuPosition_(
      target: HTMLElement, clientX: number,
      clientY: number): {x: number, y: number} {
    // When the user presses the context menu key (on the keyboard) we need
    // to detect this.
    let x;
    let y;
    if (this.keyIsDown_) {
      let rect: DOMRect;
      if ('getRectForContextMenu' in target) {
        rect = (target.getRectForContextMenu as (() => DOMRect))();
      } else {
        rect = target.getBoundingClientRect();
      }
      const offset = Math.min(rect.width, rect.height) / 2;
      x = rect.left + offset;
      y = rect.top + offset;
    } else {
      x = clientX;
      y = clientY;
    }

    return {x, y};
  }

  /**
   * Shows a menu as a context menu.
   * @param e The event triggering the show (usually a contextmenu event).
   * @param menu The menu to show.
   */
  showMenu(e: MouseEvent, menu: Menu) {
    assertInstanceof(e.currentTarget, Node);
    menu.updateCommands(e.currentTarget);
    if (!menu.hasVisibleItems()) {
      return;
    }

    const htmlElement = e.currentTarget as HTMLElement;
    const {x, y} = this.getMenuPosition_(htmlElement, e.clientX, e.clientY);
    this.menu_ = menu;
    menu.classList.remove('hide-delayed');
    menu.show({x, y});
    menu.contextElement = htmlElement;

    // When the menu is shown we steal a lot of events.
    const doc = menu.ownerDocument;
    if (this.resizeObserver_) {
      this.resizeObserver_.disconnect();
    }
    this.resizeObserver_ = new ResizeObserver((_entries) => {
      positionPopupAtPoint(x, y, menu);
    });
    this.resizeObserver_.observe(menu);
    this.abortController_ = new AbortController();
    const signal = this.abortController_.signal;
    doc.addEventListener(
        'keydown', this.handleKeyboardEvent_.bind(this),
        {signal, capture: true});
    doc.addEventListener(
        'mousedown', this.handleMouseEvent_.bind(this),
        {signal, capture: true});
    doc.addEventListener(
        'touchstart', this.handleTouchEvent_.bind(this),
        {signal, capture: true});
    doc.addEventListener('focus', this.handleFocusEvent_.bind(this), {signal});
    doc.defaultView?.addEventListener(
        'popstate', this.handleHideMenuEvent_.bind(this), {signal});
    doc.defaultView?.addEventListener(
        'resize', this.handleHideMenuEvent_.bind(this), {signal});
    doc.defaultView?.addEventListener(
        'blur', this.handleHideMenuEvent_.bind(this), {signal});
    menu.addEventListener(
        'contextmenu', this.handleContextMenuEvent_.bind(this), {signal});
    menu.addEventListener(
        'activate', this.handleActivateEvent_.bind(this), {signal});

    const ev =
        new CustomEvent('show', {detail: {element: menu.contextElement, menu}});
    this.dispatchEvent(ev);
  }

  /**
   * Hide the currently shown menu.
   * @param hideType Type of hide.
   *     default: HideType.INSTANT.
   */
  hideMenu(hideType?: HideType) {
    const menu = this.menu;
    if (!menu) {
      return;
    }

    if (hideType === HideType.DELAYED) {
      menu.classList.add('hide-delayed');
    } else {
      menu.classList.remove('hide-delayed');
    }
    menu.hide();
    const originalContextElement = menu.contextElement;
    menu.contextElement = null;
    this.abortController_?.abort();
    if (this.resizeObserver_) {
      this.resizeObserver_.unobserve(menu);
      this.resizeObserver_ = null;
    }
    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;

    const ev = new CustomEvent(
        'hide', {detail: {element: originalContextElement, menu}});
    this.dispatchEvent(ev);
  }

  private handleKeyboardEvent_(e: KeyboardEvent) {
    // 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;
    }

    if (!this.menu || e.type !== 'keydown') {
      return;
    }

    if (e.key === 'Escape') {
      this.hideMenu();
      e.stopPropagation();
      e.preventDefault();

      // If the menu is visible we let it handle all the keyboard events
      // intended for the menu.
    } else if (this.menu && this.isMenuEvent(e)) {
      this.menu.handleKeyDown(e);
      e.preventDefault();
      e.stopPropagation();
    }
  }

  private handleTouchEvent_(e: TouchEvent) {
    if (!this.menu?.contains(e.target as Node)) {
      this.hideMenu();
    }
  }

  private handleFocusEvent_(e: FocusEvent) {
    if (!this.menu?.contains(e.target as Node)) {
      this.hideMenu();
    }
  }

  private handleHideMenuEvent_(_e: Event) {
    if (this.menu) {
      this.hideMenu();
    }
  }

  private handleActivateEvent_(e: Event) {
    if (this.menu) {
      const hideDelayed = e.target instanceof MenuItem && e.target.checkable;
      this.hideMenu(hideDelayed ? HideType.DELAYED : HideType.INSTANT);
    }
  }

  private handleContextMenuEvent_(e: MouseEvent) {
    if ((!this.menu || !this.menu.contains(e.target as Node)) &&
        (!this.hideTimestamp_ || Date.now() - this.hideTimestamp_ > 50)) {
      // Focus the item which triggers the context menu before showing the menu,
      // so when the menu hides, the focus can be brought back to the item.
      if (document.activeElement !== e.currentTarget) {
        (e.currentTarget as HTMLElement).focus();
      }
      this.showMenu(
          e, (e.currentTarget as Element & WithContextMenu).contextMenu!);
    }
    e.preventDefault();
    e.stopPropagation();
  }

  /**
   * Handles mouse event callbacks.
   */
  private handleMouseEvent_(e: MouseEvent) {
    if (!this.menu) {
      return;
    }

    if (!this.menu.contains(e.target as Node)) {
      this.hideMenu();
    } else {
      e.preventDefault();
    }
  }

  /**
   * Adds a contextMenu property to an element or element class.
   * @param elementOrClass The element or class to add the contextMenu property
   *     to.
   */
  addContextMenuProperty(elementOrClass: Element|Function) {
    const target = typeof elementOrClass === 'function' ?
        elementOrClass.prototype :
        elementOrClass;

    Object.defineProperty(target, 'contextMenu', {
      get: function() {
        return this.contextMenu_;
      },
      set: function(menu) {
        const oldContextMenu = this.contextMenu;

        if (typeof menu === 'string' && menu[0] === '#') {
          menu = this.ownerDocument.getElementById(menu.slice(1));
          crInjectTypeAndInit(menu, Menu);
        }

        if (menu === oldContextMenu) {
          return;
        }

        if (oldContextMenu && !menu) {
          this.abortController_?.abort();
        }
        if (menu && !oldContextMenu) {
          this.abortController_ = new AbortController();
          const signal = this.abortController_.signal;
          this.addEventListener(
              'contextmenu',
              contextMenuHandler.handleContextMenuEvent_.bind(
                  contextMenuHandler),
              {signal});
          this.addEventListener(
              'keydown',
              contextMenuHandler.handleKeyboardEvent_.bind(contextMenuHandler),
              {signal});
          this.addEventListener(
              'keyup',
              contextMenuHandler.handleKeyboardEvent_.bind(contextMenuHandler),
              {signal});
        }

        this.contextMenu_ = menu;

        if (menu && menu.id) {
          this.setAttribute('contextmenu', '#' + menu.id);
        }

        dispatchPropertyChange(this, 'contextMenu', menu, oldContextMenu);
      },
    });

    if (!target.getRectForContextMenu) {
      /**
       * @return The rect to use for positioning the context menu when the
       *     context menu is not opened using a mouse position.
       */
      target.getRectForContextMenu = function(): ClientRect {
        return this.getBoundingClientRect();
      };
    }
  }

  /**
   * Sets the given contextMenu to the given element. A contextMenu property
   * would be added if necessary.
   * @param element The element or class to set the contextMenu to.
   * @param contextMenu The contextMenu property to be set.
   */
  setContextMenu(element: Element&WithContextMenu, contextMenu: Menu) {
    if (!element || !element.contextMenu) {
      this.addContextMenuProperty(element);
    }
    element.contextMenu = contextMenu;
  }
}

/**
 * Use this interface to define an element that also might have a context menu
 * attached, e.g. HTMLInputElement & WithContextMenu.
 */
export interface WithContextMenu {
  contextMenu?: Menu;
}

/**
 * The singleton context menu handler.
 */
export const contextMenuHandler = new ContextMenuHandler();