| // 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. |
| |
| /** |
| * @fileoverview Handles braille input keys when the user is typing or editing |
| * text in an input field. This class cooperates with the Braille IME |
| * that is built into Chrome OS to do the actual text editing. |
| */ |
| |
| goog.provide('cvox.BrailleInputHandler'); |
| |
| goog.require('StringUtil'); |
| goog.require('cvox.BrailleKeyCommand'); |
| goog.require('cvox.BrailleKeyEvent'); |
| goog.require('cvox.ExpandingBrailleTranslator'); |
| |
| /** |
| * @param {!cvox.BrailleTranslatorManager} translatorManager Keeps track of |
| * the current braille translator(s). |
| * @constructor |
| */ |
| cvox.BrailleInputHandler = function(translatorManager) { |
| /** |
| * Port of the connected IME if any. |
| * @type {Port} |
| * @private |
| */ |
| this.imePort_ = null; |
| /** |
| * {code true} when the Braille IME is connected and has signaled that it is |
| * active. |
| * @type {boolean} |
| * @private |
| */ |
| this.imeActive_ = false; |
| /** |
| * The input context of the current input field, as reported by the IME. |
| * {@code null} if no input field has focus. |
| * @type {{contextID: number, type: string}?} |
| * @private |
| */ |
| this.inputContext_ = null; |
| /** |
| * @type {!cvox.BrailleTranslatorManager} |
| * @private |
| */ |
| this.translatorManager_ = translatorManager; |
| /** |
| * Text that currently precedes the first selection end-point. |
| * @type {string} |
| * @private |
| */ |
| this.currentTextBefore_ = ''; |
| /** |
| * Text that currently follows the last selection end-point. |
| * @type {string} |
| * @private |
| */ |
| this.currentTextAfter_ = ''; |
| /** |
| * Cells that were entered while the IME wasn't active. These will be |
| * submitted once the IME becomes active and reports the current input field. |
| * This is necessary because the IME is activated on the first braille |
| * dots command, but we'll receive the command in parallel. To work around |
| * the race, we store the cell entered until we can submit it to the IME. |
| * @type {!Array<number>} |
| * @private |
| */ |
| this.pendingCells_ = []; |
| /** |
| * @type {cvox.BrailleInputHandler.EntryState_} |
| * @private |
| */ |
| this.entryState_ = null; |
| /** |
| * @type {cvox.ExtraCellsSpan} |
| * @private |
| */ |
| this.uncommittedCellsSpan_ = null; |
| /** |
| * @type {function()?} |
| * @private |
| */ |
| this.uncommittedCellsChangedListener_ = null; |
| |
| this.translatorManager_.addChangeListener( |
| this.commitAndClearEntryState_.bind(this)); |
| }; |
| |
| /** |
| * The ID of the Braille IME extension built into Chrome OS. |
| * @const {string} |
| * @private |
| */ |
| cvox.BrailleInputHandler.IME_EXTENSION_ID_ = |
| 'jddehjeebkoimngcbdkaahpobgicbffp'; |
| |
| /** |
| * Name of the port to use for communicating with the Braille IME. |
| * @const {string} |
| * @private |
| */ |
| cvox.BrailleInputHandler.IME_PORT_NAME_ = 'cvox.BrailleIme.Port'; |
| |
| /** |
| * Regular expression that matches a string that starts with at least one |
| * non-whitespace character. |
| * @const {RegExp} |
| * @private |
| */ |
| cvox.BrailleInputHandler.STARTS_WITH_NON_WHITESPACE_RE_ = /^\S/; |
| |
| /** |
| * Regular expression that matches a string that ends with at least one |
| * non-whitespace character. |
| * @const {RegExp} |
| * @private |
| */ |
| cvox.BrailleInputHandler.ENDS_WITH_NON_WHITESPACE_RE_ = /\S$/; |
| |
| cvox.BrailleInputHandler.prototype = { |
| /** |
| * Starts to listen for connections from the Chrome OS braille IME. |
| */ |
| init: function() { |
| chrome.runtime.onConnectExternal.addListener(this.onImeConnect_.bind(this)); |
| }, |
| |
| /** |
| * Called when the content on the braille display is updated. Modifies the |
| * input state according to the new content. |
| * @param {cvox.Spannable} text Text, optionally with value and selection |
| * spans. |
| * @param {function()} listener Called when the uncommitted cells |
| * have changed. |
| */ |
| onDisplayContentChanged: function(text, listener) { |
| var valueSpan = text.getSpanInstanceOf(cvox.ValueSpan); |
| var selectionSpan = text.getSpanInstanceOf(cvox.ValueSelectionSpan); |
| if (!(valueSpan && selectionSpan)) |
| return; |
| // Don't call the old listener any further, since new content is being |
| // set. If the old listener is not cleared here, it could be called |
| // spuriously if the entry state is cleared below. |
| this.uncommittedCellsChangedListener_ = null; |
| // The type casts are ok because the spans are known to exist. |
| var valueStart = /** @type {number} */ (text.getSpanStart(valueSpan)); |
| var valueEnd = /** @type {number} */ (text.getSpanEnd(valueSpan)); |
| var selectionStart = |
| /** @type {number} */ (text.getSpanStart(selectionSpan)); |
| var selectionEnd = /** @type {number} */ (text.getSpanEnd(selectionSpan)); |
| if (selectionStart < valueStart || selectionEnd > valueEnd) { |
| console.error('Selection outside of value in braille content'); |
| this.clearEntryState_(); |
| return; |
| } |
| var newTextBefore = text.toString().substring(valueStart, selectionStart); |
| if (this.currentTextBefore_ !== newTextBefore && this.entryState_) |
| this.entryState_.onTextBeforeChanged(newTextBefore); |
| this.currentTextBefore_ = newTextBefore; |
| this.currentTextAfter_ = text.toString().substring(selectionEnd, valueEnd); |
| this.uncommittedCellsSpan_ = new cvox.ExtraCellsSpan(); |
| text.setSpan(this.uncommittedCellsSpan_, selectionStart, selectionStart); |
| if (this.entryState_ && this.entryState_.usesUncommittedCells) { |
| this.updateUncommittedCells_( |
| new Uint8Array(this.entryState_.cells_).buffer); |
| } |
| this.uncommittedCellsChangedListener_ = listener; |
| }, |
| |
| /** |
| * Handles braille key events used for input by editing the current input |
| * field appropriately. |
| * @param {!cvox.BrailleKeyEvent} event The key event. |
| * @return {boolean} {@code true} if the event was handled, {@code false} |
| * if it should propagate further. |
| */ |
| onBrailleKeyEvent: function(event) { |
| if (event.command === cvox.BrailleKeyCommand.DOTS) |
| return this.onBrailleDots_(/** @type {number} */(event.brailleDots)); |
| // Any other braille command cancels the pending cells. |
| this.pendingCells_.length = 0; |
| if (event.command === cvox.BrailleKeyCommand.STANDARD_KEY) { |
| if (event.standardKeyCode === 'Backspace' && |
| !event.altKey && !event.ctrlKey && !event.shiftKey && |
| this.onBackspace_()) { |
| return true; |
| } else { |
| this.commitAndClearEntryState_(); |
| this.sendKeyEventPair_(event); |
| return true; |
| } |
| } |
| return false; |
| }, |
| |
| /** |
| * Returns how the value of the currently displayed content should be |
| * expanded given the current input state. |
| * @return {cvox.ExpandingBrailleTranslator.ExpansionType} |
| * The current expansion type. |
| */ |
| getExpansionType: function() { |
| if (this.inAlwaysUncontractedContext_()) |
| return cvox.ExpandingBrailleTranslator.ExpansionType.ALL; |
| if (this.entryState_ && |
| this.entryState_.translator === |
| this.translatorManager_.getDefaultTranslator()) { |
| return cvox.ExpandingBrailleTranslator.ExpansionType.NONE; |
| } |
| return cvox.ExpandingBrailleTranslator.ExpansionType.SELECTION; |
| }, |
| |
| /** |
| * @return {boolean} {@code true} if we have an input context and |
| * uncontracted braille should always be used for that context. |
| * @private |
| */ |
| inAlwaysUncontractedContext_: function() { |
| var inputType = this.inputContext_ ? this.inputContext_.type : ''; |
| return inputType === 'url' || inputType === 'email'; |
| }, |
| |
| /** |
| * Called when a user typed a braille cell. |
| * @param {number} dots The dot pattern of the cell. |
| * @return {boolean} Whether the event was handled or should be allowed to |
| * propagate further. |
| * @private |
| */ |
| onBrailleDots_: function(dots) { |
| if (!this.imeActive_) { |
| this.pendingCells_.push(dots); |
| return true; |
| } |
| if (!this.inputContext_) |
| return false; |
| if (!this.entryState_) { |
| if (!(this.entryState_ = this.createEntryState_())) |
| return false; |
| } |
| this.entryState_.appendCell(dots); |
| return true; |
| }, |
| |
| /** |
| * Handles the backspace key by deleting the last typed cell if possible. |
| * @return {boolean} {@code true} if the event was handled, {@code false} |
| * if it wasn't and should propagate further. |
| * @private |
| */ |
| onBackspace_: function() { |
| if (this.imeActive_ && this.entryState_) { |
| this.entryState_.deleteLastCell(); |
| return true; |
| } |
| return false; |
| }, |
| |
| /** |
| * Creates a new empty {@code EntryState_} based on the current input context |
| * and surrounding text. |
| * @return {cvox.BrailleInputHandler.EntryState_} The newly created state |
| * object, or null if it couldn't be created (e.g. if there's no braille |
| * translator available yet). |
| * @private |
| */ |
| createEntryState_: function() { |
| var translator = this.translatorManager_.getDefaultTranslator(); |
| if (!translator) |
| return null; |
| var uncontractedTranslator = |
| this.translatorManager_.getUncontractedTranslator(); |
| var constructor = cvox.BrailleInputHandler.EditsEntryState_; |
| if (uncontractedTranslator) { |
| var textBefore = this.currentTextBefore_; |
| var textAfter = this.currentTextAfter_; |
| if (this.inAlwaysUncontractedContext_() || |
| (cvox.BrailleInputHandler.ENDS_WITH_NON_WHITESPACE_RE_.test( |
| textBefore)) || |
| (cvox.BrailleInputHandler.STARTS_WITH_NON_WHITESPACE_RE_.test( |
| textAfter))) { |
| translator = uncontractedTranslator; |
| } else { |
| constructor = cvox.BrailleInputHandler.LateCommitEntryState_; |
| } |
| } |
| |
| return new constructor(this, translator); |
| }, |
| |
| /** |
| * Commits the current entry state and clears it, if any. |
| * @private |
| */ |
| commitAndClearEntryState_: function() { |
| if (this.entryState_) { |
| this.entryState_.commit(); |
| this.clearEntryState_(); |
| } |
| }, |
| |
| /** |
| * Clears the current entry state without committing it. |
| * @private |
| */ |
| clearEntryState_: function() { |
| if (this.entryState_) { |
| if (this.entryState_.usesUncommittedCells) |
| this.updateUncommittedCells_(new ArrayBuffer(0)); |
| this.entryState_.inputHandler_ = null; |
| this.entryState_ = null; |
| } |
| }, |
| |
| /** |
| * @param {ArrayBuffer} cells |
| * @private |
| */ |
| updateUncommittedCells_: function(cells) { |
| if (this.uncommittedCellsSpan_) |
| this.uncommittedCellsSpan_.cells = cells; |
| if (this.uncommittedCellsChangedListener_) |
| this.uncommittedCellsChangedListener_(); |
| }, |
| |
| /** |
| * Called when another extension connects to this extension. Accepts |
| * connections from the ChromeOS builtin Braille IME and ignores connections |
| * from other extensions. |
| * @param {Port} port The port used to communicate with the other extension. |
| * @private |
| */ |
| onImeConnect_: function(port) { |
| if (port.name !== cvox.BrailleInputHandler.IME_PORT_NAME_ || |
| port.sender.id !== cvox.BrailleInputHandler.IME_EXTENSION_ID_) { |
| return; |
| } |
| if (this.imePort_) |
| this.imePort_.disconnect(); |
| port.onDisconnect.addListener(this.onImeDisconnect_.bind(this, port)); |
| port.onMessage.addListener(this.onImeMessage_.bind(this)); |
| this.imePort_ = port; |
| }, |
| |
| /** |
| * Called when a message is received from the IME. |
| * @param {*} message The message. |
| * @private |
| */ |
| onImeMessage_: function(message) { |
| if (!goog.isObject(message)) { |
| console.error('Unexpected message from Braille IME: ', |
| JSON.stringify(message)); |
| } |
| switch (message.type) { |
| case 'activeState': |
| this.imeActive_ = message.active; |
| break; |
| case 'inputContext': |
| this.inputContext_ = message.context; |
| this.clearEntryState_(); |
| if (this.imeActive_ && this.inputContext_) |
| this.pendingCells_.forEach(this.onBrailleDots_, this); |
| this.pendingCells_.length = 0; |
| break; |
| case 'brailleDots': |
| this.onBrailleDots_(message['dots']); |
| break; |
| case 'backspace': |
| // Note that we can't send the backspace key through the |
| // virtualKeyboardPrivate API in this case because it would then be |
| // processed by the IME again, leading to an infinite loop. |
| this.postImeMessage_( |
| {type: 'keyEventHandled', requestId: message['requestId'], |
| result: this.onBackspace_()}); |
| break; |
| case 'reset': |
| this.clearEntryState_(); |
| break; |
| default: |
| console.error('Unexpected message from Braille IME: ', |
| JSON.stringify(message)); |
| break; |
| } |
| }, |
| |
| /** |
| * Called when the IME port is disconnected. |
| * @param {Port} port The port that was disconnected. |
| * @private |
| */ |
| onImeDisconnect_: function(port) { |
| this.imePort_ = null; |
| this.clearEntryState_(); |
| this.imeActive_ = false; |
| this.inputContext_ = null; |
| }, |
| |
| /** |
| * Posts a message to the IME. |
| * @param {Object} message The message. |
| * @return {boolean} {@code true} if the message was sent, {@code false} if |
| * there was no connection open to the IME. |
| * @private |
| */ |
| postImeMessage_: function(message) { |
| if (this.imePort_) { |
| this.imePort_.postMessage(message); |
| return true; |
| } |
| return false; |
| }, |
| |
| /** |
| * Sends a {@code keydown} key event followed by a {@code keyup} event |
| * corresponding to an event generated by the braille display. |
| * @param {!cvox.BrailleKeyEvent} event The braille key event to base the |
| * key events on. |
| * @private |
| */ |
| sendKeyEventPair_: function(event) { |
| // Use the virtual keyboard API instead of the IME key event API |
| // so that these keys work even if the Braille IME is not active. |
| var keyName = /** @type {string} */ (event.standardKeyCode); |
| var numericCode = cvox.BrailleKeyEvent.keyCodeToLegacyCode(keyName); |
| if (!goog.isDef(numericCode)) |
| throw Error('Unknown key code in event: ' + JSON.stringify(event)); |
| var keyEvent = { |
| type: 'keydown', |
| keyCode: numericCode, |
| keyName: keyName, |
| charValue: cvox.BrailleKeyEvent.keyCodeToCharValue(keyName), |
| // See chrome/common/extensions/api/virtual_keyboard_private.json for |
| // these constants. |
| modifiers: (event.shiftKey ? 2 : 0) | |
| (event.ctrlKey ? 4 : 0) | |
| (event.altKey ? 8 : 0) |
| }; |
| chrome.virtualKeyboardPrivate.sendKeyEvent(keyEvent); |
| keyEvent.type = 'keyup'; |
| chrome.virtualKeyboardPrivate.sendKeyEvent(keyEvent); |
| } |
| }; |
| |
| /** |
| * The entry state is the state related to entering a series of braille cells |
| * without 'interruption', where interruption can be things like non braille |
| * keyboard input or unexpected changes to the text surrounding the cursor. |
| * @param {!cvox.BrailleInputHandler} inputHandler |
| * @param {!cvox.LibLouis.Translator} translator |
| * @constructor |
| * @private |
| */ |
| cvox.BrailleInputHandler.EntryState_ = function(inputHandler, translator) { |
| /** |
| * @type {cvox.BrailleInputHandler} |
| * @private |
| */ |
| this.inputHandler_ = inputHandler; |
| /** |
| * The translator currently used for typing, if |
| * {@code this.cells_.length > 0}. |
| * @type {!cvox.LibLouis.Translator} |
| * @private |
| */ |
| this.translator_ = translator; |
| /** |
| * Braille cells that have been typed by the user so far. |
| * @type {!Array<number>} |
| * @private |
| */ |
| this.cells_ = []; |
| /** |
| * Text resulting from translating {@code this.cells_}. |
| * @type {string} |
| * @private |
| */ |
| this.text_ = ''; |
| /** |
| * List of strings that we expect to be set as preceding text of the |
| * selection. This is populated when we send text changes to the IME so that |
| * our own changes don't reset the pending cells. |
| * @type {!Array<string>} |
| * @private |
| */ |
| this.pendingTextsBefore_ = []; |
| }; |
| |
| cvox.BrailleInputHandler.EntryState_.prototype = { |
| /** |
| * @return {!cvox.LibLouis.Translator} The translator used by this entry |
| * state. This doesn't change for a given object. |
| */ |
| get translator() { |
| return this.translator_; |
| }, |
| |
| /** |
| * Appends a braille cell to the current input and updates the text if |
| * necessary. |
| * @param {number} cell The braille cell to append. |
| */ |
| appendCell: function(cell) { |
| this.cells_.push(cell); |
| this.updateText_(); |
| }, |
| |
| /** |
| * Deletes the last cell of the input and updates the text if neccary. |
| * If there's no more input in this object afterwards, clears the entry state |
| * of the input handler. |
| */ |
| deleteLastCell: function() { |
| if (--this.cells_.length <= 0) { |
| this.sendTextChange_(''); |
| this.inputHandler_.clearEntryState_(); |
| return; |
| } |
| this.updateText_(); |
| }, |
| |
| /** |
| * Called when the text before the cursor changes giving this object a |
| * chance to clear the entry state of the input handler if the change |
| * wasn't expected. |
| * @param {string} newText New text before the cursor. |
| */ |
| onTextBeforeChanged: function(newText) { |
| // See if we are expecting this change as a result of one of our own edits. |
| // Allow changes to be coalesced by the input system in an attempt to not |
| // be too brittle. |
| for (var i = 0; i < this.pendingTextsBefore_.length; ++i) { |
| if (newText === this.pendingTextsBefore_[i]) { |
| // Delete all previous expected changes and ignore this one. |
| this.pendingTextsBefore_.splice(0, i + 1); |
| return; |
| } |
| } |
| // There was an actual text change (or cursor movement) that we hadn't |
| // caused ourselves, reset any pending input. |
| this.inputHandler_.clearEntryState_(); |
| }, |
| |
| /** |
| * Makes sure the current text is permanently added to the edit field. |
| * After this call, this object should be abandoned. |
| */ |
| commit: function() { |
| }, |
| |
| /** |
| * @return {boolean} true if the entry state uses uncommitted cells. |
| */ |
| get usesUncommittedCells() { |
| return false; |
| }, |
| |
| /** |
| * Updates the translated text based on the current cells and sends the |
| * delta to the IME. |
| * @private |
| */ |
| updateText_: function() { |
| var cellsBuffer = new Uint8Array(this.cells_).buffer; |
| var commit = this.lastCellIsBlank_; |
| if (!commit && this.usesUncommittedCells) |
| this.inputHandler_.updateUncommittedCells_(cellsBuffer); |
| this.translator_.backTranslate(cellsBuffer, function(result) { |
| if (result === null) { |
| console.error('Error when backtranslating braille cells'); |
| return; |
| } |
| if (!this.inputHandler_) |
| return; |
| this.sendTextChange_(result); |
| this.text_ = result; |
| if (commit) |
| this.inputHandler_.commitAndClearEntryState_(); |
| }.bind(this)); |
| }, |
| |
| /** |
| * @return {boolean} |
| * @private |
| */ |
| get lastCellIsBlank_() { |
| return this.cells_[this.cells_.length - 1] === 0; |
| }, |
| |
| /** |
| * Sends new text to the IME. This dhould be overriden by subclasses. |
| * The old text is still available in the {@code text_} property. |
| * @param {string} newText Text to send. |
| * @private |
| */ |
| sendTextChange_: function(newText) { |
| } |
| }; |
| |
| /** |
| * Entry state that uses {@code deleteSurroundingText} and {@code commitText} |
| * calls to the IME to update the currently enetered text. |
| * @param {!cvox.BrailleInputHandler} inputHandler |
| * @param {!cvox.LibLouis.Translator} translator |
| * @constructor |
| * @extends {cvox.BrailleInputHandler.EntryState_} |
| * @private |
| */ |
| cvox.BrailleInputHandler.EditsEntryState_ = function( |
| inputHandler, translator) { |
| cvox.BrailleInputHandler.EntryState_.call(this, inputHandler, translator); |
| }; |
| |
| cvox.BrailleInputHandler.EditsEntryState_.prototype = { |
| __proto__: cvox.BrailleInputHandler.EntryState_.prototype, |
| |
| /** @override */ |
| sendTextChange_: function(newText) { |
| var oldText = this.text_; |
| // Find the common prefix of the old and new text. |
| var commonPrefixLength = StringUtil.longestCommonPrefixLength( |
| oldText, newText); |
| // How many characters we need to delete from the existing text to replace |
| // them with characters from the new text. |
| var deleteLength = oldText.length - commonPrefixLength; |
| // New text, if any, to insert after deleting the deleteLength characters |
| // before the cursor. |
| var toInsert = newText.substring(commonPrefixLength); |
| if (deleteLength > 0 || toInsert.length > 0) { |
| // After deleting, we expect this text to be present before the cursor. |
| var textBeforeAfterDelete = |
| this.inputHandler_.currentTextBefore_.substring( |
| 0, this.inputHandler_.currentTextBefore_.length - deleteLength); |
| if (deleteLength > 0) { |
| // Queue this text up to be ignored when the change comes in. |
| this.pendingTextsBefore_.push(textBeforeAfterDelete); |
| } |
| if (toInsert.length > 0) { |
| // Likewise, queue up what we expect to be before the cursor after |
| // the replacement text is inserted. |
| this.pendingTextsBefore_.push(textBeforeAfterDelete + toInsert); |
| } |
| // Send the replace operation to be performed asynchronously by the IME. |
| this.inputHandler_.postImeMessage_( |
| {type: 'replaceText', |
| contextID: this.inputHandler_.inputContext_.contextID, |
| deleteBefore: deleteLength, |
| newText: toInsert}); |
| } |
| } |
| }; |
| |
| /** |
| * Entry state that only updates the edit field when a blank cell is entered. |
| * During the input of a single 'word', the uncommitted text is stored by the |
| * IME. |
| * @param {!cvox.BrailleInputHandler} inputHandler |
| * @param {!cvox.LibLouis.Translator} translator |
| * @constructor |
| * @private |
| * @extends {cvox.BrailleInputHandler.EntryState_} |
| */ |
| cvox.BrailleInputHandler.LateCommitEntryState_ = function( |
| inputHandler, translator) { |
| cvox.BrailleInputHandler.EntryState_.call(this, inputHandler, translator); |
| }; |
| |
| cvox.BrailleInputHandler.LateCommitEntryState_.prototype = { |
| __proto__: cvox.BrailleInputHandler.EntryState_.prototype, |
| |
| /** @override */ |
| commit: function() { |
| this.inputHandler_.postImeMessage_( |
| {type: 'commitUncommitted', |
| contextID: this.inputHandler_.inputContext_.contextID}); |
| }, |
| |
| /** @override */ |
| get usesUncommittedCells() { |
| return true; |
| }, |
| |
| /** @override */ |
| sendTextChange_: function(newText) { |
| this.inputHandler_.postImeMessage_( |
| {type: 'setUncommitted', |
| contextID: this.inputHandler_.inputContext_.contextID, |
| text: newText}); |
| } |
| }; |