braille_input_handler.js revision 46d4c2bc3267f3f028f39e7e311b0f89aba2e4fd
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 Handles braille input keys when the user is typing or editing
7 * text in an input field.  This class cooperates with the Braille IME
8 * that is built into Chrome OS to do the actual text editing.
9 */
10
11goog.provide('cvox.BrailleInputHandler');
12
13goog.require('cvox.BrailleKeyCommand');
14goog.require('cvox.BrailleKeyEvent');
15goog.require('cvox.ExpandingBrailleTranslator');
16
17
18/**
19 * @constructor
20 */
21cvox.BrailleInputHandler = function() {
22  /**
23   * Port of the connected IME if any.
24   * @type {Port}
25   * @private
26   */
27  this.imePort_ = null;
28  /**
29   * {code true} when the Braille IME is connected and has signaled that it is
30   * active.
31   * @type {boolean}
32   * @private
33   */
34  this.imeActive_ = false;
35  /**
36   * The input context of the current input field, as reported by the IME.
37   * {@code null} if no input field has focus.
38   * @type {{contextID: number, type: string}?}
39   * @private
40   */
41  this.inputContext_ = null;
42  /**
43   * @type {cvox.LibLouis.Translator}
44   * @private
45   */
46  this.defaultTranslator_ = null;
47  /**
48   * @type {cvox.LibLouis.Translator}
49   * @private
50   */
51  this.uncontractedTranslator_ = null;
52  /**
53   * The translator currently used for typing, if
54   * {@code this.cells_.length > 0}.
55   * @type {cvox.LibLouis.Translator}
56   * @private
57   */
58  this.activeTranslator_ = null;
59  /**
60   * Braille cells that have been typed by the user so far.
61   * @type {Array.<number>}
62   * @private
63   */
64  this.cells_ = [];
65  /**
66   * Text resulting from translating {@code this.cells_}.
67   * @type {string}
68   * @private
69   */
70  this.text_ = '';
71  /**
72   * Text that currently precedes the first selection end-point.
73   * @type {string}
74   * @private
75   */
76  this.currentTextBefore_ = '';
77  /**
78   * Text that currently follows the last selection end-point.
79   * @type {string}
80   * @private
81   */
82  this.currentTextAfter_ = '';
83  /**
84   * List of strings that we expect to be set as preceding text of the
85   * selection.  This is populated when we send text changes to the IME so that
86   * our own changes don't reset the pending cells.
87   * @type {Array.<string>}
88   * @private
89   */
90  this.pendingTextsBefore_ = [];
91  /**
92   * Cells that were entered while the IME wasn't active.  These will be
93   * submitted once the IME becomes active and reports the current input field.
94   * This is necessary because the IME is activated on the first braille
95   * dots command, but we'll receive the command in parallel.  To work around
96   * the race, we store the cell entered until we can submit it to the IME.
97   * @type {Array.<number>}
98   * @private
99   */
100  this.pendingCells_ = [];
101};
102
103
104/**
105 * The ID of the Braille IME extension built into Chrome OS.
106 * @const {string}
107 * @private
108 */
109cvox.BrailleInputHandler.IME_EXTENSION_ID_ =
110    'jddehjeebkoimngcbdkaahpobgicbffp';
111
112
113/**
114 * Name of the port to use for communicating with the Braille IME.
115 * @const {string}
116 * @private
117 */
118cvox.BrailleInputHandler.IME_PORT_NAME_ = 'cvox.BrailleIme.Port';
119
120
121/**
122 * Starts to listen for connections from the ChromeOS braille IME.
123 */
124cvox.BrailleInputHandler.prototype.init = function() {
125  chrome.runtime.onConnectExternal.addListener(
126      goog.bind(this.onImeConnect_, this));
127};
128
129
130/**
131 * Sets the translator(s) to be used for input.
132 * @param {cvox.LibLouis.Translator} defaultTranslator Translator to use by
133 *     default from now on.
134 * @param {cvox.LibLouis.Translator=} opt_uncontractedTranslator Translator
135 *     to be used inside a word (non-whitespace).
136 */
137cvox.BrailleInputHandler.prototype.setTranslator = function(
138    defaultTranslator, opt_uncontractedTranslator) {
139  this.defaultTranslator_ = defaultTranslator;
140  this.uncontractedTranslator_ = opt_uncontractedTranslator || null;
141  this.resetText_();
142};
143
144
145/**
146 * Called when the content on the braille display is updated.  Modifies the
147 * input state according to the new content.
148 * @param {cvox.Spannable} text Text, optionally with value and selection
149 *     spans.
150 */
151cvox.BrailleInputHandler.prototype.onDisplayContentChanged = function(text) {
152  var valueSpan = text.getSpanInstanceOf(cvox.BrailleUtil.ValueSpan);
153  var selectionSpan = text.getSpanInstanceOf(
154      cvox.BrailleUtil.ValueSelectionSpan);
155  if (!(valueSpan && selectionSpan)) {
156    return;
157  }
158  // The type casts are ok because the spans are known to exist.
159  var valueStart = /** @type {number} */ (text.getSpanStart(valueSpan));
160  var valueEnd = /** @type {number} */ (text.getSpanEnd(valueSpan));
161  var selectionStart =
162      /** @type {number} */ (text.getSpanStart(selectionSpan));
163  var selectionEnd = /** @type {number} */ (text.getSpanEnd(selectionSpan));
164  if (selectionStart < valueStart || selectionEnd > valueEnd) {
165    console.error('Selection outside of value in braille content');
166    this.resetText_();
167    return;
168  }
169  var oldTextBefore = this.currentTextBefore_;
170  var oldTextAfter = this.currentTextAfter_;
171  this.currentTextBefore_ = text.toString().substring(
172      valueStart, selectionStart);
173  this.currentTextAfter_ = text.toString().substring(selectionEnd, valueEnd);
174  if (this.cells_.length > 0) {
175    // Ignore this change if the preceding text hasn't changed.
176    if (oldTextBefore === this.currentTextBefore_) {
177      return;
178    }
179    // See if we are expecting this change as a result of one of our own edits.
180    if (this.pendingTextsBefore_.length > 0) {
181      // Allow changes to be coalesced by the input system in an attempt to not
182      // be too brittle.
183      for (var i = 0; i < this.pendingTextsBefore_.length; ++i) {
184        if (this.currentTextBefore_ === this.pendingTextsBefore_[i]) {
185          // Delete all previous expected changes and ignore this one.
186          this.pendingTextsBefore_.splice(0, i + 1);
187          return;
188        }
189      }
190    }
191    // There was an actual text change (or cursor movement) that we hadn't
192    // caused ourselves, reset any pending input.
193    this.resetText_();
194  } else {
195    this.updateActiveTranslator_();
196  }
197};
198
199
200/**
201 * Handles braille key events used for input by editing the current input field
202 * appropriately.
203 * @param {!cvox.BrailleKeyEvent} event The key event.
204 * @return {boolean} {@code true} if the event was handled, {@code false}
205 *     if it should propagate further.
206 */
207cvox.BrailleInputHandler.prototype.onBrailleKeyEvent = function(event) {
208  if (event.command === cvox.BrailleKeyCommand.DOTS) {
209    return this.onBrailleDots_(/** @type {number} */(event.brailleDots));
210  } else {
211    this.pendingCells_.length = 0;
212    return false;
213  }
214};
215
216
217/**
218 * Returns how the value of the currently displayed content should be expanded
219 * given the current input state.
220 * @return {cvox.ExpandingBrailleTranslator.ExpansionType}
221 *     The current expansion type.
222 */
223cvox.BrailleInputHandler.prototype.getExpansionType = function() {
224  if (this.inAlwaysUncontractedContext_()) {
225    return cvox.ExpandingBrailleTranslator.ExpansionType.ALL;
226  }
227  if (this.cells_.length > 0 &&
228      this.activeTranslator_ === this.defaultTranslator_) {
229    return cvox.ExpandingBrailleTranslator.ExpansionType.NONE;
230  }
231  return cvox.ExpandingBrailleTranslator.ExpansionType.SELECTION;
232};
233
234
235/**
236 * @return {boolean} {@code true} if we have an input context and uncontracted
237 *     braille should always be used for that context.
238 * @private
239 */
240cvox.BrailleInputHandler.prototype.inAlwaysUncontractedContext_ = function() {
241  if (this.inputContext_) {
242    var inputType = this.inputContext_.type;
243    return inputType === 'url' || inputType === 'email';
244  }
245  return false;
246};
247
248
249/**
250 * Called when a user typed a braille cell.
251 * @param {number} dots The dot pattern of the cell.
252 * @return {boolean} Whether the event was handled or should be allowed to
253 *    propagate further.
254 * @private
255 */
256cvox.BrailleInputHandler.prototype.onBrailleDots_ = function(dots) {
257  if (!this.imeActive_) {
258    this.pendingCells_.push(dots);
259    return true;
260  }
261  if (!this.inputContext_ || !this.activeTranslator_) {
262    return false;
263  }
264  // Avoid accumulating cells forever when typing without moving the cursor
265  // by flushing the input when we see a blank cell.
266  // Note that this might switch to contracted if appropriate.
267  if (this.cells_.length > 0 && this.cells_[this.cells_.length - 1] == 0) {
268    this.resetText_();
269  }
270  this.cells_.push(dots);
271  var cellsBuffer = new Uint8Array(this.cells_).buffer;
272  this.activeTranslator_.backTranslate(cellsBuffer, goog.bind(function(result) {
273    if (!result) {
274      console.error('Error when backtranslating braille cells');
275      return;
276    }
277    var oldLength = this.text_.length;
278    // Find the common prefix of the old and new text.
279    var commonPrefixLength = this.longestCommonPrefixLength_(
280        this.text_, result);
281    this.text_ = result;
282    // How many characters we need to delete from the existing text to replace
283    // them with characters from the new text.
284    var deleteLength = oldLength - commonPrefixLength;
285    // New text to insert after deleting the deleteLength characters
286    // before the cursor.
287    var toInsert = result.substring(commonPrefixLength);
288    if (deleteLength > 0 || toInsert.length > 0) {
289      // After deleting, we expect this text to be present before the cursor.
290      var textBeforeAfterDelete = this.currentTextBefore_.substring(
291          0, this.currentTextBefore_.length - deleteLength);
292      if (deleteLength > 0) {
293        // Queue this text up to be ignored when the change comes in.
294        this.pendingTextsBefore_.push(textBeforeAfterDelete);
295      }
296      if (toInsert.length > 0) {
297        // Likewise, queue up what we expect to be before the cursor after
298        // the replacement text is inserted.
299        this.pendingTextsBefore_.push(textBeforeAfterDelete + toInsert);
300      }
301      // Send the replace operation to be performed asynchronously by
302      // the IME.
303      this.postImeMessage_({type: 'replaceText',
304                       contextID: this.inputContext_.contextID,
305                       deleteBefore: deleteLength,
306                       newText: toInsert});
307    }
308  }, this));
309  return true;
310};
311
312
313/**
314 * Resets the pending braille input and text.
315 * @private
316 */
317cvox.BrailleInputHandler.prototype.resetText_ = function() {
318  this.cells_.length = 0;
319  this.text_ = '';
320  this.pendingTextsBefore_.length = 0;
321  this.updateActiveTranslator_();
322};
323
324
325/**
326 * Updates the active translator based on the current input context.
327 * @private
328 */
329cvox.BrailleInputHandler.prototype.updateActiveTranslator_ = function() {
330  this.activeTranslator_ = this.defaultTranslator_;
331  if (this.uncontractedTranslator_) {
332    var textBefore = this.currentTextBefore_;
333    var textAfter = this.currentTextAfter_;
334    if (this.inAlwaysUncontractedContext_() ||
335        (textBefore.length > 0 && /\S$/.test(textBefore)) ||
336        (textAfter.length > 0 && /^\S/.test(textAfter))) {
337      this.activeTranslator_ = this.uncontractedTranslator_;
338    }
339  }
340};
341
342
343/**
344 * Called when another extension connects to this extension.  Accepts
345 * connections from the ChromeOS builtin Braille IME and ignores connections
346 * from other extensions.
347 * @param {Port} port The port used to communicate with the other extension.
348 * @private
349 */
350cvox.BrailleInputHandler.prototype.onImeConnect_ = function(port) {
351  if (port.name !== cvox.BrailleInputHandler.IME_PORT_NAME_ ||
352      port.sender.id !== cvox.BrailleInputHandler.IME_EXTENSION_ID_) {
353    return;
354  }
355  if (this.imePort_) {
356    this.imePort_.disconnect();
357  }
358  port.onDisconnect.addListener(goog.bind(this.onImeDisconnect_, this, port));
359  port.onMessage.addListener(goog.bind(this.onImeMessage_, this));
360  this.imePort_ = port;
361};
362
363
364/**
365 * Called when a message is received from the IME.
366 * @param {*} message The message.
367 * @private
368 */
369cvox.BrailleInputHandler.prototype.onImeMessage_ = function(message) {
370  if (!goog.isObject(message)) {
371    console.error('Unexpected message from Braille IME: ',
372                  JSON.stringify(message));
373  }
374  switch (message.type) {
375    case 'activeState':
376      this.imeActive_ = message.active;
377      break;
378    case 'inputContext':
379      this.inputContext_ = message.context;
380      this.resetText_();
381      if (this.imeActive_ && this.inputContext_) {
382        this.pendingCells_.forEach(goog.bind(this.onBrailleDots_, this));
383      }
384      this.pendingCells_.length = 0;
385      break;
386    case 'brailleDots':
387      this.onBrailleDots_(message['dots']);
388      break;
389    case 'reset':
390      this.resetText_();
391      break;
392    default:
393      console.error('Unexpected message from Braille IME: ',
394                    JSON.stringify(message));
395    break;
396  }
397};
398
399
400/**
401 * Called when the IME port is disconnected.
402 * @param {Port} port The port that was disconnected.
403 * @private
404 */
405cvox.BrailleInputHandler.prototype.onImeDisconnect_ = function(port) {
406  this.imePort_ = null;
407  this.resetText_();
408  this.imeActive_ = false;
409  this.inputContext_ = null;
410};
411
412
413/**
414 * Posts a message to the IME.
415 * @param {Object} message The message.
416 * @return {boolean} {@code true} if the message was sent, {@code false} if
417 *     there was no connection open to the IME.
418 * @private
419 */
420cvox.BrailleInputHandler.prototype.postImeMessage_ = function(message) {
421  if (this.imePort_) {
422    this.imePort_.postMessage(message);
423    return true;
424  }
425  return false;
426};
427
428
429
430/**
431 * Returns the length of the longest common prefix of two strings.
432 * @param {string} first The first string.
433 * @param {string} second The second string.
434 * @return {number} The longest common prefix, which may be 0 for an
435 *     empty common prefix.
436 * @private
437 */
438cvox.BrailleInputHandler.prototype.longestCommonPrefixLength_ = function(
439    first, second) {
440  var limit = Math.min(first.length, second.length);
441  var i;
442  for (i = 0; i < limit; ++i) {
443    if (first.charAt(i) != second.charAt(i)) {
444      break;
445    }
446  }
447  return i;
448};
449