1025 lines
28 KiB
JavaScript
1025 lines
28 KiB
JavaScript
/**
|
||
* Copyright (c) 2013 Lea Verou. All rights reserved.
|
||
*
|
||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||
* copy of this software and associated documentation files (the "Software"),
|
||
* to deal in the Software without restriction, including without limitation
|
||
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||
* and/or sell copies of the Software, and to permit persons to whom the
|
||
* Software is furnished to do so, subject to the following conditions:
|
||
*
|
||
* The above copyright notice and this permission notice shall be included in
|
||
* all copies or substantial portions of the Software.
|
||
*
|
||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||
* DEALINGS IN THE SOFTWARE.
|
||
*/
|
||
|
||
// Based on www.cubic-bezier.com by Lea Verou
|
||
// See https://github.com/LeaVerou/cubic-bezier
|
||
|
||
"use strict";
|
||
|
||
const EventEmitter = require("resource://devtools/shared/event-emitter.js");
|
||
const {
|
||
PREDEFINED,
|
||
PRESETS,
|
||
DEFAULT_PRESET_CATEGORY,
|
||
} = require("resource://devtools/client/shared/widgets/CubicBezierPresets.js");
|
||
const {
|
||
InspectorCSSParserWrapper,
|
||
} = require("resource://devtools/shared/css/lexer.js");
|
||
const XHTML_NS = "http://www.w3.org/1999/xhtml";
|
||
|
||
/**
|
||
* CubicBezier data structure helper
|
||
* Accepts an array of coordinates and exposes a few useful getters
|
||
* @param {Array} coordinates i.e. [.42, 0, .58, 1]
|
||
*/
|
||
function CubicBezier(coordinates) {
|
||
if (!coordinates) {
|
||
throw new Error("No offsets were defined");
|
||
}
|
||
|
||
this.coordinates = coordinates.map(n => +n);
|
||
|
||
for (let i = 4; i--; ) {
|
||
const xy = this.coordinates[i];
|
||
if (isNaN(xy) || (!(i % 2) && (xy < 0 || xy > 1))) {
|
||
throw new Error(`Wrong coordinate at ${i}(${xy})`);
|
||
}
|
||
}
|
||
|
||
this.coordinates.toString = function () {
|
||
return (
|
||
this.map(n => {
|
||
return (Math.round(n * 100) / 100 + "").replace(/^0\./, ".");
|
||
}) + ""
|
||
);
|
||
};
|
||
}
|
||
|
||
exports.CubicBezier = CubicBezier;
|
||
|
||
CubicBezier.prototype = {
|
||
get P1() {
|
||
return this.coordinates.slice(0, 2);
|
||
},
|
||
|
||
get P2() {
|
||
return this.coordinates.slice(2);
|
||
},
|
||
|
||
toString() {
|
||
// Check first if current coords are one of css predefined functions
|
||
const predefName = Object.keys(PREDEFINED).find(key =>
|
||
coordsAreEqual(PREDEFINED[key], this.coordinates)
|
||
);
|
||
|
||
return predefName || "cubic-bezier(" + this.coordinates + ")";
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Bezier curve canvas plotting class
|
||
* @param {DOMNode} canvas
|
||
* @param {CubicBezier} bezier
|
||
* @param {Array} padding Amount of horizontal,vertical padding around the graph
|
||
*/
|
||
function BezierCanvas(canvas, bezier, padding) {
|
||
this.canvas = canvas;
|
||
this.bezier = bezier;
|
||
this.padding = getPadding(padding);
|
||
|
||
// Convert to a cartesian coordinate system with axes from 0 to 1
|
||
this.ctx = this.canvas.getContext("2d");
|
||
const p = this.padding;
|
||
|
||
this.ctx.scale(
|
||
canvas.width * (1 - p[1] - p[3]),
|
||
-canvas.height * (1 - p[0] - p[2])
|
||
);
|
||
this.ctx.translate(p[3] / (1 - p[1] - p[3]), -1 - p[0] / (1 - p[0] - p[2]));
|
||
}
|
||
|
||
exports.BezierCanvas = BezierCanvas;
|
||
|
||
BezierCanvas.prototype = {
|
||
/**
|
||
* Get P1 and P2 current top/left offsets so they can be positioned
|
||
* @return {Array} Returns an array of 2 {top:String,left:String} objects
|
||
*/
|
||
get offsets() {
|
||
const p = this.padding,
|
||
w = this.canvas.width,
|
||
h = this.canvas.height;
|
||
|
||
return [
|
||
{
|
||
left:
|
||
w * (this.bezier.coordinates[0] * (1 - p[3] - p[1]) - p[3]) + "px",
|
||
top:
|
||
h * (1 - this.bezier.coordinates[1] * (1 - p[0] - p[2]) - p[0]) +
|
||
"px",
|
||
},
|
||
{
|
||
left:
|
||
w * (this.bezier.coordinates[2] * (1 - p[3] - p[1]) - p[3]) + "px",
|
||
top:
|
||
h * (1 - this.bezier.coordinates[3] * (1 - p[0] - p[2]) - p[0]) +
|
||
"px",
|
||
},
|
||
];
|
||
},
|
||
|
||
/**
|
||
* Convert an element's left/top offsets into coordinates
|
||
*/
|
||
offsetsToCoordinates(element) {
|
||
const w = this.canvas.width,
|
||
h = this.canvas.height;
|
||
|
||
// Convert padding percentage to actual padding
|
||
const p = this.padding.map((a, i) => a * (i % 2 ? w : h));
|
||
|
||
return [
|
||
(parseFloat(element.style.left) - p[3]) / (w + p[1] + p[3]),
|
||
(h - parseFloat(element.style.top) - p[2]) / (h - p[0] - p[2]),
|
||
];
|
||
},
|
||
|
||
/**
|
||
* Draw the cubic bezier curve for the current coordinates
|
||
*/
|
||
plot(settings = {}) {
|
||
const xy = this.bezier.coordinates;
|
||
|
||
const win = this.canvas.ownerGlobal;
|
||
const computedStyle = win.getComputedStyle(win.document.documentElement);
|
||
|
||
const defaultSettings = {
|
||
handleColor: computedStyle.getPropertyValue(
|
||
"--timing-function-control-point-background"
|
||
),
|
||
handleThickness: 0.008,
|
||
diagonalThickness: 0.01,
|
||
diagonalColor: computedStyle.getPropertyValue("--bezier-diagonal-color"),
|
||
bezierColor: computedStyle.getPropertyValue(
|
||
"--timing-function-line-color"
|
||
),
|
||
bezierThickness: 0.015,
|
||
drawHandles: true,
|
||
};
|
||
|
||
for (const setting in settings) {
|
||
defaultSettings[setting] = settings[setting];
|
||
}
|
||
|
||
// Clear the canvas –making sure to clear the
|
||
// whole area by resetting the transform first.
|
||
this.ctx.save();
|
||
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||
this.ctx.restore();
|
||
|
||
if (defaultSettings.drawHandles) {
|
||
// Draw control handles
|
||
this.ctx.beginPath();
|
||
this.ctx.lineWidth = defaultSettings.handleThickness;
|
||
this.ctx.strokeStyle = defaultSettings.handleColor;
|
||
|
||
this.ctx.moveTo(0, 0);
|
||
this.ctx.lineTo(xy[0], xy[1]);
|
||
this.ctx.moveTo(1, 1);
|
||
this.ctx.lineTo(xy[2], xy[3]);
|
||
|
||
this.ctx.stroke();
|
||
this.ctx.closePath();
|
||
|
||
// Draw diagonal between points
|
||
this.ctx.beginPath();
|
||
this.ctx.lineWidth = defaultSettings.diagonalThickness;
|
||
this.ctx.strokeStyle = defaultSettings.diagonalColor;
|
||
this.ctx.moveTo(0, 0);
|
||
this.ctx.lineTo(1, 1);
|
||
this.ctx.stroke();
|
||
this.ctx.closePath();
|
||
}
|
||
|
||
// Draw bezier curve
|
||
this.ctx.beginPath();
|
||
this.ctx.lineWidth = defaultSettings.bezierThickness;
|
||
this.ctx.strokeStyle = defaultSettings.bezierColor;
|
||
this.ctx.moveTo(0, 0);
|
||
this.ctx.bezierCurveTo(xy[0], xy[1], xy[2], xy[3], 1, 1);
|
||
this.ctx.stroke();
|
||
this.ctx.closePath();
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Cubic-bezier widget. Uses the BezierCanvas class to draw the curve and
|
||
* adds the control points and user interaction
|
||
* @param {DOMNode} parent The container where the graph should be created
|
||
* @param {Array} coordinates Coordinates of the curve to be drawn
|
||
*
|
||
* Emits "updated" events whenever the curve is changed. Along with the event is
|
||
* sent a CubicBezier object
|
||
*/
|
||
function CubicBezierWidget(
|
||
parent,
|
||
coordinates = PRESETS["ease-in"]["ease-in-sine"]
|
||
) {
|
||
EventEmitter.decorate(this);
|
||
|
||
this.parent = parent;
|
||
const { curve, p1, p2 } = this._initMarkup();
|
||
|
||
this.curveBoundingBox = curve.getBoundingClientRect();
|
||
this.curve = curve;
|
||
this.p1 = p1;
|
||
this.p2 = p2;
|
||
|
||
// Create and plot the bezier curve
|
||
this.bezierCanvas = new BezierCanvas(
|
||
this.curve,
|
||
new CubicBezier(coordinates),
|
||
[0.3, 0]
|
||
);
|
||
this.bezierCanvas.plot();
|
||
|
||
// Place the control points
|
||
const offsets = this.bezierCanvas.offsets;
|
||
this.p1.style.left = offsets[0].left;
|
||
this.p1.style.top = offsets[0].top;
|
||
this.p2.style.left = offsets[1].left;
|
||
this.p2.style.top = offsets[1].top;
|
||
|
||
this._onPointMouseDown = this._onPointMouseDown.bind(this);
|
||
this._onPointKeyDown = this._onPointKeyDown.bind(this);
|
||
this._onCurveClick = this._onCurveClick.bind(this);
|
||
this._onNewCoordinates = this._onNewCoordinates.bind(this);
|
||
this.onPrefersReducedMotionChange =
|
||
this.onPrefersReducedMotionChange.bind(this);
|
||
|
||
// Add preset preview menu
|
||
this.presets = new CubicBezierPresetWidget(parent);
|
||
|
||
// Add the timing function previewer
|
||
// if prefers-reduced-motion is not set
|
||
this.reducedMotion = parent.ownerGlobal.matchMedia(
|
||
"(prefers-reduced-motion)"
|
||
);
|
||
if (!this.reducedMotion.matches) {
|
||
this.timingPreview = new TimingFunctionPreviewWidget(parent);
|
||
}
|
||
|
||
// add event listener to change prefers-reduced-motion
|
||
// of the timing function preview during runtime
|
||
this.reducedMotion.addEventListener(
|
||
"change",
|
||
this.onPrefersReducedMotionChange
|
||
);
|
||
|
||
this._initEvents();
|
||
}
|
||
|
||
exports.CubicBezierWidget = CubicBezierWidget;
|
||
|
||
CubicBezierWidget.prototype = {
|
||
_initMarkup() {
|
||
const doc = this.parent.ownerDocument;
|
||
|
||
const wrap = doc.createElementNS(XHTML_NS, "div");
|
||
wrap.className = "display-wrap";
|
||
|
||
const plane = doc.createElementNS(XHTML_NS, "div");
|
||
plane.className = "coordinate-plane";
|
||
|
||
const p1 = doc.createElementNS(XHTML_NS, "button");
|
||
p1.className = "control-point";
|
||
plane.appendChild(p1);
|
||
|
||
const p2 = doc.createElementNS(XHTML_NS, "button");
|
||
p2.className = "control-point";
|
||
plane.appendChild(p2);
|
||
|
||
const curve = doc.createElementNS(XHTML_NS, "canvas");
|
||
curve.className = "curve";
|
||
const parentComputedStyle = this.parent.ownerGlobal.getComputedStyle(
|
||
this.parent
|
||
);
|
||
// We need to set the canvas dimension to the actual rendered dimension
|
||
// to avoid the canvas to scale. We can retrie the CSS variable values
|
||
// and striping their unit.
|
||
const dimensionRegex = /(?<size>\d+)px$/;
|
||
curve.setAttribute(
|
||
"width",
|
||
dimensionRegex.exec(
|
||
parentComputedStyle.getPropertyValue("--bezier-curve-width")
|
||
).groups.size
|
||
);
|
||
curve.setAttribute(
|
||
"height",
|
||
dimensionRegex.exec(
|
||
parentComputedStyle.getPropertyValue("--bezier-curve-height")
|
||
).groups.size
|
||
);
|
||
|
||
plane.appendChild(curve);
|
||
wrap.appendChild(plane);
|
||
|
||
this.parent.appendChild(wrap);
|
||
|
||
return {
|
||
p1,
|
||
p2,
|
||
curve,
|
||
};
|
||
},
|
||
|
||
onPrefersReducedMotionChange(event) {
|
||
// if prefers-reduced-motion is enabled destroy timing function preview
|
||
// else create it if it does not exist
|
||
if (event.matches) {
|
||
if (this.timingPreview) {
|
||
this.timingPreview.destroy();
|
||
}
|
||
this.timingPreview = undefined;
|
||
} else if (!this.timingPreview) {
|
||
this.timingPreview = new TimingFunctionPreviewWidget(this.parent);
|
||
}
|
||
},
|
||
|
||
_removeMarkup() {
|
||
this.parent.querySelector(".display-wrap").remove();
|
||
},
|
||
|
||
_initEvents() {
|
||
this.p1.addEventListener("mousedown", this._onPointMouseDown);
|
||
this.p2.addEventListener("mousedown", this._onPointMouseDown);
|
||
|
||
this.p1.addEventListener("keydown", this._onPointKeyDown);
|
||
this.p2.addEventListener("keydown", this._onPointKeyDown);
|
||
|
||
this.curve.addEventListener("click", this._onCurveClick);
|
||
|
||
this.presets.on("new-coordinates", this._onNewCoordinates);
|
||
},
|
||
|
||
_removeEvents() {
|
||
this.p1.removeEventListener("mousedown", this._onPointMouseDown);
|
||
this.p2.removeEventListener("mousedown", this._onPointMouseDown);
|
||
|
||
this.p1.removeEventListener("keydown", this._onPointKeyDown);
|
||
this.p2.removeEventListener("keydown", this._onPointKeyDown);
|
||
|
||
this.curve.removeEventListener("click", this._onCurveClick);
|
||
|
||
this.presets.off("new-coordinates", this._onNewCoordinates);
|
||
},
|
||
|
||
_onPointMouseDown(event) {
|
||
// Updating the boundingbox in case it has changed
|
||
this.curveBoundingBox = this.curve.getBoundingClientRect();
|
||
|
||
const point = event.target;
|
||
const doc = point.ownerDocument;
|
||
const self = this;
|
||
|
||
doc.onmousemove = function drag(e) {
|
||
let x = e.pageX;
|
||
const y = e.pageY;
|
||
const left = self.curveBoundingBox.left;
|
||
const top = self.curveBoundingBox.top;
|
||
|
||
if (x === 0 && y == 0) {
|
||
return;
|
||
}
|
||
|
||
// Constrain x
|
||
x = Math.min(Math.max(left, x), left + self.curveBoundingBox.width);
|
||
|
||
point.style.left = x - left + "px";
|
||
point.style.top = y - top + "px";
|
||
|
||
self._updateFromPoints();
|
||
};
|
||
|
||
doc.onmouseup = function () {
|
||
point.focus();
|
||
doc.onmousemove = doc.onmouseup = null;
|
||
};
|
||
},
|
||
|
||
_onPointKeyDown(event) {
|
||
const point = event.target;
|
||
const code = event.keyCode;
|
||
|
||
if (code >= 37 && code <= 40) {
|
||
event.preventDefault();
|
||
|
||
// Arrow keys pressed
|
||
const left = parseInt(point.style.left, 10);
|
||
const top = parseInt(point.style.top, 10);
|
||
const offset = 3 * (event.shiftKey ? 10 : 1);
|
||
|
||
switch (code) {
|
||
case 37:
|
||
point.style.left = left - offset + "px";
|
||
break;
|
||
case 38:
|
||
point.style.top = top - offset + "px";
|
||
break;
|
||
case 39:
|
||
point.style.left = left + offset + "px";
|
||
break;
|
||
case 40:
|
||
point.style.top = top + offset + "px";
|
||
break;
|
||
}
|
||
|
||
this._updateFromPoints();
|
||
}
|
||
},
|
||
|
||
_onCurveClick(event) {
|
||
this.curveBoundingBox = this.curve.getBoundingClientRect();
|
||
|
||
const left = this.curveBoundingBox.left;
|
||
const top = this.curveBoundingBox.top;
|
||
const x = event.pageX - left;
|
||
const y = event.pageY - top;
|
||
|
||
// Find which point is closer
|
||
const distP1 = distance(
|
||
x,
|
||
y,
|
||
parseInt(this.p1.style.left, 10),
|
||
parseInt(this.p1.style.top, 10)
|
||
);
|
||
const distP2 = distance(
|
||
x,
|
||
y,
|
||
parseInt(this.p2.style.left, 10),
|
||
parseInt(this.p2.style.top, 10)
|
||
);
|
||
|
||
const point = distP1 < distP2 ? this.p1 : this.p2;
|
||
point.style.left = x + "px";
|
||
point.style.top = y + "px";
|
||
|
||
this._updateFromPoints();
|
||
},
|
||
|
||
_onNewCoordinates(coordinates) {
|
||
this.coordinates = coordinates;
|
||
},
|
||
|
||
/**
|
||
* Get the current point coordinates and redraw the curve to match
|
||
*/
|
||
_updateFromPoints() {
|
||
// Get the new coordinates from the point's offsets
|
||
let coordinates = this.bezierCanvas.offsetsToCoordinates(this.p1);
|
||
coordinates = coordinates.concat(
|
||
this.bezierCanvas.offsetsToCoordinates(this.p2)
|
||
);
|
||
|
||
this.presets.refreshMenu(coordinates);
|
||
this._redraw(coordinates);
|
||
},
|
||
|
||
/**
|
||
* Redraw the curve
|
||
* @param {Array} coordinates The array of control point coordinates
|
||
*/
|
||
_redraw(coordinates) {
|
||
// Provide a new CubicBezier to the canvas and plot the curve
|
||
this.bezierCanvas.bezier = new CubicBezier(coordinates);
|
||
this.bezierCanvas.plot();
|
||
this.emit("updated", this.bezierCanvas.bezier);
|
||
|
||
if (this.timingPreview) {
|
||
this.timingPreview.preview(this.bezierCanvas.bezier.toString());
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Set new coordinates for the control points and redraw the curve
|
||
* @param {Array} coordinates
|
||
*/
|
||
set coordinates(coordinates) {
|
||
this._redraw(coordinates);
|
||
|
||
// Move the points
|
||
const offsets = this.bezierCanvas.offsets;
|
||
this.p1.style.left = offsets[0].left;
|
||
this.p1.style.top = offsets[0].top;
|
||
this.p2.style.left = offsets[1].left;
|
||
this.p2.style.top = offsets[1].top;
|
||
},
|
||
|
||
/**
|
||
* Set new coordinates for the control point and redraw the curve
|
||
* @param {String} value A string value. E.g. "linear",
|
||
* "cubic-bezier(0,0,1,1)"
|
||
*/
|
||
set cssCubicBezierValue(value) {
|
||
if (!value) {
|
||
return;
|
||
}
|
||
|
||
value = value.trim();
|
||
|
||
// Try with one of the predefined values
|
||
const coordinates = parseTimingFunction(value);
|
||
|
||
this.presets.refreshMenu(coordinates);
|
||
this.coordinates = coordinates;
|
||
},
|
||
|
||
destroy() {
|
||
this._removeEvents();
|
||
this._removeMarkup();
|
||
|
||
// remove prefers-reduced-motion event listener
|
||
this.reducedMotion.removeEventListener(
|
||
"change",
|
||
this.onPrefersReducedMotionChange
|
||
);
|
||
this.reducedMotion = null;
|
||
|
||
if (this.timingPreview) {
|
||
this.timingPreview.destroy();
|
||
this.timingPreview = null;
|
||
}
|
||
this.presets.destroy();
|
||
|
||
this.curve = this.p1 = this.p2 = null;
|
||
},
|
||
};
|
||
|
||
/**
|
||
* CubicBezierPreset widget.
|
||
* Builds a menu of presets from CubicBezierPresets
|
||
* @param {DOMNode} parent The container where the preset panel should be
|
||
* created
|
||
*
|
||
* Emits "new-coordinate" event along with the coordinates
|
||
* whenever a preset is selected.
|
||
*/
|
||
function CubicBezierPresetWidget(parent) {
|
||
this.parent = parent;
|
||
|
||
const { presetPane, presets, categories } = this._initMarkup();
|
||
this.presetPane = presetPane;
|
||
this.presets = presets;
|
||
this.categories = categories;
|
||
|
||
this._activeCategory = null;
|
||
this._activePresetList = null;
|
||
this._activePreset = null;
|
||
|
||
this._onCategoryClick = this._onCategoryClick.bind(this);
|
||
this._onPresetClick = this._onPresetClick.bind(this);
|
||
|
||
EventEmitter.decorate(this);
|
||
this._initEvents();
|
||
}
|
||
|
||
exports.CubicBezierPresetWidget = CubicBezierPresetWidget;
|
||
|
||
CubicBezierPresetWidget.prototype = {
|
||
/*
|
||
* Constructs a list of all preset categories and a list
|
||
* of presets for each category.
|
||
*
|
||
* High level markup:
|
||
* div .preset-pane
|
||
* div .preset-categories
|
||
* div .category
|
||
* div .category
|
||
* ...
|
||
* div .preset-container
|
||
* div .presetList
|
||
* div .preset
|
||
* ...
|
||
* div .presetList
|
||
* div .preset
|
||
* ...
|
||
*/
|
||
_initMarkup() {
|
||
const doc = this.parent.ownerDocument;
|
||
|
||
const presetPane = doc.createElementNS(XHTML_NS, "div");
|
||
presetPane.className = "preset-pane";
|
||
|
||
const categoryList = doc.createElementNS(XHTML_NS, "div");
|
||
categoryList.id = "preset-categories";
|
||
|
||
const presetContainer = doc.createElementNS(XHTML_NS, "div");
|
||
presetContainer.id = "preset-container";
|
||
|
||
Object.keys(PRESETS).forEach(categoryLabel => {
|
||
const category = this._createCategory(categoryLabel);
|
||
categoryList.appendChild(category);
|
||
|
||
const presetList = this._createPresetList(categoryLabel);
|
||
presetContainer.appendChild(presetList);
|
||
});
|
||
|
||
presetPane.appendChild(categoryList);
|
||
presetPane.appendChild(presetContainer);
|
||
|
||
this.parent.appendChild(presetPane);
|
||
|
||
const allCategories = presetPane.querySelectorAll(".category");
|
||
const allPresets = presetPane.querySelectorAll(".preset");
|
||
|
||
return {
|
||
presetPane,
|
||
presets: allPresets,
|
||
categories: allCategories,
|
||
};
|
||
},
|
||
|
||
_createCategory(categoryLabel) {
|
||
const doc = this.parent.ownerDocument;
|
||
|
||
const category = doc.createElementNS(XHTML_NS, "div");
|
||
category.id = categoryLabel;
|
||
category.classList.add("category");
|
||
|
||
const categoryDisplayLabel = this._normalizeCategoryLabel(categoryLabel);
|
||
category.textContent = categoryDisplayLabel;
|
||
category.setAttribute("title", categoryDisplayLabel);
|
||
|
||
return category;
|
||
},
|
||
|
||
_normalizeCategoryLabel(categoryLabel) {
|
||
return categoryLabel.replace("/-/g", " ");
|
||
},
|
||
|
||
_createPresetList(categoryLabel) {
|
||
const doc = this.parent.ownerDocument;
|
||
|
||
const presetList = doc.createElementNS(XHTML_NS, "div");
|
||
presetList.id = "preset-category-" + categoryLabel;
|
||
presetList.classList.add("preset-list");
|
||
|
||
Object.keys(PRESETS[categoryLabel]).forEach(presetLabel => {
|
||
const preset = this._createPreset(categoryLabel, presetLabel);
|
||
presetList.appendChild(preset);
|
||
});
|
||
|
||
return presetList;
|
||
},
|
||
|
||
_createPreset(categoryLabel, presetLabel) {
|
||
const doc = this.parent.ownerDocument;
|
||
|
||
const preset = doc.createElementNS(XHTML_NS, "div");
|
||
preset.classList.add("preset");
|
||
preset.id = presetLabel;
|
||
preset.coordinates = PRESETS[categoryLabel][presetLabel];
|
||
// Create preset preview
|
||
const curve = doc.createElementNS(XHTML_NS, "canvas");
|
||
const bezier = new CubicBezier(preset.coordinates);
|
||
curve.setAttribute("height", 50);
|
||
curve.setAttribute("width", 50);
|
||
preset.bezierCanvas = new BezierCanvas(curve, bezier, [0.15, 0]);
|
||
preset.bezierCanvas.plot({
|
||
drawHandles: false,
|
||
bezierThickness: 0.025,
|
||
});
|
||
preset.appendChild(curve);
|
||
|
||
// Create preset label
|
||
const presetLabelElem = doc.createElementNS(XHTML_NS, "p");
|
||
const presetDisplayLabel = this._normalizePresetLabel(
|
||
categoryLabel,
|
||
presetLabel
|
||
);
|
||
presetLabelElem.textContent = presetDisplayLabel;
|
||
preset.appendChild(presetLabelElem);
|
||
preset.setAttribute("title", presetDisplayLabel);
|
||
|
||
return preset;
|
||
},
|
||
|
||
_normalizePresetLabel(categoryLabel, presetLabel) {
|
||
return presetLabel.replace(categoryLabel + "-", "").replace("/-/g", " ");
|
||
},
|
||
|
||
_initEvents() {
|
||
for (const category of this.categories) {
|
||
category.addEventListener("click", this._onCategoryClick);
|
||
}
|
||
|
||
for (const preset of this.presets) {
|
||
preset.addEventListener("click", this._onPresetClick);
|
||
}
|
||
},
|
||
|
||
_removeEvents() {
|
||
for (const category of this.categories) {
|
||
category.removeEventListener("click", this._onCategoryClick);
|
||
}
|
||
|
||
for (const preset of this.presets) {
|
||
preset.removeEventListener("click", this._onPresetClick);
|
||
}
|
||
},
|
||
|
||
_onPresetClick(event) {
|
||
this.emit("new-coordinates", event.currentTarget.coordinates);
|
||
this.activePreset = event.currentTarget;
|
||
},
|
||
|
||
_onCategoryClick(event) {
|
||
this.activeCategory = event.target;
|
||
},
|
||
|
||
_setActivePresetList(presetListId) {
|
||
const presetList = this.presetPane.querySelector("#" + presetListId);
|
||
swapClassName("active-preset-list", this._activePresetList, presetList);
|
||
this._activePresetList = presetList;
|
||
},
|
||
|
||
set activeCategory(category) {
|
||
swapClassName("active-category", this._activeCategory, category);
|
||
this._activeCategory = category;
|
||
this._setActivePresetList("preset-category-" + category.id);
|
||
},
|
||
|
||
get activeCategory() {
|
||
return this._activeCategory;
|
||
},
|
||
|
||
set activePreset(preset) {
|
||
swapClassName("active-preset", this._activePreset, preset);
|
||
this._activePreset = preset;
|
||
},
|
||
|
||
get activePreset() {
|
||
return this._activePreset;
|
||
},
|
||
|
||
/**
|
||
* Called by CubicBezierWidget onload and when
|
||
* the curve is modified via the canvas.
|
||
* Attempts to match the new user setting with an
|
||
* existing preset.
|
||
* @param {Array} coordinates new coords [i, j, k, l]
|
||
*/
|
||
refreshMenu(coordinates) {
|
||
// If we cannot find a matching preset, keep
|
||
// menu on last known preset category.
|
||
let category = this._activeCategory;
|
||
|
||
// If we cannot find a matching preset
|
||
// deselect any selected preset.
|
||
let preset = null;
|
||
|
||
// If a category has never been viewed before
|
||
// show the default category.
|
||
if (!category) {
|
||
category = this.parent.querySelector("#" + DEFAULT_PRESET_CATEGORY);
|
||
}
|
||
|
||
// If the new coordinates do match a preset,
|
||
// set its category and preset button as active.
|
||
Object.keys(PRESETS).forEach(categoryLabel => {
|
||
Object.keys(PRESETS[categoryLabel]).forEach(presetLabel => {
|
||
if (coordsAreEqual(PRESETS[categoryLabel][presetLabel], coordinates)) {
|
||
category = this.parent.querySelector("#" + categoryLabel);
|
||
preset = this.parent.querySelector("#" + presetLabel);
|
||
}
|
||
});
|
||
});
|
||
|
||
this.activeCategory = category;
|
||
this.activePreset = preset;
|
||
},
|
||
|
||
destroy() {
|
||
this._removeEvents();
|
||
this.parent.querySelector(".preset-pane").remove();
|
||
},
|
||
};
|
||
|
||
/**
|
||
* The TimingFunctionPreviewWidget animates a dot on a scale with a given
|
||
* timing-function
|
||
* @param {DOMNode} parent The container where this widget should go
|
||
*/
|
||
function TimingFunctionPreviewWidget(parent) {
|
||
this.previousValue = null;
|
||
|
||
this.parent = parent;
|
||
this._initMarkup();
|
||
}
|
||
|
||
TimingFunctionPreviewWidget.prototype = {
|
||
PREVIEW_DURATION: 1000,
|
||
|
||
_initMarkup() {
|
||
const doc = this.parent.ownerDocument;
|
||
|
||
const container = doc.createElementNS(XHTML_NS, "div");
|
||
container.className = "timing-function-preview";
|
||
|
||
this.dot = doc.createElementNS(XHTML_NS, "div");
|
||
this.dot.className = "dot";
|
||
container.appendChild(this.dot);
|
||
|
||
const scale = doc.createElementNS(XHTML_NS, "div");
|
||
scale.className = "scale";
|
||
container.appendChild(scale);
|
||
|
||
this.parent.appendChild(container);
|
||
},
|
||
|
||
destroy() {
|
||
this.dot.getAnimations().forEach(anim => anim.cancel());
|
||
this.parent.querySelector(".timing-function-preview").remove();
|
||
this.parent = this.dot = null;
|
||
},
|
||
|
||
/**
|
||
* Preview a new timing function. The current preview will only be stopped if
|
||
* the supplied function value is different from the previous one. If the
|
||
* supplied function is invalid, the preview will stop.
|
||
* @param {String} value
|
||
*/
|
||
preview(value) {
|
||
// Don't restart the preview animation if the value is the same
|
||
if (value === this.previousValue) {
|
||
return;
|
||
}
|
||
|
||
if (parseTimingFunction(value)) {
|
||
this.restartAnimation(value);
|
||
}
|
||
|
||
this.previousValue = value;
|
||
},
|
||
|
||
/**
|
||
* Re-start the preview animation from the beginning.
|
||
* @param {String} timingFunction The value for the timing-function.
|
||
*/
|
||
restartAnimation(timingFunction) {
|
||
// Cancel the previous animation if there was any.
|
||
this.dot.getAnimations().forEach(anim => anim.cancel());
|
||
|
||
// And start the new one.
|
||
// The animation consists of a few keyframes that move the dot to the right of the
|
||
// container, and then move it back to the left.
|
||
// It also contains some pause where the dot is greyed-out, before it moves to
|
||
// the right, and once again, before it comes back to the left.
|
||
// The timing function passed to this function is applied to the keyframes that
|
||
// actually move the dot. This way it can be previewed in both direction, instead of
|
||
// being spread over the whole animation.
|
||
const translateStart = "calc(var(--bezier-curve-width) / -2)";
|
||
const translateEnd = "calc(var(--bezier-curve-width) / 2)";
|
||
const grayscaleFilter = "grayscale(100%)";
|
||
|
||
this.dot.animate(
|
||
[
|
||
{ translate: translateStart, filter: grayscaleFilter, offset: 0 },
|
||
{
|
||
translate: translateStart,
|
||
filter: grayscaleFilter,
|
||
offset: 0.19,
|
||
},
|
||
{
|
||
translate: translateStart,
|
||
filter: "none",
|
||
offset: 0.2,
|
||
easing: timingFunction,
|
||
},
|
||
{ translate: translateEnd, filter: "none", offset: 0.5 },
|
||
{ translate: translateEnd, filter: grayscaleFilter, offset: 0.51 },
|
||
{ translate: translateEnd, filter: grayscaleFilter, offset: 0.7 },
|
||
{
|
||
translate: translateEnd,
|
||
filter: "none",
|
||
offset: 0.71,
|
||
easing: timingFunction,
|
||
},
|
||
{ translate: translateStart, filter: "none", offset: 1 },
|
||
],
|
||
{
|
||
duration: this.PREVIEW_DURATION * 2,
|
||
iterations: Infinity,
|
||
}
|
||
);
|
||
},
|
||
};
|
||
|
||
// Helpers
|
||
|
||
function getPadding(padding) {
|
||
const p = typeof padding === "number" ? [padding] : padding;
|
||
|
||
if (p.length === 1) {
|
||
p[1] = p[0];
|
||
}
|
||
|
||
if (p.length === 2) {
|
||
p[2] = p[0];
|
||
}
|
||
|
||
if (p.length === 3) {
|
||
p[3] = p[1];
|
||
}
|
||
|
||
return p;
|
||
}
|
||
|
||
function distance(x1, y1, x2, y2) {
|
||
return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
|
||
}
|
||
|
||
/**
|
||
* Parse a string to see whether it is a valid timing function.
|
||
* If it is, return the coordinates as an array.
|
||
* Otherwise, return undefined.
|
||
* @param {String} value
|
||
* @return {Array} of coordinates, or undefined
|
||
*/
|
||
function parseTimingFunction(value) {
|
||
if (value in PREDEFINED) {
|
||
return PREDEFINED[value];
|
||
}
|
||
|
||
const tokenStream = new InspectorCSSParserWrapper(value);
|
||
const getNextToken = () => {
|
||
while (true) {
|
||
const token = tokenStream.nextToken();
|
||
if (
|
||
!token ||
|
||
(token.tokenType !== "WhiteSpace" && token.tokenType !== "Comment")
|
||
) {
|
||
return token;
|
||
}
|
||
}
|
||
};
|
||
|
||
let token = getNextToken();
|
||
if (token.tokenType !== "Function" || token.value !== "cubic-bezier") {
|
||
return undefined;
|
||
}
|
||
|
||
const result = [];
|
||
for (let i = 0; i < 4; ++i) {
|
||
token = getNextToken();
|
||
if (!token || token.tokenType !== "Number") {
|
||
return undefined;
|
||
}
|
||
result.push(token.number);
|
||
|
||
token = getNextToken();
|
||
if (!token || token.tokenType !== (i == 3 ? "CloseParenthesis" : "Comma")) {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
exports.parseTimingFunction = parseTimingFunction;
|
||
|
||
/**
|
||
* Removes a class from a node and adds it to another.
|
||
* @param {String} className the class to swap
|
||
* @param {DOMNode} from the node to remove the class from
|
||
* @param {DOMNode} to the node to add the class to
|
||
*/
|
||
function swapClassName(className, from, to) {
|
||
if (from !== null) {
|
||
from.classList.remove(className);
|
||
}
|
||
|
||
if (to !== null) {
|
||
to.classList.add(className);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Compares two arrays of coordinates [i, j, k, l]
|
||
* @param {Array} c1 first coordinate array to compare
|
||
* @param {Array} c2 second coordinate array to compare
|
||
* @return {Boolean}
|
||
*/
|
||
function coordsAreEqual(c1, c2) {
|
||
return c1.reduce((prev, curr, index) => prev && curr === c2[index], true);
|
||
}
|