/* 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/. */ // This file is loaded into the browser window scope. /* eslint-env mozilla/browser-window */ /** * Controls the "full zoom" setting and its site-specific preferences. */ var FullZoom = { // Identifies the setting in the content prefs database. name: "browser.content.full-zoom", // browser.zoom.siteSpecific preference cache _siteSpecificPref: undefined, // browser.zoom.updateBackgroundTabs preference cache updateBackgroundTabs: undefined, // This maps the browser to monotonically increasing integer // tokens. _browserTokenMap[browser] is increased each time the zoom is // changed in the browser. See _getBrowserToken and _ignorePendingZoomAccesses. _browserTokenMap: new WeakMap(), // Stores initial locations if we receive onLocationChange // events before we're initialized. _initialLocations: new WeakMap(), get siteSpecific() { if (this._siteSpecificPref === undefined) { this._siteSpecificPref = Services.prefs.getBoolPref( "browser.zoom.siteSpecific" ); } return this._siteSpecificPref; }, // nsISupports QueryInterface: ChromeUtils.generateQI([ "nsIObserver", "nsIContentPrefObserver", "nsISupportsWeakReference", ]), // Initialization & Destruction init: function FullZoom_init() { gBrowser.addEventListener("DoZoomEnlargeBy10", this); gBrowser.addEventListener("DoZoomReduceBy10", this); window.addEventListener("MozScaleGestureComplete", this); // Register ourselves with the service so we know when our pref changes. this._cps2 = Cc["@mozilla.org/content-pref/service;1"].getService( Ci.nsIContentPrefService2 ); this._cps2.addObserverForName(this.name, this); this.updateBackgroundTabs = Services.prefs.getBoolPref( "browser.zoom.updateBackgroundTabs" ); // Listen for changes to the browser.zoom branch so we can enable/disable // updating background tabs and per-site saving and restoring of zoom levels. Services.prefs.addObserver("browser.zoom.", this, true); // If we received onLocationChange events for any of the current browsers // before we were initialized we want to replay those upon initialization. for (let browser of gBrowser.browsers) { if (this._initialLocations.has(browser)) { this.onLocationChange(...this._initialLocations.get(browser), browser); } } // This should be nulled after initialization. this._initialLocations = null; }, destroy: function FullZoom_destroy() { Services.prefs.removeObserver("browser.zoom.", this); this._cps2.removeObserverForName(this.name, this); gBrowser.removeEventListener("DoZoomEnlargeBy10", this); gBrowser.removeEventListener("DoZoomReduceBy10", this); window.removeEventListener("MozScaleGestureComplete", this); }, // Event Handlers // EventListener handleEvent: function FullZoom_handleEvent(event) { switch (event.type) { case "DoZoomEnlargeBy10": this.changeZoomBy(this._getTargetedBrowser(event), 0.1); break; case "DoZoomReduceBy10": this.changeZoomBy(this._getTargetedBrowser(event), -0.1); break; case "MozScaleGestureComplete": { let nonDefaultScalingZoom = event.detail != 1.0; this.updateCommands(nonDefaultScalingZoom); break; } } }, // nsIObserver observe(aSubject, aTopic, aData) { switch (aTopic) { case "nsPref:changed": switch (aData) { case "browser.zoom.siteSpecific": // Invalidate pref cache. this._siteSpecificPref = undefined; break; case "browser.zoom.updateBackgroundTabs": this.updateBackgroundTabs = Services.prefs.getBoolPref( "browser.zoom.updateBackgroundTabs" ); break; case "browser.zoom.full": { this.updateCommands(); break; } } break; } }, // nsIContentPrefObserver onContentPrefSet: function FullZoom_onContentPrefSet( aGroup, aName, aValue, aIsPrivate ) { this._onContentPrefChanged(aGroup, aValue, aIsPrivate); }, onContentPrefRemoved: function FullZoom_onContentPrefRemoved( aGroup, aName, aIsPrivate ) { this._onContentPrefChanged(aGroup, undefined, aIsPrivate); }, /** * Appropriately updates the zoom level after a content preference has * changed. * * @param aGroup The group of the changed preference. * @param aValue The new value of the changed preference. Pass undefined to * indicate the preference's removal. */ _onContentPrefChanged: function FullZoom__onContentPrefChanged( aGroup, aValue, aIsPrivate ) { if (this._isNextContentPrefChangeInternal) { // Ignore changes that FullZoom itself makes. This works because the // content pref service calls callbacks before notifying observers, and it // does both in the same turn of the event loop. delete this._isNextContentPrefChangeInternal; return; } let browser = gBrowser.selectedBrowser; if (!browser.currentURI) { return; } if (this._isPDFViewer(browser)) { return; } let ctxt = this._loadContextFromBrowser(browser); let domain = this._cps2.extractDomain(browser.currentURI.spec); if (aGroup) { if (aGroup == domain && ctxt.usePrivateBrowsing == aIsPrivate) { this._applyPrefToZoom(aValue, browser); } return; } // If the current page doesn't have a site-specific preference, then its // zoom should be set to the new global preference now that the global // preference has changed. let hasPref = false; let token = this._getBrowserToken(browser); this._cps2.getByDomainAndName(browser.currentURI.spec, this.name, ctxt, { handleResult() { hasPref = true; }, handleCompletion: () => { if (!hasPref && token.isCurrent) { this._applyPrefToZoom(undefined, browser); } }, }); }, // location change observer /** * Called when the location of a tab changes. * When that happens, we need to update the current zoom level if appropriate. * * @param aURI * A URI object representing the new location. * @param aIsTabSwitch * Whether this location change has happened because of a tab switch. * @param aBrowser * (optional) browser object displaying the document */ onLocationChange: function FullZoom_onLocationChange( aURI, aIsTabSwitch, aBrowser ) { let browser = aBrowser || gBrowser.selectedBrowser; // If we haven't been initialized yet but receive an onLocationChange // notification then let's store and replay it upon initialization. if (this._initialLocations) { this._initialLocations.set(browser, [aURI, aIsTabSwitch]); return; } // Ignore all pending async zoom accesses in the browser. Pending accesses // that started before the location change will be prevented from applying // to the new location. this._ignorePendingZoomAccesses(browser); if (!aURI || (aIsTabSwitch && !this._isSiteSpecific(browser))) { this._notifyOnLocationChange(browser); return; } if (aURI.spec == "about:blank") { if ( !browser.contentPrincipal || browser.contentPrincipal.isNullPrincipal ) { // For an about:blank with a null principal, zooming any amount does not // make any sense - so simply do 100%. this._applyPrefToZoom( 1, browser, this._notifyOnLocationChange.bind(this, browser) ); } else { // If it's not a null principal, there may be content loaded into it, // so use the global pref. This will avoid a cps2 roundtrip if we've // already loaded the global pref once. Really, this should probably // use the contentPrincipal's origin if it's an http(s) principal. // (See bug 1457597) this._applyPrefToZoom( undefined, browser, this._notifyOnLocationChange.bind(this, browser) ); } return; } // Media documents should always start at 1, and are not affected by prefs. if (!aIsTabSwitch && browser.isSyntheticDocument) { ZoomManager.setZoomForBrowser(browser, 1); // _ignorePendingZoomAccesses already called above, so no need here. this._notifyOnLocationChange(browser); return; } // The PDF viewer zooming isn't handled by `ZoomManager`, ensure that the // browser zoom level always gets reset to 100% on load (to prevent the // UI elements of the PDF viewer from being zoomed in/out on load). if (this._isPDFViewer(browser)) { this._applyPrefToZoom( 1, browser, this._notifyOnLocationChange.bind(this, browser) ); return; } // See if the zoom pref is cached. let ctxt = this._loadContextFromBrowser(browser); let pref = this._cps2.getCachedByDomainAndName(aURI.spec, this.name, ctxt); if (pref) { this._applyPrefToZoom( pref.value, browser, this._notifyOnLocationChange.bind(this, browser) ); return; } // It's not cached, so we have to asynchronously fetch it. let value = undefined; let token = this._getBrowserToken(browser); this._cps2.getByDomainAndName(aURI.spec, this.name, ctxt, { handleResult(resultPref) { value = resultPref.value; }, handleCompletion: () => { if (!token.isCurrent) { this._notifyOnLocationChange(browser); return; } this._applyPrefToZoom( value, browser, this._notifyOnLocationChange.bind(this, browser) ); }, }); }, // update state of zoom menu items /** * Updates the current windows Zoom commands for zooming in, zooming out * and resetting the zoom level. * * @param {boolean} [forceResetEnabled=false] * Set to true if the zoom reset command should be enabled regardless of * whether or not the ZoomManager.zoom level is at 1.0. This is specifically * for when using scaling zoom via the pinch gesture which doesn't cause * the ZoomManager.zoom level to change. * @returns Promise * @resolves undefined */ updateCommands: async function FullZoom_updateCommands( forceResetEnabled = false ) { let zoomLevel = ZoomManager.zoom; let defaultZoomLevel = await ZoomUI.getGlobalValue(); let reduceCmd = document.getElementById("cmd_fullZoomReduce"); if (zoomLevel == ZoomManager.MIN) { reduceCmd.setAttribute("disabled", "true"); } else { reduceCmd.removeAttribute("disabled"); } let enlargeCmd = document.getElementById("cmd_fullZoomEnlarge"); if (zoomLevel == ZoomManager.MAX) { enlargeCmd.setAttribute("disabled", "true"); } else { enlargeCmd.removeAttribute("disabled"); } let resetCmd = document.getElementById("cmd_fullZoomReset"); if (zoomLevel == defaultZoomLevel && !forceResetEnabled) { resetCmd.setAttribute("disabled", "true"); } else { resetCmd.removeAttribute("disabled"); } let fullZoomCmd = document.getElementById("cmd_fullZoomToggle"); if (!ZoomManager.useFullZoom) { fullZoomCmd.setAttribute("checked", "true"); } else { fullZoomCmd.setAttribute("checked", "false"); } }, // Setting & Pref Manipulation sendMessageToPDFViewer(browser, name) { try { browser.sendMessageToActor(name, {}, "Pdfjs"); } catch (ex) { console.error(ex); } }, /** * If browser in reader mode sends message to reader in order to decrease font size, * Otherwise reduces the zoom level of the page in the current browser. */ async reduce() { let browser = gBrowser.selectedBrowser; if (browser.currentURI.spec.startsWith("about:reader")) { browser.sendMessageToActor("Reader:ZoomOut", {}, "AboutReader"); } else if (this._isPDFViewer(browser)) { this.sendMessageToPDFViewer(browser, "PDFJS:ZoomOut"); } else { ZoomManager.reduce(); this._ignorePendingZoomAccesses(browser); await this._applyZoomToPref(browser); } }, /** * If browser in reader mode sends message to reader in order to increase font size, * Otherwise enlarges the zoom level of the page in the current browser. */ async enlarge() { let browser = gBrowser.selectedBrowser; if (browser.currentURI.spec.startsWith("about:reader")) { browser.sendMessageToActor("Reader:ZoomIn", {}, "AboutReader"); } else if (this._isPDFViewer(browser)) { this.sendMessageToPDFViewer(browser, "PDFJS:ZoomIn"); } else { ZoomManager.enlarge(); this._ignorePendingZoomAccesses(browser); await this._applyZoomToPref(browser); } }, /** * If browser in reader mode sends message to reader in order to increase font size, * Otherwise enlarges the zoom level of the page in the current browser. * This function is not async like reduce/enlarge, because it is invoked by our * event handler. This means that the call to _applyZoomToPref is not awaited and * will happen asynchronously. */ changeZoomBy(aBrowser, aValue) { if (aBrowser.currentURI.spec.startsWith("about:reader")) { const message = aValue > 0 ? "Reader::ZoomIn" : "Reader:ZoomOut"; aBrowser.sendMessageToActor(message, {}, "AboutReader"); return; } else if (this._isPDFViewer(aBrowser)) { const message = aValue > 0 ? "PDFJS::ZoomIn" : "PDFJS:ZoomOut"; this.sendMessageToPDFViewer(aBrowser, message); return; } let zoom = ZoomManager.getZoomForBrowser(aBrowser); zoom += aValue; if (zoom < ZoomManager.MIN) { zoom = ZoomManager.MIN; } else if (zoom > ZoomManager.MAX) { zoom = ZoomManager.MAX; } ZoomManager.setZoomForBrowser(aBrowser, zoom); this._ignorePendingZoomAccesses(aBrowser); this._applyZoomToPref(aBrowser); }, /** * Sets the zoom level for the given browser to the given floating * point value, where 1 is the default zoom level. */ setZoom(value, browser = gBrowser.selectedBrowser) { if (this._isPDFViewer(browser)) { return; } ZoomManager.setZoomForBrowser(browser, value); this._ignorePendingZoomAccesses(browser); this._applyZoomToPref(browser); }, /** * Sets the zoom level of the page in the given browser to the global zoom * level. * * @return A promise which resolves when the zoom reset has been applied. */ reset: function FullZoom_reset(browser = gBrowser.selectedBrowser) { let forceValue; if (browser.currentURI.spec.startsWith("about:reader")) { browser.sendMessageToActor("Reader:ResetZoom", {}, "AboutReader"); } else if (this._isPDFViewer(browser)) { this.sendMessageToPDFViewer(browser, "PDFJS:ZoomReset"); // Ensure that the UI elements of the PDF viewer won't be zoomed in/out // on reset, even if/when browser default zoom value is not set to 100%. forceValue = 1; } let token = this._getBrowserToken(browser); let result = ZoomUI.getGlobalValue().then(value => { if (token.isCurrent) { ZoomManager.setZoomForBrowser(browser, forceValue || value); this._ignorePendingZoomAccesses(browser); } }); this._removePref(browser); return result; }, resetScalingZoom: function FullZoom_resetScaling( browser = gBrowser.selectedBrowser ) { browser.browsingContext?.resetScalingZoom(); }, /** * Set the zoom level for a given browser. * * Per nsPresContext::setFullZoom, we can set the zoom to its current value * without significant impact on performance, as the setting is only applied * if it differs from the current setting. In fact getting the zoom and then * checking ourselves if it differs costs more. * * And perhaps we should always set the zoom even if it was more expensive, * since nsDocumentViewer::SetTextZoom claims that child documents can have * a different text zoom (although it would be unusual), and it implies that * those child text zooms should get updated when the parent zoom gets set, * and perhaps the same is true for full zoom * (although nsDocumentViewer::SetFullZoom doesn't mention it). * * So when we apply new zoom values to the browser, we simply set the zoom. * We don't check first to see if the new value is the same as the current * one. * * @param aValue The zoom level value. * @param aBrowser The zoom is set in this browser. Required. * @param aCallback If given, it's asynchronously called when complete. */ _applyPrefToZoom: function FullZoom__applyPrefToZoom( aValue, aBrowser, aCallback ) { // The browser is sometimes half-destroyed because this method is called // by content pref service callbacks, which themselves can be called at any // time, even after browsers are closed. if ( !aBrowser.mInitialized || aBrowser.isSyntheticDocument || (!this._isSiteSpecific(aBrowser) && aBrowser.tabHasCustomZoom) ) { this._executeSoon(aCallback); return; } if (aValue !== undefined && this._isSiteSpecific(aBrowser)) { ZoomManager.setZoomForBrowser(aBrowser, this._ensureValid(aValue)); this._ignorePendingZoomAccesses(aBrowser); this._executeSoon(aCallback); return; } // Above, we check if site-specific zoom is enabled before setting // the tab browser zoom, however global zoom should work independent // of the site-specific pref, so we do no checks here. let token = this._getBrowserToken(aBrowser); ZoomUI.getGlobalValue().then(value => { if (token.isCurrent) { ZoomManager.setZoomForBrowser(aBrowser, value); this._ignorePendingZoomAccesses(aBrowser); } this._executeSoon(aCallback); }); }, /** * Saves the zoom level of the page in the given browser to the content * prefs store. * * @param browser The zoom of this browser will be saved. Required. */ _applyZoomToPref: function FullZoom__applyZoomToPref(browser) { if (!this._isSiteSpecific(browser) || browser.isSyntheticDocument) { // If site-specific zoom is disabled, we have called this function // to adjust our tab's zoom level. It is now considered "custom" // and we mark that here. browser.tabHasCustomZoom = !this._isSiteSpecific(browser); return null; } return new Promise(resolve => { this._cps2.set( browser.currentURI.spec, this.name, ZoomManager.getZoomForBrowser(browser), this._loadContextFromBrowser(browser), { handleCompletion: () => { this._isNextContentPrefChangeInternal = true; resolve(); }, } ); }); }, /** * Removes from the content prefs store the zoom level of the given browser. * * @param browser The zoom of this browser will be removed. Required. */ _removePref: function FullZoom__removePref(browser) { if (browser.isSyntheticDocument) { return; } let ctxt = this._loadContextFromBrowser(browser); this._cps2.removeByDomainAndName(browser.currentURI.spec, this.name, ctxt, { handleCompletion: () => { this._isNextContentPrefChangeInternal = true; }, }); }, // Utilities /** * Returns the zoom change token of the given browser. Asynchronous * operations that access the given browser's zoom should use this method to * capture the token before starting and use token.isCurrent to determine if * it's safe to access the zoom when done. If token.isCurrent is false, then * after the async operation started, either the browser's zoom was changed or * the browser was destroyed, and depending on what the operation is doing, it * may no longer be safe to set and get its zoom. * * @param browser The token of this browser will be returned. * @return An object with an "isCurrent" getter. */ _getBrowserToken: function FullZoom__getBrowserToken(browser) { let map = this._browserTokenMap; if (!map.has(browser)) { map.set(browser, 0); } return { token: map.get(browser), get isCurrent() { // At this point, the browser may have been destructed and unbound but // its outer ID not removed from the map because outer-window-destroyed // hasn't been received yet. In that case, the browser is unusable, it // has no properties, so return false. Check for this case by getting a // property, say, docShell. return map.get(browser) === this.token && browser.mInitialized; }, }; }, /** * Returns the browser that the supplied zoom event is associated with. * @param event The zoom event. * @return The associated browser element, if one exists, otherwise null. */ _getTargetedBrowser: function FullZoom__getTargetedBrowser(event) { let target = event.originalTarget; // With remote content browsers, the event's target is the browser // we're looking for. const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; if ( window.XULElement.isInstance(target) && target.localName == "browser" && target.namespaceURI == XUL_NS ) { return target; } // With in-process content browsers, the event's target is the content // document. if (target.nodeType == Node.DOCUMENT_NODE) { return target.ownerGlobal.docShell.chromeEventHandler; } throw new Error("Unexpected zoom event source"); }, /** * Increments the zoom change token for the given browser so that pending * async operations know that it may be unsafe to access they zoom when they * finish. * * @param browser Pending accesses in this browser will be ignored. */ _ignorePendingZoomAccesses: function FullZoom__ignorePendingZoomAccesses( browser ) { let map = this._browserTokenMap; map.set(browser, (map.get(browser) || 0) + 1); }, _ensureValid: function FullZoom__ensureValid(aValue) { // Note that undefined is a valid value for aValue that indicates a known- // not-to-exist value. if (isNaN(aValue)) { return 1; } if (aValue < ZoomManager.MIN) { return ZoomManager.MIN; } if (aValue > ZoomManager.MAX) { return ZoomManager.MAX; } return aValue; }, // Whether to remember the site specific zoom level for this browser. // This returns false when `browser.zoom.siteSpecific` is false or // the browser has content loaded that should resist fingerprinting. _isSiteSpecific(aBrowser) { if (!this.siteSpecific) { return false; } return !aBrowser?.browsingContext?.topWindowContext .shouldResistFingerprinting; }, /** * Gets the load context from the given Browser. * * @param Browser The Browser whose load context will be returned. * @return The nsILoadContext of the given Browser. */ _loadContextFromBrowser: function FullZoom__loadContextFromBrowser(browser) { return browser.loadContext; }, /** * Asynchronously broadcasts "browser-fullZoom:location-change" so that * listeners can be notified when the zoom levels on those pages change. * The notification is always asynchronous so that observers are guaranteed a * consistent behavior. */ _notifyOnLocationChange: function FullZoom__notifyOnLocationChange(browser) { this._executeSoon(function () { Services.obs.notifyObservers(browser, "browser-fullZoom:location-change"); }); }, _executeSoon: function FullZoom__executeSoon(callback) { if (!callback) { return; } Services.tm.dispatchToMainThread(callback); }, _isPDFViewer(browser) { return !!( browser.contentPrincipal.spec == "resource://pdf.js/web/viewer.html" ); }, };