options.js revision 1320f92c476a1ad9d19dba2a48c72b75566198e9
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.
4
5/**
6 * @fileoverview ChromeVox options page.
7 *
8 */
9
10goog.provide('cvox.OptionsPage');
11
12goog.require('cvox.BrailleBackground');
13goog.require('cvox.BrailleTable');
14goog.require('cvox.ChromeEarcons');
15goog.require('cvox.ChromeHost');
16goog.require('cvox.ChromeTts');
17goog.require('cvox.ChromeVox');
18goog.require('cvox.ChromeVoxPrefs');
19goog.require('cvox.CommandStore');
20goog.require('cvox.ExtensionBridge');
21goog.require('cvox.HostFactory');
22goog.require('cvox.KeyMap');
23goog.require('cvox.KeySequence');
24goog.require('cvox.Msgs');
25goog.require('cvox.PlatformFilter');
26goog.require('cvox.PlatformUtil');
27
28/**
29 * This object is exported by the main background page.
30 */
31window.braille;
32
33
34/**
35 * Class to manage the options page.
36 * @constructor
37 */
38cvox.OptionsPage = function() {
39};
40
41/**
42 * The ChromeVoxPrefs object.
43 * @type {cvox.ChromeVoxPrefs}
44 */
45cvox.OptionsPage.prefs;
46
47
48/**
49 * A mapping from keycodes to their human readable text equivalents.
50 * This is initialized in cvox.OptionsPage.init for internationalization.
51 * @type {Object.<string, string>}
52 */
53cvox.OptionsPage.KEYCODE_TO_TEXT = {
54};
55
56/**
57 * A mapping from human readable text to keycode values.
58 * This is initialized in cvox.OptionsPage.init for internationalization.
59 * @type {Object.<string, string>}
60 */
61cvox.OptionsPage.TEXT_TO_KEYCODE = {
62};
63
64/**
65 * Initialize the options page by setting the current value of all prefs,
66 * building the key bindings table, and adding event listeners.
67 * @suppress {missingProperties} Property prefs never defined on Window
68 */
69cvox.OptionsPage.init = function() {
70  cvox.ChromeVox.msgs = new cvox.Msgs();
71
72  cvox.OptionsPage.prefs = chrome.extension.getBackgroundPage().prefs;
73  cvox.OptionsPage.populateKeyMapSelect();
74  cvox.OptionsPage.addKeys();
75  cvox.OptionsPage.populateVoicesSelect();
76  cvox.BrailleTable.getAll(function(tables) {
77    /** @type {!Array.<cvox.BrailleTable.Table>} */
78    cvox.OptionsPage.brailleTables = tables;
79    cvox.OptionsPage.populateBrailleTablesSelect();
80  });
81
82  cvox.ChromeVox.msgs.addTranslatedMessagesToDom(document);
83  cvox.OptionsPage.hidePlatformSpecifics();
84
85  cvox.OptionsPage.update();
86
87  document.addEventListener('change', cvox.OptionsPage.eventListener, false);
88  document.addEventListener('click', cvox.OptionsPage.eventListener, false);
89  document.addEventListener('keydown', cvox.OptionsPage.eventListener, false);
90
91  cvox.ExtensionBridge.addMessageListener(function(message) {
92    if (message['keyBindings'] || message['prefs']) {
93      cvox.OptionsPage.update();
94    }
95  });
96
97  $('selectKeys').addEventListener(
98      'click', cvox.OptionsPage.reset, false);
99
100  if (cvox.PlatformUtil.matchesPlatform(cvox.PlatformFilter.WML)) {
101    $('version').textContent =
102        chrome.app.getDetails().version;
103  }
104};
105
106/**
107 * Update the value of controls to match the current preferences.
108 * This happens if the user presses a key in a tab that changes a
109 * pref.
110 */
111cvox.OptionsPage.update = function() {
112  var prefs = cvox.OptionsPage.prefs.getPrefs();
113  for (var key in prefs) {
114    // TODO(rshearer): 'active' is a pref, but there's no place in the
115    // options page to specify whether you want ChromeVox active.
116    var elements = document.querySelectorAll('*[name="' + key + '"]');
117    for (var i = 0; i < elements.length; i++) {
118      cvox.OptionsPage.setValue(elements[i], prefs[key]);
119    }
120  }
121};
122
123/**
124 * Populate the keymap select element with stored keymaps
125 */
126cvox.OptionsPage.populateKeyMapSelect = function() {
127  var select = $('cvox_keymaps');
128  for (var id in cvox.KeyMap.AVAILABLE_MAP_INFO) {
129    var info = cvox.KeyMap.AVAILABLE_MAP_INFO[id];
130    var option = document.createElement('option');
131    option.id = id;
132    option.className = 'i18n';
133    option.setAttribute('msgid', id);
134    if (cvox.OptionsPage.prefs.getPrefs()['currentKeyMap'] == id) {
135      option.setAttribute('selected', '');
136    }
137    select.appendChild(option);
138  }
139
140  select.addEventListener('change', cvox.OptionsPage.reset, true);
141};
142
143/**
144 * Add the input elements for the key bindings to the container element
145 * in the page. They're sorted in order of description.
146 */
147cvox.OptionsPage.addKeys = function() {
148  var container = $('keysContainer');
149  var keyMap = cvox.OptionsPage.prefs.getKeyMap();
150
151  cvox.OptionsPage.prevTime = new Date().getTime();
152  cvox.OptionsPage.keyCount = 0;
153  container.addEventListener('keypress', goog.bind(function(evt) {
154    if (evt.target.id == 'cvoxKey') {
155      return;
156    }
157    this.keyCount++;
158    var currentTime = new Date().getTime();
159    if (currentTime - this.prevTime > 1000 || this.keyCount > 2) {
160      if (document.activeElement.id == 'toggleKeyPrefix') {
161        this.keySequence = new cvox.KeySequence(evt, false);
162        this.keySequence.keys['ctrlKey'][0] = true;
163      } else {
164        this.keySequence = new cvox.KeySequence(evt, true);
165      }
166
167      this.keyCount = 1;
168    } else {
169      this.keySequence.addKeyEvent(evt);
170    }
171
172    var keySeqStr = cvox.KeyUtil.keySequenceToString(this.keySequence, true);
173    var announce = keySeqStr.replace(/\+/g,
174        ' ' + cvox.ChromeVox.msgs.getMsg('then') + ' ');
175    announce = announce.replace(/>/g,
176        ' ' + cvox.ChromeVox.msgs.getMsg('followed_by') + ' ');
177    announce = announce.replace('Cvox',
178        ' ' + cvox.ChromeVox.msgs.getMsg('modifier_key') + ' ');
179
180    // TODO(dtseng): Only basic conflict detection; it does not speak the
181    // conflicting command. Nor does it detect prefix conflicts like Cvox+L vs
182    // Cvox+L>L.
183    if (cvox.OptionsPage.prefs.setKey(document.activeElement.id,
184        this.keySequence)) {
185      document.activeElement.value = keySeqStr;
186    } else {
187      announce = cvox.ChromeVox.msgs.getMsg('key_conflict', [announce]);
188    }
189    cvox.OptionsPage.speak(announce);
190    this.prevTime = currentTime;
191
192    evt.preventDefault();
193    evt.stopPropagation();
194  }, cvox.OptionsPage), true);
195
196  var categories = cvox.CommandStore.categories();
197  for (var i = 0; i < categories.length; i++) {
198    // Braille bindings can't be customized, so don't include them.
199    if (categories[i] == 'braille') {
200      continue;
201    }
202    var headerElement = document.createElement('h3');
203    headerElement.className = 'i18n';
204    headerElement.setAttribute('msgid', categories[i]);
205    headerElement.id = categories[i];
206    container.appendChild(headerElement);
207    var commands = cvox.CommandStore.commandsForCategory(categories[i]);
208    for (var j = 0; j < commands.length; j++) {
209      var command = commands[j];
210      // TODO: Someday we may want to have more than one key
211      // mapped to a command, so we'll need to figure out how to display
212      // that. For now, just take the first key.
213      var keySeqObj = keyMap.keyForCommand(command)[0];
214
215      // Explicitly skip toggleChromeVox in ChromeOS.
216      if (command == 'toggleChromeVox' &&
217          cvox.PlatformUtil.matchesPlatform(cvox.PlatformFilter.CHROMEOS)) {
218        continue;
219      }
220
221      var inputElement = document.createElement('input');
222      inputElement.type = 'text';
223      inputElement.className = 'key active-key';
224      inputElement.id = command;
225
226      var displayedCombo;
227      if (keySeqObj != null) {
228        displayedCombo = cvox.KeyUtil.keySequenceToString(keySeqObj, true);
229      } else {
230        displayedCombo = '';
231      }
232      inputElement.value = displayedCombo;
233
234      // Don't allow the user to change the sticky mode or stop speaking key.
235      if (command == 'toggleStickyMode' || command == 'stopSpeech') {
236        inputElement.disabled = true;
237      }
238      var message = cvox.CommandStore.messageForCommand(command);
239      if (!message) {
240        // TODO(dtseng): missing message id's.
241        message = command;
242      }
243
244      var labelElement = document.createElement('label');
245      labelElement.className = 'i18n';
246      labelElement.setAttribute('msgid', message);
247      labelElement.setAttribute('for', inputElement.id);
248
249      var divElement = document.createElement('div');
250      divElement.className = 'key-container';
251      container.appendChild(divElement);
252      divElement.appendChild(inputElement);
253      divElement.appendChild(labelElement);
254    }
255      var brElement = document.createElement('br');
256      container.appendChild(brElement);
257  }
258
259  if ($('cvoxKey') == null) {
260    // Add the cvox key field
261    var inputElement = document.createElement('input');
262    inputElement.type = 'text';
263    inputElement.className = 'key';
264    inputElement.id = 'cvoxKey';
265
266    var labelElement = document.createElement('label');
267    labelElement.className = 'i18n';
268    labelElement.setAttribute('msgid', 'options_cvox_modifier_key');
269    labelElement.setAttribute('for', 'cvoxKey');
270
271    var modifierSectionSibling =
272        $('modifier_keys').nextSibling;
273    var modifierSectionParent = modifierSectionSibling.parentNode;
274    modifierSectionParent.insertBefore(labelElement, modifierSectionSibling);
275    modifierSectionParent.insertBefore(inputElement, labelElement);
276    var cvoxKey = $('cvoxKey');
277    cvoxKey.value = localStorage['cvoxKey'];
278
279    cvoxKey.addEventListener('keydown', function(evt) {
280      if (!this.modifierSeq_) {
281        this.modifierCount_ = 0;
282        this.modifierSeq_ = new cvox.KeySequence(evt, false);
283      } else {
284        this.modifierSeq_.addKeyEvent(evt);
285      }
286
287      //  Never allow non-modified keys.
288      if (!this.modifierSeq_.isAnyModifierActive()) {
289        // Indicate error and instructions excluding tab.
290        if (evt.keyCode != 9) {
291          cvox.OptionsPage.speak(
292              cvox.ChromeVox.msgs.getMsg('modifier_entry_error'), 0, {});
293        }
294        this.modifierSeq_ = null;
295      } else {
296        this.modifierCount_++;
297      }
298
299      // Don't trap tab or shift.
300      if (!evt.shiftKey && evt.keyCode != 9) {
301        evt.preventDefault();
302        evt.stopPropagation();
303      }
304    }, true);
305
306    cvoxKey.addEventListener('keyup', function(evt) {
307      if (this.modifierSeq_) {
308        this.modifierCount_--;
309
310        if (this.modifierCount_ == 0) {
311          var modifierStr =
312              cvox.KeyUtil.keySequenceToString(this.modifierSeq_, true, true);
313          evt.target.value = modifierStr;
314          cvox.OptionsPage.speak(
315              cvox.ChromeVox.msgs.getMsg('modifier_entry_set', [modifierStr]));
316          localStorage['cvoxKey'] = modifierStr;
317          this.modifierSeq_ = null;
318        }
319        evt.preventDefault();
320        evt.stopPropagation();
321      }
322    }, true);
323  }
324};
325
326/**
327 * Populates the voices select with options.
328 */
329cvox.OptionsPage.populateVoicesSelect = function() {
330  var select = $('voices');
331
332  function setVoiceList() {
333    select.innerHTML = '';
334    chrome.tts.getVoices(function(voices) {
335      voices.forEach(function(voice) {
336        var option = document.createElement('option');
337        option.voiceName = voice.voiceName || '';
338        option.innerText = option.voiceName;
339        chrome.storage.local.get('voiceName', function(items) {
340          if (items.voiceName == voice.voiceName) {
341            option.setAttribute('selected', '');
342          }
343        });
344        select.add(option);
345      });
346  });
347  }
348
349  window.speechSynthesis.onvoiceschanged = setVoiceList.bind(this);
350  setVoiceList();
351
352  select.addEventListener('change', function(evt) {
353    var selIndex = select.selectedIndex;
354    var sel = select.options[selIndex];
355    chrome.storage.local.set({voiceName: sel.voiceName});
356  }, true);
357};
358
359/**
360 * Populates the braille select control.
361 * @this {cvox.OptionsPage}
362 */
363cvox.OptionsPage.populateBrailleTablesSelect = function() {
364  if (!cvox.ChromeVox.isChromeOS) {
365    return;
366  }
367  var tables = cvox.OptionsPage.brailleTables;
368  var populateSelect = function(node, dots) {
369    var activeTable = localStorage[node.id] || localStorage['brailleTable'];
370    // Gather the display names and sort them according to locale.
371    var items = [];
372    for (var i = 0, table; table = tables[i]; i++) {
373      if (table.dots !== dots) {
374        continue;
375      }
376      items.push({id: table.id,
377                  name: cvox.BrailleTable.getDisplayName(table)});
378    }
379    items.sort(function(a, b) { return a.name.localeCompare(b.name);});
380    for (var i = 0, item; item = items[i]; ++i) {
381      var elem = document.createElement('option');
382      elem.id = item.id;
383      elem.textContent = item.name;
384      if (item.id == activeTable) {
385        elem.setAttribute('selected', '');
386      }
387      node.appendChild(elem);
388    }
389  };
390  var select6 = $('brailleTable6');
391  var select8 = $('brailleTable8');
392  populateSelect(select6, '6');
393  populateSelect(select8, '8');
394
395  var handleBrailleSelect = function(node) {
396    return function(evt) {
397      var selIndex = node.selectedIndex;
398      var sel = node.options[selIndex];
399      localStorage['brailleTable'] = sel.id;
400      localStorage[node.id] = sel.id;
401      /** @type {cvox.BrailleBackground} */
402      var braille = chrome.extension.getBackgroundPage().braille;
403      braille.refreshTranslator();
404    };
405  };
406
407  select6.addEventListener('change', handleBrailleSelect(select6), true);
408  select8.addEventListener('change', handleBrailleSelect(select8), true);
409
410  var tableTypeButton = $('brailleTableType');
411  var updateTableType = function(setFocus) {
412    var currentTableType = localStorage['brailleTableType'] || 'brailleTable6';
413    if (currentTableType == 'brailleTable6') {
414      select6.removeAttribute('aria-hidden');
415      select6.setAttribute('tabIndex', 0);
416      select6.style.display = 'block';
417      if (setFocus) {
418        select6.focus();
419      }
420      select8.setAttribute('aria-hidden', 'true');
421      select8.setAttribute('tabIndex', -1);
422      select8.style.display = 'none';
423      localStorage['brailleTable'] = localStorage['brailleTable6'];
424      localStorage['brailleTableType'] = 'brailleTable6';
425      tableTypeButton.textContent =
426          cvox.ChromeVox.msgs.getMsg('options_braille_table_type_6');
427    } else {
428      select6.setAttribute('aria-hidden', 'true');
429      select6.setAttribute('tabIndex', -1);
430      select6.style.display = 'none';
431      select8.removeAttribute('aria-hidden');
432      select8.setAttribute('tabIndex', 0);
433      select8.style.display = 'block';
434      if (setFocus) {
435        select8.focus();
436      }
437      localStorage['brailleTable'] = localStorage['brailleTable8'];
438      localStorage['brailleTableType'] = 'brailleTable8';
439      tableTypeButton.textContent =
440          cvox.ChromeVox.msgs.getMsg('options_braille_table_type_8');
441    }
442    var braille = chrome.extension.getBackgroundPage().braille;
443    braille.refreshTranslator();
444  };
445  updateTableType(false);
446
447  tableTypeButton.addEventListener('click', function(evt) {
448    var oldTableType = localStorage['brailleTableType'];
449    localStorage['brailleTableType'] =
450        oldTableType == 'brailleTable6' ? 'brailleTable8' : 'brailleTable6';
451    updateTableType(true);
452  }, true);
453};
454
455/**
456 * Set the html element for a preference to match the given value.
457 * @param {Element} element The HTML control.
458 * @param {string} value The new value.
459 */
460cvox.OptionsPage.setValue = function(element, value) {
461  if (element.tagName == 'INPUT' && element.type == 'checkbox') {
462    element.checked = (value == 'true');
463  } else if (element.tagName == 'INPUT' && element.type == 'radio') {
464    element.checked = (String(element.value) == value);
465  } else {
466    element.value = value;
467  }
468};
469
470/**
471 * Event listener, called when an event occurs in the page that might
472 * affect one of the preference controls.
473 * @param {Event} event The event.
474 * @return {boolean} True if the default action should occur.
475 */
476cvox.OptionsPage.eventListener = function(event) {
477  window.setTimeout(function() {
478    var target = event.target;
479    if (target.classList.contains('pref')) {
480      if (target.tagName == 'INPUT' && target.type == 'checkbox') {
481        cvox.OptionsPage.prefs.setPref(target.name, target.checked);
482      } else if (target.tagName == 'INPUT' && target.type == 'radio') {
483        var key = target.name;
484        var elements = document.querySelectorAll('*[name="' + key + '"]');
485        for (var i = 0; i < elements.length; i++) {
486          if (elements[i].checked) {
487            cvox.OptionsPage.prefs.setPref(target.name, elements[i].value);
488          }
489        }
490      }
491    } else if (target.classList.contains('key')) {
492      var keySeq = cvox.KeySequence.fromStr(target.value);
493      var success = false;
494      if (target.id == 'cvoxKey') {
495        cvox.OptionsPage.prefs.setPref(target.id, target.value);
496        cvox.OptionsPage.prefs.sendPrefsToAllTabs(true, true);
497        success = true;
498      } else {
499        success =
500            cvox.OptionsPage.prefs.setKey(target.id, keySeq);
501
502        // TODO(dtseng): Don't surface conflicts until we have a better
503        // workflow.
504      }
505    }
506  }, 0);
507  return true;
508};
509
510/**
511 * Refreshes all dynamic content on the page.
512This includes all key related information.
513 */
514cvox.OptionsPage.reset = function() {
515  var selectKeyMap = $('cvox_keymaps');
516  var id = selectKeyMap.options[selectKeyMap.selectedIndex].id;
517
518  var msgs = cvox.ChromeVox.msgs;
519  var announce = cvox.OptionsPage.prefs.getPrefs()['currentKeyMap'] == id ?
520      msgs.getMsg('keymap_reset', [msgs.getMsg(id)]) :
521      msgs.getMsg('keymap_switch', [msgs.getMsg(id)]);
522  cvox.OptionsPage.updateStatus_(announce);
523
524  cvox.OptionsPage.prefs.switchToKeyMap(id);
525  $('keysContainer').innerHTML = '';
526  cvox.OptionsPage.addKeys();
527  cvox.ChromeVox.msgs.addTranslatedMessagesToDom(document);
528};
529
530/**
531 * Updates the status live region.
532 * @param {string} status The new status.
533 * @private
534 */
535cvox.OptionsPage.updateStatus_ = function(status) {
536  $('status').innerText = status;
537};
538
539
540/**
541 * Hides all elements not matching the current platform.
542 */
543cvox.OptionsPage.hidePlatformSpecifics = function() {
544  if (!cvox.ChromeVox.isChromeOS) {
545    var elements = document.body.querySelectorAll('.chromeos');
546    for (var i = 0, el; el = elements[i]; i++) {
547      el.setAttribute('aria-hidden', 'true');
548      el.style.display = 'none';
549    }
550  }
551};
552
553
554/**
555 * Calls a {@code cvox.TtsInterface.speak} method in the background page to
556 * speak an utterance.  See that method for further details.
557 * @param {string} textString The string of text to be spoken.
558 * @param {number=} queueMode The queue mode to use.
559 * @param {Object=} properties Speech properties to use for this utterance.
560 */
561cvox.OptionsPage.speak = function(textString, queueMode, properties) {
562  var speak =
563      /** @type Function} */ (chrome.extension.getBackgroundPage()['speak']);
564  speak.apply(null, arguments);
565};
566
567document.addEventListener('DOMContentLoaded', function() {
568  cvox.OptionsPage.init();
569}, false);
570