summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/style-rule.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/style-rule.js')
-rw-r--r--devtools/server/actors/style-rule.js1168
1 files changed, 1168 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..6d1989fc74
--- /dev/null
+++ b/devtools/server/actors/style-rule.js
@@ -0,0 +1,1168 @@
+/* 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 protocol = require("devtools/shared/protocol");
+const { getCSSLexer } = require("devtools/shared/css/lexer");
+const InspectorUtils = require("InspectorUtils");
+const TrackChangeEmitter = require("devtools/server/actors/utils/track-change-emitter");
+
+const {
+ getRuleText,
+ getTextAtLineColumn,
+} = require("devtools/server/actors/utils/style-utils");
+
+const { styleRuleSpec } = require("devtools/shared/specs/style-rule");
+const {
+ style: { ELEMENT_STYLE },
+} = require("devtools/shared/constants");
+
+loader.lazyRequireGetter(
+ this,
+ "CssLogic",
+ "devtools/server/actors/inspector/css-logic",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "SharedCssLogic",
+ "devtools/shared/inspector/css-logic"
+);
+loader.lazyRequireGetter(
+ this,
+ ["CSSRuleTypeName", "findCssSelector", "prettifyCSS"],
+ "devtools/shared/inspector/css-logic",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "isCssPropertyKnown",
+ "devtools/server/actors/css-properties",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "inactivePropertyHelper",
+ "devtools/server/actors/utils/inactive-property-helper",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "parseNamedDeclarations",
+ "devtools/shared/css/parsing-utils",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["UPDATE_PRESERVING_RULES", "UPDATE_GENERAL"],
+ "devtools/server/actors/style-sheet",
+ true
+);
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+const SUPPORTED_RULE_TYPES = [
+ CSSRule.STYLE_RULE,
+ CSSRule.SUPPORTS_RULE,
+ CSSRule.KEYFRAME_RULE,
+ CSSRule.KEYFRAMES_RULE,
+ CSSRule.MEDIA_RULE,
+];
+
+/**
+ * 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).
+ */
+const StyleRuleActor = protocol.ActorClassWithSpec(styleRuleSpec, {
+ initialize: function(pageStyle, item) {
+ protocol.Actor.prototype.initialize.call(this, null);
+ this.pageStyle = pageStyle;
+ this.rawStyle = item.style;
+ this._parentSheet = null;
+ this._onStyleApplied = this._onStyleApplied.bind(this);
+ // 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 = [];
+
+ if (CSSRule.isInstance(item)) {
+ this.type = item.type;
+ this.rawRule = item;
+ this._computeRuleIndex();
+ if (
+ SUPPORTED_RULE_TYPES.includes(this.type) &&
+ this.rawRule.parentStyleSheet
+ ) {
+ this.line = InspectorUtils.getRelativeRuleLine(this.rawRule);
+ this.column = InspectorUtils.getRuleColumn(this.rawRule);
+ this._parentSheet = this.rawRule.parentStyleSheet;
+ if (!this.pageStyle.styleSheetWatcher) {
+ this.sheetActor = this.pageStyle._sheetRef(this._parentSheet);
+ this.sheetActor.on("style-applied", this._onStyleApplied);
+ }
+ }
+ } else {
+ // Fake a rule
+ this.type = ELEMENT_STYLE;
+ this.rawNode = item;
+ this.rawRule = {
+ style: item.style,
+ toString: function() {
+ return "[element rule " + this.style + "]";
+ },
+ };
+ }
+ },
+
+ get conn() {
+ return this.pageStyle.conn;
+ },
+
+ destroy: function() {
+ if (!this.rawStyle) {
+ return;
+ }
+ protocol.Actor.prototype.destroy.call(this);
+ this.rawStyle = null;
+ this.pageStyle = null;
+ this.rawNode = null;
+ this.rawRule = null;
+ this._declarations = null;
+ if (this.sheetActor) {
+ this.sheetActor.off("style-applied", this._onStyleApplied);
+ }
+ },
+
+ // 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() {
+ return (
+ this.type === ELEMENT_STYLE ||
+ (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
+ !InspectorUtils.hasRulesModifiedByCSSOM(this._parentSheet) &&
+ // Special case about:PreferenceStyleSheet, as it is generated on
+ // the fly and the URI is not registered with the about:handler
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=935803#c37
+ this._parentSheet.href !== "about:PreferenceStyleSheet")
+ );
+ },
+
+ /**
+ * 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).
+ // Used to show context for this change in the UI and to match the rule for undo/redo.
+ data.ancestors = this.ancestorRules.map(rule => {
+ return {
+ id: rule.actorID,
+ // Rule type as number defined by CSSRule.type (ex: 4, 7, 12)
+ // @see https://developer.mozilla.org/en-US/docs/Web/API/CSSRule
+ type: rule.rawRule.type,
+ // Rule type as human-readable string (ex: "@media", "@supports", "@keyframes")
+ typeName: CSSRuleTypeName[rule.rawRule.type],
+ // Conditions of @media and @supports rules (ex: "min-width: 1em")
+ conditionText: rule.rawRule.conditionText,
+ // Name of @keyframes rule; refrenced by the animation-name CSS property.
+ name: rule.rawRule.name,
+ // Selector of individual @keyframe rule within a @keyframes rule (ex: 0%, 100%).
+ keyText: rule.rawRule.keyText,
+ // Array with the indexes of this rule and its ancestors within the CSS rule tree.
+ ruleIndex: rule._ruleIndex,
+ };
+ });
+
+ // 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 = 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.type === CSSRule.KEYFRAME_RULE
+ ? this.rawRule.keyText
+ : this.rawRule.selectorText;
+ // Used to differentiate between changes to rules with identical selectors.
+ data.ruleIndex = this._ruleIndex;
+
+ if (this.pageStyle.styleSheetWatcher) {
+ const watcher = this.pageStyle.styleSheetWatcher;
+ const sheet = this._parentSheet;
+ const inspectorActor = this.pageStyle.inspector;
+ const resourceId = watcher.getResourceId(sheet);
+ const styleSheetIndex = watcher.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,
+ };
+ } else {
+ data.source = {
+ // Inline stylesheets have a null href; Use window URL instead.
+ type: this.sheetActor.href ? "stylesheet" : "inline",
+ href:
+ this.sheetActor.href || this.sheetActor.window.location.toString(),
+ id: this.sheetActor.actorID,
+ index: this.sheetActor.styleSheetIndex,
+ // Whether the stylesheet lives in a different frame than the host document.
+ isFramed: this.sheetActor.ownerWindow !== this.sheetActor.window,
+ };
+ }
+ }
+
+ return data;
+ },
+
+ getDocument: function(sheet) {
+ if (sheet.ownerNode) {
+ return sheet.ownerNode.nodeType == sheet.ownerNode.DOCUMENT_NODE
+ ? sheet.ownerNode
+ : sheet.ownerNode.ownerDocument;
+ } else if (sheet.parentStyleSheet) {
+ return this.getDocument(sheet.parentStyleSheet);
+ }
+ throw new Error(
+ "Failed trying to get the document of an invalid stylesheet"
+ );
+ },
+
+ toString: function() {
+ return "[StyleRuleActor for " + this.rawRule + "]";
+ },
+
+ // eslint-disable-next-line complexity
+ form: function() {
+ 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,
+ },
+ };
+
+ if (this.rawRule.parentRule) {
+ form.parentRule = this.pageStyle._styleRef(
+ this.rawRule.parentRule
+ ).actorID;
+
+ // CSS rules that we call media rules are STYLE_RULES that are children
+ // of MEDIA_RULEs. We need to check the parentRule to check if a rule is
+ // a media rule so we do this here instead of in the switch statement
+ // below.
+ if (this.rawRule.parentRule.type === CSSRule.MEDIA_RULE) {
+ form.media = [];
+ for (let i = 0, n = this.rawRule.parentRule.media.length; i < n; i++) {
+ form.media.push(this.rawRule.parentRule.media.item(i));
+ }
+ }
+ }
+ if (this._parentSheet) {
+ if (this.pageStyle.styleSheetWatcher) {
+ form.parentStyleSheet = this.pageStyle.styleSheetWatcher.getResourceId(
+ this._parentSheet
+ );
+ } else {
+ form.parentStyleSheet = this.pageStyle._sheetRef(
+ this._parentSheet
+ ).actorID;
+ }
+ }
+
+ // 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.type) {
+ case CSSRule.STYLE_RULE:
+ form.selectors = CssLogic.getSelectors(this.rawRule);
+ 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 CSSRule.CHARSET_RULE:
+ form.encoding = this.rawRule.encoding;
+ break;
+ case CSSRule.IMPORT_RULE:
+ form.href = this.rawRule.href;
+ break;
+ case CSSRule.KEYFRAMES_RULE:
+ form.cssText = this.rawRule.cssText;
+ form.name = this.rawRule.name;
+ break;
+ case CSSRule.KEYFRAME_RULE:
+ 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 = inactivePropertyHelper.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
+ );
+ return decl;
+ });
+
+ // 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;
+ },
+
+ /**
+ * 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: function(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: function() {
+ let rule = this.rawRule;
+ const result = [];
+
+ while (rule) {
+ let cssRules = [];
+ if (rule.parentRule) {
+ cssRules = rule.parentRule.cssRules;
+ } else if (rule.parentStyleSheet) {
+ cssRules = rule.parentStyleSheet.cssRules;
+ }
+
+ let found = false;
+ for (let i = 0; i < cssRules.length; i++) {
+ if (rule === cssRules.item(i)) {
+ found = true;
+ result.unshift(i);
+ break;
+ }
+ }
+
+ if (!found) {
+ this._ruleIndex = null;
+ return;
+ }
+
+ rule = rule.parentRule;
+ }
+
+ this._ruleIndex = result;
+ },
+
+ /**
+ * 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: function(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;
+ },
+
+ /**
+ * This is attached to the parent style sheet actor's
+ * "style-applied" event.
+ */
+ _onStyleApplied: function(kind) {
+ if (kind === UPDATE_GENERAL) {
+ // A general change means that the rule actors are invalidated,
+ // so stop listening to events now.
+ if (this.sheetActor) {
+ this.sheetActor.off("style-applied", this._onStyleApplied);
+ }
+ } else 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;
+ }
+ },
+
+ /**
+ * 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.
+ */
+ getAuthoredCssText: async function(skipCache = false) {
+ if (!this.canSetRuleText || !SUPPORTED_RULE_TYPES.includes(this.type)) {
+ return Promise.resolve("");
+ }
+
+ if (typeof this.authoredText === "string" && !skipCache) {
+ return Promise.resolve(this.authoredText);
+ }
+
+ if (this.pageStyle.styleSheetWatcher) {
+ await this.pageStyle.styleSheetWatcher.ensureResourceAvailable(
+ this._parentSheet
+ );
+ const resourceId = this.pageStyle.styleSheetWatcher.getResourceId(
+ this._parentSheet
+ );
+ const cssText = await this.pageStyle.styleSheetWatcher.getText(
+ resourceId
+ );
+ const { text } = getRuleText(cssText, this.line, this.column);
+
+ // Cache the result on the rule actor to avoid parsing again next time
+ this.authoredText = text;
+ return this.authoredText;
+ }
+
+ return this.sheetActor.getText().then(longStr => {
+ const cssText = longStr.str;
+ const { text } = getRuleText(cssText, this.line, this.column);
+
+ // Cache the result on the rule actor to avoid parsing again next time
+ this.authoredText = text;
+ 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}
+ */
+ getRuleText: async function() {
+ // Bail out if the rule is not supported or not an element inline style.
+ if (![...SUPPORTED_RULE_TYPES, ELEMENT_STYLE].includes(this.type)) {
+ return Promise.resolve("");
+ }
+
+ let ruleBodyText;
+ let selectorText;
+ let text;
+
+ // 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);
+
+ let stylesheetText = null;
+ if (this.pageStyle.styleSheetWatcher) {
+ await this.pageStyle.styleSheetWatcher.ensureResourceAvailable(
+ this._parentSheet
+ );
+ const resourceId = this.pageStyle.styleSheetWatcher.getResourceId(
+ this._parentSheet
+ );
+ stylesheetText = await this.pageStyle.styleSheetWatcher.getText(
+ resourceId
+ );
+ } else {
+ const { str } = await this.sheetActor.getText();
+ stylesheetText = str;
+ }
+
+ const [start, end] = getSelectorOffsets(
+ stylesheetText,
+ this.line,
+ this.column
+ );
+ selectorText = stylesheetText.substring(start, end);
+ }
+
+ // CSS rule type as a string "@media", "@supports", "@keyframes", etc.
+ const typeName = CSSRuleTypeName[this.type];
+
+ // When dealing with at-rules, getSelectorOffsets() will not return the rule type.
+ // We prepend it ourselves.
+ if (typeName) {
+ text = `${typeName}${selectorText} {${ruleBodyText}}`;
+ } else {
+ text = `${selectorText} {${ruleBodyText}}`;
+ }
+
+ const { result } = prettifyCSS(text);
+ return Promise.resolve(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");
+ }
+
+ // Log the changes before applying them so we have access to the previous values.
+ modifications.map(mod => this.logDeclarationChange(mod));
+
+ if (this.type === ELEMENT_STYLE) {
+ // For element style rules, set the node's style attribute.
+ this.rawNode.setAttributeDevtools("style", newText);
+ } else if (this.pageStyle.styleSheetWatcher) {
+ await this.pageStyle.styleSheetWatcher.ensureResourceAvailable(
+ this._parentSheet
+ );
+ const resourceId = this.pageStyle.styleSheetWatcher.getResourceId(
+ this._parentSheet
+ );
+ let cssText = await this.pageStyle.styleSheetWatcher.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.styleSheetWatcher.update(
+ resourceId,
+ cssText,
+ false,
+ UPDATE_PRESERVING_RULES
+ );
+ } else {
+ // For stylesheet rules, set the text in the stylesheet.
+ const parentStyleSheet = this.pageStyle._sheetRef(this._parentSheet);
+ let { str: cssText } = await parentStyleSheet.getText();
+
+ const { offset, text } = getRuleText(cssText, this.line, this.column);
+ cssText =
+ cssText.substring(0, offset) +
+ newText +
+ cssText.substring(offset + text.length);
+
+ await parentStyleSheet.update(cssText, false, UPDATE_PRESERVING_RULES);
+ }
+
+ this.authoredText = newText;
+ this.pageStyle.refreshObservedRules();
+
+ // Returning this updated actor over the protocol will update its corresponding front
+ // and any references to it.
+ return this;
+ },
+
+ /**
+ * 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: function(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) {
+ this.logDeclarationChange(mod);
+ 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();
+
+ 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;
+ }
+
+ if (this.pageStyle.styleSheetWatcher) {
+ await this.pageStyle.styleSheetWatcher.ensureResourceAvailable(
+ this._parentSheet
+ );
+ const resourceId = this.pageStyle.styleSheetWatcher.getResourceId(
+ this._parentSheet
+ );
+ let authoredText = await this.pageStyle.styleSheetWatcher.getText(
+ resourceId
+ );
+
+ const [startOffset, endOffset] = getSelectorOffsets(
+ authoredText,
+ this.line,
+ this.column
+ );
+ authoredText =
+ authoredText.substring(0, startOffset) +
+ value +
+ authoredText.substring(endOffset);
+
+ await this.pageStyle.styleSheetWatcher.update(
+ resourceId,
+ authoredText,
+ false,
+ UPDATE_PRESERVING_RULES
+ );
+ } else {
+ const sheetActor = this.pageStyle._sheetRef(parentStyleSheet);
+ let { str: authoredText } = await sheetActor.getText();
+
+ const [startOffset, endOffset] = getSelectorOffsets(
+ authoredText,
+ this.line,
+ this.column
+ );
+ authoredText =
+ authoredText.substring(0, startOffset) +
+ value +
+ authoredText.substring(endOffset);
+
+ await sheetActor.update(authoredText, false, UPDATE_PRESERVING_RULES);
+ }
+ } else {
+ const cssRules = parentStyleSheet.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();
+ parentStyleSheet.insertRule(value + " " + ruleText, i);
+ parentStyleSheet.deleteRule(i + 1);
+ break;
+ } catch (e) {
+ // The selector could be invalid, or the rule could fail to insert.
+ return null;
+ }
+ }
+ }
+ }
+
+ 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()|
+ */
+ logDeclarationChange(change) {
+ // 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,
+ } = this._declarations[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 provided `change.name`.
+ const name = change.newName ? change.newName : change.name;
+ // Append the "!important" string if defined in the incoming priority flag.
+ const newValue = change.priority
+ ? `${change.value} !important`
+ : change.value;
+ // 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: function(node, value, editAuthored = false) {
+ if (this.type === ELEMENT_STYLE || this.rawRule.selectorText === value) {
+ return { ruleProps: null, isMatching: true };
+ }
+
+ // 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 ruleProps = null;
+ let isMatching = false;
+
+ if (newCssRule) {
+ const ruleEntry = this.pageStyle.findEntryMatchingRule(
+ node,
+ newCssRule
+ );
+ if (ruleEntry.length === 1) {
+ ruleProps = this.pageStyle.getAppliedProps(node, ruleEntry, {
+ matchedSelectors: true,
+ });
+ } else {
+ ruleProps = this.pageStyle.getNewAppliedProps(node, newCssRule);
+ }
+
+ isMatching = ruleProps.entries.some(
+ ruleProp => ruleProp.matchedSelectors.length > 0
+ );
+ }
+
+ return { ruleProps, isMatching };
+ });
+ },
+
+ /**
+ * 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.
+ */
+ refresh() {
+ 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 = inactivePropertyHelper.isPropertyUsed(
+ el,
+ style,
+ this.rawRule,
+ decl.name
+ );
+
+ if (decl.isUsed.used !== isUsed.used) {
+ decl.isUsed = isUsed;
+ hasChanged = true;
+ }
+ }
+
+ if (hasChanged) {
+ // ⚠️ 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");
+}