chromium/chrome/browser/resources/side_panel/read_anything/app.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 './read_anything_toolbar.js';
import './strings.m.js';
import '//read-anything-side-panel.top-chrome/shared/sp_empty_state.js';
import '//resources/cr_elements/cr_button/cr_button.js';
import '//resources/cr_elements/cr_toast/cr_toast.js';

import {ColorChangeUpdater} from '//resources/cr_components/color_change_listener/colors_css_updater.js';
import type {CrToastElement} from '//resources/cr_elements/cr_toast/cr_toast.js';
import {WebUiListenerMixinLit} from '//resources/cr_elements/web_ui_listener_mixin_lit.js';
import {assert} from '//resources/js/assert.js';
import {loadTimeData} from '//resources/js/load_time_data.js';
import {listenOnce} from '//resources/js/util.js';
import {CrLitElement} from '//resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues} from '//resources/lit/v3_0/lit.rollup.js';

import {getCss} from './app.css.js';
import {getHtml} from './app.html.js';
import {AppStyleUpdater} from './app_style_updater.js';
import {getCurrentSpeechRate, minOverflowLengthToScroll, playFromSelectionTimeout, toastDurationMs} from './common.js';
import type {SettingsPrefs} from './common.js';
import {ReadAnythingLogger, TimeFrom, TimeTo} from './read_anything_logger.js';
import type {ReadAnythingToolbarElement} from './read_anything_toolbar.js';
import type {VoicePackStatus} from './voice_language_util.js';
import {areVoicesEqual, AVAILABLE_GOOGLE_TTS_LOCALES, convertLangOrLocaleForVoicePackManager, convertLangOrLocaleToExactVoicePackLocale, convertLangToAnAvailableLangIfPresent, createInitialListOfEnabledLanguages, doesLanguageHaveNaturalVoices, getFilteredVoiceList, getNaturalVoiceOrDefault, getVoicePackConvertedLangIfExists, isEspeak, isNatural, isVoicePackStatusError, isVoicePackStatusSuccess, isWaitingForInstallLocally, mojoVoicePackStatusToVoicePackStatusEnum, VoiceClientSideStatusCode, VoicePackServerStatusErrorCode, VoicePackServerStatusSuccessCode} from './voice_language_util.js';

const AppElementBase = WebUiListenerMixinLit(CrLitElement);

interface UtteranceSettings {
  lang: string;
  volume: number;
  pitch: number;
  rate: number;
}

export const previousReadHighlightClass = 'previous-read-highlight';
export const currentReadHighlightClass = 'current-read-highlight';
const parentOfHighlightClass = 'parent-of-highlight';

const linkDataAttribute = 'link';

