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.
8 * @fileoverview Braille hardware keyboard input method.
10 * This method is automatically enabled when a braille display is connected
11 * and ChromeVox is turned on. Most of the braille input and editing logic
12 * is located in ChromeVox where the braille translation library is available.
13 * This IME connects to ChromeVox and communicates using messages as follows:
15 * Sent from this IME to ChromeVox:
16 * {type: 'activeState', active: boolean}
17 * {type: 'inputContext', context: InputContext}
18 * Sent on focus/blur to inform ChromeVox of the type of the current field.
19 * In the latter case (blur), context is null.
21 * Sent when the {@code onReset} IME event fires or uncommitted text is
22 * committed without being triggered by ChromeVox (e.g. because of a
24 * {type: 'brailleDots', dots: number}
25 * Sent when the user typed a braille cell using the standard keyboard.
26 * ChromeVox treats this similarly to entering braille input using the
28 * {type: 'backspace', requestId: string}
29 * Sent when the user presses the backspace key.
30 * ChromeVox must respond with a {@code keyEventHandled} message
31 * with the same request id.
33 * Sent from ChromeVox to this IME:
34 * {type: 'replaceText', contextID: number, deleteBefore: number,
36 * Deletes {@code deleteBefore} characters before the cursor (or selection)
37 * and inserts {@code newText}. {@code contextID} identifies the text field
38 * to apply the update to (no change will happen if focus has moved to a
40 * {type: 'setUncommitted', contextID: number, text: string}
41 * Stores text for the field identified by contextID to be committed
42 * either as a result of a 'commitUncommitted' message or a by the IME
43 * unhandled key press event. Unlike 'replaceText', this does not send the
44 * uncommitted text to the input field, but instead stores it in the IME.
45 * {type: 'commitUncommitted', contextID: number}
46 * Commits any uncommitted text if it matches the given context ID.
47 * See 'setUncommitted' above.
48 * {type: 'keyEventHandled', requestId: string, result: boolean}
49 * Response to a {@code backspace} message indicating whether the
50 * backspace was handled by ChromeVox or should be allowed to propagate
51 * through the normal event handling pipeline.
57 var BrailleIme = function() {};
59 BrailleIme.prototype = {
61 * Whether to enable extra debug logging for the IME.
68 * ChromeVox extension ID.
72 CHROMEVOX_EXTENSION_ID_: 'mndnfokpggljbaajbnioimlmbfngpief',
75 * Name of the port used for communication with ChromeVox.
79 PORT_NAME: 'cvox.BrailleIme.Port',
82 * Identifier for the use standard keyboard option used in the menu and
83 * {@code localStorage}. This can be switched on to type braille using the
84 * standard keyboard, or off (default) for the usual keyboard behaviour.
87 USE_STANDARD_KEYBOARD_ID: 'useStandardKeyboard',
89 // State related to the support for typing braille using a standrad
92 /** @private {boolean} */
93 useStandardKeyboard_: false,
96 * Braille dots for keys that are currently pressed.
102 * Dots that have been pressed at some point since {@code pressed_} was last
109 * Bit in {@code pressed_} and {@code accumulated_} that represent
116 * Maps key codes on a standard keyboard to the correspodning dots.
117 * Keys on the 'home row' correspond to the keys on a Perkins-style keyboard.
118 * Note that the mapping below is arranged like the dots in a braille cell.
119 * Only 6 dot input is supported.
121 * @const {Object<number>}
123 CODE_TO_DOT_: {'KeyF': 0x01, 'KeyJ': 0x08,
124 'KeyD': 0x02, 'KeyK': 0x10,
125 'KeyS': 0x04, 'KeyL': 0x20,
129 * The current engine ID as set by {@code onActivate}, or the empty string if
130 * the IME is not active.
137 * The port used to communicate with ChromeVox.
144 * Uncommitted text and context ID.
145 * @type {?{contextID: number, text: string}}
151 * Registers event listeners in the chrome IME API.
154 chrome.input.ime.onActivate.addListener(this.onActivate_.bind(this));
155 chrome.input.ime.onDeactivated.addListener(this.onDeactivated_.bind(this));
156 chrome.input.ime.onFocus.addListener(this.onFocus_.bind(this));
157 chrome.input.ime.onBlur.addListener(this.onBlur_.bind(this));
158 chrome.input.ime.onInputContextUpdate.addListener(
159 this.onInputContextUpdate_.bind(this));
160 chrome.input.ime.onKeyEvent.addListener(this.onKeyEvent_.bind(this),
162 chrome.input.ime.onReset.addListener(this.onReset_.bind(this));
163 chrome.input.ime.onMenuItemActivated.addListener(
164 this.onMenuItemActivated_.bind(this));
165 this.connectChromeVox_();
169 * Called by the IME framework when this IME is activated.
170 * @param {string} engineID Engine ID, should be 'braille'.
173 onActivate_: function(engineID) {
174 this.log_('onActivate', engineID);
175 this.engineID_ = engineID;
177 this.connectChromeVox_();
179 this.useStandardKeyboard_ =
180 localStorage[this.USE_STANDARD_KEYBOARD_ID] === String(true);
181 this.accumulated_ = 0;
183 this.updateMenuItems_();
184 this.sendActiveState_();
188 * Called by the IME framework when this IME is deactivated.
189 * @param {string} engineID Engine ID, should be 'braille'.
192 onDeactivated_: function(engineID) {
193 this.log_('onDectivated', engineID);
195 this.sendActiveState_();
199 * Called by the IME framework when a text field receives focus.
200 * @param {InputContext} context Input field context.
203 onFocus_: function(context) {
204 this.log_('onFocus', context);
205 this.sendInputContext_(context);
209 * Called by the IME framework when a text field looses focus.
210 * @param {number} contextID Input field context ID.
213 onBlur_: function(contextID) {
214 this.log_('onBlur', contextID + '');
215 this.sendInputContext_(null);
219 * Called by the IME framework when the current input context is updated.
220 * @param {InputContext} context Input field context.
223 onInputContextUpdate_: function(context) {
224 this.log_('onInputContextUpdate', context);
225 this.sendInputContext_(context);
229 * Called by the system when this IME is active and a key event is generated.
230 * @param {string} engineID Engine ID, should be 'braille'.
231 * @param {!ChromeKeyboardEvent} event The keyboard event.
234 onKeyEvent_: function(engineID, event) {
235 var result = this.processKey_(event);
236 if (result !== undefined)
237 this.keyEventHandled_(event.requestId, event.type, result);
241 * Called when chrome ends the current text input session.
242 * @param {string} engineID Engine ID, should be 'braille'.
245 onReset_: function(engineID) {
246 this.log_('onReset', engineID);
247 this.engineID_ = engineID;
248 this.sendToChromeVox_({type: 'reset'});
252 * Called by the IME framework when a menu item is activated.
253 * @param {string} engineID Engine ID, should be 'braille'.
254 * @param {string} itemID Identifies the menu item.
257 onMenuItemActivated_: function(engineID, itemID) {
258 if (engineID === this.engineID_ &&
259 itemID === this.USE_STANDARD_KEYBOARD_ID) {
260 this.useStandardKeyboard_ = !this.useStandardKeyboard_;
261 localStorage[this.USE_STANDARD_KEYBOARD_ID] =
262 String(this.useStandardKeyboard_);
263 if (!this.useStandardKeyboard_) {
264 this.accumulated_ = 0;
267 this.updateMenuItems_();
272 * Outputs a log message to the console, only if {@link BrailleIme.DEBUG}
274 * @param {string} func Name of the caller.
275 * @param {Object|string=} message Message to output.
278 log_: function(func, message) {
280 if (typeof(message) !== 'string') {
281 message = JSON.stringify(message);
283 console.log('BrailleIme.' + func + ': ' + message);
288 * Handles a qwerty key on the home row as a braille key.
289 * @param {!ChromeKeyboardEvent} event Keyboard event.
290 * @return {boolean|undefined} Whether the event was handled, or
291 * {@code undefined} if handling was delegated to ChromeVox.
294 processKey_: function(event) {
295 if (!this.useStandardKeyboard_) {
298 if (event.code === 'Backspace' && event.type === 'keydown') {
300 this.accumulated_ = 0;
301 this.sendToChromeVox_(
302 {type: 'backspace', requestId: event.requestId});
305 var dot = this.CODE_TO_DOT_[event.code];
306 if (!dot || event.altKey || event.ctrlKey || event.shiftKey ||
309 this.accumulated_ = 0;
312 if (event.type === 'keydown') {
313 this.pressed_ |= dot;
314 this.accumulated_ |= this.pressed_;
316 } else if (event.type === 'keyup') {
317 this.pressed_ &= ~dot;
318 if (this.pressed_ === 0 && this.accumulated_ !== 0) {
319 var dotsToSend = this.accumulated_;
320 this.accumulated_ = 0;
321 if (dotsToSend & this.SPACE) {
322 if (dotsToSend != this.SPACE) {
323 // Can't combine space and actual dot keys.
326 // Space is sent as a blank cell.
329 this.sendToChromeVox_({type: 'brailleDots', dots: dotsToSend});
337 * Connects to the ChromeVox extension for message passing.
340 connectChromeVox_: function() {
342 this.port_.disconnect();
345 this.port_ = chrome.runtime.connect(
346 this.CHROMEVOX_EXTENSION_ID_, {name: this.PORT_NAME});
347 this.port_.onMessage.addListener(
348 this.onChromeVoxMessage_.bind(this));
349 this.port_.onDisconnect.addListener(
350 this.onChromeVoxDisconnect_.bind(this));
354 * Handles a message from the ChromeVox extension.
355 * @param {*} message The message from the extension.
358 onChromeVoxMessage_: function(message) {
359 message = /** @type {{type: string}} */ (message);
360 this.log_('onChromeVoxMessage', message);
361 switch (message.type) {
365 * @type {{contextID: number, deleteBefore: number,
369 this.replaceText_(message.contextID, message.deleteBefore,
372 case 'keyEventHandled':
374 /** @type {{requestId: string, result: boolean}} */ (message);
375 this.keyEventHandled_(message.requestId, 'keydown', message.result);
377 case 'setUncommitted':
379 /** @type {{contextID: number, text: string}} */ (message);
380 this.setUncommitted_(message.contextID, message.text);
382 case 'commitUncommitted':
384 /** @type {{contextID: number}} */ (message);
385 this.commitUncommitted_(message.contextID);
388 console.error('Unknown message from ChromeVox: ' +
389 JSON.stringify(message));
395 * Handles a disconnect event from the ChromeVox side.
398 onChromeVoxDisconnect_: function() {
400 this.log_('onChromeVoxDisconnect', chrome.runtime.lastError);
404 * Sends a message to the ChromeVox extension.
405 * @param {Object} message The message to send.
408 sendToChromeVox_: function(message) {
410 this.port_.postMessage(message);
415 * Sends the given input context to ChromeVox.
416 * @param {InputContext} context Input context, or null when there's no input
420 sendInputContext_: function(context) {
421 this.sendToChromeVox_({type: 'inputContext', context: context});
425 * Sends the active state to ChromeVox.
428 sendActiveState_: function() {
429 this.sendToChromeVox_({type: 'activeState',
430 active: this.engineID_.length > 0});
434 * Replaces text in the current text field.
435 * @param {number} contextID Context for the input field to replace the
437 * @param {number} deleteBefore How many characters to delete before the
439 * @param {string} toInsert Text to insert at the cursor.
441 replaceText_: function(contextID, deleteBefore, toInsert) {
442 var addText = chrome.input.ime.commitText.bind(
443 null, {contextID: contextID, text: toInsert}, function() {});
444 if (deleteBefore > 0) {
445 var deleteText = chrome.input.ime.deleteSurroundingText.bind(null,
446 {engineID: this.engineID_, contextID: contextID,
447 offset: -deleteBefore, length: deleteBefore}, addText);
448 // Make sure there's no non-zero length selection so that
449 // deleteSurroundingText works correctly.
450 chrome.input.ime.deleteSurroundingText(
451 {engineID: this.engineID_, contextID: contextID,
452 offset: 0, length: 0}, deleteText);
459 * Responds to an asynchronous key event, indicating whether it was handled
460 * or not. If it wasn't handled, any uncommitted text is committed
461 * before sending the response to the IME API.
462 * @param {string} requestId Key event request id.
463 * @param {string} type Type of key event being responded to.
464 * @param {boolean} response Whether the IME handled the event.
466 keyEventHandled_: function(requestId, type, response) {
467 if (!response && type === 'keydown' && this.uncommitted_) {
468 this.commitUncommitted_(this.uncommitted_.contextID);
469 this.sendToChromeVox_({type: 'reset'});
471 chrome.input.ime.keyEventHandled(requestId, response);
475 * Stores uncommitted text that will be committed on any key press or
476 * when {@code commitUncommitted_} is called.
477 * @param {number} contextID of the current field.
478 * @param {string} text to store.
480 setUncommitted_: function(contextID, text) {
481 this.uncommitted_ = {contextID: contextID, text: text};
485 * Commits the last set uncommitted text if it matches the given context id.
486 * @param {number} contextID
488 commitUncommitted_: function(contextID) {
489 if (this.uncommitted_ && contextID === this.uncommitted_.contextID)
490 chrome.input.ime.commitText(this.uncommitted_);
491 this.uncommitted_ = null;
495 * Updates the menu items for this IME.
497 updateMenuItems_: function() {
498 // TODO(plundblad): Localize when translations available.
499 chrome.input.ime.setMenuItems(
500 {engineID: this.engineID_,
503 id: this.USE_STANDARD_KEYBOARD_ID,
504 label: 'Use standard keyboard for braille',
507 checked: this.useStandardKeyboard_,