/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

import { ReaderMode } from "moz-src:///toolkit/components/reader/ReaderMode.sys.mjs";

import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";

const lazy = {};
let gScrollPositions = new Map();
let lastSelectedTheme = "auto";

ChromeUtils.defineESModuleGetters(lazy, {
  AsyncPrefs: "resource://gre/modules/AsyncPrefs.sys.mjs",
  NarrateControls: "resource://gre/modules/narrate/NarrateControls.sys.mjs",
});

ChromeUtils.defineLazyGetter(
  lazy,
  "l10n",
  () => new Localization(["toolkit/about/aboutReader.ftl"], true)
);

const FONT_TYPE_L10N_IDS = {
  serif: "about-reader-font-type-serif",
  "sans-serif": "about-reader-font-type-sans-serif",
  monospace: "about-reader-font-type-monospace",
};

const FONT_WEIGHT_L10N_IDS = {
  light: "about-reader-font-weight-light",
  regular: "about-reader-font-weight-regular",
  bold: "about-reader-font-weight-bold",
};

const DEFAULT_TEXT_LAYOUT = {
  fontSize: 5,
  fontType: "sans-serif",
  fontWeight: "regular",
  contentWidth: 3,
  lineSpacing: 4,
  characterSpacing: 1,
  wordSpacing: 1,
  textAlignment: "start",
};

const COLORSCHEME_L10N_IDS = {
  auto: "about-reader-color-auto-theme",
  light: "about-reader-color-light-theme",
  dark: "about-reader-color-dark-theme",
  sepia: "about-reader-color-sepia-theme",
  contrast: "about-reader-color-contrast-theme",
  gray: "about-reader-color-gray-theme",
};

const CUSTOM_THEME_COLOR_INPUTS = [
  "foreground",
  "background",
  "unvisited-links",
  "visited-links",
  "selection-highlight",
];

const COLORS_MENU_TABS = ["fxtheme", "customtheme"];

const DEFAULT_COLORS = {
  background: "#FFFFFF",
  foreground: "#14151A",
  "unvisited-links": "#0060DF",
  "visited-links": "#321C64",
  "selection-highlight": "#FFFFCC",
};

const zoomOnCtrl =
  Services.prefs.getIntPref("mousewheel.with_control.action", 3) == 3;
const zoomOnMeta =
  Services.prefs.getIntPref("mousewheel.with_meta.action", 1) == 3;
const isAppLocaleRTL = Services.locale.isAppLocaleRTL;

