gecko/browser/devtools/styleinspector/CssRuleView.jsm
Ehsan Akhgari 45fe6d3ae2 Bug 722872 - Part 1: Add nsITransferable::Init(nsILoadContext*), enforce that it's called in debug builds, and add nsIDOMDocument* arguments to nsIClipboardHelper methods; r=roc
This patch does the following:

* It adds nsITransferable::Init(nsILoadContext*).  The load context
  might be null, which means that the transferable is non-private, but
  if it's non-null, we extract the boolean value for the privacy mode
  and store it in the transferable.
* It adds checks in debug builds to make sure that Init is always
  called, in form of fatal assertions.
* It adds nsIDOMDocument* agruments to nsIClipboardHelper methods which
  represent the document that the string is coming from.
  nsIClipboardHelper implementation internally gets the nsILoadContext
  from that and passes it on to the transferable upon creation.  The
  reason that I did this was that nsIClipboardHelper is supposed to be a
  high-level helper, and in most of its call sites, we have easy access
  to a document object.
* It modifies all of the call sites of the above interfaces according to
  this change.
* It adds a GetLoadContext helper to nsIDocument to help with changing
  the call sites.
2012-04-16 22:14:01 -04:00

2318 lines
66 KiB
JavaScript

/* -*- Mode: javascript; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ts=2 et sw=2 tw=80: */
/* 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/. */
"use strict";
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
const HTML_NS = "http://www.w3.org/1999/xhtml";
const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
const FOCUS_FORWARD = Ci.nsIFocusManager.MOVEFOCUS_FORWARD;
const FOCUS_BACKWARD = Ci.nsIFocusManager.MOVEFOCUS_BACKWARD;
/**
* These regular expressions are adapted from firebug's css.js, and are
* used to parse CSSStyleDeclaration's cssText attribute.
*/
// Used to split on css line separators
const CSS_LINE_RE = /(?:[^;\(]*(?:\([^\)]*?\))?[^;\(]*)*;?/g;
// Used to parse a single property line.
const CSS_PROP_RE = /\s*([^:\s]*)\s*:\s*(.*?)\s*(?:! (important))?;?$/;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource:///modules/devtools/CssLogic.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
var EXPORTED_SYMBOLS = ["CssRuleView",
"_ElementStyle",
"_editableField",
"_getInplaceEditorForSpan"];
/**
* Our model looks like this:
*
* ElementStyle:
* Responsible for keeping track of which properties are overridden.
* Maintains a list of Rule objects that apply to the element.
* Rule:
* Manages a single style declaration or rule.
* Responsible for applying changes to the properties in a rule.
* Maintains a list of TextProperty objects.
* TextProperty:
* Manages a single property from the cssText attribute of the
* relevant declaration.
* Maintains a list of computed properties that come from this
* property declaration.
* Changes to the TextProperty are sent to its related Rule for
* application.
*/
/**
* ElementStyle maintains a list of Rule objects for a given element.
*
* @param Element aElement
* The element whose style we are viewing.
* @param object aStore
* The ElementStyle can use this object to store metadata
* that might outlast the rule view, particularly the current
* set of disabled properties.
*
* @constructor
*/
function ElementStyle(aElement, aStore)
{
this.element = aElement;
this.store = aStore || {};
// We don't want to overwrite this.store.userProperties so we only create it
// if it doesn't already exist.
if (!("userProperties" in this.store)) {
this.store.userProperties = new UserProperties();
}
if (this.store.disabled) {
this.store.disabled = aStore.disabled;
} else {
this.store.disabled = WeakMap();
}
let doc = aElement.ownerDocument;
// To figure out how shorthand properties are interpreted by the
// engine, we will set properties on a dummy element and observe
// how their .style attribute reflects them as computed values.
this.dummyElement = doc.createElementNS(this.element.namespaceURI,
this.element.tagName);
this.populate();
}
// We're exporting _ElementStyle for unit tests.
var _ElementStyle = ElementStyle;
ElementStyle.prototype = {
// The element we're looking at.
element: null,
// Empty, unconnected element of the same type as this node, used
// to figure out how shorthand properties will be parsed.
dummyElement: null,
domUtils: Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils),
/**
* Called by the Rule object when it has been changed through the
* setProperty* methods.
*/
_changed: function ElementStyle_changed()
{
if (this.onChanged) {
this.onChanged();
}
},
/**
* Refresh the list of rules to be displayed for the active element.
* Upon completion, this.rules[] will hold a list of Rule objects.
*/
populate: function ElementStyle_populate()
{
// Store the current list of rules (if any) during the population
// process. They will be reused if possible.
this._refreshRules = this.rules;
this.rules = [];
let element = this.element;
do {
this._addElementRules(element);
} while ((element = element.parentNode) &&
element.nodeType === Ci.nsIDOMNode.ELEMENT_NODE);
// Mark overridden computed styles.
this.markOverridden();
// We're done with the previous list of rules.
delete this._refreshRules;
},
_addElementRules: function ElementStyle_addElementRules(aElement)
{
let inherited = aElement !== this.element ? aElement : null;
// Include the element's style first.
this._maybeAddRule({
style: aElement.style,
selectorText: CssLogic.l10n("rule.sourceElement"),
inherited: inherited
});
// Get the styles that apply to the element.
var domRules = this.domUtils.getCSSStyleRules(aElement);
// getCSStyleRules returns ordered from least-specific to
// most-specific.
for (let i = domRules.Count() - 1; i >= 0; i--) {
let domRule = domRules.GetElementAt(i);
// XXX: Optionally provide access to system sheets.
let contentSheet = CssLogic.isContentStylesheet(domRule.parentStyleSheet);
if (!contentSheet) {
continue;
}
if (domRule.type !== Ci.nsIDOMCSSRule.STYLE_RULE) {
continue;
}
this._maybeAddRule({
domRule: domRule,
inherited: inherited
});
}
},
/**
* Add a rule if it's one we care about. Filters out duplicates and
* inherited styles with no inherited properties.
*
* @param {object} aOptions
* Options for creating the Rule, see the Rule constructor.
*
* @return true if we added the rule.
*/
_maybeAddRule: function ElementStyle_maybeAddRule(aOptions)
{
// If we've already included this domRule (for example, when a
// common selector is inherited), ignore it.
if (aOptions.domRule &&
this.rules.some(function(rule) rule.domRule === aOptions.domRule)) {
return false;
}
let rule = null;
// If we're refreshing and the rule previously existed, reuse the
// Rule object.
for (let r of (this._refreshRules || [])) {
if (r.matches(aOptions)) {
rule = r;
rule.refresh();
break;
}
}
// If this is a new rule, create its Rule object.
if (!rule) {
rule = new Rule(this, aOptions);
}
// Ignore inherited rules with no properties.
if (aOptions.inherited && rule.textProps.length == 0) {
return false;
}
this.rules.push(rule);
},
/**
* Mark the properties listed in this.rules with an overridden flag
* if an earlier property overrides it.
*/
markOverridden: function ElementStyle_markOverridden()
{
// Gather all the text properties applied by these rules, ordered
// from more- to less-specific.
let textProps = [];
for each (let rule in this.rules) {
textProps = textProps.concat(rule.textProps.slice(0).reverse());
}
// Gather all the computed properties applied by those text
// properties.
let computedProps = [];
for each (let textProp in textProps) {
computedProps = computedProps.concat(textProp.computed);
}
// Walk over the computed properties. As we see a property name
// for the first time, mark that property's name as taken by this
// property.
//
// If we come across a property whose name is already taken, check
// its priority against the property that was found first:
//
// If the new property is a higher priority, mark the old
// property overridden and mark the property name as taken by
// the new property.
//
// If the new property is a lower or equal priority, mark it as
// overridden.
//
// _overriddenDirty will be set on each prop, indicating whether its
// dirty status changed during this pass.
let taken = {};
for each (let computedProp in computedProps) {
let earlier = taken[computedProp.name];
let overridden;
if (earlier
&& computedProp.priority === "important"
&& earlier.priority !== "important") {
// New property is higher priority. Mark the earlier property
// overridden (which will reverse its dirty state).
earlier._overriddenDirty = !earlier._overriddenDirty;
earlier.overridden = true;
overridden = false;
} else {
overridden = !!earlier;
}
computedProp._overriddenDirty = (!!computedProp.overridden != overridden);
computedProp.overridden = overridden;
if (!computedProp.overridden && computedProp.textProp.enabled) {
taken[computedProp.name] = computedProp;
}
}
// For each TextProperty, mark it overridden if all of its
// computed properties are marked overridden. Update the text
// property's associated editor, if any. This will clear the
// _overriddenDirty state on all computed properties.
for each (let textProp in textProps) {
// _updatePropertyOverridden will return true if the
// overridden state has changed for the text property.
if (this._updatePropertyOverridden(textProp)) {
textProp.updateEditor();
}
}
},
/**
* Mark a given TextProperty as overridden or not depending on the
* state of its computed properties. Clears the _overriddenDirty state
* on all computed properties.
*
* @param {TextProperty} aProp
* The text property to update.
*
* @return True if the TextProperty's overridden state (or any of its
* computed properties overridden state) changed.
*/
_updatePropertyOverridden: function ElementStyle_updatePropertyOverridden(aProp)
{
let overridden = true;
let dirty = false;
for each (let computedProp in aProp.computed) {
if (!computedProp.overridden) {
overridden = false;
}
dirty = computedProp._overriddenDirty || dirty;
delete computedProp._overriddenDirty;
}
dirty = (!!aProp.overridden != overridden) || dirty;
aProp.overridden = overridden;
return dirty;
}
};
/**
* A single style rule or declaration.
*
* @param {ElementStyle} aElementStyle
* The ElementStyle to which this rule belongs.
* @param {object} aOptions
* The information used to construct this rule. Properties include:
* domRule: the nsIDOMCSSStyleRule to view, if any.
* style: the nsIDOMCSSStyleDeclaration to view. If omitted,
* the domRule's style will be used.
* selectorText: selector text to display. If omitted, the domRule's
* selectorText will be used.
* inherited: An element this rule was inherited from. If omitted,
* the rule applies directly to the current element.
* @constructor
*/
function Rule(aElementStyle, aOptions)
{
this.elementStyle = aElementStyle;
this.domRule = aOptions.domRule || null;
this.style = aOptions.style || this.domRule.style;
this.selectorText = aOptions.selectorText || this.domRule.selectorText;
this.inherited = aOptions.inherited || null;
if (this.domRule) {
let parentRule = this.domRule.parentRule;
if (parentRule && parentRule.type == Ci.nsIDOMCSSRule.MEDIA_RULE) {
this.mediaText = parentRule.media.mediaText;
}
}
// Populate the text properties with the style's current cssText
// value, and add in any disabled properties from the store.
this.textProps = this._getTextProperties();
this.textProps = this.textProps.concat(this._getDisabledProperties());
}
Rule.prototype = {
mediaText: "",
get title()
{
if (this._title) {
return this._title;
}
this._title = CssLogic.shortSource(this.sheet);
if (this.domRule) {
this._title += ":" + this.ruleLine;
}
if (this.inherited) {
let eltText = this.inherited.tagName.toLowerCase();
if (this.inherited.id) {
eltText += "#" + this.inherited.id;
}
let args = [eltText, this._title];
this._title = CssLogic._strings.formatStringFromName("rule.inheritedSource",
args, args.length);
}
return this._title + (this.mediaText ? " @media " + this.mediaText : "");
},
/**
* The rule's stylesheet.
*/
get sheet()
{
return this.domRule ? this.domRule.parentStyleSheet : null;
},
/**
* The rule's line within a stylesheet
*/
get ruleLine()
{
if (!this.sheet) {
// No stylesheet, no ruleLine
return null;
}
return this.elementStyle.domUtils.getRuleLine(this.domRule);
},
/**
* Returns true if the rule matches the creation options
* specified.
*
* @param object aOptions
* Creation options. See the Rule constructor for
* documentation.
*/
matches: function Rule_matches(aOptions)
{
return (this.style === (aOptions.style || aOptions.domRule.style));
},
/**
* Create a new TextProperty to include in the rule.
*
* @param {string} aName
* The text property name (such as "background" or "border-top").
* @param {string} aValue
* The property's value (not including priority).
* @param {string} aPriority
* The property's priority (either "important" or an empty string).
*/
createProperty: function Rule_createProperty(aName, aValue, aPriority)
{
let prop = new TextProperty(this, aName, aValue, aPriority);
this.textProps.push(prop);
this.applyProperties();
return prop;
},
/**
* Reapply all the properties in this rule, and update their
* computed styles. Store disabled properties in the element
* style's store. Will re-mark overridden properties.
*
* @param {string} [aName]
* A text property name (such as "background" or "border-top") used
* when calling from setPropertyValue & setPropertyName to signify
* that the property should be saved in store.userProperties.
*/
applyProperties: function Rule_applyProperties(aName)
{
let disabledProps = [];
let store = this.elementStyle.store;
for each (let prop in this.textProps) {
if (!prop.enabled) {
disabledProps.push({
name: prop.name,
value: prop.value,
priority: prop.priority
});
continue;
}
this.style.setProperty(prop.name, prop.value, prop.priority);
if (aName && prop.name == aName) {
store.userProperties.setProperty(
this.style, prop.name,
this.style.getPropertyValue(prop.name),
prop.value);
}
// Refresh the property's priority from the style, to reflect
// any changes made during parsing.
prop.priority = this.style.getPropertyPriority(prop.name);
prop.updateComputed();
}
this.elementStyle._changed();
// Store disabled properties in the disabled store.
let disabled = this.elementStyle.store.disabled;
disabled.set(this.style, disabledProps);
this.elementStyle.markOverridden();
},
/**
* Renames a property.
*
* @param {TextProperty} aProperty
* The property to rename.
* @param {string} aName
* The new property name (such as "background" or "border-top").
*/
setPropertyName: function Rule_setPropertyName(aProperty, aName)
{
if (aName === aProperty.name) {
return;
}
this.style.removeProperty(aProperty.name);
aProperty.name = aName;
this.applyProperties(aName);
},
/**
* Sets the value and priority of a property.
*
* @param {TextProperty} aProperty
* The property to manipulate.
* @param {string} aValue
* The property's value (not including priority).
* @param {string} aPriority
* The property's priority (either "important" or an empty string).
*/
setPropertyValue: function Rule_setPropertyValue(aProperty, aValue, aPriority)
{
if (aValue === aProperty.value && aPriority === aProperty.priority) {
return;
}
aProperty.value = aValue;
aProperty.priority = aPriority;
this.applyProperties(aProperty.name);
},
/**
* Disables or enables given TextProperty.
*/
setPropertyEnabled: function Rule_enableProperty(aProperty, aValue)
{
aProperty.enabled = !!aValue;
if (!aProperty.enabled) {
this.style.removeProperty(aProperty.name);
}
this.applyProperties();
},
/**
* Remove a given TextProperty from the rule and update the rule
* accordingly.
*/
removeProperty: function Rule_removeProperty(aProperty)
{
this.textProps = this.textProps.filter(function(prop) prop != aProperty);
this.style.removeProperty(aProperty);
// Need to re-apply properties in case removing this TextProperty
// exposes another one.
this.applyProperties();
},
/**
* Get the list of TextProperties from the style. Needs
* to parse the style's cssText.
*/
_getTextProperties: function Rule_getTextProperties()
{
let textProps = [];
let store = this.elementStyle.store;
let lines = this.style.cssText.match(CSS_LINE_RE);
for each (let line in lines) {
let matches = CSS_PROP_RE.exec(line);
if (!matches || !matches[2])
continue;
let name = matches[1];
if (this.inherited &&
!this.elementStyle.domUtils.isInheritedProperty(name)) {
continue;
}
let value = store.userProperties.getProperty(this.style, name, matches[2]);
let prop = new TextProperty(this, name, value, matches[3] || "");
textProps.push(prop);
}
return textProps;
},
/**
* Return the list of disabled properties from the store for this rule.
*/
_getDisabledProperties: function Rule_getDisabledProperties()
{
let store = this.elementStyle.store;
// Include properties from the disabled property store, if any.
let disabledProps = store.disabled.get(this.style);
if (!disabledProps) {
return [];
}
let textProps = [];
for each (let prop in disabledProps) {
let value = store.userProperties.getProperty(this.style, prop.name, prop.value);
let textProp = new TextProperty(this, prop.name, value, prop.priority);
textProp.enabled = false;
textProps.push(textProp);
}
return textProps;
},
/**
* Reread the current state of the rules and rebuild text
* properties as needed.
*/
refresh: function Rule_refresh()
{
let newTextProps = this._getTextProperties();
// Update current properties for each property present on the style.
// This will mark any touched properties with _visited so we
// can detect properties that weren't touched (because they were
// removed from the style).
// Also keep track of properties that didn't exist in the current set
// of properties.
let brandNewProps = [];
for (let newProp of newTextProps) {
if (!this._updateTextProperty(newProp)) {
brandNewProps.push(newProp);
}
}
// Refresh editors and disabled state for all the properties that
// were updated.
for (let prop of this.textProps) {
// Properties that weren't touched during the update
// process must no longer exist on the node. Mark them disabled.
if (!prop._visited) {
prop.enabled = false;
prop.updateEditor();
} else {
delete prop._visited;
}
}
// Add brand new properties.
this.textProps = this.textProps.concat(brandNewProps);
// Refresh the editor if one already exists.
if (this.editor) {
this.editor.populate();
}
},
/**
* Update the current TextProperties that match a given property
* from the cssText. Will choose one existing TextProperty to update
* with the new property's value, and will disable all others.
*
* When choosing the best match to reuse, properties will be chosen
* by assigning a rank and choosing the highest-ranked property:
* Name, value, and priority match, enabled. (6)
* Name, value, and priority match, disabled. (5)
* Name and value match, enabled. (4)
* Name and value match, disabled. (3)
* Name matches, enabled. (2)
* Name matches, disabled. (1)
*
* If no existing properties match the property, nothing happens.
*
* @param TextProperty aNewProp
* The current version of the property, as parsed from the
* cssText in Rule._getTextProperties().
*
* @returns true if a property was updated, false if no properties
* were updated.
*/
_updateTextProperty: function Rule__updateTextProperty(aNewProp) {
let match = { rank: 0, prop: null };
for each (let prop in this.textProps) {
if (prop.name != aNewProp.name)
continue;
// Mark this property visited.
prop._visited = true;
// Start at rank 1 for matching name.
let rank = 1;
// Value and Priority matches add 2 to the rank.
// Being enabled adds 1. This ranks better matches higher,
// with priority breaking ties.
if (prop.value === aNewProp.value) {
rank += 2;
if (prop.priority === aNewProp.priority) {
rank += 2;
}
}
if (prop.enabled) {
rank += 1;
}
if (rank > match.rank) {
if (match.prop) {
// We outrank a previous match, disable it.
match.prop.enabled = false;
match.prop.updateEditor();
}
match.rank = rank;
match.prop = prop;
} else if (rank) {
// A previous match outranks us, disable ourself.
prop.enabled = false;
prop.updateEditor();
}
}
// If we found a match, update its value with the new text property
// value.
if (match.prop) {
match.prop.set(aNewProp);
return true;
}
return false;
},
};
/**
* A single property in a rule's cssText.
*
* @param {Rule} aRule
* The rule this TextProperty came from.
* @param {string} aName
* The text property name (such as "background" or "border-top").
* @param {string} aValue
* The property's value (not including priority).
* @param {string} aPriority
* The property's priority (either "important" or an empty string).
*
*/
function TextProperty(aRule, aName, aValue, aPriority)
{
this.rule = aRule;
this.name = aName;
this.value = aValue;
this.priority = aPriority;
this.enabled = true;
this.updateComputed();
}
TextProperty.prototype = {
/**
* Update the editor associated with this text property,
* if any.
*/
updateEditor: function TextProperty_updateEditor()
{
if (this.editor) {
this.editor.update();
}
},
/**
* Update the list of computed properties for this text property.
*/
updateComputed: function TextProperty_updateComputed()
{
if (!this.name) {
return;
}
// This is a bit funky. To get the list of computed properties
// for this text property, we'll set the property on a dummy element
// and see what the computed style looks like.
let dummyElement = this.rule.elementStyle.dummyElement;
let dummyStyle = dummyElement.style;
dummyStyle.cssText = "";
dummyStyle.setProperty(this.name, this.value, this.priority);
this.computed = [];
for (let i = 0, n = dummyStyle.length; i < n; i++) {
let prop = dummyStyle.item(i);
this.computed.push({
textProp: this,
name: prop,
value: dummyStyle.getPropertyValue(prop),
priority: dummyStyle.getPropertyPriority(prop),
});
}
},
/**
* Set all the values from another TextProperty instance into
* this TextProperty instance.
*
* @param TextProperty aOther
* The other TextProperty instance.
*/
set: function TextProperty_set(aOther)
{
let changed = false;
for (let item of ["name", "value", "priority", "enabled"]) {
if (this[item] != aOther[item]) {
this[item] = aOther[item];
changed = true;
}
}
if (changed) {
this.updateEditor();
}
},
setValue: function TextProperty_setValue(aValue, aPriority)
{
this.rule.setPropertyValue(this, aValue, aPriority);
this.updateEditor();
},
setName: function TextProperty_setName(aName)
{
this.rule.setPropertyName(this, aName);
this.updateEditor();
},
setEnabled: function TextProperty_setEnabled(aValue)
{
this.rule.setPropertyEnabled(this, aValue);
this.updateEditor();
},
remove: function TextProperty_remove()
{
this.rule.removeProperty(this);
}
};
/**
* View hierarchy mostly follows the model hierarchy.
*
* CssRuleView:
* Owns an ElementStyle and creates a list of RuleEditors for its
* Rules.
* RuleEditor:
* Owns a Rule object and creates a list of TextPropertyEditors
* for its TextProperties.
* Manages creation of new text properties.
* TextPropertyEditor:
* Owns a TextProperty object.
* Manages changes to the TextProperty.
* Can be expanded to display computed properties.
* Can mark a property disabled or enabled.
*/
/**
* CssRuleView is a view of the style rules and declarations that
* apply to a given element. After construction, the 'element'
* property will be available with the user interface.
*
* @param Document aDoc
* The document that will contain the rule view.
* @param object aStore
* The CSS rule view can use this object to store metadata
* that might outlast the rule view, particularly the current
* set of disabled properties.
* @constructor
*/
function CssRuleView(aDoc, aStore)
{
this.doc = aDoc;
this.store = aStore;
this.element = this.doc.createElementNS(XUL_NS, "vbox");
this.element.setAttribute("tabindex", "0");
this.element.classList.add("ruleview");
this.element.flex = 1;
this._boundCopy = this._onCopy.bind(this);
this.element.addEventListener("copy", this._boundCopy);
this._createContextMenu();
this._showEmpty();
}
CssRuleView.prototype = {
// The element that we're inspecting.
_viewedElement: null,
/**
* Returns true if the rule view currently has an input editor visible.
*/
get isEditing() {
return this.element.querySelectorAll(".styleinspector-propertyeditor").length > 0;
},
destroy: function CssRuleView_destroy()
{
this.clear();
this.element.removeEventListener("copy", this._boundCopy);
this._copyItem.removeEventListener("command", this._boundCopy);
delete this._boundCopy;
this._ruleItem.removeEventListener("command", this._boundCopyRule);
delete this._boundCopyRule;
this._declarationItem.removeEventListener("command", this._boundCopyDeclaration);
delete this._boundCopyDeclaration;
this._propertyItem.removeEventListener("command", this._boundCopyProperty);
delete this._boundCopyProperty;
this._propertyValueItem.removeEventListener("command", this._boundCopyPropertyValue);
delete this._boundCopyPropertyValue;
this._contextMenu.removeEventListener("popupshowing", this._boundMenuUpdate);
delete this._boundMenuUpdate;
delete this._contextMenu;
if (this.element.parentNode) {
this.element.parentNode.removeChild(this.element);
}
},
/**
* Update the highlighted element.
*
* @param {nsIDOMElement} aElement
* The node whose style rules we'll inspect.
*/
highlight: function CssRuleView_highlight(aElement)
{
if (this._viewedElement === aElement) {
return;
}
this.clear();
if (this._elementStyle) {
delete this._elementStyle;
}
this._viewedElement = aElement;
if (!this._viewedElement) {
this._showEmpty();
return;
}
this._elementStyle = new ElementStyle(aElement, this.store);
this._elementStyle.onChanged = function() {
this._changed();
}.bind(this);
this._createEditors();
},
/**
* Update the rules for the currently highlighted element.
*/
nodeChanged: function CssRuleView_nodeChanged()
{
// Ignore refreshes during editing.
if (this.isEditing) {
return;
}
// Repopulate the element style.
this._elementStyle.populate();
// Refresh the rule editors.
this._createEditors();
// Notify anyone that cares that we refreshed.
var evt = this.doc.createEvent("Events");
evt.initEvent("CssRuleViewRefreshed", true, false);
this.element.dispatchEvent(evt);
},
/**
* Show the user that the rule view has no node selected.
*/
_showEmpty: function CssRuleView_showEmpty()
{
if (this.doc.getElementById("noResults") > 0) {
return;
}
createChild(this.element, "div", {
id: "noResults",
textContent: CssLogic.l10n("rule.empty")
});
},
/**
* Clear the rules.
*/
_clearRules: function CssRuleView_clearRules()
{
while (this.element.hasChildNodes()) {
this.element.removeChild(this.element.lastChild);
}
},
/**
* Clear the rule view.
*/
clear: function CssRuleView_clear()
{
this._clearRules();
this._viewedElement = null;
this._elementStyle = null;
},
/**
* Called when the user has made changes to the ElementStyle.
* Emits an event that clients can listen to.
*/
_changed: function CssRuleView_changed()
{
var evt = this.doc.createEvent("Events");
evt.initEvent("CssRuleViewChanged", true, false);
this.element.dispatchEvent(evt);
},
/**
* Creates editor UI for each of the rules in _elementStyle.
*/
_createEditors: function CssRuleView_createEditors()
{
// Run through the current list of rules, attaching
// their editors in order. Create editors if needed.
let last = null;
for each (let rule in this._elementStyle.rules) {
if (!rule.editor) {
new RuleEditor(this, rule);
}
let target = last ? last.nextSibling : this.element.firstChild;
this.element.insertBefore(rule.editor.element, target);
last = rule.editor.element;
}
// ... and now editors for rules that don't exist anymore
// have been pushed to the end of the list, go ahead and
// delete their nodes. The rules they edit have already been
// forgotten.
while (last && last.nextSibling) {
this.element.removeChild(last.nextSibling);
}
},
/**
* Add a context menu to the rule view.
*/
_createContextMenu: function CssRuleView_createContextMenu()
{
let popupSet = this.doc.createElement("popupset");
this.doc.documentElement.appendChild(popupSet);
let menu = this.doc.createElement("menupopup");
menu.id = "rule-view-context-menu";
this._boundMenuUpdate = this._onMenuUpdate.bind(this);
menu.addEventListener("popupshowing", this._boundMenuUpdate);
// Copy selection
this._copyItem = createMenuItem(menu, {
label: "rule.contextmenu.copyselection",
accesskey: "rule.contextmenu.copyselection.accesskey",
command: this._boundCopy
});
// Copy rule
this._boundCopyRule = this._onCopyRule.bind(this);
this._ruleItem = createMenuItem(menu, {
label: "rule.contextmenu.copyrule",
accesskey: "rule.contextmenu.copyrule.accesskey",
command: this._boundCopyRule
});
// Copy declaration
this._boundCopyDeclaration = this._onCopyDeclaration.bind(this);
this._declarationItem = createMenuItem(menu, {
label: "rule.contextmenu.copydeclaration",
accesskey: "rule.contextmenu.copydeclaration.accesskey",
command: this._boundCopyDeclaration
});
this._boundCopyProperty = this._onCopyProperty.bind(this);
this._propertyItem = createMenuItem(menu, {
label: "rule.contextmenu.copyproperty",
accesskey: "rule.contextmenu.copyproperty.accesskey",
command: this._boundCopyProperty
});
this._boundCopyPropertyValue = this._onCopyPropertyValue.bind(this);
this._propertyValueItem = createMenuItem(menu,{
label: "rule.contextmenu.copypropertyvalue",
accesskey: "rule.contextmenu.copypropertyvalue.accesskey",
command: this._boundCopyPropertyValue
});
popupSet.appendChild(menu);
this.element.setAttribute("context", menu.id);
this._contextMenu = menu;
},
/**
* Update the rule view's context menu by disabling irrelevant menuitems and
* enabling relevant ones.
*
* @param aEvent The event object
*/
_onMenuUpdate: function CssRuleView_onMenuUpdate(aEvent)
{
// Copy selection.
let disable = this.doc.defaultView.getSelection().isCollapsed;
this._copyItem.disabled = disable;
// Copy property, copy property name & copy property value.
let node = this.doc.popupNode;
if (!node) {
return;
}
if (!node.classList.contains("ruleview-property") &&
!node.classList.contains("ruleview-computed")) {
while (node = node.parentElement) {
if (node.classList.contains("ruleview-property") ||
node.classList.contains("ruleview-computed")) {
break;
}
}
}
let disablePropertyItems = !node || (node &&
!node.classList.contains("ruleview-property") &&
!node.classList.contains("ruleview-computed"));
this._declarationItem.disabled = disablePropertyItems;
this._propertyItem.disabled = disablePropertyItems;
this._propertyValueItem.disabled = disablePropertyItems;
},
/**
* Copy selected text from the rule view.
*
* @param aEvent The event object
*/
_onCopy: function CssRuleView_onCopy(aEvent)
{
let win = this.doc.defaultView;
let text = win.getSelection().toString();
// Remove any double newlines.
text = text.replace(/(\r?\n)\r?\n/g, "$1");
// Remove "inline"
let inline = _strings.GetStringFromName("rule.sourceInline");
let rx = new RegExp("^" + inline + "\\r?\\n?", "g");
text = text.replace(rx, "");
// Remove file:line
text = text.replace(/[\w\.]+:\d+(\r?\n)/g, "$1");
// Remove inherited from: line
let inheritedFrom = _strings.
GetStringFromName("rule.inheritedSource");
inheritedFrom = inheritedFrom.replace(/\s%S\s\(%S\)/g, "");
rx = new RegExp("(\r?\n)" + inheritedFrom + ".*", "g");
text = text.replace(rx, "$1");
clipboardHelper.copyString(text, this.doc);
if (aEvent) {
aEvent.preventDefault();
}
},
/**
* Copy a rule from the rule view.
*
* @param aEvent The event object
*/
_onCopyRule: function CssRuleView_onCopyRule(aEvent)
{
let terminator;
let node = this.doc.popupNode;
if (!node) {
return;
}
if (node.className != "rule-view-row") {
while (node = node.parentElement) {
if (node.className == "rule-view-row") {
break;
}
}
}
node = node.cloneNode();
let computedLists = node.querySelectorAll(".ruleview-computedlist");
for (let computedList of computedLists) {
computedList.parentNode.removeChild(computedList);
}
let autosizers = node.querySelectorAll(".autosizer");
for (let autosizer of autosizers) {
autosizer.parentNode.removeChild(autosizer);
}
let selector = node.querySelector(".ruleview-selector").textContent;
let propertyNames = node.querySelectorAll(".ruleview-propertyname");
let propertyValues = node.querySelectorAll(".ruleview-propertyvalue");
// Format the rule
if (osString == "WINNT") {
terminator = "\r\n";
} else {
terminator = "\n";
}
let out = selector + " {" + terminator;
for (let i = 0; i < propertyNames.length; i++) {
let name = propertyNames[i].textContent;
let value = propertyValues[i].textContent;
out += " " + name + ": " + value + ";" + terminator;
}
out += "}" + terminator;
clipboardHelper.copyString(out, this.doc);
},
/**
* Copy a declaration from the rule view.
*
* @param aEvent The event object
*/
_onCopyDeclaration: function CssRuleView_onCopyDeclaration(aEvent)
{
let node = this.doc.popupNode;
if (!node) {
return;
}
if (!node.classList.contains("ruleview-property") &&
!node.classList.contains("ruleview-computed")) {
while (node = node.parentElement) {
if (node.classList.contains("ruleview-property") ||
node.classList.contains("ruleview-computed")) {
break;
}
}
}
// We need to strip expanded properties from the node because we use
// node.textContent below, which also gets text from hidden nodes. The
// simplest way to do this is to clone the node and remove them from the
// clone.
node = node.cloneNode();
let computedLists = node.querySelectorAll(".ruleview-computedlist");
for (let computedList of computedLists) {
computedList.parentNode.removeChild(computedList);
}
let propertyName = node.querySelector(".ruleview-propertyname").textContent;
let propertyValue = node.querySelector(".ruleview-propertyvalue").textContent;
let out = propertyName + ": " + propertyValue + ";";
clipboardHelper.copyString(out, this.doc);
},
/**
* Copy a property name from the rule view.
*
* @param aEvent The event object
*/
_onCopyProperty: function CssRuleView_onCopyProperty(aEvent)
{
let node = this.doc.popupNode;
if (!node) {
return;
}
if (!node.classList.contains("ruleview-propertyname")) {
node = node.querySelector(".ruleview-propertyname");
}
if (node) {
clipboardHelper.copyString(node.textContent, this.doc);
}
},
/**
* Copy a property value from the rule view.
*
* @param aEvent The event object
*/
_onCopyPropertyValue: function CssRuleView_onCopyPropertyValue(aEvent)
{
let node = this.doc.popupNode;
if (!node) {
return;
}
if (!node.classList.contains("ruleview-propertyvalue")) {
node = node.querySelector(".ruleview-propertyvalue");
}
if (node) {
clipboardHelper.copyString(node.textContent, this.doc);
}
}
};
/**
* Create a RuleEditor.
*
* @param CssRuleView aRuleView
* The CssRuleView containg the document holding this rule editor.
* @param Rule aRule
* The Rule object we're editing.
* @constructor
*/
function RuleEditor(aRuleView, aRule)
{
this.ruleView = aRuleView;
this.doc = this.ruleView.doc;
this.rule = aRule;
this.rule.editor = this;
this._onNewProperty = this._onNewProperty.bind(this);
this._newPropertyDestroy = this._newPropertyDestroy.bind(this);
this._create();
}
RuleEditor.prototype = {
_create: function RuleEditor_create()
{
this.element = this.doc.createElementNS(HTML_NS, "div");
this.element.className = "rule-view-row";
this.element._ruleEditor = this;
// Give a relative position for the inplace editor's measurement
// span to be placed absolutely against.
this.element.style.position = "relative";
// Add the source link.
let source = createChild(this.element, "div", {
class: "ruleview-rule-source",
textContent: this.rule.title
});
source.addEventListener("click", function() {
let rule = this.rule;
let evt = this.doc.createEvent("CustomEvent");
evt.initCustomEvent("CssRuleViewCSSLinkClicked", true, false, {
rule: rule,
});
this.element.dispatchEvent(evt);
}.bind(this));
let code = createChild(this.element, "div", {
class: "ruleview-code"
});
let header = createChild(code, "div", {});
this.selectorText = createChild(header, "span", {
class: "ruleview-selector"
});
this.openBrace = createChild(header, "span", {
class: "ruleview-ruleopen",
textContent: " {"
});
code.addEventListener("click", function() {
let selection = this.doc.defaultView.getSelection();
if (selection.isCollapsed) {
this.newProperty();
}
}.bind(this), false);
this.propertyList = createChild(code, "ul", {
class: "ruleview-propertylist"
});
this.populate();
this.closeBrace = createChild(code, "div", {
class: "ruleview-ruleclose",
tabindex: "0",
textContent: "}"
});
// Create a property editor when the close brace is clicked.
editableItem(this.closeBrace, function(aElement) {
this.newProperty();
}.bind(this));
},
/**
* Update the rule editor with the contents of the rule.
*/
populate: function RuleEditor_populate()
{
this.selectorText.textContent = this.rule.selectorText;
for (let prop of this.rule.textProps) {
if (!prop.editor) {
new TextPropertyEditor(this, prop);
this.propertyList.appendChild(prop.editor.element);
}
}
},
/**
* Programatically add a new property to the rule.
*
* @param string aName
* Property name.
* @param string aValue
* Property value.
* @param string aPriority
* Property priority.
*/
addProperty: function RuleEditor_addProperty(aName, aValue, aPriority)
{
let prop = this.rule.createProperty(aName, aValue, aPriority);
let editor = new TextPropertyEditor(this, prop);
this.propertyList.appendChild(editor.element);
},
/**
* Create a text input for a property name. If a non-empty property
* name is given, we'll create a real TextProperty and add it to the
* rule.
*/
newProperty: function RuleEditor_newProperty()
{
// If we're already creating a new property, ignore this.
if (!this.closeBrace.hasAttribute("tabindex")) {
return;
}
// While we're editing a new property, it doesn't make sense to
// start a second new property editor, so disable focusing the
// close brace for now.
this.closeBrace.removeAttribute("tabindex");
this.newPropItem = createChild(this.propertyList, "li", {
class: "ruleview-property ruleview-newproperty",
});
this.newPropSpan = createChild(this.newPropItem, "span", {
class: "ruleview-propertyname",
tabindex: "0"
});
new InplaceEditor({
element: this.newPropSpan,
done: this._onNewProperty,
destroy: this._newPropertyDestroy,
advanceChars: ":"
});
},
/**
* Called when the new property input has been dismissed.
* Will create a new TextProperty if necessary.
*
* @param string aValue
* The value in the editor.
* @param bool aCommit
* True if the value should be committed.
*/
_onNewProperty: function RuleEditor__onNewProperty(aValue, aCommit)
{
if (!aValue || !aCommit) {
return;
}
// Create an empty-valued property and start editing it.
let prop = this.rule.createProperty(aValue, "", "");
let editor = new TextPropertyEditor(this, prop);
this.propertyList.appendChild(editor.element);
editor.valueSpan.click();
},
/**
* Called when the new property editor is destroyed.
*/
_newPropertyDestroy: function RuleEditor__newPropertyDestroy()
{
// We're done, make the close brace focusable again.
this.closeBrace.setAttribute("tabindex", "0");
this.propertyList.removeChild(this.newPropItem);
delete this.newPropItem;
delete this.newPropSpan;
}
};
/**
* Create a TextPropertyEditor.
*
* @param {RuleEditor} aRuleEditor
* The rule editor that owns this TextPropertyEditor.
* @param {TextProperty} aProperty
* The text property to edit.
* @constructor
*/
function TextPropertyEditor(aRuleEditor, aProperty)
{
this.doc = aRuleEditor.doc;
this.prop = aProperty;
this.prop.editor = this;
this._onEnableClicked = this._onEnableClicked.bind(this);
this._onExpandClicked = this._onExpandClicked.bind(this);
this._onStartEditing = this._onStartEditing.bind(this);
this._onNameDone = this._onNameDone.bind(this);
this._onValueDone = this._onValueDone.bind(this);
this._create();
this.update();
}
TextPropertyEditor.prototype = {
get editing() {
return !!(this.nameSpan.inplaceEditor || this.valueSpan.inplaceEditor);
},
/**
* Create the property editor's DOM.
*/
_create: function TextPropertyEditor_create()
{
this.element = this.doc.createElementNS(HTML_NS, "li");
this.element.classList.add("ruleview-property");
// The enable checkbox will disable or enable the rule.
this.enable = createChild(this.element, "input", {
class: "ruleview-enableproperty",
type: "checkbox",
tabindex: "-1"
});
this.enable.addEventListener("click", this._onEnableClicked, true);
// Click to expand the computed properties of the text property.
this.expander = createChild(this.element, "span", {
class: "ruleview-expander"
});
this.expander.addEventListener("click", this._onExpandClicked, true);
this.nameContainer = createChild(this.element, "span", {
class: "ruleview-namecontainer"
});
this.nameContainer.addEventListener("click", function(aEvent) {
// Clicks within the name shouldn't propagate any further.
aEvent.stopPropagation();
if (aEvent.target === propertyContainer) {
this.nameSpan.click();
}
}.bind(this), false);
// Property name, editable when focused. Property name
// is committed when the editor is unfocused.
this.nameSpan = createChild(this.nameContainer, "span", {
class: "ruleview-propertyname",
tabindex: "0",
});
editableField({
start: this._onStartEditing,
element: this.nameSpan,
done: this._onNameDone,
advanceChars: ':'
});
appendText(this.nameContainer, ": ");
// Create a span that will hold the property and semicolon.
// Use this span to create a slightly larger click target
// for the value.
let propertyContainer = createChild(this.element, "span", {
class: "ruleview-propertycontainer"
});
propertyContainer.addEventListener("click", function(aEvent) {
// Clicks within the value shouldn't propagate any further.
aEvent.stopPropagation();
if (aEvent.target === propertyContainer) {
this.valueSpan.click();
}
}.bind(this), false);
// Property value, editable when focused. Changes to the
// property value are applied as they are typed, and reverted
// if the user presses escape.
this.valueSpan = createChild(propertyContainer, "span", {
class: "ruleview-propertyvalue",
tabindex: "0",
});
editableField({
start: this._onStartEditing,
element: this.valueSpan,
done: this._onValueDone,
advanceChars: ';'
});
// Save the initial value as the last committed value,
// for restoring after pressing escape.
this.committed = { name: this.prop.name,
value: this.prop.value,
priority: this.prop.priority };
appendText(propertyContainer, ";");
this.warning = createChild(this.element, "div", {
hidden: "",
class: "ruleview-warning",
title: CssLogic.l10n("rule.warning.title"),
});
// Holds the viewers for the computed properties.
// will be populated in |_updateComputed|.
this.computed = createChild(this.element, "ul", {
class: "ruleview-computedlist",
});
},
/**
* Populate the span based on changes to the TextProperty.
*/
update: function TextPropertyEditor_update()
{
if (this.prop.enabled) {
this.enable.style.removeProperty("visibility");
this.enable.setAttribute("checked", "");
} else {
this.enable.style.visibility = "visible";
this.enable.removeAttribute("checked");
}
if (this.prop.overridden && !this.editing) {
this.element.classList.add("ruleview-overridden");
} else {
this.element.classList.remove("ruleview-overridden");
}
let name = this.prop.name;
this.nameSpan.textContent = name;
// Combine the property's value and priority into one string for
// the value.
let val = this.prop.value;
if (this.prop.priority) {
val += " !" + this.prop.priority;
}
this.valueSpan.textContent = val;
this.warning.hidden = this._validate();
let store = this.prop.rule.elementStyle.store;
let propDirty = store.userProperties.contains(this.prop.rule.style, name);
if (propDirty) {
this.element.setAttribute("dirty", "");
} else {
this.element.removeAttribute("dirty");
}
// Populate the computed styles.
this._updateComputed();
},
_onStartEditing: function TextPropertyEditor_onStartEditing()
{
this.element.classList.remove("ruleview-overridden");
},
/**
* Populate the list of computed styles.
*/
_updateComputed: function TextPropertyEditor_updateComputed()
{
// Clear out existing viewers.
while (this.computed.hasChildNodes()) {
this.computed.removeChild(this.computed.lastChild);
}
let showExpander = false;
for each (let computed in this.prop.computed) {
// Don't bother to duplicate information already
// shown in the text property.
if (computed.name === this.prop.name) {
continue;
}
showExpander = true;
let li = createChild(this.computed, "li", {
class: "ruleview-computed"
});
if (computed.overridden) {
li.classList.add("ruleview-overridden");
}
createChild(li, "span", {
class: "ruleview-propertyname",
textContent: computed.name
});
appendText(li, ": ");
createChild(li, "span", {
class: "ruleview-propertyvalue",
textContent: computed.value
});
appendText(li, ";");
}
// Show or hide the expander as needed.
if (showExpander) {
this.expander.style.visibility = "visible";
} else {
this.expander.style.visibility = "hidden";
}
},
/**
* Handles clicks on the disabled property.
*/
_onEnableClicked: function TextPropertyEditor_onEnableClicked(aEvent)
{
this.prop.setEnabled(this.enable.checked);
aEvent.stopPropagation();
},
/**
* Handles clicks on the computed property expander.
*/
_onExpandClicked: function TextPropertyEditor_onExpandClicked(aEvent)
{
this.expander.classList.toggle("styleinspector-open");
this.computed.classList.toggle("styleinspector-open");
aEvent.stopPropagation();
},
/**
* Called when the property name's inplace editor is closed.
* Ignores the change if the user pressed escape, otherwise
* commits it.
*
* @param {string} aValue
* The value contained in the editor.
* @param {boolean} aCommit
* True if the change should be applied.
*/
_onNameDone: function TextPropertyEditor_onNameDone(aValue, aCommit)
{
if (!aCommit) {
if (this.prop.overridden) {
this.element.classList.add("ruleview-overridden");
}
return;
}
if (!aValue) {
this.prop.remove();
this.element.parentNode.removeChild(this.element);
return;
}
this.prop.setName(aValue);
},
/**
* Pull priority (!important) out of the value provided by a
* value editor.
*
* @param {string} aValue
* The value from the text editor.
* @return an object with 'value' and 'priority' properties.
*/
_parseValue: function TextPropertyEditor_parseValue(aValue)
{
let pieces = aValue.split("!", 2);
return {
value: pieces[0].trim(),
priority: (pieces.length > 1 ? pieces[1].trim() : "")
};
},
/**
* Called when a value editor closes. If the user pressed escape,
* revert to the value this property had before editing.
*
* @param {string} aValue
* The value contained in the editor.
* @param {boolean} aCommit
* True if the change should be applied.
*/
_onValueDone: function PropertyEditor_onValueDone(aValue, aCommit)
{
if (aCommit) {
let val = this._parseValue(aValue);
this.prop.setValue(val.value, val.priority);
this.committed.value = this.prop.value;
this.committed.priority = this.prop.priority;
} else {
this.prop.setValue(this.committed.value, this.committed.priority);
}
},
/**
* Validate this property.
*
* @returns {Boolean}
* True if the property value is valid, false otherwise.
*/
_validate: function TextPropertyEditor_validate()
{
let name = this.prop.name;
let value = this.prop.value;
let style = this.doc.createElementNS(HTML_NS, "div").style;
style.setProperty(name, value, null);
return !!style.getPropertyValue(name);
},
};
/**
* Mark a span editable. |editableField| will listen for the span to
* be focused and create an InlineEditor to handle text input.
* Changes will be committed when the InlineEditor's input is blurred
* or dropped when the user presses escape.
*
* @param {object} aOptions
* Options for the editable field, including:
* {Element} element:
* (required) The span to be edited on focus.
* {function} start:
* Will be called when the inplace editor is initialized.
* {function} change:
* Will be called when the text input changes. Will be called
* with the current value of the text input.
* {function} done:
* Called when input is committed or blurred. Called with
* current value and a boolean telling the caller whether to
* commit the change. This function is called before the editor
* has been torn down.
* {function} destroy:
* Called when the editor is destroyed and has been torn down.
* {string} advanceChars:
* If any characters in advanceChars are typed, focus will advance
* to the next element.
*/
function editableField(aOptions)
{
editableItem(aOptions.element, function(aElement) {
new InplaceEditor(aOptions);
});
}
/**
* Handle events for an element that should respond to
* clicks and sit in the editing tab order, and call
* a callback when it is activated.
*
* @param DOMElement aElement
* The DOM element.
* @param function aCallback
* Called when the editor is activated.
*/
function editableItem(aElement, aCallback)
{
aElement.addEventListener("click", function(evt) {
let win = this.ownerDocument.defaultView;
let selection = win.getSelection();
if (selection.isCollapsed) {
aCallback(aElement);
}
evt.stopPropagation();
}, false);
// If focused by means other than a click, start editing by
// pressing enter or space.
aElement.addEventListener("keypress", function(evt) {
if (evt.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN ||
evt.charCode === Ci.nsIDOMKeyEvent.DOM_VK_SPACE) {
aCallback(aElement);
}
}, true);
// Ugly workaround - the element is focused on mousedown but
// the editor is activated on click/mouseup. This leads
// to an ugly flash of the focus ring before showing the editor.
// So hide the focus ring while the mouse is down.
aElement.addEventListener("mousedown", function(evt) {
let cleanup = function() {
aElement.style.removeProperty("outline-style");
aElement.removeEventListener("mouseup", cleanup, false);
aElement.removeEventListener("mouseout", cleanup, false);
};
aElement.style.setProperty("outline-style", "none");
aElement.addEventListener("mouseup", cleanup, false);
aElement.addEventListener("mouseout", cleanup, false);
}, false);
// Mark the element editable field for tab
// navigation while editing.
aElement._editable = true;
}
var _editableField = editableField;
function InplaceEditor(aOptions)
{
this.elt = aOptions.element;
let doc = this.elt.ownerDocument;
this.doc = doc;
this.elt.inplaceEditor = this;
this.change = aOptions.change;
this.done = aOptions.done;
this.destroy = aOptions.destroy;
this.initial = aOptions.initial ? aOptions.initial : this.elt.textContent;
this._onBlur = this._onBlur.bind(this);
this._onKeyPress = this._onKeyPress.bind(this);
this._onInput = this._onInput.bind(this);
this._createInput();
this._autosize();
// Pull out character codes for advanceChars, listing the
// characters that should trigger a blur.
this._advanceCharCodes = {};
let advanceChars = aOptions.advanceChars || '';
for (let i = 0; i < advanceChars.length; i++) {
this._advanceCharCodes[advanceChars.charCodeAt(i)] = true;
}
// Hide the provided element and add our editor.
this.originalDisplay = this.elt.style.display;
this.elt.style.display = "none";
this.elt.parentNode.insertBefore(this.input, this.elt);
this.input.select();
this.input.focus();
this.input.addEventListener("blur", this._onBlur, false);
this.input.addEventListener("keypress", this._onKeyPress, false);
this.input.addEventListener("input", this._onInput, false);
if (aOptions.start) {
aOptions.start();
}
}
InplaceEditor.prototype = {
_createInput: function InplaceEditor_createEditor()
{
this.input = this.doc.createElementNS(HTML_NS, "input");
this.input.inplaceEditor = this;
this.input.classList.add("styleinspector-propertyeditor");
this.input.value = this.initial;
copyTextStyles(this.elt, this.input);
},
/**
* Get rid of the editor.
*/
_clear: function InplaceEditor_clear()
{
if (!this.input) {
// Already cleared.
return;
}
this.input.removeEventListener("blur", this._onBlur, false);
this.input.removeEventListener("keypress", this._onKeyPress, false);
this.input.removeEventListener("oninput", this._onInput, false);
this._stopAutosize();
this.elt.style.display = this.originalDisplay;
this.elt.focus();
if (this.destroy) {
this.destroy();
}
this.elt.parentNode.removeChild(this.input);
this.input = null;
delete this.elt.inplaceEditor;
delete this.elt;
},
/**
* Keeps the editor close to the size of its input string. This is pretty
* crappy, suggestions for improvement welcome.
*/
_autosize: function InplaceEditor_autosize()
{
// Create a hidden, absolutely-positioned span to measure the text
// in the input. Boo.
// We can't just measure the original element because a) we don't
// change the underlying element's text ourselves (we leave that
// up to the client), and b) without tweaking the style of the
// original element, it might wrap differently or something.
this._measurement = this.doc.createElementNS(HTML_NS, "span");
this._measurement.className = "autosizer";
this.elt.parentNode.appendChild(this._measurement);
let style = this._measurement.style;
style.visibility = "hidden";
style.position = "absolute";
style.top = "0";
style.left = "0";
copyTextStyles(this.input, this._measurement);
this._updateSize();
},
/**
* Clean up the mess created by _autosize().
*/
_stopAutosize: function InplaceEditor_stopAutosize()
{
if (!this._measurement) {
return;
}
this._measurement.parentNode.removeChild(this._measurement);
delete this._measurement;
},
/**
* Size the editor to fit its current contents.
*/
_updateSize: function InplaceEditor_updateSize()
{
// Replace spaces with non-breaking spaces. Otherwise setting
// the span's textContent will collapse spaces and the measurement
// will be wrong.
this._measurement.textContent = this.input.value.replace(/ /g, '\u00a0');
// We add a bit of padding to the end. Should be enough to fit
// any letter that could be typed, otherwise we'll scroll before
// we get a chance to resize. Yuck.
let width = this._measurement.offsetWidth + 10;
this.input.style.width = width + "px";
},
/**
* Call the client's done handler and clear out.
*/
_apply: function InplaceEditor_apply(aEvent)
{
if (this._applied) {
return;
}
this._applied = true;
if (this.done) {
let val = this.input.value.trim();
return this.done(this.cancelled ? this.initial : val, !this.cancelled);
}
return null;
},
/**
* Handle loss of focus by calling done if it hasn't been called yet.
*/
_onBlur: function InplaceEditor_onBlur(aEvent)
{
this._apply();
this._clear();
},
_onKeyPress: function InplaceEditor_onKeyPress(aEvent)
{
let prevent = false;
if (aEvent.charCode in this._advanceCharCodes
|| aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN
|| aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_TAB) {
prevent = true;
let direction = FOCUS_FORWARD;
if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_TAB &&
aEvent.shiftKey) {
this.cancelled = true;
direction = FOCUS_BACKWARD;
}
let input = this.input;
this._apply();
let fm = Cc["@mozilla.org/focus-manager;1"].getService(Ci.nsIFocusManager);
if (fm.focusedElement === input) {
// If the focused element wasn't changed by the done callback,
// move the focus as requested.
let next = moveFocus(this.doc.defaultView, direction);
// If the next node to be focused has been tagged as an editable
// node, send it a click event to trigger
if (next && next.ownerDocument === this.doc && next._editable) {
next.click();
}
}
this._clear();
} else if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_ESCAPE) {
// Cancel and blur ourselves.
prevent = true;
this.cancelled = true;
this._apply();
this._clear();
aEvent.stopPropagation();
} else if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_SPACE) {
// No need for leading spaces here. This is particularly
// noticable when adding a property: it's very natural to type
// <name>: (which advances to the next property) then spacebar.
prevent = !this.input.value;
}
if (prevent) {
aEvent.preventDefault();
}
},
/**
* Handle changes the input text.
*/
_onInput: function InplaceEditor_onInput(aEvent)
{
// Update size if we're autosizing.
if (this._measurement) {
this._updateSize();
}
// Call the user's change handler if available.
if (this.change) {
this.change(this.input.value.trim());
}
}
};
/*
* Various API consumers (especially tests) sometimes want to grab the
* inplaceEditor expando off span elements. However, when each global has its
* own compartment, those expandos live on Xray wrappers that are only visible
* within this JSM. So we provide a little workaround here.
*/
function _getInplaceEditorForSpan(aSpan) { return aSpan.inplaceEditor; };
/**
* Store of CSSStyleDeclarations mapped to properties that have been changed by
* the user.
*/
function UserProperties()
{
this.weakMap = new WeakMap();
}
UserProperties.prototype = {
/**
* Get a named property for a given CSSStyleDeclaration.
*
* @param {CSSStyleDeclaration} aStyle
* The CSSStyleDeclaration against which the property is mapped.
* @param {String} aName
* The name of the property to get.
* @param {String} aComputedValue
* The computed value of the property. The user value will only be
* returned if the computed value hasn't changed since, and this will
* be returned as the default if no user value is available.
* @returns {String}
* The property value if it has previously been set by the user, null
* otherwise.
*/
getProperty: function UP_getProperty(aStyle, aName, aComputedValue) {
let entry = this.weakMap.get(aStyle, null);
if (entry && aName in entry) {
let item = entry[aName];
if (item.computed != aComputedValue) {
delete entry[aName];
return aComputedValue;
}
return item.user;
}
return aComputedValue;
},
/**
* Set a named property for a given CSSStyleDeclaration.
*
* @param {CSSStyleDeclaration} aStyle
* The CSSStyleDeclaration against which the property is to be mapped.
* @param {String} aName
* The name of the property to set.
* @param {String} aComputedValue
* The computed property value. The user value will not be used if the
* computed value changes.
* @param {String} aUserValue
* The value of the property to set.
*/
setProperty: function UP_setProperty(aStyle, aName, aComputedValue, aUserValue) {
let entry = this.weakMap.get(aStyle, null);
if (entry) {
entry[aName] = { computed: aComputedValue, user: aUserValue };
} else {
let props = {};
props[aName] = { computed: aComputedValue, user: aUserValue };
this.weakMap.set(aStyle, props);
}
},
/**
* Check whether a named property for a given CSSStyleDeclaration is stored.
*
* @param {CSSStyleDeclaration} aStyle
* The CSSStyleDeclaration against which the property would be mapped.
* @param {String} aName
* The name of the property to check.
*/
contains: function UP_contains(aStyle, aName) {
let entry = this.weakMap.get(aStyle, null);
return !!entry && aName in entry;
},
};
/**
* Helper functions
*/
/**
* Create a child element with a set of attributes.
*
* @param {Element} aParent
* The parent node.
* @param {string} aTag
* The tag name.
* @param {object} aAttributes
* A set of attributes to set on the node.
*/
function createChild(aParent, aTag, aAttributes)
{
let elt = aParent.ownerDocument.createElementNS(HTML_NS, aTag);
for (let attr in aAttributes) {
if (aAttributes.hasOwnProperty(attr)) {
if (attr === "textContent") {
elt.textContent = aAttributes[attr];
} else {
elt.setAttribute(attr, aAttributes[attr]);
}
}
}
aParent.appendChild(elt);
return elt;
}
function createMenuItem(aMenu, aAttributes)
{
let item = aMenu.ownerDocument.createElementNS(XUL_NS, "menuitem");
item.setAttribute("label", _strings.GetStringFromName(aAttributes.label));
item.setAttribute("accesskey", _strings.GetStringFromName(aAttributes.accesskey));
item.addEventListener("command", aAttributes.command);
aMenu.appendChild(item);
return item;
}
/**
* Append a text node to an element.
*/
function appendText(aParent, aText)
{
aParent.appendChild(aParent.ownerDocument.createTextNode(aText));
}
/**
* Copy text-related styles from one element to another.
*/
function copyTextStyles(aFrom, aTo)
{
let win = aFrom.ownerDocument.defaultView;
let style = win.getComputedStyle(aFrom);
aTo.style.fontFamily = style.getPropertyCSSValue("font-family").cssText;
aTo.style.fontSize = style.getPropertyCSSValue("font-size").cssText;
aTo.style.fontWeight = style.getPropertyCSSValue("font-weight").cssText;
aTo.style.fontStyle = style.getPropertyCSSValue("font-style").cssText;
}
/**
* Trigger a focus change similar to pressing tab/shift-tab.
*/
function moveFocus(aWin, aDirection)
{
let fm = Cc["@mozilla.org/focus-manager;1"].getService(Ci.nsIFocusManager);
return fm.moveFocus(aWin, null, aDirection, 0);
}
XPCOMUtils.defineLazyGetter(this, "clipboardHelper", function() {
return Cc["@mozilla.org/widget/clipboardhelper;1"].
getService(Ci.nsIClipboardHelper);
});
XPCOMUtils.defineLazyGetter(this, "_strings", function() {
return Services.strings.createBundle(
"chrome://browser/locale/devtools/styleinspector.properties");
});
XPCOMUtils.defineLazyGetter(this, "osString", function() {
return Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime).OS;
});