michael@0: /* michael@0: #ifdef 0 michael@0: * This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. michael@0: #endif michael@0: */ michael@0: michael@0: /** michael@0: * Controls the "full zoom" setting and its site-specific preferences. michael@0: */ michael@0: var FullZoom = { michael@0: // Identifies the setting in the content prefs database. michael@0: name: "browser.content.full-zoom", michael@0: michael@0: // browser.zoom.siteSpecific preference cache michael@0: _siteSpecificPref: undefined, michael@0: michael@0: // browser.zoom.updateBackgroundTabs preference cache michael@0: updateBackgroundTabs: undefined, michael@0: michael@0: // One of the possible values for the mousewheel.* preferences. michael@0: // From EventStateManager.h. michael@0: ACTION_ZOOM: 3, michael@0: michael@0: // This maps the browser to monotonically increasing integer michael@0: // tokens. _browserTokenMap[browser] is increased each time the zoom is michael@0: // changed in the browser. See _getBrowserToken and _ignorePendingZoomAccesses. michael@0: _browserTokenMap: new WeakMap(), michael@0: michael@0: get siteSpecific() { michael@0: return this._siteSpecificPref; michael@0: }, michael@0: michael@0: //**************************************************************************// michael@0: // nsISupports michael@0: michael@0: QueryInterface: XPCOMUtils.generateQI([Ci.nsIDOMEventListener, michael@0: Ci.nsIObserver, michael@0: Ci.nsIContentPrefObserver, michael@0: Ci.nsISupportsWeakReference, michael@0: Ci.nsISupports]), michael@0: michael@0: //**************************************************************************// michael@0: // Initialization & Destruction michael@0: michael@0: init: function FullZoom_init() { michael@0: // Listen for scrollwheel events so we can save scrollwheel-based changes. michael@0: window.addEventListener("DOMMouseScroll", this, false); michael@0: michael@0: // Register ourselves with the service so we know when our pref changes. michael@0: this._cps2 = Cc["@mozilla.org/content-pref/service;1"]. michael@0: getService(Ci.nsIContentPrefService2); michael@0: this._cps2.addObserverForName(this.name, this); michael@0: michael@0: this._siteSpecificPref = michael@0: gPrefService.getBoolPref("browser.zoom.siteSpecific"); michael@0: this.updateBackgroundTabs = michael@0: gPrefService.getBoolPref("browser.zoom.updateBackgroundTabs"); michael@0: // Listen for changes to the browser.zoom branch so we can enable/disable michael@0: // updating background tabs and per-site saving and restoring of zoom levels. michael@0: gPrefService.addObserver("browser.zoom.", this, true); michael@0: }, michael@0: michael@0: destroy: function FullZoom_destroy() { michael@0: gPrefService.removeObserver("browser.zoom.", this); michael@0: this._cps2.removeObserverForName(this.name, this); michael@0: window.removeEventListener("DOMMouseScroll", this, false); michael@0: }, michael@0: michael@0: michael@0: //**************************************************************************// michael@0: // Event Handlers michael@0: michael@0: // nsIDOMEventListener michael@0: michael@0: handleEvent: function FullZoom_handleEvent(event) { michael@0: switch (event.type) { michael@0: case "DOMMouseScroll": michael@0: this._handleMouseScrolled(event); michael@0: break; michael@0: } michael@0: }, michael@0: michael@0: _handleMouseScrolled: function FullZoom__handleMouseScrolled(event) { michael@0: // Construct the "mousewheel action" pref key corresponding to this event. michael@0: // Based on EventStateManager::WheelPrefs::GetBasePrefName(). michael@0: var pref = "mousewheel."; michael@0: michael@0: var pressedModifierCount = event.shiftKey + event.ctrlKey + event.altKey + michael@0: event.metaKey + event.getModifierState("OS"); michael@0: if (pressedModifierCount != 1) { michael@0: pref += "default."; michael@0: } else if (event.shiftKey) { michael@0: pref += "with_shift."; michael@0: } else if (event.ctrlKey) { michael@0: pref += "with_control."; michael@0: } else if (event.altKey) { michael@0: pref += "with_alt."; michael@0: } else if (event.metaKey) { michael@0: pref += "with_meta."; michael@0: } else { michael@0: pref += "with_win."; michael@0: } michael@0: michael@0: pref += "action"; michael@0: michael@0: // Don't do anything if this isn't a "zoom" scroll event. michael@0: var isZoomEvent = false; michael@0: try { michael@0: isZoomEvent = (gPrefService.getIntPref(pref) == this.ACTION_ZOOM); michael@0: } catch (e) {} michael@0: if (!isZoomEvent) michael@0: return; michael@0: michael@0: // XXX Lazily cache all the possible action prefs so we don't have to get michael@0: // them anew from the pref service for every scroll event? We'd have to michael@0: // make sure to observe them so we can update the cache when they change. michael@0: michael@0: // We have to call _applyZoomToPref in a timeout because we handle the michael@0: // event before the event state manager has a chance to apply the zoom michael@0: // during EventStateManager::PostHandleEvent. michael@0: let browser = gBrowser.selectedBrowser; michael@0: let token = this._getBrowserToken(browser); michael@0: window.setTimeout(function () { michael@0: if (token.isCurrent) michael@0: this._applyZoomToPref(browser); michael@0: }.bind(this), 0); michael@0: }, michael@0: michael@0: // nsIObserver michael@0: michael@0: observe: function (aSubject, aTopic, aData) { michael@0: switch (aTopic) { michael@0: case "nsPref:changed": michael@0: switch (aData) { michael@0: case "browser.zoom.siteSpecific": michael@0: this._siteSpecificPref = michael@0: gPrefService.getBoolPref("browser.zoom.siteSpecific"); michael@0: break; michael@0: case "browser.zoom.updateBackgroundTabs": michael@0: this.updateBackgroundTabs = michael@0: gPrefService.getBoolPref("browser.zoom.updateBackgroundTabs"); michael@0: break; michael@0: } michael@0: break; michael@0: } michael@0: }, michael@0: michael@0: // nsIContentPrefObserver michael@0: michael@0: onContentPrefSet: function FullZoom_onContentPrefSet(aGroup, aName, aValue) { michael@0: this._onContentPrefChanged(aGroup, aValue); michael@0: }, michael@0: michael@0: onContentPrefRemoved: function FullZoom_onContentPrefRemoved(aGroup, aName) { michael@0: this._onContentPrefChanged(aGroup, undefined); michael@0: }, michael@0: michael@0: /** michael@0: * Appropriately updates the zoom level after a content preference has michael@0: * changed. michael@0: * michael@0: * @param aGroup The group of the changed preference. michael@0: * @param aValue The new value of the changed preference. Pass undefined to michael@0: * indicate the preference's removal. michael@0: */ michael@0: _onContentPrefChanged: function FullZoom__onContentPrefChanged(aGroup, aValue) { michael@0: if (this._isNextContentPrefChangeInternal) { michael@0: // Ignore changes that FullZoom itself makes. This works because the michael@0: // content pref service calls callbacks before notifying observers, and it michael@0: // does both in the same turn of the event loop. michael@0: delete this._isNextContentPrefChangeInternal; michael@0: return; michael@0: } michael@0: michael@0: let browser = gBrowser.selectedBrowser; michael@0: if (!browser.currentURI) michael@0: return; michael@0: michael@0: let domain = this._cps2.extractDomain(browser.currentURI.spec); michael@0: if (aGroup) { michael@0: if (aGroup == domain) michael@0: this._applyPrefToZoom(aValue, browser); michael@0: return; michael@0: } michael@0: michael@0: this._globalValue = aValue === undefined ? aValue : michael@0: this._ensureValid(aValue); michael@0: michael@0: // If the current page doesn't have a site-specific preference, then its michael@0: // zoom should be set to the new global preference now that the global michael@0: // preference has changed. michael@0: let hasPref = false; michael@0: let ctxt = this._loadContextFromBrowser(browser); michael@0: let token = this._getBrowserToken(browser); michael@0: this._cps2.getByDomainAndName(browser.currentURI.spec, this.name, ctxt, { michael@0: handleResult: function () hasPref = true, michael@0: handleCompletion: function () { michael@0: if (!hasPref && token.isCurrent) michael@0: this._applyPrefToZoom(undefined, browser); michael@0: }.bind(this) michael@0: }); michael@0: }, michael@0: michael@0: // location change observer michael@0: michael@0: /** michael@0: * Called when the location of a tab changes. michael@0: * When that happens, we need to update the current zoom level if appropriate. michael@0: * michael@0: * @param aURI michael@0: * A URI object representing the new location. michael@0: * @param aIsTabSwitch michael@0: * Whether this location change has happened because of a tab switch. michael@0: * @param aBrowser michael@0: * (optional) browser object displaying the document michael@0: */ michael@0: onLocationChange: function FullZoom_onLocationChange(aURI, aIsTabSwitch, aBrowser) { michael@0: // Ignore all pending async zoom accesses in the browser. Pending accesses michael@0: // that started before the location change will be prevented from applying michael@0: // to the new location. michael@0: let browser = aBrowser || gBrowser.selectedBrowser; michael@0: this._ignorePendingZoomAccesses(browser); michael@0: michael@0: if (!aURI || (aIsTabSwitch && !this.siteSpecific)) { michael@0: this._notifyOnLocationChange(); michael@0: return; michael@0: } michael@0: michael@0: // Avoid the cps roundtrip and apply the default/global pref. michael@0: if (aURI.spec == "about:blank") { michael@0: this._applyPrefToZoom(undefined, browser, michael@0: this._notifyOnLocationChange.bind(this)); michael@0: return; michael@0: } michael@0: michael@0: // Media documents should always start at 1, and are not affected by prefs. michael@0: if (!aIsTabSwitch && browser.isSyntheticDocument) { michael@0: ZoomManager.setZoomForBrowser(browser, 1); michael@0: // _ignorePendingZoomAccesses already called above, so no need here. michael@0: this._notifyOnLocationChange(); michael@0: return; michael@0: } michael@0: michael@0: // See if the zoom pref is cached. michael@0: let ctxt = this._loadContextFromBrowser(browser); michael@0: let pref = this._cps2.getCachedByDomainAndName(aURI.spec, this.name, ctxt); michael@0: if (pref) { michael@0: this._applyPrefToZoom(pref.value, browser, michael@0: this._notifyOnLocationChange.bind(this)); michael@0: return; michael@0: } michael@0: michael@0: // It's not cached, so we have to asynchronously fetch it. michael@0: let value = undefined; michael@0: let token = this._getBrowserToken(browser); michael@0: this._cps2.getByDomainAndName(aURI.spec, this.name, ctxt, { michael@0: handleResult: function (resultPref) value = resultPref.value, michael@0: handleCompletion: function () { michael@0: if (!token.isCurrent) { michael@0: this._notifyOnLocationChange(); michael@0: return; michael@0: } michael@0: this._applyPrefToZoom(value, browser, michael@0: this._notifyOnLocationChange.bind(this)); michael@0: }.bind(this) michael@0: }); michael@0: }, michael@0: michael@0: // update state of zoom type menu item michael@0: michael@0: updateMenu: function FullZoom_updateMenu() { michael@0: var menuItem = document.getElementById("toggle_zoom"); michael@0: michael@0: menuItem.setAttribute("checked", !ZoomManager.useFullZoom); michael@0: }, michael@0: michael@0: //**************************************************************************// michael@0: // Setting & Pref Manipulation michael@0: michael@0: /** michael@0: * Reduces the zoom level of the page in the current browser. michael@0: */ michael@0: reduce: function FullZoom_reduce() { michael@0: ZoomManager.reduce(); michael@0: let browser = gBrowser.selectedBrowser; michael@0: this._ignorePendingZoomAccesses(browser); michael@0: this._applyZoomToPref(browser); michael@0: }, michael@0: michael@0: /** michael@0: * Enlarges the zoom level of the page in the current browser. michael@0: */ michael@0: enlarge: function FullZoom_enlarge() { michael@0: ZoomManager.enlarge(); michael@0: let browser = gBrowser.selectedBrowser; michael@0: this._ignorePendingZoomAccesses(browser); michael@0: this._applyZoomToPref(browser); michael@0: }, michael@0: michael@0: /** michael@0: * Sets the zoom level of the page in the current browser to the global zoom michael@0: * level. michael@0: */ michael@0: reset: function FullZoom_reset() { michael@0: let browser = gBrowser.selectedBrowser; michael@0: let token = this._getBrowserToken(browser); michael@0: this._getGlobalValue(browser, function (value) { michael@0: if (token.isCurrent) { michael@0: ZoomManager.setZoomForBrowser(browser, value === undefined ? 1 : value); michael@0: this._ignorePendingZoomAccesses(browser); michael@0: this._executeSoon(function () { michael@0: // _getGlobalValue may be either sync or async, so notify asyncly so michael@0: // observers are guaranteed consistent behavior. michael@0: Services.obs.notifyObservers(null, "browser-fullZoom:zoomReset", ""); michael@0: }); michael@0: } michael@0: }); michael@0: this._removePref(browser); michael@0: }, michael@0: michael@0: /** michael@0: * Set the zoom level for a given browser. michael@0: * michael@0: * Per nsPresContext::setFullZoom, we can set the zoom to its current value michael@0: * without significant impact on performance, as the setting is only applied michael@0: * if it differs from the current setting. In fact getting the zoom and then michael@0: * checking ourselves if it differs costs more. michael@0: * michael@0: * And perhaps we should always set the zoom even if it was more expensive, michael@0: * since nsDocumentViewer::SetTextZoom claims that child documents can have michael@0: * a different text zoom (although it would be unusual), and it implies that michael@0: * those child text zooms should get updated when the parent zoom gets set, michael@0: * and perhaps the same is true for full zoom michael@0: * (although nsDocumentViewer::SetFullZoom doesn't mention it). michael@0: * michael@0: * So when we apply new zoom values to the browser, we simply set the zoom. michael@0: * We don't check first to see if the new value is the same as the current michael@0: * one. michael@0: * michael@0: * @param aValue The zoom level value. michael@0: * @param aBrowser The zoom is set in this browser. Required. michael@0: * @param aCallback If given, it's asynchronously called when complete. michael@0: */ michael@0: _applyPrefToZoom: function FullZoom__applyPrefToZoom(aValue, aBrowser, aCallback) { michael@0: if (!this.siteSpecific || gInPrintPreviewMode) { michael@0: this._executeSoon(aCallback); michael@0: return; michael@0: } michael@0: michael@0: // The browser is sometimes half-destroyed because this method is called michael@0: // by content pref service callbacks, which themselves can be called at any michael@0: // time, even after browsers are closed. michael@0: if (!aBrowser.parentNode || aBrowser.isSyntheticDocument) { michael@0: this._executeSoon(aCallback); michael@0: return; michael@0: } michael@0: michael@0: if (aValue !== undefined) { michael@0: ZoomManager.setZoomForBrowser(aBrowser, this._ensureValid(aValue)); michael@0: this._ignorePendingZoomAccesses(aBrowser); michael@0: this._executeSoon(aCallback); michael@0: return; michael@0: } michael@0: michael@0: let token = this._getBrowserToken(aBrowser); michael@0: this._getGlobalValue(aBrowser, function (value) { michael@0: if (token.isCurrent) { michael@0: ZoomManager.setZoomForBrowser(aBrowser, value === undefined ? 1 : value); michael@0: this._ignorePendingZoomAccesses(aBrowser); michael@0: } michael@0: this._executeSoon(aCallback); michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Saves the zoom level of the page in the given browser to the content michael@0: * prefs store. michael@0: * michael@0: * @param browser The zoom of this browser will be saved. Required. michael@0: */ michael@0: _applyZoomToPref: function FullZoom__applyZoomToPref(browser) { michael@0: Services.obs.notifyObservers(null, "browser-fullZoom:zoomChange", ""); michael@0: if (!this.siteSpecific || michael@0: gInPrintPreviewMode || michael@0: browser.isSyntheticDocument) michael@0: return; michael@0: michael@0: this._cps2.set(browser.currentURI.spec, this.name, michael@0: ZoomManager.getZoomForBrowser(browser), michael@0: this._loadContextFromBrowser(browser), { michael@0: handleCompletion: function () { michael@0: this._isNextContentPrefChangeInternal = true; michael@0: }.bind(this), michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Removes from the content prefs store the zoom level of the given browser. michael@0: * michael@0: * @param browser The zoom of this browser will be removed. Required. michael@0: */ michael@0: _removePref: function FullZoom__removePref(browser) { michael@0: Services.obs.notifyObservers(null, "browser-fullZoom:zoomReset", ""); michael@0: if (browser.isSyntheticDocument) michael@0: return; michael@0: let ctxt = this._loadContextFromBrowser(browser); michael@0: this._cps2.removeByDomainAndName(browser.currentURI.spec, this.name, ctxt, { michael@0: handleCompletion: function () { michael@0: this._isNextContentPrefChangeInternal = true; michael@0: }.bind(this), michael@0: }); michael@0: }, michael@0: michael@0: //**************************************************************************// michael@0: // Utilities michael@0: michael@0: /** michael@0: * Returns the zoom change token of the given browser. Asynchronous michael@0: * operations that access the given browser's zoom should use this method to michael@0: * capture the token before starting and use token.isCurrent to determine if michael@0: * it's safe to access the zoom when done. If token.isCurrent is false, then michael@0: * after the async operation started, either the browser's zoom was changed or michael@0: * the browser was destroyed, and depending on what the operation is doing, it michael@0: * may no longer be safe to set and get its zoom. michael@0: * michael@0: * @param browser The token of this browser will be returned. michael@0: * @return An object with an "isCurrent" getter. michael@0: */ michael@0: _getBrowserToken: function FullZoom__getBrowserToken(browser) { michael@0: let map = this._browserTokenMap; michael@0: if (!map.has(browser)) michael@0: map.set(browser, 0); michael@0: return { michael@0: token: map.get(browser), michael@0: get isCurrent() { michael@0: // At this point, the browser may have been destructed and unbound but michael@0: // its outer ID not removed from the map because outer-window-destroyed michael@0: // hasn't been received yet. In that case, the browser is unusable, it michael@0: // has no properties, so return false. Check for this case by getting a michael@0: // property, say, docShell. michael@0: return map.get(browser) === this.token && browser.parentNode; michael@0: }, michael@0: }; michael@0: }, michael@0: michael@0: /** michael@0: * Increments the zoom change token for the given browser so that pending michael@0: * async operations know that it may be unsafe to access they zoom when they michael@0: * finish. michael@0: * michael@0: * @param browser Pending accesses in this browser will be ignored. michael@0: */ michael@0: _ignorePendingZoomAccesses: function FullZoom__ignorePendingZoomAccesses(browser) { michael@0: let map = this._browserTokenMap; michael@0: map.set(browser, (map.get(browser) || 0) + 1); michael@0: }, michael@0: michael@0: _ensureValid: function FullZoom__ensureValid(aValue) { michael@0: // Note that undefined is a valid value for aValue that indicates a known- michael@0: // not-to-exist value. michael@0: if (isNaN(aValue)) michael@0: return 1; michael@0: michael@0: if (aValue < ZoomManager.MIN) michael@0: return ZoomManager.MIN; michael@0: michael@0: if (aValue > ZoomManager.MAX) michael@0: return ZoomManager.MAX; michael@0: michael@0: return aValue; michael@0: }, michael@0: michael@0: /** michael@0: * Gets the global browser.content.full-zoom content preference. michael@0: * michael@0: * WARNING: callback may be called synchronously or asynchronously. The michael@0: * reason is that it's usually desirable to avoid turns of the event loop michael@0: * where possible, since they can lead to visible, jarring jumps in zoom michael@0: * level. It's not always possible to avoid them, though. As a convenience, michael@0: * then, this method takes a callback and returns nothing. michael@0: * michael@0: * @param browser The browser pertaining to the zoom. michael@0: * @param callback Synchronously or asynchronously called when done. It's michael@0: * bound to this object (FullZoom) and called as: michael@0: * callback(prefValue) michael@0: */ michael@0: _getGlobalValue: function FullZoom__getGlobalValue(browser, callback) { michael@0: // * !("_globalValue" in this) => global value not yet cached. michael@0: // * this._globalValue === undefined => global value known not to exist. michael@0: // * Otherwise, this._globalValue is a number, the global value. michael@0: if ("_globalValue" in this) { michael@0: callback.call(this, this._globalValue, true); michael@0: return; michael@0: } michael@0: let value = undefined; michael@0: this._cps2.getGlobal(this.name, this._loadContextFromBrowser(browser), { michael@0: handleResult: function (pref) value = pref.value, michael@0: handleCompletion: function (reason) { michael@0: this._globalValue = this._ensureValid(value); michael@0: callback.call(this, this._globalValue); michael@0: }.bind(this) michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Gets the load context from the given Browser. michael@0: * michael@0: * @param Browser The Browser whose load context will be returned. michael@0: * @return The nsILoadContext of the given Browser. michael@0: */ michael@0: _loadContextFromBrowser: function FullZoom__loadContextFromBrowser(browser) { michael@0: return browser.loadContext; michael@0: }, michael@0: michael@0: /** michael@0: * Asynchronously broadcasts "browser-fullZoom:location-change" so that michael@0: * listeners can be notified when the zoom levels on those pages change. michael@0: * The notification is always asynchronous so that observers are guaranteed a michael@0: * consistent behavior. michael@0: */ michael@0: _notifyOnLocationChange: function FullZoom__notifyOnLocationChange() { michael@0: this._executeSoon(function () { michael@0: Services.obs.notifyObservers(null, "browser-fullZoom:location-change", ""); michael@0: }); michael@0: }, michael@0: michael@0: _executeSoon: function FullZoom__executeSoon(callback) { michael@0: if (!callback) michael@0: return; michael@0: Services.tm.mainThread.dispatch(callback, Ci.nsIThread.DISPATCH_NORMAL); michael@0: }, michael@0: };