chromium/chrome/browser/resources/chromeos/accessibility/accessibility_common/dictation/input_controller_impl.ts

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

import {EditableNodeData, InputController} from '/common/action_fulfillment/input_controller.js';
import {EventHandler} from '/common/event_handler.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {EditingUtil} from './editing_util.js';
import {FocusHandler} from './focus_handler.js';
import {LocaleInfo} from './locale_info.js';

type AutomationNode = chrome.automation.AutomationNode;
import EventType = chrome.automation.EventType;
import PositionType = chrome.automation.PositionType;
import StateType = chrome.automation.StateType;

interface SurroundingInfo {
  anchor: number;
  focus: number;
  offset: number;
  text: string;
}

/** A helper class that waits for automation and IME events. */
class AutomationImeEventWaiter {
  private node_: AutomationNode;
  private event_: EventType;

  constructor(node: AutomationNode, event: EventType) {
    this.node_ = node;
    this.event_ = event;
  }

  /**
   * Calls |doAction|, then waits for |this.event_| and a
   * chrome.input.ime.onSurroundingTextChanged event. We need to wait for both
   * since we use the automation and IME APIs to retrieve the editable node
   * data.
   */
  async doActionAndWait(doAction: () => void): Promise<void> {
    let surroundingTextChanged = false;
    let eventSeen = false;
    return new Promise(resolve => {
      const onSurroundingTextChanged: () => void = () => {
        surroundingTextChanged = true;
        chrome.input.ime.onSurroundingTextChanged.removeListener(
            onSurroundingTextChanged);
        if (eventSeen) {
          resolve();
        }
      };

      let handler: EventHandler|null =
          new EventHandler([this.node_], this.event_, () => {
            eventSeen = true;
            // TODO(b/314203187): Determine if not null assertion is acceptable.
            handler!.stop();
            handler = null;
            if (surroundingTextChanged) {
              resolve();
            }
          });

      handler.start();
      chrome.input.ime.onSurroundingTextChanged.addListener(
          onSurroundingTextChanged);

      doAction();
    });
  }
}

/** InputController handles interaction with input fields for Dictation. */
// TODO(b/307904022): Remove dependency on chrome.input.ime and depend fully on
// Automation instead, then
// TODO(b/324316493): Refactor logic that isn't specific to Dictation into
// common InputController.
export class InputControllerImpl extends InputController {
  private stopDictationCallback_: () => void;
  private focusHandler_: FocusHandler;
  private activeImeContextId_: number =
      InputControllerImpl.NO_ACTIVE_IME_CONTEXT_ID_;
  private onConnectCallback_: (() => void)|null = null;
  private onFocusListener_:
      ((context: chrome.input.ime.InputContext) => void)|null = null;
  private onBlurListener_: ((contextId: number) => void)|null = null;
  /** A listener for chrome.input.ime.onSurroundingTextChanged events. */
  private onSurroundingTextChangedListener_:
      ((engineID: string,
        surroundingInfo: SurroundingInfo) => void)|null = null;
  private surroundingInfo_: SurroundingInfo|null = null;
  private onSurroundingTextChangedForTesting_: (() => void)|null = null;
  private onSelectionChangedForTesting_: (() => void)|null = null;
  /**
   * The engine ID of the previously active IME input method. Used to
   * restore the previous IME after Dictation is deactivated.
   */
  private previousImeEngineId_ = '';
  constructor(stopDictationCallback: () => void, focusHandler: FocusHandler) {
    super();
    this.stopDictationCallback_ = stopDictationCallback;
    this.focusHandler_ = focusHandler;

    this.initialize_();
  }

  /** Sets up Dictation's various IME listeners. */
  private initialize_(): void {
    this.onFocusListener_ = context => this.onImeFocus_(context);
    this.onBlurListener_ = contextId => this.onImeBlur_(contextId);
    this.onSurroundingTextChangedListener_ = (engineID, surroundingInfo) =>
        this.onSurroundingTextChanged_(engineID, surroundingInfo);
    chrome.input.ime.onFocus.addListener(this.onFocusListener_);
    chrome.input.ime.onBlur.addListener(this.onBlurListener_);
    chrome.input.ime.onSurroundingTextChanged.addListener(
        this.onSurroundingTextChangedListener_);
  }

  /** Removes IME listeners. */
  removeListeners(): void {
    if (this.onFocusListener_) {
      chrome.input.ime.onFocus.removeListener(this.onFocusListener_);
    }
    if (this.onBlurListener_) {
      chrome.input.ime.onBlur.removeListener(this.onBlurListener_);
    }
    if (this.onSurroundingTextChangedListener_) {
      chrome.input.ime.onSurroundingTextChanged.removeListener(
          this.onSurroundingTextChangedListener_);
    }
  }

