/* 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, "numberFormat", () => new Services.intl.NumberFormat(undefined) ); ChromeUtils.defineLazyGetter( lazy, "pluralRules", () => new Services.intl.PluralRules(undefined) ); 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; const fastStr = lazy.numberFormat.format(fast); const readTimeRange = lazy.numberFormat.formatRange(fast, slow); this._doc.l10n.setAttributes( this._readTimeElement, "about-reader-estimated-read-time", { range: fast === slow ? `~${fastStr}` : `${readTimeRange}`, rangePlural: fast === slow ? lazy.pluralRules.select(fast) : lazy.pluralRules.selectRange(fast, slow), } ); // 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); 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 } ); } } }, };