tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

text-property-editor.js (62337B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 const {
      8  l10n,
      9  l10nFormatStr,
     10 } = require("resource://devtools/shared/inspector/css-logic.js");
     11 const {
     12  InplaceEditor,
     13  editableField,
     14 } = require("resource://devtools/client/shared/inplace-editor.js");
     15 const {
     16  createChild,
     17  appendText,
     18  advanceValidate,
     19  blurOnMultipleProperties,
     20 } = require("resource://devtools/client/inspector/shared/utils.js");
     21 const { throttle } = require("resource://devtools/shared/throttle.js");
     22 const {
     23  style: { ELEMENT_STYLE },
     24 } = require("resource://devtools/shared/constants.js");
     25 const {
     26  canPointerEventDrag,
     27 } = require("resource://devtools/client/shared/events.js");
     28 
     29 loader.lazyRequireGetter(
     30  this,
     31  ["parseDeclarations", "parseSingleValue"],
     32  "resource://devtools/shared/css/parsing-utils.js",
     33  true
     34 );
     35 loader.lazyRequireGetter(
     36  this,
     37  "findCssSelector",
     38  "resource://devtools/shared/inspector/css-logic.js",
     39  true
     40 );
     41 loader.lazyGetter(this, "PROPERTY_NAME_INPUT_LABEL", function () {
     42  return l10n("rule.propertyName.label");
     43 });
     44 loader.lazyGetter(this, "SHORTHAND_EXPANDER_TOOLTIP", function () {
     45  return l10n("rule.shorthandExpander.tooltip");
     46 });
     47 
     48 const lazy = {};
     49 ChromeUtils.defineESModuleGetters(lazy, {
     50  AppConstants: "resource://gre/modules/AppConstants.sys.mjs",
     51 });
     52 
     53 const HTML_NS = "http://www.w3.org/1999/xhtml";
     54 
     55 const SHARED_SWATCH_CLASS = "inspector-swatch";
     56 const COLOR_SWATCH_CLASS = "inspector-colorswatch";
     57 const BEZIER_SWATCH_CLASS = "inspector-bezierswatch";
     58 const LINEAR_EASING_SWATCH_CLASS = "inspector-lineareasingswatch";
     59 const FILTER_SWATCH_CLASS = "inspector-filterswatch";
     60 const ANGLE_SWATCH_CLASS = "inspector-angleswatch";
     61 const FONT_FAMILY_CLASS = "ruleview-font-family";
     62 const SHAPE_SWATCH_CLASS = "inspector-shapeswatch";
     63 
     64 /*
     65 * An actionable element is an element which on click triggers a specific action
     66 * (e.g. shows a color tooltip, opens a link, …).
     67 */
     68 const ACTIONABLE_ELEMENTS_SELECTORS = [
     69  `.${COLOR_SWATCH_CLASS}`,
     70  `.${BEZIER_SWATCH_CLASS}`,
     71  `.${LINEAR_EASING_SWATCH_CLASS}`,
     72  `.${FILTER_SWATCH_CLASS}`,
     73  `.${ANGLE_SWATCH_CLASS}`,
     74  "a",
     75 ];
     76 
     77 /*
     78 * Speeds at which we update the value when the user is dragging its mouse
     79 * over a value.
     80 */
     81 const SLOW_DRAGGING_SPEED = 0.1;
     82 const DEFAULT_DRAGGING_SPEED = 1;
     83 const FAST_DRAGGING_SPEED = 10;
     84 
     85 // Deadzone in pixels where dragging should not update the value.
     86 const DRAGGING_DEADZONE_DISTANCE = 5;
     87 
     88 const DRAGGABLE_VALUE_CLASSNAME = "ruleview-propertyvalue-draggable";
     89 const IS_DRAGGING_CLASSNAME = "ruleview-propertyvalue-dragging";
     90 
     91 /**
     92 * TextPropertyEditor is responsible for the following:
     93 *   Owns a TextProperty object.
     94 *   Manages changes to the TextProperty.
     95 *   Can be expanded to display computed properties.
     96 *   Can mark a property disabled or enabled.
     97 *
     98 * @param {RuleEditor} ruleEditor
     99 *        The rule editor that owns this TextPropertyEditor.
    100 * @param {TextProperty} property
    101 *        The text property to edit.
    102 * @param {object} options
    103 * @param {Set} options.elementsWithPendingClicks
    104 */
    105 class TextPropertyEditor {
    106  constructor(ruleEditor, property, options) {
    107    this.ruleEditor = ruleEditor;
    108    this.ruleView = this.ruleEditor.ruleView;
    109    this.cssProperties = this.ruleView.cssProperties;
    110    this.doc = this.ruleEditor.doc;
    111    this.popup = this.ruleView.popup;
    112    this.prop = property;
    113    this.prop.editor = this;
    114    this.browserWindow = this.doc.defaultView.top;
    115    this.#elementsWithPendingClicks = options.elementsWithPendingClicks;
    116 
    117    this.toolbox = this.ruleView.inspector.toolbox;
    118    this.telemetry = this.toolbox.telemetry;
    119 
    120    this.#onValidate = this.ruleView.debounce(this.#previewValue, 10, this);
    121 
    122    this.#createUI();
    123    this.update();
    124  }
    125 
    126  #populatedComputed = false;
    127  #hasPendingClick = false;
    128  #clickedElementOptions = null;
    129  #populatedShorthandOverridden;
    130  #elementsWithPendingClicks;
    131 
    132  #colorSwatchSpans;
    133  #bezierSwatchSpans;
    134  #linearEasingSwatchSpans;
    135 
    136  #onValidate;
    137  #isDragging = false;
    138  #capturingPointerId = null;
    139  #hasDragged = false;
    140  #draggingController = null;
    141  #draggingValueCache = null;
    142 
    143  /**
    144   * Boolean indicating if the name or value is being currently edited.
    145   */
    146  get editing() {
    147    return (
    148      !!(
    149        this.nameSpan.inplaceEditor ||
    150        this.valueSpan.inplaceEditor ||
    151        this.ruleView.tooltips.isEditing
    152      ) || this.popup.isOpen
    153    );
    154  }
    155 
    156  /**
    157   * Get the rule to the current text property
    158   */
    159  get rule() {
    160    return this.prop.rule;
    161  }
    162 
    163  // Exposed for tests.
    164  get _DRAGGING_DEADZONE_DISTANCE() {
    165    return DRAGGING_DEADZONE_DISTANCE;
    166  }
    167 
    168  /**
    169   * Create the property editor's DOM.
    170   */
    171  #createUI() {
    172    const win = this.doc.defaultView;
    173    this.abortController = new win.AbortController();
    174 
    175    this.element = this.doc.createElementNS(HTML_NS, "div");
    176    this.element.setAttribute("role", "listitem");
    177    this.element.classList.add("ruleview-property");
    178    this.element.dataset.declarationId = this.prop.id;
    179    this.element._textPropertyEditor = this;
    180 
    181    this.container = createChild(this.element, "div", {
    182      class: "ruleview-propertycontainer",
    183    });
    184 
    185    const indent =
    186      ((this.ruleEditor.rule.domRule.ancestorData.length || 0) + 1) * 2;
    187    createChild(this.container, "span", {
    188      class: "ruleview-rule-indent clipboard-only",
    189      textContent: " ".repeat(indent),
    190    });
    191 
    192    // The enable checkbox will disable or enable the rule.
    193    this.enable = createChild(this.container, "input", {
    194      type: "checkbox",
    195      class: "ruleview-enableproperty",
    196      title: l10nFormatStr("rule.propertyToggle.label", this.prop.name),
    197    });
    198 
    199    this.nameContainer = createChild(this.container, "span", {
    200      class: "ruleview-namecontainer",
    201    });
    202 
    203    // Property name, editable when focused.  Property name
    204    // is committed when the editor is unfocused.
    205    this.nameSpan = createChild(this.nameContainer, "span", {
    206      class: "ruleview-propertyname theme-fg-color3",
    207      tabindex: this.ruleEditor.isEditable ? "0" : "-1",
    208      id: this.prop.id,
    209    });
    210 
    211    appendText(this.nameContainer, ": ");
    212 
    213    // Create a span that will hold the property and semicolon.
    214    // Use this span to create a slightly larger click target
    215    // for the value.
    216    this.valueContainer = createChild(this.container, "span", {
    217      class: "ruleview-propertyvaluecontainer",
    218    });
    219 
    220    // Property value, editable when focused.  Changes to the
    221    // property value are applied as they are typed, and reverted
    222    // if the user presses escape.
    223    this.valueSpan = createChild(this.valueContainer, "span", {
    224      class: "ruleview-propertyvalue theme-fg-color1",
    225      tabindex: this.ruleEditor.isEditable ? "0" : "-1",
    226    });
    227 
    228    // Storing the TextProperty on the elements for easy access
    229    // (for instance by the tooltip)
    230    this.valueSpan.textProperty = this.prop;
    231    this.nameSpan.textProperty = this.prop;
    232 
    233    appendText(this.valueContainer, ";");
    234 
    235    // This needs to be called after valueContainer, nameSpan and valueSpan are created.
    236    if (this.#shouldShowComputedExpander) {
    237      this.#createComputedExpander();
    238    }
    239 
    240    if (this.#shouldShowWarning) {
    241      this.#createWarningIcon();
    242    }
    243 
    244    if (this.#isInvalidAtComputedValueTime()) {
    245      this.#createInvalidAtComputedValueTimeIcon();
    246    }
    247 
    248    if (this.#shouldShowInactiveCssState) {
    249      this.#createInactiveCssWarningIcon();
    250    }
    251 
    252    if (this.#shouldShowFilterProperty) {
    253      this.#createFilterPropertyButton();
    254    }
    255 
    256    // Only bind event handlers if the rule is editable.
    257    if (this.ruleEditor.isEditable) {
    258      this.enable.addEventListener("click", this.#onEnableClicked, {
    259        signal: this.abortController.signal,
    260        capture: true,
    261      });
    262      this.enable.addEventListener("change", this.#onEnableChanged, {
    263        signal: this.abortController.signal,
    264        capture: true,
    265      });
    266 
    267      this.nameContainer.addEventListener(
    268        "click",
    269        event => {
    270          // Clicks within the name shouldn't propagate any further.
    271          event.stopPropagation();
    272 
    273          // Forward clicks on nameContainer to the editable nameSpan
    274          if (event.target === this.nameContainer) {
    275            this.nameSpan.click();
    276          }
    277        },
    278        { signal: this.abortController.signal }
    279      );
    280 
    281      const getCssVariables = () =>
    282        this.rule.elementStyle.getAllCustomProperties(this.rule.pseudoElement);
    283 
    284      editableField({
    285        start: this.#onStartEditing,
    286        element: this.nameSpan,
    287        done: this.#onNameDone,
    288        destroy: this.updateUI,
    289        advanceChars: ":",
    290        contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY,
    291        popup: this.popup,
    292        cssProperties: this.cssProperties,
    293        getCssVariables,
    294        // (Shift+)Tab will move the focus to the previous/next editable field (so property value
    295        // or new selector).
    296        focusEditableFieldAfterApply: true,
    297        focusEditableFieldContainerSelector: ".ruleview-rule",
    298        // We don't want Enter to trigger the next editable field, just to validate
    299        // what the user entered, close the editor, and focus the span so the user can
    300        // navigate with the keyboard as expected, unless the user has
    301        // devtools.inspector.rule-view.focusNextOnEnter set to true
    302        stopOnReturn: this.ruleView.inplaceEditorFocusNextOnEnter !== true,
    303        inputAriaLabel: PROPERTY_NAME_INPUT_LABEL,
    304      });
    305 
    306      // Auto blur name field on multiple CSS rules get pasted in.
    307      this.nameContainer.addEventListener(
    308        "paste",
    309        blurOnMultipleProperties(this.cssProperties),
    310        { signal: this.abortController.signal }
    311      );
    312 
    313      this.valueContainer.addEventListener(
    314        "click",
    315        event => {
    316          // Clicks within the value shouldn't propagate any further.
    317          event.stopPropagation();
    318 
    319          // Forward clicks on valueContainer to the editable valueSpan
    320          if (event.target === this.valueContainer) {
    321            this.valueSpan.click();
    322          }
    323 
    324          if (event.target.classList.contains("ruleview-variable-link")) {
    325            const isRuleInStartingStyle =
    326              this.ruleEditor.rule.isInStartingStyle();
    327            const rulePseudoElement = this.ruleEditor.rule.pseudoElement;
    328            this.ruleView.highlightProperty(event.target.dataset.variableName, {
    329              ruleValidator: rule => {
    330                // If the associated rule is not in starting style, the variable
    331                // definition can't be in a starting style rule.
    332                // Note that if the rule is in starting style, then the variable
    333                // definition might be in a starting style rule, or in a regular one.
    334                if (!isRuleInStartingStyle && rule.isInStartingStyle()) {
    335                  return false;
    336                }
    337 
    338                if (
    339                  rule.pseudoElement &&
    340                  rulePseudoElement !== rule.pseudoElement
    341                ) {
    342                  return false;
    343                }
    344 
    345                return true;
    346              },
    347            });
    348          }
    349        },
    350        { signal: this.abortController.signal }
    351      );
    352 
    353      // The mousedown event could trigger a blur event on nameContainer, which
    354      // will trigger a call to the update function. The update function clears
    355      // valueSpan's markup. Thus the regular click event does not bubble up, and
    356      // listener's callbacks are not called.
    357      // So we need to remember where the user clicks in order to re-trigger the click
    358      // after the valueSpan's markup is re-populated. We only need to track this for
    359      // valueSpan's child elements, because direct click on valueSpan will always
    360      // trigger a click event.
    361      this.valueSpan.addEventListener(
    362        "mousedown",
    363        event => {
    364          const clickedEl = event.target;
    365          if (clickedEl === this.valueSpan) {
    366            return;
    367          }
    368          this.#hasPendingClick = true;
    369          this.#elementsWithPendingClicks.add(this.valueSpan);
    370 
    371          const matchedSelector = ACTIONABLE_ELEMENTS_SELECTORS.find(selector =>
    372            clickedEl.matches(selector)
    373          );
    374          if (matchedSelector) {
    375            const similarElements = [
    376              ...this.valueSpan.querySelectorAll(matchedSelector),
    377            ];
    378            this.#clickedElementOptions = {
    379              selector: matchedSelector,
    380              index: similarElements.indexOf(clickedEl),
    381            };
    382          }
    383        },
    384        { signal: this.abortController.signal }
    385      );
    386 
    387      this.valueSpan.addEventListener(
    388        "pointerup",
    389        () => {
    390          // if we have dragged, we will handle the pending click in #draggingOnPointerUp instead
    391          if (this.#hasDragged) {
    392            return;
    393          }
    394          this.#clickedElementOptions = null;
    395          this.#hasPendingClick = false;
    396          this.#elementsWithPendingClicks.delete(this.valueSpan);
    397        },
    398        { signal: this.abortController.signal }
    399      );
    400 
    401      this.ruleView.on(
    402        "draggable-preference-updated",
    403        this.#onDraggablePreferenceChanged,
    404        { signal: this.abortController.signal }
    405      );
    406      if (this.#isDraggableProperty(this.prop)) {
    407        this.#addDraggingCapability();
    408      }
    409 
    410      editableField({
    411        start: this.#onStartEditing,
    412        element: this.valueSpan,
    413        done: this.#onValueDone,
    414        destroy: onValueDonePromise => {
    415          const cb = this.update;
    416          // The `done` callback is called before this `destroy` callback is.
    417          // In #onValueDone, we might preview/set the property and we want to wait for
    418          // that to be resolved before updating the view so all data are up to date (see Bug 1325145).
    419          if (
    420            onValueDonePromise &&
    421            typeof onValueDonePromise.then === "function"
    422          ) {
    423            return onValueDonePromise.then(cb);
    424          }
    425          return cb();
    426        },
    427        validate: this.#onValidate,
    428        advanceChars: advanceValidate,
    429        contentType: InplaceEditor.CONTENT_TYPES.CSS_VALUE,
    430        property: this.prop,
    431        defaultIncrement: this.prop.name === "opacity" ? 0.1 : 1,
    432        popup: this.popup,
    433        multiline: true,
    434        maxWidth: () => this.container.getBoundingClientRect().width,
    435        cssProperties: this.cssProperties,
    436        getCssVariables,
    437        getGridLineNames: this.#getGridlineNames,
    438        showSuggestCompletionOnEmpty: true,
    439        // (Shift+)Tab will move the focus to the previous/next editable field (so property name,
    440        // or new property).
    441        focusEditableFieldAfterApply: true,
    442        focusEditableFieldContainerSelector: ".ruleview-rule",
    443        // We don't want Enter to trigger the next editable field, just to validate
    444        // what the user entered, close the editor, and focus the span so the user can
    445        // navigate with the keyboard as expected, unless the user has
    446        // devtools.inspector.rule-view.focusNextOnEnter set to true
    447        stopOnReturn: this.ruleView.inplaceEditorFocusNextOnEnter !== true,
    448        // Label the value input with the name span so screenreader users know what this
    449        // applies to.
    450        inputAriaLabelledBy: this.nameSpan.id,
    451      });
    452    }
    453  }
    454 
    455  /**
    456   * Get the grid line names of the grid that the currently selected element is
    457   * contained in.
    458   *
    459   * @return {object} Contains the names of the cols and rows as arrays
    460   * {cols: [], rows: []}.
    461   */
    462  #getGridlineNames = async () => {
    463    const gridLineNames = { cols: [], rows: [] };
    464    const layoutInspector =
    465      await this.ruleView.inspector.walker.getLayoutInspector();
    466    const gridFront = await layoutInspector.getCurrentGrid(
    467      this.ruleView.inspector.selection.nodeFront
    468    );
    469 
    470    if (gridFront) {
    471      const gridFragments = gridFront.gridFragments;
    472 
    473      for (const gridFragment of gridFragments) {
    474        for (const rowLine of gridFragment.rows.lines) {
    475          // We specifically ignore implicit line names created from implicitly named
    476          // areas. This is because showing implicit line names can be confusing for
    477          // designers who may have used a line name with "-start" or "-end" and created
    478          // an implicitly named grid area without meaning to.
    479          let gridArea;
    480 
    481          for (const name of rowLine.names) {
    482            const rowLineName =
    483              name.substring(0, name.lastIndexOf("-start")) ||
    484              name.substring(0, name.lastIndexOf("-end"));
    485            gridArea = gridFragment.areas.find(
    486              area => area.name === rowLineName
    487            );
    488 
    489            if (
    490              rowLine.type === "implicit" &&
    491              gridArea &&
    492              gridArea.type === "implicit"
    493            ) {
    494              continue;
    495            }
    496            gridLineNames.rows.push(name);
    497          }
    498        }
    499 
    500        for (const colLine of gridFragment.cols.lines) {
    501          let gridArea;
    502 
    503          for (const name of colLine.names) {
    504            const colLineName =
    505              name.substring(0, name.lastIndexOf("-start")) ||
    506              name.substring(0, name.lastIndexOf("-end"));
    507            gridArea = gridFragment.areas.find(
    508              area => area.name === colLineName
    509            );
    510 
    511            if (
    512              colLine.type === "implicit" &&
    513              gridArea &&
    514              gridArea.type === "implicit"
    515            ) {
    516              continue;
    517            }
    518            gridLineNames.cols.push(name);
    519          }
    520        }
    521      }
    522    }
    523 
    524    // Emit message for test files
    525    this.ruleView.inspector.emit("grid-line-names-updated");
    526    return gridLineNames;
    527  };
    528 
    529  /**
    530   * Get the path from which to resolve requests for this
    531   * rule's stylesheet.
    532   *
    533   * @return {string} the stylesheet's href.
    534   */
    535  get #sheetHref() {
    536    const domRule = this.rule.domRule;
    537    if (domRule) {
    538      return domRule.href || domRule.nodeHref;
    539    }
    540    return undefined;
    541  }
    542 
    543  /**
    544   * Populate the span based on changes to the TextProperty.
    545   */
    546  // eslint-disable-next-line complexity
    547  update = () => {
    548    if (this.ruleView.isDestroyed) {
    549      return;
    550    }
    551 
    552    this.updateUI();
    553 
    554    const name = this.prop.name;
    555    this.nameSpan.textContent = name;
    556    this.enable.setAttribute(
    557      "title",
    558      l10nFormatStr("rule.propertyToggle.label", name)
    559    );
    560 
    561    // Combine the property's value and priority into one string for
    562    // the value.
    563    const store = this.rule.elementStyle.store;
    564    let val = store.userProperties.getProperty(
    565      this.rule.domRule,
    566      name,
    567      this.prop.value
    568    );
    569    if (this.prop.priority) {
    570      val += " !" + this.prop.priority;
    571    }
    572 
    573    const propDirty = this.prop.isPropertyChanged;
    574 
    575    if (propDirty) {
    576      this.element.setAttribute("dirty", "");
    577    } else {
    578      this.element.removeAttribute("dirty");
    579    }
    580 
    581    const outputParser = this.ruleView._outputParser;
    582    this.outputParserOptions = {
    583      angleClass: "ruleview-angle",
    584      angleSwatchClass: SHARED_SWATCH_CLASS + " " + ANGLE_SWATCH_CLASS,
    585      bezierClass: "ruleview-bezier",
    586      bezierSwatchClass: SHARED_SWATCH_CLASS + " " + BEZIER_SWATCH_CLASS,
    587      colorClass: "ruleview-color",
    588      colorSwatchClass: SHARED_SWATCH_CLASS + " " + COLOR_SWATCH_CLASS,
    589      filterClass: "ruleview-filter",
    590      filterSwatchClass: SHARED_SWATCH_CLASS + " " + FILTER_SWATCH_CLASS,
    591      flexClass: "inspector-flex js-toggle-flexbox-highlighter",
    592      gridClass: "inspector-grid js-toggle-grid-highlighter",
    593      linearEasingClass: "ruleview-lineareasing",
    594      linearEasingSwatchClass:
    595        SHARED_SWATCH_CLASS + " " + LINEAR_EASING_SWATCH_CLASS,
    596      shapeClass: "inspector-shape",
    597      shapeSwatchClass: SHAPE_SWATCH_CLASS,
    598      // Only ask the parser to convert colors to the default color type specified by the
    599      // user if the property hasn't been changed yet.
    600      useDefaultColorUnit: !propDirty,
    601      defaultColorUnit: this.ruleView.inspector.defaultColorUnit,
    602      urlClass: "theme-link",
    603      fontFamilyClass: FONT_FAMILY_CLASS,
    604      baseURI: this.#sheetHref,
    605      unmatchedClass: "inspector-unmatched",
    606      matchedVariableClass: "inspector-variable",
    607      getVariableData: varName =>
    608        this.rule.elementStyle.getVariableData(
    609          varName,
    610          this.rule.pseudoElement
    611        ),
    612      inStartingStyleRule: this.rule.isInStartingStyle(),
    613      isValid: this.isValid(),
    614    };
    615 
    616    if (this.rule.darkColorScheme !== undefined) {
    617      this.outputParserOptions.isDarkColorScheme = this.rule.darkColorScheme;
    618    }
    619    const frag = outputParser.parseCssProperty(
    620      name,
    621      val,
    622      this.outputParserOptions
    623    );
    624 
    625    // Save the initial value as the last committed value,
    626    // for restoring after pressing escape.
    627    if (!this.committed) {
    628      this.committed = {
    629        name,
    630        value: frag.textContent,
    631        priority: this.prop.priority,
    632      };
    633    }
    634 
    635    // Save focused element inside value span if one exists before wiping the innerHTML
    636    let focusedElSelector = null;
    637    if (this.valueSpan.contains(this.doc.activeElement)) {
    638      focusedElSelector = findCssSelector(this.doc.activeElement);
    639    }
    640 
    641    this.valueSpan.innerHTML = "";
    642    this.valueSpan.appendChild(frag);
    643    if (
    644      this.valueSpan.textProperty?.name === "grid-template-areas" &&
    645      (this.valueSpan.innerText.includes(`"`) ||
    646        this.valueSpan.innerText.includes(`'`))
    647    ) {
    648      this.#formatGridTemplateAreasValue();
    649    }
    650 
    651    this.ruleView.emit("property-value-updated", {
    652      rule: this.prop.rule,
    653      property: name,
    654      value: val,
    655    });
    656 
    657    // Highlight the currently used font in font-family properties.
    658    // If we cannot find a match, highlight the first generic family instead.
    659    const fontFamilySpans = this.valueSpan.querySelectorAll(
    660      "." + FONT_FAMILY_CLASS
    661    );
    662    if (fontFamilySpans.length && this.prop.enabled && !this.prop.overridden) {
    663      this.rule.elementStyle
    664        .getUsedFontFamilies()
    665        .then(families => {
    666          for (const span of fontFamilySpans) {
    667            const authoredFont = span.textContent.toLowerCase();
    668            if (families.has(authoredFont)) {
    669              span.classList.add("used-font");
    670              // In case a font-family appears multiple time in the value, we only want
    671              // to highlight the first occurence.
    672              families.delete(authoredFont);
    673            }
    674          }
    675 
    676          this.ruleView.emit("font-highlighted", this.valueSpan);
    677        })
    678        .catch(e =>
    679          console.error("Could not get the list of font families", e)
    680        );
    681    }
    682 
    683    // Attach the color picker tooltip to the color swatches
    684    this.#colorSwatchSpans = this.valueSpan.querySelectorAll(
    685      "." + COLOR_SWATCH_CLASS
    686    );
    687    if (this.ruleEditor.isEditable) {
    688      for (const span of this.#colorSwatchSpans) {
    689        // Adding this swatch to the list of swatches our colorpicker
    690        // knows about
    691        this.ruleView.tooltips.getTooltip("colorPicker").addSwatch(span, {
    692          onShow: this.#onStartEditing,
    693          onPreview: this.#onSwatchPreview,
    694          onCommit: this.#onSwatchCommit,
    695          onRevert: this.#onSwatchRevert,
    696        });
    697        const title = l10n("rule.colorSwatch.tooltip");
    698        span.setAttribute("title", title);
    699        span.dataset.propertyName = this.nameSpan.textContent;
    700      }
    701    }
    702 
    703    // Attach the cubic-bezier tooltip to the bezier swatches
    704    this.#bezierSwatchSpans = this.valueSpan.querySelectorAll(
    705      "." + BEZIER_SWATCH_CLASS
    706    );
    707    if (this.ruleEditor.isEditable) {
    708      for (const span of this.#bezierSwatchSpans) {
    709        // Adding this swatch to the list of swatches our colorpicker
    710        // knows about
    711        this.ruleView.tooltips.getTooltip("cubicBezier").addSwatch(span, {
    712          onShow: this.#onStartEditing,
    713          onPreview: this.#onSwatchPreview,
    714          onCommit: this.#onSwatchCommit,
    715          onRevert: this.#onSwatchRevert,
    716        });
    717        const title = l10n("rule.bezierSwatch.tooltip");
    718        span.setAttribute("title", title);
    719      }
    720    }
    721 
    722    // Attach the linear easing tooltip to the linear easing swatches
    723    this.#linearEasingSwatchSpans = this.valueSpan.querySelectorAll(
    724      "." + LINEAR_EASING_SWATCH_CLASS
    725    );
    726    if (this.ruleEditor.isEditable) {
    727      for (const span of this.#linearEasingSwatchSpans) {
    728        // Adding this swatch to the list of swatches our colorpicker
    729        // knows about
    730        this.ruleView.tooltips
    731          .getTooltip("linearEaseFunction")
    732          .addSwatch(span, {
    733            onShow: this.#onStartEditing,
    734            onPreview: this.#onSwatchPreview,
    735            onCommit: this.#onSwatchCommit,
    736            onRevert: this.#onSwatchRevert,
    737          });
    738        span.setAttribute("title", l10n("rule.bezierSwatch.tooltip"));
    739      }
    740    }
    741 
    742    // Attach the filter editor tooltip to the filter swatch
    743    const span = this.valueSpan.querySelector("." + FILTER_SWATCH_CLASS);
    744    if (this.ruleEditor.isEditable) {
    745      if (span) {
    746        this.outputParserOptions.filterSwatch = true;
    747 
    748        this.ruleView.tooltips.getTooltip("filterEditor").addSwatch(
    749          span,
    750          {
    751            onShow: this.#onStartEditing,
    752            onPreview: this.#onSwatchPreview,
    753            onCommit: this.#onSwatchCommit,
    754            onRevert: this.#onSwatchRevert,
    755          },
    756          outputParser,
    757          this.outputParserOptions
    758        );
    759        const title = l10n("rule.filterSwatch.tooltip");
    760        span.setAttribute("title", title);
    761      }
    762    }
    763 
    764    this.angleSwatchSpans = this.valueSpan.querySelectorAll(
    765      "." + ANGLE_SWATCH_CLASS
    766    );
    767    if (this.ruleEditor.isEditable) {
    768      for (const angleSpan of this.angleSwatchSpans) {
    769        angleSpan.addEventListener("unit-change", this.#onSwatchCommit);
    770        const title = l10n("rule.angleSwatch.tooltip");
    771        angleSpan.setAttribute("title", title);
    772      }
    773    }
    774 
    775    const nodeFront = this.ruleView.inspector.selection.nodeFront;
    776 
    777    const flexToggle = this.valueSpan.querySelector(".inspector-flex");
    778    if (flexToggle) {
    779      flexToggle.setAttribute("title", l10n("rule.flexToggle.tooltip"));
    780      flexToggle.setAttribute(
    781        "aria-pressed",
    782        this.ruleView.inspector.highlighters.getNodeForActiveHighlighter(
    783          this.ruleView.inspector.highlighters.TYPES.FLEXBOX
    784        ) === nodeFront
    785      );
    786    }
    787 
    788    const gridToggle = this.valueSpan.querySelector(".inspector-grid");
    789    if (gridToggle) {
    790      gridToggle.setAttribute("title", l10n("rule.gridToggle.tooltip"));
    791      gridToggle.setAttribute(
    792        "aria-pressed",
    793        this.ruleView.highlighters.gridHighlighters.has(nodeFront)
    794      );
    795      gridToggle.toggleAttribute(
    796        "disabled",
    797        !this.ruleView.highlighters.canGridHighlighterToggle(nodeFront)
    798      );
    799    }
    800 
    801    const shapeToggle = this.valueSpan.querySelector(".inspector-shapeswatch");
    802    if (shapeToggle) {
    803      const mode =
    804        "css" +
    805        name
    806          .split("-")
    807          .map(s => {
    808            return s[0].toUpperCase() + s.slice(1);
    809          })
    810          .join("");
    811      shapeToggle.setAttribute("data-mode", mode);
    812      shapeToggle.setAttribute("aria-pressed", false);
    813      shapeToggle.setAttribute("title", l10n("rule.shapeToggle.tooltip"));
    814    }
    815 
    816    // Now that we have updated the property's value, we might have a pending
    817    // click on the value container. If we do, we have to trigger a click event
    818    // on the right element.
    819    // If we are dragging, we don't need to handle the pending click
    820    if (this.#hasPendingClick && !this.#isDragging) {
    821      this.#hasPendingClick = false;
    822      this.#elementsWithPendingClicks.delete(this.valueSpan);
    823      let elToClick;
    824 
    825      if (this.#clickedElementOptions !== null) {
    826        const { selector, index } = this.#clickedElementOptions;
    827        elToClick = this.valueSpan.querySelectorAll(selector)[index];
    828 
    829        this.#clickedElementOptions = null;
    830      }
    831 
    832      if (!elToClick) {
    833        elToClick = this.valueSpan;
    834      }
    835      elToClick.click();
    836    }
    837 
    838    // Populate the computed styles and shorthand overridden styles.
    839    this.#updateComputed();
    840    this.#updateShorthandOverridden();
    841 
    842    // Update the rule property highlight.
    843    this.ruleView._updatePropertyHighlight(this);
    844 
    845    // Restore focus back to the element whose markup was recreated above, if
    846    // the focus is still in the current document (avoid stealing the focus, see
    847    // Bug 1911627).
    848    if (this.doc.hasFocus() && focusedElSelector) {
    849      const elementToFocus = this.doc.querySelector(focusedElSelector);
    850      if (elementToFocus) {
    851        elementToFocus.focus();
    852      }
    853    }
    854  };
    855 
    856  #onStartEditing = () => {
    857    this.element.classList.remove("ruleview-overridden", "ruleview-invalid");
    858    this.enable.style.visibility = "hidden";
    859    if (this.filterProperty) {
    860      this.filterProperty.hidden = true;
    861    }
    862    if (this.expander) {
    863      this.expander.hidden = true;
    864    }
    865  };
    866 
    867  get #shouldShowComputedExpander() {
    868    if (this.prop.name.startsWith("--") || this.editing) {
    869      return false;
    870    }
    871 
    872    // Only show the expander to reveal computed properties if:
    873    // - the computed properties are actually different from the current property (i.e
    874    //   these are longhands while the current property is the shorthand)
    875    // - all of the computed properties have defined values. In case the current property
    876    //   value contains CSS variables, then the computed properties will be missing and we
    877    //   want to avoid showing them.
    878    return (
    879      this.prop.computed.some(c => c.name !== this.prop.name) &&
    880      !this.prop.computed.every(c => !c.value)
    881    );
    882  }
    883 
    884  get #shouldShowWarning() {
    885    if (this.prop.name.startsWith("--")) {
    886      return false;
    887    }
    888 
    889    return !this.editing && !this.isValid();
    890  }
    891 
    892  get #shouldShowInactiveCssState() {
    893    return (
    894      !this.editing &&
    895      !this.prop.overridden &&
    896      this.prop.enabled &&
    897      !!this.prop.getInactiveCssData()
    898    );
    899  }
    900 
    901  get #shouldShowFilterProperty() {
    902    return (
    903      !this.editing &&
    904      this.isValid() &&
    905      this.prop.overridden &&
    906      !this.ruleEditor.rule.isUnmatched
    907    );
    908  }
    909 
    910  #createComputedExpander() {
    911    if (this.expander) {
    912      return;
    913    }
    914 
    915    // Click to expand the computed properties of the text property.
    916    this.expander = this.doc.createElementNS(HTML_NS, "button");
    917    this.expander.ariaExpanded = false;
    918    this.expander.classList.add("ruleview-expander", "theme-twisty");
    919    this.expander.title = SHORTHAND_EXPANDER_TOOLTIP;
    920 
    921    this.expander.addEventListener("click", this.#onExpandClicked, {
    922      capture: true,
    923      signal: this.abortController.signal,
    924    });
    925 
    926    this.container.insertBefore(this.expander, this.valueContainer);
    927  }
    928 
    929  #createComputedList() {
    930    if (this.computed) {
    931      return;
    932    }
    933    this.computed = this.doc.createElementNS(HTML_NS, "ul");
    934    this.computed.classList.add("ruleview-computedlist");
    935    this.element.insertBefore(this.computed, this.shorthandOverridden);
    936  }
    937 
    938  #createWarningIcon() {
    939    if (this.warning) {
    940      return;
    941    }
    942 
    943    this.warning = this.doc.createElementNS(HTML_NS, "div");
    944    this.warning.classList.add("ruleview-warning");
    945    this.warning.title = l10n("rule.warning.title");
    946    this.container.insertBefore(
    947      this.warning,
    948      this.invalidAtComputedValueTimeWarning ||
    949        this.inactiveCssState ||
    950        this.compatibilityState ||
    951        this.filterProperty
    952    );
    953  }
    954 
    955  #createInvalidAtComputedValueTimeIcon() {
    956    if (this.invalidAtComputedValueTimeWarning) {
    957      return;
    958    }
    959 
    960    this.invalidAtComputedValueTimeWarning = this.doc.createElementNS(
    961      HTML_NS,
    962      "div"
    963    );
    964    this.invalidAtComputedValueTimeWarning.classList.add(
    965      "ruleview-invalid-at-computed-value-time-warning"
    966    );
    967    this.container.insertBefore(
    968      this.invalidAtComputedValueTimeWarning,
    969      this.inactiveCssState || this.compatibilityState || this.filterProperty
    970    );
    971  }
    972 
    973  #createInactiveCssWarningIcon() {
    974    if (this.inactiveCssState) {
    975      return;
    976    }
    977 
    978    this.inactiveCssState = this.doc.createElementNS(HTML_NS, "div");
    979    this.inactiveCssState.classList.add("ruleview-inactive-css-warning");
    980    this.container.insertBefore(
    981      this.inactiveCssState,
    982      this.compatibilityState || this.filterProperty
    983    );
    984  }
    985 
    986  #createCompatibilityWarningIcon() {
    987    if (this.compatibilityState) {
    988      return;
    989    }
    990 
    991    this.compatibilityState = this.doc.createElementNS(HTML_NS, "div");
    992    this.compatibilityState.classList.add("ruleview-compatibility-warning");
    993    this.container.insertBefore(this.compatibilityState, this.filterProperty);
    994  }
    995 
    996  #createFilterPropertyButton() {
    997    if (this.filterProperty) {
    998      return;
    999    }
   1000 
   1001    // Filter button that filters for the current property name and is
   1002    // displayed when the property is overridden by another rule.
   1003    this.filterProperty = this.doc.createElementNS(HTML_NS, "button");
   1004    this.filterProperty.classList.add("ruleview-overridden-rule-filter");
   1005    this.filterProperty.title = l10n("rule.filterProperty.title");
   1006    this.container.append(this.filterProperty);
   1007 
   1008    this.filterProperty.addEventListener(
   1009      "click",
   1010      event => {
   1011        this.ruleEditor.ruleView.setFilterStyles("`" + this.prop.name + "`");
   1012        event.stopPropagation();
   1013      },
   1014      { signal: this.abortController.signal }
   1015    );
   1016  }
   1017 
   1018  /**
   1019   * Update the visibility of the enable checkbox, the warning indicator, the used
   1020   * indicator and the filter property, as well as the overridden state of the property.
   1021   */
   1022  updateUI = () => {
   1023    if (this.prop.enabled) {
   1024      this.enable.style.removeProperty("visibility");
   1025    } else {
   1026      this.enable.style.visibility = "visible";
   1027    }
   1028 
   1029    this.enable.checked = this.prop.enabled;
   1030 
   1031    if (this.#shouldShowWarning) {
   1032      this.element.classList.add("ruleview-invalid");
   1033 
   1034      if (!this.warning) {
   1035        this.#createWarningIcon();
   1036      } else {
   1037        this.warning.hidden = false;
   1038      }
   1039      this.warning.title = !this.#isNameValid()
   1040        ? l10n("rule.warningName.title")
   1041        : l10n("rule.warning.title");
   1042    } else {
   1043      this.element.classList.remove("ruleview-invalid");
   1044      if (this.warning) {
   1045        this.warning.hidden = true;
   1046      }
   1047    }
   1048 
   1049    if (!this.editing && this.#isInvalidAtComputedValueTime()) {
   1050      if (!this.invalidAtComputedValueTimeWarning) {
   1051        this.#createInvalidAtComputedValueTimeIcon();
   1052      }
   1053      this.invalidAtComputedValueTimeWarning.title = l10nFormatStr(
   1054        "rule.warningInvalidAtComputedValueTime.title",
   1055        `"${this.prop.getExpectedSyntax()}"`
   1056      );
   1057      this.invalidAtComputedValueTimeWarning.hidden = false;
   1058    } else if (this.invalidAtComputedValueTimeWarning) {
   1059      this.invalidAtComputedValueTimeWarning.hidden = true;
   1060    }
   1061 
   1062    if (this.#shouldShowFilterProperty) {
   1063      if (!this.filterProperty) {
   1064        this.#createFilterPropertyButton();
   1065      }
   1066      this.filterProperty.hidden = false;
   1067    } else if (this.filterProperty) {
   1068      this.filterProperty.hidden = true;
   1069    }
   1070 
   1071    if (this.#shouldShowComputedExpander) {
   1072      if (!this.expander) {
   1073        this.#createComputedExpander();
   1074      }
   1075      this.expander.hidden = false;
   1076    } else if (this.expander) {
   1077      this.expander.hidden = true;
   1078    }
   1079 
   1080    if (
   1081      !this.editing &&
   1082      (this.prop.overridden || !this.prop.enabled || !this.prop.isKnownProperty)
   1083    ) {
   1084      this.element.classList.add("ruleview-overridden");
   1085    } else {
   1086      this.element.classList.remove("ruleview-overridden");
   1087    }
   1088 
   1089    this.#updateInactiveCssIndicator();
   1090    this.#updatePropertyCompatibilityIndicator();
   1091  };
   1092 
   1093  #updateInactiveCssIndicator() {
   1094    const inactiveCssData = this.prop.getInactiveCssData();
   1095 
   1096    if (
   1097      this.editing ||
   1098      this.prop.overridden ||
   1099      !this.prop.enabled ||
   1100      !inactiveCssData
   1101    ) {
   1102      this.element.classList.remove("inactive-css");
   1103      if (this.inactiveCssState) {
   1104        this.inactiveCssState.hidden = true;
   1105      }
   1106    } else {
   1107      this.element.classList.add("inactive-css");
   1108      if (!this.inactiveCssState) {
   1109        this.#createInactiveCssWarningIcon();
   1110      } else {
   1111        this.inactiveCssState.hidden = false;
   1112      }
   1113    }
   1114  }
   1115 
   1116  async #updatePropertyCompatibilityIndicator() {
   1117    const { isCompatible } = await this.prop.isCompatible();
   1118 
   1119    if (this.editing || isCompatible) {
   1120      if (this.compatibilityState) {
   1121        this.compatibilityState.hidden = true;
   1122      }
   1123    } else {
   1124      if (!this.compatibilityState) {
   1125        this.#createCompatibilityWarningIcon();
   1126      }
   1127      this.compatibilityState.hidden = false;
   1128    }
   1129  }
   1130 
   1131  /**
   1132   * Update the indicator for computed styles. The computed styles themselves
   1133   * are populated on demand, when they become visible.
   1134   */
   1135  #updateComputed() {
   1136    if (this.computed) {
   1137      this.computed.replaceChildren();
   1138    }
   1139 
   1140    if (this.#shouldShowComputedExpander) {
   1141      if (!this.expander) {
   1142        this.#createComputedExpander();
   1143      }
   1144      this.expander.hidden = false;
   1145    } else if (this.expander) {
   1146      this.expander.hidden = true;
   1147    }
   1148 
   1149    this.#populatedComputed = false;
   1150    if (
   1151      this.expander &&
   1152      this.expander.getAttribute("aria-expanded" === "true")
   1153    ) {
   1154      this.populateComputed();
   1155    }
   1156  }
   1157 
   1158  /**
   1159   * Populate the list of computed styles.
   1160   */
   1161  populateComputed() {
   1162    if (this.#populatedComputed) {
   1163      return;
   1164    }
   1165    this.#populatedComputed = true;
   1166 
   1167    for (const computed of this.prop.computed) {
   1168      // Don't bother to duplicate information already
   1169      // shown in the text property.
   1170      if (computed.name === this.prop.name) {
   1171        continue;
   1172      }
   1173 
   1174      if (!this.computed) {
   1175        this.#createComputedList();
   1176      }
   1177 
   1178      // Store the computed style element for easy access when highlighting styles
   1179      computed.element = this.#createComputedListItem(
   1180        this.computed,
   1181        computed,
   1182        "ruleview-computed"
   1183      );
   1184    }
   1185  }
   1186 
   1187  /**
   1188   * Update the indicator for overridden shorthand styles. The shorthand
   1189   * overridden styles themselves are populated on demand, when they
   1190   * become visible.
   1191   */
   1192  #updateShorthandOverridden() {
   1193    if (this.shorthandOverridden) {
   1194      this.shorthandOverridden.replaceChildren();
   1195    }
   1196 
   1197    this.#populatedShorthandOverridden = false;
   1198    this.#populateShorthandOverridden();
   1199  }
   1200 
   1201  /**
   1202   * Populate the list of overridden shorthand styles.
   1203   */
   1204  #populateShorthandOverridden() {
   1205    if (
   1206      this.#populatedShorthandOverridden ||
   1207      this.prop.overridden ||
   1208      !this.#shouldShowComputedExpander
   1209    ) {
   1210      return;
   1211    }
   1212    this.#populatedShorthandOverridden = true;
   1213 
   1214    // Holds the viewers for the overridden shorthand properties.
   1215    // will be populated in |#updateShorthandOverridden|.
   1216    if (!this.shorthandOverridden) {
   1217      this.shorthandOverridden = this.doc.createElementNS(HTML_NS, "ul");
   1218      this.shorthandOverridden.classList.add("ruleview-overridden-items");
   1219      this.element.append(this.shorthandOverridden);
   1220    }
   1221 
   1222    for (const computed of this.prop.computed) {
   1223      // Don't display duplicate information or show properties
   1224      // that are completely overridden.
   1225      if (computed.name === this.prop.name || !computed.overridden) {
   1226        continue;
   1227      }
   1228 
   1229      this.#createComputedListItem(
   1230        this.shorthandOverridden,
   1231        computed,
   1232        "ruleview-overridden-item"
   1233      );
   1234    }
   1235  }
   1236 
   1237  /**
   1238   * Creates and populates a list item with the computed CSS property.
   1239   */
   1240  #createComputedListItem(parentEl, computed, className) {
   1241    const li = createChild(parentEl, "li", {
   1242      class: className,
   1243    });
   1244 
   1245    if (computed.overridden) {
   1246      li.classList.add("ruleview-overridden");
   1247    }
   1248 
   1249    const nameContainer = createChild(li, "span", {
   1250      class: "ruleview-namecontainer",
   1251    });
   1252 
   1253    createChild(nameContainer, "span", {
   1254      class: "ruleview-propertyname theme-fg-color3",
   1255      textContent: computed.name,
   1256    });
   1257    appendText(nameContainer, ": ");
   1258 
   1259    const outputParser = this.ruleView._outputParser;
   1260    const frag = outputParser.parseCssProperty(computed.name, computed.value, {
   1261      colorSwatchClass: "inspector-swatch inspector-colorswatch",
   1262      urlClass: "theme-link",
   1263      baseURI: this.#sheetHref,
   1264      fontFamilyClass: "ruleview-font-family",
   1265    });
   1266 
   1267    // Store the computed property value that was parsed for output
   1268    computed.parsedValue = frag.textContent;
   1269 
   1270    const propertyContainer = createChild(li, "span", {
   1271      class: "ruleview-propertyvaluecontainer",
   1272    });
   1273 
   1274    createChild(propertyContainer, "span", {
   1275      class: "ruleview-propertyvalue theme-fg-color1",
   1276      child: frag,
   1277    });
   1278    appendText(propertyContainer, ";");
   1279 
   1280    return li;
   1281  }
   1282 
   1283  /**
   1284   * Handle updates to the preference which disables/enables the feature to
   1285   * edit size properties on drag.
   1286   */
   1287  #onDraggablePreferenceChanged = () => {
   1288    if (this.#isDraggableProperty(this.prop)) {
   1289      this.#addDraggingCapability();
   1290    } else {
   1291      this.#removeDraggingCapacity();
   1292    }
   1293  };
   1294 
   1295  /**
   1296   * Stop clicks propogating down the tree from the enable / disable checkbox.
   1297   */
   1298  #onEnableClicked = event => {
   1299    event.stopPropagation();
   1300  };
   1301 
   1302  /**
   1303   * Handles clicks on the disabled property.
   1304   */
   1305  #onEnableChanged = event => {
   1306    this.prop.setEnabled(this.enable.checked);
   1307    event.stopPropagation();
   1308    this.telemetry.recordEvent("edit_rule", "ruleview");
   1309  };
   1310 
   1311  /**
   1312   * Handles clicks on the computed property expander. If the computed list is
   1313   * open due to user expanding or style filtering, collapse the computed list
   1314   * and close the expander. Otherwise, add user-open attribute which is used to
   1315   * expand the computed list and tracks whether or not the computed list is
   1316   * expanded by manually by the user.
   1317   */
   1318  #onExpandClicked = event => {
   1319    if (!this.computed) {
   1320      // Holds the viewers for the computed properties.
   1321      // will be populated in |#updateComputed|.
   1322      this.#createComputedList();
   1323    }
   1324    const isOpened =
   1325      this.computed.hasAttribute("filter-open") ||
   1326      this.computed.hasAttribute("user-open");
   1327 
   1328    this.expander.setAttribute("aria-expanded", !isOpened);
   1329    if (isOpened) {
   1330      this.computed.removeAttribute("filter-open");
   1331      this.computed.removeAttribute("user-open");
   1332      if (this.shorthandOverridden) {
   1333        this.shorthandOverridden.hidden = false;
   1334      }
   1335      this.#populateShorthandOverridden();
   1336    } else {
   1337      this.computed.setAttribute("user-open", "");
   1338      if (this.shorthandOverridden) {
   1339        this.shorthandOverridden.hidden = true;
   1340      }
   1341      this.populateComputed();
   1342    }
   1343 
   1344    event.stopPropagation();
   1345  };
   1346 
   1347  /**
   1348   * Expands the computed list when a computed property is matched by the style
   1349   * filtering. The filter-open attribute is used to track whether or not the
   1350   * computed list was toggled opened by the filter.
   1351   */
   1352  expandForFilter() {
   1353    if (!this.computed || !this.computed.hasAttribute("user-open")) {
   1354      if (!this.expander) {
   1355        this.#createComputedExpander();
   1356      }
   1357      this.expander.hidden = false;
   1358      this.expander.setAttribute("aria-expanded", "true");
   1359 
   1360      if (!this.computed) {
   1361        this.#createComputedList();
   1362      }
   1363      this.computed.setAttribute("filter-open", "");
   1364      this.populateComputed();
   1365    }
   1366  }
   1367 
   1368  /**
   1369   * Collapses the computed list that was expanded by style filtering.
   1370   */
   1371  collapseForFilter() {
   1372    this.computed.removeAttribute("filter-open");
   1373 
   1374    if (!this.computed.hasAttribute("user-open") && this.expander) {
   1375      this.expander.setAttribute("aria-expanded", "false");
   1376    }
   1377  }
   1378 
   1379  /**
   1380   * Called when the property name's inplace editor is closed.
   1381   * Ignores the change if the user pressed escape, otherwise
   1382   * commits it.
   1383   *
   1384   * @param {string} value
   1385   *        The value contained in the editor.
   1386   * @param {boolean} commit
   1387   *        True if the change should be applied.
   1388   * @param {number} direction
   1389   *        The move focus direction number.
   1390   */
   1391  #onNameDone = (value, commit, direction) => {
   1392    const isNameUnchanged =
   1393      (!commit && !this.ruleEditor.isEditing) || this.committed.name === value;
   1394    if (this.prop.value && isNameUnchanged) {
   1395      return;
   1396    }
   1397 
   1398    this.telemetry.recordEvent("edit_rule", "ruleview");
   1399 
   1400    // Remove a property if the name is empty
   1401    if (!value.trim()) {
   1402      this.remove(direction);
   1403      return;
   1404    }
   1405 
   1406    const isVariable = value.startsWith("--");
   1407 
   1408    // Remove a property if:
   1409    // - the property value is empty and is not a variable (empty variables are valid)
   1410    // - and the property value is not about to be focused
   1411    if (
   1412      !this.prop.value &&
   1413      !isVariable &&
   1414      direction !== Services.focus.MOVEFOCUS_FORWARD
   1415    ) {
   1416      this.remove(direction);
   1417      return;
   1418    }
   1419 
   1420    // Adding multiple rules inside of name field overwrites the current
   1421    // property with the first, then adds any more onto the property list.
   1422    const properties = parseDeclarations(this.cssProperties.isKnown, value);
   1423 
   1424    if (properties.length) {
   1425      this.prop.setName(properties[0].name);
   1426      this.committed.name = this.prop.name;
   1427 
   1428      if (!this.prop.enabled) {
   1429        this.prop.setEnabled(true);
   1430      }
   1431 
   1432      if (properties.length > 1) {
   1433        this.prop.setValue(properties[0].value, properties[0].priority);
   1434        this.ruleEditor.addProperties(properties.slice(1), this.prop);
   1435      }
   1436    }
   1437  };
   1438 
   1439  /**
   1440   * Remove property from style and the editors from DOM.
   1441   * Begin editing next or previous available property given the focus
   1442   * direction.
   1443   *
   1444   * @param {number} direction
   1445   *        The move focus direction number.
   1446   */
   1447  remove(direction) {
   1448    this.ruleEditor.rule.editClosestTextProperty(this.prop, direction);
   1449 
   1450    this.prop.remove();
   1451    this.nameSpan.textProperty = null;
   1452    this.valueSpan.textProperty = null;
   1453    this.element.remove();
   1454 
   1455    this.destroy();
   1456  }
   1457 
   1458  /**
   1459   * Called when a value editor closes.  If the user pressed escape,
   1460   * revert to the value this property had before editing.
   1461   *
   1462   * @param {string} value
   1463   *        The value contained in the editor.
   1464   * @param {boolean} commit
   1465   *        True if the change should be applied.
   1466   * @param {number} direction
   1467   *        The move focus direction number.
   1468   */
   1469  #onValueDone = (value = "", commit, direction) => {
   1470    const parsedProperties = this.#getValueAndExtraProperties(value);
   1471    const val = parseSingleValue(
   1472      this.cssProperties.isKnown,
   1473      parsedProperties.firstValue
   1474    );
   1475    const isValueUnchanged =
   1476      (!commit && !this.ruleEditor.isEditing) ||
   1477      (!parsedProperties.propertiesToAdd.length &&
   1478        this.committed.value === val.value &&
   1479        this.committed.priority === val.priority);
   1480 
   1481    const isVariable = this.prop.name.startsWith("--");
   1482 
   1483    // If the value is not empty (or is an empty variable) and unchanged,
   1484    // revert the property back to its original value and enabled or disabled state
   1485    if ((value.trim() || isVariable) && isValueUnchanged) {
   1486      const onPropertySet = this.ruleEditor.rule.previewPropertyValue(
   1487        this.prop,
   1488        val.value,
   1489        val.priority
   1490      );
   1491      this.rule.setPropertyEnabled(this.prop, this.prop.enabled);
   1492      return onPropertySet;
   1493    }
   1494 
   1495    // Check if unit of value changed to add dragging feature
   1496    if (this.#isDraggableProperty(val)) {
   1497      this.#addDraggingCapability();
   1498    } else {
   1499      this.#removeDraggingCapacity();
   1500    }
   1501 
   1502    this.telemetry.recordEvent("edit_rule", "ruleview");
   1503 
   1504    // First, set this property value (common case, only modified a property)
   1505    const onPropertySet = this.prop.setValue(val.value, val.priority);
   1506 
   1507    if (!this.prop.enabled) {
   1508      this.prop.setEnabled(true);
   1509    }
   1510 
   1511    this.committed.value = this.prop.value;
   1512    this.committed.priority = this.prop.priority;
   1513 
   1514    // If needed, add any new properties after this.prop.
   1515    this.ruleEditor.addProperties(parsedProperties.propertiesToAdd, this.prop);
   1516 
   1517    // If the input value is empty and is not a variable (empty variables are valid),
   1518    // and the focus is moving forward to the next editable field,
   1519    // then remove the whole property.
   1520    // A timeout is used here to accurately check the state, since the inplace
   1521    // editor `done` and `destroy` events fire before the next editor
   1522    // is focused.
   1523    if (
   1524      !value.trim() &&
   1525      !isVariable &&
   1526      direction !== Services.focus.MOVEFOCUS_BACKWARD
   1527    ) {
   1528      setTimeout(() => {
   1529        if (!this.editing) {
   1530          this.remove(direction);
   1531        }
   1532      }, 0);
   1533    }
   1534 
   1535    return onPropertySet;
   1536  };
   1537 
   1538  /**
   1539   * Called when the swatch editor wants to commit a value change.
   1540   */
   1541  #onSwatchCommit = () => {
   1542    this.#onValueDone(this.valueSpan.textContent, true);
   1543    this.update();
   1544  };
   1545 
   1546  /**
   1547   * Called when the swatch editor wants to preview a value change.
   1548   */
   1549  #onSwatchPreview = () => {
   1550    this.#previewValue(this.valueSpan.textContent);
   1551  };
   1552 
   1553  /**
   1554   * Called when the swatch editor closes from an ESC. Revert to the original
   1555   * value of this property before editing.
   1556   */
   1557  #onSwatchRevert = () => {
   1558    this.#previewValue(this.prop.value, true);
   1559    this.update();
   1560  };
   1561 
   1562  /**
   1563   * Parse a value string and break it into pieces, starting with the
   1564   * first value, and into an array of additional properties (if any).
   1565   *
   1566   * Example: Calling with "red; width: 100px" would return
   1567   * { firstValue: "red", propertiesToAdd: [{ name: "width", value: "100px" }] }
   1568   *
   1569   * @param {string} value
   1570   *        The string to parse
   1571   * @return {object} An object with the following properties:
   1572   *        firstValue: A string containing a simple value, like
   1573   *                    "red" or "100px!important"
   1574   *        propertiesToAdd: An array with additional properties, following the
   1575   *                         parseDeclarations format of {name,value,priority}
   1576   */
   1577  #getValueAndExtraProperties(value) {
   1578    // The inplace editor will prevent manual typing of multiple properties,
   1579    // but we need to deal with the case during a paste event.
   1580    // Adding multiple properties inside of value editor sets value with the
   1581    // first, then adds any more onto the property list (below this property).
   1582    let firstValue = value;
   1583    let propertiesToAdd = [];
   1584 
   1585    const properties = parseDeclarations(this.cssProperties.isKnown, value);
   1586 
   1587    // Check to see if the input string can be parsed as multiple properties
   1588    if (properties.length) {
   1589      // Get the first property value (if any), and any remaining
   1590      // properties (if any)
   1591      if (!properties[0].name && properties[0].value) {
   1592        firstValue = properties[0].value;
   1593        propertiesToAdd = properties.slice(1);
   1594      } else if (properties[0].name && properties[0].value) {
   1595        // In some cases, the value could be a property:value pair
   1596        // itself.  Join them as one value string and append
   1597        // potentially following properties
   1598        firstValue = properties[0].name + ": " + properties[0].value;
   1599        propertiesToAdd = properties.slice(1);
   1600      }
   1601    }
   1602 
   1603    return {
   1604      propertiesToAdd,
   1605      firstValue,
   1606    };
   1607  }
   1608 
   1609  /**
   1610   * Live preview this property, without committing changes.
   1611   *
   1612   * @param {string} value
   1613   *        The value to set the current property to.
   1614   * @param {boolean} reverting
   1615   *        True if we're reverting the previously previewed value
   1616   */
   1617  #previewValue = (value, reverting = false) => {
   1618    // Since function call is debounced, we need to make sure we are still
   1619    // editing, and any selector modifications have been completed
   1620    if (!reverting && (!this.editing || this.ruleEditor.isEditing)) {
   1621      return;
   1622    }
   1623 
   1624    const val = parseSingleValue(this.cssProperties.isKnown, value);
   1625    this.ruleEditor.rule.previewPropertyValue(
   1626      this.prop,
   1627      val.value,
   1628      val.priority
   1629    );
   1630  };
   1631 
   1632  /**
   1633   * Check if the event passed has a "small increment" modifier
   1634   * Alt on macosx and ctrl on other OSs
   1635   *
   1636   * @param  {KeyboardEvent} event
   1637   * @returns {boolean}
   1638   */
   1639  #hasSmallIncrementModifier(event) {
   1640    const modifier =
   1641      lazy.AppConstants.platform === "macosx" ? "altKey" : "ctrlKey";
   1642    return event[modifier] === true;
   1643  }
   1644 
   1645  /**
   1646   * Parses the value to check if it is a dimension
   1647   * e.g. if the input is "128px" it will return an object like
   1648   * { groups: { value: "128", unit: "px"}}
   1649   *
   1650   * @param  {string} value
   1651   * @returns {object | null}
   1652   */
   1653  #parseDimension(value) {
   1654    // The regex handles values like +1, -1, 1e4, .4, 1.3e-4, 1.567
   1655    const cssDimensionRegex =
   1656      /^(?<value>[+-]?(\d*\.)?\d+(e[+-]?\d+)?)(?<unit>(%|[a-zA-Z]+))$/;
   1657    return value.match(cssDimensionRegex);
   1658  }
   1659 
   1660  /**
   1661   * Check if a textProperty value is supported to add the dragging feature
   1662   *
   1663   * @param  {TextProperty} textProperty
   1664   * @returns {boolean}
   1665   */
   1666  #isDraggableProperty(textProperty) {
   1667    // Check if the feature is explicitly disabled.
   1668    if (!this.ruleView.draggablePropertiesEnabled) {
   1669      return false;
   1670    }
   1671    // temporary way of fixing the bug when editing inline styles
   1672    // otherwise the textPropertyEditor object is destroyed on each value edit
   1673    // See Bug 1755024
   1674    if (this.rule.domRule.type == ELEMENT_STYLE) {
   1675      return false;
   1676    }
   1677 
   1678    const nbValues = textProperty.value.split(" ").length;
   1679    if (nbValues > 1) {
   1680      // we do not support values like "1px solid red" yet
   1681      // See 1755025
   1682      return false;
   1683    }
   1684 
   1685    const dimensionMatchObj = this.#parseDimension(textProperty.value);
   1686    return !!dimensionMatchObj;
   1687  }
   1688 
   1689  #draggingOnPointerDown = event => {
   1690    if (!canPointerEventDrag(event)) {
   1691      return;
   1692    }
   1693 
   1694    this.#isDragging = true;
   1695    this.valueSpan.setPointerCapture(event.pointerId);
   1696    this.#capturingPointerId = event.pointerId;
   1697    this.#draggingController = new AbortController();
   1698    const { signal } = this.#draggingController;
   1699 
   1700    // turn off user-select in CSS when we drag
   1701    this.valueSpan.classList.add(IS_DRAGGING_CLASSNAME);
   1702 
   1703    const dimensionObj = this.#parseDimension(this.prop.value);
   1704    const { value, unit } = dimensionObj.groups;
   1705    // `pointerdown.screenX` may be fractional value and we will compare it
   1706    // with `mousemove.screenX` which is always integer value and the difference
   1707    // will be used to compute the new value.  For making the difference always
   1708    // integer, we need to convert it to integer value with the same as
   1709    // MouseEvent does.
   1710    // https://searchfox.org/mozilla-central/rev/7857ea04d142f2abc0d777085f9e54526c7cedf9/dom/events/MouseEvent.cpp#314
   1711    // https://searchfox.org/mozilla-central/rev/7857ea04d142f2abc0d777085f9e54526c7cedf9/gfx/2d/Point.h#85-86
   1712    const intScreenX = Math.floor(event.screenX + 0.5);
   1713    this.#draggingValueCache = {
   1714      isInDeadzone: true,
   1715      previousScreenX: intScreenX,
   1716      value: parseFloat(value),
   1717      unit,
   1718    };
   1719 
   1720    // "pointermove" is fired when the button state is changed too.  Therefore,
   1721    // we should listen to "mousemove" to handle the pointer position changes.
   1722    this.valueSpan.addEventListener("mousemove", this.#draggingOnMouseMove, {
   1723      signal,
   1724    });
   1725    this.valueSpan.addEventListener("pointerup", this.#draggingOnPointerUp, {
   1726      signal,
   1727    });
   1728    this.valueSpan.addEventListener("keydown", this.#draggingOnKeydown, {
   1729      signal,
   1730    });
   1731  };
   1732 
   1733  #draggingOnMouseMove = throttle(event => {
   1734    if (!this.#isDragging) {
   1735      return;
   1736    }
   1737 
   1738    const { isInDeadzone, previousScreenX } = this.#draggingValueCache;
   1739    let deltaX = event.screenX - previousScreenX;
   1740 
   1741    // If `isInDeadzone` is still true, the user has not previously left the deadzone.
   1742    if (isInDeadzone) {
   1743      // If the mouse is still in the deadzone, bail out immediately.
   1744      if (Math.abs(deltaX) < DRAGGING_DEADZONE_DISTANCE) {
   1745        return;
   1746      }
   1747 
   1748      // Otherwise, remove the DRAGGING_DEADZONE_DISTANCE from the current deltaX, so that
   1749      // the value does not update too abruptly.
   1750      deltaX =
   1751        Math.sign(deltaX) * (Math.abs(deltaX) - DRAGGING_DEADZONE_DISTANCE);
   1752 
   1753      // Update the state to remember the user is out of the deadzone.
   1754      this.#draggingValueCache.isInDeadzone = false;
   1755    }
   1756 
   1757    let draggingSpeed = DEFAULT_DRAGGING_SPEED;
   1758    if (event.shiftKey) {
   1759      draggingSpeed = FAST_DRAGGING_SPEED;
   1760    } else if (this.#hasSmallIncrementModifier(event)) {
   1761      draggingSpeed = SLOW_DRAGGING_SPEED;
   1762    }
   1763 
   1764    const delta = deltaX * draggingSpeed;
   1765    this.#draggingValueCache.previousScreenX = event.screenX;
   1766    this.#draggingValueCache.value += delta;
   1767 
   1768    if (delta == 0) {
   1769      return;
   1770    }
   1771 
   1772    const { value, unit } = this.#draggingValueCache;
   1773    // We use toFixed to avoid the case where value is too long, 9.00001px for example
   1774    const roundedValue = Number.isInteger(value) ? value : value.toFixed(1);
   1775    this.prop
   1776      .setValue(roundedValue + unit, this.prop.priority)
   1777      .then(() => this.ruleView.emitForTests("property-updated-by-dragging"));
   1778    this.#hasDragged = true;
   1779  }, 30);
   1780 
   1781  #draggingOnPointerUp = () => {
   1782    if (!this.#isDragging) {
   1783      return;
   1784    }
   1785    if (this.#hasDragged) {
   1786      this.committed.value = this.prop.value;
   1787      this.prop.setEnabled(true);
   1788    }
   1789    this.#onStopDragging();
   1790  };
   1791 
   1792  #draggingOnKeydown = event => {
   1793    if (event.key == "Escape") {
   1794      this.prop.setValue(this.committed.value, this.committed.priority);
   1795      this.#onStopDragging();
   1796      event.preventDefault();
   1797    }
   1798  };
   1799 
   1800  #onStopDragging() {
   1801    // childHasDragged is used to stop the propagation of a click event when we
   1802    // release the mouse in the ruleview.
   1803    // The click event is not emitted when we have a pending click on the text property.
   1804    if (this.#hasDragged && !this.#hasPendingClick) {
   1805      this.ruleView.childHasDragged = true;
   1806    }
   1807    this.#isDragging = false;
   1808    this.#hasDragged = false;
   1809    this.#draggingValueCache = null;
   1810    if (this.#capturingPointerId !== null) {
   1811      this.#capturingPointerId = null;
   1812      try {
   1813        this.valueSpan.releasePointerCapture(this.#capturingPointerId);
   1814      } catch (e) {
   1815        // Ignore exception even if the pointerId has already been invalidated
   1816        // before the capture has already been released implicitly.
   1817      }
   1818    }
   1819    this.valueSpan.classList.remove(IS_DRAGGING_CLASSNAME);
   1820    this.#draggingController.abort();
   1821  }
   1822 
   1823  /**
   1824   * add event listeners to add the ability to modify any size value
   1825   * by dragging the mouse horizontally
   1826   */
   1827  #addDraggingCapability() {
   1828    if (this.valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME)) {
   1829      return;
   1830    }
   1831    this.valueSpan.classList.add(DRAGGABLE_VALUE_CLASSNAME);
   1832    this.valueSpan.addEventListener(
   1833      "pointerdown",
   1834      this.#draggingOnPointerDown,
   1835      { passive: true }
   1836    );
   1837  }
   1838 
   1839  #removeDraggingCapacity() {
   1840    if (!this.valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME)) {
   1841      return;
   1842    }
   1843    this.#draggingController = null;
   1844    this.valueSpan.classList.remove(DRAGGABLE_VALUE_CLASSNAME);
   1845    this.valueSpan.removeEventListener(
   1846      "pointerdown",
   1847      this.#draggingOnPointerDown,
   1848      { passive: true }
   1849    );
   1850  }
   1851 
   1852  /**
   1853   * Validate this property. Does it make sense for this value to be assigned
   1854   * to this property name? This does not apply the property value
   1855   *
   1856   * @return {boolean} true if the property name + value pair is valid, false otherwise.
   1857   */
   1858  isValid() {
   1859    return this.prop.isValid();
   1860  }
   1861 
   1862  /**
   1863   * Validate the name of this property.
   1864   *
   1865   * @return {boolean} true if the property name is valid, false otherwise.
   1866   */
   1867  #isNameValid() {
   1868    return this.prop.isNameValid();
   1869  }
   1870 
   1871  #isInvalidAtComputedValueTime() {
   1872    return this.prop.isInvalidAtComputedValueTime();
   1873  }
   1874 
   1875  /**
   1876   * Display grid-template-area value strings each on their own line
   1877   * to display it in an ascii-art style matrix
   1878   */
   1879  #formatGridTemplateAreasValue() {
   1880    this.valueSpan.classList.add("ruleview-propertyvalue-break-spaces");
   1881 
   1882    let quoteSymbolsUsed = [];
   1883 
   1884    const getQuoteSymbolsUsed = cssValue => {
   1885      const regex = /\"|\'/g;
   1886      const found = cssValue.match(regex);
   1887      quoteSymbolsUsed = found.filter((_, i) => i % 2 === 0);
   1888    };
   1889 
   1890    getQuoteSymbolsUsed(this.valueSpan.innerText);
   1891 
   1892    this.valueSpan.innerText = this.valueSpan.innerText
   1893      .split('"')
   1894      .filter(s => s !== "")
   1895      .map(s => s.split("'"))
   1896      .flat()
   1897      .map(s => s.trim().replace(/\s+/g, " "))
   1898      .filter(s => s.length)
   1899      .map(line => line.split(" "))
   1900      .map((line, i, lines) =>
   1901        line.map((col, j) =>
   1902          col.padEnd(Math.max(...lines.map(l => l[j]?.length || 0)), " ")
   1903        )
   1904      )
   1905      .map(
   1906        (line, i) =>
   1907          `\n${quoteSymbolsUsed[i]}` + line.join(" ") + quoteSymbolsUsed[i]
   1908      )
   1909      .join(" ");
   1910  }
   1911 
   1912  destroy() {
   1913    if (this.#colorSwatchSpans && this.#colorSwatchSpans.length) {
   1914      for (const span of this.#colorSwatchSpans) {
   1915        this.ruleView.tooltips.getTooltip("colorPicker").removeSwatch(span);
   1916      }
   1917    }
   1918 
   1919    if (this.angleSwatchSpans && this.angleSwatchSpans.length) {
   1920      for (const span of this.angleSwatchSpans) {
   1921        span.removeEventListener("unit-change", this.#onSwatchCommit);
   1922        this.ruleView.tooltips.getTooltip("filterEditor").removeSwatch(span);
   1923      }
   1924    }
   1925 
   1926    if (this.#bezierSwatchSpans && this.#bezierSwatchSpans.length) {
   1927      for (const span of this.#bezierSwatchSpans) {
   1928        this.ruleView.tooltips.getTooltip("cubicBezier").removeSwatch(span);
   1929      }
   1930    }
   1931 
   1932    if (this.#linearEasingSwatchSpans && this.#linearEasingSwatchSpans.length) {
   1933      for (const span of this.#linearEasingSwatchSpans) {
   1934        this.ruleView.tooltips
   1935          .getTooltip("linearEaseFunction")
   1936          .removeSwatch(span);
   1937      }
   1938    }
   1939 
   1940    if (this.abortController) {
   1941      this.abortController.abort();
   1942      this.abortController = null;
   1943    }
   1944 
   1945    this.#elementsWithPendingClicks.delete(this.valueSpan);
   1946  }
   1947 }
   1948 
   1949 module.exports = TextPropertyEditor;