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

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

/**
 * @fileoverview Generalized logic for providing spoken feedback when editing
 * text fields, both single and multiline fields.
 *
 * {@code ChromeVoxEditableTextBase} is a generalized class that takes the
 * current state in the form of a text string, a cursor start location and a
 * cursor end location, and calls a speak method with the resulting text to
 * be spoken.  This class can be used directly for single line fields or
 * extended to override methods that extract lines for multiline fields
 * or to provide other customizations.
 */
import {LocalStorage} from '/common/local_storage.js';
import {StringUtil} from '/common/string_util.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {Personality, QueueMode, TtsCategory, TtsSpeechProperties} from '../../common/tts_types.js';
import {TtsInterface} from '../tts_interface.js';

import {TypingEchoState} from './typing_echo.js';

/**
 * A class containing the information needed to speak
 * a text change event to the user.
 */
export class TextChangeEvent {
  private value_ = '';
  start: number;
  end: number;
  triggeredByUser: boolean;

  /**
   * @param newValue The new string value of the editable text control.
   * @param newStart The new 0-based start cursor/selection index.
   * @param newEnd The new 0-based end cursor/selection index.
   */
  constructor(
      newValue: string, newStart: number, newEnd: number,
      triggeredByUser: boolean) {
    this.value = newValue;

    this.start = newStart;
    this.end = newEnd;
    this.triggeredByUser = triggeredByUser;

    // Adjust offsets to be in left to right order.
    if (this.start > this.end) {
      const tempOffset = this.end;
      this.end = this.start;
      this.start = tempOffset;
    }
  }

  get value(): string {
    return this.value_;
  }
  set value(val: string) {
    this.value_ = val.replace(/\u00a0/g, ' ');
  }
}

/**
 * A class representing an abstracted editable text control.
 */
export class ChromeVoxEditableTextBase {
  static shouldSpeakInsertions = false;
  static maxShortPhraseLen = 60;

  /** Current value of the text field. */
  private value_ = '';
  /** 0-based selection start index. */
  protected start: number;
  /** 0-based selection end index. */
  protected end: number;
  /** True if this is a password field. */
  protected isPassword: boolean;
  /** Text-to-speech object implementing speak() and stop() methods. */
  protected tts: TtsInterface;
  /** Whether or not the text field is multiline. */
  protected multiline = false;
  /**
   * Whether or not the last update to the text and selection was described.
   *
   * Some consumers of this flag like |ChromeVoxEventWatcher| depend on and
   * react to when this flag is false by generating alternative feedback.
   */
  lastChangeDescribed = false;

  /**
   * @param value The string value of the editable text control.
   * @param start The 0-based start cursor/selection index.
   * @param end The 0-based end cursor/selection index.
   * @param isPassword Whether the text control if a password field.
   */
  constructor(
      value: string, start: number, end: number, isPassword: boolean,
      tts: TtsInterface) {
    this.value = value;
    this.start = start;
    this.end = end;
    this.isPassword = isPassword;
    this.tts = tts;
  }

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

  set value(newValue: string) {
    this.value_ = newValue.replace('\u00a0', ' ');
  }

  getLineIndex(_charIndex: number): number {
    return 0;
  }

  getLineStart(_lineIndex: number): number {
    return 0;
  }

  getLineEnd(_lineIndex: number): number {
    return this.value.length;
  }

  /**
   * Get the full text of the current line.
   * @param index The 0-based line index.
   * @return The text of the line.
   */
  getLine(index: number): string {
    const lineStart = this.getLineStart(index);
    const lineEnd = this.getLineEnd(index);
    return this.value.substr(lineStart, lineEnd - lineStart);
  }

  /**
   * Speak text, but if it's a single character, describe the character.
   * @param str The string to speak.
   * @param triggeredByUser True if the speech was triggered by a user action.
   * @param personality Personality used to speak text.
   */
  speak(str: string, triggeredByUser?: boolean,
        personality?: TtsSpeechProperties): void {
    if (!str) {
      return;
    }
    let queueMode = QueueMode.QUEUE;
    if (triggeredByUser === true) {
      queueMode = QueueMode.CATEGORY_FLUSH;
    }
    const props = personality ?? new TtsSpeechProperties();
    props.category = TtsCategory.NAV;
    this.tts.speak(str, queueMode, props);
  }

