diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /devtools/client/shared/widgets/FilterWidget.js | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'devtools/client/shared/widgets/FilterWidget.js')
-rw-r--r-- | devtools/client/shared/widgets/FilterWidget.js | 1073 |
1 files changed, 1073 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/FilterWidget.js b/devtools/client/shared/widgets/FilterWidget.js new file mode 100644 index 000000000..9cdb27a5a --- /dev/null +++ b/devtools/client/shared/widgets/FilterWidget.js @@ -0,0 +1,1073 @@ +/* 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("devtools/shared/event-emitter"); +const { Cc, Ci } = require("chrome"); +const XHTML_NS = "http://www.w3.org/1999/xhtml"; + +const { LocalizationHelper } = require("devtools/shared/l10n"); +const STRINGS_URI = "devtools/client/locales/filterwidget.properties"; +const L10N = new LocalizationHelper(STRINGS_URI); + +const {cssTokenizer} = require("devtools/shared/css/parsing-utils"); + +const asyncStorage = require("devtools/shared/async-storage"); + +loader.lazyGetter(this, "DOMUtils", () => { + return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); +}); + +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 {nsIDOMNode} el + * The widget container. + * @param {String} value + * CSS filter value + * @param {Function} cssIsValid + * Test whether css name / value is valid. + */ +function CSSFilterEditorWidget(el, value = "", cssIsValid) { + this.doc = el.ownerDocument; + this.win = this.doc.defaultView; + this.el = el; + this._cssIsValid = cssIsValid; + + 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: function () { + let filterListSelectPlaceholder = + L10N.getStr("filterListSelectPlaceholder"); + let addNewFilterButton = L10N.getStr("addNewFilterButton"); + let presetsToggleButton = L10N.getStr("presetsToggleButton"); + let newPresetPlaceholder = L10N.getStr("newPresetPlaceholder"); + let savePresetButton = L10N.getStr("savePresetButton"); + + this.el.innerHTML = ` + <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> + `; + this.filtersList = this.el.querySelector("#filters"); + this.presetsList = this.el.querySelector("#presets"); + this.togglePresets = this.el.querySelector("#toggle-presets"); + this.filterSelect = this.el.querySelector("select"); + this.addPresetButton = this.el.querySelector(".presets-list .add"); + this.addPresetInput = this.el.querySelector(".presets-list .footer input"); + + this.el.querySelector(".presets-list input").value = ""; + + this._populateFilterSelect(); + }, + + _destroyMarkup: function () { + this._filterItemMarkup.remove(); + this.el.remove(); + this.el = this.filtersList = this._filterItemMarkup = null; + this.presetsList = this.togglePresets = this.filterSelect = null; + this.addPresetButton = null; + }, + + destroy: function () { + this._removeEventListeners(); + this._destroyMarkup(); + }, + + /** + * Creates <option> elements for each filter definition + * in filterList + */ + _populateFilterSelect: function () { + let select = this.filterSelect; + filterList.forEach(filter => { + let option = this.doc.createElementNS(XHTML_NS, "option"); + option.innerHTML = option.value = filter.name; + select.appendChild(option); + }); + }, + + /** + * Creates a template for filter elements which is cloned and used in render + */ + _buildFilterItemMarkup: function () { + let base = this.doc.createElementNS(XHTML_NS, "div"); + base.className = "filter"; + + let name = this.doc.createElementNS(XHTML_NS, "div"); + name.className = "filter-name"; + + let value = this.doc.createElementNS(XHTML_NS, "div"); + value.className = "filter-value"; + + let drag = this.doc.createElementNS(XHTML_NS, "i"); + drag.title = L10N.getStr("dragHandleTooltipText"); + + let label = this.doc.createElementNS(XHTML_NS, "label"); + + name.appendChild(drag); + name.appendChild(label); + + let unitPreview = this.doc.createElementNS(XHTML_NS, "span"); + let input = this.doc.createElementNS(XHTML_NS, "input"); + input.classList.add("devtools-textinput"); + + value.appendChild(input); + value.appendChild(unitPreview); + + let removeButton = this.doc.createElementNS(XHTML_NS, "button"); + removeButton.className = "remove-button"; + + base.appendChild(name); + base.appendChild(value); + base.appendChild(removeButton); + + this._filterItemMarkup = base; + }, + + _buildPresetItemMarkup: function () { + let base = this.doc.createElementNS(XHTML_NS, "div"); + base.classList.add("preset"); + + let name = this.doc.createElementNS(XHTML_NS, "label"); + base.appendChild(name); + + let value = this.doc.createElementNS(XHTML_NS, "span"); + base.appendChild(value); + + let removeButton = this.doc.createElementNS(XHTML_NS, "button"); + removeButton.classList.add("remove-button"); + + base.appendChild(removeButton); + + this._presetItemMarkup = base; + }, + + _addEventListeners: function () { + this.addButton = this.el.querySelector("#add-filter"); + this.addButton.addEventListener("click", this._addButtonClick); + this.filtersList.addEventListener("click", this._removeButtonClick); + this.filtersList.addEventListener("mousedown", this._mouseDown); + this.filtersList.addEventListener("keydown", this._keyDown); + this.el.addEventListener("mousedown", this._resetFocus); + + this.presetsList.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.filtersList.addEventListener("input", this._input); + }, + + _removeEventListeners: function () { + this.addButton.removeEventListener("click", this._addButtonClick); + this.filtersList.removeEventListener("click", this._removeButtonClick); + this.filtersList.removeEventListener("mousedown", this._mouseDown); + this.filtersList.removeEventListener("keydown", this._keyDown); + this.el.removeEventListener("mousedown", this._resetFocus); + + this.presetsList.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.filtersList.removeEventListener("input", this._input); + }, + + _getFilterElementIndex: function (el) { + return [...this.filtersList.children].indexOf(el); + }, + + _keyDown: function (e) { + if (e.target.tagName.toLowerCase() !== "input" || + (e.keyCode !== 40 && e.keyCode !== 38)) { + return; + } + let 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 keypress happened close to a number first. + if (filter.unit) { + let 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; + let num = getNeighbourNumber(input.value, selectionStart); + if (!num) { + return; + } + + let {start, end, value} = num; + + let split = input.value.split(""); + let computed = fixFloat(value + direction * multiplier); + let 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: function (e) { + let filterEl = e.target.closest(".filter"); + let index = this._getFilterElementIndex(filterEl); + let filter = this.filters[index]; + let def = this._definition(filter.name); + + if (def.type !== "string") { + e.target.value = fixFloat(e.target.value); + } + this.updateValueAt(index, e.target.value); + }, + + _mouseDown: function (e) { + let 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")) { + let label = e.target; + let input = filterEl.querySelector("input"); + let index = this._getFilterElementIndex(filterEl); + + this._dragging = { + index, label, input, + startX: e.pageX + }; + + this.isDraggingLabel = true; + } + }, + + _addButtonClick: function () { + const select = this.filterSelect; + if (!select.value) { + return; + } + + const key = select.value; + this.add(key, null); + + this.render(); + }, + + _removeButtonClick: function (e) { + const isRemoveButton = e.target.classList.contains("remove-button"); + if (!isRemoveButton) { + return; + } + + let filterEl = e.target.closest(".filter"); + let index = this._getFilterElementIndex(filterEl); + this.removeAt(index); + }, + + _mouseMove: function (e) { + if (this.isReorderingFilter) { + this._dragFilterElement(e); + } else if (this.isDraggingLabel) { + this._dragLabel(e); + } + }, + + _dragFilterElement: function (e) { + const rect = this.filtersList.getBoundingClientRect(); + let top = e.pageY - LIST_PADDING; + let 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.filtersList.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.filtersList.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.filtersList.insertBefore(filterEl, target); + } else { + this.filtersList.appendChild(filterEl); + } + + filterEl.removeAttribute("style"); + + const currentPosition = change * LIST_ITEM_HEIGHT; + filterEl.startingY = e.pageY + currentPosition - delta; + }, + + _dragLabel: function (e) { + let dragging = this._dragging; + + let 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: function () { + // Label-dragging is disabled on mouseup + this._dragging = null; + this.isDraggingLabel = false; + + // Filter drag/drop needs more cleaning + if (!this.isReorderingFilter) { + return; + } + let filterEl = this.filtersList.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: function (e) { + let el = e.target; + let preset = el.closest(".preset"); + if (!preset) { + return; + } + + let 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, + ex => console.error(ex)); + } else { + // Or if the click happened on a preset. + let p = presets[id]; + + this.setCssValue(p.value); + this.addPresetInput.value = p.name; + } + }, ex => console.error(ex)); + }, + + _togglePresets: function () { + this.el.classList.toggle("show-presets"); + this.emit("render"); + }, + + _savePreset: function (e) { + e.preventDefault(); + + let name = this.addPresetInput.value; + let value = this.getCssValue(); + + if (!name || !value || SPECIAL_VALUES.has(value)) { + this.emit("preset-save-error"); + return; + } + + this.getPresets().then(presets => { + let 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, + ex => console.error(ex)); + }, ex => console.error(ex)); + }, + + /** + * Workaround needed to reset the focus when using a HTML select inside a XUL panel. + * See Bug 1294366. + */ + _resetFocus: function () { + this.filterSelect.ownerDocument.defaultView.focus(); + }, + + /** + * Clears the list and renders filters, binding required events. + * There are some delegated events bound in _addEventListeners method + */ + render: function () { + if (!this.filters.length) { + this.filtersList.innerHTML = `<p> ${L10N.getStr("emptyFilterList")} <br /> + ${L10N.getStr("addUsingList")} </p>`; + this.emit("render"); + return; + } + + this.filtersList.innerHTML = ""; + + let base = this._filterItemMarkup; + + for (let filter of this.filters) { + const def = this._definition(filter.name); + + let el = base.cloneNode(true); + + let [name, value] = el.children; + let label = name.children[1]; + let [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.filtersList.appendChild(el); + } + + let lastInput = + this.filtersList.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: function () { + this.getPresets().then(presets => { + // getPresets is async and the widget may be destroyed in between. + if (!this.presetsList) { + return; + } + + if (!presets || !presets.length) { + this.presetsList.innerHTML = `<p>${L10N.getStr("emptyPresetList")}</p>`; + this.emit("render"); + return; + } + let base = this._presetItemMarkup; + + this.presetsList.innerHTML = ""; + + for (let [index, preset] of presets.entries()) { + let el = base.cloneNode(true); + + let [label, span] = el.children; + + el.dataset.id = index; + + label.textContent = preset.name; + span.textContent = preset.value; + + this.presetsList.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: function (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: function (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: function (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: function (index) { + let 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: function (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: function () { + 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: function (index, value) { + let 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: function () { + return asyncStorage.getItem("cssFilterPresets").then(presets => { + if (!presets) { + return []; + } + + return presets; + }, e => console.error(e)); + }, + + setPresets: function (presets) { + return asyncStorage.setItem("cssFilterPresets", presets) + .catch(e => console.error(e)); + } +}; + +// Fixes JavaScript's float precision +function fixFloat(a, number) { + let 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) { + let filters = []; + let depth = 0; + + if (SPECIAL_VALUES.has(css)) { + return filters; + } + + let state = "initial"; + let name; + let contents; + for (let 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. + let originalText = css.substring(token.startOffset, token.endOffset); + let [, quote] = /^url\([ \t\r\n\f]*(["']?)/i.exec(originalText); + + filters.push({name: "url", value: token.text.trim(), quote: 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: 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 + }; +} |