chromium/chrome/browser/resources/chromeos/accessibility/switch_access/text_navigation_manager.ts

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

import {EventGenerator} from '/common/event_generator.js';
import {EventHandler} from '/common/event_handler.js';
import {KeyCode} from '/common/key_code.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {ActionManager} from './action_manager.js';
import {Navigator} from './navigator.js';
import {SwitchAccess} from './switch_access.js';
import {ErrorType} from './switch_access_constants.js';

type AutomationNode = chrome.automation.AutomationNode;
const EventType = chrome.automation.EventType;
const MenuAction = chrome.accessibilityPrivate.SwitchAccessMenuAction;

/**
 * Class to handle navigating text. Currently, only
 * navigation and selection in editable text fields is supported.
 */
export class TextNavigationManager {
  private static instance_?: TextNavigationManager;

  private currentlySelecting_ = false;
  /** Keeps track of when there's a selection in the current node. */
  private selectionExists_ = false;
  /** Keeps track of when the clipboard is empty. */
  private clipboardHasData_ = false;

  private selectionStartIndex_ = TextNavigationManager.NO_SELECT_INDEX;
  private selectionStartObject_?: AutomationNode;
  private selectionEndIndex_ = TextNavigationManager.NO_SELECT_INDEX;
  private selectionEndObject_?: AutomationNode;
  private selectionListener_: EventHandler;

  private constructor() {
    this.selectionListener_ = new EventHandler(
        [], EventType.TEXT_SELECTION_CHANGED, () => this.onNavChange_());

    if (SwitchAccess.improvedTextInputEnabled()) {
      chrome.clipboard.onClipboardDataChanged.addListener(
          () => this.updateClipboardHasData_());
    }
  }

  static get instance(): TextNavigationManager {
    if (!TextNavigationManager.instance_) {
      TextNavigationManager.instance_ = new TextNavigationManager();
    }
    return TextNavigationManager.instance_;
  }

  // =============== Static Methods ==============

  /**
   * Returns if the selection start index is set in the current node.
   */
  static currentlySelecting(): boolean {
    const manager = TextNavigationManager.instance;
    return (
        manager.selectionStartIndex_ !==
            TextNavigationManager.NO_SELECT_INDEX &&
        manager.currentlySelecting_);
  }

  /**
   * Jumps to the beginning of the text field (does nothing
   * if already at the beginning).
   */
  static jumpToBeginning(): void {
    const manager = TextNavigationManager.instance;
    if (manager.currentlySelecting_) {
      manager.setupDynamicSelection_(false /* resetCursor */);
    }
    EventGenerator.sendKeyPress(KeyCode.HOME, {ctrl: true});
  }

  /**
   * Jumps to the end of the text field (does nothing if
   * already at the end).
   */
  static jumpToEnd(): void {
    const manager = TextNavigationManager.instance;
    if (manager.currentlySelecting_) {
      manager.setupDynamicSelection_(false /* resetCursor */);
    }
    EventGenerator.sendKeyPress(KeyCode.END, {ctrl: true});
  }

  /**
   * Moves the text caret one character back (does nothing
   * if there are no more characters preceding the current
   * location of the caret).
   */
  static moveBackwardOneChar(): void {
    const manager = TextNavigationManager.instance;
    if (manager.currentlySelecting_) {
      manager.setupDynamicSelection_(true /* resetCursor */);
    }
    EventGenerator.sendKeyPress(KeyCode.LEFT);
  }

  /**
   * Moves the text caret one word backwards (does nothing
   * if already at the beginning of the field). If the
   * text caret is in the middle of a word, moves the caret
   * to the beginning of that word.
   */
  static moveBackwardOneWord(): void {
    const manager = TextNavigationManager.instance;
    if (manager.currentlySelecting_) {
      manager.setupDynamicSelection_(false /* resetCursor */);
    }
    EventGenerator.sendKeyPress(KeyCode.LEFT, {ctrl: true});
  }

