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 }