  /** Whether this is the active IME and has a focused input. */
  isActive(): boolean {
    return this.activeImeContextId_ !==
        InputControllerImpl.NO_ACTIVE_IME_CONTEXT_ID_;
  }

  /**
   * Connect as the active Input Method Manager.
   * @param callback The callback to run after IME is connected.
   */
  connect(callback: () => void): void {
    this.onConnectCallback_ = callback;
    chrome.inputMethodPrivate.getCurrentInputMethod(
        (method: string) => this.saveCurrentInputMethodAndStart_(method));
  }

  /**
   * Called when InputController has received the current input method. We save
   * the current method to reset when InputController toggles off, then continue
   * with starting up Dictation after the input gets focus (onImeFocus_()).
   * @param method The currently active IME ID.
   */
  private saveCurrentInputMethodAndStart_(method: string): void {
    this.previousImeEngineId_ = method;
    // Add AccessibilityCommon as an input method and activate it.
    chrome.languageSettingsPrivate.addInputMethod(
        InputControllerImpl.IME_ENGINE_ID);
    chrome.inputMethodPrivate.setCurrentInputMethod(
        InputControllerImpl.IME_ENGINE_ID);
  }

  /**
   * Disconnects as the active Input Method Manager. If any text was being
   * composed, commits it.
   */
  disconnect(): void {
    // Clean up IME state and reset to the previous IME method.
    this.activeImeContextId_ = InputControllerImpl.NO_ACTIVE_IME_CONTEXT_ID_;
    chrome.inputMethodPrivate.setCurrentInputMethod(this.previousImeEngineId_);
    this.previousImeEngineId_ = '';
    this.surroundingInfo_ = null;
  }

  /**
   * Commits the given text to the active IME context.
   * @param text The text to commit
   */
  commitText(text: string): void {
    if (!this.isActive() || !text) {
      return;
    }

    const data = this.getEditableNodeData();
    if (LocaleInfo.allowSmartCapAndSpacing() &&
        this.checkEditableNodeData_(data)) {
      const {value, selStart} = data as EditableNodeData;
      text = EditingUtil.smartCapitalization(value, selStart, text);
      text = EditingUtil.smartSpacing(value, selStart, text);
    }

    chrome.input.ime.commitText({contextID: this.activeImeContextId_, text});
  }

  /**
   * chrome.input.ime.onFocus callback. Save the active context ID, and
   * finish starting speech recognition if needed. This needs to be done
   * before starting recognition in order for browser tests to know that
   * Dictation is already set up as an IME.
   * @param context Input field context.
   */
  private onImeFocus_(context: chrome.input.ime.InputContext): void {
    this.activeImeContextId_ = context.contextID;
    if (this.onConnectCallback_) {
      this.onConnectCallback_();
      this.onConnectCallback_ = null;
    }
  }

  /**
   * chrome.input.ime.onFocus callback. Stops Dictation if the active
   * context ID lost focus.
   */
  private onImeBlur_(contextId: number): void {
    if (contextId === this.activeImeContextId_) {
      // Clean up context ID immediately. We can no longer use this context.
      this.activeImeContextId_ = InputControllerImpl.NO_ACTIVE_IME_CONTEXT_ID_;
      this.surroundingInfo_ = null;
      this.stopDictationCallback_();
    }
  }

  /**
   * Called when the editable string around the caret is changed or when the
   * caret position is moved.
   */
  private onSurroundingTextChanged_(
      engineID: string, surroundingInfo: SurroundingInfo): void {
    if (engineID !==
        InputControllerImpl.ON_SURROUNDING_TEXT_CHANGED_ENGINE_ID) {
      return;
    }

    this.surroundingInfo_ = surroundingInfo;
    if (this.onSurroundingTextChangedForTesting_) {
      this.onSurroundingTextChangedForTesting_();
    }
  }

  /**
   * Deletes the sentence to the left of the text caret. If the caret is in the
   * middle of a sentence, it will delete a portion of the sentence it
   * intersects.
   */
  deletePrevSentence(): void {
    const data = this.getEditableNodeData();
    if (!this.checkEditableNodeData_(data)) {
      return;
    }

    const {value, selStart} = data as EditableNodeData;
    const prevSentenceStart = EditingUtil.navPrevSent(value, selStart);
    const length = selStart - prevSentenceStart;
    this.deleteSurroundingText_(length, -length);
  }

