mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-01 06:35:42 +00:00
557 lines
15 KiB
JavaScript
557 lines
15 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("devtools/toolkit/event-emitter");
|
|
const {setTimeout, clearTimeout} = require("sdk/timers");
|
|
|
|
const PREDEFINED = exports.PREDEFINED = {
|
|
"ease": [.25, .1, .25, 1],
|
|
"linear": [0, 0, 1, 1],
|
|
"ease-in": [.42, 0, 1, 1],
|
|
"ease-out": [0, 0, .58, 1],
|
|
"ease-in-out": [.42, 0, .58, 1]
|
|
};
|
|
|
|
/**
|
|
* 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 "No offsets were defined";
|
|
}
|
|
|
|
this.coordinates = coordinates.map(n => +n);
|
|
|
|
for (let i = 4; i--;) {
|
|
let xy = this.coordinates[i];
|
|
if (isNaN(xy) || (!(i%2) && (xy < 0 || xy > 1))) {
|
|
throw "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: function() {
|
|
return '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');
|
|
let 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() {
|
|
let 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: function(element) {
|
|
let p = this.padding, w = this.canvas.width, h = this.canvas.height;
|
|
|
|
// Convert padding percentage to actual padding
|
|
p = p.map(function(a, i) { return a * (i % 2? w : h)});
|
|
|
|
return [
|
|
(parseInt(element.style.left) - p[3]) / (w + p[1] + p[3]),
|
|
(h - parseInt(element.style.top) - p[2]) / (h - p[0] - p[2])
|
|
];
|
|
},
|
|
|
|
/**
|
|
* Draw the cubic bezier curve for the current coordinates
|
|
*/
|
|
plot: function(settings={}) {
|
|
let xy = this.bezier.coordinates;
|
|
|
|
let defaultSettings = {
|
|
handleColor: '#666',
|
|
handleThickness: .008,
|
|
bezierColor: '#4C9ED9',
|
|
bezierThickness: .015
|
|
};
|
|
|
|
for (let setting in settings) {
|
|
defaultSettings[setting] = settings[setting];
|
|
}
|
|
|
|
this.ctx.clearRect(-.5,-.5, 2, 2);
|
|
|
|
// Draw control handles
|
|
this.ctx.beginPath();
|
|
this.ctx.fillStyle = defaultSettings.handleColor;
|
|
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();
|
|
|
|
function circle(ctx, cx, cy, r) {
|
|
return ctx.beginPath();
|
|
ctx.arc(cx, cy, r, 0, 2*Math.PI, !1);
|
|
ctx.closePath();
|
|
}
|
|
|
|
circle(this.ctx, xy[0], xy[1], 1.5 * defaultSettings.handleThickness);
|
|
this.ctx.fill();
|
|
circle(this.ctx, xy[2], xy[3], 1.5 * defaultSettings.handleThickness);
|
|
this.ctx.fill();
|
|
|
|
// 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=PREDEFINED["ease-in-out"]) {
|
|
this.parent = parent;
|
|
let {curve, p1, p2} = this._initMarkup();
|
|
|
|
this.curve = curve;
|
|
this.curveBoundingBox = curve.getBoundingClientRect();
|
|
this.p1 = p1;
|
|
this.p2 = p2;
|
|
|
|
// Create and plot the bezier curve
|
|
this.bezierCanvas = new BezierCanvas(this.curve,
|
|
new CubicBezier(coordinates), [.25, 0]);
|
|
this.bezierCanvas.plot();
|
|
|
|
// Place the control points
|
|
let 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._initEvents();
|
|
|
|
// Add the timing function previewer
|
|
this.timingPreview = new TimingFunctionPreviewWidget(parent);
|
|
|
|
EventEmitter.decorate(this);
|
|
}
|
|
|
|
exports.CubicBezierWidget = CubicBezierWidget;
|
|
|
|
CubicBezierWidget.prototype = {
|
|
_initMarkup: function() {
|
|
let doc = this.parent.ownerDocument;
|
|
|
|
let plane = doc.createElement("div");
|
|
plane.className = "coordinate-plane";
|
|
|
|
let p1 = doc.createElement("button");
|
|
p1.className = "control-point";
|
|
p1.id = "P1";
|
|
plane.appendChild(p1);
|
|
|
|
let p2 = doc.createElement("button");
|
|
p2.className = "control-point";
|
|
p2.id = "P2";
|
|
plane.appendChild(p2);
|
|
|
|
let curve = doc.createElement("canvas");
|
|
curve.setAttribute("height", "400");
|
|
curve.setAttribute("width", "200");
|
|
curve.id = "curve";
|
|
plane.appendChild(curve);
|
|
|
|
this.parent.appendChild(plane);
|
|
|
|
return {
|
|
p1: p1,
|
|
p2: p2,
|
|
curve: curve
|
|
}
|
|
},
|
|
|
|
_removeMarkup: function() {
|
|
this.parent.ownerDocument.querySelector(".coordinate-plane").remove();
|
|
},
|
|
|
|
_initEvents: function() {
|
|
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);
|
|
},
|
|
|
|
_removeEvents: function() {
|
|
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);
|
|
},
|
|
|
|
_onPointMouseDown: function(event) {
|
|
// Updating the boundingbox in case it has changed
|
|
this.curveBoundingBox = this.curve.getBoundingClientRect();
|
|
|
|
let point = event.target;
|
|
let doc = point.ownerDocument;
|
|
let self = this;
|
|
|
|
doc.onmousemove = function drag(e) {
|
|
let x = e.pageX;
|
|
let y = e.pageY;
|
|
let left = self.curveBoundingBox.left;
|
|
let 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: function(event) {
|
|
let point = event.target;
|
|
let code = event.keyCode;
|
|
|
|
if (code >= 37 && code <= 40) {
|
|
event.preventDefault();
|
|
|
|
// Arrow keys pressed
|
|
let left = parseInt(point.style.left);
|
|
let top = parseInt(point.style.top);
|
|
let 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: function(event) {
|
|
let left = this.curveBoundingBox.left;
|
|
let top = this.curveBoundingBox.top;
|
|
let x = event.pageX - left;
|
|
let y = event.pageY - top;
|
|
|
|
// Find which point is closer
|
|
let distP1 = distance(x, y,
|
|
parseInt(this.p1.style.left), parseInt(this.p1.style.top));
|
|
let distP2 = distance(x, y,
|
|
parseInt(this.p2.style.left), parseInt(this.p2.style.top));
|
|
|
|
let point = distP1 < distP2 ? this.p1 : this.p2;
|
|
point.style.left = x + "px";
|
|
point.style.top = y + "px";
|
|
|
|
this._updateFromPoints();
|
|
},
|
|
|
|
/**
|
|
* Get the current point coordinates and redraw the curve to match
|
|
*/
|
|
_updateFromPoints: function() {
|
|
// 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._redraw(coordinates);
|
|
},
|
|
|
|
/**
|
|
* Redraw the curve
|
|
* @param {Array} coordinates The array of control point coordinates
|
|
*/
|
|
_redraw: function(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);
|
|
|
|
this.timingPreview.preview(this.bezierCanvas.bezier + "");
|
|
},
|
|
|
|
/**
|
|
* Set new coordinates for the control points and redraw the curve
|
|
* @param {Array} coordinates
|
|
*/
|
|
set coordinates(coordinates) {
|
|
this._redraw(coordinates)
|
|
|
|
// Move the points
|
|
let 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
|
|
let coordinates = PREDEFINED[value];
|
|
|
|
// Otherwise parse the coordinates from the cubic-bezier function
|
|
if (!coordinates && value.startsWith("cubic-bezier")) {
|
|
coordinates = value.replace(/cubic-bezier|\(|\)/g, "").split(",").map(parseFloat);
|
|
}
|
|
|
|
this.coordinates = coordinates;
|
|
},
|
|
|
|
destroy: function() {
|
|
this._removeEvents();
|
|
this._removeMarkup();
|
|
|
|
this.timingPreview.destroy();
|
|
|
|
this.curve = this.p1 = this.p2 = null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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.autoRestartAnimation = null;
|
|
|
|
this.parent = parent;
|
|
this._initMarkup();
|
|
}
|
|
|
|
TimingFunctionPreviewWidget.prototype = {
|
|
PREVIEW_DURATION: 1000,
|
|
|
|
_initMarkup: function() {
|
|
let doc = this.parent.ownerDocument;
|
|
|
|
let container = doc.createElement("div");
|
|
container.className = "timing-function-preview";
|
|
|
|
this.dot = doc.createElement("div");
|
|
this.dot.className = "dot";
|
|
container.appendChild(this.dot);
|
|
|
|
let scale = doc.createElement("div");
|
|
scale.className = "scale";
|
|
container.appendChild(scale);
|
|
|
|
this.parent.appendChild(container);
|
|
},
|
|
|
|
destroy: function() {
|
|
clearTimeout(this.autoRestartAnimation);
|
|
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: function(value) {
|
|
// Don't restart the preview animation if the value is the same
|
|
if (value === this.previousValue) {
|
|
return false;
|
|
}
|
|
|
|
clearTimeout(this.autoRestartAnimation);
|
|
|
|
if (isValidTimingFunction(value)) {
|
|
this.dot.style.animationTimingFunction = value;
|
|
this.restartAnimation();
|
|
}
|
|
|
|
this.previousValue = value;
|
|
},
|
|
|
|
/**
|
|
* Re-start the preview animation from the beginning
|
|
*/
|
|
restartAnimation: function() {
|
|
// Reset the animation duration in case it was changed
|
|
this.dot.style.animationDuration = (this.PREVIEW_DURATION * 2) + "ms";
|
|
|
|
// Just toggling the class won't do it unless there's a sync reflow
|
|
this.dot.classList.remove("animate");
|
|
let w = this.dot.offsetWidth;
|
|
this.dot.classList.add("animate");
|
|
|
|
// Restart it again after a while
|
|
this.autoRestartAnimation = setTimeout(this.restartAnimation.bind(this),
|
|
this.PREVIEW_DURATION * 2);
|
|
}
|
|
};
|
|
|
|
// Helpers
|
|
|
|
function getPadding(padding) {
|
|
let 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));
|
|
}
|
|
|
|
/**
|
|
* Checks whether a string is a valid timing-function value
|
|
* @param {String} value
|
|
* @return {Boolean}
|
|
*/
|
|
function isValidTimingFunction(value) {
|
|
// Either it's a predefined value
|
|
if (value in PREDEFINED) {
|
|
return true;
|
|
}
|
|
|
|
// Or it has to match a cubic-bezier expression
|
|
if (value.match(/^cubic-bezier\(([0-9.\- ]+,){3}[0-9.\- ]+\)/)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|