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