  /**
   * Moves the text caret one line down (does nothing
   * if there are no lines below the current location of
   * the caret).
   */
  static moveDownOneLine(): void {
    const manager = TextNavigationManager.instance;
    if (manager.currentlySelecting_) {
      manager.setupDynamicSelection_(true /* resetCursor */);
    }
    EventGenerator.sendKeyPress(KeyCode.DOWN);
  }

  /**
   * Moves the text caret one character forward (does nothing
   * if there are no more characters following the current
   * location of the caret).
   */
  static moveForwardOneChar(): void {
    const manager = TextNavigationManager.instance;
    if (manager.currentlySelecting_) {
      manager.setupDynamicSelection_(true /* resetCursor */);
    }
    EventGenerator.sendKeyPress(KeyCode.RIGHT);
  }

  /**
   * Moves the text caret one word forward (does nothing if
   * already at the end of the field). If the text caret is
   * in the middle of a word, moves the caret to the end of
   * that word.
   */
  static moveForwardOneWord(): void {
    const manager = TextNavigationManager.instance;
    if (manager.currentlySelecting_) {
      manager.setupDynamicSelection_(false /* resetCursor */);
    }
    EventGenerator.sendKeyPress(KeyCode.RIGHT, {ctrl: true});
  }

  /**
   * Moves the text caret one line up (does nothing
   * if there are no lines above the current location of
   * the caret).
   */
  static moveUpOneLine(): void {
    const manager = TextNavigationManager.instance;
    if (manager.currentlySelecting_) {
      manager.setupDynamicSelection_(true /* resetCursor */);
    }
    EventGenerator.sendKeyPress(KeyCode.UP);
  }

  /**
   * Reset the currentlySelecting variable to false, reset the selection
   * indices, and remove the listener on navigation.
   */
  static resetCurrentlySelecting(): void {
    const manager = TextNavigationManager.instance;
    manager.currentlySelecting_ = false;
    manager.manageNavigationListener_(false /** Removing listener */);
    manager.selectionStartIndex_ = TextNavigationManager.NO_SELECT_INDEX;
    manager.selectionEndIndex_ = TextNavigationManager.NO_SELECT_INDEX;
    if (manager.currentlySelecting_) {
      manager.setupDynamicSelection_(true /* resetCursor */);
    }
    EventGenerator.sendKeyPress(KeyCode.DOWN);
  }

  static get clipboardHasData(): boolean {
    return TextNavigationManager.instance.clipboardHasData_;
  }

  static get selectionExists(): boolean {
    return TextNavigationManager.instance.selectionExists_;
  }

  static set selectionExists(newVal: boolean) {
    TextNavigationManager.instance.selectionExists_ = newVal;
  }

  getSelEndIndex(): number {
    return this.selectionEndIndex_;
  }

  resetSelStartIndex(): void {
    this.selectionStartIndex_ = TextNavigationManager.NO_SELECT_INDEX;
  }

  getSelStartIndex(): number {
    return this.selectionStartIndex_;
  }

  setSelStartIndexAndNode(startIndex: number, textNode: AutomationNode): void {
    this.selectionStartIndex_ = startIndex;
    this.selectionStartObject_ = textNode;
  }

  /**
   * Sets the selectionStart variable based on the selection of the current
   * node. Also sets the currently selecting boolean to true.
   */
  static saveSelectStart(): void {
    const manager = TextNavigationManager.instance;
    chrome.automation.getFocus((focusedNode: AutomationNode | undefined) => {
      manager.selectionStartObject_ = focusedNode;
      manager.selectionStartIndex_ = manager.getSelectionIndexFromNode_(
          // TODO(b/314203187): Not null asserted, check that this is correct.
          manager.selectionStartObject_!,
          true /* We are getting the start index.*/);
      manager.currentlySelecting_ = true;
    });
  }

  // =============== Instance Methods ==============

  /**
   * Returns either the selection start index or the selection end index of the
   * node based on the getStart param.
   * @return selection start if getStart is true otherwise selection
   * end
   */
  private getSelectionIndexFromNode_(
      node: AutomationNode, getStart: boolean): number {
    let indexFromNode = TextNavigationManager.NO_SELECT_INDEX;
    // TODO(b/314203187): Not null asserted, check that this is correct.
    if (getStart) {
      indexFromNode = node.textSelStart!;
    } else {
      indexFromNode = node.textSelEnd!;
    }
    if (indexFromNode === undefined) {
      return TextNavigationManager.NO_SELECT_INDEX;
    }
    return indexFromNode;
  }

