chromium/chrome/browser/resources/chromeos/accessibility/chromevox/learn_mode/learn_mode.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 Script for ChromeOS keyboard explorer.
 *
 */
import {BridgeHelper} from '/common/bridge_helper.js';

import {BackgroundBridge} from '../common/background_bridge.js';
import {BrailleCommandData} from '../common/braille/braille_command_data.js';
import {BrailleKeyEvent} from '../common/braille/braille_key_types.js';
import {BridgeConstants} from '../common/bridge_constants.js';
import {Command} from '../common/command.js';
import {CommandStore} from '../common/command_store.js';
import {GestureCommandData} from '../common/gesture_command_data.js';
import {KeyUtil} from '../common/key_util.js';
import {ChromeVoxKbHandler} from '../common/keyboard_handler.js';
import {Msgs} from '../common/msgs.js';
import {QueueMode, TtsSpeechProperties} from '../common/tts_types.js';

import Gesture = chrome.accessibilityPrivate.Gesture;
type Tab = chrome.tabs.Tab;

const TARGET = BridgeConstants.LearnMode.TARGET;
const Action = BridgeConstants.LearnMode.Action;

declare namespace window {
  let backgroundWindow: Window;
  function close(): void;
}

/** Class to manage the keyboard explorer. */
export class LearnMode {
  /** Indicates when speech output should flush previous speech. */
  private static shouldFlushSpeech_ = true;
  /** Last time a touch explore gesture was described. */
  private static lastTouchExplore_ = new Date();
  /** Previously pressed key. */
  private static prevKey = '';

  /** Initialize keyboard explorer. */
  static init(): void {
    // Export global objects from the background page context into this one.
    window.backgroundWindow = chrome.extension.getBackgroundPage() as Window;

    window.backgroundWindow.addEventListener(
        'keydown', LearnMode.onKeyDown, true);
    window.backgroundWindow.addEventListener('keyup', LearnMode.onKeyUp, true);
    window.backgroundWindow.addEventListener(
        'keypress', LearnMode.onKeyPress, true);
    chrome.brailleDisplayPrivate.onKeyEvent.addListener(
        LearnMode.onBrailleKeyEvent);
    chrome.accessibilityPrivate.onAccessibilityGesture.addListener(
        LearnMode.onAccessibilityGesture);
    chrome.accessibilityPrivate.setKeyboardListener(true, true);
    BackgroundBridge.Braille.setBypass(true);
    BackgroundBridge.GestureCommandHandler.setBypass(true);

    ChromeVoxKbHandler.commandHandler = LearnMode.onCommand;

    // TODO(b/314203187): Not null asserted, check that this is correct.
    $('instruction')!.textContent = Msgs.getMsg('learn_mode_intro');

    // Learn mode may be created more than once. Clear the listeners to avoid
    // duplicate assignment errors.
    BridgeHelper.clearAllHandlersForTarget(TARGET);

    BridgeHelper.registerHandler(
        TARGET, Action.CLEAR_TOUCH_EXPLORE_OUTPUT_TIME,
        () => MIN_TOUCH_EXPLORE_OUTPUT_TIME_MS = 0);
    BridgeHelper.registerHandler(
        TARGET, Action.ON_ACCESSIBILITY_GESTURE,
        (gesture: Gesture) => LearnMode.onAccessibilityGesture(gesture));
    BridgeHelper.registerHandler(
        TARGET, Action.ON_BRAILLE_KEY_EVENT,
        (event: chrome.brailleDisplayPrivate.KeyEvent) =>
          LearnMode.onBrailleKeyEvent(event));
    BridgeHelper.registerHandler(
        TARGET, Action.ON_KEY_DOWN,
        (event: KeyboardEvent) => LearnMode.onKeyDown(event));
    BridgeHelper.registerHandler(
        TARGET, Action.ON_KEY_UP,
        (event: KeyboardEvent) => LearnMode.onKeyUp(event));
    BridgeHelper.registerHandler(TARGET, Action.READY, () => readyPromise);

    readyCallback();
  }

