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

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

/**
 * @fileoverview An EditableLine encapsulates all data concerning a line in the
 * automation tree necessary to provide output. Editable: an editable selection
 * (e.g. start/end offsets) get saved. Line: nodes/offsets at the beginning/end
 * of a line get saved.
 */
import {AutomationPredicate} from '/common/automation_predicate.js';
import {AutomationUtil} from '/common/automation_util.js';
import {constants} from '/common/constants.js';
import {Cursor, CURSOR_NODE_INDEX, CursorMovement, CursorUnit} from '/common/cursors/cursor.js';
import {CursorRange} from '/common/cursors/range.js';
import {RecoveryStrategy, TreePathRecoveryStrategy} from '/common/cursors/recovery_strategy.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {Spannable} from '../../common/spannable.js';
import {Output} from '../output/output.js';
import {OutputCustomEvent, OutputNodeSpan} from '../output/output_types.js';

type AutomationNode = chrome.automation.AutomationNode;
const Dir = constants.Dir;
const RoleType = chrome.automation.RoleType;
const StateType = chrome.automation.StateType;
const Movement = CursorMovement;
const Unit = CursorUnit;

interface StartMetadata {
  lineStart?: AutomationNode;
  value: Spannable;
  textCountBeforeLineStart: number;
}
interface EndMetadata {
  lineEnd?: AutomationNode;
  value: Spannable;
  textCountAfterLineEnd: number;
}

export class EditableLine {
  private start_: Cursor;
  private end_: Cursor;
  private localContainerStartOffset_: number;
  private localContainerEndOffset_: number;

  // Computed members.
  private endContainer_?: AutomationNode;
  private lineStart_?: AutomationNode;
  private lineStartContainer_?: AutomationNode;
  private lineStartContainerRecovery_?: RecoveryStrategy;
  private lineEnd_?: AutomationNode;
  private lineEndContainer_?: AutomationNode;
  private localLineStartContainerOffset_ = 0;
  private localLineEndContainerOffset_ = 0;
  private startContainer_?: AutomationNode;
  private startContainerValue_ = '';
  private value_: Spannable;

  /**
   * Controls whether line computations include offscreen inline text boxes.
   * Note that a caller should have this set prior to creating a line.
   */
  static includeOffscreen = true;

