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