 |
1 /*
2 #ifdef 0
3 * This Source Code Form is subject to the terms of the Mozilla Public
4 * License, v. 2.0. If a copy of the MPL was not distributed with this
5 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
6 #endif
7 */
8
9 /**
10 * Controls the "full zoom" setting and its site-specific preferences.
11 */
12 var FullZoom = {
13 // Identifies the setting in the content prefs database.
14 name: "browser.content.full-zoom",
15
16 // browser.zoom.siteSpecific preference cache
17 _siteSpecificPref: undefined,
18
19 // browser.zoom.updateBackgroundTabs preference cache
20 updateBackgroundTabs: undefined,
21
22 // This maps the browser to monotonically increasing integer
23 // tokens. _browserTokenMap[browser] is increased each time the zoom is
24 // changed in the browser. See _getBrowserToken and _ignorePendingZoomAccesses.
25 _browserTokenMap: new WeakMap(),
26
27 // Stores initial locations if we receive onLocationChange
28 // events before we're initialized.
29 _initialLocations: new WeakMap(),
30
31 get siteSpecific() {
32 return this._siteSpecificPref;
33 },
34
35 //**************************************************************************//
36 // nsISupports
37
38 QueryInterface: XPCOMUtils.generateQI([Ci.nsIDOMEventListener,
39 Ci.nsIObserver,
40 Ci.nsIContentPrefObserver,
41 Ci.nsISupportsWeakReference,
42 Ci.nsISupports]),
43
44 //**************************************************************************//
45 // Initialization & Destruction
46
47 init: function FullZoom_init() {
48 gBrowser.addEventListener("ZoomChangeUsingMouseWheel", this);
49
50 // Register ourselves with the service so we know when our pref changes.
51 this._cps2 = Cc["@mozilla.org/content-pref/service;1"].
52 getService(Ci.nsIContentPrefService2);
53 this._cps2.addObserverForName(this.name, this);
54
55 this._siteSpecificPref =
56 gPrefService.getBoolPref("browser.zoom.siteSpecific");
57 this.updateBackgroundTabs =
58 gPrefService.getBoolPref("browser.zoom.updateBackgroundTabs");
59 // Listen for changes to the browser.zoom branch so we can enable/disable
60 // updating background tabs and per-site saving and restoring of zoom levels.
61 gPrefService.addObserver("browser.zoom.", this, true);
62
63 // If we received onLocationChange events for any of the current browsers
64 // before we were initialized we want to replay those upon initialization.
65 for (let browser of gBrowser.browsers) {
66 if (this._initialLocations.has(browser)) {
67 this.onLocationChange(...this._initialLocations.get(browser), browser);
68 }
69 }
70
71 // This should be nulled after initialization.
72 this._initialLocations.clear();
73 this._initialLocations = null;
74 },
75
76 destroy: function FullZoom_destroy() {
77 gPrefService.removeObserver("browser.zoom.", this);
78 this._cps2.removeObserverForName(this.name, this);
79 gBrowser.removeEventListener("ZoomChangeUsingMouseWheel", this);
80 },
81
82
83 //**************************************************************************//
84 // Event Handlers
85
86 // nsIDOMEventListener
87
88 handleEvent: function FullZoom_handleEvent(event) {
89 switch (event.type) {
90 case "ZoomChangeUsingMouseWheel":
91 let browser = this._getTargetedBrowser(event);
92 this._ignorePendingZoomAccesses(browser);
93 this._applyZoomToPref(browser);
94 break;
95 }
96 },
97
98 // nsIObserver
99
100 observe: function (aSubject, aTopic, aData) {
101 switch (aTopic) {
102 case "nsPref:changed":
103 switch (aData) {
104 case "browser.zoom.siteSpecific":
105 this._siteSpecificPref =
106 gPrefService.getBoolPref("browser.zoom.siteSpecific");
107 break;
108 case "browser.zoom.updateBackgroundTabs":
109 this.updateBackgroundTabs =
110 gPrefService.getBoolPref("browser.zoom.updateBackgroundTabs");
111 break;
112 }
113 break;
114 }
115 },
116
117 // nsIContentPrefObserver
118
119 onContentPrefSet: function FullZoom_onContentPrefSet(aGroup, aName, aValue) {
120 this._onContentPrefChanged(aGroup, aValue);
121 },
122
123 onContentPrefRemoved: function FullZoom_onContentPrefRemoved(aGroup, aName) {
124 this._onContentPrefChanged(aGroup, undefined);
125 },
126
127 /**
128 * Appropriately updates the zoom level after a content preference has
129 * changed.
130 *
131 * @param aGroup The group of the changed preference.
132 * @param aValue The new value of the changed preference. Pass undefined to
133 * indicate the preference's removal.
134 */
135 _onContentPrefChanged: function FullZoom__onContentPrefChanged(aGroup, aValue) {
136 if (this._isNextContentPrefChangeInternal) {
137 // Ignore changes that FullZoom itself makes. This works because the
138 // content pref service calls callbacks before notifying observers, and it
139 // does both in the same turn of the event loop.
140 delete this._isNextContentPrefChangeInternal;
141 return;
142 }
143
144 let browser = gBrowser.selectedBrowser;
145 if (!browser.currentURI)
146 return;
147
148 let domain = this._cps2.extractDomain(browser.currentURI.spec);
149 if (aGroup) {
150 if (aGroup == domain)
151 this._applyPrefToZoom(aValue, browser);
152 return;
153 }
154
155 this._globalValue = aValue === undefined ? aValue :
156 this._ensureValid(aValue);
157
158 // If the current page doesn't have a site-specific preference, then its
159 // zoom should be set to the new global preference now that the global
160 // preference has changed.
161 let hasPref = false;
162 let ctxt = this._loadContextFromBrowser(browser);
163 let token = this._getBrowserToken(browser);
164 this._cps2.getByDomainAndName(browser.currentURI.spec, this.name, ctxt, {
165 handleResult: function () { hasPref = true; },
166 handleCompletion: function () {
167 if (!hasPref && token.isCurrent)
168 this._applyPrefToZoom(undefined, browser);
169 }.bind(this)
170 });
171 },
172
173 // location change observer
174
175 /**
176 * Called when the location of a tab changes.
177 * When that happens, we need to update the current zoom level if appropriate.
178 *
179 * @param aURI
180 * A URI object representing the new location.
181 * @param aIsTabSwitch
182 * Whether this location change has happened because of a tab switch.
183 * @param aBrowser
184 * (optional) browser object displaying the document
185 */
186 onLocationChange: function FullZoom_onLocationChange(aURI, aIsTabSwitch, aBrowser) {
187 let browser = aBrowser || gBrowser.selectedBrowser;
188
189 // If we haven't been initialized yet but receive an onLocationChange
190 // notification then let's store and replay it upon initialization.
191 if (this._initialLocations) {
192 this._initialLocations.set(browser, [aURI, aIsTabSwitch]);
193 return;
194 }
195
196 // Ignore all pending async zoom accesses in the browser. Pending accesses
197 // that started before the location change will be prevented from applying
198 // to the new location.
199 this._ignorePendingZoomAccesses(browser);
200
201 if (!aURI || (aIsTabSwitch && !this.siteSpecific)) {
202 this._notifyOnLocationChange();
203 return;
204 }
205
206 // Avoid the cps roundtrip and apply the default/global pref.
207 if (aURI.spec == "about:blank") {
208 this._applyPrefToZoom(undefined, browser,
209 this._notifyOnLocationChange.bind(this));
210 return;
211 }
212
213 // Media documents should always start at 1, and are not affected by prefs.
214 if (!aIsTabSwitch && browser.isSyntheticDocument) {
215 ZoomManager.setZoomForBrowser(browser, 1);
216 // _ignorePendingZoomAccesses already called above, so no need here.
217 this._notifyOnLocationChange();
218 return;
219 }
220
221 // See if the zoom pref is cached.
222 let ctxt = this._loadContextFromBrowser(browser);
223 let pref = this._cps2.getCachedByDomainAndName(aURI.spec, this.name, ctxt);
224 if (pref) {
225 this._applyPrefToZoom(pref.value, browser,
226 this._notifyOnLocationChange.bind(this));
227 return;
228 }
229
230 // It's not cached, so we have to asynchronously fetch it.
231 let value = undefined;
232 let token = this._getBrowserToken(browser);
233 this._cps2.getByDomainAndName(aURI.spec, this.name, ctxt, {
234 handleResult: function (resultPref) { value = resultPref.value; },
235 handleCompletion: function () {
236 if (!token.isCurrent) {
237 this._notifyOnLocationChange();
238 return;
239 }
240 this._applyPrefToZoom(value, browser,
241 this._notifyOnLocationChange.bind(this));
242 }.bind(this)
243 });
244 },
245
246 // update state of zoom type menu item
247
248 updateMenu: function FullZoom_updateMenu() {
249 var menuItem = document.getElementById("toggle_zoom");
250
251 menuItem.setAttribute("checked", !ZoomManager.useFullZoom);
252 },
253
254 //**************************************************************************//
255 // Setting & Pref Manipulation
256
257 /**
258 * Reduces the zoom level of the page in the current browser.
259 */
260 reduce: function FullZoom_reduce() {
261 ZoomManager.reduce();
262 let browser = gBrowser.selectedBrowser;
263 this._ignorePendingZoomAccesses(browser);
264 this._applyZoomToPref(browser);
265 },
266
267 /**
268 * Enlarges the zoom level of the page in the current browser.
269 */
270 enlarge: function FullZoom_enlarge() {
271 ZoomManager.enlarge();
272 let browser = gBrowser.selectedBrowser;
273 this._ignorePendingZoomAccesses(browser);
274 this._applyZoomToPref(browser);
275 },
276
277 /**
278 * Sets the zoom level of the page in the current browser to the global zoom
279 * level.
280 */
281 reset: function FullZoom_reset() {
282 let browser = gBrowser.selectedBrowser;
283 let token = this._getBrowserToken(browser);
284 this._getGlobalValue(browser, function (value) {
285 if (token.isCurrent) {
286 ZoomManager.setZoomForBrowser(browser, value === undefined ? 1 : value);
287 this._ignorePendingZoomAccesses(browser);
288 this._executeSoon(function () {
289 // _getGlobalValue may be either sync or async, so notify asyncly so
290 // observers are guaranteed consistent behavior.
291 Services.obs.notifyObservers(null, "browser-fullZoom:zoomReset", "");
292 });
293 }
294 });
295 this._removePref(browser);
296 },
297
298 /**
299 * Set the zoom level for a given browser.
300 *
301 * Per nsPresContext::setFullZoom, we can set the zoom to its current value
302 * without significant impact on performance, as the setting is only applied
303 * if it differs from the current setting. In fact getting the zoom and then
304 * checking ourselves if it differs costs more.
305 *
306 * And perhaps we should always set the zoom even if it was more expensive,
307 * since nsDocumentViewer::SetTextZoom claims that child documents can have
308 * a different text zoom (although it would be unusual), and it implies that
309 * those child text zooms should get updated when the parent zoom gets set,
310 * and perhaps the same is true for full zoom
311 * (although nsDocumentViewer::SetFullZoom doesn't mention it).
312 *
313 * So when we apply new zoom values to the browser, we simply set the zoom.
314 * We don't check first to see if the new value is the same as the current
315 * one.
316 *
317 * @param aValue The zoom level value.
318 * @param aBrowser The zoom is set in this browser. Required.
319 * @param aCallback If given, it's asynchronously called when complete.
320 */
321 _applyPrefToZoom: function FullZoom__applyPrefToZoom(aValue, aBrowser, aCallback) {
322 if (!this.siteSpecific || gInPrintPreviewMode) {
323 this._executeSoon(aCallback);
324 return;
325 }
326
327 // The browser is sometimes half-destroyed because this method is called
328 // by content pref service callbacks, which themselves can be called at any
329 // time, even after browsers are closed.
330 if (!aBrowser.parentNode || aBrowser.isSyntheticDocument) {
331 this._executeSoon(aCallback);
332 return;
333 }
334
335 if (aValue !== undefined) {
336 ZoomManager.setZoomForBrowser(aBrowser, this._ensureValid(aValue));
337 this._ignorePendingZoomAccesses(aBrowser);
338 this._executeSoon(aCallback);
339 return;
340 }
341
342 let token = this._getBrowserToken(aBrowser);
343 this._getGlobalValue(aBrowser, function (value) {
344 if (token.isCurrent) {
345 ZoomManager.setZoomForBrowser(aBrowser, value === undefined ? 1 : value);
346 this._ignorePendingZoomAccesses(aBrowser);
347 }
348 this._executeSoon(aCallback);
349 });
350 },
351
352 /**
353 * Saves the zoom level of the page in the given browser to the content
354 * prefs store.
355 *
356 * @param browser The zoom of this browser will be saved. Required.
357 */
358 _applyZoomToPref: function FullZoom__applyZoomToPref(browser) {
359 Services.obs.notifyObservers(null, "browser-fullZoom:zoomChange", "");
360 if (!this.siteSpecific ||
361 gInPrintPreviewMode ||
362 browser.isSyntheticDocument)
363 return;
364
365 this._cps2.set(browser.currentURI.spec, this.name,
366 ZoomManager.getZoomForBrowser(browser),
367 this._loadContextFromBrowser(browser), {
368 handleCompletion: function () {
369 this._isNextContentPrefChangeInternal = true;
370 }.bind(this),
371 });
372 },
373
374 /**
375 * Removes from the content prefs store the zoom level of the given browser.
376 *
377 * @param browser The zoom of this browser will be removed. Required.
378 */
379 _removePref: function FullZoom__removePref(browser) {
380 Services.obs.notifyObservers(null, "browser-fullZoom:zoomReset", "");
381 if (browser.isSyntheticDocument)
382 return;
383 let ctxt = this._loadContextFromBrowser(browser);
384 this._cps2.removeByDomainAndName(browser.currentURI.spec, this.name, ctxt, {
385 handleCompletion: function () {
386 this._isNextContentPrefChangeInternal = true;
387 }.bind(this),
388 });
389 },
390
391 //**************************************************************************//
392 // Utilities
393
394 /**
395 * Returns the zoom change token of the given browser. Asynchronous
396 * operations that access the given browser's zoom should use this method to
397 * capture the token before starting and use token.isCurrent to determine if
398 * it's safe to access the zoom when done. If token.isCurrent is false, then
399 * after the async operation started, either the browser's zoom was changed or
400 * the browser was destroyed, and depending on what the operation is doing, it
401 * may no longer be safe to set and get its zoom.
402 *
403 * @param browser The token of this browser will be returned.
404 * @return An object with an "isCurrent" getter.
405 */
406 _getBrowserToken: function FullZoom__getBrowserToken(browser) {
407 let map = this._browserTokenMap;
408 if (!map.has(browser))
409 map.set(browser, 0);
410 return {
411 token: map.get(browser),
412 get isCurrent() {
413 // At this point, the browser may have been destructed and unbound but
414 // its outer ID not removed from the map because outer-window-destroyed
415 // hasn't been received yet. In that case, the browser is unusable, it
416 // has no properties, so return false. Check for this case by getting a
417 // property, say, docShell.
418 return map.get(browser) === this.token && browser.parentNode;
419 },
420 };
421 },
422
423 /**
424 * Returns the browser that the supplied zoom event is associated with.
425 * @param event The ZoomChangeUsingMouseWheel event.
426 * @return The associated browser element, if one exists, otherwise null.
427 */
428 _getTargetedBrowser: function FullZoom__getTargetedBrowser(event) {
429 let target = event.originalTarget;
430
431 // With remote content browsers, the event's target is the browser
432 // we're looking for.
433 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
434 if (target instanceof window.XULElement &&
435 target.localName == "browser" &&
436 target.namespaceURI == XUL_NS)
437 return target;
438
439 // With in-process content browsers, the event's target is the content
440 // document.
441 if (target.nodeType == Node.DOCUMENT_NODE)
442 return gBrowser.getBrowserForDocument(target);
443
444 throw new Error("Unexpected ZoomChangeUsingMouseWheel event source");
445 },
446
447 /**
448 * Increments the zoom change token for the given browser so that pending
449 * async operations know that it may be unsafe to access they zoom when they
450 * finish.
451 *
452 * @param browser Pending accesses in this browser will be ignored.
453 */
454 _ignorePendingZoomAccesses: function FullZoom__ignorePendingZoomAccesses(browser) {
455 let map = this._browserTokenMap;
456 map.set(browser, (map.get(browser) || 0) + 1);
457 },
458
459 _ensureValid: function FullZoom__ensureValid(aValue) {
460 // Note that undefined is a valid value for aValue that indicates a known-
461 // not-to-exist value.
462 if (isNaN(aValue))
463 return 1;
464
465 if (aValue < ZoomManager.MIN)
466 return ZoomManager.MIN;
467
468 if (aValue > ZoomManager.MAX)
469 return ZoomManager.MAX;
470
471 return aValue;
472 },
473
474 /**
475 * Gets the global browser.content.full-zoom content preference.
476 *
477 * WARNING: callback may be called synchronously or asynchronously. The
478 * reason is that it's usually desirable to avoid turns of the event loop
479 * where possible, since they can lead to visible, jarring jumps in zoom
480 * level. It's not always possible to avoid them, though. As a convenience,
481 * then, this method takes a callback and returns nothing.
482 *
483 * @param browser The browser pertaining to the zoom.
484 * @param callback Synchronously or asynchronously called when done. It's
485 * bound to this object (FullZoom) and called as:
486 * callback(prefValue)
487 */
488 _getGlobalValue: function FullZoom__getGlobalValue(browser, callback) {
489 // * !("_globalValue" in this) => global value not yet cached.
490 // * this._globalValue === undefined => global value known not to exist.
491 // * Otherwise, this._globalValue is a number, the global value.
492 if ("_globalValue" in this) {
493 callback.call(this, this._globalValue, true);
494 return;
495 }
496 let value = undefined;
497 this._cps2.getGlobal(this.name, this._loadContextFromBrowser(browser), {
498 handleResult: function (pref) { value = pref.value; },
499 handleCompletion: function (reason) {
500 this._globalValue = this._ensureValid(value);
501 callback.call(this, this._globalValue);
502 }.bind(this)
503 });
504 },
505
506 /**
507 * Gets the load context from the given Browser.
508 *
509 * @param Browser The Browser whose load context will be returned.
510 * @return The nsILoadContext of the given Browser.
511 */
512 _loadContextFromBrowser: function FullZoom__loadContextFromBrowser(browser) {
513 return browser.loadContext;
514 },
515
516 /**
517 * Asynchronously broadcasts "browser-fullZoom:location-change" so that
518 * listeners can be notified when the zoom levels on those pages change.
519 * The notification is always asynchronous so that observers are guaranteed a
520 * consistent behavior.
521 */
522 _notifyOnLocationChange: function FullZoom__notifyOnLocationChange() {
523 this._executeSoon(function () {
524 Services.obs.notifyObservers(null, "browser-fullZoom:location-change", "");
525 });
526 },
527
528 _executeSoon: function FullZoom__executeSoon(callback) {
529 if (!callback)
530 return;
531 Services.tm.mainThread.dispatch(callback, Ci.nsIThread.DISPATCH_NORMAL);
532 },
533 };
534