// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Class to manage the ChromeVox menus.
*/
import {StringUtil} from '/common/string_util.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';
import {BackgroundBridge} from '../common/background_bridge.js';
import {BrailleCommandData} from '../common/braille/braille_command_data.js';
import {Command, CommandCategory} from '../common/command.js';
import {CommandStore} from '../common/command_store.js';
import {EventSourceType} from '../common/event_source_type.js';
import {GestureCommandData} from '../common/gesture_command_data.js';
import {KeyMap} from '../common/key_map.js';
import {KeyBinding} from '../common/key_sequence.js';
import {KeyUtil} from '../common/key_util.js';
import {Msgs} from '../common/msgs.js';
import {ALL_PANEL_MENU_NODE_DATA, PanelNodeMenuData, PanelNodeMenuId, PanelNodeMenuItemData} from '../common/panel_menu_data.js';
import {PanelInterface} from './panel_interface.js';
import {PanelMenu, PanelNodeMenu, PanelSearchMenu} from './panel_menu.js';
import {PanelMode} from './panel_mode.js';
const $ = (id: string): HTMLElement | null => document.getElementById(id);
interface TouchMenuData {
titleText: string;
gestureText: string;
command: Command;
}
export class MenuManager {
private activeMenu_: PanelMenu | null = null;
private lastMenu_ = '';
private menus_: PanelMenu[] = [];
private nodeMenuDictionary_:
Partial<Record<PanelNodeMenuId, PanelNodeMenu>> = {};
private searchMenu_: PanelSearchMenu | null = null;
static disableMissingMsgsErrorsForTesting = false;
/**
* Activate a menu, which implies hiding the previous active menu.
* @param menu The new menu to activate.
* @param activateFirstItem Whether or not we should activate the
* menu's first item.
*/
activateMenu(menu: PanelMenu | null, activateFirstItem: boolean): void {
if (menu === this.activeMenu_) {
return;
}
if (this.activeMenu_) {
this.activeMenu_.deactivate();
this.activeMenu_ = null;
}
this.activeMenu_ = menu;
// TODO(b/314203187): Not null asserted, check that this is correct.
PanelInterface.instance!.setPendingCallback(null);
if (this.activeMenu_) {
this.activeMenu_.activate(activateFirstItem);
}
}
async addActionsMenuItems(
actionsMenu: PanelMenu,
bindingMap: Map<Command, KeyBinding>): Promise<void> {
const actions =
await BackgroundBridge.PanelBackground.getActionsForCurrentNode();
for (const standardAction of actions.standardActions) {
const actionMsg = ACTION_TO_MSG_ID[standardAction];
if (!actionMsg) {
continue;
}
const commandName = CommandStore.commandForMessage(actionMsg);
let shortcutName = '';
if (commandName) {
const commandBinding = bindingMap.get(commandName);
shortcutName = commandBinding ? commandBinding.keySeq as string : '';
}
const actionDesc = Msgs.getMsg(actionMsg);
actionsMenu.addMenuItem(
actionDesc, shortcutName, '' /* menuItemBraille */, '' /* gesture */,
() => BackgroundBridge.PanelBackground
.performStandardActionOnCurrentNode(standardAction));
}
for (const customAction of actions.customActions) {
actionsMenu.addMenuItem(
customAction.description, '' /* menuItemShortcut */,
'' /* menuItemBraille */, '' /* gesture */,
() =>
BackgroundBridge.PanelBackground.performCustomActionOnCurrentNode(
customAction.id));
}
}
/**
* Create a new menu with the given name and add it to the menu bar.
* @param menuMsg The msg id of the new menu to add.
* @return The menu just created.
*/
addMenu(menuMsg: string): PanelMenu {
const menu = new PanelMenu(menuMsg);
$('menu-bar')!.appendChild(menu.menuBarItemElement);
menu.menuBarItemElement.addEventListener(
'mouseover',
() => this.activateMenu(menu, true /* activateFirstItem */), false);
menu.menuBarItemElement.addEventListener(
'mouseup', event => this.onMouseUpOnMenuTitle(menu, event), false);
$('menus_background')!.appendChild(menu.menuContainerElement);
this.menus_.push(menu);
return menu;
}
addMenuItemFromKeyBinding(
binding: KeyBinding, menu: PanelMenu | null,
isTouchScreen: boolean): void {
if (!binding.title || !menu) {
return;
}
let keyText;
let brailleText;
let gestureText;
if (isTouchScreen) {
const gestureData = Object.values(GestureCommandData.GESTURE_COMMAND_MAP);
const data = gestureData.find(data => data.command === binding.command);
if (data) {
gestureText = Msgs.getMsg(data.msgId);
}
} else {
keyText = binding.keySeq;
brailleText = BrailleCommandData.getDotShortcut(binding.command, true);
}
menu.addMenuItem(
binding.title, keyText, brailleText, gestureText,
() => BackgroundBridge.CommandHandler.onCommand(binding.command),
binding.command);
}
/**
* Create a new node menu with the given name and add it to the menu bar.
* @param menuData The title/predicate for the new menu.
*/
addNodeMenu(menuData: PanelNodeMenuData): void {
const menu = new PanelNodeMenu(menuData.titleId);
$('menu-bar')!.appendChild(menu.menuBarItemElement);
menu.menuBarItemElement.addEventListener(
'mouseover',
() => this.activateMenu(menu, true /* activateFirstItem */));
menu.menuBarItemElement.addEventListener(
'mouseup', event => this.onMouseUpOnMenuTitle(menu, event));
$('menus_background')!.appendChild(menu.menuContainerElement);
this.menus_.push(menu);
this.nodeMenuDictionary_[menuData.menuId] = menu;
}
addNodeMenuItem(itemData: PanelNodeMenuItemData): void {
this.nodeMenuDictionary_[itemData.menuId]?.addItemFromData(itemData);
}
/**
* Create a new search menu with the given name and add it to the menu bar.
* @param menuMsg The msg id of the new menu to add.
* @return The menu just created.
*/
addSearchMenu(menuMsg: string): PanelMenu {
this.searchMenu_ = new PanelSearchMenu(menuMsg);
// Add event listeners to search bar.
this.searchMenu_.searchBar.addEventListener(
'input',
(event: Event) => this.onSearchBarQuery(event as InputEvent), false);
this.searchMenu_.searchBar.addEventListener('mouseup', event => {
// Clicking in the panel causes us to either activate an item or close the
// menus altogether. Prevent that from happening if we click the search
// bar.
event.preventDefault();
event.stopPropagation();
}, false);
$('menu-bar')!.appendChild(this.searchMenu_.menuBarItemElement);
this.searchMenu_.menuBarItemElement.addEventListener(
'mouseover',
() =>
this.activateMenu(this.searchMenu_, false /* activateFirstItem */),
false);
this.searchMenu_.menuBarItemElement.addEventListener(
'mouseup', event => this.onMouseUpOnMenuTitle(this.searchMenu_!, event),
false);
$('menus_background')!.appendChild(this.searchMenu_.menuContainerElement);
this.menus_.push(this.searchMenu_);
return this.searchMenu_;
}
addTouchGestureMenuItems(touchMenu: PanelMenu): void {
const touchGestureItems: TouchMenuData[] = [];
for (const data of Object.values(GestureCommandData.GESTURE_COMMAND_MAP)) {
const command = data.command;
if (!command) {
continue;
}
const gestureText = Msgs.getMsg(data.msgId);
const msgForCmd = data.commandDescriptionMsgId ||
CommandStore.messageForCommand(command);
let titleText;
if (msgForCmd) {
titleText = Msgs.getMsg(msgForCmd);
} else {
console.error('No localization for: ' + command + ' (gesture)');
titleText = '';
}
touchGestureItems.push({titleText, gestureText, command});
}
touchGestureItems.sort(
(item1, item2) => item1.titleText.localeCompare(item2.titleText));
for (const item of touchGestureItems) {
touchMenu.addMenuItem(
item.titleText, '', '', item.gestureText,
() => BackgroundBridge.CommandHandler.onCommand(item.command),
item.command);
}
}
/**
* Advance the index of the current active menu by |delta|.
* @param delta The number to add to the active menu index.
*/
advanceActiveMenuBy(delta: number): void {
let activeIndex = this.menus_.findIndex(menu => menu === this.activeMenu_);
if (activeIndex >= 0) {
activeIndex += delta;
activeIndex = (activeIndex + this.menus_.length) % this.menus_.length;
} else {
if (delta >= 0) {
activeIndex = 0;
} else {
activeIndex = this.menus_.length - 1;
}
}
activeIndex = this.findEnabledMenuIndex(activeIndex, delta > 0 ? 1 : -1);
if (activeIndex === -1) {
return;
}
this.activateMenu(this.menus_[activeIndex], true /* activateFirstItem */);
}
/**
* Advance the index of the current active menu item by |delta|.
* @param delta The number to add to the active menu item index.
*/
advanceItemBy(delta: number): void {
if (this.activeMenu_) {
this.activeMenu_.advanceItemBy(delta);
}
}
/**
* Clear any previous menus. The menus are all regenerated each time the
* menus are opened.
*/
clearMenus(): void {
while (this.menus_.length) {
const menu = this.menus_.pop();
$('menu-bar')!.removeChild(menu!.menuBarItemElement);
$('menus_background')!.removeChild(menu!.menuContainerElement);
if (this.activeMenu_) {
this.lastMenu_ = this.activeMenu_.menuMsg;
}
this.activeMenu_ = null;
}
}
/** Disables menu items that are prohibited without a signed-in user. */
denySignedOut(): void {
for (const menu of this.menus_) {
for (const item of menu.items) {
// TODO(b/314203187): Not null asserted, check that this is correct.
if (CommandStore.denySignedOut(item.element!.id as Command)) {
item.disable();
}
}
}
}
/**
* Starting at |startIndex|, looks for an enabled menu.
* @return The index of the enabled menu. -1 if not found.
*/
findEnabledMenuIndex(startIndex: number, delta: number): number {
const endIndex = (delta > 0) ? this.menus_.length : -1;
while (startIndex !== endIndex) {
if (this.menus_[startIndex].enabled) {
return startIndex;
}
startIndex += delta;
}
return -1;
}
/**
* Get the callback for whatever item is currently selected.
* @return The callback for the current item.
*
* TODO(b/267329383): Specify this as Promise<void> once PanelMenu
* is converted to typescript.
*/
getCallbackForCurrentItem(): (() => Promise<any>) | null{
if (this.activeMenu_) {
return this.activeMenu_.getCallbackForCurrentItem();
}
return null;
}
getSelectedMenu(menuTitle?: string): PanelMenu {
const specifiedMenu =
this.menus_.find(menu => menu.menuMsg === menuTitle);
return specifiedMenu || this.searchMenu_ || this.menus_[0];
}
async getSortedKeyBindings(): Promise<KeyBinding[]> {
// TODO(accessibility): Commands should be based off of CommandStore and
// not the keymap. There are commands that don't have a key binding (e.g.
// commands for touch).
const keymap = KeyMap.get();
// A shallow copy of the bindings is returned, so re-ordering the elements
// does not change the original.
const sortedBindings = keymap.bindings();
for (const binding of sortedBindings) {
const command = binding.command;
const keySeq = binding.sequence;
binding.keySeq = await KeyUtil.keySequenceToString(keySeq, true);
const titleMsgId = CommandStore.messageForCommand(command);
if (!titleMsgId) {
// Title messages are intentionally missing for some keyboard shortcuts.
if (!(command in COMMANDS_WITH_NO_MSG_ID) &&
!MenuManager.disableMissingMsgsErrorsForTesting) {
console.error('No localization for: ' + command);
}
binding.title = '';
continue;
}
const title = Msgs.getMsg(titleMsgId);
binding.title = StringUtil.toTitleCase(title);
}
sortedBindings.sort(
(binding1, binding2) =>
binding1.title!.localeCompare(String(binding2.title)));
return sortedBindings;
}
makeBindingMap(sortedBindings: KeyBinding[]): Map<Command, KeyBinding> {
const bindingMap = new Map();
for (const binding of sortedBindings) {
bindingMap.set(binding.command, binding);
}
return bindingMap;
}
makeCategoryMapping(
actionsMenu: PanelMenu, chromevoxMenu: PanelMenu, jumpMenu: PanelMenu,
speechMenu: PanelMenu): Record<CommandCategory, PanelMenu|null> {
return {
[CommandCategory.ACTIONS]: actionsMenu,
[CommandCategory.BRAILLE]: null,
[CommandCategory.CONTROLLING_SPEECH]: speechMenu,
[CommandCategory.DEVELOPER]: null,
[CommandCategory.HELP_COMMANDS]: chromevoxMenu,
[CommandCategory.INFORMATION]: speechMenu,
[CommandCategory.JUMP_COMMANDS]: jumpMenu,
[CommandCategory.MODIFIER_KEYS]: chromevoxMenu,
[CommandCategory.NAVIGATION]: jumpMenu,
[CommandCategory.NO_CATEGORY]: null,
[CommandCategory.OVERVIEW]: jumpMenu,
[CommandCategory.TABLES]: jumpMenu,
};
}
/** @return True if the event was handled. */
onKeyDown(event: KeyboardEvent): boolean {
if (!this.activeMenu) {
return false;
}
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
return false;
}
// We need special logic for navigating the search bar.
// If left/right arrow are pressed, we should adjust the search bar's
// cursor. We only want to advance the active menu if we are at the
// beginning/end of the search bar's contents.
if (this.searchMenu_ && event.target === this.searchMenu_.searchBar) {
const input = event.target as HTMLInputElement;
switch (event.key) {
case 'ArrowLeft':
case 'ArrowRight':
if (input.value) {
// TODO(b/314203187): Not null asserted, check that this is correct.
const cursorIndex =
input.selectionStart! + (event.key === 'ArrowRight' ? 1 : -1);
const queryLength = input.value.length;
if (cursorIndex >= 0 && cursorIndex <= queryLength) {
return false;
}
}
break;
case ' ':
return false;
}
}
switch (event.key) {
case 'ArrowLeft':
this.advanceActiveMenuBy(-1);
break;
case 'ArrowRight':
this.advanceActiveMenuBy(1);
break;
case 'ArrowUp':
this.advanceItemBy(-1);
break;
case 'ArrowDown':
this.advanceItemBy(1);
break;
case 'Escape':
// TODO(b/314203187): Not null asserted, check that this is correct.
PanelInterface.instance!.closeMenusAndRestoreFocus();
break;
case 'PageUp':
this.advanceItemBy(10);
break;
case 'PageDown':
this.advanceItemBy(-10);
break;
case 'Home':
this.scrollToTop();
break;
case 'End':
this.scrollToBottom();
break;
case 'Enter':
case ' ':
if (!this.getCallbackForCurrentItem()) {
// If there's no callback for the current menu item, then we shouldn't
// perform any special logic. Return false here and let the key event
// propagate so that it can potentially be handled elsewhere.
return false;
}
// TODO(b/314203187): Not null asserted, check that this is correct.
PanelInterface.instance!.setPendingCallback(
this.getCallbackForCurrentItem());
PanelInterface.instance!.closeMenusAndRestoreFocus();
break;
default:
// Don't mark this event as handled.
return false;
}
return true;
}
/**
* Called when the user releases the mouse button. If it's anywhere other
* than on the menus button, close the menus and return focus to the page,
* and if the mouse was released over a menu item, execute that item's
* callback.
*/
onMouseUp(event: MouseEvent): void {
if (!this.activeMenu_) {
return;
}
let target: HTMLElement|null = event.target as HTMLElement;
while (target && !target.classList.contains('menu-item')) {
// Allow the user to click and release on the menu button and leave
// the menu button.
if (target.id === 'menus_button') {
return;
}
target = target.parentElement;
}
// TODO(b/314203187): Not null asserted, check that this is correct.
if (target && this.activeMenu_) {
PanelInterface.instance!.setPendingCallback(
this.activeMenu_.getCallbackForElement(target));
}
PanelInterface.instance!.closeMenusAndRestoreFocus();
}
/**
* Activate a menu whose title has been clicked. Stop event propagation at
* this point so we don't close the ChromeVox menus and restore focus.
* @param menu The menu we would like to activate.
* @param mouseUpEvent The mouseup event.
*/
onMouseUpOnMenuTitle(menu: PanelMenu, mouseUpEvent: MouseEvent): void {
this.activateMenu(menu, true /* activateFirstItem */);
mouseUpEvent.preventDefault();
mouseUpEvent.stopPropagation();
}
/**
* Open / show the ChromeVox Menus.
* @param {Event=} event An optional event that triggered this.
* @param {string=} activateMenuTitle?: string Title msg id of menu to open.
*/
async onOpenMenus(event?: Event, activateMenuTitle?: string): Promise<void> {
// If the menu was already open, close it now and exit early.
// TODO(b/314203187): Not null asserted, check that this is correct.
if (PanelInterface.instance!.mode !== PanelMode.COLLAPSED) {
PanelInterface.instance!.setMode(PanelMode.COLLAPSED);
return;
}
// Eat the event so that a mousedown isn't turned into a drag, allowing
// users to click-drag-release to select a menu item.
if (event) {
event.stopPropagation();
event.preventDefault();
}
await BackgroundBridge.PanelBackground.saveCurrentNode();
// TODO(b/314203187): Not null asserted, check that this is correct.
PanelInterface.instance!.setMode(PanelMode.FULLSCREEN_MENUS);
// The panel does not get focus immediately when we request to be full
// screen (handled in ChromeVoxPanel natively on hash changed). Wait, if
// needed, for focus to begin initialization.
if (!document.hasFocus()) {
await waitForWindowFocus();
}
const eventSource = await BackgroundBridge.EventSource.get();
const touchScreen = (eventSource === EventSourceType.TOUCH_GESTURE);
// Build the top-level menus.
this.addSearchMenu('panel_search_menu');
const jumpMenu = this.addMenu('panel_menu_jump');
const speechMenu = this.addMenu('panel_menu_speech');
const touchMenu =
touchScreen ? this.addMenu('panel_menu_touchgestures') : null;
const chromevoxMenu = this.addMenu('panel_menu_chromevox');
const actionsMenu = this.addMenu('panel_menu_actions');
// Create a mapping between categories from CommandStore, and our
// top-level menus. Some categories aren't mapped to any menu.
const categoryToMenu = this.makeCategoryMapping(
actionsMenu, chromevoxMenu, jumpMenu, speechMenu);
// Make a copy of the key bindings, get the localized title of each
// command, and then sort them.
const sortedBindings = await this.getSortedKeyBindings();
// Insert items from the bindings into the menus.
const bindingMap = this.makeBindingMap(sortedBindings);
for (const binding of bindingMap.values()) {
const category = CommandStore.categoryForCommand(binding.command);
const menu = category ? categoryToMenu[category] : null;
this.addMenuItemFromKeyBinding(binding, menu, touchScreen);
}
// Add Touch Gestures menu items.
if (touchMenu) {
this.addTouchGestureMenuItems(touchMenu);
}
// TODO(b/314203187): Not null asserted, check that this is correct.
if (PanelInterface.instance!.sessionState !== 'IN_SESSION') {
this.denySignedOut();
}
// Add a menu item that disables / closes ChromeVox.
// TODO(b/314203187): Not null asserted, check that this is correct.
chromevoxMenu.addMenuItem(
Msgs.getMsg('disable_chromevox'), 'Ctrl+Alt+Z', '', '',
async () => PanelInterface.instance!.onClose());
for (const menuData of ALL_PANEL_MENU_NODE_DATA) {
this.addNodeMenu(menuData);
}
await BackgroundBridge.PanelBackground.createAllNodeMenuBackgrounds(
activateMenuTitle);
await this.addActionsMenuItems(actionsMenu, bindingMap);
// Activate either the specified menu or the search menu.
const selectedMenu = this.getSelectedMenu(activateMenuTitle);
const activateFirstItem = (selectedMenu !== this.searchMenu);
this.activateMenu(selectedMenu, activateFirstItem);
}
/**
* Listens to changes in the menu search bar. Populates the search menu
* with items that match the search bar's contents.
* Note: we ignore PanelNodeMenu items and items without shortcuts.
* @param event The input event.
*/
onSearchBarQuery(event: InputEvent): void {
if (!this.searchMenu_) {
throw Error('MenuManager.searchMenu_ must be defined');
}
const query = (event.target as HTMLInputElement).value.toLowerCase();
this.searchMenu_.clear();
// Show the search results menu.
this.activateMenu(this.searchMenu_, false /* activateFirstItem */);
// Populate.
if (query) {
for (const menu of this.menus_) {
if (menu === this.searchMenu_ || menu instanceof PanelNodeMenu) {
continue;
}
for (const item of menu.items) {
if (!item.menuItemShortcut) {
// Only add menu items that have shortcuts.
continue;
}
const itemText = item.text.toLowerCase();
const match = itemText.includes(query) &&
(itemText !==
Msgs.getMsg('panel_menu_item_none').toLowerCase()) &&
item.enabled;
if (match) {
this.searchMenu_.copyAndAddMenuItem(item);
}
}
}
}
if (this.searchMenu_.items.length === 0) {
this.searchMenu_.addMenuItem(
Msgs.getMsg(
'panel_menu_item_none'), '', '', '', () => Promise.resolve());
}
this.searchMenu_.activateItem(0);
}
/** Sets the index of the current active menu to be the last index. */
scrollToBottom(): void {
this.activeMenu_!.scrollToBottom();
}
/** Sets the index of the current active menu to be 0. */
scrollToTop(): void {
this.activeMenu_!.scrollToTop();
}
// The following getters and setters are temporary during the migration from
// panel.js.
get activeMenu(): PanelMenu | null {
return this.activeMenu_;
}
set activeMenu(menu: PanelMenu | null) {
this.activeMenu_ = menu;
}
get lastMenu(): string {
return this.lastMenu_;
}
set lastMenu(menuMsg: string) {
this.lastMenu_ = menuMsg;
}
get menus(): PanelMenu[] {
return this.menus_;
}
get nodeMenuDictionary(): Partial<Record<PanelNodeMenuId, PanelNodeMenu>> {
return this.nodeMenuDictionary_;
}
get searchMenu(): PanelSearchMenu | null {
return this.searchMenu_;
}
set searchMenu(menu: PanelSearchMenu | null) {
this.searchMenu_ = menu;
}
}
// Local to module.
const COMMANDS_WITH_NO_MSG_ID = [
'nativeNextCharacter',
'nativePreviousCharacter',
'nativeNextWord',
'nativePreviousWord',
'enableLogging',
'disableLogging',
'dumpTree',
'showActionsMenu',
'enableChromeVoxArcSupportForCurrentApp',
'disableChromeVoxArcSupportForCurrentApp',
'showTalkBackKeyboardShortcuts',
'copy',
];
const ACTION_TO_MSG_ID: Record<string, string> = {
decrement: 'action_decrement_description',
doDefault: 'perform_default_action',
increment: 'action_increment_description',
scrollBackward: 'action_scroll_backward_description',
scrollForward: 'action_scroll_forward_description',
showContextMenu: 'show_context_menu',
longClick: 'force_long_click_on_current_item',
};
async function waitForWindowFocus(): Promise<any> {
return new Promise(
resolve => window.addEventListener('focus', resolve, {once: true}));
}
TestImportManager.exportForTesting(MenuManager);