1 // Copyright 2014 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
6 * @fileoverview Provides output services for ChromeVox.
9 goog.provide('Output');
10 goog.provide('Output.EventType');
12 goog.require('AutomationUtil.Dir');
13 goog.require('cursors.Cursor');
14 goog.require('cursors.Range');
15 goog.require('cursors.Unit');
16 goog.require('cvox.AbstractEarcons');
17 goog.require('cvox.NavBraille');
18 goog.require('cvox.Spannable');
19 goog.require('cvox.ValueSelectionSpan');
20 goog.require('cvox.ValueSpan');
21 goog.require('goog.i18n.MessageFormat');
23 goog.scope(function() {
24 var Dir = AutomationUtil.Dir;
27 * An Output object formats a cursors.Range into speech, braille, or both
28 * representations. This is typically a cvox.Spannable.
30 * The translation from Range to these output representations rely upon format
31 * rules which specify how to convert AutomationNode objects into annotated
33 * The format of these rules is as follows.
35 * $ prefix: used to substitute either an attribute or a specialized value from
36 * an AutomationNode. Specialized values include role and state.
37 * For example, $value $role $enabled
38 * @ prefix: used to substitute a message. Note the ability to specify params to
39 * the message. For example, '@tag_html' '@selected_index($text_sel_start,
41 * @@ prefix: similar to @, used to substitute a message, but also pulls the
42 * localized string through goog.i18n.MessageFormat to support locale
43 * aware plural handling. The first argument should be a number which will
44 * be passed as a COUNT named parameter to MessageFormat.
45 * TODO(plundblad): Make subsequent arguments normal placeholder arguments
47 * = suffix: used to specify substitution only if not previously appended.
48 * For example, $name= would insert the name attribute only if no name
49 * attribute had been inserted previously.
53 // TODO(dtseng): Include braille specific rules.
54 /** @type {!Array<!cvox.Spannable>} */
55 this.speechBuffer_ = [];
56 /** @type {!Array<!cvox.Spannable>} */
57 this.brailleBuffer_ = [];
58 /** @type {!Array<!Object>} */
60 /** @type {function(?)} */
61 this.speechEndCallback_;
64 * Current global options.
65 * @type {{speech: boolean, braille: boolean, location: boolean}}
67 this.formatOptions_ = {speech: true, braille: false, location: true};
70 * Speech properties to apply to the entire output.
73 this.speechProperties_ = {};
77 * Delimiter to use between output values.
83 * Metadata about supported automation roles.
84 * @const {Object<{msgId: string,
85 * earconId: (string|undefined),
86 * inherits: (string|undefined)}>}
87 * msgId: the message id of the role.
88 * earconId: an optional earcon to play when encountering the role.
89 * inherits: inherits rules from this role.
94 msgId: 'aria_role_alert',
95 earconId: 'ALERT_NONMODAL',
98 msgId: 'aria_role_alertdialog'
101 msgId: 'aria_role_article',
102 inherits: 'abstractContainer'
105 msgId: 'aria_role_application',
106 inherits: 'abstractContainer'
109 msgId: 'aria_role_banner',
110 inherits: 'abstractContainer'
121 msgId: 'aria_role_gridcell'
124 msgId: 'input_type_checkbox'
127 msgId: 'aria_role_columnheader',
128 inherits: 'abstractContainer'
131 msgId: 'aria_role_combobox'
134 msgId: 'aria_role_complementary',
135 inherits: 'abstractContainer'
138 msgId: 'aria_role_contentinfo',
139 inherits: 'abstractContainer'
142 msgId: 'input_type_date',
143 inherits: 'abstractContainer'
146 msgId: 'aria_role_definition',
147 inherits: 'abstractContainer'
153 msgId: 'aria_role_directory',
154 inherits: 'abstractContainer'
157 msgId: 'aria_role_document',
158 inherits: 'abstractContainer'
161 msgId: 'aria_role_form',
162 inherits: 'abstractContainer'
165 msgId: 'aria_role_grid'
168 msgId: 'aria_role_group'
171 msgId: 'aria_role_heading',
174 msgId: 'aria_role_img',
177 msgId: 'input_type_time',
178 inherits: 'abstractContainer'
185 msgId: 'aria_role_listbox',
189 msgId: 'aria_role_listitem',
190 earconId: 'LIST_ITEM'
193 msgId: 'aria_role_listitem',
194 earconId: 'LIST_ITEM'
197 msgId: 'aria_role_log',
200 msgId: 'aria_role_main',
201 inherits: 'abstractContainer'
204 msgId: 'aria_role_marquee',
207 msgId: 'aria_role_math',
208 inherits: 'abstractContainer'
211 msgId: 'aria_role_menu'
214 msgId: 'aria_role_menubar',
217 msgId: 'aria_role_menuitem'
220 msgId: 'aria_role_menuitemcheckbox'
223 msgId: 'aria_role_menuitemradio'
226 msgId: 'aria_role_menuitem'
229 msgId: 'aria_role_menu'
232 msgId: 'aria_role_navigation',
233 inherits: 'abstractContainer'
236 msgId: 'aria_role_note',
237 inherits: 'abstractContainer'
244 msgId: 'input_type_radio'
247 msgId: 'aria_role_radiogroup',
250 msgId: 'aria_role_region',
251 inherits: 'abstractContainer'
254 msgId: 'aria_role_rowheader',
255 inherits: 'abstractContainer'
258 msgId: 'aria_role_scrollbar',
261 msgId: 'aria_role_search',
262 inherits: 'abstractContainer'
265 msgId: 'aria_role_separator',
266 inherits: 'abstractContainer'
269 msgId: 'aria_role_spinbutton',
273 msgId: 'aria_role_status'
276 msgId: 'aria_role_tab'
279 msgId: 'aria_role_tablist'
282 msgId: 'aria_role_tabpanel'
285 msgId: 'input_type_text',
286 earconId: 'EDITABLE_TEXT'
289 msgId: 'input_type_text',
290 earconId: 'EDITABLE_TEXT'
294 inherits: 'abstractContainer'
297 msgId: 'aria_role_timer'
300 msgId: 'aria_role_toolbar'
303 msgId: 'aria_role_tree'
306 msgId: 'aria_role_treeitem'
311 * Metadata about supported automation states.
312 * @const {!Object<{on: {msgId: string, earconId: string},
313 * off: {msgId: string, earconId: string},
314 * omitted: {msgId: string, earconId: string}}>}
315 * on: info used to describe a state that is set to true.
316 * off: info used to describe a state that is set to false.
317 * omitted: info used to describe a state that is undefined.
320 Output.STATE_INFO_ = {
323 earconId: 'CHECK_ON',
324 msgId: 'checkbox_checked_state'
327 earconId: 'CHECK_OFF',
328 msgId: 'checkbox_unchecked_state'
331 earconId: 'CHECK_OFF',
332 msgId: 'checkbox_unchecked_state'
337 msgId: 'aria_expanded_false'
340 msgId: 'aria_expanded_true'
345 msgId: 'aria_expanded_true'
348 msgId: 'aria_expanded_false'
353 msgId: 'visited_state'
359 * Rules specifying format of AutomationNodes for output.
360 * @type {!Object<Object<Object<string>>>}
365 speak: '$name $value $help $role',
369 enter: '$name $role',
370 leave: '@exited_container($role)'
373 speak: '!doNotInterrupt $role $descendants'
376 enter: '$name $role $descendants'
379 enter: '@column_granularity $tableCellColumnIndex'
382 speak: '$name $role $checked'
391 enter: '@tag_h+$hierarchicalLevel',
392 speak: '@tag_h+$hierarchicalLevel $nameOrDescendants='
398 enter: '$name $if($visited, @visited_link, $role)',
399 stay: '$name= $if($visited, @visited_link, $role)',
400 speak: '$name= $if($visited, @visited_link, $role)'
403 enter: '$role @@list_with_items($countChildren(listItem))'
406 enter: '$name $role @@list_with_items($countChildren(listBoxOption))'
409 speak: '$name $role @describe_index($indexInParent, $parentChildCount)'
415 enter: '$name $role @@list_with_items($countChildren(menuItem))'
418 speak: '$name $role $if($haspopup, @has_submenu) ' +
419 '@describe_index($indexInParent, $parentChildCount)'
422 speak: '$name $value @aria_role_menuitem ' +
423 '@describe_index($indexInParent, $parentChildCount)'
426 speak: '$descendants'
429 speak: '$value $name $role @aria_has_popup ' +
430 '$if($collapsed, @aria_expanded_false, @aria_expanded_true)'
433 speak: '$if($checked, @describe_radio_selected($name), ' +
434 '@describe_radio_unselected($name))'
443 enter: '@row_granularity $tableRowIndex'
446 speak: '@describe_slider($value, $name) $help'
449 speak: '$value $name'
452 speak: '@describe_tab($name)'
455 speak: '$name $value $if(' +
456 '$inputType, @input_type_+$inputType, @input_type_text)',
463 enter: '$name $role @@list_with_items($countChildren(treeItem))'
466 enter: '$role $expanded $collapsed ' +
467 '@describe_index($indexInParent, $parentChildCount) ' +
468 '@describe_depth($hierarchicalLevel)'
472 speak: '@describe_window($name) $earcon(OBJECT_OPEN)'
477 speak: '@chrome_menu_opened($name) $earcon(OBJECT_OPEN)'
482 speak: '@chrome_menu_closed $earcon(OBJECT_CLOSE)'
485 menuListValueChanged: {
487 speak: '$value $name ' +
488 '$find({"state": {"selected": true, "invisible": false}}, ' +
489 '@describe_index($indexInParent, $parentChildCount)) '
494 speak: '!doNotInterrupt ' +
495 '@aria_role_alert $name $earcon(ALERT_NONMODAL) $descendants'
501 * Custom actions performed while rendering an output string.
504 Output.Action = function() {
507 Output.Action.prototype = {
513 * Action to play an earcon.
514 * @param {string} earconId
516 * @extends {Output.Action}
518 Output.EarconAction = function(earconId) {
519 Output.Action.call(this);
520 /** @type {string} */
521 this.earconId = earconId;
524 Output.EarconAction.prototype = {
525 __proto__: Output.Action.prototype,
529 cvox.ChromeVox.earcons.playEarcon(cvox.Earcon[this.earconId]);
534 * Annotation for selection.
535 * @param {number} startIndex
536 * @param {number} endIndex
539 Output.SelectionSpan = function(startIndex, endIndex) {
540 // TODO(dtseng): Direction lost below; should preserve for braille panning.
541 this.startIndex = startIndex < endIndex ? startIndex : endIndex;
542 this.endIndex = endIndex > startIndex ? endIndex : startIndex;
546 * Wrapper for automation nodes as annotations. Since the
547 * {@code chrome.automation.AutomationNode} constructor isn't exposed in
548 * the API, this class is used to allow isntanceof checks on these
550 @ @param {chrome.automation.AutomationNode} node
553 Output.NodeSpan = function(node) {
558 * Possible events handled by ChromeVox internally.
567 * Gets the spoken output with separator '|'.
568 * @return {!cvox.Spannable}
570 get speechOutputForTest() {
571 return this.speechBuffer_.reduce(function(prev, cur) {
581 * Gets the output buffer for braille.
582 * @return {!cvox.Spannable}
584 get brailleOutputForTest() {
585 return this.createBrailleOutput_();
589 * Specify ranges for speech.
590 * @param {!cursors.Range} range
591 * @param {cursors.Range} prevRange
592 * @param {chrome.automation.EventType|Output.EventType} type
595 withSpeech: function(range, prevRange, type) {
596 this.formatOptions_ = {speech: true, braille: false, location: true};
597 this.render_(range, prevRange, type, this.speechBuffer_);
602 * Specify ranges for braille.
603 * @param {!cursors.Range} range
604 * @param {cursors.Range} prevRange
605 * @param {chrome.automation.EventType|Output.EventType} type
608 withBraille: function(range, prevRange, type) {
609 this.formatOptions_ = {speech: false, braille: true, location: false};
610 this.render_(range, prevRange, type, this.brailleBuffer_);
615 * Specify the same ranges for speech and braille.
616 * @param {!cursors.Range} range
617 * @param {cursors.Range} prevRange
618 * @param {chrome.automation.EventType|Output.EventType} type
621 withSpeechAndBraille: function(range, prevRange, type) {
622 this.withSpeech(range, prevRange, type);
623 this.withBraille(range, prevRange, type);
628 * Apply a format string directly to the output buffer. This lets you
629 * output a message directly to the buffer using the format syntax.
630 * @param {string} formatStr
633 format: function(formatStr) {
634 this.formatOptions_ = {speech: true, braille: false, location: true};
635 this.format_(null, formatStr, this.speechBuffer_);
637 this.formatOptions_ = {speech: false, braille: true, location: false};
638 this.format_(null, formatStr, this.brailleBuffer_);
644 * Triggers callback for a speech event.
645 * @param {function()} callback
647 onSpeechEnd: function(callback) {
648 this.speechEndCallback_ = function(opt_cleanupOnly) {
649 if (!opt_cleanupOnly)
656 * Executes all specified output.
660 var queueMode = cvox.QueueMode.FLUSH;
661 this.speechBuffer_.forEach(function(buff, i, a) {
662 if (buff.toString()) {
664 var scopedBuff = buff;
665 this.speechProperties_['startCallback'] = function() {
666 var actions = scopedBuff.getSpansInstanceOf(Output.Action);
668 actions.forEach(function(a) {
675 if (this.speechEndCallback_ && i == a.length - 1)
676 this.speechProperties_['endCallback'] = this.speechEndCallback_;
678 this.speechProperties_['endCallback'] = null;
679 cvox.ChromeVox.tts.speak(
680 buff.toString(), queueMode, this.speechProperties_);
681 queueMode = cvox.QueueMode.QUEUE;
686 if (this.brailleBuffer_.length) {
687 var buff = this.createBrailleOutput_();
689 buff.getSpanInstanceOf(Output.SelectionSpan);
690 var startIndex = -1, endIndex = -1;
692 // Casts ok, since the span is known to be in the spannable.
694 /** @type {number} */ (buff.getSpanStart(selSpan));
696 /** @type {number} */ (buff.getSpanEnd(selSpan));
697 startIndex = valueStart + selSpan.startIndex;
698 endIndex = valueStart + selSpan.endIndex;
699 buff.setSpan(new cvox.ValueSpan(0), valueStart, valueEnd);
700 buff.setSpan(new cvox.ValueSelectionSpan(), startIndex, endIndex);
703 var output = new cvox.NavBraille({
705 startIndex: startIndex,
709 cvox.ChromeVox.braille.write(output);
713 chrome.accessibilityPrivate.setFocusRing(this.locations_);
717 * Renders the given range using optional context previous range and event
719 * @param {!cursors.Range} range
720 * @param {cursors.Range} prevRange
721 * @param {chrome.automation.EventType|string} type
722 * @param {!Array<cvox.Spannable>} buff Buffer to receive rendered output.
725 render_: function(range, prevRange, type, buff) {
726 if (range.isSubNode())
727 this.subNode_(range, prevRange, type, buff);
729 this.range_(range, prevRange, type, buff);
733 * Format the node given the format specifier.
734 * @param {chrome.automation.AutomationNode} node
735 * @param {string|!Object} format The output format either specified as an
736 * output template string or a parsed output format tree.
737 * @param {!Array<cvox.Spannable>} buff Buffer to receive rendered output.
738 * @param {!Object=} opt_exclude A set of attributes to exclude.
741 format_: function(node, format, buff, opt_exclude) {
742 opt_exclude = opt_exclude || {};
746 // Hacky way to support args.
747 if (typeof(format) == 'string') {
748 format = format.replace(/([,:])\W/g, '$1');
749 tokens = format.split(' ');
754 tokens.forEach(function(token) {
755 // Ignore empty tokens.
761 if (typeof(token) == 'string')
762 tree = this.createParseTree_(token);
766 // Obtain the operator token.
769 // Set suffix options.
771 options.annotation = [];
772 options.isUnique = token[token.length - 1] == '=';
773 if (options.isUnique)
774 token = token.substring(0, token.length - 1);
776 // Annotate braille output with the corresponding automation nodes
777 // to support acting on nodes based on location in the output.
778 if (this.formatOptions_.braille)
779 options.annotation.push(new Output.NodeSpan(node));
781 // Process token based on prefix.
782 var prefix = token[0];
783 token = token.slice(1);
785 if (opt_exclude[token])
788 // All possible tokens based on prefix.
790 if (token == 'value') {
791 var text = node.value;
792 if (text !== undefined) {
793 if (node.textSelStart !== undefined) {
794 options.annotation.push(new Output.SelectionSpan(
799 // Annotate this as a name so we don't duplicate names from ancestors.
800 if (node.role == chrome.automation.RoleType.inlineTextBox)
802 options.annotation.push(token);
803 this.append_(buff, text, options);
804 } else if (token == 'name') {
805 options.annotation.push(token);
806 if (this.formatOptions_.speech) {
807 var earconFinder = node;
808 while (earconFinder) {
809 var info = Output.ROLE_INFO_[earconFinder.role];
810 if (info && info.earconId) {
811 options.annotation.push(
812 new Output.EarconAction(info.earconId));
815 earconFinder = earconFinder.parent;
819 // Pending finalization of name calculation; we must use the
820 // description property to access aria-label. See crbug.com/473220.
821 var resolvedName = node.description || node.name;
822 this.append_(buff, resolvedName, options);
823 } else if (token == 'nameOrDescendants') {
824 options.annotation.push(token);
826 this.append_(buff, node.name, options);
828 this.format_(node, '$descendants', buff);
829 } else if (token == 'indexInParent') {
830 options.annotation.push(token);
831 this.append_(buff, String(node.indexInParent + 1));
832 } else if (token == 'parentChildCount') {
833 options.annotation.push(token);
835 this.append_(buff, String(node.parent.children.length));
836 } else if (token == 'state') {
837 options.annotation.push(token);
838 Object.getOwnPropertyNames(node.state).forEach(function(s) {
839 this.append_(buff, s, options);
841 } else if (token == 'find') {
842 // Find takes two arguments: JSON query string and format string.
843 if (tree.firstChild) {
844 var jsonQuery = tree.firstChild.value;
846 /** @type {Object}*/(JSON.parse(jsonQuery)));
847 var formatString = tree.firstChild.nextSibling;
849 this.format_(node, formatString, buff);
851 } else if (token == 'descendants') {
852 if (AutomationPredicate.leaf(node))
855 // Construct a range to the leftmost and rightmost leaves.
856 var leftmost = AutomationUtil.findNodePre(
857 node, Dir.FORWARD, AutomationPredicate.leaf);
858 var rightmost = AutomationUtil.findNodePre(
859 node, Dir.BACKWARD, AutomationPredicate.leaf);
860 if (!leftmost || !rightmost)
863 var subrange = new cursors.Range(
864 new cursors.Cursor(leftmost, 0),
865 new cursors.Cursor(rightmost, 0));
868 prev = cursors.Range.fromNode(node);
869 this.range_(subrange, prev, 'navigate', buff);
870 } else if (token == 'role') {
871 options.annotation.push(token);
873 var info = Output.ROLE_INFO_[node.role];
875 if (this.formatOptions_.braille)
876 msg = cvox.ChromeVox.msgs.getMsg(info.msgId + '_brl');
878 msg = cvox.ChromeVox.msgs.getMsg(info.msgId);
880 console.error('Missing role info for ' + node.role);
882 this.append_(buff, msg, options);
883 } else if (token == 'tableRowIndex' ||
884 token == 'tableCellColumnIndex') {
885 var value = node[token];
888 value = String(value + 1);
889 options.annotation.push(token);
890 this.append_(buff, value, options);
891 } else if (node[token] !== undefined) {
892 options.annotation.push(token);
893 var value = node[token];
894 if (typeof value == 'number')
895 value = String(value);
896 this.append_(buff, value, options);
897 } else if (Output.STATE_INFO_[token]) {
898 options.annotation.push('state');
899 var stateInfo = Output.STATE_INFO_[token];
900 var resolvedInfo = {};
901 if (node.state[token] === undefined)
902 resolvedInfo = stateInfo.omitted;
904 resolvedInfo = node.state[token] ? stateInfo.on : stateInfo.off;
907 if (this.formatOptions_.speech && resolvedInfo.earconId) {
908 options.annotation.push(
909 new Output.EarconAction(resolvedInfo.earconId));
912 this.formatOptions_.braille ? resolvedInfo.msgId + '_brl' :
914 var msg = cvox.ChromeVox.msgs.getMsg(msgId);
915 this.append_(buff, msg, options);
916 } else if (tree.firstChild) {
919 var cond = tree.firstChild;
920 var attrib = cond.value.slice(1);
921 if (node[attrib] || node.state[attrib])
922 this.format_(node, cond.nextSibling, buff);
924 this.format_(node, cond.nextSibling.nextSibling, buff);
925 } else if (token == 'earcon') {
926 // Ignore unless we're generating speech output.
927 if (!this.formatOptions_.speech)
929 // Assumes there's existing output in our buffer.
930 var lastBuff = buff[buff.length - 1];
935 new Output.EarconAction(tree.firstChild.value), 0, 0);
936 } else if (token == 'countChildren') {
937 var role = tree.firstChild.value;
938 var count = node.children.filter(function(e) {
939 return e.role == role;
941 this.append_(buff, String(count));
944 } else if (prefix == '@') {
945 var isPluralized = (token[0] == '@');
947 token = token.slice(1);
948 // Tokens can have substitutions.
949 var pieces = token.split('+');
950 token = pieces.reduce(function(prev, cur) {
953 lookup = node[cur.slice(1)];
954 return prev + lookup;
959 var curArg = tree.firstChild;
961 if (curArg.value[0] != '$') {
962 console.error('Unexpected value: ' + curArg.value);
966 this.format_(node, curArg, msgBuff);
967 msgArgs = msgArgs.concat(msgBuff);
968 curArg = curArg.nextSibling;
971 var msg = cvox.ChromeVox.msgs.getMsg(msgId, msgArgs);
973 if (this.formatOptions_.braille)
974 msg = cvox.ChromeVox.msgs.getMsg(msgId + '_brl', msgArgs) || msg;
978 console.error('Could not get message ' + msgId);
983 var arg = tree.firstChild;
984 if (!arg || arg.nextSibling) {
985 console.error('Pluralized messages take exactly one argument');
988 if (arg.value[0] != '$') {
989 console.error('Unexpected value: ' + arg.value);
993 this.format_(node, arg, argBuff);
994 var namedArgs = {COUNT: Number(argBuff[0])};
995 msg = new goog.i18n.MessageFormat(msg).format(namedArgs);
998 this.append_(buff, msg, options);
999 } else if (prefix == '!') {
1000 this.speechProperties_[token] = true;
1006 * @param {!cursors.Range} range
1007 * @param {cursors.Range} prevRange
1008 * @param {chrome.automation.EventType|string} type
1009 * @param {!Array<cvox.Spannable>} rangeBuff
1012 range_: function(range, prevRange, type, rangeBuff) {
1014 prevRange = cursors.Range.fromNode(range.start.node.root);
1016 var cursor = range.start;
1017 var prevNode = prevRange.start.node;
1019 var formatNodeAndAncestors = function(node, prevNode) {
1021 this.ancestry_(node, prevNode, type, buff);
1022 this.node_(node, prevNode, type, buff);
1023 if (this.formatOptions_.location)
1024 this.locations_.push(node.location);
1028 while (cursor.node != range.end.node) {
1029 var node = cursor.node;
1030 rangeBuff.push.apply(rangeBuff, formatNodeAndAncestors(node, prevNode));
1032 cursor = cursor.move(cursors.Unit.NODE,
1033 cursors.Movement.DIRECTIONAL,
1036 // Reached a boundary.
1037 if (cursor.node == prevNode)
1040 var lastNode = range.end.node;
1041 rangeBuff.push.apply(rangeBuff, formatNodeAndAncestors(lastNode, prevNode));
1045 * @param {!chrome.automation.AutomationNode} node
1046 * @param {!chrome.automation.AutomationNode} prevNode
1047 * @param {chrome.automation.EventType|string} type
1048 * @param {!Array<cvox.Spannable>} buff
1049 * @param {!Object=} opt_exclude A list of attributes to exclude from
1053 ancestry_: function(node, prevNode, type, buff, opt_exclude) {
1054 opt_exclude = opt_exclude || {};
1055 var prevUniqueAncestors =
1056 AutomationUtil.getUniqueAncestors(node, prevNode);
1057 var uniqueAncestors = AutomationUtil.getUniqueAncestors(prevNode, node);
1059 // First, look up the event type's format block.
1060 // Navigate is the default event.
1061 var eventBlock = Output.RULES[type] || Output.RULES['navigate'];
1063 var getMergedRoleBlock = function(role) {
1064 var parentRole = (Output.ROLE_INFO_[role] || {}).inherits;
1065 var roleBlock = eventBlock[role] || eventBlock['default'];
1066 var parentRoleBlock = parentRole ? eventBlock[parentRole] : {};
1067 var mergedRoleBlock = {};
1068 for (var key in parentRoleBlock)
1069 mergedRoleBlock[key] = parentRoleBlock[key];
1070 for (var key in roleBlock)
1071 mergedRoleBlock[key] = roleBlock[key];
1072 return mergedRoleBlock;
1075 for (var i = 0, formatPrevNode;
1076 (formatPrevNode = prevUniqueAncestors[i]);
1078 var roleBlock = getMergedRoleBlock(formatPrevNode.role);
1079 if (roleBlock.leave)
1080 this.format_(formatPrevNode, roleBlock.leave, buff, opt_exclude);
1083 var enterOutputs = [];
1085 for (var j = uniqueAncestors.length - 2, formatNode;
1086 (formatNode = uniqueAncestors[j]);
1088 var roleBlock = getMergedRoleBlock(formatNode.role);
1089 if (roleBlock.enter) {
1090 if (enterRole[formatNode.role])
1092 enterRole[formatNode.role] = true;
1094 this.format_(formatNode, roleBlock.enter, tempBuff, opt_exclude);
1095 enterOutputs.unshift(tempBuff);
1097 if (formatNode.role == 'window')
1100 enterOutputs.forEach(function(b) {
1101 buff.push.apply(buff, b);
1104 if (!opt_exclude.stay) {
1105 var commonFormatNode = uniqueAncestors[0];
1106 while (commonFormatNode && commonFormatNode.parent) {
1107 commonFormatNode = commonFormatNode.parent;
1109 eventBlock[commonFormatNode.role] || eventBlock['default'];
1111 this.format_(commonFormatNode, roleBlock.stay, buff, opt_exclude);
1117 * @param {!chrome.automation.AutomationNode} node
1118 * @param {!chrome.automation.AutomationNode} prevNode
1119 * @param {chrome.automation.EventType|string} type
1120 * @param {!Array<cvox.Spannable>} buff
1123 node_: function(node, prevNode, type, buff) {
1124 // Navigate is the default event.
1125 var eventBlock = Output.RULES[type] || Output.RULES['navigate'];
1126 var roleBlock = eventBlock[node.role] || eventBlock['default'];
1127 var speakFormat = roleBlock.speak || eventBlock['default'].speak;
1128 this.format_(node, speakFormat, buff);
1132 * @param {!cursors.Range} range
1133 * @param {cursors.Range} prevRange
1134 * @param {chrome.automation.EventType|string} type
1135 * @param {!Array<cvox.Spannable>} buff
1138 subNode_: function(range, prevRange, type, buff) {
1141 var dir = cursors.Range.getDirection(prevRange, range);
1142 var prevNode = prevRange.getBound(dir).node;
1144 range.start.node, prevNode, type, buff,
1145 {stay: true, name: true, value: true});
1146 var startIndex = range.start.getIndex();
1147 var endIndex = range.end.getIndex();
1148 if (startIndex === endIndex)
1151 buff, range.start.getText().substring(startIndex, endIndex));
1155 * Appends output to the |buff|.
1156 * @param {!Array<cvox.Spannable>} buff
1157 * @param {string|!cvox.Spannable} value
1158 * @param {{isUnique: (boolean|undefined),
1159 * annotation: !Array<*>}=} opt_options
1161 append_: function(buff, value, opt_options) {
1162 opt_options = opt_options || {isUnique: false, annotation: []};
1164 // Reject empty values without annotations.
1165 if ((!value || value.length == 0) && opt_options.annotation.length == 0)
1168 var spannableToAdd = new cvox.Spannable(value);
1169 opt_options.annotation.forEach(function(a) {
1170 spannableToAdd.setSpan(a, 0, spannableToAdd.getLength());
1173 // |isUnique| specifies an annotation that cannot be duplicated.
1174 if (opt_options.isUnique) {
1175 var annotationSansNodes = opt_options.annotation.filter(
1176 function(annotation) {
1177 return !(annotation instanceof Output.NodeSpan);
1179 var alreadyAnnotated = buff.some(function(s) {
1180 return annotationSansNodes.some(function(annotation) {
1181 var start = s.getSpanStart(annotation);
1182 var end = s.getSpanEnd(annotation);
1183 if (start === undefined)
1185 return s.substring(start, end).toString() == value.toString();
1188 if (alreadyAnnotated)
1192 buff.push(spannableToAdd);
1196 * Parses the token containing a custom function and returns a tree.
1197 * @param {string} inputStr
1201 createParseTree_: function(inputStr) {
1202 var root = {value: ''};
1203 var currentNode = root;
1205 var braceNesting = 0;
1206 while (index < inputStr.length) {
1207 if (inputStr[index] == '(') {
1208 currentNode.firstChild = {value: ''};
1209 currentNode.firstChild.parent = currentNode;
1210 currentNode = currentNode.firstChild;
1211 } else if (inputStr[index] == ')') {
1212 currentNode = currentNode.parent;
1213 } else if (inputStr[index] == '{') {
1215 currentNode.value += inputStr[index];
1216 } else if (inputStr[index] == '}') {
1218 currentNode.value += inputStr[index];
1219 } else if (inputStr[index] == ',' && braceNesting === 0) {
1220 currentNode.nextSibling = {value: ''};
1221 currentNode.nextSibling.parent = currentNode.parent;
1222 currentNode = currentNode.nextSibling;
1224 currentNode.value += inputStr[index];
1229 if (currentNode != root)
1230 throw 'Unbalanced parenthesis.';
1236 * Converts the currently rendered braille buffers to a single spannable.
1237 * @return {!cvox.Spannable}
1240 createBrailleOutput_: function() {
1241 var result = new cvox.Spannable();
1242 var separator = ''; // Changes to space as appropriate.
1243 this.brailleBuffer_.forEach(function(cur) {
1244 // If this chunk is empty, don't add it since it won't result
1245 // in any output on the braille display, but node spans would
1246 // start before the separator in that case, which is not desired.
1247 // The exception is if this chunk contains a selectionm, in which
1248 // case it will result in a cursor which has to be preserved.
1249 // In this case, having separators, potentially both before and after
1250 // the empty string is correct.
1251 if (cur.getLength() == 0 && !cur.getSpanInstanceOf(Output.SelectionSpan))
1253 var spansToExtend = [];
1254 var spansToRemove = [];
1255 // Nodes that have node spans both on the character to the left
1256 // of the separator and to the right should also cover the separator.
1257 // We extend the left span to cover both the separator and what the
1258 // right span used to cover, removing the right span, mostly for
1259 // ease of writing tests and debug.
1260 // Note that getSpan(position) never returns zero length spans
1261 // (because they don't cover any position). Still, we want to include
1262 // these because they can be included (the selection span in an empty
1263 // text field is an example), which is why we write the below code
1264 // using getSpansInstanceOf and check the endpoints (isntead of doing
1266 result.getSpansInstanceOf(Output.NodeSpan).forEach(function(leftSpan) {
1267 if (result.getSpanEnd(leftSpan) < result.getLength())
1269 var newEnd = result.getLength();
1270 cur.getSpansInstanceOf(Output.NodeSpan).forEach(function(rightSpan) {
1271 if (cur.getSpanStart(rightSpan) == 0 &&
1272 leftSpan.node === rightSpan.node) {
1275 result.getLength() + separator.length +
1276 cur.getSpanEnd(rightSpan));
1277 spansToRemove.push(rightSpan);
1280 if (newEnd > result.getLength())
1281 spansToExtend.push({span: leftSpan, end: newEnd});
1283 result.append(separator);
1285 spansToExtend.forEach(function(elem) {
1288 // Cast ok, since span is known to exist.
1289 /** @type {number} */ (result.getSpanStart(elem.span)),
1292 spansToRemove.forEach(result.removeSpan.bind(result));
1293 separator = Output.SPACE;