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
92 chrome.automation.getDesktop(this.onGotDesktop);
94 // Handle messages directed to the Next background page.
95 cvox.ExtensionBridge.addMessageListener(function(msg, port) {
96 var target = msg['target'];
97 var action = msg['action'];
101 if (action == 'getIsClassicEnabled') {
102 var url = msg['url'];
103 var isClassicEnabled = this.shouldEnableClassicForUrl_(url);
106 isClassicEnabled: isClassicEnabled
114 Background.prototype = {
115 /** Forces ChromeVox Next to be active for all tabs. */
116 forceChromeVoxNextActive: function() {
117 this.setChromeVoxMode(ChromeVoxMode.FORCE_NEXT);
121 * Handles all setup once a new automation tree appears.
122 * @param {chrome.automation.AutomationNode} desktop
124 onGotDesktop: function(desktop) {
125 // Register all automation event listeners.
126 for (var eventType in this.listeners_)
127 desktop.addEventListener(eventType, this.listeners_[eventType], true);
129 // Register a tree change observer.
130 chrome.automation.addTreeChangeObserver(this.onTreeChange);
132 // The focused state gets set on the containing webView node.
133 var webView = desktop.find({role: RoleType.webView,
134 state: {focused: true}});
136 var root = webView.find({role: RoleType.rootWebArea});
140 type: chrome.automation.EventType.loadComplete});
146 * Handles chrome.commands.onCommand.
147 * @param {string} command
148 * @param {boolean=} opt_skipCompat Whether to skip compatibility checks.
150 onGotCommand: function(command, opt_skipCompat) {
151 if (!this.currentRange_)
154 if (!opt_skipCompat && this.mode_ === ChromeVoxMode.COMPAT) {
155 if (this.compat_.onGotCommand(command))
159 var current = this.currentRange_;
160 var dir = Dir.FORWARD;
162 var predErrorMsg = undefined;
164 case 'nextCharacter':
165 current = current.move(cursors.Unit.CHARACTER, Dir.FORWARD);
167 case 'previousCharacter':
168 current = current.move(cursors.Unit.CHARACTER, Dir.BACKWARD);
171 current = current.move(cursors.Unit.WORD, Dir.FORWARD);
174 current = current.move(cursors.Unit.WORD, Dir.BACKWARD);
177 current = current.move(cursors.Unit.LINE, Dir.FORWARD);
180 current = current.move(cursors.Unit.LINE, Dir.BACKWARD);
184 pred = AutomationPredicate.button;
185 predErrorMsg = 'no_next_button';
187 case 'previousButton':
189 pred = AutomationPredicate.button;
190 predErrorMsg = 'no_previous_button';
194 pred = AutomationPredicate.checkBox;
195 predErrorMsg = 'no_next_checkbox';
197 case 'previousCheckBox':
199 pred = AutomationPredicate.checkBox;
200 predErrorMsg = 'no_previous_checkbox';
204 pred = AutomationPredicate.comboBox;
205 predErrorMsg = 'no_next_combo_box';
207 case 'previousComboBox':
209 pred = AutomationPredicate.comboBox;
210 predErrorMsg = 'no_previous_combo_box';
214 pred = AutomationPredicate.editText;
215 predErrorMsg = 'no_next_edit_text';
217 case 'previousEditText':
219 pred = AutomationPredicate.editText;
220 predErrorMsg = 'no_previous_edit_text';
222 case 'nextFormField':
224 pred = AutomationPredicate.formField;
225 predErrorMsg = 'no_next_form_field';
227 case 'previousFormField':
229 pred = AutomationPredicate.formField;
230 predErrorMsg = 'no_previous_form_field';
234 pred = AutomationPredicate.heading;
235 predErrorMsg = 'no_next_heading';
237 case 'previousHeading':
239 pred = AutomationPredicate.heading;
240 predErrorMsg = 'no_previous_heading';
244 pred = AutomationPredicate.link;
245 predErrorMsg = 'no_next_link';
249 pred = AutomationPredicate.link;
250 predErrorMsg = 'no_previous_link';
254 pred = AutomationPredicate.table;
255 predErrorMsg = 'no_next_table';
257 case 'previousTable':
259 pred = AutomationPredicate.table;
260 predErrorMsg = 'no_previous_table';
262 case 'nextVisitedLink':
264 pred = AutomationPredicate.visitedLink;
265 predErrorMsg = 'no_next_visited_link';
267 case 'previousVisitedLink':
269 pred = AutomationPredicate.visitedLink;
270 predErrorMsg = 'no_previous_visited_link';
273 current = current.move(cursors.Unit.NODE, Dir.FORWARD);
275 case 'previousElement':
276 current = current.move(cursors.Unit.NODE, Dir.BACKWARD);
278 case 'goToBeginning':
280 AutomationUtil.findNodePost(current.start.node.root,
282 AutomationPredicate.leaf);
284 current = cursors.Range.fromNode(node);
288 AutomationUtil.findNodePost(current.start.node.root,
290 AutomationPredicate.leaf);
292 current = cursors.Range.fromNode(node);
295 if (this.currentRange_) {
296 var actionNode = this.currentRange_.start.node;
297 if (actionNode.role == RoleType.inlineTextBox)
298 actionNode = actionNode.parent;
299 actionNode.doDefault();
301 // Skip all other processing; if focus changes, we should get an event
304 case 'continuousRead':
305 global.isReadingContinuously = true;
306 var continueReading = function(prevRange) {
307 if (!global.isReadingContinuously || !this.currentRange_)
310 new Output().withSpeechAndBraille(
311 this.currentRange_, prevRange, Output.EventType.NAVIGATE)
312 .onSpeechEnd(function() { continueReading(prevRange); })
314 prevRange = this.currentRange_;
316 this.currentRange_.move(cursors.Unit.NODE, Dir.FORWARD);
318 if (!this.currentRange_ || this.currentRange_.equals(prevRange))
319 global.isReadingContinuously = false;
322 continueReading(null);
324 case 'showContextMenu':
325 if (this.currentRange_) {
326 var actionNode = this.currentRange_.start.node;
327 if (actionNode.role == RoleType.inlineTextBox)
328 actionNode = actionNode.parent;
329 actionNode.showContextMenu();
333 case 'showOptionsPage':
334 var optionsPage = {url: 'chromevox/background/options.html'};
335 chrome.tabs.create(optionsPage);
340 var node = AutomationUtil.findNextNode(
341 current.getBound(dir).node, dir, pred);
344 current = cursors.Range.fromNode(node);
347 cvox.ChromeVox.tts.speak(cvox.ChromeVox.msgs.getMsg(predErrorMsg),
348 cvox.QueueMode.FLUSH);
355 // TODO(dtseng): Figure out what it means to focus a range.
356 var actionNode = current.start.node;
357 if (actionNode.role == RoleType.inlineTextBox)
358 actionNode = actionNode.parent;
361 var prevRange = this.currentRange_;
362 this.currentRange_ = current;
364 new Output().withSpeechAndBraille(
365 this.currentRange_, prevRange, Output.EventType.NAVIGATE)
371 * Handles a braille command.
372 * @param {!cvox.BrailleKeyEvent} evt
373 * @param {!cvox.NavBraille} content
374 * @return {boolean} True if evt was processed.
376 onBrailleKeyEvent: function(evt, content) {
377 if (this.mode_ === ChromeVoxMode.CLASSIC)
380 switch (evt.command) {
381 case cvox.BrailleKeyCommand.PAN_LEFT:
382 this.onGotCommand('previousElement', true);
384 case cvox.BrailleKeyCommand.PAN_RIGHT:
385 this.onGotCommand('nextElement', true);
387 case cvox.BrailleKeyCommand.LINE_UP:
388 this.onGotCommand('previousLine', true);
390 case cvox.BrailleKeyCommand.LINE_DOWN:
391 this.onGotCommand('nextLine', true);
393 case cvox.BrailleKeyCommand.TOP:
394 this.onGotCommand('goToBeginning', true);
396 case cvox.BrailleKeyCommand.BOTTOM:
397 this.onGotCommand('goToEnd', true);
399 case cvox.BrailleKeyCommand.ROUTING:
400 this.brailleRoutingCommand_(
402 // Cast ok since displayPosition is always defined in this case.
403 /** @type {number} */ (evt.displayPosition));
412 * Provides all feedback once ChromeVox's focus changes.
413 * @param {Object} evt
415 onEventDefault: function(evt) {
416 var node = evt.target;
421 var prevRange = this.currentRange_;
423 this.currentRange_ = cursors.Range.fromNode(node);
425 // Check to see if we've crossed roots. Continue if we've crossed roots or
426 // are not within web content.
427 if (node.root.role == 'desktop' ||
429 prevRange.start.node.root != node.root)
430 this.setupChromeVoxVariants_(node.root.docUrl || '');
432 // Don't process nodes inside of web content if ChromeVox Next is inactive.
433 if (node.root.role != RoleType.desktop &&
434 this.mode_ === ChromeVoxMode.CLASSIC) {
435 chrome.accessibilityPrivate.setFocusRing([]);
439 // Don't output if focused node hasn't changed.
441 evt.type == 'focus' &&
442 this.currentRange_.equals(prevRange))
445 new Output().withSpeechAndBraille(
446 this.currentRange_, prevRange, evt.type)
451 * Provides all feedback once a focus event fires.
452 * @param {Object} evt
454 onFocus: function(evt) {
455 var node = evt.target;
457 // It almost never makes sense to place focus directly on a rootWebArea.
458 if (node.role == RoleType.rootWebArea) {
459 // Try to find a focusable descendant.
460 node = AutomationUtil.findNodePost(node,
462 AutomationPredicate.focused) || node;
464 // Fall back to the first leaf node in the document.
465 if (node.role == RoleType.rootWebArea) {
466 node = AutomationUtil.findNodePost(node,
468 AutomationPredicate.leaf);
471 this.onEventDefault({target: node, type: 'focus'});
475 * Provides all feedback once a load complete event fires.
476 * @param {Object} evt
478 onLoadComplete: function(evt) {
479 this.setupChromeVoxVariants_(evt.target.docUrl);
481 // Don't process nodes inside of web content if ChromeVox Next is inactive.
482 if (evt.target.root.role != RoleType.desktop &&
483 this.mode_ === ChromeVoxMode.CLASSIC)
486 // If initial focus was already placed on this page (e.g. if a user starts
487 // tabbing before load complete), then don't move ChromeVox's position on
489 if (this.currentRange_ &&
490 this.currentRange_.start.node.role != RoleType.rootWebArea &&
491 this.currentRange_.start.node.root.docUrl == evt.target.docUrl)
494 var root = evt.target;
496 while (webView && webView.role != RoleType.webView)
497 webView = webView.parent;
499 if (!webView || !webView.state.focused)
502 var node = AutomationUtil.findNodePost(root,
504 AutomationPredicate.leaf);
507 this.currentRange_ = cursors.Range.fromNode(node);
509 if (this.currentRange_)
510 new Output().withSpeechAndBraille(
511 this.currentRange_, null, evt.type)
516 * Provides all feedback once a text selection change event fires.
517 * @param {Object} evt
519 onTextOrTextSelectionChanged: function(evt) {
520 // Don't process nodes inside of web content if ChromeVox Next is inactive.
521 if (evt.target.root.role != RoleType.desktop &&
522 this.mode_ === ChromeVoxMode.CLASSIC)
525 if (!evt.target.state.focused)
528 if (!this.currentRange_) {
529 this.onEventDefault(evt);
530 this.currentRange_ = cursors.Range.fromNode(evt.target);
533 var textChangeEvent = new cvox.TextChangeEvent(
535 evt.target.textSelStart,
536 evt.target.textSelEnd,
537 true); // triggered by user
538 if (!this.editableTextHandler ||
539 evt.target != this.currentRange_.start.node) {
540 this.editableTextHandler =
541 new cvox.ChromeVoxEditableTextBase(
542 textChangeEvent.value,
543 textChangeEvent.start,
545 evt.target.state['protected'],
549 this.editableTextHandler.changed(textChangeEvent);
550 new Output().withBraille(
551 this.currentRange_, null, evt.type)
556 * Provides all feedback once a value changed event fires.
557 * @param {Object} evt
559 onValueChanged: function(evt) {
560 // Don't process nodes inside of web content if ChromeVox Next is inactive.
561 if (evt.target.root.role != RoleType.desktop &&
562 this.mode_ === ChromeVoxMode.CLASSIC)
565 if (!evt.target.state.focused)
568 // Value change events fire on web text fields and text areas when pressing
569 // enter; suppress them.
570 if (!this.currentRange_ ||
571 evt.target.role != RoleType.textField) {
572 this.onEventDefault(evt);
573 this.currentRange_ = cursors.Range.fromNode(evt.target);
578 * Called when the automation tree is changed.
579 * @param {chrome.automation.TreeChange} treeChange
581 onTreeChange: function(treeChange) {
582 var node = treeChange.target;
583 if (!node.containerLiveStatus)
586 if (node.containerLiveRelevant.indexOf('additions') >= 0 &&
587 treeChange.type == 'nodeCreated')
588 this.outputLiveRegionChange_(node, null);
589 if (node.containerLiveRelevant.indexOf('text') >= 0 &&
590 treeChange.type == 'nodeChanged')
591 this.outputLiveRegionChange_(node, null);
592 if (node.containerLiveRelevant.indexOf('removals') >= 0 &&
593 treeChange.type == 'nodeRemoved')
594 this.outputLiveRegionChange_(node, '@live_regions_removed');
598 * Given a node that needs to be spoken as part of a live region
599 * change and an additional optional format string, output the
600 * live region description.
601 * @param {!chrome.automation.AutomationNode} node The changed node.
602 * @param {?string} opt_prependFormatStr If set, a format string for
603 * cvox2.Output to prepend to the output.
606 outputLiveRegionChange_: function(node, opt_prependFormatStr) {
607 var range = cursors.Range.fromNode(node);
608 var output = new Output();
609 if (opt_prependFormatStr) {
610 output.format(opt_prependFormatStr);
612 output.withSpeech(range, null, Output.EventType.NAVIGATE);
617 * Returns true if the url should have Classic running.
621 shouldEnableClassicForUrl_: function(url) {
622 return this.mode_ != ChromeVoxMode.FORCE_NEXT &&
623 !this.isWhitelistedForCompat_(url) &&
624 !this.isWhitelistedForNext_(url);
631 isWhitelistedForCompat_: function(url) {
632 return url.indexOf('chrome://md-settings') != -1 ||
633 url.indexOf('chrome://oobe/login') != -1 ||
635 'https://accounts.google.com/embedded/setup/chromeos') === 0 ||
641 * @param {string} url
642 * @return {boolean} Whether the given |url| is whitelisted.
644 isWhitelistedForNext_: function(url) {
645 return this.whitelist_.some(function(item) {
646 return url.indexOf(item) != -1;
651 * Setup ChromeVox variants.
652 * @param {string} url
655 setupChromeVoxVariants_: function(url) {
656 var mode = this.mode_;
657 if (mode != ChromeVoxMode.FORCE_NEXT) {
658 if (this.isWhitelistedForCompat_(url))
659 mode = ChromeVoxMode.COMPAT;
660 else if (this.isWhitelistedForNext_(url))
661 mode = ChromeVoxMode.NEXT;
663 mode = ChromeVoxMode.CLASSIC;
666 this.setChromeVoxMode(mode);
670 * Disables classic ChromeVox in current web content.
672 disableClassicChromeVox_: function() {
673 cvox.ExtensionBridge.send({
674 message: 'SYSTEM_COMMAND',
675 command: 'killChromeVox'
680 * Sets the current ChromeVox mode.
681 * @param {ChromeVoxMode} mode
683 setChromeVoxMode: function(mode) {
684 if (mode === ChromeVoxMode.NEXT ||
685 mode === ChromeVoxMode.COMPAT ||
686 mode === ChromeVoxMode.FORCE_NEXT) {
687 if (!chrome.commands.onCommand.hasListener(this.onGotCommand))
688 chrome.commands.onCommand.addListener(this.onGotCommand);
690 if (chrome.commands.onCommand.hasListener(this.onGotCommand))
691 chrome.commands.onCommand.removeListener(this.onGotCommand);
694 chrome.tabs.query({active: true}, function(tabs) {
695 if (mode === ChromeVoxMode.CLASSIC) {
696 // This case should do nothing because Classic gets injected by the
697 // extension system via our manifest. Once ChromeVox Next is enabled
698 // for tabs, re-enable.
699 // cvox.ChromeVox.injectChromeVoxIntoTabs(tabs);
701 // When in compat mode, if the focus is within the desktop tree proper,
702 // then do not disable content scripts.
703 if (this.currentRange_.start.node.root.role == 'desktop')
706 this.disableClassicChromeVox_();
714 * @param {!cvox.Spannable} text
715 * @param {number} position
718 brailleRoutingCommand_: function(text, position) {
719 var actionNode = null;
720 var selectionSpan = null;
721 text.getSpans(position).forEach(function(span) {
722 if (span instanceof Output.SelectionSpan) {
723 selectionSpan = span;
724 } else if (span instanceof Output.NodeSpan) {
726 (text.getSpanEnd(actionNode) - text.getSpanStart(actionNode) >
727 text.getSpanEnd(span) - text.getSpanStart(span))) {
728 actionNode = span.node;
734 if (actionNode.role === RoleType.inlineTextBox)
735 actionNode = actionNode.parent;
736 actionNode.doDefault();
738 var start = text.getSpanStart(selectionSpan);
739 actionNode.setSelection(position - start, position - start);
744 /** @type {Background} */
745 global.backgroundObj = new Background();