  /**
   * @param baseLineOnStart  Controls whether to use |startNode| or |endNode|
   *     for Line computations. Selections are automatically truncated up to
   *     either the line start or end.
   */
  constructor(
      startNode: AutomationNode, startIndex: number, endNode: AutomationNode,
      endIndex: number, baseLineOnStart?: boolean) {
    this.start_ = new Cursor(startNode, startIndex);
    this.start_ = this.start_.deepEquivalent ?? this.start_;

    this.end_ = new Cursor(endNode, endIndex);
    this.end_ = this.end_.deepEquivalent ?? this.end_;

    // Update |startIndex| and |endIndex| if the calls above to
    // Cursor.deepEquivalent results in cursors to different container
    // nodes. The cursors can point directly to inline text boxes, in which case
    // we should not adjust the container start or end index.
    if (!AutomationPredicate.text(startNode) ||
        (this.start_.node !== startNode &&
         this.start_.node.parent !== startNode)) {
      startIndex =
          (this.start_.index === CURSOR_NODE_INDEX && this.start_.node.name) ?
          this.start_.node.name.length :
          this.start_.index;
    }

    if (!AutomationPredicate.text(endNode) ||
        (this.end_.node !== endNode && this.end_.node.parent !== endNode)) {
      endIndex =
          (this.end_.index === CURSOR_NODE_INDEX && this.end_.node.name) ?
          this.end_.node.name.length :
          this.end_.index;
    }

    this.localContainerStartOffset_ = startIndex;
    this.localContainerEndOffset_ = endIndex;

    // Note that we calculate the line based only upon |start_| or
    // |end_| even if they do not fall on the same line. It is up to
    // the caller to specify which end to base this line upon since it requires
    // reasoning about two lines.
    let nameLen = 0;
    const lineBase = baseLineOnStart ? this.start_ : this.end_;
    const lineExtend = baseLineOnStart ? this.end_ : this.start_;

    if (lineBase.node.name) {
      nameLen = lineBase.node.name.length;
    }

    this.value_ = new Spannable(lineBase.node.name ?? '', lineBase);
    if (lineBase.node === lineExtend.node) {
      this.value_.setSpan(lineExtend, 0, nameLen);
    }

    this.startContainer_ = this.start_.node;
    if (this.startContainer_.role === RoleType.INLINE_TEXT_BOX) {
      this.startContainer_ = this.startContainer_.parent;
    }
    this.startContainerValue_ =
        this.startContainer_?.role === RoleType.TEXT_FIELD ?
        this.startContainer_?.value ?? '' :
        this.startContainer_?.name ?? '';
    this.endContainer_ = this.end_.node;
    if (this.endContainer_.role === RoleType.INLINE_TEXT_BOX) {
      this.endContainer_ = this.endContainer_.parent;
    }

    // Initialize defaults.
    this.lineStart_ = lineBase.node;
    this.lineEnd_ = this.lineStart_;
    this.lineStartContainer_ = this.lineStart_.parent;
    this.lineEndContainer_ = this.lineStart_.parent;

    // Annotate each chunk with its associated inline text box node.
    this.value_.setSpan(this.lineStart_, 0, nameLen);

    // Also, track the nodes necessary for selection (either their parents, in
    // the case of inline text boxes, or the node itself).
    // TODO(b/314203187): Not null asserted, check that this is correct.
    const parents: AutomationNode[] = [this.startContainer_!];

    // Keep track of visited nodes to ensure we don't visit the same node twice.
    // Workaround for crbug.com/1203840.
    const visited = new WeakSet();
    if (this.lineStart_) {
      visited.add(this.lineStart_);
    }

    const startData = this.computeLineStartMetadata_(
        this.lineStart_, this.value_, parents, visited);
    this.lineStart_ = startData.lineStart;
    // TODO(b/314203187): Not null asserted, check that this is correct.
    this.lineStartContainer_ = this.lineStart_!.parent;
    this.value_ = startData.value;
    const textCountBeforeLineStart = startData.textCountBeforeLineStart;
    this.localLineStartContainerOffset_ = textCountBeforeLineStart;
    if (this.lineStartContainer_) {
      this.lineStartContainerRecovery_ =
          new TreePathRecoveryStrategy(this.lineStartContainer_);
    }

    const endData = this.computeLineEndMetadata_(
        this.lineEnd_, this.value_, parents, visited);
    this.lineEnd_ = endData.lineEnd;
    // TODO(b/314203187): Not null asserted, check that this is correct.
    this.lineEndContainer_ = this.lineEnd_!.parent;
    this.value_ = endData.value;
    const textCountAfterLineEnd = endData.textCountAfterLineEnd;
    if (this.lineEndContainer_!.name) {
      this.localLineEndContainerOffset_ =
          this.lineEndContainer_!.name.length - textCountAfterLineEnd;
    }

    // Annotate with all parent static texts as NodeSpans so that braille
    // routing can key properly into the node with an offset.
    this.value_ = this.annotateWithParents_(
        this.value_, parents, textCountBeforeLineStart, textCountAfterLineEnd);
  }

  private computeLineStartMetadata_(
      scanNode: AutomationNode | undefined, value: Spannable,
      parents: AutomationNode[], visited: WeakSet<AutomationNode>)
      : StartMetadata {
    let lineStart = scanNode;
    if (scanNode) {
      scanNode = this.getPreviousOnLine_(scanNode);
    }
    // Compute |lineStart|.
    while (scanNode && !visited.has(scanNode)) {
      visited.add(scanNode);
      lineStart = scanNode;

      if (scanNode.role !== RoleType.INLINE_TEXT_BOX) {
        parents.unshift(scanNode);
      } else if (parents[0] !== scanNode.parent) {
        parents.unshift(scanNode.parent!);
      }

      const prepend = new Spannable(scanNode.name, scanNode);
      prepend.append(value);
      value = prepend;

      scanNode = this.getPreviousOnLine_(scanNode);
    }

    // Note that we need to account for potential offsets into the static texts
    // as follows.
    let textCountBeforeLineStart = 0;
    let finder = lineStart;
    // TODO(b/314203187): Not null asserted, check that this is correct.
    while (finder!.previousSibling &&
           (EditableLine.includeOffscreen ||
            !finder!.previousSibling.state![StateType.OFFSCREEN])) {
      finder = finder!.previousSibling;
      textCountBeforeLineStart += finder.name?.length ?? 0;
    }

    return {lineStart, value, textCountBeforeLineStart};
  }

