blob: 508376d6fe88943ab9c1eee0947732acd7b837e3 [file] [log] [blame]
// Copyright 2014 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 Provides output services for ChromeVox.
*/
import {AutomationPredicate} from '../../../common/automation_predicate.js';
import {AutomationUtil} from '../../../common/automation_util.js';
import {constants} from '../../../common/constants.js';
import {Cursor, CURSOR_NODE_INDEX} from '../../../common/cursors/cursor.js';
import {CursorRange} from '../../../common/cursors/range.js';
import {AutomationTreeWalker} from '../../../common/tree_walker.js';
import {NavBraille} from '../../common/braille/nav_braille.js';
import {EventSourceType} from '../../common/event_source_type.js';
import {LocaleOutputHelper} from '../../common/locale_output_helper.js';
import {LogType} from '../../common/log_types.js';
import {Msgs} from '../../common/msgs.js';
import {Spannable} from '../../common/spannable.js';
import {QueueMode, TtsCategory, TtsSpeechProperties} from '../../common/tts_interface.js';
import {ValueSelectionSpan, ValueSpan} from '../braille/spans.js';
import {ChromeVox} from '../chromevox.js';
import {EventSourceState} from '../event_source.js';
import {FocusBounds} from '../focus_bounds.js';
import {LogStore} from '../logging/log_store.js';
import {PhoneticData} from '../phonetic_data.js';
import {OutputAncestryInfo} from './output_ancestry_info.js';
import {OutputFormatParser, OutputFormatParserObserver} from './output_format_parser.js';
import {OutputFormatTree} from './output_format_tree.js';
import {OutputRulesStr} from './output_logger.js';
import {OutputRoleInfo} from './output_role_info.js';
import {OutputAction, OutputContextOrder, OutputEarconAction, OutputEventType, OutputNodeSpan, OutputSelectionSpan, OutputSpeechProperties} from './output_types.js';
const AriaCurrentState = chrome.automation.AriaCurrentState;
const AutomationNode = chrome.automation.AutomationNode;
const DescriptionFromType = chrome.automation.DescriptionFromType;
const Dir = constants.Dir;
const EventType = chrome.automation.EventType;
const NameFromType = chrome.automation.NameFromType;
const Restriction = chrome.automation.Restriction;
const RoleType = chrome.automation.RoleType;
const StateType = chrome.automation.StateType;
/**
* An Output object formats a CursorRange into speech, braille, or both
* representations. This is typically a |Spannable|.
* The translation from Range to these output representations rely upon format
* rules which specify how to convert AutomationNode objects into annotated
* strings.
* The format of these rules is as follows.
* $ prefix: used to substitute either an attribute or a specialized value from
* an AutomationNode. Specialized values include role and state.
* For example, $value $role $enabled
*
* Note: (@) means @ to avoid Closure mistaking it for an annotation.
*
* (@) prefix: used to substitute a message. Note the ability to specify params
* to the message. For example, '@tag_html' '@selected_index($text_sel_start,
* $text_sel_end').
*
* (@@) prefix: similar to @, used to substitute a message, but also pulls the
* localized string through goog.i18n.MessageFormat to support locale
* aware plural handling. The first argument should be a number which will
* be passed as a COUNT named parameter to MessageFormat.
* TODO(plundblad): Make subsequent arguments normal placeholder arguments
* when needed.
* = suffix: used to specify substitution only if not previously appended.
* For example, $name= would insert the name attribute only if no name
* attribute had been inserted previously.
*/
export class Output {
constructor() {
// TODO(dtseng): Include braille specific rules.
/** @private {!Array<!Spannable>} */
this.speechBuffer_ = [];
/** @private {!Array<!Spannable>} */
this.brailleBuffer_ = [];
/** @private {!Array<!Object>} */
this.locations_ = [];
/** @private {function(boolean=)} */
this.speechEndCallback_;
// Store output rules.
/** @private {!OutputRulesStr} */
this.speechRulesStr_ = new OutputRulesStr('enableSpeechLogging');
/** @private {!OutputRulesStr} */
this.brailleRulesStr_ = new OutputRulesStr('enableBrailleLogging');
/**
* Current global options.
* @private {{speech: boolean, braille: boolean, auralStyle: boolean}}
*/
this.formatOptions_ = {speech: true, braille: false, auralStyle: false};
/**
* The speech category for the generated speech utterance.
* @private {TtsCategory}
*/
this.speechCategory_ = TtsCategory.NAV;
/**
* The speech queue mode for the generated speech utterance.
* @private {QueueMode}
*/
this.queueMode_;
/** @private {!OutputContextOrder} */
this.contextOrder_ = OutputContextOrder.LAST;
/** @private {!Object<string, boolean>} */
this.suppressions_ = {};
/** @private {boolean} */
this.enableHints_ = true;
/** @private {!TtsSpeechProperties} */
this.initialSpeechProps_ = new TtsSpeechProperties();
/** @private {boolean} */
this.drawFocusRing_ = true;
/**
* Tracks all ancestors which have received primary formatting in
* |ancestryHelper_|.
* @private {!WeakSet<!AutomationNode>}
*/
this.formattedAncestors_ = new WeakSet();
/** @private {!Object<string, string>} */
this.replacements_ = {};
}
/**
* Calling this will make the next speech utterance use |mode| even if it
* would normally queue or do a category flush. This differs from the
* |withQueueMode| instance method as it can apply to future output.
* @param {QueueMode|undefined} mode
*/
static forceModeForNextSpeechUtterance(mode) {
if (Output.forceModeForNextSpeechUtterance_ === undefined ||
mode === undefined ||
// Only allow setting to higher queue modes.
mode < Output.forceModeForNextSpeechUtterance_) {
Output.forceModeForNextSpeechUtterance_ = mode;
}
}
/**
* For a given automation property, return true if the value
* represents something 'truthy', e.g.: for checked:
* 'true'|'mixed' -> true
* 'false'|undefined -> false
*/
static isTruthy(node, attrib) {
switch (attrib) {
case 'checked':
return node.checked && node.checked !== 'false';
case 'hasPopup':
return node.hasPopup &&
node.hasPopup !== chrome.automation.HasPopup.FALSE;
// Chrome automatically calculates these attributes.
case 'posInSet':
return node.htmlAttributes['aria-posinset'] ||
(node.root.role !== RoleType.ROOT_WEB_AREA && node.posInSet);
case 'setSize':
return node.htmlAttributes['aria-setsize'] || node.setSize;
// These attributes default to false for empty strings.
case 'roleDescription':
return Boolean(node.roleDescription);
case 'value':
return Boolean(node.value);
case 'selected':
return node.selected === true;
default:
return node[attrib] !== undefined || node.state[attrib];
}
}
/**
* represents something 'falsey', e.g.: for selected:
* node.selected === false
*/
static isFalsey(node, attrib) {
switch (attrib) {
case 'selected':
return node.selected === false;
default:
return !Output.isTruthy(node, attrib);
}
}
/**
* @return {boolean} True if there's any speech that will be output.
*/
get hasSpeech() {
for (let i = 0; i < this.speechBuffer_.length; i++) {
if (this.speechBuffer_[i].length) {
return true;
}
}
return false;
}
/**
* @return {boolean} True if there is only whitespace in this output.
*/
get isOnlyWhitespace() {
return this.speechBuffer_.every(function(buff) {
return !/\S+/.test(buff.toString());
});
}
/** @return {Spannable} */
get braille() {
return this.mergeBraille_(this.brailleBuffer_);
}
/**
* Specify ranges for speech.
* @param {!CursorRange} range
* @param {CursorRange} prevRange
* @param {EventType|OutputEventType} type
* @return {!Output}
*/
withSpeech(range, prevRange, type) {
this.formatOptions_ = {speech: true, braille: false, auralStyle: false};
this.formattedAncestors_ = new WeakSet();
this.render_(
range, prevRange, type, this.speechBuffer_, this.speechRulesStr_);
return this;
}
/**
* Specify ranges for aurally styled speech.
* @param {!CursorRange} range
* @param {CursorRange} prevRange
* @param {EventType|OutputEventType} type
* @return {!Output}
*/
withRichSpeech(range, prevRange, type) {
this.formatOptions_ = {speech: true, braille: false, auralStyle: true};
this.formattedAncestors_ = new WeakSet();
this.render_(
range, prevRange, type, this.speechBuffer_, this.speechRulesStr_);
return this;
}
/**
* Specify ranges for braille.
* @param {!CursorRange} range
* @param {CursorRange} prevRange
* @param {EventType|OutputEventType} type
* @return {!Output}
*/
withBraille(range, prevRange, type) {
this.formatOptions_ = {speech: false, braille: true, auralStyle: false};
this.formattedAncestors_ = new WeakSet();
// Braille sometimes shows contextual information depending on role.
if (range.start.equals(range.end) && range.start.node &&
AutomationPredicate.contextualBraille(range.start.node) &&
range.start.node.parent) {
let start = range.start.node.parent;
while (start.firstChild) {
start = start.firstChild;
}
let end = range.start.node.parent;
while (end.lastChild) {
end = end.lastChild;
}
prevRange = CursorRange.fromNode(range.start.node.parent);
range = new CursorRange(Cursor.fromNode(start), Cursor.fromNode(end));
}
this.render_(
range, prevRange, type, this.brailleBuffer_, this.brailleRulesStr_);
return this;
}
/**
* Specify ranges for location.
* @param {!CursorRange} range
* @param {CursorRange} prevRange
* @param {EventType|OutputEventType} type
* @return {!Output}
*/
withLocation(range, prevRange, type) {
this.formatOptions_ = {speech: false, braille: false, auralStyle: false};
this.formattedAncestors_ = new WeakSet();
this.render_(
range, prevRange, type, [] /*unused output*/,
new OutputRulesStr('') /*unused log*/);
return this;
}
/**
* Specify the same ranges for speech and braille.
* @param {!CursorRange} range
* @param {CursorRange} prevRange
* @param {EventType|OutputEventType} type
* @return {!Output}
*/
withSpeechAndBraille(range, prevRange, type) {
this.withSpeech(range, prevRange, type);
this.withBraille(range, prevRange, type);
return this;
}
/**
* Specify the same ranges for aurally styled speech and braille.
* @param {!CursorRange} range
* @param {CursorRange} prevRange
* @param {EventType|OutputEventType} type
* @return {!Output}
*/
withRichSpeechAndBraille(range, prevRange, type) {
this.withRichSpeech(range, prevRange, type);
this.withBraille(range, prevRange, type);
return this;
}
/**
* Applies the given speech category to the output.
* @param {TtsCategory} category
* @return {!Output}
*/
withSpeechCategory(category) {
this.speechCategory_ = category;
return this;
}
/**
* Applies the given speech queue mode to the output.
* @param {QueueMode} queueMode The queueMode for the speech.
* @return {!Output}
*/
withQueueMode(queueMode) {
this.queueMode_ = queueMode;
return this;
}
/**
* Output a string literal.
* @param {string} value
* @return {!Output}
*/
withString(value) {
this.append_(this.speechBuffer_, value);
this.append_(this.brailleBuffer_, value);
this.speechRulesStr_.write('withString: ' + value + '\n');
this.brailleRulesStr_.write('withString: ' + value + '\n');
return this;
}
/**
* Outputs formatting nodes after this will contain context first.
* @return {!Output}
*/
withContextFirst() {
this.contextOrder_ = OutputContextOrder.FIRST;
return this;
}
/**
* Don't include hints in subsequent output.
* @return {!Output}
*/
withoutHints() {
this.enableHints_ = false;
return this;
}
/**
* Don't draw a focus ring based on this output.
* @return {!Output}
*/
withoutFocusRing() {
this.drawFocusRing_ = false;
return this;
}
/**
* Supply initial speech properties that will be applied to all output.
* @param {!TtsSpeechProperties} speechProps
* @return {!Output}
*/
withInitialSpeechProperties(speechProps) {
this.initialSpeechProps_ = speechProps;
return this;
}
/**
* Causes any speech output to apply the replacement.
* @param {string} text The text to be replaced.
* @param {string} replace What to replace |text| with.
*/
withSpeechTextReplacement(text, replace) {
this.replacements_[text] = replace;
}
/**
* Suppresses processing of a token for subsequent formatting commands.
* @param {string} token
* @return {!Output}
*/
suppress(token) {
this.suppressions_[token] = true;
return this;
}
/**
* Apply a format string directly to the output buffer. This lets you
* output a message directly to the buffer using the format syntax.
* @param {string} formatStr
* @param {!AutomationNode=} opt_node An optional node to apply the
* formatting to.
* @return {!Output} |this| for chaining
*/
format(formatStr, opt_node) {
return this.formatForSpeech(formatStr, opt_node)
.formatForBraille(formatStr, opt_node);
}
/**
* Apply a format string directly to the speech output buffer. This lets you
* output a message directly to the buffer using the format syntax.
* @param {string} formatStr
* @param {!AutomationNode=} opt_node An optional node to apply the
* formatting to.
* @return {!Output} |this| for chaining
*/
formatForSpeech(formatStr, opt_node) {
const node = opt_node || null;
this.formatOptions_ = {speech: true, braille: false, auralStyle: false};
this.formattedAncestors_ = new WeakSet();
this.format_({
node,
outputFormat: formatStr,
outputBuffer: this.speechBuffer_,
outputRuleString: this.speechRulesStr_,
});
return this;
}
/**
* Apply a format string directly to the braille output buffer. This lets you
* output a message directly to the buffer using the format syntax.
* @param {string} formatStr
* @param {!AutomationNode=} opt_node An optional node to apply the
* formatting to.
* @return {!Output} |this| for chaining
*/
formatForBraille(formatStr, opt_node) {
const node = opt_node || null;
this.formatOptions_ = {speech: false, braille: true, auralStyle: false};
this.formattedAncestors_ = new WeakSet();
this.format_({
node,
outputFormat: formatStr,
outputBuffer: this.brailleBuffer_,
outputRuleString: this.brailleRulesStr_,
});
return this;
}
/**
* Triggers callback for a speech event.
* @param {function()} callback
* @return {!Output}
*/
onSpeechEnd(callback) {
this.speechEndCallback_ =
/** @type {function(boolean=)} */ (function(opt_cleanupOnly) {
if (!opt_cleanupOnly) {
callback();
}
}.bind(this));
return this;
}
/** Executes all specified output. */
go() {
// Speech.
let queueMode = this.determineQueueMode_();
if (this.speechBuffer_.length > 0) {
Output.forceModeForNextSpeechUtterance_ = undefined;
}
let encounteredNonWhitespace = false;
for (let i = 0; i < this.speechBuffer_.length; i++) {
const buff = this.speechBuffer_[i];
const text = buff.toString();
// Consider empty strings as non-whitespace; they often have earcons
// associated with them, so need to be "spoken".
const isNonWhitespace = text === '' || /\S+/.test(text);
encounteredNonWhitespace = isNonWhitespace || encounteredNonWhitespace;
// Skip whitespace if we've already encountered non-whitespace. This
// prevents output like 'foo', 'space', 'bar'.
if (!isNonWhitespace && encounteredNonWhitespace) {
continue;
}
let speechProps;
const speechPropsInstance = /** @type {OutputSpeechProperties} */ (
buff.getSpanInstanceOf(OutputSpeechProperties));
if (!speechPropsInstance) {
speechProps = this.initialSpeechProps_;
} else {
for (const [key, value] of Object.entries(this.initialSpeechProps_)) {
if (speechPropsInstance.properties[key] === undefined) {
speechPropsInstance.properties[key] = value;
}
}
speechProps = new TtsSpeechProperties(speechPropsInstance.properties);
}
speechProps.category = this.speechCategory_;
(function() {
const scopedBuff = buff;
speechProps.startCallback = function() {
const actions = scopedBuff.getSpansInstanceOf(OutputAction);
if (actions) {
actions.forEach(function(a) {
a.run();
});
}
};
}());
if (i === this.speechBuffer_.length - 1) {
speechProps.endCallback = this.speechEndCallback_;
}
let finalSpeech = buff.toString();
for (const text in this.replacements_) {
finalSpeech = finalSpeech.replace(text, this.replacements_[text]);
}
ChromeVox.tts.speak(finalSpeech, queueMode, speechProps);
// Skip resetting |queueMode| if the |text| is empty. If we don't do this,
// and the tts engine doesn't generate a callback, we might not properly
// flush.
if (text !== '') {
queueMode = QueueMode.QUEUE;
}
}
if (this.speechRulesStr_.str) {
LogStore.getInstance().writeTextLog(
this.speechRulesStr_.str, LogType.SPEECH_RULE);
}
// Braille.
if (this.brailleBuffer_.length) {
const buff = this.mergeBraille_(this.brailleBuffer_);
const selSpan = buff.getSpanInstanceOf(OutputSelectionSpan);
let startIndex = -1;
let endIndex = -1;
if (selSpan) {
const valueStart = buff.getSpanStart(selSpan);
const valueEnd = buff.getSpanEnd(selSpan);
startIndex = valueStart + selSpan.startIndex;
endIndex = valueStart + selSpan.endIndex;
try {
buff.setSpan(new ValueSpan(0), valueStart, valueEnd);
buff.setSpan(new ValueSelectionSpan(), startIndex, endIndex);
} catch (e) {
}
}
const output = new NavBraille({text: buff, startIndex, endIndex});
ChromeVox.braille.write(output);
if (this.brailleRulesStr_.str) {
LogStore.getInstance().writeTextLog(
this.brailleRulesStr_.str, LogType.BRAILLE_RULE);
}
}
// Display.
if (this.speechCategory_ !== TtsCategory.LIVE && this.drawFocusRing_) {
FocusBounds.set(this.locations_);
}
}
/** @return {QueueMode} */
determineQueueMode_() {
if (Output.forceModeForNextSpeechUtterance_ !== undefined) {
return Output.forceModeForNextSpeechUtterance_;
}
if (this.queueMode_ !== undefined) {
return this.queueMode_;
}
return QueueMode.QUEUE;
}
/**
* @return {boolean} True if this object is equal to |rhs|.
*/
equals(rhs) {
if (this.speechBuffer_.length !== rhs.speechBuffer_.length ||
this.brailleBuffer_.length !== rhs.brailleBuffer_.length) {
return false;
}
for (let i = 0; i < this.speechBuffer_.length; i++) {
if (this.speechBuffer_[i].toString() !==
rhs.speechBuffer_[i].toString()) {
return false;
}
}
for (let j = 0; j < this.brailleBuffer_.length; j++) {
if (this.brailleBuffer_[j].toString() !==
rhs.brailleBuffer_[j].toString()) {
return false;
}
}
return true;
}
/**
* Renders the given range using optional context previous range and event
* type.
* @param {!CursorRange} range
* @param {CursorRange} prevRange
* @param {EventType|OutputEventType} type
* @param {!Array<Spannable>} buff Buffer to receive rendered output.
* @param {!OutputRulesStr} ruleStr
* @param {{suppressStartEndAncestry: (boolean|undefined)}} optionalArgs
* @private
*/
render_(range, prevRange, type, buff, ruleStr, optionalArgs = {}) {
if (prevRange && !prevRange.isValid()) {
prevRange = null;
}
// Scan all ancestors to get the value of |contextOrder|.
let parent = range.start.node;
const prevParent = prevRange ? prevRange.start.node : parent;
if (!parent || !prevParent) {
return;
}
while (parent) {
if (parent.role === RoleType.WINDOW) {
break;
}
if (OutputRoleInfo[parent.role] &&
OutputRoleInfo[parent.role].contextOrder) {
this.contextOrder_ =
OutputRoleInfo[parent.role].contextOrder || this.contextOrder_;
break;
}
parent = parent.parent;
}
if (range.isSubNode()) {
this.subNode_(range, prevRange, type, buff, ruleStr);
} else {
this.range_(range, prevRange, type, buff, ruleStr, optionalArgs);
}
this.hint_(
range, AutomationUtil.getUniqueAncestors(prevParent, range.start.node),
type, buff, ruleStr);
}
/**
* Format the node given the format specifier.
* Please see below for more information on arguments.
* node: The AutomationNode of interest.
* outputFormat: The output format either specified as an output template
* string or a parsed output format tree.
* outputBuffer: Buffer to receive rendered output.
* outputRuleString: Used for logging and recording output.
* opt_prevNode: Optional argument. Helps provide context for certain speech
* output.
* opt_speechProps: Optional argument. Used to specify how speech should be
* verbalized; can specify pitch, rate, language, etc.
* @param {!{
* node: AutomationNode,
* outputFormat: (string|OutputFormatTree),
* outputBuffer: !Array<Spannable>,
* outputRuleString: !OutputRulesStr,
* opt_prevNode: (!AutomationNode|undefined),
* opt_speechProps: (!OutputSpeechProperties|undefined)
* }} params An object containing all required and optional parameters.
* @private
*/
format_(params) {
const node = params['node'];
const format = params['outputFormat'];
const buff = params['outputBuffer'];
const ruleStr = params['outputRuleString'];
const prevNode = params['opt_prevNode'];
let speechProps = params['opt_speechProps'];
const owner = this;
const observer =
new /** @implements {OutputFormatParserObserver} */ (class {
/** @override */
onTokenStart() {}
/** @override */
onNodeAttributeOrSpecialToken(token, tree, options) {
if (owner.suppressions_[token]) {
return true;
}
if (token === 'value') {
owner.formatValue_(node, token, buff, options, ruleStr);
} else if (token === 'name') {
owner.formatName_(node, prevNode, token, buff, options, ruleStr);
} else if (token === 'description') {
owner.formatDescription_(node, token, buff, options, ruleStr);
} else if (token === 'urlFilename') {
owner.formatUrlFilename_(node, token, buff, options, ruleStr);
} else if (token === 'nameFromNode') {
owner.formatNameFromNode_(node, token, buff, options, ruleStr);
} else if (token === 'nameOrDescendants') {
// This token is similar to nameOrTextContent except it gathers
// rich output for descendants. It also lets name from contents
// override the descendants text if |node| has only static text
// children.
owner.formatNameOrDescendants_(
node, token, buff, options, ruleStr);
} else if (token === 'indexInParent') {
owner.formatIndexInParent_(
node, token, tree, buff, options, ruleStr);
} else if (token === 'restriction') {
owner.formatRestriction_(node, token, buff, ruleStr);
} else if (token === 'checked') {
owner.formatChecked_(node, token, buff, ruleStr);
} else if (token === 'pressed') {
owner.formatPressed_(node, token, buff, ruleStr);
} else if (token === 'state') {
owner.formatState_(node, token, buff, ruleStr);
} else if (token === 'find') {
owner.formatFind_(node, token, tree, buff, ruleStr);
} else if (token === 'descendants') {
owner.formatDescendants_(node, token, buff, ruleStr);
} else if (token === 'joinedDescendants') {
owner.formatJoinedDescendants_(
node, token, buff, options, ruleStr);
} else if (token === 'role') {
if (localStorage['useVerboseMode'] === 'false') {
return true;
}
if (owner.formatOptions_.auralStyle) {
speechProps = new OutputSpeechProperties();
speechProps.properties['relativePitch'] = -0.3;
}
owner.formatRole_(node, token, buff, options, ruleStr);
} else if (token === 'inputType') {
owner.formatInputType_(node, token, buff, options, ruleStr);
} else if (
token === 'tableCellRowIndex' ||
token === 'tableCellColumnIndex') {
owner.formatTableCellIndex_(node, token, buff, options, ruleStr);
} else if (token === 'cellIndexText') {
owner.formatCellIndexText_(node, token, buff, options, ruleStr);
} else if (token === 'node') {
owner.formatNode_(
node, prevNode, token, tree, buff, options, ruleStr);
} else if (
token === 'nameOrTextContent' || token === 'textContent') {
owner.formatTextContent_(node, token, buff, options, ruleStr);
} else if (node[token] !== undefined) {
owner.formatAsFieldAccessor_(node, token, buff, options, ruleStr);
} else if (Output.STATE_INFO_[token]) {
owner.formatAsStateValue_(node, token, buff, options, ruleStr);
} else if (token === 'phoneticReading') {
owner.formatPhoneticReading_(node, buff);
} else if (token === 'listNestedLevel') {
owner.formatListNestedLevel_(node, buff);
} else if (token === 'precedingBullet') {
owner.formatPrecedingBullet_(node, buff);
} else if (tree.firstChild) {
owner.formatCustomFunction_(
node, token, tree, buff, options, ruleStr);
}
}
/** @override */
onMessageToken(token, tree, options) {
ruleStr.write(' @');
if (owner.formatOptions_.auralStyle) {
if (!speechProps) {
speechProps = new OutputSpeechProperties();
}
speechProps.properties['relativePitch'] = -0.2;
}
owner.formatMessage_(node, token, tree, buff, options, ruleStr);
}
/** @override */
onSpeechPropertyToken(token, tree, options) {
ruleStr.write(' ! ' + token + '\n');
speechProps = new OutputSpeechProperties();
speechProps.properties[token] = true;
if (tree.firstChild) {
if (!owner.formatOptions_.auralStyle) {
speechProps = undefined;
return true;
}
let value = tree.firstChild.value;
// Currently, speech params take either attributes or floats.
let float = 0;
if (float = parseFloat(value)) {
value = float;
} else {
value = parseFloat(node[value]) / -10.0;
}
speechProps.properties[token] = value;
return true;
}
}
/** @override */
onTokenEnd() {
// Post processing.
if (speechProps) {
if (buff.length > 0) {
buff[buff.length - 1].setSpan(speechProps, 0, 0);
speechProps = null;
}
}
}
})();
new OutputFormatParser(observer).parse(format);
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatValue_(node, token, buff, options, ruleStr) {
const text = node.value || '';
if (!node.state[StateType.EDITABLE] && node.name === text) {
return;
}
let selectedText = '';
if (node.textSelStart !== undefined) {
options.annotation.push(new OutputSelectionSpan(
node.textSelStart || 0, node.textSelEnd || 0));
if (node.value) {
selectedText =
node.value.substring(node.textSelStart || 0, node.textSelEnd || 0);
}
}
options.annotation.push(token);
if (selectedText && !this.formatOptions_.braille &&
node.state[StateType.FOCUSED]) {
this.append_(buff, selectedText, options);
this.append_(buff, Msgs.getMsg('selected'));
ruleStr.writeTokenWithValue(token, selectedText);
ruleStr.write('selected\n');
} else {
this.append_(buff, text, options);
ruleStr.writeTokenWithValue(token, text);
}
}
/**
* @param {AutomationNode} node
* @param {!AutomationNode|undefined} prevNode
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatName_(node, prevNode, token, buff, options, ruleStr) {
options.annotation.push(token);
const earcon = node ? this.findEarcon_(node, prevNode) : null;
if (earcon) {
options.annotation.push(earcon);
}
// Place the selection on the first character of the name if the
// node is the active descendant. This ensures the braille window is
// panned appropriately.
if (node.activeDescendantFor && node.activeDescendantFor.length > 0) {
options.annotation.push(new OutputSelectionSpan(0, 0));
}
if (localStorage['languageSwitching'] === 'true') {
this.assignLocaleAndAppend_(node.name || '', node, buff, options);
} else {
this.append_(buff, node.name || '', options);
}
ruleStr.writeTokenWithValue(token, node.name);
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatDescription_(node, token, buff, options, ruleStr) {
if (node.name === node.description) {
return;
}
options.annotation.push(token);
this.append_(buff, node.description || '', options);
ruleStr.writeTokenWithValue(token, node.description);
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatUrlFilename_(node, token, buff, options, ruleStr) {
options.annotation.push('name');
const url = node.url || '';
let filename = '';
if (url.substring(0, 4) !== 'data') {
filename = url.substring(url.lastIndexOf('/') + 1, url.lastIndexOf('.'));
// Hack to not speak the filename if it's ridiculously long.
if (filename.length >= 30) {
filename = filename.substring(0, 16) + '...';
}
}
this.append_(buff, filename, options);
ruleStr.writeTokenWithValue(token, filename);
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatNameFromNode_(node, token, buff, options, ruleStr) {
if (node.nameFrom === NameFromType.CONTENTS) {
return;
}
options.annotation.push('name');
this.append_(buff, node.name || '', options);
ruleStr.writeTokenWithValue(token, node.name);
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatNameOrDescendants_(node, token, buff, options, ruleStr) {
options.annotation.push(token);
if (node.name &&
(node.nameFrom !== NameFromType.CONTENTS ||
node.children.every(function(child) {
return child.role === RoleType.STATIC_TEXT;
}))) {
this.append_(buff, node.name || '', options);
ruleStr.writeTokenWithValue(token, node.name);
} else {
ruleStr.writeToken(token);
this.format_({
node,
outputFormat: '$descendants',
outputBuffer: buff,
outputRuleString: ruleStr,
});
}
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!OutputFormatTree} tree
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatIndexInParent_(node, token, tree, buff, options, ruleStr) {
if (node.parent) {
options.annotation.push(token);
let roles;
if (tree.firstChild) {
roles = this.createRoles_(tree);
} else {
roles = new Set();
roles.add(node.role);
}
let count = 0;
for (let i = 0, child; child = node.parent.children[i]; i++) {
if (roles.has(child.role)) {
count++;
}
if (node === child) {
break;
}
}
this.append_(buff, String(count));
ruleStr.writeTokenWithValue(token, String(count));
}
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!OutputRulesStr} ruleStr
*/
formatRestriction_(node, token, buff, ruleStr) {
const msg = Output.RESTRICTION_STATE_MAP[node.restriction];
if (msg) {
ruleStr.writeToken(token);
this.format_({
node,
outputFormat: '@' + msg,
outputBuffer: buff,
outputRuleString: ruleStr,
});
}
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!OutputRulesStr} ruleStr
*/
formatChecked_(node, token, buff, ruleStr) {
const msg = Output.CHECKED_STATE_MAP[node.checked];
if (msg) {
ruleStr.writeToken(token);
this.format_({
node,
outputFormat: '@' + msg,
outputBuffer: buff,
outputRuleString: ruleStr,
});
}
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!OutputRulesStr} ruleStr
*/
formatPressed_(node, token, buff, ruleStr) {
const msg = Output.PRESSED_STATE_MAP[node.checked];
if (msg) {
ruleStr.writeToken(token);
this.format_({
node,
outputFormat: '@' + msg,
outputBuffer: buff,
outputRuleString: ruleStr,
});
}
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!OutputRulesStr} ruleStr
*/
formatState_(node, token, buff, ruleStr) {
if (node.state) {
Object.getOwnPropertyNames(node.state).forEach(function(s) {
const stateInfo = Output.STATE_INFO_[s];
if (stateInfo && !stateInfo.isRoleSpecific && stateInfo.on) {
ruleStr.writeToken(token);
this.format_({
node,
outputFormat: '$' + s,
outputBuffer: buff,
outputRuleString: ruleStr,
});
}
}.bind(this));
}
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!OutputFormatTree} tree
* @param {!Array<Spannable>} buff
* @param {!OutputRulesStr} ruleStr
*/
formatFind_(node, token, tree, buff, ruleStr) {
// Find takes two arguments: JSON query string and format string.
if (tree.firstChild) {
const jsonQuery = tree.firstChild.value;
node = node.find(
/** @type {chrome.automation.FindParams}*/ (JSON.parse(jsonQuery)));
const formatString = tree.firstChild.nextSibling || '';
if (node) {
ruleStr.writeToken(token);
this.format_({
node,
outputFormat: formatString,
outputBuffer: buff,
outputRuleString: ruleStr,
});
}
}
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!OutputRulesStr} ruleStr
*/
formatDescendants_(node, token, buff, ruleStr) {
if (!node) {
return;
}
let leftmost = node;
let rightmost = node;
if (AutomationPredicate.leafOrStaticText(node)) {
// Find any deeper leaves, if any, by starting from one level
// down.
leftmost = node.firstChild;
rightmost = node.lastChild;
if (!leftmost || !rightmost) {
return;
}
}
// Construct a range to the leftmost and rightmost leaves. This
// range gets rendered below which results in output that is the
// same as if a user navigated through the entire subtree of |node|.
leftmost = AutomationUtil.findNodePre(
leftmost, Dir.FORWARD, AutomationPredicate.leafOrStaticText);
rightmost = AutomationUtil.findNodePre(
rightmost, Dir.BACKWARD, AutomationPredicate.leafOrStaticText);
if (!leftmost || !rightmost) {
return;
}
const subrange = new CursorRange(
new Cursor(leftmost, CURSOR_NODE_INDEX),
new Cursor(rightmost, CURSOR_NODE_INDEX));
let prev = null;
if (node) {
prev = CursorRange.fromNode(node);
}
ruleStr.writeToken(token);
this.render_(
subrange, prev, OutputEventType.NAVIGATE, buff, ruleStr,
{suppressStartEndAncestry: true});
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatJoinedDescendants_(node, token, buff, options, ruleStr) {
const unjoined = [];
ruleStr.write('joinedDescendants {');
this.format_({
node,
outputFormat: '$descendants',
outputBuffer: unjoined,
outputRuleString: ruleStr,
});
this.append_(buff, unjoined.join(' '), options);
ruleStr.write(
'}: ' + (unjoined.length ? unjoined.join(' ') : 'EMPTY') + '\n');
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatRole_(node, token, buff, options, ruleStr) {
options.annotation.push(token);
let msg = node.role;
const info = OutputRoleInfo[node.role];
if (node.roleDescription) {
msg = node.roleDescription;
} else if (info) {
if (this.formatOptions_.braille) {
msg = Msgs.getMsg(info.msgId + '_brl');
} else if (info.msgId) {
msg = Msgs.getMsg(info.msgId);
}
} else {
// We can safely ignore this role. ChromeVox output tests cover
// message id validity.
return;
}
this.append_(buff, msg || '', options);
ruleStr.writeTokenWithValue(token, msg);
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatInputType_(node, token, buff, options, ruleStr) {
if (!node.inputType) {
return;
}
options.annotation.push(token);
let msgId =
Output.INPUT_TYPE_MESSAGE_IDS_[node.inputType] || 'input_type_text';
if (this.formatOptions_.braille) {
msgId = msgId + '_brl';
}
this.append_(buff, Msgs.getMsg(msgId), options);
ruleStr.writeTokenWithValue(token, Msgs.getMsg(msgId));
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatTableCellIndex_(node, token, buff, options, ruleStr) {
let value = node[token];
if (value === undefined) {
return;
}
value = String(value + 1);
options.annotation.push(token);
this.append_(buff, value, options);
ruleStr.writeTokenWithValue(token, value);
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatCellIndexText_(node, token, buff, options, ruleStr) {
if (node.htmlAttributes['aria-coltext']) {
let value = node.htmlAttributes['aria-coltext'];
let row = node;
while (row && row.role !== RoleType.ROW) {
row = row.parent;
}
if (!row || !row.htmlAttributes['aria-rowtext']) {
return;
}
value += row.htmlAttributes['aria-rowtext'];
this.append_(buff, value, options);
ruleStr.writeTokenWithValue(token, value);
} else {
ruleStr.write(token);
this.format_({
node,
outputFormat: ` @cell_summary($if($tableCellAriaRowIndex,
$tableCellAriaRowIndex, $tableCellRowIndex),
$if($tableCellAriaColumnIndex, $tableCellAriaColumnIndex,
$tableCellColumnIndex))`,
outputBuffer: buff,
outputRuleString: ruleStr,
});
}
}
/**
* @param {AutomationNode} node
* @param {!AutomationNode|undefined} prevNode
* @param {string} token
* @param {!OutputFormatTree} tree
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatNode_(node, prevNode, token, tree, buff, options, ruleStr) {
if (!tree.firstChild) {
return;
}
const relationName = tree.firstChild.value;
if (relationName === 'tableCellColumnHeaders') {
// Skip output when previous position falls on the same column.
while (prevNode && !AutomationPredicate.cellLike(prevNode)) {
prevNode = prevNode.parent;
}
if (prevNode &&
prevNode.tableCellColumnIndex === node.tableCellColumnIndex) {
return;
}
const headers = node.tableCellColumnHeaders;
if (headers) {
for (let i = 0; i < headers.length; i++) {
const header = headers[i].name;
if (header) {
this.append_(buff, header, options);
ruleStr.writeTokenWithValue(token, header);
}
}
}
} else if (relationName === 'tableCellRowHeaders') {
const headers = node.tableCellRowHeaders;
if (headers) {
for (let i = 0; i < headers.length; i++) {
const header = headers[i].name;
if (header) {
this.append_(buff, header, options);
ruleStr.writeTokenWithValue(token, header);
}
}
}
} else if (node[relationName]) {
const related = node[relationName];
this.node_(related, related, OutputEventType.NAVIGATE, buff, ruleStr);
}
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatTextContent_(node, token, buff, options, ruleStr) {
if (node.name && token === 'nameOrTextContent') {
ruleStr.writeToken(token);
this.format_({
node,
outputFormat: '$name',
outputBuffer: buff,
outputRuleString: ruleStr,
});
return;
}
if (!node.firstChild) {
return;
}
const root = node;
const walker = new AutomationTreeWalker(node, Dir.FORWARD, {
visit: AutomationPredicate.leafOrStaticText,
leaf: n => {
// The root might be a leaf itself, but we still want to descend
// into it.
return n !== root && AutomationPredicate.leafOrStaticText(n);
},
root: r => r === root,
});
const outputStrings = [];
while (walker.next().node) {
if (walker.node.name) {
outputStrings.push(walker.node.name.trim());
}
}
const finalOutput = outputStrings.join(' ');
this.append_(buff, finalOutput, options);
ruleStr.writeTokenWithValue(token, finalOutput);
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatAsFieldAccessor_(node, token, buff, options, ruleStr) {
options.annotation.push(token);
let value = node[token];
if (typeof value === 'number') {
value = String(value);
}
this.append_(buff, value, options);
ruleStr.writeTokenWithValue(token, value);
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatAsStateValue_(node, token, buff, options, ruleStr) {
options.annotation.push('state');
const stateInfo = Output.STATE_INFO_[token];
let resolvedInfo = {};
resolvedInfo = node.state[/** @type {StateType} */ (token)] ? stateInfo.on :
stateInfo.off;
if (!resolvedInfo) {
return;
}
if (this.formatOptions_.speech && resolvedInfo.earconId) {
options.annotation.push(
new OutputEarconAction(resolvedInfo.earconId),
node.location || undefined);
}
const msgId = this.formatOptions_.braille ? resolvedInfo.msgId + '_brl' :
resolvedInfo.msgId;
const msg = Msgs.getMsg(msgId);
this.append_(buff, msg, options);
ruleStr.writeTokenWithValue(token, msg);
}
/**
* @param {AutomationNode} node
* @param {!Array<Spannable>} buff
*/
formatPhoneticReading_(node, buff) {
const text =
PhoneticData.forText(node.name || '', chrome.i18n.getUILanguage());
this.append_(buff, text);
}
/**
* @param {!AutomationNode} node
* @param {!Array<Spannable>} buff
*/
formatListNestedLevel_(node, buff) {
let level = 0;
let current = node;
while (current) {
if (current.role === RoleType.LIST) {
level += 1;
}
current = current.parent;
}
this.append_(buff, level.toString());
}
/**
* @param {!AutomationNode} node
* @param {!Array<Spannable>} buff
*/
formatPrecedingBullet_(node, buff) {
let current = node;
if (current.role === RoleType.INLINE_TEXT_BOX) {
current = current.parent;
}
if (!current || current.role !== RoleType.STATIC_TEXT) {
return;
}
current = current.previousSibling;
if (current && current.role === RoleType.LIST_MARKER) {
this.append_(buff, current.name || '');
}
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!OutputFormatTree} tree
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatCustomFunction_(node, token, tree, buff, options, ruleStr) {
// Custom functions.
if (token === 'if') {
ruleStr.writeToken(token);
const cond = tree.firstChild;
const attrib = cond.value.slice(1);
if (Output.isTruthy(node, attrib)) {
ruleStr.write(attrib + '==true => ');
this.format_({
node,
outputFormat: cond.nextSibling || '',
outputBuffer: buff,
outputRuleString: ruleStr,
});
} else if (Output.isFalsey(node, attrib)) {
ruleStr.write(attrib + '==false => ');
this.format_({
node,
outputFormat: cond.nextSibling.nextSibling || '',
outputBuffer: buff,
outputRuleString: ruleStr,
});
}
} else if (token === 'nif') {
ruleStr.writeToken(token);
const cond = tree.firstChild;
const attrib = cond.value.slice(1);
if (Output.isFalsey(node, attrib)) {
ruleStr.write(attrib + '==false => ');
this.format_({
node,
outputFormat: cond.nextSibling || '',
outputBuffer: buff,
outputRuleString: ruleStr,
});
} else if (Output.isTruthy(node, attrib)) {
ruleStr.write(attrib + '==true => ');
this.format_({
node,
outputFormat: cond.nextSibling.nextSibling || '',
outputBuffer: buff,
outputRuleString: ruleStr,
});
}
} else if (token === 'earcon') {
// Ignore unless we're generating speech output.
if (!this.formatOptions_.speech) {
return;
}
options.annotation.push(new OutputEarconAction(
tree.firstChild.value, node.location || undefined));
this.append_(buff, '', options);
ruleStr.writeTokenWithValue(token, tree.firstChild.value);
}
}
/**
* @param {AutomationNode} node
* @param {string} token
* @param {!OutputFormatTree} tree
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @param {!OutputRulesStr} ruleStr
*/
formatMessage_(node, token, tree, buff, options, ruleStr) {
const isPluralized = (token[0] === '@');
if (isPluralized) {
token = token.slice(1);
}
// Tokens can have substitutions.
const pieces = token.split('+');
token = pieces.reduce(function(prev, cur) {
let lookup = cur;
if (cur[0] === '$') {
lookup = node[cur.slice(1)];
}
return prev + lookup;
}.bind(this), '');
const msgId = token;
let msgArgs = [];
ruleStr.write(token + '{');
if (!isPluralized) {
let curArg = tree.firstChild;
while (curArg) {
if (curArg.value[0] !== '$') {
const errorMsg = 'Unexpected value: ' + curArg.value;
ruleStr.writeError(errorMsg);
console.error(errorMsg);
return;
}
let msgBuff = [];
this.format_({
node,
outputFormat: curArg,
outputBuffer: msgBuff,
outputRuleString: ruleStr,
});
// Fill in empty string if nothing was formatted.
if (!msgBuff.length) {
msgBuff = [''];
}
msgArgs = msgArgs.concat(msgBuff);
curArg = curArg.nextSibling;
}
}
let msg = Msgs.getMsg(msgId, msgArgs);
try {
if (this.formatOptions_.braille) {
msg = Msgs.getMsg(msgId + '_brl', msgArgs) || msg;
}
} catch (e) {
}
if (!msg) {
const errorMsg = 'Could not get message ' + msgId;
ruleStr.writeError(errorMsg);
console.error(errorMsg);
return;
}
if (isPluralized) {
const arg = tree.firstChild;
if (!arg || arg.nextSibling) {
const errorMsg = 'Pluralized messages take exactly one argument';
ruleStr.writeError(errorMsg);
console.error(errorMsg);
return;
}
if (arg.value[0] !== '$') {
const errorMsg = 'Unexpected value: ' + arg.value;
ruleStr.writeError(errorMsg);
console.error(errorMsg);
return;
}
const argBuff = [];
this.format_({
node,
outputFormat: arg,
outputBuffer: argBuff,
outputRuleString: ruleStr,
});
const namedArgs = {COUNT: Number(argBuff[0])};
msg = new goog.i18n.MessageFormat(msg).format(namedArgs);
}
ruleStr.write('}');
this.append_(buff, msg, options);
ruleStr.write(': ' + msg + '\n');
}
/**
* @param {!OutputFormatTree} tree
* @return {!Set}
* @private
*/
createRoles_(tree) {
const roles = new Set();
let currentNode = tree.firstChild;
for (; currentNode; currentNode = currentNode.nextSibling) {
roles.add(currentNode.value);
}
return roles;
}
/**
* @param {!CursorRange} range
* @param {CursorRange} prevRange
* @param {EventType|OutputEventType} type
* @param {!Array<Spannable>} rangeBuff
* @param {!OutputRulesStr} ruleStr
* @param {{suppressStartEndAncestry: (boolean|undefined)}} optionalArgs
* @private
*/
range_(range, prevRange, type, rangeBuff, ruleStr, optionalArgs = {}) {
if (!range.start.node || !range.end.node) {
return;
}
if (!prevRange && range.start.node.root) {
prevRange = CursorRange.fromNode(range.start.node.root);
} else if (!prevRange) {
return;
}
const isForward = prevRange.compare(range) === Dir.FORWARD;
const addContextBefore = this.contextOrder_ === OutputContextOrder.FIRST ||
this.contextOrder_ === OutputContextOrder.FIRST_AND_LAST ||
(this.contextOrder_ === OutputContextOrder.DIRECTED && isForward);
const addContextAfter = this.contextOrder_ === OutputContextOrder.LAST ||
this.contextOrder_ === OutputContextOrder.FIRST_AND_LAST ||
(this.contextOrder_ === OutputContextOrder.DIRECTED && !isForward);
const preferStartOrEndAncestry =
this.contextOrder_ === OutputContextOrder.FIRST_AND_LAST;
let prevNode = prevRange.start.node;
let node = range.start.node;
const formatNodeAndAncestors = function(node, prevNode) {
const buff = [];
if (addContextBefore) {
this.ancestry_(
node, prevNode, type, buff, ruleStr,
{preferStart: preferStartOrEndAncestry});
}
this.node_(node, prevNode, type, buff, ruleStr);
if (addContextAfter) {
this.ancestry_(
node, prevNode, type, buff, ruleStr,
{preferEnd: preferStartOrEndAncestry});
}
if (node.location) {
this.locations_.push(node.location);
}
return buff;
}.bind(this);
let lca = null;
if (range.start.node !== range.end.node) {
lca = AutomationUtil.getLeastCommonAncestor(
range.end.node, range.start.node);
}
if (addContextAfter) {
prevNode = lca || prevNode;
}
// Do some bookkeeping to see whether this range partially covers node(s) at
// its endpoints.
let hasPartialNodeStart = false;
let hasPartialNodeEnd = false;
if (AutomationPredicate.selectableText(range.start.node) &&
range.start.index > 0) {
hasPartialNodeStart = true;
}
if (AutomationPredicate.selectableText(range.end.node) &&
range.end.index >= 0 && range.end.index < range.end.node.name.length) {
hasPartialNodeEnd = true;
}
let pred;
if (range.isInlineText()) {
pred = AutomationPredicate.leaf;
} else if (hasPartialNodeStart || hasPartialNodeEnd) {
pred = AutomationPredicate.selectableText;
} else {
pred = AutomationPredicate.object;
}
// Computes output for nodes (including partial subnodes) between endpoints
// of |range|.
while (node && range.end.node &&
AutomationUtil.getDirection(node, range.end.node) === Dir.FORWARD) {
if (hasPartialNodeStart && node === range.start.node) {
if (range.start.index !== range.start.node.name.length) {
const partialRange = new CursorRange(
new Cursor(node, range.start.index),
new Cursor(
node, node.name.length, {preferNodeStartEquivalent: true}));
this.subNode_(partialRange, prevRange, type, rangeBuff, ruleStr);
}
} else if (hasPartialNodeEnd && node === range.end.node) {
if (range.end.index !== 0) {
const partialRange = new CursorRange(
new Cursor(node, 0), new Cursor(node, range.end.index));
this.subNode_(partialRange, prevRange, type, rangeBuff, ruleStr);
}
} else {
rangeBuff.push.apply(rangeBuff, formatNodeAndAncestors(node, prevNode));
}
// End early if the range is just a single node.
if (range.start.node === range.end.node) {
break;
}
prevNode = node;
node = AutomationUtil.findNextNode(
node, Dir.FORWARD, pred, {root: r => r === lca}) ||
prevNode;
// Reached a boundary.
if (node === prevNode) {
break;
}
}
// Finally, add on ancestry announcements, if needed.
if (addContextAfter) {
// No lca; the range was already fully described.
if (lca == null || !prevRange.start.node) {
return;
}
// Since the lca itself needs to be part of the ancestry output, use its
// first child as a target.
const target = lca.firstChild || lca;
this.ancestry_(target, prevRange.start.node, type, rangeBuff, ruleStr);
}
}
/**
* @param {!AutomationNode} node
* @param {!AutomationNode} prevNode
* @param {EventType|OutputEventType} type
* @param {!Array<Spannable>} buff
* @param {!OutputRulesStr} ruleStr
* @param {{suppressStartEndAncestry: (boolean|undefined),
* preferStart: (boolean|undefined),
* preferEnd: (boolean|undefined)
* }} optionalArgs
* @private
*/
ancestry_(node, prevNode, type, buff, ruleStr, optionalArgs = {}) {
if (localStorage['useVerboseMode'] === 'false') {
return;
}
if (OutputRoleInfo[node.role] && OutputRoleInfo[node.role].ignoreAncestry) {
return;
}
const info = new OutputAncestryInfo(
node, prevNode, Boolean(optionalArgs.suppressStartEndAncestry));
// Enter, leave ancestry.
this.ancestryHelper_({
node,
prevNode,
buff,
ruleStr,
type,
ancestors: info.leaveAncestors,
formatName: 'leave',
exclude: [...info.enterAncestors, node],
});
this.ancestryHelper_({
node,
prevNode,
buff,
ruleStr,
type,
ancestors: info.enterAncestors,
formatName: 'enter',
excludePreviousAncestors: true,
});
if (optionalArgs.suppressStartEndAncestry) {
return;
}
// Start of, end of ancestry.
if (!optionalArgs.preferEnd) {
this.ancestryHelper_({
node,
prevNode,
buff,
ruleStr,
type,
ancestors: info.startAncestors,
formatName: 'startOf',
excludePreviousAncestors: true,
});
}
if (!optionalArgs.preferStart) {
this.ancestryHelper_({
node,
prevNode,
buff,
ruleStr,
type,
ancestors: info.endAncestors,
formatName: 'endOf',
exclude: [...info.startAncestors].concat(node),
});
}
}
/**
* @param {{
* node: !AutomationNode,
* prevNode: !AutomationNode,
* type: (EventType|OutputEventType),
* buff: !Array<Spannable>,
* ruleStr: !OutputRulesStr,
* ancestors: !Array<!AutomationNode>,
* formatName: string,
* exclude: (!Array<!AutomationNode>|undefined),
* excludePreviousAncestors: (boolean|undefined)
* }} args
* @private
*/
ancestryHelper_(args) {
let {node, prevNode, buff, ruleStr, type, ancestors, formatName} = args;
/** Following types are contained: {event, role, navigation, output} */
const rule = {};
// First, look up the event type's format block.
// Navigate is the default event.
rule.event = Output.RULES[type] ? type : 'navigate';
const eventBlock = Output.RULES[rule.event];
const excludeRoles =
args.exclude ? new Set(args.exclude.map(node => node.role)) : new Set();
// Customize for braille node annotations.
const originalBuff = buff;
for (let j = ancestors.length - 1, formatNode; (formatNode = ancestors[j]);
j--) {
const roleInfo = OutputRoleInfo[formatNode.role] || {};
if (!roleInfo.verboseAncestry &&
(excludeRoles.has(formatNode.role) ||
(args.excludePreviousAncestors &&
this.formattedAncestors_.has(formatNode)))) {
continue;
}
const parentRole = roleInfo.inherits;
if (eventBlock[formatNode.role] &&
eventBlock[formatNode.role][formatName]) {
rule.role = formatNode.role;
} else if (eventBlock[parentRole] && eventBlock[parentRole][formatName]) {
rule.role = parentRole;
} else {
rule.role = 'default';
}
if (eventBlock[rule.role][formatName]) {
rule.navigation = formatName;
rule.output =
eventBlock[rule.role][formatName].speak ? 'speak' : undefined;
if (this.formatOptions_.braille) {
buff = [];
ruleStr.bufferClear();
if (eventBlock[rule.role][formatName].braille) {
rule.output = 'braille';
}
}
excludeRoles.add(formatNode.role);
ruleStr.writeRule /** @type {OutputRulesStr.Rule} */ ((rule));
const enterFormat = rule.output ?
eventBlock[rule.role][formatName][rule.output] :
eventBlock[rule.role][formatName];
this.formattedAncestors_.add(formatNode);
this.format_({
node: formatNode,
outputFormat: enterFormat,
outputBuffer: buff,
outputRuleString: ruleStr,
opt_prevNode: prevNode,
});
if (this.formatOptions_.braille && buff.length) {
const nodeSpan = this.mergeBraille_(buff);
nodeSpan.setSpan(new OutputNodeSpan(formatNode), 0, nodeSpan.length);
originalBuff.push(nodeSpan);
}
}
}
}
/**
* @param {!AutomationNode} node
* @param {!AutomationNode} prevNode
* @param {EventType|OutputEventType} type
* @param {!Array<Spannable>} buff
* @param {!OutputRulesStr} ruleStr
* @private
*/
node_(node, prevNode, type, buff, ruleStr) {
const originalBuff = buff;
if (this.formatOptions_.braille) {
buff = [];
ruleStr.bufferClear();
}
const rule = {};
// Navigate is the default event.
rule.event = Output.RULES[type] ? type : 'navigate';
const eventBlock = Output.RULES[rule.event];
const parentRole = (OutputRoleInfo[node.role] || {}).inherits || '';
/**
* Use Output.RULES for node.role if exists.
* If not, use Output.RULES for parentRole if exists.
* If not, use Output.RULES for 'default'.
*/
if (node.role && (eventBlock[node.role] || {}).speak !== undefined) {
rule.role = node.role;
} else if ((eventBlock[parentRole] || {}).speak !== undefined) {
rule.role = parentRole;
} else {
rule.role = 'default';
}
rule.output = 'speak';
if (this.formatOptions_.braille) {
// Overwrite rule by braille rule if exists.
if (node.role && (eventBlock[node.role] || {}).braille !== undefined) {
rule.role = node.role;
rule.output = 'braille';
} else if ((eventBlock[parentRole] || {}).braille !== undefined) {
rule.role = parentRole;
rule.output = 'braille';
}
}
ruleStr.writeRule(rule);
this.format_({
node,
outputFormat: eventBlock[rule.role][rule.output],
outputBuffer: buff,
outputRuleString: ruleStr,
opt_prevNode: prevNode,
});
// Restore braille and add an annotation for this node.
if (this.formatOptions_.braille) {
const nodeSpan = this.mergeBraille_(buff);
nodeSpan.setSpan(new OutputNodeSpan(node), 0, nodeSpan.length);
originalBuff.push(nodeSpan);
}
}
/**
* @param {!CursorRange} range
* @param {CursorRange} prevRange
* @param {EventType|OutputEventType} type
* @param {!Array<Spannable>} buff
* @private
*/
subNode_(range, prevRange, type, buff, ruleStr) {
if (!prevRange) {
prevRange = range;
}
const dir = CursorRange.getDirection(prevRange, range);
const node = range.start.node;
const prevNode = prevRange.getBound(dir).node;
if (!node || !prevNode) {
return;
}
const options = {annotation: ['name'], isUnique: true};
const rangeStart = range.start.index;
const rangeEnd = range.end.index;
if (this.formatOptions_.braille) {
options.annotation.push(new OutputNodeSpan(node));
const selStart = node.textSelStart;
const selEnd = node.textSelEnd;
if (selStart !== undefined && selEnd >= rangeStart &&
selStart <= rangeEnd) {
// Editable text selection.
// |rangeStart| and |rangeEnd| are indices set by the caller and are
// assumed to be inside of the range. In braille, we only ever expect
// to get ranges surrounding a line as anything smaller doesn't make
// sense.
// |selStart| and |selEnd| reflect the editable selection. The
// relative selStart and relative selEnd for the current line are then
// just the difference between |selStart|, |selEnd| with |rangeStart|.
// See editing_test.js for examples.
options.annotation.push(new OutputSelectionSpan(
selStart - rangeStart, selEnd - rangeStart));
} else if (
rangeStart !== 0 || rangeEnd !== range.start.getText().length) {
// Non-editable text selection over less than the full contents
// covered by the range. We exclude full content underlines because it
// is distracting to read braille with all cells underlined with a
// cursor.
options.annotation.push(new OutputSelectionSpan(rangeStart, rangeEnd));
}
}
// Intentionally skip subnode output for OutputContextOrder.DIRECTED.
if (this.contextOrder_ === OutputContextOrder.FIRST ||
(this.contextOrder_ === OutputContextOrder.FIRST_AND_LAST &&
range.start.index === 0)) {
this.ancestry_(node, prevNode, type, buff, ruleStr, {preferStart: true});
}
const earcon = this.findEarcon_(node, prevNode);
if (earcon) {
options.annotation.push(earcon);
}
let text = '';
if (this.formatOptions_.braille && !node.state[StateType.EDITABLE]) {
// In braille, we almost always want to show the entire contents and
// simply place the cursor under the SelectionSpan we set above.
text = range.start.getText();
} else {
// This is output for speech or editable braille.
text = range.start.getText().substring(rangeStart, rangeEnd);
}
if (localStorage['languageSwitching'] === 'true') {
this.assignLocaleAndAppend_(text, node, buff, options);
} else {
this.append_(buff, text, options);
}
ruleStr.write('subNode_: ' + text + '\n');
if (this.contextOrder_ === OutputContextOrder.LAST ||
(this.contextOrder_ === OutputContextOrder.FIRST_AND_LAST &&
range.end.index === range.end.getText().length)) {
this.ancestry_(node, prevNode, type, buff, ruleStr, {preferEnd: true});
}
range.start.node.boundsForRange(rangeStart, rangeEnd, loc => {
if (loc) {
this.locations_.push(loc);
}
});
}
/**
* Renders all hints for the given |range|.
*
* Add new hints to either method computeHints_ or computeDelayedHints_. Hints
* are outputted in order, so consider the relative priority of any new
* additions. Rendering processes these two methods in order. The only
* distinction is a small delay gets introduced before the first hint in
* |computeDelayedHints_|.
* @param {!CursorRange} range
* @param {!Array<AutomationNode>} uniqueAncestors
* @param {EventType|OutputEventType} type
* @param {!Array<Spannable>} buff Buffer to receive rendered output.
* @param {!OutputRulesStr} ruleStr
* @private
*/
hint_(range, uniqueAncestors, type, buff, ruleStr) {
if (!this.enableHints_ || localStorage['useVerboseMode'] !== 'true') {
return;
}
// No hints for alerts, which can be targeted at controls.
if (type === EventType.ALERT) {
return;
}
// Hints are not yet specialized for braille.
if (this.formatOptions_.braille) {
return;
}
const node = range.start.node;
if (node.restriction === chrome.automation.Restriction.DISABLED) {
return;
}
const msgs = Output.computeHints_(node, uniqueAncestors);
const delayedMsgs =
Output.computeDelayedHints_(node, uniqueAncestors, type);
if (delayedMsgs.length > 0) {
delayedMsgs[0].props = new OutputSpeechProperties();
delayedMsgs[0].props.properties['delay'] = true;
}
const allMsgs = msgs.concat(delayedMsgs);
for (const msg of allMsgs) {
if (msg.msgId) {
const text = Msgs.getMsg(msg.msgId);
this.append_(buff, text, {annotation: [msg.props]});
ruleStr.write('hint_: ' + text + '\n');
} else if (msg.text) {
this.append_(buff, msg.text, {annotation: [msg.props]});
ruleStr.write('hint_: ' + msg.text + '\n');
} else if (msg.outputFormat) {
ruleStr.write('hint_: ...');
this.format_({
node,
outputFormat: msg.outputFormat,
outputBuffer: buff,
outputRuleString: ruleStr,
opt_speechProps: msg.props,
});
} else {
throw new Error('Unexpected hint: ' + msg);
}
}
}
/**
* Internal helper to |hint_|. Returns a list of message hints.
* @param {!AutomationNode} node
* @param {!Array<AutomationNode>} uniqueAncestors
* @return {!Array<{text: (string|undefined),
* msgId: (string|undefined),
* outputFormat: (string|undefined)}>} Note that the above caller
* expects one and only one key be set.
* @private
*/
static computeHints_(node, uniqueAncestors) {
const ret = [];
if (node.errorMessage) {
ret.push({outputFormat: '$node(errorMessage)'});
}
// Provide a hint for sort direction.
let sortDirectionNode = node;
while (sortDirectionNode && sortDirectionNode !== sortDirectionNode.root) {
if (!sortDirectionNode.sortDirection) {
sortDirectionNode = sortDirectionNode.parent;
continue;
}
if (sortDirectionNode.sortDirection ===
chrome.automation.SortDirectionType.ASCENDING) {
ret.push({msgId: 'sort_ascending'});
} else if (
sortDirectionNode.sortDirection ===
chrome.automation.SortDirectionType.DESCENDING) {
ret.push({msgId: 'sort_descending'});
}
break;
}
let currentNode = node;
let ancestorIndex = 0;
do {
if (currentNode.ariaCurrentState &&
Output.ARIA_CURRENT_STATE_INFO_[currentNode.ariaCurrentState]) {
ret.push({
msgId: Output.ARIA_CURRENT_STATE_INFO_[currentNode.ariaCurrentState],
});
break;
}
currentNode = uniqueAncestors[ancestorIndex++];
} while (currentNode);
return ret;
}
/**
* Internal helper to |hint_|. Returns a list of message hints.
* @param {!AutomationNode} node
* @param {!Array<AutomationNode>} uniqueAncestors
* @param {EventType|OutputEventType} type
* @return {!Array<{text: (string|undefined),
* msgId: (string|undefined),
* outputFormat: (string|undefined)}>} Note that the above caller
* expects one and only one key be set.
* @private
*/
static computeDelayedHints_(node, uniqueAncestors, type) {
const ret = [];
if (EventSourceState.get() === EventSourceType.TOUCH_GESTURE) {
if (node.state[StateType.EDITABLE]) {
ret.push({
msgId: node.state[StateType.FOCUSED] ? 'hint_is_editing' :
'hint_double_tap_to_edit',
});
return ret;
}
const isWithinVirtualKeyboard = AutomationUtil.getAncestors(node).find(
n => n.role === RoleType.KEYBOARD);
if (AutomationPredicate.clickable(node) && !isWithinVirtualKeyboard) {
ret.push({msgId: 'hint_double_tap'});
}
const enteredVirtualKeyboard =
uniqueAncestors.find(n => n.role === RoleType.KEYBOARD);
if (enteredVirtualKeyboard) {
ret.push({msgId: 'hint_touch_type'});
}
return ret;
}
if (node.state[StateType.EDITABLE] && node.state[StateType.FOCUSED] &&
(node.state[StateType.MULTILINE] ||
node.state[StateType.RICHLY_EDITABLE])) {
ret.push({msgId: 'hint_search_within_text_field'});
}
if (node.placeholder) {
ret.push({text: node.placeholder});
}
// Invalid Grammar text.
if (uniqueAncestors.find(
/** @type {function(?) : boolean} */
(AutomationPredicate.hasInvalidGrammarMarker))) {
ret.push({msgId: 'hint_invalid_grammar'});
}
// Invalid Spelling text.
if (uniqueAncestors.find(
/** @type {function(?) : boolean} */
(AutomationPredicate.hasInvalidSpellingMarker))) {
ret.push({msgId: 'hint_invalid_spelling'});
}
// Only include tooltip as a hint as a last alternative. It may have been
// included as the name or description previously. As a rule of thumb,
// only include it if there's no name and no description.
if (node.tooltip && !node.name && !node.description) {
ret.push({text: node.tooltip});
}
if (AutomationPredicate.checkable(node)) {
ret.push({msgId: 'hint_checkable'});
} else if (AutomationPredicate.clickable(node)) {
ret.push({msgId: 'hint_clickable'});
}
if (node.autoComplete === 'list' || node.autoComplete === 'both' ||
node.state[StateType.AUTOFILL_AVAILABLE]) {
ret.push({msgId: 'hint_autocomplete_list'});
}
if (node.autoComplete === 'inline' || node.autoComplete === 'both') {
ret.push({msgId: 'hint_autocomplete_inline'});
}
if (node.customActions && node.customActions.length > 0) {
ret.push({msgId: 'hint_action'});
}
if (node.accessKey) {
ret.push({text: Msgs.getMsg('access_key', [node.accessKey])});
}
// Ancestry based hints.
/** @type {AutomationNode|undefined} */
let foundAncestor;
if (uniqueAncestors.find(
/** @type {function(?) : boolean} */ (AutomationPredicate.table))) {
ret.push({msgId: 'hint_table'});
}
// This hint is not based on the role (it uses state), so we need to care
// about ordering; prefer deepest ancestor first.
if ((foundAncestor = uniqueAncestors.reverse().find(
/** @type {function(?) : boolean} */ (AutomationPredicate.roles(
[RoleType.MENU, RoleType.MENU_BAR]))))) {
ret.push({
msgId: foundAncestor.state.horizontal ? 'hint_menu_horizontal' :
'hint_menu',
});
}
if (uniqueAncestors.find(
/** @type {function(?) : boolean} */ (function(n) {
return Boolean(n.details);
}))) {
ret.push({msgId: 'hint_details'});
}
return ret;
}
/**
* Appends output to the |buff|.
* @param {!Array<Spannable>} buff
* @param {string|!Spannable} value
* @param {{annotation: Array<*>, isUnique: (boolean|undefined)}=} opt_options
*/
append_(buff, value, opt_options) {
opt_options = opt_options || {isUnique: false, annotation: []};
// Reject empty values without meaningful annotations.
if ((!value || value.length === 0) &&
opt_options.annotation.every(function(a) {
return !(a instanceof OutputAction) &&
!(a instanceof OutputSelectionSpan);
})) {
return;
}
const spannableToAdd = new Spannable(value);
opt_options.annotation.forEach(function(a) {
spannableToAdd.setSpan(a, 0, spannableToAdd.length);
});
// |isUnique| specifies an annotation that cannot be duplicated.
if (opt_options.isUnique) {
const annotationSansNodes =
opt_options.annotation.filter(function(annotation) {
return !(annotation instanceof OutputNodeSpan);
});
const alreadyAnnotated = buff.some(function(s) {
return annotationSansNodes.some(function(annotation) {
if (!s.hasSpan(annotation)) {
return false;
}
const start = s.getSpanStart(annotation);
const end = s.getSpanEnd(annotation);
const substr = s.substring(start, end);
if (substr && value) {
return substr.toString() === value.toString();
} else {
return false;
}
});
});
if (alreadyAnnotated) {
return;
}
}
buff.push(spannableToAdd);
}
/**
* Converts the braille |spans| buffer to a single spannable.
* @param {!Array<Spannable>} spans
* @return {!Spannable}
* @private
*/
mergeBraille_(spans) {
let separator = ''; // Changes to space as appropriate.
let prevHasInlineNode = false;
let prevIsName = false;
return spans.reduce(function(result, cur) {
// Ignore empty spans except when they contain a selection.
const hasSelection = cur.getSpanInstanceOf(OutputSelectionSpan);
if (cur.length === 0 && !hasSelection) {
return result;
}
// For empty selections, we just add the space separator to account for
// showing the braille cursor.
if (cur.length === 0 && hasSelection) {
result.append(cur);
result.append(Output.SPACE);
separator = '';
return result;
}
// Keep track of if there's an inline node associated with
// |cur|.
const hasInlineNode =
cur.getSpansInstanceOf(OutputNodeSpan).some(function(s) {
if (!s.node) {
return false;
}
return s.node.display === 'inline' ||
s.node.role === RoleType.INLINE_TEXT_BOX;
});
const isName = cur.hasSpan('name');
// Now, decide whether we should include separators between the previous
// span and |cur|.
// Never separate chunks without something already there at this point.
// The only case where we know for certain that a separator is not
// needed is when the previous and current values are in-lined and part
// of the node's name. In all other cases, use the surrounding
// whitespace to ensure we only have one separator between the node
// text.
if (result.length === 0 ||
(hasInlineNode && prevHasInlineNode && isName && prevIsName)) {
separator = '';
} else if (
result.toString()[result.length - 1] === Output.SPACE ||
cur.toString()[0] === Output.SPACE) {
separator = '';
} else {
separator = Output.SPACE;
}
prevHasInlineNode = hasInlineNode;
prevIsName = isName;
result.append(separator);
result.append(cur);
return result;
}, new Spannable());
}
/**
* Find the earcon for a given node (including ancestry).
* @param {!AutomationNode} node
* @param {!AutomationNode=} opt_prevNode
* @return {OutputAction}
*/
findEarcon_(node, opt_prevNode) {
if (node === opt_prevNode) {
return null;
}
if (this.formatOptions_.speech) {
let earconFinder = node;
let ancestors;
if (opt_prevNode) {
ancestors = AutomationUtil.getUniqueAncestors(opt_prevNode, node);
} else {
ancestors = AutomationUtil.getAncestors(node);
}
while (earconFinder = ancestors.pop()) {
const info = OutputRoleInfo[earconFinder.role];
if (info && info.earconId) {
return new OutputEarconAction(
info.earconId, node.location || undefined);
break;
}
earconFinder = earconFinder.parent;
}
}
return null;
}
/**
* Gets a human friendly string with the contents of output.
* @return {string}
*/
toString() {
return this.speechBuffer_.reduce(function(prev, cur) {
if (prev === null || prev === '') {
return cur.toString();
}
prev += ' ' + cur.toString();
return prev;
}, null);
}
/**
* Gets the spoken output with separator '|'.
* @return {!Spannable}
*/
get speechOutputForTest() {
return this.speechBuffer_.reduce(function(prev, cur) {
if (prev === null) {
return cur;
}
prev.append('|');
prev.append(cur);
return prev;
}, null);
}
/**
* Gets the output buffer for braille.
* @return {!Spannable}
*/
get brailleOutputForTest() {
return this.mergeBraille_(this.brailleBuffer_);
}
/**
* @param {string} text
* @param {!AutomationNode} contextNode
* @param {!Array<Spannable>} buff
* @param {!{annotation: Array<*>, isUnique: (boolean|undefined)}} options
* @private
*/
assignLocaleAndAppend_(text, contextNode, buff, options) {
const data =
LocaleOutputHelper.instance.computeTextAndLocale(text, contextNode);
const speechProps = new OutputSpeechProperties();
speechProps.properties['lang'] = data.locale;
this.append_(buff, data.text, options);
// Attach associated SpeechProperties if the buffer is
// non-empty.
if (buff.length > 0) {
buff[buff.length - 1].setSpan(speechProps, 0, 0);
}
}
}
/**
* Delimiter to use between output values.
* @type {string}
*/
Output.SPACE = ' ';
/**
* Metadata about supported automation states.
* @const {!Object<string, {on: {msgId: string, earconId: string},
* off: {msgId: string, earconId: string},
* isRoleSpecific: (boolean|undefined)}>}
* on: info used to describe a state that is set to true.
* off: info used to describe a state that is set to undefined.
* isRoleSpecific: info used for specific roles.
* @private
*/
Output.STATE_INFO_ = {
collapsed: {on: {msgId: 'aria_expanded_false'}},
default: {on: {msgId: 'default_state'}},
expanded: {on: {msgId: 'aria_expanded_true'}},
multiselectable: {on: {msgId: 'aria_multiselectable_true'}},
required: {on: {msgId: 'aria_required_true'}},
visited: {on: {msgId: 'visited_state'}},
};
/**
* Maps aria-current state types to message IDs.
* @const {Object<string>}
* @private
*/
Output.ARIA_CURRENT_STATE_INFO_ = {
[AriaCurrentState.TRUE]: 'aria_current_true',
[AriaCurrentState.PAGE]: 'aria_current_page',
[AriaCurrentState.STEP]: 'aria_current_step',
[AriaCurrentState.LOCATION]: 'aria_current_location',
[AriaCurrentState.DATE]: 'aria_current_date',
[AriaCurrentState.TIME]: 'aria_current_time',
};
/**
* Maps input types to message IDs.
* @const {Object<string>}
* @private
*/
Output.INPUT_TYPE_MESSAGE_IDS_ = {
'email': 'input_type_email',
'number': 'input_type_number',
'password': 'input_type_password',
'search': 'input_type_search',
'tel': 'input_type_number',
'text': 'input_type_text',
'url': 'input_type_url',
};
/**
* Rules for mapping the restriction property to a msg id
* @const {Object<string>}
* @private
*/
Output.RESTRICTION_STATE_MAP = {};
Output.RESTRICTION_STATE_MAP[Restriction.DISABLED] = 'aria_disabled_true';
Output.RESTRICTION_STATE_MAP[Restriction.READ_ONLY] = 'aria_readonly_true';
/**
* Rules for mapping the checked property to a msg id
* @const {Object<string>}
* @private
*/
Output.CHECKED_STATE_MAP = {
'true': 'checked_true',
'false': 'checked_false',
'mixed': 'checked_mixed',
};
/**
* Rules for mapping the checked property to a msg id
* @const {Object<string>}
* @private
*/
Output.PRESSED_STATE_MAP = {
'true': 'aria_pressed_true',
'false': 'aria_pressed_false',
'mixed': 'aria_pressed_mixed',
};
/**
* Rules specifying format of AutomationNodes for output.
* @type {!Object<Object<Object<string>>>}
* Please see below for more information on properties.
* speak: The speech rule for when ChromeVox range lands exactly on the node.
* braille: The braille rule for when ChromeVox range lands exactly on the node.
* enter: The rule for when ChromeVox range enters the node's subtree.
* Can contain speak and braille properties.
* leave: The rule for when ChromeVox range exits the node's subtree.
* startOf: The rule applied for each ancestor diff of a range and its previous
* leaf range. endOf: The rule applied for each ancestor diff of a range and its
* next leaf range.
*/
Output.RULES = {
navigate: {
'default': {
speak: `$name $node(activeDescendant) $value $state $restriction $role
$description`,
braille: ``,
},
abstractContainer: {
startOf: `$nameFromNode $role $state $description`,
endOf: `@end_of_container($role)`,
},
abstractFormFieldContainer: {
enter: `$nameFromNode $role $state $description`,
leave: `@exited_container($role)`,
},
abstractItem: {
// Note that ChromeVox generally does not output position/count. Only for
// some roles (see sub-output rules) or when explicitly provided by an
// author (via posInSet), do we include them in the output.
enter: `$nameFromNode $role $state $restriction $description
$if($posInSet, @describe_index($posInSet, $setSize))`,
speak: `$state $nameOrTextContent= $role
$if($posInSet, @describe_index($posInSet, $setSize))
$description $restriction`,
},
abstractList: {
startOf: `$nameFromNode $role @@list_with_items($setSize)
$restriction $description`,
endOf: `@end_of_container($role) @@list_nested_level($listNestedLevel)`,
},
abstractNameFromContents: {
speak: `$nameOrDescendants $node(activeDescendant) $value $state
$restriction $role $description`,
},
abstractRange: {
speak: `$name $node(activeDescendant) $description $role
$if($value, $value, $if($valueForRange, $valueForRange))
$state $restriction
$if($minValueForRange, @aria_value_min($minValueForRange))
$if($maxValueForRange, @aria_value_max($maxValueForRange))`,
},
abstractSpan: {
startOf: `$nameFromNode $role $state $description`,
endOf: `@end_of_container($role)`,
},
alert: {
enter: `$name $role $state`,
speak: `$earcon(ALERT_NONMODAL) $role $nameOrTextContent $description
$state`,
},
alertDialog: {
enter: `$earcon(ALERT_MODAL) $name $state $description $roleDescription
$textContent`,
speak: `$earcon(ALERT_MODAL) $name $nameOrTextContent $description $state
$role`,
},
button: {
speak: `$name $node(activeDescendant) $state $restriction $role
$description`,
},
cell: {
enter: {
speak: `$cellIndexText $node(tableCellColumnHeaders) $nameFromNode
$roleDescription $state`,
braille: `$state $cellIndexText $node(tableCellColumnHeaders)
$nameFromNode $roleDescription`,
},
speak: `$nameFromNode $descendants $cellIndexText
$node(tableCellColumnHeaders) $roleDescription $state $description`,
braille: `$state
$name $cellIndexText $node(tableCellColumnHeaders) $roleDescription
$description
$if($selected, @aria_selected_true)`,
},
checkBox: {
speak: `$if($checked, $earcon(CHECK_ON), $earcon(CHECK_OFF))
$name $role $if($checkedStateDescription, $checkedStateDescription, $checked)
$description $state $restriction`,
},
client: {speak: `$name`},
comboBoxMenuButton: {
speak: `$name $value $role @aria_has_popup
$if($setSize, @@list_with_items($setSize))
$state $restriction $description`,
},
date: {enter: `$nameFromNode $role $state $restriction $description`},
dialog: {enter: `$nameFromNode $role $description`},
genericContainer: {
enter: `$nameFromNode $description $state`,
speak: `$nameOrTextContent $description $state`,
},
embeddedObject: {speak: `$name`},
grid: {
speak: `$name $node(activeDescendant) $role $state $restriction
$description`,
},
group: {
enter: `$nameFromNode $roleDescription $state $restriction $description`,
speak: `$nameOrDescendants $value $state $restriction $roleDescription
$description`,
leave: ``,
},
heading: {
enter: `!relativePitch(hierarchicalLevel)
$nameFromNode=
$if($hierarchicalLevel, @tag_h+$hierarchicalLevel, $role) $state
$description`,
speak: `!relativePitch(hierarchicalLevel)
$nameOrDescendants=
$if($hierarchicalLevel, @tag_h+$hierarchicalLevel, $role) $state
$restriction $description`,
},
image: {
speak: `$if($name, $name,
$if($imageAnnotation, $imageAnnotation, $urlFilename))
$value $state $role $description`,
},
imeCandidate:
{speak: '$name $phoneticReading @describe_index($posInSet, $setSize)'},
inlineTextBox: {speak: `$precedingBullet $name=`},
inputTime: {enter: `$nameFromNode $role $state $restriction $description`},
labelText: {
speak: `$name $value $state $restriction $roleDescription $description`,
},
lineBreak: {speak: `$name=`},
link: {
enter: `$nameFromNode= $role $state $restriction`,
speak: `$name $value $state $restriction
$if($inPageLinkTarget, @internal_link, $role) $description`,
},
list: {
speak: `$nameFromNode $descendants $role
@@list_with_items($setSize) $description $state`,
},
listBox: {
enter: `$nameFromNode $role @@list_with_items($setSize)
$restriction $description`,
},
listBoxOption: {
speak: `$state $name $role @describe_index($posInSet, $setSize)
$description $restriction
$nif($selected, @aria_selected_false)`,
braille: `$state $name $role @describe_index($posInSet, $setSize)
$description $restriction
$if($selected, @aria_selected_true, @aria_selected_false)`,
},
listMarker: {speak: `$name`},
menu: {
enter: `$name $role `,
speak: `$name $node(activeDescendant)
$role @@list_with_items($setSize) $description $state $restriction`,
},
menuItem: {
speak: `$name $role $if($hasPopup, @has_submenu)
@describe_index($posInSet, $setSize) $description $state $restriction`,
},
menuItemCheckBox: {
speak: `$if($checked, $earcon(CHECK_ON), $earcon(CHECK_OFF))
$name $role $checked $state $restriction $description
@describe_index($posInSet, $setSize)`,
},
menuItemRadio: {
speak: `$if($checked, $earcon(CHECK_ON), $earcon(CHECK_OFF))
$if($checked, @describe_menu_item_radio_selected($name),
@describe_menu_item_radio_unselected($name)) $state $roleDescription
$restriction $description
@describe_index($posInSet, $setSize)`,
},
menuListOption: {
speak: `$name $role @describe_index($posInSet, $setSize) $state
$nif($selected, @aria_selected_false)
$restriction $description`,
braille: `$name $role @describe_index($posInSet, $setSize) $state
$if($selected, @aria_selected_true, @aria_selected_false)
$restriction $description`,
},
paragraph: {speak: `$nameOrDescendants $roleDescription`},
radioButton: {
speak: `$if($checked, $earcon(CHECK_ON), $earcon(CHECK_OFF))
$if($checked, @describe_radio_selected($name),
@describe_radio_unselected($name))
@describe_index($posInSet, $setSize)
$roleDescription $description $state $restriction`,
},
rootWebArea: {enter: `$name`, speak: `$if($name, $name, @web_content)`},
region: {speak: `$state $nameOrTextContent $description $roleDescription`},
row: {
startOf: `$node(tableRowHeader) $roleDescription
$if($hierarchicalLevel, @describe_depth($hierarchicalLevel))`,
speak: ` $if($hierarchicalLevel, @describe_depth($hierarchicalLevel))
$name $node(activeDescendant) $value $state $restriction $role
$if($selected, @aria_selected_true) $description`,
},
staticText: {speak: `$precedingBullet $name= $description`},
switch: {
speak: `$if($checked, $earcon(CHECK_ON), $earcon(CHECK_OFF))
$if($checked, @describe_switch_on($name),
@describe_switch_off($name)) $roleDescription
$description $state $restriction`,
},
tab: {
speak: `@describe_tab($name) $roleDescription $description
@describe_index($posInSet, $setSize) $state $restriction
$if($selected, @aria_selected_true)`,
},
table: {
enter: `$roleDescription @table_summary($name,
$if($ariaRowCount, $ariaRowCount, $tableRowCount),
$if($ariaColumnCount, $ariaColumnCount, $tableColumnCount))
$node(tableHeader)`,
},
tabList: {
speak: `$name $node(activeDescendant) $state $restriction $role
$description`,
},
textField: {
speak: `$name $value
$if($roleDescription, $roleDescription,
$if($multiline, @tag_textarea,
$if($inputType, $inputType, $role)))
$description $state $restriction`,
},
timer: {
speak: `$nameFromNode $descendants $value $state $role
$description`,
},
toggleButton: {
speak: `$if($checked, $earcon(CHECK_ON), $earcon(CHECK_OFF))
$name $role $pressed $description $state $restriction`,
},
toolbar: {enter: `$name $role $description $restriction`},
tree: {enter: `$name $role @@list_with_items($setSize) $restriction`},
treeItem: {
enter: `$role $expanded $collapsed $restriction
@describe_index($posInSet, $setSize)
@describe_depth($hierarchicalLevel)`,
speak: `$name
$role $description $state $restriction
$nif($selected, @aria_selected_false)
@describe_index($posInSet, $setSize)
@describe_depth($hierarchicalLevel)`,
},
unknown: {speak: ``},
window: {
enter: `@describe_window($name) $description`,
speak: `@describe_window($name) $description $earcon(OBJECT_OPEN)`,
},
},
menuStart:
{'default': {speak: `@chrome_menu_opened($name) $earcon(OBJECT_OPEN)`}},
menuEnd: {'default': {speak: `@chrome_menu_closed $earcon(OBJECT_CLOSE)`}},
menuListValueChanged: {
'default': {
speak: `$value $name
$find({"state": {"selected": true, "invisible": false}},
@describe_index($posInSet, $setSize)) `,
},
},
alert: {
default: {speak: `$earcon(ALERT_NONMODAL) $nameOrTextContent $description`},
},
};
/**
* If set, the next speech utterance will use this value instead of the normal
* queueing mode.
* @type {QueueMode|undefined}
* @private
*/
Output.forceModeForNextSpeechUtterance_;