blob: 5dc4c9a0ed9001c2a5a71942c25795b863c8de09 [file] [log] [blame]
// 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});
}
};