/* 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/. */ "use strict"; var Ci = Components.interfaces, Cc = Components.classes, Cu = Components.utils; this.EXPORTED_SYMBOLS = [ "AboutReader" ]; Cu.import("resource://gre/modules/ReaderMode.jsm"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "AsyncPrefs", "resource://gre/modules/AsyncPrefs.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "NarrateControls", "resource://gre/modules/narrate/NarrateControls.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PluralForm", "resource://gre/modules/PluralForm.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", "resource://gre/modules/PlacesUtils.jsm"); var gStrings = Services.strings.createBundle("chrome://global/locale/aboutReader.properties"); var AboutReader = function(win, articlePromise) { let url = this._getOriginalUrl(win); if (!(url.startsWith("http://") || url.startsWith("https://"))) { let errorMsg = "Only http:// and https:// URLs can be loaded in about:reader."; if (Services.prefs.getBoolPref("reader.errors.includeURLs")) errorMsg += " Tried to load: " + url + "."; Cu.reportError(errorMsg); win.location.href = "about:blank"; return; } let doc = win.document; this._docRef = Cu.getWeakReference(doc); this._winRef = Cu.getWeakReference(win); this._innerWindowId = win.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID; this._article = null; this._languagePromise = new Promise(resolve => { this._foundLanguage = resolve; }); 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._toolbarElementRef = Cu.getWeakReference(doc.querySelector(".reader-toolbar")); this._messageElementRef = Cu.getWeakReference(doc.querySelector(".reader-message")); this._containerElementRef = Cu.getWeakReference(doc.querySelector(".container")); this._scrollOffset = win.pageYOffset; doc.addEventListener("mousedown", this); doc.addEventListener("click", this); win.addEventListener("pagehide", this); win.addEventListener("scroll", this); win.addEventListener("resize", this); win.addEventListener("AboutReaderAddButton", this, false, true); win.addEventListener("AboutReaderRemoveButton", this, false, true); Services.obs.addObserver(this, "inner-window-destroyed", false); this._setupStyleDropdown(); this._setupButton("close-button", this._onReaderClose.bind(this), "aboutReader.toolbar.close"); // we're ready for any external setup, send a signal for that. doc.dispatchEvent( new win.CustomEvent("AboutReaderOnSetup", { bubbles: true, cancelable: false })); let colorSchemeValues = JSON.parse(Services.prefs.getCharPref("reader.color_scheme.values")); let colorSchemeOptions = colorSchemeValues.map((value) => { return { name: gStrings.GetStringFromName("aboutReader.colorScheme." + value), value, itemClass: value + "-button" }; }); let colorScheme = Services.prefs.getCharPref("reader.color_scheme"); this._setupSegmentedButton("color-scheme-buttons", colorSchemeOptions, colorScheme, this._setColorSchemePref.bind(this)); this._setColorSchemePref(colorScheme); let fontTypeSample = gStrings.GetStringFromName("aboutReader.fontTypeSample"); let fontTypeOptions = [ { name: fontTypeSample, description: gStrings.GetStringFromName("aboutReader.fontType.sans-serif"), value: "sans-serif", itemClass: "sans-serif-button" }, { name: fontTypeSample, description: gStrings.GetStringFromName("aboutReader.fontType.serif"), value: "serif", itemClass: "serif-button" }, ]; let fontType = Services.prefs.getCharPref("reader.font_type"); this._setupSegmentedButton("font-type-buttons", fontTypeOptions, fontType, this._setFontType.bind(this)); this._setFontType(fontType); this._setupFontSizeButtons(); this._setupContentWidthButtons(); this._setupLineHeightButtons(); if (win.speechSynthesis && Services.prefs.getBoolPref("narrate.enabled")) { new NarrateControls(win, this._languagePromise); } this._loadArticle(); let dropdown = this._toolbarElement; let elemL10nMap = { ".minus-button": "minus", ".plus-button": "plus", ".content-width-minus-button": "contentwidthminus", ".content-width-plus-button": "contentwidthplus", ".line-height-minus-button": "lineheightminus", ".line-height-plus-button": "lineheightplus", ".light-button": "colorschemelight", ".dark-button": "colorschemedark", ".sepia-button": "colorschemesepia", }; for (let [selector, stringID] of Object.entries(elemL10nMap)) { dropdown.querySelector(selector).setAttribute("title", gStrings.GetStringFromName("aboutReader.toolbar." + stringID)); } }; 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", 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 _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"); }, // Provides unique view Id. get viewId() { let _viewId = Cc["@mozilla.org/uuid-generator;1"]. getService(Ci.nsIUUIDGenerator).generateUUID().toString(); Object.defineProperty(this, "viewId", { value: _viewId }); return _viewId; }, handleEvent(aEvent) { if (!aEvent.isTrusted) return; let target = aEvent.target; switch (aEvent.type) { case "mousedown": if (!target.closest(".dropdown-popup")) { this._closeDropdowns(); } break; case "click": if (target.classList.contains("dropdown-toggle")) { this._toggleDropdownClicked(aEvent); } break; case "scroll": this._closeDropdowns(true); this._scrollOffset = aEvent.pageY; break; case "resize": this._updateImageMargins(); if (this._isToolbarVertical) { this._win.setTimeout(() => { for (let dropdown of this._doc.querySelectorAll(".dropdown.open")) { this._updatePopupPosition(dropdown); } }, 0); } break; case "pagehide": // Close the Banners Font-dropdown, cleanup BackPressListener. this._closeDropdowns(); this._windowUnloaded = true; break; case "AboutReaderAddButton": { if (aEvent.detail.id && aEvent.detail.image && !this._doc.getElementById(aEvent.detail.id)) { let btn = this._doc.createElement("button"); btn.setAttribute("class", "button " + aEvent.detail.id); btn.setAttribute("style", "background-image: url('" + aEvent.detail.image + "')"); btn.setAttribute("id", aEvent.detail.id); if (aEvent.detail.title) btn.setAttribute("title", aEvent.detail.title); if (aEvent.detail.text) btn.textContent = aEvent.detail.text; let tb = this._toolbarElement; tb.appendChild(btn); this._setupButton(aEvent.detail.id, button => { var data = { article: this._article }; this._doc.dispatchEvent( new this._win.CustomEvent("AboutReaderButtonClicked-" + button.getAttribute("id"), {detail: data, bubbles: true, cancelable: false})); }); } break; } case "AboutReaderRemoveButton": { if (aEvent.detail.id) { let btn = this._doc.getElementById(aEvent.detail.id); if (btn) btn.remove(); } break; } } }, observe(subject, topic, data) { if (subject.QueryInterface(Ci.nsISupportsPRUint64).data != this._innerWindowId) { return; } Services.obs.removeObserver(this, "inner-window-destroyed"); this._windowUnloaded = true; }, _onReaderClose() { ReaderMode.leaveReaderMode(this._win.document.docShell, this._win); }, _setFontSize(newFontSize) { this._fontSize = newFontSize; let size = (10 + 2 * this._fontSize) + "px"; this._containerElement.style.setProperty("--font-size", size); return AsyncPrefs.set("reader.font_size", this._fontSize); }, _setupFontSizeButtons() { const FONT_SIZE_MIN = 1; const FONT_SIZE_MAX = 9; let currentSize = Services.prefs.getIntPref("reader.font_size"); currentSize = Math.max(FONT_SIZE_MIN, Math.min(FONT_SIZE_MAX, currentSize)); let plusButton = this._doc.querySelector(".plus-button"); let minusButton = this._doc.querySelector(".minus-button"); function updateControls() { if (currentSize === FONT_SIZE_MIN) { minusButton.setAttribute("disabled", true); } else { minusButton.removeAttribute("disabled"); } if (currentSize === FONT_SIZE_MAX) { plusButton.setAttribute("disabled", true); } else { plusButton.removeAttribute("disabled"); } } updateControls(); this._setFontSize(currentSize); plusButton.addEventListener("click", (event) => { if (!event.isTrusted) { return; } event.stopPropagation(); if (currentSize >= FONT_SIZE_MAX) { return; } currentSize++; updateControls(); this._setFontSize(currentSize); }, true); minusButton.addEventListener("click", (event) => { if (!event.isTrusted) { return; } event.stopPropagation(); if (currentSize <= FONT_SIZE_MIN) { return; } currentSize--; updateControls(); this._setFontSize(currentSize); }, true); }, _setContentWidth(newContentWidth) { let containerClasses = this._containerElement.classList; if (this._contentWidth > 0) containerClasses.remove("content-width" + this._contentWidth); this._contentWidth = newContentWidth; containerClasses.add("content-width" + this._contentWidth); return AsyncPrefs.set("reader.content_width", this._contentWidth); }, _setupContentWidthButtons() { const CONTENT_WIDTH_MIN = 1; const CONTENT_WIDTH_MAX = 9; let currentContentWidth = Services.prefs.getIntPref("reader.content_width"); currentContentWidth = Math.max(CONTENT_WIDTH_MIN, Math.min(CONTENT_WIDTH_MAX, currentContentWidth)); let plusButton = this._doc.querySelector(".content-width-plus-button"); let minusButton = this._doc.querySelector(".content-width-minus-button"); function updateControls() { if (currentContentWidth === CONTENT_WIDTH_MIN) { minusButton.setAttribute("disabled", true); } else { minusButton.removeAttribute("disabled"); } if (currentContentWidth === CONTENT_WIDTH_MAX) { plusButton.setAttribute("disabled", true); } else { plusButton.removeAttribute("disabled"); } } updateControls(); this._setContentWidth(currentContentWidth); plusButton.addEventListener("click", (event) => { if (!event.isTrusted) { return; } event.stopPropagation(); if (currentContentWidth >= CONTENT_WIDTH_MAX) { return; } currentContentWidth++; updateControls(); this._setContentWidth(currentContentWidth); }, true); minusButton.addEventListener("click", (event) => { if (!event.isTrusted) { return; } event.stopPropagation(); if (currentContentWidth <= CONTENT_WIDTH_MIN) { return; } currentContentWidth--; updateControls(); this._setContentWidth(currentContentWidth); }, true); }, _setLineHeight(newLineHeight) { let contentClasses = this._contentElement.classList; if (this._lineHeight > 0) contentClasses.remove("line-height" + this._lineHeight); this._lineHeight = newLineHeight; contentClasses.add("line-height" + this._lineHeight); return AsyncPrefs.set("reader.line_height", this._lineHeight); }, _setupLineHeightButtons() { const LINE_HEIGHT_MIN = 1; const LINE_HEIGHT_MAX = 9; let currentLineHeight = Services.prefs.getIntPref("reader.line_height"); currentLineHeight = Math.max(LINE_HEIGHT_MIN, Math.min(LINE_HEIGHT_MAX, currentLineHeight)); let plusButton = this._doc.querySelector(".line-height-plus-button"); let minusButton = this._doc.querySelector(".line-height-minus-button"); function updateControls() { if (currentLineHeight === LINE_HEIGHT_MIN) { minusButton.setAttribute("disabled", true); } else { minusButton.removeAttribute("disabled"); } if (currentLineHeight === LINE_HEIGHT_MAX) { plusButton.setAttribute("disabled", true); } else { plusButton.removeAttribute("disabled"); } } updateControls(); this._setLineHeight(currentLineHeight); plusButton.addEventListener("click", (event) => { if (!event.isTrusted) { return; } event.stopPropagation(); if (currentLineHeight >= LINE_HEIGHT_MAX) { return; } currentLineHeight++; updateControls(); this._setLineHeight(currentLineHeight); }, true); minusButton.addEventListener("click", (event) => { if (!event.isTrusted) { return; } event.stopPropagation(); if (currentLineHeight <= LINE_HEIGHT_MIN) { return; } currentLineHeight--; updateControls(); this._setLineHeight(currentLineHeight); }, true); }, _setColorScheme(newColorScheme) { if (this._colorScheme === newColorScheme) return; let bodyClasses = this._doc.body.classList; if (this._colorScheme) bodyClasses.remove(this._colorScheme); this._colorScheme = newColorScheme; bodyClasses.add(this._colorScheme); }, // Pref values include "dark", "light", and "sepia". _setColorSchemePref(colorSchemePref) { this._setColorScheme(colorSchemePref); AsyncPrefs.set("reader.color_scheme", colorSchemePref); }, _setFontType(newFontType) { if (this._fontType === newFontType) return; let bodyClasses = this._doc.body.classList; if (this._fontType) bodyClasses.remove(this._fontType); this._fontType = newFontType; bodyClasses.add(this._fontType); AsyncPrefs.set("reader.font_type", this._fontType); }, _setToolbarVisibility(visible) { let tb = this._toolbarElement; if (visible) { if (tb.style.opacity != "1") { tb.removeAttribute("hidden"); tb.style.opacity = "1"; } } else if (tb.style.opacity != "0") { tb.addEventListener("transitionend", evt => { if (tb.style.opacity == "0") { tb.setAttribute("hidden", ""); } }, { once: true }); tb.style.opacity = "0"; } }, async _loadArticle() { let url = this._getOriginalUrl(); this._showProgressDelayed(); let article; if (this._articlePromise) { article = await this._articlePromise; } else { try { article = await this._getArticle(url); } catch (e) { if (e && e.newURL) { let readerURL = "about:reader?url=" + encodeURIComponent(e.newURL); this._win.location.replace(readerURL); return; } } } if (this._windowUnloaded) { 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); }, _getArticle(url) { return ReaderMode.downloadAndParseDocument(url); }, _requestFavicon() { let faviconUrl = PlacesUtils.promiseFaviconLinkUrl(this._article.url); var self = this; faviconUrl.then(function onResolution(favicon) { self._loadFavicon(self._article.url, favicon.path.replace(/^favicon:/, "")); }, function onRejection(reason) { Cu.reportError("Error requesting favicon URL for about:reader content: " + reason); }).catch(Cu.reportError); }, _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) { // 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); }; } } }, _maybeSetTextDirection: function Read_maybeSetTextDirection(article) { if (article.dir) { // Set "dir" attribute on content this._contentElement.setAttribute("dir", article.dir); this._headerElement.setAttribute("dir", article.dir); // The native locale could be set differently than the article's text direction. var localeDirection = Services.locale.isAppLocaleRTL ? "rtl" : "ltr"; this._readTimeElement.setAttribute("dir", localeDirection); this._readTimeElement.style.textAlign = article.dir == "rtl" ? "right" : "left"; } }, _fixLocalLinks() { // We need to do this because preprocessing the content through nsIParserUtils // gives back a DOM with a element. That influences how these URLs get // resolved, making them no longer match the document URI (which is // about:reader?url=...). To fix this, make all the hash URIs absolute. This // is hacky, but the alternative of removing the base element has potential // security implications if Readability has not successfully made all the URLs // absolute, so we pick just fixing these in-document links explicitly. let localLinks = this._contentElement.querySelectorAll("a[href^='#']"); for (let localLink of localLinks) { // Have to get the attribute because .href provides an absolute URI. localLink.href = this._doc.documentURI + localLink.getAttribute("href"); } }, _formatReadTime(slowEstimate, fastEstimate) { let displayStringKey = "aboutReader.estimatedReadTimeRange1"; // only show one reading estimate when they are the same value if (slowEstimate == fastEstimate) { displayStringKey = "aboutReader.estimatedReadTimeValue1"; } return PluralForm.get(slowEstimate, gStrings.GetStringFromName(displayStringKey)) .replace("#1", fastEstimate) .replace("#2", slowEstimate); }, _showError() { this._headerElement.style.display = "none"; this._contentElement.style.display = "none"; let errorMessage = gStrings.GetStringFromName("aboutReader.loadError"); this._messageElement.textContent = errorMessage; this._messageElement.style.display = "block"; this._doc.title = errorMessage; 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.style.display = "none"; this._article = article; this._domainElement.href = article.url; let articleUri = Services.io.newURI(article.url); this._domainElement.textContent = this._stripHost(articleUri.host); this._creditsElement.textContent = article.byline; this._titleElement.textContent = article.title; this._readTimeElement.textContent = this._formatReadTime(article.readingTimeMinsSlow, article.readingTimeMinsFast); this._doc.title = article.title; this._headerElement.style.display = "block"; 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._fixLocalLinks(); this._maybeSetTextDirection(article); this._foundLanguage(article.language); this._contentElement.style.display = "block"; this._updateImageMargins(); this._requestFavicon(); this._doc.body.classList.add("loaded"); this._goToReference(articleUri.ref); Services.obs.notifyObservers(this._win, "AboutReader:Ready", ""); this._doc.dispatchEvent( new this._win.CustomEvent("AboutReaderContentReady", { bubbles: true, cancelable: false })); }, _hideContent() { this._headerElement.style.display = "none"; this._contentElement.style.display = "none"; }, _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._windowUnloaded || this._error) { return; } this._headerElement.style.display = "none"; this._contentElement.style.display = "none"; this._messageElement.textContent = gStrings.GetStringFromName("aboutReader.loading2"); this._messageElement.style.display = "block"; }, 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 i = 0; i < options.length; i++) { let option = options[i]; let item = doc.createElement("button"); // Put the name in a div so that it can be hidden if desired. let div = doc.createElement("div"); div.textContent = option.name; div.classList.add("name"); item.appendChild(div); if (option.itemClass !== undefined) item.classList.add(option.itemClass); if (option.description !== undefined) { let description = doc.createElement("div"); description.textContent = option.description; description.classList.add("description"); item.appendChild(description); } segmentedButton.appendChild(item); item.addEventListener("click", function(aEvent) { if (!aEvent.isTrusted) return; aEvent.stopPropagation(); let items = segmentedButton.children; for (let j = items.length - 1; j >= 0; j--) { items[j].classList.remove("selected"); } item.classList.add("selected"); callback(option.value); }, true); if (option.value === initialValue) item.classList.add("selected"); } }, _setupButton(id, callback, titleEntity, textEntity) { if (titleEntity) { this._setButtonTip(id, titleEntity); } let button = this._doc.getElementsByClassName(id)[0]; if (textEntity) { button.textContent = gStrings.GetStringFromName(textEntity); } button.removeAttribute("hidden"); button.addEventListener("click", function(aEvent) { if (!aEvent.isTrusted) return; aEvent.stopPropagation(); let btn = aEvent.target; callback(btn); }, true); }, /** * Sets a toolTip for a button. Performed at initial button setup * and dynamically as button state changes. * @param Localizable string providing UI element usage tip. */ _setButtonTip(id, titleEntity) { let button = this._doc.getElementsByClassName(id)[0]; button.setAttribute("title", gStrings.GetStringFromName(titleEntity)); }, _setupStyleDropdown() { let dropdownToggle = this._doc.querySelector(".style-dropdown .dropdown-toggle"); dropdownToggle.setAttribute("title", gStrings.GetStringFromName("aboutReader.toolbar.typeControls")); }, _updatePopupPosition(dropdown) { let dropdownToggle = dropdown.querySelector(".dropdown-toggle"); let dropdownPopup = dropdown.querySelector(".dropdown-popup"); let toggleHeight = dropdownToggle.offsetHeight; let toggleTop = dropdownToggle.offsetTop; let popupTop = toggleTop - toggleHeight / 2; dropdownPopup.style.top = popupTop + "px"; }, _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 (this._isToolbarVertical) { this._updatePopupPosition(dropdown); } } }, /* * If the ReaderView banner font-dropdown is closed, open it. */ _openDropdown(dropdown) { if (dropdown.classList.contains("open")) { return; } this._closeDropdowns(); dropdown.classList.add("open"); }, /* * 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); for (let dropdown of openDropdowns) { dropdown.classList.remove("open"); } }, /* * Scroll reader view to a reference */ _goToReference(ref) { if (ref) { this._win.location.hash = ref; } } };