tor-browser

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

FilterWidget.js (30883B)


      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 /**
      8 * This is a CSS Filter Editor widget used
      9 * for Rule View's filter swatches
     10 */
     11 
     12 const EventEmitter = require("resource://devtools/shared/event-emitter.js");
     13 const XHTML_NS = "http://www.w3.org/1999/xhtml";
     14 
     15 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
     16 const STRINGS_URI = "devtools/client/locales/filterwidget.properties";
     17 const L10N = new LocalizationHelper(STRINGS_URI);
     18 
     19 const {
     20  cssTokenizer,
     21 } = require("resource://devtools/shared/css/parsing-utils.js");
     22 
     23 const asyncStorage = require("resource://devtools/shared/async-storage.js");
     24 
     25 const DEFAULT_FILTER_TYPE = "length";
     26 const UNIT_MAPPING = {
     27  percentage: "%",
     28  length: "px",
     29  angle: "deg",
     30  string: "",
     31 };
     32 
     33 const FAST_VALUE_MULTIPLIER = 10;
     34 const SLOW_VALUE_MULTIPLIER = 0.1;
     35 const DEFAULT_VALUE_MULTIPLIER = 1;
     36 
     37 const LIST_PADDING = 7;
     38 const LIST_ITEM_HEIGHT = 32;
     39 
     40 const filterList = [
     41  {
     42    name: "blur",
     43    range: [0, Infinity],
     44    type: "length",
     45  },
     46  {
     47    name: "brightness",
     48    range: [0, Infinity],
     49    type: "percentage",
     50  },
     51  {
     52    name: "contrast",
     53    range: [0, Infinity],
     54    type: "percentage",
     55  },
     56  {
     57    name: "drop-shadow",
     58    placeholder: L10N.getStr("dropShadowPlaceholder"),
     59    type: "string",
     60  },
     61  {
     62    name: "grayscale",
     63    range: [0, 100],
     64    type: "percentage",
     65  },
     66  {
     67    name: "hue-rotate",
     68    range: [0, Infinity],
     69    type: "angle",
     70  },
     71  {
     72    name: "invert",
     73    range: [0, 100],
     74    type: "percentage",
     75  },
     76  {
     77    name: "opacity",
     78    range: [0, 100],
     79    type: "percentage",
     80  },
     81  {
     82    name: "saturate",
     83    range: [0, Infinity],
     84    type: "percentage",
     85  },
     86  {
     87    name: "sepia",
     88    range: [0, 100],
     89    type: "percentage",
     90  },
     91  {
     92    name: "url",
     93    placeholder: "example.svg#c1",
     94    type: "string",
     95  },
     96 ];
     97 
     98 // Valid values that shouldn't be parsed for filters.
     99 const SPECIAL_VALUES = new Set(["none", "unset", "initial", "inherit"]);
    100 
    101 /**
    102 * A CSS Filter editor widget used to add/remove/modify
    103 * filters.
    104 *
    105 * Normally, it takes a CSS filter value as input, parses it
    106 * and creates the required elements / bindings.
    107 *
    108 * You can, however, use add/remove/update methods manually.
    109 * See each method's comments for more details
    110 */
    111 class CSSFilterEditorWidget extends EventEmitter {
    112  /**
    113   * @param {Node} el
    114   *        The widget container.
    115   * @param {string} value
    116   *        CSS filter value
    117   */
    118  constructor(el, value = "") {
    119    super();
    120 
    121    this.doc = el.ownerDocument;
    122    this.win = this.doc.defaultView;
    123    this.el = el;
    124    this._cssIsValid = (name, val) => {
    125      return this.win.CSS.supports(name, val);
    126    };
    127 
    128    this._addButtonClick = this._addButtonClick.bind(this);
    129    this._removeButtonClick = this._removeButtonClick.bind(this);
    130    this._mouseMove = this._mouseMove.bind(this);
    131    this._mouseUp = this._mouseUp.bind(this);
    132    this._mouseDown = this._mouseDown.bind(this);
    133    this._keyDown = this._keyDown.bind(this);
    134    this._input = this._input.bind(this);
    135    this._presetClick = this._presetClick.bind(this);
    136    this._savePreset = this._savePreset.bind(this);
    137    this._togglePresets = this._togglePresets.bind(this);
    138    this._resetFocus = this._resetFocus.bind(this);
    139 
    140    // Passed to asyncStorage, requires binding
    141    this.renderPresets = this.renderPresets.bind(this);
    142 
    143    this._initMarkup();
    144    this._buildFilterItemMarkup();
    145    this._buildPresetItemMarkup();
    146    this._addEventListeners();
    147 
    148    this.filters = [];
    149    this.setCssValue(value);
    150    this.renderPresets();
    151  }
    152 
    153  _initMarkup() {
    154    // The following structure is created:
    155    //   <div class="filters-list">
    156    //     <div id="filters"></div>
    157    //     <div class="footer">
    158    //       <select value="">
    159    //         <option value="">${filterListSelectPlaceholder}</option>
    160    //       </select>
    161    //       <button id="add-filter" class="add">${addNewFilterButton}</button>
    162    //       <button id="toggle-presets">${presetsToggleButton}</button>
    163    //     </div>
    164    //   </div>
    165    //   <div class="presets-list">
    166    //     <div id="presets"></div>
    167    //     <div class="footer">
    168    //       <input value="" class="devtools-textinput"
    169    //              placeholder="${newPresetPlaceholder}"></input>
    170    //       <button class="add">${savePresetButton}</button>
    171    //     </div>
    172    //   </div>
    173    const content = this.doc.createDocumentFragment();
    174 
    175    const filterListWrapper = this.doc.createElementNS(XHTML_NS, "div");
    176    filterListWrapper.classList.add("filters-list");
    177    content.appendChild(filterListWrapper);
    178 
    179    this.filterList = this.doc.createElementNS(XHTML_NS, "div");
    180    this.filterList.setAttribute("id", "filters");
    181    filterListWrapper.appendChild(this.filterList);
    182 
    183    const filterListFooter = this.doc.createElementNS(XHTML_NS, "div");
    184    filterListFooter.classList.add("footer");
    185    filterListWrapper.appendChild(filterListFooter);
    186 
    187    this.filterSelect = this.doc.createElementNS(XHTML_NS, "select");
    188    this.filterSelect.setAttribute("value", "");
    189    filterListFooter.appendChild(this.filterSelect);
    190 
    191    const filterListPlaceholder = this.doc.createElementNS(XHTML_NS, "option");
    192    filterListPlaceholder.setAttribute("value", "");
    193    filterListPlaceholder.textContent = L10N.getStr(
    194      "filterListSelectPlaceholder"
    195    );
    196    this.filterSelect.appendChild(filterListPlaceholder);
    197 
    198    const addFilter = this.doc.createElementNS(XHTML_NS, "button");
    199    addFilter.setAttribute("id", "add-filter");
    200    addFilter.classList.add("add");
    201    addFilter.textContent = L10N.getStr("addNewFilterButton");
    202    filterListFooter.appendChild(addFilter);
    203 
    204    this.togglePresets = this.doc.createElementNS(XHTML_NS, "button");
    205    this.togglePresets.setAttribute("id", "toggle-presets");
    206    this.togglePresets.textContent = L10N.getStr("presetsToggleButton");
    207    filterListFooter.appendChild(this.togglePresets);
    208 
    209    const presetListWrapper = this.doc.createElementNS(XHTML_NS, "div");
    210    presetListWrapper.classList.add("presets-list");
    211    content.appendChild(presetListWrapper);
    212 
    213    this.presetList = this.doc.createElementNS(XHTML_NS, "div");
    214    this.presetList.setAttribute("id", "presets");
    215    presetListWrapper.appendChild(this.presetList);
    216 
    217    const presetListFooter = this.doc.createElementNS(XHTML_NS, "div");
    218    presetListFooter.classList.add("footer");
    219    presetListWrapper.appendChild(presetListFooter);
    220 
    221    this.addPresetInput = this.doc.createElementNS(XHTML_NS, "input");
    222    this.addPresetInput.setAttribute("value", "");
    223    this.addPresetInput.classList.add("devtools-textinput");
    224    this.addPresetInput.setAttribute(
    225      "placeholder",
    226      L10N.getStr("newPresetPlaceholder")
    227    );
    228    presetListFooter.appendChild(this.addPresetInput);
    229 
    230    this.addPresetButton = this.doc.createElementNS(XHTML_NS, "button");
    231    this.addPresetButton.classList.add("add");
    232    this.addPresetButton.textContent = L10N.getStr("savePresetButton");
    233    presetListFooter.appendChild(this.addPresetButton);
    234 
    235    this.el.appendChild(content);
    236 
    237    this._populateFilterSelect();
    238  }
    239 
    240  _destroyMarkup() {
    241    this._filterItemMarkup.remove();
    242    this.el.remove();
    243    this.el = this.filterList = this._filterItemMarkup = null;
    244    this.presetList = this.togglePresets = this.filterSelect = null;
    245    this.addPresetButton = null;
    246  }
    247 
    248  destroy() {
    249    this._removeEventListeners();
    250    this._destroyMarkup();
    251  }
    252 
    253  /**
    254   * Creates <option> elements for each filter definition
    255   * in filterList
    256   */
    257  _populateFilterSelect() {
    258    const select = this.filterSelect;
    259    filterList.forEach(filter => {
    260      const option = this.doc.createElementNS(XHTML_NS, "option");
    261      option.textContent = option.value = filter.name;
    262      select.appendChild(option);
    263    });
    264  }
    265 
    266  /**
    267   * Creates a template for filter elements which is cloned and used in render
    268   */
    269  _buildFilterItemMarkup() {
    270    const base = this.doc.createElementNS(XHTML_NS, "div");
    271    base.className = "filter";
    272 
    273    const name = this.doc.createElementNS(XHTML_NS, "div");
    274    name.className = "filter-name";
    275 
    276    const value = this.doc.createElementNS(XHTML_NS, "div");
    277    value.className = "filter-value";
    278 
    279    const drag = this.doc.createElementNS(XHTML_NS, "i");
    280    drag.title = L10N.getStr("dragHandleTooltipText");
    281 
    282    const label = this.doc.createElementNS(XHTML_NS, "label");
    283 
    284    name.appendChild(drag);
    285    name.appendChild(label);
    286 
    287    const unitPreview = this.doc.createElementNS(XHTML_NS, "span");
    288    const input = this.doc.createElementNS(XHTML_NS, "input");
    289    input.classList.add("devtools-textinput");
    290 
    291    value.appendChild(input);
    292    value.appendChild(unitPreview);
    293 
    294    const removeButton = this.doc.createElementNS(XHTML_NS, "button");
    295    removeButton.className = "remove-button";
    296 
    297    base.appendChild(name);
    298    base.appendChild(value);
    299    base.appendChild(removeButton);
    300 
    301    this._filterItemMarkup = base;
    302  }
    303 
    304  _buildPresetItemMarkup() {
    305    const base = this.doc.createElementNS(XHTML_NS, "div");
    306    base.classList.add("preset");
    307 
    308    const name = this.doc.createElementNS(XHTML_NS, "label");
    309    base.appendChild(name);
    310 
    311    const value = this.doc.createElementNS(XHTML_NS, "span");
    312    base.appendChild(value);
    313 
    314    const removeButton = this.doc.createElementNS(XHTML_NS, "button");
    315    removeButton.classList.add("remove-button");
    316 
    317    base.appendChild(removeButton);
    318 
    319    this._presetItemMarkup = base;
    320  }
    321 
    322  _addEventListeners() {
    323    this.addButton = this.el.querySelector("#add-filter");
    324    this.addButton.addEventListener("click", this._addButtonClick);
    325    this.filterList.addEventListener("click", this._removeButtonClick);
    326    this.filterList.addEventListener("mousedown", this._mouseDown);
    327    this.filterList.addEventListener("keydown", this._keyDown);
    328    this.el.addEventListener("mousedown", this._resetFocus);
    329 
    330    this.presetList.addEventListener("click", this._presetClick);
    331    this.togglePresets.addEventListener("click", this._togglePresets);
    332    this.addPresetButton.addEventListener("click", this._savePreset);
    333 
    334    // These events are event delegators for
    335    // drag-drop re-ordering and label-dragging
    336    this.win.addEventListener("mousemove", this._mouseMove);
    337    this.win.addEventListener("mouseup", this._mouseUp);
    338 
    339    // Used to workaround float-precision problems
    340    this.filterList.addEventListener("input", this._input);
    341  }
    342 
    343  _removeEventListeners() {
    344    this.addButton.removeEventListener("click", this._addButtonClick);
    345    this.filterList.removeEventListener("click", this._removeButtonClick);
    346    this.filterList.removeEventListener("mousedown", this._mouseDown);
    347    this.filterList.removeEventListener("keydown", this._keyDown);
    348    this.el.removeEventListener("mousedown", this._resetFocus);
    349 
    350    this.presetList.removeEventListener("click", this._presetClick);
    351    this.togglePresets.removeEventListener("click", this._togglePresets);
    352    this.addPresetButton.removeEventListener("click", this._savePreset);
    353 
    354    // These events are used for drag drop re-ordering
    355    this.win.removeEventListener("mousemove", this._mouseMove);
    356    this.win.removeEventListener("mouseup", this._mouseUp);
    357 
    358    // Used to workaround float-precision problems
    359    this.filterList.removeEventListener("input", this._input);
    360  }
    361 
    362  _getFilterElementIndex(el) {
    363    return [...this.filterList.children].indexOf(el);
    364  }
    365 
    366  _keyDown(e) {
    367    if (
    368      e.target.tagName.toLowerCase() !== "input" ||
    369      (e.keyCode !== 40 && e.keyCode !== 38)
    370    ) {
    371      return;
    372    }
    373    const input = e.target;
    374 
    375    const direction = e.keyCode === 40 ? -1 : 1;
    376 
    377    let multiplier = DEFAULT_VALUE_MULTIPLIER;
    378    if (e.altKey) {
    379      multiplier = SLOW_VALUE_MULTIPLIER;
    380    } else if (e.shiftKey) {
    381      multiplier = FAST_VALUE_MULTIPLIER;
    382    }
    383 
    384    const filterEl = e.target.closest(".filter");
    385    const index = this._getFilterElementIndex(filterEl);
    386    const filter = this.filters[index];
    387 
    388    // Filters that have units are number-type filters. For them,
    389    // the value can be incremented/decremented simply.
    390    // For other types of filters (e.g. drop-shadow) we need to check
    391    // if the keydown happened close to a number first.
    392    if (filter.unit) {
    393      const startValue = parseFloat(e.target.value);
    394      let value = startValue + direction * multiplier;
    395 
    396      const [min, max] = this._definition(filter.name).range;
    397      if (value < min) {
    398        value = min;
    399      } else if (value > max) {
    400        value = max;
    401      }
    402 
    403      input.value = fixFloat(value);
    404 
    405      this.updateValueAt(index, value);
    406    } else {
    407      let selectionStart = input.selectionStart;
    408      const num = getNeighbourNumber(input.value, selectionStart);
    409      if (!num) {
    410        return;
    411      }
    412 
    413      let { start, end, value } = num;
    414 
    415      const split = input.value.split("");
    416      let computed = fixFloat(value + direction * multiplier);
    417      const dotIndex = computed.indexOf(".0");
    418      if (dotIndex > -1) {
    419        computed = computed.slice(0, -2);
    420 
    421        selectionStart =
    422          selectionStart > start + dotIndex ? start + dotIndex : selectionStart;
    423      }
    424      split.splice(start, end - start, computed);
    425 
    426      value = split.join("");
    427      input.value = value;
    428      this.updateValueAt(index, value);
    429      input.setSelectionRange(selectionStart, selectionStart);
    430    }
    431    e.preventDefault();
    432  }
    433 
    434  _input(e) {
    435    const filterEl = e.target.closest(".filter");
    436    const index = this._getFilterElementIndex(filterEl);
    437    const filter = this.filters[index];
    438    const def = this._definition(filter.name);
    439 
    440    if (def.type !== "string") {
    441      e.target.value = fixFloat(e.target.value);
    442    }
    443    this.updateValueAt(index, e.target.value);
    444  }
    445 
    446  _mouseDown(e) {
    447    const filterEl = e.target.closest(".filter");
    448 
    449    // re-ordering drag handle
    450    if (e.target.tagName.toLowerCase() === "i") {
    451      this.isReorderingFilter = true;
    452      filterEl.startingY = e.pageY;
    453      filterEl.classList.add("dragging");
    454 
    455      this.el.classList.add("dragging");
    456      // label-dragging
    457    } else if (e.target.classList.contains("devtools-draglabel")) {
    458      const label = e.target;
    459      const input = filterEl.querySelector("input");
    460      const index = this._getFilterElementIndex(filterEl);
    461 
    462      this._dragging = {
    463        index,
    464        label,
    465        input,
    466        startX: e.pageX,
    467      };
    468 
    469      this.isDraggingLabel = true;
    470    }
    471  }
    472 
    473  _addButtonClick() {
    474    const select = this.filterSelect;
    475    if (!select.value) {
    476      return;
    477    }
    478 
    479    const key = select.value;
    480    this.add(key, null);
    481 
    482    this.render();
    483  }
    484 
    485  _removeButtonClick(e) {
    486    const isRemoveButton = e.target.classList.contains("remove-button");
    487    if (!isRemoveButton) {
    488      return;
    489    }
    490 
    491    const filterEl = e.target.closest(".filter");
    492    const index = this._getFilterElementIndex(filterEl);
    493    this.removeAt(index);
    494  }
    495 
    496  _mouseMove(e) {
    497    if (this.isReorderingFilter) {
    498      this._dragFilterElement(e);
    499    } else if (this.isDraggingLabel) {
    500      this._dragLabel(e);
    501    }
    502  }
    503 
    504  _dragFilterElement(e) {
    505    const rect = this.filterList.getBoundingClientRect();
    506    const top = e.pageY - LIST_PADDING;
    507    const bottom = e.pageY + LIST_PADDING;
    508    // don't allow dragging over top/bottom of list
    509    if (top < rect.top || bottom > rect.bottom) {
    510      return;
    511    }
    512 
    513    const filterEl = this.filterList.querySelector(".dragging");
    514 
    515    const delta = e.pageY - filterEl.startingY;
    516    filterEl.style.top = delta + "px";
    517 
    518    // change is the number of _steps_ taken from initial position
    519    // i.e. how many elements we have passed
    520    let change = delta / LIST_ITEM_HEIGHT;
    521    if (change > 0) {
    522      change = Math.floor(change);
    523    } else if (change < 0) {
    524      change = Math.ceil(change);
    525    }
    526 
    527    const children = this.filterList.children;
    528    const index = [...children].indexOf(filterEl);
    529    const destination = index + change;
    530 
    531    // If we're moving out, or there's no change at all, stop and return
    532    if (destination >= children.length || destination < 0 || change === 0) {
    533      return;
    534    }
    535 
    536    // Re-order filter objects
    537    swapArrayIndices(this.filters, index, destination);
    538 
    539    // Re-order the dragging element in markup
    540    const target =
    541      change > 0 ? children[destination + 1] : children[destination];
    542    if (target) {
    543      this.filterList.insertBefore(filterEl, target);
    544    } else {
    545      this.filterList.appendChild(filterEl);
    546    }
    547 
    548    filterEl.removeAttribute("style");
    549 
    550    const currentPosition = change * LIST_ITEM_HEIGHT;
    551    filterEl.startingY = e.pageY + currentPosition - delta;
    552  }
    553 
    554  _dragLabel(e) {
    555    const dragging = this._dragging;
    556 
    557    const input = dragging.input;
    558 
    559    let multiplier = DEFAULT_VALUE_MULTIPLIER;
    560 
    561    if (e.altKey) {
    562      multiplier = SLOW_VALUE_MULTIPLIER;
    563    } else if (e.shiftKey) {
    564      multiplier = FAST_VALUE_MULTIPLIER;
    565    }
    566 
    567    dragging.lastX = e.pageX;
    568    const delta = e.pageX - dragging.startX;
    569    const startValue = parseFloat(input.value);
    570    let value = startValue + delta * multiplier;
    571 
    572    const filter = this.filters[dragging.index];
    573    const [min, max] = this._definition(filter.name).range;
    574    if (value < min) {
    575      value = min;
    576    } else if (value > max) {
    577      value = max;
    578    }
    579 
    580    input.value = fixFloat(value);
    581 
    582    dragging.startX = e.pageX;
    583 
    584    this.updateValueAt(dragging.index, value);
    585  }
    586 
    587  _mouseUp() {
    588    // Label-dragging is disabled on mouseup
    589    this._dragging = null;
    590    this.isDraggingLabel = false;
    591 
    592    // Filter drag/drop needs more cleaning
    593    if (!this.isReorderingFilter) {
    594      return;
    595    }
    596    const filterEl = this.filterList.querySelector(".dragging");
    597 
    598    this.isReorderingFilter = false;
    599    filterEl.classList.remove("dragging");
    600    this.el.classList.remove("dragging");
    601    filterEl.removeAttribute("style");
    602 
    603    this.emit("updated", this.getCssValue());
    604    this.render();
    605  }
    606 
    607  _presetClick(e) {
    608    const el = e.target;
    609    const preset = el.closest(".preset");
    610    if (!preset) {
    611      return;
    612    }
    613 
    614    const id = +preset.dataset.id;
    615 
    616    this.getPresets().then(presets => {
    617      if (el.classList.contains("remove-button")) {
    618        // If the click happened on the remove button.
    619        presets.splice(id, 1);
    620        this.setPresets(presets).then(this.renderPresets, console.error);
    621      } else {
    622        // Or if the click happened on a preset.
    623        const p = presets[id];
    624 
    625        this.setCssValue(p.value);
    626        this.addPresetInput.value = p.name;
    627      }
    628    }, console.error);
    629  }
    630 
    631  _togglePresets() {
    632    this.el.classList.toggle("show-presets");
    633    this.emit("render");
    634  }
    635 
    636  _savePreset(e) {
    637    e.preventDefault();
    638 
    639    const name = this.addPresetInput.value;
    640    const value = this.getCssValue();
    641 
    642    if (!name || !value || SPECIAL_VALUES.has(value)) {
    643      this.emit("preset-save-error");
    644      return;
    645    }
    646 
    647    this.getPresets().then(presets => {
    648      const index = presets.findIndex(preset => preset.name === name);
    649 
    650      if (index > -1) {
    651        presets[index].value = value;
    652      } else {
    653        presets.push({ name, value });
    654      }
    655 
    656      this.setPresets(presets).then(this.renderPresets, console.error);
    657    }, console.error);
    658  }
    659 
    660  /**
    661   * Workaround needed to reset the focus when using a HTML select inside a XUL panel.
    662   * See Bug 1294366.
    663   */
    664  _resetFocus() {
    665    this.filterSelect.ownerDocument.defaultView.focus();
    666  }
    667 
    668  /**
    669   * Clears the list and renders filters, binding required events.
    670   * There are some delegated events bound in _addEventListeners method
    671   */
    672  render() {
    673    if (!this.filters.length) {
    674      // eslint-disable-next-line no-unsanitized/property
    675      this.filterList.innerHTML = `<p> ${L10N.getStr("emptyFilterList")} <br />
    676                                 ${L10N.getStr("addUsingList")} </p>`;
    677      this.emit("render");
    678      return;
    679    }
    680 
    681    this.filterList.innerHTML = "";
    682 
    683    const base = this._filterItemMarkup;
    684 
    685    for (const filter of this.filters) {
    686      const def = this._definition(filter.name);
    687 
    688      const el = base.cloneNode(true);
    689 
    690      const [name, value] = el.children;
    691      const label = name.children[1];
    692      const [input, unitPreview] = value.children;
    693 
    694      let min, max;
    695      if (def.range) {
    696        [min, max] = def.range;
    697      }
    698 
    699      label.textContent = filter.name;
    700      input.value = filter.value;
    701 
    702      switch (def.type) {
    703        case "percentage":
    704        case "angle":
    705        case "length":
    706          input.type = "number";
    707          input.min = min;
    708          if (max !== Infinity) {
    709            input.max = max;
    710          }
    711          input.step = "0.1";
    712          break;
    713        case "string":
    714          input.type = "text";
    715          input.placeholder = def.placeholder;
    716          break;
    717      }
    718 
    719      // use photoshop-style label-dragging
    720      // and show filters' unit next to their <input>
    721      if (def.type !== "string") {
    722        unitPreview.textContent = filter.unit;
    723 
    724        label.classList.add("devtools-draglabel");
    725        label.title = L10N.getStr("labelDragTooltipText");
    726      } else {
    727        // string-type filters have no unit
    728        unitPreview.remove();
    729      }
    730 
    731      this.filterList.appendChild(el);
    732    }
    733 
    734    const lastInput = this.filterList.querySelector(
    735      ".filter:last-of-type input"
    736    );
    737    if (lastInput) {
    738      lastInput.focus();
    739      if (lastInput.type === "text") {
    740        // move cursor to end of input
    741        const end = lastInput.value.length;
    742        lastInput.setSelectionRange(end, end);
    743      }
    744    }
    745 
    746    this.emit("render");
    747  }
    748 
    749  renderPresets() {
    750    this.getPresets().then(presets => {
    751      // getPresets is async and the widget may be destroyed in between.
    752      if (!this.presetList) {
    753        return;
    754      }
    755 
    756      if (!presets || !presets.length) {
    757        // eslint-disable-next-line no-unsanitized/property
    758        this.presetList.innerHTML = `<p>${L10N.getStr("emptyPresetList")}</p>`;
    759        this.emit("render");
    760        return;
    761      }
    762      const base = this._presetItemMarkup;
    763 
    764      this.presetList.innerHTML = "";
    765 
    766      for (const [index, preset] of presets.entries()) {
    767        const el = base.cloneNode(true);
    768 
    769        const [label, span] = el.children;
    770 
    771        el.dataset.id = index;
    772 
    773        label.textContent = preset.name;
    774        span.textContent = preset.value;
    775 
    776        this.presetList.appendChild(el);
    777      }
    778 
    779      this.emit("render");
    780    });
    781  }
    782 
    783  /**
    784   * returns definition of a filter as defined in filterList
    785   *
    786   * @param {string} name
    787   *        filter name (e.g. blur)
    788   * @return {object}
    789   *        filter's definition
    790   */
    791  _definition(name) {
    792    name = name.toLowerCase();
    793    return filterList.find(a => a.name === name);
    794  }
    795 
    796  /**
    797   * Parses the CSS value specified, updating widget's filters
    798   *
    799   * @param {string} cssValue
    800   *        css value to be parsed
    801   */
    802  setCssValue(cssValue) {
    803    if (!cssValue) {
    804      throw new Error("Missing CSS filter value in setCssValue");
    805    }
    806 
    807    this.filters = [];
    808 
    809    if (SPECIAL_VALUES.has(cssValue)) {
    810      this._specialValue = cssValue;
    811      this.emit("updated", this.getCssValue());
    812      this.render();
    813      return;
    814    }
    815 
    816    for (let { name, value } of tokenizeFilterValue(cssValue)) {
    817      // If the specified value is invalid, replace it with the
    818      // default.
    819      if (name !== "url") {
    820        if (!this._cssIsValid("filter", name + "(" + value + ")")) {
    821          value = null;
    822        }
    823      }
    824 
    825      this.add(name, value, true);
    826    }
    827 
    828    this.emit("updated", this.getCssValue());
    829    this.render();
    830  }
    831 
    832  /**
    833   * Creates a new [name] filter record with value
    834   *
    835   * @param {string} name
    836   *        filter name (e.g. blur)
    837   * @param {string} value
    838   *        value of the filter (e.g. 30px, 20%)
    839   *        If this is |null|, then a default value may be supplied.
    840   * @return {number}
    841   *        The index of the new filter in the current list of filters
    842   * @param {boolean}
    843   *        By default, adding a new filter emits an "updated" event, but if
    844   *        you're calling add in a loop and wait to emit a single event after
    845   *        the loop yourself, set this parameter to true.
    846   */
    847  add(name, value, noEvent) {
    848    const def = this._definition(name);
    849    if (!def) {
    850      return false;
    851    }
    852 
    853    if (value === null) {
    854      // UNIT_MAPPING[string] is an empty string (falsy), so
    855      // using || doesn't work here
    856      const unitLabel =
    857        typeof UNIT_MAPPING[def.type] === "undefined"
    858          ? UNIT_MAPPING[DEFAULT_FILTER_TYPE]
    859          : UNIT_MAPPING[def.type];
    860 
    861      // string-type filters have no default value but a placeholder instead
    862      if (!unitLabel) {
    863        value = "";
    864      } else {
    865        value = def.range[0] + unitLabel;
    866      }
    867    }
    868 
    869    let unit = def.type === "string" ? "" : (/[a-zA-Z%]+/.exec(value) || [])[0];
    870 
    871    if (def.type !== "string") {
    872      value = parseFloat(value);
    873 
    874      // You can omit percentage values' and use a value between 0..1
    875      if (def.type === "percentage" && !unit) {
    876        value = value * 100;
    877        unit = "%";
    878      }
    879 
    880      const [min, max] = def.range;
    881      if (value < min) {
    882        value = min;
    883      } else if (value > max) {
    884        value = max;
    885      }
    886    }
    887 
    888    const index = this.filters.push({ value, unit, name }) - 1;
    889    if (!noEvent) {
    890      this.emit("updated", this.getCssValue());
    891    }
    892 
    893    return index;
    894  }
    895 
    896  /**
    897   * returns value + unit of the specified filter
    898   *
    899   * @param {number} index
    900   *        filter index
    901   * @return {string}
    902   *        css value of filter
    903   */
    904  getValueAt(index) {
    905    const filter = this.filters[index];
    906    if (!filter) {
    907      return null;
    908    }
    909 
    910    // Just return the value url functions.
    911    if (filter.name === "url") {
    912      return filter.value;
    913    }
    914 
    915    return filter.value + filter.unit;
    916  }
    917 
    918  removeAt(index) {
    919    if (!this.filters[index]) {
    920      return;
    921    }
    922 
    923    this.filters.splice(index, 1);
    924    this.emit("updated", this.getCssValue());
    925    this.render();
    926  }
    927 
    928  /**
    929   * Generates CSS filter value for filters of the widget
    930   *
    931   * @return {string}
    932   *        css value of filters
    933   */
    934  getCssValue() {
    935    return (
    936      this.filters
    937        .map((filter, i) => {
    938          return `${filter.name}(${this.getValueAt(i)})`;
    939        })
    940        .join(" ") ||
    941      this._specialValue ||
    942      "none"
    943    );
    944  }
    945 
    946  /**
    947   * Updates specified filter's value
    948   *
    949   * @param {number} index
    950   *        The index of the filter in the current list of filters
    951   * @param {number/string} value
    952   *        value to set, string for string-typed filters
    953   *        number for the rest (unit automatically determined)
    954   */
    955  updateValueAt(index, value) {
    956    const filter = this.filters[index];
    957    if (!filter) {
    958      return;
    959    }
    960 
    961    const def = this._definition(filter.name);
    962 
    963    if (def.type !== "string") {
    964      const [min, max] = def.range;
    965      if (value < min) {
    966        value = min;
    967      } else if (value > max) {
    968        value = max;
    969      }
    970    }
    971 
    972    filter.value = filter.unit ? fixFloat(value, true) : value;
    973 
    974    this.emit("updated", this.getCssValue());
    975  }
    976 
    977  getPresets() {
    978    return asyncStorage.getItem("cssFilterPresets").then(presets => {
    979      if (!presets) {
    980        return [];
    981      }
    982 
    983      return presets;
    984    }, console.error);
    985  }
    986 
    987  setPresets(presets) {
    988    return asyncStorage
    989      .setItem("cssFilterPresets", presets)
    990      .catch(console.error);
    991  }
    992 }
    993 
    994 exports.CSSFilterEditorWidget = CSSFilterEditorWidget;
    995 
    996 // Fixes JavaScript's float precision
    997 function fixFloat(a, number) {
    998  const fixed = parseFloat(a).toFixed(1);
    999  return number ? parseFloat(fixed) : fixed;
   1000 }
   1001 
   1002 /**
   1003 * Used to swap two filters' indexes
   1004 * after drag/drop re-ordering
   1005 *
   1006 * @param {Array} array
   1007 *        the array to swap elements of
   1008 * @param {number} a
   1009 *        index of first element
   1010 * @param {number} b
   1011 *        index of second element
   1012 */
   1013 function swapArrayIndices(array, a, b) {
   1014  array[a] = array.splice(b, 1, array[a])[0];
   1015 }
   1016 
   1017 /**
   1018 * Tokenizes a CSS Filter value and returns an array of {name, value} pairs.
   1019 *
   1020 * @param {string} css CSS Filter value to be parsed
   1021 * @return {Array} An array of {name, value} pairs
   1022 */
   1023 function tokenizeFilterValue(css) {
   1024  const filters = [];
   1025  let depth = 0;
   1026 
   1027  if (SPECIAL_VALUES.has(css)) {
   1028    return filters;
   1029  }
   1030 
   1031  let state = "initial";
   1032  let name;
   1033  let contents;
   1034  for (const token of cssTokenizer(css)) {
   1035    switch (state) {
   1036      case "initial":
   1037        if (token.tokenType === "Function") {
   1038          name = token.value;
   1039          contents = "";
   1040          state = "function";
   1041          depth = 1;
   1042        } else if (
   1043          token.tokenType === "UnquotedUrl" ||
   1044          token.tokenType === "BadUrl"
   1045        ) {
   1046          const url = token.text
   1047            .substring(
   1048              // token text starts with `url(`
   1049              4,
   1050              // unquoted url also include the closing parenthesis
   1051              token.tokenType == "UnquotedUrl"
   1052                ? token.text.length - 1
   1053                : undefined
   1054            )
   1055            .trim();
   1056 
   1057          filters.push({ name: "url", value: url });
   1058          // Leave state as "initial" because the URL token includes
   1059          // the trailing close paren.
   1060        }
   1061        break;
   1062 
   1063      case "function":
   1064        if (token.tokenType === "CloseParenthesis") {
   1065          --depth;
   1066          if (depth === 0) {
   1067            filters.push({ name, value: contents.trim() });
   1068            state = "initial";
   1069            break;
   1070          }
   1071        }
   1072        contents += css.substring(token.startOffset, token.endOffset);
   1073        if (
   1074          token.tokenType === "Function" ||
   1075          token.tokenType === "Parenthesis"
   1076        ) {
   1077          ++depth;
   1078        }
   1079        break;
   1080    }
   1081  }
   1082 
   1083  return filters;
   1084 }
   1085 
   1086 /**
   1087 * Finds neighbour number characters of an index in a string
   1088 * the numbers may be floats (containing dots)
   1089 * It's assumed that the value given to this function is a valid number
   1090 *
   1091 * @param {string} string
   1092 *        The string containing numbers
   1093 * @param {number} index
   1094 *        The index to look for neighbours for
   1095 * @return {object}
   1096 *         returns null if no number is found
   1097 *         value: The number found
   1098 *         start: The number's starting index
   1099 *         end: The number's ending index
   1100 */
   1101 function getNeighbourNumber(string, index) {
   1102  if (!/\d/.test(string)) {
   1103    return null;
   1104  }
   1105 
   1106  let left = /-?[0-9.]*$/.exec(string.slice(0, index));
   1107  let right = /-?[0-9.]*/.exec(string.slice(index));
   1108 
   1109  left = left ? left[0] : "";
   1110  right = right ? right[0] : "";
   1111 
   1112  if (!right && !left) {
   1113    return null;
   1114  }
   1115 
   1116  return {
   1117    value: fixFloat(left + right, true),
   1118    start: index - left.length,
   1119    end: index + right.length,
   1120  };
   1121 }