| // Copyright 2014 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| 'use strict'; |
| |
| /** |
| * @fileoverview Braille hardware keyboard input method. |
| * |
| * This method is automatically enabled when a braille display is connected |
| * and ChromeVox is turned on. Most of the braille input and editing logic |
| * is located in ChromeVox where the braille translation library is available. |
| * This IME connects to ChromeVox and communicates using messages as follows: |
| * |
| * Sent from this IME to ChromeVox: |
| * {type: 'activeState', active: boolean} |
| * {type: 'inputContext', context: InputContext} |
| * Sent on focus/blur to inform ChromeVox of the type of the current field. |
| * In the latter case (blur), context is null. |
| * {type: 'reset'} |
| * Sent when the {@code onReset} IME event fires or uncommitted text is |
| * committed without being triggered by ChromeVox (e.g. because of a |
| * key press). |
| * {type: 'brailleDots', dots: number} |
| * Sent when the user typed a braille cell using the standard keyboard. |
| * ChromeVox treats this similarly to entering braille input using the |
| * braille display. |
| * {type: 'backspace', requestId: string} |
| * Sent when the user presses the backspace key. |
| * ChromeVox must respond with a {@code keyEventHandled} message |
| * with the same request id. |
| * |
| * Sent from ChromeVox to this IME: |
| * {type: 'replaceText', contextID: number, deleteBefore: number, |
| * newText: string} |
| * Deletes {@code deleteBefore} characters before the cursor (or selection) |
| * and inserts {@code newText}. {@code contextID} identifies the text field |
| * to apply the update to (no change will happen if focus has moved to a |
| * different field). |
| * {type: 'setUncommitted', contextID: number, text: string} |
| * Stores text for the field identified by contextID to be committed |
| * either as a result of a 'commitUncommitted' message or a by the IME |
| * unhandled key press event. Unlike 'replaceText', this does not send the |
| * uncommitted text to the input field, but instead stores it in the IME. |
| * {type: 'commitUncommitted', contextID: number} |
| * Commits any uncommitted text if it matches the given context ID. |
| * See 'setUncommitted' above. |
| * {type: 'keyEventHandled', requestId: string, result: boolean} |
| * Response to a {@code backspace} message indicating whether the |
| * backspace was handled by ChromeVox or should be allowed to propagate |
| * through the normal event handling pipeline. |
| */ |
| |
| /** |
| * @constructor |
| */ |
| var BrailleIme = function() {}; |
| |
| BrailleIme.prototype = { |
| /** |
| * Whether to enable extra debug logging for the IME. |
| * @const {boolean} |
| * @private |
| */ |
| DEBUG: false, |
| |
| /** |
| * ChromeVox extension ID. |
| * @const {string} |
| * @private |
| */ |
| CHROMEVOX_EXTENSION_ID_: 'mndnfokpggljbaajbnioimlmbfngpief', |
| |
| /** |
| * Name of the port used for communication with ChromeVox. |
| * @const {string} |
| * @private |
| */ |
| PORT_NAME: 'cvox.BrailleIme.Port', |
| |
| /** |
| * Identifier for the use standard keyboard option used in the menu and |
| * {@code localStorage}. This can be switched on to type braille using the |
| * standard keyboard, or off (default) for the usual keyboard behaviour. |
| * @const {string} |
| */ |
| USE_STANDARD_KEYBOARD_ID: 'useStandardKeyboard', |
| |
| // State related to the support for typing braille using a standrad |
| // (qwerty) keyboard. |
| |
| /** @private {boolean} */ |
| useStandardKeyboard_: false, |
| |
| /** |
| * Braille dots for keys that are currently pressed. |
| * @private {number} |
| */ |
| pressed_: 0, |
| |
| /** |
| * Dots that have been pressed at some point since {@code pressed_} was last |
| * {@code 0}. |
| * @private {number} |
| */ |
| accumulated_: 0, |
| |
| /** |
| * Bit in {@code pressed_} and {@code accumulated_} that represent |
| * the space key. |
| * @const {number} |
| */ |
| SPACE: 0x100, |
| |
| /** |
| * Maps key codes on a standard keyboard to the correspodning dots. |
| * Keys on the 'home row' correspond to the keys on a Perkins-style keyboard. |
| * Note that the mapping below is arranged like the dots in a braille cell. |
| * Only 6 dot input is supported. |
| * @private |
| * @const {Object<number>} |
| */ |
| CODE_TO_DOT_: {'KeyF': 0x01, 'KeyJ': 0x08, |
| 'KeyD': 0x02, 'KeyK': 0x10, |
| 'KeyS': 0x04, 'KeyL': 0x20, |
| 'Space': 0x100 }, |
| |
| /** |
| * The current engine ID as set by {@code onActivate}, or the empty string if |
| * the IME is not active. |
| * @type {string} |
| * @private |
| */ |
| engineID_: '', |
| |
| /** |
| * The port used to communicate with ChromeVox. |
| * @type {Port} port_ |
| * @private |
| */ |
| port_: null, |
| |
| /** |
| * Uncommitted text and context ID. |
| * @type {?{contextID: number, text: string}} |
| * @private |
| */ |
| uncommitted_: null, |
| |
| /** |
| * Registers event listeners in the chrome IME API. |
| */ |
| init: function() { |
| chrome.input.ime.onActivate.addListener(this.onActivate_.bind(this)); |
| chrome.input.ime.onDeactivated.addListener(this.onDeactivated_.bind(this)); |
| chrome.input.ime.onFocus.addListener(this.onFocus_.bind(this)); |
| chrome.input.ime.onBlur.addListener(this.onBlur_.bind(this)); |
| chrome.input.ime.onInputContextUpdate.addListener( |
| this.onInputContextUpdate_.bind(this)); |
| chrome.input.ime.onKeyEvent.addListener(this.onKeyEvent_.bind(this), |
| ['async']); |
| chrome.input.ime.onReset.addListener(this.onReset_.bind(this)); |
| chrome.input.ime.onMenuItemActivated.addListener( |
| this.onMenuItemActivated_.bind(this)); |
| this.connectChromeVox_(); |
| }, |
| |
| /** |
| * Called by the IME framework when this IME is activated. |
| * @param {string} engineID Engine ID, should be 'braille'. |
| * @private |
| */ |
| onActivate_: function(engineID) { |
| this.log_('onActivate', engineID); |
| this.engineID_ = engineID; |
| if (!this.port_) { |
| this.connectChromeVox_(); |
| } |
| this.useStandardKeyboard_ = |
| localStorage[this.USE_STANDARD_KEYBOARD_ID] === String(true); |
| this.accumulated_ = 0; |
| this.pressed_ = 0; |
| this.updateMenuItems_(); |
| this.sendActiveState_(); |
| }, |
| |
| /** |
| * Called by the IME framework when this IME is deactivated. |
| * @param {string} engineID Engine ID, should be 'braille'. |
| * @private |
| */ |
| onDeactivated_: function(engineID) { |
| this.log_('onDectivated', engineID); |
| this.engineID_ = ''; |
| this.sendActiveState_(); |
| }, |
| |
| /** |
| * Called by the IME framework when a text field receives focus. |
| * @param {InputContext} context Input field context. |
| * @private |
| */ |
| onFocus_: function(context) { |
| this.log_('onFocus', context); |
| this.sendInputContext_(context); |
| }, |
| |
| /** |
| * Called by the IME framework when a text field looses focus. |
| * @param {number} contextID Input field context ID. |
| * @private |
| */ |
| onBlur_: function(contextID) { |
| this.log_('onBlur', contextID + ''); |
| this.sendInputContext_(null); |
| }, |
| |
| /** |
| * Called by the IME framework when the current input context is updated. |
| * @param {InputContext} context Input field context. |
| * @private |
| */ |
| onInputContextUpdate_: function(context) { |
| this.log_('onInputContextUpdate', context); |
| this.sendInputContext_(context); |
| }, |
| |
| /** |
| * Called by the system when this IME is active and a key event is generated. |
| * @param {string} engineID Engine ID, should be 'braille'. |
| * @param {!ChromeKeyboardEvent} event The keyboard event. |
| * @private |
| */ |
| onKeyEvent_: function(engineID, event) { |
| var result = this.processKey_(event); |
| if (result !== undefined) |
| this.keyEventHandled_(event.requestId, event.type, result); |
| }, |
| |
| /** |
| * Called when chrome ends the current text input session. |
| * @param {string} engineID Engine ID, should be 'braille'. |
| * @private |
| */ |
| onReset_: function(engineID) { |
| this.log_('onReset', engineID); |
| this.engineID_ = engineID; |
| this.sendToChromeVox_({type: 'reset'}); |
| }, |
| |
| /** |
| * Called by the IME framework when a menu item is activated. |
| * @param {string} engineID Engine ID, should be 'braille'. |
| * @param {string} itemID Identifies the menu item. |
| * @private |
| */ |
| onMenuItemActivated_: function(engineID, itemID) { |
| if (engineID === this.engineID_ && |
| itemID === this.USE_STANDARD_KEYBOARD_ID) { |
| this.useStandardKeyboard_ = !this.useStandardKeyboard_; |
| localStorage[this.USE_STANDARD_KEYBOARD_ID] = |
| String(this.useStandardKeyboard_); |
| if (!this.useStandardKeyboard_) { |
| this.accumulated_ = 0; |
| this.pressed_ = 0; |
| } |
| this.updateMenuItems_(); |
| } |
| }, |
| |
| /** |
| * Outputs a log message to the console, only if {@link BrailleIme.DEBUG} |
| * is set to true. |
| * @param {string} func Name of the caller. |
| * @param {Object|string=} message Message to output. |
| * @private |
| */ |
| log_: function(func, message) { |
| if (this.DEBUG) { |
| if (typeof(message) !== 'string') { |
| message = JSON.stringify(message); |
| } |
| console.log('BrailleIme.' + func + ': ' + message); |
| } |
| }, |
| |
| /** |
| * Handles a qwerty key on the home row as a braille key. |
| * @param {!ChromeKeyboardEvent} event Keyboard event. |
| * @return {boolean|undefined} Whether the event was handled, or |
| * {@code undefined} if handling was delegated to ChromeVox. |
| * @private |
| */ |
| processKey_: function(event) { |
| if (!this.useStandardKeyboard_) { |
| return false; |
| } |
| if (event.code === 'Backspace' && event.type === 'keydown') { |
| this.pressed_ = 0; |
| this.accumulated_ = 0; |
| this.sendToChromeVox_( |
| {type: 'backspace', requestId: event.requestId}); |
| return undefined; |
| } |
| var dot = this.CODE_TO_DOT_[event.code]; |
| if (!dot || event.altKey || event.ctrlKey || event.shiftKey || |
| event.capsLock) { |
| this.pressed_ = 0; |
| this.accumulated_ = 0; |
| return false; |
| } |
| if (event.type === 'keydown') { |
| this.pressed_ |= dot; |
| this.accumulated_ |= this.pressed_; |
| return true; |
| } else if (event.type === 'keyup') { |
| this.pressed_ &= ~dot; |
| if (this.pressed_ === 0 && this.accumulated_ !== 0) { |
| var dotsToSend = this.accumulated_; |
| this.accumulated_ = 0; |
| if (dotsToSend & this.SPACE) { |
| if (dotsToSend != this.SPACE) { |
| // Can't combine space and actual dot keys. |
| return true; |
| } |
| // Space is sent as a blank cell. |
| dotsToSend = 0; |
| } |
| this.sendToChromeVox_({type: 'brailleDots', dots: dotsToSend}); |
| } |
| return true; |
| } |
| return false; |
| }, |
| |
| /** |
| * Connects to the ChromeVox extension for message passing. |
| * @private |
| */ |
| connectChromeVox_: function() { |
| if (this.port_) { |
| this.port_.disconnect(); |
| this.port_ = null; |
| } |
| this.port_ = chrome.runtime.connect( |
| this.CHROMEVOX_EXTENSION_ID_, {name: this.PORT_NAME}); |
| this.port_.onMessage.addListener( |
| this.onChromeVoxMessage_.bind(this)); |
| this.port_.onDisconnect.addListener( |
| this.onChromeVoxDisconnect_.bind(this)); |
| }, |
| |
| /** |
| * Handles a message from the ChromeVox extension. |
| * @param {*} message The message from the extension. |
| * @private |
| */ |
| onChromeVoxMessage_: function(message) { |
| message = /** @type {{type: string}} */ (message); |
| this.log_('onChromeVoxMessage', message); |
| switch (message.type) { |
| case 'replaceText': |
| message = |
| /** |
| * @type {{contextID: number, deleteBefore: number, |
| * newText: string}} |
| */ |
| (message); |
| this.replaceText_(message.contextID, message.deleteBefore, |
| message.newText); |
| break; |
| case 'keyEventHandled': |
| message = |
| /** @type {{requestId: string, result: boolean}} */ (message); |
| this.keyEventHandled_(message.requestId, 'keydown', message.result); |
| break; |
| case 'setUncommitted': |
| message = |
| /** @type {{contextID: number, text: string}} */ (message); |
| this.setUncommitted_(message.contextID, message.text); |
| break; |
| case 'commitUncommitted': |
| message = |
| /** @type {{contextID: number}} */ (message); |
| this.commitUncommitted_(message.contextID); |
| break; |
| default: |
| console.error('Unknown message from ChromeVox: ' + |
| JSON.stringify(message)); |
| break; |
| } |
| }, |
| |
| /** |
| * Handles a disconnect event from the ChromeVox side. |
| * @private |
| */ |
| onChromeVoxDisconnect_: function() { |
| this.port_ = null; |
| this.log_('onChromeVoxDisconnect', chrome.runtime.lastError); |
| }, |
| |
| /** |
| * Sends a message to the ChromeVox extension. |
| * @param {Object} message The message to send. |
| * @private |
| */ |
| sendToChromeVox_: function(message) { |
| if (this.port_) { |
| this.port_.postMessage(message); |
| } |
| }, |
| |
| /** |
| * Sends the given input context to ChromeVox. |
| * @param {InputContext} context Input context, or null when there's no input |
| * context. |
| * @private |
| */ |
| sendInputContext_: function(context) { |
| this.sendToChromeVox_({type: 'inputContext', context: context}); |
| }, |
| |
| /** |
| * Sends the active state to ChromeVox. |
| * @private |
| */ |
| sendActiveState_: function() { |
| this.sendToChromeVox_({type: 'activeState', |
| active: this.engineID_.length > 0}); |
| }, |
| |
| /** |
| * Replaces text in the current text field. |
| * @param {number} contextID Context for the input field to replace the |
| * text in. |
| * @param {number} deleteBefore How many characters to delete before the |
| * cursor. |
| * @param {string} toInsert Text to insert at the cursor. |
| */ |
| replaceText_: function(contextID, deleteBefore, toInsert) { |
| var addText = chrome.input.ime.commitText.bind( |
| null, {contextID: contextID, text: toInsert}, function() {}); |
| if (deleteBefore > 0) { |
| var deleteText = chrome.input.ime.deleteSurroundingText.bind(null, |
| {engineID: this.engineID_, contextID: contextID, |
| offset: -deleteBefore, length: deleteBefore}, addText); |
| // Make sure there's no non-zero length selection so that |
| // deleteSurroundingText works correctly. |
| chrome.input.ime.deleteSurroundingText( |
| {engineID: this.engineID_, contextID: contextID, |
| offset: 0, length: 0}, deleteText); |
| } else { |
| addText(); |
| } |
| }, |
| |
| /** |
| * Responds to an asynchronous key event, indicating whether it was handled |
| * or not. If it wasn't handled, any uncommitted text is committed |
| * before sending the response to the IME API. |
| * @param {string} requestId Key event request id. |
| * @param {string} type Type of key event being responded to. |
| * @param {boolean} response Whether the IME handled the event. |
| */ |
| keyEventHandled_: function(requestId, type, response) { |
| if (!response && type === 'keydown' && this.uncommitted_) { |
| this.commitUncommitted_(this.uncommitted_.contextID); |
| this.sendToChromeVox_({type: 'reset'}); |
| } |
| chrome.input.ime.keyEventHandled(requestId, response); |
| }, |
| |
| /** |
| * Stores uncommitted text that will be committed on any key press or |
| * when {@code commitUncommitted_} is called. |
| * @param {number} contextID of the current field. |
| * @param {string} text to store. |
| */ |
| setUncommitted_: function(contextID, text) { |
| this.uncommitted_ = {contextID: contextID, text: text}; |
| }, |
| |
| /** |
| * Commits the last set uncommitted text if it matches the given context id. |
| * @param {number} contextID |
| */ |
| commitUncommitted_: function(contextID) { |
| if (this.uncommitted_ && contextID === this.uncommitted_.contextID) |
| chrome.input.ime.commitText(this.uncommitted_); |
| this.uncommitted_ = null; |
| }, |
| |
| /** |
| * Updates the menu items for this IME. |
| */ |
| updateMenuItems_: function() { |
| // TODO(plundblad): Localize when translations available. |
| chrome.input.ime.setMenuItems( |
| {engineID: this.engineID_, |
| items: [ |
| { |
| id: this.USE_STANDARD_KEYBOARD_ID, |
| label: 'Use standard keyboard for braille', |
| style: 'check', |
| visible: true, |
| checked: this.useStandardKeyboard_, |
| enabled: true |
| } |
| ] |
| }); |
| } |
| }; |