/* -*- 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, Ci, Cu} = require("chrome"); let {CssLogic} = require("devtools/styleinspector/css-logic"); let {InplaceEditor, editableField, editableItem} = require("devtools/shared/inplace-editor"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); const HTML_NS = "http://www.w3.org/1999/xhtml"; const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; /** * 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))?;?$/; // Used to parse an external resource from a property value const CSS_RESOURCE_RE = /url\([\'\"]?(.*?)[\'\"]?\)/; const IOService = Cc["@mozilla.org/network/io-service;1"] .getService(Ci.nsIIOService); /** * 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 (!("disabled" in this.store)) { this.store.disabled = new 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. exports._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, /** * 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 = 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 {bool} 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 {bool} 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; } return this._title + (this.mediaText ? " @media " + this.mediaText : ""); }, get inheritedSource() { if (this._inheritedSource) { return this._inheritedSource; } this._inheritedSource = ""; if (this.inherited) { let eltText = this.inherited.tagName.toLowerCase(); if (this.inherited.id) { eltText += "#" + this.inherited.id; } this._inheritedSource = CssLogic._strings.formatStringFromName("rule.inheritedFrom", [eltText], 1); } return this._inheritedSource; }, /** * 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 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; if (disabledProps.length > 0) { disabled.set(this.style, disabledProps); } else { disabled.delete(this.style); } 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 && !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(). * * @return {bool} 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. * @param {