CubicBezierWidget.js (29198B)
1 /** 2 * Copyright (c) 2013 Lea Verou. All rights reserved. 3 * 4 * Permission is hereby granted, free of charge, to any person obtaining a 5 * copy of this software and associated documentation files (the "Software"), 6 * to deal in the Software without restriction, including without limitation 7 * the rights to use, copy, modify, merge, publish, distribute, sublicense, 8 * and/or sell copies of the Software, and to permit persons to whom the 9 * Software is furnished to do so, subject to the following conditions: 10 * 11 * The above copyright notice and this permission notice shall be included in 12 * all copies or substantial portions of the Software. 13 * 14 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 20 * DEALINGS IN THE SOFTWARE. 21 */ 22 23 // Based on www.cubic-bezier.com by Lea Verou 24 // See https://github.com/LeaVerou/cubic-bezier 25 26 "use strict"; 27 28 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 29 const { 30 PREDEFINED, 31 PRESETS, 32 DEFAULT_PRESET_CATEGORY, 33 } = require("resource://devtools/client/shared/widgets/CubicBezierPresets.js"); 34 const { 35 InspectorCSSParserWrapper, 36 } = require("resource://devtools/shared/css/lexer.js"); 37 const XHTML_NS = "http://www.w3.org/1999/xhtml"; 38 39 /** 40 * CubicBezier data structure helper 41 * Accepts an array of coordinates and exposes a few useful getters 42 */ 43 class CubicBezier { 44 /** 45 * @param {Array<number>} coordinates i.e. [.42, 0, .58, 1] 46 */ 47 constructor(coordinates) { 48 if (!coordinates) { 49 throw new Error("No offsets were defined"); 50 } 51 52 this.coordinates = coordinates.map(n => +n); 53 54 for (let i = 4; i--; ) { 55 const xy = this.coordinates[i]; 56 if (isNaN(xy) || (!(i % 2) && (xy < 0 || xy > 1))) { 57 throw new Error(`Wrong coordinate at ${i}(${xy})`); 58 } 59 } 60 61 this.coordinates.toString = function () { 62 return ( 63 this.map(n => { 64 return (Math.round(n * 100) / 100 + "").replace(/^0\./, "."); 65 }) + "" 66 ); 67 }; 68 } 69 get P1() { 70 return this.coordinates.slice(0, 2); 71 } 72 73 get P2() { 74 return this.coordinates.slice(2); 75 } 76 77 toString() { 78 // Check first if current coords are one of css predefined functions 79 const predefName = Object.keys(PREDEFINED).find(key => 80 coordsAreEqual(PREDEFINED[key], this.coordinates) 81 ); 82 83 return predefName || "cubic-bezier(" + this.coordinates + ")"; 84 } 85 } 86 87 exports.CubicBezier = CubicBezier; 88 89 /** 90 * Bezier curve canvas plotting class 91 */ 92 class BezierCanvas { 93 /** 94 * @param {HTMLCanvasElement} canvas 95 * @param {CubicBezier} bezier 96 * @param {Array} padding Amount of horizontal,vertical padding around the graph 97 */ 98 constructor(canvas, bezier, padding) { 99 this.canvas = canvas; 100 this.bezier = bezier; 101 this.padding = getPadding(padding); 102 103 // Convert to a cartesian coordinate system with axes from 0 to 1 104 this.ctx = this.canvas.getContext("2d"); 105 const p = this.padding; 106 107 this.ctx.scale( 108 canvas.width * (1 - p[1] - p[3]), 109 -canvas.height * (1 - p[0] - p[2]) 110 ); 111 this.ctx.translate(p[3] / (1 - p[1] - p[3]), -1 - p[0] / (1 - p[0] - p[2])); 112 } 113 114 /** 115 * Get P1 and P2 current top/left offsets so they can be positioned 116 * 117 * @return {Array} Returns an array of 2 {top:String,left:String} objects 118 */ 119 get offsets() { 120 const p = this.padding, 121 w = this.canvas.width, 122 h = this.canvas.height; 123 124 return [ 125 { 126 left: 127 w * (this.bezier.coordinates[0] * (1 - p[3] - p[1]) - p[3]) + "px", 128 top: 129 h * (1 - this.bezier.coordinates[1] * (1 - p[0] - p[2]) - p[0]) + 130 "px", 131 }, 132 { 133 left: 134 w * (this.bezier.coordinates[2] * (1 - p[3] - p[1]) - p[3]) + "px", 135 top: 136 h * (1 - this.bezier.coordinates[3] * (1 - p[0] - p[2]) - p[0]) + 137 "px", 138 }, 139 ]; 140 } 141 142 /** 143 * Convert an element's left/top offsets into coordinates 144 */ 145 offsetsToCoordinates(element) { 146 const w = this.canvas.width, 147 h = this.canvas.height; 148 149 // Convert padding percentage to actual padding 150 const p = this.padding.map((a, i) => a * (i % 2 ? w : h)); 151 152 return [ 153 (parseFloat(element.style.left) - p[3]) / (w + p[1] + p[3]), 154 (h - parseFloat(element.style.top) - p[2]) / (h - p[0] - p[2]), 155 ]; 156 } 157 158 /** 159 * Draw the cubic bezier curve for the current coordinates 160 */ 161 plot(settings = {}) { 162 const xy = this.bezier.coordinates; 163 164 const win = this.canvas.ownerGlobal; 165 const computedStyle = win.getComputedStyle(win.document.documentElement); 166 167 const defaultSettings = { 168 handleColor: computedStyle.getPropertyValue( 169 "--timing-function-control-point-background" 170 ), 171 handleThickness: 0.008, 172 diagonalThickness: 0.01, 173 diagonalColor: computedStyle.getPropertyValue("--bezier-diagonal-color"), 174 bezierColor: computedStyle.getPropertyValue( 175 "--timing-function-line-color" 176 ), 177 bezierThickness: 0.015, 178 drawHandles: true, 179 }; 180 181 for (const setting in settings) { 182 defaultSettings[setting] = settings[setting]; 183 } 184 185 // Clear the canvas –making sure to clear the 186 // whole area by resetting the transform first. 187 this.ctx.save(); 188 this.ctx.setTransform(1, 0, 0, 1, 0, 0); 189 this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); 190 this.ctx.restore(); 191 192 if (defaultSettings.drawHandles) { 193 // Draw control handles 194 this.ctx.beginPath(); 195 this.ctx.lineWidth = defaultSettings.handleThickness; 196 this.ctx.strokeStyle = defaultSettings.handleColor; 197 198 this.ctx.moveTo(0, 0); 199 this.ctx.lineTo(xy[0], xy[1]); 200 this.ctx.moveTo(1, 1); 201 this.ctx.lineTo(xy[2], xy[3]); 202 203 this.ctx.stroke(); 204 this.ctx.closePath(); 205 206 // Draw diagonal between points 207 this.ctx.beginPath(); 208 this.ctx.lineWidth = defaultSettings.diagonalThickness; 209 this.ctx.strokeStyle = defaultSettings.diagonalColor; 210 this.ctx.moveTo(0, 0); 211 this.ctx.lineTo(1, 1); 212 this.ctx.stroke(); 213 this.ctx.closePath(); 214 } 215 216 // Draw bezier curve 217 this.ctx.beginPath(); 218 this.ctx.lineWidth = defaultSettings.bezierThickness; 219 this.ctx.strokeStyle = defaultSettings.bezierColor; 220 this.ctx.moveTo(0, 0); 221 this.ctx.bezierCurveTo(xy[0], xy[1], xy[2], xy[3], 1, 1); 222 this.ctx.stroke(); 223 this.ctx.closePath(); 224 } 225 } 226 227 exports.BezierCanvas = BezierCanvas; 228 229 /** 230 * Cubic-bezier widget. Uses the BezierCanvas class to draw the curve and 231 * adds the control points and user interaction 232 * 233 * Emits "updated" events whenever the curve is changed. Along with the event is 234 * sent a CubicBezier object 235 */ 236 class CubicBezierWidget extends EventEmitter { 237 /** 238 * @param {Element} parent The container where the graph should be created 239 * @param {Array<number>} coordinates Coordinates of the curve to be drawn 240 */ 241 constructor(parent, coordinates = PRESETS["ease-in"]["ease-in-sine"]) { 242 super(); 243 244 this.parent = parent; 245 const { curve, p1, p2 } = this._initMarkup(); 246 247 this.curveBoundingBox = curve.getBoundingClientRect(); 248 this.curve = curve; 249 this.p1 = p1; 250 this.p2 = p2; 251 252 // Create and plot the bezier curve 253 this.bezierCanvas = new BezierCanvas( 254 this.curve, 255 new CubicBezier(coordinates), 256 [0.3, 0] 257 ); 258 this.bezierCanvas.plot(); 259 260 // Place the control points 261 const offsets = this.bezierCanvas.offsets; 262 this.p1.style.left = offsets[0].left; 263 this.p1.style.top = offsets[0].top; 264 this.p2.style.left = offsets[1].left; 265 this.p2.style.top = offsets[1].top; 266 267 this._onPointMouseDown = this._onPointMouseDown.bind(this); 268 this._onPointKeyDown = this._onPointKeyDown.bind(this); 269 this._onCurveClick = this._onCurveClick.bind(this); 270 this._onNewCoordinates = this._onNewCoordinates.bind(this); 271 this.onPrefersReducedMotionChange = 272 this.onPrefersReducedMotionChange.bind(this); 273 274 // Add preset preview menu 275 this.presets = new CubicBezierPresetWidget(parent); 276 277 // Add the timing function previewer 278 // if prefers-reduced-motion is not set 279 this.reducedMotion = parent.ownerGlobal.matchMedia( 280 "(prefers-reduced-motion)" 281 ); 282 if (!this.reducedMotion.matches) { 283 this.timingPreview = new TimingFunctionPreviewWidget(parent); 284 } 285 286 // add event listener to change prefers-reduced-motion 287 // of the timing function preview during runtime 288 this.reducedMotion.addEventListener( 289 "change", 290 this.onPrefersReducedMotionChange 291 ); 292 293 this._initEvents(); 294 } 295 _initMarkup() { 296 const doc = this.parent.ownerDocument; 297 298 const wrap = doc.createElementNS(XHTML_NS, "div"); 299 wrap.className = "display-wrap"; 300 301 const plane = doc.createElementNS(XHTML_NS, "div"); 302 plane.className = "coordinate-plane"; 303 304 const p1 = doc.createElementNS(XHTML_NS, "button"); 305 p1.className = "control-point"; 306 plane.appendChild(p1); 307 308 const p2 = doc.createElementNS(XHTML_NS, "button"); 309 p2.className = "control-point"; 310 plane.appendChild(p2); 311 312 const curve = doc.createElementNS(XHTML_NS, "canvas"); 313 curve.className = "curve"; 314 const parentComputedStyle = this.parent.ownerGlobal.getComputedStyle( 315 this.parent 316 ); 317 // We need to set the canvas dimension to the actual rendered dimension 318 // to avoid the canvas to scale. We can retrie the CSS variable values 319 // and striping their unit. 320 const dimensionRegex = /(?<size>\d+)px$/; 321 curve.setAttribute( 322 "width", 323 dimensionRegex.exec( 324 parentComputedStyle.getPropertyValue("--bezier-curve-width") 325 ).groups.size 326 ); 327 curve.setAttribute( 328 "height", 329 dimensionRegex.exec( 330 parentComputedStyle.getPropertyValue("--bezier-curve-height") 331 ).groups.size 332 ); 333 334 plane.appendChild(curve); 335 wrap.appendChild(plane); 336 337 this.parent.appendChild(wrap); 338 339 return { 340 p1, 341 p2, 342 curve, 343 }; 344 } 345 346 onPrefersReducedMotionChange(event) { 347 // if prefers-reduced-motion is enabled destroy timing function preview 348 // else create it if it does not exist 349 if (event.matches) { 350 if (this.timingPreview) { 351 this.timingPreview.destroy(); 352 } 353 this.timingPreview = undefined; 354 } else if (!this.timingPreview) { 355 this.timingPreview = new TimingFunctionPreviewWidget(this.parent); 356 } 357 } 358 359 _removeMarkup() { 360 this.parent.querySelector(".display-wrap").remove(); 361 } 362 363 _initEvents() { 364 this.p1.addEventListener("mousedown", this._onPointMouseDown); 365 this.p2.addEventListener("mousedown", this._onPointMouseDown); 366 367 this.p1.addEventListener("keydown", this._onPointKeyDown); 368 this.p2.addEventListener("keydown", this._onPointKeyDown); 369 370 this.curve.addEventListener("click", this._onCurveClick); 371 372 this.presets.on("new-coordinates", this._onNewCoordinates); 373 } 374 375 _removeEvents() { 376 this.p1.removeEventListener("mousedown", this._onPointMouseDown); 377 this.p2.removeEventListener("mousedown", this._onPointMouseDown); 378 379 this.p1.removeEventListener("keydown", this._onPointKeyDown); 380 this.p2.removeEventListener("keydown", this._onPointKeyDown); 381 382 this.curve.removeEventListener("click", this._onCurveClick); 383 384 this.presets.off("new-coordinates", this._onNewCoordinates); 385 } 386 387 _onPointMouseDown(event) { 388 // Updating the boundingbox in case it has changed 389 this.curveBoundingBox = this.curve.getBoundingClientRect(); 390 391 const point = event.target; 392 const doc = point.ownerDocument; 393 const self = this; 394 395 doc.onmousemove = function drag(e) { 396 let x = e.pageX; 397 const y = e.pageY; 398 const left = self.curveBoundingBox.left; 399 const top = self.curveBoundingBox.top; 400 401 if (x === 0 && y == 0) { 402 return; 403 } 404 405 // Constrain x 406 x = Math.min(Math.max(left, x), left + self.curveBoundingBox.width); 407 408 point.style.left = x - left + "px"; 409 point.style.top = y - top + "px"; 410 411 self._updateFromPoints(); 412 }; 413 414 doc.onmouseup = function () { 415 point.focus(); 416 doc.onmousemove = doc.onmouseup = null; 417 }; 418 } 419 420 _onPointKeyDown(event) { 421 const point = event.target; 422 const code = event.keyCode; 423 424 if (code >= 37 && code <= 40) { 425 event.preventDefault(); 426 427 // Arrow keys pressed 428 const left = parseInt(point.style.left, 10); 429 const top = parseInt(point.style.top, 10); 430 const offset = 3 * (event.shiftKey ? 10 : 1); 431 432 switch (code) { 433 case 37: 434 point.style.left = left - offset + "px"; 435 break; 436 case 38: 437 point.style.top = top - offset + "px"; 438 break; 439 case 39: 440 point.style.left = left + offset + "px"; 441 break; 442 case 40: 443 point.style.top = top + offset + "px"; 444 break; 445 } 446 447 this._updateFromPoints(); 448 } 449 } 450 451 _onCurveClick(event) { 452 this.curveBoundingBox = this.curve.getBoundingClientRect(); 453 454 const left = this.curveBoundingBox.left; 455 const top = this.curveBoundingBox.top; 456 const x = event.pageX - left; 457 const y = event.pageY - top; 458 459 // Find which point is closer 460 const distP1 = distance( 461 x, 462 y, 463 parseInt(this.p1.style.left, 10), 464 parseInt(this.p1.style.top, 10) 465 ); 466 const distP2 = distance( 467 x, 468 y, 469 parseInt(this.p2.style.left, 10), 470 parseInt(this.p2.style.top, 10) 471 ); 472 473 const point = distP1 < distP2 ? this.p1 : this.p2; 474 point.style.left = x + "px"; 475 point.style.top = y + "px"; 476 477 this._updateFromPoints(); 478 } 479 480 _onNewCoordinates(coordinates) { 481 this.coordinates = coordinates; 482 } 483 484 /** 485 * Get the current point coordinates and redraw the curve to match 486 */ 487 _updateFromPoints() { 488 // Get the new coordinates from the point's offsets 489 let coordinates = this.bezierCanvas.offsetsToCoordinates(this.p1); 490 coordinates = coordinates.concat( 491 this.bezierCanvas.offsetsToCoordinates(this.p2) 492 ); 493 494 this.presets.refreshMenu(coordinates); 495 this._redraw(coordinates); 496 } 497 498 /** 499 * Redraw the curve 500 * 501 * @param {Array} coordinates The array of control point coordinates 502 */ 503 _redraw(coordinates) { 504 // Provide a new CubicBezier to the canvas and plot the curve 505 this.bezierCanvas.bezier = new CubicBezier(coordinates); 506 this.bezierCanvas.plot(); 507 this.emit("updated", this.bezierCanvas.bezier); 508 509 if (this.timingPreview) { 510 this.timingPreview.preview(this.bezierCanvas.bezier.toString()); 511 } 512 } 513 514 /** 515 * Set new coordinates for the control points and redraw the curve 516 * 517 * @param {Array} coordinates 518 */ 519 set coordinates(coordinates) { 520 this._redraw(coordinates); 521 522 // Move the points 523 const offsets = this.bezierCanvas.offsets; 524 this.p1.style.left = offsets[0].left; 525 this.p1.style.top = offsets[0].top; 526 this.p2.style.left = offsets[1].left; 527 this.p2.style.top = offsets[1].top; 528 } 529 530 /** 531 * Set new coordinates for the control point and redraw the curve 532 * 533 * @param {string} value A string value. E.g. "linear", 534 * "cubic-bezier(0,0,1,1)" 535 */ 536 set cssCubicBezierValue(value) { 537 if (!value) { 538 return; 539 } 540 541 value = value.trim(); 542 543 // Try with one of the predefined values 544 const coordinates = parseTimingFunction(value); 545 546 this.presets.refreshMenu(coordinates); 547 this.coordinates = coordinates; 548 } 549 550 destroy() { 551 this._removeEvents(); 552 this._removeMarkup(); 553 554 // remove prefers-reduced-motion event listener 555 this.reducedMotion.removeEventListener( 556 "change", 557 this.onPrefersReducedMotionChange 558 ); 559 this.reducedMotion = null; 560 561 if (this.timingPreview) { 562 this.timingPreview.destroy(); 563 this.timingPreview = null; 564 } 565 this.presets.destroy(); 566 567 this.curve = this.p1 = this.p2 = null; 568 } 569 } 570 571 exports.CubicBezierWidget = CubicBezierWidget; 572 573 /** 574 * CubicBezierPreset widget. 575 * Builds a menu of presets from CubicBezierPresets 576 * 577 * Emits "new-coordinate" event along with the coordinates 578 * whenever a preset is selected. 579 */ 580 class CubicBezierPresetWidget extends EventEmitter { 581 /** 582 * @param {Element} parent The container where the preset panel should be 583 * created 584 */ 585 constructor(parent) { 586 super(); 587 588 this.parent = parent; 589 590 const { presetPane, presets, categories } = this._initMarkup(); 591 this.presetPane = presetPane; 592 this.presets = presets; 593 this.categories = categories; 594 595 this._activeCategory = null; 596 this._activePresetList = null; 597 this._activePreset = null; 598 599 this._onCategoryClick = this._onCategoryClick.bind(this); 600 this._onPresetClick = this._onPresetClick.bind(this); 601 602 this._initEvents(); 603 } 604 605 /* 606 * Constructs a list of all preset categories and a list 607 * of presets for each category. 608 * 609 * High level markup: 610 * div .preset-pane 611 * div .preset-categories 612 * div .category 613 * div .category 614 * ... 615 * div .preset-container 616 * div .presetList 617 * div .preset 618 * ... 619 * div .presetList 620 * div .preset 621 * ... 622 */ 623 _initMarkup() { 624 const doc = this.parent.ownerDocument; 625 626 const presetPane = doc.createElementNS(XHTML_NS, "div"); 627 presetPane.className = "preset-pane"; 628 629 const categoryList = doc.createElementNS(XHTML_NS, "div"); 630 categoryList.id = "preset-categories"; 631 632 const presetContainer = doc.createElementNS(XHTML_NS, "div"); 633 presetContainer.id = "preset-container"; 634 635 Object.keys(PRESETS).forEach(categoryLabel => { 636 const category = this._createCategory(categoryLabel); 637 categoryList.appendChild(category); 638 639 const presetList = this._createPresetList(categoryLabel); 640 presetContainer.appendChild(presetList); 641 }); 642 643 presetPane.appendChild(categoryList); 644 presetPane.appendChild(presetContainer); 645 646 this.parent.appendChild(presetPane); 647 648 const allCategories = presetPane.querySelectorAll(".category"); 649 const allPresets = presetPane.querySelectorAll(".preset"); 650 651 return { 652 presetPane, 653 presets: allPresets, 654 categories: allCategories, 655 }; 656 } 657 658 _createCategory(categoryLabel) { 659 const doc = this.parent.ownerDocument; 660 661 const category = doc.createElementNS(XHTML_NS, "div"); 662 category.id = categoryLabel; 663 category.classList.add("category"); 664 665 const categoryDisplayLabel = this._normalizeCategoryLabel(categoryLabel); 666 category.textContent = categoryDisplayLabel; 667 category.setAttribute("title", categoryDisplayLabel); 668 669 return category; 670 } 671 672 _normalizeCategoryLabel(categoryLabel) { 673 return categoryLabel.replace("/-/g", " "); 674 } 675 676 _createPresetList(categoryLabel) { 677 const doc = this.parent.ownerDocument; 678 679 const presetList = doc.createElementNS(XHTML_NS, "div"); 680 presetList.id = "preset-category-" + categoryLabel; 681 presetList.classList.add("preset-list"); 682 683 Object.keys(PRESETS[categoryLabel]).forEach(presetLabel => { 684 const preset = this._createPreset(categoryLabel, presetLabel); 685 presetList.appendChild(preset); 686 }); 687 688 return presetList; 689 } 690 691 _createPreset(categoryLabel, presetLabel) { 692 const doc = this.parent.ownerDocument; 693 694 const preset = doc.createElementNS(XHTML_NS, "div"); 695 preset.classList.add("preset"); 696 preset.id = presetLabel; 697 preset.coordinates = PRESETS[categoryLabel][presetLabel]; 698 // Create preset preview 699 const curve = doc.createElementNS(XHTML_NS, "canvas"); 700 const bezier = new CubicBezier(preset.coordinates); 701 curve.setAttribute("height", 50); 702 curve.setAttribute("width", 50); 703 preset.bezierCanvas = new BezierCanvas(curve, bezier, [0.15, 0]); 704 preset.bezierCanvas.plot({ 705 drawHandles: false, 706 bezierThickness: 0.025, 707 }); 708 preset.appendChild(curve); 709 710 // Create preset label 711 const presetLabelElem = doc.createElementNS(XHTML_NS, "p"); 712 const presetDisplayLabel = this._normalizePresetLabel( 713 categoryLabel, 714 presetLabel 715 ); 716 presetLabelElem.textContent = presetDisplayLabel; 717 preset.appendChild(presetLabelElem); 718 preset.setAttribute("title", presetDisplayLabel); 719 720 return preset; 721 } 722 723 _normalizePresetLabel(categoryLabel, presetLabel) { 724 return presetLabel.replace(categoryLabel + "-", "").replace("/-/g", " "); 725 } 726 727 _initEvents() { 728 for (const category of this.categories) { 729 category.addEventListener("click", this._onCategoryClick); 730 } 731 732 for (const preset of this.presets) { 733 preset.addEventListener("click", this._onPresetClick); 734 } 735 } 736 737 _removeEvents() { 738 for (const category of this.categories) { 739 category.removeEventListener("click", this._onCategoryClick); 740 } 741 742 for (const preset of this.presets) { 743 preset.removeEventListener("click", this._onPresetClick); 744 } 745 } 746 747 _onPresetClick(event) { 748 this.emit("new-coordinates", event.currentTarget.coordinates); 749 this.activePreset = event.currentTarget; 750 } 751 752 _onCategoryClick(event) { 753 this.activeCategory = event.target; 754 } 755 756 _setActivePresetList(presetListId) { 757 const presetList = this.presetPane.querySelector("#" + presetListId); 758 swapClassName("active-preset-list", this._activePresetList, presetList); 759 this._activePresetList = presetList; 760 } 761 762 set activeCategory(category) { 763 swapClassName("active-category", this._activeCategory, category); 764 this._activeCategory = category; 765 this._setActivePresetList("preset-category-" + category.id); 766 } 767 768 get activeCategory() { 769 return this._activeCategory; 770 } 771 772 set activePreset(preset) { 773 swapClassName("active-preset", this._activePreset, preset); 774 this._activePreset = preset; 775 } 776 777 get activePreset() { 778 return this._activePreset; 779 } 780 781 /** 782 * Called by CubicBezierWidget onload and when 783 * the curve is modified via the canvas. 784 * Attempts to match the new user setting with an 785 * existing preset. 786 * 787 * @param {Array} coordinates new coords [i, j, k, l] 788 */ 789 refreshMenu(coordinates) { 790 // If we cannot find a matching preset, keep 791 // menu on last known preset category. 792 let category = this._activeCategory; 793 794 // If we cannot find a matching preset 795 // deselect any selected preset. 796 let preset = null; 797 798 // If a category has never been viewed before 799 // show the default category. 800 if (!category) { 801 category = this.parent.querySelector("#" + DEFAULT_PRESET_CATEGORY); 802 } 803 804 // If the new coordinates do match a preset, 805 // set its category and preset button as active. 806 Object.keys(PRESETS).forEach(categoryLabel => { 807 Object.keys(PRESETS[categoryLabel]).forEach(presetLabel => { 808 if (coordsAreEqual(PRESETS[categoryLabel][presetLabel], coordinates)) { 809 category = this.parent.querySelector("#" + categoryLabel); 810 preset = this.parent.querySelector("#" + presetLabel); 811 } 812 }); 813 }); 814 815 this.activeCategory = category; 816 this.activePreset = preset; 817 } 818 819 destroy() { 820 this._removeEvents(); 821 this.parent.querySelector(".preset-pane").remove(); 822 } 823 } 824 825 exports.CubicBezierPresetWidget = CubicBezierPresetWidget; 826 827 /** 828 * The TimingFunctionPreviewWidget animates a dot on a scale with a given 829 * timing-function 830 * 831 */ 832 class TimingFunctionPreviewWidget { 833 /** 834 * @param {Element} parent The container where this widget should go 835 */ 836 constructor(parent) { 837 this.previousValue = null; 838 839 this.parent = parent; 840 this._initMarkup(); 841 } 842 843 PREVIEW_DURATION = 1000; 844 845 _initMarkup() { 846 const doc = this.parent.ownerDocument; 847 848 const container = doc.createElementNS(XHTML_NS, "div"); 849 container.className = "timing-function-preview"; 850 851 this.dot = doc.createElementNS(XHTML_NS, "div"); 852 this.dot.className = "dot"; 853 container.appendChild(this.dot); 854 855 const scale = doc.createElementNS(XHTML_NS, "div"); 856 scale.className = "scale"; 857 container.appendChild(scale); 858 859 this.parent.appendChild(container); 860 } 861 862 destroy() { 863 this.dot.getAnimations().forEach(anim => anim.cancel()); 864 this.parent.querySelector(".timing-function-preview").remove(); 865 this.parent = this.dot = null; 866 } 867 868 /** 869 * Preview a new timing function. The current preview will only be stopped if 870 * the supplied function value is different from the previous one. If the 871 * supplied function is invalid, the preview will stop. 872 * 873 * @param {string} value 874 */ 875 preview(value) { 876 // Don't restart the preview animation if the value is the same 877 if (value === this.previousValue) { 878 return; 879 } 880 881 if (parseTimingFunction(value)) { 882 this.restartAnimation(value); 883 } 884 885 this.previousValue = value; 886 } 887 888 /** 889 * Re-start the preview animation from the beginning. 890 * 891 * @param {string} timingFunction The value for the timing-function. 892 */ 893 restartAnimation(timingFunction) { 894 // Cancel the previous animation if there was any. 895 this.dot.getAnimations().forEach(anim => anim.cancel()); 896 897 // And start the new one. 898 // The animation consists of a few keyframes that move the dot to the right of the 899 // container, and then move it back to the left. 900 // It also contains some pause where the dot is greyed-out, before it moves to 901 // the right, and once again, before it comes back to the left. 902 // The timing function passed to this function is applied to the keyframes that 903 // actually move the dot. This way it can be previewed in both direction, instead of 904 // being spread over the whole animation. 905 const translateStart = "calc(var(--bezier-curve-width) / -2)"; 906 const translateEnd = "calc(var(--bezier-curve-width) / 2)"; 907 const grayscaleFilter = "grayscale(100%)"; 908 909 this.dot.animate( 910 [ 911 { translate: translateStart, filter: grayscaleFilter, offset: 0 }, 912 { 913 translate: translateStart, 914 filter: grayscaleFilter, 915 offset: 0.19, 916 }, 917 { 918 translate: translateStart, 919 filter: "none", 920 offset: 0.2, 921 easing: timingFunction, 922 }, 923 { translate: translateEnd, filter: "none", offset: 0.5 }, 924 { translate: translateEnd, filter: grayscaleFilter, offset: 0.51 }, 925 { translate: translateEnd, filter: grayscaleFilter, offset: 0.7 }, 926 { 927 translate: translateEnd, 928 filter: "none", 929 offset: 0.71, 930 easing: timingFunction, 931 }, 932 { translate: translateStart, filter: "none", offset: 1 }, 933 ], 934 { 935 duration: this.PREVIEW_DURATION * 2, 936 iterations: Infinity, 937 } 938 ); 939 } 940 } 941 942 // Helpers 943 944 function getPadding(padding) { 945 const p = typeof padding === "number" ? [padding] : padding; 946 947 if (p.length === 1) { 948 p[1] = p[0]; 949 } 950 951 if (p.length === 2) { 952 p[2] = p[0]; 953 } 954 955 if (p.length === 3) { 956 p[3] = p[1]; 957 } 958 959 return p; 960 } 961 962 function distance(x1, y1, x2, y2) { 963 return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)); 964 } 965 966 /** 967 * Parse a string to see whether it is a valid timing function. 968 * If it is, return the coordinates as an array. 969 * Otherwise, return undefined. 970 * 971 * @param {string} value 972 * @return {Array} of coordinates, or undefined 973 */ 974 function parseTimingFunction(value) { 975 if (value in PREDEFINED) { 976 return PREDEFINED[value]; 977 } 978 979 const tokenStream = new InspectorCSSParserWrapper(value); 980 const getNextToken = () => { 981 while (true) { 982 const token = tokenStream.nextToken(); 983 if ( 984 !token || 985 (token.tokenType !== "WhiteSpace" && token.tokenType !== "Comment") 986 ) { 987 return token; 988 } 989 } 990 }; 991 992 let token = getNextToken(); 993 if (token.tokenType !== "Function" || token.value !== "cubic-bezier") { 994 return undefined; 995 } 996 997 const result = []; 998 for (let i = 0; i < 4; ++i) { 999 token = getNextToken(); 1000 if (!token || token.tokenType !== "Number") { 1001 return undefined; 1002 } 1003 result.push(token.number); 1004 1005 token = getNextToken(); 1006 if (!token || token.tokenType !== (i == 3 ? "CloseParenthesis" : "Comma")) { 1007 return undefined; 1008 } 1009 } 1010 1011 return result; 1012 } 1013 1014 exports.parseTimingFunction = parseTimingFunction; 1015 1016 /** 1017 * Removes a class from a node and adds it to another. 1018 * 1019 * @param {string} className the class to swap 1020 * @param {DOMNode} from the node to remove the class from 1021 * @param {DOMNode} to the node to add the class to 1022 */ 1023 function swapClassName(className, from, to) { 1024 if (from !== null) { 1025 from.classList.remove(className); 1026 } 1027 1028 if (to !== null) { 1029 to.classList.add(className); 1030 } 1031 } 1032 1033 /** 1034 * Compares two arrays of coordinates [i, j, k, l] 1035 * 1036 * @param {Array} c1 first coordinate array to compare 1037 * @param {Array} c2 second coordinate array to compare 1038 * @return {boolean} 1039 */ 1040 function coordsAreEqual(c1, c2) { 1041 return c1.reduce((prev, curr, index) => prev && curr === c2[index], true); 1042 }