chromium/chrome/browser/resources/chromeos/accessibility/chromevox/common/key_sequence.ts

// 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);