  /**
   * Handles keydown events by speaking the human understandable name of the
   * key.
   * @return True if the default action should be performed.
   */
  static onKeyDown(evt: KeyboardEvent): boolean {
    // Process this event only once; it isn't a repeat (i.e. a user is holding a
    // key down).
    if (!evt.repeat) {
      LearnMode.output(KeyUtil.getReadableNameForKeyCode(evt.keyCode));

      // Allow Ctrl+W or escape to be handled.
      if ((evt.key === 'w' && evt.ctrlKey)) {
        LearnMode.close_();
        return true;
      }
      if (evt.key === 'Escape') {
        // Escape must be pressed twice in a row to exit.
        if (LearnMode.prevKey === 'Escape') {
          LearnMode.close_();
          return true;
        } else {
          // Append a message about pressing escape a second time.
          LearnMode.output(Msgs.getMsg('learn_mode_escape_to_exit'));
        }
      }
      LearnMode.prevKey = evt.key;

      BackgroundBridge.ForcedActionPath.onKeyDown(evt).then(
          (shouldPropagate) => {
            if (shouldPropagate) {
              ChromeVoxKbHandler.basicKeyDownActionsListener(evt);
            }
            LearnMode.clearRange();
          });
    }

    evt.preventDefault();
    evt.stopPropagation();
    return false;
  }

  static onKeyUp(evt: KeyboardEvent): void {
    LearnMode.shouldFlushSpeech_ = true;
    LearnMode.maybeClose_();
    LearnMode.clearRange();
    evt.preventDefault();
    evt.stopPropagation();
  }

  static onKeyPress(evt: KeyboardEvent): void {
    LearnMode.clearRange();
    evt.preventDefault();
    evt.stopPropagation();
  }

  static onBrailleKeyEvent(evt: chrome.brailleDisplayPrivate.KeyEvent): void {
    LearnMode.shouldFlushSpeech_ = true;
    LearnMode.maybeClose_();
    let msgid;
    const msgArgs: string[] = [];
    let text;
    let callback;
    switch (evt.command) {
      case chrome.brailleDisplayPrivate.KeyCommand.PAN_LEFT:
        msgid = 'braille_pan_left';
        break;
      case chrome.brailleDisplayPrivate.KeyCommand.PAN_RIGHT:
        msgid = 'braille_pan_right';
        break;
      case chrome.brailleDisplayPrivate.KeyCommand.LINE_UP:
        msgid = 'braille_line_up';
        break;
      case chrome.brailleDisplayPrivate.KeyCommand.LINE_DOWN:
        msgid = 'braille_line_down';
        break;
      case chrome.brailleDisplayPrivate.KeyCommand.TOP:
        msgid = 'braille_top';
        break;
      case chrome.brailleDisplayPrivate.KeyCommand.BOTTOM:
        msgid = 'braille_bottom';
        break;
      case chrome.brailleDisplayPrivate.KeyCommand.ROUTING:
      case chrome.brailleDisplayPrivate.KeyCommand.SECONDARY_ROUTING:
        msgid = 'braille_routing';
        // TODO(b/314203187): Not null asserted, check that this is correct.
        msgArgs.push(String(evt.displayPosition! + 1));
        break;
      case chrome.brailleDisplayPrivate.KeyCommand.CHORD:
        const dots = evt.brailleDots;
        if (!dots) {
          return;
        }

        // First, check for the dots mapping to a key code.
        const keyCode = BrailleKeyEvent.brailleChordsToStandardKeyCode[dots];
        if (keyCode) {
          if (keyCode === 'Escape') {
            callback = LearnMode.close_;
          }

          text = keyCode;
          break;
        }

        // Next, check for the modifier mappings.
        const mods = BrailleKeyEvent.brailleDotsToModifiers[dots];
        if (mods) {
          const outputs: string[] = [];
          for (const mod in mods) {
            if (mod === 'ctrlKey') {
              outputs.push('control');
            } else if (mod === 'altKey') {
              outputs.push('alt');
            } else if (mod === 'shiftKey') {
              outputs.push('shift');
            }
          }

          text = outputs.join(' ');
          break;
        }

        const command = BrailleCommandData.getCommand(dots);
        if (command && LearnMode.onCommand(command)) {
          return;
        }
        text = BrailleCommandData.makeShortcutText(dots, true);
        break;
      case chrome.brailleDisplayPrivate.KeyCommand.DOTS: {
        const dots = evt.brailleDots;
        if (!dots) {
          return;
        }
        const cells = new ArrayBuffer(1);
        const view = new Uint8Array(cells);
        view[0] = dots;
        BackgroundBridge.Braille.backTranslate(cells).then(res => {
          if (res !== null) {
            LearnMode.output(res);
          }
        });
      }
        return;
      case chrome.brailleDisplayPrivate.KeyCommand.STANDARD_KEY:
        break;
    }
    if (msgid) {
      text = Msgs.getMsg(msgid, msgArgs);
    }

    LearnMode.output(text || evt.command, callback);
    LearnMode.clearRange();
  }

