https://github.com/mozilla/gecko-dev
Raw File
Tip revision: 717b1cdc7109767ac16cf4aaa0e6c4803352a9e8 authored by Ted Campbell on 07 November 2020, 05:36:31 UTC
Bug 1675905 - Simplify IonBuilder::createThisScripted. r=jandem,iain a=RyanVM
Tip revision: 717b1cd
styles.js
/* 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,
  "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,
  "prettifyCSS",
  "devtools/shared/inspector/css-logic",
  true
);
loader.lazyRequireGetter(
  this,
  "UPDATE_PRESERVING_RULES",
  "devtools/server/actors/stylesheets",
  true
);
loader.lazyRequireGetter(
  this,
  "UPDATE_GENERAL",
  "devtools/server/actors/stylesheets",
  true
);
loader.lazyRequireGetter(
  this,
  "findCssSelector",
  "devtools/shared/inspector/css-logic",
  true
);
loader.lazyRequireGetter(
  this,
  "CSSRuleTypeName",
  "devtools/shared/inspector/css-logic",
  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 actor has had bug 1103993 fixed, which means that the
        // getApplied method calls cssLogic.highlight(node) to recreate the
        // style cache. Clients requesting getApplied from actors that have not
        // been fixed must make sure cssLogic.highlight(node) was called before.
        getAppliedCreatesStyleCache: true,
        // 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%"),
      },
    };
  },

  /**
   * 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" <optional>
   *       matched: <true if there are matched selectors for this value>
   *     },
   *     ...
   *   }
   */
  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: <rule actorid>,
   *       sourceText: <string>, // The source of the selector, relative
   *                             // to the node in question.
   *       selector: <string>, // the selector ID that matched
   *       value: <string>, // the value of the property
   *       status: <int>,
   *         // 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: [ <domrule>, ... ], // The full form of any domrule referenced
   *
   *     // The full form of any sheets referenced.
   *     sheets: [ <domsheet>, ... ]
   *  }
   */
  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-math-anonymous":
        // This one should be internal, really.
        return false;
      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");
      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();
    }
  },
});
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;
      });
      // Cache parsed declarations so we don't needlessly re-parse authoredText every time
      // we need 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: <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 {
      // 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;
      }

      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;
back to top