browser/devtools/styleinspector/CssRuleView.jsm
author Dave Camp <dcamp@mozilla.com>
Fri, 01 Jun 2012 15:13:48 -0700
changeset 95400 85c153eea9d4f26a1c459c1bc152e11cc5f1e4b9
parent 95266 40462fc79a4bd656ebd1106a040856b38492dded
child 95401 13d899f2545494d17090c8ed5156af649e5c8807
permissions -rw-r--r--
Bug 734365 - Rule view focus management needs an overhaul. r=paul

/* -*- 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,

  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()
  {
    // Repopulate the element style.
    this._elementStyle.populate();

    // Refresh the rule editors.
    this._createEditors();
  },

  /**
   * 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);

    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);
  },

  /**
   * 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);
  },

  /**
   * 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);
    }
  },

 /**
   * 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);
    }
  }
};

/**
 * 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: " {"
    });

    this.openBrace.addEventListener("click", function() {
      this.newProperty();
    }.bind(this), true);

    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()
  {
    // 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);

    // Property name, editable when focused.  Property name
    // is committed when the editor is unfocused.
    this.nameSpan = createChild(this.element, "span", {
      class: "ruleview-propertyname",
      tabindex: "0",
    });
    editableField({
      start: this._onStartEditing,
      element: this.nameSpan,
      done: this._onNameDone,
      advanceChars: ':'
    });

    appendText(this.element, ": ");

    // 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(this.element, "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(this.element, ";");

    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()
  {
    this.prop.setEnabled(this.enable.checked);
  },

  /**
   * Handles clicks on the computed property expander.
   */
  _onExpandClicked: function TextPropertyEditor_onExpandClicked()
  {
    this.expander.classList.toggle("styleinspector-open");
    this.computed.classList.toggle("styleinspector-open");
  },

  /**
   * 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) {
      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() {
    let win = this.ownerDocument.defaultView;
    let selection = win.getSelection();
    if (selection.isCollapsed) {
      aCallback(aElement);
    }
  }, 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;
});