blob: 7e86c47b7c0516796931074c55212fa0176d5c06 [file] [log] [blame]
// Copyright 2021 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.
/**
* @fileoverview Handles auto scrolling on navigation.
*/
import {AutomationPredicate} from '../../common/automation_predicate.js';
import {AutomationUtil} from '../../common/automation_util.js';
import {CursorUnit} from '../../common/cursors/cursor.js';
import {CursorRange} from '../../common/cursors/range.js';
import {TtsSpeechProperties} from '../common/tts_interface.js';
import {ChromeVoxState} from './chromevox_state.js';
import {CommandHandlerInterface} from './command_handler_interface.js';
// setTimeout and its clean-up are referencing each other. So, we need to set
// "ignoreReadBeforeAssign" in this file. ESLint doesn't support per-line rule
// option modification.
/* eslint prefer-const: ["error", {"ignoreReadBeforeAssign": true}] */
const AutomationNode = chrome.automation.AutomationNode;
const EventType = chrome.automation.EventType;
/**
* Handler of auto scrolling. Most logics are for supporting ARC++.
*/
export class AutoScrollHandler {
constructor() {
/** @private {boolean} */
this.isScrolling_ = false;
/** @private {AutomationNode} */
this.scrollingNode_ = null;
/** @private {CursorRange} */
this.rangeBeforeScroll_ = null;
/** @private {!Date} */
this.lastScrolledTime_ = new Date(0);
/** @private {boolean} */
this.relatedFocusEventHappened_ = false;
}
/** @return {!AutoScrollHandler} */
static getInstance() {
if (!AutoScrollHandler.instance_) {
AutoScrollHandler.instance_ = new AutoScrollHandler();
}
return AutoScrollHandler.instance_;
}
/**
* This should be called before any command triggers ChromeVox navigation.
*
* @param {!CursorRange} target The range that is going to be navigated
* before scrolling.
* @param {constants.Dir} dir The direction to navigate.
* @param {?AutomationPredicate.Unary} pred The predicate to match.
* @param {?CursorUnit} unit The unit to navigate by.
* @param {?TtsSpeechProperties} speechProps The optional speech properties
* given to |navigateToRange| to provide feedback of the current command.
* @param {AutomationPredicate.Unary} rootPred The predicate that expresses
* the current navigation root.
* @param {Function} retryCommandFunc The callback used to retry the command
* with refreshed tree after scrolling.
* @return {boolean} True if the given navigation can be executed. False if
* the given navigation shouldn't happen, and AutoScrollHandler handles
* the command instead.
*/
onCommandNavigation(
target, dir, pred, unit, speechProps, rootPred, retryCommandFunc) {
if (this.isScrolling_) {
// Prevent interrupting the current scrolling.
return false;
}
if (!target.start || !target.start.node ||
!ChromeVoxState.instance.currentRange.start.node) {
return true;
}
const rangeBeforeScroll = ChromeVoxState.instance.currentRange;
let scrollable = this.findScrollableAncestor_(target);
// At the beginning or the end of the document, there is a case where the
// range stays there. It's worth trying scrolling the containing scrollable.
if (!scrollable && target.equals(rangeBeforeScroll) &&
(unit === CursorUnit.WORD || unit === CursorUnit.CHARACTER)) {
scrollable =
this.tryFindingContainingScrollableIfAtEdge_(target, dir, scrollable);
}
if (!scrollable) {
return true;
}
this.isScrolling_ = true;
this.scrollingNode_ = scrollable;
this.rangeBeforeScroll_ = rangeBeforeScroll;
this.lastScrolledTime_ = new Date();
this.relatedFocusEventHappened_ = false;
this.scrollForCommandNavigation_.apply(this, arguments)
.catch(() => this.isScrolling_ = false);
return false;
}
/**
* @param {!CursorRange} target The range that is going to be navigated
* before scrolling.
* @return {?AutomationNode}
*/
findScrollableAncestor_(target) {
let scrollable = null;
const ancestors = AutomationUtil.getUniqueAncestors(
target.start.node, ChromeVoxState.instance.currentRange.start.node);
for (let i = 0; i < ancestors.length; i++) {
if (AutomationPredicate.autoScrollable(ancestors[i])) {
scrollable = ancestors[i];
break;
}
}
return scrollable;
}
/**
* @param {!CursorRange} target
* @param {constants.Dir} dir
* @param {AutomationNode} scrollable
* @return {AutomationNode}
* @private
*/
tryFindingContainingScrollableIfAtEdge_(target, dir, scrollable) {
let current = target.start.node;
let parent = current.parent;
while (parent && parent.root === current.root) {
if (!(dir === constants.Dir.BACKWARD && parent.firstChild === current) &&
!(dir === constants.Dir.FORWARD && parent.lastChild === current)) {
// Currently on non-edge node. Don't try scrolling.
return null;
}
if (AutomationPredicate.autoScrollable(current)) {
scrollable = current;
}
current = parent;
parent = current.parent;
}
return scrollable;
}
/**
* @param {!CursorRange} target The range that is going to be navigated
* before scrolling.
* @param {constants.Dir} dir The direction to navigate.
* @param {?AutomationPredicate.Unary} pred The predicate to match.
* @param {?CursorUnit} unit The unit to navigate by.
* @param {?TtsSpeechProperties} speechProps The optional speech properties
* given to |navigateToRange| to provide feedback of the current command.
* @param {AutomationPredicate.Unary} rootPred The predicate that expresses
* the current navigation root.
* @param {Function} retryCommandFunc The callback used to retry the command
* with refreshed tree after scrolling.
* @private
*/
async scrollForCommandNavigation_(
target, dir, pred, unit, speechProps, rootPred, retryCommandFunc) {
const scrollResult =
await this.scrollInDirection_(this.scrollingNode_, dir);
if (!scrollResult) {
this.isScrolling_ = false;
ChromeVoxState.instance.navigateToRange(target, false, speechProps);
return;
}
// Wait for a scrolled event or timeout.
await this.waitForScrollEvent_(this.scrollingNode_);
// When a scrolling animation happens, SCROLL_POSITION_CHANGED event can
// be dispatched multiple times, and there is a chance that the ui tree is
// in an intermediate state. Just wait for a while so that the UI gets
// stabilized.
await new Promise(
resolve =>
setTimeout(resolve, AutoScrollHandler.DELAY_HANDLE_SCROLLED_MS));
this.isScrolling_ = false;
if (!this.scrollingNode_) {
throw Error('Illegal state in AutoScrollHandler.');
}
if (!this.scrollingNode_.root) {
// Maybe scrollable node has disappeared. Do nothing.
return;
}
if (this.relatedFocusEventHappened_) {
const nextRange = this.handleScrollingInAndroidRecyclerView_(
pred, unit, dir, rootPred, this.scrollingNode_);
ChromeVoxState.instance.navigateToRange(
nextRange || target, false, speechProps);
return;
}
// If the focus has been changed for some reason, do nothing to
// prevent disturbing the latest navigation.
if (!ChromeVoxState.instance.currentRange ||
!this.rangeBeforeScroll_.equals(ChromeVoxState.instance.currentRange)) {
return;
}
// Usual case. Retry navigation with a refreshed tree.
retryCommandFunc();
}
/**
* @param {AutomationNode} scrollable
* @param {!constants.Dir} dir
* @return {!Promise<boolean>}
* @private
*/
async scrollInDirection_(scrollable, dir) {
return new Promise((resolve, reject) => {
if (!scrollable) {
reject('Scrollable cannot be null or undefined when scrolling');
}
if (dir === constants.Dir.FORWARD) {
scrollable.scrollForward(resolve);
} else {
scrollable.scrollBackward(resolve);
}
});
}
/**
* @param {AutomationNode} scrollable
* @return {!Promise}
* @private
*/
async waitForScrollEvent_(scrollable) {
return new Promise((resolve, reject) => {
if (!scrollable) {
reject('Scrollable cannot be null when waiting for scroll event');
}
let cleanUp;
let timeoutId;
const onTimeout = () => {
cleanUp();
reject('timeout to wait for scrolled event.');
};
const onScrolled = () => {
cleanUp();
resolve();
};
cleanUp = () => {
for (const e of AutoScrollHandler.RELATED_SCROLL_EVENT_TYPES) {
scrollable.removeEventListener(e, onScrolled, true);
}
clearTimeout(timeoutId);
};
for (const e of AutoScrollHandler.RELATED_SCROLL_EVENT_TYPES) {
scrollable.addEventListener(e, onScrolled, true);
}
timeoutId =
setTimeout(onTimeout, AutoScrollHandler.TIMEOUT_SCROLLED_EVENT_MS);
});
}
/**
* This block handles scrolling on Android RecyclerView.
* When scrolling happens, RecyclerView can delete an invisible item, which
* might be the focused node before scrolling, or can re-use the focused node
* for a newly added node. When one of these happens, the focused node first
* disappears from the ARC tree and the focus is moved up to the View that has
* the ARC tree. In this case, navigate to the first or the last matching
* range in the scrollable.
*
* @param {?AutomationPredicate.Unary} pred The predicate to match.
* @param {?CursorUnit} unit The unit to navigate by.
* @param {constants.Dir} dir The direction to navigate.
* @param {AutomationPredicate.Unary} rootPred The predicate that expresses
* the current navigation root.
* @param {!AutomationNode} scrollable
* @return {?CursorRange}
* @private
*/
handleScrollingInAndroidRecyclerView_(pred, unit, dir, rootPred, scrollable) {
let nextRange = null;
if (!pred && unit) {
nextRange = CursorRange.fromNode(scrollable).sync(unit, dir);
if (unit === CursorUnit.NODE) {
nextRange = CommandHandlerInterface.instance.skipLabelOrDescriptionFor(
nextRange, dir);
}
} else if (pred) {
let node;
if (dir === constants.Dir.FORWARD) {
node = AutomationUtil.findNextNode(
scrollable, dir, pred, {root: rootPred, skipInitialSubtree: false});
} else {
node = AutomationUtil.findNodePost(this.scrollingNode_, dir, pred);
}
if (node) {
nextRange = CursorRange.fromNode(node);
}
}
return nextRange;
}
/**
* This should be called before the focus event triggers the navigation.
*
* On scrolling, sometimes previously focused node disappears.
* There are two common problematic patterns in ARC tree here:
* 1. No node has focus. The root ash window now get focus.
* 2. Another node in the window gets focus.
* Above two cases interrupt the auto scrolling. So when any of these are
* happening, this returns false.
*
* @param {AutomationNode} node the node that is the focus event target.
* @return {boolean} True if others can handle the event. False if the
* event shouldn't be propagated.
*/
onFocusEventNavigation(node) {
if (!this.scrollingNode_ || !node) {
return true;
}
const elapsedTime = new Date() - this.lastScrolledTime_;
if (elapsedTime > AutoScrollHandler.TIMEOUT_FOCUS_EVENT_DROP_MS) {
return true;
}
const isUnrelatedEvent = this.scrollingNode_.root !== node.root &&
!AutomationUtil.isDescendantOf(this.scrollingNode_, node);
if (isUnrelatedEvent) {
return true;
}
this.relatedFocusEventHappened_ = true;
return false;
}
}
/**
* An array of Automation event types that AutoScrollHandler observes when
* performing a scroll action.
* @private
* @const {!Array<EventType>}
*/
AutoScrollHandler.RELATED_SCROLL_EVENT_TYPES = [
// This is sent by ARC++.
EventType.SCROLL_POSITION_CHANGED,
// These two events are sent by Web and Views via AXEventGenerator.
EventType.SCROLL_HORIZONTAL_POSITION_CHANGED,
EventType.SCROLL_VERTICAL_POSITION_CHANGED,
];
/**
* The timeout that we wait for scroll event since scrolling action's callback
* is executed.
* TODO(hirokisato): Find an appropriate timeout in our case. TalkBack uses 500
* milliseconds. See
* https://github.com/google/talkback/blob/acd0bc7631a3dfbcf183789c7557596a45319e1f/talkback/src/main/java/ScrollEventInterpreter.java#L169
* @const {number}
*/
AutoScrollHandler.TIMEOUT_SCROLLED_EVENT_MS = 1500;
/**
* The timeout that the focused event should be dropped. This is longer than
* |TIMEOUT_CALLBACK_MS| because a focus event can happen after the scrolling.
* @const {number}
*/
AutoScrollHandler.TIMEOUT_FOCUS_EVENT_DROP_MS = 2000;
/**
* The delay in milliseconds to wait to handle a scrolled event after the event
* is first dispatched in order to wait for UI stabilized. See also
* https://github.com/google/talkback/blob/6c0b475b7f52469e309e51bfcc13de58f18176ff/talkback/src/main/java/com/google/android/accessibility/talkback/interpreters/AutoScrollInterpreter.java#L42
* @const {number}
*/
AutoScrollHandler.DELAY_HANDLE_SCROLLED_MS = 150;
/** @private {?AutoScrollHandler} */
AutoScrollHandler.instance_ = null;