CubicBezierWidget.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898
  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. // Based on www.cubic-bezier.com by Lea Verou
  23. // See https://github.com/LeaVerou/cubic-bezier
  24. "use strict";
  25. const EventEmitter = require("devtools/shared/event-emitter");
  26. const {
  27. PREDEFINED,
  28. PRESETS,
  29. DEFAULT_PRESET_CATEGORY
  30. } = require("devtools/client/shared/widgets/CubicBezierPresets");
  31. const {getCSSLexer} = require("devtools/shared/css/lexer");
  32. const XHTML_NS = "http://www.w3.org/1999/xhtml";
  33. /**
  34. * CubicBezier data structure helper
  35. * Accepts an array of coordinates and exposes a few useful getters
  36. * @param {Array} coordinates i.e. [.42, 0, .58, 1]
  37. */
  38. function CubicBezier(coordinates) {
  39. if (!coordinates) {
  40. throw new Error("No offsets were defined");
  41. }
  42. this.coordinates = coordinates.map(n => +n);
  43. for (let i = 4; i--;) {
  44. let xy = this.coordinates[i];
  45. if (isNaN(xy) || (!(i % 2) && (xy < 0 || xy > 1))) {
  46. throw new Error(`Wrong coordinate at ${i}(${xy})`);
  47. }
  48. }
  49. this.coordinates.toString = function () {
  50. return this.map(n => {
  51. return (Math.round(n * 100) / 100 + "").replace(/^0\./, ".");
  52. }) + "";
  53. };
  54. }
  55. exports.CubicBezier = CubicBezier;
  56. CubicBezier.prototype = {
  57. get P1() {
  58. return this.coordinates.slice(0, 2);
  59. },
  60. get P2() {
  61. return this.coordinates.slice(2);
  62. },
  63. toString: function () {
  64. // Check first if current coords are one of css predefined functions
  65. let predefName = Object.keys(PREDEFINED)
  66. .find(key => coordsAreEqual(PREDEFINED[key],
  67. this.coordinates));
  68. return predefName || "cubic-bezier(" + this.coordinates + ")";
  69. }
  70. };
  71. /**
  72. * Bezier curve canvas plotting class
  73. * @param {DOMNode} canvas
  74. * @param {CubicBezier} bezier
  75. * @param {Array} padding Amount of horizontal,vertical padding around the graph
  76. */
  77. function BezierCanvas(canvas, bezier, padding) {
  78. this.canvas = canvas;
  79. this.bezier = bezier;
  80. this.padding = getPadding(padding);
  81. // Convert to a cartesian coordinate system with axes from 0 to 1
  82. this.ctx = this.canvas.getContext("2d");
  83. let p = this.padding;
  84. this.ctx.scale(canvas.width * (1 - p[1] - p[3]),
  85. -canvas.height * (1 - p[0] - p[2]));
  86. this.ctx.translate(p[3] / (1 - p[1] - p[3]),
  87. -1 - p[0] / (1 - p[0] - p[2]));
  88. }
  89. exports.BezierCanvas = BezierCanvas;
  90. BezierCanvas.prototype = {
  91. /**
  92. * Get P1 and P2 current top/left offsets so they can be positioned
  93. * @return {Array} Returns an array of 2 {top:String,left:String} objects
  94. */
  95. get offsets() {
  96. let p = this.padding, w = this.canvas.width, h = this.canvas.height;
  97. return [{
  98. left: w * (this.bezier.coordinates[0] * (1 - p[3] - p[1]) - p[3]) + "px",
  99. top: h * (1 - this.bezier.coordinates[1] * (1 - p[0] - p[2]) - p[0])
  100. + "px"
  101. }, {
  102. left: w * (this.bezier.coordinates[2] * (1 - p[3] - p[1]) - p[3]) + "px",
  103. top: h * (1 - this.bezier.coordinates[3] * (1 - p[0] - p[2]) - p[0])
  104. + "px"
  105. }];
  106. },
  107. /**
  108. * Convert an element's left/top offsets into coordinates
  109. */
  110. offsetsToCoordinates: function (element) {
  111. let p = this.padding, w = this.canvas.width, h = this.canvas.height;
  112. // Convert padding percentage to actual padding
  113. p = p.map((a, i) => a * (i % 2 ? w : h));
  114. return [
  115. (parseFloat(element.style.left) - p[3]) / (w + p[1] + p[3]),
  116. (h - parseFloat(element.style.top) - p[2]) / (h - p[0] - p[2])
  117. ];
  118. },
  119. /**
  120. * Draw the cubic bezier curve for the current coordinates
  121. */
  122. plot: function (settings = {}) {
  123. let xy = this.bezier.coordinates;
  124. let defaultSettings = {
  125. handleColor: "#666",
  126. handleThickness: .008,
  127. bezierColor: "#4C9ED9",
  128. bezierThickness: .015,
  129. drawHandles: true
  130. };
  131. for (let setting in settings) {
  132. defaultSettings[setting] = settings[setting];
  133. }
  134. // Clear the canvas –making sure to clear the
  135. // whole area by resetting the transform first.
  136. this.ctx.save();
  137. this.ctx.setTransform(1, 0, 0, 1, 0, 0);
  138. this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
  139. this.ctx.restore();
  140. if (defaultSettings.drawHandles) {
  141. // Draw control handles
  142. this.ctx.beginPath();
  143. this.ctx.fillStyle = defaultSettings.handleColor;
  144. this.ctx.lineWidth = defaultSettings.handleThickness;
  145. this.ctx.strokeStyle = defaultSettings.handleColor;
  146. this.ctx.moveTo(0, 0);
  147. this.ctx.lineTo(xy[0], xy[1]);
  148. this.ctx.moveTo(1, 1);
  149. this.ctx.lineTo(xy[2], xy[3]);
  150. this.ctx.stroke();
  151. this.ctx.closePath();
  152. let circle = (ctx, cx, cy, r) => {
  153. ctx.beginPath();
  154. ctx.arc(cx, cy, r, 0, 2 * Math.PI, !1);
  155. ctx.closePath();
  156. };
  157. circle(this.ctx, xy[0], xy[1], 1.5 * defaultSettings.handleThickness);
  158. this.ctx.fill();
  159. circle(this.ctx, xy[2], xy[3], 1.5 * defaultSettings.handleThickness);
  160. this.ctx.fill();
  161. }
  162. // Draw bezier curve
  163. this.ctx.beginPath();
  164. this.ctx.lineWidth = defaultSettings.bezierThickness;
  165. this.ctx.strokeStyle = defaultSettings.bezierColor;
  166. this.ctx.moveTo(0, 0);
  167. this.ctx.bezierCurveTo(xy[0], xy[1], xy[2], xy[3], 1, 1);
  168. this.ctx.stroke();
  169. this.ctx.closePath();
  170. }
  171. };
  172. /**
  173. * Cubic-bezier widget. Uses the BezierCanvas class to draw the curve and
  174. * adds the control points and user interaction
  175. * @param {DOMNode} parent The container where the graph should be created
  176. * @param {Array} coordinates Coordinates of the curve to be drawn
  177. *
  178. * Emits "updated" events whenever the curve is changed. Along with the event is
  179. * sent a CubicBezier object
  180. */
  181. function CubicBezierWidget(parent,
  182. coordinates = PRESETS["ease-in"]["ease-in-sine"]) {
  183. EventEmitter.decorate(this);
  184. this.parent = parent;
  185. let {curve, p1, p2} = this._initMarkup();
  186. this.curveBoundingBox = curve.getBoundingClientRect();
  187. this.curve = curve;
  188. this.p1 = p1;
  189. this.p2 = p2;
  190. // Create and plot the bezier curve
  191. this.bezierCanvas = new BezierCanvas(this.curve,
  192. new CubicBezier(coordinates), [0.30, 0]);
  193. this.bezierCanvas.plot();
  194. // Place the control points
  195. let offsets = this.bezierCanvas.offsets;
  196. this.p1.style.left = offsets[0].left;
  197. this.p1.style.top = offsets[0].top;
  198. this.p2.style.left = offsets[1].left;
  199. this.p2.style.top = offsets[1].top;
  200. this._onPointMouseDown = this._onPointMouseDown.bind(this);
  201. this._onPointKeyDown = this._onPointKeyDown.bind(this);
  202. this._onCurveClick = this._onCurveClick.bind(this);
  203. this._onNewCoordinates = this._onNewCoordinates.bind(this);
  204. // Add preset preview menu
  205. this.presets = new CubicBezierPresetWidget(parent);
  206. // Add the timing function previewer
  207. this.timingPreview = new TimingFunctionPreviewWidget(parent);
  208. this._initEvents();
  209. }
  210. exports.CubicBezierWidget = CubicBezierWidget;
  211. CubicBezierWidget.prototype = {
  212. _initMarkup: function () {
  213. let doc = this.parent.ownerDocument;
  214. let wrap = doc.createElementNS(XHTML_NS, "div");
  215. wrap.className = "display-wrap";
  216. let plane = doc.createElementNS(XHTML_NS, "div");
  217. plane.className = "coordinate-plane";
  218. let p1 = doc.createElementNS(XHTML_NS, "button");
  219. p1.className = "control-point";
  220. plane.appendChild(p1);
  221. let p2 = doc.createElementNS(XHTML_NS, "button");
  222. p2.className = "control-point";
  223. plane.appendChild(p2);
  224. let curve = doc.createElementNS(XHTML_NS, "canvas");
  225. curve.setAttribute("width", 150);
  226. curve.setAttribute("height", 370);
  227. curve.className = "curve";
  228. plane.appendChild(curve);
  229. wrap.appendChild(plane);
  230. this.parent.appendChild(wrap);
  231. return {
  232. p1,
  233. p2,
  234. curve
  235. };
  236. },
  237. _removeMarkup: function () {
  238. this.parent.querySelector(".display-wrap").remove();
  239. },
  240. _initEvents: function () {
  241. this.p1.addEventListener("mousedown", this._onPointMouseDown);
  242. this.p2.addEventListener("mousedown", this._onPointMouseDown);
  243. this.p1.addEventListener("keydown", this._onPointKeyDown);
  244. this.p2.addEventListener("keydown", this._onPointKeyDown);
  245. this.curve.addEventListener("click", this._onCurveClick);
  246. this.presets.on("new-coordinates", this._onNewCoordinates);
  247. },
  248. _removeEvents: function () {
  249. this.p1.removeEventListener("mousedown", this._onPointMouseDown);
  250. this.p2.removeEventListener("mousedown", this._onPointMouseDown);
  251. this.p1.removeEventListener("keydown", this._onPointKeyDown);
  252. this.p2.removeEventListener("keydown", this._onPointKeyDown);
  253. this.curve.removeEventListener("click", this._onCurveClick);
  254. this.presets.off("new-coordinates", this._onNewCoordinates);
  255. },
  256. _onPointMouseDown: function (event) {
  257. // Updating the boundingbox in case it has changed
  258. this.curveBoundingBox = this.curve.getBoundingClientRect();
  259. let point = event.target;
  260. let doc = point.ownerDocument;
  261. let self = this;
  262. doc.onmousemove = function drag(e) {
  263. let x = e.pageX;
  264. let y = e.pageY;
  265. let left = self.curveBoundingBox.left;
  266. let top = self.curveBoundingBox.top;
  267. if (x === 0 && y == 0) {
  268. return;
  269. }
  270. // Constrain x
  271. x = Math.min(Math.max(left, x), left + self.curveBoundingBox.width);
  272. point.style.left = x - left + "px";
  273. point.style.top = y - top + "px";
  274. self._updateFromPoints();
  275. };
  276. doc.onmouseup = function () {
  277. point.focus();
  278. doc.onmousemove = doc.onmouseup = null;
  279. };
  280. },
  281. _onPointKeyDown: function (event) {
  282. let point = event.target;
  283. let code = event.keyCode;
  284. if (code >= 37 && code <= 40) {
  285. event.preventDefault();
  286. // Arrow keys pressed
  287. let left = parseInt(point.style.left, 10);
  288. let top = parseInt(point.style.top, 10);
  289. let offset = 3 * (event.shiftKey ? 10 : 1);
  290. switch (code) {
  291. case 37: point.style.left = left - offset + "px"; break;
  292. case 38: point.style.top = top - offset + "px"; break;
  293. case 39: point.style.left = left + offset + "px"; break;
  294. case 40: point.style.top = top + offset + "px"; break;
  295. }
  296. this._updateFromPoints();
  297. }
  298. },
  299. _onCurveClick: function (event) {
  300. this.curveBoundingBox = this.curve.getBoundingClientRect();
  301. let left = this.curveBoundingBox.left;
  302. let top = this.curveBoundingBox.top;
  303. let x = event.pageX - left;
  304. let y = event.pageY - top;
  305. // Find which point is closer
  306. let distP1 = distance(x, y,
  307. parseInt(this.p1.style.left, 10), parseInt(this.p1.style.top, 10));
  308. let distP2 = distance(x, y,
  309. parseInt(this.p2.style.left, 10), parseInt(this.p2.style.top, 10));
  310. let point = distP1 < distP2 ? this.p1 : this.p2;
  311. point.style.left = x + "px";
  312. point.style.top = y + "px";
  313. this._updateFromPoints();
  314. },
  315. _onNewCoordinates: function (event, coordinates) {
  316. this.coordinates = coordinates;
  317. },
  318. /**
  319. * Get the current point coordinates and redraw the curve to match
  320. */
  321. _updateFromPoints: function () {
  322. // Get the new coordinates from the point's offsets
  323. let coordinates = this.bezierCanvas.offsetsToCoordinates(this.p1);
  324. coordinates = coordinates.concat(
  325. this.bezierCanvas.offsetsToCoordinates(this.p2)
  326. );
  327. this.presets.refreshMenu(coordinates);
  328. this._redraw(coordinates);
  329. },
  330. /**
  331. * Redraw the curve
  332. * @param {Array} coordinates The array of control point coordinates
  333. */
  334. _redraw: function (coordinates) {
  335. // Provide a new CubicBezier to the canvas and plot the curve
  336. this.bezierCanvas.bezier = new CubicBezier(coordinates);
  337. this.bezierCanvas.plot();
  338. this.emit("updated", this.bezierCanvas.bezier);
  339. this.timingPreview.preview(this.bezierCanvas.bezier + "");
  340. },
  341. /**
  342. * Set new coordinates for the control points and redraw the curve
  343. * @param {Array} coordinates
  344. */
  345. set coordinates(coordinates) {
  346. this._redraw(coordinates);
  347. // Move the points
  348. let offsets = this.bezierCanvas.offsets;
  349. this.p1.style.left = offsets[0].left;
  350. this.p1.style.top = offsets[0].top;
  351. this.p2.style.left = offsets[1].left;
  352. this.p2.style.top = offsets[1].top;
  353. },
  354. /**
  355. * Set new coordinates for the control point and redraw the curve
  356. * @param {String} value A string value. E.g. "linear",
  357. * "cubic-bezier(0,0,1,1)"
  358. */
  359. set cssCubicBezierValue(value) {
  360. if (!value) {
  361. return;
  362. }
  363. value = value.trim();
  364. // Try with one of the predefined values
  365. let coordinates = parseTimingFunction(value);
  366. this.presets.refreshMenu(coordinates);
  367. this.coordinates = coordinates;
  368. },
  369. destroy: function () {
  370. this._removeEvents();
  371. this._removeMarkup();
  372. this.timingPreview.destroy();
  373. this.presets.destroy();
  374. this.curve = this.p1 = this.p2 = null;
  375. }
  376. };
  377. /**
  378. * CubicBezierPreset widget.
  379. * Builds a menu of presets from CubicBezierPresets
  380. * @param {DOMNode} parent The container where the preset panel should be
  381. * created
  382. *
  383. * Emits "new-coordinate" event along with the coordinates
  384. * whenever a preset is selected.
  385. */
  386. function CubicBezierPresetWidget(parent) {
  387. this.parent = parent;
  388. let {presetPane, presets, categories} = this._initMarkup();
  389. this.presetPane = presetPane;
  390. this.presets = presets;
  391. this.categories = categories;
  392. this._activeCategory = null;
  393. this._activePresetList = null;
  394. this._activePreset = null;
  395. this._onCategoryClick = this._onCategoryClick.bind(this);
  396. this._onPresetClick = this._onPresetClick.bind(this);
  397. EventEmitter.decorate(this);
  398. this._initEvents();
  399. }
  400. exports.CubicBezierPresetWidget = CubicBezierPresetWidget;
  401. CubicBezierPresetWidget.prototype = {
  402. /*
  403. * Constructs a list of all preset categories and a list
  404. * of presets for each category.
  405. *
  406. * High level markup:
  407. * div .preset-pane
  408. * div .preset-categories
  409. * div .category
  410. * div .category
  411. * ...
  412. * div .preset-container
  413. * div .presetList
  414. * div .preset
  415. * ...
  416. * div .presetList
  417. * div .preset
  418. * ...
  419. */
  420. _initMarkup: function () {
  421. let doc = this.parent.ownerDocument;
  422. let presetPane = doc.createElementNS(XHTML_NS, "div");
  423. presetPane.className = "preset-pane";
  424. let categoryList = doc.createElementNS(XHTML_NS, "div");
  425. categoryList.id = "preset-categories";
  426. let presetContainer = doc.createElementNS(XHTML_NS, "div");
  427. presetContainer.id = "preset-container";
  428. Object.keys(PRESETS).forEach(categoryLabel => {
  429. let category = this._createCategory(categoryLabel);
  430. categoryList.appendChild(category);
  431. let presetList = this._createPresetList(categoryLabel);
  432. presetContainer.appendChild(presetList);
  433. });
  434. presetPane.appendChild(categoryList);
  435. presetPane.appendChild(presetContainer);
  436. this.parent.appendChild(presetPane);
  437. let allCategories = presetPane.querySelectorAll(".category");
  438. let allPresets = presetPane.querySelectorAll(".preset");
  439. return {
  440. presetPane: presetPane,
  441. presets: allPresets,
  442. categories: allCategories
  443. };
  444. },
  445. _createCategory: function (categoryLabel) {
  446. let doc = this.parent.ownerDocument;
  447. let category = doc.createElementNS(XHTML_NS, "div");
  448. category.id = categoryLabel;
  449. category.classList.add("category");
  450. let categoryDisplayLabel = this._normalizeCategoryLabel(categoryLabel);
  451. category.textContent = categoryDisplayLabel;
  452. category.setAttribute("title", categoryDisplayLabel);
  453. return category;
  454. },
  455. _normalizeCategoryLabel: function (categoryLabel) {
  456. return categoryLabel.replace("/-/g", " ");
  457. },
  458. _createPresetList: function (categoryLabel) {
  459. let doc = this.parent.ownerDocument;
  460. let presetList = doc.createElementNS(XHTML_NS, "div");
  461. presetList.id = "preset-category-" + categoryLabel;
  462. presetList.classList.add("preset-list");
  463. Object.keys(PRESETS[categoryLabel]).forEach(presetLabel => {
  464. let preset = this._createPreset(categoryLabel, presetLabel);
  465. presetList.appendChild(preset);
  466. });
  467. return presetList;
  468. },
  469. _createPreset: function (categoryLabel, presetLabel) {
  470. let doc = this.parent.ownerDocument;
  471. let preset = doc.createElementNS(XHTML_NS, "div");
  472. preset.classList.add("preset");
  473. preset.id = presetLabel;
  474. preset.coordinates = PRESETS[categoryLabel][presetLabel];
  475. // Create preset preview
  476. let curve = doc.createElementNS(XHTML_NS, "canvas");
  477. let bezier = new CubicBezier(preset.coordinates);
  478. curve.setAttribute("height", 50);
  479. curve.setAttribute("width", 50);
  480. preset.bezierCanvas = new BezierCanvas(curve, bezier, [0.15, 0]);
  481. preset.bezierCanvas.plot({
  482. drawHandles: false,
  483. bezierThickness: 0.025
  484. });
  485. preset.appendChild(curve);
  486. // Create preset label
  487. let presetLabelElem = doc.createElementNS(XHTML_NS, "p");
  488. let presetDisplayLabel = this._normalizePresetLabel(categoryLabel,
  489. presetLabel);
  490. presetLabelElem.textContent = presetDisplayLabel;
  491. preset.appendChild(presetLabelElem);
  492. preset.setAttribute("title", presetDisplayLabel);
  493. return preset;
  494. },
  495. _normalizePresetLabel: function (categoryLabel, presetLabel) {
  496. return presetLabel.replace(categoryLabel + "-", "").replace("/-/g", " ");
  497. },
  498. _initEvents: function () {
  499. for (let category of this.categories) {
  500. category.addEventListener("click", this._onCategoryClick);
  501. }
  502. for (let preset of this.presets) {
  503. preset.addEventListener("click", this._onPresetClick);
  504. }
  505. },
  506. _removeEvents: function () {
  507. for (let category of this.categories) {
  508. category.removeEventListener("click", this._onCategoryClick);
  509. }
  510. for (let preset of this.presets) {
  511. preset.removeEventListener("click", this._onPresetClick);
  512. }
  513. },
  514. _onPresetClick: function (event) {
  515. this.emit("new-coordinates", event.currentTarget.coordinates);
  516. this.activePreset = event.currentTarget;
  517. },
  518. _onCategoryClick: function (event) {
  519. this.activeCategory = event.target;
  520. },
  521. _setActivePresetList: function (presetListId) {
  522. let presetList = this.presetPane.querySelector("#" + presetListId);
  523. swapClassName("active-preset-list", this._activePresetList, presetList);
  524. this._activePresetList = presetList;
  525. },
  526. set activeCategory(category) {
  527. swapClassName("active-category", this._activeCategory, category);
  528. this._activeCategory = category;
  529. this._setActivePresetList("preset-category-" + category.id);
  530. },
  531. get activeCategory() {
  532. return this._activeCategory;
  533. },
  534. set activePreset(preset) {
  535. swapClassName("active-preset", this._activePreset, preset);
  536. this._activePreset = preset;
  537. },
  538. get activePreset() {
  539. return this._activePreset;
  540. },
  541. /**
  542. * Called by CubicBezierWidget onload and when
  543. * the curve is modified via the canvas.
  544. * Attempts to match the new user setting with an
  545. * existing preset.
  546. * @param {Array} coordinates new coords [i, j, k, l]
  547. */
  548. refreshMenu: function (coordinates) {
  549. // If we cannot find a matching preset, keep
  550. // menu on last known preset category.
  551. let category = this._activeCategory;
  552. // If we cannot find a matching preset
  553. // deselect any selected preset.
  554. let preset = null;
  555. // If a category has never been viewed before
  556. // show the default category.
  557. if (!category) {
  558. category = this.parent.querySelector("#" + DEFAULT_PRESET_CATEGORY);
  559. }
  560. // If the new coordinates do match a preset,
  561. // set its category and preset button as active.
  562. Object.keys(PRESETS).forEach(categoryLabel => {
  563. Object.keys(PRESETS[categoryLabel]).forEach(presetLabel => {
  564. if (coordsAreEqual(PRESETS[categoryLabel][presetLabel], coordinates)) {
  565. category = this.parent.querySelector("#" + categoryLabel);
  566. preset = this.parent.querySelector("#" + presetLabel);
  567. }
  568. });
  569. });
  570. this.activeCategory = category;
  571. this.activePreset = preset;
  572. },
  573. destroy: function () {
  574. this._removeEvents();
  575. this.parent.querySelector(".preset-pane").remove();
  576. }
  577. };
  578. /**
  579. * The TimingFunctionPreviewWidget animates a dot on a scale with a given
  580. * timing-function
  581. * @param {DOMNode} parent The container where this widget should go
  582. */
  583. function TimingFunctionPreviewWidget(parent) {
  584. this.previousValue = null;
  585. this.autoRestartAnimation = null;
  586. this.parent = parent;
  587. this._initMarkup();
  588. }
  589. TimingFunctionPreviewWidget.prototype = {
  590. PREVIEW_DURATION: 1000,
  591. _initMarkup: function () {
  592. let doc = this.parent.ownerDocument;
  593. let container = doc.createElementNS(XHTML_NS, "div");
  594. container.className = "timing-function-preview";
  595. this.dot = doc.createElementNS(XHTML_NS, "div");
  596. this.dot.className = "dot";
  597. container.appendChild(this.dot);
  598. let scale = doc.createElementNS(XHTML_NS, "div");
  599. scale.className = "scale";
  600. container.appendChild(scale);
  601. this.parent.appendChild(container);
  602. },
  603. destroy: function () {
  604. clearTimeout(this.autoRestartAnimation);
  605. this.parent.querySelector(".timing-function-preview").remove();
  606. this.parent = this.dot = null;
  607. },
  608. /**
  609. * Preview a new timing function. The current preview will only be stopped if
  610. * the supplied function value is different from the previous one. If the
  611. * supplied function is invalid, the preview will stop.
  612. * @param {String} value
  613. */
  614. preview: function (value) {
  615. // Don't restart the preview animation if the value is the same
  616. if (value === this.previousValue) {
  617. return;
  618. }
  619. clearTimeout(this.autoRestartAnimation);
  620. if (parseTimingFunction(value)) {
  621. this.dot.style.animationTimingFunction = value;
  622. this.restartAnimation();
  623. }
  624. this.previousValue = value;
  625. },
  626. /**
  627. * Re-start the preview animation from the beginning
  628. */
  629. restartAnimation: function () {
  630. // Just toggling the class won't do it unless there's a sync reflow
  631. this.dot.animate([
  632. { left: "-7px", offset: 0 },
  633. { left: "143px", offset: 0.25 },
  634. { left: "143px", offset: 0.5 },
  635. { left: "-7px", offset: 0.75 },
  636. { left: "-7px", offset: 1 }
  637. ], {
  638. duration: (this.PREVIEW_DURATION * 2),
  639. fill: "forwards"
  640. });
  641. // Restart it again after a while
  642. this.autoRestartAnimation = setTimeout(this.restartAnimation.bind(this),
  643. this.PREVIEW_DURATION * 2);
  644. }
  645. };
  646. // Helpers
  647. function getPadding(padding) {
  648. let p = typeof padding === "number" ? [padding] : padding;
  649. if (p.length === 1) {
  650. p[1] = p[0];
  651. }
  652. if (p.length === 2) {
  653. p[2] = p[0];
  654. }
  655. if (p.length === 3) {
  656. p[3] = p[1];
  657. }
  658. return p;
  659. }
  660. function distance(x1, y1, x2, y2) {
  661. return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
  662. }
  663. /**
  664. * Parse a string to see whether it is a valid timing function.
  665. * If it is, return the coordinates as an array.
  666. * Otherwise, return undefined.
  667. * @param {String} value
  668. * @return {Array} of coordinates, or undefined
  669. */
  670. function parseTimingFunction(value) {
  671. if (value in PREDEFINED) {
  672. return PREDEFINED[value];
  673. }
  674. let tokenStream = getCSSLexer(value);
  675. let getNextToken = () => {
  676. while (true) {
  677. let token = tokenStream.nextToken();
  678. if (!token || (token.tokenType !== "whitespace" &&
  679. token.tokenType !== "comment")) {
  680. return token;
  681. }
  682. }
  683. };
  684. let token = getNextToken();
  685. if (token.tokenType !== "function" || token.text !== "cubic-bezier") {
  686. return undefined;
  687. }
  688. let result = [];
  689. for (let i = 0; i < 4; ++i) {
  690. token = getNextToken();
  691. if (!token || token.tokenType !== "number") {
  692. return undefined;
  693. }
  694. result.push(token.number);
  695. token = getNextToken();
  696. if (!token || token.tokenType !== "symbol" ||
  697. token.text !== (i == 3 ? ")" : ",")) {
  698. return undefined;
  699. }
  700. }
  701. return result;
  702. }
  703. // This is exported for testing.
  704. exports._parseTimingFunction = parseTimingFunction;
  705. /**
  706. * Removes a class from a node and adds it to another.
  707. * @param {String} className the class to swap
  708. * @param {DOMNode} from the node to remove the class from
  709. * @param {DOMNode} to the node to add the class to
  710. */
  711. function swapClassName(className, from, to) {
  712. if (from !== null) {
  713. from.classList.remove(className);
  714. }
  715. if (to !== null) {
  716. to.classList.add(className);
  717. }
  718. }
  719. /**
  720. * Compares two arrays of coordinates [i, j, k, l]
  721. * @param {Array} c1 first coordinate array to compare
  722. * @param {Array} c2 second coordinate array to compare
  723. * @return {Boolean}
  724. */
  725. function coordsAreEqual(c1, c2) {
  726. return c1.reduce((prev, curr, index) => prev && (curr === c2[index]), true);
  727. }