  /**
   * @param length The number of characters to be deleted.
   * @param offset The offset from the caret position where deletion will start.
   *     This value can be negative.
   */
  private async deleteSurroundingText_(length: number, offset: number):
      Promise<void> {
    const editableNode = this.focusHandler_.getEditableNode();
    if (!editableNode) {
      throw new Error('deleteSurroundingText_ requires a valid editable node');
    }

    const deleteSurroundingText: () => void = () => {
      chrome.input.ime.deleteSurroundingText({
        contextID: this.activeImeContextId_,
        engineID: InputControllerImpl.IME_ENGINE_ID,
        length,
        offset,
      });
    };

    // Delete the surrounding text and wait for events to propagate.
    const waiter = new AutomationImeEventWaiter(
        editableNode, EventType.VALUE_IN_TEXT_FIELD_CHANGED);
    await waiter.doActionAndWait(deleteSurroundingText);
  }

  /**
   * Deletes a phrase to the left of the text caret. If multiple instances of
   * `phrase` are present, it deletes the one closest to the text caret.
   * @param phrase The phrase to be deleted.
   */
  deletePhrase(phrase: string): void {
    this.replacePhrase(phrase, '');
  }

  /**
   * Replaces a phrase to the left of the text caret with another phrase. If
   * multiple instances of `deletePhrase` are present, this function will
   * replace the one closest to the text caret.
   * @param deletePhrase The phrase to be deleted.
   * @param insertPhrase The phrase to be inserted.
   */
  async replacePhrase(deletePhrase: string, insertPhrase: string):
      Promise<void> {
    const data = this.getEditableNodeData();
    if (!this.checkEditableNodeData_(data)) {
      return;
    }

    const {value, selStart} = data as EditableNodeData;
    const replacePhraseData =
        EditingUtil.getReplacePhraseData(value, selStart, deletePhrase);
    if (!replacePhraseData) {
      return;
    }

    const {newIndex, deleteLength} = replacePhraseData;
    await this.setSelection_(newIndex, newIndex);
    await this.deleteSurroundingText_(deleteLength, deleteLength);
    if (insertPhrase) {
      this.commitText(insertPhrase);
    }
  }

  /**
   * Sets the selection within the editable node. `selStart` and `selEnd` are
   * relative to the value of the editable node. Works in all types of text
   * fields, including content editables.
   */
  private async setSelection_(selStart: number, selEnd: number): Promise<void> {
    const editableNode = this.focusHandler_.getEditableNode();
    if (!editableNode) {
      return;
    }

    let anchorObject = editableNode;
    let anchorOffset = selStart;
    let focusObject = editableNode;
    let focusOffset = selEnd;

    // TODO(b/314203187): Determine if not null assertion is acceptable.
    const isContentEditable = editableNode.state![StateType.RICHLY_EDITABLE];
    if (isContentEditable) {
      // Contenteditables can contain multiple inline text nodes, so we need to
      // translate `selStart` and `selEnd` to a node and index within the
      // contenteditable.
      let data = this.textNodeAndIndex_(selStart);
      if (data) {
        anchorObject = data.node;
        anchorOffset = data.index;
      }
      data = this.textNodeAndIndex_(selEnd);
      if (data) {
        focusObject = data.node;
        focusOffset = data.index;
      }
    }

    const setDocumentSelection: () => void = () => {
      chrome.automation.setDocumentSelection(
          {anchorObject, anchorOffset, focusObject, focusOffset});
    };

    // Set selection and wait for events to propagate.
    const waiter = new AutomationImeEventWaiter(
        editableNode, EventType.TEXT_SELECTION_CHANGED);
    await waiter.doActionAndWait(setDocumentSelection);
    if (this.onSelectionChangedForTesting_) {
      this.onSelectionChangedForTesting_();
    }
  }

  /**
   * Inserts `insertPhrase` directly before `beforePhrase` (and separates them
   * with a space). This function operates on the text to the left of the caret.
   * If multiple instances of `beforePhrase` are present, this function will
   * use the one closest to the text caret.
   */
  async insertBefore(insertPhrase: string, beforePhrase: string):
      Promise<void> {
    const data = this.getEditableNodeData();
    if (!this.checkEditableNodeData_(data)) {
      return;
    }

    const {value, selStart} = data as EditableNodeData;
    const newIndex =
        EditingUtil.getInsertBeforeIndex(value, selStart, beforePhrase);
    if (newIndex === -1) {
      return;
    }

    await this.setSelection_(newIndex, newIndex);
    this.commitText(insertPhrase);
  }

