/* 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, PRES_HINTS }, } = 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. */ /** * @typedef AppliedStyle * This described a rule currently applying to a given DOM Element. * This object comes from the backend and is defined by the "appliedstyle" * protocol.js data type (Keep in sync with style-types.js spec file). * @property {StyleRuleFront} rule * The main front to get data about the rule to describe. * @property {string} pseudoElement * If this rule is about a pseudo element, its name (e.g. `::before`). * @property {boolean} isSystem * Is this a user agent style? * @property {NodeFront} inherited * An NodeFront for the element this rule was inherited from. * If omitted, the rule applies directly to the current element. * @property {boolean} darkColorScheme * True if dark color scheme is enabled. * @property {Array} matchedSelectorIndexes * To report which ones of the many selectors the rule may have * that matches the selected element. * @property {StyleRuleFront} keyframes * If this rule relate to a @keyframes rule, the parent keyframes rule. * * Note that the following attribute isn't related to "appliedstyle" * protocol.js type. This is a pure frontend attribute, only used when * creating a Rule from `ElementStyle.modifySelector()`) * * @property {boolean} isUnmatched * True if the rule does not match the current selected * element, otherwise, false. */ class Rule { /** * @param {ElementStyle} elementStyle * The ElementStyle to which this rule belongs. * * @param {AppliedStyle} appliedStyle * The information used to construct this rule. */ constructor(elementStyle, appliedStyle) { this.elementStyle = elementStyle; this.domRule = appliedStyle.rule; this.compatibilityIssues = null; this.matchedSelectorIndexes = appliedStyle.matchedSelectorIndexes || []; this.isSystem = appliedStyle.isSystem; this.isUnmatched = appliedStyle.isUnmatched || false; this.darkColorScheme = appliedStyle.darkColorScheme; this.inherited = appliedStyle.inherited || null; this.pseudoElement = appliedStyle.pseudoElement || ""; this.keyframes = appliedStyle.keyframes || null; this.userAdded = appliedStyle.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() { this.domRule.off("rule-updated", this.onStyleRuleFrontUpdated); this.compatibilityIssues = null; this.destroyed = true; } get declarations() { return this.textProps; } get selector() { return { getUniqueSelector: this.getUniqueSelector, matchedSelectorIndexes: this.matchedSelectorIndexes, selectors: this.domRule.selectors, selectorsSpecificity: this.domRule.selectorsSpecificity, 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; } #inheritedSectionLabel; get inheritedSectionLabel() { if (this.#inheritedSectionLabel) { return this.#inheritedSectionLabel; } this.#inheritedSectionLabel = ""; if (this.inherited) { let eltText = this.inherited.displayName; if (this.inherited.id) { eltText += "#" + this.inherited.id; } if (CssLogic.ELEMENT_BACKED_PSEUDO_ELEMENTS.has(this.pseudoElement)) { eltText += this.pseudoElement; } this.#inheritedSectionLabel = STYLE_INSPECTOR_L10N.getFormatStr( "rule.inheritedFrom", eltText ); } return this.#inheritedSectionLabel; } #keyframesName; 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() { if (Array.isArray(this.domRule.selectors)) { return this.domRule.selectors.join(", "); } if (this.domRule.type === PRES_HINTS) { return CssLogic.l10n("rule.sourceElementAttributesStyle"); } return 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 same rule front. * specified. * * @param {object} appliedStyle * Applied style object. See the Rule constructor for documentation. */ matches(appliedStyle) { return this.domRule === appliedStyle.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({ rule: 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); this.store.userProperties.setProperty(this.domRule, name, value); // 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. */ async #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); } await modifications.apply(); 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. */ async #applyPropertiesAuthored(modifications) { await modifications.apply(); // 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.inspector.panelWin, 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.notifyChanged(); } }) .catch(promiseWarn); // Expose as a public field as this is queried from CssRuleView class, // as well as tests 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} */ async previewPropertyValue(property, value, priority) { this.elementStyle.ruleView.emitForTests("start-preview-property-value"); const modifications = this.domRule.startModifyingProperties( this.inspector.panelWin, this.cssProperties ); modifications.setProperty( this.textProps.indexOf(property), property.name, value, priority ); await modifications.apply(); // Ensure dispatching a ruleview-changed event // also for previews this.elementStyle.notifyChanged(); } /** * 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({ rule: this, name, value, priority: prop.priority, enabled: !("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({ rule: this, name: prop.name, value, priority: prop.priority, }); textProp.enabled = false; textProps.push(textProp); } return textProps; } /** * Reread the current state of the rules and rebuild text * properties as needed. */ refresh(appliedStyle) { this.matchedSelectorIndexes = appliedStyle.matchedSelectorIndexes || []; const colorSchemeChanged = this.darkColorScheme !== appliedStyle.darkColorScheme; this.darkColorScheme = appliedStyle.darkColorScheme; // The `domRule`'s StyleRule actor doesn't change (this.domRule == appliedStyle.rule) // but the appliedStyle may object may update any of its other attributes. // Here we may select two distinct elements, matching the same CSS Rule, // but the inheritance may be different. // (this is covered by browser_inspector_pseudoclass-lock.js) if (appliedStyle.inherited != this.inherited) { this.inherited = appliedStyle.inherited; this.#inheritedSectionLabel = null; } 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. // 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) { // Valid properties that aren't disabled might need to get updated in some condition if ( prop.enabled && prop.isValid() && // Update if: // - it's using light-dark() and the color scheme changed ((colorSchemeChanged && prop.value.includes("light-dark(")) || // - it's using attr() (we don't check if the attribute changed as it would be // cumbersome and this is unlikely to be perf sensitive as the function might // not be used that much) prop.value.includes("attr(")) ) { prop.updateEditor(); } } // 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; } // 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) { match.rank = rank; match.prop = prop; } } // 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) { // The prop could be invisible or a hidden unused variable if (this.textProps[index].editor) { 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) { // The prop could be invisible or a hidden unused variable if (this.textProps[index].editor) { 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) ); }); } /** * @returns {boolean} Whether or not the rule is in a @starting-style rule */ isInStartingStyle() { return this.domRule.ancestorData.some( ({ type }) => type === "starting-style" ); } /** * @returns {boolean} Whether or not the rule can be edited */ isEditable() { return ( !this.isSystem && this.domRule.type !== PRES_HINTS && // FIXME: Should be removed as part of Bug 2004046 this.domRule.className !== "CSSPositionTryRule" ); } /** * 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;