blob: a28ff9664d6db3e3927400d314b48cdfe4f58820 [file] [log] [blame]
// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Implements support for live regions in ChromeVox Next.
*/
import {AutomationUtil} from '../../common/automation_util.js';
import {CursorRange} from '../../common/cursors/range.js';
import {QueueMode, TtsCategory} from '../common/tts_types.js';
import {ChromeVoxState} from './chromevox_state.js';
import {Output} from './output/output.js';
import {OutputCustomEvent} from './output/output_types.js';
const AutomationNode = chrome.automation.AutomationNode;
const RoleType = chrome.automation.RoleType;
const StateType = chrome.automation.StateType;
const TreeChange = chrome.automation.TreeChange;
const TreeChangeObserverFilter = chrome.automation.TreeChangeObserverFilter;
const TreeChangeType = chrome.automation.TreeChangeType;
/**
* ChromeVox live region handler.
*/
export class LiveRegions {
/** @private */
constructor() {
/**
* The time the last live region event was output.
* @type {!Date}
* @private
*/
this.lastLiveRegionTime_ = new Date(0);
/**
* Set of nodes that have been announced as part of a live region since
* |this.lastLiveRegionTime_|, to prevent duplicate announcements.
* @type {!WeakSet<AutomationNode>}
* @private
*/
this.liveRegionNodeSet_ = new WeakSet();
/**
* A list of nodes that have changed as part of one atomic tree update.
* @private {!Array}
*/
this.changedNodes_ = [];
chrome.automation.addTreeChangeObserver(
TreeChangeObserverFilter.LIVE_REGION_TREE_CHANGES,
this.onTreeChange.bind(this));
}
static init() {
if (LiveRegions.instance) {
throw 'Error: Trying to create two instances of singleton LiveRegions';
}
LiveRegions.instance = new LiveRegions();
}
/**
* Called when the automation tree is changed.
* @param {TreeChange} treeChange
*/
onTreeChange(treeChange) {
const type = treeChange.type;
const node = treeChange.target;
if ((!node.containerLiveStatus || node.containerLiveStatus === 'off') &&
type !== TreeChangeType.SUBTREE_UPDATE_END) {
return;
}
if (this.shouldIgnoreLiveRegion_(node)) {
return;
}
const relevant = node.containerLiveRelevant || '';
const additions = relevant.indexOf('additions') >= 0;
const text = relevant.indexOf('text') >= 0;
const removals = relevant.indexOf('removals') >= 0;
const all = relevant.indexOf('all') >= 0;
if (all ||
(additions &&
(type === TreeChangeType.NODE_CREATED ||
type === TreeChangeType.SUBTREE_CREATED))) {
this.queueLiveRegionChange_(node);
} else if (all || (text && type === TreeChangeType.TEXT_CHANGED)) {
this.queueLiveRegionChange_(node);
}
if ((all || removals) && type === TreeChangeType.NODE_REMOVED) {
this.outputLiveRegionChange_(node, '@live_regions_removed');
}
if (type === TreeChangeType.SUBTREE_UPDATE_END) {
this.processQueuedTreeChanges_();
}
}
/**
* @param {!AutomationNode} node
* @private
*/
queueLiveRegionChange_(node) {
this.changedNodes_.push(node);
}
/**
* @private
*/
processQueuedTreeChanges_() {
// Schedule all live regions after all events in the native C++
// EventBundle.
this.liveRegionNodeSet_ = new WeakSet();
for (let i = 0; i < this.changedNodes_.length; i++) {
const node = this.changedNodes_[i];
this.outputLiveRegionChange_(node, null);
}
this.changedNodes_ = [];
}
/**
* Given a node that needs to be spoken as part of a live region
* change and an additional optional format string, output the
* live region description.
* @param {!AutomationNode} node The changed node.
* @param {?string=} opt_prependFormatStr If set, a format string for
* Output to prepend to the output.
* @private
*/
outputLiveRegionChange_(node, opt_prependFormatStr) {
if (node.containerLiveBusy) {
return;
}
while (node.containerLiveAtomic && !node.liveAtomic && node.parent) {
node = node.parent;
}
if (this.liveRegionNodeSet_.has(node)) {
this.lastLiveRegionTime_ = new Date();
return;
}
this.outputLiveRegionChangeForNode_(node, opt_prependFormatStr);
}
/**
* @param {!AutomationNode} node The changed node.
* @param {?string=} opt_prependFormatStr If set, a format string for
* Output to prepend to the output.
* @private
*/
outputLiveRegionChangeForNode_(node, opt_prependFormatStr) {
const range = CursorRange.fromNode(node);
const output = new Output();
output.withSpeechCategory(TtsCategory.LIVE);
// Queue live regions coming from background tabs.
let hostView = AutomationUtil.getTopLevelRoot(node);
hostView = hostView ? hostView.parent : null;
const currentRange = ChromeVoxState.instance.currentRange;
const forceQueue = !hostView || !hostView.state.focused ||
(currentRange && currentRange.start.node.root !== node.root) ||
node.containerLiveStatus === 'polite';
// Enqueue live region updates that were received at approximately
// the same time, otherwise flush previous live region updates.
const queueTime = LiveRegions.LIVE_REGION_QUEUE_TIME_MS;
const currentTime = new Date();
const delta = currentTime - this.lastLiveRegionTime_;
if (delta > queueTime && !forceQueue) {
output.withQueueMode(QueueMode.CATEGORY_FLUSH);
} else {
output.withQueueMode(QueueMode.QUEUE);
}
if (opt_prependFormatStr) {
output.format(opt_prependFormatStr);
}
output.withSpeech(range, range, OutputCustomEvent.NAVIGATE);
if (!output.hasSpeech && node.liveAtomic) {
output.format('$joinedDescendants', node);
}
if (!output.hasSpeech) {
return;
}
// We also have to add recursively the children of this live region node
// since all children could potentially get described and we don't want to
// describe their tree changes especially during page load within the
// LiveRegions.LIVE_REGION_MIN_SAME_NODE_MS to prevent excessive chatter.
this.addNodeToNodeSetRecursive_(node);
output.go();
this.lastLiveRegionTime_ = currentTime;
}
/**
* @param {AutomationNode} root
* @private
*/
addNodeToNodeSetRecursive_(root) {
this.liveRegionNodeSet_.add(root);
for (let child = root.firstChild; child; child = child.nextSibling) {
this.addNodeToNodeSetRecursive_(child);
}
}
/**
* @param {!AutomationNode} node
* @return {boolean}
* @private
*/
shouldIgnoreLiveRegion_(node) {
if (LiveRegions.announceLiveRegionsFromBackgroundTabs_) {
return false;
}
const currentRange = ChromeVoxState.instance.currentRange;
if (currentRange && currentRange.start.node.root === node.root) {
return false;
}
let hostView = AutomationUtil.getTopLevelRoot(node);
hostView = hostView ? hostView.parent : null;
if (!hostView) {
return true;
}
if (hostView.role === RoleType.WINDOW &&
!hostView.state[StateType.INVISIBLE]) {
return false;
}
if (hostView.state.focused) {
return false;
}
return true;
}
}
/**
* Live region events received in fewer than this many milliseconds will
* queue, otherwise they'll be output with a category flush.
* @const {number}
*/
LiveRegions.LIVE_REGION_QUEUE_TIME_MS = 5000;
/**
* Live region events received on the same node in fewer than this many
* milliseconds will be dropped to avoid a stream of constant chatter.
* @const {number}
*/
LiveRegions.LIVE_REGION_MIN_SAME_NODE_MS = 20;
/**
* Whether live regions from background tabs should be announced or not.
* @private {boolean}
*/
LiveRegions.announceLiveRegionsFromBackgroundTabs_ = false;
/** @type {LiveRegions} */
LiveRegions.instance;