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 The entry point for all ChromeVox2 related code for the
10 goog.provide('Background');
11 goog.provide('global');
13 goog.require('AutomationPredicate');
14 goog.require('AutomationUtil');
15 goog.require('ClassicCompatibility');
16 goog.require('Output');
17 goog.require('Output.EventType');
18 goog.require('cursors.Cursor');
19 goog.require('cvox.BrailleKeyCommand');
20 goog.require('cvox.ChromeVoxEditableTextBase');
21 goog.require('cvox.ExtensionBridge');
22 goog.require('cvox.NavBraille');
24 goog.scope(function() {
25 var AutomationNode = chrome.automation.AutomationNode;
26 var Dir = AutomationUtil.Dir;
27 var EventType = chrome.automation.EventType;
28 var RoleType = chrome.automation.RoleType;
31 * All possible modes ChromeVox can run.
38 FORCE_NEXT: 'force_next'
42 * ChromeVox2 background page.
45 Background = function() {
47 * A list of site substring patterns to use with ChromeVox next. Keep these
48 * strings relatively specific.
49 * @type {!Array<string>}
52 this.whitelist_ = ['chromevox_next_test'];
55 * @type {cursors.Range}
58 this.currentRange_ = null;
61 * Which variant of ChromeVox is active.
62 * @type {ChromeVoxMode}
65 this.mode_ = ChromeVoxMode.CLASSIC;
67 /** @type {!ClassicCompatibility} @private */
68 this.compat_ = new ClassicCompatibility();
70 // Manually bind all functions to |this|.
71 for (var func in this) {
72 if (typeof(this[func]) == 'function')
73 this[func] = this[func].bind(this);
77 * Maps an automation event to its listener.
78 * @type {!Object<EventType, function(Object) : void>}
83 hover: this.onEventDefault,
84 loadComplete: this.onLoadComplete,
85 menuStart: this.onEventDefault,
86 menuEnd: this.onEventDefault,
87 textChanged: this.onTextOrTextSelectionChanged,
88 textSelectionChanged: this.onTextOrTextSelectionChanged,
89 valueChanged: this.onValueChanged
93 * The object that speaks changes to an editable text field.
94 * @type {?cvox.ChromeVoxEditableTextBase}
96 this.editableTextHandler_ = null;
98 chrome.automation.getDesktop(this.onGotDesktop);
100 // Handle messages directed to the Next background page.
101 cvox.ExtensionBridge.addMessageListener(function(msg, port) {
102 var target = msg['target'];
103 var action = msg['action'];
107 if (action == 'getIsClassicEnabled') {
108 var url = msg['url'];
109 var isClassicEnabled = this.shouldEnableClassicForUrl_(url);
112 isClassicEnabled: isClassicEnabled
120 Background.prototype = {
121 /** Forces ChromeVox Next to be active for all tabs. */
122 forceChromeVoxNextActive: function() {
123 this.setChromeVoxMode(ChromeVoxMode.FORCE_NEXT);
127 * Handles all setup once a new automation tree appears.
128 * @param {chrome.automation.AutomationNode} desktop
130 onGotDesktop: function(desktop) {
131 // Register all automation event listeners.
132 for (var eventType in this.listeners_)
133 desktop.addEventListener(eventType, this.listeners_[eventType], true);
135 // Register a tree change observer.
136 chrome.automation.addTreeChangeObserver(this.onTreeChange);
138 // The focused state gets set on the containing webView node.
139 var webView = desktop.find({role: RoleType.webView,
140 state: {focused: true}});
142 var root = webView.find({role: RoleType.rootWebArea});
146 type: chrome.automation.EventType.loadComplete});
152 * Handles chrome.commands.onCommand.
153 * @param {string} command
154 * @param {boolean=} opt_skipCompat Whether to skip compatibility checks.
156 onGotCommand: function(command, opt_skipCompat) {
157 if (!this.currentRange_)
160 if (!opt_skipCompat && this.mode_ === ChromeVoxMode.COMPAT) {
161 if (this.compat_.onGotCommand(command))
165 var current = this.currentRange_;
166 var dir = Dir.FORWARD;
168 var predErrorMsg = undefined;
170 case 'nextCharacter':
171 current = current.move(cursors.Unit.CHARACTER, Dir.FORWARD);
173 case 'previousCharacter':
174 current = current.move(cursors.Unit.CHARACTER, Dir.BACKWARD);
177 current = current.move(cursors.Unit.WORD, Dir.FORWARD);
180 current = current.move(cursors.Unit.WORD, Dir.BACKWARD);
183 current = current.move(cursors.Unit.LINE, Dir.FORWARD);
186 current = current.move(cursors.Unit.LINE, Dir.BACKWARD);
190 pred = AutomationPredicate.button;
191 predErrorMsg = 'no_next_button';
193 case 'previousButton':
195 pred = AutomationPredicate.button;
196 predErrorMsg = 'no_previous_button';
200 pred = AutomationPredicate.checkBox;
201 predErrorMsg = 'no_next_checkbox';
203 case 'previousCheckBox':
205 pred = AutomationPredicate.checkBox;
206 predErrorMsg = 'no_previous_checkbox';
210 pred = AutomationPredicate.comboBox;
211 predErrorMsg = 'no_next_combo_box';
213 case 'previousComboBox':
215 pred = AutomationPredicate.comboBox;
216 predErrorMsg = 'no_previous_combo_box';
220 pred = AutomationPredicate.editText;
221 predErrorMsg = 'no_next_edit_text';
223 case 'previousEditText':
225 pred = AutomationPredicate.editText;
226 predErrorMsg = 'no_previous_edit_text';
228 case 'nextFormField':
230 pred = AutomationPredicate.formField;
231 predErrorMsg = 'no_next_form_field';
233 case 'previousFormField':
235 pred = AutomationPredicate.formField;
236 predErrorMsg = 'no_previous_form_field';
240 pred = AutomationPredicate.heading;
241 predErrorMsg = 'no_next_heading';
243 case 'previousHeading':
245 pred = AutomationPredicate.heading;
246 predErrorMsg = 'no_previous_heading';
250 pred = AutomationPredicate.link;
251 predErrorMsg = 'no_next_link';
255 pred = AutomationPredicate.link;
256 predErrorMsg = 'no_previous_link';
260 pred = AutomationPredicate.table;
261 predErrorMsg = 'no_next_table';
263 case 'previousTable':
265 pred = AutomationPredicate.table;
266 predErrorMsg = 'no_previous_table';
268 case 'nextVisitedLink':
270 pred = AutomationPredicate.visitedLink;
271 predErrorMsg = 'no_next_visited_link';
273 case 'previousVisitedLink':
275 pred = AutomationPredicate.visitedLink;
276 predErrorMsg = 'no_previous_visited_link';
279 current = current.move(cursors.Unit.DOM_NODE, Dir.FORWARD);
281 case 'previousElement':
282 current = current.move(cursors.Unit.DOM_NODE, Dir.BACKWARD);
284 case 'goToBeginning':
286 AutomationUtil.findNodePost(current.start.node.root,
288 AutomationPredicate.leaf);
290 current = cursors.Range.fromNode(node);
294 AutomationUtil.findNodePost(current.start.node.root,
296 AutomationPredicate.leaf);
298 current = cursors.Range.fromNode(node);
301 if (this.currentRange_) {
302 var actionNode = this.currentRange_.start.node;
303 if (actionNode.role == RoleType.inlineTextBox)
304 actionNode = actionNode.parent;
305 actionNode.doDefault();
307 // Skip all other processing; if focus changes, we should get an event
310 case 'continuousRead':
311 global.isReadingContinuously = true;
312 var continueReading = function(prevRange) {
313 if (!global.isReadingContinuously || !this.currentRange_)
316 new Output().withSpeechAndBraille(
317 this.currentRange_, prevRange, Output.EventType.NAVIGATE)
318 .onSpeechEnd(function() { continueReading(prevRange); })
320 prevRange = this.currentRange_;
322 this.currentRange_.move(cursors.Unit.NODE, Dir.FORWARD);
324 if (!this.currentRange_ || this.currentRange_.equals(prevRange))
325 global.isReadingContinuously = false;
328 continueReading(null);
330 case 'showContextMenu':
331 if (this.currentRange_) {
332 var actionNode = this.currentRange_.start.node;
333 if (actionNode.role == RoleType.inlineTextBox)
334 actionNode = actionNode.parent;
335 actionNode.showContextMenu();
339 case 'showOptionsPage':
340 var optionsPage = {url: 'chromevox/background/options.html'};
341 chrome.tabs.create(optionsPage);
346 var node = AutomationUtil.findNextNode(
347 current.getBound(dir).node, dir, pred);
350 current = cursors.Range.fromNode(node);
353 cvox.ChromeVox.tts.speak(cvox.ChromeVox.msgs.getMsg(predErrorMsg),
354 cvox.QueueMode.FLUSH);
361 // TODO(dtseng): Figure out what it means to focus a range.
362 var actionNode = current.start.node;
363 if (actionNode.role == RoleType.inlineTextBox)
364 actionNode = actionNode.parent;
367 var prevRange = this.currentRange_;
368 this.currentRange_ = current;
370 new Output().withSpeechAndBraille(
371 this.currentRange_, prevRange, Output.EventType.NAVIGATE)
377 * Handles a braille command.
378 * @param {!cvox.BrailleKeyEvent} evt
379 * @param {!cvox.NavBraille} content
380 * @return {boolean} True if evt was processed.
382 onBrailleKeyEvent: function(evt, content) {
383 if (this.mode_ === ChromeVoxMode.CLASSIC)
386 switch (evt.command) {
387 case cvox.BrailleKeyCommand.PAN_LEFT:
388 this.onGotCommand('previousElement', true);
390 case cvox.BrailleKeyCommand.PAN_RIGHT:
391 this.onGotCommand('nextElement', true);
393 case cvox.BrailleKeyCommand.LINE_UP:
394 this.onGotCommand('previousLine', true);
396 case cvox.BrailleKeyCommand.LINE_DOWN:
397 this.onGotCommand('nextLine', true);
399 case cvox.BrailleKeyCommand.TOP:
400 this.onGotCommand('goToBeginning', true);
402 case cvox.BrailleKeyCommand.BOTTOM:
403 this.onGotCommand('goToEnd', true);
405 case cvox.BrailleKeyCommand.ROUTING:
406 this.brailleRoutingCommand_(
408 // Cast ok since displayPosition is always defined in this case.
409 /** @type {number} */ (evt.displayPosition));
418 * Provides all feedback once ChromeVox's focus changes.
419 * @param {Object} evt
421 onEventDefault: function(evt) {
422 var node = evt.target;
427 var prevRange = this.currentRange_;
429 this.currentRange_ = cursors.Range.fromNode(node);
431 // Check to see if we've crossed roots. Continue if we've crossed roots or
432 // are not within web content.
433 if (node.root.role == 'desktop' ||
435 prevRange.start.node.root != node.root)
436 this.setupChromeVoxVariants_(node.root.docUrl || '');
438 // Don't process nodes inside of web content if ChromeVox Next is inactive.
439 if (node.root.role != RoleType.desktop &&
440 this.mode_ === ChromeVoxMode.CLASSIC) {
441 chrome.accessibilityPrivate.setFocusRing([]);
445 // Don't output if focused node hasn't changed.
447 evt.type == 'focus' &&
448 this.currentRange_.equals(prevRange))
451 new Output().withSpeechAndBraille(
452 this.currentRange_, prevRange, evt.type)
457 * Makes an announcement without changing focus.
458 * @param {Object} evt
460 onAlert: function(evt) {
461 var node = evt.target;
465 // Don't process nodes inside of web content if ChromeVox Next is inactive.
466 if (node.root.role != RoleType.desktop &&
467 this.mode_ === ChromeVoxMode.CLASSIC) {
471 var range = cursors.Range.fromNode(node);
473 new Output().withSpeechAndBraille(range, null, evt.type).go();
477 * Provides all feedback once a focus event fires.
478 * @param {Object} evt
480 onFocus: function(evt) {
481 // Invalidate any previous editable text handler state.
482 this.editableTextHandler = null;
484 var node = evt.target;
486 // It almost never makes sense to place focus directly on a rootWebArea.
487 if (node.role == RoleType.rootWebArea) {
488 // Try to find a focusable descendant.
489 node = AutomationUtil.findNodePost(node,
491 AutomationPredicate.focused) || node;
493 // Fall back to the first leaf node in the document.
494 if (node.role == RoleType.rootWebArea) {
495 node = AutomationUtil.findNodePost(node,
497 AutomationPredicate.leaf);
501 if (evt.target.role == RoleType.textField)
502 this.createEditableTextHandlerIfNeeded_(evt.target);
504 this.onEventDefault({target: node, type: 'focus'});
508 * Provides all feedback once a load complete event fires.
509 * @param {Object} evt
511 onLoadComplete: function(evt) {
512 this.setupChromeVoxVariants_(evt.target.docUrl);
514 // Don't process nodes inside of web content if ChromeVox Next is inactive.
515 if (evt.target.root.role != RoleType.desktop &&
516 this.mode_ === ChromeVoxMode.CLASSIC)
519 // If initial focus was already placed on this page (e.g. if a user starts
520 // tabbing before load complete), then don't move ChromeVox's position on
522 if (this.currentRange_ &&
523 this.currentRange_.start.node.role != RoleType.rootWebArea &&
524 this.currentRange_.start.node.root.docUrl == evt.target.docUrl)
527 var root = evt.target;
529 while (webView && webView.role != RoleType.webView)
530 webView = webView.parent;
532 if (!webView || !webView.state.focused)
535 var node = AutomationUtil.findNodePost(root,
537 AutomationPredicate.leaf);
540 this.currentRange_ = cursors.Range.fromNode(node);
542 if (this.currentRange_)
543 new Output().withSpeechAndBraille(
544 this.currentRange_, null, evt.type)
549 * Provides all feedback once a text selection change event fires.
550 * @param {Object} evt
552 onTextOrTextSelectionChanged: function(evt) {
553 // Don't process nodes inside of web content if ChromeVox Next is inactive.
554 if (evt.target.root.role != RoleType.desktop &&
555 this.mode_ === ChromeVoxMode.CLASSIC)
558 if (!evt.target.state.focused)
561 if (!this.currentRange_) {
562 this.onEventDefault(evt);
563 this.currentRange_ = cursors.Range.fromNode(evt.target);
566 this.createEditableTextHandlerIfNeeded_(evt.target);
567 var textChangeEvent = new cvox.TextChangeEvent(
569 evt.target.textSelStart,
570 evt.target.textSelEnd,
571 true); // triggered by user
573 this.editableTextHandler_.changed(textChangeEvent);
575 new Output().withBraille(
576 this.currentRange_, null, evt.type)
581 * Provides all feedback once a value changed event fires.
582 * @param {Object} evt
584 onValueChanged: function(evt) {
585 // Don't process nodes inside of web content if ChromeVox Next is inactive.
586 if (evt.target.root.role != RoleType.desktop &&
587 this.mode_ === ChromeVoxMode.CLASSIC)
590 if (!evt.target.state.focused)
593 // Value change events fire on web text fields and text areas when pressing
594 // enter; suppress them.
595 if (!this.currentRange_ ||
596 evt.target.role != RoleType.textField) {
597 this.onEventDefault(evt);
598 this.currentRange_ = cursors.Range.fromNode(evt.target);
603 * Called when the automation tree is changed.
604 * @param {chrome.automation.TreeChange} treeChange
606 onTreeChange: function(treeChange) {
607 if (this.mode_ === ChromeVoxMode.CLASSIC)
610 var node = treeChange.target;
611 if (!node.containerLiveStatus)
614 if (node.containerLiveRelevant.indexOf('additions') >= 0 &&
615 treeChange.type == 'nodeCreated')
616 this.outputLiveRegionChange_(node, null);
617 if (node.containerLiveRelevant.indexOf('text') >= 0 &&
618 treeChange.type == 'nodeChanged')
619 this.outputLiveRegionChange_(node, null);
620 if (node.containerLiveRelevant.indexOf('removals') >= 0 &&
621 treeChange.type == 'nodeRemoved')
622 this.outputLiveRegionChange_(node, '@live_regions_removed');
626 * Given a node that needs to be spoken as part of a live region
627 * change and an additional optional format string, output the
628 * live region description.
629 * @param {!chrome.automation.AutomationNode} node The changed node.
630 * @param {?string} opt_prependFormatStr If set, a format string for
631 * cvox2.Output to prepend to the output.
634 outputLiveRegionChange_: function(node, opt_prependFormatStr) {
635 var range = cursors.Range.fromNode(node);
636 var output = new Output();
637 if (opt_prependFormatStr) {
638 output.format(opt_prependFormatStr);
640 output.withSpeech(range, null, Output.EventType.NAVIGATE);
645 * Returns true if the url should have Classic running.
649 shouldEnableClassicForUrl_: function(url) {
650 return this.mode_ != ChromeVoxMode.FORCE_NEXT &&
651 !this.isWhitelistedForCompat_(url) &&
652 !this.isWhitelistedForNext_(url);
659 isWhitelistedForCompat_: function(url) {
660 return url.indexOf('chrome://md-settings') != -1 ||
661 url.indexOf('chrome://oobe/login') != -1 ||
663 'https://accounts.google.com/embedded/setup/chromeos') === 0 ||
669 * @param {string} url
670 * @return {boolean} Whether the given |url| is whitelisted.
672 isWhitelistedForNext_: function(url) {
673 return this.whitelist_.some(function(item) {
674 return url.indexOf(item) != -1;
679 * Setup ChromeVox variants.
680 * @param {string} url
683 setupChromeVoxVariants_: function(url) {
684 var mode = this.mode_;
685 if (mode != ChromeVoxMode.FORCE_NEXT) {
686 if (this.isWhitelistedForCompat_(url))
687 mode = ChromeVoxMode.COMPAT;
688 else if (this.isWhitelistedForNext_(url))
689 mode = ChromeVoxMode.NEXT;
691 mode = ChromeVoxMode.CLASSIC;
694 this.setChromeVoxMode(mode);
698 * Disables classic ChromeVox in current web content.
700 disableClassicChromeVox_: function() {
701 cvox.ExtensionBridge.send({
702 message: 'SYSTEM_COMMAND',
703 command: 'killChromeVox'
708 * Sets the current ChromeVox mode.
709 * @param {ChromeVoxMode} mode
711 setChromeVoxMode: function(mode) {
712 if (mode === ChromeVoxMode.NEXT ||
713 mode === ChromeVoxMode.COMPAT ||
714 mode === ChromeVoxMode.FORCE_NEXT) {
715 if (!chrome.commands.onCommand.hasListener(this.onGotCommand))
716 chrome.commands.onCommand.addListener(this.onGotCommand);
718 if (chrome.commands.onCommand.hasListener(this.onGotCommand))
719 chrome.commands.onCommand.removeListener(this.onGotCommand);
722 chrome.tabs.query({active: true}, function(tabs) {
723 if (mode === ChromeVoxMode.CLASSIC) {
724 // This case should do nothing because Classic gets injected by the
725 // extension system via our manifest. Once ChromeVox Next is enabled
726 // for tabs, re-enable.
727 // cvox.ChromeVox.injectChromeVoxIntoTabs(tabs);
729 // When in compat mode, if the focus is within the desktop tree proper,
730 // then do not disable content scripts.
731 if (this.currentRange_.start.node.root.role == 'desktop')
734 this.disableClassicChromeVox_();
742 * @param {!cvox.Spannable} text
743 * @param {number} position
746 brailleRoutingCommand_: function(text, position) {
747 var actionNode = null;
748 var selectionSpan = null;
749 text.getSpans(position).forEach(function(span) {
750 if (span instanceof Output.SelectionSpan) {
751 selectionSpan = span;
752 } else if (span instanceof Output.NodeSpan) {
754 (text.getSpanEnd(actionNode) - text.getSpanStart(actionNode) >
755 text.getSpanEnd(span) - text.getSpanStart(span))) {
756 actionNode = span.node;
762 if (actionNode.role === RoleType.inlineTextBox)
763 actionNode = actionNode.parent;
764 actionNode.doDefault();
766 var start = text.getSpanStart(selectionSpan);
767 actionNode.setSelection(position - start, position - start);
772 * Create an editable text handler for the given node if needed.
773 * @param {Object} node
775 createEditableTextHandlerIfNeeded_: function(node) {
776 if (!this.editableTextHandler_ || node != this.currentRange_.start.node) {
777 var start = node.textSelStart;
778 var end = node.textSelEnd;
780 var tempOffset = end;
785 this.editableTextHandler_ =
786 new cvox.ChromeVoxEditableTextBase(
790 node.state.protected,
796 /** @type {Background} */
797 global.backgroundObj = new Background();