  /**
   * Sets selection starting at `startPhrase` and ending at `endPhrase`
   * (inclusive). The function operates on the text to the left of the text
   * caret. If multiple instances of `startPhrase` or `endPhrase` are present,
   * the function will use the ones closest to the text caret.
   */
  selectBetween(startPhrase: string, endPhrase: string): void {
    const data = this.getEditableNodeData();
    if (!this.checkEditableNodeData_(data)) {
      return;
    }

    const {value, selStart} = data as EditableNodeData;
    const selection =
        EditingUtil.selectBetween(value, selStart, startPhrase, endPhrase);
    if (!selection) {
      return;
    }

    this.setSelection_(selection.start, selection.end);
  }

  /** Moves the text caret to the next sentence. */
  async navNextSent(): Promise<void> {
    const data = this.getEditableNodeData();
    if (!this.checkEditableNodeData_(data)) {
      return;
    }

    const {value, selStart} = data as EditableNodeData;
    const newCaretIndex = EditingUtil.navNextSent(value, selStart);
    await this.setSelection_(newCaretIndex, newCaretIndex);
  }

  /** Moves the text caret to the previous sentence. */
  async navPrevSent(): Promise<void> {
    const data = this.getEditableNodeData();
    if (!this.checkEditableNodeData_(data)) {
      return;
    }

    const {value, selStart} = data as EditableNodeData;
    const newCaretIndex = EditingUtil.navPrevSent(value, selStart);
    await this.setSelection_(newCaretIndex, newCaretIndex);
  }

  /**
   * Returns the editable node, its value, the selection start, and the
   * selection end.
   * TODO(crbug.com/1353871): Only return text that is visible on-screen.
   */
  getEditableNodeData(): EditableNodeData|null {
    const node = this.focusHandler_.getEditableNode();
    if (!node) {
      return null;
    }

    let value;
    let selStart;
    let selEnd;
    // TODO(b/314203187): Determine if not null assertion is acceptable.
    const isContentEditable = node.state![StateType.RICHLY_EDITABLE];
    if (isContentEditable && this.surroundingInfo_) {
      const info = this.surroundingInfo_;
      // Use IME data only in contenteditables.
      value = info.text;
      selStart = Math.min(info.anchor, info.focus);
      selEnd = Math.max(info.anchor, info.focus);
      return {node, value, selStart, selEnd};
    }

    // Fall back to data from Automation.
    value = node.value || '';
    selStart = (node.textSelStart !== undefined && node.textSelStart !== -1) ?
        node.textSelStart :
        value.length;
    selEnd = (node.textSelEnd !== undefined && node.textSelEnd !== -1) ?
        node.textSelEnd :
        value.length;
    return {
      node,
      value,
      selStart: Math.min(selStart, selEnd),
      selEnd: Math.max(selStart, selEnd),
    };
  }

  /**
   * Returns whether or not `data` meets the prerequisites for performing an
   * editing command.
   */
  private checkEditableNodeData_(data: EditableNodeData|null): boolean {
    if (!data || data.selStart !== data.selEnd) {
      // TODO(b:259353226): Move this selection check into checkContext()
      // method.
      return false;
    }

    return true;
  }

  /**
   * Translates `index`, which is relative to the editable's value, to an inline
   * text node and index within the editable. Only returns valid data when the
   * editable node is a contenteditable.
   */
  private textNodeAndIndex_(index: number):
      {node: AutomationNode, index: number}|null {
    const editableNode = this.focusHandler_.getEditableNode();
    // TODO(b/314203187): Determine if not null assertion is acceptable.
    if (!editableNode || !editableNode.state![StateType.RICHLY_EDITABLE]) {
      throw new Error('textNodeAndIndex_ requires a content editable node');
    }

    const position = editableNode.createPosition(PositionType.TEXT, index);
    position.asLeafTextPosition();
    if (!position || !position.node || position.textOffset === undefined) {
      return null;
    }

    return {
      node: position.node,
      index: position.textOffset,
    };
  }
}

export namespace InputControllerImpl {
  /** The IME engine ID for AccessibilityCommon. */
  export const IME_ENGINE_ID =
      '_ext_ime_egfdjlfmgnehecnclamagfafdccgfndpdictation';

  /**
   * The engine ID that is passed into `onSurroundingTextChanged_` when
   * Dictation modifies the text field.
   */
  export const ON_SURROUNDING_TEXT_CHANGED_ENGINE_ID = 'dictation';

  export const NO_ACTIVE_IME_CONTEXT_ID_ = -1;
}

TestImportManager.exportForTesting(InputControllerImpl);