export var AboutReader = function (
  actor,
  articlePromise,
  docContentType = "document",
  docTitle = ""
) {
  let win = actor.contentWindow;
  let url = this._getOriginalUrl(win);
  if (
    !(
      url.startsWith("http://") ||
      url.startsWith("https://") ||
      url.startsWith("file://")
    )
  ) {
    let errorMsg =
      "Only http://, https:// and file:// URLs can be loaded in about:reader.";
    if (Services.prefs.getBoolPref("reader.errors.includeURLs")) {
      errorMsg += " Tried to load: " + url + ".";
    }
    console.error(errorMsg);
    win.location.href = "about:blank";
    return;
  }

  let doc = win.document;
  if (isAppLocaleRTL) {
    doc.dir = "rtl";
  }
  doc.documentElement.setAttribute("platform", AppConstants.platform);

  doc.title = docTitle;

  this._actor = actor;

  this._docRef = Cu.getWeakReference(doc);
  this._winRef = Cu.getWeakReference(win);
  this._innerWindowId = win.windowGlobalChild.innerWindowId;

  this._article = null;
  this._languageDeferred = Promise.withResolvers();

  if (articlePromise) {
    this._articlePromise = articlePromise;
  }

  this._headerElementRef = Cu.getWeakReference(
    doc.querySelector(".reader-header")
  );
  this._domainElementRef = Cu.getWeakReference(
    doc.querySelector(".reader-domain")
  );
  this._titleElementRef = Cu.getWeakReference(
    doc.querySelector(".reader-title")
  );
  this._readTimeElementRef = Cu.getWeakReference(
    doc.querySelector(".reader-estimated-time")
  );
  this._creditsElementRef = Cu.getWeakReference(
    doc.querySelector(".reader-credits")
  );
  this._contentElementRef = Cu.getWeakReference(
    doc.querySelector(".moz-reader-content")
  );
  this._toolbarContainerElementRef = Cu.getWeakReference(
    doc.querySelector(".toolbar-container")
  );
  this._toolbarElementRef = Cu.getWeakReference(
    doc.querySelector(".reader-controls")
  );
  this._messageElementRef = Cu.getWeakReference(
    doc.querySelector(".reader-message")
  );
  this._containerElementRef = Cu.getWeakReference(
    doc.querySelector(".container")
  );

  doc.addEventListener("mousedown", this);
  doc.addEventListener("keydown", this);
  doc.addEventListener("click", this);
  doc.addEventListener("blur", this, true);
  doc.addEventListener("touchstart", this);

  win.addEventListener("pagehide", this);
  win.addEventListener("resize", this);
  win.addEventListener("wheel", this, { passive: false });

  this.colorSchemeMediaList = win.matchMedia("(prefers-color-scheme: dark)");
  this.colorSchemeMediaList.addEventListener("change", this);

  this.forcedColorsMediaList = win.matchMedia("(forced-colors)");
  this.forcedColorsMediaList.addEventListener("change", this);

  this._topScrollChange = this._topScrollChange.bind(this);
  this._intersectionObs = new win.IntersectionObserver(this._topScrollChange, {
    root: null,
    threshold: [0, 1],
  });
  this._intersectionObs.observe(doc.querySelector(".top-anchor"));

  Services.obs.addObserver(this, "inner-window-destroyed");

  this._setupButton("close-button", this._onReaderClose.bind(this));

  // we're ready for any external setup, send a signal for that.
  this._actor.sendAsyncMessage("Reader:OnSetup");

  // set up segmented tab controls for colors menu.
  this._setupColorsTabs(
    COLORS_MENU_TABS,
    this._handleColorsTabClick.bind(this)
  );

  // fetch color scheme values from prefs.
  let colorSchemeValues = JSON.parse(
    Services.prefs.getCharPref("reader.color_scheme.values")
  );

  let colorSchemeOptions = colorSchemeValues.map(value => ({
    l10nId: COLORSCHEME_L10N_IDS[value],
    groupName: "color-scheme",
    value,
    itemClass: value + "-button",
  }));
  let colorScheme = Services.prefs.getCharPref("reader.color_scheme");

  this._setupSegmentedButton(
    "color-scheme-buttons",
    colorSchemeOptions,
    colorScheme,
    this._setColorSchemePref.bind(this)
  );

  this._setupCustomColors(
    CUSTOM_THEME_COLOR_INPUTS,
    "custom-colors-selection",
    "about-reader-custom-colors"
  );

  this._setupButton(
    "custom-colors-reset-button",
    this._resetCustomColors.bind(this)
  );

  this._handleThemeFocus();

  this._setColorSchemePref(colorScheme);

  // Differentiates between the tick mark labels for width vs spacing controls
  // for localization purposes.
  const [standardSpacingLabel, wideSpacingLabel] = lazy.l10n.formatMessagesSync(
    [
      "about-reader-slider-label-spacing-standard",
      "about-reader-slider-label-spacing-wide",
    ]
  );

  let contentWidthSliderOptions = {
    min: 1,
    max: 9,
    ticks: 9,
    tickLabels: `[]`,
    l10nId: "about-reader-content-width-label",
    icon: "chrome://global/skin/reader/content-width-20.svg",
    telemetryId: "content-width-slider",
  };

  let lineSpacingSliderOptions = {
    min: 1,
    max: 9,
    ticks: 9,
    tickLabels: `[]`,
    l10nId: "about-reader-line-spacing-label",
    icon: "chrome://global/skin/reader/line-spacing-20.svg",
    telemetryId: "line-spacing-slider",
  };

  let characterSpacingSliderOptions = {
    min: 1,
    max: 9,
    ticks: 9,
    tickLabels: `["${standardSpacingLabel.value}", "${wideSpacingLabel.value}"]`,
    l10nId: "about-reader-character-spacing-label",
    icon: "chrome://global/skin/reader/character-spacing-20.svg",
    telemetryId: "character-spacing-slider",
  };

  let wordSpacingSliderOptions = {
    min: 1,
    max: 9,
    ticks: 9,
    tickLabels: `["${standardSpacingLabel.value}", "${wideSpacingLabel.value}"]`,
    l10nId: "about-reader-word-spacing-label",
    icon: "chrome://global/skin/reader/word-spacing-20.svg",
    telemetryId: "word-spacing-slider",
  };

  let textAlignmentOptions = [
    {
      l10nId: "about-reader-text-alignment-left",
      groupName: "text-alignment",
      value: "left",
      itemClass: "left-align-button",
    },
    {
      l10nId: "about-reader-text-alignment-center",
      groupName: "text-alignment",
      value: "center",
      itemClass: "center-align-button",
    },
    {
      l10nId: "about-reader-text-alignment-right",
      groupName: "text-alignment",
      value: "right",
      itemClass: "right-align-button",
    },
    {
      l10nId: "about-reader-text-alignment-justify",
      groupName: "text-alignment",
      value: "justify",
      itemClass: "justify-align-button",
    },
  ];

  // If the page is rtl, reverse order of text alignment options.
  // The justify text option remains as the last.
  if (isAppLocaleRTL) {
    textAlignmentOptions = [
      ...textAlignmentOptions.slice(0, 3).reverse(),
      ...textAlignmentOptions.slice(3),
    ];
  }

  let selectorFontTypeValues = ["sans-serif", "serif", "monospace"];
  try {
    selectorFontTypeValues = JSON.parse(
      Services.prefs.getCharPref("reader.font_type.values")
    );
  } catch (e) {
    console.error(
      "There was an error fetching the font type values pref: ",
      e.message
    );
  }
  let fontType = Services.prefs.getCharPref("reader.font_type", "sans-serif");
  this._setupSelector(
    "font-type",
    selectorFontTypeValues,
    fontType,
    this._setFontTypeSelector.bind(this),
    FONT_TYPE_L10N_IDS
  );
  this._setFontTypeSelector(fontType);

  let fontWeightValues = ["regular", "light", "bold"];
  try {
    fontWeightValues = JSON.parse(
      Services.prefs.getCharPref("reader.font_weight.values")
    );
  } catch (e) {
    console.error(
      "There was an error fetching the font weight values pref: ",
      e.message
    );
  }
  let fontWeight = Services.prefs.getCharPref("reader.font_weight", "regular");
  this._setupSelector(
    "font-weight",
    fontWeightValues,
    fontWeight,
    this._setFontWeight.bind(this),
    FONT_WEIGHT_L10N_IDS
  );
  this._setFontWeight(fontWeight);

  let contentWidth = Services.prefs.getIntPref("reader.content_width", 3);
  this._setupSlider(
    "content-width",
    contentWidthSliderOptions,
    contentWidth,
    this._setContentWidth.bind(this)
  );
  this._setContentWidth(contentWidth);

  let lineSpacing = Services.prefs.getIntPref("reader.line_height", 4);
  this._setupSlider(
    "line-spacing",
    lineSpacingSliderOptions,
    lineSpacing,
    this._setLineSpacing.bind(this)
  );
  this._setLineSpacing(lineSpacing);

  let characterSpacing = Services.prefs.getIntPref(
    "reader.character_spacing",
    1
  );
  this._setupSlider(
    "character-spacing",
    characterSpacingSliderOptions,
    characterSpacing,
    this._setCharacterSpacing.bind(this)
  );
  this._setCharacterSpacing(characterSpacing);

  let wordSpacing = Services.prefs.getIntPref("reader.word_spacing", 1);
  this._setupSlider(
    "word-spacing",
    wordSpacingSliderOptions,
    wordSpacing,
    this._setWordSpacing.bind(this)
  );
  this._setWordSpacing(wordSpacing);

  let textAlignment = Services.prefs.getCharPref(
    "reader.text_alignment",
    "start"
  );
  this._setupSegmentedButton(
    "text-alignment-buttons",
    textAlignmentOptions,
    textAlignment,
    this._setTextAlignment.bind(this)
  );
  this._setTextAlignment(textAlignment);

  this._setupButton(
    "text-layout-reset-button",
    this._resetTextLayout.bind(this)
  );

  this._handleTextLayoutFocus();

  this._setupFontSizeButtons();

  if (win.speechSynthesis && Services.prefs.getBoolPref("narrate.enabled")) {
    new lazy.NarrateControls(win, this._languageDeferred.promise);
  }

  this._loadArticle(docContentType);
};

