summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/widgets/FilterWidget.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/widgets/FilterWidget.js')
-rw-r--r--devtools/client/shared/widgets/FilterWidget.js1131
1 files changed, 1131 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/FilterWidget.js b/devtools/client/shared/widgets/FilterWidget.js
new file mode 100644
index 0000000000..bb23bdfeca
--- /dev/null
+++ b/devtools/client/shared/widgets/FilterWidget.js
@@ -0,0 +1,1131 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * This is a CSS Filter Editor widget used
+ * for Rule View's filter swatches
+ */
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const STRINGS_URI = "devtools/client/locales/filterwidget.properties";
+const L10N = new LocalizationHelper(STRINGS_URI);
+
+const {
+ cssTokenizer,
+} = require("resource://devtools/shared/css/parsing-utils.js");
+
+const asyncStorage = require("resource://devtools/shared/async-storage.js");
+
+const DEFAULT_FILTER_TYPE = "length";
+const UNIT_MAPPING = {
+ percentage: "%",
+ length: "px",
+ angle: "deg",
+ string: "",
+};
+
+const FAST_VALUE_MULTIPLIER = 10;
+const SLOW_VALUE_MULTIPLIER = 0.1;
+const DEFAULT_VALUE_MULTIPLIER = 1;
+
+const LIST_PADDING = 7;
+const LIST_ITEM_HEIGHT = 32;
+
+const filterList = [
+ {
+ name: "blur",
+ range: [0, Infinity],
+ type: "length",
+ },
+ {
+ name: "brightness",
+ range: [0, Infinity],
+ type: "percentage",
+ },
+ {
+ name: "contrast",
+ range: [0, Infinity],
+ type: "percentage",
+ },
+ {
+ name: "drop-shadow",
+ placeholder: L10N.getStr("dropShadowPlaceholder"),
+ type: "string",
+ },
+ {
+ name: "grayscale",
+ range: [0, 100],
+ type: "percentage",
+ },
+ {
+ name: "hue-rotate",
+ range: [0, Infinity],
+ type: "angle",
+ },
+ {
+ name: "invert",
+ range: [0, 100],
+ type: "percentage",
+ },
+ {
+ name: "opacity",
+ range: [0, 100],
+ type: "percentage",
+ },
+ {
+ name: "saturate",
+ range: [0, Infinity],
+ type: "percentage",
+ },
+ {
+ name: "sepia",
+ range: [0, 100],
+ type: "percentage",
+ },
+ {
+ name: "url",
+ placeholder: "example.svg#c1",
+ type: "string",
+ },
+];
+
+// Valid values that shouldn't be parsed for filters.
+const SPECIAL_VALUES = new Set(["none", "unset", "initial", "inherit"]);
+
+/**
+ * A CSS Filter editor widget used to add/remove/modify
+ * filters.
+ *
+ * Normally, it takes a CSS filter value as input, parses it
+ * and creates the required elements / bindings.
+ *
+ * You can, however, use add/remove/update methods manually.
+ * See each method's comments for more details
+ *
+ * @param {Node} el
+ * The widget container.
+ * @param {String} value
+ * CSS filter value
+ */
+function CSSFilterEditorWidget(el, value = "") {
+ this.doc = el.ownerDocument;
+ this.win = this.doc.defaultView;
+ this.el = el;
+ this._cssIsValid = (name, val) => {
+ return this.win.CSS.supports(name, val);
+ };
+
+ this._addButtonClick = this._addButtonClick.bind(this);
+ this._removeButtonClick = this._removeButtonClick.bind(this);
+ this._mouseMove = this._mouseMove.bind(this);
+ this._mouseUp = this._mouseUp.bind(this);
+ this._mouseDown = this._mouseDown.bind(this);
+ this._keyDown = this._keyDown.bind(this);
+ this._input = this._input.bind(this);
+ this._presetClick = this._presetClick.bind(this);
+ this._savePreset = this._savePreset.bind(this);
+ this._togglePresets = this._togglePresets.bind(this);
+ this._resetFocus = this._resetFocus.bind(this);
+
+ // Passed to asyncStorage, requires binding
+ this.renderPresets = this.renderPresets.bind(this);
+
+ this._initMarkup();
+ this._buildFilterItemMarkup();
+ this._buildPresetItemMarkup();
+ this._addEventListeners();
+
+ EventEmitter.decorate(this);
+
+ this.filters = [];
+ this.setCssValue(value);
+ this.renderPresets();
+}
+
+exports.CSSFilterEditorWidget = CSSFilterEditorWidget;
+
+CSSFilterEditorWidget.prototype = {
+ _initMarkup() {
+ // The following structure is created:
+ // <div class="filters-list">
+ // <div id="filters"></div>
+ // <div class="footer">
+ // <select value="">
+ // <option value="">${filterListSelectPlaceholder}</option>
+ // </select>
+ // <button id="add-filter" class="add">${addNewFilterButton}</button>
+ // <button id="toggle-presets">${presetsToggleButton}</button>
+ // </div>
+ // </div>
+ // <div class="presets-list">
+ // <div id="presets"></div>
+ // <div class="footer">
+ // <input value="" class="devtools-textinput"
+ // placeholder="${newPresetPlaceholder}"></input>
+ // <button class="add">${savePresetButton}</button>
+ // </div>
+ // </div>
+ const content = this.doc.createDocumentFragment();
+
+ const filterListWrapper = this.doc.createElementNS(XHTML_NS, "div");
+ filterListWrapper.classList.add("filters-list");
+ content.appendChild(filterListWrapper);
+
+ this.filterList = this.doc.createElementNS(XHTML_NS, "div");
+ this.filterList.setAttribute("id", "filters");
+ filterListWrapper.appendChild(this.filterList);
+
+ const filterListFooter = this.doc.createElementNS(XHTML_NS, "div");
+ filterListFooter.classList.add("footer");
+ filterListWrapper.appendChild(filterListFooter);
+
+ this.filterSelect = this.doc.createElementNS(XHTML_NS, "select");
+ this.filterSelect.setAttribute("value", "");
+ filterListFooter.appendChild(this.filterSelect);
+
+ const filterListPlaceholder = this.doc.createElementNS(XHTML_NS, "option");
+ filterListPlaceholder.setAttribute("value", "");
+ filterListPlaceholder.textContent = L10N.getStr(
+ "filterListSelectPlaceholder"
+ );
+ this.filterSelect.appendChild(filterListPlaceholder);
+
+ const addFilter = this.doc.createElementNS(XHTML_NS, "button");
+ addFilter.setAttribute("id", "add-filter");
+ addFilter.classList.add("add");
+ addFilter.textContent = L10N.getStr("addNewFilterButton");
+ filterListFooter.appendChild(addFilter);
+
+ this.togglePresets = this.doc.createElementNS(XHTML_NS, "button");
+ this.togglePresets.setAttribute("id", "toggle-presets");
+ this.togglePresets.textContent = L10N.getStr("presetsToggleButton");
+ filterListFooter.appendChild(this.togglePresets);
+
+ const presetListWrapper = this.doc.createElementNS(XHTML_NS, "div");
+ presetListWrapper.classList.add("presets-list");
+ content.appendChild(presetListWrapper);
+
+ this.presetList = this.doc.createElementNS(XHTML_NS, "div");
+ this.presetList.setAttribute("id", "presets");
+ presetListWrapper.appendChild(this.presetList);
+
+ const presetListFooter = this.doc.createElementNS(XHTML_NS, "div");
+ presetListFooter.classList.add("footer");
+ presetListWrapper.appendChild(presetListFooter);
+
+ this.addPresetInput = this.doc.createElementNS(XHTML_NS, "input");
+ this.addPresetInput.setAttribute("value", "");
+ this.addPresetInput.classList.add("devtools-textinput");
+ this.addPresetInput.setAttribute(
+ "placeholder",
+ L10N.getStr("newPresetPlaceholder")
+ );
+ presetListFooter.appendChild(this.addPresetInput);
+
+ this.addPresetButton = this.doc.createElementNS(XHTML_NS, "button");
+ this.addPresetButton.classList.add("add");
+ this.addPresetButton.textContent = L10N.getStr("savePresetButton");
+ presetListFooter.appendChild(this.addPresetButton);
+
+ this.el.appendChild(content);
+
+ this._populateFilterSelect();
+ },
+
+ _destroyMarkup() {
+ this._filterItemMarkup.remove();
+ this.el.remove();
+ this.el = this.filterList = this._filterItemMarkup = null;
+ this.presetList = this.togglePresets = this.filterSelect = null;
+ this.addPresetButton = null;
+ },
+
+ destroy() {
+ this._removeEventListeners();
+ this._destroyMarkup();
+ },
+
+ /**
+ * Creates <option> elements for each filter definition
+ * in filterList
+ */
+ _populateFilterSelect() {
+ const select = this.filterSelect;
+ filterList.forEach(filter => {
+ const option = this.doc.createElementNS(XHTML_NS, "option");
+ option.textContent = option.value = filter.name;
+ select.appendChild(option);
+ });
+ },
+
+ /**
+ * Creates a template for filter elements which is cloned and used in render
+ */
+ _buildFilterItemMarkup() {
+ const base = this.doc.createElementNS(XHTML_NS, "div");
+ base.className = "filter";
+
+ const name = this.doc.createElementNS(XHTML_NS, "div");
+ name.className = "filter-name";
+
+ const value = this.doc.createElementNS(XHTML_NS, "div");
+ value.className = "filter-value";
+
+ const drag = this.doc.createElementNS(XHTML_NS, "i");
+ drag.title = L10N.getStr("dragHandleTooltipText");
+
+ const label = this.doc.createElementNS(XHTML_NS, "label");
+
+ name.appendChild(drag);
+ name.appendChild(label);
+
+ const unitPreview = this.doc.createElementNS(XHTML_NS, "span");
+ const input = this.doc.createElementNS(XHTML_NS, "input");
+ input.classList.add("devtools-textinput");
+
+ value.appendChild(input);
+ value.appendChild(unitPreview);
+
+ const removeButton = this.doc.createElementNS(XHTML_NS, "button");
+ removeButton.className = "remove-button";
+
+ base.appendChild(name);
+ base.appendChild(value);
+ base.appendChild(removeButton);
+
+ this._filterItemMarkup = base;
+ },
+
+ _buildPresetItemMarkup() {
+ const base = this.doc.createElementNS(XHTML_NS, "div");
+ base.classList.add("preset");
+
+ const name = this.doc.createElementNS(XHTML_NS, "label");
+ base.appendChild(name);
+
+ const value = this.doc.createElementNS(XHTML_NS, "span");
+ base.appendChild(value);
+
+ const removeButton = this.doc.createElementNS(XHTML_NS, "button");
+ removeButton.classList.add("remove-button");
+
+ base.appendChild(removeButton);
+
+ this._presetItemMarkup = base;
+ },
+
+ _addEventListeners() {
+ this.addButton = this.el.querySelector("#add-filter");
+ this.addButton.addEventListener("click", this._addButtonClick);
+ this.filterList.addEventListener("click", this._removeButtonClick);
+ this.filterList.addEventListener("mousedown", this._mouseDown);
+ this.filterList.addEventListener("keydown", this._keyDown);
+ this.el.addEventListener("mousedown", this._resetFocus);
+
+ this.presetList.addEventListener("click", this._presetClick);
+ this.togglePresets.addEventListener("click", this._togglePresets);
+ this.addPresetButton.addEventListener("click", this._savePreset);
+
+ // These events are event delegators for
+ // drag-drop re-ordering and label-dragging
+ this.win.addEventListener("mousemove", this._mouseMove);
+ this.win.addEventListener("mouseup", this._mouseUp);
+
+ // Used to workaround float-precision problems
+ this.filterList.addEventListener("input", this._input);
+ },
+
+ _removeEventListeners() {
+ this.addButton.removeEventListener("click", this._addButtonClick);
+ this.filterList.removeEventListener("click", this._removeButtonClick);
+ this.filterList.removeEventListener("mousedown", this._mouseDown);
+ this.filterList.removeEventListener("keydown", this._keyDown);
+ this.el.removeEventListener("mousedown", this._resetFocus);
+
+ this.presetList.removeEventListener("click", this._presetClick);
+ this.togglePresets.removeEventListener("click", this._togglePresets);
+ this.addPresetButton.removeEventListener("click", this._savePreset);
+
+ // These events are used for drag drop re-ordering
+ this.win.removeEventListener("mousemove", this._mouseMove);
+ this.win.removeEventListener("mouseup", this._mouseUp);
+
+ // Used to workaround float-precision problems
+ this.filterList.removeEventListener("input", this._input);
+ },
+
+ _getFilterElementIndex(el) {
+ return [...this.filterList.children].indexOf(el);
+ },
+
+ _keyDown(e) {
+ if (
+ e.target.tagName.toLowerCase() !== "input" ||
+ (e.keyCode !== 40 && e.keyCode !== 38)
+ ) {
+ return;
+ }
+ const input = e.target;
+
+ const direction = e.keyCode === 40 ? -1 : 1;
+
+ let multiplier = DEFAULT_VALUE_MULTIPLIER;
+ if (e.altKey) {
+ multiplier = SLOW_VALUE_MULTIPLIER;
+ } else if (e.shiftKey) {
+ multiplier = FAST_VALUE_MULTIPLIER;
+ }
+
+ const filterEl = e.target.closest(".filter");
+ const index = this._getFilterElementIndex(filterEl);
+ const filter = this.filters[index];
+
+ // Filters that have units are number-type filters. For them,
+ // the value can be incremented/decremented simply.
+ // For other types of filters (e.g. drop-shadow) we need to check
+ // if the keydown happened close to a number first.
+ if (filter.unit) {
+ const startValue = parseFloat(e.target.value);
+ let value = startValue + direction * multiplier;
+
+ const [min, max] = this._definition(filter.name).range;
+ if (value < min) {
+ value = min;
+ } else if (value > max) {
+ value = max;
+ }
+
+ input.value = fixFloat(value);
+
+ this.updateValueAt(index, value);
+ } else {
+ let selectionStart = input.selectionStart;
+ const num = getNeighbourNumber(input.value, selectionStart);
+ if (!num) {
+ return;
+ }
+
+ let { start, end, value } = num;
+
+ const split = input.value.split("");
+ let computed = fixFloat(value + direction * multiplier);
+ const dotIndex = computed.indexOf(".0");
+ if (dotIndex > -1) {
+ computed = computed.slice(0, -2);
+
+ selectionStart =
+ selectionStart > start + dotIndex ? start + dotIndex : selectionStart;
+ }
+ split.splice(start, end - start, computed);
+
+ value = split.join("");
+ input.value = value;
+ this.updateValueAt(index, value);
+ input.setSelectionRange(selectionStart, selectionStart);
+ }
+ e.preventDefault();
+ },
+
+ _input(e) {
+ const filterEl = e.target.closest(".filter");
+ const index = this._getFilterElementIndex(filterEl);
+ const filter = this.filters[index];
+ const def = this._definition(filter.name);
+
+ if (def.type !== "string") {
+ e.target.value = fixFloat(e.target.value);
+ }
+ this.updateValueAt(index, e.target.value);
+ },
+
+ _mouseDown(e) {
+ const filterEl = e.target.closest(".filter");
+
+ // re-ordering drag handle
+ if (e.target.tagName.toLowerCase() === "i") {
+ this.isReorderingFilter = true;
+ filterEl.startingY = e.pageY;
+ filterEl.classList.add("dragging");
+
+ this.el.classList.add("dragging");
+ // label-dragging
+ } else if (e.target.classList.contains("devtools-draglabel")) {
+ const label = e.target;
+ const input = filterEl.querySelector("input");
+ const index = this._getFilterElementIndex(filterEl);
+
+ this._dragging = {
+ index,
+ label,
+ input,
+ startX: e.pageX,
+ };
+
+ this.isDraggingLabel = true;
+ }
+ },
+
+ _addButtonClick() {
+ const select = this.filterSelect;
+ if (!select.value) {
+ return;
+ }
+
+ const key = select.value;
+ this.add(key, null);
+
+ this.render();
+ },
+
+ _removeButtonClick(e) {
+ const isRemoveButton = e.target.classList.contains("remove-button");
+ if (!isRemoveButton) {
+ return;
+ }
+
+ const filterEl = e.target.closest(".filter");
+ const index = this._getFilterElementIndex(filterEl);
+ this.removeAt(index);
+ },
+
+ _mouseMove(e) {
+ if (this.isReorderingFilter) {
+ this._dragFilterElement(e);
+ } else if (this.isDraggingLabel) {
+ this._dragLabel(e);
+ }
+ },
+
+ _dragFilterElement(e) {
+ const rect = this.filterList.getBoundingClientRect();
+ const top = e.pageY - LIST_PADDING;
+ const bottom = e.pageY + LIST_PADDING;
+ // don't allow dragging over top/bottom of list
+ if (top < rect.top || bottom > rect.bottom) {
+ return;
+ }
+
+ const filterEl = this.filterList.querySelector(".dragging");
+
+ const delta = e.pageY - filterEl.startingY;
+ filterEl.style.top = delta + "px";
+
+ // change is the number of _steps_ taken from initial position
+ // i.e. how many elements we have passed
+ let change = delta / LIST_ITEM_HEIGHT;
+ if (change > 0) {
+ change = Math.floor(change);
+ } else if (change < 0) {
+ change = Math.ceil(change);
+ }
+
+ const children = this.filterList.children;
+ const index = [...children].indexOf(filterEl);
+ const destination = index + change;
+
+ // If we're moving out, or there's no change at all, stop and return
+ if (destination >= children.length || destination < 0 || change === 0) {
+ return;
+ }
+
+ // Re-order filter objects
+ swapArrayIndices(this.filters, index, destination);
+
+ // Re-order the dragging element in markup
+ const target =
+ change > 0 ? children[destination + 1] : children[destination];
+ if (target) {
+ this.filterList.insertBefore(filterEl, target);
+ } else {
+ this.filterList.appendChild(filterEl);
+ }
+
+ filterEl.removeAttribute("style");
+
+ const currentPosition = change * LIST_ITEM_HEIGHT;
+ filterEl.startingY = e.pageY + currentPosition - delta;
+ },
+
+ _dragLabel(e) {
+ const dragging = this._dragging;
+
+ const input = dragging.input;
+
+ let multiplier = DEFAULT_VALUE_MULTIPLIER;
+
+ if (e.altKey) {
+ multiplier = SLOW_VALUE_MULTIPLIER;
+ } else if (e.shiftKey) {
+ multiplier = FAST_VALUE_MULTIPLIER;
+ }
+
+ dragging.lastX = e.pageX;
+ const delta = e.pageX - dragging.startX;
+ const startValue = parseFloat(input.value);
+ let value = startValue + delta * multiplier;
+
+ const filter = this.filters[dragging.index];
+ const [min, max] = this._definition(filter.name).range;
+ if (value < min) {
+ value = min;
+ } else if (value > max) {
+ value = max;
+ }
+
+ input.value = fixFloat(value);
+
+ dragging.startX = e.pageX;
+
+ this.updateValueAt(dragging.index, value);
+ },
+
+ _mouseUp() {
+ // Label-dragging is disabled on mouseup
+ this._dragging = null;
+ this.isDraggingLabel = false;
+
+ // Filter drag/drop needs more cleaning
+ if (!this.isReorderingFilter) {
+ return;
+ }
+ const filterEl = this.filterList.querySelector(".dragging");
+
+ this.isReorderingFilter = false;
+ filterEl.classList.remove("dragging");
+ this.el.classList.remove("dragging");
+ filterEl.removeAttribute("style");
+
+ this.emit("updated", this.getCssValue());
+ this.render();
+ },
+
+ _presetClick(e) {
+ const el = e.target;
+ const preset = el.closest(".preset");
+ if (!preset) {
+ return;
+ }
+
+ const id = +preset.dataset.id;
+
+ this.getPresets().then(presets => {
+ if (el.classList.contains("remove-button")) {
+ // If the click happened on the remove button.
+ presets.splice(id, 1);
+ this.setPresets(presets).then(this.renderPresets, console.error);
+ } else {
+ // Or if the click happened on a preset.
+ const p = presets[id];
+
+ this.setCssValue(p.value);
+ this.addPresetInput.value = p.name;
+ }
+ }, console.error);
+ },
+
+ _togglePresets() {
+ this.el.classList.toggle("show-presets");
+ this.emit("render");
+ },
+
+ _savePreset(e) {
+ e.preventDefault();
+
+ const name = this.addPresetInput.value;
+ const value = this.getCssValue();
+
+ if (!name || !value || SPECIAL_VALUES.has(value)) {
+ this.emit("preset-save-error");
+ return;
+ }
+
+ this.getPresets().then(presets => {
+ const index = presets.findIndex(preset => preset.name === name);
+
+ if (index > -1) {
+ presets[index].value = value;
+ } else {
+ presets.push({ name, value });
+ }
+
+ this.setPresets(presets).then(this.renderPresets, console.error);
+ }, console.error);
+ },
+
+ /**
+ * Workaround needed to reset the focus when using a HTML select inside a XUL panel.
+ * See Bug 1294366.
+ */
+ _resetFocus() {
+ this.filterSelect.ownerDocument.defaultView.focus();
+ },
+
+ /**
+ * Clears the list and renders filters, binding required events.
+ * There are some delegated events bound in _addEventListeners method
+ */
+ render() {
+ if (!this.filters.length) {
+ // eslint-disable-next-line no-unsanitized/property
+ this.filterList.innerHTML = `<p> ${L10N.getStr("emptyFilterList")} <br />
+ ${L10N.getStr("addUsingList")} </p>`;
+ this.emit("render");
+ return;
+ }
+
+ this.filterList.innerHTML = "";
+
+ const base = this._filterItemMarkup;
+
+ for (const filter of this.filters) {
+ const def = this._definition(filter.name);
+
+ const el = base.cloneNode(true);
+
+ const [name, value] = el.children;
+ const label = name.children[1];
+ const [input, unitPreview] = value.children;
+
+ let min, max;
+ if (def.range) {
+ [min, max] = def.range;
+ }
+
+ label.textContent = filter.name;
+ input.value = filter.value;
+
+ switch (def.type) {
+ case "percentage":
+ case "angle":
+ case "length":
+ input.type = "number";
+ input.min = min;
+ if (max !== Infinity) {
+ input.max = max;
+ }
+ input.step = "0.1";
+ break;
+ case "string":
+ input.type = "text";
+ input.placeholder = def.placeholder;
+ break;
+ }
+
+ // use photoshop-style label-dragging
+ // and show filters' unit next to their <input>
+ if (def.type !== "string") {
+ unitPreview.textContent = filter.unit;
+
+ label.classList.add("devtools-draglabel");
+ label.title = L10N.getStr("labelDragTooltipText");
+ } else {
+ // string-type filters have no unit
+ unitPreview.remove();
+ }
+
+ this.filterList.appendChild(el);
+ }
+
+ const lastInput = this.filterList.querySelector(
+ ".filter:last-of-type input"
+ );
+ if (lastInput) {
+ lastInput.focus();
+ if (lastInput.type === "text") {
+ // move cursor to end of input
+ const end = lastInput.value.length;
+ lastInput.setSelectionRange(end, end);
+ }
+ }
+
+ this.emit("render");
+ },
+
+ renderPresets() {
+ this.getPresets().then(presets => {
+ // getPresets is async and the widget may be destroyed in between.
+ if (!this.presetList) {
+ return;
+ }
+
+ if (!presets || !presets.length) {
+ // eslint-disable-next-line no-unsanitized/property
+ this.presetList.innerHTML = `<p>${L10N.getStr("emptyPresetList")}</p>`;
+ this.emit("render");
+ return;
+ }
+ const base = this._presetItemMarkup;
+
+ this.presetList.innerHTML = "";
+
+ for (const [index, preset] of presets.entries()) {
+ const el = base.cloneNode(true);
+
+ const [label, span] = el.children;
+
+ el.dataset.id = index;
+
+ label.textContent = preset.name;
+ span.textContent = preset.value;
+
+ this.presetList.appendChild(el);
+ }
+
+ this.emit("render");
+ });
+ },
+
+ /**
+ * returns definition of a filter as defined in filterList
+ *
+ * @param {String} name
+ * filter name (e.g. blur)
+ * @return {Object}
+ * filter's definition
+ */
+ _definition(name) {
+ name = name.toLowerCase();
+ return filterList.find(a => a.name === name);
+ },
+
+ /**
+ * Parses the CSS value specified, updating widget's filters
+ *
+ * @param {String} cssValue
+ * css value to be parsed
+ */
+ setCssValue(cssValue) {
+ if (!cssValue) {
+ throw new Error("Missing CSS filter value in setCssValue");
+ }
+
+ this.filters = [];
+
+ if (SPECIAL_VALUES.has(cssValue)) {
+ this._specialValue = cssValue;
+ this.emit("updated", this.getCssValue());
+ this.render();
+ return;
+ }
+
+ for (let { name, value, quote } of tokenizeFilterValue(cssValue)) {
+ // If the specified value is invalid, replace it with the
+ // default.
+ if (name !== "url") {
+ if (!this._cssIsValid("filter", name + "(" + value + ")")) {
+ value = null;
+ }
+ }
+
+ this.add(name, value, quote, true);
+ }
+
+ this.emit("updated", this.getCssValue());
+ this.render();
+ },
+
+ /**
+ * Creates a new [name] filter record with value
+ *
+ * @param {String} name
+ * filter name (e.g. blur)
+ * @param {String} value
+ * value of the filter (e.g. 30px, 20%)
+ * If this is |null|, then a default value may be supplied.
+ * @param {String} quote
+ * For a url filter, the quoting style. This can be a
+ * single quote, a double quote, or empty.
+ * @return {Number}
+ * The index of the new filter in the current list of filters
+ * @param {Boolean}
+ * By default, adding a new filter emits an "updated" event, but if
+ * you're calling add in a loop and wait to emit a single event after
+ * the loop yourself, set this parameter to true.
+ */
+ add(name, value, quote, noEvent) {
+ const def = this._definition(name);
+ if (!def) {
+ return false;
+ }
+
+ if (value === null) {
+ // UNIT_MAPPING[string] is an empty string (falsy), so
+ // using || doesn't work here
+ const unitLabel =
+ typeof UNIT_MAPPING[def.type] === "undefined"
+ ? UNIT_MAPPING[DEFAULT_FILTER_TYPE]
+ : UNIT_MAPPING[def.type];
+
+ // string-type filters have no default value but a placeholder instead
+ if (!unitLabel) {
+ value = "";
+ } else {
+ value = def.range[0] + unitLabel;
+ }
+
+ if (name === "url") {
+ // Default quote.
+ quote = '"';
+ }
+ }
+
+ let unit = def.type === "string" ? "" : (/[a-zA-Z%]+/.exec(value) || [])[0];
+
+ if (def.type !== "string") {
+ value = parseFloat(value);
+
+ // You can omit percentage values' and use a value between 0..1
+ if (def.type === "percentage" && !unit) {
+ value = value * 100;
+ unit = "%";
+ }
+
+ const [min, max] = def.range;
+ if (value < min) {
+ value = min;
+ } else if (value > max) {
+ value = max;
+ }
+ }
+
+ const index = this.filters.push({ value, unit, name, quote }) - 1;
+ if (!noEvent) {
+ this.emit("updated", this.getCssValue());
+ }
+
+ return index;
+ },
+
+ /**
+ * returns value + unit of the specified filter
+ *
+ * @param {Number} index
+ * filter index
+ * @return {String}
+ * css value of filter
+ */
+ getValueAt(index) {
+ const filter = this.filters[index];
+ if (!filter) {
+ return null;
+ }
+
+ // Just return the value+unit for non-url functions.
+ if (filter.name !== "url") {
+ return filter.value + filter.unit;
+ }
+
+ // url values need to be quoted and escaped.
+ if (filter.quote === "'") {
+ return "'" + filter.value.replace(/\'/g, "\\'") + "'";
+ } else if (filter.quote === '"') {
+ return '"' + filter.value.replace(/\"/g, '\\"') + '"';
+ }
+
+ // Unquoted. This approach might change the original input -- for
+ // example the original might be over-quoted. But, this is
+ // correct and probably good enough.
+ return filter.value.replace(/[\\ \t()"']/g, "\\$&");
+ },
+
+ removeAt(index) {
+ if (!this.filters[index]) {
+ return;
+ }
+
+ this.filters.splice(index, 1);
+ this.emit("updated", this.getCssValue());
+ this.render();
+ },
+
+ /**
+ * Generates CSS filter value for filters of the widget
+ *
+ * @return {String}
+ * css value of filters
+ */
+ getCssValue() {
+ return (
+ this.filters
+ .map((filter, i) => {
+ return `${filter.name}(${this.getValueAt(i)})`;
+ })
+ .join(" ") ||
+ this._specialValue ||
+ "none"
+ );
+ },
+
+ /**
+ * Updates specified filter's value
+ *
+ * @param {Number} index
+ * The index of the filter in the current list of filters
+ * @param {number/string} value
+ * value to set, string for string-typed filters
+ * number for the rest (unit automatically determined)
+ */
+ updateValueAt(index, value) {
+ const filter = this.filters[index];
+ if (!filter) {
+ return;
+ }
+
+ const def = this._definition(filter.name);
+
+ if (def.type !== "string") {
+ const [min, max] = def.range;
+ if (value < min) {
+ value = min;
+ } else if (value > max) {
+ value = max;
+ }
+ }
+
+ filter.value = filter.unit ? fixFloat(value, true) : value;
+
+ this.emit("updated", this.getCssValue());
+ },
+
+ getPresets() {
+ return asyncStorage.getItem("cssFilterPresets").then(presets => {
+ if (!presets) {
+ return [];
+ }
+
+ return presets;
+ }, console.error);
+ },
+
+ setPresets(presets) {
+ return asyncStorage
+ .setItem("cssFilterPresets", presets)
+ .catch(console.error);
+ },
+};
+
+// Fixes JavaScript's float precision
+function fixFloat(a, number) {
+ const fixed = parseFloat(a).toFixed(1);
+ return number ? parseFloat(fixed) : fixed;
+}
+
+/**
+ * Used to swap two filters' indexes
+ * after drag/drop re-ordering
+ *
+ * @param {Array} array
+ * the array to swap elements of
+ * @param {Number} a
+ * index of first element
+ * @param {Number} b
+ * index of second element
+ */
+function swapArrayIndices(array, a, b) {
+ array[a] = array.splice(b, 1, array[a])[0];
+}
+
+/**
+ * Tokenizes a CSS Filter value and returns an array of {name, value} pairs.
+ *
+ * @param {String} css CSS Filter value to be parsed
+ * @return {Array} An array of {name, value} pairs
+ */
+function tokenizeFilterValue(css) {
+ const filters = [];
+ let depth = 0;
+
+ if (SPECIAL_VALUES.has(css)) {
+ return filters;
+ }
+
+ let state = "initial";
+ let name;
+ let contents;
+ for (const token of cssTokenizer(css)) {
+ switch (state) {
+ case "initial":
+ if (token.tokenType === "function") {
+ name = token.text;
+ contents = "";
+ state = "function";
+ depth = 1;
+ } else if (token.tokenType === "url" || token.tokenType === "bad_url") {
+ // Extract the quoting style from the url.
+ const originalText = css.substring(
+ token.startOffset,
+ token.endOffset
+ );
+ const [, quote] = /^url\([ \t\r\n\f]*(["']?)/i.exec(originalText);
+
+ filters.push({ name: "url", value: token.text.trim(), quote });
+ // Leave state as "initial" because the URL token includes
+ // the trailing close paren.
+ }
+ break;
+
+ case "function":
+ if (token.tokenType === "symbol" && token.text === ")") {
+ --depth;
+ if (depth === 0) {
+ filters.push({ name, value: contents.trim() });
+ state = "initial";
+ break;
+ }
+ }
+ contents += css.substring(token.startOffset, token.endOffset);
+ if (
+ token.tokenType === "function" ||
+ (token.tokenType === "symbol" && token.text === "(")
+ ) {
+ ++depth;
+ }
+ break;
+ }
+ }
+
+ return filters;
+}
+
+/**
+ * Finds neighbour number characters of an index in a string
+ * the numbers may be floats (containing dots)
+ * It's assumed that the value given to this function is a valid number
+ *
+ * @param {String} string
+ * The string containing numbers
+ * @param {Number} index
+ * The index to look for neighbours for
+ * @return {Object}
+ * returns null if no number is found
+ * value: The number found
+ * start: The number's starting index
+ * end: The number's ending index
+ */
+function getNeighbourNumber(string, index) {
+ if (!/\d/.test(string)) {
+ return null;
+ }
+
+ let left = /-?[0-9.]*$/.exec(string.slice(0, index));
+ let right = /-?[0-9.]*/.exec(string.slice(index));
+
+ left = left ? left[0] : "";
+ right = right ? right[0] : "";
+
+ if (!right && !left) {
+ return null;
+ }
+
+ return {
+ value: fixFloat(left + right, true),
+ start: index - left.length,
+ end: index + right.length,
+ };
+}