chromium/chrome/browser/resources/chromeos/accessibility/chromevox/background/input/background_keyboard_handler.ts

// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview ChromeVox keyboard handler.
 */
import {KeyCode} from '/common/key_code.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {EventSourceType} from '../../common/event_source_type.js';
import {ChromeVoxKbHandler} from '../../common/keyboard_handler.js';
import {Msgs} from '../../common/msgs.js';
import {QueueMode} from '../../common/tts_types.js';
import {ChromeVox} from '../chromevox.js';
import {ChromeVoxRange} from '../chromevox_range.js';
import {EventSource} from '../event_source.js';
import {ForcedActionPath} from '../forced_action_path.js';
import {MathHandler} from '../math_handler.js';
import {Output} from '../output/output.js';
import {ChromeVoxPrefs} from '../prefs.js';

/**
 * Internal pass through mode state (see usage below).
 */
enum KeyboardPassThroughState {
  // No pass through is in progress.
  NO_PASS_THROUGH = 'no_pass_through',

  // The pass through shortcut command has been pressed (keydowns), waiting for
  // user to release (keyups) all the shortcut keys.
  PENDING_PASS_THROUGH_SHORTCUT_KEYUPS = 'pending_pass_through_keyups',

  // The pass through shortcut command has been pressed and released, waiting
  // for the user to press/release a shortcut to be passed through.
  PENDING_SHORTCUT_KEYUPS = 'pending_shortcut_keyups',
}

class InternalKeyEvent extends KeyboardEvent {
  stickyMode?: boolean;
}

export class BackgroundKeyboardHandler {
  static instance?: BackgroundKeyboardHandler;
  private static passThroughModeEnabled_: boolean = false;
  private eatenKeyDowns_: Set<number>;
  private passThroughState_: KeyboardPassThroughState;
  private passedThroughKeyDowns_: Set<number>;

  private constructor() {
    this.eatenKeyDowns_ = new Set();
    this.passThroughState_ = KeyboardPassThroughState.NO_PASS_THROUGH;
    this.passedThroughKeyDowns_ = new Set();

    document.addEventListener(
        'keydown', (event) => this.onKeyDown(event), false);
    document.addEventListener('keyup', (event) => this.onKeyUp(event), false);

    chrome.accessibilityPrivate.setKeyboardListener(
        true, ChromeVoxPrefs.isStickyPrefOn);
  }

  static init(): void {
    if (BackgroundKeyboardHandler.instance) {
      throw 'Error: trying to create two instances of singleton BackgroundKeyboardHandler.';
    }
    BackgroundKeyboardHandler.instance = new BackgroundKeyboardHandler();
  }

  static enablePassThroughMode(): void {
    ChromeVox.tts.speak(Msgs.getMsg('pass_through_key'), QueueMode.QUEUE);
    BackgroundKeyboardHandler.passThroughModeEnabled_ = true;
  }

  /**
   * Handles key down events.
   * The return value has no effect since we ignore it in
   *     SpokenFeedbackEventRewriterDelegate::HandleKeyboardEvent.
   */
  onKeyDown(evt: InternalKeyEvent): boolean {
    EventSource.set(EventSourceType.STANDARD_KEYBOARD);
    evt.stickyMode = ChromeVoxPrefs.isStickyModeOn();

    // If somehow the user gets into a state where there are dangling key downs
    // don't get a key up, clear the eaten key downs. This is detected by a set
    // list of modifier flags.
    if (!evt.altKey && !evt.ctrlKey && !evt.metaKey && !evt.shiftKey) {
      this.eatenKeyDowns_.clear();
      this.passedThroughKeyDowns_.clear();
    }

    if (BackgroundKeyboardHandler.passThroughModeEnabled_) {
      this.passedThroughKeyDowns_.add(evt.keyCode);
      return false;
    }

    Output.forceModeForNextSpeechUtterance(QueueMode.FLUSH);

    // Try to restore to the last valid range.
    ChromeVoxRange.restoreLastValidRangeIfNeeded();

    if (!this.callOnKeyDownHandlers_(evt) ||
        this.shouldConsumeSearchKey_(evt)) {
      if (BackgroundKeyboardHandler.passThroughModeEnabled_) {
        this.passThroughState_ =
            KeyboardPassThroughState.PENDING_PASS_THROUGH_SHORTCUT_KEYUPS;
      }
      evt.preventDefault();
      evt.stopPropagation();
      this.eatenKeyDowns_.add(evt.keyCode);
    }

    return false;
  }

  /** Returns true if the key should continue propagation. */
  private callOnKeyDownHandlers_(evt: KeyboardEvent): boolean {
    // Defer first to the math handler, if it exists, then ordinary keyboard
    // commands.
    if (!MathHandler.onKeyDown(evt)) {
      return false;
    }

    const forcedActionPath = ForcedActionPath.instance;
    if (forcedActionPath && !forcedActionPath.onKeyDown(evt)) {
      return false;
    }

    return ChromeVoxKbHandler.basicKeyDownActionsListener(evt);
  }

  private shouldConsumeSearchKey_(evt: InternalKeyEvent): boolean {
    // We natively always capture Search, so we have to be very careful to
    // either eat it here or re-inject it; otherwise, some components, like
    // ARC++ with TalkBack never get it. We only want to re-inject when
    // ChromeVox has no range.
    if (!ChromeVoxRange.current) {
      return false;
    }

    // TODO(accessibility): address this awkward indexing once we convert
    // key_code.js to TS.
    return Boolean(evt.metaKey) || evt.keyCode === KeyCode['SEARCH'];
  }

  /**
   * The return value has no effect since we ignore it in
   *     SpokenFeedbackEventRewriterDelegate::HandleKeyboardEvent.
   */
  onKeyUp(evt: InternalKeyEvent): boolean {
    if (this.eatenKeyDowns_.has(evt.keyCode)) {
      evt.preventDefault();
      evt.stopPropagation();
      this.eatenKeyDowns_.delete(evt.keyCode);
    }

    if (BackgroundKeyboardHandler.passThroughModeEnabled_) {
      this.passedThroughKeyDowns_.delete(evt.keyCode);

      // Assuming we have no keys held (detected by held modifiers + keys we've
      // eaten in key down), we can start pass through for the next keys.
      if (this.passThroughState_ ===
              KeyboardPassThroughState.PENDING_PASS_THROUGH_SHORTCUT_KEYUPS &&
          !evt.altKey && !evt.ctrlKey && !evt.metaKey && !evt.shiftKey &&
          this.eatenKeyDowns_.size === 0) {
        // All keys of the pass through shortcut command have been released.
        // Ready to pass through the next shortcut.
        this.passThroughState_ =
            KeyboardPassThroughState.PENDING_SHORTCUT_KEYUPS;
      } else if (
          this.passThroughState_ ===
              KeyboardPassThroughState.PENDING_SHORTCUT_KEYUPS &&
          this.passedThroughKeyDowns_.size === 0) {
        // All keys of the passed through shortcut have been released. Ready to
        // go back to normal processing (aka no pass through).
        BackgroundKeyboardHandler.passThroughModeEnabled_ = false;
        this.passThroughState_ = KeyboardPassThroughState.NO_PASS_THROUGH;
      }
    }

    return false;
  }
}

TestImportManager.exportForTesting(BackgroundKeyboardHandler);