/* 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 { getCSSLexer } = require("resource://devtools/shared/css/lexer.js"); const TrackChangeEmitter = require("resource://devtools/server/actors/utils/track-change-emitter.js"); const { getRuleText, getTextAtLineColumn, } = require("resource://devtools/server/actors/utils/style-utils.js"); const { style: { ELEMENT_STYLE }, } = require("resource://devtools/shared/constants.js"); loader.lazyRequireGetter( this, "CssLogic", "resource://devtools/server/actors/inspector/css-logic.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, "isPropertyUsed", "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/utils/stylesheets-manager.js", 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 { constructor(pageStyle, item, userAdded = false) { super(pageStyle.conn, styleRuleSpec); this.pageStyle = pageStyle; this.rawStyle = item.style; this._userAdded = userAdded; 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 { // 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; } // 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); const styleSheetIndex = this.pageStyle.styleSheetsManager.getStyleSheetIndex(resourceId); 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, index: styleSheetIndex, // Whether the stylesheet lives in a different frame than the host document. isFramed: inspectorActor.window !== inspectorActor.window.top, }; } return data; } getDocument(sheet) { if (!sheet.associatedDocument) { throw new Error( "Failed trying to get the document of an invalid stylesheet" ); } return sheet.associatedDocument; } /** * When a rule is nested in another non-at-rule (aka CSS Nesting), the client * will need its desugared selector, i.e. the full selector, which includes ancestor * selectors, that is computed by the platform when applying the rule. * To compute it, the parent selector (&) is recursively replaced by the parent * rule selector wrapped in `:is()`. * For example, with the following nested rule: `body { & > main {} }`, * the desugared selector will be `:is(body) > main`. * See https://www.w3.org/TR/css-nesting-1/#nest-selector for more information. * * Returns an array of the desugared selectors. For example, if rule is: * * body { * & > main, & section { * } * } * * this will return: * * [ * `:is(body) > main`, * `:is(body) section`, * ] * * @returns Array */ getDesugaredSelectors() { // Cache the desugared selectors as it can be expensive to compute if (!this._desugaredSelectors) { this._desugaredSelectors = CssLogic.getSelectors(this.rawRule, true); } return this._desugaredSelectors; } toString() { return "[StyleRuleActor for " + this.rawRule + "]"; } // eslint-disable-next-line complexity form() { const form = { actor: this.actorID, type: this.type, 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; } const { computeDesugaredSelector, ancestorData } = this._getAncestorDataForForm(); form.ancestorData = ancestorData; 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; switch (this.ruleClassName) { case "CSSStyleRule": form.selectors = CssLogic.getSelectors(this.rawRule); // 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; } if (computeDesugaredSelector) { form.desugaredSelectors = this.getDesugaredSelectors(); } form.cssText = this.rawStyle.cssText || ""; 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.cssText = this.rawStyle.cssText || ""; form.authoredText = this.rawNode.getAttribute("style"); break; case "CSSCharsetRule": form.encoding = this.rawRule.encoding; break; case "CSSImportRule": form.href = this.rawRule.href; break; case "CSSKeyframesRule": form.cssText = this.rawRule.cssText; form.name = this.rawRule.name; break; case "CSSKeyframeRule": form.cssText = this.rawStyle.cssText || ""; 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) { // authoredText may be an empty string when deleting all properties; it's ok to use. const cssText = typeof form.authoredText === "string" ? form.authoredText : form.cssText; const declarations = parseNamedDeclarations( isCssPropertyKnown, cssText, true ); const el = this.pageStyle.selectedElement; const style = this.pageStyle.cssLogic.computedStyle; // 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 }; 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 = InspectorUtils.supports( `${decl.name}:${decl.value}`, supportsOptions ); // TODO: convert from Object to Boolean. See Bug 1574471 decl.isUsed = isPropertyUsed(el, style, this.rawRule, decl.name); // Check property name. All valid CSS properties support "initial" as a value. decl.isNameValid = InspectorUtils.supports( `${decl.name}:initial`, supportsOptions ); if (SharedCssLogic.isCssVariable(decl.name)) { decl.isCustomProperty = true; // 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; } /** * * @returns {Object} Object with the following properties: * - {Array} ancestorData: An array of ancestor item data * - {Boolean} computeDesugaredSelector: true if the rule has a non-at-rule * parent rule (i.e. rule is likely to be a nested rule) */ _getAncestorDataForForm() { const ancestorData = []; // Flag that will be set to true if the rule has a non-at-rule parent rule let computeDesugaredSelector = false; // Go through all ancestor so we can build an array of all the media queries and // layers this rule is in. for (const ancestorRule of this.ancestorRules) { const rawRule = ancestorRule.rawRule; const ruleClassName = ChromeUtils.getClassName(rawRule); const type = SharedCssLogic.CSSAtRuleClassNameType[ruleClassName]; if (ruleClassName === "CSSMediaRule" && rawRule.media?.length) { ancestorData.push({ type, value: Array.from(rawRule.media).join(", "), }); } else if (ruleClassName === "CSSLayerBlockRule") { ancestorData.push({ // we need the actorID so we can uniquely identify nameless layers on the client actorID: ancestorRule.actorID, type, value: rawRule.name, }); } else if (ruleClassName === "CSSContainerRule") { ancestorData.push({ type, // Send containerName and containerQuery separately (instead of conditionText) // so the client has more flexibility to display the information. containerName: rawRule.containerName, containerQuery: rawRule.containerQuery, }); } else if (ruleClassName === "CSSSupportsRule") { ancestorData.push({ type, conditionText: rawRule.conditionText, }); } else if (rawRule.selectorText) { // All the previous cases where about at-rules; this one is for regular rule // that are ancestors because CSS nesting was used. // In such case, we want to return the selectorText so it can be displayed in the UI. const ancestor = { type, selectors: CssLogic.getSelectors(rawRule), }; // Only add the property when there are elements in the array to save up on serialization. const selectorWarnings = rawRule.getSelectorWarnings(); if (selectorWarnings.length) { ancestor.selectorWarnings = selectorWarnings; } ancestorData.push(ancestor); computeDesugaredSelector = true; } } if (this._parentSheet) { // Loop through all parent stylesheets to get the whole list of @import rules. let rule = this.rawRule; while ((rule = rule.parentStyleSheet?.ownerRule)) { // If the rule is in a imported stylesheet with a specified layer if (rule.layerName !== null) { // Put the item at the top of the ancestor data array, as we're going up // in the stylesheet hierarchy, and we want to display ancestor rules in the // orders they're applied. ancestorData.unshift({ type: "layer", value: rule.layerName, }); } // If the rule is in a imported stylesheet with specified media/supports conditions if (rule.media?.mediaText || rule.supportsText) { const parts = []; if (rule.supportsText) { parts.push(`supports(${rule.supportsText})`); } if (rule.media?.mediaText) { parts.push(rule.media.mediaText); } // Put the item at the top of the ancestor data array, as we're going up // in the stylesheet hierarchy, and we want to display ancestor rules in the // orders they're applied. ancestorData.unshift({ type: "import", value: parts.join(" "), }); } } } return { ancestorData, computeDesugaredSelector }; } /** * Send an event notifying that the location of the rule has * changed. * * @param {Number} line the new line number * @param {Number} column the new column number */ _notifyLocationChanged(line, column) { this.emit("location-changed", line, column); } /** * Compute the index of this actor's raw rule in its parent style * sheet. The index is a vector where each element is the index of * a given CSS rule in its parent. A vector is used to support * nested rules. */ _computeRuleIndex() { const index = InspectorUtils.getRuleIndex(this.rawRule); this._ruleIndex = index.length ? index : null; } /** * Get the rule corresponding to |this._ruleIndex| from the given * style sheet. * * @param {DOMStyleSheet} sheet * The style sheet. * @return {CSSStyleRule} the rule corresponding to * |this._ruleIndex| */ _getRuleFromIndex(parentSheet) { let currentRule = null; for (const i of this._ruleIndex) { if (currentRule === null) { currentRule = parentSheet.cssRules[i]; } else { currentRule = currentRule.cssRules.item(i); } } return currentRule; } /** * Called from PageStyle actor _onStylesheetUpdated. */ onStyleApplied(kind) { if (kind === UPDATE_GENERAL) { // A general change means that the rule actors are invalidated, nothing // to do here. return; } if (this._ruleIndex) { // The sheet was updated by this actor, in a way that preserves // the rules. Now, recompute our new rule from the style sheet, // so that we aren't left with a reference to a dangling rule. const oldRule = this.rawRule; const oldActor = this.pageStyle.refMap.get(oldRule); this.rawRule = this._getRuleFromIndex(this._parentSheet); if (oldActor) { // Also tell the page style so that future calls to _styleRef // return the same StyleRuleActor. this.pageStyle.updateStyleRef(oldRule, this.rawRule, this); } const line = InspectorUtils.getRelativeRuleLine(this.rawRule); const column = InspectorUtils.getRuleColumn(this.rawRule); if (line !== this.line || column !== this.column) { this._notifyLocationChanged(line, column); } this.line = line; this.column = column; } } #SUPPORTED_RULES_CLASSNAMES = new Set([ "CSSContainerRule", "CSSKeyframeRule", "CSSKeyframesRule", "CSSLayerBlockRule", "CSSMediaRule", "CSSStyleRule", "CSSSupportsRule", ]); #isRuleSupported() { // this.rawRule might not be an actual CSSRule (e.g. when this represent an element style), // and in such case, ChromeUtils.getClassName will throw try { const ruleClassName = ChromeUtils.getClassName(this.rawRule); return this.#SUPPORTED_RULES_CLASSNAMES.has(ruleClassName); } catch (e) {} return false; } /** * Return a promise that resolves to the authored form of a rule's * text, if available. If the authored form is not available, the * returned promise simply resolves to the empty string. If the * authored form is available, this also sets |this.authoredText|. * The authored text will include invalid and otherwise ignored * properties. * * @param {Boolean} skipCache * If a value for authoredText was previously found and cached, * ignore it and parse the stylehseet again. The authoredText * may be outdated if a descendant of this rule has changed. */ async getAuthoredCssText(skipCache = false) { if (!this.canSetRuleText || !this.#isRuleSupported()) { return ""; } if (!skipCache) { if (this._failedToGetRuleText) { return ""; } if (typeof this.authoredText === "string") { return this.authoredText; } } try { const resourceId = this.pageStyle.styleSheetsManager.getStyleSheetResourceId( this._parentSheet ); const cssText = await this.pageStyle.styleSheetsManager.getText( resourceId ); const text = getRuleText(cssText, this.line, this.column); // Cache the result on the rule actor to avoid parsing again next time this._failedToGetRuleText = false; this.authoredText = text; } catch (e) { this._failedToGetRuleText = true; this.authoredText = undefined; return ""; } return this.authoredText; } /** * Return a promise that resolves to the complete cssText of the rule as authored. * * Unlike |getAuthoredCssText()|, which only returns the contents of the rule, this * method includes the CSS selectors and at-rules (@media, @supports, @keyframes, etc.) * * If the rule type is unrecongized, the promise resolves to an empty string. * If the rule is an element inline style, the promise resolves with the generated * selector that uniquely identifies the element and with the rule body consisting of * the element's style attribute. * * @return {String} */ async getRuleText() { // Bail out if the rule is not supported or not an element inline style. if (!this.#isRuleSupported(true) && this.type !== ELEMENT_STYLE) { return ""; } let ruleBodyText; let selectorText; // For element inline styles, use the style attribute and generated unique selector. if (this.type === ELEMENT_STYLE) { ruleBodyText = this.rawNode.getAttribute("style"); selectorText = this.metadata.selector; } else { // Get the rule's authored text and skip any cached value. ruleBodyText = await this.getAuthoredCssText(true); const resourceId = this.pageStyle.styleSheetsManager.getStyleSheetResourceId( this._parentSheet ); const stylesheetText = await this.pageStyle.styleSheetsManager.getText( resourceId ); const [start, end] = getSelectorOffsets( stylesheetText, this.line, this.column ); selectorText = stylesheetText.substring(start, end); } const text = `${selectorText} {${ruleBodyText}}`; const { result } = SharedCssLogic.prettifyCSS(text); return result; } /** * Set the contents of the rule. This rewrites the rule in the * stylesheet and causes it to be re-evaluated. * * @param {String} newText * The new text of the rule * @param {Array} modifications * Array with modifications applied to the rule. Contains objects like: * { * type: "set", * index: , * name: , * value: , * priority: * } * or * { * type: "remove", * index: , * name: , * } * @returns the rule with updated properties */ async setRuleText(newText, modifications = []) { if (!this.canSetRuleText) { throw new Error("invalid call to setRuleText"); } if (this.type === ELEMENT_STYLE) { // For element style rules, set the node's style attribute. this.rawNode.setAttributeDevtools("style", newText); } else { const resourceId = this.pageStyle.styleSheetsManager.getStyleSheetResourceId( this._parentSheet ); const sheetText = await this.pageStyle.styleSheetsManager.getText( resourceId ); const cssText = InspectorUtils.replaceBlockRuleBodyTextInStylesheet( sheetText, this.line, this.column, newText ); if (typeof cssText !== "string") { throw new Error( "Error in InspectorUtils.replaceBlockRuleBodyTextInStylesheet" ); } // setStyleSheetText will parse the stylesheet which can be costly, so only do it // if the text has actually changed. if (sheetText !== newText) { await this.pageStyle.styleSheetsManager.setStyleSheetText( resourceId, cssText, { kind: UPDATE_PRESERVING_RULES } ); } } this.authoredText = newText; await this.updateAncestorRulesAuthoredText(); this.pageStyle.refreshObservedRules(this.ancestorRules); // Add processed modifications to the _pendingDeclarationChanges array, // they will be emitted as CSS_CHANGE resources once `declarations` have // been re-computed in `form`. this._pendingDeclarationChanges.push(...modifications); // Returning this updated actor over the protocol will update its corresponding front // and any references to it. return this; } /** * Update the authored text of the ancestor rules. This should be called when setting * the authored text of a (nested) rule, so all the references are properly updated. */ async updateAncestorRulesAuthoredText() { return Promise.all( this.ancestorRules.map(rule => rule.getAuthoredCssText(true)) ); } /** * Modify a rule's properties. Passed an array of modifications: * { * type: "set", * index: , * name: , * value: , * priority: * } * or * { * type: "remove", * index: , * name: , * } * * @returns the rule with updated properties */ modifyProperties(modifications) { // Use a fresh element for each call to this function to prevent side // effects that pop up based on property values that were already set on the // element. let document; if (this.rawNode) { document = this.rawNode.ownerDocument; } else { let parentStyleSheet = this._parentSheet; while (parentStyleSheet.ownerRule) { parentStyleSheet = parentStyleSheet.ownerRule.parentStyleSheet; } document = this.getDocument(parentStyleSheet); } const tempElement = document.createElementNS(XHTML_NS, "div"); for (const mod of modifications) { if (mod.type === "set") { tempElement.style.setProperty(mod.name, mod.value, mod.priority || ""); this.rawStyle.setProperty( mod.name, tempElement.style.getPropertyValue(mod.name), mod.priority || "" ); } else if (mod.type === "remove" || mod.type === "disable") { this.rawStyle.removeProperty(mod.name); } } this.pageStyle.refreshObservedRules(this.ancestorRules); // Add processed modifications to the _pendingDeclarationChanges array, // they will be emitted as CSS_CHANGE resources once `declarations` have // been re-computed in `form`. this._pendingDeclarationChanges.push(...modifications); return this; } /** * Helper function for modifySelector, inserts the new * rule with the new selector into the parent style sheet and removes the * current rule. Returns the newly inserted css rule or null if the rule is * unsuccessfully inserted to the parent style sheet. * * @param {String} value * The new selector value * @param {Boolean} editAuthored * True if the selector should be updated by editing the * authored text; false if the selector should be updated via * CSSOM. * * @returns {CSSRule} * The new CSS rule added */ async _addNewSelector(value, editAuthored) { const rule = this.rawRule; const parentStyleSheet = this._parentSheet; // We know the selector modification is ok, so if the client asked // for the authored text to be edited, do it now. if (editAuthored) { const document = this.getDocument(this._parentSheet); try { document.querySelector(value); } catch (e) { return null; } const resourceId = this.pageStyle.styleSheetsManager.getStyleSheetResourceId( this._parentSheet ); let authoredText = await this.pageStyle.styleSheetsManager.getText( resourceId ); const [startOffset, endOffset] = getSelectorOffsets( authoredText, this.line, this.column ); authoredText = authoredText.substring(0, startOffset) + value + authoredText.substring(endOffset); await this.pageStyle.styleSheetsManager.setStyleSheetText( resourceId, authoredText, { kind: UPDATE_PRESERVING_RULES } ); } else { // We retrieve the parent of the rule, which can be a regular stylesheet, but also // another rule, in case the underlying rule is nested. // If the rule is nested in another rule, we need to use its parent rule to "edit" it. // If the rule has no parent rules, we can simply use the stylesheet. const parent = this.rawRule.parentRule || parentStyleSheet; const cssRules = parent.cssRules; const cssText = rule.cssText; const selectorText = rule.selectorText; for (let i = 0; i < cssRules.length; i++) { if (rule === cssRules.item(i)) { try { // Inserts the new style rule into the current style sheet and // delete the current rule const ruleText = cssText.slice(selectorText.length).trim(); parent.insertRule(value + " " + ruleText, i); parent.deleteRule(i + 1); break; } catch (e) { // The selector could be invalid, or the rule could fail to insert. return null; } } } } await this.updateAncestorRulesAuthoredText(); return this._getRuleFromIndex(parentStyleSheet); } /** * Take an object with instructions to modify a CSS declaration and log an object with * normalized metadata which describes the change in the context of this rule. * * @param {Object} change * Data about a modification to a declaration. @see |modifyProperties()| * @param {Object} newDeclarations * The current declarations array to get the latest values, names... * @param {Object} oldDeclarations * The previous declarations array to use to fetch old values, names... */ logDeclarationChange(change, newDeclarations, oldDeclarations) { // Position of the declaration within its rule. const index = change.index; // Destructure properties from the previous CSS declaration at this index, if any, // to new variable names to indicate the previous state. let { value: prevValue, name: prevName, priority: prevPriority, commentOffsets, } = oldDeclarations[index] || {}; const { value: currentValue, name: currentName } = newDeclarations[index] || {}; // A declaration is disabled if it has a `commentOffsets` array. // Here we type coerce the value to a boolean with double-bang (!!) const prevDisabled = !!commentOffsets; // Append the "!important" string if defined in the previous priority flag. prevValue = prevValue && prevPriority ? `${prevValue} !important` : prevValue; const data = this.metadata; switch (change.type) { case "set": data.type = prevValue ? "declaration-add" : "declaration-update"; // If `change.newName` is defined, use it because the property is being renamed. // Otherwise, a new declaration is being created or the value of an existing // declaration is being updated. In that case, use the currentName computed // by the engine. const changeName = currentName || change.name; const name = change.newName ? change.newName : changeName; // Append the "!important" string if defined in the incoming priority flag. const changeValue = currentValue || change.value; const newValue = change.priority ? `${changeValue} !important` : changeValue; // Reuse the previous value string, when the property is renamed. // Otherwise, use the incoming value string. const value = change.newName ? prevValue : newValue; data.add = [{ property: name, value, index }]; // If there is a previous value, log its removal together with the previous // property name. Using the previous name handles the case for renaming a property // and is harmless when updating an existing value (the name stays the same). if (prevValue) { data.remove = [{ property: prevName, value: prevValue, index }]; } else { data.remove = null; } // When toggling a declaration from OFF to ON, if not renaming the property, // do not mark the previous declaration for removal, otherwise the add and // remove operations will cancel each other out when tracked. Tracked changes // have no context of "disabled", only "add" or remove, like diffs. if (prevDisabled && !change.newName && prevValue === newValue) { data.remove = null; } break; case "remove": data.type = "declaration-remove"; data.add = null; data.remove = [{ property: change.name, value: prevValue, index }]; break; case "disable": data.type = "declaration-disable"; data.add = null; data.remove = [{ property: change.name, value: prevValue, index }]; break; } TrackChangeEmitter.trackChange(data); } /** * Helper method for tracking CSS changes. Logs the change of this rule's selector as * two operations: a removal using the old selector and an addition using the new one. * * @param {String} oldSelector * This rule's previous selector. * @param {String} newSelector * This rule's new selector. */ logSelectorChange(oldSelector, newSelector) { TrackChangeEmitter.trackChange({ ...this.metadata, type: "selector-remove", add: null, remove: null, selector: oldSelector, }); TrackChangeEmitter.trackChange({ ...this.metadata, type: "selector-add", add: null, remove: null, selector: newSelector, }); } /** * Modify the current rule's selector by inserting a new rule with the new * selector value and removing the current rule. * * Returns information about the new rule and applied style * so that consumers can immediately display the new rule, whether or not the * selector matches the current element without having to refresh the whole * list. * * @param {DOMNode} node * The current selected element * @param {String} value * The new selector value * @param {Boolean} editAuthored * True if the selector should be updated by editing the * authored text; false if the selector should be updated via * CSSOM. * @returns {Object} * Returns an object that contains the applied style properties of the * new rule and a boolean indicating whether or not the new selector * matches the current selected element */ modifySelector(node, value, editAuthored = false) { if (this.type === ELEMENT_STYLE || this.rawRule.selectorText === value) { return { ruleProps: null, isMatching: true }; } // Nullify cached desugared selectors as it might be outdated this._desugaredSelectors = null; // The rule's previous selector is lost after calling _addNewSelector(). Save it now. const oldValue = this.rawRule.selectorText; let selectorPromise = this._addNewSelector(value, editAuthored); if (editAuthored) { selectorPromise = selectorPromise.then(newCssRule => { if (newCssRule) { this.logSelectorChange(oldValue, value); const style = this.pageStyle._styleRef(newCssRule); // See the comment in |form| to understand this. return style.getAuthoredCssText().then(() => newCssRule); } return newCssRule; }); } return selectorPromise.then(newCssRule => { let entries = null; let isMatching = false; if (newCssRule) { const ruleEntry = this.pageStyle.findEntryMatchingRule( node, newCssRule ); if (ruleEntry.length === 1) { entries = this.pageStyle.getAppliedProps(node, ruleEntry, { matchedSelectors: true, }); } else { entries = this.pageStyle.getNewAppliedProps(node, newCssRule); } isMatching = entries.some( ruleProp => !!ruleProp.matchedDesugaredSelectors.length ); } const result = { isMatching }; if (entries) { result.ruleProps = { entries }; } return result; }); } /** * Get the eligible query container for a given @container rule and a given node * * @param {Number} ancestorRuleIndex: The index of the @container rule in this.ancestorRules * @param {NodeActor} nodeActor: The nodeActor for which we want to retrieve the query container * @returns {Object} An object with the following properties: * - node: {NodeActor|null} The nodeActor representing the query container, * null if none were found * - containerType: {string} The computed `containerType` value of the query container * - inlineSize: {string} The computed `inlineSize` value of the query container (e.g. `120px`) * - blockSize: {string} The computed `blockSize` value of the query container (e.g. `812px`) */ getQueryContainerForNode(ancestorRuleIndex, nodeActor) { const ancestorRule = this.ancestorRules[ancestorRuleIndex]; if (!ancestorRule) { console.error( `Couldn't not find an ancestor rule at index ${ancestorRuleIndex}` ); return { node: null }; } const containerEl = ancestorRule.rawRule.queryContainerFor( nodeActor.rawNode ); // queryContainerFor returns null when the container name wasn't find in any ancestor. // In practice this shouldn't happen, as if the rule is applied, it means that an // elligible container was found. if (!containerEl) { return { node: null }; } const computedStyle = CssLogic.getComputedStyle(containerEl); return { node: this.pageStyle.walker.getNode(containerEl), containerType: computedStyle.containerType, inlineSize: computedStyle.inlineSize, blockSize: computedStyle.blockSize, }; } /** * Using the latest computed style applicable to the selected element, * check the states of declarations in this CSS rule. * * If any have changed their used/unused state, potentially as a result of changes in * another rule, fire a "rule-updated" event with this rule actor in its latest state. * * @param {Boolean} forceRefresh: Set to true to emit "rule-updated", even if the state * of the declarations didn't change. */ maybeRefresh(forceRefresh) { let hasChanged = false; const el = this.pageStyle.selectedElement; const style = CssLogic.getComputedStyle(el); for (const decl of this._declarations) { // TODO: convert from Object to Boolean. See Bug 1574471 const isUsed = isPropertyUsed(el, style, this.rawRule, decl.name); if (decl.isUsed.used !== isUsed.used) { decl.isUsed = isUsed; hasChanged = true; } } if (hasChanged || forceRefresh) { // ⚠️ IMPORTANT ⚠️ // When an event is emitted via the protocol with the StyleRuleActor as payload, the // corresponding StyleRuleFront will be automatically updated under the hood. // Therefore, when the client looks up properties on the front reference it already // has, it will get the latest values set on the actor, not the ones it originally // had when the front was created. The client is not required to explicitly replace // its previous front reference to the one it receives as this event's payload. // The client doesn't even need to explicitly listen for this event. // The update of the front happens automatically. this.emit("rule-updated", this); } } } exports.StyleRuleActor = StyleRuleActor; /** * Compute the start and end offsets of a rule's selector text, given * the CSS text and the line and column at which the rule begins. * @param {String} initialText * @param {Number} line (1-indexed) * @param {Number} column (1-indexed) * @return {array} An array with two elements: [startOffset, endOffset]. * The elements mark the bounds in |initialText| of * the CSS rule's selector. */ function getSelectorOffsets(initialText, line, column) { if (typeof line === "undefined" || typeof column === "undefined") { throw new Error("Location information is missing"); } const { offset: textOffset, text } = getTextAtLineColumn( initialText, line, column ); const lexer = getCSSLexer(text); // Search forward for the opening brace. let endOffset; while (true) { const token = lexer.nextToken(); if (!token) { break; } if (token.tokenType === "symbol" && token.text === "{") { if (endOffset === undefined) { break; } return [textOffset, textOffset + endOffset]; } // Preserve comments and whitespace just before the "{". if (token.tokenType !== "comment" && token.tokenType !== "whitespace") { endOffset = token.endOffset; } } throw new Error("could not find bounds of rule"); }