diff -r 000000000000 -r 6474c204b198 browser/devtools/styleinspector/rule-view.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/browser/devtools/styleinspector/rule-view.js Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,2722 @@ +/* -*- 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"); +const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {}); +const {CssLogic} = require("devtools/styleinspector/css-logic"); +const {InplaceEditor, editableField, editableItem} = require("devtools/shared/inplace-editor"); +const {ELEMENT_STYLE, PSEUDO_ELEMENTS} = require("devtools/server/actors/styles"); +const {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {}); +const {Tooltip, SwatchColorPickerTooltip} = require("devtools/shared/widgets/Tooltip"); +const {OutputParser} = require("devtools/output-parser"); +const {PrefObserver, PREF_ORIG_SOURCES} = require("devtools/styleeditor/utils"); +const {parseSingleValue, parseDeclarations} = require("devtools/styleinspector/css-parsing-utils"); + +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); + +function promiseWarn(err) { + console.error(err); + return promise.reject(err); +} + +/** + * 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 function creates the document in which those dummy elements + * will be created. + */ +var gDummyPromise; +function createDummyDocument() { + if (gDummyPromise) { + return gDummyPromise; + } + const { getDocShell, create: makeFrame } = require("sdk/frame/utils"); + + let frame = makeFrame(Services.appShell.hiddenDOMWindow.document, { + nodeName: "iframe", + namespaceURI: "http://www.w3.org/1999/xhtml", + allowJavascript: false, + allowPlugins: false, + allowAuth: false + }); + let docShell = getDocShell(frame); + let eventTarget = docShell.chromeEventHandler; + docShell.createAboutBlankContentViewer(Cc["@mozilla.org/nullprincipal;1"].createInstance(Ci.nsIPrincipal)); + let window = docShell.contentViewer.DOMDocument.defaultView; + window.location = "data:text/html,"; + let deferred = promise.defer(); + eventTarget.addEventListener("DOMContentLoaded", function handler(event) { + eventTarget.removeEventListener("DOMContentLoaded", handler, false); + deferred.resolve(window.document); + frame.remove(); + }, false); + gDummyPromise = deferred.promise; + return gDummyPromise; +} + +/** + * 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. + * @param {PageStyleFront} aPageStyle + * Front for the page style actor that will be providing + * the style information. + * + * @constructor + */ +function ElementStyle(aElement, aStore, aPageStyle) { + this.element = aElement; + this.store = aStore || {}; + this.pageStyle = aPageStyle; + + // 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(); + } +} + +// 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, + + init: function() + { + // 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. + return this.dummyElementPromise = createDummyDocument().then(document => { + this.dummyElement = document.createElementNS(this.element.namespaceURI, + this.element.tagName); + document.documentElement.appendChild(this.dummyElement); + return this.dummyElement; + }).then(null, promiseWarn); + }, + + destroy: function() { + this.dummyElement = null; + this.dummyElementPromise.then(dummyElement => { + if (dummyElement.parentNode) { + dummyElement.parentNode.removeChild(dummyElement); + } + this.dummyElementPromise = null; + }); + }, + + /** + * Called by the Rule object when it has been changed through the + * setProperty* methods. + */ + _changed: function() { + 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. + * + * Returns a promise that will be resolved when the elementStyle is + * ready. + */ + populate: function() { + let populated = this.pageStyle.getApplied(this.element, { + inherited: true, + matchedSelectors: true + }).then(entries => { + // Make sure the dummy element has been created before continuing... + return this.dummyElementPromise.then(() => { + if (this.populated != populated) { + // Don't care anymore. + return promise.reject("unused"); + } + + // Store the current list of rules (if any) during the population + // process. They will be reused if possible. + this._refreshRules = this.rules; + + this.rules = []; + + for (let entry of entries) { + this._maybeAddRule(entry); + } + + // Mark overridden computed styles. + this.markOverriddenAll(); + + this._sortRulesForPseudoElement(); + + // We're done with the previous list of rules. + delete this._refreshRules; + + return null; + }); + }).then(null, promiseWarn); + this.populated = populated; + return this.populated; + }, + + /** + * Put pseudo elements in front of others. + */ + _sortRulesForPseudoElement: function() { + this.rules = this.rules.sort((a, b) => { + return (a.pseudoElement || "z") > (b.pseudoElement || "z"); + }); + }, + + /** + * 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(aOptions) { + // If we've already included this domRule (for example, when a + // common selector is inherited), ignore it. + if (aOptions.rule && + this.rules.some(function(rule) rule.domRule === aOptions.rule)) { + return false; + } + + if (aOptions.system) { + return false; + } + + let rule = null; + + // If we're refreshing and the rule previously existed, reuse the + // Rule object. + if (this._refreshRules) { + for (let r of this._refreshRules) { + if (r.matches(aOptions)) { + rule = r; + rule.refresh(aOptions); + 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); + return true; + }, + + /** + * Calls markOverridden with all supported pseudo elements + */ + markOverriddenAll: function() { + this.markOverridden(); + for (let pseudo of PSEUDO_ELEMENTS) { + this.markOverridden(pseudo); + } + }, + + /** + * Mark the properties listed in this.rules for a given pseudo element + * with an overridden flag if an earlier property overrides it. + * @param {string} pseudo + * Which pseudo element to flag as overridden. + * Empty string or undefined will default to no pseudo element. + */ + markOverridden: function(pseudo="") { + // Gather all the text properties applied by these rules, ordered + // from more- to less-specific. + let textProps = []; + for (let rule of this.rules) { + if (rule.pseudoElement == pseudo) { + textProps = textProps.concat(rule.textProps.slice(0).reverse()); + } + } + + // Gather all the computed properties applied by those text + // properties. + let computedProps = []; + for (let textProp of 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 (let computedProp of 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 (let textProp of 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(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: + * rule: A StyleRuleActor + * 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.rule || null; + this.style = aOptions.rule; + this.matchedSelectors = aOptions.matchedSelectors || []; + this.pseudoElement = aOptions.pseudoElement || ""; + + this.inherited = aOptions.inherited || null; + this._modificationDepth = 0; + + if (this.domRule) { + let parentRule = this.domRule.parentRule; + if (parentRule && parentRule.type == Ci.nsIDOMCSSRule.MEDIA_RULE) { + this.mediaText = parentRule.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.type !== ELEMENT_STYLE) { + this._title += ":" + this.ruleLine; + } + + this._title = this._title + (this.mediaText ? " @media " + this.mediaText : ""); + return this._title; + }, + + 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; + }, + + get selectorText() { + return this.domRule.selectors ? this.domRule.selectors.join(", ") : CssLogic.l10n("rule.sourceElement"); + }, + + /** + * The rule's stylesheet. + */ + get sheet() { + return this.domRule ? this.domRule.parentStyleSheet : null; + }, + + /** + * The rule's line within a stylesheet + */ + get ruleLine() { + return this.domRule ? this.domRule.line : null; + }, + + /** + * The rule's column within a stylesheet + */ + get ruleColumn() { + return this.domRule ? this.domRule.column : null; + }, + + /** + * Get display name for this rule based on the original source + * for this rule's style sheet. + * + * @return {Promise} + * Promise which resolves with location as an object containing + * both the full and short version of the source string. + */ + getOriginalSourceStrings: function() { + if (this._originalSourceStrings) { + return promise.resolve(this._originalSourceStrings); + } + return this.domRule.getOriginalLocation().then(({href, line}) => { + let sourceStrings = { + full: href + ":" + line, + short: CssLogic.shortSource({href: href}) + ":" + line + }; + + this._originalSourceStrings = sourceStrings; + return sourceStrings; + }); + }, + + /** + * Returns true if the rule matches the creation options + * specified. + * + * @param {object} aOptions + * Creation options. See the Rule constructor for documentation. + */ + matches: function(aOptions) { + return this.style === aOptions.rule; + }, + + /** + * 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). + * @param {TextProperty} aSiblingProp + * Optional, property next to which the new property will be added. + */ + createProperty: function(aName, aValue, aPriority, aSiblingProp) { + let prop = new TextProperty(this, aName, aValue, aPriority); + + if (aSiblingProp) { + let ind = this.textProps.indexOf(aSiblingProp); + this.textProps.splice(ind + 1, 0, prop); + } + else { + 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(aModifications, aName) { + this.elementStyle.markOverriddenAll(); + + if (!aModifications) { + aModifications = this.style.startModifyingProperties(); + } + let disabledProps = []; + let store = this.elementStyle.store; + + for (let prop of this.textProps) { + if (!prop.enabled) { + disabledProps.push({ + name: prop.name, + value: prop.value, + priority: prop.priority + }); + continue; + } + if (prop.value.trim() === "") { + continue; + } + + aModifications.setProperty(prop.name, prop.value, prop.priority); + + prop.updateComputed(); + } + + // 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); + } + + let promise = aModifications.apply().then(() => { + let cssProps = {}; + for (let cssProp of parseDeclarations(this.style.cssText)) { + cssProps[cssProp.name] = cssProp; + } + + for (let textProp of this.textProps) { + if (!textProp.enabled) { + continue; + } + let cssProp = cssProps[textProp.name]; + + if (!cssProp) { + cssProp = { + name: textProp.name, + value: "", + priority: "" + }; + } + + if (aName && textProp.name == aName) { + store.userProperties.setProperty( + this.style, + textProp.name, + textProp.value); + } + textProp.priority = cssProp.priority; + } + + this.elementStyle.markOverriddenAll(); + + if (promise === this._applyingModifications) { + this._applyingModifications = null; + } + + this.elementStyle._changed(); + }).then(null, promiseWarn); + + this._applyingModifications = promise; + return promise; + }, + + /** + * 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(aProperty, aName) { + if (aName === aProperty.name) { + return; + } + let modifications = this.style.startModifyingProperties(); + modifications.removeProperty(aProperty.name); + aProperty.name = aName; + this.applyProperties(modifications, aName); + }, + + /** + * Sets the value and priority of a property, then reapply all properties. + * + * @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(aProperty, aValue, aPriority) { + if (aValue === aProperty.value && aPriority === aProperty.priority) { + return; + } + + aProperty.value = aValue; + aProperty.priority = aPriority; + this.applyProperties(null, aProperty.name); + }, + + /** + * Just sets the value and priority of a property, in order to preview its + * effect on the content document. + * + * @param {TextProperty} aProperty + * The property which value will be previewed + * @param {String} aValue + * The value to be used for the preview + * @param {String} aPriority + * The property's priority (either "important" or an empty string). + */ + previewPropertyValue: function(aProperty, aValue, aPriority) { + let modifications = this.style.startModifyingProperties(); + modifications.setProperty(aProperty.name, aValue, aPriority); + modifications.apply(); + }, + + /** + * Disables or enables given TextProperty. + * + * @param {TextProperty} aProperty + * The property to enable/disable + * @param {Boolean} aValue + */ + setPropertyEnabled: function(aProperty, aValue) { + aProperty.enabled = !!aValue; + let modifications = this.style.startModifyingProperties(); + if (!aProperty.enabled) { + modifications.removeProperty(aProperty.name); + } + this.applyProperties(modifications); + }, + + /** + * Remove a given TextProperty from the rule and update the rule + * accordingly. + * + * @param {TextProperty} aProperty + * The property to be removed + */ + removeProperty: function(aProperty) { + this.textProps = this.textProps.filter(function(prop) prop != aProperty); + let modifications = this.style.startModifyingProperties(); + modifications.removeProperty(aProperty.name); + // Need to re-apply properties in case removing this TextProperty + // exposes another one. + this.applyProperties(modifications); + }, + + /** + * Get the list of TextProperties from the style. Needs + * to parse the style's cssText. + */ + _getTextProperties: function() { + let textProps = []; + let store = this.elementStyle.store; + let props = parseDeclarations(this.style.cssText); + for (let prop of props) { + let name = prop.name; + if (this.inherited && !domUtils.isInheritedProperty(name)) { + continue; + } + let value = store.userProperties.getProperty(this.style, name, prop.value); + let textProp = new TextProperty(this, name, value, prop.priority); + textProps.push(textProp); + } + + return textProps; + }, + + /** + * Return the list of disabled properties from the store for this rule. + */ + _getDisabledProperties: function() { + 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(aOptions) { + this.matchedSelectors = aOptions.matchedSelectors || []; + 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(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; + }, + + /** + * Jump between editable properties in the UI. Will begin editing the next + * name, if possible. If this is the last element in the set, then begin + * editing the previous value. If this is the *only* element in the set, + * then settle for focusing the new property editor. + * + * @param {TextProperty} aTextProperty + * The text property that will be left to focus on a sibling. + * + */ + editClosestTextProperty: function(aTextProperty) { + let index = this.textProps.indexOf(aTextProperty); + let previous = false; + + // If this is the last element, move to the previous instead of next + if (index === this.textProps.length - 1) { + index = index - 1; + previous = true; + } + else { + index = index + 1; + } + + let nextProp = this.textProps[index]; + + // If possible, begin editing the next name or previous value. + // Otherwise, settle for focusing the new property element. + if (nextProp) { + if (previous) { + nextProp.editor.valueSpan.click(); + } else { + nextProp.editor.nameSpan.click(); + } + } else { + aTextProperty.rule.editor.closeBrace.focus(); + } + } +}; + +/** + * 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() { + if (this.editor) { + this.editor.update(); + } + }, + + /** + * Update the list of computed properties for this text property. + */ + updateComputed: function() { + 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(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(aValue, aPriority) { + this.rule.setPropertyValue(this, aValue, aPriority); + this.updateEditor(); + }, + + setName: function(aName) { + this.rule.setPropertyName(this, aName); + this.updateEditor(); + }, + + setEnabled: function(aValue) { + this.rule.setPropertyEnabled(this, aValue); + this.updateEditor(); + }, + + remove: function() { + 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 {Inspector} aInspector + * @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 {PageStyleFront} aPageStyle + * The PageStyleFront for communicating with the remote server. + * @constructor + */ +function CssRuleView(aInspector, aDoc, aStore, aPageStyle) { + this.inspector = aInspector; + this.doc = aDoc; + this.store = aStore || {}; + this.pageStyle = aPageStyle; + this.element = this.doc.createElementNS(HTML_NS, "div"); + this.element.className = "ruleview devtools-monospace"; + this.element.flex = 1; + + this._outputParser = new OutputParser(); + + this._buildContextMenu = this._buildContextMenu.bind(this); + this._contextMenuUpdate = this._contextMenuUpdate.bind(this); + this._onSelectAll = this._onSelectAll.bind(this); + this._onCopy = this._onCopy.bind(this); + this._onToggleOrigSources = this._onToggleOrigSources.bind(this); + + this.element.addEventListener("copy", this._onCopy); + + this._handlePrefChange = this._handlePrefChange.bind(this); + gDevTools.on("pref-changed", this._handlePrefChange); + + this._onSourcePrefChanged = this._onSourcePrefChanged.bind(this); + this._prefObserver = new PrefObserver("devtools."); + this._prefObserver.on(PREF_ORIG_SOURCES, this._onSourcePrefChanged); + + let options = { + autoSelect: true, + theme: "auto" + }; + this.popup = new AutocompletePopup(aDoc.defaultView.parent.document, options); + + // Create a tooltip for previewing things in the rule view (images for now) + this.previewTooltip = new Tooltip(this.inspector.panelDoc); + this.previewTooltip.startTogglingOnHover(this.element, + this._onTooltipTargetHover.bind(this)); + + // Also create a more complex tooltip for editing colors with the spectrum + // color picker + this.colorPicker = new SwatchColorPickerTooltip(this.inspector.panelDoc); + + this._buildContextMenu(); + this._showEmpty(); +} + +exports.CssRuleView = CssRuleView; + +CssRuleView.prototype = { + // The element that we're inspecting. + _viewedElement: null, + + /** + * Build the context menu. + */ + _buildContextMenu: function() { + let doc = this.doc.defaultView.parent.document; + + this._contextmenu = doc.createElementNS(XUL_NS, "menupopup"); + this._contextmenu.addEventListener("popupshowing", this._contextMenuUpdate); + this._contextmenu.id = "rule-view-context-menu"; + + this.menuitemSelectAll = createMenuItem(this._contextmenu, { + label: "ruleView.contextmenu.selectAll", + accesskey: "ruleView.contextmenu.selectAll.accessKey", + command: this._onSelectAll + }); + this.menuitemCopy = createMenuItem(this._contextmenu, { + label: "ruleView.contextmenu.copy", + accesskey: "ruleView.contextmenu.copy.accessKey", + command: this._onCopy + }); + this.menuitemSources= createMenuItem(this._contextmenu, { + label: "ruleView.contextmenu.showOrigSources", + accesskey: "ruleView.contextmenu.showOrigSources.accessKey", + command: this._onToggleOrigSources + }); + + let popupset = doc.documentElement.querySelector("popupset"); + if (!popupset) { + popupset = doc.createElementNS(XUL_NS, "popupset"); + doc.documentElement.appendChild(popupset); + } + + popupset.appendChild(this._contextmenu); + }, + + /** + * Which type of hover-tooltip should be shown for the given element? + * This depends on the element: does it contain an image URL, a CSS transform, + * a font-family, ... + * @param {DOMNode} el The element to test + * @return {String} The type of hover-tooltip + */ + _getHoverTooltipTypeForTarget: function(el) { + let prop = el.textProperty; + + // Test for css transform + if (prop && prop.name === "transform") { + return "transform"; + } + + // Test for image + let isUrl = el.classList.contains("theme-link") && + el.parentNode.classList.contains("ruleview-propertyvalue"); + if (this.inspector.hasUrlToImageDataResolver && isUrl) { + return "image"; + } + + // Test for font-family + let propertyRoot = el.parentNode; + let propertyNameNode = propertyRoot.querySelector(".ruleview-propertyname"); + if (!propertyNameNode) { + propertyRoot = propertyRoot.parentNode; + propertyNameNode = propertyRoot.querySelector(".ruleview-propertyname"); + } + let propertyName; + if (propertyNameNode) { + propertyName = propertyNameNode.textContent; + } + if (propertyName === "font-family" && el.classList.contains("ruleview-propertyvalue")) { + return "font"; + } + }, + + /** + * Executed by the tooltip when the pointer hovers over an element of the view. + * Used to decide whether the tooltip should be shown or not and to actually + * put content in it. + * Checks if the hovered target is a css value we support tooltips for. + * @param {DOMNode} target + * @return {Boolean|Promise} Either a boolean or a promise, used by the + * Tooltip class to wait for the content to be put in the tooltip and finally + * decide whether or not the tooltip should be shown. + */ + _onTooltipTargetHover: function(target) { + let tooltipType = this._getHoverTooltipTypeForTarget(target); + if (!tooltipType) { + return false; + } + + if (this.colorPicker.tooltip.isShown()) { + this.colorPicker.revert(); + this.colorPicker.hide(); + } + + if (tooltipType === "transform") { + return this.previewTooltip.setCssTransformContent(target.textProperty.value, + this.pageStyle, this._viewedElement); + } + if (tooltipType === "image") { + let prop = target.parentNode.textProperty; + let dim = Services.prefs.getIntPref("devtools.inspector.imagePreviewTooltipSize"); + let uri = CssLogic.getBackgroundImageUriFromProperty(prop.value, prop.rule.domRule.href); + return this.previewTooltip.setRelativeImageContent(uri, this.inspector.inspector, dim); + } + if (tooltipType === "font") { + this.previewTooltip.setFontFamilyContent(target.textContent); + return true; + } + + return false; + }, + + /** + * Update the context menu. This means enabling or disabling menuitems as + * appropriate. + */ + _contextMenuUpdate: function() { + let win = this.doc.defaultView; + + // Copy selection. + let selection = win.getSelection(); + let copy; + + if (selection.toString()) { + // Panel text selected + copy = true; + } else if (selection.anchorNode) { + // input type="text" + let { selectionStart, selectionEnd } = this.doc.popupNode; + + if (isFinite(selectionStart) && isFinite(selectionEnd) && + selectionStart !== selectionEnd) { + copy = true; + } + } else { + // No text selected, disable copy. + copy = false; + } + + this.menuitemCopy.disabled = !copy; + + let label = "ruleView.contextmenu.showOrigSources"; + if (Services.prefs.getBoolPref(PREF_ORIG_SOURCES)) { + label = "ruleView.contextmenu.showCSSSources"; + } + this.menuitemSources.setAttribute("label", + _strings.GetStringFromName(label)); + + let accessKey = label + ".accessKey"; + this.menuitemSources.setAttribute("accesskey", + _strings.GetStringFromName(accessKey)); + }, + + /** + * Select all text. + */ + _onSelectAll: function() { + let win = this.doc.defaultView; + let selection = win.getSelection(); + + selection.selectAllChildren(this.doc.documentElement); + }, + + /** + * Copy selected text from the rule view. + * + * @param {Event} event + * The event object. + */ + _onCopy: function(event) { + try { + let target = event.target; + let text; + + if (event.target.nodeName === "menuitem") { + target = this.doc.popupNode; + } + + if (target.nodeName == "input") { + let start = Math.min(target.selectionStart, target.selectionEnd); + let end = Math.max(target.selectionStart, target.selectionEnd); + let count = end - start; + text = target.value.substr(start, count); + } else { + let win = this.doc.defaultView; + let selection = win.getSelection(); + + text = selection.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, ""); + } + + clipboardHelper.copyString(text, this.doc); + event.preventDefault(); + } catch(e) { + console.error(e); + } + }, + + /** + * Toggle the original sources pref. + */ + _onToggleOrigSources: function() { + let isEnabled = Services.prefs.getBoolPref(PREF_ORIG_SOURCES); + Services.prefs.setBoolPref(PREF_ORIG_SOURCES, !isEnabled); + }, + + setPageStyle: function(aPageStyle) { + this.pageStyle = aPageStyle; + }, + + /** + * Return {bool} true if the rule view currently has an input editor visible. + */ + get isEditing() { + return this.element.querySelectorAll(".styleinspector-propertyeditor").length > 0 + || this.colorPicker.tooltip.isShown(); + }, + + _handlePrefChange: function(event, data) { + if (data.pref == "devtools.defaultColorUnit") { + let element = this._viewedElement; + this._viewedElement = null; + this.highlight(element); + } + }, + + _onSourcePrefChanged: function() { + if (this.menuitemSources) { + let isEnabled = Services.prefs.getBoolPref(PREF_ORIG_SOURCES); + this.menuitemSources.setAttribute("checked", isEnabled); + } + + // update text of source links + for (let rule of this._elementStyle.rules) { + if (rule.editor) { + rule.editor.updateSourceLink(); + } + } + }, + + destroy: function() { + this.clear(); + + gDummyPromise = null; + gDevTools.off("pref-changed", this._handlePrefChange); + + this._prefObserver.off(PREF_ORIG_SOURCES, this._onSourcePrefChanged); + this._prefObserver.destroy(); + + this.element.removeEventListener("copy", this._onCopy); + delete this._onCopy; + + delete this._outputParser; + + // Remove context menu + if (this._contextmenu) { + // Destroy the Select All menuitem. + this.menuitemSelectAll.removeEventListener("command", this._onSelectAll); + this.menuitemSelectAll = null; + + // Destroy the Copy menuitem. + this.menuitemCopy.removeEventListener("command", this._onCopy); + this.menuitemCopy = null; + + this.menuitemSources.removeEventListener("command", this._onToggleOrigSources); + this.menuitemSources = null; + + // Destroy the context menu. + this._contextmenu.removeEventListener("popupshowing", this._contextMenuUpdate); + this._contextmenu.parentNode.removeChild(this._contextmenu); + this._contextmenu = null; + } + + // We manage the popupNode ourselves so we also need to destroy it. + this.doc.popupNode = null; + + this.previewTooltip.stopTogglingOnHover(this.element); + this.previewTooltip.destroy(); + this.colorPicker.destroy(); + + if (this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + + if (this.elementStyle) { + this.elementStyle.destroy(); + } + + this.popup.destroy(); + }, + + /** + * Update the highlighted element. + * + * @param {NodeActor} aElement + * The node whose style rules we'll inspect. + */ + highlight: function(aElement) { + if (this._viewedElement === aElement) { + return promise.resolve(undefined); + } + + this.clear(); + + if (this._elementStyle) { + delete this._elementStyle; + } + + this._viewedElement = aElement; + if (!this._viewedElement) { + this._showEmpty(); + return promise.resolve(undefined); + } + + this._elementStyle = new ElementStyle(aElement, this.store, this.pageStyle); + return this._elementStyle.init().then(() => { + return this._populate(); + }).then(() => { + // A new node may already be selected, in which this._elementStyle will + // be null. + if (this._elementStyle) { + this._elementStyle.onChanged = () => { + this._changed(); + }; + } + }).then(null, console.error); + }, + + /** + * Update the rules for the currently highlighted element. + */ + nodeChanged: function() { + // Ignore refreshes during editing or when no element is selected. + if (this.isEditing || !this._elementStyle) { + return; + } + + this._clearRules(); + + // Repopulate the element style. + this._populate(); + }, + + _populate: function() { + let elementStyle = this._elementStyle; + return this._elementStyle.populate().then(() => { + if (this._elementStyle != elementStyle) { + return; + } + this._createEditors(); + + // Notify anyone that cares that we refreshed. + var evt = this.doc.createEvent("Events"); + evt.initEvent("CssRuleViewRefreshed", true, false); + this.element.dispatchEvent(evt); + return undefined; + }).then(null, promiseWarn); + }, + + /** + * Show the user that the rule view has no node selected. + */ + _showEmpty: function() { + if (this.doc.getElementById("noResults") > 0) { + return; + } + + createChild(this.element, "div", { + id: "noResults", + textContent: CssLogic.l10n("rule.empty") + }); + }, + + /** + * Clear the rules. + */ + _clearRules: function() { + while (this.element.hasChildNodes()) { + this.element.removeChild(this.element.lastChild); + } + }, + + /** + * Clear the rule view. + */ + clear: function() { + this._clearRules(); + this._viewedElement = null; + this._elementStyle = null; + + this.previewTooltip.hide(); + this.colorPicker.hide(); + }, + + /** + * Called when the user has made changes to the ElementStyle. + * Emits an event that clients can listen to. + */ + _changed: function() { + var evt = this.doc.createEvent("Events"); + evt.initEvent("CssRuleViewChanged", true, false); + this.element.dispatchEvent(evt); + }, + + /** + * Text for header that shows above rules for this element + */ + get selectedElementLabel() { + if (this._selectedElementLabel) { + return this._selectedElementLabel; + } + this._selectedElementLabel = CssLogic.l10n("rule.selectedElement"); + return this._selectedElementLabel; + }, + + /** + * Text for header that shows above rules for pseudo elements + */ + get pseudoElementLabel() { + if (this._pseudoElementLabel) { + return this._pseudoElementLabel; + } + this._pseudoElementLabel = CssLogic.l10n("rule.pseudoElement"); + return this._pseudoElementLabel; + }, + + togglePseudoElementVisibility: function(value) { + this._showPseudoElements = !!value; + let isOpen = this.showPseudoElements; + + Services.prefs.setBoolPref("devtools.inspector.show_pseudo_elements", + isOpen); + + this.element.classList.toggle("show-pseudo-elements", isOpen); + + if (this.pseudoElementTwisty) { + if (isOpen) { + this.pseudoElementTwisty.setAttribute("open", "true"); + } + else { + this.pseudoElementTwisty.removeAttribute("open"); + } + } + }, + + get showPseudoElements() { + if (this._showPseudoElements === undefined) { + this._showPseudoElements = + Services.prefs.getBoolPref("devtools.inspector.show_pseudo_elements"); + } + return this._showPseudoElements; + }, + + _getRuleViewHeaderClassName: function(isPseudo) { + let baseClassName = "theme-gutter ruleview-header"; + return isPseudo ? baseClassName + " ruleview-expandable-header" : baseClassName; + }, + + /** + * Creates editor UI for each of the rules in _elementStyle. + */ + _createEditors: function() { + // Run through the current list of rules, attaching + // their editors in order. Create editors if needed. + let lastInheritedSource = ""; + let seenPseudoElement = false; + let seenNormalElement = false; + + for (let rule of this._elementStyle.rules) { + if (rule.domRule.system) { + continue; + } + + // Only print header for this element if there are pseudo elements + if (seenPseudoElement && !seenNormalElement && !rule.pseudoElement) { + seenNormalElement = true; + let div = this.doc.createElementNS(HTML_NS, "div"); + div.className = this._getRuleViewHeaderClassName(); + div.textContent = this.selectedElementLabel; + this.element.appendChild(div); + } + + let inheritedSource = rule.inheritedSource; + if (inheritedSource != lastInheritedSource) { + let div = this.doc.createElementNS(HTML_NS, "div"); + div.className = this._getRuleViewHeaderClassName(); + div.textContent = inheritedSource; + lastInheritedSource = inheritedSource; + this.element.appendChild(div); + } + + if (!seenPseudoElement && rule.pseudoElement) { + seenPseudoElement = true; + + let div = this.doc.createElementNS(HTML_NS, "div"); + div.className = this._getRuleViewHeaderClassName(true); + div.textContent = this.pseudoElementLabel; + div.addEventListener("dblclick", () => { + this.togglePseudoElementVisibility(!this.showPseudoElements); + }, false); + + let twisty = this.pseudoElementTwisty = + this.doc.createElementNS(HTML_NS, "span"); + twisty.className = "ruleview-expander theme-twisty"; + twisty.addEventListener("click", () => { + this.togglePseudoElementVisibility(!this.showPseudoElements); + }, false); + + div.insertBefore(twisty, div.firstChild); + this.element.appendChild(div); + } + + if (!rule.editor) { + rule.editor = new RuleEditor(this, rule); + } + + this.element.appendChild(rule.editor.element); + } + + this.togglePseudoElementVisibility(this.showPseudoElements); + } +}; + +/** + * 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._onNewProperty = this._onNewProperty.bind(this); + this._newPropertyDestroy = this._newPropertyDestroy.bind(this); + + this._create(); +} + +RuleEditor.prototype = { + _create: function() { + this.element = this.doc.createElementNS(HTML_NS, "div"); + this.element.className = "ruleview-rule theme-separator"; + this.element._ruleEditor = this; + if (this.rule.pseudoElement) { + this.element.classList.add("ruleview-rule-pseudo-element"); + } + + // 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 theme-link" + }); + source.addEventListener("click", function() { + let rule = this.rule.domRule; + let evt = this.doc.createEvent("CustomEvent"); + evt.initCustomEvent("CssRuleViewCSSLinkClicked", true, false, { + rule: rule, + }); + this.element.dispatchEvent(evt); + }.bind(this)); + let sourceLabel = this.doc.createElementNS(XUL_NS, "label"); + sourceLabel.setAttribute("crop", "center"); + sourceLabel.classList.add("source-link-label"); + source.appendChild(sourceLabel); + + this.updateSourceLink(); + + let code = createChild(this.element, "div", { + class: "ruleview-code" + }); + + let header = createChild(code, "div", {}); + + this.selectorText = createChild(header, "span", { + class: "ruleview-selector theme-fg-color3" + }); + + 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.element.addEventListener("mousedown", function() { + this.doc.defaultView.focus(); + }.bind(this), false); + + this.element.addEventListener("contextmenu", event => { + try { + // In the sidebar we do not have this.doc.popupNode so we need to save + // the node ourselves. + this.doc.popupNode = event.explicitOriginalTarget; + let win = this.doc.defaultView; + win.focus(); + + this.ruleView._contextmenu.openPopupAtScreen( + event.screenX, event.screenY, true); + + } catch(e) { + console.error(e); + } + }, 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({ element: this.closeBrace }, (aElement) => { + this.newProperty(); + }); + }, + + updateSourceLink: function RuleEditor_updateSourceLink() + { + let sourceLabel = this.element.querySelector(".source-link-label"); + sourceLabel.setAttribute("value", this.rule.title); + + let sourceHref = (this.rule.sheet && this.rule.sheet.href) ? + this.rule.sheet.href : this.rule.title; + + sourceLabel.setAttribute("tooltiptext", sourceHref); + + let showOrig = Services.prefs.getBoolPref(PREF_ORIG_SOURCES); + if (showOrig && this.rule.domRule.type != ELEMENT_STYLE) { + this.rule.getOriginalSourceStrings().then((strings) => { + sourceLabel.setAttribute("value", strings.short); + sourceLabel.setAttribute("tooltiptext", strings.full); + }) + } + }, + + /** + * Update the rule editor with the contents of the rule. + */ + populate: function() { + // Clear out existing viewers. + while (this.selectorText.hasChildNodes()) { + this.selectorText.removeChild(this.selectorText.lastChild); + } + + // If selector text comes from a css rule, highlight selectors that + // actually match. For custom selector text (such as for the 'element' + // style, just show the text directly. + if (this.rule.domRule.type === ELEMENT_STYLE) { + this.selectorText.textContent = this.rule.selectorText; + } else { + this.rule.domRule.selectors.forEach((selector, i) => { + if (i != 0) { + createChild(this.selectorText, "span", { + class: "ruleview-selector-separator", + textContent: ", " + }); + } + let cls; + if (this.rule.matchedSelectors.indexOf(selector) > -1) { + cls = "ruleview-selector-matched"; + } else { + cls = "ruleview-selector-unmatched"; + } + createChild(this.selectorText, "span", { + class: cls, + textContent: selector + }); + }); + } + + for (let prop of this.rule.textProps) { + if (!prop.editor) { + let editor = new TextPropertyEditor(this, prop); + this.propertyList.appendChild(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. + * @param {TextProperty} aSiblingProp + * Optional, property next to which the new property will be added. + * @return {TextProperty} + * The new property + */ + addProperty: function(aName, aValue, aPriority, aSiblingProp) { + let prop = this.rule.createProperty(aName, aValue, aPriority, aSiblingProp); + let index = this.rule.textProps.indexOf(prop); + let editor = new TextPropertyEditor(this, prop); + + // Insert this node before the DOM node that is currently at its new index + // in the property list. There is currently one less node in the DOM than + // in the property list, so this causes it to appear after aSiblingProp. + // If there is no node at its index, as is the case where this is the last + // node being inserted, then this behaves as appendChild. + this.propertyList.insertBefore(editor.element, + this.propertyList.children[index]); + + return prop; + }, + + /** + * Programatically add a list of new properties to the rule. Focus the UI + * to the proper location after adding (either focus the value on the + * last property if it is empty, or create a new property and focus it). + * + * @param {Array} aProperties + * Array of properties, which are objects with this signature: + * { + * name: {string}, + * value: {string}, + * priority: {string} + * } + * @param {TextProperty} aSiblingProp + * Optional, the property next to which all new props should be added. + */ + addProperties: function(aProperties, aSiblingProp) { + if (!aProperties || !aProperties.length) { + return; + } + + let lastProp = aSiblingProp; + for (let p of aProperties) { + lastProp = this.addProperty(p.name, p.value, p.priority, lastProp); + } + + // Either focus on the last value if incomplete, or start a new one. + if (lastProp && lastProp.value.trim() === "") { + lastProp.editor.valueSpan.click(); + } else { + this.newProperty(); + } + }, + + /** + * 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() { + // 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" + }); + + this.multipleAddedProperties = null; + + this.editor = new InplaceEditor({ + element: this.newPropSpan, + done: this._onNewProperty, + destroy: this._newPropertyDestroy, + advanceChars: ":", + contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY, + popup: this.ruleView.popup + }); + + // Auto-close the input if multiple rules get pasted into new property. + this.editor.input.addEventListener("paste", + blurOnMultipleProperties, false); + }, + + /** + * Called when the new property input has been dismissed. + * + * @param {string} aValue + * The value in the editor. + * @param {bool} aCommit + * True if the value should be committed. + */ + _onNewProperty: function(aValue, aCommit) { + if (!aValue || !aCommit) { + return; + } + + // parseDeclarations allows for name-less declarations, but in the present + // case, we're creating a new declaration, it doesn't make sense to accept + // these entries + this.multipleAddedProperties = parseDeclarations(aValue).filter(d => d.name); + + // Blur the editor field now and deal with adding declarations later when + // the field gets destroyed (see _newPropertyDestroy) + this.editor.input.blur(); + }, + + /** + * Called when the new property editor is destroyed. + * This is where the properties (type TextProperty) are actually being + * added, since we want to wait until after the inplace editor `destroy` + * event has been fired to keep consistent UI state. + */ + _newPropertyDestroy: function() { + // 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; + + // If properties were added, we want to focus the proper element. + // If the last new property has no value, focus the value on it. + // Otherwise, start a new property and focus that field. + if (this.multipleAddedProperties && this.multipleAddedProperties.length) { + this.addProperties(this.multipleAddedProperties); + } + } +}; + +/** + * 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.ruleEditor = aRuleEditor; + this.doc = this.ruleEditor.doc; + this.popup = this.ruleEditor.ruleView.popup; + this.prop = aProperty; + this.prop.editor = this; + this.browserWindow = this.doc.defaultView.top; + this.removeOnRevert = this.prop.value === ""; + + 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._onValidate = throttle(this._previewValue, 10, this); + this.update = this.update.bind(this); + + this._create(); + this.update(); +} + +TextPropertyEditor.prototype = { + /** + * Boolean indicating if the name or value is being currently edited. + */ + get editing() { + return !!(this.nameSpan.inplaceEditor || this.valueSpan.inplaceEditor || + this.ruleEditor.ruleView.colorPicker.tooltip.isShown() || + this.ruleEditor.ruleView.colorPicker.eyedropperOpen) || + this.popup.isOpen; + }, + + /** + * Create the property editor's DOM. + */ + _create: function() { + 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, "div", { + class: "ruleview-enableproperty theme-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 theme-twisty" + }); + this.expander.addEventListener("click", this._onExpandClicked, true); + + this.nameContainer = createChild(this.element, "span", { + class: "ruleview-namecontainer" + }); + this.nameContainer.addEventListener("click", (aEvent) => { + // Clicks within the name shouldn't propagate any further. + aEvent.stopPropagation(); + if (aEvent.target === propertyContainer) { + this.nameSpan.click(); + } + }, false); + + // Property name, editable when focused. Property name + // is committed when the editor is unfocused. + this.nameSpan = createChild(this.nameContainer, "span", { + class: "ruleview-propertyname theme-fg-color5", + tabindex: "0", + }); + + editableField({ + start: this._onStartEditing, + element: this.nameSpan, + done: this._onNameDone, + destroy: this.update, + advanceChars: ':', + contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY, + popup: this.popup + }); + + // Auto blur name field on multiple CSS rules get pasted in. + this.nameContainer.addEventListener("paste", + blurOnMultipleProperties, false); + + 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", (aEvent) => { + // Clicks within the value shouldn't propagate any further. + aEvent.stopPropagation(); + + if (aEvent.target === propertyContainer) { + this.valueSpan.click(); + } + }, 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 theme-fg-color1", + tabindex: "0", + }); + + this.valueSpan.addEventListener("click", (event) => { + let target = event.target; + + if (target.nodeName === "a") { + event.stopPropagation(); + event.preventDefault(); + this.browserWindow.openUILinkIn(target.href, "tab"); + } + }, false); + + // Storing the TextProperty on the valuespan for easy access + // (for instance by the tooltip) + this.valueSpan.textProperty = this.prop; + + // 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", { + class: "ruleview-warning", + hidden: "", + 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", + }); + + editableField({ + start: this._onStartEditing, + element: this.valueSpan, + done: this._onValueDone, + destroy: this.update, + validate: this._onValidate, + advanceChars: ';', + contentType: InplaceEditor.CONTENT_TYPES.CSS_VALUE, + property: this.prop, + popup: this.popup + }); + }, + + /** + * Get the path from which to resolve requests for this + * rule's stylesheet. + * @return {string} the stylesheet's href. + */ + get sheetHref() { + let domRule = this.prop.rule.domRule; + if (domRule) { + return domRule.href || domRule.nodeHref; + } + }, + + /** + * Get the URI from which to resolve relative requests for + * this rule's stylesheet. + * @return {nsIURI} A URI based on the the stylesheet's href. + */ + get sheetURI() { + if (this._sheetURI === undefined) { + if (this.sheetHref) { + this._sheetURI = IOService.newURI(this.sheetHref, null, null); + } else { + this._sheetURI = null; + } + } + + return this._sheetURI; + }, + + /** + * Resolve a URI based on the rule stylesheet + * @param {string} relativePath the path to resolve + * @return {string} the resolved path. + */ + resolveURI: function(relativePath) { + if (this.sheetURI) { + relativePath = this.sheetURI.resolve(relativePath); + } + return relativePath; + }, + + /** + * Check the property value to find an external resource (if any). + * @return {string} the URI in the property value, or null if there is no match. + */ + getResourceURI: function() { + let val = this.prop.value; + let uriMatch = CSS_RESOURCE_RE.exec(val); + let uri = null; + + if (uriMatch && uriMatch[1]) { + uri = uriMatch[1]; + } + + return uri; + }, + + /** + * Populate the span based on changes to the TextProperty. + */ + update: function() { + if (this.prop.enabled) { + this.enable.style.removeProperty("visibility"); + this.enable.setAttribute("checked", ""); + } else { + this.enable.style.visibility = "visible"; + this.enable.removeAttribute("checked"); + } + + this.warning.hidden = this.editing || this.isValid(); + + if ((this.prop.overridden || !this.prop.enabled) && !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; + } + + 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"); + } + + let swatchClass = "ruleview-colorswatch"; + let outputParser = this.ruleEditor.ruleView._outputParser; + let frag = outputParser.parseCssProperty(name, val, { + colorSwatchClass: swatchClass, + colorClass: "ruleview-color", + defaultColorType: !propDirty, + urlClass: "theme-link", + baseURI: this.sheetURI + }); + this.valueSpan.innerHTML = ""; + this.valueSpan.appendChild(frag); + + // Attach the color picker tooltip to the color swatches + this._swatchSpans = this.valueSpan.querySelectorAll("." + swatchClass); + for (let span of this._swatchSpans) { + // Capture the original declaration value to be able to revert later + let originalValue = this.valueSpan.textContent; + // Adding this swatch to the list of swatches our colorpicker knows about + this.ruleEditor.ruleView.colorPicker.addSwatch(span, { + onPreview: () => this._previewValue(this.valueSpan.textContent), + onCommit: () => this._applyNewValue(this.valueSpan.textContent), + onRevert: () => this._applyNewValue(originalValue) + }); + } + + // Populate the computed styles. + this._updateComputed(); + }, + + _onStartEditing: function() { + this.element.classList.remove("ruleview-overridden"); + this._previewValue(this.prop.value); + }, + + /** + * Populate the list of computed styles. + */ + _updateComputed: function () { + // 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 theme-fg-color5", + textContent: computed.name + }); + appendText(li, ": "); + + let outputParser = this.ruleEditor.ruleView._outputParser; + let frag = outputParser.parseCssProperty( + computed.name, computed.value, { + colorSwatchClass: "ruleview-colorswatch", + urlClass: "theme-link", + baseURI: this.sheetURI + } + ); + + createChild(li, "span", { + class: "ruleview-propertyvalue theme-fg-color1", + child: frag + }); + + 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(aEvent) { + let checked = this.enable.hasAttribute("checked"); + if (checked) { + this.enable.removeAttribute("checked"); + } else { + this.enable.setAttribute("checked", ""); + } + this.prop.setEnabled(!checked); + aEvent.stopPropagation(); + }, + + /** + * Handles clicks on the computed property expander. + */ + _onExpandClicked: function(aEvent) { + this.computed.classList.toggle("styleinspector-open"); + if (this.computed.classList.contains("styleinspector-open")) { + this.expander.setAttribute("open", "true"); + } else { + this.expander.removeAttribute("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(aValue, aCommit) { + if (aCommit) { + // Unlike the value editor, if a name is empty the entire property + // should always be removed. + if (aValue.trim() === "") { + this.remove(); + } else { + // Adding multiple rules inside of name field overwrites the current + // property with the first, then adds any more onto the property list. + let properties = parseDeclarations(aValue); + + if (properties.length) { + this.prop.setName(properties[0].name); + if (properties.length > 1) { + this.prop.setValue(properties[0].value, properties[0].priority); + this.ruleEditor.addProperties(properties.slice(1), this.prop); + } + } + } + } + }, + + /** + * Remove property from style and the editors from DOM. + * Begin editing next available property. + */ + remove: function() { + if (this._swatchSpans && this._swatchSpans.length) { + for (let span of this._swatchSpans) { + this.ruleEditor.ruleView.colorPicker.removeSwatch(span); + } + } + + this.element.parentNode.removeChild(this.element); + this.ruleEditor.rule.editClosestTextProperty(this.prop); + this.valueSpan.textProperty = null; + this.prop.remove(); + }, + + /** + * 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 {bool} aCommit + * True if the change should be applied. + */ + _onValueDone: function(aValue, aCommit) { + if (!aCommit) { + // A new property should be removed when escape is pressed. + if (this.removeOnRevert) { + this.remove(); + } else { + this.prop.setValue(this.committed.value, this.committed.priority); + } + return; + } + + let {propertiesToAdd,firstValue} = this._getValueAndExtraProperties(aValue); + + // First, set this property value (common case, only modified a property) + let val = parseSingleValue(firstValue); + this.prop.setValue(val.value, val.priority); + this.removeOnRevert = false; + this.committed.value = this.prop.value; + this.committed.priority = this.prop.priority; + + // If needed, add any new properties after this.prop. + this.ruleEditor.addProperties(propertiesToAdd, this.prop); + + // If the name or value is not actively being edited, and the value is + // empty, then remove the whole property. + // A timeout is used here to accurately check the state, since the inplace + // editor `done` and `destroy` events fire before the next editor + // is focused. + if (val.value.trim() === "") { + setTimeout(() => { + if (!this.editing) { + this.remove(); + } + }, 0); + } + }, + + /** + * Parse a value string and break it into pieces, starting with the + * first value, and into an array of additional properties (if any). + * + * Example: Calling with "red; width: 100px" would return + * { firstValue: "red", propertiesToAdd: [{ name: "width", value: "100px" }] } + * + * @param {string} aValue + * The string to parse + * @return {object} An object with the following properties: + * firstValue: A string containing a simple value, like + * "red" or "100px!important" + * propertiesToAdd: An array with additional properties, following the + * parseDeclarations format of {name,value,priority} + */ + _getValueAndExtraProperties: function(aValue) { + // The inplace editor will prevent manual typing of multiple properties, + // but we need to deal with the case during a paste event. + // Adding multiple properties inside of value editor sets value with the + // first, then adds any more onto the property list (below this property). + let firstValue = aValue; + let propertiesToAdd = []; + + let properties = parseDeclarations(aValue); + + // Check to see if the input string can be parsed as multiple properties + if (properties.length) { + // Get the first property value (if any), and any remaining properties (if any) + if (!properties[0].name && properties[0].value) { + firstValue = properties[0].value; + propertiesToAdd = properties.slice(1); + } + // In some cases, the value could be a property:value pair itself. + // Join them as one value string and append potentially following properties + else if (properties[0].name && properties[0].value) { + firstValue = properties[0].name + ": " + properties[0].value; + propertiesToAdd = properties.slice(1); + } + } + + return { + propertiesToAdd: propertiesToAdd, + firstValue: firstValue + }; + }, + + _applyNewValue: function(aValue) { + let val = parseSingleValue(aValue); + + this.prop.setValue(val.value, val.priority); + this.removeOnRevert = false; + this.committed.value = this.prop.value; + this.committed.priority = this.prop.priority; + }, + + /** + * Live preview this property, without committing changes. + * @param {string} aValue The value to set the current property to. + */ + _previewValue: function(aValue) { + // Since function call is throttled, we need to make sure we are still editing + if (!this.editing) { + return; + } + + let val = parseSingleValue(aValue); + this.ruleEditor.rule.previewPropertyValue(this.prop, val.value, val.priority); + }, + + /** + * Validate this property. Does it make sense for this value to be assigned + * to this property name? This does not apply the property value + * + * @param {string} [aValue] + * The property value used for validation. + * Defaults to the current value for this.prop + * + * @return {bool} true if the property value is valid, false otherwise. + */ + isValid: function(aValue) { + let name = this.prop.name; + let value = typeof aValue == "undefined" ? this.prop.value : aValue; + let val = parseSingleValue(value); + + let style = this.doc.createElementNS(HTML_NS, "div").style; + let prefs = Services.prefs; + + // We toggle output of errors whilst the user is typing a property value. + let prefVal = prefs.getBoolPref("layout.css.report_errors"); + prefs.setBoolPref("layout.css.report_errors", false); + + let validValue = false; + try { + style.setProperty(name, val.value, val.priority); + validValue = style.getPropertyValue(name) !== "" || val.value === ""; + } finally { + prefs.setBoolPref("layout.css.report_errors", prefVal); + } + return validValue; + } +}; + +/** + * Store of CSSStyleDeclarations mapped to properties that have been changed by + * the user. + */ +function UserProperties() { + this.map = new Map(); +} + +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} aDefault + * The value to return if the property is has been changed outside of + * the rule view. + * @return {string} + * The property value if it has previously been set by the user, null + * otherwise. + */ + getProperty: function(aStyle, aName, aDefault) { + let key = this.getKey(aStyle); + let entry = this.map.get(key, null); + + if (entry && aName in entry) { + let item = entry[aName]; + if (item != aDefault) { + delete entry[aName]; + return aDefault; + } + return item; + } + return aDefault; + }, + + /** + * 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} aUserValue + * The value of the property to set. + */ + setProperty: function(aStyle, aName, aUserValue) { + let key = this.getKey(aStyle); + let entry = this.map.get(key, null); + if (entry) { + entry[aName] = aUserValue; + } else { + let props = {}; + props[aName] = aUserValue; + this.map.set(key, 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(aStyle, aName) { + let key = this.getKey(aStyle); + let entry = this.map.get(key, null); + return !!entry && aName in entry; + }, + + getKey: function(aStyle) { + return aStyle.href + ":" + aStyle.line; + } +}; + +/** + * 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 if(attr === "child") { + elt.appendChild(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; +} + +function setTimeout() { + let window = Services.appShell.hiddenDOMWindow; + return window.setTimeout.apply(window, arguments); +} + +function clearTimeout() { + let window = Services.appShell.hiddenDOMWindow; + return window.clearTimeout.apply(window, arguments); +} + +function throttle(func, wait, scope) { + var timer = null; + return function() { + if(timer) { + clearTimeout(timer); + } + var args = arguments; + timer = setTimeout(function() { + timer = null; + func.apply(scope, args); + }, wait); + }; +} + +/** + * Event handler that causes a blur on the target if the input has + * multiple CSS properties as the value. + */ +function blurOnMultipleProperties(e) { + setTimeout(() => { + let props = parseDeclarations(e.target.value); + if (props.length > 1) { + e.target.blur(); + } + }, 0); +} + +/** + * Append a text node to an element. + */ +function appendText(aParent, aText) { + aParent.appendChild(aParent.ownerDocument.createTextNode(aText)); +} + +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://global/locale/devtools/styleinspector.properties"); +}); + +XPCOMUtils.defineLazyGetter(this, "domUtils", function() { + return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); +}); + +loader.lazyGetter(this, "AutocompletePopup", () => require("devtools/shared/autocomplete-popup").AutocompletePopup);