Source code

Revision control

Copy as Markdown

Other Tools

/* 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<number>} 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: <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 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;