1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 // This file is loaded into the browser window scope.
6 /* eslint-env mozilla/browser-window */
9 * Controls the "full zoom" setting and its site-specific preferences.
12 // Identifies the setting in the content prefs database.
13 name: "browser.content.full-zoom",
15 // browser.zoom.siteSpecific preference cache
16 _siteSpecificPref: undefined,
18 // browser.zoom.updateBackgroundTabs preference cache
19 updateBackgroundTabs: undefined,
21 // This maps the browser to monotonically increasing integer
22 // tokens. _browserTokenMap[browser] is increased each time the zoom is
23 // changed in the browser. See _getBrowserToken and _ignorePendingZoomAccesses.
24 _browserTokenMap: new WeakMap(),
26 // Stores initial locations if we receive onLocationChange
27 // events before we're initialized.
28 _initialLocations: new WeakMap(),
31 if (this._siteSpecificPref === undefined) {
32 this._siteSpecificPref = Services.prefs.getBoolPref(
33 "browser.zoom.siteSpecific"
36 return this._siteSpecificPref;
41 QueryInterface: ChromeUtils.generateQI([
43 "nsIContentPrefObserver",
44 "nsISupportsWeakReference",
47 // Initialization & Destruction
49 init: function FullZoom_init() {
50 gBrowser.addEventListener("DoZoomEnlargeBy10", this);
51 gBrowser.addEventListener("DoZoomReduceBy10", this);
52 window.addEventListener("MozScaleGestureComplete", this);
54 // Register ourselves with the service so we know when our pref changes.
55 this._cps2 = Cc["@mozilla.org/content-pref/service;1"].getService(
56 Ci.nsIContentPrefService2
58 this._cps2.addObserverForName(this.name, this);
60 this.updateBackgroundTabs = Services.prefs.getBoolPref(
61 "browser.zoom.updateBackgroundTabs"
64 // Listen for changes to the browser.zoom branch so we can enable/disable
65 // updating background tabs and per-site saving and restoring of zoom levels.
66 Services.prefs.addObserver("browser.zoom.", this, true);
68 // If we received onLocationChange events for any of the current browsers
69 // before we were initialized we want to replay those upon initialization.
70 for (let browser of gBrowser.browsers) {
71 if (this._initialLocations.has(browser)) {
72 this.onLocationChange(...this._initialLocations.get(browser), browser);
76 // This should be nulled after initialization.
77 this._initialLocations = null;
80 destroy: function FullZoom_destroy() {
81 Services.prefs.removeObserver("browser.zoom.", this);
82 this._cps2.removeObserverForName(this.name, this);
83 gBrowser.removeEventListener("DoZoomEnlargeBy10", this);
84 gBrowser.removeEventListener("DoZoomReduceBy10", this);
85 window.removeEventListener("MozScaleGestureComplete", this);
92 handleEvent: function FullZoom_handleEvent(event) {
94 case "DoZoomEnlargeBy10":
95 this.changeZoomBy(this._getTargetedBrowser(event), 0.1);
97 case "DoZoomReduceBy10":
98 this.changeZoomBy(this._getTargetedBrowser(event), -0.1);
100 case "MozScaleGestureComplete": {
101 let nonDefaultScalingZoom = event.detail != 1.0;
102 this.updateCommands(nonDefaultScalingZoom);
110 observe(aSubject, aTopic, aData) {
112 case "nsPref:changed":
114 case "browser.zoom.siteSpecific":
115 // Invalidate pref cache.
116 this._siteSpecificPref = undefined;
118 case "browser.zoom.updateBackgroundTabs":
119 this.updateBackgroundTabs = Services.prefs.getBoolPref(
120 "browser.zoom.updateBackgroundTabs"
123 case "browser.zoom.full": {
124 this.updateCommands();
132 // nsIContentPrefObserver
134 onContentPrefSet: function FullZoom_onContentPrefSet(
140 this._onContentPrefChanged(aGroup, aValue, aIsPrivate);
143 onContentPrefRemoved: function FullZoom_onContentPrefRemoved(
148 this._onContentPrefChanged(aGroup, undefined, aIsPrivate);
152 * Appropriately updates the zoom level after a content preference has
155 * @param aGroup The group of the changed preference.
156 * @param aValue The new value of the changed preference. Pass undefined to
157 * indicate the preference's removal.
159 _onContentPrefChanged: function FullZoom__onContentPrefChanged(
164 if (this._isNextContentPrefChangeInternal) {
165 // Ignore changes that FullZoom itself makes. This works because the
166 // content pref service calls callbacks before notifying observers, and it
167 // does both in the same turn of the event loop.
168 delete this._isNextContentPrefChangeInternal;
172 let browser = gBrowser.selectedBrowser;
173 if (!browser.currentURI) {
177 if (this._isPDFViewer(browser)) {
181 let ctxt = this._loadContextFromBrowser(browser);
182 let domain = this._cps2.extractDomain(browser.currentURI.spec);
184 if (aGroup == domain && ctxt.usePrivateBrowsing == aIsPrivate) {
185 this._applyPrefToZoom(aValue, browser);
190 // If the current page doesn't have a site-specific preference, then its
191 // zoom should be set to the new global preference now that the global
192 // preference has changed.
194 let token = this._getBrowserToken(browser);
195 this._cps2.getByDomainAndName(browser.currentURI.spec, this.name, ctxt, {
199 handleCompletion: () => {
200 if (!hasPref && token.isCurrent) {
201 this._applyPrefToZoom(undefined, browser);
207 // location change observer
210 * Called when the location of a tab changes.
211 * When that happens, we need to update the current zoom level if appropriate.
214 * A URI object representing the new location.
215 * @param aIsTabSwitch
216 * Whether this location change has happened because of a tab switch.
218 * (optional) browser object displaying the document
220 onLocationChange: function FullZoom_onLocationChange(
225 let browser = aBrowser || gBrowser.selectedBrowser;
227 // If we haven't been initialized yet but receive an onLocationChange
228 // notification then let's store and replay it upon initialization.
229 if (this._initialLocations) {
230 this._initialLocations.set(browser, [aURI, aIsTabSwitch]);
234 // Ignore all pending async zoom accesses in the browser. Pending accesses
235 // that started before the location change will be prevented from applying
236 // to the new location.
237 this._ignorePendingZoomAccesses(browser);
239 if (!aURI || (aIsTabSwitch && !this._isSiteSpecific(browser))) {
240 this._notifyOnLocationChange(browser);
244 if (aURI.spec == "about:blank") {
246 !browser.contentPrincipal ||
247 browser.contentPrincipal.isNullPrincipal
249 // For an about:blank with a null principal, zooming any amount does not
250 // make any sense - so simply do 100%.
251 this._applyPrefToZoom(
254 this._notifyOnLocationChange.bind(this, browser)
257 // If it's not a null principal, there may be content loaded into it,
258 // so use the global pref. This will avoid a cps2 roundtrip if we've
259 // already loaded the global pref once. Really, this should probably
260 // use the contentPrincipal's origin if it's an http(s) principal.
262 this._applyPrefToZoom(
265 this._notifyOnLocationChange.bind(this, browser)
271 // Media documents should always start at 1, and are not affected by prefs.
272 if (!aIsTabSwitch && browser.isSyntheticDocument) {
273 ZoomManager.setZoomForBrowser(browser, 1);
274 // _ignorePendingZoomAccesses already called above, so no need here.
275 this._notifyOnLocationChange(browser);
279 // The PDF viewer zooming isn't handled by `ZoomManager`, ensure that the
280 // browser zoom level always gets reset to 100% on load (to prevent the
281 // UI elements of the PDF viewer from being zoomed in/out on load).
282 if (this._isPDFViewer(browser)) {
283 this._applyPrefToZoom(
286 this._notifyOnLocationChange.bind(this, browser)
291 // See if the zoom pref is cached.
292 let ctxt = this._loadContextFromBrowser(browser);
293 let pref = this._cps2.getCachedByDomainAndName(aURI.spec, this.name, ctxt);
295 this._applyPrefToZoom(
298 this._notifyOnLocationChange.bind(this, browser)
303 // It's not cached, so we have to asynchronously fetch it.
304 let value = undefined;
305 let token = this._getBrowserToken(browser);
306 this._cps2.getByDomainAndName(aURI.spec, this.name, ctxt, {
307 handleResult(resultPref) {
308 value = resultPref.value;
310 handleCompletion: () => {
311 if (!token.isCurrent) {
312 this._notifyOnLocationChange(browser);
315 this._applyPrefToZoom(
318 this._notifyOnLocationChange.bind(this, browser)
324 // update state of zoom menu items
327 * Updates the current windows Zoom commands for zooming in, zooming out
328 * and resetting the zoom level.
330 * @param {boolean} [forceResetEnabled=false]
331 * Set to true if the zoom reset command should be enabled regardless of
332 * whether or not the ZoomManager.zoom level is at 1.0. This is specifically
333 * for when using scaling zoom via the pinch gesture which doesn't cause
334 * the ZoomManager.zoom level to change.
336 * @resolves undefined
338 updateCommands: async function FullZoom_updateCommands(
339 forceResetEnabled = false
341 let zoomLevel = ZoomManager.zoom;
342 let defaultZoomLevel = await ZoomUI.getGlobalValue();
343 let reduceCmd = document.getElementById("cmd_fullZoomReduce");
344 if (zoomLevel == ZoomManager.MIN) {
345 reduceCmd.setAttribute("disabled", "true");
347 reduceCmd.removeAttribute("disabled");
350 let enlargeCmd = document.getElementById("cmd_fullZoomEnlarge");
351 if (zoomLevel == ZoomManager.MAX) {
352 enlargeCmd.setAttribute("disabled", "true");
354 enlargeCmd.removeAttribute("disabled");
357 let resetCmd = document.getElementById("cmd_fullZoomReset");
358 if (zoomLevel == defaultZoomLevel && !forceResetEnabled) {
359 resetCmd.setAttribute("disabled", "true");
361 resetCmd.removeAttribute("disabled");
364 let fullZoomCmd = document.getElementById("cmd_fullZoomToggle");
365 if (!ZoomManager.useFullZoom) {
366 fullZoomCmd.setAttribute("checked", "true");
368 fullZoomCmd.setAttribute("checked", "false");
372 // Setting & Pref Manipulation
374 sendMessageToPDFViewer(browser, name) {
376 browser.sendMessageToActor(name, {}, "Pdfjs");
383 * If browser in reader mode sends message to reader in order to decrease font size,
384 * Otherwise reduces the zoom level of the page in the current browser.
387 let browser = gBrowser.selectedBrowser;
388 if (browser.currentURI.spec.startsWith("about:reader")) {
389 browser.sendMessageToActor("Reader:ZoomOut", {}, "AboutReader");
390 } else if (this._isPDFViewer(browser)) {
391 this.sendMessageToPDFViewer(browser, "PDFJS:ZoomOut");
393 ZoomManager.reduce();
394 this._ignorePendingZoomAccesses(browser);
395 await this._applyZoomToPref(browser);
400 * If browser in reader mode sends message to reader in order to increase font size,
401 * Otherwise enlarges the zoom level of the page in the current browser.
404 let browser = gBrowser.selectedBrowser;
405 if (browser.currentURI.spec.startsWith("about:reader")) {
406 browser.sendMessageToActor("Reader:ZoomIn", {}, "AboutReader");
407 } else if (this._isPDFViewer(browser)) {
408 this.sendMessageToPDFViewer(browser, "PDFJS:ZoomIn");
410 ZoomManager.enlarge();
411 this._ignorePendingZoomAccesses(browser);
412 await this._applyZoomToPref(browser);
417 * If browser in reader mode sends message to reader in order to increase font size,
418 * Otherwise enlarges the zoom level of the page in the current browser.
419 * This function is not async like reduce/enlarge, because it is invoked by our
420 * event handler. This means that the call to _applyZoomToPref is not awaited and
421 * will happen asynchronously.
423 changeZoomBy(aBrowser, aValue) {
424 if (aBrowser.currentURI.spec.startsWith("about:reader")) {
425 const message = aValue > 0 ? "Reader::ZoomIn" : "Reader:ZoomOut";
426 aBrowser.sendMessageToActor(message, {}, "AboutReader");
428 } else if (this._isPDFViewer(aBrowser)) {
429 const message = aValue > 0 ? "PDFJS::ZoomIn" : "PDFJS:ZoomOut";
430 this.sendMessageToPDFViewer(aBrowser, message);
433 let zoom = ZoomManager.getZoomForBrowser(aBrowser);
435 if (zoom < ZoomManager.MIN) {
436 zoom = ZoomManager.MIN;
437 } else if (zoom > ZoomManager.MAX) {
438 zoom = ZoomManager.MAX;
440 ZoomManager.setZoomForBrowser(aBrowser, zoom);
441 this._ignorePendingZoomAccesses(aBrowser);
442 this._applyZoomToPref(aBrowser);
446 * Sets the zoom level for the given browser to the given floating
447 * point value, where 1 is the default zoom level.
449 setZoom(value, browser = gBrowser.selectedBrowser) {
450 if (this._isPDFViewer(browser)) {
453 ZoomManager.setZoomForBrowser(browser, value);
454 this._ignorePendingZoomAccesses(browser);
455 this._applyZoomToPref(browser);
459 * Sets the zoom level of the page in the given browser to the global zoom
462 * @return A promise which resolves when the zoom reset has been applied.
464 reset: function FullZoom_reset(browser = gBrowser.selectedBrowser) {
466 if (browser.currentURI.spec.startsWith("about:reader")) {
467 browser.sendMessageToActor("Reader:ResetZoom", {}, "AboutReader");
468 } else if (this._isPDFViewer(browser)) {
469 this.sendMessageToPDFViewer(browser, "PDFJS:ZoomReset");
470 // Ensure that the UI elements of the PDF viewer won't be zoomed in/out
471 // on reset, even if/when browser default zoom value is not set to 100%.
474 let token = this._getBrowserToken(browser);
475 let result = ZoomUI.getGlobalValue().then(value => {
476 if (token.isCurrent) {
477 ZoomManager.setZoomForBrowser(browser, forceValue || value);
478 this._ignorePendingZoomAccesses(browser);
481 this._removePref(browser);
486 * Called from the URL bar's inline zoom reset indicator button.
488 * @param {Event} event the click/keyboard event that triggered the call.
490 resetFromURLBar(event) {
491 if (event.button > 0) {
495 this.resetScalingZoom();
498 resetScalingZoom: function FullZoom_resetScaling(
499 browser = gBrowser.selectedBrowser
501 browser.browsingContext?.resetScalingZoom();
505 * Set the zoom level for a given browser.
507 * Per nsPresContext::setFullZoom, we can set the zoom to its current value
508 * without significant impact on performance, as the setting is only applied
509 * if it differs from the current setting. In fact getting the zoom and then
510 * checking ourselves if it differs costs more.
512 * And perhaps we should always set the zoom even if it was more expensive,
513 * since nsDocumentViewer::SetTextZoom claims that child documents can have
514 * a different text zoom (although it would be unusual), and it implies that
515 * those child text zooms should get updated when the parent zoom gets set,
516 * and perhaps the same is true for full zoom
517 * (although nsDocumentViewer::SetFullZoom doesn't mention it).
519 * So when we apply new zoom values to the browser, we simply set the zoom.
520 * We don't check first to see if the new value is the same as the current
523 * @param aValue The zoom level value.
524 * @param aBrowser The zoom is set in this browser. Required.
525 * @param aCallback If given, it's asynchronously called when complete.
527 _applyPrefToZoom: function FullZoom__applyPrefToZoom(
532 // The browser is sometimes half-destroyed because this method is called
533 // by content pref service callbacks, which themselves can be called at any
534 // time, even after browsers are closed.
536 !aBrowser.mInitialized ||
537 aBrowser.isSyntheticDocument ||
538 (!this._isSiteSpecific(aBrowser) && aBrowser.tabHasCustomZoom)
540 this._executeSoon(aCallback);
544 if (aValue !== undefined && this._isSiteSpecific(aBrowser)) {
545 ZoomManager.setZoomForBrowser(aBrowser, this._ensureValid(aValue));
546 this._ignorePendingZoomAccesses(aBrowser);
547 this._executeSoon(aCallback);
551 // Above, we check if site-specific zoom is enabled before setting
552 // the tab browser zoom, however global zoom should work independent
553 // of the site-specific pref, so we do no checks here.
554 let token = this._getBrowserToken(aBrowser);
555 ZoomUI.getGlobalValue().then(value => {
556 if (token.isCurrent) {
557 ZoomManager.setZoomForBrowser(aBrowser, value);
558 this._ignorePendingZoomAccesses(aBrowser);
560 this._executeSoon(aCallback);
565 * Saves the zoom level of the page in the given browser to the content
568 * @param browser The zoom of this browser will be saved. Required.
570 _applyZoomToPref: function FullZoom__applyZoomToPref(browser) {
571 if (!this._isSiteSpecific(browser) || browser.isSyntheticDocument) {
572 // If site-specific zoom is disabled, we have called this function
573 // to adjust our tab's zoom level. It is now considered "custom"
574 // and we mark that here.
575 browser.tabHasCustomZoom = !this._isSiteSpecific(browser);
579 return new Promise(resolve => {
581 browser.currentURI.spec,
583 ZoomManager.getZoomForBrowser(browser),
584 this._loadContextFromBrowser(browser),
586 handleCompletion: () => {
587 this._isNextContentPrefChangeInternal = true;
596 * Removes from the content prefs store the zoom level of the given browser.
598 * @param browser The zoom of this browser will be removed. Required.
600 _removePref: function FullZoom__removePref(browser) {
601 if (browser.isSyntheticDocument) {
604 let ctxt = this._loadContextFromBrowser(browser);
605 this._cps2.removeByDomainAndName(browser.currentURI.spec, this.name, ctxt, {
606 handleCompletion: () => {
607 this._isNextContentPrefChangeInternal = true;
615 * Returns the zoom change token of the given browser. Asynchronous
616 * operations that access the given browser's zoom should use this method to
617 * capture the token before starting and use token.isCurrent to determine if
618 * it's safe to access the zoom when done. If token.isCurrent is false, then
619 * after the async operation started, either the browser's zoom was changed or
620 * the browser was destroyed, and depending on what the operation is doing, it
621 * may no longer be safe to set and get its zoom.
623 * @param browser The token of this browser will be returned.
624 * @return An object with an "isCurrent" getter.
626 _getBrowserToken: function FullZoom__getBrowserToken(browser) {
627 let map = this._browserTokenMap;
628 if (!map.has(browser)) {
632 token: map.get(browser),
634 // At this point, the browser may have been destructed and unbound but
635 // its outer ID not removed from the map because outer-window-destroyed
636 // hasn't been received yet. In that case, the browser is unusable, it
637 // has no properties, so return false. Check for this case by getting a
638 // property, say, docShell.
639 return map.get(browser) === this.token && browser.mInitialized;
645 * Returns the browser that the supplied zoom event is associated with.
646 * @param event The zoom event.
647 * @return The associated browser element, if one exists, otherwise null.
649 _getTargetedBrowser: function FullZoom__getTargetedBrowser(event) {
650 let target = event.originalTarget;
652 // With remote content browsers, the event's target is the browser
653 // we're looking for.
655 "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
657 window.XULElement.isInstance(target) &&
658 target.localName == "browser" &&
659 target.namespaceURI == XUL_NS
664 // With in-process content browsers, the event's target is the content
666 if (target.nodeType == Node.DOCUMENT_NODE) {
667 return target.ownerGlobal.docShell.chromeEventHandler;
670 throw new Error("Unexpected zoom event source");
674 * Increments the zoom change token for the given browser so that pending
675 * async operations know that it may be unsafe to access they zoom when they
678 * @param browser Pending accesses in this browser will be ignored.
680 _ignorePendingZoomAccesses: function FullZoom__ignorePendingZoomAccesses(
683 let map = this._browserTokenMap;
684 map.set(browser, (map.get(browser) || 0) + 1);
687 _ensureValid: function FullZoom__ensureValid(aValue) {
688 // Note that undefined is a valid value for aValue that indicates a known-
689 // not-to-exist value.
694 if (aValue < ZoomManager.MIN) {
695 return ZoomManager.MIN;
698 if (aValue > ZoomManager.MAX) {
699 return ZoomManager.MAX;
705 // Whether to remember the site specific zoom level for this browser.
706 // This returns false when `browser.zoom.siteSpecific` is false or
707 // the browser has content loaded that should resist fingerprinting.
708 _isSiteSpecific(aBrowser) {
709 if (!this.siteSpecific) {
713 !aBrowser?.browsingContext?.topWindowContext.shouldResistFingerprinting ||
714 !ChromeUtils.shouldResistFingerprinting(
716 aBrowser?.browsingContext?.topWindowContext
717 .overriddenFingerprintingSettings
723 * Gets the load context from the given Browser.
725 * @param Browser The Browser whose load context will be returned.
726 * @return The nsILoadContext of the given Browser.
728 _loadContextFromBrowser: function FullZoom__loadContextFromBrowser(browser) {
729 return browser.loadContext;
733 * Asynchronously broadcasts "browser-fullZoom:location-change" so that
734 * listeners can be notified when the zoom levels on those pages change.
735 * The notification is always asynchronous so that observers are guaranteed a
736 * consistent behavior.
738 _notifyOnLocationChange: function FullZoom__notifyOnLocationChange(browser) {
739 this._executeSoon(function () {
740 Services.obs.notifyObservers(browser, "browser-fullZoom:location-change");
744 _executeSoon: function FullZoom__executeSoon(callback) {
748 Services.tm.dispatchToMainThread(callback);
751 _isPDFViewer(browser) {
753 browser.contentPrincipal.spec == "resource://pdf.js/web/viewer.html"