braille_input_handler.js revision 116680a4aac90f2aa7413d9095a592090648e557
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  }
211  // Any other braille command cancels the pending cells.
212  this.pendingCells_.length = 0;
213  if (event.command === cvox.BrailleKeyCommand.STANDARD_KEY) {
214    if (event.standardKeyCode === 'Backspace' &&
215        !event.altKey && !event.ctrlKey && !event.shiftKey &&
216        this.onBackspace_()) {
217      return true;
218    } else {
219      this.sendKeyEventPair_(event);
220      return true;
221    }
222  }
223  return false;
224};
225
226
227/**
228 * Returns how the value of the currently displayed content should be expanded
229 * given the current input state.
230 * @return {cvox.ExpandingBrailleTranslator.ExpansionType}
231 *     The current expansion type.
232 */
233cvox.BrailleInputHandler.prototype.getExpansionType = function() {
234  if (this.inAlwaysUncontractedContext_()) {
235    return cvox.ExpandingBrailleTranslator.ExpansionType.ALL;
236  }
237  if (this.cells_.length > 0 &&
238      this.activeTranslator_ === this.defaultTranslator_) {
239    return cvox.ExpandingBrailleTranslator.ExpansionType.NONE;
240  }
241  return cvox.ExpandingBrailleTranslator.ExpansionType.SELECTION;
242};
243
244
245/**
246 * @return {boolean} {@code true} if we have an input context and uncontracted
247 *     braille should always be used for that context.
248 * @private
249 */
250cvox.BrailleInputHandler.prototype.inAlwaysUncontractedContext_ = function() {
251  if (this.inputContext_) {
252    var inputType = this.inputContext_.type;
253    return inputType === 'url' || inputType === 'email';
254  }
255  return false;
256};
257
258
259/**
260 * Called when a user typed a braille cell.
261 * @param {number} dots The dot pattern of the cell.
262 * @return {boolean} Whether the event was handled or should be allowed to
263 *    propagate further.
264 * @private
265 */
266cvox.BrailleInputHandler.prototype.onBrailleDots_ = function(dots) {
267  if (!this.imeActive_) {
268    this.pendingCells_.push(dots);
269    return true;
270  }
271  if (!this.inputContext_ || !this.activeTranslator_) {
272    return false;
273  }
274  // Avoid accumulating cells forever when typing without moving the cursor
275  // by flushing the input when we see a blank cell.
276  // Note that this might switch to contracted if appropriate.
277  if (this.cells_.length > 0 && this.cells_[this.cells_.length - 1] == 0) {
278    this.resetText_();
279  }
280  this.cells_.push(dots);
281  this.updateText_();
282  return true;
283};
284
285
286/**
287 * Handles the backspace key by deleting the last typed cell if possible.
288 * @return {boolean} {@code true} if the event was handled, {@code false}
289 *     if it wasn't and should propagate further.
290 * @private
291 */
292cvox.BrailleInputHandler.prototype.onBackspace_ = function() {
293  if (this.imeActive_ && this.cells_.length > 0) {
294    --this.cells_.length;
295    this.updateText_();
296    return true;
297  }
298  return false;
299};
300
301
302/**
303 * Updates the translated text based on the current cells and sends the
304 * delta to the IME.
305 * @private
306 */
307cvox.BrailleInputHandler.prototype.updateText_ = function() {
308  var cellsBuffer = new Uint8Array(this.cells_).buffer;
309  this.activeTranslator_.backTranslate(cellsBuffer, goog.bind(function(result) {
310    if (result === null) {
311      console.error('Error when backtranslating braille cells');
312      return;
313    }
314    var oldLength = this.text_.length;
315    // Find the common prefix of the old and new text.
316    var commonPrefixLength = this.longestCommonPrefixLength_(
317        this.text_, result);
318    this.text_ = result;
319    // How many characters we need to delete from the existing text to replace
320    // them with characters from the new text.
321    var deleteLength = oldLength - commonPrefixLength;
322    // New text, if any, to insert after deleting the deleteLength characters
323    // before the cursor.
324    var toInsert = result.substring(commonPrefixLength);
325    if (deleteLength > 0 || toInsert.length > 0) {
326      // After deleting, we expect this text to be present before the cursor.
327      var textBeforeAfterDelete = this.currentTextBefore_.substring(
328          0, this.currentTextBefore_.length - deleteLength);
329      if (deleteLength > 0) {
330        // Queue this text up to be ignored when the change comes in.
331        this.pendingTextsBefore_.push(textBeforeAfterDelete);
332      }
333      if (toInsert.length > 0) {
334        // Likewise, queue up what we expect to be before the cursor after
335        // the replacement text is inserted.
336        this.pendingTextsBefore_.push(textBeforeAfterDelete + toInsert);
337      }
338      // Send the replace operation to be performed asynchronously by
339      // the IME.
340      this.postImeMessage_({type: 'replaceText',
341                       contextID: this.inputContext_.contextID,
342                       deleteBefore: deleteLength,
343                       newText: toInsert});
344    }
345  }, this));
346};
347
348
349/**
350 * Resets the pending braille input and text.
351 * @private
352 */
353cvox.BrailleInputHandler.prototype.resetText_ = function() {
354  this.cells_.length = 0;
355  this.text_ = '';
356  this.pendingTextsBefore_.length = 0;
357  this.updateActiveTranslator_();
358};
359
360
361/**
362 * Updates the active translator based on the current input context.
363 * @private
364 */
365cvox.BrailleInputHandler.prototype.updateActiveTranslator_ = function() {
366  this.activeTranslator_ = this.defaultTranslator_;
367  if (this.uncontractedTranslator_) {
368    var textBefore = this.currentTextBefore_;
369    var textAfter = this.currentTextAfter_;
370    if (this.inAlwaysUncontractedContext_() ||
371        (textBefore.length > 0 && /\S$/.test(textBefore)) ||
372        (textAfter.length > 0 && /^\S/.test(textAfter))) {
373      this.activeTranslator_ = this.uncontractedTranslator_;
374    }
375  }
376};
377
378
379/**
380 * Called when another extension connects to this extension.  Accepts
381 * connections from the ChromeOS builtin Braille IME and ignores connections
382 * from other extensions.
383 * @param {Port} port The port used to communicate with the other extension.
384 * @private
385 */
386cvox.BrailleInputHandler.prototype.onImeConnect_ = function(port) {
387  if (port.name !== cvox.BrailleInputHandler.IME_PORT_NAME_ ||
388      port.sender.id !== cvox.BrailleInputHandler.IME_EXTENSION_ID_) {
389    return;
390  }
391  if (this.imePort_) {
392    this.imePort_.disconnect();
393  }
394  port.onDisconnect.addListener(goog.bind(this.onImeDisconnect_, this, port));
395  port.onMessage.addListener(goog.bind(this.onImeMessage_, this));
396  this.imePort_ = port;
397};
398
399
400/**
401 * Called when a message is received from the IME.
402 * @param {*} message The message.
403 * @private
404 */
405cvox.BrailleInputHandler.prototype.onImeMessage_ = function(message) {
406  if (!goog.isObject(message)) {
407    console.error('Unexpected message from Braille IME: ',
408                  JSON.stringify(message));
409  }
410  switch (message.type) {
411    case 'activeState':
412      this.imeActive_ = message.active;
413      break;
414    case 'inputContext':
415      this.inputContext_ = message.context;
416      this.resetText_();
417      if (this.imeActive_ && this.inputContext_) {
418        this.pendingCells_.forEach(goog.bind(this.onBrailleDots_, this));
419      }
420      this.pendingCells_.length = 0;
421      break;
422    case 'brailleDots':
423      this.onBrailleDots_(message['dots']);
424      break;
425    case 'backspace':
426      // Note that we can't send the backspace key through the
427      // virtualKeyboardPrivate API in this case because it would then be
428      // processed by the IME again, leading to an infinite loop.
429      this.postImeMessage_(
430          {type: 'keyEventHandled', requestId: message['requestId'],
431           result: this.onBackspace_()});
432      break;
433    case 'reset':
434      this.resetText_();
435      break;
436    default:
437      console.error('Unexpected message from Braille IME: ',
438                    JSON.stringify(message));
439    break;
440  }
441};
442
443
444/**
445 * Called when the IME port is disconnected.
446 * @param {Port} port The port that was disconnected.
447 * @private
448 */
449cvox.BrailleInputHandler.prototype.onImeDisconnect_ = function(port) {
450  this.imePort_ = null;
451  this.resetText_();
452  this.imeActive_ = false;
453  this.inputContext_ = null;
454};
455
456
457/**
458 * Posts a message to the IME.
459 * @param {Object} message The message.
460 * @return {boolean} {@code true} if the message was sent, {@code false} if
461 *     there was no connection open to the IME.
462 * @private
463 */
464cvox.BrailleInputHandler.prototype.postImeMessage_ = function(message) {
465  if (this.imePort_) {
466    this.imePort_.postMessage(message);
467    return true;
468  }
469  return false;
470};
471
472
473/**
474 * Sends a {@code keydown} key event followed by a {@code keyup} event
475 * corresponding to an event generated by the braille display.
476 * @param {!cvox.BrailleKeyEvent} event The braille key event to base the
477 *     key events on.
478 * @private
479 */
480cvox.BrailleInputHandler.prototype.sendKeyEventPair_ = function(event) {
481  // Use the virtual keyboard API instead of the IME key event API
482  // so that these keys work even if the Braille IME is not active.
483  var keyName = /** @type {string} */ (event.standardKeyCode);
484  var numericCode = cvox.BrailleKeyEvent.keyCodeToLegacyCode(keyName);
485  if (!goog.isDef(numericCode)) {
486    throw Error('Unknown key code in event: ' + JSON.stringify(event));
487  }
488  var keyEvent = {
489    type: 'keydown',
490    keyCode: numericCode,
491    keyName: keyName,
492    charValue: cvox.BrailleKeyEvent.keyCodeToCharValue(keyName),
493    // See chrome/common/extensions/api/virtual_keyboard_private.json for
494    // these constants.
495    modifiers: (event.shiftKey ? 2 : 0) |
496        (event.ctrlKey ? 4 : 0) |
497        (event.altKey ? 8 : 0)
498  };
499  chrome.virtualKeyboardPrivate.sendKeyEvent(keyEvent);
500  keyEvent.type = 'keyup';
501  chrome.virtualKeyboardPrivate.sendKeyEvent(keyEvent);
502};
503
504
505/**
506 * Returns the length of the longest common prefix of two strings.
507 * @param {string} first The first string.
508 * @param {string} second The second string.
509 * @return {number} The longest common prefix, which may be 0 for an
510 *     empty common prefix.
511 * @private
512 */
513cvox.BrailleInputHandler.prototype.longestCommonPrefixLength_ = function(
514    first, second) {
515  var limit = Math.min(first.length, second.length);
516  var i;
517  for (i = 0; i < limit; ++i) {
518    if (first.charAt(i) != second.charAt(i)) {
519      break;
520    }
521  }
522  return i;
523};
524