src/extensions/default/InlineTimingFunctionEditor/BezierCurveEditor.js
/*
* Copyright (c) 2013 - present Adobe Systems Incorporated. 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.
*
*/
define(function (require, exports, module) {
"use strict";
var KeyEvent = brackets.getModule("utils/KeyEvent"),
Strings = brackets.getModule("strings"),
Mustache = brackets.getModule("thirdparty/mustache/mustache");
var TimingFunctionUtils = require("TimingFunctionUtils");
/** Mustache template that forms the bare DOM structure of the UI */
var BezierCurveEditorTemplate = require("text!BezierCurveEditorTemplate.html");
/** @const @type {number} */
var HEIGHT_ABOVE = 75, // extra height above main grid
HEIGHT_BELOW = 75, // extra height below main grid
HEIGHT_MAIN = 150, // height of main grid
WIDTH_MAIN = 150; // width of main grid
var animationRequest = null;
/**
* CubicBezier object constructor
*
* @param {string|Array.number[4]} coordinates Four parameters passes to cubic-bezier()
* either in string or array format.
*/
function CubicBezier(coordinates) {
if (typeof coordinates === "string") {
this.coordinates = coordinates.split(",");
} else {
this.coordinates = coordinates;
}
if (!this.coordinates) {
throw "No offsets were defined";
}
this.coordinates = this.coordinates.map(function (n) { return +n; });
var i;
for (i = 3; i >= 0; i--) {
var xy = this.coordinates[i];
if (isNaN(xy) || (((i % 2) === 0) && (xy < 0 || xy > 1))) {
throw "Wrong coordinate at " + i + "(" + xy + ")";
}
}
}
/**
* BezierCanvas object constructor
*
* @param {Element} canvas Inline editor <canvas> element
* @param {CubicBezier} bezier Associated CubicBezier object
* @param {number|Array.number} padding Element padding
*/
function BezierCanvas(canvas, bezier, padding) {
this.canvas = canvas;
this.bezier = bezier;
this.padding = this.getPadding(padding);
// Convert to a cartesian coordinate system with axes from 0 to 1
var ctx = this.canvas.getContext("2d"),
p = this.padding;
ctx.scale(canvas.width * (1 - p[1] - p[3]), -canvas.height * 0.5 * (1 - p[0] - p[2]));
ctx.translate(p[3] / (1 - p[1] - p[3]), (-1 - p[0] / (1 - p[0] - p[2])) - 0.5);
}
BezierCanvas.prototype = {
/**
* Calculates CSS offsets for <canvas> element
*
* @return {left:string, top:string}
*/
getOffsets: function () {
var p = this.padding,
w = this.canvas.width,
h = this.canvas.height * 0.5;
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"
}];
},
/**
* Round off number to hundreths place, convert to string, and strip leading zero
*
* @param {number} v Value
* @return {string}
*/
prettify: function (v) {
return (Math.round(v * 100) / 100).toString().replace(/^0\./, ".");
},
/**
* Get CSS left, top offsets for endpoint handle
*
* @param {Element} element Endpoint handle <button> element
* @return {Array.string[2]}
*/
offsetsToCoordinates: function (element) {
var p = this.padding,
w = this.canvas.width,
h = this.canvas.height * 0.5;
// Convert padding percentage to actual padding
p = p.map(function (a, i) {
return a * ((i % 2) ? w : h);
});
return [
this.prettify((parseInt($(element).css("left"), 10) - p[3]) / (w + p[1] + p[3])),
this.prettify((h - parseInt($(element).css("top"), 10) - p[2]) / (h - p[0] - p[2]))
];
},
/**
* Paint canvas
*
* @param {Object} settings Paint settings
*/
plot: function (settings) {
var xy = this.bezier.coordinates,
ctx = this.canvas.getContext("2d"),
setting;
var defaultSettings = {
handleTimingFunction: "#2893ef",
handleThickness: 0.008,
vBorderThickness: 0.02,
hBorderThickness: 0.01,
bezierTimingFunction: "#2893ef",
bezierThickness: 0.03
};
settings = settings || {};
for (setting in defaultSettings) {
if (defaultSettings.hasOwnProperty(setting)) {
if (!settings.hasOwnProperty(setting)) {
settings[setting] = defaultSettings[setting];
}
}
}
ctx.clearRect(-0.5, -0.5, 2, 2);
// Draw control handles
ctx.beginPath();
ctx.fillStyle = settings.handleTimingFunction;
ctx.lineWidth = settings.handleThickness;
ctx.strokeStyle = settings.handleTimingFunction;
ctx.moveTo(0, 0);
ctx.lineTo(xy[0], xy[1]);
ctx.moveTo(1, 1);
ctx.lineTo(xy[2], xy[3]);
ctx.stroke();
ctx.closePath();
ctx.beginPath();
ctx.arc(xy[0], xy[1], 1.5 * settings.handleThickness, 0, 2 * Math.PI, false);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.arc(xy[2], xy[3], 1.5 * settings.handleThickness, 0, 2 * Math.PI, false);
ctx.closePath();
ctx.fill();
// Draw bezier curve
ctx.beginPath();
ctx.lineWidth = settings.bezierThickness;
ctx.strokeStyle = settings.bezierColor;
ctx.moveTo(0, 0);
ctx.bezierCurveTo(xy[0], xy[1], xy[2], xy[3], 1, 1);
ctx.stroke();
ctx.closePath();
},
/**
* Convert CSS padding shorthand to longhand
*
* @param {number|Array.number} padding Element padding
* @return {Array.number}
*/
getPadding: function (padding) {
var 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;
}
};
// Event handlers
/**
* Handle click in <canvas> element
*
* @param {Event} e Mouse click event
*/
function _curveClick(e) {
var self = e.target,
bezierEditor = self.bezierEditor;
var curveBoundingBox = bezierEditor._getCurveBoundingBox(),
left = curveBoundingBox.left,
top = curveBoundingBox.top,
x = e.pageX - left,
y = e.pageY - top - HEIGHT_ABOVE,
$P1 = $(bezierEditor.P1),
$P2 = $(bezierEditor.P2);
// Helper function to calculate distance between 2-D points
function distance(x1, y1, x2, y2) {
return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
}
// Find which point is closer
var distP1 = distance(x, y, parseInt($P1.css("left"), 10), parseInt($P1.css("top"), 10)),
distP2 = distance(x, y, parseInt($P2.css("left"), 10), parseInt($P2.css("top"), 10)),
$P = (distP1 < distP2) ? $P1 : $P2;
$P.css({
left: x + "px",
top: y + "px"
});
$P.get(0).focus();
// update coords
bezierEditor._cubicBezierCoords = bezierEditor.bezierCanvas
.offsetsToCoordinates(bezierEditor.P1)
.concat(bezierEditor.bezierCanvas.offsetsToCoordinates(bezierEditor.P2));
bezierEditor._commitTimingFunction();
bezierEditor._updateCanvas();
}
/**
* Helper function for handling point move
*
* @param {Event} e Mouse move event
* @param {number} x New horizontal position
* @param {number} y New vertical position
*/
function handlePointMove(e, x, y) {
var self = e.target,
bezierEditor = self.bezierEditor;
// Helper function to redraw curve
function mouseMoveRedraw() {
if (!bezierEditor.dragElement) {
animationRequest = null;
return;
}
// Update code
bezierEditor._commitTimingFunction();
bezierEditor._updateCanvas();
animationRequest = window.requestAnimationFrame(mouseMoveRedraw);
}
// This is a dragging state, but left button is no longer down, so mouse
// exited element, was released, and re-entered element. Treat like a drop.
if (bezierEditor.dragElement && (e.which !== 1)) {
bezierEditor.dragElement = null;
bezierEditor._commitTimingFunction();
bezierEditor._updateCanvas();
bezierEditor = null;
return;
}
// Constrain time (x-axis) to 0 to 1 range. Progression (y-axis) is
// theoretically not constrained, although canvas to drawing curve is
// arbitrarily constrained to -0.5 to 1.5 range.
x = Math.min(Math.max(0, x), WIDTH_MAIN);
if (bezierEditor.dragElement) {
$(bezierEditor.dragElement).css({
left: x + "px",
top: y + "px"
});
}
// update coords
bezierEditor._cubicBezierCoords = bezierEditor.bezierCanvas
.offsetsToCoordinates(bezierEditor.P1)
.concat(bezierEditor.bezierCanvas.offsetsToCoordinates(bezierEditor.P2));
if (!animationRequest) {
animationRequest = window.requestAnimationFrame(mouseMoveRedraw);
}
}
/**
* Update Time (x-axis) and Progression (y-axis) data for mouse position
*
* @param {Element} canvas <canvas> element
* @param {number} x Horizontal position
* @param {number} y Vertical position
*/
function updateTimeProgression(curve, x, y) {
var percentX = Math.round(100 * x / WIDTH_MAIN),
percentY = Math.round(100 * ((HEIGHT_MAIN - y) / HEIGHT_MAIN));
// Constrain horizontal percentage to [0, 100] range
percentX = Math.min(Math.max(0, percentX), 100);
curve.parentNode.setAttribute("data-time", percentX);
curve.parentNode.setAttribute("data-progression", percentY);
}
/**
* Handle mouse move in <canvas> element
*
* @param {Event} e Mouse move event
*/
function _curveMouseMove(e) {
var self = e.target,
bezierEditor = self.bezierEditor,
curveBoundingBox = bezierEditor._getCurveBoundingBox(),
left = curveBoundingBox.left,
top = curveBoundingBox.top,
x = e.pageX - left,
y = e.pageY - top - HEIGHT_ABOVE;
updateTimeProgression(self, x, y);
if (bezierEditor.dragElement) {
if (e.pageX === 0 && e.pageY === 0) {
return;
}
handlePointMove(e, x, y);
}
}
/**
* Handle mouse move in <button> element
*
* @param {Event} e Mouse move event
*/
function _pointMouseMove(e) {
var self = e.target,
bezierEditor = self.bezierEditor,
curveBoundingBox = bezierEditor._getCurveBoundingBox(),
left = curveBoundingBox.left,
top = curveBoundingBox.top,
x = e.pageX - left,
y = e.pageY - top - HEIGHT_ABOVE;
updateTimeProgression(bezierEditor.curve, x, y);
if (e.pageX === 0 && e.pageY === 0) {
return;
}
handlePointMove(e, x, y);
}
/**
* Handle mouse down in <button> element
*
* @param {Event} e Mouse down event
*/
function _pointMouseDown(e) {
var self = e.target;
self.bezierEditor.dragElement = self;
}
/**
* Handle mouse up in <button> element
*
* @param {Event} e Mouse up event
*/
function _pointMouseUp(e) {
var self = e.target;
self.focus();
if (self.bezierEditor.dragElement) {
self.bezierEditor.dragElement = null;
self.bezierEditor._commitTimingFunction();
self.bezierEditor._updateCanvas();
}
}
/**
* Handle key down in <button> element
*
* @param {Event} e Key down event
*/
function _pointKeyDown(e) {
var code = e.keyCode,
self = e.target,
bezierEditor = self.bezierEditor;
if (code >= KeyEvent.DOM_VK_LEFT && code <= KeyEvent.DOM_VK_DOWN) {
e.preventDefault();
// Arrow keys pressed
var $this = $(e.target),
left = parseInt($this.css("left"), 10),
top = parseInt($this.css("top"), 10),
offset = (e.shiftKey ? 15 : 3),
newVal;
switch (code) {
case KeyEvent.DOM_VK_LEFT:
newVal = Math.max(0, left - offset);
if (left === newVal) {
return false;
}
$this.css({ left: newVal + "px" });
break;
case KeyEvent.DOM_VK_UP:
newVal = Math.max(-HEIGHT_ABOVE, top - offset);
if (top === newVal) {
return false;
}
$this.css({ top: newVal + "px" });
break;
case KeyEvent.DOM_VK_RIGHT:
newVal = Math.min(WIDTH_MAIN, left + offset);
if (left === newVal) {
return false;
}
$this.css({ left: newVal + "px" });
break;
case KeyEvent.DOM_VK_DOWN:
newVal = Math.min(HEIGHT_MAIN + HEIGHT_BELOW, top + offset);
if (top === newVal) {
return false;
}
$this.css({ top: newVal + "px" });
break;
}
// update coords
bezierEditor._cubicBezierCoords = bezierEditor.bezierCanvas
.offsetsToCoordinates(bezierEditor.P1)
.concat(bezierEditor.bezierCanvas.offsetsToCoordinates(bezierEditor.P2));
bezierEditor._commitTimingFunction();
bezierEditor._updateCanvas();
return true;
} else if (code === KeyEvent.DOM_VK_ESCAPE) {
return true;
} else if (code === KeyEvent.DOM_VK_TAB && !e.ctrlKey && !e.metaKey && !e.altKey) {
// Switch between the two points by tabbing
if ($(e.target).hasClass("P1")) {
$(".P2").focus();
} else {
$(".P1").focus();
}
e.preventDefault();
return true;
}
return false;
}
/**
* Constructor for BezierCurveEditor Object. This control may be used standalone
* or within an InlineTimingFunctionEditor inline widget.
*
* @param {!jQuery} $parent DOM node into which to append the root of the bezier curve editor UI
* @param {!RegExpMatch} bezierCurve RegExp match object of initially selected bezierCurve
* @param {!function(string)} callback Called whenever selected bezierCurve changes
*/
function BezierCurveEditor($parent, bezierCurve, callback) {
// Create the DOM structure, filling in localized strings via Mustache
this.$element = $(Mustache.render(BezierCurveEditorTemplate, Strings));
$parent.append(this.$element);
this._callback = callback;
this.dragElement = null;
// current cubic-bezier() function params
this._cubicBezierCoords = this._getCubicBezierCoords(bezierCurve);
this.hint = {};
this.hint.elem = $(".hint", this.$element);
// If function was auto-corrected, then originalString holds the original function,
// and an informational message needs to be shown
if (bezierCurve.originalString) {
TimingFunctionUtils.showHideHint(this.hint, true, bezierCurve.originalString, "cubic-bezier(" + this._cubicBezierCoords.join(", ") + ")");
} else {
TimingFunctionUtils.showHideHint(this.hint, false);
}
this.P1 = this.$element.find(".P1")[0];
this.P2 = this.$element.find(".P2")[0];
this.curve = this.$element.find(".curve")[0];
this.P1.bezierEditor = this.P2.bezierEditor = this.curve.bezierEditor = this;
this.bezierCanvas = new BezierCanvas(this.curve, null, [0, 0]);
// redraw canvas
this._updateCanvas();
$(this.curve)
.on("click", _curveClick)
.on("mousemove", _curveMouseMove);
$(this.P1)
.on("mousemove", _pointMouseMove)
.on("mousedown", _pointMouseDown)
.on("mouseup", _pointMouseUp)
.on("keydown", _pointKeyDown);
$(this.P2)
.on("mousemove", _pointMouseMove)
.on("mousedown", _pointMouseDown)
.on("mouseup", _pointMouseUp)
.on("keydown", _pointKeyDown);
}
/**
* Destructor called by InlineTimingFunctionEditor.onClosed()
*/
BezierCurveEditor.prototype.destroy = function () {
this.P1.bezierEditor = this.P2.bezierEditor = this.curve.bezierEditor = null;
$(this.curve)
.off("click", _curveClick)
.off("mousemove", _curveMouseMove);
$(this.P1)
.off("mousemove", _pointMouseMove)
.off("mousedown", _pointMouseDown)
.off("mouseup", _pointMouseUp)
.off("keydown", _pointKeyDown);
$(this.P2)
.off("mousemove", _pointMouseMove)
.off("mousedown", _pointMouseDown)
.off("mouseup", _pointMouseUp)
.off("keydown", _pointKeyDown);
};
/** Returns the root DOM node of the BezierCurveEditor UI */
BezierCurveEditor.prototype.getRootElement = function () {
return this.$element;
};
/**
* Default focus needs to go somewhere, so give it to P1
*/
BezierCurveEditor.prototype.focus = function () {
this.P1.focus();
return true;
};
/**
* Generates cubic-bezier function based on coords, and updates the doc
*/
BezierCurveEditor.prototype._commitTimingFunction = function () {
var bezierCurveVal = "cubic-bezier(" +
this._cubicBezierCoords[0] + ", " +
this._cubicBezierCoords[1] + ", " +
this._cubicBezierCoords[2] + ", " +
this._cubicBezierCoords[3] + ")";
this._callback(bezierCurveVal);
TimingFunctionUtils.showHideHint(this.hint, false);
};
/**
* Handle all matches returned from TimingFunctionUtils.cubicBezierMatch() and
* return array of coords
*
* @param {RegExp.match} match Matches returned from cubicBezierMatch()
* @return {Array.number[4]}
*/
BezierCurveEditor.prototype._getCubicBezierCoords = function (match) {
if (match[0].match(/^cubic-bezier/)) {
// cubic-bezier()
return match.slice(1, 5);
} else {
// handle special cases of cubic-bezier calls
switch (match[0]) {
case "linear":
return [ "0", "0", "1", "1" ];
case "ease":
return [ ".25", ".1", ".25", "1" ];
case "ease-in":
return [ ".42", "0", "1", "1" ];
case "ease-out":
return [ "0", "0", ".58", "1" ];
case "ease-in-out":
return [ ".42", "0", ".58", "1" ];
}
}
window.console.log("brackets-cubic-bezier: getCubicBezierCoords() passed invalid RegExp match array");
return [ "0", "0", "0", "0" ];
};
/**
* Get <canvas> element's bounding box
*
* @return {left: number, top: number, width: number, height: number}
*/
BezierCurveEditor.prototype._getCurveBoundingBox = function () {
var $canvas = this.$element.find(".curve"),
canvasOffset = $canvas.offset();
return {
left: canvasOffset.left,
top: canvasOffset.top,
width: $canvas.width(),
height: $canvas.height()
};
};
/**
* Update <canvas> after a change
*/
BezierCurveEditor.prototype._updateCanvas = function () {
// collect data, build model
if (this._cubicBezierCoords) {
this.bezierCanvas.bezier = window.bezier = new CubicBezier(this._cubicBezierCoords);
var offsets = this.bezierCanvas.getOffsets();
$(this.P1).css({
left: offsets[0].left,
top: offsets[0].top
});
$(this.P2).css({
left: offsets[1].left,
top: offsets[1].top
});
this.bezierCanvas.plot();
}
};
/**
* Handle external update
*
* @param {!RegExpMatch} bezierCurve RegExp match object of updated bezierCurve
*/
BezierCurveEditor.prototype.handleExternalUpdate = function (bezierCurve) {
this._cubicBezierCoords = this._getCubicBezierCoords(bezierCurve);
this._updateCanvas();
// If function was auto-corrected, then originalString holds the original function,
// and an informational message needs to be shown
if (bezierCurve.originalString) {
TimingFunctionUtils.showHideHint(this.hint, true, bezierCurve.originalString, "cubic-bezier(" + this._cubicBezierCoords.join(", ") + ")");
} else {
TimingFunctionUtils.showHideHint(this.hint, false);
}
};
exports.BezierCurveEditor = BezierCurveEditor;
});