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>}
81 alert: this.onEventDefault,
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 * Provides all feedback once a focus event fires.
458 * @param {Object} evt
460 onFocus: function(evt) {
461 // Invalidate any previous editable text handler state.
462 this.editableTextHandler = null;
464 var node = evt.target;
466 // It almost never makes sense to place focus directly on a rootWebArea.
467 if (node.role == RoleType.rootWebArea) {
468 // Try to find a focusable descendant.
469 node = AutomationUtil.findNodePost(node,
471 AutomationPredicate.focused) || node;
473 // Fall back to the first leaf node in the document.
474 if (node.role == RoleType.rootWebArea) {
475 node = AutomationUtil.findNodePost(node,
477 AutomationPredicate.leaf);
481 if (evt.target.role == RoleType.textField)
482 this.createEditableTextHandlerIfNeeded_(evt.target);
484 this.onEventDefault({target: node, type: 'focus'});
488 * Provides all feedback once a load complete event fires.
489 * @param {Object} evt
491 onLoadComplete: function(evt) {
492 this.setupChromeVoxVariants_(evt.target.docUrl);
494 // Don't process nodes inside of web content if ChromeVox Next is inactive.
495 if (evt.target.root.role != RoleType.desktop &&
496 this.mode_ === ChromeVoxMode.CLASSIC)
499 // If initial focus was already placed on this page (e.g. if a user starts
500 // tabbing before load complete), then don't move ChromeVox's position on
502 if (this.currentRange_ &&
503 this.currentRange_.start.node.role != RoleType.rootWebArea &&
504 this.currentRange_.start.node.root.docUrl == evt.target.docUrl)
507 var root = evt.target;
509 while (webView && webView.role != RoleType.webView)
510 webView = webView.parent;
512 if (!webView || !webView.state.focused)
515 var node = AutomationUtil.findNodePost(root,
517 AutomationPredicate.leaf);
520 this.currentRange_ = cursors.Range.fromNode(node);
522 if (this.currentRange_)
523 new Output().withSpeechAndBraille(
524 this.currentRange_, null, evt.type)
529 * Provides all feedback once a text selection change event fires.
530 * @param {Object} evt
532 onTextOrTextSelectionChanged: function(evt) {
533 // Don't process nodes inside of web content if ChromeVox Next is inactive.
534 if (evt.target.root.role != RoleType.desktop &&
535 this.mode_ === ChromeVoxMode.CLASSIC)
538 if (!evt.target.state.focused)
541 if (!this.currentRange_) {
542 this.onEventDefault(evt);
543 this.currentRange_ = cursors.Range.fromNode(evt.target);
546 this.createEditableTextHandlerIfNeeded_(evt.target);
547 var textChangeEvent = new cvox.TextChangeEvent(
549 evt.target.textSelStart,
550 evt.target.textSelEnd,
551 true); // triggered by user
553 this.editableTextHandler_.changed(textChangeEvent);
555 new Output().withBraille(
556 this.currentRange_, null, evt.type)
561 * Provides all feedback once a value changed event fires.
562 * @param {Object} evt
564 onValueChanged: function(evt) {
565 // Don't process nodes inside of web content if ChromeVox Next is inactive.
566 if (evt.target.root.role != RoleType.desktop &&
567 this.mode_ === ChromeVoxMode.CLASSIC)
570 if (!evt.target.state.focused)
573 // Value change events fire on web text fields and text areas when pressing
574 // enter; suppress them.
575 if (!this.currentRange_ ||
576 evt.target.role != RoleType.textField) {
577 this.onEventDefault(evt);
578 this.currentRange_ = cursors.Range.fromNode(evt.target);
583 * Called when the automation tree is changed.
584 * @param {chrome.automation.TreeChange} treeChange
586 onTreeChange: function(treeChange) {
587 if (this.mode_ === ChromeVoxMode.CLASSIC)
590 var node = treeChange.target;
591 if (!node.containerLiveStatus)
594 if (node.containerLiveRelevant.indexOf('additions') >= 0 &&
595 treeChange.type == 'nodeCreated')
596 this.outputLiveRegionChange_(node, null);
597 if (node.containerLiveRelevant.indexOf('text') >= 0 &&
598 treeChange.type == 'nodeChanged')
599 this.outputLiveRegionChange_(node, null);
600 if (node.containerLiveRelevant.indexOf('removals') >= 0 &&
601 treeChange.type == 'nodeRemoved')
602 this.outputLiveRegionChange_(node, '@live_regions_removed');
606 * Given a node that needs to be spoken as part of a live region
607 * change and an additional optional format string, output the
608 * live region description.
609 * @param {!chrome.automation.AutomationNode} node The changed node.
610 * @param {?string} opt_prependFormatStr If set, a format string for
611 * cvox2.Output to prepend to the output.
614 outputLiveRegionChange_: function(node, opt_prependFormatStr) {
615 var range = cursors.Range.fromNode(node);
616 var output = new Output();
617 if (opt_prependFormatStr) {
618 output.format(opt_prependFormatStr);
620 output.withSpeech(range, null, Output.EventType.NAVIGATE);
625 * Returns true if the url should have Classic running.
629 shouldEnableClassicForUrl_: function(url) {
630 return this.mode_ != ChromeVoxMode.FORCE_NEXT &&
631 !this.isWhitelistedForCompat_(url) &&
632 !this.isWhitelistedForNext_(url);
639 isWhitelistedForCompat_: function(url) {
640 return url.indexOf('chrome://md-settings') != -1 ||
641 url.indexOf('chrome://oobe/login') != -1 ||
643 'https://accounts.google.com/embedded/setup/chromeos') === 0 ||
649 * @param {string} url
650 * @return {boolean} Whether the given |url| is whitelisted.
652 isWhitelistedForNext_: function(url) {
653 return this.whitelist_.some(function(item) {
654 return url.indexOf(item) != -1;
659 * Setup ChromeVox variants.
660 * @param {string} url
663 setupChromeVoxVariants_: function(url) {
664 var mode = this.mode_;
665 if (mode != ChromeVoxMode.FORCE_NEXT) {
666 if (this.isWhitelistedForCompat_(url))
667 mode = ChromeVoxMode.COMPAT;
668 else if (this.isWhitelistedForNext_(url))
669 mode = ChromeVoxMode.NEXT;
671 mode = ChromeVoxMode.CLASSIC;
674 this.setChromeVoxMode(mode);
678 * Disables classic ChromeVox in current web content.
680 disableClassicChromeVox_: function() {
681 cvox.ExtensionBridge.send({
682 message: 'SYSTEM_COMMAND',
683 command: 'killChromeVox'
688 * Sets the current ChromeVox mode.
689 * @param {ChromeVoxMode} mode
691 setChromeVoxMode: function(mode) {
692 if (mode === ChromeVoxMode.NEXT ||
693 mode === ChromeVoxMode.COMPAT ||
694 mode === ChromeVoxMode.FORCE_NEXT) {
695 if (!chrome.commands.onCommand.hasListener(this.onGotCommand))
696 chrome.commands.onCommand.addListener(this.onGotCommand);
698 if (chrome.commands.onCommand.hasListener(this.onGotCommand))
699 chrome.commands.onCommand.removeListener(this.onGotCommand);
702 chrome.tabs.query({active: true}, function(tabs) {
703 if (mode === ChromeVoxMode.CLASSIC) {
704 // This case should do nothing because Classic gets injected by the
705 // extension system via our manifest. Once ChromeVox Next is enabled
706 // for tabs, re-enable.
707 // cvox.ChromeVox.injectChromeVoxIntoTabs(tabs);
709 // When in compat mode, if the focus is within the desktop tree proper,
710 // then do not disable content scripts.
711 if (this.currentRange_.start.node.root.role == 'desktop')
714 this.disableClassicChromeVox_();
722 * @param {!cvox.Spannable} text
723 * @param {number} position
726 brailleRoutingCommand_: function(text, position) {
727 var actionNode = null;
728 var selectionSpan = null;
729 text.getSpans(position).forEach(function(span) {
730 if (span instanceof Output.SelectionSpan) {
731 selectionSpan = span;
732 } else if (span instanceof Output.NodeSpan) {
734 (text.getSpanEnd(actionNode) - text.getSpanStart(actionNode) >
735 text.getSpanEnd(span) - text.getSpanStart(span))) {
736 actionNode = span.node;
742 if (actionNode.role === RoleType.inlineTextBox)
743 actionNode = actionNode.parent;
744 actionNode.doDefault();
746 var start = text.getSpanStart(selectionSpan);
747 actionNode.setSelection(position - start, position - start);
752 * Create an editable text handler for the given node if needed.
753 * @param {Object} node
755 createEditableTextHandlerIfNeeded_: function(node) {
756 if (!this.editableTextHandler_ || node != this.currentRange_.start.node) {
757 var start = node.textSelStart;
758 var end = node.textSelEnd;
760 var tempOffset = end;
765 this.editableTextHandler_ =
766 new cvox.ChromeVoxEditableTextBase(
770 node.state.protected,
776 /** @type {Background} */
777 global.backgroundObj = new Background();