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.
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.
11 goog.provide('cvox.BrailleInputHandler');
13 goog.require('StringUtil');
14 goog.require('cvox.BrailleKeyCommand');
15 goog.require('cvox.BrailleKeyEvent');
16 goog.require('cvox.ExpandingBrailleTranslator');
19 * @param {!cvox.BrailleTranslatorManager} translatorManager Keeps track of
20 * the current braille translator(s).
23 cvox.BrailleInputHandler = function(translatorManager) {
25 * Port of the connected IME if any.
31 * {code true} when the Braille IME is connected and has signaled that it is
36 this.imeActive_ = false;
38 * The input context of the current input field, as reported by the IME.
39 * {@code null} if no input field has focus.
40 * @type {{contextID: number, type: string}?}
43 this.inputContext_ = null;
45 * @type {!cvox.BrailleTranslatorManager}
48 this.translatorManager_ = translatorManager;
50 * Text that currently precedes the first selection end-point.
54 this.currentTextBefore_ = '';
56 * Text that currently follows the last selection end-point.
60 this.currentTextAfter_ = '';
62 * Cells that were entered while the IME wasn't active. These will be
63 * submitted once the IME becomes active and reports the current input field.
64 * This is necessary because the IME is activated on the first braille
65 * dots command, but we'll receive the command in parallel. To work around
66 * the race, we store the cell entered until we can submit it to the IME.
67 * @type {!Array<number>}
70 this.pendingCells_ = [];
72 * @type {cvox.BrailleInputHandler.EntryState_}
75 this.entryState_ = null;
77 * @type {cvox.ExtraCellsSpan}
80 this.uncommittedCellsSpan_ = null;
85 this.uncommittedCellsChangedListener_ = null;
87 this.translatorManager_.addChangeListener(
88 this.commitAndClearEntryState_.bind(this));
92 * The ID of the Braille IME extension built into Chrome OS.
96 cvox.BrailleInputHandler.IME_EXTENSION_ID_ =
97 'jddehjeebkoimngcbdkaahpobgicbffp';
100 * Name of the port to use for communicating with the Braille IME.
104 cvox.BrailleInputHandler.IME_PORT_NAME_ = 'cvox.BrailleIme.Port';
107 * Regular expression that matches a string that starts with at least one
108 * non-whitespace character.
112 cvox.BrailleInputHandler.STARTS_WITH_NON_WHITESPACE_RE_ = /^\S/;
115 * Regular expression that matches a string that ends with at least one
116 * non-whitespace character.
120 cvox.BrailleInputHandler.ENDS_WITH_NON_WHITESPACE_RE_ = /\S$/;
122 cvox.BrailleInputHandler.prototype = {
124 * Starts to listen for connections from the Chrome OS braille IME.
127 chrome.runtime.onConnectExternal.addListener(this.onImeConnect_.bind(this));
131 * Called when the content on the braille display is updated. Modifies the
132 * input state according to the new content.
133 * @param {cvox.Spannable} text Text, optionally with value and selection
135 * @param {function()} listener Called when the uncommitted cells
138 onDisplayContentChanged: function(text, listener) {
139 var valueSpan = text.getSpanInstanceOf(cvox.ValueSpan);
140 var selectionSpan = text.getSpanInstanceOf(cvox.ValueSelectionSpan);
141 if (!(valueSpan && selectionSpan))
143 // Don't call the old listener any further, since new content is being
144 // set. If the old listener is not cleared here, it could be called
145 // spuriously if the entry state is cleared below.
146 this.uncommittedCellsChangedListener_ = null;
147 // The type casts are ok because the spans are known to exist.
148 var valueStart = /** @type {number} */ (text.getSpanStart(valueSpan));
149 var valueEnd = /** @type {number} */ (text.getSpanEnd(valueSpan));
151 /** @type {number} */ (text.getSpanStart(selectionSpan));
152 var selectionEnd = /** @type {number} */ (text.getSpanEnd(selectionSpan));
153 if (selectionStart < valueStart || selectionEnd > valueEnd) {
154 console.error('Selection outside of value in braille content');
155 this.clearEntryState_();
158 var newTextBefore = text.toString().substring(valueStart, selectionStart);
159 if (this.currentTextBefore_ !== newTextBefore && this.entryState_)
160 this.entryState_.onTextBeforeChanged(newTextBefore);
161 this.currentTextBefore_ = newTextBefore;
162 this.currentTextAfter_ = text.toString().substring(selectionEnd, valueEnd);
163 this.uncommittedCellsSpan_ = new cvox.ExtraCellsSpan();
164 text.setSpan(this.uncommittedCellsSpan_, selectionStart, selectionStart);
165 if (this.entryState_ && this.entryState_.usesUncommittedCells) {
166 this.updateUncommittedCells_(
167 new Uint8Array(this.entryState_.cells_).buffer);
169 this.uncommittedCellsChangedListener_ = listener;
173 * Handles braille key events used for input by editing the current input
174 * field appropriately.
175 * @param {!cvox.BrailleKeyEvent} event The key event.
176 * @return {boolean} {@code true} if the event was handled, {@code false}
177 * if it should propagate further.
179 onBrailleKeyEvent: function(event) {
180 if (event.command === cvox.BrailleKeyCommand.DOTS)
181 return this.onBrailleDots_(/** @type {number} */(event.brailleDots));
182 // Any other braille command cancels the pending cells.
183 this.pendingCells_.length = 0;
184 if (event.command === cvox.BrailleKeyCommand.STANDARD_KEY) {
185 if (event.standardKeyCode === 'Backspace' &&
186 !event.altKey && !event.ctrlKey && !event.shiftKey &&
187 this.onBackspace_()) {
190 this.commitAndClearEntryState_();
191 this.sendKeyEventPair_(event);
199 * Returns how the value of the currently displayed content should be
200 * expanded given the current input state.
201 * @return {cvox.ExpandingBrailleTranslator.ExpansionType}
202 * The current expansion type.
204 getExpansionType: function() {
205 if (this.inAlwaysUncontractedContext_())
206 return cvox.ExpandingBrailleTranslator.ExpansionType.ALL;
207 if (this.entryState_ &&
208 this.entryState_.translator ===
209 this.translatorManager_.getDefaultTranslator()) {
210 return cvox.ExpandingBrailleTranslator.ExpansionType.NONE;
212 return cvox.ExpandingBrailleTranslator.ExpansionType.SELECTION;
216 * @return {boolean} {@code true} if we have an input context and
217 * uncontracted braille should always be used for that context.
220 inAlwaysUncontractedContext_: function() {
221 var inputType = this.inputContext_ ? this.inputContext_.type : '';
222 return inputType === 'url' || inputType === 'email';
226 * Called when a user typed a braille cell.
227 * @param {number} dots The dot pattern of the cell.
228 * @return {boolean} Whether the event was handled or should be allowed to
232 onBrailleDots_: function(dots) {
233 if (!this.imeActive_) {
234 this.pendingCells_.push(dots);
237 if (!this.inputContext_)
239 if (!this.entryState_) {
240 if (!(this.entryState_ = this.createEntryState_()))
243 this.entryState_.appendCell(dots);
248 * Handles the backspace key by deleting the last typed cell if possible.
249 * @return {boolean} {@code true} if the event was handled, {@code false}
250 * if it wasn't and should propagate further.
253 onBackspace_: function() {
254 if (this.imeActive_ && this.entryState_) {
255 this.entryState_.deleteLastCell();
262 * Creates a new empty {@code EntryState_} based on the current input context
263 * and surrounding text.
264 * @return {cvox.BrailleInputHandler.EntryState_} The newly created state
265 * object, or null if it couldn't be created (e.g. if there's no braille
266 * translator available yet).
269 createEntryState_: function() {
270 var translator = this.translatorManager_.getDefaultTranslator();
273 var uncontractedTranslator =
274 this.translatorManager_.getUncontractedTranslator();
275 var constructor = cvox.BrailleInputHandler.EditsEntryState_;
276 if (uncontractedTranslator) {
277 var textBefore = this.currentTextBefore_;
278 var textAfter = this.currentTextAfter_;
279 if (this.inAlwaysUncontractedContext_() ||
280 (cvox.BrailleInputHandler.ENDS_WITH_NON_WHITESPACE_RE_.test(
282 (cvox.BrailleInputHandler.STARTS_WITH_NON_WHITESPACE_RE_.test(
284 translator = uncontractedTranslator;
286 constructor = cvox.BrailleInputHandler.LateCommitEntryState_;
290 return new constructor(this, translator);
294 * Commits the current entry state and clears it, if any.
297 commitAndClearEntryState_: function() {
298 if (this.entryState_) {
299 this.entryState_.commit();
300 this.clearEntryState_();
305 * Clears the current entry state without committing it.
308 clearEntryState_: function() {
309 if (this.entryState_) {
310 if (this.entryState_.usesUncommittedCells)
311 this.updateUncommittedCells_(new ArrayBuffer(0));
312 this.entryState_.inputHandler_ = null;
313 this.entryState_ = null;
318 * @param {ArrayBuffer} cells
321 updateUncommittedCells_: function(cells) {
322 if (this.uncommittedCellsSpan_)
323 this.uncommittedCellsSpan_.cells = cells;
324 if (this.uncommittedCellsChangedListener_)
325 this.uncommittedCellsChangedListener_();
329 * Called when another extension connects to this extension. Accepts
330 * connections from the ChromeOS builtin Braille IME and ignores connections
331 * from other extensions.
332 * @param {Port} port The port used to communicate with the other extension.
335 onImeConnect_: function(port) {
336 if (port.name !== cvox.BrailleInputHandler.IME_PORT_NAME_ ||
337 port.sender.id !== cvox.BrailleInputHandler.IME_EXTENSION_ID_) {
341 this.imePort_.disconnect();
342 port.onDisconnect.addListener(this.onImeDisconnect_.bind(this, port));
343 port.onMessage.addListener(this.onImeMessage_.bind(this));
344 this.imePort_ = port;
348 * Called when a message is received from the IME.
349 * @param {*} message The message.
352 onImeMessage_: function(message) {
353 if (!goog.isObject(message)) {
354 console.error('Unexpected message from Braille IME: ',
355 JSON.stringify(message));
357 switch (message.type) {
359 this.imeActive_ = message.active;
362 this.inputContext_ = message.context;
363 this.clearEntryState_();
364 if (this.imeActive_ && this.inputContext_)
365 this.pendingCells_.forEach(this.onBrailleDots_, this);
366 this.pendingCells_.length = 0;
369 this.onBrailleDots_(message['dots']);
372 // Note that we can't send the backspace key through the
373 // virtualKeyboardPrivate API in this case because it would then be
374 // processed by the IME again, leading to an infinite loop.
375 this.postImeMessage_(
376 {type: 'keyEventHandled', requestId: message['requestId'],
377 result: this.onBackspace_()});
380 this.clearEntryState_();
383 console.error('Unexpected message from Braille IME: ',
384 JSON.stringify(message));
390 * Called when the IME port is disconnected.
391 * @param {Port} port The port that was disconnected.
394 onImeDisconnect_: function(port) {
395 this.imePort_ = null;
396 this.clearEntryState_();
397 this.imeActive_ = false;
398 this.inputContext_ = null;
402 * Posts a message to the IME.
403 * @param {Object} message The message.
404 * @return {boolean} {@code true} if the message was sent, {@code false} if
405 * there was no connection open to the IME.
408 postImeMessage_: function(message) {
410 this.imePort_.postMessage(message);
417 * Sends a {@code keydown} key event followed by a {@code keyup} event
418 * corresponding to an event generated by the braille display.
419 * @param {!cvox.BrailleKeyEvent} event The braille key event to base the
423 sendKeyEventPair_: function(event) {
424 // Use the virtual keyboard API instead of the IME key event API
425 // so that these keys work even if the Braille IME is not active.
426 var keyName = /** @type {string} */ (event.standardKeyCode);
427 var numericCode = cvox.BrailleKeyEvent.keyCodeToLegacyCode(keyName);
428 if (!goog.isDef(numericCode))
429 throw Error('Unknown key code in event: ' + JSON.stringify(event));
432 keyCode: numericCode,
434 charValue: cvox.BrailleKeyEvent.keyCodeToCharValue(keyName),
435 // See chrome/common/extensions/api/virtual_keyboard_private.json for
437 modifiers: (event.shiftKey ? 2 : 0) |
438 (event.ctrlKey ? 4 : 0) |
439 (event.altKey ? 8 : 0)
441 chrome.virtualKeyboardPrivate.sendKeyEvent(keyEvent);
442 keyEvent.type = 'keyup';
443 chrome.virtualKeyboardPrivate.sendKeyEvent(keyEvent);
448 * The entry state is the state related to entering a series of braille cells
449 * without 'interruption', where interruption can be things like non braille
450 * keyboard input or unexpected changes to the text surrounding the cursor.
451 * @param {!cvox.BrailleInputHandler} inputHandler
452 * @param {!cvox.LibLouis.Translator} translator
456 cvox.BrailleInputHandler.EntryState_ = function(inputHandler, translator) {
458 * @type {cvox.BrailleInputHandler}
461 this.inputHandler_ = inputHandler;
463 * The translator currently used for typing, if
464 * {@code this.cells_.length > 0}.
465 * @type {!cvox.LibLouis.Translator}
468 this.translator_ = translator;
470 * Braille cells that have been typed by the user so far.
471 * @type {!Array<number>}
476 * Text resulting from translating {@code this.cells_}.
482 * List of strings that we expect to be set as preceding text of the
483 * selection. This is populated when we send text changes to the IME so that
484 * our own changes don't reset the pending cells.
485 * @type {!Array<string>}
488 this.pendingTextsBefore_ = [];
491 cvox.BrailleInputHandler.EntryState_.prototype = {
493 * @return {!cvox.LibLouis.Translator} The translator used by this entry
494 * state. This doesn't change for a given object.
497 return this.translator_;
501 * Appends a braille cell to the current input and updates the text if
503 * @param {number} cell The braille cell to append.
505 appendCell: function(cell) {
506 this.cells_.push(cell);
511 * Deletes the last cell of the input and updates the text if neccary.
512 * If there's no more input in this object afterwards, clears the entry state
513 * of the input handler.
515 deleteLastCell: function() {
516 if (--this.cells_.length <= 0) {
517 this.sendTextChange_('');
518 this.inputHandler_.clearEntryState_();
525 * Called when the text before the cursor changes giving this object a
526 * chance to clear the entry state of the input handler if the change
528 * @param {string} newText New text before the cursor.
530 onTextBeforeChanged: function(newText) {
531 // See if we are expecting this change as a result of one of our own edits.
532 // Allow changes to be coalesced by the input system in an attempt to not
534 for (var i = 0; i < this.pendingTextsBefore_.length; ++i) {
535 if (newText === this.pendingTextsBefore_[i]) {
536 // Delete all previous expected changes and ignore this one.
537 this.pendingTextsBefore_.splice(0, i + 1);
541 // There was an actual text change (or cursor movement) that we hadn't
542 // caused ourselves, reset any pending input.
543 this.inputHandler_.clearEntryState_();
547 * Makes sure the current text is permanently added to the edit field.
548 * After this call, this object should be abandoned.
554 * @return {boolean} true if the entry state uses uncommitted cells.
556 get usesUncommittedCells() {
561 * Updates the translated text based on the current cells and sends the
565 updateText_: function() {
566 var cellsBuffer = new Uint8Array(this.cells_).buffer;
567 var commit = this.lastCellIsBlank_;
568 if (!commit && this.usesUncommittedCells)
569 this.inputHandler_.updateUncommittedCells_(cellsBuffer);
570 this.translator_.backTranslate(cellsBuffer, function(result) {
571 if (result === null) {
572 console.error('Error when backtranslating braille cells');
575 if (!this.inputHandler_)
577 this.sendTextChange_(result);
580 this.inputHandler_.commitAndClearEntryState_();
588 get lastCellIsBlank_() {
589 return this.cells_[this.cells_.length - 1] === 0;
593 * Sends new text to the IME. This dhould be overriden by subclasses.
594 * The old text is still available in the {@code text_} property.
595 * @param {string} newText Text to send.
598 sendTextChange_: function(newText) {
603 * Entry state that uses {@code deleteSurroundingText} and {@code commitText}
604 * calls to the IME to update the currently enetered text.
605 * @param {!cvox.BrailleInputHandler} inputHandler
606 * @param {!cvox.LibLouis.Translator} translator
608 * @extends {cvox.BrailleInputHandler.EntryState_}
611 cvox.BrailleInputHandler.EditsEntryState_ = function(
612 inputHandler, translator) {
613 cvox.BrailleInputHandler.EntryState_.call(this, inputHandler, translator);
616 cvox.BrailleInputHandler.EditsEntryState_.prototype = {
617 __proto__: cvox.BrailleInputHandler.EntryState_.prototype,
620 sendTextChange_: function(newText) {
621 var oldText = this.text_;
622 // Find the common prefix of the old and new text.
623 var commonPrefixLength = StringUtil.longestCommonPrefixLength(
625 // How many characters we need to delete from the existing text to replace
626 // them with characters from the new text.
627 var deleteLength = oldText.length - commonPrefixLength;
628 // New text, if any, to insert after deleting the deleteLength characters
629 // before the cursor.
630 var toInsert = newText.substring(commonPrefixLength);
631 if (deleteLength > 0 || toInsert.length > 0) {
632 // After deleting, we expect this text to be present before the cursor.
633 var textBeforeAfterDelete =
634 this.inputHandler_.currentTextBefore_.substring(
635 0, this.inputHandler_.currentTextBefore_.length - deleteLength);
636 if (deleteLength > 0) {
637 // Queue this text up to be ignored when the change comes in.
638 this.pendingTextsBefore_.push(textBeforeAfterDelete);
640 if (toInsert.length > 0) {
641 // Likewise, queue up what we expect to be before the cursor after
642 // the replacement text is inserted.
643 this.pendingTextsBefore_.push(textBeforeAfterDelete + toInsert);
645 // Send the replace operation to be performed asynchronously by the IME.
646 this.inputHandler_.postImeMessage_(
647 {type: 'replaceText',
648 contextID: this.inputHandler_.inputContext_.contextID,
649 deleteBefore: deleteLength,
656 * Entry state that only updates the edit field when a blank cell is entered.
657 * During the input of a single 'word', the uncommitted text is stored by the
659 * @param {!cvox.BrailleInputHandler} inputHandler
660 * @param {!cvox.LibLouis.Translator} translator
663 * @extends {cvox.BrailleInputHandler.EntryState_}
665 cvox.BrailleInputHandler.LateCommitEntryState_ = function(
666 inputHandler, translator) {
667 cvox.BrailleInputHandler.EntryState_.call(this, inputHandler, translator);
670 cvox.BrailleInputHandler.LateCommitEntryState_.prototype = {
671 __proto__: cvox.BrailleInputHandler.EntryState_.prototype,
675 this.inputHandler_.postImeMessage_(
676 {type: 'commitUncommitted',
677 contextID: this.inputHandler_.inputContext_.contextID});
681 get usesUncommittedCells() {
686 sendTextChange_: function(newText) {
687 this.inputHandler_.postImeMessage_(
688 {type: 'setUncommitted',
689 contextID: this.inputHandler_.inputContext_.contextID,