Source code

Revision control

Copy as Markdown

Other Tools

/* 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;
},
/**
* Called from the URL bar's inline zoom reset indicator button.
*
* @param {Event} event the click/keyboard event that triggered the call.
*/
resetFromURLBar(event) {
if (event.button > 0) {
return;
}
this.reset();
this.resetScalingZoom();
},
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 =
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 ||
!ChromeUtils.shouldResistFingerprinting(
"SiteSpecificZoom",
aBrowser?.browsingContext?.topWindowContext
.overriddenFingerprintingSettings
)
);
},
/**
* 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"
);
},
};