// 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.
/**
* @fileoverview A JavaScript class that represents a sequence of keys entered
* by the user.
*/
/**
* A class to represent a sequence of keys entered by a user or affiliated with
* a ChromeVox command.
* This class can represent the data from both types of key sequences:
* COMMAND KEYS SPECIFIED IN A KEYMAP:
* - Two discrete keys (at most): [Down arrow], [A, A] or [O, W] etc. Can
* specify one or both.
* - Modifiers (like ctrl, alt, meta, etc)
* - Whether or not the ChromeVox modifier key is required with the command.
* USER INPUT:
* - Two discrete keys (at most): [Down arrow], [A, A] or [O, W] etc.
* - Modifiers (like ctlr, alt, meta, etc)
* - Whether or not the ChromeVox modifier key was active when the keys were
* entered.
* - Whether or not a prefix key was entered before the discrete keys.
* - Whether sticky mode was active.
*/
import {KeyCode} from '/common/key_code.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';
import {Command} from './command.js';
export interface KeyBinding {
command: Command;
sequence: KeySequence;
keySeq?: string;
title?: string;
}
export interface SerializedKeyBinding {
command: Command;
sequence: SerializedKeySequence;
}
interface Keys {
keyCode: KeyCode[];
altGraphKey?: boolean[];
altKey?: boolean[];
ctrlKey?: boolean[];
metaKey?: boolean[];
searchKeyHeld?: boolean[];
shiftKey?: boolean[];
// To access the above properties with bracket notation.
[key: string]: boolean[] | number[] | undefined;
}
export interface SerializedKeySequence {
keys: Keys;
cvoxModifier?: boolean;
doubleTap?: boolean;
prefixKey?: boolean;
requireStickyMode?: boolean;
skipStripping?: boolean;
stickyMode?: boolean;
}
interface EventLikeObject {
type: string;
keyCode: number;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
searchKeyHeld?: boolean;
stickyMode?: boolean;
keyPrefix?: boolean;
prefixKey?: boolean;
[key: string]: string|number|boolean|undefined;
}
export class KeySequence {
cvoxModifier: boolean;
doubleTap: boolean;
requireStickyMode: boolean;
prefixKey: boolean;
skipStripping: boolean;
stickyMode: boolean;
/**
* Stores the key codes and modifiers for the keys in the key sequence.
* TODO(rshearer): Consider making this structure an array of minimal
* keyEvent-like objects instead so we don't have to worry about what
* happens when ctrlKey.length is different from altKey.length.
*
* NOTE: If a modifier key is pressed by itself, we will store the keyCode
* *and* set the appropriate modKey to be true. This mirrors the way key
* events are created on Mac and Windows. For example, if the Meta key was
* pressed by itself, the keys object will have:
* {metaKey: [true], keyCode:[91]}
*/
keys: Keys = {
ctrlKey: [],
searchKeyHeld: [],
altKey: [],
altGraphKey: [],
shiftKey: [],
metaKey: [],
keyCode: [],
};
/**
* @param originalEvent The original key event entered by a user.
* The originalEvent may or may not have parameters stickyMode and keyPrefix
* specified. We will also accept an event-shaped object.
* @param cvoxModifier Whether or not the ChromeVox modifier key is active.
* If not specified, we will try to determine whether the modifier was active
* by looking at the originalEvent from key events when the cvox modifiers
* are set. Defaults to false.
* @param doubleTap Whether this is triggered via double tap.
* @param skipStripping Whether to strip cvox modifiers.
* @param requireStickyMode Whether to require sticky mode.
*/
constructor(
originalEvent: KeyboardEvent | EventLikeObject, cvoxModifier?: boolean,
doubleTap?: boolean, skipStripping?: boolean,
requireStickyMode?: boolean) {
this.doubleTap = Boolean(doubleTap);
this.requireStickyMode = Boolean(requireStickyMode);
this.skipStripping = Boolean(skipStripping);
this.cvoxModifier =
cvoxModifier ?? this.isCVoxModifierActive(originalEvent);
this.stickyMode = Boolean((originalEvent as EventLikeObject).stickyMode);
this.prefixKey = Boolean((originalEvent as EventLikeObject).keyPrefix);
if (this.stickyMode && this.prefixKey) {
throw 'Prefix key and sticky mode cannot both be enabled: ' +
originalEvent;
}
// TODO (rshearer): We should take the user out of sticky mode if they
// try to use the CVox modifier or prefix key.
this.extractKey_(originalEvent);
}
/**
* Adds an additional key onto the original sequence, for use when the user
* is entering two shortcut keys. This happens when the user presses a key,
* releases it, and then presses a second key. Those two keys together are
* considered part of the sequence.
* @param additionalKeyEvent The additional key to be added to
* the original event. Should be an event or an event-shaped object.
* @return Whether or not we were able to add a key. Returns false
* if there are already two keys attached to this event.
*/
addKeyEvent(additionalKeyEvent: KeyboardEvent | EventLikeObject): boolean {
if (this.keys.keyCode.length > 1) {
return false;
}
this.extractKey_(additionalKeyEvent);
return true;
}
/**
* Check for equality. Commands are matched based on the actual key codes
* involved and on whether or not they both require a ChromeVox modifier key.
*
* If sticky mode or a prefix is active on one of the commands but not on
* the other, then we try and match based on key code first.
* - If both commands have the same key code and neither of them have the
* ChromeVox modifier active then we have a match.
* - Next we try and match with the ChromeVox modifier. If both commands have
* the same key code, and one of them has the ChromeVox modifier and the other
* has sticky mode or an active prefix, then we also have a match.
*/
equals(rhs: KeySequence): boolean {
// Check to make sure the same keys with the same modifiers were pressed.
if (!this.checkKeyEquality_(rhs)) {
return false;
}
if (this.doubleTap !== rhs.doubleTap) {
return false;
}
// So now we know the actual keys are the same.
// If one key sequence requires sticky mode, return early the strict
// sticky mode state.
if (this.requireStickyMode || rhs.requireStickyMode) {
return (this.stickyMode || rhs.stickyMode) && !this.cvoxModifier &&
!rhs.cvoxModifier;
}
// If they both have the ChromeVox modifier, or they both don't have the
// ChromeVox modifier, then they are considered equal.
if (this.cvoxModifier === rhs.cvoxModifier) {
return true;
}
// So only one of them has the ChromeVox modifier. If the one that doesn't
// have the ChromeVox modifier has sticky mode or the prefix key then the
// keys are still considered equal.
const unmodified = this.cvoxModifier ? rhs : this;
return unmodified.stickyMode || unmodified.prefixKey;
}
/**
* Utility method that extracts the key code and any modifiers from a given
* event and adds them to the object map.
* @param keyEvent The keyEvent or event-shaped object to extract from.
*/
private extractKey_(keyEvent: KeyboardEvent | EventLikeObject): void {
let keyCode;
// TODO (rshearer): This is temporary until we find a library that can
// convert between ASCII charcodes and keycodes.
if (keyEvent.type === 'keypress' && keyEvent.keyCode >= 97 &&
keyEvent.keyCode <= 122) {
// Alphabetic keypress. Convert to the upper case ASCII code.
keyCode = keyEvent.keyCode - 32;
} else if (keyEvent.type === 'keypress') {
keyCode = KEY_PRESS_CODE[keyEvent.keyCode];
}
this.keys.keyCode.push(keyCode || keyEvent.keyCode);
for (const prop in this.keys) {
if (prop !== 'keyCode') {
if (this.isKeyModifierActive(keyEvent, prop)) {
(this.keys[prop] as boolean[]).push(true);
} else {
(this.keys[prop] as boolean[]).push(false);
}
}
}
if (this.cvoxModifier) {
this.rationalizeKeys_();
}
}
/**
* Rationalizes the key codes and the ChromeVox modifier for this keySequence.
* This means we strip out the key codes and key modifiers stored for this
* KeySequence that are also present in the ChromeVox modifier. For example,
* if the ChromeVox modifier keys are Ctrl+Alt, and we've determined that the
* ChromeVox modifier is active (meaning the user has pressed Ctrl+Alt), we
* don't want this.keys.ctrlKey = true also because that implies that this
* KeySequence involves the ChromeVox modifier and the ctrl key being held
* down together, which doesn't make any sense.
*/
private rationalizeKeys_(): void {
if (this.skipStripping) {
return;
}
// TODO (rshearer): This is a hack. When the modifier key becomes
// customizable then we will not have to deal with strings here.
const modifierKeyCombo = KeySequence.modKeyStr.split(/\+/g);
const index = this.keys.keyCode.length - 1;
// For each modifier that is part of the CVox modifier, remove it from keys.
if (modifierKeyCombo.indexOf('Ctrl') !== -1) {
// TODO(b/314203187): Not null asserted, check these to make sure this is
// correct.
this.keys.ctrlKey![index] = false;
}
if (modifierKeyCombo.indexOf('Alt') !== -1) {
// TODO(b/314203187): Not null asserted, check these to make sure this is
// correct.
this.keys.altKey![index] = false;
}
if (modifierKeyCombo.indexOf('Shift') !== -1) {
// TODO(b/314203187): Not null asserted, check these to make sure this is
// correct.
this.keys.shiftKey![index] = false;
}
const metaKeyName = this.getMetaKeyName_();
if (modifierKeyCombo.indexOf(metaKeyName) !== -1) {
if (metaKeyName === 'Search') {
// TODO(b/314203187): Not null asserted, check these to make sure this
// is correct.
this.keys.searchKeyHeld![index] = false;
this.keys.metaKey![index] = false;
} else if (metaKeyName === 'Cmd' || metaKeyName === 'Win') {
this.keys.metaKey![index] = false;
}
}
}
/**
* Get the user-facing name for the meta key (keyCode = 91), which varies
* depending on the platform.
* @return The user-facing string name for the meta key.
*/
private getMetaKeyName_(): string {
return 'Search';
}
/**
* Utility method that checks for equality of the modifiers (like shift and
* alt) and the equality of key codes.
* @return True if the modifiers and key codes in the key sequence are the
* same.
*/
private checkKeyEquality_(rhs: KeySequence): boolean {
for (const i in this.keys) {
// TODO(b/314203187): Not null asserted, check these to make sure this is
// correct.
for (let j = this.keys[i]!.length; j--;) {
if (this.keys[i]![j] !== rhs.keys[i]![j]) {
return false;
}
}
}
return true;
}
getFirstKeyCode(): number {
return this.keys.keyCode[0];
}
/**
* Gets the number of keys in the sequence. Should be 1 or 2.
* @return The number of keys in the sequence.
*/
length(): number {
return this.keys.keyCode.length;
}
/**
* Checks if the specified key code represents a modifier key, i.e. Ctrl, Alt,
* Shift, Search (on ChromeOS) or Meta.
*/
isModifierKey(keyCode: number): boolean {
// Shift, Ctrl, Alt, Search/LWin
return keyCode === KeyCode.SHIFT || keyCode === KeyCode.CONTROL ||
keyCode === KeyCode.ALT || keyCode === KeyCode.SEARCH ||
keyCode === KeyCode.APPS;
}
/**
* Determines whether the Cvox modifier key is active during the keyEvent.
* @param keyEvent The keyEvent or event-shaped object to check.
* @return Whether or not the modifier key was active during the keyEvent.
*/
isCVoxModifierActive(keyEvent: KeyboardEvent | EventLikeObject): boolean {
// TODO (rshearer): Update this when the modifier key becomes customizable
let modifierKeyCombo = KeySequence.modKeyStr.split(/\+/g);
// For each modifier that is held down, remove it from the combo.
// If the combo string becomes empty, then the user has activated the combo.
if (this.isKeyModifierActive(keyEvent, 'ctrlKey')) {
modifierKeyCombo =
modifierKeyCombo.filter(modifier => modifier !== 'Ctrl');
}
if (this.isKeyModifierActive(keyEvent, 'altKey')) {
modifierKeyCombo =
modifierKeyCombo.filter(modifier => modifier !== 'Alt');
}
if (this.isKeyModifierActive(keyEvent, 'shiftKey')) {
modifierKeyCombo =
modifierKeyCombo.filter(modifier => modifier !== 'Shift');
}
if (this.isKeyModifierActive(keyEvent, 'metaKey') ||
this.isKeyModifierActive(keyEvent, 'searchKeyHeld')) {
const metaKeyName = this.getMetaKeyName_();
modifierKeyCombo =
modifierKeyCombo.filter(modifier => modifier !== metaKeyName);
}
return (modifierKeyCombo.length === 0);
}
/**
* Determines whether a particular key modifier (for example, ctrl or alt) is
* active during the keyEvent.
* @param keyEvent The keyEvent or Event-shaped object to check.
* @param modifier The modifier to check.
* @return Whether or not the modifier key was active during the keyEvent.
*/
isKeyModifierActive(
keyEvent: KeyboardEvent | EventLikeObject, modifier: string): boolean {
// We need to check the key event modifier and the keyCode because Linux
// will not set the keyEvent.modKey property if it is the modKey by itself.
// This bug filed as crbug.com/74044
switch (modifier) {
case 'ctrlKey':
return (keyEvent.ctrlKey || keyEvent.keyCode === KeyCode.CONTROL);
case 'altKey':
return (keyEvent.altKey || (keyEvent.keyCode === KeyCode.ALT));
case 'shiftKey':
return (keyEvent.shiftKey || (keyEvent.keyCode === KeyCode.SHIFT));
case 'metaKey':
return (keyEvent.metaKey || (keyEvent.keyCode === KeyCode.SEARCH));
case 'searchKeyHeld':
// TODO(b/314203187): Not null asserted, check that this is correct.
return keyEvent.keyCode === KeyCode.SEARCH ||
(keyEvent as EventLikeObject)['searchKeyHeld']!;
}
return false;
}
isAnyModifierActive(): boolean {
for (const modifierType in this.keys) {
for (let i = 0; i < this.length(); i++) {
// TODO(b/314203187): Not null asserted, check that this is correct.
if (this.keys[modifierType]![i] && modifierType !== 'keyCode') {
return true;
}
}
}
return false;
}
/** Creates a KeySequence event from a generic object. */
static deserialize(sequenceObject: SerializedKeySequence): KeySequence {
const firstSequenceEvent = newEventLikeObject();
firstSequenceEvent['stickyMode'] =
(sequenceObject.stickyMode === undefined) ? false :
sequenceObject.stickyMode;
firstSequenceEvent['prefixKey'] = (sequenceObject.prefixKey === undefined) ?
false :
sequenceObject.prefixKey;
const secondKeyPressed = sequenceObject.keys.keyCode.length > 1;
const secondSequenceEvent = newEventLikeObject();
for (const keyPressed in sequenceObject.keys) {
// TODO(b/314203187): Not null asserted, check that this is correct.
firstSequenceEvent[keyPressed] = sequenceObject.keys[keyPressed]![0];
if (secondKeyPressed) {
secondSequenceEvent[keyPressed] = sequenceObject.keys[keyPressed]![1];
}
}
const skipStripping = sequenceObject.skipStripping !== undefined ?
sequenceObject.skipStripping :
true;
const keySeq = new KeySequence(
firstSequenceEvent, sequenceObject.cvoxModifier,
sequenceObject.doubleTap, skipStripping,
sequenceObject.requireStickyMode);
if (secondKeyPressed) {
KeySequence.sequenceSwitchKeyCodes.push(
new KeySequence(firstSequenceEvent, sequenceObject.cvoxModifier));
keySeq.addKeyEvent(secondSequenceEvent);
}
if (sequenceObject.doubleTap) {
KeySequence.doubleTapCache.push(keySeq);
}
return keySeq;
}
/**
* Creates a KeySequence event from a given string. The string should be in
* the standard key sequence format described in keyUtil.keySequenceToString
* and used in the key map JSON files.
* @param keyStr The string representation of a key sequence.
* @return The created KeySequence object.
*/
static fromStr(keyStr: string): KeySequence {
const sequenceEvent: EventLikeObject = newEventLikeObject();
const secondSequenceEvent: EventLikeObject = newEventLikeObject();
let secondKeyPressed;
if (keyStr.indexOf('>') === -1) {
secondKeyPressed = false;
} else {
secondKeyPressed = true;
}
let cvoxPressed = false;
sequenceEvent['stickyMode'] = false;
sequenceEvent['prefixKey'] = false;
const tokens = keyStr.split('+');
for (let i = 0; i < tokens.length; i++) {
const seqs = tokens[i].split('>');
for (let j = 0; j < seqs.length; j++) {
if (seqs[j].charAt(0) === '#') {
const keyCode = parseInt(seqs[j].substr(1), 10);
if (j > 0) {
secondSequenceEvent['keyCode'] = keyCode;
} else {
sequenceEvent['keyCode'] = keyCode;
}
}
const keyName = seqs[j];
if (seqs[j].length === 1) {
// Key is A/B/C...1/2/3 and we don't need to worry about setting
// modifiers.
if (j > 0) {
secondSequenceEvent['keyCode'] = seqs[j].charCodeAt(0);
} else {
sequenceEvent['keyCode'] = seqs[j].charCodeAt(0);
}
} else {
// Key is a modifier key
if (j > 0) {
KeySequence.setModifiersOnEvent_(keyName, secondSequenceEvent);
if (keyName === 'Cvox') {
cvoxPressed = true;
}
} else {
KeySequence.setModifiersOnEvent_(keyName, sequenceEvent);
if (keyName === 'Cvox') {
cvoxPressed = true;
}
}
}
}
}
const keySeq = new KeySequence(sequenceEvent, cvoxPressed);
if (secondKeyPressed) {
keySeq.addKeyEvent(secondSequenceEvent);
}
return keySeq;
}
/**
* Utility method for populating the modifiers on an event object that will be
* used to create a KeySequence.
* @param keyName A particular modifier key name (such as 'Ctrl').
* @param seqEvent The event to populate.
*/
private static setModifiersOnEvent_(
keyName: string, seqEvent: EventLikeObject): void {
if (keyName === 'Ctrl') {
seqEvent['ctrlKey'] = true;
seqEvent['keyCode'] = KeyCode.CONTROL;
} else if (keyName === 'Alt') {
seqEvent['altKey'] = true;
seqEvent['keyCode'] = KeyCode.ALT;
} else if (keyName === 'Shift') {
seqEvent['shiftKey'] = true;
seqEvent['keyCode'] = KeyCode.SHIFT;
} else if (keyName === 'Search') {
seqEvent['searchKeyHeld'] = true;
seqEvent['keyCode'] = KeyCode.SEARCH;
} else if (keyName === 'Cmd') {
seqEvent['metaKey'] = true;
seqEvent['keyCode'] = KeyCode.SEARCH;
} else if (keyName === 'Win') {
seqEvent['metaKey'] = true;
seqEvent['keyCode'] = KeyCode.SEARCH;
} else if (keyName === 'Insert') {
seqEvent['keyCode'] = KeyCode.INSERT;
}
}
/**
* A cache of all key sequences that have been set as double-tappable. We need
* this cache because repeated key down computations causes ChromeVox to
* become less responsive. This list is small so we currently use an array.
*/
static doubleTapCache: KeySequence[] = [];
/**
* If any of these keys is pressed with the modifier key, we go in sequence
* mode where the subsequent independent key downs (while modifier keys are
* down) are a part of the same shortcut.
*/
static sequenceSwitchKeyCodes: KeySequence[] = [];
static modKeyStr = 'Search';
}
// Private to module.
function newEventLikeObject(): EventLikeObject {
return Object.assign({}, {type: '', keyCode: 0});
}
// TODO(dtseng): This is incomplete; pull once we have appropriate libs.
/** Maps a keypress keycode to a keydown or keyup keycode. */
const KEY_PRESS_CODE: Record<number, number> = {
39: 222,
44: 188,
45: 189,
46: 190,
47: 191,
59: 186,
91: 219,
92: 220,
93: 221,
};
TestImportManager.exportForTesting(KeySequence);