blob: 344aa2883904df57c150a2c98cbb2174a929ab18 [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.
/**
* @fileoverview Handles automation from ChromeVox's current range.
*/
import {AutomationPredicate} from '../../common/automation_predicate.js';
import {AutomationUtil} from '../../common/automation_util.js';
import {CursorRange} from '../../common/cursors/range.js';
import {ChromeVoxEvent, CustomAutomationEvent} from '../common/custom_automation_event.js';
import {Msgs} from '../common/msgs.js';
import {BaseAutomationHandler} from './base_automation_handler.js';
import {ChromeVoxState, ChromeVoxStateObserver} from './chromevox_state.js';
import {DesktopAutomationHandler} from './desktop_automation_handler.js';
import {FocusBounds} from './focus_bounds.js';
import {Output} from './output/output.js';
import {OutputEventType} from './output/output_types.js';
const AutomationEvent = chrome.automation.AutomationEvent;
const AutomationNode = chrome.automation.AutomationNode;
const Dir = constants.Dir;
const EventType = chrome.automation.EventType;
const RoleType = chrome.automation.RoleType;
const StateType = chrome.automation.StateType;
/** @implements {ChromeVoxStateObserver} */
export class RangeAutomationHandler extends BaseAutomationHandler {
/** @private */
constructor() {
super(null);
/** @private {AutomationNode} */
this.lastAttributeTarget_;
/** @private {Output} */
this.lastAttributeOutput_;
/** @private {number} */
this.delayedAttributeOutputId_ = -1;
ChromeVoxState.addObserver(this);
}
static init() {
if (RangeAutomationHandler.instance) {
throw 'Error: Trying to create two copies of singleton RangeAutomationHandler';
}
RangeAutomationHandler.instance = new RangeAutomationHandler();
}
/**
* @param {CursorRange} newRange
* @param {boolean=} opt_fromEditing
*/
onCurrentRangeChanged(newRange, opt_fromEditing) {
if (this.node_) {
this.removeAllListeners();
this.node_ = null;
}
if (!newRange || !newRange.start.node || !newRange.end.node) {
return;
}
this.node_ = AutomationUtil.getLeastCommonAncestor(
newRange.start.node, newRange.end.node) ||
newRange.start.node;
// Some re-targeting is needed for cases like tables.
let retarget = this.node_;
while (retarget && retarget !== retarget.root) {
// Table headers require retargeting for events because they often have
// event types we care about e.g. sort direction.
if (retarget.role === RoleType.COLUMN_HEADER ||
retarget.role === RoleType.ROW_HEADER) {
this.node_ = retarget;
break;
}
retarget = retarget.parent;
}
// TODO: some of the events mapped to onAriaAttributeChanged need to have
// specific handlers that only output the specific attribute. There also
// needs to be an audit of all attribute change events to ensure they get
// outputted.
this.addListener_(
EventType.ARIA_ATTRIBUTE_CHANGED, this.onAriaAttributeChanged);
this.addListener_(
EventType.AUTO_COMPLETE_CHANGED, this.onAriaAttributeChanged);
this.addListener_(
EventType.IMAGE_ANNOTATION_CHANGED, this.onAriaAttributeChanged);
this.addListener_(EventType.NAME_CHANGED, this.onAriaAttributeChanged);
this.addListener_(
EventType.DESCRIPTION_CHANGED, this.onAriaAttributeChanged);
this.addListener_(EventType.ROLE_CHANGED, this.onAriaAttributeChanged);
this.addListener_(EventType.AUTOCORRECTION_OCCURED, this.onEventIfInRange);
this.addListener_(
EventType.CHECKED_STATE_CHANGED, this.onCheckedStateChanged);
this.addListener_(
EventType.CHECKED_STATE_DESCRIPTION_CHANGED,
this.onCheckedStateChanged);
this.addListener_(EventType.COLLAPSED, this.onEventIfInRange);
this.addListener_(EventType.EXPANDED, this.onEventIfInRange);
this.addListener_(EventType.INVALID_STATUS_CHANGED, this.onEventIfInRange);
this.addListener_(EventType.LOCATION_CHANGED, this.onLocationChanged);
this.addListener_(
EventType.RELATED_NODE_CHANGED, this.onAriaAttributeChanged);
this.addListener_(EventType.ROW_COLLAPSED, this.onEventIfInRange);
this.addListener_(EventType.ROW_EXPANDED, this.onEventIfInRange);
this.addListener_(EventType.STATE_CHANGED, this.onAriaAttributeChanged);
this.addListener_(EventType.SORT_CHANGED, this.onAriaAttributeChanged);
}
/** @param {!ChromeVoxEvent} evt */
onEventIfInRange(evt) {
if (BaseAutomationHandler.disallowEventFromAction(evt)) {
return;
}
const prev = ChromeVoxState.instance.currentRange;
if (!prev) {
return;
}
// TODO: we need more fine grained filters for attribute changes.
if (prev.contentEquals(CursorRange.fromNode(evt.target)) ||
evt.target.state.focused) {
const prevTarget = this.lastAttributeTarget_;
// Re-target to active descendant if it exists.
const prevOutput = this.lastAttributeOutput_;
this.lastAttributeTarget_ = evt.target.activeDescendant || evt.target;
this.lastAttributeOutput_ = new Output().withRichSpeechAndBraille(
CursorRange.fromNode(this.lastAttributeTarget_), prev,
OutputEventType.NAVIGATE);
if (this.lastAttributeTarget_ === prevTarget && prevOutput &&
prevOutput.equals(this.lastAttributeOutput_)) {
return;
}
// If the target or an ancestor is controlled by another control, we may
// want to delay the output.
let maybeControlledBy = evt.target;
while (maybeControlledBy) {
if (maybeControlledBy.controlledBy &&
maybeControlledBy.controlledBy.find(n => Boolean(n.autoComplete))) {
clearTimeout(this.delayedAttributeOutputId_);
this.delayedAttributeOutputId_ = setTimeout(() => {
this.lastAttributeOutput_.go();
}, DesktopAutomationHandler.ATTRIBUTE_DELAY_MS);
return;
}
maybeControlledBy = maybeControlledBy.parent;
}
this.lastAttributeOutput_.go();
}
}
/** @param {!ChromeVoxEvent} evt */
onAriaAttributeChanged(evt) {
// Don't report changes on editable nodes since they interfere with text
// selection changes. Users can query via Search+k for the current state
// of the text field (which would also report the entire value).
if (evt.target.state[StateType.EDITABLE]) {
return;
}
// Don't report changes in static text nodes which can be extremely noisy.
if (evt.target.role === RoleType.STATIC_TEXT) {
return;
}
// Report attribute changes for specific generated events.
if (evt.type === chrome.automation.EventType.SORT_CHANGED) {
let msgId;
if (evt.target.sortDirection ===
chrome.automation.SortDirectionType.ASCENDING) {
msgId = 'sort_ascending';
} else if (
evt.target.sortDirection ===
chrome.automation.SortDirectionType.DESCENDING) {
msgId = 'sort_descending';
}
if (msgId) {
new Output().withString(Msgs.getMsg(msgId)).go();
}
return;
}
// Only report attribute changes on some *Option roles if it is selected.
if ((evt.target.role === RoleType.MENU_LIST_OPTION ||
evt.target.role === RoleType.LIST_BOX_OPTION) &&
!evt.target.selected) {
return;
}
this.onEventIfInRange(evt);
}
/**
* Provides all feedback once a checked state changed event fires.
* @param {!ChromeVoxEvent} evt
*/
onCheckedStateChanged(evt) {
if (!AutomationPredicate.checkable(evt.target)) {
return;
}
const event =
new CustomAutomationEvent(EventType.CHECKED_STATE_CHANGED, evt.target, {
eventFrom: evt.eventFrom,
eventFromAction: evt.eventFromAction,
intents: evt.intents,
});
this.onEventIfInRange(event);
}
/**
* Updates the focus ring if the location of the current range, or
* an descendant of the current range, changes.
* @param {!ChromeVoxEvent} evt
*/
onLocationChanged(evt) {
const cur = ChromeVoxState.instance.currentRange;
if (!cur || !cur.isValid()) {
if (FocusBounds.get().length) {
FocusBounds.set([]);
}
return;
}
// Rather than trying to figure out if the current range falls somewhere
// in |evt.target|, just update it if our cached bounds don't match.
const oldFocusBounds = FocusBounds.get();
const startRect = cur.start.node.location;
const endRect = cur.end.node.location;
const found =
oldFocusBounds.some(rect => this.areRectsEqual_(rect, startRect)) &&
oldFocusBounds.some(rect => this.areRectsEqual_(rect, endRect));
if (found) {
return;
}
new Output().withLocation(cur, null, evt.type).go();
}
/**
* @param {!chrome.accessibilityPrivate.ScreenRect} rectA
* @param {!chrome.accessibilityPrivate.ScreenRect} rectB
* @return {boolean} Whether the rects are the same.
* @private
*/
areRectsEqual_(rectA, rectB) {
return rectA.left === rectB.left && rectA.top === rectB.top &&
rectA.width === rectB.width && rectA.height === rectB.height;
}
}
/** @type {RangeAutomationHandler} */
RangeAutomationHandler.instance;