AboutReader.prototype = {
  _BLOCK_IMAGES_SELECTOR:
    ".content p > img:only-child, " +
    ".content p > a:only-child > img:only-child, " +
    ".content .wp-caption img, " +
    ".content figure img",

  _TABLES_SELECTOR: ".content table",

  FONT_SIZE_MIN: 1,

  FONT_SIZE_LEGACY_MAX: 9,

  FONT_SIZE_MAX: 15,

  FONT_SIZE_EXTENDED_VALUES: [32, 40, 56, 72, 96, 128],

  get _doc() {
    return this._docRef.get();
  },

  get _win() {
    return this._winRef.get();
  },

  get _headerElement() {
    return this._headerElementRef.get();
  },

  get _domainElement() {
    return this._domainElementRef.get();
  },

  get _titleElement() {
    return this._titleElementRef.get();
  },

  get _readTimeElement() {
    return this._readTimeElementRef.get();
  },

  get _creditsElement() {
    return this._creditsElementRef.get();
  },

  get _contentElement() {
    return this._contentElementRef.get();
  },

  get _toolbarElement() {
    return this._toolbarElementRef.get();
  },

  get _toolbarContainerElement() {
    return this._toolbarContainerElementRef.get();
  },

  get _messageElement() {
    return this._messageElementRef.get();
  },

  get _containerElement() {
    return this._containerElementRef.get();
  },

  get _isToolbarVertical() {
    if (this._toolbarVertical !== undefined) {
      return this._toolbarVertical;
    }
    return (this._toolbarVertical = Services.prefs.getBoolPref(
      "reader.toolbar.vertical"
    ));
  },

  receiveMessage({ data, name }) {
    const doc = this._doc;
    switch (name) {
      case "Reader:AddButton": {
        if (data.id && data.image && !doc.getElementsByClassName(data.id)[0]) {
          let btn = doc.createElement("button");
          btn.dataset.buttonid = data.id;
          btn.dataset.telemetryId = `reader-${data.telemetryId}`;
          btn.className = "toolbar-button " + data.id;
          btn.setAttribute("aria-labelledby", "label-" + data.id);
          let tip = doc.createElement("span");
          tip.className = "hover-label";
          tip.id = "label-" + data.id;
          doc.l10n.setAttributes(tip, data.l10nId);
          btn.append(tip);
          btn.style.backgroundImage = "url('" + data.image + "')";
          if (data.width && data.height) {
            btn.style.backgroundSize = `${data.width}px ${data.height}px`;
          }
          let tb = this._toolbarElement;
          tb.appendChild(btn);
          this._setupButton(data.id, button => {
            this._actor.sendAsyncMessage(
              "Reader:Clicked-" + button.dataset.buttonid,
              { article: this._article }
            );
          });
        }
        break;
      }
      case "Reader:RemoveButton": {
        if (data.id) {
          let btn = doc.getElementsByClassName(data.id)[0];
          if (btn) {
            btn.remove();
          }
        }
        break;
      }
      case "Reader:ZoomIn": {
        this._changeFontSize(+1);
        break;
      }
      case "Reader:ZoomOut": {
        this._changeFontSize(-1);
        break;
      }
      case "Reader:ResetZoom": {
        this._resetFontSize();
        break;
      }
    }
  },

  handleEvent(aEvent) {
    // To avoid buttons that are programmatically clicked being counted twice,
    // and account for controls that don't fire click events, define a set of
    // blur only telemetry ids.
    const blurTelemetryIds = new Set([
      "colors-menu-custom-tab",
      "left-align-button",
      "font-type-selector",
      "font-weight-selector",
    ]);

    if (!aEvent.isTrusted) {
      return;
    }

    let target = aEvent.target;
    switch (aEvent.type) {
      case "touchstart":
      /* fall through */
      case "mousedown":
        if (
          !target.closest(".dropdown-popup") &&
          // Skip handling the toggle button here becase
          // the dropdown will get toggled with the 'click' event.
          !target.classList.contains("dropdown-toggle")
        ) {
          this._closeDropdowns();
        }
        break;
      case "keydown":
        if (aEvent.keyCode == 27) {
          this._closeDropdowns();
        }
        break;
      case "click": {
        let clickTelemetryId =
          target.attributes.getNamedItem(`data-telemetry-id`)?.value;

        if (clickTelemetryId && !blurTelemetryIds.has(clickTelemetryId)) {
          Glean.readermode.buttonClick.record({
            label: clickTelemetryId,
          });
        }

        if (target.classList.contains("dropdown-toggle")) {
          this._toggleDropdownClicked(aEvent);
        }
        break;
      }
      case "blur":
        if (HTMLElement.isInstance(target)) {
          let blurTelemetryId =
            target.attributes.getNamedItem(`data-telemetry-id`)?.value;

          if (blurTelemetryId && blurTelemetryIds.has(blurTelemetryId)) {
            Glean.readermode.buttonClick.record({
              label: blurTelemetryId,
            });
          }
        }
        break;
      case "scroll": {
        let lastHeight = this._lastHeight;
        let { windowUtils } = this._win;
        this._lastHeight = windowUtils.getBoundsWithoutFlushing(
          this._doc.body
        ).height;
        // Only close dropdowns if the scroll events are not a result of line
        // height / font-size changes that caused a page height change.
        // Prevent dropdowns from closing when scrolling within the popup.
        let mouseInDropdown = !!this._doc.querySelector(".dropdown.open:hover");
        if (lastHeight == this._lastHeight && !mouseInDropdown) {
          this._closeDropdowns(true);
        }

        break;
      }
      case "resize":
        this._updateImageMargins();
        this._scheduleToolbarOverlapHandler();
        break;

      case "wheel": {
        let doZoom =
          (aEvent.ctrlKey && zoomOnCtrl) || (aEvent.metaKey && zoomOnMeta);
        if (!doZoom) {
          return;
        }
        aEvent.preventDefault();

        // Throttle events to once per 150ms. This avoids excessively fast zooming.
        if (aEvent.timeStamp <= this._zoomBackoffTime) {
          return;
        }
        this._zoomBackoffTime = aEvent.timeStamp + 150;

        // Determine the direction of the delta (we don't care about its size);
        // This code is adapted from normalizeWheelEventDelta in
        // toolkit/components/pdfjs/content/web/viewer.mjs
        let delta = Math.abs(aEvent.deltaX) + Math.abs(aEvent.deltaY);
        let angle = Math.atan2(aEvent.deltaY, aEvent.deltaX);
        if (-0.25 * Math.PI < angle && angle < 0.75 * Math.PI) {
          delta = -delta;
        }

        if (delta > 0) {
          this._changeFontSize(+1);
        } else if (delta < 0) {
          this._changeFontSize(-1);
        }
        break;
      }

      case "pagehide":
        this._closeDropdowns();
        this._saveScrollPosition();

        this._actor.readerModeHidden();
        this.clearActor();

        // Disconnect and delete IntersectionObservers to prevent memory leaks:

        this._intersectionObs.unobserve(this._doc.querySelector(".top-anchor"));

        delete this._intersectionObs;

        break;

      case "change": {
        let colorScheme;
        if (this.forcedColorsMediaList.matches) {
          colorScheme = "hcm";
        } else {
          colorScheme = Services.prefs.getCharPref("reader.color_scheme");
          // We should be changing the color scheme in relation to a preference change
          // if the user has the color scheme preference set to "Auto".
          if (colorScheme == "auto") {
            colorScheme = this.colorSchemeMediaList.matches ? "dark" : "light";
          }
        }
        this._setColorScheme(colorScheme);

        break;
      }
    }
  },

  clearActor() {
    this._actor = null;
  },

  _onReaderClose() {
    if (this._actor) {
      this._actor.closeReaderMode();
    }
  },

  async _resetFontSize() {
    await lazy.AsyncPrefs.reset("reader.font_size");
    let currentSize = Services.prefs.getIntPref("reader.font_size");
    this._setFontSize(currentSize);
  },

  _setFontSize(newFontSize) {
    this._fontSize = Math.min(
      this.FONT_SIZE_MAX,
      Math.max(this.FONT_SIZE_MIN, newFontSize)
    );
    let size;
    if (this._fontSize > this.FONT_SIZE_LEGACY_MAX) {
      // -1 because we're indexing into a 0-indexed array, so the first value
      // over the legacy max should be 0, the next 1, etc.
      let index = this._fontSize - this.FONT_SIZE_LEGACY_MAX - 1;
      size = this.FONT_SIZE_EXTENDED_VALUES[index];
    } else {
      size = 10 + 2 * this._fontSize;
    }

    let readerBody = this._doc.body;
    readerBody.style.setProperty("--font-size", size + "px");
    return lazy.AsyncPrefs.set("reader.font_size", this._fontSize);
  },

  _setupFontSizeButtons() {
    let plusButton = this._doc.querySelector(".text-size-plus-button");
    let minusButton = this._doc.querySelector(".text-size-minus-button");

    let currentSize = Services.prefs.getIntPref("reader.font_size");
    this._setFontSize(currentSize);
    this._updateFontSizeButtonControls();

    plusButton.addEventListener(
      "click",
      event => {
        if (!event.isTrusted) {
          return;
        }
        this._changeFontSize(+1);
      },
      true
    );

    minusButton.addEventListener(
      "click",
      event => {
        if (!event.isTrusted) {
          return;
        }
        this._changeFontSize(-1);
      },
      true
    );
  },

  _updateFontSizeButtonControls() {
    let currentSize = this._fontSize;
    let plusButton = this._doc.querySelector(".text-size-plus-button");
    let minusButton = this._doc.querySelector(".text-size-minus-button");

    if (currentSize === this.FONT_SIZE_MIN) {
      minusButton.setAttribute("disabled", true);
    } else {
      minusButton.removeAttribute("disabled");
    }
    if (currentSize === this.FONT_SIZE_MAX) {
      plusButton.setAttribute("disabled", true);
    } else {
      plusButton.removeAttribute("disabled");
    }
  },

  _changeFontSize(changeAmount) {
    let currentSize =
      Services.prefs.getIntPref("reader.font_size") + changeAmount;
    this._setFontSize(currentSize);
    this._updateFontSizeButtonControls();
    this._scheduleToolbarOverlapHandler();
  },

  _setupSelector(id, options, initialValue, callback, l10nIds) {
    let doc = this._doc;
    let selector = doc.getElementById(`${id}-selector`);

    options.forEach(option => {
      let selectorOption = doc.createElement("option");
      let presetl10nId = l10nIds[option];
      if (presetl10nId) {
        doc.l10n.setAttributes(selectorOption, presetl10nId);
      } else {
        selectorOption.text = option;
      }
      selectorOption.value = option;
      selector.appendChild(selectorOption);
      if (option == initialValue) {
        selectorOption.setAttribute("selected", true);
      }
    });

    selector.addEventListener("change", e => {
      callback(e.target.value);
    });
  },

  _setFontTypeSelector(newFontType) {
    this._doc.documentElement.style.setProperty(
      "--font-family",
      newFontType.includes(" ") ? `"${newFontType}"` : newFontType
    );

    lazy.AsyncPrefs.set("reader.font_type", newFontType);
  },

  _setFontWeight(newFontWeight) {
    if (newFontWeight === "light") {
      this._doc.documentElement.style.setProperty("--font-weight", "lighter");
    } else if (newFontWeight === "regular") {
      this._doc.documentElement.style.setProperty("--font-weight", "normal");
    } else if (newFontWeight === "bold") {
      this._doc.documentElement.style.setProperty("--font-weight", "bolder");
    }

    lazy.AsyncPrefs.set("reader.font_weight", newFontWeight);
  },

  _setupSlider(id, options, initialValue, callback) {
    let doc = this._doc;
    let slider = doc.createElement("moz-slider");

    slider.setAttribute("min", options.min);
    slider.setAttribute("max", options.max);
    slider.setAttribute("value", initialValue);
    slider.setAttribute("ticks", options.ticks);
    slider.setAttribute("tick-labels", options.tickLabels);
    slider.setAttribute("data-l10n-id", options.l10nId);
    slider.setAttribute("data-l10n-attrs", "label");
    slider.setAttribute("slider-icon", options.icon);
    slider.setAttribute("data-telemetry-id", options.telemetryId);

    slider.addEventListener("slider-changed", e => {
      callback(e.detail);
    });

    let sliderContainer = doc.getElementById(`${id}-slider`);
    sliderContainer.appendChild(slider);
  },

  _setContentWidth(newContentWidth) {
    // We map the slider range [1-9] to 20-60em.
    let width = 20 + 5 * (newContentWidth - 1) + "em";
    this._doc.body.style.setProperty("--content-width", width);
    this._scheduleToolbarOverlapHandler();
    return lazy.AsyncPrefs.set(
      "reader.content_width",
      parseInt(newContentWidth)
    );
  },

  _setLineSpacing(newLineSpacing) {
    // We map the slider range [1-9] to 1-2.6em.
    let spacing = 1 + 0.2 * (newLineSpacing - 1) + "em";
    this._containerElement.style.setProperty("--line-height", spacing);
    return lazy.AsyncPrefs.set("reader.line_height", parseInt(newLineSpacing));
  },

  _setCharacterSpacing(newCharSpacing) {
    // We map the slider range [1-9] to 0-0.24em.
    let spacing = (newCharSpacing - 1) * 0.03;
    this._containerElement.style.setProperty(
      "--letter-spacing",
      `${parseFloat(spacing).toFixed(2)}em`
    );
    lazy.AsyncPrefs.set("reader.character_spacing", parseInt(newCharSpacing));
  },

  _setWordSpacing(newWordSpacing) {
    // We map the slider range [1-9] to 0-0.4em.
    let spacing = (newWordSpacing - 1) * 0.05;
    this._containerElement.style.setProperty(
      "--word-spacing",
      `${parseFloat(spacing).toFixed(2)}em`
    );
    lazy.AsyncPrefs.set("reader.word_spacing", parseInt(newWordSpacing));
  },

  _setTextAlignment(newTextAlignment) {
    if (this._textAlignment === newTextAlignment) {
      return false;
    }

    const blockImageMarginRight = {
      left: "auto",
      center: "auto",
      right: "0",
      start: "unset",
    };

    const blockImageMarginLeft = {
      left: "0",
      center: "auto",
      right: "auto",
      start: "unset",
    };

    if (newTextAlignment === "start") {
      let startAlignButton;
      if (isAppLocaleRTL) {
        startAlignButton = this._doc.querySelector(".right-align-button");
      } else {
        startAlignButton = this._doc.querySelector(".left-align-button");
      }
      startAlignButton.click();
    }

    this._containerElement.style.setProperty(
      "--text-alignment",
      newTextAlignment
    );

    this._containerElement.style.setProperty(
      "--block-img-margin-right",
      blockImageMarginRight[newTextAlignment]
    );

    this._containerElement.style.setProperty(
      "--block-img-margin-left",
      blockImageMarginLeft[newTextAlignment]
    );

    lazy.AsyncPrefs.set("reader.text_alignment", newTextAlignment);
    return true;
  },

  async _resetTextLayout() {
    let doc = this._doc;
    const initial = DEFAULT_TEXT_LAYOUT;
    const changeEvent = new Event("change", { bubbles: true });

    this._resetFontSize();
    let plusButton = this._doc.querySelector(".text-size-plus-button");
    let minusButton = this._doc.querySelector(".text-size-minus-button");
    plusButton.removeAttribute("disabled");
    minusButton.removeAttribute("disabled");

    let fontType = doc.getElementById("font-type-selector");
    fontType.value = initial.fontType;
    fontType.dispatchEvent(changeEvent);

    let fontWeight = doc.getElementById("font-weight-selector");
    fontWeight.value = initial.fontWeight;
    fontWeight.dispatchEvent(changeEvent);

    let contentWidth = doc.querySelector("#content-width-slider moz-slider");
    contentWidth.setAttribute("value", initial.contentWidth);
    this._setContentWidth(initial.contentWidth);

    let lineSpacing = doc.querySelector("#line-spacing-slider moz-slider");
    lineSpacing.setAttribute("value", initial.lineSpacing);
    this._setLineSpacing(initial.lineSpacing);

    let characterSpacing = doc.querySelector(
      "#character-spacing-slider moz-slider"
    );
    characterSpacing.setAttribute("value", initial.characterSpacing);
    this._setCharacterSpacing(initial.characterSpacing);

    let wordSpacing = doc.querySelector("#word-spacing-slider moz-slider");
    wordSpacing.setAttribute("value", initial.wordSpacing);
    this._setWordSpacing(initial.wordSpacing);

    this._setTextAlignment(initial.textAlignment);
  },

  _handleTextLayoutFocus() {
    // Retain focus inside the menu panel.
    let doc = this._doc;
    let accordion = doc.querySelector("#about-reader-advanced-layout");
    let advancedHeader = doc.querySelector(".accordion-header");
    let textResetButton = doc.querySelector(".text-layout-reset-button");
    let textFirstFocusable = doc.querySelector(".text-size-minus-button");

    textResetButton.addEventListener("keydown", e => {
      if (e.key === "Tab" && !e.shiftKey) {
        e.preventDefault();
        textFirstFocusable.focus();
      }
    });
    advancedHeader.addEventListener("keydown", e => {
      if (!accordion.hasAttribute("open") && e.key === "Tab" && !e.shiftKey) {
        e.preventDefault();
        textFirstFocusable.focus();
      }
    });
    textFirstFocusable.addEventListener("keydown", e => {
      if (e.key === "Tab" && e.shiftKey) {
        e.preventDefault();
        if (accordion.hasAttribute("open")) {
          textResetButton.focus();
        } else {
          advancedHeader.focus();
        }
      }
    });
  },

  _setColorScheme(newColorScheme) {
    // There's nothing to change if the new color scheme is the same as our current scheme.
    if (this._colorScheme === newColorScheme) {
      return;
    }

    let bodyClasses = this._doc.body.classList;

    if (this._colorScheme) {
      bodyClasses.remove(this._colorScheme);
    }

    if (!this._win.matchMedia("(forced-colors)").matches) {
      if (newColorScheme === "auto") {
        this._colorScheme = this.colorSchemeMediaList.matches
          ? "dark"
          : "light";
      } else {
        this._colorScheme = newColorScheme;
      }
    } else {
      this._colorScheme = "hcm";
    }

    if (this._colorScheme == "custom") {
      const colorInputs = this._doc.querySelectorAll("moz-input-color");
      colorInputs.forEach(input => {
        // Set document body styles to pref values.
        let property = input.getAttribute("name");
        let pref = `reader.custom_colors.${property}`;
        let customColor = Services.prefs.getStringPref(pref, "");
        // If customColor is truthy, set the value from pref.
        if (customColor) {
          let cssProp = `--custom-theme-${property}`;
          this._doc.body.style.setProperty(cssProp, customColor);
        }
      });
    }

    bodyClasses.add(this._colorScheme);
  },

  // Pref values include "auto", "dark", "light", "sepia",
  // "gray", "contrast", and "custom"
  _setColorSchemePref(colorSchemePref, fromInputEvent = false) {
    if (this._colorScheme == "custom" && fromInputEvent) {
      // The input event for the last selected radio button is fired
      // upon loading a reader article in the same session. To prevent it
      // from overwriting custom colors, we return false.
      lastSelectedTheme = colorSchemePref;
      return false;
    }
    this._setColorScheme(colorSchemePref);

    lazy.AsyncPrefs.set("reader.color_scheme", colorSchemePref);
    return true;
  },

  _handleColorsTabClick(option) {
    let doc = this._doc;
    let deck = doc.querySelector("named-deck");
    if (option == deck.getAttribute("selected-view")) {
      return;
    }

    if (option == "customtheme") {
      this._setColorSchemePref("custom");
      lazy.AsyncPrefs.set("reader.color_scheme", "custom");

      // Store the last selected preset theme button.
      const colorSchemePresets = doc.querySelector(".color-scheme-buttons");
      const labels = colorSchemePresets.querySelectorAll("label");
      labels.forEach(label => {
        if (label.hasAttribute("checked")) {
          lastSelectedTheme = label.className.split("-")[0];
        }
      });
    } else if (option == "fxtheme") {
      this._setColorSchemePref(lastSelectedTheme);
      lazy.AsyncPrefs.set("reader.color_scheme", lastSelectedTheme);
      // set the last selected button to checked.
      const colorSchemePresets = doc.querySelector(".color-scheme-buttons");
      const labels = colorSchemePresets.querySelectorAll("label");
      labels.forEach(label => {
        if (label.className == `${lastSelectedTheme}-button`) {
          label.setAttribute("checked", "true");
          label.previousElementSibling.setAttribute("checked", "true");
        }
      });
    }
  },

  _setupColorsTabs(options, callback) {
    let doc = this._doc;
    let colorScheme = Services.prefs.getCharPref("reader.color_scheme");
    for (let option of options) {
      let tabButton = doc.getElementById(`tabs-deck-button-${option}`);
      // Open custom theme tab if color scheme is set to custom.
      if (option == "customtheme" && colorScheme == "custom") {
        tabButton.click();
      }
      tabButton.addEventListener(
        "click",
        function (aEvent) {
          if (!aEvent.isTrusted) {
            return;
          }

          callback(option);
        },
        true
      );
    }
  },

  _setupColorInput(prop) {
    let doc = this._doc;
    let input = doc.createElement("moz-input-color");
    input.setAttribute("name", prop);
    let labelL10nId = `about-reader-custom-colors-${prop}2`;
    input.setAttribute("data-l10n-id", labelL10nId);

    let pref = `reader.custom_colors.${prop}`;
    let customColor = Services.prefs.getStringPref(pref, "");
    // Set the swatch color from prefs if one has been set.
    let defaultColor = customColor || DEFAULT_COLORS[prop];
    input.setAttribute("value", defaultColor);

    // Attach event listener to update the pref and page colors on input.
    input.addEventListener("input", e => {
      const cssPropToUpdate = `--custom-theme-${prop}`;
      const { value: updatedValue } = e.originalTarget;
      this._doc.body.style.setProperty(cssPropToUpdate, updatedValue);

      const prefToUpdate = `reader.custom_colors.${prop}`;
      lazy.AsyncPrefs.set(prefToUpdate, updatedValue);

      if (updatedValue != DEFAULT_COLORS[prop].toLowerCase()) {
        this._toggleColorsResetButton(true);
      }
    });

    return input;
  },

  _setupCustomColors(options, id) {
    let doc = this._doc;
    const list = doc.getElementsByClassName(id)[0];
    let isCustom = false;

    for (let option of options) {
      let listItem = doc.createElement("li");
      let colorInput = this._setupColorInput(option);
      listItem.appendChild(colorInput);
      list.appendChild(listItem);

      // Verify that user preferences exist and use custom colors.
      let pref = `reader.custom_colors.${option}`;
      let customColor = Services.prefs.getStringPref(pref, "");
      if (
        customColor &&
        customColor.toLowerCase() !== DEFAULT_COLORS[option].toLowerCase()
      ) {
        isCustom = true;
      }
    }
    this._toggleColorsResetButton(isCustom);
  },

  _resetCustomColors() {
    // Need to reset prefs, page colors, and color inputs.
    const colorInputs = this._doc.querySelectorAll("moz-input-color");
    colorInputs.forEach(input => {
      let property = input.getAttribute("name");
      let pref = `reader.custom_colors.${property}`;
      lazy.AsyncPrefs.set(pref, "");

      // Set css props to empty strings so they use fallback value.
      let cssProp = `--custom-theme-${property}`;
      this._doc.body.style.setProperty(cssProp, "");

      let defaultColor = DEFAULT_COLORS[property];
      input.setAttribute("value", defaultColor);
    });
    this._toggleColorsResetButton(false);
  },

  _toggleColorsResetButton(visible) {
    let button = this._doc.querySelector(".custom-colors-reset-button");
    button.hidden = !visible;
  },

  _handleThemeFocus() {
    // Retain focus inside the menu panel.
    let doc = this._doc;
    let themeButtons = doc.querySelector(".color-scheme-buttons");
    let defaultThemeFirstFocusable = doc.querySelector(
      "#tabs-deck-button-fxtheme"
    );
    let themeResetButton = doc.querySelector(".custom-colors-reset-button");
    let customThemeFirstFocusable = doc.querySelector(
      "#tabs-deck-button-customtheme"
    );

    themeButtons.addEventListener("keydown", e => {
      if (e.key === "Tab" && !e.shiftKey) {
        e.preventDefault();
        defaultThemeFirstFocusable.focus();
      }
    });
    themeResetButton.addEventListener("keydown", e => {
      if (e.key === "Tab" && !e.shiftKey) {
        e.preventDefault();
        customThemeFirstFocusable.focus();
      }
    });
    defaultThemeFirstFocusable.addEventListener("keydown", e => {
      if (e.key === "Tab" && e.shiftKey) {
        e.preventDefault();
        let themeLabels = themeButtons.getElementsByTagName("label");
        for (const label of themeLabels) {
          if (label.hasAttribute("checked")) {
            doc.querySelector(`.${label.className}`).focus();
          }
        }
      }
    });
    customThemeFirstFocusable.addEventListener("keydown", e => {
      if (e.key === "Tab" && e.shiftKey) {
        e.preventDefault();
        themeResetButton.focus();
      }
    });
  },

  async _loadArticle(docContentType = "document") {
    let url = this._getOriginalUrl();
    this._showProgressDelayed();

    let article;
    if (this._articlePromise) {
      article = await this._articlePromise;
    }

    if (!article) {
      try {
        article = await ReaderMode.downloadAndParseDocument(
          url,
          { ...this._doc.nodePrincipal?.originAttributes },
          docContentType
        );
      } catch (e) {
        if (e?.newURL && this._actor) {
          await this._actor.sendQuery("RedirectTo", {
            newURL: e.newURL,
            article: e.article,
          });

          let readerURL = "about:reader?url=" + encodeURIComponent(e.newURL);
          this._win.location.replace(readerURL);
          return;
        }
      }
    }

    if (!this._actor) {
      return;
    }

    // Replace the loading message with an error message if there's a failure.
    // Users are supposed to navigate away by themselves (because we cannot
    // remove ourselves from session history.)
    if (!article) {
      this._showError();
      return;
    }

    this._showContent(article);
  },

  async _requestFavicon() {
    let iconDetails = await this._actor.sendQuery("Reader:FaviconRequest", {
      url: this._article.url,
      preferredWidth: 16 * this._win.devicePixelRatio,
    });

    if (iconDetails) {
      this._loadFavicon(iconDetails.url, iconDetails.faviconUrl);
    }
  },

  _loadFavicon(url, faviconUrl) {
    if (this._article.url !== url) {
      return;
    }

    let doc = this._doc;

    let link = doc.createElement("link");
    link.rel = "shortcut icon";
    link.href = faviconUrl;

    doc.getElementsByTagName("head")[0].appendChild(link);
  },

  _updateImageMargins() {
    let windowWidth = this._win.innerWidth;
    let bodyWidth = this._doc.body.clientWidth;

    let setImageMargins = function (img) {
      img.classList.add("moz-reader-block-img");

      // If the image is at least as wide as the window, make it fill edge-to-edge on mobile.
      if (img.naturalWidth >= windowWidth) {
        img.setAttribute("moz-reader-full-width", true);
      } else {
        img.removeAttribute("moz-reader-full-width");
      }

      // If the image is at least half as wide as the body, center it on desktop.
      if (img.naturalWidth >= bodyWidth / 2) {
        img.setAttribute("moz-reader-center", true);
      } else {
        img.removeAttribute("moz-reader-center");
      }
    };

    let imgs = this._doc.querySelectorAll(this._BLOCK_IMAGES_SELECTOR);
    for (let i = imgs.length; --i >= 0; ) {
      let img = imgs[i];

      if (img.naturalWidth > 0) {
        setImageMargins(img);
      } else {
        img.onload = function () {
          setImageMargins(img);
        };
      }
    }
  },

  _updateWideTables() {
    let windowWidth = this._win.innerWidth;

    // Avoid horizontal overflow in the document by making tables that are wider than half browser window's size
    // by making it scrollable.
    let tables = this._doc.querySelectorAll(this._TABLES_SELECTOR);
    for (let i = tables.length; --i >= 0; ) {
      let table = tables[i];
      let rect = table.getBoundingClientRect();
      let tableWidth = rect.width;

      if (windowWidth / 2 <= tableWidth) {
        table.classList.add("moz-reader-wide-table");
      }
    }
  },

  _maybeSetTextDirection: function Read_maybeSetTextDirection(article) {
    // Set the article's "dir" on the contents.
    // If no direction is specified, the contents should automatically be LTR
    // regardless of the UI direction to avoid inheriting the parent's direction
    // if the UI is RTL.
    this._containerElement.dir = article.dir || "ltr";

    // The native locale could be set differently than the article's text direction.
    this._readTimeElement.dir = isAppLocaleRTL ? "rtl" : "ltr";

    // This is used to mirror the line height buttons in the toolbar, when relevant.
    this._toolbarElement.setAttribute("articledir", article.dir || "ltr");
  },

  _showError() {
    this._headerElement.classList.remove("reader-show-element");
    this._contentElement.classList.remove("reader-show-element");

    this._doc.l10n.setAttributes(
      this._messageElement,
      "about-reader-load-error"
    );
    this._doc.l10n.setAttributes(
      this._doc.getElementById("reader-title"),
      "about-reader-load-error"
    );
    this._messageElement.style.display = "block";

    this._doc.documentElement.dataset.isError = true;

    this._error = true;

    this._doc.dispatchEvent(
      new this._win.CustomEvent("AboutReaderContentError", {
        bubbles: true,
        cancelable: false,
      })
    );
  },

  // This function is the JS version of Java's StringUtils.stripCommonSubdomains.
  _stripHost(host) {
    if (!host) {
      return host;
    }

    let start = 0;

    if (host.startsWith("www.")) {
      start = 4;
    } else if (host.startsWith("m.")) {
      start = 2;
    } else if (host.startsWith("mobile.")) {
      start = 7;
    }

    return host.substring(start);
  },

  _showContent(article) {
    this._messageElement.classList.remove("reader-show-element");

    this._article = article;

    this._domainElement.href = article.url;
    let articleUri = Services.io.newURI(article.url);

    try {
      this._domainElement.textContent = this._stripHost(articleUri.host);
    } catch (ex) {
      let url = this._actor.document.URL;
      url = url.substring(url.indexOf("%2F") + 6);
      url = url.substring(0, url.indexOf("%2F"));

      this._domainElement.textContent = url;
    }

    this._creditsElement.textContent = article.byline;

    this._titleElement.textContent = article.title;

    const slow = article.readingTimeMinsSlow;
    const fast = article.readingTimeMinsFast;

    // Display times in hours if either is at least 120 minutes.
    if (slow < 120) {
      const readingTimeRange = new Intl.NumberFormat(undefined, {
        style: "unit",
        unit: "minute",
        unitDisplay: "long",
      }).formatRange(fast, slow);

      this._readTimeElement.textContent = readingTimeRange;
    } else {
      const fastHours = Math.round(fast / 60);
      const slowHours = Math.round(slow / 60);

      const readingTimeRange = new Intl.NumberFormat(undefined, {
        style: "unit",
        unit: "hour",
        unitDisplay: "long",
      }).formatRange(fastHours, slowHours);

      this._readTimeElement.textContent = readingTimeRange;
    }

    // If a document title was not provided in the constructor, we'll fall back
    // to using the article title.
    if (!this._doc.title) {
      this._doc.title = article.title;
    }

    let lang = article.lang ?? article.detectedLanguage;
    if (lang) {
      this._containerElement.setAttribute("lang", lang);
    }

    this._headerElement.classList.add("reader-show-element");

    let parserUtils = Cc["@mozilla.org/parserutils;1"].getService(
      Ci.nsIParserUtils
    );
    let contentFragment = parserUtils.parseFragment(
      article.content,
      Ci.nsIParserUtils.SanitizerDropForms |
        Ci.nsIParserUtils.SanitizerAllowStyle,
      false,
      articleUri,
      this._contentElement
    );
    this._contentElement.innerHTML = "";
    this._contentElement.appendChild(contentFragment);
    this._maybeSetTextDirection(article);
    this._languageDeferred.resolve(article.detectedLanguage);

    if (article.textPlainDoc) {
      this._contentElement.classList.add("plain-text-doc");
    }
    this._contentElement.classList.add("reader-show-element");
    this._updateImageMargins();
    this._updateWideTables();

    this._requestFavicon();
    this._doc.body.classList.add("loaded");

    this._goToReference(articleUri.ref);
    this._getScrollPosition();

    Services.obs.notifyObservers(this._win, "AboutReader:Ready");

    this._doc.dispatchEvent(
      new this._win.CustomEvent("AboutReaderContentReady", {
        bubbles: true,
        cancelable: false,
      })
    );
  },

  _hideContent() {
    this._headerElement.classList.remove("reader-show-element");
    this._contentElement.classList.remove("reader-show-element");
  },

  _showProgressDelayed() {
    this._win.setTimeout(() => {
      // No need to show progress if the article has been loaded,
      // if the window has been unloaded, or if there was an error
      // trying to load the article.
      if (this._article || !this._actor || this._error) {
        return;
      }

      this._headerElement.classList.remove("reader-show-element");
      this._contentElement.classList.remove("reader-show-element");

      this._doc.l10n.setAttributes(
        this._messageElement,
        "about-reader-loading"
      );
      this._messageElement.classList.add("reader-show-element");
    }, 300);
  },

  /**
   * Returns the original article URL for this about:reader view.
   */
  _getOriginalUrl(win) {
    let url = win ? win.location.href : this._win.location.href;
    return ReaderMode.getOriginalUrl(url) || url;
  },

  _setupSegmentedButton(id, options, initialValue, callback) {
    let doc = this._doc;
    let segmentedButton = doc.getElementsByClassName(id)[0];

    for (let option of options) {
      let radioButton = doc.createElement("input");
      radioButton.id = "radio-item" + option.itemClass;
      radioButton.type = "radio";
      radioButton.classList.add("radio-button");
      radioButton.name = option.groupName;
      radioButton.setAttribute("data-telemetry-id", option.itemClass);
      segmentedButton.appendChild(radioButton);

      let item = doc.createElement("label");
      item.htmlFor = radioButton.id;
      item.classList.add(option.itemClass);
      doc.l10n.setAttributes(item, option.l10nId);

      segmentedButton.appendChild(item);

      radioButton.addEventListener(
        "input",
        function (aEvent) {
          if (!aEvent.isTrusted) {
            return;
          }

          let labels = segmentedButton.children;
          for (let label of labels) {
            label.removeAttribute("checked");
          }

          let setOption = callback(option.value, true);
          if (setOption) {
            aEvent.target.setAttribute("checked", "true");
            aEvent.target.nextElementSibling.setAttribute("checked", "true");
          }
        },
        true
      );

      if (option.value === initialValue) {
        radioButton.setAttribute("checked", "true");
        item.setAttribute("checked", "true");
      }
    }
  },

  _setupButton(id, callback) {
    let button = this._doc.querySelector("." + id);
    button.addEventListener(
      "click",
      function (aEvent) {
        if (!aEvent.isTrusted) {
          return;
        }

        let btn = aEvent.target;
        callback(btn);
      },
      true
    );
  },

  _toggleDropdownClicked(event) {
    let dropdown = event.target.closest(".dropdown");

    if (!dropdown) {
      return;
    }

    event.stopPropagation();

    if (dropdown.classList.contains("open")) {
      this._closeDropdowns();
    } else {
      this._openDropdown(dropdown);
    }
  },

  /*
   * If the ReaderView banner font-dropdown is closed, open it.
   */
  _openDropdown(dropdown) {
    if (dropdown.classList.contains("open")) {
      return;
    }

    this._closeDropdowns();

    // Get the height of the doc and start handling scrolling:
    let { windowUtils } = this._win;
    this._lastHeight = windowUtils.getBoundsWithoutFlushing(
      this._doc.body
    ).height;
    this._doc.addEventListener("scroll", this);

    dropdown.classList.add("open");
    this._toolbarElement.classList.add("dropdown-open");

    this._toolbarContainerElement.classList.add("dropdown-open");
    this._toggleToolbarFixedPosition(true);
  },

  /*
   * If the ReaderView has open dropdowns, close them. If we are closing the
   * dropdowns because the page is scrolling, allow popups to stay open with
   * the keep-open class.
   */
  _closeDropdowns(scrolling) {
    let selector = ".dropdown.open";
    if (scrolling) {
      selector += ":not(.keep-open)";
    }

    let openDropdowns = this._doc.querySelectorAll(selector);
    let haveOpenDropdowns = openDropdowns.length;
    for (let dropdown of openDropdowns) {
      dropdown.classList.remove("open");
    }
    this._toolbarElement.classList.remove("dropdown-open");

    if (haveOpenDropdowns) {
      this._toolbarContainerElement.classList.remove("dropdown-open");
      this._toggleToolbarFixedPosition(false);
    }

    // Stop handling scrolling:
    this._doc.removeEventListener("scroll", this);
  },

  _toggleToolbarFixedPosition(shouldBeFixed) {
    let el = this._toolbarContainerElement;
    let fontSize = this._doc.body.style.getPropertyValue("--font-size");
    let contentWidth = this._doc.body.style.getPropertyValue("--content-width");
    if (shouldBeFixed) {
      el.style.setProperty("--font-size", fontSize);
      el.style.setProperty("--content-width", contentWidth);
      el.classList.add("transition-location");
    } else {
      let expectTransition =
        el.style.getPropertyValue("--font-size") != fontSize ||
        el.style.getPropertyValue("--content-width") != contentWidth;
      if (expectTransition) {
        el.addEventListener(
          "transitionend",
          () => el.classList.remove("transition-location"),
          { once: true }
        );
      } else {
        el.classList.remove("transition-location");
      }
      el.style.removeProperty("--font-size");
      el.style.removeProperty("--content-width");
      el.classList.remove("overlaps");
    }
  },

  _scheduleToolbarOverlapHandler() {
    if (this._enqueuedToolbarOverlapHandler) {
      return;
    }
    this._enqueuedToolbarOverlapHandler = this._win.requestAnimationFrame(
      () => {
        this._win.setTimeout(() => this._toolbarOverlapHandler(), 0);
      }
    );
  },

  _toolbarOverlapHandler() {
    delete this._enqueuedToolbarOverlapHandler;
    // Ensure the dropdown is still open to avoid racing with that changing.
    if (this._toolbarContainerElement.classList.contains("dropdown-open")) {
      let { windowUtils } = this._win;
      let toolbarBounds = windowUtils.getBoundsWithoutFlushing(
        this._toolbarElement.parentNode
      );
      let textBounds = windowUtils.getBoundsWithoutFlushing(
        this._containerElement
      );
      let overlaps = false;
      if (isAppLocaleRTL) {
        overlaps = textBounds.right > toolbarBounds.left;
      } else {
        overlaps = textBounds.left < toolbarBounds.right;
      }
      this._toolbarContainerElement.classList.toggle("overlaps", overlaps);
    }
  },

  _topScrollChange(entries) {
    if (!entries.length) {
      return;
    }
    // If we don't intersect the item at the top of the document, we're
    // scrolled down:
    let scrolled = !entries[entries.length - 1].isIntersecting;
    let tbc = this._toolbarContainerElement;
    tbc.classList.toggle("scrolled", scrolled);
  },

  /*
   * Scroll reader view to a reference
   */
  _goToReference(ref) {
    if (ref) {
      if (this._doc.readyState == "complete") {
        this._win.location.hash = ref;
      } else {
        this._win.addEventListener(
          "load",
          () => {
            this._win.location.hash = ref;
          },
          { once: true }
        );
      }
    }
  },

  _scrollToSavedPosition(pos) {
    this._win.scrollTo({
      top: pos,
      left: 0,
      behavior: "auto",
    });
    gScrollPositions.delete(this._win.location.href);
  },

  /*
   * Save reader view vertical scroll position
   */
  _saveScrollPosition() {
    let scrollTop = this._doc.documentElement.scrollTop;
    gScrollPositions.set(this._win.location.href, scrollTop);
  },

  /*
   * Scroll reader view to a saved position
   */
  _getScrollPosition() {
    let scrollPosition = gScrollPositions.get(this._win.location.href);
    if (scrollPosition !== undefined) {
      if (this._doc.readyState == "complete") {
        this._scrollToSavedPosition(scrollPosition);
      } else {
        this._win.addEventListener(
          "load",
          () => {
            this._scrollToSavedPosition(scrollPosition);
          },
          { once: true }
        );
      }
    }
  },
};
