chromium/chrome/browser/resources/chromeos/accessibility/chromevox/background/editing/text_edit_handler.ts

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

import {AutomationPredicate} from '/common/automation_predicate.js';
import {AutomationUtil} from '/common/automation_util.js';
import {constants} from '/common/constants.js';
import {CursorRange} from '/common/cursors/range.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {ChromeVoxEvent} from '../../common/custom_automation_event.js';
import {ChromeVoxRange} from '../chromevox_range.js';

import {EditableLine} from './editable_line.js';
import {AutomationEditableText} from './editable_text.js';
import {RichEditableText} from './rich_editable_text.js';

import AutomationIntent = chrome.automation.AutomationIntent;
type AutomationNode = chrome.automation.AutomationNode;
import Dir = constants.Dir;
import IntentCommandType = chrome.automation.IntentCommandType;
import RoleType = chrome.automation.RoleType;
import StateType = chrome.automation.StateType;

/**
 * A handler for automation events in a focused text field or editable root
 * such as a |contenteditable| subtree.
 */
export class TextEditHandler {
  readonly node: AutomationNode;
  private editableText_: AutomationEditableText;
  private inferredIntents_: AutomationIntent[] = [];

  constructor(node: AutomationNode) {
    this.node = node;

    // TODO(b/314203187): Not null asserted, check to make sure it's correct.
    if (!node.state![StateType.EDITABLE]) {
      throw new Error('|node| must be editable.');
    }
    this.editableText_ = this.createEditableText_();
  }

  /**
   * ChromeVox handles two general groups of text fields:
   * A rich text field is one where selection gets placed on a DOM
   * descendant to a root text field. This is one of:
   * - content editables (detected via editable state and contenteditable
   * html attribute, or just richly editable state)
   * - text areas (<textarea>) detected via its html tag
   *
   * A non-rich text field is one where accessibility only provides a value,
   * and a pair of numbers for the selection start and end. ChromeVox places
   * single-lined text fields, including those from web content, and ARC++
   * in this group. In addition, multiline ARC++ text fields are treated
   * this way.
   *
   * Note that these definitions slightly differ from those in Blink, which
   * only considers text fields in web content.
   */
  private useRichText_(): boolean {
    // TODO(b/314203187): Not null asserted, check to make sure it's correct.
    return this.node.state![StateType.RICHLY_EDITABLE] ||
        // This condition is a full proof way to ensure the node is editable
        // and has the content editable attribute set to any valid value.
        (this.node.state![StateType.EDITABLE] && this.node.htmlAttributes &&
         this.node.htmlAttributes['contenteditable'] !== undefined &&
         this.node.htmlAttributes['contenteditable'] !== 'false') ||
        false;
  }

  private createEditableText_(): AutomationEditableText {
    const isTextArea = this.node.htmlTag === 'textarea';

    const useRichText = this.useRichText_() || isTextArea;

    // Prior to creating the specific editable text handler, ensure that text
    // areas exclude offscreen elements in line computations. This is because
    // text areas from Blink expose a single large static text node which can
    // have thousands or more inline text boxes. This is a very specific check
    // because ignoring offscreen nodes can impact the way in which we convert
    // from a tree position to a deep equivalent on the inline text boxes.
    const firstStaticText = this.node.find({role: RoleType.STATIC_TEXT});
    EditableLine.includeOffscreen = !isTextArea || !firstStaticText ||
        firstStaticText.children.length < MAX_INLINE_TEXT_BOXES;

    return useRichText ? new RichEditableText(this.node) :
                         new AutomationEditableText(this.node);
  }

  /**
   * Receives the following kinds of events when the node provided to the
   * constructor is focused: |focus|, |textChanged|, |textSelectionChanged| and
   * |valueInTextFieldChanged|.
   * An implementation of this method should emit the appropriate braille and
   * spoken feedback for the event.
   */
  onEvent(evt: ChromeVoxEvent): void {
    // TODO(b/314203187): Not null asserted, check to make sure it's correct.
    if (!evt.target.state!['focused'] || evt.target !== this.node) {
      return;
    }

    let intents = evt.intents;

    // Check for inferred intents applied by other modules e.g. CommandHandler.
    // Be strict about what's allowed and limit only to overriding set
    // selections.
    if (this.inferredIntents_.length > 0 &&
        (intents.length === 0 || intents.some(isSetOrClear))) {
      intents = this.inferredIntents_;
    }
    this.inferredIntents_ = [];

    this.editableText_.onUpdate(intents);
  }

  /** Returns true if selection starts at the first line. */
  isSelectionOnFirstLine(): boolean {
    return this.editableText_.isSelectionOnFirstLine();
  }

  /** Returns true if selection ends at the last line. */
  isSelectionOnLastLine(): boolean {
    return this.editableText_.isSelectionOnLastLine();
  }

  /** Moves range to after this text field. */
  moveToAfterEditText(): void {
    const after = AutomationUtil.findNextNode(
        this.node, Dir.FORWARD, AutomationPredicate.object,
        {skipInitialSubtree: true});
    ChromeVoxRange.navigateTo(CursorRange.fromNode(after ?? this.node));
  }

  /**
   * Injects intents into the stream of editing events. In particular, |intents|
   * will be applied to the next processed edfiting event.
   */
  injectInferredIntents(intents: AutomationIntent[]): void {
    this.inferredIntents_ = intents;
  }

  /**
   * @param node The root editable node, i.e. the root of a
   *     contenteditable subtree or a text field.
   */
  static createForNode(node: AutomationNode): TextEditHandler {
    // TODO(b/314203187): Not null asserted, check to make sure it's correct.
    if (!node.state!['editable']) {
      throw new Error('Expected editable node.');
    }
    return new TextEditHandler(node);
  }
}

// Local to module.

const MAX_INLINE_TEXT_BOXES = 500;

function isSetOrClear(intent: AutomationIntent): boolean {
  return intent.command === IntentCommandType.SET_SELECTION ||
      intent.command === IntentCommandType.CLEAR_SELECTION;
}

TestImportManager.exportForTesting(TextEditHandler);