/* 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 Services = require("Services"); const protocol = require("devtools/shared/protocol"); const { getCSSLexer } = require("devtools/shared/css/lexer"); const { LongStringActor } = require("devtools/server/actors/string"); const InspectorUtils = require("InspectorUtils"); const TrackChangeEmitter = require("devtools/server/actors/utils/track-change-emitter"); // This will also add the "stylesheet" actor type for protocol.js to recognize const { pageStyleSpec, styleRuleSpec, ELEMENT_STYLE, } = require("devtools/shared/specs/styles"); 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, "getDefinedGeometryProperties", "devtools/server/actors/highlighters/geometry-editor", 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/stylesheets", true ); loader.lazyGetter(this, "PSEUDO_ELEMENTS", () => { return InspectorUtils.getCSSPseudoElementNames(); }); loader.lazyGetter(this, "FONT_VARIATIONS_ENABLED", () => { return Services.prefs.getBoolPref("layout.css.font-variations.enabled"); }); const XHTML_NS = "http://www.w3.org/1999/xhtml"; const FONT_PREVIEW_TEXT = "Abc"; const FONT_PREVIEW_FONT_SIZE = 40; const FONT_PREVIEW_FILLSTYLE = "black"; const NORMAL_FONT_WEIGHT = 400; const BOLD_FONT_WEIGHT = 700; // Offset (in px) to avoid cutting off text edges of italic fonts. const FONT_PREVIEW_OFFSET = 4; /** * The PageStyle actor lets the client look at the styles on a page, as * they are applied to a given node. */ var PageStyleActor = protocol.ActorClassWithSpec(pageStyleSpec, { /** * Create a PageStyleActor. * * @param inspector * The InspectorActor that owns this PageStyleActor. * * @constructor */ initialize: function(inspector) { protocol.Actor.prototype.initialize.call(this, null); this.inspector = inspector; if (!this.inspector.walker) { throw Error( "The inspector's WalkerActor must be created before " + "creating a PageStyleActor." ); } this.walker = inspector.walker; this.cssLogic = new CssLogic(); // Stores the association of DOM objects -> actors this.refMap = new Map(); // Latest node queried for its applied styles. this.selectedElement = null; // Maps document elements to style elements, used to add new rules. this.styleElements = new WeakMap(); this.onFrameUnload = this.onFrameUnload.bind(this); this.onStyleSheetAdded = this.onStyleSheetAdded.bind(this); this.inspector.targetActor.on("will-navigate", this.onFrameUnload); this.inspector.targetActor.on("stylesheet-added", this.onStyleSheetAdded); this._observedRules = []; this._styleApplied = this._styleApplied.bind(this); this._watchedSheets = new Set(); }, destroy: function() { if (!this.walker) { return; } protocol.Actor.prototype.destroy.call(this); this.inspector.targetActor.off("will-navigate", this.onFrameUnload); this.inspector.targetActor.off("stylesheet-added", this.onStyleSheetAdded); this.inspector = null; this.walker = null; this.refMap = null; this.selectedElement = null; this.cssLogic = null; this.styleElements = null; for (const sheet of this._watchedSheets) { sheet.off("style-applied", this._styleApplied); } this._observedRules = []; this._watchedSheets.clear(); }, get conn() { return this.inspector.conn; }, get ownerWindow() { return this.inspector.targetActor.window; }, form: function() { // We need to use CSS from the inspected window in order to use CSS.supports() and // detect the right platform features from there. const CSS = this.inspector.targetActor.window.CSS; return { actor: this.actorID, traits: { // Whether the page supports values of font-stretch from CSS Fonts Level 4. fontStretchLevel4: CSS.supports("font-stretch: 100%"), // Whether the page supports values of font-style from CSS Fonts Level 4. fontStyleLevel4: CSS.supports("font-style: oblique 20deg"), // Whether getAllUsedFontFaces/getUsedFontFaces accepts the includeVariations // argument. fontVariations: FONT_VARIATIONS_ENABLED, // Whether the page supports values of font-weight from CSS Fonts Level 4. // font-weight at CSS Fonts Level 4 accepts values in increments of 1 rather // than 100. However, CSS.supports() returns false positives, so we guard with the // expected support of font-stretch at CSS Fonts Level 4. fontWeightLevel4: CSS.supports("font-weight: 1") && CSS.supports("font-stretch: 100%"), // Introduced in Firefox 80. getAttributesInOwnerDocument: true, }, }; }, /** * Called when a style sheet is updated. */ _styleApplied: function(kind, styleSheet) { // No matter what kind of update is done, we need to invalidate // the keyframe cache. this.cssLogic.reset(); if (kind === UPDATE_GENERAL) { this.emit("stylesheet-updated", styleSheet); } }, /** * Return or create a StyleRuleActor for the given item. * @param item Either a CSSStyleRule or a DOM element. */ _styleRef: function(item) { if (this.refMap.has(item)) { return this.refMap.get(item); } const actor = StyleRuleActor(this, item); this.manage(actor); this.refMap.set(item, actor); return actor; }, /** * Update the association between a StyleRuleActor and its * corresponding item. This is used when a StyleRuleActor updates * as style sheet and starts using a new rule. * * @param oldItem The old association; either a CSSStyleRule or a * DOM element. * @param item Either a CSSStyleRule or a DOM element. * @param actor a StyleRuleActor */ updateStyleRef: function(oldItem, item, actor) { this.refMap.delete(oldItem); this.refMap.set(item, actor); }, /** * Return or create a StyleSheetActor for the given CSSStyleSheet. * @param {CSSStyleSheet} sheet * The style sheet to create an actor for. * @return {StyleSheetActor} * The actor for this style sheet */ _sheetRef: function(sheet) { const targetActor = this.inspector.targetActor; const actor = targetActor.createStyleSheetActor(sheet); return actor; }, /** * Get the StyleRuleActor matching the given rule id or null if no match is found. * * @param {String} ruleId * Actor ID of the StyleRuleActor * @return {StyleRuleActor|null} */ getRule: function(ruleId) { let match = null; for (const actor of this.refMap.values()) { if (actor.actorID === ruleId) { match = actor; continue; } } return match; }, /** * Get the computed style for a node. * * @param NodeActor node * @param object options * `filter`: A string filter that affects the "matched" handling. * 'user': Include properties from user style sheets. * 'ua': Include properties from user and user-agent sheets. * Default value is 'ua' * `markMatched`: true if you want the 'matched' property to be added * when a computed property has been modified by a style included * by `filter`. * `onlyMatched`: true if unmatched properties shouldn't be included. * `filterProperties`: An array of properties names that you would like * returned. * * @returns a JSON blob with the following form: * { * "property-name": { * value: "property-value", * priority: "!important" * matched: * }, * ... * } */ getComputed: function(node, options) { const ret = Object.create(null); this.cssLogic.sourceFilter = options.filter || SharedCssLogic.FILTER.UA; this.cssLogic.highlight(node.rawNode); const computed = this.cssLogic.computedStyle || []; Array.prototype.forEach.call(computed, name => { if ( Array.isArray(options.filterProperties) && !options.filterProperties.includes(name) ) { return; } ret[name] = { value: computed.getPropertyValue(name), priority: computed.getPropertyPriority(name) || undefined, }; }); if (options.markMatched || options.onlyMatched) { const matched = this.cssLogic.hasMatchedSelectors(Object.keys(ret)); for (const key in ret) { if (matched[key]) { ret[key].matched = options.markMatched ? true : undefined; } else if (options.onlyMatched) { delete ret[key]; } } } return ret; }, /** * Get all the fonts from a page. * * @param object options * `includePreviews`: Whether to also return image previews of the fonts. * `previewText`: The text to display in the previews. * `previewFontSize`: The font size of the text in the previews. * * @returns object * object with 'fontFaces', a list of fonts that apply to this node. */ getAllUsedFontFaces: function(options) { const windows = this.inspector.targetActor.windows; let fontsList = []; for (const win of windows) { // Fall back to the documentElement for XUL documents. const node = win.document.body ? win.document.body : win.document.documentElement; fontsList = [...fontsList, ...this.getUsedFontFaces(node, options)]; } return fontsList; }, /** * Get the font faces used in an element. * * @param NodeActor node / actual DOM node * The node to get fonts from. * @param object options * `includePreviews`: Whether to also return image previews of the fonts. * `previewText`: The text to display in the previews. * `previewFontSize`: The font size of the text in the previews. * * @returns object * object with 'fontFaces', a list of fonts that apply to this node. */ getUsedFontFaces: function(node, options) { // node.rawNode is defined for NodeActor objects const actualNode = node.rawNode || node; const contentDocument = actualNode.ownerDocument; // We don't get fonts for a node, but for a range const rng = contentDocument.createRange(); const isPseudoElement = Boolean( CssLogic.getBindingElementAndPseudo(actualNode).pseudo ); if (isPseudoElement) { rng.selectNodeContents(actualNode); } else { rng.selectNode(actualNode); } const fonts = InspectorUtils.getUsedFontFaces(rng); const fontsArray = []; for (let i = 0; i < fonts.length; i++) { const font = fonts[i]; const fontFace = { name: font.name, CSSFamilyName: font.CSSFamilyName, CSSGeneric: font.CSSGeneric || null, srcIndex: font.srcIndex, URI: font.URI, format: font.format, localName: font.localName, metadata: font.metadata, }; // If this font comes from a @font-face rule if (font.rule) { const styleActor = StyleRuleActor(this, font.rule); this.manage(styleActor); fontFace.rule = styleActor; fontFace.ruleText = font.rule.cssText; } // Get the weight and style of this font for the preview and sort order let weight = NORMAL_FONT_WEIGHT, style = ""; if (font.rule) { weight = font.rule.style.getPropertyValue("font-weight") || NORMAL_FONT_WEIGHT; if (weight == "bold") { weight = BOLD_FONT_WEIGHT; } else if (weight == "normal") { weight = NORMAL_FONT_WEIGHT; } style = font.rule.style.getPropertyValue("font-style") || ""; } fontFace.weight = weight; fontFace.style = style; if (options.includePreviews) { const opts = { previewText: options.previewText, previewFontSize: options.previewFontSize, fontStyle: weight + " " + style, fillStyle: options.previewFillStyle, }; const { dataURL, size } = getFontPreviewData( font.CSSFamilyName, contentDocument, opts ); fontFace.preview = { data: LongStringActor(this.conn, dataURL), size: size, }; } if (options.includeVariations && FONT_VARIATIONS_ENABLED) { fontFace.variationAxes = font.getVariationAxes(); fontFace.variationInstances = font.getVariationInstances(); } fontsArray.push(fontFace); } // @font-face fonts at the top, then alphabetically, then by weight fontsArray.sort(function(a, b) { return a.weight > b.weight ? 1 : -1; }); fontsArray.sort(function(a, b) { if (a.CSSFamilyName == b.CSSFamilyName) { return 0; } return a.CSSFamilyName > b.CSSFamilyName ? 1 : -1; }); fontsArray.sort(function(a, b) { if ((a.rule && b.rule) || (!a.rule && !b.rule)) { return 0; } return !a.rule && b.rule ? 1 : -1; }); return fontsArray; }, /** * Get a list of selectors that match a given property for a node. * * @param NodeActor node * @param string property * @param object options * `filter`: A string filter that affects the "matched" handling. * 'user': Include properties from user style sheets. * 'ua': Include properties from user and user-agent sheets. * Default value is 'ua' * * @returns a JSON object with the following form: * { * // An ordered list of rules that apply * matched: [{ * rule: , * sourceText: , // The source of the selector, relative * // to the node in question. * selector: , // the selector ID that matched * value: , // the value of the property * status: , * // The status of the match - high numbers are better placed * // to provide styling information: * // 3: Best match, was used. * // 2: Matched, but was overridden. * // 1: Rule from a parent matched. * // 0: Unmatched (never returned in this API) * }, ...], * * // The full form of any domrule referenced. * rules: [ , ... ], // The full form of any domrule referenced * * // The full form of any sheets referenced. * sheets: [ , ... ] * } */ getMatchedSelectors: function(node, property, options) { this.cssLogic.sourceFilter = options.filter || SharedCssLogic.FILTER.UA; this.cssLogic.highlight(node.rawNode); const rules = new Set(); const sheets = new Set(); const matched = []; const propInfo = this.cssLogic.getPropertyInfo(property); for (const selectorInfo of propInfo.matchedSelectors) { const cssRule = selectorInfo.selector.cssRule; const domRule = cssRule.sourceElement || cssRule.domRule; const rule = this._styleRef(domRule); rules.add(rule); matched.push({ rule: rule, sourceText: this.getSelectorSource(selectorInfo, node.rawNode), selector: selectorInfo.selector.text, name: selectorInfo.property, value: selectorInfo.value, status: selectorInfo.status, }); } this.expandSets(rules, sheets); return { matched: matched, rules: [...rules], sheets: [...sheets], }; }, // Get a selector source for a CssSelectorInfo relative to a given // node. getSelectorSource: function(selectorInfo, relativeTo) { let result = selectorInfo.selector.text; if (selectorInfo.inlineStyle) { const source = selectorInfo.sourceElement; if (source === relativeTo) { result = "this"; } else { result = CssLogic.getShortName(source); } result += ".style"; } return result; }, /** * Get the set of styles that apply to a given node. * @param NodeActor node * @param object options * `filter`: A string filter that affects the "matched" handling. * 'user': Include properties from user style sheets. * 'ua': Include properties from user and user-agent sheets. * Default value is 'ua' * `inherited`: Include styles inherited from parent nodes. * `matchedSelectors`: Include an array of specific selectors that * caused this rule to match its node. * `skipPseudo`: Exclude styles applied to pseudo elements of the provided node. */ async getApplied(node, options) { // Clear any previous references to StyleRuleActor instances for CSS rules. // Assume the consumer has switched context to a new node and no longer // interested in state changes of previous rules. this._observedRules = []; this.selectedElement = node.rawNode; if (!node) { return { entries: [], rules: [], sheets: [] }; } this.cssLogic.highlight(node.rawNode); let entries = []; entries = entries.concat( this._getAllElementRules(node, undefined, options) ); const result = this.getAppliedProps(node, entries, options); for (const rule of result.rules) { // See the comment in |form| to understand this. await rule.getAuthoredCssText(); } // Reference to instances of StyleRuleActor for CSS rules matching the node. // Assume these are used by a consumer which wants to be notified when their // state or declarations change either directly or indirectly. this._observedRules = result.rules; return result; }, _hasInheritedProps: function(style) { return Array.prototype.some.call(style, prop => { return InspectorUtils.isInheritedProperty(prop); }); }, async isPositionEditable(node) { if (!node || node.rawNode.nodeType !== node.rawNode.ELEMENT_NODE) { return false; } const props = getDefinedGeometryProperties(node.rawNode); // Elements with only `width` and `height` are currently not considered // editable. return ( props.has("top") || props.has("right") || props.has("left") || props.has("bottom") ); }, /** * Helper function for getApplied, gets all the rules from a given * element. See getApplied for documentation on parameters. * @param NodeActor node * @param bool inherited * @param object options * @return Array The rules for a given element. Each item in the * array has the following signature: * - rule RuleActor * - isSystem Boolean * - inherited Boolean * - pseudoElement String */ _getAllElementRules: function(node, inherited, options) { const { bindingElement, pseudo } = CssLogic.getBindingElementAndPseudo( node.rawNode ); const rules = []; if (!bindingElement || !bindingElement.style) { return rules; } const elementStyle = this._styleRef(bindingElement); const showElementStyles = !inherited && !pseudo; const showInheritedStyles = inherited && this._hasInheritedProps(bindingElement.style); const rule = { rule: elementStyle, pseudoElement: null, isSystem: false, inherited: false, }; // First any inline styles if (showElementStyles) { rules.push(rule); } // Now any inherited styles if (showInheritedStyles) { rule.inherited = inherited; rules.push(rule); } // Add normal rules. Typically this is passing in the node passed into the // function, unless if that node was ::before/::after. In which case, // it will pass in the parentNode along with "::before"/"::after". this._getElementRules(bindingElement, pseudo, inherited, options).forEach( oneRule => { // The only case when there would be a pseudo here is // ::before/::after, and in this case we want to tell the // view that it belongs to the element (which is a // _moz_generated_content native anonymous element). oneRule.pseudoElement = null; rules.push(oneRule); } ); // Now any pseudos. if (showElementStyles && !options.skipPseudo) { for (const readPseudo of PSEUDO_ELEMENTS) { if (this._pseudoIsRelevant(bindingElement, readPseudo)) { this._getElementRules( bindingElement, readPseudo, inherited, options ).forEach(oneRule => { rules.push(oneRule); }); } } } return rules; }, _nodeIsTextfieldLike(node) { if (node.nodeName == "TEXTAREA") { return true; } return ( node.mozIsTextField && (node.mozIsTextField(false) || node.type == "number") ); }, _nodeIsButtonLike(node) { if (node.nodeName == "BUTTON") { return true; } return ( node.nodeName == "INPUT" && ["submit", "color", "button"].includes(node.type) ); }, _nodeIsListItem(node) { const display = CssLogic.getComputedStyle(node).getPropertyValue("display"); // This is written this way to handle `inline list-item` and such. return display.split(" ").includes("list-item"); }, // eslint-disable-next-line complexity _pseudoIsRelevant(node, pseudo) { switch (pseudo) { case ":after": case ":before": case ":first-letter": case ":first-line": case ":selection": return true; case ":marker": return this._nodeIsListItem(node); case ":backdrop": return node.matches(":fullscreen"); case ":cue": return node.nodeName == "VIDEO"; case ":file-chooser-button": return node.nodeName == "INPUT" && node.type == "file"; case ":placeholder": case ":-moz-placeholder": return this._nodeIsTextfieldLike(node); case ":-moz-focus-inner": return this._nodeIsButtonLike(node); case ":-moz-meter-bar": return node.nodeName == "METER"; case ":-moz-progress-bar": return node.nodeName == "PROGRESS"; case ":-moz-color-swatch": return node.nodeName == "INPUT" && node.type == "color"; case ":-moz-range-progress": case ":-moz-range-thumb": case ":-moz-range-track": case ":-moz-focus-outer": return node.nodeName == "INPUT" && node.type == "range"; default: throw Error("Unhandled pseudo-element " + pseudo); } }, /** * Helper function for _getAllElementRules, returns the rules from a given * element. See getApplied for documentation on parameters. * @param DOMNode node * @param string pseudo * @param DOMNode inherited * @param object options * * @returns Array */ _getElementRules: function(node, pseudo, inherited, options) { const domRules = InspectorUtils.getCSSStyleRules( node, pseudo, CssLogic.hasVisitedState(node) ); if (!domRules) { return []; } const rules = []; // getCSSStyleRules returns ordered from least-specific to // most-specific. for (let i = domRules.length - 1; i >= 0; i--) { const domRule = domRules[i]; const isSystem = !SharedCssLogic.isAuthorStylesheet( domRule.parentStyleSheet ); if (isSystem && options.filter != SharedCssLogic.FILTER.UA) { continue; } if (inherited) { // Don't include inherited rules if none of its properties // are inheritable. const hasInherited = [...domRule.style].some(prop => InspectorUtils.isInheritedProperty(prop) ); if (!hasInherited) { continue; } } const ruleActor = this._styleRef(domRule); rules.push({ rule: ruleActor, inherited: inherited, isSystem: isSystem, pseudoElement: pseudo, }); } return rules; }, /** * Given a node and a CSS rule, walk up the DOM looking for a * matching element rule. Return an array of all found entries, in * the form generated by _getAllElementRules. Note that this will * always return an array of either zero or one element. * * @param {NodeActor} node the node * @param {CSSStyleRule} filterRule the rule to filter for * @return {Array} array of zero or one elements; if one, the element * is the entry as returned by _getAllElementRules. */ findEntryMatchingRule: function(node, filterRule) { const options = { matchedSelectors: true, inherited: true }; let entries = []; let parent = this.walker.parentNode(node); while (parent && parent.rawNode.nodeType != Node.DOCUMENT_NODE) { entries = entries.concat( this._getAllElementRules(parent, parent, options) ); parent = this.walker.parentNode(parent); } return entries.filter(entry => entry.rule.rawRule === filterRule); }, /** * Helper function for getApplied that fetches a set of style properties that * apply to the given node and associated rules * @param NodeActor node * @param object options * `filter`: A string filter that affects the "matched" handling. * 'user': Include properties from user style sheets. * 'ua': Include properties from user and user-agent sheets. * Default value is 'ua' * `inherited`: Include styles inherited from parent nodes. * `matchedSelectors`: Include an array of specific selectors that * caused this rule to match its node. * `skipPseudo`: Exclude styles applied to pseudo elements of the provided node. * @param array entries * List of appliedstyle objects that lists the rules that apply to the * node. If adding a new rule to the stylesheet, only the new rule entry * is provided and only the style properties that apply to the new * rule is fetched. * @returns Object containing the list of rule entries, rule actors and * stylesheet actors that applies to the given node and its associated * rules. */ getAppliedProps: function(node, entries, options) { if (options.inherited) { let parent = this.walker.parentNode(node); while (parent && parent.rawNode.nodeType != Node.DOCUMENT_NODE) { entries = entries.concat( this._getAllElementRules(parent, parent, options) ); parent = this.walker.parentNode(parent); } } if (options.matchedSelectors) { for (const entry of entries) { if (entry.rule.type === ELEMENT_STYLE) { continue; } const domRule = entry.rule.rawRule; const selectors = CssLogic.getSelectors(domRule); const element = entry.inherited ? entry.inherited.rawNode : node.rawNode; const { bindingElement, pseudo } = CssLogic.getBindingElementAndPseudo( element ); const relevantLinkVisited = CssLogic.hasVisitedState(bindingElement); entry.matchedSelectors = []; for (let i = 0; i < selectors.length; i++) { if ( InspectorUtils.selectorMatchesElement( bindingElement, domRule, i, pseudo, relevantLinkVisited ) ) { entry.matchedSelectors.push(selectors[i]); } } } } // Add all the keyframes rule associated with the element const computedStyle = this.cssLogic.computedStyle; if (computedStyle) { let animationNames = computedStyle.animationName.split(","); animationNames = animationNames.map(name => name.trim()); if (animationNames) { // Traverse through all the available keyframes rule and add // the keyframes rule that matches the computed animation name for (const keyframesRule of this.cssLogic.keyframesRules) { if (animationNames.indexOf(keyframesRule.name) > -1) { for (const rule of keyframesRule.cssRules) { entries.push({ rule: this._styleRef(rule), keyframes: this._styleRef(keyframesRule), }); } } } } } const rules = new Set(); const sheets = new Set(); entries.forEach(entry => rules.add(entry.rule)); this.expandSets(rules, sheets); return { entries: entries, rules: [...rules], sheets: [...sheets], }; }, /** * Expand Sets of rules and sheets to include all parent rules and sheets. */ expandSets: function(ruleSet, sheetSet) { // Sets include new items in their iteration for (const rule of ruleSet) { if (rule.rawRule.parentRule) { const parent = this._styleRef(rule.rawRule.parentRule); if (!ruleSet.has(parent)) { ruleSet.add(parent); } } if (rule.rawRule.parentStyleSheet) { const parent = this._sheetRef(rule.rawRule.parentStyleSheet); if (!sheetSet.has(parent)) { sheetSet.add(parent); } } } for (const sheet of sheetSet) { if (sheet.rawSheet.parentStyleSheet) { const parent = this._sheetRef(sheet.rawSheet.parentStyleSheet); if (!sheetSet.has(parent)) { sheetSet.add(parent); } } } }, /** * Get layout-related information about a node. * This method returns an object with properties giving information about * the node's margin, border, padding and content region sizes, as well * as information about the type of box, its position, z-index, etc... * @param {NodeActor} node * @param {Object} options The only available option is autoMargins. * If set to true, the element's margins will receive an extra check to see * whether they are set to "auto" (knowing that the computed-style in this * case would return "0px"). * The returned object will contain an extra property (autoMargins) listing * all margins that are set to auto, e.g. {top: "auto", left: "auto"}. * @return {Object} */ getLayout: function(node, options) { this.cssLogic.highlight(node.rawNode); const layout = {}; // First, we update the first part of the box model view, with // the size of the element. const clientRect = node.rawNode.getBoundingClientRect(); layout.width = parseFloat(clientRect.width.toPrecision(6)); layout.height = parseFloat(clientRect.height.toPrecision(6)); // We compute and update the values of margins & co. const style = CssLogic.getComputedStyle(node.rawNode); for (const prop of [ "position", "top", "right", "bottom", "left", "margin-top", "margin-right", "margin-bottom", "margin-left", "padding-top", "padding-right", "padding-bottom", "padding-left", "border-top-width", "border-right-width", "border-bottom-width", "border-left-width", "z-index", "box-sizing", "display", "float", "line-height", ]) { layout[prop] = style.getPropertyValue(prop); } if (options.autoMargins) { layout.autoMargins = this.processMargins(this.cssLogic); } for (const i in this.map) { const property = this.map[i].property; this.map[i].value = parseFloat(style.getPropertyValue(property)); } return layout; }, /** * Find 'auto' margin properties. */ processMargins: function(cssLogic) { const margins = {}; for (const prop of ["top", "bottom", "left", "right"]) { const info = cssLogic.getPropertyInfo("margin-" + prop); const selectors = info.matchedSelectors; if (selectors && selectors.length > 0 && selectors[0].value == "auto") { margins[prop] = "auto"; } } return margins; }, /** * On page navigation, tidy up remaining objects. */ onFrameUnload: function() { this.styleElements = new WeakMap(); }, /** * When a stylesheet is added, handle the related StyleSheetActor to listen for changes. * @param {StyleSheetActor} actor * The actor for the added stylesheet. */ onStyleSheetAdded: function(actor) { if (!this._watchedSheets.has(actor)) { this._watchedSheets.add(actor); actor.on("style-applied", this._styleApplied); } }, /** * Helper function to addNewRule to get or create a style tag in the provided * document. * * @param {Document} document * The document in which the style element should be appended. * @returns DOMElement of the style tag */ getStyleElement: function(document) { if (!this.styleElements.has(document)) { const style = document.createElementNS(XHTML_NS, "style"); style.setAttribute("type", "text/css"); style.setDevtoolsAsTriggeringPrincipal(); document.documentElement.appendChild(style); this.styleElements.set(document, style); } return this.styleElements.get(document); }, /** * Helper function for adding a new rule and getting its applied style * properties * @param NodeActor node * @param CSSStyleRule rule * @returns Object containing its applied style properties */ getNewAppliedProps: function(node, rule) { const ruleActor = this._styleRef(rule); return this.getAppliedProps(node, [{ rule: ruleActor }], { matchedSelectors: true, }); }, /** * Adds a new rule, and returns the new StyleRuleActor. * @param {NodeActor} node * @param {String} pseudoClasses The list of pseudo classes to append to the * new selector. * @returns {StyleRuleActor} the new rule */ async addNewRule(node, pseudoClasses) { const style = this.getStyleElement(node.rawNode.ownerDocument); const sheet = style.sheet; const cssRules = sheet.cssRules; const rawNode = node.rawNode; const classes = [...rawNode.classList]; let selector; if (rawNode.id) { selector = "#" + CSS.escape(rawNode.id); } else if (classes.length > 0) { selector = "." + classes.map(c => CSS.escape(c)).join("."); } else { selector = rawNode.localName; } if (pseudoClasses && pseudoClasses.length > 0) { selector += pseudoClasses.join(""); } const index = sheet.insertRule(selector + " {}", cssRules.length); // If inserting the rule succeeded, go ahead and edit the source // text if requested. const sheetActor = this._sheetRef(sheet); let { str: authoredText } = await sheetActor.getText(); authoredText += "\n" + selector + " {\n" + "}"; await sheetActor.update(authoredText, false); const cssRule = sheet.cssRules.item(index); const ruleActor = this._styleRef(cssRule); TrackChangeEmitter.trackChange({ ...ruleActor.metadata, type: "rule-add", add: null, remove: null, selector, }); return this.getNewAppliedProps(node, cssRule); }, /** * Cause all StyleRuleActor instances of observed CSS rules to check whether the * states of their declarations have changed. * * Observed rules are the latest rules returned by a call to PageStyleActor.getApplied() * * This is necessary because changes in one rule can cause the declarations in another * to not be applicable (inactive CSS). The observers of those rules should be notified. * Rules will fire a "rule-updated" event if any of their declarations changed state. * * Call this method whenever a CSS rule is mutated: * - a CSS declaration is added/changed/disabled/removed * - a selector is added/changed/removed */ refreshObservedRules() { for (const rule of this._observedRules) { rule.refresh(); } }, /** * Get an array of existing attribute values in a node document. * * @param {String} search: A string to filter attribute value on. * @param {String} attributeType: The type of attribute we want to retrieve the values. * @param {Element} node: The element we want to get possible attributes for. This will * be used to get the document where the search is happening. * @returns {Array} An array of strings */ getAttributesInOwnerDocument(search, attributeType, node) { if (!search) { throw new Error("search is mandatory"); } // In a non-fission world, a node from an iframe shares the same `rootNode` as a node // in the top-level document. So here we need to retrieve the document from the node // in parameter in order to retrieve the right document. // This may change once we have a dedicated walker for every target in a tab, as we'll // be able to directly talk to the "right" walker actor. const targetDocument = node.rawNode.ownerDocument; // We store the result in a Set which will contain the attribute value const result = new Set(); const lcSearch = search.toLowerCase(); this._collectAttributesFromDocumentDOM( result, lcSearch, attributeType, targetDocument ); this._collectAttributesFromDocumentStyleSheets( result, lcSearch, attributeType, targetDocument ); return Array.from(result).sort(); }, /** * Collect attribute values from the document DOM tree, matching the passed filter and * type, to the result Set. * * @param {Set} result: A Set to which the results will be added. * @param {String} search: A string to filter attribute value on. * @param {String} attributeType: The type of attribute we want to retrieve the values. * @param {Document} targetDocument: The document the search occurs in. */ _collectAttributesFromDocumentDOM( result, search, attributeType, targetDocument ) { // In order to retrieve attributes from DOM elements in the document, we're going to // do a query on the root node using attributes selector, to directly get the elements // matching the attributes we're looking for. // For classes, we need something a bit different as the className we're looking // for might not be the first in the attribute value, meaning we can't use the // "attribute starts with X" selector. const attributeSelectorPositionChar = attributeType === "class" ? "*" : "^"; const selector = `[${attributeType}${attributeSelectorPositionChar}=${search} i]`; const matchingElements = targetDocument.querySelectorAll(selector); for (const element of matchingElements) { // For class attribute, we need to add the elements of the classList that match // the filter string. if (attributeType === "class") { for (const cls of element.classList) { if (!result.has(cls) && cls.toLowerCase().startsWith(search)) { result.add(cls); } } } else { const { value } = element.attributes[attributeType]; // For other attributes, we can directly use the attribute value. result.add(value); } } }, /** * Collect attribute values from the document stylesheets, matching the passed filter * and type, to the result Set. * * @param {Set} result: A Set to which the results will be added. * @param {String} search: A string to filter attribute value on. * @param {String} attributeType: The type of attribute we want to retrieve the values. * It only supports "class" and "id" at the moment. * @param {Document} targetDocument: The document the search occurs in. */ _collectAttributesFromDocumentStyleSheets( result, search, attributeType, targetDocument ) { if (attributeType !== "class" && attributeType !== "id") { return; } // We loop through all the stylesheets and their rules, and then use the lexer to only // get the attributes we're looking for. for (const styleSheet of targetDocument.styleSheets) { for (const rule of styleSheet.rules) { this._collectAttributesFromRule(result, rule, search, attributeType); } } }, /** * Collect attribute values from the rule, matching the passed filter and type, to the * result Set. * * @param {Set} result: A Set to which the results will be added. * @param {Rule} rule: The rule the search occurs in. * @param {String} search: A string to filter attribute value on. * @param {String} attributeType: The type of attribute we want to retrieve the values. * It only supports "class" and "id" at the moment. */ _collectAttributesFromRule(result, rule, search, attributeType) { const shouldRetrieveClasses = attributeType === "class"; const shouldRetrieveIds = attributeType === "id"; const { selectorText } = rule; // If there's no selectorText, or if the selectorText does not include the // filter, we can bail out. if (!selectorText || !selectorText.toLowerCase().includes(search)) { return; } // Check if we should parse the selectorText (do we need to check for class/id and // if so, does the selector contains class/id related chars). const parseForClasses = shouldRetrieveClasses && selectorText.toLowerCase().includes(`.${search}`); const parseForIds = shouldRetrieveIds && selectorText.toLowerCase().includes(`#${search}`); if (!parseForClasses && !parseForIds) { return; } const lexer = getCSSLexer(selectorText); let token; while ((token = lexer.nextToken())) { if ( token.tokenType === "symbol" && ((shouldRetrieveClasses && token.text === ".") || (shouldRetrieveIds && token.text === "#")) ) { token = lexer.nextToken(); if ( token.tokenType === "ident" && token.text.toLowerCase().startsWith(search) ) { result.add(token.text); } } } }, }); exports.PageStyleActor = PageStyleActor; 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). */ var 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; 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 !this.sheetActor.hasRulesModifiedByCSSOM() && // 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; 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, }; // Used to differentiate between changes to rules with identical selectors. data.ruleIndex = this._ruleIndex; } 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 + "]"; }, 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, // Indicates that StyleRuleActor emits the "rule-updated" event. // Added in Firefox 72. emitsRuleUpdatedEvent: true, }, }; 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) { 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; // We need to grab CSS from the window, since calling supports() on the // one from the current global will fail due to not being an HTML global. const CSS = this.pageStyle.inspector.targetActor.window.CSS; form.declarations = declarations.map(decl => { // Use the 1-arg CSS.supports() call so that we also accept !important // in the value. decl.isValid = CSS.supports(`${decl.name}:${decl.value}`); // 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 = CSS.supports(decl.name, "initial"); return decl; }); // Associate all the compatibility issues for the declarations with the // form. Once Bug 1648339 // https://bugzilla.mozilla.org/show_bug.cgi?id=1648339 // is solved we can directly associate compatibility issue with the // declaration themselves. const compatibility = this.pageStyle.inspector.getCompatibility(); form.compatibilityIssues = compatibility.getCSSDeclarationBlockIssues( declarations ); // 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; this.rawRule = this._getRuleFromIndex(this._parentSheet); // 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: 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); } 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); const { str: stylesheetText } = await this.sheetActor.getText(); 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: , * name: , * value: , * priority: * } * or * { * type: "remove", * index: , * name: , * } * @returns the rule with updated properties */ async setRuleText(newText, modifications = []) { if (!this.canSetRuleText) { throw new Error("invalid call to setRuleText"); } // 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 { // 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: , * name: , * value: , * priority: * } * or * { * type: "remove", * index: , * name: , * } * * @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; } 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); } }, }); /** * Helper function for getting an image preview of the given font. * * @param font {string} * Name of font to preview * @param doc {Document} * Document to use to render font * @param options {object} * Object with options 'previewText' and 'previewFontSize' * * @return dataUrl * The data URI of the font preview image */ function getFontPreviewData(font, doc, options) { options = options || {}; const previewText = options.previewText || FONT_PREVIEW_TEXT; const previewFontSize = options.previewFontSize || FONT_PREVIEW_FONT_SIZE; const fillStyle = options.fillStyle || FONT_PREVIEW_FILLSTYLE; const fontStyle = options.fontStyle || ""; const canvas = doc.createElementNS(XHTML_NS, "canvas"); const ctx = canvas.getContext("2d"); const fontValue = fontStyle + " " + previewFontSize + "px " + font + ", serif"; // Get the correct preview text measurements and set the canvas dimensions ctx.font = fontValue; ctx.fillStyle = fillStyle; const textWidth = Math.round(ctx.measureText(previewText).width); canvas.width = textWidth * 2 + FONT_PREVIEW_OFFSET * 4; canvas.height = previewFontSize * 3; // we have to reset these after changing the canvas size ctx.font = fontValue; ctx.fillStyle = fillStyle; // Oversample the canvas for better text quality ctx.textBaseline = "top"; ctx.scale(2, 2); ctx.fillText( previewText, FONT_PREVIEW_OFFSET, Math.round(previewFontSize / 3) ); const dataURL = canvas.toDataURL("image/png"); return { dataURL: dataURL, size: textWidth + FONT_PREVIEW_OFFSET * 2, }; } exports.getFontPreviewData = getFontPreviewData; /** * Get the text content of a rule given some CSS text, a line and a column * Consider the following example: * body { * color: red; * } * p { * line-height: 2em; * color: blue; * } * Calling the function with the whole text above and line=4 and column=1 would * return "line-height: 2em; color: blue;" * @param {String} initialText * @param {Number} line (1-indexed) * @param {Number} column (1-indexed) * @return {object} An object of the form {offset: number, text: string} * The offset is the index into the input string where * the rule text started. The text is the content of * the rule. */ function getRuleText(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. while (true) { const token = lexer.nextToken(); if (!token) { throw new Error("couldn't find start of the rule"); } if (token.tokenType === "symbol" && token.text === "{") { break; } } // Now collect text until we see the matching close brace. let braceDepth = 1; let startOffset, endOffset; while (true) { const token = lexer.nextToken(); if (!token) { break; } if (startOffset === undefined) { startOffset = token.startOffset; } if (token.tokenType === "symbol") { if (token.text === "{") { ++braceDepth; } else if (token.text === "}") { --braceDepth; if (braceDepth == 0) { break; } } } endOffset = token.endOffset; } // If the rule was of the form "selector {" with no closing brace // and no properties, just return an empty string. if (startOffset === undefined) { return { offset: 0, text: "" }; } // If the input didn't have any tokens between the braces (e.g., // "div {}"), then the endOffset won't have been set yet; so account // for that here. if (endOffset === undefined) { endOffset = startOffset; } // Note that this approach will preserve comments, despite the fact // that cssTokenizer skips them. return { offset: textOffset + startOffset, text: text.substring(startOffset, endOffset), }; } exports.getRuleText = getRuleText; /** * 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"); } /** * Return the offset and substring of |text| that starts at the given * line and column. * @param {String} text * @param {Number} line (1-indexed) * @param {Number} column (1-indexed) * @return {object} An object of the form {offset: number, text: string}, * where the offset is the offset into the input string * where the text starts, and where text is the text. */ function getTextAtLineColumn(text, line, column) { let offset; if (line > 1) { const rx = new RegExp( "(?:[^\\r\\n\\f]*(?:\\r\\n|\\n|\\r|\\f)){" + (line - 1) + "}" ); offset = rx.exec(text)[0].length; } else { offset = 0; } offset += column - 1; return { offset: offset, text: text.substr(offset) }; } exports.getTextAtLineColumn = getTextAtLineColumn;