  /**
   * Handles accessibility gestures from the touch screen.
   * @param gesture The gesture to handle, based on the
   *     ax::mojom::Gesture enum defined in ui/accessibility/ax_enums.mojom
   */
  static onAccessibilityGesture(gesture: string): void {
    LearnMode.shouldFlushSpeech_ = true;
    LearnMode.maybeClose_();

    let callback;
    if (gesture === Gesture.TOUCH_EXPLORE) {
      if ((Number(new Date()) - Number(LearnMode.lastTouchExplore_)) <
          MIN_TOUCH_EXPLORE_OUTPUT_TIME_MS) {
        return;
      }
      LearnMode.lastTouchExplore_ = new Date();
    } else if (gesture === Gesture.SWIPE_LEFT2) {
      callback = LearnMode.close_;
    }

    const gestureData = GestureCommandData.GESTURE_COMMAND_MAP[gesture];
    if (gestureData) {
      if (gestureData.msgId) {
        LearnMode.output(Msgs.getMsg(gestureData.msgId));
      }
      if (gestureData.command) {
        LearnMode.onCommand(gestureData.command);
      }
      if (gestureData.commandDescriptionMsgId) {
        LearnMode.output(
            Msgs.getMsg(gestureData.commandDescriptionMsgId), callback);
      }
    }
  }

  /**
   * Queues up command description.
   * @return True if command existed and was handled.
   */
  static onCommand(command: Command): boolean | undefined {
    const msg = CommandStore.messageForCommand(command);
    if (msg) {
      const commandText = Msgs.getMsg(msg);
      LearnMode.output(commandText);
      LearnMode.clearRange();
      return true;
    }
    return undefined;
  }

  /** @param outputCallback A callback to run after output is requested. */
  static output(text: string, outputCallback?: VoidFunction): void {
    BackgroundBridge.TtsBackground.speak(
        text,
        LearnMode.shouldFlushSpeech_ ? QueueMode.CATEGORY_FLUSH :
                                       QueueMode.QUEUE,
        new TtsSpeechProperties({endCallback: outputCallback}));
    BackgroundBridge.Braille.write(text);
    LearnMode.shouldFlushSpeech_ = false;
    if (outputCallback) {
      outputCallback();
    }
  }

  /** Clears ChromeVox range. */
  static async clearRange(): Promise<void> {
    await BackgroundBridge.ChromeVoxRange.clearCurrentRange();
  }

  private static resetListeners_(): void {
    window.backgroundWindow.removeEventListener(
        'keydown', LearnMode.onKeyDown, true);
    window.backgroundWindow.removeEventListener(
        'keyup', LearnMode.onKeyUp, true);
    window.backgroundWindow.removeEventListener(
        'keypress', LearnMode.onKeyPress, true);
    chrome.brailleDisplayPrivate.onKeyEvent.removeListener(
        LearnMode.onBrailleKeyEvent);
    chrome.accessibilityPrivate.onAccessibilityGesture.removeListener(
        LearnMode.onAccessibilityGesture);
    chrome.accessibilityPrivate.setKeyboardListener(true, false);
    BackgroundBridge.Braille.setBypass(false);
    BackgroundBridge.GestureCommandHandler.setBypass(false);
  }

  private static maybeClose_(): void {
    // Reset listeners and close this page if we somehow move outside of the
    // explorer window.
    chrome.windows.getLastFocused(
        {populate: true}, (focusedWindow: chrome.windows.Window) => {
      // TODO(b/314203187): Not null asserted, check that this is correct.
      if (focusedWindow && focusedWindow.focused &&
          focusedWindow.tabs!.find((tab: Tab) => tab.url === location.href)) {
        return;
      }

      LearnMode.close_();
    });
  }

  private static close_(): void {
    LearnMode.output(Msgs.getMsg('learn_mode_outtro'));
    LearnMode.resetListeners_();
    window.close();
  }
}

document.addEventListener('DOMContentLoaded', function(): void {
  LearnMode.init();
}, false);

// Local to module.

/**
 * Shortcut for document.getElementById.
 * @param {string} id of the element.
 * @return {Element} with the id.
 */
function $(id: string): HTMLElement | null {
  return document.getElementById(id);
}


/**
 * The minimum time to wait before describing another touch explore gesture.
 */
let MIN_TOUCH_EXPLORE_OUTPUT_TIME_MS = 1000;


let readyCallback: VoidFunction;
const readyPromise =
    new Promise(resolve => readyCallback = resolve as VoidFunction);