  /** Adds or removes the selection listener. */
  private manageNavigationListener_(addListener: boolean): void {
    if (!this.selectionStartObject_) {
      return;
    }

    if (addListener) {
      this.selectionListener_.setNodes(this.selectionStartObject_);
      this.selectionListener_.start();
    } else {
      this.selectionListener_.stop();
    }
  }

  /**
   * Function to handle changes in the cursor position during selection.
   * This function will remove the selection listener and set the end of the
   * selection based on the new position.
   */
  private onNavChange_(): void {
    this.manageNavigationListener_(false);
    if (this.currentlySelecting_) {
      TextNavigationManager.saveSelectEnd();
    }
  }

  /**
   * Sets the selectionEnd variable based on the selection of the current node.
   */
  static saveSelectEnd(): void {
    const manager = TextNavigationManager.instance;
    chrome.automation.getFocus(focusedNode => {
      manager.selectionEndObject_ = focusedNode;
      manager.selectionEndIndex_ = manager.getSelectionIndexFromNode_(
          manager.selectionEndObject_,
          false /*We are not getting the start index.*/);
      manager.saveSelection_();
    });
  }

  /** Sets the selection after verifying that the bounds are set. */
  private saveSelection_(): void {
    if (this.selectionStartIndex_ === TextNavigationManager.NO_SELECT_INDEX ||
        this.selectionEndIndex_ === TextNavigationManager.NO_SELECT_INDEX) {
      console.error(SwitchAccess.error(
          ErrorType.INVALID_SELECTION_BOUNDS,
          'Selection bounds are not set properly: ' +
              this.selectionStartIndex_ + ' ' + this.selectionEndIndex_));
    } else {
      this.setSelection_();
    }
  }

  /**
   * Sets up the cursor position and selection listener for dynamic selection.
   * If the needToResetCursor boolean is true, the function will move the cursor
   * to the end point of the selection before adding the event listener. If not,
   * it will simply add the listener.
   */
  private setupDynamicSelection_(needToResetCursor: boolean): void {
    /**
     * TODO(crbug.com/999400): Work on text selection dynamic highlight and
     * text selection implementation.
     */
    if (needToResetCursor) {
      if (TextNavigationManager.currentlySelecting() &&
          this.selectionEndIndex_ !== TextNavigationManager.NO_SELECT_INDEX) {
        // Move the cursor to the end of the existing selection.
        this.setSelection_();
      }
    }
    this.manageNavigationListener_(true /** Add the listener */);
  }

  /**
   * Sets the selection. If start and end object are equal, uses
   * AutomationNode.setSelection. Otherwise calls
   * chrome.automation.setDocumentSelection.
   */
  private setSelection_(): void {
    if (this.selectionStartObject_ === this.selectionEndObject_) {
      // TODO(b/314203187): Not null asserted, check that this is correct.
      this.selectionStartObject_!.setSelection(
          this.selectionStartIndex_, this.selectionEndIndex_);
    } else {
      chrome.automation.setDocumentSelection({
        anchorObject: this.selectionStartObject_!,
        anchorOffset: this.selectionStartIndex_,
        focusObject: this.selectionEndObject_!,
        focusOffset: this.selectionEndIndex_,
      });
    }
  }

  /*
   * TODO(rosalindag): Add functionality to catch when clipboardHasData_ needs
   * to be set to false.
   * Set the clipboardHasData variable to true and reload the menu.
   */
  private updateClipboardHasData_(): void {
    this.clipboardHasData_ = true;
    const node = Navigator.byItem.currentNode;
    if (node.hasAction(MenuAction.PASTE)) {
      ActionManager.refreshMenuForNode(node);
    }
  }
}

export namespace TextNavigationManager {
  export const NO_SELECT_INDEX = -1;
}

TestImportManager.exportForTesting(TextNavigationManager);