/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
const {
styleRuleSpec,
} = require("resource://devtools/shared/specs/style-rule.js");
const {
InspectorCSSParserWrapper,
} = require("resource://devtools/shared/css/lexer.js");
const {
getRuleText,
getTextAtLineColumn,
} = require("resource://devtools/server/actors/stylesheets/style-utils.js");
const {
style: { ELEMENT_STYLE, PRES_HINTS },
} = require("resource://devtools/shared/constants.js");
loader.lazyRequireGetter(
this,
"CssLogic",
"resource://devtools/server/actors/inspector/css-logic.js",
true
);
loader.lazyRequireGetter(
this,
"getNodeDisplayName",
"resource://devtools/server/actors/inspector/utils.js",
true
);
loader.lazyRequireGetter(
this,
"SharedCssLogic",
"resource://devtools/shared/inspector/css-logic.js"
);
loader.lazyRequireGetter(
this,
"isCssPropertyKnown",
"resource://devtools/server/actors/css-properties.js",
true
);
loader.lazyRequireGetter(
this,
"getInactiveCssDataForProperty",
"resource://devtools/server/actors/utils/inactive-property-helper.js",
true
);
loader.lazyRequireGetter(
this,
"parseNamedDeclarations",
"resource://devtools/shared/css/parsing-utils.js",
true
);
loader.lazyRequireGetter(
this,
["UPDATE_PRESERVING_RULES", "UPDATE_GENERAL"],
"resource://devtools/server/actors/stylesheets/stylesheets-manager.js",
true
);
loader.lazyRequireGetter(
this,
"DocumentWalker",
"devtools/server/actors/inspector/document-walker",
true
);
const XHTML_NS = "http://www.w3.org/1999/xhtml";
/**
* An actor that represents a CSS style object on the protocol.
*
* We slightly flatten the CSSOM for this actor, it represents
* both the CSSRule and CSSStyle objects in one actor. For nodes
* (which have a CSSStyle but no CSSRule) we create a StyleRuleActor
* with a special rule type (100).
*/
class StyleRuleActor extends Actor {
/**
*
* @param {object} options
* @param {PageStyleActor} options.pageStyle
* @param {CSSStyleRule|Element} options.item
* @param {boolean} options.userAdded: Optional boolean to distinguish rules added by the user.
* @param {string} options.pseudoElement An optional pseudo-element type in cases when
* the CSS rule applies to a pseudo-element.
*/
constructor({ pageStyle, item, userAdded = false, pseudoElement = null }) {
super(pageStyle.conn, styleRuleSpec);
this.pageStyle = pageStyle;
this.rawStyle = item.style;
this._userAdded = userAdded;
this._pseudoElements = new Set();
this._pseudoElement = pseudoElement;
if (pseudoElement) {
this._pseudoElements.add(pseudoElement);
}
this._parentSheet = null;
// Parsed CSS declarations from this.form().declarations used to check CSS property
// names and values before tracking changes. Using cached values instead of accessing
// this.form().declarations on demand because that would cause needless re-parsing.
this._declarations = [];
this._pendingDeclarationChanges = [];
this._failedToGetRuleText = false;
if (CSSRule.isInstance(item)) {
this.type = item.type;
this.ruleClassName = ChromeUtils.getClassName(item);
this.rawRule = item;
this._computeRuleIndex();
if (this.#isRuleSupported() && this.rawRule.parentStyleSheet) {
this.line = InspectorUtils.getRelativeRuleLine(this.rawRule);
this.column = InspectorUtils.getRuleColumn(this.rawRule);
this._parentSheet = this.rawRule.parentStyleSheet;
}
} else if (item.declarationOrigin === "pres-hints") {
this.type = PRES_HINTS;
this.ruleClassName = PRES_HINTS;
this.rawNode = item;
this.rawRule = {
style: item.style,
toString() {
return "[element attribute styles " + this.style + "]";
},
};
} else {
// Fake a rule
this.type = ELEMENT_STYLE;
this.ruleClassName = ELEMENT_STYLE;
this.rawNode = item;
this.rawRule = {
style: item.style,
toString() {
return "[element rule " + this.style + "]";
},
};
}
}
destroy() {
if (!this.rawStyle) {
return;
}
super.destroy();
this.rawStyle = null;
this.pageStyle = null;
this.rawNode = null;
this.rawRule = null;
this._declarations = null;
if (this._pseudoElements) {
this._pseudoElements.clear();
this._pseudoElements = null;
}
}
// Objects returned by this actor are owned by the PageStyleActor
// to which this rule belongs.
get marshallPool() {
return this.pageStyle;
}
// True if this rule supports as-authored styles, meaning that the
// rule text can be rewritten using setRuleText.
get canSetRuleText() {
if (this.type === ELEMENT_STYLE) {
// Element styles are always editable.
return true;
}
if (!this._parentSheet) {
return false;
}
if (InspectorUtils.hasRulesModifiedByCSSOM(this._parentSheet)) {
// If a rule has been modified via CSSOM, then we should fall back to
// non-authored editing.
// https://bugzilla.mozilla.org/show_bug.cgi?id=1224121
return false;
}
return true;
}
/**
* Return an array with StyleRuleActor instances for each of this rule's ancestor rules
* (@media, @supports, @keyframes, etc) obtained by recursively reading rule.parentRule.
* If the rule has no ancestors, return an empty array.
*
* @return {Array}
*/
get ancestorRules() {
const ancestors = [];
let rule = this.rawRule;
while (rule.parentRule) {
ancestors.unshift(this.pageStyle.styleRef(rule.parentRule));
rule = rule.parentRule;
}
return ancestors;
}
/**
* Return an object with information about this rule used for tracking changes.
* It will be decorated with information about a CSS change before being tracked.
*
* It contains:
* - the rule selector (or generated selectror for inline styles)
* - the rule's host stylesheet (or element for inline styles)
* - the rule's ancestor rules (@media, @supports, @keyframes), if any
* - the rule's position within its ancestor tree, if any
*
* @return {object}
*/
get metadata() {
const data = {};
data.id = this.actorID;
// Collect information about the rule's ancestors (@media, @supports, @keyframes, parent rules).
// Used to show context for this change in the UI and to match the rule for undo/redo.
data.ancestors = this.ancestorRules.map(rule => {
const ancestorData = {
id: rule.actorID,
// Array with the indexes of this rule and its ancestors within the CSS rule tree.
ruleIndex: rule._ruleIndex,
};
// Rule type as human-readable string (ex: "@media", "@supports", "@keyframes")
const typeName = SharedCssLogic.getCSSAtRuleTypeName(rule.rawRule);
if (typeName) {
ancestorData.typeName = typeName;
}
// Conditions of @container, @media and @supports rules (ex: "min-width: 1em")
if (rule.rawRule.conditionText !== undefined) {
ancestorData.conditionText = rule.rawRule.conditionText;
}
// Name of @keyframes rule; referenced by the animation-name CSS property.
if (rule.rawRule.name !== undefined) {
ancestorData.name = rule.rawRule.name;
}
// Selector of individual @keyframe rule within a @keyframes rule (ex: 0%, 100%).
if (rule.rawRule.keyText !== undefined) {
ancestorData.keyText = rule.rawRule.keyText;
}
// Selector of the rule; might be useful in case for nested rules
if (rule.rawRule.selectorText !== undefined) {
ancestorData.selectorText = rule.rawRule.selectorText;
}
return ancestorData;
});
// For changes in element style attributes, generate a unique selector.
if (this.type === ELEMENT_STYLE && this.rawNode) {
// findCssSelector() fails on XUL documents. Catch and silently ignore that error.
try {
data.selector = SharedCssLogic.findCssSelector(this.rawNode);
} catch (err) {}
data.source = {
type: "element",
// Used to differentiate between elements which match the same generated selector
// but live in different documents (ex: host document and iframe).
href: this.rawNode.baseURI,
// Element style attributes don't have a rule index; use the generated selector.
index: data.selector,
// Whether the element lives in a different frame than the host document.
isFramed: this.rawNode.ownerGlobal !== this.pageStyle.ownerWindow,
};
const nodeActor = this.pageStyle.walker.getNode(this.rawNode);
if (nodeActor) {
data.source.id = nodeActor.actorID;
}
data.ruleIndex = 0;
} else {
data.selector =
this.ruleClassName === "CSSKeyframeRule"
? this.rawRule.keyText
: this.rawRule.selectorText;
// Used to differentiate between changes to rules with identical selectors.
data.ruleIndex = this._ruleIndex;
const sheet = this._parentSheet;
const inspectorActor = this.pageStyle.inspector;
const resourceId =
this.pageStyle.styleSheetsManager.getStyleSheetResourceId(sheet);
data.source = {
// Inline stylesheets have a null href; Use window URL instead.
type: sheet.href ? "stylesheet" : "inline",
href: sheet.href || inspectorActor.window.location.toString(),
id: resourceId,
// Whether the stylesheet lives in a different frame than the host document.
isFramed: inspectorActor.window !== inspectorActor.window.top,
};
}
return data;
}
/**
* Returns true if the pseudo element anonymous node (e.g. ::before, ::marker, …) is selected.
* Returns false if a non pseudo element node is selected and we're looking into its pseudo
* elements rules (i.e. this is for the "Pseudo-elements" section in the Rules view")
*/
get isPseudoElementAnonymousNodeSelected() {
if (!this._pseudoElement) {
return false;
}
// `this._pseudoElement` is the returned value by getNodeDisplayName, i.e that does
// differ from this.pageStyle.selectedElement.implementedPseudoElement (e.g. for
// view transition element, it will be `::view-transition-group(root)`, while
// implementedPseudoElement will be `::view-transition-group`).
return (
this._pseudoElement === getNodeDisplayName(this.pageStyle.selectedElement)
);
}
/**
* StyleRuleActor is spawned once per CSS Rule, but will be refreshed based on the
* currently selected DOM Element, which is updated when PageStyleActor.getApplied
* is called.
*/
get currentlySelectedElement() {
let { selectedElement } = this.pageStyle;
// If we're not handling a pseudo element, or if the pseudo element node
// (e.g. ::before, ::marker, …) is the one selected in the markup view, we can
// directly return selected element.
if (!this._pseudoElement || this.isPseudoElementAnonymousNodeSelected) {
return selectedElement;
}
// Otherwise we are selecting the pseudo element "parent" (binding), and we need to
// walk down the tree from `selectedElement` to find the pseudo element.
// FIXME: ::view-transition pseudo elements don't have a _moz_generated_content_ prefixed
// nodename, but have specific type and name attribute.
// At the moment this isn't causing any issues because we don't display the view
// transition rules in the pseudo element section, but this should be fixed in Bug 1998345.
const pseudo = this._pseudoElement.replaceAll(":", "");
const nodeName = `_moz_generated_content_${pseudo}`;
if (selectedElement.nodeName !== nodeName) {
const walker = new DocumentWalker(
selectedElement,
selectedElement.ownerGlobal
);
for (let next = walker.firstChild(); next; next = walker.nextSibling()) {
if (next.nodeName === nodeName) {
selectedElement = next;
break;
}
}
}
return selectedElement;
}
get currentlySelectedElementComputedStyle() {
if (!this._pseudoElement) {
return this.pageStyle.cssLogic.computedStyle;
}
const { selectedElement } = this.pageStyle;
return selectedElement.ownerGlobal.getComputedStyle(
selectedElement,
// If we are selecting the pseudo element parent, we need to pass the pseudo element
// to getComputedStyle to actually get the computed style of the pseudo element.
!this.isPseudoElementAnonymousNodeSelected ? this._pseudoElement : null
);
}
get pseudoElements() {
return this._pseudoElements;
}
addPseudo(pseudoElement) {
this._pseudoElements.add(pseudoElement);
}
getDocument(sheet) {
if (!sheet.associatedDocument) {
throw new Error(
"Failed trying to get the document of an invalid stylesheet"
);
}
return sheet.associatedDocument;
}
toString() {
return "[StyleRuleActor for " + this.rawRule + "]";
}
// eslint-disable-next-line complexity
form() {
const form = {
actor: this.actorID,
type: this.type,
className: this.ruleClassName,
line: this.line || undefined,
column: this.column,
traits: {
// Indicates whether StyleRuleActor implements and can use the setRuleText method.
// It cannot use it if the stylesheet was programmatically mutated via the CSSOM.
canSetRuleText: this.canSetRuleText,
},
};
// This rule was manually added by the user and may be automatically focused by the frontend.
if (this._userAdded) {
form.userAdded = true;
}
form.ancestorData = this._getAncestorDataForForm();
if (this._parentSheet) {
form.parentStyleSheet =
this.pageStyle.styleSheetsManager.getStyleSheetResourceId(
this._parentSheet
);
}
// One tricky thing here is that other methods in this actor must
// ensure that authoredText has been set before |form| is called.
// This has to be treated specially, for now, because we cannot
// synchronously compute the authored text, but |form| also cannot
// return a promise. See bug 1205868.
form.authoredText = this.authoredText;
form.cssText = this._getCssText();
switch (this.ruleClassName) {
case "CSSNestedDeclarations":
form.isNestedDeclarations = true;
form.selectors = [];
form.selectorsSpecificity = [];
break;
case "CSSStyleRule": {
form.selectors = [];
form.selectorsSpecificity = [];
for (let i = 0, len = this.rawRule.selectorCount; i < len; i++) {
form.selectors.push(this.rawRule.selectorTextAt(i));
form.selectorsSpecificity.push(
this.rawRule.selectorSpecificityAt(
i,
/* desugared, so we get the actual specificity */ true
)
);
}
// Only add the property when there are elements in the array to save up on serialization.
const selectorWarnings = this.rawRule.getSelectorWarnings();
if (selectorWarnings.length) {
form.selectorWarnings = selectorWarnings;
}
break;
}
case ELEMENT_STYLE: {
// Elements don't have a parent stylesheet, and therefore
// don't have an associated URI. Provide a URI for
// those.
const doc = this.rawNode.ownerDocument;
form.href = doc.location ? doc.location.href : "";
form.authoredText = this.rawNode.getAttribute("style");
break;
}
case PRES_HINTS:
form.href = "";
break;
case "CSSCharsetRule":
form.encoding = this.rawRule.encoding;
break;
case "CSSImportRule":
form.href = this.rawRule.href;
break;
case "CSSKeyframesRule":
case "CSSPositionTryRule":
form.name = this.rawRule.name;
break;
case "CSSKeyframeRule":
form.keyText = this.rawRule.keyText || "";
break;
}
// Parse the text into a list of declarations so the client doesn't have to
// and so that we can safely determine if a declaration is valid rather than
// have the client guess it.
if (form.authoredText || form.cssText) {
const declarations = this.parseRuleDeclarations({
parseComments: true,
});
const el = this.currentlySelectedElement;
const style = this.currentlySelectedElementComputedStyle;
// Whether the stylesheet is a user-agent stylesheet. This affects the
// validity of some properties and property values.
const userAgent =
this._parentSheet &&
SharedCssLogic.isAgentStylesheet(this._parentSheet);
// Whether the stylesheet is a chrome stylesheet. Ditto.
//
// Note that chrome rules are also enabled in user sheets, see
// ParserContext::chrome_rules_enabled().
//
// https://searchfox.org/mozilla-central/rev/919607a3610222099fbfb0113c98b77888ebcbfb/servo/components/style/parser.rs#164
const chrome = (() => {
if (!this._parentSheet) {
return false;
}
if (SharedCssLogic.isUserStylesheet(this._parentSheet)) {
return true;
}
if (this._parentSheet.href) {
return this._parentSheet.href.startsWith("chrome:");
}
return el && el.ownerDocument.documentURI.startsWith("chrome:");
})();
// Whether the document is in quirks mode. This affects whether stuff
// like `width: 10` is valid.
const quirks =
!userAgent && el && el.ownerDocument.compatMode == "BackCompat";
const supportsOptions = { userAgent, chrome, quirks };
const targetDocument =
this.pageStyle.inspector.targetActor.window.document;
let registeredProperties;
form.declarations = declarations.map(decl => {
// InspectorUtils.supports only supports the 1-arg version, but that's
// what we want to do anyways so that we also accept !important in the
// value.
decl.isValid =
// Always consider pres hints styles declarations valid. We need this because
// in some cases we might get quirks declarations for which we serialize the
// value to something meaningful for the user, but that can't be actually set.
// (e.g. for
in quirks mode, we get a `color: -moz-inherit-from-body-quirk`)
// In such case InspectorUtils.supports() would return false, but that would be
// odd to show "invalid" pres hints declaration in the UI.
this.ruleClassName === PRES_HINTS ||
(InspectorUtils.supports(
`${decl.name}:${decl.value}`,
supportsOptions
) &&
// !important values are not valid in @position-try and @keyframes
// TODO: We might extend InspectorUtils.supports to take the actual rule
// so we wouldn't have to hardcode this, but this does come with some
// challenges (see Bug 2004379).
!(
decl.priority === "important" &&
(this.ruleClassName === "CSSPositionTryRule" ||
this.ruleClassName === "CSSKeyframesRule")
));
const inactiveCssData = getInactiveCssDataForProperty(
el,
style,
this.rawRule,
decl.name
);
if (inactiveCssData !== null) {
decl.inactiveCssData = inactiveCssData;
}
// Check property name. All valid CSS properties support "initial" as a value.
decl.isNameValid =
// InspectorUtils.supports can be costly, don't call it when the declaration
// is a CSS variable, it should always be valid
decl.isCustomProperty ||
InspectorUtils.supports(`${decl.name}:initial`, supportsOptions);
if (decl.isCustomProperty) {
decl.computedValue = style.getPropertyValue(decl.name);
// If the variable is a registered property, we check if the variable is
// invalid at computed-value time (e.g. if the declaration value matches
// the `syntax` defined in the registered property)
if (!registeredProperties) {
registeredProperties =
InspectorUtils.getCSSRegisteredProperties(targetDocument);
}
const registeredProperty = registeredProperties.find(
prop => prop.name === decl.name
);
if (
registeredProperty &&
// For now, we don't handle variable based on top of other variables. This would
// require to build some kind of dependency tree and check the validity for
// all the leaves.
!decl.value.includes("var(") &&
!InspectorUtils.valueMatchesSyntax(
targetDocument,
decl.value,
registeredProperty.syntax
)
) {
// if the value doesn't match the syntax, it's invalid
decl.invalidAtComputedValueTime = true;
// pass the syntax down to the client so it can easily be used in a warning message
decl.syntax = registeredProperty.syntax;
}
// We only compute `inherits` for css variable declarations.
// For "regular" declaration, we use `CssPropertiesFront.isInherited`,
// which doesn't depend on the state of the document (a given property will
// always have the same isInherited value).
// CSS variables on the other hand can be registered custom properties (e.g.,
// `@property`/`CSS.registerProperty`), with a `inherits` definition that can
// be true or false.
// As such custom properties can be registered at any time during the page
// lifecycle, we always recompute the `inherits` information for CSS variables.
decl.inherits = InspectorUtils.isInheritedProperty(
this.pageStyle.inspector.window.document,
decl.name
);
}
return decl;
});
// We have computed the new `declarations` array, before forgetting about
// the old declarations compute the CSS changes for pending modifications
// applied by the user. Comparing the old and new declarations arrays
// ensures we only rely on values understood by the engine and not authored
// values. See Bug 1590031.
this._pendingDeclarationChanges.forEach(change =>
this.logDeclarationChange(change, declarations, this._declarations)
);
this._pendingDeclarationChanges = [];
// Cache parsed declarations so we don't needlessly re-parse authoredText every time
// we need to check previous property names and values when tracking changes.
this._declarations = declarations;
}
return form;
}
/**
* Return the rule cssText if applicable, null otherwise
*
* @returns {string | null}
*/
_getCssText() {
switch (this.ruleClassName) {
case "CSSNestedDeclarations":
case "CSSPositionTryRule":
case "CSSStyleRule":
case ELEMENT_STYLE:
case PRES_HINTS:
return this.rawStyle.cssText || "";
case "CSSKeyframesRule":
case "CSSKeyframeRule":
return this.rawRule.cssText;
}
return null;
}
/**
* Parse the rule declarations from its text.
*
* @param {object} options
* @param {boolean} options.parseComments
* @returns {Array} @see parseNamedDeclarations
*/
parseRuleDeclarations({ parseComments }) {
const authoredText =
this.ruleClassName === ELEMENT_STYLE
? this.rawNode.getAttribute("style")
: this.authoredText;
// authoredText may be an empty string when deleting all properties; it's ok to use.
const cssText =
typeof authoredText === "string" ? authoredText : this._getCssText();
if (!cssText) {
return [];
}
return parseNamedDeclarations(isCssPropertyKnown, cssText, parseComments);
}
/**
*
* @returns {Array