diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js
index d40e53377fb..98097dd2829 100644
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1018,6 +1018,9 @@ pref("devtools.ruleview.enabled", true);
// Enable the Scratchpad tool.
pref("devtools.scratchpad.enabled", true);
+// Enable the Style Editor.
+pref("devtools.styleeditor.enabled", true);
+
// Enable tools for Chrome development.
pref("devtools.chrome.enabled", false);
diff --git a/browser/base/content/browser-appmenu.inc b/browser/base/content/browser-appmenu.inc
index 72f320b228b..54f382b624a 100644
--- a/browser/base/content/browser-appmenu.inc
+++ b/browser/base/content/browser-appmenu.inc
@@ -192,6 +192,11 @@
label="&scratchpad.label;"
key="key_scratchpad"
command="Tools:Scratchpad"/>
+
+
+
@@ -244,6 +245,8 @@
+
diff --git a/browser/base/content/browser.js b/browser/base/content/browser.js
index 739ac2b295d..d1d96c4b08c 100644
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -1721,6 +1721,16 @@ function delayedStartup(isLoadingBlank, mustLoadSidebar) {
#endif
}
+ // Enable Style Editor?
+ let styleEditorEnabled = gPrefService.getBoolPref(StyleEditor.prefEnabledName);
+ if (styleEditorEnabled) {
+ document.getElementById("menu_styleeditor").hidden = false;
+ document.getElementById("Tools:StyleEditor").removeAttribute("disabled");
+#ifdef MENUBAR_CAN_AUTOHIDE
+ document.getElementById("appmenu_styleeditor").hidden = false;
+#endif
+ }
+
#ifdef MENUBAR_CAN_AUTOHIDE
// If the user (or the locale) hasn't enabled the top-level "Character
// Encoding" menu via the "browser.menu.showCharacterEncoding" preference,
@@ -8970,6 +8980,34 @@ XPCOMUtils.defineLazyGetter(Scratchpad, "ScratchpadManager", function() {
return tmp.ScratchpadManager;
});
+var StyleEditor = {
+ prefEnabledName: "devtools.styleeditor.enabled",
+ openChrome: function SE_openChrome()
+ {
+ const CHROME_URL = "chrome://browser/content/styleeditor.xul";
+ const CHROME_WINDOW_TYPE = "Tools:StyleEditor";
+ const CHROME_WINDOW_FLAGS = "chrome,centerscreen,resizable,dialog=no";
+
+ // focus currently open Style Editor window for this document, if any
+ let contentWindow = gBrowser.selectedBrowser.contentWindow;
+ let contentWindowID = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).
+ getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID;
+ let enumerator = Services.wm.getEnumerator(CHROME_WINDOW_TYPE);
+ while (enumerator.hasMoreElements()) {
+ var win = enumerator.getNext();
+ if (win.styleEditorChrome.contentWindowID == contentWindowID) {
+ win.focus();
+ return win;
+ }
+ }
+
+ let chromeWindow = Services.ww.openWindow(null, CHROME_URL, "_blank",
+ CHROME_WINDOW_FLAGS,
+ contentWindow);
+ chromeWindow.focus();
+ return chromeWindow;
+ }
+};
XPCOMUtils.defineLazyGetter(window, "gShowPageResizers", function () {
#ifdef XP_WIN
diff --git a/browser/devtools/Makefile.in b/browser/devtools/Makefile.in
index 83271a16c81..290329ace4f 100644
--- a/browser/devtools/Makefile.in
+++ b/browser/devtools/Makefile.in
@@ -50,6 +50,7 @@ DIRS = \
highlighter \
webconsole \
sourceeditor \
+ styleeditor \
styleinspector \
scratchpad \
shared \
diff --git a/browser/devtools/jar.mn b/browser/devtools/jar.mn
index 7fca7adc924..9a35144980b 100644
--- a/browser/devtools/jar.mn
+++ b/browser/devtools/jar.mn
@@ -3,9 +3,13 @@ browser.jar:
content/browser/NetworkPanel.xhtml (webconsole/NetworkPanel.xhtml)
* content/browser/scratchpad.xul (scratchpad/scratchpad.xul)
* content/browser/scratchpad.js (scratchpad/scratchpad.js)
+* content/browser/styleeditor.xul (styleeditor/styleeditor.xul)
+ content/browser/splitview.css (styleeditor/splitview.css)
+ content/browser/styleeditor.css (styleeditor/styleeditor.css)
content/browser/devtools/csshtmltree.xul (styleinspector/csshtmltree.xul)
content/browser/devtools/cssruleview.xul (styleinspector/cssruleview.xul)
content/browser/devtools/styleinspector.css (styleinspector/styleinspector.css)
content/browser/orion.js (sourceeditor/orion/orion.js)
content/browser/orion.css (sourceeditor/orion/orion.css)
content/browser/orion-mozilla.css (sourceeditor/orion/mozilla.css)
+
diff --git a/browser/devtools/styleeditor/Makefile.in b/browser/devtools/styleeditor/Makefile.in
new file mode 100644
index 00000000000..f49fa5a32e0
--- /dev/null
+++ b/browser/devtools/styleeditor/Makefile.in
@@ -0,0 +1,54 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is Style Editor code.
+#
+# The Initial Developer of the Original Code is Mozilla Foundation.
+#
+# Portions created by the Initial Developer are Copyright (C) 2010
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+# Cedric Vivier (Original author)
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+DEPTH = ../../..
+topsrcdir = @top_srcdir@
+srcdir = @srcdir@
+VPATH = @srcdir@
+
+include $(DEPTH)/config/autoconf.mk
+
+ifdef ENABLE_TESTS
+ ifneq (mobile,$(MOZ_BUILD_APP))
+ DIRS += test
+ endif
+endif
+
+include $(topsrcdir)/config/rules.mk
+
+libs::
+ $(NSINSTALL) $(srcdir)/*.jsm $(FINAL_TARGET)/modules/devtools
diff --git a/browser/devtools/styleeditor/SplitView.jsm b/browser/devtools/styleeditor/SplitView.jsm
new file mode 100644
index 00000000000..2b083ba1bbb
--- /dev/null
+++ b/browser/devtools/styleeditor/SplitView.jsm
@@ -0,0 +1,488 @@
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Style Editor code.
+ *
+ * The Initial Developer of the Original Code is Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2011
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ * Cedric Vivier (original author)
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["SplitView"];
+
+/* this must be kept in sync with CSS (ie. splitview.css) */
+const LANDSCAPE_MEDIA_QUERY = "(min-aspect-ratio: 5/3)";
+
+const BINDING_USERDATA = "splitview-binding";
+
+
+/**
+ * SplitView constructor
+ *
+ * Initialize the split view UI on an existing DOM element.
+ *
+ * A split view contains items, each of those having one summary and one details
+ * elements.
+ * It is adaptive as it behaves similarly to a richlistbox when there the aspect
+ * ratio is narrow or as a pair listbox-box otherwise.
+ *
+ * @param DOMElement aRoot
+ * @see appendItem
+ */
+function SplitView(aRoot)
+{
+ this._root = aRoot;
+ this._controller = aRoot.querySelector(".splitview-controller");
+ this._nav = aRoot.querySelector(".splitview-nav");
+ this._side = aRoot.querySelector(".splitview-side-details");
+ this._activeSummary = null
+
+ this._mql = aRoot.ownerDocument.defaultView.matchMedia(LANDSCAPE_MEDIA_QUERY);
+
+ this._filter = aRoot.querySelector(".splitview-filter");
+ if (this._filter) {
+ this._setupFilterBox();
+ }
+
+ // items list focus and search-on-type handling
+ this._nav.addEventListener("keydown", function onKeyCatchAll(aEvent) {
+ function getFocusedItemWithin(nav) {
+ let node = nav.ownerDocument.activeElement;
+ while (node && node.parentNode != nav) {
+ node = node.parentNode;
+ }
+ return node;
+ }
+
+ // do not steal focus from inside iframes or textboxes
+ if (aEvent.target.ownerDocument != this._nav.ownerDocument ||
+ aEvent.target.tagName == "input" ||
+ aEvent.target.tagName == "textbox" ||
+ aEvent.target.tagName == "textarea" ||
+ aEvent.target.classList.contains("textbox")) {
+ return false;
+ }
+
+ // handle keyboard navigation within the items list
+ let newFocusOrdinal;
+ if (aEvent.keyCode == aEvent.DOM_VK_PAGE_UP ||
+ aEvent.keyCode == aEvent.DOM_VK_HOME) {
+ newFocusOrdinal = 0;
+ } else if (aEvent.keyCode == aEvent.DOM_VK_PAGE_DOWN ||
+ aEvent.keyCode == aEvent.DOM_VK_END) {
+ newFocusOrdinal = this._nav.childNodes.length - 1;
+ } else if (aEvent.keyCode == aEvent.DOM_VK_UP) {
+ newFocusOrdinal = getFocusedItemWithin(this._nav).getAttribute("data-ordinal");
+ newFocusOrdinal--;
+ } else if (aEvent.keyCode == aEvent.DOM_VK_DOWN) {
+ newFocusOrdinal = getFocusedItemWithin(this._nav).getAttribute("data-ordinal");
+ newFocusOrdinal++;
+ }
+ if (newFocusOrdinal !== undefined) {
+ aEvent.stopPropagation();
+ let el = this.getSummaryElementByOrdinal(newFocusOrdinal);
+ if (el) {
+ el.focus();
+ }
+ return false;
+ }
+
+ // search-on-type when any non-whitespace character is pressed while list
+ // has the focus
+ if (this._filter &&
+ !/\s/.test(String.fromCharCode(aEvent.which))) {
+ this._filter.focus();
+ }
+ }.bind(this), false);
+}
+
+SplitView.prototype = {
+ /**
+ * Retrieve whether the UI currently has a landscape orientation.
+ *
+ * @return boolean
+ */
+ get isLandscape() this._mql.matches,
+
+ /**
+ * Retrieve the root element.
+ *
+ * @return DOMElement
+ */
+ get rootElement() this._root,
+
+ /**
+ * Retrieve the active item's summary element or null if there is none.
+ *
+ * @return DOMElement
+ */
+ get activeSummary() this._activeSummary,
+
+ /**
+ * Set the active item's summary element.
+ *
+ * @param DOMElement aSummary
+ */
+ set activeSummary(aSummary)
+ {
+ if (aSummary == this._activeSummary) {
+ return;
+ }
+
+ if (this._activeSummary) {
+ let binding = this._activeSummary.getUserData(BINDING_USERDATA);
+ this._activeSummary.classList.remove("splitview-active");
+ binding._details.classList.remove("splitview-active");
+
+ if (binding.onHide) {
+ binding.onHide(this._activeSummary, binding._details, binding.data);
+ }
+ }
+
+ if (!aSummary) {
+ return;
+ }
+
+ let binding = aSummary.getUserData(BINDING_USERDATA);
+ aSummary.classList.add("splitview-active");
+ binding._details.classList.add("splitview-active");
+
+ this._activeSummary = aSummary;
+
+ if (binding.onShow) {
+ binding.onShow(aSummary, binding._details, binding.data);
+ }
+ aSummary.scrollIntoView();
+ },
+
+ /**
+ * Retrieve the active item's details element or null if there is none.
+ * @return DOMElement
+ */
+ get activeDetails()
+ {
+ let summary = this.activeSummary;
+ return summary ? summary.getUserData(BINDING_USERDATA)._details : null;
+ },
+
+ /**
+ * Retrieve the summary element for a given ordinal.
+ *
+ * @param number aOrdinal
+ * @return DOMElement
+ * Summary element with given ordinal or null if not found.
+ * @see appendItem
+ */
+ getSummaryElementByOrdinal: function SEC_getSummaryElementByOrdinal(aOrdinal)
+ {
+ return this._nav.querySelector("* > li[data-ordinal='" + aOrdinal + "']");
+ },
+
+ /**
+ * Append an item to the split view.
+ *
+ * @param DOMElement aSummary
+ * The summary element for the item.
+ * @param DOMElement aDetails
+ * The details element for the item.
+ * @param object aOptions
+ * Optional object that defines custom behavior and data for the item.
+ * All properties are optional :
+ * - function(DOMElement summary, DOMElement details, object data) onCreate
+ * Called when the item has been added.
+ * - function(summary, details, data) onShow
+ * Called when the item is shown/active.
+ * - function(summary, details, data) onHide
+ * Called when the item is hidden/inactive.
+ * - function(summary, details, data) onDestroy
+ * Called when the item has been removed.
+ * - function(summary, details, data, query) onFilterBy
+ * Called when the user performs a filtering search.
+ * If the function returns false, the item does not match query
+ * string and will be hidden.
+ * - object data
+ * Object to pass to the callbacks above.
+ * - boolean disableAnimations
+ * If true there is no animation or scrolling when this item is
+ * appended. Set this when batch appending (eg. initial population).
+ * - number ordinal
+ * Items with a lower ordinal are displayed before those with a
+ * higher ordinal.
+ */
+ appendItem: function ASV_appendItem(aSummary, aDetails, aOptions)
+ {
+ let binding = aOptions || {};
+
+ binding._summary = aSummary;
+ binding._details = aDetails;
+ aSummary.setUserData(BINDING_USERDATA, binding, null);
+
+ if (!binding.disableAnimations) {
+ aSummary.classList.add("splitview-slide");
+ aSummary.classList.add("splitview-flash");
+ }
+ this._nav.appendChild(aSummary);
+
+ aSummary.addEventListener("click", function onSummaryClick(aEvent) {
+ aEvent.stopPropagation();
+ this.activeSummary = aSummary;
+ }.bind(this), false);
+
+ this._side.appendChild(aDetails);
+
+ if (binding.onCreate) {
+ // queue onCreate handler
+ this._root.ownerDocument.defaultView.setTimeout(function () {
+ binding.onCreate(aSummary, aDetails, binding.data);
+ }, 0);
+ }
+
+ if (!binding.disableAnimations) {
+ scheduleAnimation(aSummary, "splitview-slide", "splitview-flash");
+ aSummary.scrollIntoView();
+ }
+ },
+
+ /**
+ * Append an item to the split view according to two template elements
+ * (one for the item's summary and the other for the item's details).
+ *
+ * @param string aName
+ * Name of the template elements to instantiate.
+ * Requires two (hidden) DOM elements with id "splitview-tpl-summary-"
+ * and "splitview-tpl-details-" suffixed with aName.
+ * @param object aOptions
+ * Optional object that defines custom behavior and data for the item.
+ * See appendItem for full description.
+ * @return object{summary:,details:}
+ * Object with the new DOM elements created for summary and details.
+ * @see appendItem
+ */
+ appendTemplatedItem: function ASV_appendTemplatedItem(aName, aOptions)
+ {
+ aOptions = aOptions || {};
+ let summary = this._root.querySelector("#splitview-tpl-summary-" + aName);
+ let details = this._root.querySelector("#splitview-tpl-details-" + aName);
+
+ summary = summary.cloneNode(true);
+ summary.id = "";
+ if (aOptions.ordinal !== undefined) { // can be zero
+ summary.style.MozBoxOrdinalGroup = aOptions.ordinal;
+ summary.setAttribute("data-ordinal", aOptions.ordinal);
+ }
+ details = details.cloneNode(true);
+ details.id = "";
+
+ this.appendItem(summary, details, aOptions);
+ return {summary: summary, details: details};
+ },
+
+ /**
+ * Remove an item from the split view.
+ *
+ * @param DOMElement aSummary
+ * Summary element of the item to remove.
+ */
+ removeItem: function ASV_removeItem(aSummary)
+ {
+ if (aSummary == this._activeSummary) {
+ this.activeSummary = null;
+ }
+
+ let binding = aSummary.getUserData(BINDING_USERDATA);
+ aSummary.parentNode.removeChild(aSummary);
+ binding._details.parentNode.removeChild(binding._details);
+
+ if (binding.onDestroy) {
+ binding.onDestroy(aSummary, binding._details, binding.data);
+ }
+ },
+
+ /**
+ * Remove all items from the split view.
+ */
+ removeAll: function ASV_removeAll()
+ {
+ while (this._nav.hasChildNodes()) {
+ this.removeItem(this._nav.firstChild);
+ }
+ },
+
+ /**
+ * Filter items by given string.
+ * Matching is performed on every item by calling onFilterBy when defined
+ * and then by searching aQuery in the summary element's text item.
+ * Non-matching item is hidden.
+ *
+ * If no item matches, 'splitview-all-filtered' class is set on the filter
+ * input element and the splitview-nav element.
+ *
+ * @param string aQuery
+ * The query string. Use null to reset (no filter).
+ * @return number
+ * The number of filtered (non-matching) item.
+ */
+ filterItemsBy: function ASV_filterItemsBy(aQuery)
+ {
+ if (!this._nav.hasChildNodes()) {
+ return 0;
+ }
+ if (aQuery) {
+ aQuery = aQuery.trim();
+ }
+ if (!aQuery) {
+ for (let i = 0; i < this._nav.childNodes.length; ++i) {
+ this._nav.childNodes[i].classList.remove("splitview-filtered");
+ }
+ this._filter.classList.remove("splitview-all-filtered");
+ this._nav.classList.remove("splitview-all-filtered");
+ return 0;
+ }
+
+ let count = 0;
+ let filteredCount = 0;
+ for (let i = 0; i < this._nav.childNodes.length; ++i) {
+ let summary = this._nav.childNodes[i];
+
+ let matches = false;
+ let binding = summary.getUserData(BINDING_USERDATA);
+ if (binding.onFilterBy) {
+ matches = binding.onFilterBy(summary, binding._details, binding.data, aQuery);
+ }
+ if (!matches) { // try text content
+ let content = summary.textContent.toUpperCase();
+ matches = (content.indexOf(aQuery.toUpperCase()) > -1);
+ }
+
+ count++;
+ if (!matches) {
+ summary.classList.add("splitview-filtered");
+ filteredCount++;
+ } else {
+ summary.classList.remove("splitview-filtered");
+ }
+ }
+
+ if (count > 0 && filteredCount == count) {
+ this._filter.classList.add("splitview-all-filtered");
+ this._nav.classList.add("splitview-all-filtered");
+ } else {
+ this._filter.classList.remove("splitview-all-filtered");
+ this._nav.classList.remove("splitview-all-filtered");
+ }
+ return filteredCount;
+ },
+
+ /**
+ * Set the item's CSS class name.
+ * This sets the class on both the summary and details elements, retaining
+ * any SplitView-specific classes.
+ *
+ * @param DOMElement aSummary
+ * Summary element of the item to set.
+ * @param string aClassName
+ * One or more space-separated CSS classes.
+ */
+ setItemClassName: function ASV_setItemClassName(aSummary, aClassName)
+ {
+ let binding = aSummary.getUserData(BINDING_USERDATA);
+ let viewSpecific;
+
+ viewSpecific = aSummary.className.match(/(splitview\-[\w-]+)/g);
+ viewSpecific = viewSpecific ? viewSpecific.join(" ") : "";
+ aSummary.className = viewSpecific + " " + aClassName;
+
+ viewSpecific = binding._details.className.match(/(splitview\-[\w-]+)/g);
+ viewSpecific = viewSpecific ? viewSpecific.join(" ") : "";
+ binding._details.className = viewSpecific + " " + aClassName;
+ },
+
+ /**
+ * Set up filter search box.
+ */
+ _setupFilterBox: function ASV__setupFilterBox()
+ {
+ let clearFilter = function clearFilter(aEvent) {
+ this._filter.value = "";
+ this.filterItemsBy("");
+ return false;
+ }.bind(this);
+
+ this._filter.addEventListener("command", function onFilterInput(aEvent) {
+ this.filterItemsBy(this._filter.value);
+ }.bind(this), false);
+
+ this._filter.addEventListener("keyup", function onFilterKeyUp(aEvent) {
+ if (aEvent.keyCode == aEvent.DOM_VK_ESCAPE) {
+ clearFilter();
+ }
+ if (aEvent.keyCode == aEvent.DOM_VK_ENTER ||
+ aEvent.keyCode == aEvent.DOM_VK_RETURN) {
+ // autofocus matching item if there is only one
+ let matches = this._nav.querySelectorAll("* > li:not(.splitview-filtered)");
+ if (matches.length == 1) {
+ this.activeSummary = matches[0];
+ }
+ }
+ }.bind(this), false);
+
+ let clearButtons = this._root.querySelectorAll(".splitview-filter-clearButton");
+ for (let i = 0; i < clearButtons.length; ++i) {
+ clearButtons[i].addEventListener("click", clearFilter, false);
+ }
+ }
+};
+
+//
+// private helpers
+
+/**
+ * Schedule one or multiple CSS animation(s) on an element.
+ *
+ * @param DOMElement aElement
+ * @param string ...
+ * One or multiple animation class name(s).
+ */
+function scheduleAnimation(aElement)
+{
+ let classes = Array.prototype.slice.call(arguments, 1);
+ for each (let klass in classes) {
+ aElement.classList.add(klass);
+ }
+
+ let window = aElement.ownerDocument.defaultView;
+ window.mozRequestAnimationFrame(function triggerAnimation() {
+ for each (let klass in classes) {
+ aElement.classList.remove(klass);
+ }
+ });
+}
diff --git a/browser/devtools/styleeditor/StyleEditor.jsm b/browser/devtools/styleeditor/StyleEditor.jsm
new file mode 100644
index 00000000000..ed04c5fe946
--- /dev/null
+++ b/browser/devtools/styleeditor/StyleEditor.jsm
@@ -0,0 +1,1093 @@
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Style Editor code.
+ *
+ * The Initial Developer of the Original Code is Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2011
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ * Cedric Vivier (original author)
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["StyleEditor", "StyleEditorFlags"];
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/NetUtil.jsm");
+Cu.import("resource://gre/modules/FileUtils.jsm");
+Cu.import("resource:///modules/devtools/StyleEditorUtil.jsm");
+Cu.import("resource:///modules/source-editor.jsm");
+
+const LOAD_ERROR = "error-load";
+const SAVE_ERROR = "error-save";
+
+// max update frequency in ms (avoid potential typing lag and/or flicker)
+// @see StyleEditor.updateStylesheet
+const UPDATE_STYLESHEET_THROTTLE_DELAY = 500;
+
+// @see StyleEditor._persistExpando
+const STYLESHEET_EXPANDO = "-moz-styleeditor-stylesheet-";
+
+
+/**
+ * StyleEditor constructor.
+ *
+ * The StyleEditor is initialized 'headless', it does not display source
+ * or receive input. Setting inputElement attaches a DOMElement to handle this.
+ *
+ * An editor can be created stand-alone or created by StyleEditorChrome to
+ * manage all the style sheets of a document, including @import'ed sheets.
+ *
+ * @param DOMDocument aDocument
+ * The content document where changes will be applied to.
+ * @param DOMStyleSheet aStyleSheet
+ * Optional. The DOMStyleSheet to edit.
+ * If not set, a new empty style sheet will be appended to the document.
+ * @see inputElement
+ * @see StyleEditorChrome
+ */
+function StyleEditor(aDocument, aStyleSheet)
+{
+ assert(aDocument, "Argument 'aDocument' is required.");
+
+ this._document = aDocument; // @see contentDocument
+ this._inputElement = null; // @see inputElement
+ this._sourceEditor = null; // @see sourceEditor
+
+ this._state = { // state to handle inputElement attach/detach
+ text: "", // seamlessly
+ selection: {start: 0, end: 0},
+ readOnly: false
+ };
+
+ this._styleSheet = aStyleSheet;
+ this._styleSheetIndex = -1; // unknown for now, will be set after load
+
+ this._loaded = false;
+
+ this._flags = []; // @see flags
+ this._savedFile = null; // @see savedFile
+
+ this._errorMessage = null; // @see errorMessage
+
+ // listeners for significant editor actions. @see addActionListener
+ this._actionListeners = [];
+
+ // this is to perform pending updates before editor closing
+ this._onWindowUnloadBinding = this._onWindowUnload.bind(this);
+ // this is to proxy the focus event to underlying SourceEditor
+ this._onInputElementFocusBinding = this._onInputElementFocus.bind(this);
+ this._focusOnSourceEditorReady = false;
+}
+
+StyleEditor.prototype = {
+ /**
+ * Retrieve the content document this editor will apply changes to.
+ *
+ * @return DOMDocument
+ */
+ get contentDocument() this._document,
+
+ /**
+ * Retrieve the stylesheet this editor is attached to.
+ *
+ * @return DOMStyleSheet
+ */
+ get styleSheet()
+ {
+ assert(this._styleSheet, "StyleSheet must be loaded first.")
+ return this._styleSheet;
+ },
+
+ /**
+ * Retrieve the index (order) of stylesheet in the document.
+ *
+ * @return number
+ */
+ get styleSheetIndex()
+ {
+ let document = this.contentDocument;
+ if (this._styleSheetIndex == -1) {
+ for (let i = 0; i < document.styleSheets.length; ++i) {
+ if (document.styleSheets[i] == this.styleSheet) {
+ this._styleSheetIndex = i;
+ break;
+ }
+ }
+ }
+ return this._styleSheetIndex;
+ },
+
+ /**
+ * Retrieve the input element that handles display and input for this editor.
+ * Can be null if the editor is detached/headless, which means that this
+ * StyleEditor is not attached to an input element.
+ *
+ * @return DOMElement
+ */
+ get inputElement() this._inputElement,
+
+ /**
+ * Set the input element that handles display and input for this editor.
+ * This detaches the previous input element if previously set.
+ *
+ * @param DOMElement aElement
+ */
+ set inputElement(aElement)
+ {
+ if (aElement == this._inputElement) {
+ return; // no change
+ }
+
+ if (this._inputElement) {
+ // detach from current input element
+ if (this._sourceEditor) {
+ // save existing state first (for seamless reattach)
+ this._state = {
+ text: this._sourceEditor.getText(),
+ selection: this._sourceEditor.getSelection(),
+ readOnly: this._sourceEditor.readOnly
+ };
+ this._sourceEditor.destroy();
+ this._sourceEditor = null;
+ }
+
+ this.window.removeEventListener("unload",
+ this._onWindowUnloadBinding, false);
+ this._inputElement.removeEventListener("focus",
+ this._onInputElementFocusBinding, true);
+ this._triggerAction("Detach");
+ }
+
+ this._inputElement = aElement;
+ if (!aElement) {
+ return;
+ }
+
+ // attach to new input element
+ this.window.addEventListener("unload", this._onWindowUnloadBinding, false);
+ this._focusOnSourceEditorReady = false;
+ aElement.addEventListener("focus", this._onInputElementFocusBinding, true);
+
+ this._sourceEditor = null; // set it only when ready (safe to use)
+
+ let sourceEditor = new SourceEditor();
+ let config = {
+ placeholderText: this._state.text, //! this is initialText (bug 680371)
+ showLineNumbers: true,
+ mode: SourceEditor.MODES.CSS,
+ readOnly: this._state.readOnly,
+ keys: this._getKeyBindings()
+ };
+
+ sourceEditor.init(aElement, config, function onSourceEditorReady() {
+ sourceEditor.setSelection(this._state.selection.start,
+ this._state.selection.end);
+
+ if (this._focusOnSourceEditorReady) {
+ sourceEditor.focus();
+ }
+
+ sourceEditor.addEventListener(SourceEditor.EVENTS.TEXT_CHANGED,
+ function onTextChanged(aEvent) {
+ this.updateStyleSheet();
+ }.bind(this));
+
+ this._sourceEditor = sourceEditor;
+ this._triggerAction("Attach");
+ }.bind(this));
+ },
+
+ /**
+ * Retrieve the underlying SourceEditor instance for this StyleEditor.
+ * Can be null if not ready or Style Editor is detached/headless.
+ *
+ * @return SourceEditor
+ */
+ get sourceEditor() this._sourceEditor,
+
+ /**
+ * Setter for the read-only state of the editor.
+ *
+ * @param boolean aValue
+ * Tells if you want the editor to be read-only or not.
+ */
+ set readOnly(aValue)
+ {
+ this._state.readOnly = aValue;
+ if (this._sourceEditor) {
+ this._sourceEditor.readOnly = aValue;
+ }
+ },
+
+ /**
+ * Getter for the read-only state of the editor.
+ *
+ * @return boolean
+ */
+ get readOnly()
+ {
+ return this._state.readOnly;
+ },
+
+ /**
+ * Retrieve the window that contains the editor.
+ * Can be null if the editor is detached/headless.
+ *
+ * @return DOMWindow
+ */
+ get window()
+ {
+ if (!this.inputElement) {
+ return null;
+ }
+ return this.inputElement.ownerDocument.defaultView;
+ },
+
+ /**
+ * Retrieve the last file this editor has been saved to or null if none.
+ *
+ * @return nsIFile
+ */
+ get savedFile() this._savedFile,
+
+ /**
+ * Import style sheet from file and load it into the editor asynchronously.
+ * "Load" action triggers when complete.
+ *
+ * @param mixed aFile
+ * Optional nsIFile or filename string.
+ * If not set a file picker will be shown.
+ * @param nsIWindow aParentWindow
+ * Optional parent window for the file picker.
+ */
+ importFromFile: function SE_importFromFile(aFile, aParentWindow)
+ {
+ aFile = this._showFilePicker(aFile, false, aParentWindow);
+ if (!aFile) {
+ return;
+ }
+ this._savedFile = aFile; // remember filename for next save if any
+
+ NetUtil.asyncFetch(aFile, function onAsyncFetch(aStream, aStatus) {
+ if (!Components.isSuccessCode(aStatus)) {
+ return this._signalError(LOAD_ERROR);
+ }
+ let source = NetUtil.readInputStreamToString(aStream, aStream.available());
+ aStream.close();
+
+ this._appendNewStyleSheet(source);
+ this.clearFlag(StyleEditorFlags.ERROR);
+ }.bind(this));
+ },
+
+ /**
+ * Retrieve localized error message of last error condition, or null if none.
+ * This is set when the editor has flag StyleEditorFlags.ERROR.
+ *
+ * @see addActionListener
+ */
+ get errorMessage() this._errorMessage,
+
+ /**
+ * Tell whether the stylesheet has been loaded and ready for modifications.
+ *
+ * @return boolean
+ */
+ get isLoaded() this._loaded,
+
+ /**
+ * Load style sheet source into the editor, asynchronously.
+ * "Load" handler triggers when complete.
+ *
+ * @see addActionListener
+ */
+ load: function SE_load()
+ {
+ if (!this._styleSheet) {
+ this._flags.push(StyleEditorFlags.NEW);
+ this._appendNewStyleSheet();
+ }
+ this._loadSource();
+ },
+
+ /**
+ * Get a user-friendly name for the style sheet.
+ *
+ * @return string
+ */
+ getFriendlyName: function SE_getFriendlyName()
+ {
+ if (this.savedFile) { // reuse the saved filename if any
+ return this.savedFile.leafName;
+ }
+
+ if (this.hasFlag(StyleEditorFlags.NEW)) {
+ let index = this.styleSheetIndex + 1; // 0-indexing only works for devs
+ return _("newStyleSheet", index);
+ }
+
+ if (this.hasFlag(StyleEditorFlags.INLINE)) {
+ let index = this.styleSheetIndex + 1; // 0-indexing only works for devs
+ return _("inlineStyleSheet", index);
+ }
+
+ if (!this._friendlyName) {
+ let sheetURI = this.styleSheet.href;
+ let contentURI = this.contentDocument.baseURIObject;
+ let contentURIScheme = contentURI.scheme;
+ let contentURILeafIndex = contentURI.specIgnoringRef.lastIndexOf("/");
+ contentURI = contentURI.specIgnoringRef;
+
+ // get content base URI without leaf name (if any)
+ if (contentURILeafIndex > contentURIScheme.length) {
+ contentURI = contentURI.substring(0, contentURILeafIndex + 1);
+ }
+
+ // avoid verbose repetition of absolute URI when the style sheet URI
+ // is relative to the content URI
+ this._friendlyName = (sheetURI.indexOf(contentURI) == 0)
+ ? sheetURI.substring(contentURI.length)
+ : sheetURI;
+ }
+ return this._friendlyName;
+ },
+
+ /**
+ * Add a listener for significant StyleEditor actions.
+ *
+ * The listener implements IStyleEditorActionListener := {
+ * onLoad: Called when the style sheet has been loaded and
+ * parsed.
+ * Arguments: (StyleEditor editor)
+ * @see load
+ *
+ * onFlagChange: Called when a flag has been set or cleared.
+ * Arguments: (StyleEditor editor, string flagName)
+ * @see setFlag
+ *
+ * onAttach: Called when an input element has been attached.
+ * Arguments: (StyleEditor editor)
+ * @see inputElement
+ *
+ * onDetach: Called when input element has been detached.
+ * Arguments: (StyleEditor editor)
+ * @see inputElement
+ *
+ * onCommit: Called when changes have been committed/applied
+ * to the live DOM style sheet.
+ * Arguments: (StyleEditor editor)
+ * }
+ *
+ * All listener methods are optional.
+ *
+ * @param IStyleEditorActionListener aListener
+ * @see removeActionListener
+ */
+ addActionListener: function SE_addActionListener(aListener)
+ {
+ this._actionListeners.push(aListener);
+ },
+
+ /**
+ * Remove a listener for editor actions from the current list of listeners.
+ *
+ * @param IStyleEditorActionListener aListener
+ * @see addActionListener
+ */
+ removeActionListener: function SE_removeActionListener(aListener)
+ {
+ let index = this._actionListeners.indexOf(aListener);
+ if (index != -1) {
+ this._actionListeners.splice(index, 1);
+ }
+ },
+
+ /**
+ * Editor UI flags.
+ *
+ * These are 1-bit indicators that can be used for UI feedback/indicators or
+ * extensions to track the editor status.
+ * Since they are simple strings, they promote loose coupling and can simply
+ * map to CSS class names, which allows to 'expose' indicators declaratively
+ * via CSS (including possibly complex combinations).
+ *
+ * Flag changes can be tracked via onFlagChange (@see addActionListener).
+ *
+ * @see StyleEditorFlags
+ */
+
+ /**
+ * Retrieve a space-separated string of all UI flags set on this editor.
+ *
+ * @return string
+ * @see setFlag
+ * @see clearFlag
+ */
+ get flags() this._flags.join(" "),
+
+ /**
+ * Set a flag.
+ *
+ * @param string aName
+ * Name of the flag to set. One of StyleEditorFlags members.
+ * @return boolean
+ * True if the flag has been set, false if flag is already set.
+ * @see StyleEditorFlags
+ */
+ setFlag: function SE_setFlag(aName)
+ {
+ let prop = aName.toUpperCase();
+ assert(StyleEditorFlags[prop], "Unknown flag: " + prop);
+
+ if (this.hasFlag(aName)) {
+ return false;
+ }
+ this._flags.push(aName);
+ this._triggerAction("FlagChange", [aName]);
+ return true;
+ },
+
+ /**
+ * Clear a flag.
+ *
+ * @param string aName
+ * Name of the flag to clear.
+ * @return boolean
+ * True if the flag has been cleared, false if already clear.
+ */
+ clearFlag: function SE_clearFlag(aName)
+ {
+ let index = this._flags.indexOf(aName);
+ if (index == -1) {
+ return false;
+ }
+ this._flags.splice(index, 1);
+ this._triggerAction("FlagChange", [aName]);
+ return true;
+ },
+
+ /**
+ * Toggle a flag, according to a condition.
+ *
+ * @param aCondition
+ * If true the flag is set, otherwise cleared.
+ * @param string aName
+ * Name of the flag to toggle.
+ * @return boolean
+ * True if the flag has been set or cleared, ie. the flag got switched.
+ */
+ toggleFlag: function SE_toggleFlag(aCondition, aName)
+ {
+ return (aCondition) ? this.setFlag(aName) : this.clearFlag(aName);
+ },
+
+ /**
+ * Check if given flag is set.
+ *
+ * @param string aName
+ * Name of the flag to check presence for.
+ * @return boolean
+ * True if the flag is set, false otherwise.
+ */
+ hasFlag: function SE_hasFlag(aName) (this._flags.indexOf(aName) != -1),
+
+ /**
+ * Enable or disable style sheet.
+ *
+ * @param boolean aEnabled
+ */
+ enableStyleSheet: function SE_enableStyleSheet(aEnabled)
+ {
+ this.styleSheet.disabled = !aEnabled;
+ this.toggleFlag(this.styleSheet.disabled, StyleEditorFlags.DISABLED);
+
+ if (this._updateTask) {
+ this._updateStyleSheet(); // perform cancelled update
+ }
+ },
+
+ /**
+ * Save the editor contents into a file and set savedFile property.
+ * A file picker UI will open if file is not set and editor is not headless.
+ *
+ * @param mixed aFile
+ * Optional nsIFile or string representing the filename to save in the
+ * background, no UI will be displayed.
+ * To implement 'Save' instead of 'Save as', you can pass savedFile here.
+ * @param function(nsIFile aFile) aCallback
+ * Optional callback called when the operation has finished.
+ * aFile has the nsIFile object for saved file or null if the operation
+ * has failed or has been canceled by the user.
+ * @see savedFile
+ */
+ saveToFile: function SE_saveToFile(aFile, aCallback)
+ {
+ aFile = this._showFilePicker(aFile, true);
+ if (!aFile) {
+ if (aCallback) {
+ aCallback(null);
+ }
+ return;
+ }
+
+ if (this._sourceEditor) {
+ this._state.text = this._sourceEditor.getText();
+ }
+
+ let ostream = FileUtils.openSafeFileOutputStream(aFile);
+ let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
+ .createInstance(Ci.nsIScriptableUnicodeConverter);
+ converter.charset = "UTF-8";
+ let istream = converter.convertToInputStream(this._state.text);
+
+ NetUtil.asyncCopy(istream, ostream, function SE_onStreamCopied(status) {
+ if (!Components.isSuccessCode(status)) {
+ if (aCallback) {
+ aCallback(null);
+ }
+ this._signalError(SAVE_ERROR);
+ return;
+ }
+ FileUtils.closeSafeFileOutputStream(ostream);
+
+ // remember filename for next save if any
+ this._friendlyName = null;
+ this._savedFile = aFile;
+ this._persistExpando();
+
+ if (aCallback) {
+ aCallback(aFile);
+ }
+ this.clearFlag(StyleEditorFlags.UNSAVED);
+ this.clearFlag(StyleEditorFlags.ERROR);
+ }.bind(this));
+ },
+
+ /**
+ * Queue a throttled task to update the live style sheet.
+ *
+ * @param boolean aImmediate
+ * Optional. If true the update is performed immediately.
+ */
+ updateStyleSheet: function SE_updateStyleSheet(aImmediate)
+ {
+ let window = this.window;
+
+ if (this._updateTask) {
+ // cancel previous queued task not executed within throttle delay
+ window.clearTimeout(this._updateTask);
+ }
+
+ if (aImmediate) {
+ this._updateStyleSheet();
+ } else {
+ this._updateTask = window.setTimeout(this._updateStyleSheet.bind(this),
+ UPDATE_STYLESHEET_THROTTLE_DELAY);
+ }
+ },
+
+ /**
+ * Update live style sheet according to modifications.
+ */
+ _updateStyleSheet: function SE__updateStyleSheet()
+ {
+ this.setFlag(StyleEditorFlags.UNSAVED);
+
+ if (this.styleSheet.disabled) {
+ return;
+ }
+
+ this._updateTask = null; // reset only if we actually perform an update
+ // (stylesheet is enabled) so that 'missed' updates
+ // while the stylesheet is disabled can be performed
+ // when it is enabled back. @see enableStylesheet
+
+ if (this.sourceEditor) {
+ this._state.text = this.sourceEditor.getText();
+ }
+ let source = this._state.text;
+ let oldNode = this.styleSheet.ownerNode;
+ let oldIndex = this.styleSheetIndex;
+
+ let newNode = this.contentDocument.createElement("style");
+ newNode.setAttribute("type", "text/css");
+ newNode.appendChild(this.contentDocument.createTextNode(source));
+ oldNode.parentNode.replaceChild(newNode, oldNode);
+
+ this._styleSheet = this.contentDocument.styleSheets[oldIndex];
+ this._persistExpando();
+
+ this._triggerAction("Commit");
+ },
+
+ /**
+ * Show file picker and return the file user selected.
+ *
+ * @param mixed aFile
+ * Optional nsIFile or string representing the filename to auto-select.
+ * @param boolean aSave
+ * If true, the user is selecting a filename to save.
+ * @param nsIWindow aParentWindow
+ * Optional parent window. If null the parent window of the file picker
+ * will be the window of the attached input element.
+ * @return nsIFile
+ * The selected file or null if the user did not pick one.
+ */
+ _showFilePicker: function SE__showFilePicker(aFile, aSave, aParentWindow)
+ {
+ if (typeof(aFile) == "string") {
+ try {
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
+ file.initWithPath(aFile);
+ return file;
+ } catch (ex) {
+ this._signalError(aSave ? SAVE_ERROR : LOAD_ERROR);
+ return null;
+ }
+ }
+ if (aFile) {
+ return aFile;
+ }
+
+ let window = aParentWindow
+ ? aParentWindow
+ : this.inputElement.ownerDocument.defaultView;
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ let mode = aSave ? fp.modeSave : fp.modeOpen;
+ let key = aSave ? "saveStyleSheet" : "importStyleSheet";
+
+ fp.init(window, _(key + ".title"), mode);
+ fp.appendFilters(_(key + ".filter"), "*.css");
+ fp.appendFilters(fp.filterAll);
+
+ let rv = fp.show();
+ return (rv == fp.returnCancel) ? null : fp.file;
+ },
+
+ /**
+ * Retrieve the style sheet source from the cache or from a local file.
+ */
+ _loadSource: function SE__loadSource()
+ {
+ if (!this.styleSheet.href) {
+ // this is an inline