blob: f38112ff74cfb1ebd004591be8a36b3f515cc2d1 [file] [log] [blame]
// Copyright 2019 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.
/**
* This class handles interactions with an onscreen element based on a single
* AutomationNode.
*/
class BasicNode extends SAChildNode {
/**
* @param {!AutomationNode} baseNode
* @param {?SARootNode} parent
* @protected
*/
constructor(baseNode, parent) {
super();
/** @private {!AutomationNode} */
this.baseNode_ = baseNode;
/** @private {?SARootNode} */
this.parent_ = parent;
/** @private {RepeatedEventHandler} */
this.locationChangedHandler_;
}
// ================= Getters and setters =================
/** @override */
get actions() {
const actions = [];
actions.push(SwitchAccessMenuAction.SELECT);
const ancestor = this.getScrollableAncestor_();
if (ancestor.scrollable) {
if (ancestor.scrollX > ancestor.scrollXMin) {
actions.push(SwitchAccessMenuAction.SCROLL_LEFT);
}
if (ancestor.scrollX < ancestor.scrollXMax) {
actions.push(SwitchAccessMenuAction.SCROLL_RIGHT);
}
if (ancestor.scrollY > ancestor.scrollYMin) {
actions.push(SwitchAccessMenuAction.SCROLL_UP);
}
if (ancestor.scrollY < ancestor.scrollYMax) {
actions.push(SwitchAccessMenuAction.SCROLL_DOWN);
}
}
const standardActions = /** @type {!Array<!SwitchAccessMenuAction>} */ (
this.baseNode_.standardActions.filter(
action => Object.values(SwitchAccessMenuAction).includes(action)));
return actions.concat(standardActions);
}
/** @override */
get automationNode() {
return this.baseNode_;
}
/** @override */
get location() {
return this.baseNode_.location;
}
/** @override */
get role() {
return this.baseNode_.role;
}
// ================= General methods =================
/** @override */
asRootNode() {
if (!this.isGroup()) {
return null;
}
return BasicRootNode.buildTree(this.baseNode_);
}
/** @override */
equals(other) {
if (!other || !(other instanceof BasicNode)) {
return false;
}
other = /** @type {!BasicNode} */ (other);
return other.baseNode_ === this.baseNode_;
}
/** @override */
isEquivalentTo(node) {
if (node instanceof BasicNode || node instanceof BasicRootNode) {
return this.baseNode_ === node.baseNode_;
}
if (node instanceof SAChildNode) {
return node.isEquivalentTo(this);
}
return this.baseNode_ === node;
}
/** @override */
isGroup() {
const cache = new SACache();
return SwitchAccessPredicate.isGroup(this.baseNode_, this.parent_, cache);
}
/** @override */
isValidAndVisible() {
// Nodes may have been deleted or orphaned.
if (!this.baseNode_ || !this.baseNode_.role) {
return false;
}
return SwitchAccessPredicate.isVisible(this.baseNode_) &&
super.isValidAndVisible();
}
/** @override */
onFocus() {
super.onFocus();
this.locationChangedHandler_ = new RepeatedEventHandler(
this.baseNode_, chrome.automation.EventType.LOCATION_CHANGED, () => {
if (this.isValidAndVisible()) {
FocusRingManager.setFocusedNode(this);
} else {
NavigationManager.moveToValidNode();
}
}, {exactMatch: true, allAncestors: true});
}
/** @override */
onUnfocus() {
super.onUnfocus();
if (this.locationChangedHandler_) {
this.locationChangedHandler_.stopListening();
}
}
/** @override */
performAction(action) {
let ancestor;
switch (action) {
case SwitchAccessMenuAction.SELECT:
if (this.isGroup()) {
NavigationManager.enterGroup();
} else {
this.baseNode_.doDefault();
}
return SAConstants.ActionResponse.CLOSE_MENU;
case SwitchAccessMenuAction.SCROLL_DOWN:
ancestor = this.getScrollableAncestor_();
if (ancestor.scrollable) {
ancestor.scrollDown();
}
return SAConstants.ActionResponse.RELOAD_MAIN_MENU;
case SwitchAccessMenuAction.SCROLL_UP:
ancestor = this.getScrollableAncestor_();
if (ancestor.scrollable) {
ancestor.scrollUp();
}
return SAConstants.ActionResponse.RELOAD_MAIN_MENU;
case SwitchAccessMenuAction.SCROLL_RIGHT:
ancestor = this.getScrollableAncestor_();
if (ancestor.scrollable) {
ancestor.scrollRight();
}
return SAConstants.ActionResponse.RELOAD_MAIN_MENU;
case SwitchAccessMenuAction.SCROLL_LEFT:
ancestor = this.getScrollableAncestor_();
if (ancestor.scrollable) {
ancestor.scrollLeft();
}
return SAConstants.ActionResponse.RELOAD_MAIN_MENU;
default:
if (Object.values(chrome.automation.ActionType).includes(action)) {
this.baseNode_.performStandardAction(
/** @type {chrome.automation.ActionType} */ (action));
}
return SAConstants.ActionResponse.CLOSE_MENU;
}
}
// ================= Private methods =================
/**
* @return {AutomationNode}
* @protected
*/
getScrollableAncestor_() {
let ancestor = this.baseNode_;
while (!ancestor.scrollable && ancestor.parent) {
ancestor = ancestor.parent;
}
return ancestor;
}
// ================= Static methods =================
/**
* @param {!AutomationNode} baseNode
* @param {?SARootNode} parent
* @return {!BasicNode}
*/
static create(baseNode, parent) {
if (SwitchAccessPredicate.isTextInput(baseNode)) {
return new EditableTextNode(baseNode, parent);
}
if (AutomationPredicate.comboBox(baseNode)) {
return new ComboBoxNode(baseNode, parent);
}
switch (baseNode.role) {
case chrome.automation.RoleType.SLIDER:
return new SliderNode(baseNode, parent);
case chrome.automation.RoleType.TAB:
return TabNode.create(baseNode, parent);
default:
return new BasicNode(baseNode, parent);
}
}
}
/**
* This class handles constructing and traversing a group of onscreen elements
* based on all the interesting descendants of a single AutomationNode.
*/
class BasicRootNode extends SARootNode {
/**
* WARNING: If you call this constructor, you must *explicitly* set children.
* Use the static function BasicRootNode.buildTree for most use cases.
* @param {!AutomationNode} baseNode
*/
constructor(baseNode) {
super(baseNode);
/** @private {boolean} */
this.invalidated_ = false;
/** @private {RepeatedEventHandler} */
this.childrenChangedHandler_;
}
// ================= Getters and setters =================
/** @override */
get location() {
return this.automationNode.location || super.location;
}
// ================= General methods =================
/** @override */
equals(other) {
if (!(other instanceof BasicRootNode)) {
return false;
}
return super.equals(other) && this.automationNode === other.automationNode;
}
/** @override */
isEquivalentTo(node) {
if (node instanceof BasicRootNode || node instanceof BasicNode) {
return this.automationNode === node.automationNode;
}
if (node instanceof SAChildNode) {
return node.isEquivalentTo(this);
}
return this.automationNode === node;
}
/** @override */
isValidGroup() {
if (!this.automationNode.role) {
// If the underlying automation node has been invalidated, return false.
return false;
}
return !this.invalidated_ &&
SwitchAccessPredicate.isVisible(this.automationNode) &&
super.isValidGroup();
}
/** @override */
onFocus() {
super.onFocus();
this.childrenChangedHandler_ = new RepeatedEventHandler(
this.automationNode, chrome.automation.EventType.CHILDREN_CHANGED,
this.refresh.bind(this));
}
/** @override */
onUnfocus() {
super.onUnfocus();
if (this.childrenChangedHandler_) {
this.childrenChangedHandler_.stopListening();
}
}
/** @override */
refreshChildren() {
const childConstructor = (node) => BasicNode.create(node, this);
try {
BasicRootNode.findAndSetChildren(this, childConstructor);
} catch (e) {
this.invalidated_ = true;
}
}
/** @override */
refresh() {
// Find the currently focused child.
let focusedChild = null;
for (const child of this.children) {
if (child.isFocused()) {
focusedChild = child;
break;
}
}
// Update this BasicRootNode's children.
this.refreshChildren();
if (this.invalidated_) {
this.onUnfocus();
NavigationManager.moveToValidNode();
return;
}
// Set the new instance of that child to be the focused node.
if (focusedChild) {
for (const child of this.children) {
if (child.isEquivalentTo(focusedChild)) {
NavigationManager.forceFocusedNode(child);
return;
}
}
}
// If we didn't find a match, fall back and reset.
NavigationManager.moveToValidNode();
}
// ================= Static methods =================
/**
* @param {!AutomationNode} rootNode
* @return {!BasicRootNode}
*/
static buildTree(rootNode) {
if (rootNode.role === chrome.automation.RoleType.KEYBOARD) {
return KeyboardRootNode.buildTree();
}
if (SwitchAccessPredicate.isWindow(rootNode)) {
return WindowRootNode.buildTree(rootNode);
}
const root = new BasicRootNode(rootNode);
const childConstructor = (node) => BasicNode.create(node, root);
BasicRootNode.findAndSetChildren(root, childConstructor);
return root;
}
/**
* Helper function to connect tree elements, given the root node and a
* constructor for the child type.
* @param {!BasicRootNode} root
* @param {function(!AutomationNode): !SAChildNode} childConstructor
* Constructs a child node from an automation node.
*/
static findAndSetChildren(root, childConstructor) {
const interestingChildren = BasicRootNode.getInterestingChildren(root);
const children = interestingChildren.map(childConstructor)
.filter((child) => child.isValidAndVisible());
if (children.length < 1) {
throw SwitchAccess.error(
SAConstants.ErrorType.NO_CHILDREN,
'Root node must have at least 1 interesting child.',
true /* shouldRecover */);
}
children.push(new BackButtonNode(root));
root.children = children;
}
/**
* @param {!BasicRootNode|!AutomationNode} root
* @return {!Array<!AutomationNode>}
*/
static getInterestingChildren(root) {
if (root instanceof BasicRootNode) {
root = root.automationNode;
}
if (root.children.length === 0) {
return [];
}
const interestingChildren = [];
const treeWalker = new AutomationTreeWalker(
root, constants.Dir.FORWARD, SwitchAccessPredicate.restrictions(root));
let node = treeWalker.next().node;
while (node) {
interestingChildren.push(node);
node = treeWalker.next().node;
}
return interestingChildren;
}
}