diff options
Diffstat (limited to 'devtools/server/actors/style-rule.js')
-rw-r--r-- | devtools/server/actors/style-rule.js | 1328 |
1 files changed, 1328 insertions, 0 deletions
diff --git a/devtools/server/actors/style-rule.js b/devtools/server/actors/style-rule.js new file mode 100644 index 0000000000..e9f39fa3d0 --- /dev/null +++ b/devtools/server/actors/style-rule.js @@ -0,0 +1,1328 @@ +/* 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<String> + */ + 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<Object>} 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: <number>, + * name: <string>, + * value: <string>, + * priority: <optional string> + * } + * or + * { + * type: "remove", + * index: <number>, + * name: <string>, + * } + * @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 + ); + let cssText = await this.pageStyle.styleSheetsManager.getText(resourceId); + + const { offset, text } = getRuleText(cssText, this.line, this.column); + cssText = + cssText.substring(0, offset) + + newText + + cssText.substring(offset + text.length); + + 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: <number>, + * name: <string>, + * value: <string>, + * priority: <optional string> + * } + * or + * { + * type: "remove", + * index: <number>, + * name: <string>, + * } + * + * @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"); +} |