gecko-dev/devtools/server/actors/style-rule.js

1160 строки
39 KiB
JavaScript

/* 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.hasStyleSheetWatcherSupport) {
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.hasStyleSheetWatcherSupport) {
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,
};
} 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.hasStyleSheetWatcherSupport) {
form.parentStyleSheet = this.pageStyle.styleSheetsManager.getStyleSheetResourceId(
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.hasStyleSheetWatcherSupport) {
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.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.hasStyleSheetWatcherSupport) {
const resourceId = this.pageStyle.styleSheetsManager.getStyleSheetResourceId(
this._parentSheet
);
stylesheetText = await this.pageStyle.styleSheetsManager.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.hasStyleSheetWatcherSupport) {
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.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.hasStyleSheetWatcherSupport) {
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.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");
}