tor-browser

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

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 }