// Characters that should be ignored for word highlighting when not accompanied
// by other characters.
const IGNORED_HIGHLIGHT_CHARACTERS_REGEX: RegExp = /^[.,!?'"(){}\[\]]+$/;

// A two-way map where each key is unique and each value is unique. The keys are
// DOM nodes and the values are numbers, representing AXNodeIDs.
class TwoWayMap<K, V> extends Map<K, V> {
  #reverseMap: Map<V, K>;
  constructor() {
    super();
    this.#reverseMap = new Map();
  }
  override set(key: K, value: V) {
    super.set(key, value);
    this.#reverseMap.set(value, key);
    return this;
  }
  keyFrom(value: V) {
    return this.#reverseMap.get(value);
  }
  override clear() {
    super.clear();
    this.#reverseMap.clear();
  }
}

export enum PauseActionSource {
  DEFAULT,
  BUTTON_CLICK,
  VOICE_PREVIEW,
  VOICE_SETTINGS_CHANGE,
}

export enum WordBoundaryMode {
  // Used if word boundaries are not supported (i.e. we haven't received enough
  // information to determine if word boundaries are supported.)
  BOUNDARIES_NOT_SUPPORTED,
  NO_BOUNDARIES,
  BOUNDARY_DETECTED,
}

export interface SpeechPlayingState {
  // If the speech tree for the current page has been initialized. This happens
  // in updateContent before speech has been initiated by users but it can
  // also be set to true via a play from selection.
  isSpeechTreeInitialized: boolean;
  // True when the user presses play, regardless of if audio has actually
  // started yet. This will be false when speech is paused.
  isSpeechActive: boolean;
  // When `isSpeechActive` is false, this indicates how it became false. e.g.
  // via pause button click or because other speech settings were changed.
  pauseSource?: PauseActionSource;
  // Indicates that audio is currently playing.
  // When a user presses the play button, isSpeechActive becomes true, but
  // `isAudioCurrentlyPlaying` will tell us whether audio actually started
  // playing yet. This is a separate state because audio starting has a delay.
  isAudioCurrentlyPlaying: boolean;
  // Indicates if speech has been triggered on the current page by a play
  // button press. This will be true throughout the lifetime of reading
  // the content on the page. It will only be reset when speech has completely
  // stopped from reaching the end of content or changing pages. Pauses will
  // not update it.
  hasSpeechBeenTriggered: boolean;
}

export interface WordBoundaryState {
  mode: WordBoundaryMode;
  // The charIndex of the last word boundary index retrieved by the "Boundary"
  // event. Default is 0.
  previouslySpokenIndex: number;
  // Is only non-zero if the current state has already resumed speech on a
  // word boundary. e.g. If we interrupted speech for the segment
  // "This is a sentence" at "is," so the next segment spoken is "is a
  // sentence," if we attempt to interrupt speech again at "a." This helps us
  // keep track of the correct index in the overall granularity string- not
  // just the correct index within the current string.
  // Default is 0.
  speechUtteranceStartIndex: number;
}

export interface AppElement {
  $: {
    toolbar: ReadAnythingToolbarElement,
    appFlexParent: HTMLElement,
    container: HTMLElement,
    containerParent: HTMLElement,
  };
}

function isInvalidHighlightForWordHighlighting(textToHighlight: string|
                                               undefined): boolean {
  // If a highlight is just white space or punctuation, we can skip
  // highlighting.
  return !textToHighlight || textToHighlight === '' ||
      IGNORED_HIGHLIGHT_CHARACTERS_REGEX.test(textToHighlight);
}

export class AppElement extends AppElementBase {
  static get is() {
    return 'read-anything-app';
  }

  static override get styles() {
    return getCss();
  }

  override render() {
    return getHtml.bind(this)();
  }

  static override get properties() {
    return {
      speechPlayingState: {type: Object},
      imagesEnabled: {type: Boolean, reflect: true},
      enabledLangs: {type: Array},
      settingsPrefs_: {type: Object},
      selectedVoice_: {type: Object},
      availableVoices_: {type: Array},
      voiceStatusLocalState_: {type: Object},
      previewVoicePlaying_: {type: Object},
      localeToDisplayName_: {type: Object},
      lastDownloadedLang_: {type: String},
      hasContent_: {type: Boolean},
      speechEngineLoaded_: {type: Boolean},
      willDrawAgainSoon_: {type: Boolean},
      emptyStateImagePath_: {type: String},
      emptyStateDarkImagePath_: {type: String},
      emptyStateHeading_: {type: String},
      emptyStateSubheading_: {type: String},
    };
  }

  private startTime = Date.now();
  private constructorTime: number;

  // Maps a DOM node to the AXNodeID that was used to create it. DOM nodes and
  // AXNodeIDs are unique, so this is a two way map where either DOM node or
  // AXNodeID can be used to access the other.
  private domNodeToAxNodeIdMap_: TwoWayMap<Node, number> = new TwoWayMap();
  // Key: a DOM node that's already been read aloud
  // Value: the index offset at which this node's text begins within its parent
  // text. For reading aloud we sometimes split up nodes so the speech sounds
  // more natural. When that text is then selected we need to pass the correct
  // index down the pipeline, so we store that info here.
  private highlightedNodeToOffsetInParent: Map<Node, number> = new Map();
  private imageNodeIdsToFetch_: Set<number> = new Set();

  private scrollingOnSelection_ = false;
  protected hasContent_ = false;
  protected emptyStateImagePath_?: string;
  protected emptyStateDarkImagePath_?: string;
  protected emptyStateHeading_?: string;
  protected lastDownloadedLang_?: string;
  protected toastDuration_: number = toastDurationMs;
  protected emptyStateSubheading_ = '';

  private previousHighlights_: HTMLElement[] = [];
  private previousRootId_: number;

  private isReadAloudEnabled_: boolean;
  protected isDocsLoadMoreButtonVisible_: boolean = false;

  // If the speech engine is considered "loaded." If it is, we should display
  // the play / pause buttons normally. Otherwise, we should disable the
  // Read Aloud controls until the engine has loaded in order to provide
  // visual feedback that a voice is about to be spoken.
  private speechEngineLoaded_: boolean = true;

  // Sometimes distillations are queued up while distillation is happening so
  // when the current distillation finishes, we re-distill immediately. In that
  // case we shouldn't allow playing speech until the next distillation to avoid
  // resetting speech right after starting it.
  private willDrawAgainSoon_: boolean = false;

  // After the first utterance has been spoken, we should assume that the
  // speech engine has loaded, and we shouldn't adjust the play / pause
  // disabled state based on the message.onStart callback to avoid flickering.
  private firstUtteranceSpoken_ = false;

  synth = window.speechSynthesis;

  protected selectedVoice_: SpeechSynthesisVoice|undefined;
  // The set of languages currently enabled for use by Read Aloud. This
  // includes user-enabled languages and auto-downloaded languages. The former
  // are stored in preferences. The latter are not.
  enabledLangs: string[] = [];

  // All possible available voices for the current speech engine.
  protected availableVoices_: SpeechSynthesisVoice[] = [];
  // The set of languages found in availableVoices.
  private availableLangs_: string[] = [];
  // If a preview is playing, this is set to the voice the preview is playing.
  // Otherwise, this is undefined.
  protected previewVoicePlaying_?: SpeechSynthesisVoice;

  protected localeToDisplayName_: {[locale: string]: string};

  // Our local representation of the status of voice pack downloads and
  // availability
  protected voiceStatusLocalState_:
      {[language: string]: VoiceClientSideStatusCode} = {};

  // Cache of responses from LanguagePackManager
  private voicePackInstallStatusServerResponses_:
      {[language: string]: VoicePackStatus} = {};

  // Set of languages of the browser and/or of the pages navigated to that we
  // need to download Natural voices for automatically
  private languagesForVoiceDownloads: Set<string> = new Set();

  // Metrics captured for logging.
  private playSessionStartTime: number = -1;

  private logger_: ReadAnythingLogger = ReadAnythingLogger.getInstance();
  private styleUpdater_: AppStyleUpdater;
  protected settingsPrefs_: SettingsPrefs;

  // State for speech synthesis paused/play state needs to be tracked explicitly
  // because there are bugs with window.speechSynthesis.paused and
  // window.speechSynthesis.speaking on some platforms.
  speechPlayingState: SpeechPlayingState = {
    isSpeechTreeInitialized: false,
    isSpeechActive: false,
    pauseSource: PauseActionSource.DEFAULT,
    isAudioCurrentlyPlaying: false,
    hasSpeechBeenTriggered: false,
  };

  private imagesEnabled: boolean = false;

  maxSpeechLength: number = 175;

  wordBoundaryState: WordBoundaryState = {
    mode: WordBoundaryMode.BOUNDARIES_NOT_SUPPORTED,
    speechUtteranceStartIndex: 0,
    previouslySpokenIndex: 0,
  };

  // If the node id of the first text node that should be used by Read Aloud
  // has been set. This is null if the id has not been set.
  firstTextNodeSetForReadAloud: number|null = null;

  speechSynthesisLanguage: string;

  // If we weren't able to restore language preferences successfully and we
  // should attempt to restore settings if voices refresh.
  // Sometimes, the speech synthesis engine hasn't refreshed available
  // voices by the time we restore settings, which means we end up
  // ignoring previous settings if we get an onvoiceschanged callback
  // a few seconds later. By keeping track of whether or not preferences
  // were successfully restored, we can re-attempt to restore voice and
  // language preferences from settings in onVoicesChanged.
  shouldAttemptLanguageSettingsRestore: boolean = true;


  override willUpdate(changedProperties: PropertyValues<this>) {
    super.willUpdate(changedProperties);

    if (changedProperties.has('speechPlayingState')) {
      chrome.readingMode.onSpeechPlayingStateChanged(
          this.speechPlayingState.isSpeechActive);
    }
  }

  constructor() {
    super();
    this.constructorTime = Date.now();
    this.logger_.logTimeBetween(
        TimeFrom.APP, TimeTo.CONSTRUCTOR, this.startTime, this.constructorTime);
    this.isReadAloudEnabled_ = chrome.readingMode.isReadAloudEnabled;
    this.speechSynthesisLanguage = chrome.readingMode.baseLanguageForSpeech;
    this.styleUpdater_ = new AppStyleUpdater(this);
    ColorChangeUpdater.forDocument().start();
  }

  override connectedCallback() {
    super.connectedCallback();

    // onConnected should always be called first in connectedCallback to ensure
    // we're not blocking onConnected on anything else during WebUI setup.
    if (chrome.readingMode) {
      chrome.readingMode.onConnected();
      const connectedCallbackTime = Date.now();
      this.logger_.logTimeBetween(
          TimeFrom.APP, TimeTo.CONNNECTED_CALLBACK, this.startTime,
          connectedCallbackTime);
      this.logger_.logTimeBetween(
          TimeFrom.APP_CONSTRUCTOR, TimeTo.CONNNECTED_CALLBACK,
          this.constructorTime, connectedCallbackTime);
    }

    // Wait until the side panel is fully rendered before showing the side
    // panel. This follows Side Panel best practices and prevents loading
    // artifacts from showing if the side panel is shown before content is
    // ready.
    listenOnce(this.$.appFlexParent, 'dom-change', () => {
      setTimeout(() => chrome.readingMode.shouldShowUi(), 0);
    });

    this.showLoading();

    if (this.isReadAloudEnabled_) {
      // Clear state. We don't do this in disconnectedCallback because that's
      // not always reliabled called.
      this.synth.cancel();
      this.hasContent_ = false;
      this.firstUtteranceSpoken_ = false;
      this.firstTextNodeSetForReadAloud = null;
      this.domNodeToAxNodeIdMap_.clear();
      this.clearReadAloudState();

      this.synth.onvoiceschanged = () => {
        this.onVoicesChanged();
      };
    }

    this.settingsPrefs_ = {
      letterSpacing: chrome.readingMode.letterSpacing,
      lineSpacing: chrome.readingMode.lineSpacing,
      theme: chrome.readingMode.colorTheme,
      speechRate: chrome.readingMode.speechRate,
      font: chrome.readingMode.fontName,
    };

    document.onselectionchange = () => {
      // When Read Aloud is playing, user-selection is disabled on the Read
      // Anything panel, so don't attempt to update selection, as this can
      // end up clearing selection in the main part of the browser.
      if (!this.hasContent_ || this.speechPlayingState.isSpeechActive) {
        return;
      }
      const selection: Selection = this.getSelection();
      assert(selection, 'no selection');
      if (!selection.anchorNode || !selection.focusNode) {
        // The selection was collapsed by clicking inside the selection.
        chrome.readingMode.onCollapseSelection();
        return;
      }

      const {anchorNodeId, anchorOffset, focusNodeId, focusOffset} =
          this.getSelectedIds();
      if (!anchorNodeId || !focusNodeId) {
        return;
      }

      chrome.readingMode.onSelectionChange(
          anchorNodeId, anchorOffset, focusNodeId, focusOffset);
      // If there's been a selection, clear the current
      // Read Aloud highlight.
      const elements =
          this.shadowRoot?.querySelectorAll('.' + currentReadHighlightClass);
      if (elements && anchorNodeId && focusNodeId) {
        elements.forEach(el => el.classList.remove(currentReadHighlightClass));
      }

      // Clear the previously read highlight if there's been a selection.
      // If speech is resumed, this won't be restored.
      // TODO(b/40927698): Restore the previous highlight after speech
      // is resumed after a selection.
      this.previousHighlights_.forEach((element) => {
        if (element) {
          element.classList.remove(previousReadHighlightClass);
        }
      });
      this.previousHighlights_ = [];
    };

    this.$.containerParent.onscroll = () => {
      chrome.readingMode.onScroll(this.scrollingOnSelection_);
      this.scrollingOnSelection_ = false;
    };

    // Pass copy commands to main page. Copy commands will not work if they are
    // disabled on the main page.
    document.oncopy = () => {
      chrome.readingMode.onCopy();
      return false;
    };

    /////////////////////////////////////////////////////////////////////
    // Called by ReadAnythingUntrustedPageHandler via callback router. //
    /////////////////////////////////////////////////////////////////////
    chrome.readingMode.updateContent = () => {
      this.updateContent();
    };

    chrome.readingMode.updateLinks = () => {
      this.updateLinks_();
    };

    chrome.readingMode.updateImages = () => {
      this.updateImages_();
    };

    chrome.readingMode.onImageDownloaded = (nodeId) => {
      this.onImageDownloaded(nodeId);
    };

    chrome.readingMode.updateSelection = () => {
      this.updateSelection();
    };

    chrome.readingMode.updateVoicePackStatus =
        (lang: string, status: string) => {
          this.updateVoicePackStatus(lang, status);
        };

    chrome.readingMode.updateVoicePackStatusFromInstallResponse =
        (lang: string, status: string) => {
          this.updateVoicePackStatusFromInstallResponse(lang, status);
        };

    chrome.readingMode.showLoading = () => {
      this.showLoading();
    };

    chrome.readingMode.showEmpty = () => {
      this.showEmpty();
    };

    chrome.readingMode.restoreSettingsFromPrefs = () => {
      this.restoreSettingsFromPrefs();
    };

    chrome.readingMode.languageChanged = () => {
      this.languageChanged();
    };

    chrome.readingMode.onLockScreen = () => {
      this.onLockScreen();
    };
  }

  private getOffsetInAncestor(node: Node): number {
    if (this.highlightedNodeToOffsetInParent.has(node)) {
      return this.highlightedNodeToOffsetInParent.get(node)!;
    }

    return 0;
  }

  private getHighlightedAncestorId_(node: Node): number|undefined {
    if (!node.parentElement || !node.parentNode) {
      return undefined;
    }

    let ancestor;
    if (node.parentElement.classList.contains(parentOfHighlightClass)) {
      ancestor = node.parentNode;
    } else if (node.parentElement.parentElement?.classList.contains(
                   parentOfHighlightClass)) {
      ancestor = node.parentNode.parentNode;
    }

    return ancestor ? this.domNodeToAxNodeIdMap_.get(ancestor) : undefined;
  }

  private buildSubtree_(nodeId: number): Node {
    let htmlTag = chrome.readingMode.getHtmlTag(nodeId);
    const dataAttributes = new Map<string, string>();

    // Text nodes do not have an html tag.
    if (!htmlTag.length) {
      return this.createTextNode_(nodeId);
    }

    // For Google Docs, we extract text from Annotated Canvas. The Annotated
    // Canvas elements with text are leaf nodes with <rect> html tag.
    if (chrome.readingMode.isGoogleDocs &&
        chrome.readingMode.isLeafNode(nodeId)) {
      return this.createTextNode_(nodeId);
    }

    // getHtmlTag might return '#document' which is not a valid to pass to
    // createElement.
    if (htmlTag === '#document') {
      htmlTag = 'div';
    }

    // Only one body tag is allowed per document.
    if (htmlTag === 'body') {
      htmlTag = 'div';
    }

    // Images will be written to a canvas.
    if (htmlTag === 'img') {
      htmlTag = 'canvas';
    }

    const url = chrome.readingMode.getUrl(nodeId);

    if (!this.shouldShowLinks() && htmlTag === 'a') {
      htmlTag = 'span';
      dataAttributes.set(linkDataAttribute, url ?? '');
    }

    const element = document.createElement(htmlTag);
    // Add required data attributes.
    for (const [attr, val] of dataAttributes) {
      element.dataset[attr] = val;
    }
    this.domNodeToAxNodeIdMap_.set(element, nodeId);
    const direction = chrome.readingMode.getTextDirection(nodeId);
    if (direction) {
      element.setAttribute('dir', direction);
    }

    if (element.nodeName === 'CANVAS') {
      this.imageNodeIdsToFetch_.add(nodeId);
      const altText = chrome.readingMode.getAltText(nodeId);
      element.setAttribute('alt', altText);
      element.classList.add('downloaded-image');
    }

    if (url && element.nodeName === 'A') {
      element.setAttribute('href', url);
      element.onclick = () => {
        chrome.readingMode.onLinkClicked(nodeId);
      };
    }
    const language = chrome.readingMode.getLanguage(nodeId);
    if (language) {
      element.setAttribute('lang', language);
    }

    this.appendChildSubtrees_(element, nodeId);
    return element;
  }

  // TODO(crbug.com/40910704): Potentially hide links during distillation.
  private shouldShowLinks(): boolean {
    // Links should only show when Read Aloud is paused.
    return chrome.readingMode.linksEnabled &&
        !this.speechPlayingState.isSpeechActive;
  }

  private appendChildSubtrees_(node: Node, nodeId: number) {
    for (const childNodeId of chrome.readingMode.getChildren(nodeId)) {
      const childNode = this.buildSubtree_(childNodeId);
      node.appendChild(childNode);
    }
  }

  private createTextNode_(nodeId: number): Node {
    // When creating text nodes, save the first text node id. We need this
    // node id to call InitAXPosition in playSpeech. If it's not saved here,
    // we have to retrieve it through a DOM search such as createTreeWalker,
    // which can be computationally expensive.
    if (!this.firstTextNodeSetForReadAloud) {
      this.firstTextNodeSetForReadAloud = nodeId;
      this.initializeSpeechTree();
    }

    const textContent = chrome.readingMode.getTextContent(nodeId);
    const textNode = document.createTextNode(textContent);
    this.domNodeToAxNodeIdMap_.set(textNode, nodeId);
    const isOverline = chrome.readingMode.isOverline(nodeId);
    let shouldBold = chrome.readingMode.shouldBold(nodeId);

    if (chrome.readingMode.isGoogleDocs) {
      const dataFontCss = chrome.readingMode.getDataFontCss(nodeId);
      if (dataFontCss) {
        const styleNode = document.createElement('style');
        styleNode.style.cssText = `font:${dataFontCss}`;
        if (styleNode.style.fontStyle === 'italic') {
          shouldBold = true;
        }
        const fontWeight = +styleNode.style.fontWeight;
        if (!isNaN(fontWeight) && fontWeight > 500) {
          shouldBold = true;
        }
      }
    }

    if (!shouldBold && !isOverline) {
      return textNode;
    }

    const htmlTag = shouldBold ? 'b' : 'span';
    const parentElement = document.createElement(htmlTag);
    if (isOverline) {
      parentElement.style.textDecoration = 'overline';
    }
    parentElement.appendChild(textNode);
    return parentElement;
  }

  showEmpty() {
    if (chrome.readingMode.isGoogleDocs) {
      this.emptyStateHeading_ = loadTimeData.getString('emptyStateHeader');
    } else {
      this.emptyStateHeading_ = loadTimeData.getString('notSelectableHeader');
    }
    this.emptyStateImagePath_ = './images/empty_state.svg';
    this.emptyStateDarkImagePath_ = './images/empty_state.svg';
    this.emptyStateSubheading_ = loadTimeData.getString('emptyStateSubheader');
    this.hasContent_ = false;
  }

  showLoading() {
    this.emptyStateImagePath_ = '//resources/images/throbber_small.svg';
    this.emptyStateDarkImagePath_ =
        '//resources/images/throbber_small_dark.svg';
    this.emptyStateHeading_ =
        loadTimeData.getString('readAnythingLoadingMessage');
    this.emptyStateSubheading_ = '';
    this.hasContent_ = false;
    if (this.isReadAloudEnabled_) {
      this.synth.cancel();
      this.clearReadAloudState();
    }
  }

  // TODO(crbug.com/40927698): Handle focus changes for speech, including
  // updating speech state.
  updateContent() {
    // Each time we rebuild the subtree, we should clear the node id of the
    // first text node.
    this.firstTextNodeSetForReadAloud = null;
    this.synth.cancel();
    this.clearReadAloudState();
    const container = this.$.container;

    // Remove all children from container. Use `replaceChildren` rather than
    // setting `innerHTML = ''` in order to remove all listeners, too.
    container.replaceChildren();
    this.domNodeToAxNodeIdMap_.clear();

    // Construct a dom subtree starting with the display root and append it to
    // the container. The display root may be invalid if there are no content
    // nodes and no selection.
    // This does not use polymer's templating abstraction, which
    // would create a shadow node element representing each AXNode, because
    // experimentation found the shadow node creation to be ~8-10x slower than
    // constructing and appending nodes directly to the container element.
    const rootId = chrome.readingMode.rootId;
    if (!rootId) {
      return;
    }

    this.willDrawAgainSoon_ = chrome.readingMode.requiresDistillation;
    const node = this.buildSubtree_(rootId);
    if (!node.textContent) {
      return;
    }

    if (this.previousRootId_ !== rootId) {
      this.previousRootId_ = rootId;
      this.logger_.logNewPage(/*speechPlayed=*/ false);
    }

    // Always load images even if they are disabled to ensure a fast response
    // when toggling.
    this.loadImages_();

    this.isDocsLoadMoreButtonVisible_ =
        chrome.readingMode.isDocsLoadMoreButtonVisible;

    container.scrollTop = 0;
    this.hasContent_ = true;
    container.appendChild(node);
    this.updateImages_();
  }

  async onImageDownloaded(nodeId: number) {
    const data = chrome.readingMode.getImageBitmap(nodeId);
    const element = this.domNodeToAxNodeIdMap_.keyFrom(nodeId);
    if (data && element && element instanceof HTMLCanvasElement) {
      element.width = data.width;
      element.height = data.height;
      const context = element.getContext('2d');
      // Context should not be null unless another was already requested.
      assert(context);
      const imgData = new ImageData(data.data, data.width);
      const bitmap = await createImageBitmap(imgData, {
        colorSpaceConversion: 'none',
        premultiplyAlpha: 'premultiply',
      });
      context.drawImage(bitmap, 0, 0);
    }
  }

  private sendGetVoicePackInfoRequest(langOrLocale: string) {
    const langOrLocaleForPackManager =
        convertLangOrLocaleForVoicePackManager(langOrLocale);
    if (langOrLocaleForPackManager) {
      chrome.readingMode.sendGetVoicePackInfoRequest(
          langOrLocaleForPackManager);
    }
  }

  private async loadImages_() {
    if (!chrome.readingMode.imagesFeatureEnabled) {
      return;
    }

    for (const nodeId of this.imageNodeIdsToFetch_) {
      chrome.readingMode.requestImageData(nodeId);
    }

    this.imageNodeIdsToFetch_.clear();
  }

  getSelection(): any {
    const selection = document.getSelection();
    return selection;
  }

  updateSelection() {
    const selection: Selection = this.getSelection()!;
    selection.removeAllRanges();

    const range = new Range();
    const startNodeId = chrome.readingMode.startNodeId;
    const endNodeId = chrome.readingMode.endNodeId;
    let startOffset = chrome.readingMode.startOffset;
    let endOffset = chrome.readingMode.endOffset;
    let startNode = this.domNodeToAxNodeIdMap_.keyFrom(startNodeId);
    let endNode = this.domNodeToAxNodeIdMap_.keyFrom(endNodeId);
    if (!startNode || !endNode) {
      return;
    }

    // Range.setStart/setEnd behaves differently if the node is an element or a
    // text node. If the former, the offset refers to the index of the children.
    // If the latter, the offset refers to the character offset inside the text
    // node. The start and end nodes are elements if they've been read aloud
    // because we add formatting to the text that wasn't there before. However,
    // the information we receive from chrome.readingMode is always the id of a
    // text node and character offset for that text, so find the corresponding
    // text child here and adjust the offset
    if (startNode.nodeType !== Node.TEXT_NODE) {
      const startTreeWalker =
          document.createTreeWalker(startNode, NodeFilter.SHOW_TEXT);
      while (startTreeWalker.nextNode()) {
        const textNodeLength = startTreeWalker.currentNode.textContent!.length;
        // Once we find the child text node inside which the starting index
        // fits, update the start node to be that child node and the adjusted
        // offset will be relative to this child node
        if (startOffset < textNodeLength) {
          startNode = startTreeWalker.currentNode;
          break;
        }

        startOffset -= textNodeLength;
      }
    }
    if (endNode.nodeType !== Node.TEXT_NODE) {
      const endTreeWalker =
          document.createTreeWalker(endNode, NodeFilter.SHOW_TEXT);
      while (endTreeWalker.nextNode()) {
        const textNodeLength = endTreeWalker.currentNode.textContent!.length;
        if (endOffset <= textNodeLength) {
          endNode = endTreeWalker.currentNode;
          break;
        }

        endOffset -= textNodeLength;
      }
    }

    // Gmail will try to select text when collapsing the node. At the same time,
    // the node contents are then shortened because of the collapse which causes
    // the range to go out of bounds. When this happens we should reset the
    // selection.
    try {
      range.setStart(startNode, startOffset);
      range.setEnd(endNode, endOffset);
    } catch (err) {
      selection.removeAllRanges();
      return;
    }

    selection.addRange(range);

    // Scroll the start node into view. ScrollIntoView is available on the
    // Element class.
    const startElement = startNode.nodeType === Node.ELEMENT_NODE ?
        startNode as Element :
        startNode.parentElement;
    if (!startElement) {
      return;
    }
    this.scrollingOnSelection_ = true;
    startElement.scrollIntoViewIfNeeded();
  }

  protected updateLinks_(shouldRehighlightCurrentNodes: boolean = true) {
    if (!this.shadowRoot) {
      return;
    }

    const originallyHadHighlights =
        this.shadowRoot
            .querySelectorAll<HTMLElement>('.' + currentReadHighlightClass)
            .length > 0;

    const selector = this.shouldShowLinks() ? 'span[data-link]' : 'a';
    const elements = this.shadowRoot.querySelectorAll(selector);

    for (const elem of elements) {
      assert(elem instanceof HTMLElement, 'link is not an HTMLElement');
      const nodeId = this.domNodeToAxNodeIdMap_.get(elem);
      assert(nodeId !== undefined, 'link node id is undefined');
      const replacement = this.buildSubtree_(nodeId);
      this.replaceElement(elem, replacement);
    }

    // Rehighlight the current granularity text after links have been
    // toggled on or off to ensure the entire granularity segment is
    // highlighted.
    if (shouldRehighlightCurrentNodes && originallyHadHighlights) {
      this.highlightCurrentGranularity(chrome.readingMode.getCurrentText());
    }
  }

  protected updateImages_() {
    this.imagesEnabled = chrome.readingMode.imagesEnabled;
    // There is some strange issue where the HTML css application does not work
    // on canvases.
    for (const canvas of document.querySelectorAll('canvas')) {
      canvas.style.display = chrome.readingMode.imagesEnabled ? '' : 'none';
    }
  }

  protected onDocsLoadMoreButtonClick_() {
    chrome.readingMode.onScrolledToBottom();
  }

  updateVoicePackStatusFromInstallResponse(lang: string, status: string) {
    if (!lang) {
      return;
    }

    const newVoicePackStatus = mojoVoicePackStatusToVoicePackStatusEnum(status);
    const oldVoicePackStatus = this.getVoicePackServerStatus_(lang);

    if (isVoicePackStatusError(newVoicePackStatus)) {
      // Keep the server responses.
      this.setVoicePackServerStatus_(lang, newVoicePackStatus);

      // Update application state.
      this.updateApplicationState(lang, newVoicePackStatus, oldVoicePackStatus);

      // Disable the associated language if there are no other Google voices for
      // it.
      const availableVoicesForLang = this.getVoices_().filter(
          v => getVoicePackConvertedLangIfExists(v.lang) === lang);
      if (availableVoicesForLang.length === 0 ||
          availableVoicesForLang.every(v => isEspeak(v))) {
        this.enabledLangs = this.enabledLangs.filter(
            enabledLang =>
                getVoicePackConvertedLangIfExists(enabledLang) !== lang);
      }
    } else {
      // Do not rely on the status from Install response. It has responded
      // "installed" for voices that are not installed. Instead, request the
      // status from GetVoicePackInfo. The result will be returned in
      // updateVoicePackStatus().
      this.sendGetVoicePackInfoRequest(lang);
    }
  }

  updateVoicePackStatus(lang: string, status: string) {
    if (!lang) {
      return;
    }

    const newVoicePackStatus = mojoVoicePackStatusToVoicePackStatusEnum(status);
    const oldVoicePackStatus = this.getVoicePackServerStatus_(lang);

    // Keep the server responses
    this.setVoicePackServerStatus_(lang, newVoicePackStatus);

    // Update application state
    this.updateApplicationState(lang, newVoicePackStatus, oldVoicePackStatus);
  }


  // Store client side voice pack state and trigger side effects
  private updateApplicationState(
      lang: string, newVoicePackStatus: VoicePackStatus,
      oldVoicePackStatus?: VoicePackStatus) {
    if (isVoicePackStatusSuccess(newVoicePackStatus)) {
      const newStatusCode = newVoicePackStatus.code;

      switch (newStatusCode) {
        case VoicePackServerStatusSuccessCode.NOT_INSTALLED:
          // Install the voice if it's not currently installed and it's marked
          // as a language that should be installed
          if (this.languagesForVoiceDownloads.has(lang)) {
            // Don't re-send install request if it's already been sent
            if (this.getVoicePackLocalStatus_(lang) !==
                VoiceClientSideStatusCode.SENT_INSTALL_REQUEST) {
              this.forceInstallRequest(lang, /* isRetry = */ false);
            }
          } else {
            this.setVoicePackLocalStatus(
                lang, VoiceClientSideStatusCode.NOT_INSTALLED);
          }
          break;
        case VoicePackServerStatusSuccessCode.INSTALLING:
          // Do nothing- we mark our local state as installing when we send the
          // request. Locally, we may time out a slow request and mark it as
          // errored, and we don't want to overwrite that state here.
          break;
        case VoicePackServerStatusSuccessCode.INSTALLED:
          // See if voice is newly downloaded and should have a toast notifying
          // the user.
          // If the old voice pack status is undefined, it means we haven't
          // received a server status yet. If we are now receiving an installed
          // status, and we were locally waiting for an install, then we know
          // the language is newly downloaded.
          if ((!oldVoicePackStatus &&
               isWaitingForInstallLocally(
                   this.getVoicePackLocalStatus_(lang))) ||
              (oldVoicePackStatus &&
               oldVoicePackStatus.code !==
                   VoicePackServerStatusSuccessCode.INSTALLED)) {
            const genericVoicePackLanguage =
                getVoicePackConvertedLangIfExists(lang);
            const exactVoicePackLanguage =
                convertLangOrLocaleToExactVoicePackLocale(
                    genericVoicePackLanguage);

            this.lastDownloadedLang_ = exactVoicePackLanguage ?
                exactVoicePackLanguage :
                genericVoicePackLanguage;

            // Force a refresh of the voices list since we might not get an
            // update the voices have changed.
            this.getVoices_(true);
            this.showToast_();
          }

          this.autoSwitchVoice_(lang);

          // Some languages may require a download from the voice pack
          // but may not have associated natural voices.
          const languageHasNaturalVoices = doesLanguageHaveNaturalVoices(lang);

          // Even though the voice may be installed on disk, it still may not be
          // available to the speechSynthesis API. Check whether to mark the
          // voice as AVAILABLE or INSTALLED_AND_UNAVAILABLE
          const voicesForLanguageAreAvailable = this.availableVoices_.some(
              voice =>
                  ((isNatural(voice) || !languageHasNaturalVoices) &&
                   getVoicePackConvertedLangIfExists(voice.lang) === lang));

          // If natural voices are currently available for the language or the
          // language does not support natural voices, set the status to
          // available. Otherwise, set the status to install and unavailabled.
          this.setVoicePackLocalStatus(
              lang,
              voicesForLanguageAreAvailable ?
                  VoiceClientSideStatusCode.AVAILABLE :
                  VoiceClientSideStatusCode.INSTALLED_AND_UNAVAILABLE);
          break;
        default:
          // This ensures the switch statement is exhaustive
          return newStatusCode satisfies never;
      }
    } else if (isVoicePackStatusError(newVoicePackStatus)) {
      this.autoSwitchVoice_(lang);
      const newStatusCode = newVoicePackStatus.code;

      switch (newStatusCode) {
        case VoicePackServerStatusErrorCode.OTHER:
        case VoicePackServerStatusErrorCode.WRONG_ID:
        case VoicePackServerStatusErrorCode.NEED_REBOOT:
        case VoicePackServerStatusErrorCode.UNSUPPORTED_PLATFORM:
          this.setVoicePackLocalStatus(
              lang, VoiceClientSideStatusCode.ERROR_INSTALLING);
          break;
        case VoicePackServerStatusErrorCode.ALLOCATION:
          this.setVoicePackLocalStatus(
              lang, VoiceClientSideStatusCode.INSTALL_ERROR_ALLOCATION);
          break;
        default:
          // This ensures the switch statement is exhaustive
          return newStatusCode satisfies never;
      }
    } else {
      // Couldn't parse the response
      this.setVoicePackLocalStatus(
          lang, VoiceClientSideStatusCode.ERROR_INSTALLING);
    }
  }


  protected getLanguageDownloadedTitle_() {
    const langDisplayName = this.getLangDisplayName(this.lastDownloadedLang_);

    return loadTimeData.getStringF(
        'readingModeVoiceDownloadedTitle', langDisplayName);
  }

  // TODO(b/325962407): replace toast with system notification
  private showToast_(): void {
    assert(this.shadowRoot);
    // Tests don't have menus and dialogs set up, no need to check
    const voiceSelectionMenu =
        this.$.toolbar.shadowRoot?.querySelector('voice-selection-menu');
    const languageMenu =
        voiceSelectionMenu?.shadowRoot?.querySelector('language-menu');
    const languageMenuToast =
        languageMenu?.shadowRoot?.querySelector('cr-dialog')
            ?.querySelector<CrToastElement>('#toast-in-dialog');

    const toast = languageMenuToast ||
        this.shadowRoot!.querySelector<CrToastElement>('#toast')!;
    assert(toast);

    if (toast.open) {
      toast.hide();
    }
    toast.show();
  }

  onVoicesChanged() {
    const previousSize = this.availableVoices_.length;
    // Get a new list of voices. This should be done before we call
    // refreshVoicePackStatuses();
    this.getVoices_(/*refresh =*/ true);

    if (this.shouldAttemptLanguageSettingsRestore && previousSize === 0 &&
        this.availableVoices_.length > 0) {
      // If we go from having no available voices to having voices available,
      // restore voice settings from preferences.
      this.restoreEnabledLanguagesFromPref();
      this.selectPreferredVoice();
    }

    // If voice was selected automatically and not by the user, check if
    // there's a higher quality voice available now.
    if (!this.currentVoiceIsUserChosen_()) {
      const naturalVoicesForLang = this.availableVoices_.filter(
          voice => isNatural(voice) &&
              voice.lang.startsWith(chrome.readingMode.baseLanguageForSpeech));

      if (naturalVoicesForLang) {
        this.selectedVoice_ = naturalVoicesForLang[0];
        this.resetSpeechPostSettingChange_();
      }
    }

    // Now that the voice list has changed, refresh the VoicePackStatuses in
    // case a language has been uninstalled.
    this.refreshVoicePackStatuses();

    // If the selected voice is now unavailable, such as after an uninstall,
    // reselect a new voice.
    if (this.selectedVoice_ &&
        !this.availableVoices_.some(
            voice => areVoicesEqual(voice, this.selectedVoice_!))) {
      this.selectedVoice_ = undefined;
    }

    if (!this.selectedVoice_) {
      this.getSpeechSynthesisVoice();
    }
  }

  getSpeechSynthesisVoice(): SpeechSynthesisVoice|undefined {
    if (!this.selectedVoice_) {
      this.selectedVoice_ = this.defaultVoice();
    }
    return this.selectedVoice_;
  }

  defaultVoice(): SpeechSynthesisVoice|undefined {
    const baseLang = this.speechSynthesisLanguage;
    const allPossibleVoices = this.getVoices_();
    const voicesForLanguage =
        allPossibleVoices.filter(voice => voice.lang.startsWith(baseLang));

    if (!voicesForLanguage || (voicesForLanguage.length === 0)) {
      // Stay with the current voice if no voices are available for this
      // language.
      return this.selectedVoice_ ? this.selectedVoice_ :
                                   getNaturalVoiceOrDefault(allPossibleVoices);
    }

    // First try to choose a voice only from currently enabled locales for this
    // language.
    const voicesForCurrentEnabledLocale = voicesForLanguage.filter(
        v => this.enabledLangs.includes(v.lang.toLowerCase()));
    if (!voicesForCurrentEnabledLocale ||
        !voicesForCurrentEnabledLocale.length) {
      // If there's no enabled locales for this language, check for any other
      // voices for enabled locales.
      const allVoicesForEnabledLocales = allPossibleVoices.filter(
          v => this.enabledLangs.includes(v.lang.toLowerCase()));
      if (!allVoicesForEnabledLocales.length) {
        // If there are no voices for the enabled locales, or no enabled
        // locales at all, we can't select a voice. So return undefined so we
        // can disable the play button.
        return undefined;
      } else {
        return getNaturalVoiceOrDefault(allVoicesForEnabledLocales);
      }
    }

    return getNaturalVoiceOrDefault(voicesForCurrentEnabledLocale);
  }

  // Attempt to get a new voice using the current language. In theory, the
  // previously unavailable voice should no longer be showing up in
  // getVoices, but we ensure that the alternative voice does not match
  // the previously unavailable voice as an extra measure. This method should
  // only be called when speech synthesis returns an error.
  getAlternativeVoice(unavailableVoice: SpeechSynthesisVoice|
                      undefined): SpeechSynthesisVoice|undefined {
    const newVoice = this.defaultVoice();

    // If the default voice is not the same as the original, unavailable voice,
    // use that, only if the new voice is also defined.
    if (newVoice !== undefined && !areVoicesEqual(newVoice, unavailableVoice)) {
      return newVoice;
    }

    // If the default voice won't work, try another voice in that language.
    const baseLang = this.speechSynthesisLanguage;
    const voicesForLanguage =
        this.getVoices_().filter(voice => voice.lang.startsWith(baseLang));

    // TODO(b/40927698): It's possible we can get stuck in an infinite loop
    // of jumping back and forth between two or more invalid voices, if
    // multiple voices are invalid. Investigate if we need to do more to handle
    // this case.

    // TODO(b/336596926): If there still aren't voices for the language,
    // attempt to fallback to the browser language, if we're using the page
    // language.
    if (!voicesForLanguage || (voicesForLanguage.length === 0)) {
      return undefined;
    }

    let voiceIndex = 0;
    while (voiceIndex < voicesForLanguage.length) {
      if (!areVoicesEqual(voicesForLanguage[voiceIndex], unavailableVoice)) {
        // Return another voice in the same language, ensuring we're not
        // returning the previously unavailable voice for extra safety.
        return voicesForLanguage[voiceIndex];
      }
      voiceIndex++;
    }

    // TODO(b/336596926): Handle language updates if there aren't any available
    // voices in the current language other than the unavailable voice.
    return undefined;
  }

  private getVoices_(refresh: boolean = false): SpeechSynthesisVoice[] {
    if (!this.availableVoices_.length || refresh) {
      this.availableVoices_ = getFilteredVoiceList(this.synth.getVoices());
      this.availableLangs_ =
          [...new Set(this.availableVoices_.map(({lang}) => lang))];

      this.populateDisplayNamesForLocaleCodes();
    }
    return this.availableVoices_;
  }

  private refreshVoicePackStatuses() {
    for (const lang of Object.keys(
             this.voicePackInstallStatusServerResponses_)) {
      this.sendGetVoicePackInfoRequest(lang);
    }
  }

  private getLangDisplayName(lang?: string): string {
    if (!lang) {
      return '';
    }
    const langLower = lang.toLowerCase();
    return this.localeToDisplayName_[langLower] || langLower;
  }

  private populateDisplayNamesForLocaleCodes() {
    this.localeToDisplayName_ = {};

    // Get display names for all the pack manager supported locales, only on
    // ChromeOS.
    if (chrome.readingMode.isChromeOsAsh) {
      AVAILABLE_GOOGLE_TTS_LOCALES.forEach((lang) => {
        this.maybeAddDisplayName(lang);
      });
    }

    // Get any remaining display names for languages of available voices.
    for (const {lang} of this.availableVoices_) {
      this.maybeAddDisplayName(lang);
    }
  }

  private maybeAddDisplayName(lang: string) {
    const langLower = lang.toLowerCase();
    if (!(langLower in this.localeToDisplayName_)) {
      const langDisplayName =
          chrome.readingMode.getDisplayNameForLocale(langLower, langLower);
      if (langDisplayName) {
        this.localeToDisplayName_ =
            {...this.localeToDisplayName_, [langLower]: langDisplayName};
      }
    }
  }

  private replaceElement(current: HTMLElement, replacer: Node) {
    const nodeId = this.domNodeToAxNodeIdMap_.get(current);
    assert(
        nodeId !== undefined,
        'trying to replace an element that doesn\'t exist');
    // Update map.
    this.domNodeToAxNodeIdMap_.delete(current);
    this.domNodeToAxNodeIdMap_.set(replacer, nodeId);
    // Replace element in DOM.
    current.replaceWith(replacer);
  }

  protected onPreviewVoice_(
      event: CustomEvent<{previewVoice: SpeechSynthesisVoice}>) {
    event.preventDefault();
    event.stopPropagation();

    this.stopSpeech(PauseActionSource.VOICE_PREVIEW);

    // If there's no previewVoice, return after stopping the current preview
    if (!event.detail) {
      this.previewVoicePlaying_ = undefined;
      return;
    }

    const defaultUtteranceSettings = this.defaultUtteranceSettings();
    const utterance = new SpeechSynthesisUtterance(
        loadTimeData.getString('readingModeVoicePreviewText'));
    const voice = event.detail.previewVoice;
    utterance.voice = voice;
    utterance.lang = defaultUtteranceSettings.lang;
    utterance.volume = defaultUtteranceSettings.volume;
    utterance.pitch = defaultUtteranceSettings.pitch;
    utterance.rate = defaultUtteranceSettings.rate;

    utterance.onstart = event => {
      this.previewVoicePlaying_ = event.utterance.voice || undefined;
    };

    utterance.onend = () => {
      this.previewVoicePlaying_ = undefined;
    };

    // TODO(b/40927698): There should probably be more sophisticated error
    // handling for voice previews, but for now, simply setting the preview
    // voice to null should be sufficient to reset state if an error is
    // encountered during a preview.
    utterance.onerror = () => {
      this.previewVoicePlaying_ = undefined;
    };

    this.synth.speak(utterance);
  }

  protected onVoiceMenuClose_(
      event: CustomEvent<{voicePlayingWhenMenuOpened: boolean}>) {
    event.preventDefault();
    event.stopPropagation();

    // TODO(b/323912186) Handle when menu is closed mid-preview and the user
    // presses play/pause button.
    if (!this.speechPlayingState.isSpeechActive &&
        event.detail.voicePlayingWhenMenuOpened) {
      this.playSpeech();
    }
  }

  protected onPlayPauseClick_() {
    if (this.speechPlayingState.isSpeechActive) {
      this.logSpeechPlaySession_();
      this.stopSpeech(PauseActionSource.BUTTON_CLICK);
    } else {
      this.playSessionStartTime = Date.now();
      this.playSpeech();
    }
  }

  stopSpeech(pauseSource: PauseActionSource) {
    this.speechPlayingState = {
      ...this.speechPlayingState,
      isSpeechActive: false,
      isAudioCurrentlyPlaying: false,
      pauseSource,
    };

    const pausedFromButton = pauseSource === PauseActionSource.BUTTON_CLICK;

    // Voice and speed changes take effect on the next call of synth.play(),
    // but not on .resume(). In order to be responsive to the user's settings
    // changes, we call synth.cancel() and synth.play(). However, we don't do
    // synth.cancel() and synth.play() when user clicks play/pause button,
    // because synth.cancel() and synth.play() plays from the beginning of the
    // current utterance, even if parts of it had been spoken already.
    // Therefore, when a user toggles the play/pause button, we call
    // synth.pause() and synth.resume() for speech to resume from where it left
    // off.
    if (pausedFromButton) {
      this.synth.pause();
    } else {
      // Canceling clears all the Utterances that are queued up via synth.play()
      this.synth.cancel();
    }

    // Restore links if they're enabled when speech pauses. Don't restore links
    // if it's paused from a non-pause button (e.g. voice previews) so the links
    // don't flash off and on.
    if (chrome.readingMode.linksEnabled && pausedFromButton) {
      this.updateLinks_();
    }
  }

  private logSpeechPlaySession_() {
    // Don't log a playback session just in case something has gotten out of
    // sync and we call stopSpeech before playSpeech.
    if (this.playSessionStartTime > 0) {
      this.logger_.logSpeechPlaySession(
          this.playSessionStartTime, this.selectedVoice_);
      this.playSessionStartTime = -1;
    }
  }

  protected playNextGranularity_() {
    this.synth.cancel();
    this.resetPreviousHighlight_();
    // Reset the word boundary index whenever we move the granularity position.
    this.resetToDefaultWordBoundaryState();
    chrome.readingMode.movePositionToNextGranularity();

    if (!this.highlightAndPlayMessage()) {
      this.onSpeechFinished();
    }
  }

  protected playPreviousGranularity_() {
    this.synth.cancel();
    // This must be called BEFORE calling
    // chrome.readingMode.movePositionToPreviousGranularity so we can accurately
    // determine what's currently being highlighted.
    this.resetPreviousHighlightAndRemoveCurrentHighlight();
    // Reset the word boundary index whenever we move the granularity position.
    this.resetToDefaultWordBoundaryState();
    chrome.readingMode.movePositionToPreviousGranularity();

    if (!this.highlightAndPlayMessage()) {
      this.onSpeechFinished();
    }
  }

  playSpeech() {
    const container = this.$.container;
    const {anchorNode, anchorOffset, focusNode, focusOffset} =
        this.getSelection();
    const hasSelection =
        anchorNode !== focusNode || anchorOffset !== focusOffset;
    if (this.speechPlayingState.hasSpeechBeenTriggered &&
        !this.speechPlayingState.isSpeechActive) {
      const pausedFromButton = this.speechPlayingState.pauseSource ===
          PauseActionSource.BUTTON_CLICK;

      let playedFromSelection = false;
      if (hasSelection) {
        this.synth.cancel();
        this.resetToDefaultWordBoundaryState();
        playedFromSelection = this.playFromSelection();
      }

      if (!playedFromSelection) {
        if (pausedFromButton &&
            this.wordBoundaryState.mode !==
                WordBoundaryMode.BOUNDARY_DETECTED) {
          // If word boundaries aren't supported for the given voice, we should
          // still continue to use synth.resume, as this is preferable to
          // restarting the current message.
          this.synth.resume();
        } else {
          this.synth.cancel();
          if (!this.highlightAndPlayInterruptedMessage()) {
            // Ensure we're updating Read Aloud state if there's no text to
            // speak.
            this.onSpeechFinished();
          }
        }
      }

      this.speechPlayingState = {
        isSpeechTreeInitialized:
            this.speechPlayingState.isSpeechTreeInitialized,
        isSpeechActive: true,
        isAudioCurrentlyPlaying:
            this.speechPlayingState.isAudioCurrentlyPlaying,
        hasSpeechBeenTriggered: this.speechPlayingState.hasSpeechBeenTriggered,
      };

      // Hide links when speech resumes. We only hide links when the page was
      // paused from the play/pause button.
      if (chrome.readingMode.linksEnabled && pausedFromButton) {
        // Toggle links and ensure that the new nodes are also highlighted.
        this.updateLinks_(
            /* shouldRehiglightCurrentNodes= */ !playedFromSelection);
      }

      // If the current read highlight has been cleared from a call to
      // updateContent, such as via a preference change, rehighlight the nodes
      // after a pause.
      if (!playedFromSelection &&
          !container.querySelector('.' + currentReadHighlightClass)) {
        this.highlightCurrentGranularity(chrome.readingMode.getCurrentText());
      }

      return;
    }
    if (container.textContent) {
      // Log that we're playing speech on a new page, but not when resuming.
      // This helps us compare how many reading mode pages are opened with
      // speech played and without speech played. Counting resumes would
      // inflate the speech played number.
      this.logger_.logNewPage(/*speechPlayed=*/ true);
      this.speechPlayingState = {
        isSpeechTreeInitialized:
            this.speechPlayingState.isSpeechTreeInitialized,
        isSpeechActive: true,
        isAudioCurrentlyPlaying:
            this.speechPlayingState.isAudioCurrentlyPlaying,
        hasSpeechBeenTriggered: true,
      };
      // Hide links when speech begins playing.
      if (chrome.readingMode.linksEnabled) {
        this.updateLinks_();
      }

      const playedFromSelection = hasSelection && this.playFromSelection();
      if (!playedFromSelection && this.firstTextNodeSetForReadAloud) {
        if (!this.speechPlayingState.isSpeechTreeInitialized) {
          this.initializeSpeechTree();
        }
        if (!this.highlightAndPlayMessage()) {
          // Ensure we're updating Read Aloud state if there's no text to speak.
          this.onSpeechFinished();
        }
      }
    }
  }

  initializeSpeechTree() {
    if (this.firstTextNodeSetForReadAloud) {
      // TODO(crbug.com/40927698): There should be a way to use AXPosition so
      // that this step can be skipped.
      chrome.readingMode.initAxPositionWithNode(
          this.firstTextNodeSetForReadAloud);
      this.speechPlayingState = {
        isAudioCurrentlyPlaying:
            this.speechPlayingState.isAudioCurrentlyPlaying,
        isSpeechActive: this.speechPlayingState.isSpeechActive,
        isSpeechTreeInitialized: true,
        hasSpeechBeenTriggered: this.speechPlayingState.hasSpeechBeenTriggered,
      };

      this.preprocessTextForSpeech();
    }
  }

  async preprocessTextForSpeech() {
    chrome.readingMode.preprocessTextForSpeech();
  }

  private getSelectedIds(): {
    anchorNodeId: number|undefined,
    anchorOffset: number,
    focusNodeId: number|undefined,
    focusOffset: number,
  } {
    const {anchorNode, anchorOffset, focusNode, focusOffset} =
        this.getSelection();
    let anchorNodeId = this.domNodeToAxNodeIdMap_.get(anchorNode);
    let focusNodeId = this.domNodeToAxNodeIdMap_.get(focusNode);
    let adjustedAnchorOffset = anchorOffset;
    let adjustedFocusOffset = focusOffset;
    if (!anchorNodeId) {
      anchorNodeId = this.getHighlightedAncestorId_(anchorNode);
      adjustedAnchorOffset += this.getOffsetInAncestor(anchorNode);
    }
    if (!focusNodeId) {
      focusNodeId = this.getHighlightedAncestorId_(focusNode);
      adjustedFocusOffset += this.getOffsetInAncestor(focusNode);
    }
    return {
      anchorNodeId: anchorNodeId,
      anchorOffset: adjustedAnchorOffset,
      focusNodeId: focusNodeId,
      focusOffset: adjustedFocusOffset,
    };
  }

  playFromSelection(): boolean {
    const selection = this.getSelection();
    if (!this.firstTextNodeSetForReadAloud || !selection) {
      return false;
    }

    const {anchorNodeId, anchorOffset, focusNodeId, focusOffset} =
        this.getSelectedIds();
    // If only one of the ids is present, use that one.
    let startingNodeId: number|undefined =
        anchorNodeId ? anchorNodeId : focusNodeId;
    let startingOffset = anchorNodeId ? anchorOffset : focusOffset;
    // If both are present, start with the node that is sooner in the page.
    if (anchorNodeId && focusNodeId) {
      const pos =
          selection.anchorNode.compareDocumentPosition(selection.focusNode);
      const focusIsFirst = pos === Node.DOCUMENT_POSITION_PRECEDING;
      startingNodeId = focusIsFirst ? focusNodeId : anchorNodeId;
      startingOffset = focusIsFirst ? focusOffset : anchorOffset;
    }

    if (!startingNodeId) {
      return false;
    }

    // Clear the selection so we don't keep trying to play from the same
    // selection every time they press play.
    selection.removeAllRanges();
    // Iterate through the page from the beginning until we get to the
    // selection. This is so clicking previous works before the selection and
    // so the previous highlights are properly set.
    chrome.readingMode.resetGranularityIndex();

    // Iterate through the nodes asynchronously so that we can show the spinner
    // in the toolbar while we move up to the selection.
    setTimeout(() => {
      this.movePlaybackToNode_(startingNodeId, startingOffset);
      // Set everything to previous and then play the next granularity, which
      // includes the selection.
      this.resetPreviousHighlight_();
      if (!this.highlightAndPlayMessage()) {
        this.onSpeechFinished();
      }
    }, playFromSelectionTimeout);

    return true;
  }

  private movePlaybackToNode_(nodeId: number, offset: number): void {
    let currentTextIds = chrome.readingMode.getCurrentText();
    let hasCurrentText = currentTextIds.length > 0;
    // Since a node could spread across multiple granularities, we use the
    // offset to determine if the selected text is in this granularity or if
    // we have to move to the next one.
    let startOfSelectionIsInCurrentText = currentTextIds.includes(nodeId) &&
        chrome.readingMode.getCurrentTextEndIndex(nodeId) > offset;
    while (hasCurrentText && !startOfSelectionIsInCurrentText) {
      this.highlightCurrentGranularity(
          currentTextIds, /*scrollIntoView=*/ false);
      chrome.readingMode.movePositionToNextGranularity();
      currentTextIds = chrome.readingMode.getCurrentText();
      hasCurrentText = currentTextIds.length > 0;
      startOfSelectionIsInCurrentText = currentTextIds.includes(nodeId) &&
          chrome.readingMode.getCurrentTextEndIndex(nodeId) > offset;
    }
  }

  highlightAndPlayInterruptedMessage(): boolean {
    return this.highlightAndPlayMessage(/* isInterrupted = */ true);
  }

  // Play text of these axNodeIds. When finished, read and highlight to read the
  // following text.
  // TODO (crbug.com/1474951): Investigate using AXRange.GetText to get text
  // between start node / end nodes and their offsets.
  highlightAndPlayMessage(isInterrupted: boolean = false): boolean {
    // getCurrentText gets the AX Node IDs of text that should be spoken and
    // highlighted.
    const axNodeIds: number[] = chrome.readingMode.getCurrentText();

    // If there aren't any valid ax node ids returned by getCurrentText,
    // speech should stop.
    if (axNodeIds.length === 0) {
      return false;
    }

    const utteranceText = this.extractTextOf(axNodeIds);
    // If node ids were returned but they don't exist in the Reading Mode panel,
    // there's been a mismatch between Reading Mode and Read Aloud. In this
    // case, we should move to the next Read Aloud node and attempt to continue
    // playing.
    if (!utteranceText) {
      // TODO(b/332694565): This fallback should never be needed, but it is.
      // Investigate root cause of Read Aloud / Reading Mode mismatch.
      chrome.readingMode.movePositionToNextGranularity();
      return this.highlightAndPlayMessage(isInterrupted);
    }

    // The TTS engine may not like attempts to speak whitespace, so move to the
    // next utterance.
    if (utteranceText.trim().length === 0) {
      chrome.readingMode.movePositionToNextGranularity();
      return this.highlightAndPlayMessage(isInterrupted);
    }

    // If we're resuming a previously interrupted message, use word
    // boundaries (if available) to resume at the beginning of the current
    // word.
    if (isInterrupted &&
        this.wordBoundaryState.mode === WordBoundaryMode.BOUNDARY_DETECTED) {
      const substringIndex = this.wordBoundaryState.previouslySpokenIndex +
          this.wordBoundaryState.speechUtteranceStartIndex;
      this.wordBoundaryState.previouslySpokenIndex = 0;
      this.wordBoundaryState.speechUtteranceStartIndex = substringIndex;
      const utteranceTextForWordBoundary =
          utteranceText.substring(substringIndex);
      // Don't use the word boundary if it's going to cause a TTS engine issue.
      if (utteranceTextForWordBoundary.trim().length === 0) {
        this.playText(utteranceText);
      } else {
        this.playText(utteranceText.substring(substringIndex));
      }
    } else {
      this.playText(utteranceText);
    }

    this.highlightCurrentGranularity(axNodeIds);
    return true;
  }

  // Highlights or rehighlights the current granularity, sentence or word.
  highlightCurrentGranularity(
      axNodeIds: number[], scrollIntoView: boolean = true) {
    if (this.wordBoundaryState.mode ===
            WordBoundaryMode.BOUNDARIES_NOT_SUPPORTED ||
        !this.shouldUseWordHighlighting()) {
      this.highlightCurrentSentence(axNodeIds, scrollIntoView);
    } else {
      this.highlightCurrentWord();
    }
  }

  // Gets the accessible text boundary for the given string.
  getAccessibleTextLength(utteranceText: string): number {
    // Splicing on commas won't work for all locales, but since this is a
    // simple strategy for splicing text in languages that do use commas
    // that reduces the need for calling getAccessibleBoundary.
    // TODO(crub.com/1474951): Investigate if we can utilize comma splices
    // directly in the utils methods called by #getAccessibleBoundary.
    const lastCommaIndex =
        utteranceText.substring(0, this.maxSpeechLength).lastIndexOf(',');

    // To prevent infinite looping, only use the lastCommaIndex if it's not the
    // first character. Otherwise, use getAccessibleBoundary to prevent
    // repeatedly splicing on the first comma of the same substring.
    if (lastCommaIndex > 0) {
      return lastCommaIndex;
    }

    // TODO(crbug.com/40927698): getAccessibleBoundary breaks on the nearest
    // word boundary, but if there's some type of punctuation (such as a comma),
    // it would be preferable to break on the punctuation so the pause in
    // speech sounds more natural.
    return chrome.readingMode.getAccessibleBoundary(
        utteranceText, this.maxSpeechLength);
  }

  private playText(utteranceText: string) {
    // This check is needed due limits of TTS audio for remote voices. See
    // crbug.com/1176078 for more details.
    // Since the TTS bug only impacts remote voices, no need to check for
    // maximum text length if we're using a local voice. If we do somehow
    // attempt to speak text that's too long, this will be able to be handled
    // by listening for a text-too-long error in message.onerror.
    const isTextTooLong = this.selectedVoice_?.localService ?
        false :
        utteranceText.length > this.maxSpeechLength;
    const endBoundary = isTextTooLong ?
        this.getAccessibleTextLength(utteranceText) :
        utteranceText.length;
    this.playTextWithBoundaries(utteranceText, isTextTooLong, endBoundary);
  }

  private playTextWithBoundaries(
      utteranceText: string, isTextTooLong: boolean, endBoundary: number) {
    const message =
        new SpeechSynthesisUtterance(utteranceText.substring(0, endBoundary));

    message.onerror = (error) => {
      // We can't be sure that the engine has loaded at this point, but
      // if there's an error, we want to ensure we keep the play buttons
      // to prevent trapping users in a state where they can no longer play
      // Read Aloud, as this is preferable to a long delay before speech
      // with no feedback.
      this.speechEngineLoaded_ = true;

      if (error.error === 'interrupted') {
        // SpeechSynthesis.cancel() was called, therefore, do nothing.
        return;
      }

      // Log a speech error. We aren't concerned with logging an interrupted
      // error, since that can be triggered from play / pause.
      this.logger_.logSpeechError(error.error);

      if (error.error === 'text-too-long') {
        // This is unlikely to happen, as the length limit on most voices
        // is quite long. However, if we do hit a limit, we should just use
        // the accessible text length boundaries to shorten the text. Even
        // if this gives a much smaller sentence than TTS would have supported,
        // this is still preferable to no speech.
        this.synth.cancel();
        this.playTextWithBoundaries(
            utteranceText, true, this.getAccessibleTextLength(utteranceText));
        return;
      }
      if (error.error === 'invalid-argument') {
        // invalid-argument can be triggered when the rate, pitch, or volume
        // is not supported by the synthesizer. Since we're only setting the
        // speech rate, update the speech rate to the WebSpeech default of 1.
        chrome.readingMode.onSpeechRateChange(1);
        this.resetSpeechPostSettingChange_();
      }

      // No appropriate voice is available for the language designated in
      // SpeechSynthesisUtterance lang.
      if (error.error === 'language-unavailable') {
        const possibleNewLanguage = convertLangToAnAvailableLangIfPresent(
            this.speechSynthesisLanguage, this.availableLangs_,
            /* allowCurrentLanguageIfExists */ false);
        if (possibleNewLanguage) {
          this.speechSynthesisLanguage = possibleNewLanguage;
        }
      }

      // The voice designated in SpeechSynthesisUtterance voice attribute
      // is not available.
      if (error.error === 'voice-unavailable') {
        let newVoice = this.selectedVoice_ ? this.selectedVoice_ : undefined;
        this.selectedVoice_ = undefined;
        newVoice = this.getAlternativeVoice(newVoice);

        if (newVoice) {
          this.selectedVoice_ = newVoice;
        }
      }

      // When we hit an error, stop speech to clear all utterances, update the
      // button state, and highlighting in order to give visual feedback that
      // something went wrong.
      // TODO(b/40927698: Consider showing an error message.
      this.stopSpeech(PauseActionSource.DEFAULT);
    };

    message.addEventListener('boundary', (event) => {
      // Some voices may give sentence boundaries, but we're only concerned
      // with word boundaries in boundary event because we're speaking text at
      // the sentence granularity level, so we'll retrieve these boundaries in
      // message.onEnd instead.
      if (event.name === 'word') {
        this.updateBoundary(event.charIndex);

        // Only update the highlighting with word highlights if they should be
        // used.
        if (this.shouldUseWordHighlighting()) {
          this.highlightCurrentWord();
        }
      }
    });

    message.onstart = () => {
      // We've gotten the signal that the speech engine has loaded, therefore
      // we can enable the Read Aloud buttons.
      this.speechEngineLoaded_ = true;

      if (!this.speechPlayingState.isAudioCurrentlyPlaying) {
        this.speechPlayingState = {
          ...this.speechPlayingState,
          isAudioCurrentlyPlaying: true,
        };
      }
    };

    message.onend = () => {
      if (isTextTooLong) {
        // Since our previous utterance was too long, continue speaking pieces
        // of the current utterance until the utterance is complete. The entire
        // utterance is highlighted, so there's no need to update highlighting
        // until the utterance substring is an acceptable size.
        this.playText(utteranceText.substring(endBoundary));
        return;
      }

      // Now that we've finiished reading this utterance, update the Granularity
      // state to point to the next one
      // Reset the word boundary index whenever we move the granularity
      // position.
      this.resetToDefaultWordBoundaryState();
      chrome.readingMode.movePositionToNextGranularity();
      // Continue speaking with the next block of text.
      if (!this.highlightAndPlayMessage()) {
        this.onSpeechFinished();
      }
    };

    const voice = this.getSpeechSynthesisVoice();
    if (!voice) {
      // TODO(crbug.com/40927698): Handle when no voices are available.
      return;
    }

    // This should only be false in tests where we can't properly construct an
    // actual SpeechSynthesisVoice object even though the test voices pass the
    // type checking of method signatures.
    if (voice instanceof SpeechSynthesisVoice) {
      message.voice = voice;
    }

    const utteranceSettings = this.defaultUtteranceSettings();
    message.lang = utteranceSettings.lang;
    message.volume = utteranceSettings.volume;
    message.pitch = utteranceSettings.pitch;
    message.rate = utteranceSettings.rate;


    if (!this.firstUtteranceSpoken_) {
      this.speechEngineLoaded_ = false;
      this.firstUtteranceSpoken_ = true;
    }
    this.synth.speak(message);
  }

  updateBoundary(charIndex: number) {
    this.wordBoundaryState.previouslySpokenIndex = charIndex;
    this.wordBoundaryState.mode = WordBoundaryMode.BOUNDARY_DETECTED;
  }

  resetToDefaultWordBoundaryState(
      possibleWordBoundarySupportChange: boolean = false) {
    this.wordBoundaryState = {
      previouslySpokenIndex: 0,
      // If a boundary has been detected, the mode should be reset to
      // NO_BOUNDARIES instead of BOUNDARIES_NOT_SUPPORTED because we know word
      // boundaries are supported- we just need to clear the current boundary
      // state. This allows us to highlight the next word at the start of a
      // sentence when playback state changes.
      // However, if there's been a change that potentially impacts if word
      // boundaries are supported (such as changing the voice), we should
      // reset to BOUNDARIES_NOT_SUPPORTED because we don't know yet if word
      // boundaries are supported for this voice.
      mode: ((this.wordBoundaryState.mode ===
              WordBoundaryMode.BOUNDARY_DETECTED) &&
             !possibleWordBoundarySupportChange) ?
          WordBoundaryMode.NO_BOUNDARIES :
          WordBoundaryMode.BOUNDARIES_NOT_SUPPORTED,
      speechUtteranceStartIndex: 0,
    };
  }

  private extractTextOf(axNodeIds: number[]): string {
    let utteranceText: string = '';
    for (let i = 0; i < axNodeIds.length; i++) {
      assert(axNodeIds[i], 'trying to get text from an undefined node id');
      const nodeId = axNodeIds[i];
      const startIndex = chrome.readingMode.getCurrentTextStartIndex(nodeId);
      const endIndex = chrome.readingMode.getCurrentTextEndIndex(nodeId);
      const element = this.domNodeToAxNodeIdMap_.keyFrom(nodeId);
      if (!element || startIndex < 0 || endIndex < 0) {
        continue;
      }
      const content = chrome.readingMode.getTextContent(nodeId).substring(
          startIndex, endIndex);
      if (content) {
        // Add all of the text from the current nodes into a single utterance.
        utteranceText += content;
      }
    }
    return utteranceText;
  }

  // TODO(b/301131238): Verify all edge cases.
  highlightCurrentWord() {
    // Word highlights can be called quite frequently which can create some
    // misordering, so just make sure we've cleared the previous word highlight
    // before showing the next one.
    this.removeCurrentHighlight();
    const index = this.wordBoundaryState.speechUtteranceStartIndex +
        this.wordBoundaryState.previouslySpokenIndex;
    const highlightNodes =
        chrome.readingMode.getHighlightForCurrentSegmentIndex(index);
    let anyHighlighted: boolean = false;
    for (let i = 0; i < highlightNodes.length; i++) {
      const highlightNode = highlightNodes[i].nodeId;
      const highlightLength: number = highlightNodes[i].length;
      const highlightStartIndex = highlightNodes[i].start;
      const endIndex = highlightStartIndex + highlightLength;
      const element = this.domNodeToAxNodeIdMap_.keyFrom(highlightNode);
      if (!element ||
          isInvalidHighlightForWordHighlighting(
              element.textContent?.substring(highlightStartIndex, endIndex)
                  .trim())) {
        continue;
      }
      anyHighlighted = true;
      this.highlightCurrentText_(
          highlightStartIndex, endIndex, element as HTMLElement);
    }
    if (anyHighlighted) {
      // Only scroll if at least one node was highlighted.
      this.scrollHighlightIntoView();
    }
  }

  highlightCurrentSentence(
      nextTextIds: number[], scrollIntoView: boolean = true) {
    if (nextTextIds.length === 0) {
      return;
    }

    this.resetPreviousHighlight_();
    for (let i = 0; i < nextTextIds.length; i++) {
      const nodeId = nextTextIds[i];
      const element = this.domNodeToAxNodeIdMap_.keyFrom(nodeId) as HTMLElement;
      if (!element) {
        continue;
      }
      const start = chrome.readingMode.getCurrentTextStartIndex(nodeId);
      const end = chrome.readingMode.getCurrentTextEndIndex(nodeId);
      if ((start < 0) || (end < 0)) {
        // If the start or end index is invalid, don't use this node.
        continue;
      }
      this.highlightCurrentText_(start, end, element);
    }

    if (!scrollIntoView) {
      return;
    }

    this.scrollHighlightIntoView();
  }

  private scrollHighlightIntoView() {
    // Ensure all the current highlights are in view.
    // TODO: b/40927698 - Handle if the highlight is longer than the full height
    // of the window (e.g. when font size is very large). Possibly using word
    // boundaries to know when we've reached the bottom of the window and need
    // to scroll so the rest of the current highlight is showing.
    assert(this.shadowRoot);
    const currentHighlights = this.shadowRoot!.querySelectorAll<HTMLElement>(
        '.' + currentReadHighlightClass);
    if (!currentHighlights) {
      return;
    }
    const firstHighlight = currentHighlights.item(0);
    const lastHighlight = currentHighlights.item(currentHighlights.length - 1);
    const highlightBottom = lastHighlight.getBoundingClientRect().bottom;
    const highlightTop = firstHighlight.getBoundingClientRect().top;
    const highlightHeight = highlightBottom - highlightTop;
    if (highlightHeight > (window.innerHeight / 2)) {
      // If the bottom of the highlight would be offscreen if we center it,
      // scroll the first highlight to the top instead of centering it.
      firstHighlight.scrollIntoView({block: 'start'});
    } else if ((highlightBottom > window.innerHeight) || (highlightTop < 0)) {
      // Otherwise center the current highlight if part of it would be cut off.
      firstHighlight.scrollIntoView({block: 'center'});
    }
  }

  private defaultUtteranceSettings(): UtteranceSettings {
    const lang = this.speechSynthesisLanguage;

    return {
      lang,
      // TODO(crbug.com/40927698): Ensure the rate is valid for the current
      // speech engine.
      rate: getCurrentSpeechRate(),
      volume: 1,
      pitch: 1,
    };
  }

  // The following results in
  // <span>
  //   <span class="previous-read-highlight"> prefix text </span>
  //   <span class="current-read-highlight"> highlighted text </span>
  //   suffix text
  // </span>
  private highlightCurrentText_(
      highlightStart: number, highlightEnd: number,
      currentNode: HTMLElement): void {
    const parentOfHighlight = document.createElement('span');
    parentOfHighlight.classList.add(parentOfHighlightClass);

    // First pull out any text within this node before the highlighted section.
    // Since it's already been highlighted, we fade it out.
    const highlightPrefix =
        currentNode.textContent!.substring(0, highlightStart);
    if (highlightPrefix.length > 0) {
      const prefixNode = document.createElement('span');
      prefixNode.classList.add(previousReadHighlightClass);
      prefixNode.textContent = highlightPrefix;
      this.previousHighlights_.push(prefixNode);
      parentOfHighlight.appendChild(prefixNode);
    }

    // Then get the section of text to highlight and mark it for
    // highlighting.
    const readingHighlight = document.createElement('span');
    readingHighlight.classList.add(currentReadHighlightClass);
    const textNode = document.createTextNode(
        currentNode.textContent!.substring(highlightStart, highlightEnd));
    readingHighlight.appendChild(textNode);
    this.highlightedNodeToOffsetInParent.set(textNode, highlightStart);
    parentOfHighlight.appendChild(readingHighlight);

    // Finally, append the rest of the text for this node that has yet to be
    // highlighted.
    const highlightSuffix = currentNode.textContent!.substring(highlightEnd);
    if (highlightSuffix.length > 0) {
      const suffixNode = document.createTextNode(highlightSuffix);
      this.highlightedNodeToOffsetInParent.set(suffixNode, highlightEnd);
      parentOfHighlight.appendChild(suffixNode);
    }

    // Replace the current node in the tree with the split up version of the
    // node.
    this.previousHighlights_.push(readingHighlight);
    this.replaceElement(currentNode, parentOfHighlight);
  }

  private onSpeechFinished() {
    this.clearReadAloudState();

    // Show links when speech finishes playing.
    if (chrome.readingMode.linksEnabled) {
      this.updateLinks_();
    }
    // Clear the formatting we added for highlighting.
    this.updateContent();
    this.logSpeechPlaySession_();
  }

  private clearReadAloudState() {
    this.speechPlayingState = {
      isSpeechActive: false,
      pauseSource: PauseActionSource.DEFAULT,
      isSpeechTreeInitialized: false,
      isAudioCurrentlyPlaying: false,
      hasSpeechBeenTriggered: false,
    };
    this.previousHighlights_ = [];
    this.resetToDefaultWordBoundaryState();
  }

  private shouldUseWordHighlighting(): boolean {
    // Word highlighting should only be used for speech rates less than or
    // equal to 1x speed. It should be skipped on espeak voices, since espeak
    // boundaries are different than Google TTS word boundaries.
    return chrome.readingMode.isAutomaticWordHighlightingEnabled &&
        getCurrentSpeechRate() <= 1 && !isEspeak(this.selectedVoice_);
  }

  protected onSelectVoice_(
      event: CustomEvent<{selectedVoice: SpeechSynthesisVoice}>) {
    event.preventDefault();
    event.stopPropagation();

    let localesAreIdentical = false;
    if (this.selectedVoice_) {
      localesAreIdentical = this.selectedVoice_.lang.toLowerCase() ===
          event.detail.selectedVoice.lang.toLowerCase();
    }

    this.selectedVoice_ = event.detail.selectedVoice;
    chrome.readingMode.onVoiceChange(
        this.selectedVoice_.name, this.selectedVoice_.lang);

    // If the locales are identical, the voices are likely from the same
    // voice pack and use the same TTS engine, therefore, we don't need
    // to reset the word boundary state.
    if (!localesAreIdentical) {
      this.resetToDefaultWordBoundaryState(
          /*possibleWordBoundarySupportChange=*/ true);
    }

    this.resetSpeechPostSettingChange_();
  }

  protected onVoiceLanguageToggle_(event: CustomEvent<{language: string}>) {
    event.preventDefault();
    event.stopPropagation();
    const toggledLanguage = event.detail.language;
    const currentlyEnabled = this.enabledLangs.includes(toggledLanguage);

    if (!currentlyEnabled) {
      this.autoSwitchVoice_(toggledLanguage);
      this.installVoicePackIfPossible(
          toggledLanguage, /* onlyInstallExactGoogleLocaleMatch=*/ true,
          /* retryIfPreviousInstallFailed= */ true);
    } else {
      // If the language has been deselected, remove the language from the list
      // of language packs to download
      const langCodeForVoicePackManager =
          convertLangOrLocaleForVoicePackManager(toggledLanguage);
      if (langCodeForVoicePackManager) {
        this.languagesForVoiceDownloads.delete(langCodeForVoicePackManager);
      }
    }
    this.enabledLangs = currentlyEnabled ?
        this.enabledLangs.filter(lang => lang !== toggledLanguage) :
        [...this.enabledLangs, toggledLanguage];

    chrome.readingMode.onLanguagePrefChange(toggledLanguage, !currentlyEnabled);

    if (!currentlyEnabled && !this.selectedVoice_) {
      // If there were no enabled languages (and thus no selected voice), select
      // a voice.
      this.getSpeechSynthesisVoice();
    }
  }

  protected resetSpeechPostSettingChange_() {
    // Don't call stopSpeech() if the speech tree hasn't been initialized or
    // if speech hasn't been triggered yet.
    if (!this.speechPlayingState.isSpeechTreeInitialized ||
        !this.speechPlayingState.hasSpeechBeenTriggered) {
      return;
    }

    const playSpeechOnChange = this.speechPlayingState.isSpeechActive;

    // Cancel the queued up Utterance using the old speech settings
    this.stopSpeech(PauseActionSource.VOICE_SETTINGS_CHANGE);

    // If speech was playing when a setting was changed, continue playing speech
    if (playSpeechOnChange) {
      this.playSpeech();
    }
  }

  // This must be called BEFORE calling
  // chrome.readingMode.movePositionToPreviousGranularity so we can accurately
  // determine what's currently being highlighted.
  private resetPreviousHighlightAndRemoveCurrentHighlight() {
    this.removeCurrentHighlight();
    this.resetPreviousHighlight_();
  }

  private removeCurrentHighlight() {
    // The most recent highlight could have been spread across multiple segments
    // so clear the formatting for all of the segments.
    for (let i = 0; i < chrome.readingMode.getCurrentText().length; i++) {
      const lastElement = this.previousHighlights_.pop();
      if (lastElement) {
        lastElement.classList.remove(currentReadHighlightClass);
      }
    }
  }

  private resetPreviousHighlight_() {
    this.previousHighlights_.forEach((element) => {
      if (element) {
        element.classList.add(previousReadHighlightClass);
        element.classList.remove(currentReadHighlightClass);
      }
    });
  }

  restoreSettingsFromPrefs() {
    if (this.isReadAloudEnabled_) {
      // We need to restore enabled languages prior to selecting the preferred
      // voice to ensure we have the right voices available.
      this.restoreEnabledLanguagesFromPref();
      this.selectPreferredVoice();
    }
    this.settingsPrefs_ = {
      ...this.settingsPrefs_,
      letterSpacing: chrome.readingMode.letterSpacing,
      lineSpacing: chrome.readingMode.lineSpacing,
      theme: chrome.readingMode.colorTheme,
      speechRate: chrome.readingMode.speechRate,
      font: chrome.readingMode.fontName,
    };
    this.styleUpdater_.setAllTextStyles();
    // TODO(crbug.com/40927698): Remove this call. Using this.settingsPrefs_
    // should replace this direct call to the toolbar.
    this.$.toolbar.restoreSettingsFromPrefs();
  }

  restoreEnabledLanguagesFromPref() {
    // We need to make sure the languages we choose correspond to voices, so
    // refresh the list of voices and available langs
    this.getVoices_();

    // If there are no available languages or voices yet, we might not be
    // able to restore voice settings yet, so signal that we should attempt
    // to restore settings the next time onVoicesChanged is called with
    // available voices.
    this.shouldAttemptLanguageSettingsRestore =
        !(this.availableLangs_ && this.availableLangs_.length > 0);

    const storedLanguagesPref: string[] =
        chrome.readingMode.getLanguagesEnabledInPref();
    const browserOrPageBaseLang = chrome.readingMode.baseLanguageForSpeech;
    this.speechSynthesisLanguage = browserOrPageBaseLang;

    this.enabledLangs = createInitialListOfEnabledLanguages(
        browserOrPageBaseLang, storedLanguagesPref, this.availableLangs_,
        this.defaultVoice()?.lang);

    storedLanguagesPref.forEach(storedLanguage => {
      if (!this.enabledLangs.find(language => language === storedLanguage)) {
        // If a stored language doesn't have a match in the enabled languages
        // list, disable the original preference. This can guard against issues
        // with preferences after bugs are fixed.
        // e.g. if "de-DE" is accidentally stored as a language, the preference
        // will always be converted to "de-de" in
        // #createInitialListOfEnabledLanguages, and if we disable the
        // preference, "de-de" will be disabled, meaning the original
        // pref will never be deleted and it will be impossible to disable
        // the preference.
        chrome.readingMode.onLanguagePrefChange(storedLanguage, false);
      }
    });

    for (const lang of this.enabledLangs) {
      this.installVoicePackIfPossible(
          lang, /* onlyInstallExactGoogleLocaleMatch=*/ true,
          /* retryIfPreviousInstallFailed= */ false);
    }
  }

  private currentVoiceIsUserChosen_(): boolean {
    const storedVoiceName = chrome.readingMode.getStoredVoice();

    // `this.selectedVoice` is not necessarily chosen by the user, it is just
    // the voice that read aloud is using. It may be a default voice chosen by
    // read aloud, so we check it against user preferences to see if it was
    // user-chosen.
    if (storedVoiceName) {
      return this.selectedVoice_?.name === storedVoiceName;
    }
    return false;
  }

  selectPreferredVoice() {
    // TODO: b/40275871 - decide whether this is the behavior we want. This
    // shouldn't happen often, so just skip selecting a new voice for now.
    // Another option would be to update the voice and the call
    // resetSpeechPostSettingsChange(), but that could be jarring.
    if (this.speechPlayingState.hasSpeechBeenTriggered) {
      return;
    }

    const storedVoiceName = chrome.readingMode.getStoredVoice();
    if (!storedVoiceName) {
      this.selectedVoice_ = this.defaultVoice();
      return;
    }

    const selectedVoice =
        this.getVoices_().filter(voice => voice.name === storedVoiceName);
    this.selectedVoice_ = selectedVoice && (selectedVoice.length > 0) ?
        selectedVoice[0] :
        this.defaultVoice();

    // Enable the locale for the preferred voice for this language.
    if (this.selectedVoice_ &&
        !this.enabledLangs.includes(this.selectedVoice_.lang)) {
      this.enabledLangs = [...this.enabledLangs, this.selectedVoice_.lang];
    }
  }

  protected onLineSpacingChange_() {
    this.styleUpdater_.setLineSpacing();
  }

  protected onLetterSpacingChange_() {
    this.styleUpdater_.setLetterSpacing();
  }

  protected onFontChange_() {
    this.styleUpdater_.setFont();
  }

  protected onFontSizeChange_() {
    this.styleUpdater_.setFontSize();
  }

  protected onHighlightToggle_() {
    this.styleUpdater_.setHighlight();
  }

  protected onThemeChange_() {
    this.styleUpdater_.setTheme();
  }

  protected onResetToolbar_() {
    this.styleUpdater_.resetToolbar();
  }

  protected onToolbarOverflow_(event: CustomEvent<{overflowLength: number}>) {
    const shouldScroll =
        (event.detail.overflowLength >= minOverflowLengthToScroll);
    this.styleUpdater_.overflowToolbar(shouldScroll);
  }

  // If the screen is locked during speech, we should stop speaking.
  onLockScreen() {
    if (this.speechPlayingState.isSpeechActive) {
      this.stopSpeech(PauseActionSource.DEFAULT);
    }
  }

  languageChanged() {
    this.speechSynthesisLanguage = chrome.readingMode.baseLanguageForSpeech;
    this.$.toolbar.updateFonts();
    // Don't check for Google locales when the language has changed.
    this.installVoicePackIfPossible(
        this.speechSynthesisLanguage,
        /* onlyInstallExactGoogleLocaleMatch=*/ false,
        /* retryIfPreviousInstallFailed= */ false);
  }

  protected computeIsReadAloudPlayable(): boolean {
    return this.hasContent_ && this.speechEngineLoaded_ &&
        !!this.selectedVoice_ && !this.willDrawAgainSoon_;
  }

  private autoSwitchVoice_(lang: string) {
    if (!chrome.readingMode.isAutoVoiceSwitchingEnabled) {
      return;
    }

    // Only enable this language if it has available voices and is the current
    // language. Otherwise switch to a default voice if nothing is selected.
    const availableLang =
        convertLangToAnAvailableLangIfPresent(lang, this.availableLangs_);
    if (!availableLang ||
        !availableLang.startsWith(this.speechSynthesisLanguage.split('-')[0])) {
      this.selectPreferredVoice();
      return;
    }

    // Only enable Google TTS supported locales for this language if they exist.
    let localesToEnable: string[] = [];
    const voicePackLocale =
        convertLangOrLocaleToExactVoicePackLocale(availableLang);
    if (voicePackLocale) {
      localesToEnable.push(voicePackLocale);
    } else {
      // If there are no Google TTS locales for this language then enable any
      // available locale for this language.
      localesToEnable =
          this.availableLangs_.filter(l => l.startsWith(availableLang));
    }

    // Enable the locales so we can select a voice for the given language and
    // show it in the voice menu.
    localesToEnable.forEach(langToEnable => {
      if (!this.enabledLangs.includes(langToEnable)) {
        this.enabledLangs = [...this.enabledLangs, langToEnable];
      }
    });
    this.selectPreferredVoice();
  }

  // Kicks off a workflow to install a voice pack.
  // 1) Checks if Language Pack Manager supports a version of this voice/locale
  // 2) If so, adds voice to installVoicePackIfPossible set
  // 3) Kicks off request GetVoicePackInfo to see if the voice is installed
  // 4) Upon response, if we see the voice is not installed and that it's in
  // installVoicePackIfPossible, then we trigger an install request
  private installVoicePackIfPossible(
      langOrLocale: string, onlyInstallExactGoogleLocaleMatch: boolean,
      retryIfPreviousInstallFailed: boolean) {
    if (!chrome.readingMode.isLanguagePackDownloadingEnabled) {
      return;
    }

    // Don't attempt to install a language if it's not a Google TTS language
    // available for downloading. It's possible for other non-Google TTS
    // voices to have a valid language code from
    // convertLangOrLocaleForVoicePackManager, so return early instead to
    // prevent accidentally downloading untoggled voices.
    // If we shouldn't check for Google locales (such as when installing a new
    // page language), this check can be skipped.
    if (onlyInstallExactGoogleLocaleMatch &&
        !AVAILABLE_GOOGLE_TTS_LOCALES.has(langOrLocale)) {
      this.autoSwitchVoice_(langOrLocale);
      return;
    }

    const langCodeForVoicePackManager = convertLangOrLocaleForVoicePackManager(
        langOrLocale, this.enabledLangs, this.availableLangs_);

    if (!langCodeForVoicePackManager) {
      this.autoSwitchVoice_(langOrLocale);
      return;
    }

    const statusForLang =
        this.voicePackInstallStatusServerResponses_[langCodeForVoicePackManager];

    if (!statusForLang) {
      if (retryIfPreviousInstallFailed) {
        this.forceInstallRequest(
            langCodeForVoicePackManager, /* isRetry = */ false);
      } else {
        this.languagesForVoiceDownloads.add(langCodeForVoicePackManager);
        // Inquire if the voice pack is downloaded. If not, it'll trigger a
        // download when we get the response in updateVoicePackStatus().
        this.sendGetVoicePackInfoRequest(langCodeForVoicePackManager);
      }
      return;
    }

    // If we send an install request for this language, we'll auto switch
    // voices after it installs.
    if (isVoicePackStatusSuccess(statusForLang) &&
        statusForLang.code === VoicePackServerStatusSuccessCode.NOT_INSTALLED) {
      this.languagesForVoiceDownloads.add(langCodeForVoicePackManager);
      // Inquire if the voice pack is downloaded. If not, it'll trigger a
      // download when we get the response in updateVoicePackStatus().
      this.sendGetVoicePackInfoRequest(langCodeForVoicePackManager);
    } else if (
        retryIfPreviousInstallFailed && isVoicePackStatusError(statusForLang)) {
      this.languagesForVoiceDownloads.add(langCodeForVoicePackManager);

      // If the previous install attempt failed (e.g. due to no internet
      // connection), the PackManager sends a failure for subsequent GetInfo
      // requests. Therefore, we need to bypass our normal flow of calling
      // GetInfo to see if the voice is available to install, and just call
      // sendInstallVoicePackRequest directly
      this.forceInstallRequest(
          langCodeForVoicePackManager, /* isRetry = */ true);
    } else {
      this.autoSwitchVoice_(langCodeForVoicePackManager);
    }
  }

  private forceInstallRequest(
      langCodeForVoicePackManager: string, isRetry: boolean) {
    this.setVoicePackLocalStatus(
        langCodeForVoicePackManager,
        isRetry ? VoiceClientSideStatusCode.SENT_INSTALL_REQUEST_ERROR_RETRY :
                  VoiceClientSideStatusCode.SENT_INSTALL_REQUEST);

    chrome.readingMode.sendInstallVoicePackRequest(langCodeForVoicePackManager);
  }

  protected onKeyDown_(e: KeyboardEvent) {
    if (e.key === 'k') {
      e.stopPropagation();
      this.onPlayPauseClick_();
    }
  }

  getVoicePackStatusForTesting(lang: string):
      {server: VoicePackStatus, client: VoiceClientSideStatusCode} {
    const server = this.getVoicePackServerStatus_(lang);
    const client = this.getVoicePackLocalStatus_(lang);
    assert(server);
    assert(client);
    return {server, client};
  }

  private getVoicePackServerStatus_(lang: string): VoicePackStatus|undefined {
    const voicePackLanguage = getVoicePackConvertedLangIfExists(lang);
    return this.voicePackInstallStatusServerResponses_[voicePackLanguage];
  }

  private getVoicePackLocalStatus_(lang: string): VoiceClientSideStatusCode
      |undefined {
    const voicePackLanguage = getVoicePackConvertedLangIfExists(lang);
    return this.voiceStatusLocalState_[voicePackLanguage];
  }

  setVoicePackLocalStatus(lang: string, status: VoiceClientSideStatusCode) {
    const voicePackLanguage = getVoicePackConvertedLangIfExists(lang);
    this.voiceStatusLocalState_ = {
      ...this.voiceStatusLocalState_,
      [voicePackLanguage]: status,
    };
  }

  resetVoiceForTesting() {
    this.selectedVoice_ = undefined;
  }

  private setVoicePackServerStatus_(lang: string, status: VoicePackStatus) {
    // Convert the language string to ensure consistency across
    // languages and locales when setting the status.
    const voicePackLanguage = getVoicePackConvertedLangIfExists(lang);
    this.voicePackInstallStatusServerResponses_ = {
      ...this.voicePackInstallStatusServerResponses_,
      [voicePackLanguage]: status,
    };
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'read-anything-app': AppElement;
  }
}

customElements.define(AppElement.is, AppElement);