| // 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. |
| */ |
| |
| goog.provide('Output'); |
| goog.provide('Output.EventType'); |
| |
| goog.require('AutomationTreeWalker'); |
| goog.require('EarconEngine'); |
| goog.require('Spannable'); |
| goog.require('constants'); |
| goog.require('cursors.Cursor'); |
| goog.require('cursors.Range'); |
| goog.require('cursors.Unit'); |
| goog.require('cvox.AbstractEarcons'); |
| goog.require('cvox.ChromeVox'); |
| goog.require('cvox.NavBraille'); |
| goog.require('cvox.TtsCategory'); |
| goog.require('cvox.ValueSelectionSpan'); |
| goog.require('cvox.ValueSpan'); |
| goog.require('goog.i18n.MessageFormat'); |
| |
| goog.scope(function() { |
| var AutomationNode = chrome.automation.AutomationNode; |
| var Dir = constants.Dir; |
| var EventType = chrome.automation.EventType; |
| var RoleType = chrome.automation.RoleType; |
| var StateType = chrome.automation.StateType; |
| |
| /** |
| * An Output object formats a cursors.Range 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 |
| * @ 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. |
| * @constructor |
| */ |
| Output = function() { |
| // TODO(dtseng): Include braille specific rules. |
| /** @type {!Array<!Spannable>} @private */ |
| this.speechBuffer_ = []; |
| /** @type {!Array<!Spannable>} @private */ |
| this.brailleBuffer_ = []; |
| /** @type {!Array<!Object>} @private */ |
| this.locations_ = []; |
| /** @type {function(?)} @private */ |
| this.speechEndCallback_; |
| |
| /** |
| * Current global options. |
| * @type {{speech: boolean, braille: boolean, auralStyle: boolean}} |
| * @private |
| */ |
| this.formatOptions_ = {speech: true, braille: false, auralStyle: false}; |
| |
| /** |
| * The speech category for the generated speech utterance. |
| * @type {cvox.TtsCategory} |
| * @private |
| */ |
| this.speechCategory_ = cvox.TtsCategory.NAV; |
| |
| /** |
| * The speech queue mode for the generated speech utterance. |
| * @type {cvox.QueueMode} |
| * @private |
| */ |
| this.queueMode_ = cvox.QueueMode.QUEUE; |
| |
| /** |
| * @type {boolean} |
| * @private |
| */ |
| this.outputContextFirst_ = false; |
| }; |
| |
| /** |
| * Delimiter to use between output values. |
| * @type {string} |
| */ |
| Output.SPACE = ' '; |
| |
| /** |
| * Metadata about supported automation roles. |
| * @const {Object<{msgId: string, |
| * earconId: (string|undefined), |
| * inherits: (string|undefined), |
| * outputContextFirst: (boolean|undefined)}>} |
| * msgId: the message id of the role. |
| * earconId: an optional earcon to play when encountering the role. |
| * inherits: inherits rules from this role. |
| * outputContextFirst: where to place the context output. |
| * @private |
| */ |
| Output.ROLE_INFO_ = { |
| alert: { |
| msgId: 'role_alert' |
| }, |
| alertDialog: { |
| msgId: 'role_alertdialog', |
| outputContextFirst: true |
| }, |
| article: { |
| msgId: 'role_article', |
| inherits: 'abstractContainer' |
| }, |
| application: { |
| msgId: 'role_application', |
| inherits: 'abstractContainer' |
| }, |
| banner: { |
| msgId: 'role_banner', |
| inherits: 'abstractContainer' |
| }, |
| button: { |
| msgId: 'role_button', |
| earconId: 'BUTTON' |
| }, |
| buttonDropDown: { |
| msgId: 'role_button', |
| earconId: 'BUTTON' |
| }, |
| checkBox: { |
| msgId: 'role_checkbox' |
| }, |
| columnHeader: { |
| msgId: 'role_columnheader', |
| inherits: 'cell' |
| }, |
| comboBox: { |
| msgId: 'role_combobox', |
| earconId: 'LISTBOX' |
| }, |
| complementary: { |
| msgId: 'role_complementary', |
| inherits: 'abstractContainer' |
| }, |
| contentInfo: { |
| msgId: 'role_contentinfo', |
| inherits: 'abstractContainer' |
| }, |
| date: { |
| msgId: 'input_type_date', |
| inherits: 'abstractContainer' |
| }, |
| definition: { |
| msgId: 'role_definition', |
| inherits: 'abstractContainer' |
| }, |
| dialog: { |
| msgId: 'role_dialog', |
| outputContextFirst: true |
| }, |
| directory: { |
| msgId: 'role_directory', |
| inherits: 'abstractContainer' |
| }, |
| document: { |
| msgId: 'role_document', |
| inherits: 'abstractContainer' |
| }, |
| form: { |
| msgId: 'role_form', |
| inherits: 'abstractContainer' |
| }, |
| grid: { |
| msgId: 'role_grid' |
| }, |
| group: { |
| msgId: 'role_group', |
| inherits: 'abstractContainer' |
| }, |
| heading: { |
| msgId: 'role_heading', |
| }, |
| image: { |
| msgId: 'role_img', |
| }, |
| inputTime: { |
| msgId: 'input_type_time', |
| inherits: 'abstractContainer' |
| }, |
| link: { |
| msgId: 'role_link', |
| earconId: 'LINK' |
| }, |
| listBox: { |
| msgId: 'role_listbox', |
| earconId: 'LISTBOX' |
| }, |
| listBoxOption: { |
| msgId: 'role_listitem', |
| earconId: 'LIST_ITEM' |
| }, |
| listItem: { |
| msgId: 'role_listitem', |
| earconId: 'LIST_ITEM' |
| }, |
| log: { |
| msgId: 'role_log', |
| }, |
| main: { |
| msgId: 'role_main', |
| inherits: 'abstractContainer' |
| }, |
| marquee: { |
| msgId: 'role_marquee', |
| }, |
| math: { |
| msgId: 'role_math', |
| inherits: 'abstractContainer' |
| }, |
| menu: { |
| msgId: 'role_menu', |
| outputContextFirst: true |
| }, |
| menuBar: { |
| msgId: 'role_menubar', |
| }, |
| menuItem: { |
| msgId: 'role_menuitem' |
| }, |
| menuItemCheckBox: { |
| msgId: 'role_menuitemcheckbox' |
| }, |
| menuItemRadio: { |
| msgId: 'role_menuitemradio' |
| }, |
| menuListOption: { |
| msgId: 'role_menuitem' |
| }, |
| menuListPopup: { |
| msgId: 'role_menu' |
| }, |
| meter: { |
| msgId: 'role_meter', |
| inherits: 'abstractRange' |
| }, |
| navigation: { |
| msgId: 'role_navigation', |
| inherits: 'abstractContainer' |
| }, |
| note: { |
| msgId: 'role_note', |
| inherits: 'abstractContainer' |
| }, |
| progressIndicator: { |
| msgId: 'role_progress_indicator', |
| inherits: 'abstractRange' |
| }, |
| popUpButton: { |
| msgId: 'role_button', |
| earconId: 'POP_UP_BUTTON' |
| }, |
| radioButton: { |
| msgId: 'role_radio' |
| }, |
| radioGroup: { |
| msgId: 'role_radiogroup', |
| }, |
| rootWebArea: { |
| outputContextFirst: true |
| }, |
| row: { |
| msgId: 'role_row', |
| inherits: 'abstractContainer' |
| }, |
| rowHeader: { |
| msgId: 'role_rowheader', |
| inherits: 'cell' |
| }, |
| scrollBar: { |
| msgId: 'role_scrollbar', |
| inherits: 'abstractRange' |
| }, |
| search: { |
| msgId: 'role_search', |
| inherits: 'abstractContainer' |
| }, |
| separator: { |
| msgId: 'role_separator', |
| inherits: 'abstractContainer' |
| }, |
| slider: { |
| msgId: 'role_slider', |
| inherits: 'abstractRange', |
| earconId: 'SLIDER' |
| }, |
| spinButton: { |
| msgId: 'role_spinbutton', |
| inherits: 'abstractRange', |
| earconId: 'LISTBOX' |
| }, |
| status: { |
| msgId: 'role_status' |
| }, |
| tab: { |
| msgId: 'role_tab' |
| }, |
| tabList: { |
| msgId: 'role_tablist' |
| }, |
| tabPanel: { |
| msgId: 'role_tabpanel' |
| }, |
| textBox: { |
| msgId: 'input_type_text', |
| earconId: 'EDITABLE_TEXT' |
| }, |
| textField: { |
| msgId: 'input_type_text', |
| earconId: 'EDITABLE_TEXT' |
| }, |
| time: { |
| msgId: 'tag_time', |
| inherits: 'abstractContainer' |
| }, |
| timer: { |
| msgId: 'role_timer' |
| }, |
| toolbar: { |
| msgId: 'role_toolbar' |
| }, |
| toggleButton: { |
| msgId: 'role_button', |
| inherits: 'checkBox' |
| }, |
| tree: { |
| msgId: 'role_tree' |
| }, |
| treeItem: { |
| msgId: 'role_treeitem' |
| } |
| }; |
| |
| /** |
| * 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_ = { |
| busy: {on: {msgId: 'busy_state'}}, |
| collapsed: {on: {msgId: 'aria_expanded_false'}}, |
| default: {on: {msgId: 'default_state'}}, |
| disabled: {on: {msgId: 'aria_disabled_true'}}, |
| expanded: {on: {msgId: 'aria_expanded_true'}}, |
| multiselectable: {on: {msgId: 'aria_multiselectable_true'}}, |
| pressed: { |
| isRoleSpecific: true, |
| on: {msgId: 'aria_pressed_true'}, |
| off: {msgId: 'aria_pressed_false'} |
| }, |
| required: {on: {msgId: 'aria_required_true'}}, |
| selected: {on: {msgId: 'aria_selected_true'}}, |
| visited: {on: {msgId: 'visited_state'}} |
| }; |
| |
| /** |
| * 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 specifying format of AutomationNodes for output. |
| * @type {!Object<Object<Object<string>>>} |
| */ |
| Output.RULES = { |
| navigate: { |
| 'default': { |
| speak: '$name $value $state $role $description', |
| braille: '' |
| }, |
| abstractContainer: { |
| enter: '$nameFromNode $role $state $description', |
| leave: '@exited_container($role)' |
| }, |
| abstractRange: { |
| speak: |
| '$if($valueForRange, $valueForRange, $value) ' + |
| '$if($minValueForRange, @aria_value_min($minValueForRange)) ' + |
| '$if($maxValueForRange, @aria_value_max($maxValueForRange)) ' + |
| '$name $role $description $state' |
| }, |
| alert: { |
| enter: '$name $role $state', |
| speak: '$earcon(ALERT_NONMODAL) $role $nameOrTextContent $state' |
| }, |
| alertDialog: { |
| enter: '$earcon(ALERT_MODAL) $name $state', |
| speak: '$earcon(ALERT_MODAL) $name $nameOrTextContent $state $role' |
| }, |
| cell: { |
| enter: '@cell_summary($if($ariaCellRowIndex, $ariaCellRowIndex, ' + |
| '$tableCellRowIndex), ' + |
| '$if($ariaCellColumnIndex, $ariaCellColumnIndex, ' + |
| '$tableCellColumnIndex)) $node(tableColumnHeader)', |
| speak: '@cell_summary($if($ariaCellRowIndex, $ariaCellRowIndex, ' + |
| '$tableCellRowIndex), ' + |
| '$if($ariaCellColumnIndex, $ariaCellColumnIndex, ' + |
| '$tableCellColumnIndex)) $node(tableColumnHeader) $state' |
| }, |
| checkBox: { |
| speak: '$if($checked, $earcon(CHECK_ON), $earcon(CHECK_OFF)) ' + |
| '$name $role $checked $description $state' |
| }, |
| client: { |
| speak: '$name' |
| }, |
| date: { |
| enter: '$nameFromNode $role $description' |
| }, |
| dialog: { |
| enter: '$nameFromNode $role $description' |
| }, |
| div: { |
| enter: '$nameFromNode', |
| speak: '$nameOrTextContent $description' |
| }, |
| embeddedObject: { |
| speak: '$name' |
| }, |
| grid: { |
| enter: '$nameFromNode $role $description' |
| }, |
| group: { |
| enter: '$nameFromNode $state $description', |
| speak: '$nameOrDescendants $value $state $description', |
| leave: '' |
| }, |
| heading: { |
| enter: '!relativePitch(hierarchicalLevel) ' + |
| '$nameFromNode= ' + |
| '$if($hierarchicalLevel, @tag_h+$hierarchicalLevel, $role) $state', |
| speak: '!relativePitch(hierarchicalLevel) ' + |
| '$nameOrDescendants= ' + |
| '$if($hierarchicalLevel, @tag_h+$hierarchicalLevel, $role) $state' |
| }, |
| image: { |
| speak: '$if($name, $name, $urlFilename) ' + |
| '$value $state $role $description', |
| }, |
| inlineTextBox: { |
| speak: '$name=' |
| }, |
| inputTime: { |
| enter: '$nameFromNode $role $description' |
| }, |
| link: { |
| enter: '$nameFromNode= $role $state', |
| speak: '$name $value $state ' + |
| '$if($inPageLinkTarget, @internal_link, $role) $description', |
| }, |
| list: { |
| enter: '$role @@list_with_items($countChildren(listItem))', |
| speak: '$descendants $role @@list_with_items($countChildren(listItem))' |
| }, |
| listBox: { |
| enter: '$nameFromNode ' + |
| '$role @@list_with_items($countChildren(listBoxOption)) ' + |
| '$description' |
| }, |
| listBoxOption: { |
| speak: '$name $role @describe_index($indexInParent, $parentChildCount) ' + |
| '$description $state' |
| }, |
| listItem: { |
| enter: '$role' |
| }, |
| menu: { |
| enter: '$name $role', |
| speak: '$name $role @@list_with_items($countChildren(menuItem)) $state' |
| }, |
| menuItem: { |
| speak: '$name $role $if($haspopup, @has_submenu) ' + |
| '@describe_index($indexInParent, $parentChildCount) ' + |
| '$description $state' |
| }, |
| menuItemCheckBox: { |
| speak: '$if($checked, $earcon(CHECK_ON), $earcon(CHECK_OFF)) ' + |
| '$name $role $checked $description ' + |
| '@describe_index($indexInParent, $parentChildCount) ' |
| }, |
| menuItemRadio: { |
| speak: '$if($checked, $earcon(CHECK_ON), $earcon(CHECK_OFF)) ' + |
| '$if($checked, @describe_radio_selected($name), ' + |
| '@describe_radio_unselected($name)) $description ' + |
| '@describe_index($indexInParent, $parentChildCount) ' |
| }, |
| menuListOption: { |
| speak: '$name @role_menuitem ' + |
| '@describe_index($indexInParent, $parentChildCount) $description' |
| }, |
| paragraph: { |
| speak: '$descendants' |
| }, |
| popUpButton: { |
| speak: '$value $name $role @aria_has_popup ' + |
| '$state $description' |
| }, |
| radioButton: { |
| speak: '$if($checked, $earcon(CHECK_ON), $earcon(CHECK_OFF)) ' + |
| '$if($checked, @describe_radio_selected($name), ' + |
| '@describe_radio_unselected($name)) $description' |
| }, |
| radioGroup: { |
| enter: '$name $role $description' |
| }, |
| rootWebArea: { |
| enter: '$name', |
| speak: '$if($name, $name, $docUrl)' |
| }, |
| region: { |
| speak: '$nameOrTextContent' |
| }, |
| row: { |
| enter: '$node(tableRowHeader)' |
| }, |
| rowHeader: { |
| speak: '$nameOrTextContent $state' |
| }, |
| staticText: { |
| speak: '$name=' |
| }, |
| switch: { |
| speak: '$if($checked, $earcon(CHECK_ON), $earcon(CHECK_OFF)) ' + |
| '$if($checked, @describe_switch_on($name), ' + |
| '@describe_switch_off($name)) $description $state' |
| }, |
| tab: { |
| speak: '@describe_tab($name) $state $description ' + |
| '$if($setSize, @describe_index($posInSet, $setSize))', |
| }, |
| table: { |
| enter: '@table_summary($name, ' + |
| '$if($ariaRowCount, $ariaRowCount, $tableRowCount), ' + |
| '$if($ariaColumnCount, $ariaColumnCount, $tableColumnCount)) ' + |
| '$node(tableHeader)' |
| }, |
| tableHeaderContainer: { |
| speak: '$nameOrTextContent $state $description' |
| }, |
| textField: { |
| speak: '$name $value $if($multiline, @tag_textarea, $if(' + |
| '$inputType, $inputType, $role)) $description $state', |
| braille: '' |
| }, |
| timer: { |
| speak: '$nameFromNode $descendants $value $state $description' |
| }, |
| toggleButton: { |
| speak: '$if($pressed, $earcon(CHECK_ON), $earcon(CHECK_OFF)) ' + |
| '$name $role $pressed $description $state' |
| }, |
| toolbar: { |
| enter: '$name $role $description' |
| }, |
| tree: { |
| enter: '$name $role @@list_with_items($countChildren(treeItem))' |
| }, |
| treeItem: { |
| enter: '$role $expanded $collapsed ' + |
| '@describe_index($indexInParent, $parentChildCount) ' + |
| '@describe_depth($hierarchicalLevel)', |
| speak: '$name ' + |
| '$role $state ' + |
| '@describe_index($indexInParent, $parentChildCount) ' + |
| '@describe_depth($hierarchicalLevel)' |
| }, |
| window: { |
| enter: '@describe_window($name)', |
| speak: '@describe_window($name) $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($indexInParent, $parentChildCount)) ' |
| } |
| }, |
| alert: { |
| default: { |
| speak: '$earcon(ALERT_NONMODAL) @role_alert ' + |
| '$nameOrTextContent $description' |
| } |
| } |
| }; |
| |
| /** |
| * Used to annotate utterances with speech properties. |
| * @constructor |
| */ |
| Output.SpeechProperties = function() {}; |
| |
| /** |
| * Custom actions performed while rendering an output string. |
| * @constructor |
| */ |
| Output.Action = function() { |
| }; |
| |
| Output.Action.prototype = { |
| run: function() { |
| } |
| }; |
| |
| /** |
| * Action to play an earcon. |
| * @param {string} earconId |
| * @param {chrome.automation.Rect=} opt_location |
| * @constructor |
| * @extends {Output.Action} |
| */ |
| Output.EarconAction = function(earconId, opt_location) { |
| Output.Action.call(this); |
| /** @type {string} */ |
| this.earconId = earconId; |
| /** @type {chrome.automation.Rect|undefined} */ |
| this.location = opt_location; |
| }; |
| |
| Output.EarconAction.prototype = { |
| __proto__: Output.Action.prototype, |
| |
| /** @override */ |
| run: function() { |
| cvox.ChromeVox.earcons.playEarcon( |
| cvox.Earcon[this.earconId], this.location); |
| }, |
| |
| /** @override */ |
| toJSON: function() { |
| return {earconId: this.earconId}; |
| } |
| }; |
| |
| /** |
| * Annotation for text with a selection inside it. |
| * @param {number} startIndex |
| * @param {number} endIndex |
| * @constructor |
| */ |
| Output.SelectionSpan = function(startIndex, endIndex) { |
| // TODO(dtseng): Direction lost below; should preserve for braille panning. |
| this.startIndex = startIndex < endIndex ? startIndex : endIndex; |
| this.endIndex = endIndex > startIndex ? endIndex : startIndex; |
| }; |
| |
| /** |
| * Wrapper for automation nodes as annotations. Since the |
| * {@code AutomationNode} constructor isn't exposed in the API, this class is |
| * used to allow instanceof checks on these annotations. |
| * @param {!AutomationNode} node |
| * @constructor |
| */ |
| Output.NodeSpan = function(node) { |
| this.node = node; |
| }; |
| |
| /** |
| * Possible events handled by ChromeVox internally. |
| * @enum {string} |
| */ |
| Output.EventType = { |
| NAVIGATE: 'navigate' |
| }; |
| |
| /** |
| * If set, the next speech utterance will use this value instead of the normal |
| * queueing mode. |
| * @type {cvox.QueueMode|undefined} |
| * @private |
| */ |
| Output.forceModeForNextSpeechUtterance_; |
| |
| /** |
| * 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 {cvox.QueueMode} mode |
| */ |
| Output.forceModeForNextSpeechUtterance = function(mode) { |
| 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 |
| */ |
| Output.isTruthy = function(node, attrib) { |
| switch(attrib) { |
| case 'checked': |
| return node.checked && node.checked !== 'false'; |
| default: |
| return node[attrib] !== undefined || node.state[attrib]; |
| } |
| }; |
| |
| Output.prototype = { |
| /** |
| * @return {boolean} True if there's any speech that will be output. |
| */ |
| get hasSpeech() { |
| for (var i = 0; i < this.speechBuffer_.length; i++) { |
| if (this.speechBuffer_[i].trim().length) |
| return true; |
| } |
| return false; |
| }, |
| |
| /** |
| * Specify ranges for speech. |
| * @param {!cursors.Range} range |
| * @param {cursors.Range} prevRange |
| * @param {EventType|Output.EventType} type |
| * @return {!Output} |
| */ |
| withSpeech: function(range, prevRange, type) { |
| this.formatOptions_ = {speech: true, braille: false, auralStyle: false}; |
| this.render_(range, prevRange, type, this.speechBuffer_); |
| return this; |
| }, |
| |
| /** |
| * Specify ranges for aurally styled speech. |
| * @param {!cursors.Range} range |
| * @param {cursors.Range} prevRange |
| * @param {EventType|Output.EventType} type |
| * @return {!Output} |
| */ |
| withRichSpeech: function(range, prevRange, type) { |
| this.formatOptions_ = {speech: true, braille: false, auralStyle: true}; |
| this.render_(range, prevRange, type, this.speechBuffer_); |
| return this; |
| }, |
| |
| /** |
| * Specify ranges for braille. |
| * @param {!cursors.Range} range |
| * @param {cursors.Range} prevRange |
| * @param {EventType|Output.EventType} type |
| * @return {!Output} |
| */ |
| withBraille: function(range, prevRange, type) { |
| this.formatOptions_ = {speech: false, braille: true, auralStyle: false}; |
| this.render_(range, prevRange, type, this.brailleBuffer_); |
| return this; |
| }, |
| |
| /** |
| * Specify ranges for location. |
| * @param {!cursors.Range} range |
| * @param {cursors.Range} prevRange |
| * @param {EventType|Output.EventType} type |
| * @return {!Output} |
| */ |
| withLocation: function(range, prevRange, type) { |
| this.formatOptions_ = {speech: false, braille: false, auralStyle: false}; |
| this.render_(range, prevRange, type, [] /*unused output*/); |
| return this; |
| }, |
| |
| /** |
| * Specify the same ranges for speech and braille. |
| * @param {!cursors.Range} range |
| * @param {cursors.Range} prevRange |
| * @param {EventType|Output.EventType} type |
| * @return {!Output} |
| */ |
| withSpeechAndBraille: function(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 {!cursors.Range} range |
| * @param {cursors.Range} prevRange |
| * @param {EventType|Output.EventType} type |
| * @return {!Output} |
| */ |
| withRichSpeechAndBraille: function(range, prevRange, type) { |
| this.withRichSpeech(range, prevRange, type); |
| this.withBraille(range, prevRange, type); |
| return this; |
| }, |
| |
| /** |
| * Applies the given speech category to the output. |
| * @param {cvox.TtsCategory} category |
| * @return {!Output} |
| */ |
| withSpeechCategory: function(category) { |
| this.speechCategory_ = category; |
| return this; |
| }, |
| |
| /** |
| * Applies the given speech queue mode to the output. |
| * @param {cvox.QueueMode} queueMode The queueMode for the speech. |
| * @return {!Output} |
| */ |
| withQueueMode: function(queueMode) { |
| this.queueMode_ = queueMode; |
| return this; |
| }, |
| |
| /** |
| * Output a string literal. |
| * @param {string} value |
| * @return {!Output} |
| */ |
| withString: function(value) { |
| this.append_(this.speechBuffer_, value); |
| this.append_(this.brailleBuffer_, value); |
| return this; |
| }, |
| |
| |
| /** |
| * Outputs formatting nodes after this will contain context first. |
| * @return {!Output} |
| */ |
| withContextFirst: function() { |
| this.outputContextFirst_ = 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: function(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: function(formatStr, opt_node) { |
| var node = opt_node || null; |
| |
| this.formatOptions_ = {speech: true, braille: false, auralStyle: false}; |
| this.format_(node, formatStr, this.speechBuffer_); |
| |
| 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: function(formatStr, opt_node) { |
| var node = opt_node || null; |
| |
| this.formatOptions_ = {speech: false, braille: true, auralStyle: false}; |
| this.format_(node, formatStr, this.brailleBuffer_); |
| return this; |
| }, |
| |
| /** |
| * Triggers callback for a speech event. |
| * @param {function()} callback |
| * @return {Output} |
| */ |
| onSpeechEnd: function(callback) { |
| this.speechEndCallback_ = function(opt_cleanupOnly) { |
| if (!opt_cleanupOnly) |
| callback(); |
| }.bind(this); |
| return this; |
| }, |
| |
| /** |
| * Executes all specified output. |
| */ |
| go: function() { |
| // Speech. |
| var queueMode = cvox.QueueMode.FLUSH; |
| if (Output.forceModeForNextSpeechUtterance_ !== undefined) |
| queueMode = Output.forceModeForNextSpeechUtterance_; |
| else if (this.queueMode_ !== undefined) |
| queueMode = this.queueMode_; |
| |
| if (this.speechBuffer_.length > 0) |
| Output.forceModeForNextSpeechUtterance_ = undefined; |
| |
| for (var i = 0; i < this.speechBuffer_.length; i++) { |
| var buff = this.speechBuffer_[i]; |
| var speechProps = /** @type {Object} */( |
| buff.getSpanInstanceOf(Output.SpeechProperties)) || {}; |
| |
| speechProps.category = this.speechCategory_; |
| |
| (function() { |
| var scopedBuff = buff; |
| speechProps['startCallback'] = function() { |
| var actions = scopedBuff.getSpansInstanceOf(Output.Action); |
| if (actions) { |
| actions.forEach(function(a) { |
| a.run(); |
| }); |
| } |
| }; |
| }()); |
| |
| if (i == this.speechBuffer_.length - 1) |
| speechProps['endCallback'] = this.speechEndCallback_; |
| |
| cvox.ChromeVox.tts.speak( |
| buff.toString(), queueMode, speechProps); |
| queueMode = cvox.QueueMode.QUEUE; |
| } |
| |
| // Braille. |
| if (this.brailleBuffer_.length) { |
| var buff = this.mergeBraille_(this.brailleBuffer_); |
| var selSpan = |
| buff.getSpanInstanceOf(Output.SelectionSpan); |
| var startIndex = -1, endIndex = -1; |
| if (selSpan) { |
| var valueStart = buff.getSpanStart(selSpan); |
| var valueEnd = buff.getSpanEnd(selSpan); |
| startIndex = valueStart + selSpan.startIndex; |
| endIndex = valueStart + selSpan.endIndex; |
| buff.setSpan(new cvox.ValueSpan(0), valueStart, valueEnd); |
| buff.setSpan(new cvox.ValueSelectionSpan(), startIndex, endIndex); |
| } |
| |
| var output = new cvox.NavBraille({ |
| text: buff, |
| startIndex: startIndex, |
| endIndex: endIndex |
| }); |
| |
| cvox.ChromeVox.braille.write(output); |
| } |
| |
| // Display. |
| if (this.speechCategory_ != cvox.TtsCategory.LIVE) |
| chrome.accessibilityPrivate.setFocusRing(this.locations_); |
| }, |
| |
| /** |
| * Renders the given range using optional context previous range and event |
| * type. |
| * @param {!cursors.Range} range |
| * @param {cursors.Range} prevRange |
| * @param {EventType|Output.EventType} type |
| * @param {!Array<Spannable>} buff Buffer to receive rendered output. |
| * @private |
| */ |
| render_: function(range, prevRange, type, buff) { |
| if (prevRange && !prevRange.isValid()) |
| prevRange = null; |
| |
| // Scan unique ancestors to get the value of |outputContextFirst|. |
| var parent = range.start.node; |
| var prevParent = prevRange ? prevRange.start.node : parent; |
| if (!parent || !prevParent) |
| return; |
| var uniqueAncestors = AutomationUtil.getUniqueAncestors(prevParent, parent); |
| for (var i = 0; parent = uniqueAncestors[i]; i++) { |
| if (parent.role == RoleType.WINDOW) |
| break; |
| if (Output.ROLE_INFO_[parent.role] && |
| Output.ROLE_INFO_[parent.role].outputContextFirst) { |
| this.outputContextFirst_ = true; |
| break; |
| } |
| } |
| |
| if (range.isSubNode()) |
| this.subNode_(range, prevRange, type, buff); |
| else |
| this.range_(range, prevRange, type, buff); |
| }, |
| |
| /** |
| * Format the node given the format specifier. |
| * @param {AutomationNode} node |
| * @param {string|!Object} format The output format either specified as an |
| * output template string or a parsed output format tree. |
| * @param {!Array<Spannable>} buff Buffer to receive rendered output. |
| * @param {!AutomationNode=} opt_prevNode |
| * @private |
| */ |
| format_: function(node, format, buff, opt_prevNode) { |
| var tokens = []; |
| var args = null; |
| |
| // Hacky way to support args. |
| if (typeof(format) == 'string') { |
| format = format.replace(/([,:])\W/g, '$1'); |
| tokens = format.split(' '); |
| } else { |
| tokens = [format]; |
| } |
| |
| var speechProps = null; |
| tokens.forEach(function(token) { |
| // Ignore empty tokens. |
| if (!token) |
| return; |
| |
| // Parse the token. |
| var tree; |
| if (typeof(token) == 'string') |
| tree = this.createParseTree_(token); |
| else |
| tree = token; |
| |
| // Obtain the operator token. |
| token = tree.value; |
| |
| // Set suffix options. |
| var options = {}; |
| options.annotation = []; |
| options.isUnique = token[token.length - 1] == '='; |
| if (options.isUnique) |
| token = token.substring(0, token.length - 1); |
| |
| // Process token based on prefix. |
| var prefix = token[0]; |
| token = token.slice(1); |
| |
| // All possible tokens based on prefix. |
| if (prefix == '$') { |
| if (token == 'value') { |
| var text = node.value || ''; |
| if (!node.state[StateType.EDITABLE] && node.name == text) |
| return; |
| |
| var selectedText = ''; |
| if (node.textSelStart !== undefined) { |
| options.annotation.push(new Output.SelectionSpan( |
| node.textSelStart || 0, |
| node.textSelEnd || 0)); |
| |
| selectedText = |
| node.value.substring(node.textSelStart || 0, |
| node.textSelEnd || 0); |
| } |
| options.annotation.push(token); |
| if (selectedText && !this.formatOptions_.braille) { |
| this.append_(buff, selectedText, options); |
| this.append_(buff, Msgs.getMsg('selected')); |
| } else { |
| this.append_(buff, text, options); |
| } |
| } else if (token == 'name') { |
| options.annotation.push(token); |
| var earcon = node ? this.findEarcon_(node, opt_prevNode) : null; |
| if (earcon) |
| options.annotation.push(earcon); |
| this.append_(buff, node.name || '', options); |
| } else if (token == 'description') { |
| if (node.name == node.description) |
| return; |
| |
| options.annotation.push(token); |
| this.append_(buff, node.description || '', options); |
| } else if (token == 'urlFilename') { |
| options.annotation.push('name'); |
| var url = node.url || ''; |
| var 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); |
| } else if (token == 'nameFromNode') { |
| if (node.nameFrom == chrome.automation.NameFromType.CONTENTS) |
| return; |
| |
| options.annotation.push('name'); |
| this.append_(buff, node.name || '', options); |
| } else if (token == 'nameOrDescendants') { |
| options.annotation.push(token); |
| if (node.name) |
| this.append_(buff, node.name || '', options); |
| else |
| this.format_(node, '$descendants', buff); |
| } else if (token == 'description') { |
| if (node.name == node.description || node.value == node.description) |
| return; |
| options.annotation.push(token); |
| this.append_(buff, node.description || '', options); |
| } else if (token == 'indexInParent') { |
| if (node.parent) { |
| options.annotation.push(token); |
| var count = 0; |
| for (var i = 0, child; child = node.parent.children[i]; i++) { |
| if (node.role == child.role) |
| count++; |
| if (node === child) |
| break; |
| } |
| this.append_(buff, String(count)); |
| } |
| } else if (token == 'parentChildCount') { |
| if (node.parent) { |
| options.annotation.push(token); |
| var count = node.parent.children.filter(function(child) { |
| return node.role == child.role; |
| }).length; |
| this.append_(buff, String(count)); |
| } |
| } else if (token == 'checked') { |
| var msg; |
| switch (node.checked) { |
| case 'mixed': |
| msg = 'checked_mixed'; |
| break; |
| case 'true': |
| msg = 'checked_true'; |
| break; |
| default: |
| msg = 'checked_false'; |
| break; |
| } |
| this.format_(node, '@' + msg, buff); |
| } else if (token == 'state') { |
| if (node.state) { |
| Object.getOwnPropertyNames(node.state).forEach(function(s) { |
| var stateInfo = Output.STATE_INFO_[s]; |
| if (stateInfo && !stateInfo.isRoleSpecific && stateInfo.on) |
| this.format_(node, '@' + stateInfo.on.msgId, buff); |
| }.bind(this)); |
| } |
| } else if (token == 'find') { |
| // Find takes two arguments: JSON query string and format string. |
| if (tree.firstChild) { |
| var jsonQuery = tree.firstChild.value; |
| node = node.find( |
| /** @type {chrome.automation.FindParams}*/( |
| JSON.parse(jsonQuery))); |
| var formatString = tree.firstChild.nextSibling; |
| if (node) |
| this.format_(node, formatString, buff); |
| } |
| } else if (token == 'descendants') { |
| if (!node || AutomationPredicate.leafOrStaticText(node)) |
| return; |
| |
| // Construct a range to the leftmost and rightmost leaves. |
| var leftmost = AutomationUtil.findNodePre( |
| node, Dir.FORWARD, AutomationPredicate.leafOrStaticText); |
| var rightmost = AutomationUtil.findNodePre( |
| node, Dir.BACKWARD, AutomationPredicate.leafOrStaticText); |
| if (!leftmost || !rightmost) |
| return; |
| |
| var subrange = new cursors.Range( |
| new cursors.Cursor(leftmost, cursors.NODE_INDEX), |
| new cursors.Cursor(rightmost, cursors.NODE_INDEX)); |
| var prev = null; |
| if (node) |
| prev = cursors.Range.fromNode(node); |
| this.render_(subrange, prev, Output.EventType.NAVIGATE, buff); |
| } else if (token == 'joinedDescendants') { |
| var unjoined = []; |
| this.format_(node, '$descendants', unjoined); |
| this.append_(buff, unjoined.join(' '), options); |
| } else if (token == 'role') { |
| if (localStorage['useVerboseMode'] == 'false') |
| return; |
| |
| if (this.formatOptions_.auralStyle) { |
| speechProps = new Output.SpeechProperties(); |
| speechProps['relativePitch'] = -0.3; |
| } |
| options.annotation.push(token); |
| var msg = node.role; |
| var info = Output.ROLE_INFO_[node.role]; |
| if (info) { |
| if (this.formatOptions_.braille) |
| msg = Msgs.getMsg(info.msgId + '_brl'); |
| else |
| msg = Msgs.getMsg(info.msgId); |
| } else { |
| console.error('Missing role info for ' + node.role); |
| } |
| this.append_(buff, msg || '', options); |
| } else if (token == 'inputType') { |
| if (!node.inputType) |
| return; |
| options.annotation.push(token); |
| var 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); |
| } else if (token == 'tableCellRowIndex' || |
| token == 'tableCellColumnIndex') { |
| var value = node[token]; |
| if (value == undefined) |
| return; |
| value = String(value + 1); |
| options.annotation.push(token); |
| this.append_(buff, value, options); |
| } else if (token == 'node') { |
| if (!tree.firstChild || !node[tree.firstChild.value]) |
| return; |
| var related = node[tree.firstChild.value]; |
| this.node_(related, related, Output.EventType.NAVIGATE, buff); |
| } else if (token == 'nameOrTextContent') { |
| if (node.name) { |
| this.format_(node, '$name', buff); |
| } else { |
| var walker = new AutomationTreeWalker(node, |
| Dir.FORWARD, |
| {visit: AutomationPredicate.leafOrStaticText, |
| leaf: AutomationPredicate.leafOrStaticText}); |
| var outputStrings = []; |
| while (walker.next().node && |
| walker.phase == AutomationTreeWalkerPhase.DESCENDANT) { |
| if (walker.node.name) |
| outputStrings.push(walker.node.name); |
| } |
| var joinedOutput = outputStrings.join(' '); |
| this.append_(buff, joinedOutput, options); |
| } |
| } else if (node[token] !== undefined) { |
| options.annotation.push(token); |
| var value = node[token]; |
| if (typeof value == 'number') |
| value = String(value); |
| this.append_(buff, value, options); |
| } else if (Output.STATE_INFO_[token]) { |
| options.annotation.push('state'); |
| var stateInfo = Output.STATE_INFO_[token]; |
| var resolvedInfo = {}; |
| resolvedInfo = node.state[token] ? stateInfo.on : stateInfo.off; |
| if (!resolvedInfo) |
| return; |
| if (this.formatOptions_.speech && resolvedInfo.earconId) { |
| options.annotation.push( |
| new Output.EarconAction(resolvedInfo.earconId), |
| node.location || undefined); |
| } |
| var msgId = |
| this.formatOptions_.braille ? resolvedInfo.msgId + '_brl' : |
| resolvedInfo.msgId; |
| var msg = Msgs.getMsg(msgId); |
| this.append_(buff, msg, options); |
| } else if (tree.firstChild) { |
| // Custom functions. |
| if (token == 'if') { |
| var cond = tree.firstChild; |
| var attrib = cond.value.slice(1); |
| if (Output.isTruthy(node, attrib)) |
| this.format_(node, cond.nextSibling, buff); |
| else |
| this.format_(node, cond.nextSibling.nextSibling, buff); |
| } else if (token == 'earcon') { |
| // Ignore unless we're generating speech output. |
| if (!this.formatOptions_.speech) |
| return; |
| |
| options.annotation.push( |
| new Output.EarconAction(tree.firstChild.value, |
| node.location || undefined)); |
| this.append_(buff, '', options); |
| } else if (token == 'countChildren') { |
| var role = tree.firstChild.value; |
| var count = node.children.filter(function(e) { |
| return e.role == role; |
| }).length; |
| this.append_(buff, String(count)); |
| } |
| } |
| } else if (prefix == '@') { |
| if (this.formatOptions_.auralStyle) { |
| speechProps = new Output.SpeechProperties(); |
| speechProps['relativePitch'] = -0.2; |
| } |
| var isPluralized = (token[0] == '@'); |
| if (isPluralized) |
| token = token.slice(1); |
| // Tokens can have substitutions. |
| var pieces = token.split('+'); |
| token = pieces.reduce(function(prev, cur) { |
| var lookup = cur; |
| if (cur[0] == '$') |
| lookup = node[cur.slice(1)]; |
| return prev + lookup; |
| }.bind(this), ''); |
| var msgId = token; |
| var msgArgs = []; |
| if (!isPluralized) { |
| var curArg = tree.firstChild; |
| while (curArg) { |
| if (curArg.value[0] != '$') { |
| console.error('Unexpected value: ' + curArg.value); |
| return; |
| } |
| var msgBuff = []; |
| this.format_(node, curArg, msgBuff); |
| // Fill in empty string if nothing was formatted. |
| if (!msgBuff.length) |
| msgBuff = ['']; |
| msgArgs = msgArgs.concat(msgBuff); |
| curArg = curArg.nextSibling; |
| } |
| } |
| var msg = Msgs.getMsg(msgId, msgArgs); |
| try { |
| if (this.formatOptions_.braille) |
| msg = Msgs.getMsg(msgId + '_brl', msgArgs) || msg; |
| } catch(e) {} |
| |
| if (!msg) { |
| console.error('Could not get message ' + msgId); |
| return; |
| } |
| |
| if (isPluralized) { |
| var arg = tree.firstChild; |
| if (!arg || arg.nextSibling) { |
| console.error('Pluralized messages take exactly one argument'); |
| return; |
| } |
| if (arg.value[0] != '$') { |
| console.error('Unexpected value: ' + arg.value); |
| return; |
| } |
| var argBuff = []; |
| this.format_(node, arg, argBuff); |
| var namedArgs = {COUNT: Number(argBuff[0])}; |
| msg = new goog.i18n.MessageFormat(msg).format(namedArgs); |
| } |
| |
| this.append_(buff, msg, options); |
| } else if (prefix == '!') { |
| speechProps = new Output.SpeechProperties(); |
| speechProps[token] = true; |
| if (tree.firstChild) { |
| if (!this.formatOptions_.auralStyle) { |
| speechProps = undefined; |
| return; |
| } |
| |
| var value = tree.firstChild.value; |
| |
| // Currently, speech params take either attributes or floats. |
| var float = 0; |
| if (float = parseFloat(value)) |
| value = float; |
| else |
| value = parseFloat(node[value]) / -10.0; |
| speechProps[token] = value; |
| return; |
| } |
| } |
| |
| // Post processing. |
| if (speechProps) { |
| if (buff.length > 0) { |
| buff[buff.length - 1].setSpan(speechProps, 0, 0); |
| speechProps = null; |
| } |
| } |
| }.bind(this)); |
| }, |
| |
| /** |
| * @param {!cursors.Range} range |
| * @param {cursors.Range} prevRange |
| * @param {EventType|Output.EventType} type |
| * @param {!Array<Spannable>} rangeBuff |
| * @private |
| */ |
| range_: function(range, prevRange, type, rangeBuff) { |
| if (!range.start.node || !range.end.node) |
| return; |
| |
| if (!prevRange && range.start.node.root) |
| prevRange = cursors.Range.fromNode(range.start.node.root); |
| var cursor = cursors.Cursor.fromNode(range.start.node); |
| var prevNode = prevRange.start.node; |
| |
| var formatNodeAndAncestors = function(node, prevNode) { |
| var buff = []; |
| |
| if (this.outputContextFirst_) |
| this.ancestry_(node, prevNode, type, buff); |
| this.node_(node, prevNode, type, buff); |
| if (!this.outputContextFirst_) |
| this.ancestry_(node, prevNode, type, buff); |
| if (node.location) |
| this.locations_.push(node.location); |
| return buff; |
| }.bind(this); |
| |
| var unit = range.isInlineText() ? cursors.Unit.TEXT : cursors.Unit.NODE; |
| while (cursor.node && |
| range.end.node && |
| AutomationUtil.getDirection(cursor.node, range.end.node) == |
| Dir.FORWARD) { |
| var node = cursor.node; |
| rangeBuff.push.apply(rangeBuff, formatNodeAndAncestors(node, prevNode)); |
| prevNode = node; |
| cursor = cursor.move(unit, |
| cursors.Movement.DIRECTIONAL, |
| Dir.FORWARD); |
| |
| // Reached a boundary. |
| if (cursor.node == prevNode) |
| break; |
| } |
| }, |
| |
| /** |
| * @param {!AutomationNode} node |
| * @param {!AutomationNode} prevNode |
| * @param {EventType|Output.EventType} type |
| * @param {!Array<Spannable>} buff |
| * @private |
| */ |
| ancestry_: function(node, prevNode, type, buff) { |
| // Expects |ancestors| to be ordered from root down to leaf. Outputs in |
| // reverse; place context first nodes at the end. |
| function byContextFirst(ancestors) { |
| var contextFirst = []; |
| var rest = []; |
| for (i = 0; i < ancestors.length - 1; i++) { |
| var node = ancestors[i]; |
| // Discard ancestors of deepest window. |
| if (node.role == RoleType.WINDOW) { |
| contextFirst = []; |
| rest = []; |
| } |
| if ((Output.ROLE_INFO_[node.role] || {}).outputContextFirst) |
| contextFirst.push(node); |
| else |
| rest.push(node); |
| } |
| return rest.concat(contextFirst.reverse()); |
| } |
| var prevUniqueAncestors = byContextFirst(AutomationUtil.getUniqueAncestors( |
| node, prevNode)); |
| var uniqueAncestors = byContextFirst(AutomationUtil.getUniqueAncestors( |
| prevNode, node)); |
| |
| // First, look up the event type's format block. |
| // Navigate is the default event. |
| var eventBlock = Output.RULES[type] || Output.RULES['navigate']; |
| |
| var getMergedRoleBlock = function(role) { |
| var parentRole = (Output.ROLE_INFO_[role] || {}).inherits; |
| var roleBlock = eventBlock[role] || eventBlock['default']; |
| var parentRoleBlock = parentRole ? eventBlock[parentRole] : {}; |
| var mergedRoleBlock = {}; |
| for (var key in parentRoleBlock) |
| mergedRoleBlock[key] = parentRoleBlock[key]; |
| for (var key in roleBlock) |
| mergedRoleBlock[key] = roleBlock[key]; |
| return mergedRoleBlock; |
| }; |
| |
| // Hash the roles we've entered. |
| var enteredRoleSet = {}; |
| for (var j = uniqueAncestors.length - 1, hashNode; |
| (hashNode = uniqueAncestors[j]); |
| j--) |
| enteredRoleSet[hashNode.role] = true; |
| |
| for (var i = 0, formatPrevNode; |
| (formatPrevNode = prevUniqueAncestors[i]); |
| i++) { |
| // This prevents very repetitive announcements. |
| if (enteredRoleSet[formatPrevNode.role] || |
| node.role == formatPrevNode.role || |
| localStorage['useVerboseMode'] == 'false') |
| continue; |
| |
| var roleBlock = getMergedRoleBlock(formatPrevNode.role); |
| if (roleBlock.leave && localStorage['useVerboseMode'] == 'true') |
| this.format_(formatPrevNode, roleBlock.leave, buff, prevNode); |
| } |
| |
| // Customize for braille node annotations. |
| var originalBuff = buff; |
| var enterRole = {}; |
| for (var j = uniqueAncestors.length - 1, formatNode; |
| (formatNode = uniqueAncestors[j]); |
| j--) { |
| var roleBlock = getMergedRoleBlock(formatNode.role); |
| if (roleBlock.enter) { |
| if (enterRole[formatNode.role]) |
| continue; |
| |
| if (this.formatOptions_.braille) |
| buff = []; |
| |
| enterRole[formatNode.role] = true; |
| this.format_(formatNode, roleBlock.enter, buff, prevNode); |
| |
| if (this.formatOptions_.braille && buff.length) { |
| var nodeSpan = this.mergeBraille_(buff); |
| nodeSpan.setSpan(new Output.NodeSpan(formatNode), 0, nodeSpan.length); |
| originalBuff.push(nodeSpan); |
| } |
| } |
| } |
| }, |
| |
| /** |
| * @param {!AutomationNode} node |
| * @param {!AutomationNode} prevNode |
| * @param {EventType|Output.EventType} type |
| * @param {!Array<Spannable>} buff |
| * @private |
| */ |
| node_: function(node, prevNode, type, buff) { |
| var originalBuff = buff; |
| |
| if (this.formatOptions_.braille) |
| buff = []; |
| |
| // Navigate is the default event. |
| var eventBlock = Output.RULES[type] || Output.RULES['navigate']; |
| var roleBlock = eventBlock[node.role] || {}; |
| var parentRole = (Output.ROLE_INFO_[node.role] || {}).inherits; |
| var parentRoleBlock = eventBlock[parentRole || ''] || {}; |
| var speakFormat = roleBlock.speak || |
| parentRoleBlock.speak || |
| eventBlock['default'].speak; |
| |
| this.format_(node, speakFormat, buff, prevNode); |
| |
| // Restore braille and add an annotation for this node. |
| if (this.formatOptions_.braille) { |
| var nodeSpan = this.mergeBraille_(buff); |
| nodeSpan.setSpan(new Output.NodeSpan(node), 0, nodeSpan.length); |
| originalBuff.push(nodeSpan); |
| } |
| }, |
| |
| /** |
| * @param {!cursors.Range} range |
| * @param {cursors.Range} prevRange |
| * @param {EventType|Output.EventType} type |
| * @param {!Array<Spannable>} buff |
| * @private |
| */ |
| subNode_: function(range, prevRange, type, buff) { |
| if (!prevRange) |
| prevRange = range; |
| var dir = cursors.Range.getDirection(prevRange, range); |
| var node = range.start.node; |
| var prevNode = prevRange.getBound(dir).node; |
| if (!node || !prevNode) |
| return; |
| |
| var options = {annotation: ['name'], isUnique: true}; |
| var rangeStart = range.start.index; |
| var rangeEnd = range.end.index; |
| if (this.formatOptions_.braille) { |
| options.annotation.push(new Output.NodeSpan(node)); |
| var selStart = node.textSelStart; |
| var 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 Output.SelectionSpan( |
| 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 Output.SelectionSpan(rangeStart, rangeEnd)); |
| } |
| } |
| |
| if (this.outputContextFirst_) |
| this.ancestry_(node, prevNode, type, buff); |
| var earcon = this.findEarcon_(node, prevNode); |
| if (earcon) |
| options.annotation.push(earcon); |
| var 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); |
| } |
| |
| this.append_(buff, text, options); |
| |
| if (!this.outputContextFirst_) |
| this.ancestry_(node, prevNode, type, buff); |
| |
| var loc = |
| range.start.node.boundsForRange(rangeStart, rangeEnd); |
| if (loc) |
| this.locations_.push(loc); |
| }, |
| |
| /** |
| * Appends output to the |buff|. |
| * @param {!Array<Spannable>} buff |
| * @param {string|!Spannable} value |
| * @param {{isUnique: (boolean|undefined), |
| * annotation: !Array<*>}=} opt_options |
| */ |
| append_: function(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 Output.Action) && |
| !(a instanceof Output.SelectionSpan); |
| |
| })) |
| return; |
| |
| var 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) { |
| var annotationSansNodes = opt_options.annotation.filter( |
| function(annotation) { |
| return !(annotation instanceof Output.NodeSpan); |
| }); |
| |
| var alreadyAnnotated = buff.some(function(s) { |
| return annotationSansNodes.some(function(annotation) { |
| if (!s.hasSpan(annotation)) |
| return false; |
| var start = s.getSpanStart(annotation); |
| var end = s.getSpanEnd(annotation); |
| var substr = s.substring(start, end); |
| if (substr && value) |
| return substr.toString() == value.toString(); |
| else |
| return false; |
| }); |
| }); |
| if (alreadyAnnotated) |
| return; |
| } |
| |
| buff.push(spannableToAdd); |
| }, |
| |
| /** |
| * Parses the token containing a custom function and returns a tree. |
| * @param {string} inputStr |
| * @return {Object} |
| * @private |
| */ |
| createParseTree_: function(inputStr) { |
| var root = {value: ''}; |
| var currentNode = root; |
| var index = 0; |
| var braceNesting = 0; |
| while (index < inputStr.length) { |
| if (inputStr[index] == '(') { |
| currentNode.firstChild = {value: ''}; |
| currentNode.firstChild.parent = currentNode; |
| currentNode = currentNode.firstChild; |
| } else if (inputStr[index] == ')') { |
| currentNode = currentNode.parent; |
| } else if (inputStr[index] == '{') { |
| braceNesting++; |
| currentNode.value += inputStr[index]; |
| } else if (inputStr[index] == '}') { |
| braceNesting--; |
| currentNode.value += inputStr[index]; |
| } else if (inputStr[index] == ',' && braceNesting === 0) { |
| currentNode.nextSibling = {value: ''}; |
| currentNode.nextSibling.parent = currentNode.parent; |
| currentNode = currentNode.nextSibling; |
| } else { |
| currentNode.value += inputStr[index]; |
| } |
| index++; |
| } |
| |
| if (currentNode != root) |
| throw 'Unbalanced parenthesis: ' + inputStr; |
| |
| return root; |
| }, |
| |
| /** |
| * Converts the braille |spans| buffer to a single spannable. |
| * @param {!Array<Spannable>} spans |
| * @return {!Spannable} |
| * @private |
| */ |
| mergeBraille_: function(spans) { |
| var separator = ''; // Changes to space as appropriate. |
| var prevHasInlineNode = false; |
| var prevIsName = false; |
| return spans.reduce(function(result, cur) { |
| // Ignore empty spans except when they contain a selection. |
| var hasSelection = cur.getSpanInstanceOf(Output.SelectionSpan); |
| 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|. |
| var hasInlineNode = cur.getSpansInstanceOf(Output.NodeSpan) |
| .some(function(s) { |
| if (!s.node) |
| return false; |
| return s.node.display == 'inline' || |
| s.node.role == RoleType.INLINE_TEXT_BOX; |
| }); |
| |
| var 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 {Output.Action} |
| */ |
| findEarcon_: function(node, opt_prevNode) { |
| if (node === opt_prevNode) |
| return null; |
| |
| if (this.formatOptions_.speech) { |
| var earconFinder = node; |
| var ancestors; |
| if (opt_prevNode) |
| ancestors = AutomationUtil.getUniqueAncestors(opt_prevNode, node); |
| else |
| ancestors = AutomationUtil.getAncestors(node); |
| |
| while (earconFinder = ancestors.pop()) { |
| var info = Output.ROLE_INFO_[earconFinder.role]; |
| if (info && info.earconId) { |
| return new Output.EarconAction(info.earconId, |
| node.location || undefined); |
| break; |
| } |
| earconFinder = earconFinder.parent; |
| } |
| } |
| return null; |
| }, |
| |
| /** |
| * Gets a human friendly string with the contents of output. |
| * @return {string} |
| */ |
| toString: function() { |
| return this.speechBuffer_.reduce(function(prev, cur) { |
| if (prev === null) |
| 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_); |
| } |
| }; |
| |
| }); // goog.scope |