  private computeLineEndMetadata_(
      scanNode: AutomationNode | undefined, value: Spannable,
      parents: AutomationNode[], visited: WeakSet<AutomationNode>)
      : EndMetadata {
    let lineEnd = scanNode;
    if (scanNode) {
      scanNode = this.getNextOnLine_(scanNode);
    }
    // Compute |lineEnd|.
    while (scanNode && !visited.has(scanNode)) {
      visited.add(scanNode);
      lineEnd = scanNode;

      if (scanNode.role !== RoleType.INLINE_TEXT_BOX) {
        parents.push(scanNode);
      } else if (parents[parents.length - 1] !== scanNode.parent) {
        // TODO(b/314203187): Not null asserted, check that this is correct.
        parents.push(scanNode.parent!);
      }

      let annotation: AutomationNode | Cursor = scanNode;
      if (scanNode === this.end_.node) {
        annotation = this.end_;
      }

      value.append(new Spannable(scanNode.name, annotation));

      scanNode = this.getNextOnLine_(scanNode);
    }

    // Note that we need to account for potential offsets into the static texts
    // as follows.
    let textCountAfterLineEnd = 0;
    let finder: AutomationNode | undefined = lineEnd;
    // TODO(b/314203187): Not null asserted, check that this is correct.
    while (finder!.nextSibling &&
           (EditableLine.includeOffscreen ||
            !finder!.nextSibling.state![StateType.OFFSCREEN])) {
      finder = finder!.nextSibling;
      textCountAfterLineEnd += finder.name?.length ?? 0;
    }

    return {lineEnd, value, textCountAfterLineEnd};
  }

  private annotateWithParents_(
      value: Spannable, parents: AutomationNode[],
      textCountBeforeLineStart: number, textCountAfterLineEnd: number)
      : Spannable {
    let len = 0;
    for (let i = 0; i < parents.length; i++) {
      const parent = parents[i];

      if (!parent.name) {
        continue;
      }

      const prevLen = len;
      let currentLen = parent.name.length;
      let offset = 0;

      // Subtract off the text count before when at the start of line.
      if (i === 0) {
        currentLen -= textCountBeforeLineStart;
        offset = textCountBeforeLineStart;
      }

      // Subtract text count after when at the end of the line.
      if (i === parents.length - 1) {
        currentLen -= textCountAfterLineEnd;
      }

      len += currentLen;

      try {
        value.setSpan(new OutputNodeSpan(parent, offset), prevLen, len);

        // Also, annotate this span if it is associated with line container.
        if (parent === this.startContainer_) {
          value.setSpan(parent, prevLen, len);
        }
      } catch (e) {
        console.error(e);
      }
    }
    return value;
  }

  private getNextOnLine_(node: AutomationNode): AutomationNode | undefined {
    const nextOnLine = node.nextOnLine;
    const nextSibling = node.nextSibling;
    if (nextOnLine?.role) {
      // Ensure that there is a next-on-line node. The role can be undefined
      // for an object that has been destroyed since the object was first
      // cached.
      return nextOnLine;
    }

    if (nextSibling?.previousOnLine === node) {
      // Catches potential breaks in the chain of next-on-line nodes.
      return nextSibling.firstChild;
    }

    return undefined;
  }

  private getPreviousOnLine_(node: AutomationNode): AutomationNode | undefined {
    const previousLine = node.previousOnLine;
    const previousSibling = node.previousSibling;
    if (previousLine?.role) {
      // Ensure that there is a previous-on-line node. The role can be undefined
      // for an object that has been destroyed since the object was first
      // cached.
      return previousLine;
    }

    if (previousSibling?.lastChild?.nextOnLine === node) {
      // Catches potential breaks in the chain of previous-on-line nodes.
      return previousSibling.lastChild;
    }

    return undefined;
  }

  /** Gets the selection offset based on the text content of this line. */
  get startOffset(): number {
    // It is possible that the start cursor points to content before this line
    // (e.g. in a multi-line selection).
    try {
      return this.value_.getSpanStart(this.start_) +
          (this.start_.index === CURSOR_NODE_INDEX ? 0 : this.start_.index);
    } catch (e) {
      // When that happens, fall back to the start of this line.
      return 0;
    }
  }

