diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js index 6feca4f1d923..00cec4a595ab 100644 --- a/browser/app/profile/firefox.js +++ b/browser/app/profile/firefox.js @@ -1041,6 +1041,7 @@ pref("devtools.scratchpad.enabled", true); // Enable the Style Editor. pref("devtools.styleeditor.enabled", true); +pref("devtools.styleeditor.transitions", true); // Enable tools for Chrome development. pref("devtools.chrome.enabled", false); diff --git a/browser/base/content/browser-thumbnails.js b/browser/base/content/browser-thumbnails.js new file mode 100644 index 000000000000..59a6f743a4d1 --- /dev/null +++ b/browser/base/content/browser-thumbnails.js @@ -0,0 +1,119 @@ +#ifdef 0 +/* 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/. */ +#endif + +/** + * Keeps thumbnails of open web pages up-to-date. + */ +let gBrowserThumbnails = { + _captureDelayMS: 2000, + + /** + * Map of capture() timeouts assigned to their browsers. + */ + _timeouts: null, + + /** + * Cache for the PageThumbs module. + */ + _pageThumbs: null, + + /** + * List of tab events we want to listen for. + */ + _tabEvents: ["TabClose", "TabSelect"], + + init: function Thumbnails_init() { + gBrowser.addTabsProgressListener(this); + + this._tabEvents.forEach(function (aEvent) { + gBrowser.tabContainer.addEventListener(aEvent, this, false); + }, this); + + this._timeouts = new WeakMap(); + + XPCOMUtils.defineLazyModuleGetter(this, "_pageThumbs", + "resource:///modules/PageThumbs.jsm", "PageThumbs"); + }, + + uninit: function Thumbnails_uninit() { + gBrowser.removeTabsProgressListener(this); + + this._tabEvents.forEach(function (aEvent) { + gBrowser.tabContainer.removeEventListener(aEvent, this, false); + }, this); + + this._timeouts = null; + this._pageThumbs = null; + }, + + handleEvent: function Thumbnails_handleEvent(aEvent) { + switch (aEvent.type) { + case "TabSelect": + this._delayedCapture(aEvent.target.linkedBrowser); + break; + case "TabClose": { + let browser = aEvent.target.linkedBrowser; + if (this._timeouts.has(browser)) { + clearTimeout(this._timeouts.get(browser)); + this._timeouts.delete(browser); + } + break; + } + } + }, + + /** + * State change progress listener for all tabs. + */ + onStateChange: function Thumbnails_onStateChange(aBrowser, aWebProgress, + aRequest, aStateFlags, aStatus) { + if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP && + aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK) + this._delayedCapture(aBrowser); + }, + + _capture: function Thumbnails_capture(aBrowser) { + if (this._shouldCapture(aBrowser)) { + let canvas = this._pageThumbs.capture(aBrowser.contentWindow); + this._pageThumbs.store(aBrowser.currentURI.spec, canvas); + } + }, + + _delayedCapture: function Thumbnails_delayedCapture(aBrowser) { + if (this._timeouts.has(aBrowser)) + clearTimeout(this._timeouts.get(aBrowser)); + + let timeout = setTimeout(function () { + this._timeouts.delete(aBrowser); + this._capture(aBrowser); + }.bind(this), this._captureDelayMS); + + this._timeouts.set(aBrowser, timeout); + }, + + _shouldCapture: function Thumbnails_shouldCapture(aBrowser) { + // There's no point in taking screenshot of loading pages. + if (aBrowser.docShell.busyFlags != Ci.nsIDocShell.BUSY_FLAGS_NONE) + return false; + + // Don't take screenshots of about: pages. + if (aBrowser.currentURI.schemeIs("about")) + return false; + + let channel = aBrowser.docShell.currentDocumentChannel; + + try { + // If the channel is a nsIHttpChannel get its http status code. + let httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); + + // Continue only if we have a 2xx status code. + return Math.floor(httpChannel.responseStatus / 100) == 2; + } catch (e) { + // Not a http channel, we just assume a success status code. + return true; + } + } +}; diff --git a/browser/base/content/browser.js b/browser/base/content/browser.js index e0283b7ef1df..55b43e9488e6 100644 --- a/browser/base/content/browser.js +++ b/browser/base/content/browser.js @@ -196,6 +196,7 @@ let gInitialPages = [ #include browser-places.js #include browser-tabPreviews.js #include browser-tabview.js +#include browser-thumbnails.js #ifdef MOZ_SERVICES_SYNC #include browser-syncui.js @@ -1699,6 +1700,7 @@ function delayedStartup(isLoadingBlank, mustLoadSidebar) { gSyncUI.init(); #endif + gBrowserThumbnails.init(); TabView.init(); setUrlAndSearchBarWidthForConditionalForwardButton(); @@ -1820,6 +1822,7 @@ function BrowserShutdown() { gPrefService.removeObserver(allTabs.prefName, allTabs); ctrlTab.uninit(); TabView.uninit(); + gBrowserThumbnails.uninit(); try { FullZoom.destroy(); diff --git a/browser/components/Makefile.in b/browser/components/Makefile.in index eca4d1fb8967..a7bc311e53f2 100644 --- a/browser/components/Makefile.in +++ b/browser/components/Makefile.in @@ -71,6 +71,7 @@ PARALLEL_DIRS = \ shell \ sidebar \ tabview \ + thumbnails \ migration \ $(NULL) diff --git a/browser/components/thumbnails/BrowserPageThumbs.manifest b/browser/components/thumbnails/BrowserPageThumbs.manifest new file mode 100644 index 000000000000..8dfc0597b261 --- /dev/null +++ b/browser/components/thumbnails/BrowserPageThumbs.manifest @@ -0,0 +1,2 @@ +component {5a4ae9b5-f475-48ae-9dce-0b4c1d347884} PageThumbsProtocol.js +contract @mozilla.org/network/protocol;1?name=moz-page-thumb {5a4ae9b5-f475-48ae-9dce-0b4c1d347884} diff --git a/browser/components/thumbnails/Makefile.in b/browser/components/thumbnails/Makefile.in new file mode 100644 index 000000000000..bdc0aee6ac96 --- /dev/null +++ b/browser/components/thumbnails/Makefile.in @@ -0,0 +1,23 @@ +# 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/. + +DEPTH = ../../.. +topsrcdir = @top_srcdir@ +srcdir = @srcdir@ +VPATH = @srcdir@ + +include $(DEPTH)/config/autoconf.mk + +EXTRA_COMPONENTS = \ + BrowserPageThumbs.manifest \ + PageThumbsProtocol.js \ + $(NULL) + +ifdef ENABLE_TESTS + DIRS += test +endif + +include $(topsrcdir)/config/rules.mk + +XPIDL_FLAGS += -I$(topsrcdir)/browser/components/ diff --git a/browser/components/thumbnails/PageThumbsProtocol.js b/browser/components/thumbnails/PageThumbsProtocol.js new file mode 100644 index 000000000000..3ee7a7f2d696 --- /dev/null +++ b/browser/components/thumbnails/PageThumbsProtocol.js @@ -0,0 +1,448 @@ +/* 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/. */ + +/** + * PageThumbsProtocol.js + * + * This file implements the moz-page-thumb:// protocol and the corresponding + * channel delivering cached thumbnails. + * + * URL structure: + * + * moz-page-thumb://thumbnail?url=http%3A%2F%2Fwww.mozilla.org%2F + * + * This URL requests an image for 'http://www.mozilla.org/'. + */ + +"use strict"; + +const Cu = Components.utils; +const Cc = Components.classes; +const Cr = Components.results; +const Ci = Components.interfaces; + +Cu.import("resource:///modules/PageThumbs.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +/** + * Implements the thumbnail protocol handler responsible for moz-page-thumb: URIs. + */ +function Protocol() { +} + +Protocol.prototype = { + /** + * The scheme used by this protocol. + */ + get scheme() PageThumbs.scheme, + + /** + * The default port for this protocol (we don't support ports). + */ + get defaultPort() -1, + + /** + * The flags specific to this protocol implementation. + */ + get protocolFlags() { + return Ci.nsIProtocolHandler.URI_IS_UI_RESOURCE | + Ci.nsIProtocolHandler.URI_NORELATIVE | + Ci.nsIProtocolHandler.URI_NOAUTH; + }, + + /** + * Creates a new URI object that is suitable for loading by this protocol. + * @param aSpec The URI string in UTF8 encoding. + * @param aOriginCharset The charset of the document from which the URI originated. + * @return The newly created URI. + */ + newURI: function Proto_newURI(aSpec, aOriginCharset) { + let uri = Cc["@mozilla.org/network/simple-uri;1"].createInstance(Ci.nsIURI); + uri.spec = aSpec; + return uri; + }, + + /** + * Constructs a new channel from the given URI for this protocol handler. + * @param aURI The URI for which to construct a channel. + * @return The newly created channel. + */ + newChannel: function Proto_newChannel(aURI) { + return new Channel(aURI); + }, + + /** + * Decides whether to allow a blacklisted port. + * @return Always false, we'll never allow ports. + */ + allowPort: function () false, + + classID: Components.ID("{5a4ae9b5-f475-48ae-9dce-0b4c1d347884}"), + QueryInterface: XPCOMUtils.generateQI([Ci.nsIProtocolHandler]) +}; + +let NSGetFactory = XPCOMUtils.generateNSGetFactory([Protocol]); + +/** + * A channel implementation responsible for delivering cached thumbnails. + */ +function Channel(aURI) { + this._uri = aURI; + + // nsIChannel + this.originalURI = aURI; + + // nsIHttpChannel + this._responseHeaders = {"content-type": PageThumbs.contentType}; +} + +Channel.prototype = { + /** + * Tracks if the channel has been opened, yet. + */ + _wasOpened: false, + + /** + * Opens this channel asynchronously. + * @param aListener The listener that receives the channel data when available. + * @param aContext A custom context passed to the listener's methods. + */ + asyncOpen: function Channel_asyncOpen(aListener, aContext) { + if (this._wasOpened) + throw Cr.NS_ERROR_ALREADY_OPENED; + + if (this.canceled) + return; + + this._listener = aListener; + this._context = aContext; + + this._isPending = true; + this._wasOpened = true; + + // Try to read the data from the thumbnail cache. + this._readCache(function (aData) { + // Update response if there's no data. + if (!aData) { + this._responseStatus = 404; + this._responseText = "Not Found"; + } + + this._startRequest(); + + if (!this.canceled) { + this._addToLoadGroup(); + + if (aData) + this._serveData(aData); + + if (!this.canceled) + this._stopRequest(); + } + }.bind(this)); + }, + + /** + * Reads a data stream from the cache entry. + * @param aCallback The callback the data is passed to. + */ + _readCache: function Channel_readCache(aCallback) { + let {url} = parseURI(this._uri); + + // Return early if there's no valid URL given. + if (!url) { + aCallback(null); + return; + } + + // Try to get a cache entry. + PageThumbsCache.getReadEntry(url, function (aEntry) { + let inputStream = aEntry && aEntry.openInputStream(0); + + function closeEntryAndFinish(aData) { + if (aEntry) { + aEntry.close(); + } + aCallback(aData); + } + + // Check if we have a valid entry and if it has any data. + if (!inputStream || !inputStream.available()) { + closeEntryAndFinish(); + return; + } + + try { + // Read the cache entry's data. + NetUtil.asyncFetch(inputStream, function (aData, aStatus) { + // We might have been canceled while waiting. + if (this.canceled) + return; + + // Check if we have a valid data stream. + if (!Components.isSuccessCode(aStatus) || !aData.available()) + aData = null; + + closeEntryAndFinish(aData); + }.bind(this)); + } catch (e) { + closeEntryAndFinish(); + } + }.bind(this)); + }, + + /** + * Calls onStartRequest on the channel listener. + */ + _startRequest: function Channel_startRequest() { + try { + this._listener.onStartRequest(this, this._context); + } catch (e) { + // The listener might throw if the request has been canceled. + this.cancel(Cr.NS_BINDING_ABORTED); + } + }, + + /** + * Calls onDataAvailable on the channel listener and passes the data stream. + * @param aData The data to be delivered. + */ + _serveData: function Channel_serveData(aData) { + try { + let available = aData.available(); + this._listener.onDataAvailable(this, this._context, aData, 0, available); + } catch (e) { + // The listener might throw if the request has been canceled. + this.cancel(Cr.NS_BINDING_ABORTED); + } + }, + + /** + * Calls onStopRequest on the channel listener. + */ + _stopRequest: function Channel_stopRequest() { + try { + this._listener.onStopRequest(this, this._context, this.status); + } catch (e) { + // This might throw but is generally ignored. + } + + // The request has finished, clean up after ourselves. + this._cleanup(); + }, + + /** + * Adds this request to the load group, if any. + */ + _addToLoadGroup: function Channel_addToLoadGroup() { + if (this.loadGroup) + this.loadGroup.addRequest(this, this._context); + }, + + /** + * Removes this request from its load group, if any. + */ + _removeFromLoadGroup: function Channel_removeFromLoadGroup() { + if (!this.loadGroup) + return; + + try { + this.loadGroup.removeRequest(this, this._context, this.status); + } catch (e) { + // This might throw but is ignored. + } + }, + + /** + * Cleans up the channel when the request has finished. + */ + _cleanup: function Channel_cleanup() { + this._removeFromLoadGroup(); + this.loadGroup = null; + + this._isPending = false; + + delete this._listener; + delete this._context; + }, + + /* :::::::: nsIChannel ::::::::::::::: */ + + contentType: PageThumbs.contentType, + contentLength: -1, + owner: null, + contentCharset: null, + notificationCallbacks: null, + + get URI() this._uri, + get securityInfo() null, + + /** + * Opens this channel synchronously. Not supported. + */ + open: function Channel_open() { + // Synchronous data delivery is not implemented. + throw Cr.NS_ERROR_NOT_IMPLEMENTED; + }, + + /* :::::::: nsIHttpChannel ::::::::::::::: */ + + redirectionLimit: 10, + requestMethod: "GET", + allowPipelining: true, + referrer: null, + + get requestSucceeded() true, + + _responseStatus: 200, + get responseStatus() this._responseStatus, + + _responseText: "OK", + get responseStatusText() this._responseText, + + /** + * Checks if the server sent the equivalent of a "Cache-control: no-cache" + * response header. + * @return Always false. + */ + isNoCacheResponse: function () false, + + /** + * Checks if the server sent the equivalent of a "Cache-control: no-cache" + * response header. + * @return Always false. + */ + isNoStoreResponse: function () false, + + /** + * Returns the value of a particular request header. Not implemented. + */ + getRequestHeader: function Channel_getRequestHeader() { + throw Cr.NS_ERROR_NOT_AVAILABLE; + }, + + /** + * This method is called to set the value of a particular request header. + * Not implemented. + */ + setRequestHeader: function Channel_setRequestHeader() { + if (this._wasOpened) + throw Cr.NS_ERROR_IN_PROGRESS; + }, + + /** + * Call this method to visit all request headers. Not implemented. + */ + visitRequestHeaders: function () {}, + + /** + * Gets the value of a particular response header. + * @param aHeader The case-insensitive name of the response header to query. + * @return The header value. + */ + getResponseHeader: function Channel_getResponseHeader(aHeader) { + let name = aHeader.toLowerCase(); + if (name in this._responseHeaders) + return this._responseHeaders[name]; + + throw Cr.NS_ERROR_NOT_AVAILABLE; + }, + + /** + * This method is called to set the value of a particular response header. + * @param aHeader The case-insensitive name of the response header to query. + * @param aValue The response header value to set. + */ + setResponseHeader: function Channel_setResponseHeader(aHeader, aValue, aMerge) { + let name = aHeader.toLowerCase(); + if (!aValue && !aMerge) + delete this._responseHeaders[name]; + else + this._responseHeaders[name] = aValue; + }, + + /** + * Call this method to visit all response headers. + * @param aVisitor The header visitor. + */ + visitResponseHeaders: function Channel_visitResponseHeaders(aVisitor) { + for (let name in this._responseHeaders) { + let value = this._responseHeaders[name]; + + try { + aVisitor.visitHeader(name, value); + } catch (e) { + // The visitor can throw to stop the iteration. + return; + } + } + }, + + /* :::::::: nsIRequest ::::::::::::::: */ + + loadFlags: Ci.nsIRequest.LOAD_NORMAL, + loadGroup: null, + + get name() this._uri.spec, + + _status: Cr.NS_OK, + get status() this._status, + + _isPending: false, + isPending: function () this._isPending, + + resume: function () {}, + suspend: function () {}, + + /** + * Cancels this request. + * @param aStatus The reason for cancelling. + */ + cancel: function Channel_cancel(aStatus) { + if (this.canceled) + return; + + this._isCanceled = true; + this._status = aStatus; + + this._cleanup(); + }, + + /* :::::::: nsIHttpChannelInternal ::::::::::::::: */ + + documentURI: null, + + _isCanceled: false, + get canceled() this._isCanceled, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIChannel, + Ci.nsIHttpChannel, + Ci.nsIHttpChannelInternal, + Ci.nsIRequest]) +}; + +/** + * Parses a given URI and extracts all parameters relevant to this protocol. + * @param aURI The URI to parse. + * @return The parsed parameters. + */ +function parseURI(aURI) { + let {scheme, staticHost} = PageThumbs; + let re = new RegExp("^" + scheme + "://" + staticHost + ".*?\\?"); + let query = aURI.spec.replace(re, ""); + let params = {}; + + query.split("&").forEach(function (aParam) { + let [key, value] = aParam.split("=").map(decodeURIComponent); + params[key.toLowerCase()] = value; + }); + + return params; +} diff --git a/browser/components/thumbnails/test/Makefile.in b/browser/components/thumbnails/test/Makefile.in new file mode 100644 index 000000000000..014bf0828a31 --- /dev/null +++ b/browser/components/thumbnails/test/Makefile.in @@ -0,0 +1,21 @@ +# 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/. + +DEPTH = ../../../.. +topsrcdir = @top_srcdir@ +srcdir = @srcdir@ +VPATH = @srcdir@ +relativesrcdir = browser/components/thumbnails/test + +include $(DEPTH)/config/autoconf.mk +include $(topsrcdir)/config/rules.mk + +_BROWSER_FILES = \ + browser_thumbnails_cache.js \ + browser_thumbnails_capture.js \ + head.js \ + $(NULL) + +libs:: $(_BROWSER_FILES) + $(INSTALL) $(foreach f,$^,"$f") $(DEPTH)/_tests/testing/mochitest/browser/$(relativesrcdir) diff --git a/browser/components/thumbnails/test/browser_thumbnails_cache.js b/browser/components/thumbnails/test/browser_thumbnails_cache.js new file mode 100644 index 000000000000..c0917f74fffd --- /dev/null +++ b/browser/components/thumbnails/test/browser_thumbnails_cache.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests ensure that saving a thumbnail to the cache works. They also + * retrieve the thumbnail and display it using an element to compare + * its pixel colors. + */ +function runTests() { + // Create a new tab with a red background. + yield addTab("data:text/html,
"); + let cw = gBrowser.selectedTab.linkedBrowser.contentWindow; + + // Capture a thumbnail for the tab. + let canvas = PageThumbs.capture(cw); + + // Store the tab into the thumbnail cache. + yield PageThumbs.store("key", canvas, next); + + let {width, height} = canvas; + let thumb = PageThumbs.getThumbnailURL("key", width, height); + + // Create a new tab with an image displaying the previously stored thumbnail. + yield addTab("data:text/html," + + ""); + + cw = gBrowser.selectedTab.linkedBrowser.contentWindow; + let [img, canvas] = cw.document.querySelectorAll("img, canvas"); + + // Draw the image to a canvas and compare the pixel color values. + let ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0, width, height); + checkCanvasColor(ctx, 255, 0, 0, "we have a red image and canvas"); +} diff --git a/browser/components/thumbnails/test/browser_thumbnails_capture.js b/browser/components/thumbnails/test/browser_thumbnails_capture.js new file mode 100644 index 000000000000..38886159659d --- /dev/null +++ b/browser/components/thumbnails/test/browser_thumbnails_capture.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests ensure that capturing a site's screenshot to a canvas actually + * works. + */ +function runTests() { + // Create a tab with a red background. + yield addTab("data:text/html,"); + checkCurrentThumbnailColor(255, 0, 0, "we have a red thumbnail"); + + // Load a page with a green background. + yield navigateTo("data:text/html,"); + checkCurrentThumbnailColor(0, 255, 0, "we have a green thumbnail"); + + // Load a page with a blue background. + yield navigateTo("data:text/html,"); + checkCurrentThumbnailColor(0, 0, 255, "we have a blue thumbnail"); +} + +/** + * Captures a thumbnail of the currently selected tab and checks the color of + * the resulting canvas. + * @param aRed The red component's intensity. + * @param aGreen The green component's intensity. + * @param aBlue The blue component's intensity. + * @param aMessage The info message to print when checking the pixel color. + */ +function checkCurrentThumbnailColor(aRed, aGreen, aBlue, aMessage) { + let tab = gBrowser.selectedTab; + let cw = tab.linkedBrowser.contentWindow; + + let canvas = PageThumbs.capture(cw); + let ctx = canvas.getContext("2d"); + + checkCanvasColor(ctx, aRed, aGreen, aBlue, aMessage); +} diff --git a/browser/components/thumbnails/test/head.js b/browser/components/thumbnails/test/head.js new file mode 100644 index 000000000000..65eb8c4003b2 --- /dev/null +++ b/browser/components/thumbnails/test/head.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource:///modules/PageThumbs.jsm"); + +registerCleanupFunction(function () { + while (gBrowser.tabs.length > 1) + gBrowser.removeTab(gBrowser.tabs[1]); +}); + +/** + * Provide the default test function to start our test runner. + */ +function test() { + TestRunner.run(); +} + +/** + * The test runner that controls the execution flow of our tests. + */ +let TestRunner = { + /** + * Starts the test runner. + */ + run: function () { + waitForExplicitFinish(); + + this._iter = runTests(); + this.next(); + }, + + /** + * Runs the next available test or finishes if there's no test left. + */ + next: function () { + try { + TestRunner._iter.next(); + } catch (e if e instanceof StopIteration) { + finish(); + } + } +}; + +/** + * Continues the current test execution. + */ +function next() { + TestRunner.next(); +} + +/** + * Creates a new tab with the given URI. + * @param aURI The URI that's loaded in the tab. + */ +function addTab(aURI) { + let tab = gBrowser.selectedTab = gBrowser.addTab(aURI); + whenBrowserLoaded(tab.linkedBrowser); +} + +/** + * Loads a new URI into the currently selected tab. + * @param aURI The URI to load. + */ +function navigateTo(aURI) { + let browser = gBrowser.selectedTab.linkedBrowser; + whenBrowserLoaded(browser); + browser.loadURI(aURI); +} + +/** + * Continues the current test execution when a load event for the given browser + * has been received + * @param aBrowser The browser to listen on. + */ +function whenBrowserLoaded(aBrowser) { + aBrowser.addEventListener("load", function onLoad() { + aBrowser.removeEventListener("load", onLoad, true); + executeSoon(next); + }, true); +} + +/** + * Checks the top-left pixel of a given canvas' 2d context for a given color. + * @param aContext The 2D context of a canvas. + * @param aRed The red component's intensity. + * @param aGreen The green component's intensity. + * @param aBlue The blue component's intensity. + * @param aMessage The info message to print when comparing the pixel color. + */ +function checkCanvasColor(aContext, aRed, aGreen, aBlue, aMessage) { + let [r, g, b] = aContext.getImageData(0, 0, 1, 1).data; + ok(r == aRed && g == aGreen && b == aBlue, aMessage); +} diff --git a/browser/devtools/styleeditor/StyleEditor.jsm b/browser/devtools/styleeditor/StyleEditor.jsm index 5b5c4f6a5645..092e2bd89831 100644 --- a/browser/devtools/styleeditor/StyleEditor.jsm +++ b/browser/devtools/styleeditor/StyleEditor.jsm @@ -59,6 +59,23 @@ const UPDATE_STYLESHEET_THROTTLE_DELAY = 500; // @see StyleEditor._persistExpando const STYLESHEET_EXPANDO = "-moz-styleeditor-stylesheet-"; +const TRANSITIONS_PREF = "devtools.styleeditor.transitions"; + +const TRANSITION_CLASS = "moz-styleeditor-transitioning"; +const TRANSITION_DURATION_MS = 500; +const TRANSITION_RULE = "\ +:root.moz-styleeditor-transitioning, :root.moz-styleeditor-transitioning * {\ +-moz-transition-duration: " + TRANSITION_DURATION_MS + "ms !important; \ +-moz-transition-delay: 0ms !important;\ +-moz-transition-timing-function: ease-out !important;\ +-moz-transition-property: all !important;\ +}"; + +/** + * Style Editor module-global preferences + */ +const TRANSITIONS_ENABLED = Services.prefs.getBoolPref(TRANSITIONS_PREF); + /** * StyleEditor constructor. @@ -107,6 +124,9 @@ function StyleEditor(aDocument, aStyleSheet) // this is to perform pending updates before editor closing this._onWindowUnloadBinding = this._onWindowUnload.bind(this); + + this._transitionRefCount = 0; + this._focusOnSourceEditorReady = false; } @@ -405,8 +425,13 @@ StyleEditor.prototype = { * Arguments: (StyleEditor editor) * @see inputElement * - * onCommit: Called when changes have been committed/applied - * to the live DOM style sheet. + * onUpdate: Called when changes are being applied to the live + * DOM style sheet but might not be complete from + * a WYSIWYG perspective (eg. transitioned update). + * Arguments: (StyleEditor editor) + * + * onCommit: Called when changes have been completely committed + * /applied to the live DOM style sheet. * Arguments: (StyleEditor editor) * } * @@ -640,15 +665,50 @@ StyleEditor.prototype = { let source = this._state.text; let oldNode = this.styleSheet.ownerNode; let oldIndex = this.styleSheetIndex; - - let newNode = this.contentDocument.createElement("style"); + let content = this.contentDocument; + let newNode = content.createElement("style"); newNode.setAttribute("type", "text/css"); - newNode.appendChild(this.contentDocument.createTextNode(source)); + newNode.appendChild(content.createTextNode(source)); oldNode.parentNode.replaceChild(newNode, oldNode); - this._styleSheet = this.contentDocument.styleSheets[oldIndex]; + this._styleSheet = content.styleSheets[oldIndex]; this._persistExpando(); + if (!TRANSITIONS_ENABLED) { + this._triggerAction("Update"); + this._triggerAction("Commit"); + return; + } + + // Insert the global transition rule + // Use a ref count to make sure we do not add it multiple times.. and remove + // it only when all pending StyleEditor-generated transitions ended. + if (!this._transitionRefCount) { + this._styleSheet.insertRule(TRANSITION_RULE, 0); + content.documentElement.classList.add(TRANSITION_CLASS); + } + + this._transitionRefCount++; + + // Set up clean up and commit after transition duration (+10% buffer) + // @see _onTransitionEnd + content.defaultView.setTimeout(this._onTransitionEnd.bind(this), + Math.floor(TRANSITION_DURATION_MS * 1.1)); + + this._triggerAction("Update"); + }, + + /** + * This cleans up class and rule added for transition effect and then trigger + * Commit as the changes have been completed. + */ + _onTransitionEnd: function SE__onTransitionEnd() + { + if (--this._transitionRefCount == 0) { + this.contentDocument.documentElement.classList.remove(TRANSITION_CLASS); + this.styleSheet.deleteRule(0); + } + this._triggerAction("Commit"); }, diff --git a/browser/devtools/styleeditor/StyleEditorChrome.jsm b/browser/devtools/styleeditor/StyleEditorChrome.jsm index b71a8d15bbbd..097fda5746b8 100644 --- a/browser/devtools/styleeditor/StyleEditorChrome.jsm +++ b/browser/devtools/styleeditor/StyleEditorChrome.jsm @@ -156,11 +156,14 @@ StyleEditorChrome.prototype = { aContentWindow.addEventListener("unload", onContentUnload, false); if (aContentWindow.document.readyState == "complete") { + this._root.classList.remove("loading"); this._populateChrome(); return; } else { + this._root.classList.add("loading"); let onContentReady = function () { aContentWindow.removeEventListener("load", onContentReady, false); + this._root.classList.remove("loading"); this._populateChrome(); }.bind(this); aContentWindow.addEventListener("load", onContentReady, false); @@ -299,7 +302,7 @@ StyleEditorChrome.prototype = { }, /** - * Reset the chrome UI to an empty state. + * Reset the chrome UI to an empty and ready state. */ _resetChrome: function SEC__resetChrome() { @@ -309,6 +312,12 @@ StyleEditorChrome.prototype = { this._editors = []; this._view.removeAll(); + + // (re)enable UI + let matches = this._root.querySelectorAll("toolbarbutton,input,select"); + for (let i = 0; i < matches.length; ++i) { + matches[i].removeAttribute("disabled"); + } }, /** diff --git a/browser/devtools/styleeditor/splitview.css b/browser/devtools/styleeditor/splitview.css index cf1f68eb0690..1bc270225104 100644 --- a/browser/devtools/styleeditor/splitview.css +++ b/browser/devtools/styleeditor/splitview.css @@ -46,6 +46,10 @@ box, -moz-box-pack: center; } +.loading .splitview-nav-container > .placeholder { + display: none !important; +} + .splitview-controller, .splitview-main { -moz-box-flex: 0; diff --git a/browser/devtools/styleeditor/styleeditor.xul b/browser/devtools/styleeditor/styleeditor.xul index 86c12abf9355..b29f64d40795 100644 --- a/browser/devtools/styleeditor/styleeditor.xul +++ b/browser/devtools/styleeditor/styleeditor.xul @@ -54,18 +54,20 @@ persist="screenX screenY width height sizemode">WebConsole test: iframe associated to the wrong HUD.
+ src="http://example.com/browser/browser/devtools/webconsole/test/test-bug-593003-iframe-wrong-hud-iframe.html">