diff options
Diffstat (limited to 'devtools/client/inspector/rules/models/element-style.js')
-rw-r--r-- | devtools/client/inspector/rules/models/element-style.js | 818 |
1 files changed, 818 insertions, 0 deletions
diff --git a/devtools/client/inspector/rules/models/element-style.js b/devtools/client/inspector/rules/models/element-style.js new file mode 100644 index 0000000000..0de235fe96 --- /dev/null +++ b/devtools/client/inspector/rules/models/element-style.js @@ -0,0 +1,818 @@ +/* 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 Rule = require("resource://devtools/client/inspector/rules/models/rule.js"); +const UserProperties = require("resource://devtools/client/inspector/rules/models/user-properties.js"); +const { + style: { ELEMENT_STYLE }, +} = require("resource://devtools/shared/constants.js"); + +loader.lazyRequireGetter( + this, + "promiseWarn", + "resource://devtools/client/inspector/shared/utils.js", + true +); +loader.lazyRequireGetter( + this, + ["parseDeclarations", "parseNamedDeclarations", "parseSingleValue"], + "resource://devtools/shared/css/parsing-utils.js", + true +); +loader.lazyRequireGetter( + this, + "isCssVariable", + "resource://devtools/client/fronts/css-properties.js", + true +); + +const PREF_INACTIVE_CSS_ENABLED = "devtools.inspector.inactive.css.enabled"; + +/** + * ElementStyle is responsible for the following: + * Keeps track of which properties are overridden. + * Maintains a list of Rule objects for a given element. + */ +class ElementStyle { + /** + * @param {Element} element + * The element whose style we are viewing. + * @param {CssRuleView} ruleView + * The instance of the rule-view panel. + * @param {Object} store + * The ElementStyle can use this object to store metadata + * that might outlast the rule view, particularly the current + * set of disabled properties. + * @param {PageStyleFront} pageStyle + * Front for the page style actor that will be providing + * the style information. + * @param {Boolean} showUserAgentStyles + * Should user agent styles be inspected? + */ + constructor(element, ruleView, store, pageStyle, showUserAgentStyles) { + this.element = element; + this.ruleView = ruleView; + this.store = store || {}; + this.pageStyle = pageStyle; + this.pseudoElements = []; + this.showUserAgentStyles = showUserAgentStyles; + this.rules = []; + this.cssProperties = this.ruleView.cssProperties; + this.variablesMap = new Map(); + + // 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(); + } + } + + get unusedCssEnabled() { + if (!this._unusedCssEnabled) { + this._unusedCssEnabled = Services.prefs.getBoolPref( + PREF_INACTIVE_CSS_ENABLED, + false + ); + } + return this._unusedCssEnabled; + } + + destroy() { + if (this.destroyed) { + return; + } + + this.destroyed = true; + this.pseudoElements = []; + + for (const rule of this.rules) { + if (rule.editor) { + rule.editor.destroy(); + } + + rule.destroy(); + } + } + + /** + * Called by the Rule object when it has been changed through the + * setProperty* methods. + */ + _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. + * + * Returns a promise that will be resolved when the elementStyle is + * ready. + */ + populate() { + const populated = this.pageStyle + .getApplied(this.element, { + inherited: true, + matchedSelectors: true, + filter: this.showUserAgentStyles ? "ua" : undefined, + }) + .then(entries => { + if (this.destroyed || this.populated !== populated) { + return Promise.resolve(undefined); + } + + // Store the current list of rules (if any) during the population + // process. They will be reused if possible. + const existingRules = this.rules; + + this.rules = []; + + for (const entry of entries) { + this._maybeAddRule(entry, existingRules); + } + + // Store a list of all pseudo-element types found in the matching rules. + this.pseudoElements = this.rules + .filter(r => r.pseudoElement) + .map(r => r.pseudoElement); + + // Mark overridden computed styles. + this.onRuleUpdated(); + + this._sortRulesForPseudoElement(); + + // We're done with the previous list of rules. + for (const r of existingRules) { + if (r?.editor) { + r.editor.destroy(); + } + + r.destroy(); + } + + return undefined; + }) + .catch(e => { + // populate is often called after a setTimeout, + // the connection may already be closed. + if (this.destroyed) { + return Promise.resolve(undefined); + } + return promiseWarn(e); + }); + this.populated = populated; + return this.populated; + } + + /** + * Returns the Rule object of the given rule id. + * + * @param {String|null} id + * The id of the Rule object. + * @return {Rule|undefined} of the given rule id or undefined if it cannot be found. + */ + getRule(id) { + return id + ? this.rules.find(rule => rule.domRule.actorID === id) + : undefined; + } + + /** + * Get the font families in use by the element. + * + * Returns a promise that will be resolved to a list of CSS family + * names. The list might have duplicates. + */ + getUsedFontFamilies() { + return new Promise((resolve, reject) => { + this.ruleView.styleWindow.requestIdleCallback(async () => { + if (this.element.isDestroyed()) { + resolve([]); + return; + } + try { + const fonts = await this.pageStyle.getUsedFontFaces(this.element, { + includePreviews: false, + }); + resolve(fonts.map(font => font.CSSFamilyName)); + } catch (e) { + reject(e); + } + }); + }); + } + + /** + * Put pseudo elements in front of others. + */ + _sortRulesForPseudoElement() { + 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} options + * Options for creating the Rule, see the Rule constructor. + * @param {Array} existingRules + * Rules to reuse if possible. If a rule is reused, then it + * it will be deleted from this array. + * @return {Boolean} true if we added the rule. + */ + _maybeAddRule(options, existingRules) { + // If we've already included this domRule (for example, when a + // common selector is inherited), ignore it. + if ( + options.system || + (options.rule && this.rules.some(rule => rule.domRule === options.rule)) + ) { + return false; + } + + let rule = null; + + // If we're refreshing and the rule previously existed, reuse the + // Rule object. + if (existingRules) { + const ruleIndex = existingRules.findIndex(r => r.matches(options)); + if (ruleIndex >= 0) { + rule = existingRules[ruleIndex]; + rule.refresh(options); + existingRules.splice(ruleIndex, 1); + } + } + + // If this is a new rule, create its Rule object. + if (!rule) { + rule = new Rule(this, options); + } + + // Ignore inherited rules with no visible properties. + if (options.inherited && !rule.hasAnyVisibleProperties()) { + return false; + } + + this.rules.push(rule); + return true; + } + + /** + * Calls updateDeclarations with all supported pseudo elements + */ + onRuleUpdated() { + this.updateDeclarations(); + + // Update declarations for matching rules for pseudo-elements. + for (const pseudo of this.pseudoElements) { + this.updateDeclarations(pseudo); + } + } + + /** + * Go over all CSS rules matching the selected element and mark the CSS declarations + * (aka TextProperty instances) with an `overridden` Boolean flag if an earlier or + * higher priority declaration overrides it. Rules are already ordered by specificity. + * + * If a pseudo-element type is passed (ex: ::before, ::first-line, etc), + * restrict the operation only to declarations in rules matching that pseudo-element. + * + * At the end, update the declaration's view (TextPropertyEditor instance) so it relects + * the latest state. Use this opportunity to also trigger checks for the "inactive" + * state of the declaration (whether it has effect or not). + * + * @param {String} pseudo + * Optional pseudo-element for which to restrict marking CSS declarations as + * overridden. + */ + updateDeclarations(pseudo = "") { + // Gather all text properties applicable to the selected element or pseudo-element. + const textProps = this._getDeclarations(pseudo); + // Gather all the computed properties applied by those text properties. + let computedProps = []; + for (const textProp of textProps) { + computedProps = computedProps.concat(textProp.computed); + } + + // CSS Variables inherits from the normal element in case of pseudo element. + const variables = new Map(pseudo ? this.variablesMap.get("") : null); + + // 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. + const taken = {}; + for (const computedProp of computedProps) { + const earlier = taken[computedProp.name]; + + // Prevent -webkit-gradient from being selected after unchecking + // linear-gradient in this case: + // -moz-linear-gradient: ...; + // -webkit-linear-gradient: ...; + // linear-gradient: ...; + if (!computedProp.textProp.isValid()) { + computedProp.overridden = true; + continue; + } + + let overridden; + if ( + earlier && + computedProp.priority === "important" && + earlier.priority !== "important" && + // For !important only consider rules applying to the same parent node. + computedProp.textProp.rule.inherited == earlier.textProp.rule.inherited + ) { + // 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; + + if (isCssVariable(computedProp.name)) { + variables.set(computedProp.name, computedProp.value); + } + } + } + + // Find the CSS variables that have been updated. + const previousVariablesMap = new Map(this.variablesMap.get(pseudo)); + const changedVariableNamesSet = new Set( + [...variables.keys(), ...previousVariablesMap.keys()].filter( + k => variables.get(k) !== previousVariablesMap.get(k) + ) + ); + + this.variablesMap.set(pseudo, variables); + + // 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 editor we also show or hide the inactive + // CSS icon as needed. + for (const textProp of textProps) { + // _updatePropertyOverridden will return true if the + // overridden state has changed for the text property. + // _hasUpdatedCSSVariable will return true if the declaration contains any + // of the updated CSS variable names. + if ( + this._updatePropertyOverridden(textProp) || + this._hasUpdatedCSSVariable(textProp, changedVariableNamesSet) + ) { + textProp.updateEditor(); + } + + // For each editor show or hide the inactive CSS icon as needed. + if (textProp.editor && this.unusedCssEnabled) { + textProp.editor.updatePropertyState(); + } + } + } + + /** + * Returns true if the given declaration's property value contains a CSS variable + * matching any of the updated CSS variable names. + * + * @param {TextProperty} declaration + * A TextProperty of a rule. + * @param {Set<>String} variableNamesSet + * A Set of CSS variable names that have been updated. + */ + _hasUpdatedCSSVariable(declaration, variableNamesSet) { + for (const variableName of variableNamesSet) { + if (declaration.hasCSSVariable(variableName)) { + return true; + } + } + + return false; + } + + /** + * Helper for |this.updateDeclarations()| to mark CSS declarations as overridden. + * + * Returns an array of CSS declarations (aka TextProperty instances) from all rules + * applicable to the selected element ordered from more- to less-specific. + * + * If a pseudo-element type is given, restrict the result only to declarations + * applicable to that pseudo-element. + * + * NOTE: this method skips CSS declarations in @keyframes rules because a number of + * criteria such as time and animation delay need to be checked in order to determine + * if the property is overridden at runtime. + * + * @param {String} pseudo + * Optional pseudo-element for which to restrict marking CSS declarations as + * overridden. If omitted, only declarations for regular style rules are + * returned (no pseudo-element style rules). + * + * @return {Array} + * Array of TextProperty instances. + */ + _getDeclarations(pseudo = "") { + const textProps = []; + + for (const rule of this.rules) { + // Skip @keyframes rules + if (rule.keyframes) { + continue; + } + + // Style rules must be considered only when they have selectors that match the node. + // When renaming a selector, the unmatched rule lingers in the Rule view, but it no + // longer matches the node. This strict check avoids accidentally causing + // declarations to be overridden in the remaining matching rules. + const isStyleRule = + rule.pseudoElement === "" && !!rule.matchedSelectors.length; + + // Style rules for pseudo-elements must always be considered, regardless if their + // selector matches the node. As a convenience, declarations in rules for + // pseudo-elements show up in a separate Pseudo-elements accordion when selecting + // the host node (instead of the pseudo-element node directly, which is sometimes + // impossible, for example with ::selection or ::first-line). + // Loosening the strict check on matched selectors ensures these declarations + // participate in the algorithm below to mark them as overridden. + const isPseudoElementRule = + rule.pseudoElement !== "" && rule.pseudoElement === pseudo; + + const isElementStyle = rule.domRule.type === ELEMENT_STYLE; + + const filterCondition = + pseudo === "" ? isStyleRule || isElementStyle : isPseudoElementRule; + + // Collect all relevant CSS declarations (aka TextProperty instances). + if (filterCondition) { + for (const textProp of rule.textProps.slice(0).reverse()) { + if (textProp.enabled) { + textProps.push(textProp); + } + } + } + } + + return textProps; + } + + /** + * Adds a new declaration to the rule. + * + * @param {String} ruleId + * The id of the Rule to be modified. + * @param {String} value + * The new declaration value. + */ + addNewDeclaration(ruleId, value) { + const rule = this.getRule(ruleId); + if (!rule) { + return; + } + + const declarationsToAdd = parseNamedDeclarations( + this.cssProperties.isKnown, + value, + true + ); + if (!declarationsToAdd.length) { + return; + } + + this._addMultipleDeclarations(rule, declarationsToAdd); + } + + /** + * Adds a new rule. The rules view is updated from a "stylesheet-updated" event + * emitted the PageStyleActor as a result of the rule being inserted into the + * the stylesheet. + */ + async addNewRule() { + await this.pageStyle.addNewRule( + this.element, + this.element.pseudoClassLocks + ); + } + + /** + * Given the id of the rule and the new declaration name, modifies the existing + * declaration name to the new given value. + * + * @param {String} ruleId + * The Rule id of the given CSS declaration. + * @param {String} declarationId + * The TextProperty id for the CSS declaration. + * @param {String} name + * The new declaration name. + */ + async modifyDeclarationName(ruleId, declarationId, name) { + const rule = this.getRule(ruleId); + if (!rule) { + return; + } + + const declaration = rule.getDeclaration(declarationId); + if (!declaration || declaration.name === name) { + return; + } + + // Adding multiple rules inside of name field overwrites the current + // property with the first, then adds any more onto the property list. + const declarations = parseDeclarations(this.cssProperties.isKnown, name); + if (!declarations.length) { + return; + } + + await declaration.setName(declarations[0].name); + + if (!declaration.enabled) { + await declaration.setEnabled(true); + } + } + + /** + * Helper function to addNewDeclaration() and modifyDeclarationValue() for + * adding multiple declarations to a rule. + * + * @param {Rule} rule + * The Rule object to write new declarations to. + * @param {Array<Object>} declarationsToAdd + * An array of object containg the parsed declaration data to be added. + * @param {TextProperty|null} siblingDeclaration + * Optional declaration next to which the new declaration will be added. + */ + _addMultipleDeclarations(rule, declarationsToAdd, siblingDeclaration = null) { + for (const { commentOffsets, name, value, priority } of declarationsToAdd) { + const isCommented = Boolean(commentOffsets); + const enabled = !isCommented; + siblingDeclaration = rule.createProperty( + name, + value, + priority, + enabled, + siblingDeclaration + ); + } + } + + /** + * Parse a value string and break it into pieces, starting with the + * first value, and into an array of additional declarations (if any). + * + * Example: Calling with "red; width: 100px" would return + * { firstValue: "red", propertiesToAdd: [{ name: "width", value: "100px" }] } + * + * @param {String} value + * The string to parse. + * @return {Object} An object with the following properties: + * firstValue: A string containing a simple value, like + * "red" or "100px!important" + * declarationsToAdd: An array with additional declarations, following the + * parseDeclarations format of { name, value, priority } + */ + _getValueAndExtraProperties(value) { + // The inplace editor will prevent manual typing of multiple declarations, + // but we need to deal with the case during a paste event. + // Adding multiple declarations inside of value editor sets value with the + // first, then adds any more onto the declaration list (below this declarations). + let firstValue = value; + let declarationsToAdd = []; + + const declarations = parseDeclarations(this.cssProperties.isKnown, value); + + // Check to see if the input string can be parsed as multiple declarations + if (declarations.length) { + // Get the first property value (if any), and any remaining + // declarations (if any) + if (!declarations[0].name && declarations[0].value) { + firstValue = declarations[0].value; + declarationsToAdd = declarations.slice(1); + } else if (declarations[0].name && declarations[0].value) { + // In some cases, the value could be a property:value pair + // itself. Join them as one value string and append + // potentially following declarations + firstValue = declarations[0].name + ": " + declarations[0].value; + declarationsToAdd = declarations.slice(1); + } + } + + return { + declarationsToAdd, + firstValue, + }; + } + + /** + * Given the id of the rule and the new declaration value, modifies the existing + * declaration value to the new given value. + * + * @param {String} ruleId + * The Rule id of the given CSS declaration. + * @param {String} declarationId + * The TextProperty id for the CSS declaration. + * @param {String} value + * The new declaration value. + */ + async modifyDeclarationValue(ruleId, declarationId, value) { + const rule = this.getRule(ruleId); + if (!rule) { + return; + } + + const declaration = rule.getDeclaration(declarationId); + if (!declaration) { + return; + } + + const { declarationsToAdd, firstValue } = + this._getValueAndExtraProperties(value); + const parsedValue = parseSingleValue( + this.cssProperties.isKnown, + firstValue + ); + + if ( + !declarationsToAdd.length && + declaration.value === parsedValue.value && + declaration.priority === parsedValue.priority + ) { + return; + } + + // First, set this declaration value (common case, only modified a property) + await declaration.setValue(parsedValue.value, parsedValue.priority); + + if (!declaration.enabled) { + await declaration.setEnabled(true); + } + + this._addMultipleDeclarations(rule, declarationsToAdd, declaration); + } + + /** + * Modifies the existing rule's selector to the new given value. + * + * @param {String} ruleId + * The id of the Rule to be modified. + * @param {String} selector + * The new selector value. + */ + async modifySelector(ruleId, selector) { + try { + const rule = this.getRule(ruleId); + if (!rule) { + return; + } + + const response = await rule.domRule.modifySelector( + this.element, + selector + ); + const { ruleProps, isMatching } = response; + + if (!ruleProps) { + // Notify for changes, even when nothing changes, just to allow tests + // being able to track end of this request. + this.ruleView.emit("ruleview-invalid-selector"); + return; + } + + const newRule = new Rule(this, { + ...ruleProps, + isUnmatched: !isMatching, + }); + + // Recompute the list of applied styles because editing a + // selector might cause this rule's position to change. + const appliedStyles = await this.pageStyle.getApplied(this.element, { + inherited: true, + matchedSelectors: true, + filter: this.showUserAgentStyles ? "ua" : undefined, + }); + const newIndex = appliedStyles.findIndex(r => r.rule == ruleProps.rule); + const oldIndex = this.rules.indexOf(rule); + + // Remove the old rule and insert the new rule according to where it appears + // in the list of applied styles. + this.rules.splice(oldIndex, 1); + // If the selector no longer matches, then we leave the rule in + // the same relative position. + this.rules.splice(newIndex === -1 ? oldIndex : newIndex, 0, newRule); + + // Recompute, mark and update the UI for any properties that are + // overridden or contain inactive CSS according to the new list of rules. + this.onRuleUpdated(); + + // In order to keep the new rule in place of the old in the rules view, we need + // to remove the rule again if the rule was inserted to its new index according + // to the list of applied styles. + // Note: you might think we would replicate the list-modification logic above, + // but that is complicated due to the way the UI installs pseudo-element rules + // and the like. + if (newIndex !== -1) { + this.rules.splice(newIndex, 1); + this.rules.splice(oldIndex, 0, newRule); + } + this._changed(); + } catch (e) { + console.error(e); + } + } + + /** + * Toggles the enabled state of the given CSS declaration. + * + * @param {String} ruleId + * The Rule id of the given CSS declaration. + * @param {String} declarationId + * The TextProperty id for the CSS declaration. + */ + toggleDeclaration(ruleId, declarationId) { + const rule = this.getRule(ruleId); + if (!rule) { + return; + } + + const declaration = rule.getDeclaration(declarationId); + if (!declaration) { + return; + } + + declaration.setEnabled(!declaration.enabled); + } + + /** + * 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} prop + * The text property to update. + * @return {Boolean} true if the TextProperty's overridden state (or any of + * its computed properties overridden state) changed. + */ + _updatePropertyOverridden(prop) { + let overridden = true; + let dirty = false; + + for (const computedProp of prop.computed) { + if (!computedProp.overridden) { + overridden = false; + } + + dirty = computedProp._overriddenDirty || dirty; + delete computedProp._overriddenDirty; + } + + dirty = !!prop.overridden !== overridden || dirty; + prop.overridden = overridden; + return dirty; + } + + /** + * Returns the current value of a CSS variable; or null if the + * variable is not defined. + * + * @param {String} name + * The name of the variable. + * @param {String} pseudo + * The pseudo-element name of the rule. + * @return {String} the variable's value or null if the variable is + * not defined. + */ + getVariable(name, pseudo = "") { + const variables = this.variablesMap.get(pseudo); + return variables ? variables.get(name) : null; + } +} + +module.exports = ElementStyle; |