  /** Gets the selection offset based on the text content of this line. */
  get endOffset(): number {
    try {
      return this.value_.getSpanStart(this.end_) +
          (this.end_.index === CURSOR_NODE_INDEX ? 0 : this.end_.index);
    } catch (e) {
      // When that happens, fall back to the end of this line.
      return this.value_.length;
    }
  }

  /**
   * Gets the selection offset based on the parent's text.
   * The parent is expected to be static text.
   */
  get localStartOffset(): number {
    return this.localContainerStartOffset_;
  }

  /**
   * Gets the selection offset based on the parent's text.
   * The parent is expected to be static text.
   */
  get localEndOffset(): number {
    return this.localContainerEndOffset_;
  }

  /**
   * Gets the start offset of the container, relative to the line text
   * content. The container refers to the static text parenting the inline
   * text box.
   */
  get containerStartOffset(): number {
    return this.value_.getSpanStart(this.startContainer_);
  }

  /**
   * Gets the end offset of the container, relative to the line text content.
   * The container refers to the static text parenting the inline text box.
   */
  get containerEndOffset(): number {
    return this.value_.getSpanEnd(this.startContainer_) - 1;
  }

  /** @return The text content of this line. */
  get text(): string {
    return this.value_.toString();
  }

  get selectedText(): string {
    return this.value_.toString().substring(this.startOffset, this.endOffset);
  }

  get startContainer(): AutomationNode | undefined {
    return this.startContainer_;
  }

  get endContainer(): AutomationNode | undefined {
    return this.endContainer_;
  }

  get value(): Spannable {
    return this.value_;
  }

  get start(): Cursor {
    return this.start_;
  }

  get end(): Cursor {
    return this.end_;
  }

  get localContainerStartOffset(): number {
    return this.localContainerStartOffset_;
  }

  get localContainerEndOffset(): number {
    return this.localContainerEndOffset_;
  }

  get startContainerValue(): string {
    return this.startContainerValue_;
  }

  hasCollapsedSelection(): boolean {
    return this.start_.equals(this.end_);
  }

  /** @return Whether this line has selection over text nodes. */
  hasTextSelection(): boolean {
    if (this.start_.node && this.end_.node) {
      return AutomationPredicate.text(this.start_.node) &&
          AutomationPredicate.text(this.end_.node);
    }

    return false;
  }

  /**
   * Returns true if |otherLine| surrounds the same line as |this|. Note that
   * the contents of the line might be different.
   */
  isSameLine(otherLine: EditableLine): boolean {
    // Equality is intentionally loose here as any of the state nodes can be
    // invalidated at any time. We rely upon the start/anchor of the line
    // staying the same.
    const startNodeAndOffsetMatch =
        otherLine.lineStartContainer_ === this.lineStartContainer_ &&
        otherLine.localLineStartContainerOffset_ ===
            this.localLineStartContainerOffset_;
    const endNodeAndOffsetMatch =
        otherLine.lineEndContainer_ === this.lineEndContainer_ &&
        otherLine.localLineEndContainerOffset_ ===
            this.localLineEndContainerOffset_;
    const recoveryNodeAndOffsetMatch =
        otherLine.lineStartContainerRecovery_?.node ===
            this.lineStartContainerRecovery_?.node &&
        otherLine.localLineStartContainerOffset_ ===
            this.localLineStartContainerOffset_;


    return startNodeAndOffsetMatch || endNodeAndOffsetMatch ||
        recoveryNodeAndOffsetMatch;
  }

  /**
   * Returns true if |otherLine| surrounds the same line as |this| and has the
   * same selection.
   */
  isSameLineAndSelection(otherLine: EditableLine): boolean {
    return this.isSameLine(otherLine) &&
        this.startOffset === otherLine.startOffset &&
        this.endOffset === otherLine.endOffset;
  }

  /** Returns whether this line comes before |otherLine| in document order. */
  isBeforeLine(otherLine: EditableLine): boolean {
    if (!this.lineStartContainer_ || !otherLine.lineStartContainer_) {
      return false;
    }

    if (this.isSameLine(otherLine)) {
      return this.endOffset <= otherLine.endOffset;
    }

    return AutomationUtil.getDirection(
               this.lineStartContainer_, otherLine.lineStartContainer_) ===
        Dir.FORWARD;
  }

