summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/rules/models
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /devtools/client/inspector/rules/models
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/inspector/rules/models')
-rw-r--r--devtools/client/inspector/rules/models/class-list.js271
-rw-r--r--devtools/client/inspector/rules/models/element-style.js904
-rw-r--r--devtools/client/inspector/rules/models/moz.build13
-rw-r--r--devtools/client/inspector/rules/models/rule.js874
-rw-r--r--devtools/client/inspector/rules/models/text-property.js400
-rw-r--r--devtools/client/inspector/rules/models/user-properties.js85
6 files changed, 2547 insertions, 0 deletions
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<Array<String>>} 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<String>} 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<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 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<String, String> 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: <string>,
+ * // The CSS declaration that has compatibility issues
+ * property: <string>,
+ * // Alias to the given CSS property
+ * alias: <Array>,
+ * // Link to MDN documentation for the particular CSS rule
+ * url: <string>,
+ * deprecated: <boolean>,
+ * experimental: <boolean>,
+ * // An array of all the browsers that don't support the given CSS rule
+ * unsupportedBrowsers: <Array>,
+ * }
+ */
+ 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: <boolean>,
+ * // The CSS declaration that has compatibility issues
+ * property: <string>,
+ * // The un-aliased root CSS declaration for the given property
+ * rootProperty: <string>,
+ * // The l10n message id for the tooltip message
+ * msgId: <string>,
+ * // Link to MDN documentation for the rootProperty
+ * url: <string>,
+ * // An array of all the browsers that don't support the given CSS rule
+ * unsupportedBrowsers: <Array>,
+ * }
+ */
+ 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;