diff options
Diffstat (limited to 'browser/base/content/browser-fullZoom.js')
-rw-r--r-- | browser/base/content/browser-fullZoom.js | 737 |
1 files changed, 737 insertions, 0 deletions
diff --git a/browser/base/content/browser-fullZoom.js b/browser/base/content/browser-fullZoom.js new file mode 100644 index 0000000000..7790df3f41 --- /dev/null +++ b/browser/base/content/browser-fullZoom.js @@ -0,0 +1,737 @@ +/* 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" + ); + }, +}; |