  /**
   * The function is called by describeTextChanged and process if there's
   * some text changes likely made by IME.
   * @param {TextChangeEvent} prev The previous text change event.
   * @param {TextChangeEvent} evt The text change event.
   * @param {number} commonPrefixLen The number of characters in the common
   *     prefix of this.value and newValue.
   * @param {number} commonSuffixLen The number of characters in the common
   *     suffix of this.value and newValue.
   * @return {boolean} True if the event was processed.
   */
  describeTextChangedByIME(
      prev: TextChangeEvent, evt: TextChangeEvent, commonPrefixLen: number,
      commonSuffixLen: number): boolean {
    // This supports typing Echo with IME.
    // - no selection range before and after.
    // - suffixes are common after both cursor end.
    // - prefixes are common at least max(0, "before length - 3").
    // Then, something changed in composition range. Announce the new
    // characters.
    const relaxedPrefixLen =
        Math.max(prev.start - MAX_CHANGE_CHARS_BY_SINGLE_TYPE, 0);
    let suffixLen = evt.value.length - evt.end;
    if (prev.start === prev.end && evt.start === evt.end &&
        prev.value.length - prev.end === suffixLen &&
        commonPrefixLen >= relaxedPrefixLen && commonPrefixLen < evt.start &&
        commonSuffixLen >= suffixLen) {
      if (LocalStorage.get('typingEcho') === TypingEchoState.CHARACTER ||
          LocalStorage.get('typingEcho') ===
              TypingEchoState.CHARACTER_AND_WORD) {
        this.speak(
            evt.value.substring(commonPrefixLen, evt.start),
            evt.triggeredByUser);
      }
      return true;
    }

    // The followings happens when a user starts to select candidates.
    // - no selection range before and after.
    // - prefixes are common before "new cursor point".
    // - suffixes are common after "old cursor point".
    // Then, this suggests that pressing a space or a tab to start composition.
    // Let's announce the first suggested content.
    // Note that after announcing this, announcements will be made by candidate
    // window's selection event instead of ChromeVox's editable.
    const prefixLen = evt.start;
    suffixLen = prev.value.length - prev.end;
    if (prev.start === prev.end && evt.start === evt.end &&
        evt.start < prev.start && evt.value.length > prefixLen + suffixLen &&
        commonPrefixLen >= prefixLen && commonSuffixLen >= suffixLen) {
      this.speak(
          evt.value.substring(prefixLen, evt.value.length - suffixLen),
          evt.triggeredByUser,
          new TtsSpeechProperties({'phoneticCharacters': true}));
      return true;
    }

    return false;
  }

  /**
   * The function is called by describeTextChanged after it's figured out
   * what text was deleted, what text was inserted, and what additional
   * autocomplete text was added.
   * @param {TextChangeEvent} prev The previous text change event.
   * @param {TextChangeEvent} evt The text change event.
   * @param {number} prefixLen The number of characters in the common prefix
   *     of this.value and newValue.
   * @param {number} suffixLen The number of characters in the common suffix
   *     of this.value and newValue.
   * @param {string} autocompleteSuffix The autocomplete string that was added
   *     to the end, if any. It should be spoken at the end of the utterance
   *     describing the change.
   * @param {TtsSpeechProperties=} personality Personality to speak the
   *     text.
   */
  describeTextChangedHelper(
      prev: TextChangeEvent, evt: TextChangeEvent, prefixLen: number,
      suffixLen: number, autocompleteSuffix: string,
      personality?: TtsSpeechProperties): void {
    const len = prev.value.length;
    const newLen = evt.value.length;
    const deletedLen = len - prefixLen - suffixLen;
    const deleted = prev.value.substr(prefixLen, deletedLen);
    const insertedLen = newLen - prefixLen - suffixLen;
    const inserted = evt.value.substr(prefixLen, insertedLen);
    let utterance = '';
    let triggeredByUser = evt.triggeredByUser;

    if (insertedLen > 1) {
      if (!ChromeVoxEditableTextBase.shouldSpeakInsertions) {
        return;
      }
      utterance = inserted;
    } else if (insertedLen === 1) {
      if ((LocalStorage.get('typingEcho') === TypingEchoState.WORD ||
           LocalStorage.get('typingEcho') ===
               TypingEchoState.CHARACTER_AND_WORD) &&
          StringUtil.isWordBreakChar(inserted) && prefixLen > 0 &&
          !StringUtil.isWordBreakChar(evt.value.substr(prefixLen - 1, 1))) {
        // Speak previous word.
        let index = prefixLen;
        while (index > 0 &&
               !StringUtil.isWordBreakChar(evt.value[index - 1]!)) {
          index--;
        }
        if (index < prefixLen) {
          utterance = evt.value.substr(index, prefixLen + 1 - index);
        } else {
          utterance = inserted;
          triggeredByUser = false;  // Implies QUEUE_MODE_QUEUE.
        }
      } else if (
          LocalStorage.get('typingEcho') === TypingEchoState.CHARACTER ||
          LocalStorage.get('typingEcho') ===
              TypingEchoState.CHARACTER_AND_WORD) {
        utterance = inserted;
      }
    } else if (deletedLen > 1 && !autocompleteSuffix) {
      utterance = deleted + ', deleted';
    } else if (deletedLen === 1) {
      utterance = deleted;
      // Single-deleted characters should also use Personality.DELETED.
      personality = Personality.DELETED;
    }

    if (autocompleteSuffix && utterance) {
      utterance += ', ' + autocompleteSuffix;
    } else if (autocompleteSuffix) {
      utterance = autocompleteSuffix;
    }

    if (utterance) {
      this.speak(utterance, triggeredByUser, personality);
    }
  }
}

// Private to module.

const MAX_CHANGE_CHARS_BY_SINGLE_TYPE = 3;

TestImportManager.exportForTesting(ChromeVoxEditableTextBase, TextChangeEvent);