tor-browser

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

inplace-editor.js (70558B)


      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 /**
      6 * Basic use:
      7 * let spanToEdit = document.getElementById("somespan");
      8 *
      9 * editableField({
     10 *   element: spanToEdit,
     11 *   done: function(value, commit, direction, key) {
     12 *     if (commit) {
     13 *       spanToEdit.textContent = value;
     14 *     }
     15 *   },
     16 *   trigger: "dblclick"
     17 * });
     18 *
     19 * See editableField() for more options.
     20 */
     21 
     22 "use strict";
     23 
     24 const focusManager = Services.focus;
     25 const isOSX = Services.appinfo.OS === "Darwin";
     26 const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");
     27 const EventEmitter = require("resource://devtools/shared/event-emitter.js");
     28 const {
     29  findMostRelevantCssPropertyIndex,
     30 } = require("resource://devtools/client/shared/suggestion-picker.js");
     31 
     32 loader.lazyRequireGetter(
     33  this,
     34  "InspectorCSSParserWrapper",
     35  "resource://devtools/shared/css/lexer.js",
     36  true
     37 );
     38 
     39 const HTML_NS = "http://www.w3.org/1999/xhtml";
     40 const CONTENT_TYPES = {
     41  PLAIN_TEXT: 0,
     42  CSS_VALUE: 1,
     43  CSS_MIXED: 2,
     44  CSS_PROPERTY: 3,
     45 };
     46 
     47 // The limit of 500 autocomplete suggestions should not be reached but is kept
     48 // for safety.
     49 const MAX_POPUP_ENTRIES = 500;
     50 
     51 const FOCUS_FORWARD = focusManager.MOVEFOCUS_FORWARD;
     52 const FOCUS_BACKWARD = focusManager.MOVEFOCUS_BACKWARD;
     53 
     54 const WORD_REGEXP = /\w/;
     55 const isWordChar = function (str) {
     56  return str && WORD_REGEXP.test(str);
     57 };
     58 
     59 const GRID_PROPERTY_NAMES = [
     60  "grid-area",
     61  "grid-row",
     62  "grid-row-start",
     63  "grid-row-end",
     64  "grid-column",
     65  "grid-column-start",
     66  "grid-column-end",
     67 ];
     68 const GRID_ROW_PROPERTY_NAMES = [
     69  "grid-area",
     70  "grid-row",
     71  "grid-row-start",
     72  "grid-row-end",
     73 ];
     74 const GRID_COL_PROPERTY_NAMES = [
     75  "grid-area",
     76  "grid-column",
     77  "grid-column-start",
     78  "grid-column-end",
     79 ];
     80 
     81 /**
     82 * Helper to check if the provided key matches one of the expected keys.
     83 * Keys will be prefixed with DOM_VK_ and should match a key in KeyCodes.
     84 *
     85 * @param {string} key
     86 *        the key to check (can be a keyCode).
     87 * @param {...string} keys
     88 *        list of possible keys allowed.
     89 * @return {boolean} true if the key matches one of the keys.
     90 */
     91 function isKeyIn(key, ...keys) {
     92  return keys.some(expectedKey => {
     93    return key === KeyCodes["DOM_VK_" + expectedKey];
     94  });
     95 }
     96 
     97 /**
     98 * Mark a span editable.  |editableField| will listen for the span to
     99 * be focused and create an InlineEditor to handle text input.
    100 * Changes will be committed when the InlineEditor's input is blurred
    101 * or dropped when the user presses escape.
    102 *
    103 * @param {object} options: Options for the editable field
    104 * @param {Element} options.element:
    105 *        (required) The span to be edited on focus.
    106 * @param {string} options.inputClass:
    107 *        An optional class to be added to the input.
    108 * @param {Function} options.canEdit:
    109 *        Will be called before creating the inplace editor.  Editor
    110 *        won't be created if canEdit returns false.
    111 * @param {Function} options.start:
    112 *        Will be called when the inplace editor is initialized.
    113 * @param {Function} options.change:
    114 *        Will be called when the text input changes.  Will be called
    115 *        with the current value of the text input.
    116 * @param {Function} options.done:
    117 *        Called when input is committed or blurred.  Called with
    118 *        current value, a boolean telling the caller whether to
    119 *        commit the change, the direction of the next element to be
    120 *        selected and the event keybode. Direction may be one of Services.focus.MOVEFOCUS_FORWARD,
    121 *        Services.focus.MOVEFOCUS_BACKWARD, or null (no movement).
    122 *        This function is called before the editor has been torn down.
    123 * @param {Function} options.destroy:
    124 *        Called when the editor is destroyed and has been torn down.
    125 *        This may be called with the return value of the options.done callback (if it is passed).
    126 * @param {Function} options.contextMenu:
    127 *        Called when the user triggers a contextmenu event on the input.
    128 * @param {object} options.advanceChars:
    129 *        This can be either a string or a function.
    130 *        If it is a string, then if any characters in it are typed,
    131 *        focus will advance to the next element.
    132 *        Otherwise, if it is a function, then the function will
    133 *        be called with three arguments: a key code, the current text,
    134 *        and the insertion point.  If the function returns true,
    135 *        then the focus advance takes place.  If it returns false,
    136 *        then the character is inserted instead.
    137 * @param {boolean} options.stopOnReturn:
    138 *        If true, the return key will not advance the editor to the next
    139 *        focusable element. Note that Ctrl/Cmd+Enter will still advance the editor
    140 * @param {boolean} options.stopOnTab:
    141 *        If true, the tab key will not advance the editor to the next
    142 *        focusable element.
    143 * @param {boolean} options.stopOnShiftTab:
    144 *        If true, shift tab will not advance the editor to the previous
    145 *        focusable element.
    146 * @param {string} options.trigger: The DOM event that should trigger editing,
    147 *        defaults to "click"
    148 * @param {boolean} options.multiline: Should the editor be a multiline textarea?
    149 *        defaults to false
    150 * @param {Function or options.Number} maxWidth:
    151 *        Should the editor wrap to remain below the provided max width. Only
    152 *        available if multiline is true. If a function is provided, it will be
    153 *        called when replacing the element by the inplace input.
    154 * @param {boolean} options.trimOutput: Should the returned string be trimmed?
    155 *        defaults to true
    156 * @param {boolean} options.preserveTextStyles: If true, do not copy text-related styles
    157 *        from `element` to the new input.
    158 *        defaults to false
    159 * @param {object} options.cssProperties: An instance of CSSProperties.
    160 * @param {object} options.getCssVariables: A function that returns a Map containing
    161 *        all CSS variables. The Map key is the variable name, the value is the variable value
    162 * @param {number} options.defaultIncrement: The value by which the input is incremented
    163 *        or decremented by default (0.1 for properties like opacity and 1 by default)
    164 * @param {Function} options.getGridLineNames:
    165 *        Will be called before offering autocomplete sugestions, if the property is
    166 *        a member of GRID_PROPERTY_NAMES.
    167 * @param {boolean} options.showSuggestCompletionOnEmpty:
    168 *        If true, show the suggestions in case that the current text becomes empty.
    169 *        Defaults to false.
    170 * @param {boolean} options.focusEditableFieldAfterApply
    171 *        If true, try to focus the next editable field after the input value is commited.
    172 *        When set to true, focusEditableFieldContainerSelector is mandatory.
    173 *        If no editable field can be found within the element retrieved with
    174 *        focusEditableFieldContainerSelector, the focus will be moved to the next focusable
    175 *        element (which won't be an editable field)
    176 * @param {string} options.focusEditableFieldContainerSelector
    177 *        A CSS selector that will be used to retrieve the container element into which
    178 *        the next focused element should be in, when focusEditableFieldAfterApply
    179 *        is set to true. This allows to bail out if we can't find a suitable
    180 *        focusable field.
    181 * @param {string} options.inputAriaLabel
    182 *        Optional aria-label attribute value that will be added to the input.
    183 * @param {string} options.inputAriaLabelledBy
    184 *        Optional aria-labelled-by attribute value that will be added to the input.
    185 */
    186 function editableField(options) {
    187  return editableItem(options, function (element, event) {
    188    if (!options.element.inplaceEditor) {
    189      new InplaceEditor(options, event);
    190    }
    191  });
    192 }
    193 
    194 exports.editableField = editableField;
    195 
    196 /**
    197 * Handle events for an element that should respond to
    198 * clicks and sit in the editing tab order, and call
    199 * a callback when it is activated.
    200 *
    201 * @param {object} options
    202 *    The options for this editor, including:
    203 *    {Element} element: The DOM element.
    204 *    {String} trigger: The DOM event that should trigger editing,
    205 *      defaults to "click"
    206 * @param {Function} callback
    207 *        Called when the editor is activated.
    208 * @return {Function} function which calls callback
    209 */
    210 function editableItem(options, callback) {
    211  const trigger = options.trigger || "click";
    212  const element = options.element;
    213  element.addEventListener(trigger, function (evt) {
    214    if (!isValidTargetForEditableItemCallback(evt.target)) {
    215      return;
    216    }
    217 
    218    const win = this.ownerDocument.defaultView;
    219    const selection = win.getSelection();
    220    if (trigger != "click" || selection.isCollapsed) {
    221      callback(element, evt);
    222    }
    223    evt.stopPropagation();
    224  });
    225 
    226  // If focused by means other than a click, start editing by
    227  // pressing enter or space.
    228  element.addEventListener(
    229    "keypress",
    230    function (evt) {
    231      if (!isValidTargetForEditableItemCallback(evt.target)) {
    232        return;
    233      }
    234 
    235      if (isKeyIn(evt.keyCode, "RETURN") || isKeyIn(evt.charCode, "SPACE")) {
    236        callback(element);
    237      }
    238    },
    239    true
    240  );
    241 
    242  // Mark the element editable field for tab
    243  // navigation while editing.
    244  element._editable = true;
    245  // And an attribute that can be used to target
    246  element.setAttribute("editable", "");
    247 
    248  // Save the trigger type so we can dispatch this later
    249  element._trigger = trigger;
    250 
    251  // Add button semantics to the element, to indicate that it can be activated.
    252  element.setAttribute("role", "button");
    253 
    254  return function turnOnEditMode() {
    255    callback(element);
    256  };
    257 }
    258 
    259 exports.editableItem = editableItem;
    260 
    261 /**
    262 * Returns false if the passed event target should not trigger the callback passed
    263 * to the editable item.
    264 *
    265 * @param {Element} eventTarget
    266 * @returns {boolean}
    267 */
    268 function isValidTargetForEditableItemCallback(eventTarget) {
    269  const { nodeName } = eventTarget;
    270  // If the event happened on a link or a button, we shouldn't trigger the callback
    271  return nodeName !== "a" && nodeName !== "button";
    272 }
    273 
    274 /*
    275 * Various API consumers (especially tests) sometimes want to grab the
    276 * inplaceEditor expando off span elements. However, when each global has its
    277 * own compartment, those expandos live on Xray wrappers that are only visible
    278 * within this JSM. So we provide a little workaround here.
    279 */
    280 
    281 function getInplaceEditorForSpan(span) {
    282  return span.inplaceEditor;
    283 }
    284 
    285 exports.getInplaceEditorForSpan = getInplaceEditorForSpan;
    286 
    287 class InplaceEditor extends EventEmitter {
    288  constructor(options, event) {
    289    super();
    290 
    291    this.elt = options.element;
    292    const doc = this.elt.ownerDocument;
    293    this.doc = doc;
    294    this.elt.inplaceEditor = this;
    295    this.cssProperties = options.cssProperties;
    296    this.getCssVariables = options.getCssVariables
    297      ? options.getCssVariables.bind(this)
    298      : null;
    299    this.change = options.change;
    300    this.done = options.done;
    301    this.contextMenu = options.contextMenu;
    302    this.defaultIncrement = options.defaultIncrement || 1;
    303    this.destroy = options.destroy;
    304    this.initial = options.initial ? options.initial : this.elt.textContent;
    305    this.multiline = options.multiline || false;
    306    this.maxWidth = options.maxWidth;
    307    if (typeof this.maxWidth == "function") {
    308      this.maxWidth = this.maxWidth();
    309    }
    310 
    311    this.trimOutput =
    312      options.trimOutput === undefined ? true : !!options.trimOutput;
    313    this.stopOnShiftTab = !!options.stopOnShiftTab;
    314    this.stopOnTab = !!options.stopOnTab;
    315    this.stopOnReturn = !!options.stopOnReturn;
    316    this.contentType = options.contentType || CONTENT_TYPES.PLAIN_TEXT;
    317    this.property = options.property;
    318    this.popup = options.popup;
    319    this.preserveTextStyles =
    320      options.preserveTextStyles === undefined
    321        ? false
    322        : !!options.preserveTextStyles;
    323    this.showSuggestCompletionOnEmpty = !!options.showSuggestCompletionOnEmpty;
    324    this.focusEditableFieldAfterApply =
    325      options.focusEditableFieldAfterApply === true;
    326    this.focusEditableFieldContainerSelector =
    327      options.focusEditableFieldContainerSelector;
    328 
    329    if (
    330      this.focusEditableFieldAfterApply &&
    331      !this.focusEditableFieldContainerSelector
    332    ) {
    333      throw new Error(
    334        "focusEditableFieldContainerSelector is mandatory when focusEditableFieldAfterApply is true"
    335      );
    336    }
    337 
    338    this.#createInput(options);
    339 
    340    // Hide the provided element and add our editor.
    341    this.originalDisplay = this.elt.style.display;
    342    this.elt.style.display = "none";
    343    this.elt.parentNode.insertBefore(this.input, this.elt);
    344 
    345    // After inserting the input to have all CSS styles applied, start autosizing.
    346    this.#autosize();
    347 
    348    this.inputCharDimensions = this.#getInputCharDimensions();
    349    // Pull out character codes for advanceChars, listing the
    350    // characters that should trigger a blur.
    351    if (typeof options.advanceChars === "function") {
    352      this.#advanceChars = options.advanceChars;
    353    } else {
    354      const advanceCharcodes = {};
    355      const advanceChars = options.advanceChars || "";
    356      for (let i = 0; i < advanceChars.length; i++) {
    357        advanceCharcodes[advanceChars.charCodeAt(i)] = true;
    358      }
    359      this.#advanceChars = charCode => charCode in advanceCharcodes;
    360    }
    361 
    362    this.input.focus();
    363 
    364    if (typeof options.selectAll == "undefined" || options.selectAll) {
    365      this.input.select();
    366    }
    367 
    368    const win = doc.defaultView;
    369    this.#abortController = new win.AbortController();
    370    const eventListenerConfig = { signal: this.#abortController.signal };
    371 
    372    this.input.addEventListener("blur", this.#onBlur, eventListenerConfig);
    373    this.input.addEventListener(
    374      "keypress",
    375      this.#onKeyPress,
    376      eventListenerConfig
    377    );
    378    this.input.addEventListener("wheel", this.#onWheel, eventListenerConfig);
    379    this.input.addEventListener("input", this.#onInput, eventListenerConfig);
    380    this.input.addEventListener(
    381      "dblclick",
    382      this.#stopEventPropagation,
    383      eventListenerConfig
    384    );
    385    this.input.addEventListener(
    386      "click",
    387      this.#stopEventPropagation,
    388      eventListenerConfig
    389    );
    390    this.input.addEventListener(
    391      "mousedown",
    392      this.#stopEventPropagation,
    393      eventListenerConfig
    394    );
    395    this.input.addEventListener(
    396      "contextmenu",
    397      this.#onContextMenu,
    398      eventListenerConfig
    399    );
    400    win.addEventListener("blur", this.#onWindowBlur, eventListenerConfig);
    401 
    402    this.validate = options.validate;
    403 
    404    if (this.validate) {
    405      this.input.addEventListener("keyup", this.#onKeyup, eventListenerConfig);
    406    }
    407 
    408    this.#updateSize();
    409 
    410    if (options.start) {
    411      options.start(this, event);
    412    }
    413 
    414    this.#getGridNamesBeforeCompletion(options.getGridLineNames);
    415  }
    416  static CONTENT_TYPES = CONTENT_TYPES;
    417 
    418  #abortController;
    419  #advanceChars;
    420  #applied;
    421  #measurement;
    422  #openPopupTimeout;
    423  #pressedKey;
    424  #preventSuggestions;
    425  #selectedIndex;
    426  #variableNames;
    427  #variables;
    428 
    429  get currentInputValue() {
    430    const val = this.trimOutput ? this.input.value.trim() : this.input.value;
    431    return val;
    432  }
    433 
    434  /**
    435   * Create the input element.
    436   *
    437   * @param {object} options
    438   * @param {string} options.inputAriaLabel
    439   *        Optional aria-label attribute value that will be added to the input.
    440   * @param {string} options.inputAriaLabelledBy
    441   *        Optional aria-labelledby attribute value that will be added to the input.
    442   * @param {string} options.inputClass:
    443   *        Optional class to be added to the input.
    444   */
    445  #createInput(options = {}) {
    446    this.input = this.doc.createElementNS(
    447      HTML_NS,
    448      this.multiline ? "textarea" : "input"
    449    );
    450    this.input.inplaceEditor = this;
    451 
    452    if (this.multiline) {
    453      // Hide the textarea resize handle.
    454      this.input.style.resize = "none";
    455      this.input.style.overflow = "hidden";
    456      // Also reset padding.
    457      this.input.style.padding = "0";
    458    }
    459 
    460    this.input.classList.add("styleinspector-propertyeditor");
    461    if (options.inputClass) {
    462      this.input.classList.add(options.inputClass);
    463    }
    464    this.input.value = this.initial;
    465    if (options.inputAriaLabel) {
    466      this.input.setAttribute("aria-label", options.inputAriaLabel);
    467    } else if (options.inputAriaLabelledBy) {
    468      this.input.setAttribute("aria-labelledby", options.inputAriaLabelledBy);
    469    }
    470 
    471    if (!this.preserveTextStyles) {
    472      copyTextStyles(this.elt, this.input);
    473    }
    474  }
    475 
    476  /**
    477   * Get rid of the editor.
    478   *
    479   * @param {*|null} doneCallResult: When #clear is called after calling #apply, this will
    480   *        be the returned value of the call to options.done that is done there.
    481   *        Will be null when options.done is undefined.
    482   */
    483  #clear(doneCallResult) {
    484    if (!this.input) {
    485      // Already cleared.
    486      return;
    487    }
    488 
    489    this.#abortController.abort();
    490    this.#stopAutosize();
    491 
    492    this.elt.style.display = this.originalDisplay;
    493 
    494    if (this.doc.activeElement == this.input) {
    495      this.elt.focus();
    496    }
    497 
    498    this.input.remove();
    499    this.input = null;
    500 
    501    delete this.elt.inplaceEditor;
    502    delete this.elt;
    503 
    504    if (this.destroy) {
    505      this.destroy(doneCallResult);
    506    }
    507  }
    508 
    509  /**
    510   * Keeps the editor close to the size of its input string.  This is pretty
    511   * crappy, suggestions for improvement welcome.
    512   */
    513  #autosize() {
    514    // Create a hidden, absolutely-positioned span to measure the text
    515    // in the input.  Boo.
    516 
    517    // We can't just measure the original element because a) we don't
    518    // change the underlying element's text ourselves (we leave that
    519    // up to the client), and b) without tweaking the style of the
    520    // original element, it might wrap differently or something.
    521    this.#measurement = this.doc.createElementNS(
    522      HTML_NS,
    523      this.multiline ? "pre" : "span"
    524    );
    525    this.#measurement.className = "autosizer";
    526    this.elt.parentNode.appendChild(this.#measurement);
    527    const style = this.#measurement.style;
    528    style.visibility = "hidden";
    529    style.position = "absolute";
    530    style.top = "0";
    531    style.left = "0";
    532 
    533    if (this.multiline) {
    534      style.whiteSpace = "pre-wrap";
    535      style.wordWrap = "break-word";
    536      if (this.maxWidth) {
    537        style.maxWidth = this.maxWidth + "px";
    538        // Use position fixed to measure dimensions without any influence from
    539        // the container of the editor.
    540        style.position = "fixed";
    541      }
    542    }
    543 
    544    copyAllStyles(this.input, this.#measurement);
    545    this.#updateSize();
    546  }
    547 
    548  /**
    549   * Clean up the mess created by _autosize().
    550   */
    551  #stopAutosize() {
    552    if (!this.#measurement) {
    553      return;
    554    }
    555    this.#measurement.remove();
    556    this.#measurement = null;
    557  }
    558 
    559  /**
    560   * Size the editor to fit its current contents.
    561   */
    562  #updateSize() {
    563    // Replace spaces with non-breaking spaces.  Otherwise setting
    564    // the span's textContent will collapse spaces and the measurement
    565    // will be wrong.
    566    let content = this.input.value;
    567    const unbreakableSpace = "\u00a0";
    568 
    569    // Make sure the content is not empty.
    570    if (content === "") {
    571      content = unbreakableSpace;
    572    }
    573 
    574    // If content ends with a new line, add a blank space to force the autosize
    575    // element to adapt its height.
    576    if (content.lastIndexOf("\n") === content.length - 1) {
    577      content = content + unbreakableSpace;
    578    }
    579 
    580    if (!this.multiline) {
    581      content = content.replace(/ /g, unbreakableSpace);
    582    }
    583 
    584    this.#measurement.textContent = content;
    585 
    586    // Do not use offsetWidth: it will round floating width values.
    587    let width = this.#measurement.getBoundingClientRect().width;
    588    if (this.multiline) {
    589      if (this.maxWidth) {
    590        width = Math.min(this.maxWidth, width);
    591      }
    592      const height = this.#measurement.getBoundingClientRect().height;
    593      this.input.style.height = height + "px";
    594    }
    595    this.input.style.width = width + "px";
    596  }
    597 
    598  /**
    599   * Get the width and height of a single character in the input to properly
    600   * position the autocompletion popup.
    601   */
    602  #getInputCharDimensions() {
    603    // Just make the text content to be 'x' to get the width and height of any
    604    // character in a monospace font.
    605    this.#measurement.textContent = "x";
    606    const width = this.#measurement.clientWidth;
    607    const height = this.#measurement.clientHeight;
    608    return { width, height };
    609  }
    610 
    611  /**
    612   * Increment property values in rule view.
    613   *
    614   * @param {number} increment
    615   *        The amount to increase/decrease the property value.
    616   * @return {boolean} true if value has been incremented.
    617   */
    618  #incrementValue(increment) {
    619    const value = this.input.value;
    620    const selectionStart = this.input.selectionStart;
    621    const selectionEnd = this.input.selectionEnd;
    622 
    623    const newValue = this.#incrementCSSValue(
    624      value,
    625      increment,
    626      selectionStart,
    627      selectionEnd
    628    );
    629 
    630    if (!newValue) {
    631      return false;
    632    }
    633 
    634    this.input.value = newValue.value;
    635    this.input.setSelectionRange(newValue.start, newValue.end);
    636    this.#doValidation();
    637 
    638    // Call the user's change handler if available.
    639    if (this.change) {
    640      this.change(this.currentInputValue);
    641    }
    642 
    643    return true;
    644  }
    645 
    646  /**
    647   * Increment the property value based on the property type.
    648   *
    649   * @param {string} value
    650   *        Property value.
    651   * @param {number} increment
    652   *        Amount to increase/decrease the property value.
    653   * @param {number} selStart
    654   *        Starting index of the value.
    655   * @param {number} selEnd
    656   *        Ending index of the value.
    657   * @return {object} object with properties 'value', 'start', and 'end'.
    658   */
    659  #incrementCSSValue(value, increment, selStart, selEnd) {
    660    const range = this.#parseCSSValue(value, selStart);
    661    const type = range?.type || "";
    662    const rawValue = range ? value.substring(range.start, range.end) : "";
    663    const preRawValue = range ? value.substr(0, range.start) : "";
    664    const postRawValue = range ? value.substr(range.end) : "";
    665    let info;
    666 
    667    let incrementedValue = null,
    668      selection;
    669    if (type === "num") {
    670      if (rawValue == "0") {
    671        info = {};
    672        info.units = this.#findCompatibleUnit(preRawValue, postRawValue);
    673      }
    674 
    675      const newValue = this.#incrementRawValue(rawValue, increment, info);
    676      if (newValue !== null) {
    677        incrementedValue = newValue;
    678        selection = [0, incrementedValue.length];
    679      }
    680    } else if (type === "hex") {
    681      const exprOffset = selStart - range.start;
    682      const exprOffsetEnd = selEnd - range.start;
    683      const newValue = this.#incHexColor(
    684        rawValue,
    685        increment,
    686        exprOffset,
    687        exprOffsetEnd
    688      );
    689      if (newValue) {
    690        incrementedValue = newValue.value;
    691        selection = newValue.selection;
    692      }
    693    } else {
    694      if (type === "rgb" || type === "hsl" || type === "hwb") {
    695        info = {};
    696        const isCSS4Color = !value.includes(",");
    697        // In case the value uses the new syntax of the CSS Color 4 specification,
    698        // it is split by the spaces and the slash separating the alpha value
    699        // between the different color components.
    700        // Example: rgb(255 0 0 / 0.5)
    701        // Otherwise, the value is represented using the old color syntax and is
    702        // split by the commas between the color components.
    703        // Example: rgba(255, 0, 0, 0.5)
    704        const part =
    705          value
    706            .substring(range.start, selStart)
    707            .split(isCSS4Color ? / ?\/ ?| / : ",").length - 1;
    708        if (part === 3) {
    709          // alpha
    710          info.minValue = 0;
    711          info.maxValue = 1;
    712        } else if (type === "rgb") {
    713          info.minValue = 0;
    714          info.maxValue = 255;
    715        } else if (part !== 0) {
    716          // hsl or hwb percentage
    717          info.minValue = 0;
    718          info.maxValue = 100;
    719 
    720          // select the previous number if the selection is at the end of a
    721          // percentage sign.
    722          if (value.charAt(selStart - 1) === "%") {
    723            --selStart;
    724          }
    725        }
    726      }
    727      return this.#incrementGenericValue(
    728        value,
    729        increment,
    730        selStart,
    731        selEnd,
    732        info
    733      );
    734    }
    735 
    736    if (incrementedValue === null) {
    737      return null;
    738    }
    739 
    740    return {
    741      value: preRawValue + incrementedValue + postRawValue,
    742      start: range.start + selection[0],
    743      end: range.start + selection[1],
    744    };
    745  }
    746 
    747  /**
    748   * Find a compatible unit to use for a CSS number value inserted between the
    749   * provided beforeValue and afterValue. The compatible unit will be picked
    750   * from a selection of default units corresponding to supported CSS value
    751   * dimensions (distance, angle, duration).
    752   *
    753   * @param {string} beforeValue
    754   *        The string preceeding the number value in the current property
    755   *        value.
    756   * @param {string} afterValue
    757   *        The string following the number value in the current property value.
    758   * @return {string} a valid unit that can be used for this number value or
    759   *         empty string if no match could be found.
    760   */
    761  #findCompatibleUnit(beforeValue, afterValue) {
    762    if (!this.property || !this.property.name) {
    763      return "";
    764    }
    765 
    766    // A DOM element is used to test the validity of various units. This is to
    767    // avoid having to do an async call to the server to get this information.
    768    const el = this.doc.createElement("div");
    769 
    770    // Cycle through unitless (""), pixels, degrees and seconds.
    771    const units = ["", "px", "deg", "s"];
    772    for (const unit of units) {
    773      const value = beforeValue + "1" + unit + afterValue;
    774      el.style.setProperty(this.property.name, "");
    775      el.style.setProperty(this.property.name, value);
    776      // The property was set to `""` first, so if the value is no longer `""`,
    777      // it means that the second `setProperty` call set a valid property and we
    778      // can use this unit.
    779      if (el.style.getPropertyValue(this.property.name) !== "") {
    780        return unit;
    781      }
    782    }
    783    return "";
    784  }
    785 
    786  /**
    787   * Parses the property value and type.
    788   *
    789   * @param {string} value
    790   *        Property value.
    791   * @param {number} offset
    792   *        Starting index of value.
    793   * @return {object} object with properties 'value', 'start', 'end', and
    794   *         'type'.
    795   */
    796  #parseCSSValue(value, offset) {
    797    /* eslint-disable max-len */
    798    const reSplitCSS =
    799      /(?<url>url\("?[^"\)]+"?\)?)|(?<rgb>rgba?\([^)]*\)?)|(?<hsl>hsla?\([^)]*\)?)|(?<hwb>hwb\([^)]*\)?)|(?<hex>#[\dA-Fa-f]+)|(?<number>-?\d*\.?\d+(%|[a-z]{1,4})?)|"([^"]*)"?|'([^']*)'?|([^,\s\/!\(\)]+)|(!(.*)?)/;
    800    /* eslint-enable */
    801    let start = 0;
    802    let m;
    803 
    804    // retreive values from left to right until we find the one at our offset
    805    while ((m = reSplitCSS.exec(value)) && m.index + m[0].length < offset) {
    806      value = value.substring(m.index + m[0].length);
    807      start += m.index + m[0].length;
    808      offset -= m.index + m[0].length;
    809    }
    810 
    811    if (!m) {
    812      return null;
    813    }
    814 
    815    let type;
    816    if (m.groups.url) {
    817      type = "url";
    818    } else if (m.groups.rgb) {
    819      type = "rgb";
    820    } else if (m.groups.hsl) {
    821      type = "hsl";
    822    } else if (m.groups.hwb) {
    823      type = "hwb";
    824    } else if (m.groups.hex) {
    825      type = "hex";
    826    } else if (m.groups.number) {
    827      type = "num";
    828    }
    829 
    830    return {
    831      value: m[0],
    832      start: start + m.index,
    833      end: start + m.index + m[0].length,
    834      type,
    835    };
    836  }
    837 
    838  /**
    839   * Increment the property value for types other than
    840   * number or hex, such as rgb, hsl, hwb, and file names.
    841   *
    842   * @param {string} value
    843   *        Property value.
    844   * @param {number} increment
    845   *        Amount to increment/decrement.
    846   * @param {number} offset
    847   *        Starting index of the property value.
    848   * @param {number} offsetEnd
    849   *        Ending index of the property value.
    850   * @param {object} info
    851   *        Object with details about the property value.
    852   * @return {object} object with properties 'value', 'start', and 'end'.
    853   */
    854  #incrementGenericValue(value, increment, offset, offsetEnd, info) {
    855    // Try to find a number around the cursor to increment.
    856    let start, end;
    857    // Check if we are incrementing in a non-number context (such as a URL)
    858    if (
    859      /^-?[0-9.]/.test(value.substring(offset, offsetEnd)) &&
    860      !/\d/.test(value.charAt(offset - 1) + value.charAt(offsetEnd))
    861    ) {
    862      // We have a number selected, possibly with a suffix, and we are not in
    863      // the disallowed case of just part of a known number being selected.
    864      // Use that number.
    865      start = offset;
    866      end = offsetEnd;
    867    } else {
    868      // Parse periods as belonging to the number only if we are in a known
    869      // number context. (This makes incrementing the 1 in 'image1.gif' work.)
    870      const pattern = "[" + (info ? "0-9." : "0-9") + "]*";
    871      const before = new RegExp(pattern + "$").exec(value.substr(0, offset))[0]
    872        .length;
    873      const after = new RegExp("^" + pattern).exec(value.substr(offset))[0]
    874        .length;
    875 
    876      start = offset - before;
    877      end = offset + after;
    878 
    879      // Expand the number to contain an initial minus sign if it seems
    880      // free-standing.
    881      if (
    882        value.charAt(start - 1) === "-" &&
    883        (start - 1 === 0 || /[ (:,='"]/.test(value.charAt(start - 2)))
    884      ) {
    885        --start;
    886      }
    887    }
    888 
    889    if (start !== end) {
    890      // Include percentages as part of the incremented number (they are
    891      // common enough).
    892      if (value.charAt(end) === "%") {
    893        ++end;
    894      }
    895 
    896      const first = value.substr(0, start);
    897      let mid = value.substring(start, end);
    898      const last = value.substr(end);
    899 
    900      mid = this.#incrementRawValue(mid, increment, info);
    901 
    902      if (mid !== null) {
    903        return {
    904          value: first + mid + last,
    905          start,
    906          end: start + mid.length,
    907        };
    908      }
    909    }
    910 
    911    return null;
    912  }
    913 
    914  /**
    915   * Increment the property value for numbers.
    916   *
    917   * @param {string} rawValue
    918   *        Raw value to increment.
    919   * @param {number} increment
    920   *        Amount to increase/decrease the raw value.
    921   * @param {object} info
    922   *        Object with info about the property value.
    923   * @return {string} the incremented value.
    924   */
    925  #incrementRawValue(rawValue, increment, info) {
    926    const num = parseFloat(rawValue);
    927 
    928    if (isNaN(num)) {
    929      return null;
    930    }
    931 
    932    const number = /\d+(\.\d+)?/.exec(rawValue);
    933 
    934    let units = rawValue.substr(number.index + number[0].length);
    935    if (info && "units" in info) {
    936      units = info.units;
    937    }
    938 
    939    // avoid rounding errors
    940    let newValue = Math.round((num + increment) * 1000) / 1000;
    941 
    942    if (info && "minValue" in info) {
    943      newValue = Math.max(newValue, info.minValue);
    944    }
    945    if (info && "maxValue" in info) {
    946      newValue = Math.min(newValue, info.maxValue);
    947    }
    948 
    949    newValue = newValue.toString();
    950 
    951    return newValue + units;
    952  }
    953 
    954  /**
    955   * Increment the property value for hex.
    956   *
    957   * @param {string} value
    958   *        Property value.
    959   * @param {number} increment
    960   *        Amount to increase/decrease the property value.
    961   * @param {number} offset
    962   *        Starting index of the property value.
    963   * @param {number} offsetEnd
    964   *        Ending index of the property value.
    965   * @return {object} object with properties 'value' and 'selection'.
    966   */
    967  #incHexColor(rawValue, increment, offset, offsetEnd) {
    968    // Return early if no part of the rawValue is selected.
    969    if (offsetEnd > rawValue.length && offset >= rawValue.length) {
    970      return null;
    971    }
    972    if (offset < 1 && offsetEnd <= 1) {
    973      return null;
    974    }
    975    // Ignore the leading #.
    976    rawValue = rawValue.substr(1);
    977    --offset;
    978    --offsetEnd;
    979 
    980    // Clamp the selection to within the actual value.
    981    offset = Math.max(offset, 0);
    982    offsetEnd = Math.min(offsetEnd, rawValue.length);
    983    offsetEnd = Math.max(offsetEnd, offset);
    984 
    985    // Normalize #ABC -> #AABBCC.
    986    if (rawValue.length === 3) {
    987      rawValue =
    988        rawValue.charAt(0) +
    989        rawValue.charAt(0) +
    990        rawValue.charAt(1) +
    991        rawValue.charAt(1) +
    992        rawValue.charAt(2) +
    993        rawValue.charAt(2);
    994      offset *= 2;
    995      offsetEnd *= 2;
    996    }
    997 
    998    // Normalize #ABCD -> #AABBCCDD.
    999    if (rawValue.length === 4) {
   1000      rawValue =
   1001        rawValue.charAt(0) +
   1002        rawValue.charAt(0) +
   1003        rawValue.charAt(1) +
   1004        rawValue.charAt(1) +
   1005        rawValue.charAt(2) +
   1006        rawValue.charAt(2) +
   1007        rawValue.charAt(3) +
   1008        rawValue.charAt(3);
   1009      offset *= 2;
   1010      offsetEnd *= 2;
   1011    }
   1012 
   1013    if (rawValue.length !== 6 && rawValue.length !== 8) {
   1014      return null;
   1015    }
   1016 
   1017    // If no selection, increment an adjacent color, preferably one to the left.
   1018    if (offset === offsetEnd) {
   1019      if (offset === 0) {
   1020        offsetEnd = 1;
   1021      } else {
   1022        offset = offsetEnd - 1;
   1023      }
   1024    }
   1025 
   1026    // Make the selection cover entire parts.
   1027    offset -= offset % 2;
   1028    offsetEnd += offsetEnd % 2;
   1029 
   1030    // Remap the increments from [0.1, 1, 10] to [1, 1, 16].
   1031    if (increment > -1 && increment < 1) {
   1032      increment = increment < 0 ? -1 : 1;
   1033    }
   1034    if (Math.abs(increment) === 10) {
   1035      increment = increment < 0 ? -16 : 16;
   1036    }
   1037 
   1038    const isUpper = rawValue.toUpperCase() === rawValue;
   1039 
   1040    for (let pos = offset; pos < offsetEnd; pos += 2) {
   1041      // Increment the part in [pos, pos+2).
   1042      let mid = rawValue.substr(pos, 2);
   1043      const value = parseInt(mid, 16);
   1044 
   1045      if (isNaN(value)) {
   1046        return null;
   1047      }
   1048 
   1049      mid = Math.min(Math.max(value + increment, 0), 255).toString(16);
   1050 
   1051      while (mid.length < 2) {
   1052        mid = "0" + mid;
   1053      }
   1054      if (isUpper) {
   1055        mid = mid.toUpperCase();
   1056      }
   1057 
   1058      rawValue = rawValue.substr(0, pos) + mid + rawValue.substr(pos + 2);
   1059    }
   1060 
   1061    return {
   1062      value: "#" + rawValue,
   1063      selection: [offset + 1, offsetEnd + 1],
   1064    };
   1065  }
   1066 
   1067  /**
   1068   * Cycle through the autocompletion suggestions in the popup.
   1069   *
   1070   * @param {boolean} reverse
   1071   *        true to select previous item from the popup.
   1072   * @param {boolean} noSelect
   1073   *        true to not select the text after selecting the newly selectedItem
   1074   *        from the popup.
   1075   */
   1076  #cycleCSSSuggestion(reverse, noSelect) {
   1077    // selectedItem can be null when nothing is selected in an empty editor.
   1078    const { label, preLabel } = this.popup.selectedItem || {
   1079      label: "",
   1080      preLabel: "",
   1081    };
   1082    if (reverse) {
   1083      this.popup.selectPreviousItem();
   1084    } else {
   1085      this.popup.selectNextItem();
   1086    }
   1087 
   1088    this.#selectedIndex = this.popup.selectedIndex;
   1089    const input = this.input;
   1090    let pre = "";
   1091 
   1092    if (input.selectionStart < input.selectionEnd) {
   1093      pre = input.value.slice(0, input.selectionStart);
   1094    } else {
   1095      pre = input.value.slice(
   1096        0,
   1097        input.selectionStart - label.length + preLabel.length
   1098      );
   1099    }
   1100 
   1101    const post = input.value.slice(input.selectionEnd, input.value.length);
   1102    const item = this.popup.selectedItem;
   1103    const toComplete = item.label.slice(item.preLabel.length);
   1104    input.value = pre + toComplete + post;
   1105 
   1106    if (!noSelect) {
   1107      input.setSelectionRange(pre.length, pre.length + toComplete.length);
   1108    } else {
   1109      input.setSelectionRange(
   1110        pre.length + toComplete.length,
   1111        pre.length + toComplete.length
   1112      );
   1113    }
   1114 
   1115    this.#updateSize();
   1116    // This emit is mainly for the purpose of making the test flow simpler.
   1117    this.emit("after-suggest");
   1118  }
   1119 
   1120  /**
   1121   * Call the client's done handler and clear out.
   1122   */
   1123  #apply(direction, key) {
   1124    if (this.#applied) {
   1125      return null;
   1126    }
   1127 
   1128    this.#applied = true;
   1129 
   1130    if (this.done) {
   1131      const val = this.cancelled ? this.initial : this.currentInputValue;
   1132      return this.done(val, !this.cancelled, direction, key);
   1133    }
   1134 
   1135    return null;
   1136  }
   1137 
   1138  /**
   1139   * Hide the popup and cancel any pending popup opening.
   1140   */
   1141  #onWindowBlur = () => {
   1142    if (this.popup && this.popup.isOpen) {
   1143      this.popup.hidePopup();
   1144    }
   1145 
   1146    if (this.#openPopupTimeout) {
   1147      this.doc.defaultView.clearTimeout(this.#openPopupTimeout);
   1148    }
   1149  };
   1150 
   1151  /**
   1152   * Event handler called when the inplace-editor's input loses focus.
   1153   */
   1154  #onBlur = event => {
   1155    if (
   1156      event &&
   1157      this.popup &&
   1158      this.popup.isOpen &&
   1159      this.popup.selectedIndex >= 0
   1160    ) {
   1161      this.#acceptPopupSuggestion();
   1162    } else {
   1163      const onApplied = this.#apply();
   1164      this.#clear(onApplied);
   1165    }
   1166  };
   1167 
   1168  /**
   1169   * Before offering autocomplete, set this.gridLineNames as the line names
   1170   * of the current grid, if they exist.
   1171   *
   1172   * @param {Function} getGridLineNames
   1173   *        A function which gets the line names of the current grid.
   1174   */
   1175  async #getGridNamesBeforeCompletion(getGridLineNames) {
   1176    if (
   1177      getGridLineNames &&
   1178      this.property &&
   1179      GRID_PROPERTY_NAMES.includes(this.property.name)
   1180    ) {
   1181      this.gridLineNames = await getGridLineNames();
   1182    }
   1183 
   1184    if (
   1185      this.contentType == CONTENT_TYPES.CSS_VALUE &&
   1186      this.input &&
   1187      this.input.value == ""
   1188    ) {
   1189      this.#maybeSuggestCompletion(false);
   1190    }
   1191  }
   1192 
   1193  /**
   1194   * Event handler called by the autocomplete popup when receiving a click
   1195   * event.
   1196   */
   1197  #onAutocompletePopupClick = () => {
   1198    this.#acceptPopupSuggestion();
   1199  };
   1200 
   1201  #acceptPopupSuggestion() {
   1202    let label, preLabel;
   1203 
   1204    if (this.#selectedIndex === undefined) {
   1205      ({ label, preLabel } = this.popup.getItemAtIndex(
   1206        this.popup.selectedIndex
   1207      ));
   1208    } else {
   1209      ({ label, preLabel } = this.popup.getItemAtIndex(this.#selectedIndex));
   1210    }
   1211 
   1212    const input = this.input;
   1213 
   1214    let pre = "";
   1215 
   1216    // CSS_MIXED needs special treatment here to make it so that
   1217    // multiple presses of tab will cycle through completions, but
   1218    // without selecting the completed text.  However, this same
   1219    // special treatment will do the wrong thing for other editing
   1220    // styles.
   1221    if (
   1222      input.selectionStart < input.selectionEnd ||
   1223      this.contentType !== CONTENT_TYPES.CSS_MIXED
   1224    ) {
   1225      pre = input.value.slice(0, input.selectionStart);
   1226    } else {
   1227      pre = input.value.slice(
   1228        0,
   1229        input.selectionStart - label.length + preLabel.length
   1230      );
   1231    }
   1232    const post = input.value.slice(input.selectionEnd, input.value.length);
   1233    const item = this.popup.selectedItem;
   1234    this.#selectedIndex = this.popup.selectedIndex;
   1235    const toComplete = item.label.slice(item.preLabel.length);
   1236    input.value = pre + toComplete + post;
   1237    input.setSelectionRange(
   1238      pre.length + toComplete.length,
   1239      pre.length + toComplete.length
   1240    );
   1241    this.#updateSize();
   1242    // Wait for the popup to hide and then focus input async otherwise it does
   1243    // not work.
   1244    const onPopupHidden = () => {
   1245      this.popup.off("popup-closed", onPopupHidden);
   1246      this.doc.defaultView.setTimeout(() => {
   1247        input.focus();
   1248        this.emit("after-suggest");
   1249      }, 0);
   1250    };
   1251    this.popup.on("popup-closed", onPopupHidden);
   1252    this.#hideAutocompletePopup();
   1253  }
   1254 
   1255  /**
   1256   * Handle the input field's keypress event.
   1257   */
   1258  // eslint-disable-next-line complexity
   1259  #onKeyPress = event => {
   1260    let prevent = false;
   1261 
   1262    const key = event.keyCode;
   1263    const input = this.input;
   1264 
   1265    // We want to autoclose some characters, remember the pressed key in order to process
   1266    // it later on in maybeSuggestionCompletion().
   1267    this.#pressedKey = event.key;
   1268 
   1269    const multilineNavigation =
   1270      !this.#isSingleLine() && isKeyIn(key, "UP", "DOWN", "LEFT", "RIGHT");
   1271    const isPlainText = this.contentType == CONTENT_TYPES.PLAIN_TEXT;
   1272    const isPopupOpen = this.popup && this.popup.isOpen;
   1273 
   1274    let increment = 0;
   1275    if (!isPlainText && !multilineNavigation) {
   1276      increment = this.#getIncrement(event);
   1277    }
   1278 
   1279    if (isKeyIn(key, "PAGE_UP", "PAGE_DOWN")) {
   1280      this.#preventSuggestions = true;
   1281    }
   1282 
   1283    let cycling = false;
   1284    if (increment && this.#incrementValue(increment)) {
   1285      this.#updateSize();
   1286      prevent = true;
   1287      cycling = true;
   1288    }
   1289 
   1290    if (isPopupOpen && isKeyIn(key, "UP", "DOWN", "PAGE_UP", "PAGE_DOWN")) {
   1291      prevent = true;
   1292      cycling = true;
   1293      this.#cycleCSSSuggestion(isKeyIn(key, "UP", "PAGE_UP"));
   1294      this.#doValidation();
   1295    }
   1296 
   1297    if (isKeyIn(key, "BACK_SPACE", "DELETE", "LEFT", "RIGHT", "HOME", "END")) {
   1298      if (isPopupOpen && this.currentInputValue !== "") {
   1299        this.#hideAutocompletePopup();
   1300      }
   1301    } else if (
   1302      // We may show the suggestion completion if Ctrl+space is pressed, or if an
   1303      // otherwise unhandled key is pressed and the user is not cycling through the
   1304      // options in the pop-up menu, it is not an expanded shorthand property, no
   1305      // modifier key is pressed, and the pressed key isn't Shift+Arrow(Up|Down).
   1306      (event.key === " " && event.ctrlKey) ||
   1307      (!cycling &&
   1308        !multilineNavigation &&
   1309        !event.metaKey &&
   1310        !event.altKey &&
   1311        !event.ctrlKey &&
   1312        // We only need to handle the case where the Shift key is pressed because maybeSuggestCompletion
   1313        // will trigger the completion because there are selected character here, and it
   1314        // will look like a "regular" completion with a suggested value. We don't need
   1315        // to care about other shift + key (e.g. LEFT, HOME, …), since we're not coming
   1316        // here for them.
   1317        !(isKeyIn(key, "UP", "DOWN") && event.shiftKey))
   1318    ) {
   1319      this.#maybeSuggestCompletion(true);
   1320    }
   1321 
   1322    if (this.multiline && event.shiftKey && isKeyIn(key, "RETURN")) {
   1323      prevent = false;
   1324    } else if (
   1325      this.#advanceChars(event.charCode, input.value, input.selectionStart) ||
   1326      isKeyIn(key, "RETURN", "TAB")
   1327    ) {
   1328      prevent = true;
   1329 
   1330      const ctrlOrCmd = isOSX ? event.metaKey : event.ctrlKey;
   1331 
   1332      let direction;
   1333      if (
   1334        (this.stopOnReturn && isKeyIn(key, "RETURN") && !ctrlOrCmd) ||
   1335        (this.stopOnTab && !event.shiftKey && isKeyIn(key, "TAB")) ||
   1336        (this.stopOnShiftTab && event.shiftKey && isKeyIn(key, "TAB"))
   1337      ) {
   1338        direction = null;
   1339      } else if (event.shiftKey && isKeyIn(key, "TAB")) {
   1340        direction = FOCUS_BACKWARD;
   1341      } else {
   1342        direction = FOCUS_FORWARD;
   1343      }
   1344 
   1345      // Now we don't want to suggest anything as we are moving out.
   1346      this.#preventSuggestions = true;
   1347      // But we still want to show suggestions for css values. i.e. moving out
   1348      // of css property input box in forward direction
   1349      if (
   1350        this.contentType == CONTENT_TYPES.CSS_PROPERTY &&
   1351        direction == FOCUS_FORWARD
   1352      ) {
   1353        this.#preventSuggestions = false;
   1354      }
   1355 
   1356      if (isKeyIn(key, "TAB") && this.contentType == CONTENT_TYPES.CSS_MIXED) {
   1357        if (this.popup && input.selectionStart < input.selectionEnd) {
   1358          event.preventDefault();
   1359          input.setSelectionRange(input.selectionEnd, input.selectionEnd);
   1360          this.emit("after-suggest");
   1361          return;
   1362        } else if (this.popup && this.popup.isOpen) {
   1363          event.preventDefault();
   1364          this.#cycleCSSSuggestion(event.shiftKey, true);
   1365          return;
   1366        }
   1367      }
   1368 
   1369      const onApplied = this.#apply(direction, key);
   1370 
   1371      // Close the popup if open
   1372      if (this.popup && this.popup.isOpen) {
   1373        this.#hideAutocompletePopup();
   1374      }
   1375 
   1376      if (direction !== null && focusManager.focusedElement === input) {
   1377        // If the focused element wasn't changed by the done callback,
   1378        // move the focus as requested.
   1379        const next = moveFocus(
   1380          this.doc.defaultView,
   1381          direction,
   1382          this.focusEditableFieldAfterApply,
   1383          this.focusEditableFieldContainerSelector
   1384        );
   1385 
   1386        // If the next node to be focused has been tagged as an editable
   1387        // node, trigger editing using the configured event
   1388        if (next && next.ownerDocument === this.doc && next._editable) {
   1389          const e = this.doc.createEvent("Event");
   1390          e.initEvent(next._trigger, true, true);
   1391          next.dispatchEvent(e);
   1392        }
   1393      }
   1394 
   1395      this.#clear(onApplied);
   1396    } else if (isKeyIn(key, "ESCAPE")) {
   1397      // Cancel and blur ourselves.
   1398      // Now we don't want to suggest anything as we are moving out.
   1399      this.#preventSuggestions = true;
   1400      // Close the popup if open
   1401      if (this.popup && this.popup.isOpen) {
   1402        this.#hideAutocompletePopup();
   1403      } else {
   1404        this.cancelled = true;
   1405        const onApplied = this.#apply();
   1406        this.#clear(onApplied);
   1407      }
   1408      prevent = true;
   1409      event.stopPropagation();
   1410    } else if (isKeyIn(key, "SPACE")) {
   1411      // No need for leading spaces here.  This is particularly
   1412      // noticable when adding a property: it's very natural to type
   1413      // <name>: (which advances to the next property) then spacebar.
   1414      prevent = !input.value;
   1415    }
   1416 
   1417    if (prevent) {
   1418      event.preventDefault();
   1419    }
   1420  };
   1421 
   1422  #onContextMenu = event => {
   1423    if (this.contextMenu) {
   1424      // Call stopPropagation() and preventDefault() here so that avoid to show default
   1425      // context menu in about:devtools-toolbox. See Bug 1515265.
   1426      event.stopPropagation();
   1427      event.preventDefault();
   1428      this.contextMenu(event);
   1429    }
   1430  };
   1431 
   1432  /**
   1433   * Open the autocomplete popup, adding a custom click handler and classname.
   1434   *
   1435   * @param {number} offset
   1436   *        X-offset relative to the input starting edge.
   1437   * @param {number} selectedIndex
   1438   *        The index of the item that should be selected. Use -1 to have no
   1439   *        item selected.
   1440   */
   1441  #openAutocompletePopup(offset, selectedIndex) {
   1442    this.popup.on("popup-click", this.#onAutocompletePopupClick);
   1443    this.popup.openPopup(this.input, offset, 0, selectedIndex);
   1444  }
   1445 
   1446  /**
   1447   * Remove the custom classname and click handler and close the autocomplete
   1448   * popup.
   1449   */
   1450  #hideAutocompletePopup() {
   1451    this.popup.off("popup-click", this.#onAutocompletePopupClick);
   1452    this.popup.hidePopup();
   1453  }
   1454 
   1455  /**
   1456   * Get the increment/decrement step to use for the provided key or wheel
   1457   * event.
   1458   *
   1459   * @param {Event} event
   1460   *        The event from which the increment should be comuted
   1461   * @return {number} The computed increment value.
   1462   */
   1463  #getIncrement(event) {
   1464    const largeIncrement = 100;
   1465    const mediumIncrement = 10;
   1466    const smallIncrement = 0.1;
   1467 
   1468    let increment = 0;
   1469 
   1470    let wheelUp = false;
   1471    let wheelDown = false;
   1472    if (event.type === "wheel") {
   1473      if (event.wheelDelta > 0) {
   1474        wheelUp = true;
   1475      } else if (event.wheelDelta < 0) {
   1476        wheelDown = true;
   1477      }
   1478    }
   1479 
   1480    const key = event.keyCode;
   1481 
   1482    if (wheelUp || isKeyIn(key, "UP", "PAGE_UP")) {
   1483      increment = 1 * this.defaultIncrement;
   1484    } else if (wheelDown || isKeyIn(key, "DOWN", "PAGE_DOWN")) {
   1485      increment = -1 * this.defaultIncrement;
   1486    }
   1487 
   1488    const largeIncrementKeyPressed = event.shiftKey;
   1489    const smallIncrementKeyPressed = this.#isSmallIncrementKeyPressed(event);
   1490    if (largeIncrementKeyPressed && !smallIncrementKeyPressed) {
   1491      if (isKeyIn(key, "PAGE_UP", "PAGE_DOWN")) {
   1492        increment *= largeIncrement;
   1493      } else {
   1494        increment *= mediumIncrement;
   1495      }
   1496    } else if (smallIncrementKeyPressed && !largeIncrementKeyPressed) {
   1497      increment *= smallIncrement;
   1498    }
   1499 
   1500    return increment;
   1501  }
   1502 
   1503  #isSmallIncrementKeyPressed = evt => {
   1504    if (isOSX) {
   1505      return evt.altKey;
   1506    }
   1507    return evt.ctrlKey;
   1508  };
   1509 
   1510  /**
   1511   * Handle the input field's keyup event.
   1512   */
   1513  #onKeyup = () => {
   1514    this.#applied = false;
   1515  };
   1516 
   1517  /**
   1518   * Handle changes to the input text.
   1519   */
   1520  #onInput = () => {
   1521    // Validate the entered value.
   1522    this.#doValidation();
   1523 
   1524    // Update size if we're autosizing.
   1525    if (this.#measurement) {
   1526      this.#updateSize();
   1527    }
   1528 
   1529    // Call the user's change handler if available.
   1530    if (this.change) {
   1531      this.change(this.currentInputValue);
   1532    }
   1533 
   1534    // In case that the current value becomes empty, show the suggestions if needed.
   1535    if (this.currentInputValue === "" && this.showSuggestCompletionOnEmpty) {
   1536      this.#maybeSuggestCompletion(false);
   1537    }
   1538  };
   1539 
   1540  /**
   1541   * Handle the input field's wheel event.
   1542   *
   1543   * @param {WheelEvent} event
   1544   */
   1545  #onWheel = event => {
   1546    const isPlainText = this.contentType == CONTENT_TYPES.PLAIN_TEXT;
   1547    let increment = 0;
   1548    if (!isPlainText) {
   1549      increment = this.#getIncrement(event);
   1550    }
   1551 
   1552    if (increment && this.#incrementValue(increment)) {
   1553      this.#updateSize();
   1554      event.preventDefault();
   1555    }
   1556  };
   1557 
   1558  /**
   1559   * Stop propagation on the provided event
   1560   */
   1561  #stopEventPropagation(e) {
   1562    e.stopPropagation();
   1563  }
   1564 
   1565  /**
   1566   * Fire validation callback with current input
   1567   */
   1568  #doValidation() {
   1569    if (this.validate && this.input) {
   1570      this.validate(this.input.value);
   1571    }
   1572  }
   1573 
   1574  /**
   1575   * Handles displaying suggestions based on the current input.
   1576   *
   1577   * @param {boolean} autoInsert
   1578   *        Pass true to automatically insert the most relevant suggestion.
   1579   */
   1580  #maybeSuggestCompletion(autoInsert) {
   1581    // Input can be null in cases when you intantaneously switch out of it.
   1582    if (!this.input) {
   1583      return;
   1584    }
   1585 
   1586    const preTimeoutQuery = this.input.value;
   1587 
   1588    // Since we are calling this method from a keypress event handler, the
   1589    // |input.value| does not include currently typed character. Thus we perform
   1590    // this method async.
   1591    // eslint-disable-next-line complexity
   1592    this.#openPopupTimeout = this.doc.defaultView.setTimeout(() => {
   1593      if (this.#preventSuggestions) {
   1594        this.#preventSuggestions = false;
   1595        return;
   1596      }
   1597      if (this.contentType == CONTENT_TYPES.PLAIN_TEXT) {
   1598        return;
   1599      }
   1600      if (!this.input) {
   1601        return;
   1602      }
   1603      const input = this.input;
   1604      // The length of input.value should be increased by 1
   1605      if (input.value.length - preTimeoutQuery.length > 1) {
   1606        return;
   1607      }
   1608      const query = input.value.slice(0, input.selectionStart);
   1609      let startCheckQuery = query;
   1610      if (query == null) {
   1611        return;
   1612      }
   1613      // If nothing is selected and there is a word (\w) character after the cursor, do
   1614      // not autocomplete.
   1615      if (
   1616        input.selectionStart == input.selectionEnd &&
   1617        input.selectionStart < input.value.length
   1618      ) {
   1619        const nextChar = input.value.slice(input.selectionStart)[0];
   1620        // Check if the next character is a valid word character, no suggestion should be
   1621        // provided when preceeding a word.
   1622        if (/[\w-]/.test(nextChar)) {
   1623          // This emit is mainly to make the test flow simpler.
   1624          this.emit("after-suggest", "nothing to autocomplete");
   1625          return;
   1626        }
   1627      }
   1628 
   1629      let list = [];
   1630      let postLabelValues = [];
   1631 
   1632      if (this.contentType == CONTENT_TYPES.CSS_PROPERTY) {
   1633        list = this.#getCSSVariableNames().concat(this.#getCSSPropertyList());
   1634      } else if (this.contentType == CONTENT_TYPES.CSS_VALUE) {
   1635        // Build the context for the autocomplete
   1636        // TODO: We may want to parse the whole input, or at least, until we get into
   1637        // an empty state (e.g. if cursor is in a function, we might check what's after
   1638        // the cursor to build good autocomplete).
   1639        const lexer = new InspectorCSSParserWrapper(query);
   1640        const functionStack = [];
   1641        let token;
   1642        // The last parsed token that isn't a whitespace or a comment
   1643        let lastMeaningfulToken;
   1644        let foundImportant = false;
   1645        let importantState = "";
   1646 
   1647        let queryStartIndex = 0;
   1648        while ((token = lexer.nextToken())) {
   1649          const currentFunction = functionStack.at(-1);
   1650          if (
   1651            token.tokenType !== "WhiteSpace" &&
   1652            token.tokenType !== "Comment"
   1653          ) {
   1654            lastMeaningfulToken = token;
   1655            if (currentFunction) {
   1656              currentFunction.tokens.push(token);
   1657            }
   1658          }
   1659          if (
   1660            token.tokenType === "Function" ||
   1661            token.tokenType === "ParenthesisBlock"
   1662          ) {
   1663            functionStack.push({ fnToken: token, tokens: [] });
   1664          } else if (token.tokenType === "CloseParenthesis") {
   1665            functionStack.pop();
   1666          }
   1667 
   1668          if (
   1669            token.tokenType === "WhiteSpace" ||
   1670            token.tokenType === "Comma" ||
   1671            token.tokenType === "Function" ||
   1672            (token.tokenType === "Comment" &&
   1673              // The parser already returns a comment token for non-closed comment, like "/*".
   1674              // But we only want to start the completion after the comment is closed
   1675              // Make sure we have a closed comment,i.e. at least `/**/`
   1676              token.text.length >= 4 &&
   1677              token.text.endsWith("*/"))
   1678          ) {
   1679            queryStartIndex = token.endOffset;
   1680          }
   1681 
   1682          // Checking for the presence of !important (once is enough)
   1683          if (!foundImportant) {
   1684            // !important is composed of 2 tokens, `!` is a Delim, and `important` is an Ident.
   1685            // Here we have a potential start
   1686            if (token.tokenType === "Delim" && token.text === "!") {
   1687              importantState = "!";
   1688            } else if (importantState === "!") {
   1689              // If we saw the "!" char, then we need to have an "important" Ident
   1690              if (token.tokenType === "Ident" && token.text === "important") {
   1691                foundImportant = true;
   1692                break;
   1693              } else {
   1694                // otherwise, we can reset the state.
   1695                importantState = "";
   1696              }
   1697            }
   1698          }
   1699        }
   1700 
   1701        startCheckQuery = query.substring(queryStartIndex);
   1702 
   1703        const lastFunctionEntry = functionStack.at(-1);
   1704        const functionValues = lastFunctionEntry
   1705          ? this.#getAutocompleteDataForFunction(lastFunctionEntry)
   1706          : null;
   1707 
   1708        // Don't autocomplete after !important
   1709        if (foundImportant) {
   1710          list = [];
   1711          postLabelValues = [];
   1712        } else if (functionValues) {
   1713          list = functionValues.list;
   1714          postLabelValues = functionValues.postLabelValues;
   1715        } else {
   1716          list = this.#getCSSValuesForPropertyName(this.property.name);
   1717          // Only show !important if:
   1718          if (
   1719            // we're not in a function
   1720            !functionStack.length &&
   1721            // and there is no non-whitespace items after the cursor
   1722            !input.value.slice(input.selectionStart).trim() &&
   1723            // and the last meaningful token wasn't a delimiter or a comma
   1724            lastMeaningfulToken &&
   1725            (lastMeaningfulToken.tokenType !== "Delim" ||
   1726              lastMeaningfulToken.text !== "/") &&
   1727            lastMeaningfulToken.tokenType !== "Comma" &&
   1728            // and the input value doesn't start with ! ("!important" is parsed as a
   1729            // Delim, "!", and then an indent, "important", so we can't just check the
   1730            // last token)
   1731            !input.value.trim().startsWith("!")
   1732          ) {
   1733            list.unshift("!important");
   1734          }
   1735        }
   1736      } else if (
   1737        this.contentType == CONTENT_TYPES.CSS_MIXED &&
   1738        /^\s*style\s*=/.test(query)
   1739      ) {
   1740        // Check if the style attribute is closed before the selection.
   1741        const styleValue = query.replace(/^\s*style\s*=\s*/, "");
   1742        // Look for a quote matching the opening quote (single or double).
   1743        if (/^("[^"]*"|'[^']*')/.test(styleValue)) {
   1744          // This emit is mainly to make the test flow simpler.
   1745          this.emit("after-suggest", "nothing to autocomplete");
   1746          return;
   1747        }
   1748 
   1749        // Detecting if cursor is at property or value;
   1750        const match = query.match(/([:;"'=]?)\s*([^"';:=]+)?$/);
   1751        if (match && match.length >= 2) {
   1752          if (match[1] == ":") {
   1753            // We are in CSS value completion
   1754            const propertyName = query.match(
   1755              /[;"'=]\s*([^"';:= ]+)\s*:\s*[^"';:=]*$/
   1756            )[1];
   1757            list = [
   1758              "!important;",
   1759              ...this.#getCSSValuesForPropertyName(propertyName),
   1760            ];
   1761            const matchLastQuery = /([^\s,.\/]+$)/.exec(match[2] || "");
   1762            if (matchLastQuery) {
   1763              startCheckQuery = matchLastQuery[0];
   1764            } else {
   1765              startCheckQuery = "";
   1766            }
   1767            if (!match[2]) {
   1768              // Don't suggest '!important' without any manually typed character
   1769              list.splice(0, 1);
   1770            }
   1771          } else if (match[1]) {
   1772            // We are in CSS property name completion
   1773            list = this.#getCSSVariableNames().concat(
   1774              this.#getCSSPropertyList()
   1775            );
   1776            startCheckQuery = match[2];
   1777          }
   1778          if (startCheckQuery == null) {
   1779            // This emit is mainly to make the test flow simpler.
   1780            this.emit("after-suggest", "nothing to autocomplete");
   1781            return;
   1782          }
   1783        }
   1784      }
   1785 
   1786      if (!this.popup) {
   1787        // This emit is mainly to make the test flow simpler.
   1788        this.emit("after-suggest", "no popup");
   1789        return;
   1790      }
   1791 
   1792      const finalList = [];
   1793      const length = list.length;
   1794      for (let i = 0, count = 0; i < length && count < MAX_POPUP_ENTRIES; i++) {
   1795        if (startCheckQuery != null && list[i].startsWith(startCheckQuery)) {
   1796          count++;
   1797          finalList.push({
   1798            preLabel: startCheckQuery,
   1799            label: list[i],
   1800            postLabel: postLabelValues[i] ? postLabelValues[i] : "",
   1801          });
   1802        } else if (count > 0) {
   1803          // Since count was incremented, we had already crossed the entries
   1804          // which would have started with query, assuming that list is sorted.
   1805          break;
   1806        } else if (startCheckQuery != null && list[i][0] > startCheckQuery[0]) {
   1807          // We have crossed all possible matches alphabetically.
   1808          break;
   1809        }
   1810      }
   1811 
   1812      // Sort items starting with [a-z0-9] first, to make sure vendor-prefixed
   1813      // values and "!important" are suggested only after standard values.
   1814      finalList.sort((item1, item2) => {
   1815        // Get the expected alphabetical comparison between the items.
   1816        let comparison = item1.label.localeCompare(item2.label);
   1817        if (/^\w/.test(item1.label) != /^\w/.test(item2.label)) {
   1818          // One starts with [a-z0-9], one does not: flip the comparison.
   1819          comparison = -1 * comparison;
   1820        }
   1821        return comparison;
   1822      });
   1823 
   1824      let index = 0;
   1825      if (startCheckQuery) {
   1826        // Only select a "best" suggestion when the user started a query.
   1827        const cssValues = finalList.map(item => item.label);
   1828        index = findMostRelevantCssPropertyIndex(cssValues);
   1829      }
   1830 
   1831      // Insert the most relevant item from the final list as the input value.
   1832      if (autoInsert && finalList[index]) {
   1833        const item = finalList[index].label;
   1834        input.value =
   1835          query +
   1836          item.slice(startCheckQuery.length) +
   1837          input.value.slice(query.length);
   1838        input.setSelectionRange(
   1839          query.length,
   1840          query.length + item.length - startCheckQuery.length
   1841        );
   1842        this.#updateSize();
   1843      }
   1844 
   1845      // Display the list of suggestions if there are more than one.
   1846      if (finalList.length > 1) {
   1847        // Calculate the popup horizontal offset.
   1848        const indent = this.input.selectionStart - startCheckQuery.length;
   1849        let offset = indent * this.inputCharDimensions.width;
   1850        offset = this.#isSingleLine() ? offset : 0;
   1851 
   1852        // Select the most relevantItem if autoInsert is allowed
   1853        const selectedIndex = autoInsert ? index : -1;
   1854 
   1855        // Open the suggestions popup.
   1856        this.popup.setItems(finalList, selectedIndex);
   1857        this.#openAutocompletePopup(offset, selectedIndex);
   1858      } else {
   1859        this.#hideAutocompletePopup();
   1860      }
   1861 
   1862      this.#autocloseParenthesis();
   1863 
   1864      // This emit is mainly for the purpose of making the test flow simpler.
   1865      this.emit("after-suggest");
   1866      this.#doValidation();
   1867    }, 0);
   1868  }
   1869 
   1870  /**
   1871   * Returns the autocomplete data for the passed function.
   1872   *
   1873   * @param {object} functionStackEntry
   1874   * @param {InspectorCSSToken} functionStackEntry.fnToken: The token for the
   1875   *        function call
   1876   * @returns {object | null} Return null if there's nothing specific to display for the function.
   1877   *          Otherwise, return an object of the following shape:
   1878   *            - {Array<String>} list: The list of autocomplete items
   1879   *            - {Array<String>} postLabelValue: The list of autocomplete items
   1880   *              post labels (e.g. for variable names, their values).
   1881   */
   1882  #getAutocompleteDataForFunction(functionStackEntry) {
   1883    const functionName = functionStackEntry?.fnToken?.value;
   1884    if (!functionName) {
   1885      return null;
   1886    }
   1887 
   1888    let list = [];
   1889    let postLabelValues = [];
   1890 
   1891    if (functionName === "var") {
   1892      // We only want to return variables for the first parameters of var(), not for its
   1893      // fallback. If we get more than one tokens, and given we don't get comments or
   1894      // whitespace, this means we're in the fallback value already.
   1895      if (functionStackEntry.tokens.length > 1) {
   1896        // In such case we'll use the default behavior
   1897        return null;
   1898      }
   1899      list = this.#getCSSVariableNames();
   1900      postLabelValues = list.map(varName => this.#getCSSVariableValue(varName));
   1901    } else if (functionName.includes("gradient")) {
   1902      // For gradient functions we want to display named colors and color functions,
   1903      // but only if the user didn't already entered a color token after the last comma.
   1904      list = this.#getCSSValuesForPropertyName("color");
   1905    }
   1906 
   1907    // TODO: Handle other functions, e.g. color functions to autocomplete on relative
   1908    // color format (Bug 1898273), `color()` to suggest color space (Bug 1898277),
   1909    // `anchor()` to display existing anchor names (Bug 1903278)
   1910 
   1911    return { list, postLabelValues };
   1912  }
   1913 
   1914  /**
   1915   * Automatically add closing parenthesis and skip closing parenthesis when needed.
   1916   */
   1917  #autocloseParenthesis() {
   1918    // Split the current value at the cursor index to rebuild the string.
   1919    const { selectionStart, selectionEnd } = this.input;
   1920 
   1921    const parts = this.#splitStringAt(
   1922      this.input.value,
   1923      // Use selectionEnd, so when an autocomplete item was inserted, we put the closing
   1924      // parenthesis after the suggestion
   1925      selectionEnd
   1926    );
   1927 
   1928    // Lookup the character following the caret to know if the string should be modified.
   1929    const nextChar = parts[1][0];
   1930 
   1931    // Autocomplete closing parenthesis if the last key pressed was "(" and the next
   1932    // character is not a "word" character.
   1933    if (this.#pressedKey == "(" && !isWordChar(nextChar)) {
   1934      this.#updateValue(parts[0] + ")" + parts[1]);
   1935    }
   1936 
   1937    // Skip inserting ")" if the next character is already a ")" (note that we actually
   1938    // insert and remove the extra ")" here, as the input has already been modified).
   1939    if (this.#pressedKey == ")" && nextChar == ")") {
   1940      this.#updateValue(parts[0] + parts[1].substring(1));
   1941    }
   1942 
   1943    // set original selection range
   1944    this.input.setSelectionRange(selectionStart, selectionEnd);
   1945 
   1946    this.#pressedKey = null;
   1947  }
   1948 
   1949  /**
   1950   * Update the current value of the input while preserving the caret position.
   1951   */
   1952  #updateValue(str) {
   1953    const start = this.input.selectionStart;
   1954    this.input.value = str;
   1955    this.input.setSelectionRange(start, start);
   1956    this.#updateSize();
   1957  }
   1958 
   1959  /**
   1960   * Split the provided string at the provided index. Returns an array of two strings.
   1961   * _splitStringAt("1234567", 3) will return ["123", "4567"]
   1962   */
   1963  #splitStringAt(str, index) {
   1964    return [str.substring(0, index), str.substring(index, str.length)];
   1965  }
   1966 
   1967  /**
   1968   * Check if the current input is displaying more than one line of text.
   1969   *
   1970   * @return {boolean} true if the input has a single line of text
   1971   */
   1972  #isSingleLine() {
   1973    if (!this.multiline) {
   1974      // Checking the inputCharDimensions.height only makes sense with multiline
   1975      // editors, because the textarea is directly sized using
   1976      // inputCharDimensions (see _updateSize()).
   1977      // By definition if !this.multiline, then we are in single line mode.
   1978      return true;
   1979    }
   1980    const inputRect = this.input.getBoundingClientRect();
   1981    return inputRect.height < 2 * this.inputCharDimensions.height;
   1982  }
   1983 
   1984  /**
   1985   * Returns the list of CSS properties to use for the autocompletion. This
   1986   * method is overridden by tests in order to use mocked suggestion lists.
   1987   *
   1988   * @return {Array} array of CSS property names (Strings)
   1989   */
   1990  #getCSSPropertyList() {
   1991    return this.cssProperties.getNames().sort();
   1992  }
   1993 
   1994  /**
   1995   * Returns a list of CSS values valid for a provided property name to use for
   1996   * the autocompletion. This method is overridden by tests in order to use
   1997   * mocked suggestion lists.
   1998   *
   1999   * @param {string} propertyName
   2000   * @return {Array} array of CSS property values (Strings)
   2001   */
   2002  #getCSSValuesForPropertyName(propertyName) {
   2003    const gridLineList = [];
   2004    if (this.gridLineNames) {
   2005      if (GRID_ROW_PROPERTY_NAMES.includes(this.property.name)) {
   2006        gridLineList.push(...this.gridLineNames.rows);
   2007      }
   2008      if (GRID_COL_PROPERTY_NAMES.includes(this.property.name)) {
   2009        gridLineList.push(...this.gridLineNames.cols);
   2010      }
   2011    }
   2012    // Must be alphabetically sorted before comparing the results with
   2013    // the user input, otherwise we will lose some results.
   2014    return gridLineList
   2015      .concat(this.cssProperties.getValues(propertyName))
   2016      .sort();
   2017  }
   2018 
   2019  #getCSSVariablesMap() {
   2020    if (!this.getCssVariables) {
   2021      return null;
   2022    }
   2023 
   2024    if (!this.#variables) {
   2025      this.#variables = this.getCssVariables();
   2026    }
   2027    return this.#variables;
   2028  }
   2029 
   2030  /**
   2031   * Returns the list of all CSS variables to use for the autocompletion.
   2032   *
   2033   * @return {Array} array of CSS variable names (Strings)
   2034   */
   2035  #getCSSVariableNames() {
   2036    if (!this.#variableNames) {
   2037      const variables = this.#getCSSVariablesMap();
   2038      if (!variables) {
   2039        return [];
   2040      }
   2041      this.#variableNames = Array.from(variables.keys()).sort();
   2042    }
   2043    return this.#variableNames;
   2044  }
   2045 
   2046  /**
   2047   * Returns the variable's value for the given CSS variable name.
   2048   *
   2049   * @param {string} varName
   2050   *        The variable name to retrieve the value of
   2051   * @return {string} the variable value to the given CSS variable name
   2052   */
   2053  #getCSSVariableValue(varName) {
   2054    return this.#getCSSVariablesMap()?.get(varName);
   2055  }
   2056 }
   2057 
   2058 exports.InplaceEditor = InplaceEditor;
   2059 
   2060 /**
   2061 * Copy text-related styles from one element to another.
   2062 */
   2063 function copyTextStyles(from, to) {
   2064  const win = from.ownerDocument.defaultView;
   2065  const style = win.getComputedStyle(from);
   2066 
   2067  to.style.fontFamily = style.fontFamily;
   2068  to.style.fontSize = style.fontSize;
   2069  to.style.fontWeight = style.fontWeight;
   2070  to.style.fontStyle = style.fontStyle;
   2071 }
   2072 
   2073 /**
   2074 * Copy all styles which could have an impact on the element size.
   2075 */
   2076 function copyAllStyles(from, to) {
   2077  const win = from.ownerDocument.defaultView;
   2078  const style = win.getComputedStyle(from);
   2079 
   2080  copyTextStyles(from, to);
   2081  to.style.lineHeight = style.lineHeight;
   2082 
   2083  // If box-sizing is set to border-box, box model styles also need to be
   2084  // copied.
   2085  const boxSizing = style.boxSizing;
   2086  if (boxSizing === "border-box") {
   2087    to.style.boxSizing = boxSizing;
   2088    copyBoxModelStyles(from, to);
   2089  }
   2090 }
   2091 
   2092 /**
   2093 * Copy box model styles that can impact width and height measurements when box-
   2094 * sizing is set to "border-box" instead of "content-box".
   2095 *
   2096 * @param {DOMNode} from
   2097 *        the element from which styles are copied
   2098 * @param {DOMNode} to
   2099 *        the element on which copied styles are applied
   2100 */
   2101 function copyBoxModelStyles(from, to) {
   2102  const properties = [
   2103    // Copy all paddings.
   2104    "paddingTop",
   2105    "paddingRight",
   2106    "paddingBottom",
   2107    "paddingLeft",
   2108    // Copy border styles.
   2109    "borderTopStyle",
   2110    "borderRightStyle",
   2111    "borderBottomStyle",
   2112    "borderLeftStyle",
   2113    // Copy border widths.
   2114    "borderTopWidth",
   2115    "borderRightWidth",
   2116    "borderBottomWidth",
   2117    "borderLeftWidth",
   2118  ];
   2119 
   2120  const win = from.ownerDocument.defaultView;
   2121  const style = win.getComputedStyle(from);
   2122  for (const property of properties) {
   2123    to.style[property] = style[property];
   2124  }
   2125 }
   2126 
   2127 /**
   2128 * Trigger a focus change similar to pressing tab/shift-tab.
   2129 *
   2130 * @param {Window} win: The window into which the focus should be moved
   2131 * @param {number} direction: See Services.focus.MOVEFOCUS_*
   2132 * @param {boolean} focusEditableField: Set to true to move the focus to the previous/next
   2133 *        editable field. If not set, the focus will be set on the next focusable element.
   2134 *        The function might still put the focus on a non-editable field, if none is found
   2135 *        within the element matching focusEditableFieldContainerSelector
   2136 * @param {string} focusEditableFieldContainerSelector: A CSS selector the editabled element
   2137 *        we want to focus should be in. This is only used when focusEditableField is set
   2138 *        to true.
   2139 *        It's important to pass a boundary otherwise we might hit an infinite loop
   2140 * @returns {Element} The element that received the focus
   2141 */
   2142 function moveFocus(
   2143  win,
   2144  direction,
   2145  focusEditableField,
   2146  focusEditableFieldContainerSelector
   2147 ) {
   2148  if (!focusEditableField) {
   2149    return focusManager.moveFocus(win, null, direction, 0);
   2150  }
   2151 
   2152  if (!win.document.querySelector(focusEditableFieldContainerSelector)) {
   2153    console.error(
   2154      focusEditableFieldContainerSelector,
   2155      "can't be found in document.",
   2156      `focusEditableFieldContainerSelector should match an existing element`
   2157    );
   2158    return focusManager.moveFocus(win, null, direction, 0);
   2159  }
   2160 
   2161  // Let's look for the next/previous editable element to focus
   2162  while (true) {
   2163    const focusedElement = focusManager.moveFocus(win, null, direction, 0);
   2164    // The _editable property is set by the InplaceEditor on the target element
   2165    if (focusedElement._editable) {
   2166      return focusedElement;
   2167    }
   2168 
   2169    // If the focus was moved outside of the container, simply return the focused element
   2170    if (!focusedElement.closest(focusEditableFieldContainerSelector)) {
   2171      return focusedElement;
   2172    }
   2173  }
   2174 }