From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../client/inspector/rules/models/class-list.js | 271 ++++++ .../client/inspector/rules/models/element-style.js | 904 +++++++++++++++++++++ devtools/client/inspector/rules/models/moz.build | 13 + devtools/client/inspector/rules/models/rule.js | 874 ++++++++++++++++++++ .../client/inspector/rules/models/text-property.js | 400 +++++++++ .../inspector/rules/models/user-properties.js | 85 ++ 6 files changed, 2547 insertions(+) create mode 100644 devtools/client/inspector/rules/models/class-list.js create mode 100644 devtools/client/inspector/rules/models/element-style.js create mode 100644 devtools/client/inspector/rules/models/moz.build create mode 100644 devtools/client/inspector/rules/models/rule.js create mode 100644 devtools/client/inspector/rules/models/text-property.js create mode 100644 devtools/client/inspector/rules/models/user-properties.js (limited to 'devtools/client/inspector/rules/models') diff --git a/devtools/client/inspector/rules/models/class-list.js b/devtools/client/inspector/rules/models/class-list.js new file mode 100644 index 0000000000..9173977382 --- /dev/null +++ b/devtools/client/inspector/rules/models/class-list.js @@ -0,0 +1,271 @@ +/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +// This serves as a local cache for the classes applied to each of the node we care about +// here. +// The map is indexed by NodeFront. Any time a new node is selected in the inspector, an +// entry is added here, indexed by the corresponding NodeFront. +// The value for each entry is an array of each of the class this node has. Items of this +// array are objects like: { name, isApplied } where the name is the class itself, and +// isApplied is a Boolean indicating if the class is applied on the node or not. +const CLASSES = new WeakMap(); + +/** + * Manages the list classes per DOM elements we care about. + * The actual list is stored in the CLASSES const, indexed by NodeFront objects. + * The responsibility of this class is to be the source of truth for anyone who wants to + * know which classes a given NodeFront has, and which of these are enabled and which are + * disabled. + * It also reacts to DOM mutations so the list of classes is up to date with what is in + * the DOM. + * It can also be used to enable/disable a given class, or add classes. + * + * @param {Inspector} inspector + * The current inspector instance. + */ +class ClassList { + constructor(inspector) { + EventEmitter.decorate(this); + + this.inspector = inspector; + + this.onMutations = this.onMutations.bind(this); + this.inspector.on("markupmutation", this.onMutations); + + this.classListProxyNode = this.inspector.panelDoc.createElement("div"); + this.previewClasses = []; + this.unresolvedStateChanges = []; + } + + destroy() { + this.inspector.off("markupmutation", this.onMutations); + this.inspector = null; + this.classListProxyNode = null; + } + + /** + * The current node selection (which only returns if the node is an ELEMENT_NODE type + * since that's the only type this model can work with.) + */ + get currentNode() { + if ( + this.inspector.selection.isElementNode() && + !this.inspector.selection.isPseudoElementNode() + ) { + return this.inspector.selection.nodeFront; + } + return null; + } + + /** + * The class states for the current node selection. See the documentation of the CLASSES + * constant. + */ + get currentClasses() { + if (!this.currentNode) { + return []; + } + + if (!CLASSES.has(this.currentNode)) { + // Use the proxy node to get a clean list of classes. + this.classListProxyNode.className = this.currentNode.className; + const nodeClasses = [...new Set([...this.classListProxyNode.classList])] + .filter( + className => + !this.previewClasses.some( + previewClass => + previewClass.className === className && + !previewClass.wasAppliedOnNode + ) + ) + .map(name => { + return { name, isApplied: true }; + }); + + CLASSES.set(this.currentNode, nodeClasses); + } + + return CLASSES.get(this.currentNode); + } + + /** + * Same as currentClasses, but returns it in the form of a className string, where only + * enabled classes are added. + */ + get currentClassesPreview() { + const currentClasses = this.currentClasses + .filter(({ isApplied }) => isApplied) + .map(({ name }) => name); + const previewClasses = this.previewClasses + .filter(previewClass => !currentClasses.includes(previewClass.className)) + .filter(item => item !== "") + .map(({ className }) => className); + + return currentClasses.concat(previewClasses).join(" ").trim(); + } + + /** + * Set the state for a given class on the current node. + * + * @param {String} name + * The class which state should be changed. + * @param {Boolean} isApplied + * True if the class should be enabled, false otherwise. + * @return {Promise} Resolves when the change has been made in the DOM. + */ + setClassState(name, isApplied) { + // Do the change in our local model. + const nodeClasses = this.currentClasses; + nodeClasses.find(({ name: cName }) => cName === name).isApplied = isApplied; + + return this.applyClassState(); + } + + /** + * Add several classes to the current node at once. + * + * @param {String} classNameString + * The string that contains all classes. + * @return {Promise} Resolves when the change has been made in the DOM. + */ + addClassName(classNameString) { + this.classListProxyNode.className = classNameString; + this.eraseClassPreview(); + return Promise.all( + [...new Set([...this.classListProxyNode.classList])].map(name => { + return this.addClass(name); + }) + ); + } + + /** + * Add a class to the current node at once. + * + * @param {String} name + * The class to be added. + * @return {Promise} Resolves when the change has been made in the DOM. + */ + addClass(name) { + // Avoid adding the same class again. + if (this.currentClasses.some(({ name: cName }) => cName === name)) { + return Promise.resolve(); + } + + // Change the local model, so we retain the state of the existing classes. + this.currentClasses.push({ name, isApplied: true }); + + return this.applyClassState(); + } + + /** + * Used internally by other functions like addClass or setClassState. Actually applies + * the class change to the DOM. + * + * @return {Promise} Resolves when the change has been made in the DOM. + */ + applyClassState() { + // If there is no valid inspector selection, bail out silently. No need to report an + // error here. + if (!this.currentNode) { + return Promise.resolve(); + } + + // Remember which node & className we applied until their mutation event is received, so we + // can filter out dom mutations that are caused by us in onMutations, even in situations when + // a new change is applied before that the event of the previous one has been received yet + this.unresolvedStateChanges.push({ + node: this.currentNode, + className: this.currentClassesPreview, + }); + + // Apply the change to the node. + const mod = this.currentNode.startModifyingAttributes(); + mod.setAttribute("class", this.currentClassesPreview); + return mod.apply(); + } + + onMutations(mutations) { + for (const { type, target, attributeName } of mutations) { + // Only care if this mutation is for the class attribute. + if (type !== "attributes" || attributeName !== "class") { + continue; + } + + const isMutationForOurChange = this.unresolvedStateChanges.some( + previousStateChange => + previousStateChange.node === target && + previousStateChange.className === target.className + ); + + if (!isMutationForOurChange) { + CLASSES.delete(target); + if (target === this.currentNode) { + this.emit("current-node-class-changed"); + } + } else { + this.removeResolvedStateChanged(target, target.className); + } + } + } + + /** + * Get the available classNames in the document where the current selected node lives: + * - the one already used on elements of the document + * - the one defined in Stylesheets of the document + * + * @param {String} filter: A string the classNames should start with (an insensitive + * case matching will be done). + * @returns {Promise>} A promise that resolves with an array of strings + * matching the passed filter. + */ + getClassNames(filter) { + return this.currentNode.inspectorFront.pageStyle.getAttributesInOwnerDocument( + filter, + "class", + this.currentNode + ); + } + + previewClass(inputClasses) { + if ( + this.previewClasses + .map(previewClass => previewClass.className) + .join(" ") !== inputClasses + ) { + this.previewClasses = []; + inputClasses.split(" ").forEach(className => { + this.previewClasses.push({ + className, + wasAppliedOnNode: this.isClassAlreadyApplied(className), + }); + }); + this.applyClassState(); + } + } + + eraseClassPreview() { + this.previewClass(""); + } + + removeResolvedStateChanged(currentNode, currentClassesPreview) { + this.unresolvedStateChanges.splice( + 0, + this.unresolvedStateChanges.findIndex( + previousState => + previousState.node === currentNode && + previousState.className === currentClassesPreview + ) + 1 + ); + } + + isClassAlreadyApplied(className) { + return this.currentClasses.some(({ name }) => name === className); + } +} + +module.exports = ClassList; 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..e280a5e4a0 --- /dev/null +++ b/devtools/client/inspector/rules/models/element-style.js @@ -0,0 +1,904 @@ +/* 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/shared/inspector/css-logic.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. + // + // Note that this is different if layers are involved: if both + // old and new properties have a high priority, and if the new + // property is in a rule belonging to a layer that is different + // from the the one the old property rule might be in, + // mark the old property overridden and mark the property name as + // taken by the new property. + // + // _overriddenDirty will be set on each prop, indicating whether its + // dirty status changed during this pass. + const taken = new Map(); + for (const computedProp of computedProps) { + const earlier = taken.get(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" || + // Even if the earlier property was important, if the current rule is in a layer + // it will take precedence, unless the earlier property rule was in the same layer. + (computedProp.textProp.rule?.isInLayer() && + computedProp.textProp.rule.isInDifferentLayer( + earlier.textProp.rule + ))) && + // 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.set(computedProp.name, computedProp); + + // At this point, we can get CSS variable from "inherited" rules. + // When this is a registered custom property with `inherits` set to false, + // the text prop is "invisible" (i.e. not shown in the rule view). + // In such case, we don't want to get the value in the Map, and we'll rather + // get the initial value from the registered property definition. + if ( + isCssVariable(computedProp.name) && + !computedProp.textProp.invisible + ) { + 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(); + } + } + } + + /** + * Update CSS variable tooltip information on textProp editor when registered property + * are added/modified/removed. + * + * @param {Set} registeredPropertyNamesSet: A Set containing the name of the + * registered properties which were added/modified/removed. + */ + onRegisteredPropertiesChange(registeredPropertyNamesSet) { + for (const rule of this.rules) { + for (const textProp of rule.textProps) { + if (this._hasUpdatedCSSVariable(textProp, registeredPropertyNamesSet)) { + textProp.updateEditor(); + } + } + } + } + + /** + * 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.matchedDesugaredSelectors.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} 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 its initial value if the + * variable is registered but not defined; or null if it's not registered and not defined. + * + * @param {String} name + * The name of the variable. + * @param {String} pseudo + * The pseudo-element name of the rule. + * @return {String|null} the variable's value (or initial value) or null if the variable + * is not defined and not registered. + */ + getVariable(name, pseudo = "") { + const variables = this.variablesMap.get(pseudo); + + if (variables && variables.has(name)) { + return variables.get(name); + } + + // If the variable wasn't defined, we want to check if it is a registered custom + // properties so we can get its initial value + const registeredPropertiesMap = + this.ruleView.getRegisteredPropertiesForSelectedNodeTarget(); + return registeredPropertiesMap && registeredPropertiesMap.has(name) + ? registeredPropertiesMap.get(name).initialValue + : null; + } + + /** + * Get all custom properties. + * + * @param {String} pseudo + * The pseudo-element name of the rule. + * @returns Map A map whose key is the custom property name and value is + * the custom property value (or registered property initial + * value if the property is not defined) + */ + getAllCustomProperties(pseudo = "") { + let customProperties = this.variablesMap.get(pseudo); + + const registeredPropertiesMap = + this.ruleView.getRegisteredPropertiesForSelectedNodeTarget(); + + // If there's no registered properties, we can return the Map as is + if (!registeredPropertiesMap || registeredPropertiesMap.size === 0) { + return customProperties; + } + + let newMapCreated = false; + for (const [name, propertyDefinition] of registeredPropertiesMap) { + // Only set the registered property if it's not defined (i.e. not in this.variablesMap) + if (!customProperties.has(name)) { + // Since we want to return registered property, we need to create a new Map + // to not modify the one in this.variablesMap. + if (!newMapCreated) { + customProperties = new Map(customProperties); + newMapCreated = true; + } + customProperties.set(name, propertyDefinition.initialValue); + } + } + + return customProperties; + } +} + +module.exports = ElementStyle; diff --git a/devtools/client/inspector/rules/models/moz.build b/devtools/client/inspector/rules/models/moz.build new file mode 100644 index 0000000000..7a5561e213 --- /dev/null +++ b/devtools/client/inspector/rules/models/moz.build @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "class-list.js", + "element-style.js", + "rule.js", + "text-property.js", + "user-properties.js", +) diff --git a/devtools/client/inspector/rules/models/rule.js b/devtools/client/inspector/rules/models/rule.js new file mode 100644 index 0000000000..cfc4a60263 --- /dev/null +++ b/devtools/client/inspector/rules/models/rule.js @@ -0,0 +1,874 @@ +/* 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 { + style: { ELEMENT_STYLE }, +} = require("resource://devtools/shared/constants.js"); +const CssLogic = require("resource://devtools/shared/inspector/css-logic.js"); +const TextProperty = require("resource://devtools/client/inspector/rules/models/text-property.js"); + +loader.lazyRequireGetter( + this, + "promiseWarn", + "resource://devtools/client/inspector/shared/utils.js", + true +); +loader.lazyRequireGetter( + this, + "parseNamedDeclarations", + "resource://devtools/shared/css/parsing-utils.js", + true +); + +const STYLE_INSPECTOR_PROPERTIES = + "devtools/shared/locales/styleinspector.properties"; +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const STYLE_INSPECTOR_L10N = new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES); + +/** + * Rule is responsible for the following: + * Manages a single style declaration or rule. + * Applies changes to the properties in a rule. + * Maintains a list of TextProperty objects. + */ +class Rule { + /** + * @param {ElementStyle} elementStyle + * The ElementStyle to which this rule belongs. + * @param {Object} options + * 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. + * isSystem: Is this a user agent style? + * isUnmatched: True if the rule does not match the current selected + * element, otherwise, false. + */ + constructor(elementStyle, options) { + this.elementStyle = elementStyle; + this.domRule = options.rule; + this.compatibilityIssues = null; + this.matchedDesugaredSelectors = options.matchedDesugaredSelectors || []; + this.pseudoElement = options.pseudoElement || ""; + this.isSystem = options.isSystem; + this.isUnmatched = options.isUnmatched || false; + this.inherited = options.inherited || null; + this.keyframes = options.keyframes || null; + this.userAdded = options.rule.userAdded; + + this.cssProperties = this.elementStyle.ruleView.cssProperties; + this.inspector = this.elementStyle.ruleView.inspector; + this.store = this.elementStyle.ruleView.store; + + // Populate the text properties with the style's current authoredText + // value, and add in any disabled properties from the store. + this.textProps = this._getTextProperties(); + this.textProps = this.textProps.concat(this._getDisabledProperties()); + + this.getUniqueSelector = this.getUniqueSelector.bind(this); + this.onStyleRuleFrontUpdated = this.onStyleRuleFrontUpdated.bind(this); + + this.domRule.on("rule-updated", this.onStyleRuleFrontUpdated); + } + + destroy() { + if (this._unsubscribeSourceMap) { + this._unsubscribeSourceMap(); + } + + this.domRule.off("rule-updated", this.onStyleRuleFrontUpdated); + this.compatibilityIssues = null; + this.destroyed = true; + } + + get declarations() { + return this.textProps; + } + + get inheritance() { + if (!this.inherited) { + return null; + } + + return { + inherited: this.inherited, + inheritedSource: this.inheritedSource, + }; + } + + get selector() { + return { + getUniqueSelector: this.getUniqueSelector, + matchedDesugaredSelectors: this.matchedDesugaredSelectors, + selectors: this.domRule.selectors, + selectorWarnings: this.domRule.selectors, + selectorText: this.keyframes ? this.domRule.keyText : this.selectorText, + }; + } + + get sourceMapURLService() { + return this.inspector.toolbox.sourceMapURLService; + } + + get title() { + let title = CssLogic.shortSource(this.sheet); + if (this.domRule.type !== ELEMENT_STYLE && this.ruleLine > 0) { + title += ":" + this.ruleLine; + } + + return title; + } + + get inheritedSource() { + if (this._inheritedSource) { + return this._inheritedSource; + } + this._inheritedSource = ""; + if (this.inherited) { + let eltText = this.inherited.displayName; + if (this.inherited.id) { + eltText += "#" + this.inherited.id; + } + this._inheritedSource = STYLE_INSPECTOR_L10N.getFormatStr( + "rule.inheritedFrom", + eltText + ); + } + return this._inheritedSource; + } + + get keyframesName() { + if (this._keyframesName) { + return this._keyframesName; + } + this._keyframesName = ""; + if (this.keyframes) { + this._keyframesName = STYLE_INSPECTOR_L10N.getFormatStr( + "rule.keyframe", + this.keyframes.name + ); + } + return this._keyframesName; + } + + get keyframesRule() { + if (!this.keyframes) { + return null; + } + + return { + id: this.keyframes.actorID, + keyframesName: this.keyframesName, + }; + } + + 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 : -1; + } + + /** + * The rule's column within a stylesheet + */ + get ruleColumn() { + return this.domRule ? this.domRule.column : null; + } + + /** + * Get the declaration block issues from the compatibility actor + * @returns A promise that resolves with an array of objects in following form: + * { + * // Type of compatibility issue + * type: , + * // The CSS declaration that has compatibility issues + * property: , + * // Alias to the given CSS property + * alias: , + * // Link to MDN documentation for the particular CSS rule + * url: , + * deprecated: , + * experimental: , + * // An array of all the browsers that don't support the given CSS rule + * unsupportedBrowsers: , + * } + */ + async getCompatibilityIssues() { + if (!this.compatibilityIssues) { + this.compatibilityIssues = + this.inspector.commands.inspectorCommand.getCSSDeclarationBlockIssues( + this.domRule.declarations + ); + } + + return this.compatibilityIssues; + } + + /** + * Returns the TextProperty with the given id or undefined if it cannot be found. + * + * @param {String|null} id + * A TextProperty id. + * @return {TextProperty|undefined} with the given id in the current Rule or undefined + * if it cannot be found. + */ + getDeclaration(id) { + return id ? this.textProps.find(textProp => textProp.id === id) : undefined; + } + + /** + * Returns an unique selector for the CSS rule. + */ + async getUniqueSelector() { + let selector = ""; + + if (this.domRule.selectors) { + // This is a style rule with a selector. + selector = this.domRule.selectors.join(", "); + } else if (this.inherited) { + // This is an inline style from an inherited rule. Need to resolve the unique + // selector from the node which rule this is inherited from. + selector = await this.inherited.getUniqueSelector(); + } else { + // This is an inline style from the current node. + selector = await this.inspector.selection.nodeFront.getUniqueSelector(); + } + + return selector; + } + + /** + * Returns true if the rule matches the creation options + * specified. + * + * @param {Object} options + * Creation options. See the Rule constructor for documentation. + */ + matches(options) { + return this.domRule === options.rule; + } + + /** + * Create a new TextProperty to include in the rule. + * + * @param {String} name + * The text property name (such as "background" or "border-top"). + * @param {String} value + * The property's value (not including priority). + * @param {String} priority + * The property's priority (either "important" or an empty string). + * @param {Boolean} enabled + * True if the property should be enabled. + * @param {TextProperty} siblingProp + * Optional, property next to which the new property will be added. + */ + createProperty(name, value, priority, enabled, siblingProp) { + const prop = new TextProperty(this, name, value, priority, enabled); + + let ind; + if (siblingProp) { + ind = this.textProps.indexOf(siblingProp) + 1; + this.textProps.splice(ind, 0, prop); + } else { + ind = this.textProps.length; + this.textProps.push(prop); + } + + this.applyProperties(modifications => { + modifications.createProperty(ind, name, value, priority, enabled); + // Now that the rule has been updated, the server might have given us data + // that changes the state of the property. Update it now. + prop.updateEditor(); + }); + + return prop; + } + + /** + * Helper function for applyProperties that is called when the actor + * does not support as-authored styles. Store disabled properties + * in the element style's store. + */ + _applyPropertiesNoAuthored(modifications) { + this.elementStyle.onRuleUpdated(); + + const disabledProps = []; + + for (const prop of this.textProps) { + if (prop.invisible) { + continue; + } + if (!prop.enabled) { + disabledProps.push({ + name: prop.name, + value: prop.value, + priority: prop.priority, + }); + continue; + } + if (prop.value.trim() === "") { + continue; + } + + modifications.setProperty(-1, prop.name, prop.value, prop.priority); + + prop.updateComputed(); + } + + // Store disabled properties in the disabled store. + const disabled = this.elementStyle.store.disabled; + if (disabledProps.length) { + disabled.set(this.domRule, disabledProps); + } else { + disabled.delete(this.domRule); + } + + return modifications.apply().then(() => { + const cssProps = {}; + // Note that even though StyleRuleActors normally provide parsed + // declarations already, _applyPropertiesNoAuthored is only used when + // connected to older backend that do not provide them. So parse here. + for (const cssProp of parseNamedDeclarations( + this.cssProperties.isKnown, + this.domRule.authoredText + )) { + cssProps[cssProp.name] = cssProp; + } + + for (const textProp of this.textProps) { + if (!textProp.enabled) { + continue; + } + let cssProp = cssProps[textProp.name]; + + if (!cssProp) { + cssProp = { + name: textProp.name, + value: "", + priority: "", + }; + } + + textProp.priority = cssProp.priority; + } + }); + } + + /** + * A helper for applyProperties that applies properties in the "as + * authored" case; that is, when the StyleRuleActor supports + * setRuleText. + */ + _applyPropertiesAuthored(modifications) { + return modifications.apply().then(() => { + // The rewriting may have required some other property values to + // change, e.g., to insert some needed terminators. Update the + // relevant properties here. + for (const index in modifications.changedDeclarations) { + const newValue = modifications.changedDeclarations[index]; + this.textProps[index].updateValue(newValue); + } + // Recompute and redisplay the computed properties. + for (const prop of this.textProps) { + if (!prop.invisible && prop.enabled) { + prop.updateComputed(); + prop.updateEditor(); + } + } + }); + } + + /** + * Reapply all the properties in this rule, and update their + * computed styles. Will re-mark overridden properties. Sets the + * |_applyingModifications| property to a promise which will resolve + * when the edit has completed. + * + * @param {Function} modifier a function that takes a RuleModificationList + * (or RuleRewriter) as an argument and that modifies it + * to apply the desired edit + * @return {Promise} a promise which will resolve when the edit + * is complete + */ + applyProperties(modifier) { + // If there is already a pending modification, we have to wait + // until it settles before applying the next modification. + const resultPromise = Promise.resolve(this._applyingModifications) + .then(() => { + const modifications = this.domRule.startModifyingProperties( + this.cssProperties + ); + modifier(modifications); + if (this.domRule.canSetRuleText) { + return this._applyPropertiesAuthored(modifications); + } + return this._applyPropertiesNoAuthored(modifications); + }) + .then(() => { + this.elementStyle.onRuleUpdated(); + + if (resultPromise === this._applyingModifications) { + this._applyingModifications = null; + this.elementStyle._changed(); + } + }) + .catch(promiseWarn); + + this._applyingModifications = resultPromise; + return resultPromise; + } + + /** + * Renames a property. + * + * @param {TextProperty} property + * The property to rename. + * @param {String} name + * The new property name (such as "background" or "border-top"). + * @return {Promise} + */ + setPropertyName(property, name) { + if (name === property.name) { + return Promise.resolve(); + } + + const oldName = property.name; + property.name = name; + const index = this.textProps.indexOf(property); + return this.applyProperties(modifications => { + modifications.renameProperty(index, oldName, name); + }); + } + + /** + * Sets the value and priority of a property, then reapply all properties. + * + * @param {TextProperty} property + * The property to manipulate. + * @param {String} value + * The property's value (not including priority). + * @param {String} priority + * The property's priority (either "important" or an empty string). + * @return {Promise} + */ + setPropertyValue(property, value, priority) { + if (value === property.value && priority === property.priority) { + return Promise.resolve(); + } + + property.value = value; + property.priority = priority; + + const index = this.textProps.indexOf(property); + return this.applyProperties(modifications => { + modifications.setProperty(index, property.name, value, priority); + }); + } + + /** + * Just sets the value and priority of a property, in order to preview its + * effect on the content document. + * + * @param {TextProperty} property + * The property which value will be previewed + * @param {String} value + * The value to be used for the preview + * @param {String} priority + * The property's priority (either "important" or an empty string). + **@return {Promise} + */ + previewPropertyValue(property, value, priority) { + this.elementStyle.ruleView.emitForTests("start-preview-property-value"); + const modifications = this.domRule.startModifyingProperties( + this.cssProperties + ); + modifications.setProperty( + this.textProps.indexOf(property), + property.name, + value, + priority + ); + return modifications.apply().then(() => { + // Ensure dispatching a ruleview-changed event + // also for previews + this.elementStyle._changed(); + }); + } + + /** + * Disables or enables given TextProperty. + * + * @param {TextProperty} property + * The property to enable/disable + * @param {Boolean} value + */ + setPropertyEnabled(property, value) { + if (property.enabled === !!value) { + return; + } + property.enabled = !!value; + const index = this.textProps.indexOf(property); + this.applyProperties(modifications => { + modifications.setPropertyEnabled(index, property.name, property.enabled); + }); + } + + /** + * Remove a given TextProperty from the rule and update the rule + * accordingly. + * + * @param {TextProperty} property + * The property to be removed + */ + removeProperty(property) { + const index = this.textProps.indexOf(property); + this.textProps.splice(index, 1); + // Need to re-apply properties in case removing this TextProperty + // exposes another one. + this.applyProperties(modifications => { + modifications.removeProperty(index, property.name); + }); + } + + /** + * Event handler for "rule-updated" event fired by StyleRuleActor. + * + * @param {StyleRuleFront} front + */ + onStyleRuleFrontUpdated(front) { + // Overwritting this reference is not required, but it's here to avoid confusion. + // Whenever an actor is passed over the protocol, either as a return value or as + // payload on an event, the `form` of its corresponding front will be automatically + // updated. No action required. + // Even if this `domRule` reference here is not explicitly updated, lookups of + // `this.domRule.declarations` will point to the latest state of declarations set + // on the actor. Everything on `StyleRuleForm.form` will point to the latest state. + this.domRule = front; + } + + /** + * Get the list of TextProperties from the style. Needs + * to parse the style's authoredText. + */ + _getTextProperties() { + const textProps = []; + const store = this.elementStyle.store; + + for (const prop of this.domRule.declarations) { + const name = prop.name; + // In an inherited rule, we only show inherited properties. + // However, we must keep all properties in order for rule + // rewriting to work properly. So, compute the "invisible" + // property here. + const inherits = prop.isCustomProperty + ? prop.inherits + : this.cssProperties.isInherited(name); + const invisible = this.inherited && !inherits; + + const value = store.userProperties.getProperty( + this.domRule, + name, + prop.value + ); + const textProp = new TextProperty( + this, + name, + value, + prop.priority, + !("commentOffsets" in prop), + invisible + ); + textProps.push(textProp); + } + + return textProps; + } + + /** + * Return the list of disabled properties from the store for this rule. + */ + _getDisabledProperties() { + const store = this.elementStyle.store; + + // Include properties from the disabled property store, if any. + const disabledProps = store.disabled.get(this.domRule); + if (!disabledProps) { + return []; + } + + const textProps = []; + + for (const prop of disabledProps) { + const value = store.userProperties.getProperty( + this.domRule, + prop.name, + prop.value + ); + const 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(options) { + this.matchedDesugaredSelectors = options.matchedDesugaredSelectors || []; + const newTextProps = this._getTextProperties(); + + // The element style rule behaves differently on refresh. We basically need to update + // it to reflect the new text properties exactly. The order might have changed, some + // properties might have been removed, etc. And we don't need to mark anything as + // disabled here. The element style rule should always reflect the content of the + // style attribute. + if (this.domRule.type === ELEMENT_STYLE) { + this.textProps = newTextProps; + + if (this.editor) { + this.editor.populate(true); + } + + return; + } + + // 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. + const brandNewProps = []; + for (const newProp of newTextProps) { + if (!this._updateTextProperty(newProp)) { + brandNewProps.push(newProp); + } + } + + // Refresh editors and disabled state for all the properties that + // were updated. + for (const 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 authoredText. 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} newProp + * The current version of the property, as parsed from the + * authoredText in Rule._getTextProperties(). + * @return {Boolean} true if a property was updated, false if no properties + * were updated. + */ + _updateTextProperty(newProp) { + const match = { rank: 0, prop: null }; + + for (const prop of this.textProps) { + if (prop.name !== newProp.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 === newProp.value) { + rank += 2; + if (prop.priority === newProp.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(newProp); + return true; + } + + return false; + } + + /** + * Jump between editable properties in the UI. If the focus direction is + * forward, begin editing the next property name if available or focus the + * new property editor otherwise. If the focus direction is backward, + * begin editing the previous property value or focus the selector editor if + * this is the first element in the property list. + * + * @param {TextProperty} textProperty + * The text property that will be left to focus on a sibling. + * @param {Number} direction + * The move focus direction number. + */ + editClosestTextProperty(textProperty, direction) { + let index = this.textProps.indexOf(textProperty); + + if (direction === Services.focus.MOVEFOCUS_FORWARD) { + for (++index; index < this.textProps.length; ++index) { + if (!this.textProps[index].invisible) { + break; + } + } + if (index === this.textProps.length) { + textProperty.rule.editor.closeBrace.click(); + } else { + this.textProps[index].editor.nameSpan.click(); + } + } else if (direction === Services.focus.MOVEFOCUS_BACKWARD) { + for (--index; index >= 0; --index) { + if (!this.textProps[index].invisible) { + break; + } + } + if (index < 0) { + textProperty.editor.ruleEditor.selectorText.click(); + } else { + this.textProps[index].editor.valueSpan.click(); + } + } + } + + /** + * Return a string representation of the rule. + */ + stringifyRule() { + const selectorText = this.selectorText; + let cssText = ""; + const terminator = Services.appinfo.OS === "WINNT" ? "\r\n" : "\n"; + + for (const textProp of this.textProps) { + if (!textProp.invisible) { + cssText += "\t" + textProp.stringifyProperty() + terminator; + } + } + + return selectorText + " {" + terminator + cssText + "}"; + } + + /** + * @returns {Boolean} Whether or not the rule is in a layer + */ + isInLayer() { + return this.domRule.ancestorData.some(({ type }) => type === "layer"); + } + + /** + * Return whether this rule and the one passed are in the same layer, + * (as in described in the spec; this is not checking that the 2 rules are children + * of the same CSSLayerBlockRule) + * + * @param {Rule} otherRule: The rule we want to compare with + * @returns {Boolean} + */ + isInDifferentLayer(otherRule) { + const filterLayer = ({ type }) => type === "layer"; + const thisLayers = this.domRule.ancestorData.filter(filterLayer); + const otherRuleLayers = otherRule.domRule.ancestorData.filter(filterLayer); + + if (thisLayers.length !== otherRuleLayers.length) { + return true; + } + + return thisLayers.some((layer, i) => { + const otherRuleLayer = otherRuleLayers[i]; + // For named layers, we can compare the layer name directly, since we want to identify + // the actual layer, not the specific CSSLayerBlockRule. + // For nameless layers though, we don't have a choice and we can only identify them + // via their CSSLayerBlockRule, so we're using the rule actorID. + return ( + (layer.value || layer.actorID) !== + (otherRuleLayer.value || otherRuleLayer.actorID) + ); + }); + } + + /** + * See whether this rule has any non-invisible properties. + * @return {Boolean} true if there is any visible property, or false + * if all properties are invisible + */ + hasAnyVisibleProperties() { + for (const prop of this.textProps) { + if (!prop.invisible) { + return true; + } + } + return false; + } +} + +module.exports = Rule; diff --git a/devtools/client/inspector/rules/models/text-property.js b/devtools/client/inspector/rules/models/text-property.js new file mode 100644 index 0000000000..d7568f74f3 --- /dev/null +++ b/devtools/client/inspector/rules/models/text-property.js @@ -0,0 +1,400 @@ +/* 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 { generateUUID } = require("resource://devtools/shared/generate-uuid.js"); +const { + COMPATIBILITY_TOOLTIP_MESSAGE, +} = require("resource://devtools/client/inspector/rules/constants.js"); + +loader.lazyRequireGetter( + this, + "escapeCSSComment", + "resource://devtools/shared/css/parsing-utils.js", + true +); + +loader.lazyRequireGetter( + this, + "getCSSVariables", + "resource://devtools/client/inspector/rules/utils/utils.js", + true +); + +/** + * TextProperty is responsible for the following: + * Manages a single property from the authoredText 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. + */ +class TextProperty { + /** + * @param {Rule} rule + * The rule this TextProperty came from. + * @param {String} name + * The text property name (such as "background" or "border-top"). + * @param {String} value + * The property's value (not including priority). + * @param {String} priority + * The property's priority (either "important" or an empty string). + * @param {Boolean} enabled + * Whether the property is enabled. + * @param {Boolean} invisible + * Whether the property is invisible. In an inherited rule, only show + * the inherited declarations. The other declarations are considered + * invisible and does not show up in the UI. These are needed so that + * the index of a property in Rule.textProps is the same as the index + * coming from parseDeclarations. + */ + constructor(rule, name, value, priority, enabled = true, invisible = false) { + this.id = name + "_" + generateUUID().toString(); + this.rule = rule; + this.name = name; + this.value = value; + this.priority = priority; + this.enabled = !!enabled; + this.invisible = invisible; + this.elementStyle = this.rule.elementStyle; + this.cssProperties = this.elementStyle.ruleView.cssProperties; + this.panelDoc = this.elementStyle.ruleView.inspector.panelDoc; + this.userProperties = this.elementStyle.store.userProperties; + // Names of CSS variables used in the value of this declaration. + this.usedVariables = new Set(); + + this.updateComputed(); + this.updateUsedVariables(); + } + + get computedProperties() { + return this.computed + .filter(computed => computed.name !== this.name) + .map(computed => { + return { + isOverridden: computed.overridden, + name: computed.name, + priority: computed.priority, + value: computed.value, + }; + }); + } + + /** + * Returns whether or not the declaration's name is known. + * + * @return {Boolean} true if the declaration name is known, false otherwise. + */ + get isKnownProperty() { + return this.cssProperties.isKnown(this.name); + } + + /** + * Returns whether or not the declaration is changed by the user. + * + * @return {Boolean} true if the declaration is changed by the user, false + * otherwise. + */ + get isPropertyChanged() { + return this.userProperties.contains(this.rule.domRule, this.name); + } + + /** + * Update the editor associated with this text property, + * if any. + */ + updateEditor() { + // When the editor updates, reset the saved + // compatibility issues list as any updates + // may alter the compatibility status of declarations + this.rule.compatibilityIssues = null; + if (this.editor) { + this.editor.update(); + } + } + + /** + * Update the list of computed properties for this text property. + */ + updateComputed() { + if (!this.name) { + return; + } + + // This is a bit funky. To get the list of computed properties + // for this text property, we'll set the property on a dummy element + // and see what the computed style looks like. + const dummyElement = this.elementStyle.ruleView.dummyElement; + const dummyStyle = dummyElement.style; + dummyStyle.cssText = ""; + dummyStyle.setProperty(this.name, this.value, this.priority); + + this.computed = []; + + // Manually get all the properties that are set when setting a value on + // this.name and check the computed style on dummyElement for each one. + // If we just read dummyStyle, it would skip properties when value === "". + const subProps = this.cssProperties.getSubproperties(this.name); + + for (const prop of subProps) { + this.computed.push({ + textProp: this, + name: prop, + value: dummyStyle.getPropertyValue(prop), + priority: dummyStyle.getPropertyPriority(prop), + }); + } + } + + /** + * Extract all CSS variable names used in this declaration's value into a Set for + * easy querying. Call this method any time the declaration's value changes. + */ + updateUsedVariables() { + this.usedVariables.clear(); + + for (const variable of getCSSVariables(this.value)) { + this.usedVariables.add(variable); + } + } + + /** + * Set all the values from another TextProperty instance into + * this TextProperty instance. + * + * @param {TextProperty} prop + * The other TextProperty instance. + */ + set(prop) { + let changed = false; + for (const item of ["name", "value", "priority", "enabled"]) { + if (this[item] !== prop[item]) { + this[item] = prop[item]; + changed = true; + } + } + + if (changed) { + this.updateUsedVariables(); + this.updateEditor(); + } + } + + setValue(value, priority, force = false) { + if (value !== this.value || force) { + this.userProperties.setProperty(this.rule.domRule, this.name, value); + } + return this.rule.setPropertyValue(this, value, priority).then(() => { + this.updateUsedVariables(); + this.updateEditor(); + }); + } + + /** + * Called when the property's value has been updated externally, and + * the property and editor should update to reflect that value. + * + * @param {String} value + * Property value + */ + updateValue(value) { + if (value !== this.value) { + this.value = value; + this.updateUsedVariables(); + this.updateEditor(); + } + } + + async setName(name) { + if (name !== this.name) { + this.userProperties.setProperty(this.rule.domRule, name, this.value); + } + + await this.rule.setPropertyName(this, name); + this.updateEditor(); + } + + setEnabled(value) { + this.rule.setPropertyEnabled(this, value); + this.updateEditor(); + } + + remove() { + this.rule.removeProperty(this); + } + + /** + * Return a string representation of the rule property. + */ + stringifyProperty() { + // Get the displayed property value + let declaration = this.name + ": " + this.value; + + if (this.priority) { + declaration += " !" + this.priority; + } + + declaration += ";"; + + // Comment out property declarations that are not enabled + if (!this.enabled) { + declaration = "/* " + escapeCSSComment(declaration) + " */"; + } + + return declaration; + } + + /** + * Validate this property. Does it make sense for this value to be assigned + * to this property name? + * + * @return {Boolean} true if the whole CSS declaration is valid, false otherwise. + */ + isValid() { + const selfIndex = this.rule.textProps.indexOf(this); + + // When adding a new property in the rule-view, the TextProperty object is + // created right away before the rule gets updated on the server, so we're + // not going to find the corresponding declaration object yet. Default to + // true. + if (!this.rule.domRule.declarations[selfIndex]) { + return true; + } + + return this.rule.domRule.declarations[selfIndex].isValid; + } + + isUsed() { + const selfIndex = this.rule.textProps.indexOf(this); + const declarations = this.rule.domRule.declarations; + + // StyleRuleActor's declarations may have a isUsed flag (if the server is the right + // version). Just return true if the information is missing. + if ( + !declarations || + !declarations[selfIndex] || + !declarations[selfIndex].isUsed + ) { + return { used: true }; + } + + return declarations[selfIndex].isUsed; + } + + /** + * Get compatibility issue linked with the textProp. + * + * @returns A JSON objects with compatibility information in following form: + * { + * // A boolean to denote the compatibility status + * isCompatible: , + * // The CSS declaration that has compatibility issues + * property: , + * // The un-aliased root CSS declaration for the given property + * rootProperty: , + * // The l10n message id for the tooltip message + * msgId: , + * // Link to MDN documentation for the rootProperty + * url: , + * // An array of all the browsers that don't support the given CSS rule + * unsupportedBrowsers: , + * } + */ + async isCompatible() { + // This is a workaround for Bug 1648339 + // https://bugzilla.mozilla.org/show_bug.cgi?id=1648339 + // that makes the tooltip icon inconsistent with the + // position of the rule it is associated with. Once solved, + // the compatibility data can be directly accessed from the + // declaration and this logic can be used to set isCompatible + // property directly to domRule in StyleRuleActor's form() method. + if (!this.enabled) { + return { isCompatible: true }; + } + + const compatibilityIssues = await this.rule.getCompatibilityIssues(); + if (!compatibilityIssues.length) { + return { isCompatible: true }; + } + + const property = this.name; + const indexOfProperty = compatibilityIssues.findIndex( + issue => issue.property === property || issue.aliases?.includes(property) + ); + + if (indexOfProperty < 0) { + return { isCompatible: true }; + } + + const { + property: rootProperty, + deprecated, + experimental, + specUrl, + url, + unsupportedBrowsers, + } = compatibilityIssues[indexOfProperty]; + + let msgId = COMPATIBILITY_TOOLTIP_MESSAGE.default; + if (deprecated && experimental && !unsupportedBrowsers.length) { + msgId = + COMPATIBILITY_TOOLTIP_MESSAGE["deprecated-experimental-supported"]; + } else if (deprecated && experimental) { + msgId = COMPATIBILITY_TOOLTIP_MESSAGE["deprecated-experimental"]; + } else if (deprecated && !unsupportedBrowsers.length) { + msgId = COMPATIBILITY_TOOLTIP_MESSAGE["deprecated-supported"]; + } else if (deprecated) { + msgId = COMPATIBILITY_TOOLTIP_MESSAGE.deprecated; + } else if (experimental && !unsupportedBrowsers.length) { + msgId = COMPATIBILITY_TOOLTIP_MESSAGE["experimental-supported"]; + } else if (experimental) { + msgId = COMPATIBILITY_TOOLTIP_MESSAGE.experimental; + } + + return { + isCompatible: false, + property, + rootProperty, + msgId, + specUrl, + url, + unsupportedBrowsers, + }; + } + + /** + * Validate the name of this property. + * + * @return {Boolean} true if the property name is valid, false otherwise. + */ + isNameValid() { + const selfIndex = this.rule.textProps.indexOf(this); + + // When adding a new property in the rule-view, the TextProperty object is + // created right away before the rule gets updated on the server, so we're + // not going to find the corresponding declaration object yet. Default to + // true. + if (!this.rule.domRule.declarations[selfIndex]) { + return true; + } + + return this.rule.domRule.declarations[selfIndex].isNameValid; + } + + /** + * Returns true if the property value is a CSS variables and contains the given variable + * name, and false otherwise. + * + * @param {String} + * CSS variable name (e.g. "--color") + * @return {Boolean} + */ + hasCSSVariable(name) { + return this.usedVariables.has(name); + } +} + +module.exports = TextProperty; diff --git a/devtools/client/inspector/rules/models/user-properties.js b/devtools/client/inspector/rules/models/user-properties.js new file mode 100644 index 0000000000..381b800e59 --- /dev/null +++ b/devtools/client/inspector/rules/models/user-properties.js @@ -0,0 +1,85 @@ +/* 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"; + +/** + * Store of CSSStyleDeclarations mapped to properties that have been changed by + * the user. + */ +class UserProperties { + constructor() { + this.map = new Map(); + } + + /** + * Get a named property for a given CSSStyleDeclaration. + * + * @param {CSSStyleDeclaration} style + * The CSSStyleDeclaration against which the property is mapped. + * @param {String} name + * The name of the property to get. + * @param {String} value + * Default value. + * @return {String} + * The property value if it has previously been set by the user, null + * otherwise. + */ + getProperty(style, name, value) { + const key = this.getKey(style); + const entry = this.map.get(key, null); + + if (entry && name in entry) { + return entry[name]; + } + return value; + } + + /** + * Set a named property for a given CSSStyleDeclaration. + * + * @param {CSSStyleDeclaration} style + * The CSSStyleDeclaration against which the property is to be mapped. + * @param {String} name + * The name of the property to set. + * @param {String} userValue + * The value of the property to set. + */ + setProperty(style, name, userValue) { + const key = this.getKey(style, name); + const entry = this.map.get(key, null); + + if (entry) { + entry[name] = userValue; + } else { + const props = {}; + props[name] = userValue; + this.map.set(key, props); + } + } + + /** + * Check whether a named property for a given CSSStyleDeclaration is stored. + * + * @param {CSSStyleDeclaration} style + * The CSSStyleDeclaration against which the property would be mapped. + * @param {String} name + * The name of the property to check. + */ + contains(style, name) { + const key = this.getKey(style, name); + const entry = this.map.get(key, null); + return !!entry && name in entry; + } + + getKey(style, name) { + return style.actorID + ":" + name; + } + + clear() { + this.map.clear(); + } +} + +module.exports = UserProperties; -- cgit v1.2.3