  /**
   * Performs a validation that this line still refers to a line given its
   * internally tracked state.
   */
  isValidLine(): boolean {
    if (!this.lineStartContainer_ || !this.lineEndContainer_) {
      return false;
    }

    const start = new Cursor(
        this.lineStartContainer_, this.localLineStartContainerOffset_);
    const end = new Cursor(
        this.lineEndContainer_, this.localLineEndContainerOffset_ - 1);
    const localStart = start.deepEquivalent ?? start;
    const localEnd = end.deepEquivalent ?? end;
    const localStartNode = localStart.node;
    const localEndNode = localEnd.node;

    // Unfortunately, there are asymmetric errors in lines, so we need to
    // check in both directions.
    let testStartNode = localStartNode;
    do {
      if (testStartNode === localEndNode) {
        return true;
      }

      // Hack/workaround for broken *OnLine links.
      if (testStartNode.nextOnLine?.role) {
        testStartNode = testStartNode.nextOnLine;
      } else if (testStartNode.nextSibling?.previousOnLine === testStartNode) {
        testStartNode = testStartNode.nextSibling;
      } else {
        break;
      }
    } while (testStartNode);

    let testEndNode = localEndNode;
    do {
      if (testEndNode === localStartNode) {
        return true;
      }

      // Hack/workaround for broken *OnLine links.
      if (testEndNode.previousOnLine?.role) {
        testEndNode = testEndNode.previousOnLine;
      } else if (testEndNode.previousSibling?.nextOnLine === testEndNode) {
        testEndNode = testEndNode.previousSibling;
      } else {
        break;
      }
    } while (testEndNode);

    return false;
  }

  /** Speaks the line using text to speech. */
  speakLine(prevLine: EditableLine): void {
    // Detect when the entire line is just a breaking space. This occurs on
    // Google Docs and requires that we speak it as a new line. However, we
    // still need to account for all of the possible rich output occurring from
    // ancestors of line nodes.
    const isLineBreakingSpace = this.text === '\u00a0';

    // TODO(b/314203187): Not null asserted, check that this is correct.
    const prev =
        prevLine?.startContainer_!.role ? prevLine.startContainer_ : null;
    const lineNodes: AutomationNode[] =
        this.value_.getSpansInstanceOf(this.startContainer_!.constructor);
    const speakNodeAtIndex = (index: number, prev: AutomationNode): void => {
      const cur = lineNodes[index];
      if (!cur) {
        return;
      }

      if (cur.children.length) {
        speakNodeAtIndex(++index, cur);
        return;
      }

      const o = new Output();

      if (isLineBreakingSpace) {
        // Apply a replacement for \u00a0 to \n.
        o.withSpeechTextReplacement('\u00a0', '\n');
      }

      o.withRichSpeech(
           CursorRange.fromNode(cur),
           prev ? CursorRange.fromNode(prev) : CursorRange.fromNode(cur),
           OutputCustomEvent.NAVIGATE)
          .onSpeechEnd(() => speakNodeAtIndex(++index, cur));

      // Ignore whitespace only output except if it is leading content on the
      // line.
      if (!o.isOnlyWhitespace || index === 0) {
        o.go();
      } else {
        speakNodeAtIndex(++index, cur);
      }
    };

    // TODO(b/314203187): Not null asserted, check that this is correct.
    speakNodeAtIndex(0, prev!);
  }

  /**
   * Creates a range around the character to the right of the line's starting
   * position.
   */
  createCharRange(): CursorRange {
    const start = this.start_;
    let end = start.move(Unit.CHARACTER, Movement.DIRECTIONAL, Dir.FORWARD);

    // The following conditions detect when|start|moves across a node boundary
    // to|end|.
    if (start.node !== end.node ||
        // When |start| and |end| are equal, that means we've reached
        // the end of the document. This is a node boundary as well.
        start.equals(end)) {
      end = new Cursor(start.node, start.index + 1);
    }
    return new CursorRange(start, end);
  }

  createWordRange(shouldMoveToPreviousWord: boolean): CursorRange {
    const pos = this.start_;
    // When movement goes to the end of a word, we actually want to
    // describe the word itself; this is considered the previous word so
    // impacts the movement type below. We can give further context e.g.
    // by saying "end of word", if we chose to be more verbose.
    const start = pos.move(
        Unit.WORD,
        shouldMoveToPreviousWord ? Movement.DIRECTIONAL : Movement.BOUND,
        Dir.BACKWARD);
    const end = start.move(Unit.WORD, Movement.BOUND, Dir.FORWARD);
    return new CursorRange(start, end);
  }
}

TestImportManager.exportForTesting(EditableLine);