chromium/chrome/browser/resources/chromeos/accessibility/braille_ime/braille_ime.js

// Copyright 2014 The Chromium Authors
// 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.
 */

class BrailleIme {
  constructor() {
    /**
     * Whether to enable extra debug logging for the IME.
     * @const {boolean}
     * @private
     */
    this.DEBUG = false;

    /**
     * ChromeVox extension ID.
     * @const {string}
     * @private
     */
    this.CHROMEVOX_EXTENSION_ID_ = 'mndnfokpggljbaajbnioimlmbfngpief';

    /**
     * Name of the port used for communication with ChromeVox.
     * @const {string}
     * @private
     */
    this.PORT_NAME = '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}
     */
    this.USE_STANDARD_KEYBOARD_ID = 'useStandardKeyboard';

    /** @private {boolean} */
    this.useStandardKeyboard_ = false;

    /**
     * Braille dots for keys that are currently pressed.
     * @private {number}
     */
    this.pressed_ = 0;

    /**
     * Dots that have been pressed at some point
     * since {@code pressed_} was last
     * {@code 0}.
     * @private {number}
     */
    this.accumulated_ = 0;

    /**
     * Bit in {@code pressed_} and {@code accumulated_} that
     * represent the space key.
     * @const {number}
     */
    this.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>}
     */
    this.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
     */
    this.engineID_ = '';

    /**
     * The port used to communicate with ChromeVox.
     * @type {Port} port_
     * @private
     */
    this.port_ = null;

    /**
     * Uncommitted text and context ID.
     * @type {?{contextID: number, text: string}}
     * @private
     */
    this.uncommitted_ = null;
  }

  /**
   * Registers event listeners in the chrome IME API.
   */
  init() {
    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_(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_(engineID) {
    this.log_('onDectivated', engineID);
    this.engineID_ = '';
    this.sendActiveState_();
  }

  /**
   * Called by the IME framework when a text field receives focus.
   * @param {chrome.input.ime.InputContext} context Input field context.
   * @private
   */
  onFocus_(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_(contextID) {
    this.log_('onBlur', contextID + '');
    this.sendInputContext_(null);
  }

  /**
   * Called by the IME framework when the current input context is updated.
   * @param {chrome.input.ime.InputContext} context Input field context.
   * @private
   */
  onInputContextUpdate_(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_(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_(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_(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_(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_(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_() {
    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_(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_() {
    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_(message) {
    if (this.port_) {
      this.port_.postMessage(message);
    }
  }

  /**
   * Sends the given input context to ChromeVox.
   * @param {chrome.input.ime.InputContext} context Input context, or null
   *     when
   *    there's no input context.
   * @private
   */
  sendInputContext_(context) {
    this.sendToChromeVox_({type: 'inputContext', context});
  }

  /**
   * Sends the active state to ChromeVox.
   * @private
   */
  sendActiveState_() {
    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_(contextID, deleteBefore, toInsert) {
    var addText = chrome.input.ime.commitText.bind(
        null, {contextID, text: toInsert}, function() {});
    if (deleteBefore > 0) {
      var deleteText = chrome.input.ime.deleteSurroundingText.bind(
          null, {
            engineID: this.engineID_,
            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, 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_(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_(contextID, text) {
    this.uncommitted_ = {contextID, text};
  }

  /**
   * Commits the last set uncommitted text if it matches the given context id.
   * @param {number} contextID
   */
  commitUncommitted_(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_() {
    // 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,
      }],
    });
  }
}