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'use strict'; 6 7/** 8 * @fileoverview Braille hardware keyboard input method. 9 * 10 * This method is automatically enabled when a braille display is connected 11 * and ChromeVox is turned on. Most of the braille input and editing logic 12 * is located in ChromeVox where the braille translation library is available. 13 * This IME connects to ChromeVox and communicates using messages as follows: 14 * 15 * Sent from this IME to ChromeVox: 16 * {type: 'activeState', active: boolean} 17 * {type: 'inputContext', context: InputContext} 18 * Sent on focus/blur to inform ChromeVox of the type of the current field. 19 * In the latter case (blur), context is null. 20 * {type: 'reset'} 21 * Sent when the {code onReset} IME event fires. 22 * {type: 'brailleDots', dots: number} 23 * Sent when the user typed a braille cell using the standard keyboard. 24 * ChromeVox treats this similarly to entering braille input using the 25 * braille display. 26 * {type: 'backspace', requestId: string} 27 * Sent when the user presses the backspace key. 28 * ChromeVox must respond with a {@code keyEventHandled} message 29 * with the same request id. 30 * 31 * Sent from ChromeVox to this IME: 32 * {type: 'replaceText', contextID: number, deleteBefore: number, 33 * newText: string} 34 * Deletes {@code deleteBefore} characters before the cursor (or selection) 35 * and inserts {@code newText}. {@code contextID} identifies the text field 36 * to apply the update to (no change will happen if focus has moved to a 37 * different field). 38 * {type: 'keyEventHandled', requestId: string, result: boolean} 39 * Response to a {@code backspace} message indicating whether the 40 * backspace was handled by ChromeVox or should be allowed to propagate 41 * through the normal event handling pipeline. 42 */ 43 44/** 45 * @constructor 46 */ 47var BrailleIme = function() {}; 48 49BrailleIme.prototype = { 50 /** 51 * Whether to enable extra debug logging for the IME. 52 * @const {boolean} 53 * @private 54 */ 55 DEBUG: false, 56 57 /** 58 * ChromeVox extension ID. 59 * @const {string} 60 * @private 61 */ 62 CHROMEVOX_EXTENSION_ID_: 'mndnfokpggljbaajbnioimlmbfngpief', 63 64 /** 65 * Name of the port used for communication with ChromeVox. 66 * @const {string} 67 * @private 68 */ 69 PORT_NAME: 'cvox.BrailleIme.Port', 70 71 /** 72 * Identifier for the use standard keyboard option used in the menu and 73 * {@code localStorage}. This can be switched on to type braille using the 74 * standard keyboard, or off (default) for the usual keyboard behaviour. 75 * @const {string} 76 */ 77 USE_STANDARD_KEYBOARD_ID: 'useStandardKeyboard', 78 79 // State related to the support for typing braille using a standrad 80 // (qwerty) keyboard. 81 82 /** @private {boolean} */ 83 useStandardKeyboard_: false, 84 85 /** 86 * Braille dots for keys that are currently pressed. 87 * @private {number} 88 */ 89 pressed_: 0, 90 91 /** 92 * Dots that have been pressed at some point since {@code pressed_} was last 93 * {@code 0}. 94 * @private {number} 95 */ 96 accumulated_: 0, 97 98 /** 99 * Bit in {@code pressed_} and {@code accumulated_} that represent 100 * the space key. 101 * @const {number} 102 */ 103 SPACE: 0x100, 104 105 /** 106 * Maps key codes on a standard keyboard to the correspodning dots. 107 * Keys on the 'home row' correspond to the keys on a Perkins-style keyboard. 108 * Note that the mapping below is arranged like the dots in a braille cell. 109 * Only 6 dot input is supported. 110 * @private 111 * @const {Object.<string, number>} 112 */ 113 CODE_TO_DOT_: {'KeyF': 0x01, 'KeyJ': 0x08, 114 'KeyD': 0x02, 'KeyK': 0x10, 115 'KeyS': 0x04, 'KeyL': 0x20, 116 'Space': 0x100 }, 117 118 /** 119 * The current engine ID as set by {@code onActivate}, or the empty string if 120 * the IME is not active. 121 * @type {string} 122 * @private 123 */ 124 engineID_: '', 125 126 /** 127 * The port used to communicate with ChromeVox. 128 * @type {Port} port_ 129 * @private 130 */ 131 port_: null, 132 133 /** 134 * Registers event listeners in the chrome IME API. 135 */ 136 init: function() { 137 chrome.input.ime.onActivate.addListener(this.onActivate_.bind(this)); 138 chrome.input.ime.onDeactivated.addListener(this.onDeactivated_.bind(this)); 139 chrome.input.ime.onFocus.addListener(this.onFocus_.bind(this)); 140 chrome.input.ime.onBlur.addListener(this.onBlur_.bind(this)); 141 chrome.input.ime.onInputContextUpdate.addListener( 142 this.onInputContextUpdate_.bind(this)); 143 chrome.input.ime.onKeyEvent.addListener(this.onKeyEvent_.bind(this), 144 ['async']); 145 chrome.input.ime.onReset.addListener(this.onReset_.bind(this)); 146 chrome.input.ime.onMenuItemActivated.addListener( 147 this.onMenuItemActivated_.bind(this)); 148 this.connectChromeVox_(); 149 }, 150 151 /** 152 * Called by the IME framework when this IME is activated. 153 * @param {string} engineID Engine ID, should be 'braille'. 154 * @private 155 */ 156 onActivate_: function(engineID) { 157 this.log_('onActivate', engineID); 158 this.engineID_ = engineID; 159 if (!this.port_) { 160 this.connectChromeVox_(); 161 } 162 this.useStandardKeyboard_ = 163 localStorage[this.USE_STANDARD_KEYBOARD_ID] === String(true); 164 this.accumulated_ = 0; 165 this.pressed_ = 0; 166 this.updateMenuItems_(); 167 this.sendActiveState_(); 168 }, 169 170 /** 171 * Called by the IME framework when this IME is deactivated. 172 * @param {string} engineID Engine ID, should be 'braille'. 173 * @private 174 */ 175 onDeactivated_: function(engineID) { 176 this.log_('onDectivated', engineID); 177 this.engineID_ = ''; 178 this.sendActiveState_(); 179 }, 180 181 /** 182 * Called by the IME framework when a text field receives focus. 183 * @param {InputContext} context Input field context. 184 * @private 185 */ 186 onFocus_: function(context) { 187 this.log_('onFocus', JSON.stringify(context)); 188 this.sendInputContext_(context); 189 }, 190 191 /** 192 * Called by the IME framework when a text field looses focus. 193 * @param {number} contextID Input field context ID. 194 * @private 195 */ 196 onBlur_: function(contextID) { 197 this.log_('onBlur', contextID + ''); 198 this.sendInputContext_(null); 199 }, 200 201 /** 202 * Called by the IME framework when the current input context is updated. 203 * @param {InputContext} context Input field context. 204 * @private 205 */ 206 onInputContextUpdate_: function(context) { 207 this.log_('onInputContextUpdate', JSON.stringify(context)); 208 this.sendInputContext_(context); 209 }, 210 211 /** 212 * Called by the system when this IME is active and a key event is generated. 213 * @param {string} engineID Engine ID, should be 'braille'. 214 * @param {!ChromeKeyboardEvent} event The keyboard event. 215 * @private 216 */ 217 onKeyEvent_: function(engineID, event) { 218 this.log_('onKeyEvent', engineID + ', ' + JSON.stringify(event)); 219 var result = this.processKey_(event); 220 if (result !== undefined) { 221 chrome.input.ime.keyEventHandled(event.requestId, result); 222 } 223 }, 224 225 /** 226 * Called when chrome ends the current text input session. 227 * @param {string} engineID Engine ID, should be 'braille'. 228 * @private 229 */ 230 onReset_: function(engineID) { 231 this.log_('onReset', engineID); 232 this.engineID_ = engineID; 233 this.sendToChromeVox_({type: 'reset'}); 234 }, 235 236 /** 237 * Called by the IME framework when a menu item is activated. 238 * @param {string} engineID Engine ID, should be 'braille'. 239 * @param {string} itemID Identifies the menu item. 240 * @private 241 */ 242 onMenuItemActivated_: function(engineID, itemID) { 243 if (engineID === this.engineID_ && 244 itemID === this.USE_STANDARD_KEYBOARD_ID) { 245 this.useStandardKeyboard_ = !this.useStandardKeyboard_; 246 localStorage[this.USE_STANDARD_KEYBOARD_ID] = 247 String(this.useStandardKeyboard_); 248 if (!this.useStandardKeyboard_) { 249 this.accumulated_ = 0; 250 this.pressed_ = 0; 251 } 252 this.updateMenuItems_(); 253 } 254 }, 255 256 /** 257 * Outputs a log message to the console, only if {@link BrailleIme.DEBUG} 258 * is set to true. 259 * @param {string} func Name of the caller. 260 * @param {string} message Message to output. 261 * @private 262 */ 263 log_: function(func, message) { 264 if (func === 'onKeyEvent') { 265 return; 266 } 267 if (this.DEBUG) { 268 console.log('BrailleIme.' + func + ': ' + message); 269 } 270 }, 271 272 /** 273 * Handles a qwerty key on the home row as a braille key. 274 * @param {!ChromeKeyboardEvent} event Keyboard event. 275 * @return {boolean|undefined} Whether the event was handled, or 276 * {@code undefined} if handling was delegated to ChromeVox. 277 * @private 278 */ 279 processKey_: function(event) { 280 if (!this.useStandardKeyboard_) { 281 return false; 282 } 283 if (event.code === 'Backspace' && event.type === 'keydown') { 284 this.pressed_ = 0; 285 this.accumulated_ = 0; 286 this.sendToChromeVox_( 287 {type: 'backspace', requestId: event.requestId}); 288 return undefined; 289 } 290 var dot = this.CODE_TO_DOT_[event.code]; 291 if (!dot || event.altKey || event.ctrlKey || event.shiftKey || 292 event.capsLock) { 293 this.pressed_ = 0; 294 this.accumulated_ = 0; 295 return false; 296 } 297 if (event.type === 'keydown') { 298 this.pressed_ |= dot; 299 this.accumulated_ |= this.pressed_; 300 return true; 301 } else if (event.type === 'keyup') { 302 this.pressed_ &= ~dot; 303 if (this.pressed_ === 0 && this.accumulated_ !== 0) { 304 var dotsToSend = this.accumulated_; 305 this.accumulated_ = 0; 306 if (dotsToSend & this.SPACE) { 307 if (dotsToSend != this.SPACE) { 308 // Can't combine space and actual dot keys. 309 return true; 310 } 311 // Space is sent as a blank cell. 312 dotsToSend = 0; 313 } 314 this.sendToChromeVox_({type: 'brailleDots', dots: dotsToSend}); 315 } 316 return true; 317 } 318 return false; 319 }, 320 321 /** 322 * Connects to the ChromeVox extension for message passing. 323 * @private 324 */ 325 connectChromeVox_: function() { 326 if (this.port_) { 327 this.port_.disconnect(); 328 this.port_ = null; 329 } 330 this.port_ = chrome.runtime.connect( 331 this.CHROMEVOX_EXTENSION_ID_, {name: this.PORT_NAME}); 332 this.port_.onMessage.addListener( 333 this.onChromeVoxMessage_.bind(this)); 334 this.port_.onDisconnect.addListener( 335 this.onChromeVoxDisconnect_.bind(this)); 336 }, 337 338 /** 339 * Handles a message from the ChromeVox extension. 340 * @param {*} message The message from the extension. 341 * @private 342 */ 343 onChromeVoxMessage_: function(message) { 344 this.log_('onChromeVoxMessage', JSON.stringify(message)); 345 message = /** @type {{type: string}} */ (message); 346 switch (message.type) { 347 case 'replaceText': 348 message = 349 /** 350 * @type {{contextID: number, deleteBefore: number, 351 * newText: string}} 352 */ 353 (message); 354 this.replaceText_(message.contextID, message.deleteBefore, 355 message.newText); 356 break; 357 case 'keyEventHandled': 358 message = 359 /** @type {{requestId: string, result: boolean}} */ (message); 360 chrome.input.ime.keyEventHandled(message.requestId, message.result); 361 break; 362 default: 363 console.error('Unknown message from ChromeVox: ' + 364 JSON.stringify(message)); 365 break; 366 } 367 }, 368 369 /** 370 * Handles a disconnect event from the ChromeVox side. 371 * @private 372 */ 373 onChromeVoxDisconnect_: function() { 374 this.port_ = null; 375 this.log_('onChromeVoxDisconnect', 376 JSON.stringify(chrome.runtime.lastError)); 377 }, 378 379 /** 380 * Sends a message to the ChromeVox extension. 381 * @param {Object} message The message to send. 382 * @private 383 */ 384 sendToChromeVox_: function(message) { 385 if (this.port_) { 386 this.port_.postMessage(message); 387 } 388 }, 389 390 /** 391 * Sends the given input context to ChromeVox. 392 * @param {InputContext} context Input context, or null when there's no input 393 * context. 394 * @private 395 */ 396 sendInputContext_: function(context) { 397 this.sendToChromeVox_({type: 'inputContext', context: context}); 398 }, 399 400 /** 401 * Sends the active state to ChromeVox. 402 * @private 403 */ 404 sendActiveState_: function() { 405 this.sendToChromeVox_({type: 'activeState', 406 active: this.engineID_.length > 0}); 407 }, 408 409 /** 410 * Replaces text in the current text field. 411 * @param {number} contextID Context for the input field to replace the 412 * text in. 413 * @param {number} deleteBefore How many characters to delete before the 414 * cursor. 415 * @param {string} toInsert Text to insert at the cursor. 416 */ 417 replaceText_: function(contextID, deleteBefore, toInsert) { 418 var addText = function() { 419 chrome.input.ime.commitText( 420 {contextID: contextID, text: toInsert}); 421 }.bind(this); 422 if (deleteBefore > 0) { 423 var deleteText = function() { 424 chrome.input.ime.deleteSurroundingText( 425 {engineID: this.engineID_, contextID: contextID, 426 offset: -deleteBefore, length: deleteBefore}, addText); 427 }.bind(this); 428 // Make sure there's no non-zero length selection so that 429 // deleteSurroundingText works correctly. 430 chrome.input.ime.deleteSurroundingText( 431 {engineID: this.engineID_, contextID: contextID, 432 offset: 0, length: 0}, deleteText); 433 } else { 434 addText(); 435 } 436 }, 437 438 /** 439 * Updates the menu items for this IME. 440 */ 441 updateMenuItems_: function() { 442 // TODO(plundblad): Localize when translations available. 443 chrome.input.ime.setMenuItems( 444 {engineID: this.engineID_, 445 items: [ 446 { 447 id: this.USE_STANDARD_KEYBOARD_ID, 448 label: 'Use standard keyboard for braille', 449 style: 'check', 450 visible: true, 451 checked: this.useStandardKeyboard_, 452 enabled: true 453 } 454 ] 455 }); 456 } 457}; 458