blob: c31c533d2202193fb4bcb22bb702df29f905ac2d [file] [log] [blame]
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* Class to handle interactions with the Switch Access menu, including moving
* through and selecting actions.
*/
class MenuManager {
/**
* @param {!NavigationManager} navigationManager
* @param {!chrome.automation.AutomationNode} desktop
*/
constructor(navigationManager, desktop) {
/**
* A list of the Menu actions that are currently enabled.
* @private {!Array<!SAConstants.MenuAction>}
*/
this.actions_ = [];
/**
* The parent automation manager.
* @private {!NavigationManager}
*/
this.navigationManager_ = navigationManager;
/**
* The root node of the screen.
* @private {!chrome.automation.AutomationNode}
*/
this.desktop_ = desktop;
/**
* The root node of the menu panel.
* @private {chrome.automation.AutomationNode}
*/
this.menuPanelNode_;
/**
* The root node of the menu.
* @private {chrome.automation.AutomationNode}
*/
this.menuNode_;
/**
* The current node of the menu.
* @private {chrome.automation.AutomationNode}
*/
this.node_;
/**
* The node that the menu has been opened for. Null if the menu is not
* currently open.
* @private {chrome.automation.AutomationNode}
*/
this.menuOriginNode_;
/**
* Keeps track of when we're in the Switch Access menu.
* @private {boolean}
*/
this.inMenu_ = false;
/**
* Keeps track of when there's a selection in the current node.
* @private {boolean}
*/
this.selectionExists_ = false;
/**
* Callback for reloading the menu when the text selection has changed.
* Bind creates a new function, so this function is saved as a field to
* add and remove the selection event listener properly.
* @private {function(chrome.automation.AutomationEvent): undefined}
*/
this.onSelectionChanged_ = this.reloadMenuForSelectionChange_.bind(this);
/**
* Keeps track of when the clipboard is empty.
* @private {boolean}
*/
this.clipboardHasData_ = false;
/**
* A reference to the Switch Access Menu Panel class.
* @private {PanelInterface}
*/
this.menuPanel_;
/**
* Callback for highlighting the first available action once a
* menu has been loaded in the panel. Bind creates a new function, so
* this function is saved as a field so that the event listener
* associated with this callback can be removed properly.
* @private {function(chrome.automation.AutomationEvent): undefined}
*/
this.onMenuPanelChildrenChanged_ = this.highlightFirstAction_.bind(this);
/**
* A stack to keep track of all menus that have been opened before
* the current menu (so the top of the stack will be the parent
* menu of the current menu).
* @private {!Array<SAConstants.MenuId>}
*/
this.menuStack_ = [];
this.init_();
}
/**
* Set up clipboardListener for showing/hiding paste button.
* @private
*/
init_() {
if (window.switchAccess.improvedTextInputEnabled()) {
chrome.clipboard.onClipboardDataChanged.addListener(
this.updateClipboardHasData.bind(this));
}
}
/**
* If multiple actions are available for the currently highlighted node,
* opens the main menu. Otherwise, selects the node by default.
* @param {!chrome.automation.AutomationNode} navNode the currently
* highlighted node, for which the menu is to be displayed.
*/
enter(navNode) {
if (!this.menuPanel_) {
console.log('Error: Menu panel has not loaded.');
return;
}
if (!this.openMenu_(navNode, SAConstants.MenuId.MAIN)) {
// openMenu_ will return false (indicating that the menu was not opened
// successfully) when there is only one interesting action (selection)
// specific to this node. In this case, rather than forcing the user to
// repeatedly disambiguate, we will simply select by default.
this.navigationManager_.selectCurrentNode();
return;
}
this.inMenu_ = true;
}
/**
* Exits the menu.
*/
exit() {
this.closeCurrentMenu_();
this.inMenu_ = false;
if (window.switchAccess.improvedTextInputEnabled() &&
this.menuOriginNode_) {
this.menuOriginNode_.removeEventListener(
chrome.automation.EventType.TEXT_SELECTION_CHANGED,
this.onSelectionChanged_, false /** Don't use capture. */);
}
this.menuOriginNode_ = null;
chrome.accessibilityPrivate.setSwitchAccessMenuState(
false /** Hide the menu. */, SAConstants.EMPTY_LOCATION, 0);
}
/**
* Opens the menu with given |menuId|. Shows the menu actions that are
* applicable to the currently highlighted node in the menu panel. If the
* menu being opened is the same as the current menu open (i.e. the menu is
* being reloaded), then the action that triggered the reload
* will be highlighted. Otherwise, the first available action will
* be highlighted. Returns a boolean of whether or not the menu was
* successfully opened.
* @param {!chrome.automation.AutomationNode} navNode The currently
* highlighted node, for which the menu is being opened.
* @param {!SAConstants.MenuId} menuId Indicates the menu being opened.
* @param {boolean=} isSubmenu Whether or not the menu being opened is a
* submenu of the current menu.
* @return {boolean} Whether or not the menu was successfully opened.
* @private
*/
openMenu_(navNode, menuId, isSubmenu = false) {
// Action currently highlighted in the menu (null if the menu was closed
// before this function was called).
const actionNode = this.node_;
const currentMenuId = this.menuPanel_.currentMenuId();
const shouldReloadMenu = (currentMenuId === menuId);
if (!shouldReloadMenu) {
// Close the current menu before opening a new one.
this.closeCurrentMenu_();
if (currentMenuId && isSubmenu) {
// Opening a submenu, so push the parent menu onto the stack.
this.menuStack_.push(currentMenuId);
}
}
const actions = this.getMenuActions_(navNode, menuId);
if (!actions) {
return false;
}
if (!shouldReloadMenu) {
// Wait for the menu to appear in the panel before highlighting the
// first available action.
this.menuPanelNode().addEventListener(
chrome.automation.EventType.CHILDREN_CHANGED,
this.onMenuPanelChildrenChanged_, false /** Don't use capture. */);
}
// Converting to JSON strings to check equality of Array contents.
if (JSON.stringify(actions) !== JSON.stringify(this.actions_)) {
// Set new menu actions in the panel.
this.actions_ = actions;
this.menuPanel_.setActions(this.actions_, menuId);
}
if (!navNode.location) {
console.log('Unable to show Switch Access menu.');
return false;
}
// Show the menu panel.
chrome.accessibilityPrivate.setSwitchAccessMenuState(
true, navNode.location, actions.length);
this.menuOriginNode_ = navNode;
if (!shouldReloadMenu && window.switchAccess.improvedTextInputEnabled()) {
this.menuOriginNode_.addEventListener(
chrome.automation.EventType.TEXT_SELECTION_CHANGED,
this.onSelectionChanged_, false /** Don't use capture. */);
}
if (shouldReloadMenu && actionNode) {
let buttonId = actionNode.htmlAttributes.id;
if (actions.includes(buttonId)) {
// Highlight the same action that was highlighted before the menu was
// reloaded.
this.node_ = actionNode;
this.updateFocusRing_();
} else {
while (!actions.includes(buttonId) && buttonId != 'back') {
this.moveForward();
buttonId = this.node_.htmlAttributes.id;
}
}
}
return true;
}
/**
* Closes the current menu and clears the menu panel.
* @private
*/
closeCurrentMenu_() {
this.clearFocusRing_();
if (this.node_) {
this.node_ = null;
}
this.menuPanel_.clear();
this.actions_ = [];
this.menuNode_ = null;
}
/**
* Get the actions applicable for |navNode| from the menu with given
* |menuId|.
* @param {!chrome.automation.AutomationNode} navNode The currently selected
* node, for which the menu is being opened.
* @param {SAConstants.MenuId} menuId
* @return {Array<SAConstants.MenuAction>}
* @private
*/
getMenuActions_(navNode, menuId) {
switch (menuId) {
case SAConstants.MenuId.MAIN:
return this.getMainMenuActionsForNode_(navNode);
case SAConstants.MenuId.TEXT_NAVIGATION:
return this.getTextNavigationActions_();
default:
return this.getMainMenuActionsForNode_(navNode);
}
}
/**
* Get the actions in the text navigation submenu.
* @return {!Array<SAConstants.MenuAction>}
* @private
*/
getTextNavigationActions_() {
return [
SAConstants.MenuAction.JUMP_TO_BEGINNING_OF_TEXT,
SAConstants.MenuAction.JUMP_TO_END_OF_TEXT,
SAConstants.MenuAction.MOVE_BACKWARD_ONE_CHAR_OF_TEXT,
SAConstants.MenuAction.MOVE_BACKWARD_ONE_WORD_OF_TEXT,
SAConstants.MenuAction.MOVE_DOWN_ONE_LINE_OF_TEXT,
SAConstants.MenuAction.MOVE_FORWARD_ONE_CHAR_OF_TEXT,
SAConstants.MenuAction.MOVE_FORWARD_ONE_WORD_OF_TEXT,
SAConstants.MenuAction.MOVE_UP_ONE_LINE_OF_TEXT
];
}
/**
* Highlights the first available action in the menu.
* @private
*/
highlightFirstAction_() {
let firstNode =
this.menuNode().find({role: chrome.automation.RoleType.BUTTON});
while (firstNode && !this.isActionAvailable_(firstNode.htmlAttributes.id))
firstNode = firstNode.nextSibling;
if (firstNode) {
this.node_ = firstNode;
this.updateFocusRing_();
}
// The event is fired multiple times when a new menu is opened in the
// panel, so remove the listener once the callback has been called once.
// This ensures the first action is not continually highlighted as we
// navigate through the menu.
this.menuPanelNode().removeEventListener(
chrome.automation.EventType.CHILDREN_CHANGED,
this.onMenuPanelChildrenChanged_, false /** Don't use capture. */);
}
/**
* Move to the next available action in the menu. If this is no next action,
* focus the whole menu to loop again.
* @return {boolean} Whether this function had any effect.
*/
moveForward() {
this.calculateCurrentNode();
if (!this.inMenu_ || !this.node_)
return false;
this.clearFocusRing_();
const treeWalker = new AutomationTreeWalker(
this.node_, constants.Dir.FORWARD,
SwitchAccessPredicate.restrictions(this.menuNode()));
const node = treeWalker.next().node;
if (!node)
this.node_ = null;
else
this.node_ = node;
this.updateFocusRing_();
return true;
}
/**
* Move to the previous available action in the menu. If we're at the
* beginning of the list, start again at the end.
* @return {boolean} Whether this function had any effect.
*/
moveBackward() {
this.calculateCurrentNode();
if (!this.inMenu_ || !this.node_)
return false;
this.clearFocusRing_();
const treeWalker = new AutomationTreeWalker(
this.node_, constants.Dir.BACKWARD,
SwitchAccessPredicate.restrictions(this.menuNode()));
let node = treeWalker.next().node;
// If node is null, find the last enabled button.
let lastChild = this.menuNode().lastChild;
while (!node && lastChild) {
if (SwitchAccessPredicate.isActionable(lastChild)) {
node = lastChild;
break;
} else {
lastChild = lastChild.previousSibling;
}
}
this.node_ = node;
this.updateFocusRing_();
return true;
}
/**
* Perform the action indicated by the current button.
* @return {boolean} Whether this function had any effect.
*/
selectCurrentNode() {
this.calculateCurrentNode();
if (!this.inMenu_ || !this.node_) {
return false;
}
if (this.node_.role === RoleType.BUTTON) {
// A menu action was selected.
this.node_.doDefault();
} else {
// The back button was selected.
this.selectBackButton();
}
return true;
}
/**
* Selects the back button for the menu. If the current menu is a submenu
* (i.e. not the main menu), then the current menu will be
* closed and the parent menu that opened the current menu will be re-opened.
* If the current menu is the main menu, then exit the menu panel entirely
* and return to traditional navigation.
*/
selectBackButton() {
// Id of the menu that opened the current menu (null if the current
// menu is the main menu and not a submenu).
const parentMenuId = this.menuStack_.pop();
if (parentMenuId && this.menuOriginNode_) {
// Re-open the parent menu.
this.openMenu_(this.menuOriginNode_, parentMenuId);
} else {
this.exit();
}
}
/**
* Sets up the connection between the menuPanel and the menuManager.
* @param {!PanelInterface} menuPanel
* @return {!MenuManager}
*/
connectMenuPanel(menuPanel) {
this.menuPanel_ = menuPanel;
return this;
}
/**
* Get the menu panel node. If it's not defined, search for it.
* @return {!chrome.automation.AutomationNode}
*/
menuPanelNode() {
if (this.menuPanelNode_) {
return this.menuPanelNode_;
}
const treeWalker = new AutomationTreeWalker(
this.desktop_, constants.Dir.FORWARD,
SwitchAccessPredicate.switchAccessMenuPanelDiscoveryRestrictions());
const node = treeWalker.next().node;
if (node) {
this.menuPanelNode_ = node;
return this.menuPanelNode_;
}
console.log('Unable to find the Switch Access menu panel.');
return this.desktop_;
}
/**
* Get the menu node. With the current design, the menu panel should
* always contain at most one menu. When a menu is open in the panel,
* the menu node is the first child of the menu panel node.
* @return {!chrome.automation.AutomationNode}
*/
menuNode() {
if (this.menuNode_) {
return this.menuNode_;
}
if (this.menuPanelNode() !== this.desktop_) {
if (this.menuPanelNode_.firstChild) {
this.menuNode_ = this.menuPanelNode_.firstChild;
return this.menuNode_;
}
}
return this.desktop_;
}
/**
* Whether or not the menu is currently open.
* @return {boolean}
* @public
*/
inMenu() {
return this.inMenu_;
}
/**
* TODO(rosalindag): Add functionality to catch when clipboardHasData_ needs
* to be set to false.
* Set the clipboardHasData variable to true and reload the menu.
*/
updateClipboardHasData() {
this.clipboardHasData_ = true;
if (this.menuOriginNode_) {
this.openMenu_(this.menuOriginNode_, SAConstants.MenuId.MAIN);
}
}
/**
* Clear the focus ring.
* @private
*/
clearFocusRing_() {
this.updateFocusRing_(true);
}
/**
* Returns if there is a selection in the current node.
* @private
* @returns {boolean} whether or not there's a selection
*/
nodeHasSelection_() {
if (this.menuOriginNode_) {
if (this.menuOriginNode_.textSelStart !==
this.menuOriginNode_.textSelEnd) {
return true;
} else {
return false;
}
}
return false;
}
/**
* Check to see if there is a change in the selection in the current node and
* reload the menu if so.
* @private
*/
reloadMenuForSelectionChange_() {
let newSelectionState = this.nodeHasSelection_();
if (this.selectionExists_ != newSelectionState) {
this.selectionExists_ = newSelectionState;
if (this.menuOriginNode_ &&
!this.navigationManager_.currentlySelecting()) {
let currentMenuId = this.menuPanel_.currentMenuId();
if (currentMenuId) {
this.openMenu_(this.menuOriginNode_, currentMenuId);
} else {
this.openMenu_(this.menuOriginNode_, SAConstants.MenuId.MAIN);
}
}
}
}
/**
* Determines which menu actions are relevant, given the current node. If
* there are no node-specific actions, return |null|, to indicate that we
* should select the current node automatically.
*
* @param {!chrome.automation.AutomationNode} node
* @return {Array<!SAConstants.MenuAction>}
* @private
*/
getMainMenuActionsForNode_(node) {
let actions = [];
let scrollableAncestor = node;
while (!scrollableAncestor.scrollable && scrollableAncestor.parent)
scrollableAncestor = scrollableAncestor.parent;
if (scrollableAncestor.scrollable) {
if (scrollableAncestor.scrollX > scrollableAncestor.scrollXMin)
actions.push(SAConstants.MenuAction.SCROLL_LEFT);
if (scrollableAncestor.scrollX < scrollableAncestor.scrollXMax)
actions.push(SAConstants.MenuAction.SCROLL_RIGHT);
if (scrollableAncestor.scrollY > scrollableAncestor.scrollYMin)
actions.push(SAConstants.MenuAction.SCROLL_UP);
if (scrollableAncestor.scrollY < scrollableAncestor.scrollYMax)
actions.push(SAConstants.MenuAction.SCROLL_DOWN);
}
const standardActions = /** @type {!Array<!SAConstants.MenuAction>} */ (
node.standardActions.filter(
action => Object.values(SAConstants.MenuAction).includes(action)));
actions = actions.concat(standardActions);
if (SwitchAccessPredicate.isTextInput(node)) {
actions.push(SAConstants.MenuAction.KEYBOARD);
actions.push(SAConstants.MenuAction.DICTATION);
if (window.switchAccess.improvedTextInputEnabled() &&
node.state[StateType.FOCUSED]) {
actions.push(SAConstants.MenuAction.MOVE_CURSOR);
actions.push(SAConstants.MenuAction.SELECT_START);
if (this.navigationManager_.currentlySelecting()) {
actions.push(SAConstants.MenuAction.SELECT_END);
}
if (this.selectionExists_) {
actions.push(SAConstants.MenuAction.CUT);
actions.push(SAConstants.MenuAction.COPY);
}
if (this.clipboardHasData_) {
actions.push(SAConstants.MenuAction.PASTE);
}
}
} else if (actions.length > 0) {
actions.push(SAConstants.MenuAction.SELECT);
}
if (actions.length === 0)
return null;
actions.push(SAConstants.MenuAction.SETTINGS);
return actions;
}
/**
* Verify if a specified action is available in the current menu.
* @param {!SAConstants.MenuAction} action
* @return {boolean}
* @private
*/
isActionAvailable_(action) {
if (!this.inMenu_)
return false;
return this.actions_.includes(action);
}
/**
* Perform a specified action on the Switch Access menu.
* @param {!SAConstants.MenuAction} action
*/
performAction(action) {
if (!window.switchAccess.improvedTextInputEnabled()) {
this.exit();
}
switch (action) {
case SAConstants.MenuAction.SELECT:
if (window.switchAccess.improvedTextInputEnabled()) {
// Explicitly call exit for actions where the menu closes after
// the icon is clicked.
this.exit();
}
this.navigationManager_.selectCurrentNode();
break;
case SAConstants.MenuAction.KEYBOARD:
if (window.switchAccess.improvedTextInputEnabled()) {
this.exit();
}
this.navigationManager_.openKeyboard();
break;
case SAConstants.MenuAction.DICTATION:
if (window.switchAccess.improvedTextInputEnabled()) {
this.exit();
}
chrome.accessibilityPrivate.toggleDictation();
break;
case SAConstants.MenuAction.SETTINGS:
if (window.switchAccess.improvedTextInputEnabled()) {
this.exit();
}
chrome.accessibilityPrivate.openSettingsSubpage(
'manageAccessibility/switchAccess');
break;
case SAConstants.MenuAction.SCROLL_DOWN:
case SAConstants.MenuAction.SCROLL_UP:
case SAConstants.MenuAction.SCROLL_LEFT:
case SAConstants.MenuAction.SCROLL_RIGHT:
if (window.switchAccess.improvedTextInputEnabled()) {
this.exit();
}
this.navigationManager_.scroll(action);
break;
case SAConstants.MenuAction.MOVE_CURSOR:
if (this.menuOriginNode_) {
this.openMenu_(
this.menuOriginNode_, SAConstants.MenuId.TEXT_NAVIGATION,
true /** Opening a submenu. */);
}
break;
case SAConstants.MenuAction.JUMP_TO_BEGINNING_OF_TEXT:
this.navigationManager_.jumpToBeginningOfText();
break;
case SAConstants.MenuAction.JUMP_TO_END_OF_TEXT:
this.navigationManager_.jumpToEndOfText();
break;
case SAConstants.MenuAction.MOVE_BACKWARD_ONE_CHAR_OF_TEXT:
this.navigationManager_.moveBackwardOneCharOfText();
break;
case SAConstants.MenuAction.MOVE_BACKWARD_ONE_WORD_OF_TEXT:
this.navigationManager_.moveBackwardOneWordOfText();
break;
case SAConstants.MenuAction.MOVE_DOWN_ONE_LINE_OF_TEXT:
this.navigationManager_.moveDownOneLineOfText();
break;
case SAConstants.MenuAction.MOVE_FORWARD_ONE_CHAR_OF_TEXT:
this.navigationManager_.moveForwardOneCharOfText();
break;
case SAConstants.MenuAction.MOVE_FORWARD_ONE_WORD_OF_TEXT:
this.navigationManager_.moveForwardOneWordOfText();
break;
case SAConstants.MenuAction.MOVE_UP_ONE_LINE_OF_TEXT:
this.navigationManager_.moveUpOneLineOfText();
break;
case SAConstants.MenuAction.CUT:
this.navigationManager_.cut();
break;
case SAConstants.MenuAction.COPY:
this.navigationManager_.copy();
break;
case SAConstants.MenuAction.PASTE:
this.navigationManager_.paste();
break;
case SAConstants.MenuAction.SELECT_START:
this.navigationManager_.saveSelectStart();
if (this.menuOriginNode_) {
this.openMenu_(this.menuOriginNode_, SAConstants.MenuId.MAIN);
}
break;
case SAConstants.MenuAction.SELECT_END:
this.navigationManager_.endSelection();
if (this.menuOriginNode_)
this.openMenu_(this.menuOriginNode_, SAConstants.MenuId.MAIN);
break;
default:
if (window.switchAccess.improvedTextInputEnabled()) {
this.exit();
}
this.navigationManager_.performActionOnCurrentNode(action);
}
}
/**
* Send a message to the menu to update the focus ring around the current
* node.
* TODO(anastasi): Revisit focus rings before launch
* @private
* @param {boolean=} opt_clear If true, will clear the focus ring.
*/
updateFocusRing_(opt_clear) {
if (!this.menuPanel_) {
console.log('Error: Menu panel has not loaded.');
return;
}
this.calculateCurrentNode();
if (!this.inMenu_ || !this.node_)
return;
let id = this.node_.htmlAttributes.id;
// If the selection will close the menu, highlight the back button.
if (id === this.menuPanel_.currentMenuId()) {
id = SAConstants.BACK_ID;
}
const enable = !opt_clear;
this.menuPanel_.setFocusRing(id, enable);
}
/**
* Updates the value of |this.node_|.
*
* - If it has a value, change nothing.
* - Otherwise, if menu node has a reasonable value, set |this.node_| to menu
* node.
* - If not, set it to null.
*
* Return |this.node_|'s value after the update.
*
* @private
* @return {chrome.automation.AutomationNode}
*/
calculateCurrentNode() {
if (this.node_)
return this.node_;
this.node_ = this.menuNode();
if (this.node_ === this.desktop_)
this.node_ = null;
return this.node_;
}
}