diff options
author | Matt A. Tobin <email@mattatobin.com> | 2016-10-16 19:34:53 -0400 |
---|---|---|
committer | Matt A. Tobin <email@mattatobin.com> | 2016-10-16 19:34:53 -0400 |
commit | 81805ce3f63e2e4a799bd54f174083c58a9b5640 (patch) | |
tree | 6e13374b213ac9b2ae74c25d8aac875faf71fdd0 /toolkit/devtools/shared/widgets/CubicBezierWidget.js | |
parent | 28c8da71bf521bb3ee76f27b8a241919e24b7cd5 (diff) | |
download | palemoon-gre-81805ce3f63e2e4a799bd54f174083c58a9b5640.tar.gz |
Move Mozilla DevTools to Platform - Part 3: Merge the browser/devtools and toolkit/devtools adjusting for directory collisions
Diffstat (limited to 'toolkit/devtools/shared/widgets/CubicBezierWidget.js')
-rw-r--r-- | toolkit/devtools/shared/widgets/CubicBezierWidget.js | 556 |
1 files changed, 556 insertions, 0 deletions
diff --git a/toolkit/devtools/shared/widgets/CubicBezierWidget.js b/toolkit/devtools/shared/widgets/CubicBezierWidget.js new file mode 100644 index 000000000..9177253b8 --- /dev/null +++ b/toolkit/devtools/shared/widgets/CubicBezierWidget.js @@ -0,0 +1,556 @@ +/** + * 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; +} |