├── .babelrc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .jsdoc ├── .jshintrc ├── .npmrc ├── CONTRIBUTING.md ├── LICENSE-MIT ├── README.md ├── index.md ├── package-lock.json ├── package.json ├── src ├── actor.js ├── keyframe-property.js ├── main.js ├── rekapi.js ├── renderers │ ├── canvas.js │ └── dom.js └── utils.js ├── test ├── actor.js ├── canvas.js ├── dom.js ├── get-css.js ├── index.html ├── index.js ├── keyframe-property.js ├── rekapi.js ├── test-utils.js └── utils.js ├── tutorials ├── dom-rendering-in-depth.json ├── dom-rendering-in-depth.md ├── getting-started.json ├── getting-started.md ├── keyframes-in-depth.json ├── keyframes-in-depth.md ├── multiple-renderers.json └── multiple-renderers.md ├── webpack.common.js ├── webpack.config.js └── webpack.test.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | 8 | jobs: 9 | run_tests: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 16 16 | - run: npm ci 17 | - run: npm run ci 18 | - run: npm run build 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .DS_Store 3 | node_modules 4 | dist 5 | -------------------------------------------------------------------------------- /.jsdoc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["plugins/markdown"], 3 | "opts": { 4 | "destination": "dist/doc", 5 | "readme": "index.md", 6 | "tutorials": "tutorials", 7 | "template": "node_modules/@jeremyckahn/minami" 8 | }, 9 | "templates": { 10 | "cleverLinks": true, 11 | "useLongnameInNav": false, 12 | "showInheritedInNav": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "asi": false, 3 | "boss": true, 4 | "browser": true, 5 | "curly": true, 6 | "eqeqeq": true, 7 | "eqnull": true, 8 | "immed": true, 9 | "lastsemic": true, 10 | "latedef": true, 11 | "laxbreak": true, 12 | "laxcomma": true, 13 | "newcap": true, 14 | "noarg": true, 15 | "nomen": false, 16 | "plusplus": false, 17 | "sub": true, 18 | "undef": true, 19 | "white": false, 20 | "esversion": 6, 21 | "node": true 22 | } 23 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremyckahn/rekapi/717d8b80ededdf0ad602ee21bd483e94ef74afd2/.npmrc -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Rekapi 2 | 3 | First of all, thanks! Community contribution is what makes open source great. 4 | If you find a bug or would like to make a feature request, please report it on 5 | the [issue tracker](https://github.com/jeremyckahn/rekapi/issues). If you 6 | would like to make changes to the code yourself, read on! 7 | 8 | ## Getting started 9 | 10 | To get started with working on Rekapi, you'll need to get all of the 11 | dependencies: 12 | 13 | ``` 14 | $: npm install 15 | ``` 16 | 17 | ## Pull Requests and branches 18 | 19 | The project maintainer ([@jeremyckahn](https://github.com/jeremyckahn)) manages 20 | releases. `master` contains the latest stable code, and `develop` contains 21 | commits that are ahead of (newer than) `master` that have yet to be officially 22 | released (built and tagged). *When making a Pull Request, please branch off of 23 | `develop` and request to merge back into it.* `master` is only merged into 24 | from `develop`. 25 | 26 | ## Building 27 | 28 | ``` 29 | $: npm run build 30 | ``` 31 | 32 | A note about the `dist/` directory: You should not modify the files in this 33 | directory manually, as your changes will be overwritten by the build process. 34 | The Rekapi source files are in the `src/` directory. 35 | 36 | ## Testing 37 | 38 | Please make sure that all tests pass before submitting a Pull Request. To run 39 | the tests on the command line: 40 | 41 | ``` 42 | $: npm run test 43 | ``` 44 | 45 | ## Style 46 | 47 | Please try to remain consitent with existing code. To automatically check for 48 | style issues or other potential problems, you can run: 49 | 50 | ``` 51 | $: npm run lint 52 | ``` 53 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Jeremy Kahn 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rekapi - Keyframes for JavaScript 2 | 3 | Rekapi is a keyframe animation library for JavaScript. It gives you an API 4 | for: 5 | 6 | * Defining keyframe-based animations 7 | * Controlling animation playback 8 | 9 | Rekapi is renderer-agnostic. At its core, Rekapi does not perform any 10 | rendering. However, it does expose an API for defining renderers, and comes 11 | bundled with renderers for the HTML DOM and HTML5 2D ``. 12 | 13 | ## Browser compatibility 14 | 15 | Rekapi officially supports Evergreen browsers. 16 | 17 | ## Installation 18 | 19 | ``` 20 | npm install --save rekapi 21 | ``` 22 | 23 | ## Developing Rekapi 24 | 25 | First, install the dependencies via npm like so: 26 | 27 | ``` 28 | npm install 29 | ``` 30 | 31 | Once those are installed, you can generate `dist/rekapi.js` with: 32 | 33 | ``` 34 | npm run build 35 | ``` 36 | 37 | To run the tests in CLI: 38 | 39 | ``` 40 | npm test 41 | ``` 42 | 43 | To generate the documentation (`dist/doc`): 44 | 45 | ``` 46 | npm run doc 47 | ``` 48 | 49 | To generate, live-update, and view the documentation in your browser: 50 | 51 | ``` 52 | npm run doc:live 53 | ``` 54 | 55 | To start a development server: 56 | 57 | ``` 58 | npm start 59 | ``` 60 | 61 | Once that's running, you can run the tests at http://localhost:9010/test/ and 62 | view the documentation at http://localhost:9010/dist/doc/. 63 | 64 | ## Loading Rekapi 65 | 66 | Rekapi exposes a UMD module, so you can load it however you like: 67 | 68 | ```javascript 69 | // ES6 70 | import { Rekapi, Actor } from 'rekapi'; 71 | ``` 72 | 73 | Or: 74 | 75 | ```javascript 76 | // AMD 77 | define(['rekapi'], rekapi => { }); 78 | ``` 79 | 80 | Or even: 81 | 82 | ```javascript 83 | // CommonJS 84 | const rekapi = require('rekapi'); 85 | ``` 86 | 87 | ## Contributors 88 | 89 | Take a peek at the [Network](https://github.com/jeremyckahn/rekapi/network) 90 | page to see all of the Rekapi contributors. 91 | 92 | ## License 93 | 94 | Rekapi is distributed under the [MIT 95 | license](http://opensource.org/licenses/MIT). You are encouraged to use and 96 | modify the code to suit your needs, as well as redistribute it. 97 | -------------------------------------------------------------------------------- /index.md: -------------------------------------------------------------------------------- 1 | # Rekapi 2 | 3 | ## A JavaScript Keyframe Library 4 | 5 | ### `npm install --save rekapi` 6 | ### [Download](../rekapi.js) • [Source](https://github.com/jeremyckahn/rekapi) 7 | 8 | [![Gitter](https://badges.gitter.im/jeremyckahn/rekapi.svg)](https://gitter.im/jeremyckahn/rekapi?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) 9 | 10 | 11 | Rekapi is a keyframe animation library for JavaScript. It gives you an API 12 | for: 13 | 14 | * Defining keyframe-based animations 15 | * Controlling animation playback 16 | 17 | Rekapi is renderer-agnostic. At its core, Rekapi does not perform any 18 | rendering. However, it does expose an API for defining renderers, and comes 19 | bundled with renderers for the HTML DOM and HTML5 2D ``. 20 | 21 | Rekapi officially supports Evergreen browsers and is published as a UMD module. 22 | 23 |

See the Pen Rekapi Confetti by Jeremy Kahn (@jeremyckahn) on CodePen.

24 | 25 | 26 | ## What is keyframing? 27 | 28 | Keyframing is an animation technique for defining states at specific points in 29 | time. Keyframing allows you to declaratively define the points at which an 30 | animation changes. All of the frames that exist between keyframes are 31 | interpolated for you. It is a powerful way to construct a complex animation! 32 | 33 | ## How do I use Rekapi? 34 | 35 | Using Rekapi boils down to four steps: 36 | 37 | * Define one or more actors 38 | * Add actors to the animation 39 | * Define keyframe states for the actors 40 | * Play the animation 41 | 42 | For a fuller explanation with a runnable example, check out the 43 | [Getting Started]{@tutorial getting-started} guide. 44 | 45 | ## Rendering 46 | 47 | Rekapi works by providing state data to the actors for every frame. The actors 48 | then render the data according to their rendering context. Rekapi treats rendering 49 | contexts generically, and you can create new ones as needed. 50 | 51 | Rekapi ships with {@link rekapi.CanvasRenderer} and {@link rekapi.DOMRenderer} 52 | which are designed to cover a variety of common use cases. However, you can 53 | create your own {@link rekapi.renderer}-like class to fit whatever use case you 54 | have. 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rekapi", 3 | "version": "2.3.0", 4 | "homepage": "http://rekapi.com", 5 | "author": "Jeremy Kahn ", 6 | "description": "A keyframe animation library for JavaScript", 7 | "main": "dist/rekapi.js", 8 | "contributors": [ 9 | { 10 | "name": "Franck Lecollinet" 11 | }, 12 | { 13 | "name": "Brian Downing" 14 | } 15 | ], 16 | "devDependencies": { 17 | "@jeremyckahn/minami": "^1.3.1", 18 | "babel-core": "^6.22.1", 19 | "babel-loader": "^6.2.10", 20 | "babel-preset-es2015": "^6.22.0", 21 | "concurrently": "^3.5.0", 22 | "gh-pages": "^1.0.0", 23 | "jquery": "^3.2.1", 24 | "jsdoc": "^3.5.5", 25 | "jsdom": "11.0.0", 26 | "jsdom-global": "^3.0.2", 27 | "jshint": "^2.9.4", 28 | "live-server": "^1.2.0", 29 | "lodash": "~2.4.1", 30 | "mocha": "^3.2.0", 31 | "nodemon": "^1.11.0", 32 | "request": "^2.83.0", 33 | "webpack": "2.5.1", 34 | "webpack-dev-server": "2.4.5" 35 | }, 36 | "scripts": { 37 | "build": "webpack", 38 | "ci": "npm test && npm run lint", 39 | "start": "webpack-dev-server --config webpack.test.config.js", 40 | "test": "mocha -r jsdom-global/register ./node_modules/babel-core/register.js test/index.js", 41 | "test:watch": "nodemon --exec \"npm test\" --watch src --watch renderers --watch test", 42 | "doc": "jsdoc -c .jsdoc src/*.js src/renderers/*.js", 43 | "doc:view": "live-server dist/doc --port=9124", 44 | "doc:watch": "nodemon --exec \"npm run doc\" --watch src --watch ./ --ext js,md --ignore dist", 45 | "doc:live": "concurrently --kill-others \"npm run doc:watch\" \"npm run doc:view\"", 46 | "lint": "jshint src", 47 | "deploy": "npm run build && npm run doc && gh-pages -d dist -b gh-pages", 48 | "preversion": "npm run lint && npm test", 49 | "postversion": "git push && git push --tags && npm run deploy && npm publish" 50 | }, 51 | "files": [ 52 | "src", 53 | "dist" 54 | ], 55 | "license": "MIT", 56 | "dependencies": { 57 | "lodash.sortedindexby": "^4.6.0", 58 | "shifty": "^2.20.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/actor.js: -------------------------------------------------------------------------------- 1 | import { Tweenable } from 'shifty'; 2 | import { KeyframeProperty } from './keyframe-property'; 3 | import { 4 | fireEvent, 5 | invalidateAnimationLength, 6 | DEFAULT_EASING 7 | } from './rekapi'; 8 | 9 | import { 10 | clone, 11 | each, 12 | pick, 13 | uniqueId 14 | } from './utils'; 15 | 16 | import sortedIndexBy from 'lodash.sortedindexby'; 17 | 18 | const noop = () => {}; 19 | 20 | /*! 21 | * @param {Object} obj 22 | * @return {number} millisecond 23 | */ 24 | const getMillisecond = obj => obj.millisecond; 25 | 26 | // TODO: Make this a prototype method 27 | /*! 28 | * @param {Actor} actor 29 | * @param {string} event 30 | * @param {any} [data] 31 | */ 32 | const fire = (actor, event, data) => 33 | actor.rekapi && fireEvent(actor.rekapi, event, data); 34 | 35 | /*! 36 | * Retrieves the most recent property cache entry for a given millisecond. 37 | * @param {Actor} actor 38 | * @param {number} millisecond 39 | * @return {(Object|undefined)} undefined if there is no property cache for 40 | * the millisecond, i.e. an empty cache. 41 | */ 42 | const getPropertyCacheEntryForMillisecond = (actor, millisecond) => { 43 | const { _timelinePropertyCache } = actor; 44 | const index = sortedIndexBy( 45 | _timelinePropertyCache, 46 | { _millisecond: millisecond }, 47 | obj => obj._millisecond 48 | ); 49 | 50 | if (!_timelinePropertyCache[index]) { 51 | return; 52 | } 53 | 54 | return _timelinePropertyCache[index]._millisecond === millisecond ? 55 | _timelinePropertyCache[index] : 56 | index >= 1 ? 57 | _timelinePropertyCache[index - 1] : 58 | _timelinePropertyCache[0]; 59 | }; 60 | 61 | /*! 62 | * Search property track `track` and find the correct index to insert a 63 | * new element at `millisecond`. 64 | * @param {Array(KeyframeProperty)} track 65 | * @param {number} millisecond 66 | * @return {number} index 67 | */ 68 | const insertionPointInTrack = (track, millisecond) => 69 | sortedIndexBy(track, { millisecond }, getMillisecond); 70 | 71 | /*! 72 | * Gets all of the current and most recent Rekapi.KeyframeProperties for a 73 | * given millisecond. 74 | * @param {Actor} actor 75 | * @param {number} forMillisecond 76 | * @return {Object} An Object containing Rekapi.KeyframeProperties 77 | */ 78 | const getLatestProperties = (actor, forMillisecond) => { 79 | const latestProperties = {}; 80 | 81 | each(actor._propertyTracks, (propertyTrack, propertyName) => { 82 | const index = insertionPointInTrack(propertyTrack, forMillisecond); 83 | 84 | latestProperties[propertyName] = 85 | propertyTrack[index] && propertyTrack[index].millisecond === forMillisecond ? 86 | // Found forMillisecond exactly. 87 | propertyTrack[index] : 88 | index >= 1 ? 89 | // forMillisecond doesn't exist in the track and index is 90 | // where we'd need to insert it, therefore the previous 91 | // keyframe is the most recent one before forMillisecond. 92 | propertyTrack[index - 1] : 93 | // Return first property. This is after forMillisecond. 94 | propertyTrack[0]; 95 | }); 96 | 97 | return latestProperties; 98 | }; 99 | 100 | /*! 101 | * Search property track `track` and find the index to the element that is 102 | * at `millisecond`. Returns `undefined` if not found. 103 | * @param {Array(KeyframeProperty)} track 104 | * @param {number} millisecond 105 | * @return {number} index or -1 if not present 106 | */ 107 | const propertyIndexInTrack = (track, millisecond) => { 108 | const index = insertionPointInTrack(track, millisecond); 109 | 110 | return track[index] && track[index].millisecond === millisecond ? 111 | index : -1; 112 | }; 113 | 114 | /*! 115 | * Mark the cache of internal KeyframeProperty data as invalid. The cache 116 | * will be rebuilt on the next call to ensurePropertyCacheValid. 117 | * @param {Actor} 118 | */ 119 | const invalidateCache = actor => actor._timelinePropertyCacheValid = false; 120 | 121 | /*! 122 | * Empty out and rebuild the cache of internal KeyframeProperty data if it 123 | * has been marked as invalid. 124 | * @param {Actor} 125 | */ 126 | const ensurePropertyCacheValid = actor => { 127 | if (actor._timelinePropertyCacheValid) { 128 | return; 129 | } 130 | 131 | actor._timelinePropertyCache = []; 132 | actor._timelineFunctionCache = []; 133 | 134 | const { 135 | _keyframeProperties, 136 | _timelineFunctionCache, 137 | _timelinePropertyCache 138 | } = actor; 139 | 140 | // Build the cache map 141 | const props = Object.keys(_keyframeProperties) 142 | .map(key => _keyframeProperties[key]) 143 | .sort((a, b) => a.millisecond - b.millisecond); 144 | 145 | let curCacheEntry = getLatestProperties(actor, 0); 146 | 147 | curCacheEntry._millisecond = 0; 148 | _timelinePropertyCache.push(curCacheEntry); 149 | 150 | props.forEach(property => { 151 | if (property.millisecond !== curCacheEntry._millisecond) { 152 | curCacheEntry = clone(curCacheEntry); 153 | curCacheEntry._millisecond = property.millisecond; 154 | _timelinePropertyCache.push(curCacheEntry); 155 | } 156 | 157 | curCacheEntry[property.name] = property; 158 | 159 | if (property.name === 'function') { 160 | _timelineFunctionCache.push(property); 161 | } 162 | }); 163 | 164 | actor._timelinePropertyCacheValid = true; 165 | }; 166 | 167 | /*! 168 | * Remove any property tracks that are empty. 169 | * @param {Actor} actor 170 | * @fires rekapi.removeKeyframePropertyTrack 171 | */ 172 | const removeEmptyPropertyTracks = actor => { 173 | const { _propertyTracks } = actor; 174 | 175 | Object.keys(_propertyTracks).forEach(trackName => { 176 | if (!_propertyTracks[trackName].length) { 177 | delete _propertyTracks[trackName]; 178 | fire(actor, 'removeKeyframePropertyTrack', trackName); 179 | } 180 | }); 181 | }; 182 | 183 | /*! 184 | * Stably sort all of the property tracks of an actor 185 | * @param {Actor} actor 186 | */ 187 | const sortPropertyTracks = actor => { 188 | each(actor._propertyTracks, (propertyTrack, trackName) => { 189 | propertyTrack = propertyTrack.sort( 190 | (a, b) => a.millisecond - b.millisecond 191 | ); 192 | 193 | propertyTrack.forEach((keyframeProperty, i) => 194 | keyframeProperty.linkToNext(propertyTrack[i + 1]) 195 | ); 196 | 197 | actor._propertyTracks[trackName] = propertyTrack; 198 | }); 199 | }; 200 | 201 | /*! 202 | * Updates internal Rekapi and Actor data after a KeyframeProperty 203 | * modification method is called. 204 | * 205 | * @param {Actor} actor 206 | * @fires rekapi.timelineModified 207 | */ 208 | const cleanupAfterKeyframeModification = actor => { 209 | sortPropertyTracks(actor); 210 | invalidateCache(actor); 211 | 212 | if (actor.rekapi) { 213 | invalidateAnimationLength(actor.rekapi); 214 | } 215 | 216 | fire(actor, 'timelineModified'); 217 | }; 218 | 219 | /** 220 | * A {@link rekapi.Actor} represents an individual component of an animation. 221 | * An animation may have one or many {@link rekapi.Actor}s. 222 | * 223 | * @param {Object} [config={}] 224 | * @param {(Object|CanvasRenderingContext2D|HTMLElement)} [config.context] Sets 225 | * {@link rekapi.Actor#context}. 226 | * @param {Function} [config.setup] Sets {@link rekapi.Actor#setup}. 227 | * @param {rekapi.render} [config.render] Sets {@link rekapi.Actor#render}. 228 | * @param {Function} [config.teardown] Sets {@link rekapi.Actor#teardown}. 229 | * @constructs rekapi.Actor 230 | */ 231 | export class Actor extends Tweenable { 232 | constructor (config = {}) { 233 | super(); 234 | 235 | /** 236 | * @member {rekapi.Rekapi|undefined} rekapi.Actor#rekapi The {@link 237 | * rekapi.Rekapi} instance to which this {@link rekapi.Actor} belongs, if 238 | * any. 239 | */ 240 | 241 | Object.assign(this, { 242 | _propertyTracks: {}, 243 | _timelinePropertyCache: [], 244 | _timelineFunctionCache: [], 245 | _timelinePropertyCacheValid: false, 246 | _keyframeProperties: {}, 247 | 248 | /** 249 | * @member {string} rekapi.Actor#id The unique ID of this {@link rekapi.Actor}. 250 | */ 251 | id: uniqueId(), 252 | 253 | /** 254 | * @member {(Object|CanvasRenderingContext2D|HTMLElement|undefined)} 255 | * [rekapi.Actor#context] If this {@link rekapi.Actor} was created by or 256 | * provided as an argument to {@link rekapi.Rekapi#addActor}, then this 257 | * member is a reference to that {@link rekapi.Rekapi}'s {@link 258 | * rekapi.Rekapi#context}. 259 | */ 260 | context: config.context, 261 | 262 | /** 263 | * @member {Function} rekapi.Actor#setup Gets called when an actor is 264 | * added to an animation by {@link rekapi.Rekapi#addActor}. 265 | */ 266 | setup: config.setup || noop, 267 | 268 | /** 269 | * @member {rekapi.render} rekapi.Actor#render The function that renders 270 | * this {@link rekapi.Actor}. 271 | */ 272 | render: config.render || noop, 273 | 274 | /** 275 | * @member {Function} rekapi.Actor#teardown Gets called when an actor is 276 | * removed from an animation by {@link rekapi.Rekapi#removeActor}. 277 | */ 278 | teardown: config.teardown || noop, 279 | 280 | /** 281 | * @member {boolean} rekapi.Actor#wasActive A flag that records whether 282 | * this {@link rekapi.Actor} had any state in the previous updated cycle. 283 | * Handy for immediate-mode renderers (such as {@link 284 | * rekapi.CanvasRenderer}) to prevent unintended renders after the actor 285 | * has no state. Also used to prevent redundant {@link 286 | * rekapi.keyframeFunction} calls. 287 | */ 288 | wasActive: true 289 | }); 290 | } 291 | 292 | /** 293 | * Create a keyframe for the actor. The animation timeline begins at `0`. 294 | * The timeline's length will automatically "grow" to accommodate new 295 | * keyframes as they are added. 296 | * 297 | * `state` should contain all of the properties that define this keyframe's 298 | * state. These properties can be any value that can be tweened by 299 | * [Shifty](http://jeremyckahn.github.io/shifty/doc/) (numbers, 300 | * RGB/hexadecimal color strings, and CSS property strings). `state` can 301 | * also be a [function]{@link rekapi.keyframeFunction}, but 302 | * [this works differently]{@tutorial keyframes-in-depth}. 303 | * 304 | * __Note:__ Internally, this creates {@link rekapi.KeyframeProperty}s and 305 | * places them on a "track." Tracks are automatically named to match the 306 | * relevant {@link rekapi.KeyframeProperty#name}s. These {@link 307 | * rekapi.KeyframeProperty}s are managed for you by the {@link rekapi.Actor} 308 | * APIs. 309 | * 310 | * ## [Click to learn about keyframes in depth]{@tutorial keyframes-in-depth} 311 | * @method rekapi.Actor#keyframe 312 | * @param {number} millisecond Where on the timeline to set the keyframe. 313 | * @param {(Object|rekapi.keyframeFunction)} state The state properties of 314 | * the keyframe. If this is an Object, the properties will be interpolated 315 | * between this and those of the following keyframe for a given point on the 316 | * animation timeline. If this is a function ({@link 317 | * rekapi.keyframeFunction}), it will be called at the keyframe specified by 318 | * `millisecond`. 319 | * @param {rekapi.easingOption} [easing] Optional easing string or Object. 320 | * If `state` is a function, this is ignored. 321 | * @return {rekapi.Actor} 322 | * @fires rekapi.timelineModified 323 | */ 324 | keyframe (millisecond, state, easing = DEFAULT_EASING) { 325 | if (state instanceof Function) { 326 | state = { 'function': state }; 327 | } 328 | 329 | each(state, (value, name) => 330 | this.addKeyframeProperty( 331 | new KeyframeProperty( 332 | millisecond, 333 | name, 334 | value, 335 | typeof easing === 'string' || Array.isArray(easing) ? 336 | easing : 337 | (easing[name] || DEFAULT_EASING) 338 | ) 339 | ) 340 | ); 341 | 342 | if (this.rekapi) { 343 | invalidateAnimationLength(this.rekapi); 344 | } 345 | 346 | invalidateCache(this); 347 | fire(this, 'timelineModified'); 348 | 349 | return this; 350 | } 351 | 352 | /** 353 | * @method rekapi.Actor#hasKeyframeAt 354 | * @param {number} millisecond Point on the timeline to query. 355 | * @param {rekapi.KeyframeProperty#name} [trackName] Optionally scope the 356 | * lookup to a particular track. 357 | * @return {boolean} Whether or not the actor has any {@link 358 | * rekapi.KeyframeProperty}s set at `millisecond`. 359 | */ 360 | hasKeyframeAt (millisecond, trackName = undefined) { 361 | const { _propertyTracks } = this; 362 | 363 | if (trackName && !_propertyTracks[trackName]) { 364 | return false; 365 | } 366 | 367 | const propertyTracks = trackName ? 368 | pick(_propertyTracks, [trackName]) : 369 | _propertyTracks; 370 | 371 | return Object.keys(propertyTracks).some(track => 372 | propertyTracks.hasOwnProperty(track) && 373 | !!this.getKeyframeProperty(track, millisecond) 374 | ); 375 | } 376 | 377 | /** 378 | * Copies all of the {@link rekapi.KeyframeProperty}s from one point on the 379 | * actor's timeline to another. This is particularly useful for animating an 380 | * actor back to its original position. 381 | * 382 | * actor 383 | * .keyframe(0, { 384 | * x: 10, 385 | * y: 15 386 | * }).keyframe(1000, { 387 | * x: 50, 388 | * y: 75 389 | * }); 390 | * 391 | * // Return the actor to its original position 392 | * actor.copyKeyframe(0, 2000); 393 | * 394 | * @method rekapi.Actor#copyKeyframe 395 | * @param {number} copyFrom The timeline millisecond to copy {@link 396 | * rekapi.KeyframeProperty}s from. 397 | * @param {number} copyTo The timeline millisecond to copy {@link 398 | * rekapi.KeyframeProperty}s to. 399 | * @return {rekapi.Actor} 400 | */ 401 | copyKeyframe (copyFrom, copyTo) { 402 | // Build the configuation objects to be passed to Actor#keyframe 403 | const sourcePositions = {}; 404 | const sourceEasings = {}; 405 | 406 | each(this._propertyTracks, (propertyTrack, trackName) => { 407 | const keyframeProperty = 408 | this.getKeyframeProperty(trackName, copyFrom); 409 | 410 | if (keyframeProperty) { 411 | sourcePositions[trackName] = keyframeProperty.value; 412 | sourceEasings[trackName] = keyframeProperty.easing; 413 | } 414 | }); 415 | 416 | this.keyframe(copyTo, sourcePositions, sourceEasings); 417 | 418 | return this; 419 | } 420 | 421 | /** 422 | * Moves all of the {@link rekapi.KeyframeProperty}s from one point on the 423 | * actor's timeline to another. Although this method does error checking for 424 | * you to make sure the operation can be safely performed, an effective 425 | * pattern is to use {@link rekapi.Actor#hasKeyframeAt} to see if there is 426 | * already a keyframe at the requested `to` destination. 427 | * 428 | * @method rekapi.Actor#moveKeyframe 429 | * @param {number} from The millisecond of the keyframe to be moved. 430 | * @param {number} to The millisecond of where the keyframe should be moved 431 | * to. 432 | * @return {boolean} Whether or not the keyframe was successfully moved. 433 | */ 434 | moveKeyframe (from, to) { 435 | if (!this.hasKeyframeAt(from) || this.hasKeyframeAt(to)) { 436 | return false; 437 | } 438 | 439 | // Move each of the relevant KeyframeProperties to the new location in the 440 | // timeline 441 | each(this._propertyTracks, (propertyTrack, trackName) => { 442 | const oldIndex = propertyIndexInTrack(propertyTrack, from); 443 | 444 | if (oldIndex !== -1) { 445 | propertyTrack[oldIndex].millisecond = to; 446 | } 447 | }); 448 | 449 | cleanupAfterKeyframeModification(this); 450 | 451 | return true; 452 | } 453 | 454 | /** 455 | * Augment the `value` or `easing` of the {@link rekapi.KeyframeProperty}s 456 | * at a given millisecond. Any {@link rekapi.KeyframeProperty}s omitted in 457 | * `state` or `easing` are not modified. 458 | * 459 | * actor.keyframe(0, { 460 | * 'x': 10, 461 | * 'y': 20 462 | * }).keyframe(1000, { 463 | * 'x': 20, 464 | * 'y': 40 465 | * }).keyframe(2000, { 466 | * 'x': 30, 467 | * 'y': 60 468 | * }) 469 | * 470 | * // Changes the state of the keyframe at millisecond 1000. 471 | * // Modifies the value of 'y' and the easing of 'x.' 472 | * actor.modifyKeyframe(1000, { 473 | * 'y': 150 474 | * }, { 475 | * 'x': 'easeFrom' 476 | * }); 477 | * 478 | * @method rekapi.Actor#modifyKeyframe 479 | * @param {number} millisecond 480 | * @param {Object} state 481 | * @param {Object} [easing={}] 482 | * @return {rekapi.Actor} 483 | */ 484 | modifyKeyframe (millisecond, state, easing = {}) { 485 | each(this._propertyTracks, (propertyTrack, trackName) => { 486 | const property = this.getKeyframeProperty(trackName, millisecond); 487 | 488 | if (property) { 489 | property.modifyWith({ 490 | value: state[trackName], 491 | easing: easing[trackName] 492 | }); 493 | } else if (state[trackName]) { 494 | this.addKeyframeProperty( 495 | new KeyframeProperty( 496 | millisecond, 497 | trackName, 498 | state[trackName], 499 | easing[trackName] 500 | ) 501 | ); 502 | } 503 | }); 504 | 505 | cleanupAfterKeyframeModification(this); 506 | 507 | return this; 508 | } 509 | 510 | /** 511 | * Remove all {@link rekapi.KeyframeProperty}s set 512 | * on the actor at a given millisecond in the animation. 513 | * 514 | * @method rekapi.Actor#removeKeyframe 515 | * @param {number} millisecond The location on the timeline of the keyframe 516 | * to remove. 517 | * @return {rekapi.Actor} 518 | * @fires rekapi.timelineModified 519 | */ 520 | removeKeyframe (millisecond) { 521 | each(this._propertyTracks, (propertyTrack, propertyName) => { 522 | const index = propertyIndexInTrack(propertyTrack, millisecond); 523 | 524 | if (index !== -1) { 525 | const keyframeProperty = propertyTrack[index]; 526 | this._deleteKeyframePropertyAt(propertyTrack, index); 527 | keyframeProperty.detach(); 528 | } 529 | }); 530 | 531 | removeEmptyPropertyTracks(this); 532 | cleanupAfterKeyframeModification(this); 533 | fire(this, 'timelineModified'); 534 | 535 | return this; 536 | } 537 | 538 | /** 539 | * Remove all {@link rekapi.KeyframeProperty}s set 540 | * on the actor. 541 | * 542 | * **NOTE**: This method does _not_ fire the `beforeRemoveKeyframeProperty` 543 | * or `removeKeyframePropertyComplete` events. This method is a bulk 544 | * operation that is more efficient than calling {@link 545 | * rekapi.Actor#removeKeyframeProperty} many times individually, but 546 | * foregoes firing events. 547 | * 548 | * @method rekapi.Actor#removeAllKeyframes 549 | * @return {rekapi.Actor} 550 | */ 551 | removeAllKeyframes () { 552 | each(this._propertyTracks, propertyTrack => 553 | propertyTrack.length = 0 554 | ); 555 | 556 | each(this._keyframeProperties, keyframeProperty => 557 | keyframeProperty.detach() 558 | ); 559 | 560 | removeEmptyPropertyTracks(this); 561 | this._keyframeProperties = {}; 562 | 563 | // Calling removeKeyframe performs some necessary post-removal cleanup, the 564 | // earlier part of this method skipped all of that for the sake of 565 | // efficiency. 566 | return this.removeKeyframe(0); 567 | } 568 | 569 | /** 570 | * @method rekapi.Actor#getKeyframeProperty 571 | * @param {string} property The name of the property track. 572 | * @param {number} millisecond The millisecond of the property in the 573 | * timeline. 574 | * @return {(rekapi.KeyframeProperty|undefined)} A {@link 575 | * rekapi.KeyframeProperty} that is stored on the actor, as specified by the 576 | * `property` and `millisecond` parameters. This is `undefined` if no 577 | * properties were found. 578 | */ 579 | getKeyframeProperty (property, millisecond) { 580 | const propertyTrack = this._propertyTracks[property]; 581 | 582 | return propertyTrack[propertyIndexInTrack(propertyTrack, millisecond)]; 583 | } 584 | 585 | /** 586 | * Modify a {@link rekapi.KeyframeProperty} stored on an actor. 587 | * Internally, this calls {@link rekapi.KeyframeProperty#modifyWith} and 588 | * then performs some cleanup. 589 | * 590 | * @method rekapi.Actor#modifyKeyframeProperty 591 | * @param {string} property The name of the {@link rekapi.KeyframeProperty} 592 | * to modify. 593 | * @param {number} millisecond The timeline millisecond of the {@link 594 | * rekapi.KeyframeProperty} to modify. 595 | * @param {Object} newProperties The properties to augment the {@link 596 | * rekapi.KeyframeProperty} with. 597 | * @return {rekapi.Actor} 598 | */ 599 | modifyKeyframeProperty (property, millisecond, newProperties) { 600 | const keyframeProperty = this.getKeyframeProperty(property, millisecond); 601 | 602 | if (keyframeProperty) { 603 | if ('millisecond' in newProperties && 604 | this.hasKeyframeAt(newProperties.millisecond, property) 605 | ) { 606 | throw new Error( 607 | `Tried to move ${property} to ${newProperties.millisecond}ms, but a keyframe property already exists there` 608 | ); 609 | } 610 | 611 | keyframeProperty.modifyWith(newProperties); 612 | cleanupAfterKeyframeModification(this); 613 | } 614 | 615 | return this; 616 | } 617 | 618 | /** 619 | * Remove a single {@link rekapi.KeyframeProperty} 620 | * from the actor. 621 | * @method rekapi.Actor#removeKeyframeProperty 622 | * @param {string} property The name of the {@link rekapi.KeyframeProperty} 623 | * to remove. 624 | * @param {number} millisecond Where in the timeline the {@link 625 | * rekapi.KeyframeProperty} to remove is. 626 | * @return {(rekapi.KeyframeProperty|undefined)} The removed 627 | * KeyframeProperty, if one was found. 628 | * @fires rekapi.beforeRemoveKeyframeProperty 629 | * @fires rekapi.removeKeyframePropertyComplete 630 | */ 631 | removeKeyframeProperty (property, millisecond) { 632 | const { _propertyTracks } = this; 633 | 634 | if (_propertyTracks[property]) { 635 | const propertyTrack = _propertyTracks[property]; 636 | const index = propertyIndexInTrack(propertyTrack, millisecond); 637 | const keyframeProperty = propertyTrack[index]; 638 | 639 | fire(this, 'beforeRemoveKeyframeProperty', keyframeProperty); 640 | this._deleteKeyframePropertyAt(propertyTrack, index); 641 | keyframeProperty.detach(); 642 | 643 | removeEmptyPropertyTracks(this); 644 | cleanupAfterKeyframeModification(this); 645 | fire(this, 'removeKeyframePropertyComplete', keyframeProperty); 646 | 647 | return keyframeProperty; 648 | } 649 | } 650 | 651 | /** 652 | * 653 | * @method rekapi.Actor#getTrackNames 654 | * @return {Array.} A list of all the track 655 | * names for a {@link rekapi.Actor}. 656 | */ 657 | getTrackNames () { 658 | return Object.keys(this._propertyTracks); 659 | } 660 | 661 | /** 662 | * Get all of the {@link rekapi.KeyframeProperty}s for a track. 663 | * @method rekapi.Actor#getPropertiesInTrack 664 | * @param {rekapi.KeyframeProperty#name} trackName The track name to query. 665 | * @return {Array(rekapi.KeyframeProperty)} 666 | */ 667 | getPropertiesInTrack (trackName) { 668 | return (this._propertyTracks[trackName] || []).slice(0); 669 | } 670 | 671 | /** 672 | * @method rekapi.Actor#getStart 673 | * @param {rekapi.KeyframeProperty#name} [trackName] Optionally scope the 674 | * lookup to a particular track. 675 | * @return {number} The millisecond of the first animating state of a {@link 676 | * rekapi.Actor} (for instance, if the first keyframe is later than 677 | * millisecond `0`). If there are no keyframes, this is `0`. 678 | */ 679 | getStart (trackName = undefined) { 680 | const { _propertyTracks } = this; 681 | const starts = []; 682 | 683 | // Null check to see if trackName was provided and is valid 684 | if (_propertyTracks.hasOwnProperty(trackName)) { 685 | const firstKeyframeProperty = _propertyTracks[trackName][0]; 686 | 687 | if (firstKeyframeProperty) { 688 | starts.push(firstKeyframeProperty.millisecond); 689 | } 690 | } else { 691 | // Loop over all property tracks and accumulate the first 692 | // keyframeProperties from non-empty tracks 693 | each(_propertyTracks, propertyTrack => { 694 | if (propertyTrack.length) { 695 | starts.push(propertyTrack[0].millisecond); 696 | } 697 | }); 698 | } 699 | 700 | return starts.length > 0 ? 701 | Math.min.apply(Math, starts) : 702 | 0; 703 | } 704 | 705 | /** 706 | * @method rekapi.Actor#getEnd 707 | * @param {rekapi.KeyframeProperty#name} [trackName] Optionally scope the 708 | * lookup to a particular keyframe track. 709 | * @return {number} The millisecond of the last state of an actor (the point 710 | * in the timeline in which it is done animating). If there are no 711 | * keyframes, this is `0`. 712 | */ 713 | getEnd (trackName = undefined) { 714 | const endingTracks = [0]; 715 | 716 | const tracksToInspect = trackName ? 717 | { [trackName]: this._propertyTracks[trackName] } : 718 | this._propertyTracks; 719 | 720 | each(tracksToInspect, propertyTrack => { 721 | if (propertyTrack.length) { 722 | endingTracks.push(propertyTrack[propertyTrack.length - 1].millisecond); 723 | } 724 | }); 725 | 726 | return Math.max.apply(Math, endingTracks); 727 | } 728 | 729 | /** 730 | * @method rekapi.Actor#getLength 731 | * @param {rekapi.KeyframeProperty#name} [trackName] Optionally scope the 732 | * lookup to a particular track. 733 | * @return {number} The length of time in milliseconds that the actor 734 | * animates for. 735 | */ 736 | getLength (trackName = undefined) { 737 | return this.getEnd(trackName) - this.getStart(trackName); 738 | } 739 | 740 | /** 741 | * Extend the last state on this actor's timeline to simulate a pause. 742 | * Internally, this method copies the final state of the actor in the 743 | * timeline to the millisecond defined by `until`. 744 | * 745 | * @method rekapi.Actor#wait 746 | * @param {number} until At what point in the animation the Actor should wait 747 | * until (relative to the start of the animation timeline). If this number 748 | * is less than the value returned from {@link rekapi.Actor#getLength}, 749 | * this method does nothing. 750 | * @return {rekapi.Actor} 751 | */ 752 | wait (until) { 753 | const end = this.getEnd(); 754 | 755 | if (until <= end) { 756 | return this; 757 | } 758 | 759 | const latestProps = getLatestProperties(this, this.getEnd()); 760 | const serializedProps = {}; 761 | const serializedEasings = {}; 762 | 763 | each(latestProps, (latestProp, propName) => { 764 | serializedProps[propName] = latestProp.value; 765 | serializedEasings[propName] = latestProp.easing; 766 | }); 767 | 768 | this.modifyKeyframe(end, serializedProps, serializedEasings); 769 | this.keyframe(until, serializedProps, serializedEasings); 770 | 771 | return this; 772 | } 773 | 774 | /*! 775 | * Insert a `KeyframeProperty` into a property track at `index`. The linked 776 | * list structure of the property track is maintained. 777 | * @method rekapi.Actor#_insertKeyframePropertyAt 778 | * @param {KeyframeProperty} keyframeProperty 779 | * @param {Array(KeyframeProperty)} propertyTrack 780 | * @param {number} index 781 | */ 782 | _insertKeyframePropertyAt (keyframeProperty, propertyTrack, index) { 783 | propertyTrack.splice(index, 0, keyframeProperty); 784 | } 785 | 786 | /*! 787 | * Remove the `KeyframeProperty` at `index` from a property track. The linked 788 | * list structure of the property track is maintained. The removed property 789 | * is not modified or unlinked internally. 790 | * @method rekapi.Actor#_deleteKeyframePropertyAt 791 | * @param {Array(KeyframeProperty)} propertyTrack 792 | * @param {number} index 793 | */ 794 | _deleteKeyframePropertyAt (propertyTrack, index) { 795 | propertyTrack.splice(index, 1); 796 | } 797 | 798 | /** 799 | * Associate a {@link rekapi.KeyframeProperty} to this {@link rekapi.Actor}. 800 | * Updates {@link rekapi.KeyframeProperty#actor} to maintain a link between 801 | * the two objects. This is a lower-level method and it is generally better 802 | * to use {@link rekapi.Actor#keyframe}. This is mostly useful for adding a 803 | * {@link rekapi.KeyframeProperty} back to an actor after it was {@link 804 | * rekapi.KeyframeProperty#detach}ed. 805 | * @method rekapi.Actor#addKeyframeProperty 806 | * @param {rekapi.KeyframeProperty} keyframeProperty 807 | * @return {rekapi.Actor} 808 | * @fires rekapi.beforeAddKeyframeProperty 809 | * @fires rekapi.addKeyframePropertyTrack 810 | * @fires rekapi.addKeyframeProperty 811 | */ 812 | addKeyframeProperty (keyframeProperty) { 813 | if (this.rekapi) { 814 | fire(this, 'beforeAddKeyframeProperty', keyframeProperty); 815 | } 816 | 817 | keyframeProperty.actor = this; 818 | this._keyframeProperties[keyframeProperty.id] = keyframeProperty; 819 | 820 | const { name } = keyframeProperty; 821 | const { _propertyTracks, rekapi } = this; 822 | 823 | if (!this._propertyTracks[name]) { 824 | _propertyTracks[name] = [keyframeProperty]; 825 | 826 | if (rekapi) { 827 | fire(this, 'addKeyframePropertyTrack', keyframeProperty); 828 | } 829 | } else { 830 | const index = insertionPointInTrack(_propertyTracks[name], keyframeProperty.millisecond); 831 | 832 | if (_propertyTracks[name][index]) { 833 | const newMillisecond = keyframeProperty.millisecond; 834 | const targetMillisecond = _propertyTracks[name][index].millisecond; 835 | 836 | if (targetMillisecond === newMillisecond) { 837 | throw new Error( 838 | `Cannot add duplicate ${name} keyframe property @ ${newMillisecond}ms` 839 | ); 840 | } else if (rekapi && rekapi._warnOnOutOfOrderKeyframes) { 841 | console.warn( 842 | new Error( 843 | `Added a keyframe property before end of ${name} track @ ${newMillisecond}ms (< ${targetMillisecond}ms)` 844 | ) 845 | ); 846 | } 847 | } 848 | 849 | this._insertKeyframePropertyAt(keyframeProperty, _propertyTracks[name], index); 850 | cleanupAfterKeyframeModification(this); 851 | } 852 | 853 | if (rekapi) { 854 | fire(this, 'addKeyframeProperty', keyframeProperty); 855 | } 856 | 857 | return this; 858 | } 859 | 860 | /*! 861 | * TODO: Explain the use case for this method 862 | * Set the actor to be active or inactive starting at `millisecond`. 863 | * @method rekapi.Actor#setActive 864 | * @param {number} millisecond The time at which to change the actor's active state 865 | * @param {boolean} isActive Whether the actor should be active or inactive 866 | * @return {rekapi.Actor} 867 | */ 868 | setActive (millisecond, isActive) { 869 | const hasActiveTrack = !!this._propertyTracks._active; 870 | const activeProperty = hasActiveTrack 871 | && this.getKeyframeProperty('_active', millisecond); 872 | 873 | if (activeProperty) { 874 | activeProperty.value = isActive; 875 | } else { 876 | this.addKeyframeProperty( 877 | new KeyframeProperty(millisecond, '_active', isActive) 878 | ); 879 | } 880 | 881 | return this; 882 | } 883 | 884 | /*! 885 | * Calculate and set the actor's position at `millisecond` in the animation. 886 | * @method rekapi.Actor#_updateState 887 | * @param {number} millisecond 888 | * @param {boolean} [resetLaterFnKeyframes] If true, allow all function 889 | * keyframes later in the timeline to be run again. 890 | */ 891 | _updateState (millisecond, resetLaterFnKeyframes = false) { 892 | const start = this.getStart(); 893 | const end = this.getEnd(); 894 | const interpolatedObject = {}; 895 | 896 | millisecond = Math.min(end, millisecond); 897 | 898 | ensurePropertyCacheValid(this); 899 | 900 | const propertyCacheEntry = clone( 901 | getPropertyCacheEntryForMillisecond(this, millisecond) 902 | ); 903 | 904 | delete propertyCacheEntry._millisecond; 905 | 906 | // All actors are active at time 0 unless otherwise specified; 907 | // make sure a future time deactivation doesn't deactive the actor 908 | // by default. 909 | if (propertyCacheEntry._active 910 | && millisecond >= propertyCacheEntry._active.millisecond) { 911 | 912 | this.wasActive = propertyCacheEntry._active.getValueAt(millisecond); 913 | 914 | if (!this.wasActive) { 915 | return this; 916 | } 917 | } else { 918 | this.wasActive = true; 919 | } 920 | 921 | if (start === end) { 922 | // If there is only one keyframe, use that for the state of the actor 923 | each(propertyCacheEntry, (keyframeProperty, propName) => { 924 | if (keyframeProperty.shouldInvokeForMillisecond(millisecond)) { 925 | keyframeProperty.invoke(); 926 | keyframeProperty.hasFired = false; 927 | return; 928 | } 929 | 930 | interpolatedObject[propName] = keyframeProperty.value; 931 | }); 932 | 933 | } else { 934 | each(propertyCacheEntry, (keyframeProperty, propName) => { 935 | if (this._beforeKeyframePropertyInterpolate !== noop) { 936 | this._beforeKeyframePropertyInterpolate(keyframeProperty); 937 | } 938 | 939 | if (keyframeProperty.shouldInvokeForMillisecond(millisecond)) { 940 | keyframeProperty.invoke(); 941 | return; 942 | } 943 | 944 | interpolatedObject[propName] = 945 | keyframeProperty.getValueAt(millisecond); 946 | 947 | if (this._afterKeyframePropertyInterpolate !== noop) { 948 | this._afterKeyframePropertyInterpolate( 949 | keyframeProperty, interpolatedObject); 950 | } 951 | }); 952 | } 953 | 954 | this.set(interpolatedObject); 955 | 956 | if (!resetLaterFnKeyframes) { 957 | this._resetFnKeyframesFromMillisecond(millisecond); 958 | } 959 | 960 | return this; 961 | } 962 | 963 | /*! 964 | * @method rekapi.Actor#_resetFnKeyframesFromMillisecond 965 | * @param {number} millisecond 966 | */ 967 | _resetFnKeyframesFromMillisecond (millisecond) { 968 | const cache = this._timelineFunctionCache; 969 | const { length } = cache; 970 | let index = sortedIndexBy(cache, { millisecond: millisecond }, getMillisecond); 971 | 972 | while (index < length) { 973 | cache[index++].hasFired = false; 974 | } 975 | } 976 | 977 | /** 978 | * Export this {@link rekapi.Actor} to a `JSON.stringify`-friendly `Object`. 979 | * @method rekapi.Actor#exportTimeline 980 | * @param {Object} [config] 981 | * @param {boolean} [config.withId=false] If `true`, include internal `id` 982 | * values in exported data. 983 | * @return {rekapi.actorData} This data can later be consumed by {@link 984 | * rekapi.Actor#importTimeline}. 985 | */ 986 | exportTimeline ({ withId = false } = {}) { 987 | const exportData = { 988 | start: this.getStart(), 989 | end: this.getEnd(), 990 | trackNames: this.getTrackNames(), 991 | propertyTracks: {} 992 | }; 993 | 994 | if (withId) { 995 | exportData.id = this.id; 996 | } 997 | 998 | each(this._propertyTracks, (propertyTrack, trackName) => { 999 | const track = []; 1000 | 1001 | propertyTrack.forEach(keyframeProperty => { 1002 | track.push(keyframeProperty.exportPropertyData({ withId })); 1003 | }); 1004 | 1005 | exportData.propertyTracks[trackName] = track; 1006 | }); 1007 | 1008 | return exportData; 1009 | } 1010 | 1011 | /** 1012 | * Import an Object to augment this actor's state. This does not remove 1013 | * keyframe properties before importing new ones. 1014 | * @method rekapi.Actor#importTimeline 1015 | * @param {rekapi.actorData} actorData Any object that has the same data 1016 | * format as the object generated from {@link rekapi.Actor#exportTimeline}. 1017 | */ 1018 | importTimeline (actorData) { 1019 | each(actorData.propertyTracks, propertyTrack => { 1020 | propertyTrack.forEach(property => { 1021 | this.keyframe( 1022 | property.millisecond, 1023 | { [property.name]: property.value }, 1024 | property.easing 1025 | ); 1026 | }); 1027 | }); 1028 | } 1029 | } 1030 | 1031 | Object.assign(Actor.prototype, { 1032 | /*! 1033 | * @method rekapi.Actor#_beforeKeyframePropertyInterpolate 1034 | * @param {KeyframeProperty} keyframeProperty 1035 | * @abstract 1036 | */ 1037 | _beforeKeyframePropertyInterpolate: noop, 1038 | 1039 | /*! 1040 | * @method rekapi.Actor#_afterKeyframePropertyInterpolate 1041 | * @param {KeyframeProperty} keyframeProperty 1042 | * @param {Object} interpolatedObject 1043 | * @abstract 1044 | */ 1045 | _afterKeyframePropertyInterpolate: noop 1046 | }); 1047 | -------------------------------------------------------------------------------- /src/keyframe-property.js: -------------------------------------------------------------------------------- 1 | import { interpolate } from 'shifty'; 2 | import { 3 | fireEvent 4 | } from './rekapi'; 5 | import { 6 | pick, 7 | uniqueId 8 | } from './utils'; 9 | 10 | const DEFAULT_EASING = 'linear'; 11 | 12 | /** 13 | * Represents an individual component of an {@link rekapi.Actor}'s keyframe 14 | * state. In most cases you won't need to deal with this object directly, as 15 | * the {@link rekapi.Actor} APIs abstract a lot of what this Object does away 16 | * for you. 17 | * @param {number} millisecond Sets {@link 18 | * rekapi.KeyframeProperty#millisecond}. 19 | * @param {string} name Sets {@link rekapi.KeyframeProperty#name}. 20 | * @param {(number|string|boolean|rekapi.keyframeFunction)} value Sets {@link 21 | * rekapi.KeyframeProperty#value}. 22 | * @param {rekapi.easingOption} [easing="linear"] Sets {@link 23 | * rekapi.KeyframeProperty#easing}. 24 | * @constructs rekapi.KeyframeProperty 25 | */ 26 | export class KeyframeProperty { 27 | constructor (millisecond, name, value, easing = DEFAULT_EASING) { 28 | /** 29 | * @member {string} rekapi.KeyframeProperty#id The unique ID of this {@link 30 | * rekapi.KeyframeProperty}. 31 | */ 32 | this.id = uniqueId('keyframeProperty_'); 33 | 34 | /** 35 | * @member {boolean} rekapi.KeyframeProperty#hasFired Flag to determine if 36 | * this {@link rekapi.KeyframeProperty}'s {@link rekapi.keyframeFunction} 37 | * should be invoked in the current animation loop. 38 | */ 39 | this.hasFired = null; 40 | 41 | /** 42 | * @member {(rekapi.Actor|undefined)} rekapi.KeyframeProperty#actor The 43 | * {@link rekapi.Actor} to which this {@link rekapi.KeyframeProperty} 44 | * belongs, if any. 45 | */ 46 | 47 | /** 48 | * @member {(rekapi.KeyframeProperty|null)} 49 | * rekapi.KeyframeProperty#nextProperty A reference to the {@link 50 | * rekapi.KeyframeProperty} that follows this one in a {@link 51 | * rekapi.Actor}'s property track. 52 | */ 53 | this.nextProperty = null; 54 | 55 | Object.assign(this, { 56 | /** 57 | * @member {number} rekapi.KeyframeProperty#millisecond Where on the 58 | * animation timeline this {@link rekapi.KeyframeProperty} is. 59 | */ 60 | millisecond, 61 | /** 62 | * @member {string} rekapi.KeyframeProperty#name This {@link 63 | * rekapi.KeyframeProperty}'s name, such as `"x"` or `"opacity"`. 64 | */ 65 | name, 66 | /** 67 | * @member {number|string|boolean|rekapi.keyframeFunction} 68 | * rekapi.KeyframeProperty#value The value that this {@link 69 | * rekapi.KeyframeProperty} represents. 70 | */ 71 | value, 72 | /** 73 | * @member {rekapi.easingOption} rekapi.KeyframeProperty#easing The 74 | * easing curve by which this {@link rekapi.KeyframeProperty} should be 75 | * animated. 76 | */ 77 | easing 78 | }); 79 | } 80 | 81 | /** 82 | * Modify this {@link rekapi.KeyframeProperty}. 83 | * @method rekapi.KeyframeProperty#modifyWith 84 | * @param {Object} newProperties Valid values are: 85 | * @param {number} [newProperties.millisecond] Sets {@link 86 | * rekapi.KeyframeProperty#millisecond}. 87 | * @param {string} [newProperties.name] Sets {@link rekapi.KeyframeProperty#name}. 88 | * @param {(number|string|boolean|rekapi.keyframeFunction)} [newProperties.value] Sets {@link 89 | * rekapi.KeyframeProperty#value}. 90 | * @param {string} [newProperties.easing] Sets {@link 91 | * rekapi.KeyframeProperty#easing}. 92 | */ 93 | modifyWith (newProperties) { 94 | Object.assign(this, newProperties); 95 | } 96 | 97 | /** 98 | * Calculate the midpoint between this {@link rekapi.KeyframeProperty} and 99 | * the next {@link rekapi.KeyframeProperty} in a {@link rekapi.Actor}'s 100 | * property track. 101 | * 102 | * In just about all cases, `millisecond` should be between this {@link 103 | * rekapi.KeyframeProperty}'s `millisecond` and the `millisecond` of the 104 | * {@link rekapi.KeyframeProperty} that follows it in the animation 105 | * timeline, but it is valid to specify a value outside of this range. 106 | * @method rekapi.KeyframeProperty#getValueAt 107 | * @param {number} millisecond The millisecond in the animation timeline to 108 | * compute the state value for. 109 | * @return {(number|string|boolean|rekapi.keyframeFunction|rekapi.KeyframeProperty#value)} 110 | */ 111 | getValueAt (millisecond) { 112 | const nextProperty = this.nextProperty; 113 | 114 | if (typeof this.value === 'boolean') { 115 | return this.value; 116 | } else if (nextProperty) { 117 | const boundedMillisecond = Math.min( 118 | Math.max(millisecond, this.millisecond), 119 | nextProperty.millisecond 120 | ); 121 | 122 | const { name } = this; 123 | const delta = nextProperty.millisecond - this.millisecond; 124 | const interpolatePosition = 125 | (boundedMillisecond - this.millisecond) / delta; 126 | 127 | return interpolate( 128 | { [name]: this.value }, 129 | { [name]: nextProperty.value }, 130 | interpolatePosition, 131 | nextProperty.easing 132 | )[name]; 133 | } else { 134 | return this.value; 135 | } 136 | } 137 | 138 | /** 139 | * Create the reference to the {@link rekapi.KeyframeProperty} that follows 140 | * this one on a {@link rekapi.Actor}'s property track. Property tracks 141 | * are just linked lists of {@link rekapi.KeyframeProperty}s. 142 | * @method rekapi.KeyframeProperty#linkToNext 143 | * @param {KeyframeProperty=} nextProperty The {@link 144 | * rekapi.KeyframeProperty} that should immediately follow this one on the 145 | * animation timeline. 146 | */ 147 | linkToNext (nextProperty = null) { 148 | this.nextProperty = nextProperty; 149 | } 150 | 151 | /** 152 | * Disassociates this {@link rekapi.KeyframeProperty} from its {@link 153 | * rekapi.Actor}. This is called by various {@link rekapi.Actor} methods 154 | * and triggers the [removeKeyframeProperty]{@link rekapi.Rekapi#on} event 155 | * on the associated {@link rekapi.Rekapi} instance. 156 | * @method rekapi.KeyframeProperty#detach 157 | * @fires rekapi.removeKeyframeProperty 158 | */ 159 | detach () { 160 | const { actor } = this; 161 | 162 | if (actor && actor.rekapi) { 163 | fireEvent(actor.rekapi, 'removeKeyframeProperty', this); 164 | delete actor._keyframeProperties[this.id]; 165 | this.actor = null; 166 | } 167 | 168 | return this; 169 | } 170 | 171 | /** 172 | * Export this {@link rekapi.KeyframeProperty} to a `JSON.stringify`-friendly 173 | * `Object`. 174 | * @method rekapi.KeyframeProperty#exportPropertyData 175 | * @param {Object} [config] 176 | * @param {boolean} [config.withId=false] If `true`, include internal `id` 177 | * value in exported data. 178 | * @return {rekapi.propertyData} 179 | */ 180 | exportPropertyData ({ withId = false } = {}) { 181 | const props = ['millisecond', 'name', 'value', 'easing']; 182 | 183 | if (withId) { 184 | props.push('id'); 185 | } 186 | 187 | return pick(this, props); 188 | } 189 | 190 | /*! 191 | * Whether or not this is a function keyframe and should be invoked for the 192 | * current frame. Helper method for Actor. 193 | * @method rekapi.KeyframeProperty#shouldInvokeForMillisecond 194 | * @return {boolean} 195 | */ 196 | shouldInvokeForMillisecond (millisecond) { 197 | return (millisecond >= this.millisecond && 198 | this.name === 'function' && 199 | !this.hasFired 200 | ); 201 | } 202 | 203 | /** 204 | * Calls {@link rekapi.KeyframeProperty#value} if it is a {@link 205 | * rekapi.keyframeFunction}. 206 | * @method rekapi.KeyframeProperty#invoke 207 | * @return {any} Whatever value is returned for this {@link 208 | * rekapi.KeyframeProperty}'s {@link rekapi.keyframeFunction}. 209 | */ 210 | invoke () { 211 | const drift = this.actor.rekapi._loopPosition - this.millisecond; 212 | const returnValue = this.value(this.actor, drift); 213 | this.hasFired = true; 214 | 215 | return returnValue; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @namespace rekapi 3 | */ 4 | 5 | /** 6 | * Either the name of an [easing 7 | * curve](https://jeremyckahn.github.io/shifty/doc/Tweenable.html#.formulas) or 8 | * an array of four `number`s (`[x1, y1, x2, y2]`) that represent the points of 9 | * a [Bezier curve](https://cubic-bezier.com/). 10 | * @typedef rekapi.easingOption 11 | * @type {string|Array.} 12 | * @see {@link https://jeremyckahn.github.io/shifty/doc/tutorial-easing-function-in-depth.html} 13 | */ 14 | 15 | /** 16 | * An Object that provides utilities for rendering a {@link rekapi.Actor}. 17 | * @typedef {Object} rekapi.renderer 18 | * @property {rekapi.render} render A function that renders a {@link 19 | * rekapi.Actor}. 20 | */ 21 | 22 | /** 23 | * @typedef {Object} rekapi.propertyData 24 | * @property {number|string} value 25 | * @property {number} millisecond 26 | * @property {string} easing 27 | * @property {string} name 28 | * @property {string|undefined} id 29 | */ 30 | 31 | /** 32 | * @typedef {Object} rekapi.actorData 33 | * @property {Array.} trackNames The values of this array must 34 | * correspond 1:1 to the key names in `propertyTracks`. 35 | * @property {Object.>} propertyTracks 36 | * @property {number} end 37 | * @property {number} start 38 | * @property {string|undefined} id 39 | */ 40 | 41 | /** 42 | * The properties of this object are used as arguments provided to 43 | * [`shifty.setBezierFunction`](http://jeremyckahn.github.io/shifty/doc/shifty.html#.setBezierFunction). 44 | * @typedef {Object} rekapi.curveData 45 | * @property {number} x1 46 | * @property {number} x2 47 | * @property {number} y1 48 | * @property {number} y2 49 | * @property {string} displayName 50 | */ 51 | 52 | /** 53 | * The `JSON.stringify`-friendly data format for serializing a Rekapi 54 | * animation. 55 | * @typedef {Object} rekapi.timelineData 56 | * @property {Array.} actors 57 | * @property {Object.} curves 58 | * @property {number} duration 59 | */ 60 | 61 | /** 62 | * A function that is called when an event is fired. See the events listed 63 | * below for details on the types of events that Rekapi supports. 64 | * @callback rekapi.eventHandler 65 | * @param {rekapi.Rekapi} rekapi A {@link rekapi.Rekapi} instance. 66 | * @param {Object} data Data provided from the event (see {@link 67 | * rekapi.Rekapi#on} for details). 68 | */ 69 | 70 | /** 71 | * A function that gets called every time the actor's state is updated (once 72 | * every frame). This function should do something meaningful with the state of 73 | * the actor (for example, visually rendering to the screen). 74 | * @callback rekapi.render 75 | * @param {Object} context An actor's {@link rekapi.Actor#context} Object. 76 | * @param {Object} state An actor's current state properties. 77 | */ 78 | 79 | /** 80 | * @callback rekapi.keyframeFunction 81 | * @param {rekapi.Actor} actor The {@link rekapi.Actor} to which this 82 | * {@link rekapi.keyframeFunction} was provided. 83 | * @param {number} drift A number that represents the delay between when the 84 | * function is called and when it was scheduled. There is typically some amount 85 | * of delay due to the nature of JavaScript timers. 86 | */ 87 | 88 | /** 89 | * @callback rekapi.actorSortFunction 90 | * @param {rekapi.Actor} actor A {@link rekapi.Actor} that should expose a 91 | * `number` value to sort by. 92 | * @return {number} 93 | */ 94 | 95 | /** 96 | * Fires when all animation loops have completed. 97 | * @event rekapi.animationComplete 98 | */ 99 | /** 100 | * Fires when the animation is played, paused, or stopped. 101 | * @event rekapi.playStateChange 102 | */ 103 | /** 104 | * Fires when the animation is {@link rekapi.Rekapi#play}ed. 105 | * @event rekapi.play 106 | */ 107 | /** 108 | * Fires when the animation is {@link rekapi.Rekapi#pause}d. 109 | * @event rekapi.pause 110 | */ 111 | /** 112 | * Fires when the animation is {@link rekapi.Rekapi#stop}ped. 113 | * @event rekapi.stop 114 | */ 115 | /** 116 | * Fires each frame before all actors are rendered. 117 | * @event rekapi.beforeUpdate 118 | */ 119 | /** 120 | * Fires each frame after all actors are rendered. 121 | * @event rekapi.afterUpdate 122 | */ 123 | /** 124 | * @event rekapi.addActor 125 | * @param {rekapi.Actor} actor The {@link rekapi.Actor} that was added. 126 | */ 127 | /** 128 | * @event rekapi.removeActor 129 | * @param {rekapi.Actor} actor The {@link rekapi.Actor} that was removed. 130 | */ 131 | /** 132 | * Fires just before the point where a {@link rekapi.KeyframeProperty} is added 133 | * to the timeline. This event is called before any modifications to the 134 | * timeline are done. 135 | * @event rekapi.beforeAddKeyframeProperty 136 | */ 137 | /** 138 | * @event rekapi.addKeyframeProperty 139 | * @param {rekapi.KeyframeProperty} keyframeProperty The {@link 140 | * rekapi.KeyframeProperty} that was added. 141 | */ 142 | /** 143 | * Fires just before the point where a {@link rekapi.KeyframeProperty} is 144 | * removed. This event is called before any modifications to the timeline are 145 | * done. 146 | * @event rekapi.beforeRemoveKeyframeProperty 147 | */ 148 | /** 149 | * Fires when a {@link rekapi.KeyframeProperty} is removed. This event is 150 | * fired _before_ the internal state of the keyframe (but not the timeline, in 151 | * contrast to {@link rekapi.event:beforeRemoveKeyframeProperty}) has been 152 | * updated to reflect the keyframe property removal (this is in contrast to 153 | * {@link rekapi.event:removeKeyframePropertyComplete}). 154 | * @event rekapi.removeKeyframeProperty 155 | * @param {rekapi.KeyframeProperty} keyframeProperty The {@link 156 | * rekapi.KeyframeProperty} that was removed. 157 | */ 158 | /** 159 | * Fires when a {@link rekapi.KeyframeProperty} has finished being removed from 160 | * the timeline. Unlike {@link rekapi.event:removeKeyframeProperty}, this is 161 | * fired _after_ the internal state of Rekapi has been updated to reflect the 162 | * removal of the keyframe property. 163 | * @event rekapi.removeKeyframePropertyComplete 164 | * @param {rekapi.KeyframeProperty} keyframeProperty The {@link 165 | * rekapi.KeyframeProperty} that was removed. 166 | */ 167 | /** 168 | * Fires when the a keyframe is added to an actor that creates a new keyframe 169 | * property track. 170 | * @event rekapi.addKeyframePropertyTrack 171 | * @param {rekapi.KeyframeProperty} keyframeProperty The {@link 172 | * rekapi.KeyframeProperty} that was added to create the property track. 173 | */ 174 | /** 175 | * Fires when the last keyframe property in an actor's keyframe property track 176 | * is removed. Rekapi automatically removes property tracks when they are 177 | * emptied out, which causes this event to be fired. 178 | * @event rekapi.removeKeyframePropertyTrack 179 | * @param {string} trackName name of the track that was removed. 180 | */ 181 | /** 182 | * Fires when a keyframe is added, modified or removed. 183 | * @event rekapi.timelineModified 184 | */ 185 | /** 186 | * Fires when an animation loop ends and a new one begins. 187 | * @event rekapi.animationLooped 188 | */ 189 | 190 | export { Rekapi } from './rekapi'; 191 | export { Actor } from './actor'; 192 | export { KeyframeProperty } from './keyframe-property'; 193 | export { CanvasRenderer } from './renderers/canvas'; 194 | export { DOMRenderer } from './renderers/dom'; 195 | -------------------------------------------------------------------------------- /src/rekapi.js: -------------------------------------------------------------------------------- 1 | import { Tweenable, setBezierFunction } from 'shifty'; 2 | import { Actor } from './actor'; 3 | 4 | import { 5 | each, 6 | pick, 7 | without 8 | } from './utils'; 9 | 10 | const UPDATE_TIME = 1000 / 60; 11 | 12 | export const DEFAULT_EASING = 'linear'; 13 | 14 | /*! 15 | * Fire an event bound to a Rekapi. 16 | * @param {Rekapi} rekapi 17 | * @param {string} eventName 18 | * @param {Object} [data={}] Optional event-specific data 19 | */ 20 | export const fireEvent = (rekapi, eventName, data = {}) => 21 | rekapi._events[eventName].forEach(handler => handler(rekapi, data)); 22 | 23 | /*! 24 | * @param {Rekapi} rekapi 25 | */ 26 | export const invalidateAnimationLength = rekapi => 27 | rekapi._animationLengthValid = false; 28 | 29 | /*! 30 | * Determines which iteration of the loop the animation is currently in. 31 | * @param {Rekapi} rekapi 32 | * @param {number} timeSinceStart 33 | */ 34 | export const determineCurrentLoopIteration = (rekapi, timeSinceStart) => { 35 | const animationLength = rekapi.getAnimationLength(); 36 | 37 | if (animationLength === 0) { 38 | return timeSinceStart; 39 | } 40 | 41 | return Math.floor(timeSinceStart / animationLength); 42 | }; 43 | 44 | /*! 45 | * Calculate how many milliseconds since the animation began. 46 | * @param {Rekapi} rekapi 47 | * @return {number} 48 | */ 49 | export const calculateTimeSinceStart = rekapi => 50 | Tweenable.now() - rekapi._loopTimestamp; 51 | 52 | /*! 53 | * Determines if the animation is complete or not. 54 | * @param {Rekapi} rekapi 55 | * @param {number} currentLoopIteration 56 | * @return {boolean} 57 | */ 58 | export const isAnimationComplete = (rekapi, currentLoopIteration) => 59 | currentLoopIteration >= rekapi._timesToIterate 60 | && rekapi._timesToIterate !== -1; 61 | 62 | /*! 63 | * Stops the animation if it is complete. 64 | * @param {Rekapi} rekapi 65 | * @param {number} currentLoopIteration 66 | * @fires rekapi.animationComplete 67 | */ 68 | export const updatePlayState = (rekapi, currentLoopIteration) => { 69 | if (isAnimationComplete(rekapi, currentLoopIteration)) { 70 | rekapi.stop(); 71 | fireEvent(rekapi, 'animationComplete'); 72 | } 73 | }; 74 | 75 | /*! 76 | * Calculate how far in the animation loop `rekapi` is, in milliseconds, 77 | * based on the current time. Also overflows into a new loop if necessary. 78 | * @param {Rekapi} rekapi 79 | * @param {number} forMillisecond 80 | * @param {number} currentLoopIteration 81 | * @return {number} 82 | */ 83 | export const calculateLoopPosition = (rekapi, forMillisecond, currentLoopIteration) => { 84 | const animationLength = rekapi.getAnimationLength(); 85 | 86 | return animationLength === 0 ? 87 | 0 : 88 | isAnimationComplete(rekapi, currentLoopIteration) ? 89 | animationLength : 90 | forMillisecond % animationLength; 91 | }; 92 | 93 | /*! 94 | * Calculate the timeline position and state for a given millisecond. 95 | * Updates the `rekapi` state internally and accounts for how many loop 96 | * iterations the animation runs for. 97 | * @param {Rekapi} rekapi 98 | * @param {number} forMillisecond 99 | * @fires rekapi.animationLooped 100 | */ 101 | export const updateToMillisecond = (rekapi, forMillisecond) => { 102 | const currentIteration = determineCurrentLoopIteration(rekapi, forMillisecond); 103 | const loopPosition = calculateLoopPosition( 104 | rekapi, forMillisecond, currentIteration 105 | ); 106 | 107 | rekapi._loopPosition = loopPosition; 108 | 109 | const keyframeResetList = []; 110 | 111 | if (currentIteration > rekapi._latestIteration) { 112 | fireEvent(rekapi, 'animationLooped'); 113 | 114 | rekapi._actors.forEach(actor => { 115 | 116 | const { _keyframeProperties } = actor; 117 | const fnKeyframes = Object.keys(_keyframeProperties).reduce( 118 | (acc, propertyId) => { 119 | const property = _keyframeProperties[propertyId]; 120 | 121 | if (property.name === 'function') { 122 | acc.push(property); 123 | } 124 | 125 | return acc; 126 | }, 127 | [] 128 | ); 129 | 130 | const lastFnKeyframe = fnKeyframes[fnKeyframes.length - 1]; 131 | 132 | if (lastFnKeyframe && !lastFnKeyframe.hasFired) { 133 | lastFnKeyframe.invoke(); 134 | } 135 | 136 | keyframeResetList.push(...fnKeyframes); 137 | }); 138 | } 139 | 140 | rekapi._latestIteration = currentIteration; 141 | rekapi.update(loopPosition, true); 142 | updatePlayState(rekapi, currentIteration); 143 | 144 | keyframeResetList.forEach(fnKeyframe => { 145 | fnKeyframe.hasFired = false; 146 | }); 147 | }; 148 | 149 | /*! 150 | * Calculate how far into the animation loop `rekapi` is, in milliseconds, 151 | * and update based on that time. 152 | * @param {Rekapi} rekapi 153 | */ 154 | export const updateToCurrentMillisecond = rekapi => 155 | updateToMillisecond(rekapi, calculateTimeSinceStart(rekapi)); 156 | 157 | /*! 158 | * This is the heartbeat of an animation. This updates `rekapi`'s state and 159 | * then calls itself continuously. 160 | * @param {Rekapi} rekapi 161 | */ 162 | const tick = rekapi => 163 | // Need to check for .call presence to get around an IE limitation. See 164 | // annotation for cancelLoop for more info. 165 | rekapi._loopId = rekapi._scheduleUpdate.call ? 166 | rekapi._scheduleUpdate.call(global, rekapi._updateFn, UPDATE_TIME) : 167 | setTimeout(rekapi._updateFn, UPDATE_TIME); 168 | 169 | /*! 170 | * @return {Function} 171 | */ 172 | const getUpdateMethod = () => 173 | // requestAnimationFrame() shim by Paul Irish (modified for Rekapi) 174 | // http://paulirish.com/2011/requestanimationframe-for-smart-animating/ 175 | global.requestAnimationFrame || 176 | global.webkitRequestAnimationFrame || 177 | global.oRequestAnimationFrame || 178 | global.msRequestAnimationFrame || 179 | (global.mozCancelRequestAnimationFrame && global.mozRequestAnimationFrame) || 180 | global.setTimeout; 181 | 182 | /*! 183 | * @return {Function} 184 | */ 185 | const getCancelMethod = () => 186 | global.cancelAnimationFrame || 187 | global.webkitCancelAnimationFrame || 188 | global.oCancelAnimationFrame || 189 | global.msCancelAnimationFrame || 190 | global.mozCancelRequestAnimationFrame || 191 | global.clearTimeout; 192 | 193 | /*! 194 | * Cancels an update loop. This abstraction is needed to get around the fact 195 | * that in IE, clearTimeout is not technically a function 196 | * (https://twitter.com/kitcambridge/status/206655060342603777) and thus 197 | * Function.prototype.call cannot be used upon it. 198 | * @param {Rekapi} rekapi 199 | */ 200 | const cancelLoop = rekapi => 201 | rekapi._cancelUpdate.call ? 202 | rekapi._cancelUpdate.call(global, rekapi._loopId) : 203 | clearTimeout(rekapi._loopId); 204 | 205 | const STOPPED = 'stopped'; 206 | const PAUSED = 'paused'; 207 | const PLAYING = 'playing'; 208 | 209 | /*! 210 | * @type {Object.} Contains the context init function to be called in 211 | * the Rekapi constructor. This array is populated by modules in the 212 | * renderers/ directory. 213 | */ 214 | export const rendererBootstrappers = []; 215 | 216 | /** 217 | * If this is a rendered animation, the appropriate renderer is accessible as 218 | * `this.renderer`. If provided, a reference to `context` is accessible 219 | * as `this.context`. 220 | * @param {(Object|CanvasRenderingContext2D|HTMLElement)} [context={}] Sets 221 | * {@link rekapi.Rekapi#context}. This determines how to render the animation. 222 | * {@link rekapi.Rekapi} will also automatically set up all necessary {@link 223 | * rekapi.Rekapi#renderers} based on this value: 224 | * 225 | * * If this is not provided or is a plain object (`{}`), the animation will 226 | * not render anything and {@link rekapi.Rekapi#renderers} will be empty. 227 | * * If this is a 228 | * [`CanvasRenderingContext2D`](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D), 229 | * {@link rekapi.Rekapi#renderers} will contain a {@link 230 | * rekapi.CanvasRenderer}. 231 | * * If this is a DOM element, {@link rekapi.Rekapi#renderers} will contain a 232 | * {@link rekapi.DOMRenderer}. 233 | * @constructs rekapi.Rekapi 234 | */ 235 | export class Rekapi { 236 | constructor (context = {}) { 237 | /** 238 | * @member {(Object|CanvasRenderingContext2D|HTMLElement)} 239 | * rekapi.Rekapi#context The rendering context for an animation. 240 | * @default {} 241 | */ 242 | this.context = context; 243 | this._actors = []; 244 | this._playState = STOPPED; 245 | 246 | /** 247 | * @member {(rekapi.actorSortFunction|null)} rekapi.Rekapi#sort Optional 248 | * function for sorting the render order of {@link rekapi.Actor}s. If set, 249 | * this is called each frame before the {@link rekapi.Actor}s are rendered. 250 | * If not set, {@link rekapi.Actor}s will render in the order they were 251 | * added via {@link rekapi.Rekapi#addActor}. 252 | * 253 | * The following example assumes that all {@link rekapi.Actor}s are circles 254 | * that have a `radius` {@link rekapi.KeyframeProperty}. The circles will 255 | * be rendered in order of the value of their `radius`, from smallest to 256 | * largest. This has the effect of layering larger circles on top of 257 | * smaller circles, thus giving a sense of perspective. 258 | * 259 | * const rekapi = new Rekapi(); 260 | * rekapi.sort = actor => actor.get().radius; 261 | * @default null 262 | */ 263 | this.sort = null; 264 | 265 | this._events = { 266 | animationComplete: [], 267 | playStateChange: [], 268 | play: [], 269 | pause: [], 270 | stop: [], 271 | beforeUpdate: [], 272 | afterUpdate: [], 273 | addActor: [], 274 | removeActor: [], 275 | beforeAddKeyframeProperty: [], 276 | addKeyframeProperty: [], 277 | removeKeyframeProperty: [], 278 | removeKeyframePropertyComplete: [], 279 | beforeRemoveKeyframeProperty: [], 280 | addKeyframePropertyTrack: [], 281 | removeKeyframePropertyTrack: [], 282 | timelineModified: [], 283 | animationLooped: [] 284 | }; 285 | 286 | // How many times to loop the animation before stopping 287 | this._timesToIterate = -1; 288 | 289 | // Millisecond duration of the animation 290 | this._animationLength = 0; 291 | this._animationLengthValid = false; 292 | 293 | // The setTimeout ID of `tick` 294 | this._loopId = null; 295 | 296 | // The UNIX time at which the animation loop started 297 | this._loopTimestamp = null; 298 | 299 | // Used for maintaining position when the animation is paused 300 | this._pausedAtTime = null; 301 | 302 | // The last millisecond position that was updated 303 | this._lastUpdatedMillisecond = 0; 304 | 305 | // The most recent loop iteration a frame was calculated for 306 | this._latestIteration = 0; 307 | 308 | // The most recent millisecond position within the loop that the animation 309 | // was updated to 310 | this._loopPosition = null; 311 | 312 | this._scheduleUpdate = getUpdateMethod(); 313 | this._cancelUpdate = getCancelMethod(); 314 | 315 | this._updateFn = () => { 316 | tick(this); 317 | updateToCurrentMillisecond(this); 318 | }; 319 | 320 | /** 321 | * @member {Array.} rekapi.Rekapi#renderers Instances of 322 | * {@link rekapi.renderer} classes, as inferred by the `context` 323 | * parameter provided to the {@link rekapi.Rekapi} constructor. You can 324 | * add more renderers to this list manually; see the {@tutorial 325 | * multiple-renderers} tutorial for an example. 326 | */ 327 | this.renderers = rendererBootstrappers 328 | .map(renderer => renderer(this)) 329 | .filter(_ => _); 330 | } 331 | 332 | /** 333 | * Add a {@link rekapi.Actor} to the animation. Decorates the added {@link 334 | * rekapi.Actor} with a reference to this {@link rekapi.Rekapi} instance as 335 | * {@link rekapi.Actor#rekapi}. 336 | * 337 | * @method rekapi.Rekapi#addActor 338 | * @param {(rekapi.Actor|Object)} [actor={}] If this is an `Object`, it is used to as 339 | * the constructor parameters for a new {@link rekapi.Actor} instance that 340 | * is created by this method. 341 | * @return {rekapi.Actor} The {@link rekapi.Actor} that was added. 342 | * @fires rekapi.addActor 343 | */ 344 | addActor (actor = {}) { 345 | const rekapiActor = actor instanceof Actor ? 346 | actor : 347 | new Actor(actor); 348 | 349 | // You can't add an actor more than once. 350 | if (~this._actors.indexOf(rekapiActor)) { 351 | return rekapiActor; 352 | } 353 | 354 | rekapiActor.context = rekapiActor.context || this.context; 355 | rekapiActor.rekapi = this; 356 | 357 | // Store a reference to the actor internally 358 | this._actors.push(rekapiActor); 359 | 360 | invalidateAnimationLength(this); 361 | rekapiActor.setup(); 362 | 363 | fireEvent(this, 'addActor', rekapiActor); 364 | 365 | return rekapiActor; 366 | } 367 | 368 | /** 369 | * @method rekapi.Rekapi#getActor 370 | * @param {number} actorId 371 | * @return {rekapi.Actor} A reference to an actor from the animation by its 372 | * `id`. You can use {@link rekapi.Rekapi#getActorIds} to get a list of IDs 373 | * for all actors in the animation. 374 | */ 375 | getActor (actorId) { 376 | return this._actors.filter(actor => actor.id === actorId)[0]; 377 | } 378 | 379 | /** 380 | * @method rekapi.Rekapi#getActorIds 381 | * @return {Array.} The `id`s of all {@link rekapi.Actor}`s in the 382 | * animation. 383 | */ 384 | getActorIds () { 385 | return this._actors.map(actor => actor.id); 386 | } 387 | 388 | /** 389 | * @method rekapi.Rekapi#getAllActors 390 | * @return {Array.} All {@link rekapi.Actor}s in the animation. 391 | */ 392 | getAllActors () { 393 | return this._actors.slice(); 394 | } 395 | 396 | /** 397 | * @method rekapi.Rekapi#getActorCount 398 | * @return {number} The number of {@link rekapi.Actor}s in the animation. 399 | */ 400 | getActorCount () { 401 | return this._actors.length; 402 | } 403 | 404 | /** 405 | * Remove an actor from the animation. This does not destroy the actor, it 406 | * only removes the link between it and this {@link rekapi.Rekapi} instance. 407 | * This method calls the actor's {@link rekapi.Actor#teardown} method, if 408 | * defined. 409 | * @method rekapi.Rekapi#removeActor 410 | * @param {rekapi.Actor} actor 411 | * @return {rekapi.Actor} The {@link rekapi.Actor} that was removed. 412 | * @fires rekapi.removeActor 413 | */ 414 | removeActor (actor) { 415 | // Remove the link between Rekapi and actor 416 | this._actors = without(this._actors, actor); 417 | delete actor.rekapi; 418 | 419 | actor.teardown(); 420 | invalidateAnimationLength(this); 421 | 422 | fireEvent(this, 'removeActor', actor); 423 | 424 | return actor; 425 | } 426 | 427 | /** 428 | * Remove all {@link rekapi.Actor}s from the animation. 429 | * @method rekapi.Rekapi#removeAllActors 430 | * @return {Array.} The {@link rekapi.Actor}s that were 431 | * removed. 432 | */ 433 | removeAllActors () { 434 | return this.getAllActors().map(actor => this.removeActor(actor)); 435 | } 436 | 437 | /** 438 | * Play the animation. 439 | * 440 | * @method rekapi.Rekapi#play 441 | * @param {number} [iterations=-1] If omitted, the animation will loop 442 | * endlessly. 443 | * @return {rekapi.Rekapi} 444 | * @fires rekapi.playStateChange 445 | * @fires rekapi.play 446 | */ 447 | play (iterations = -1) { 448 | cancelLoop(this); 449 | 450 | if (this._playState === PAUSED) { 451 | // Move the playhead to the correct position in the timeline if resuming 452 | // from a pause 453 | this._loopTimestamp += Tweenable.now() - this._pausedAtTime; 454 | } else { 455 | this._loopTimestamp = Tweenable.now(); 456 | } 457 | 458 | this._timesToIterate = iterations; 459 | this._playState = PLAYING; 460 | 461 | // Start the update loop 462 | tick(this); 463 | 464 | fireEvent(this, 'playStateChange'); 465 | fireEvent(this, 'play'); 466 | 467 | return this; 468 | } 469 | 470 | /** 471 | * Move to a specific millisecond on the timeline and play from there. 472 | * 473 | * @method rekapi.Rekapi#playFrom 474 | * @param {number} millisecond 475 | * @param {number} [iterations] Works as it does in {@link 476 | * rekapi.Rekapi#play}. 477 | * @return {rekapi.Rekapi} 478 | */ 479 | playFrom (millisecond, iterations) { 480 | this.play(iterations); 481 | this._loopTimestamp = Tweenable.now() - millisecond; 482 | 483 | this._actors.forEach( 484 | actor => actor._resetFnKeyframesFromMillisecond(millisecond) 485 | ); 486 | 487 | return this; 488 | } 489 | 490 | /** 491 | * Play from the last frame that was rendered with {@link 492 | * rekapi.Rekapi#update}. 493 | * 494 | * @method rekapi.Rekapi#playFromCurrent 495 | * @param {number} [iterations] Works as it does in {@link 496 | * rekapi.Rekapi#play}. 497 | * @return {rekapi.Rekapi} 498 | */ 499 | playFromCurrent (iterations) { 500 | return this.playFrom(this._lastUpdatedMillisecond, iterations); 501 | } 502 | 503 | /** 504 | * Pause the animation. A "paused" animation can be resumed from where it 505 | * left off with {@link rekapi.Rekapi#play}. 506 | * 507 | * @method rekapi.Rekapi#pause 508 | * @return {rekapi.Rekapi} 509 | * @fires rekapi.playStateChange 510 | * @fires rekapi.pause 511 | */ 512 | pause () { 513 | if (this._playState === PAUSED) { 514 | return this; 515 | } 516 | 517 | this._playState = PAUSED; 518 | cancelLoop(this); 519 | this._pausedAtTime = Tweenable.now(); 520 | 521 | fireEvent(this, 'playStateChange'); 522 | fireEvent(this, 'pause'); 523 | 524 | return this; 525 | } 526 | 527 | /** 528 | * Stop the animation. A "stopped" animation will start from the beginning 529 | * if {@link rekapi.Rekapi#play} is called. 530 | * 531 | * @method rekapi.Rekapi#stop 532 | * @return {rekapi.Rekapi} 533 | * @fires rekapi.playStateChange 534 | * @fires rekapi.stop 535 | */ 536 | stop () { 537 | this._playState = STOPPED; 538 | cancelLoop(this); 539 | 540 | // Also kill any shifty tweens that are running. 541 | this._actors.forEach(actor => 542 | actor._resetFnKeyframesFromMillisecond(0) 543 | ); 544 | 545 | fireEvent(this, 'playStateChange'); 546 | fireEvent(this, 'stop'); 547 | 548 | return this; 549 | } 550 | 551 | /** 552 | * @method rekapi.Rekapi#isPlaying 553 | * @return {boolean} Whether or not the animation is playing (meaning not paused or 554 | * stopped). 555 | */ 556 | isPlaying () { 557 | return this._playState === PLAYING; 558 | } 559 | 560 | /** 561 | * @method rekapi.Rekapi#isPaused 562 | * @return {boolean} Whether or not the animation is paused (meaning not playing or 563 | * stopped). 564 | */ 565 | isPaused () { 566 | return this._playState === PAUSED; 567 | } 568 | 569 | /** 570 | * @method rekapi.Rekapi#isStopped 571 | * @return {boolean} Whether or not the animation is stopped (meaning not playing or 572 | * paused). 573 | */ 574 | isStopped () { 575 | return this._playState === STOPPED; 576 | } 577 | 578 | /** 579 | * Render an animation frame at a specific point in the timeline. 580 | * 581 | * @method rekapi.Rekapi#update 582 | * @param {number} [millisecond=this._lastUpdatedMillisecond] The point in 583 | * the timeline at which to render. If omitted, this renders the last 584 | * millisecond that was rendered (it's a re-render). 585 | * @param {boolean} [doResetLaterFnKeyframes=false] If `true`, allow all 586 | * {@link rekapi.keyframeFunction}s later in the timeline to be run again. 587 | * This is a low-level feature, it should not be `true` (or even provided) 588 | * for most use cases. 589 | * @return {rekapi.Rekapi} 590 | * @fires rekapi.beforeUpdate 591 | * @fires rekapi.afterUpdate 592 | */ 593 | update ( 594 | millisecond = this._lastUpdatedMillisecond, 595 | doResetLaterFnKeyframes = false 596 | ) { 597 | fireEvent(this, 'beforeUpdate'); 598 | 599 | const { sort } = this; 600 | 601 | const renderOrder = sort ? 602 | this._actors.sort((a, b) => sort(a) - sort(b)) : 603 | this._actors; 604 | 605 | // Update and render each of the actors 606 | renderOrder.forEach(actor => { 607 | actor._updateState(millisecond, doResetLaterFnKeyframes); 608 | 609 | if (actor.wasActive) { 610 | actor.render(actor.context, actor.get()); 611 | } 612 | }); 613 | 614 | this._lastUpdatedMillisecond = millisecond; 615 | fireEvent(this, 'afterUpdate'); 616 | 617 | return this; 618 | } 619 | 620 | /** 621 | * @method rekapi.Rekapi#getLastPositionUpdated 622 | * @return {number} The normalized timeline position (between 0 and 1) that 623 | * was last rendered. 624 | */ 625 | getLastPositionUpdated () { 626 | return (this._lastUpdatedMillisecond / this.getAnimationLength()); 627 | } 628 | 629 | /** 630 | * @method rekapi.Rekapi#getLastMillisecondUpdated 631 | * @return {number} The millisecond that was last rendered. 632 | */ 633 | getLastMillisecondUpdated () { 634 | return this._lastUpdatedMillisecond; 635 | } 636 | 637 | /** 638 | * @method rekapi.Rekapi#getAnimationLength 639 | * @return {number} The length of the animation timeline, in milliseconds. 640 | */ 641 | getAnimationLength () { 642 | if (!this._animationLengthValid) { 643 | this._animationLength = Math.max.apply( 644 | Math, 645 | this._actors.map(actor => actor.getEnd()) 646 | ); 647 | 648 | this._animationLengthValid = true; 649 | } 650 | 651 | return this._animationLength; 652 | } 653 | 654 | /** 655 | * Bind a {@link rekapi.eventHandler} function to a Rekapi event. 656 | * @method rekapi.Rekapi#on 657 | * @param {string} eventName 658 | * @param {rekapi.eventHandler} handler The event handler function. 659 | * @return {rekapi.Rekapi} 660 | */ 661 | on (eventName, handler) { 662 | if (!this._events[eventName]) { 663 | return this; 664 | } 665 | 666 | this._events[eventName].push(handler); 667 | 668 | return this; 669 | } 670 | 671 | /** 672 | * Manually fire a Rekapi event, thereby calling all {@link 673 | * rekapi.eventHandler}s bound to that event. 674 | * @param {string} eventName The name of the event to trigger. 675 | * @param {any} [data] Optional data to provide to the `eventName` {@link 676 | * rekapi.eventHandler}s. 677 | * @method rekapi.Rekapi#trigger 678 | * @return {rekapi.Rekapi} 679 | * @fires * 680 | */ 681 | trigger (eventName, data) { 682 | fireEvent(this, eventName, data); 683 | 684 | return this; 685 | } 686 | 687 | /** 688 | * Unbind one or more handlers from a Rekapi event. 689 | * @method rekapi.Rekapi#off 690 | * @param {string} eventName Valid values correspond to the list under 691 | * {@link rekapi.Rekapi#on}. 692 | * @param {rekapi.eventHandler} [handler] A reference to the {@link 693 | * rekapi.eventHandler} to unbind. If omitted, all {@link 694 | * rekapi.eventHandler}s bound to `eventName` are unbound. 695 | * @return {rekapi.Rekapi} 696 | */ 697 | off (eventName, handler) { 698 | if (!this._events[eventName]) { 699 | return this; 700 | } 701 | 702 | this._events[eventName] = handler ? 703 | without(this._events[eventName], handler) : 704 | []; 705 | 706 | return this; 707 | } 708 | 709 | /** 710 | * Export the timeline to a `JSON.stringify`-friendly `Object`. 711 | * 712 | * @method rekapi.Rekapi#exportTimeline 713 | * @param {Object} [config] 714 | * @param {boolean} [config.withId=false] If `true`, include internal `id` 715 | * values in exported data. 716 | * @return {rekapi.timelineData} This data can later be consumed by {@link 717 | * rekapi.Rekapi#importTimeline}. 718 | */ 719 | exportTimeline ({ withId = false } = {}) { 720 | const exportData = { 721 | duration: this.getAnimationLength(), 722 | actors: this._actors.map(actor => actor.exportTimeline({ withId })) 723 | }; 724 | 725 | const { formulas } = Tweenable; 726 | 727 | const filteredFormulas = Object.keys(formulas).filter( 728 | formulaName => typeof formulas[formulaName].x1 === 'number' 729 | ); 730 | 731 | const pickProps = ['displayName', 'x1', 'y1', 'x2', 'y2']; 732 | 733 | exportData.curves = filteredFormulas.reduce((acc, formulaName) => { 734 | const formula = formulas[formulaName]; 735 | acc[formula.displayName] = pick(formula, pickProps); 736 | 737 | return acc; 738 | }, 739 | {} 740 | ); 741 | 742 | return exportData; 743 | } 744 | 745 | /** 746 | * Import data that was created by {@link rekapi.Rekapi#exportTimeline}. 747 | * This sets up all actors, keyframes, and custom easing curves specified in 748 | * the `rekapiData` parameter. These two methods collectively allow you 749 | * serialize an animation (for sending to a server for persistence, for 750 | * example) and later recreating an identical animation. 751 | * 752 | * @method rekapi.Rekapi#importTimeline 753 | * @param {rekapi.timelineData} rekapiData Any object that has the same data 754 | * format as the object generated from {@link rekapi.Rekapi#exportTimeline}. 755 | */ 756 | importTimeline (rekapiData) { 757 | each(rekapiData.curves, (curve, curveName) => 758 | setBezierFunction( 759 | curveName, 760 | curve.x1, 761 | curve.y1, 762 | curve.x2, 763 | curve.y2 764 | ) 765 | ); 766 | 767 | rekapiData.actors.forEach(actorData => { 768 | const actor = new Actor(); 769 | actor.importTimeline(actorData); 770 | this.addActor(actor); 771 | }); 772 | } 773 | 774 | /** 775 | * @method rekapi.Rekapi#getEventNames 776 | * @return {Array.} The list of event names that this Rekapi instance 777 | * supports. 778 | */ 779 | getEventNames () { 780 | return Object.keys(this._events); 781 | } 782 | 783 | /** 784 | * Get a reference to a {@link rekapi.renderer} that was initialized for this 785 | * animation. 786 | * @method rekapi.Rekapi#getRendererInstance 787 | * @param {rekapi.renderer} rendererConstructor The type of {@link 788 | * rekapi.renderer} subclass (such as {@link rekapi.CanvasRenderer} or {@link 789 | * rekapi.DOMRenderer}) to look up an instance of. 790 | * @return {rekapi.renderer|undefined} The matching {@link rekapi.renderer}, 791 | * if any. 792 | */ 793 | getRendererInstance (rendererConstructor) { 794 | return this.renderers.filter(renderer => 795 | renderer instanceof rendererConstructor 796 | )[0]; 797 | } 798 | 799 | /** 800 | * Move a {@link rekapi.Actor} around within the internal render order list. 801 | * By default, a {@link rekapi.Actor} is rendered in the order it was added 802 | * with {@link rekapi.Rekapi#addActor}. 803 | * 804 | * This method has no effect if {@link rekapi.Rekapi#sort} is set. 805 | * 806 | * @method rekapi.Rekapi#moveActorToPosition 807 | * @param {rekapi.Actor} actor 808 | * @param {number} layer This should be within `0` and the total number of 809 | * {@link rekapi.Actor}s in the animation. That number can be found with 810 | * {@link rekapi.Rekapi#getActorCount}. 811 | * @return {rekapi.Rekapi} 812 | */ 813 | moveActorToPosition (actor, position) { 814 | if (position < this._actors.length && position > -1) { 815 | this._actors = without(this._actors, actor); 816 | this._actors.splice(position, 0, actor); 817 | } 818 | 819 | return this; 820 | } 821 | } 822 | -------------------------------------------------------------------------------- /src/renderers/canvas.js: -------------------------------------------------------------------------------- 1 | import Rekapi, { 2 | rendererBootstrappers 3 | } from '../rekapi'; 4 | 5 | // PRIVATE UTILITY FUNCTIONS 6 | // 7 | 8 | /*! 9 | * Gets (and optionally sets) height or width on a canvas. 10 | * @param {HTMLCanvas} canvas 11 | * @param {string} heightOrWidth The dimension (either "height" or "width") 12 | * to get or set. 13 | * @param {number=} newSize The new value to set for `dimension`. 14 | * @return {number} 15 | */ 16 | const dimension = (canvas, heightOrWidth, newSize = undefined) => { 17 | if (newSize !== undefined) { 18 | canvas[heightOrWidth] = newSize; 19 | canvas.style[heightOrWidth] = `${newSize}px`; 20 | } 21 | 22 | return canvas[heightOrWidth]; 23 | }; 24 | 25 | // CANVAS RENDERER OBJECT 26 | // 27 | 28 | /** 29 | * You can use Rekapi to render animations to an HTML5 ``. To do so, 30 | * just provide a 31 | * [`CanvasRenderingContext2D`](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D) 32 | * instance to the {@link rekapi.Rekapi} constructor to 33 | * automatically set up the renderer: 34 | * 35 | * const rekapi = new Rekapi(document.createElement('canvas').getContext('2d')); 36 | * 37 | * To use this renderer's API, get a reference to the initialized object: 38 | * 39 | * const canvasRenderer = rekapi.getRendererInstance(CanvasRenderer); 40 | * 41 | * __Note__: {@link rekapi.CanvasRenderer} is added to {@link 42 | * rekapi.Rekapi#renderers} automatically, there is no reason to call the 43 | * constructor yourself in most cases. 44 | * @param {rekapi.Rekapi} rekapi The {@link rekapi.Rekapi} instance to render for. 45 | * @param {CanvasRenderingContext2D=} context See [the canvas 46 | * docs](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D). 47 | * @constructor rekapi.CanvasRenderer 48 | * @extends {rekapi.renderer} 49 | */ 50 | export class CanvasRenderer { 51 | 52 | constructor (rekapi, context = undefined) { 53 | Object.assign(this, { 54 | rekapi, 55 | canvasContext: context || rekapi.context 56 | }); 57 | 58 | rekapi.on('beforeUpdate', () => this.clear()); 59 | } 60 | 61 | /** 62 | * Get and optionally set the height of the associated `` element. 63 | * @method rekapi.CanvasRenderer#height 64 | * @param {number} [height] The height to optionally set. 65 | * @return {number} 66 | */ 67 | height (height = undefined) { 68 | return dimension(this.canvasContext.canvas, 'height', height); 69 | } 70 | 71 | /** 72 | * Get and optionally set the width of the associated `` element. 73 | * @method rekapi.CanvasRenderer#width 74 | * @param {number} [width] The width to optionally set. 75 | * @return {number} 76 | */ 77 | width (width = undefined) { 78 | return dimension(this.canvasContext.canvas, 'width', width); 79 | } 80 | 81 | /** 82 | * Erase the ``. 83 | * @method rekapi.CanvasRenderer#clear 84 | * @return {rekapi.CanvasRenderer} 85 | */ 86 | clear () { 87 | this.canvasContext.clearRect(0, 0, this.width(), this.height()); 88 | 89 | return this; 90 | } 91 | } 92 | 93 | /*! 94 | * Sets up an instance of CanvasRenderer and attaches it to a `Rekapi` 95 | * instance. Also augments the Rekapi instance with canvas-specific 96 | * functions. 97 | * @param {Rekapi} rekapi 98 | */ 99 | rendererBootstrappers.push(rekapi => { 100 | if (typeof CanvasRenderingContext2D === 'undefined' || 101 | !(rekapi.context instanceof CanvasRenderingContext2D)) { 102 | 103 | return; 104 | } 105 | 106 | return new CanvasRenderer(rekapi); 107 | }); 108 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {Object} obj 3 | * @return {Object} 4 | */ 5 | export const clone = obj => Object.assign({}, obj); 6 | 7 | /** 8 | * Simplified version of https://lodash.com/docs/4.17.4#difference 9 | * @param {Array.} arr 10 | * @param {Array.} values 11 | * @return {Array.} 12 | */ 13 | export const difference = (arr, values) => 14 | arr.filter(value => !~values.indexOf(value)); 15 | 16 | /** 17 | * Simplified version of https://lodash.com/docs/4.17.4#forEach, but only for 18 | * Objects. 19 | * @param {Object.} obj 20 | * @param {Function(any)} fn 21 | */ 22 | export const each = (obj, fn) => 23 | Object.keys(obj).forEach(key => fn(obj[key], key)); 24 | 25 | /*! 26 | * Simplified version of https://lodash.com/docs/4.17.4#intersection 27 | * @param {Array.} arr1 28 | * @param {Array.} arr2 29 | * @return {Array.} 30 | */ 31 | export const intersection = 32 | (arr1, arr2) => arr1.filter(el => ~arr2.indexOf(el)); 33 | 34 | /** 35 | * Simplified version of https://lodash.com/docs/4.17.4#pick 36 | * @param {Object.} obj 37 | * @param {Array.} keyNames 38 | */ 39 | export const pick = (obj, keyNames) => 40 | keyNames.reduce( 41 | (acc, keyName) => { 42 | const val = obj[keyName]; 43 | 44 | if (typeof val !== 'undefined') { 45 | acc[keyName] = val; 46 | } 47 | 48 | return acc; 49 | }, 50 | {} 51 | ); 52 | 53 | /** 54 | * Simplified version of https://lodash.com/docs/4.17.4#reject 55 | * @param {Array.} arr 56 | * @param {Function(any)} fn 57 | * @return {Array.} 58 | */ 59 | export const reject = (arr, fn) => arr.filter(el => !fn(el)); 60 | 61 | /** 62 | * Simplified version of https://lodash.com/docs/4.17.4#uniq 63 | * @param {Array.} arr 64 | * @return {Array.} 65 | */ 66 | export const uniq = arr => 67 | arr.reduce((acc, value) => { 68 | if (!~acc.indexOf(value)) { 69 | acc.push(value); 70 | } 71 | 72 | return acc; 73 | }, []); 74 | 75 | let incrementer = 0; 76 | /** 77 | * @param {string} [prefix] 78 | * @return {string} 79 | */ 80 | export const uniqueId = (prefix = '') => prefix + incrementer++; 81 | 82 | /** 83 | * Simplified version of https://lodash.com/docs/4.17.4#without 84 | * @param {Array.} array 85 | * @param {...any} values 86 | * @return {Array.} 87 | */ 88 | export const without = (array, ...values) => 89 | array.filter(value => !~values.indexOf(value)); 90 | -------------------------------------------------------------------------------- /test/actor.js: -------------------------------------------------------------------------------- 1 | /* global describe:true, it:true, before:true, beforeEach:true, afterEach:true */ 2 | import assert from 'assert'; 3 | import { contains } from 'lodash'; 4 | import { setupTestRekapi, setupTestActor } from './test-utils'; 5 | 6 | import { Rekapi, Actor, KeyframeProperty } from '../src/main'; 7 | import { 8 | Tweenable, 9 | interpolate, 10 | setBezierFunction, 11 | unsetBezierFunction 12 | } from 'shifty'; 13 | 14 | import { 15 | updateToMillisecond, 16 | updateToCurrentMillisecond 17 | } from '../src/rekapi'; 18 | 19 | describe('Actor', () => { 20 | let rekapi, actor; 21 | 22 | beforeEach(() => { 23 | rekapi = setupTestRekapi(); 24 | actor = setupTestActor(rekapi); 25 | }); 26 | 27 | describe('constructor', () => { 28 | it('is a function', () => { 29 | assert.equal(typeof Actor, 'function'); 30 | }); 31 | }); 32 | 33 | describe('#_updateState', () => { 34 | describe('interpolating positions', () => { 35 | describe('actors that start at 0', () => { 36 | it('interpolates actor positions at arbitrary positions mid-frame', () => { 37 | actor.keyframe(0, { 38 | x: 0, 39 | y: 0 40 | }).keyframe(1000, { 41 | x: 100, 42 | y:100 43 | }); 44 | 45 | actor._updateState(0); 46 | assert.equal(actor.get().x, 0); 47 | assert.equal(actor.get().y, 0); 48 | 49 | actor._updateState(500); 50 | assert.equal(actor.get().x, 50); 51 | assert.equal(actor.get().y, 50); 52 | 53 | actor._updateState(1000); 54 | assert.equal(actor.get().x, 100); 55 | assert.equal(actor.get().y, 100); 56 | }); 57 | }); 58 | 59 | describe('actors that start later than 0', () => { 60 | it('interpolates actor positions at arbitrary positions mid-frame', () => { 61 | actor.keyframe(1000, { 62 | x: 0, 63 | y: 0 64 | }).keyframe(2000, { 65 | x: 100, 66 | y: 100 67 | }); 68 | 69 | actor._updateState(1000); 70 | assert.equal(actor.get().x, 0); 71 | assert.equal(actor.get().y, 0); 72 | 73 | actor._updateState(1500); 74 | 75 | assert.equal(actor.get().x, 50); 76 | assert.equal(actor.get().y, 50, 77 | 'Value "y" was properly interpolated at position 0.5'); 78 | 79 | actor._updateState(2000); 80 | assert.equal(actor.get().x, 100); 81 | assert.equal(actor.get().y, 100); 82 | }); 83 | 84 | describe('property look-ahead', () => { 85 | describe('single track', () => { 86 | it('looks ahead to first keyframe when computing states prior to actor start', () => { 87 | actor.keyframe(0, { 88 | // Nothing! 89 | }).keyframe(1000, { 90 | y: 100 91 | }); 92 | 93 | actor._updateState(500); 94 | assert.equal(actor.get().y, 100); 95 | }); 96 | }); 97 | 98 | describe('multiple tracks', () => { 99 | it('looks ahead to first keyframe when computing states prior to actor start', () => { 100 | actor.keyframe(0, { 101 | x: 50 102 | }).keyframe(1000, { 103 | y: 100 104 | }); 105 | 106 | actor._updateState(500); 107 | assert.equal(actor.get().y, 100); 108 | }); 109 | }); 110 | }); 111 | }); 112 | }); 113 | 114 | describe('computing state past a keyframe track end', () => { 115 | it('leaves keyframe tracks at their final position', () => { 116 | actor.keyframe(0,{ 117 | x: 100 118 | }).keyframe(1000, { 119 | y: 200 120 | }); 121 | 122 | actor._updateState(500); 123 | assert.equal(actor.get().x, 100); 124 | }); 125 | }); 126 | 127 | describe('applying easing curves', () => { 128 | it('easing is taken from the destination frame', () => { 129 | let tweenableComparator; 130 | 131 | actor 132 | .keyframe(0, { x: 0 }, 'linear') 133 | .keyframe(1000, { x: 100 }, 'easeInSine') 134 | .keyframe(2000, { x: 200 }, 'easeOutCirc'); 135 | 136 | tweenableComparator = 137 | interpolate({ x: 0 }, { x: 100 }, 0.5, 'easeInSine'); 138 | 139 | actor._updateState(500); 140 | assert.equal(actor.get().x, tweenableComparator.x); 141 | 142 | tweenableComparator = 143 | interpolate({ x: 100 }, { x: 200 }, 0.5, 'easeOutCirc'); 144 | 145 | actor._updateState(1500); 146 | assert.equal(actor.get().x, tweenableComparator.x); 147 | }); 148 | }); 149 | 150 | describe('#wasActive management', () => { 151 | it('updates wasActive for arbitrary state updates', () => { 152 | actor 153 | .keyframe(0,{ x: 0 }) 154 | .setActive(250, false) 155 | .setActive(750, true) 156 | .keyframe(1000, { x: 100 }); 157 | 158 | actor._updateState(100); 159 | assert(actor.wasActive); 160 | assert.equal(actor.get().x, 10); 161 | 162 | actor._updateState(500); 163 | assert.equal(actor.wasActive, false); 164 | assert.equal(actor.get().x, 10); 165 | 166 | actor._updateState(900); 167 | assert(actor.wasActive); 168 | assert.equal(actor.get().x, 90); 169 | }); 170 | }); 171 | }); 172 | 173 | describe('#addKeyframeProperty', () => { 174 | it('creates a link between property and actor', () => { 175 | const keyframeProperty = new KeyframeProperty(0, 'x', 50); 176 | actor.addKeyframeProperty(keyframeProperty); 177 | 178 | assert.equal(keyframeProperty.actor, actor); 179 | assert.equal(actor._keyframeProperties[keyframeProperty.id], keyframeProperty); 180 | }); 181 | }); 182 | 183 | describe('#removeKeyframe', () => { 184 | it('removes arbitrary keyframes', () => { 185 | actor 186 | .keyframe(0, { x: 1 }) 187 | .keyframe(1000, { x: 2 }) 188 | .keyframe(2000, { x: 3 }); 189 | 190 | actor.removeKeyframe(1000); 191 | 192 | assert.equal(Object.keys(actor._keyframeProperties).length, 2); 193 | assert.equal(actor._propertyTracks.x.length, 2); 194 | assert.equal(actor._propertyTracks.x[0].value, 1); 195 | assert.equal(actor._propertyTracks.x[1].value, 3); 196 | }); 197 | 198 | it('removes all keyframes at a given millisecond', () => { 199 | actor.keyframe(0, { 200 | x: 0, 201 | y: 1 202 | }).keyframe(1000, { 203 | x: 50, 204 | y: 51 205 | }).keyframe(2000, { 206 | x: 100, 207 | y: 101 208 | }); 209 | 210 | actor.removeKeyframe(1000); 211 | 212 | assert.equal(actor._propertyTracks.x.length, 2); 213 | assert.equal(actor._propertyTracks.y.length, 2); 214 | assert.equal(actor._propertyTracks.x[0].value, 0); 215 | assert.equal(actor._propertyTracks.x[1].value, 100); 216 | assert.equal(actor._propertyTracks.y[0].value, 1); 217 | assert.equal(actor._propertyTracks.y[1].value, 101); 218 | assert.equal(actor._propertyTracks.x[0].nextProperty, actor._propertyTracks.x[1]); 219 | assert.equal(actor._propertyTracks.x[1].nextProperty, null); 220 | }); 221 | 222 | it('removes keyframes at the end of a timeline', () => { 223 | actor 224 | .keyframe(0, { x: 1 }) 225 | .keyframe(1000, { x: 2 }) 226 | .keyframe(2000, { x: 3 }); 227 | 228 | actor.removeKeyframe(2000); 229 | 230 | assert.equal(rekapi.getAnimationLength(), 1000); 231 | assert.equal(actor._propertyTracks.x.length, 2); 232 | assert.equal(actor._propertyTracks.x[0].nextProperty, actor._propertyTracks.x[1]); 233 | assert.equal(actor._propertyTracks.x[1].nextProperty, null); 234 | }); 235 | }); 236 | 237 | describe('#removeAllKeyframes', () => { 238 | it('removes all keyframes', () => { 239 | actor 240 | .keyframe(0, { x: 0 }) 241 | .keyframe(1000, { x: 1 }) 242 | .keyframe(2000, { x: 2 }); 243 | 244 | actor.removeAllKeyframes(); 245 | 246 | assert.equal(rekapi.getAnimationLength(), 0); 247 | assert.equal(actor._propertyTracks.x, undefined); 248 | }); 249 | }); 250 | 251 | describe('#copyKeyframe', () => { 252 | it('copies keyframe properties', () => { 253 | actor = setupTestActor(rekapi); 254 | actor.keyframe(0, { 255 | x: 50 256 | }, { 257 | x: 'easeInQuad' 258 | }); 259 | 260 | actor.copyKeyframe(0, 1000); 261 | 262 | const { _propertyTracks } = actor; 263 | 264 | assert.equal(_propertyTracks.x.length, 2); 265 | assert(_propertyTracks.x[0] !== _propertyTracks.x[1]); 266 | assert.equal(_propertyTracks.x[0].value, _propertyTracks.x[1].value); 267 | assert.equal(_propertyTracks.x[0].easing, _propertyTracks.x[1].easing); 268 | }); 269 | }); 270 | 271 | describe('#modifyKeyframe', () => { 272 | it('modifies keyframe property value', () => { 273 | actor 274 | .keyframe(0, { x: 100 }) 275 | .keyframe(1000, { x: 200 }); 276 | 277 | actor.modifyKeyframe(0, { x: 0 }); 278 | assert.equal(actor._propertyTracks.x[0].value, 0); 279 | }); 280 | 281 | it('modifies keyframe property easing', () => { 282 | actor 283 | .keyframe(0, { x: 0 }, { x: 'elastic' }) 284 | .keyframe(1000, { x: 100 }, { x: 'elastic' }); 285 | 286 | actor.modifyKeyframe(1000, {}, { x: 'linear' }); 287 | 288 | assert.equal(actor._propertyTracks.x[1].easing, 'linear'); 289 | }); 290 | }); 291 | 292 | describe('#hasKeyframeAt', () => { 293 | beforeEach(() => { 294 | actor 295 | .keyframe(0, { x: 0 }, { x: 'elastic' }) 296 | .keyframe(1000, { x: 100 }, { x: 'elastic' }); 297 | }); 298 | 299 | it('determines if there are any properties at a given millisecond', () => { 300 | assert.equal(actor.hasKeyframeAt(500), false); 301 | assert.equal(actor.hasKeyframeAt(2000), false); 302 | assert.equal(actor.hasKeyframeAt(1000), true); 303 | }); 304 | 305 | it('determines if there is a specific property at a given millisecond', () => { 306 | assert.equal(actor.hasKeyframeAt(0, 'y'), false); 307 | assert.equal(actor.hasKeyframeAt(1200, 'x'), false); 308 | assert.equal(actor.hasKeyframeAt(1000, 'x'), true); 309 | }); 310 | }); 311 | 312 | describe('#moveKeyframe', () => { 313 | it('does not move a keyframe to a point on the timeline if the target is not empty', () => { 314 | actor 315 | .keyframe(0, { x: 1 }) 316 | .keyframe(10, { x: 2 }) 317 | .keyframe(20, { x: 3 }); 318 | 319 | assert.equal(actor.moveKeyframe(20, 10), false); 320 | assert.equal(actor.getKeyframeProperty('x', 10).value, 2); 321 | assert.equal(actor.getKeyframeProperty('x', 20).value, 3); 322 | }); 323 | 324 | it('does not move a keyframe is the source keyframe is not there', () => { 325 | actor 326 | .keyframe(0, { x: 1 }) 327 | .keyframe(10, { x: 2 }); 328 | 329 | assert.equal(actor.moveKeyframe(20, 30), false); 330 | }); 331 | 332 | it('moves valid source keyframe to valid target', () => { 333 | actor 334 | .keyframe(0, { x: 1 }) 335 | .keyframe(10, { x: 2 }); 336 | 337 | const didMoveKeyframe = actor.moveKeyframe(10, 20); 338 | 339 | assert(didMoveKeyframe); 340 | assert.equal(actor.hasKeyframeAt(20), true); 341 | assert.equal(actor.hasKeyframeAt(10), false); 342 | assert.equal(rekapi.getAnimationLength(), 20); 343 | }); 344 | }); 345 | 346 | describe('#wait', () => { 347 | it('extends final state of fully defined tracks', () => { 348 | actor.keyframe(0, { x: 50 }).wait(1000); 349 | 350 | assert.equal(actor._propertyTracks.x[1].value, 50); 351 | }); 352 | 353 | // TODO: This is sort of weird behavior. Could this be simplified/removed? 354 | it('sets an explicit final state for implicit properties before extending them', () => { 355 | actor 356 | .keyframe(0, { x: 51, y: 50 }) 357 | .keyframe(500, { y: 100 }) 358 | .keyframe(1000, { x: 101 }) 359 | .wait(2000); 360 | 361 | assert.equal(actor._propertyTracks.x[2].value, 101); 362 | assert.equal(actor._propertyTracks.x[2].millisecond, 2000); 363 | assert.equal(actor._propertyTracks.x.length, 3); 364 | 365 | // The missing y property at millisecond 1000 was implicitly filled in and copied 366 | assert.equal(actor._propertyTracks.y.length, 4); 367 | }); 368 | }); 369 | 370 | describe('#getKeyframeProperty', () => { 371 | it('retrieves KeyframeProperty instances', () => { 372 | actor.keyframe(0, { x: 10 }); 373 | 374 | assert.equal(actor.getKeyframeProperty('x', 0).value, 10); 375 | }); 376 | }); 377 | 378 | describe('#modifyKeyframeProperty', () => { 379 | it('modifies KeyframeProperty instances', () => { 380 | actor.keyframe(0, { x: 10 }); 381 | actor.modifyKeyframeProperty('x', 0, { value: 20 }); 382 | 383 | assert.equal(actor.getKeyframeProperty('x', 0).value, 20); 384 | }); 385 | 386 | it('can reorder properties', () => { 387 | actor 388 | .keyframe(0, {x: 1}) 389 | .keyframe(10, {x: 2}); 390 | 391 | actor.modifyKeyframeProperty('x', 0, { millisecond: 20 }); 392 | const propertyAt10 = actor.getKeyframeProperty('x', 10); 393 | const propertyAt20 = actor.getKeyframeProperty('x', 20); 394 | 395 | assert.equal(propertyAt10.nextProperty, propertyAt20); 396 | assert.equal(propertyAt20.nextProperty, null); 397 | }); 398 | 399 | it('cannot move a KeyframeProperty where there already is one', () => { 400 | actor 401 | .keyframe(0, { x: 1 }) 402 | .keyframe(10, { x: 2 }); 403 | 404 | assert.throws(() => { 405 | actor.modifyKeyframeProperty('x', 10, { millisecond: 0 }); 406 | }, Error); 407 | }); 408 | }); 409 | 410 | describe('#removeKeyframeProperty', () => { 411 | it('remove KeyframeProperty instances', () => { 412 | actor.keyframe(0, { x: 10 }); 413 | actor.removeKeyframeProperty('x', 0); 414 | 415 | assert.equal(actor.getPropertiesInTrack('x').length, 0); 416 | }); 417 | }); 418 | 419 | describe('#getPropertiesInTrack', () => { 420 | it('returns an array KeyframeProperty instances in a track', () => { 421 | actor 422 | .keyframe(0, { x: 1 }) 423 | .keyframe(1000, { x: 2 }) 424 | .keyframe(2000, { x: 3 }); 425 | 426 | assert.equal(actor.getPropertiesInTrack('x').length, 3); 427 | }); 428 | 429 | it('returns nothing if there are no properties in a given track', () => { 430 | actor 431 | .keyframe(0, {}) 432 | .keyframe(1000, {}) 433 | .keyframe(2000, {}); 434 | 435 | assert.equal(actor.getPropertiesInTrack('x').length, 0); 436 | }); 437 | }); 438 | 439 | describe('#getStart', () => { 440 | it('gets the start of an actor\'s movement in the animation', () => { 441 | actor 442 | .keyframe(250, { 443 | x: 1 444 | }).keyframe(1000, { 445 | x: 10 446 | }).keyframe(2000, { 447 | x: 20 448 | }); 449 | 450 | assert.equal(actor.getStart(), 250); 451 | }); 452 | 453 | it('gets the start of an actor\'s movement for a given track in the animation', () => { 454 | actor 455 | .keyframe(0, { y: 45 }) 456 | .keyframe(250, { x: 1 }) 457 | .keyframe(1000, { x: 10, y: 100 }); 458 | 459 | assert.equal(actor.getStart('y'), 0); 460 | assert.equal(actor.getStart('x'), 250); 461 | }); 462 | 463 | it('handles an actor with no keyframes', () => { 464 | assert.equal(actor.getStart(), 0); 465 | }); 466 | }); 467 | 468 | describe('#getEnd', () => { 469 | it('gets the end of an actor\'s movement', () => { 470 | actor 471 | .keyframe(250, { x: 1 }) 472 | .keyframe(1000, { x: 10 }) 473 | .keyframe(2000, { x: 20 }); 474 | 475 | assert.equal(actor.getEnd(), 2000); 476 | }); 477 | 478 | it('can scope to a track', () => { 479 | actor 480 | .keyframe(250, { x: 1, y: 10 }) 481 | .keyframe(1000, { x: 10, y: 20 }) 482 | .keyframe(2000, { x: 20 }); 483 | 484 | assert.equal(actor.getEnd('x'), 2000); 485 | assert.equal(actor.getEnd('y'), 1000); 486 | }); 487 | 488 | it('is unaffected by keyframes that were moved', () => { 489 | actor 490 | .keyframe(0, { x: 1 }) 491 | .keyframe(1000, { x: 10 }) 492 | .keyframe(2000, { x: 20 }); 493 | 494 | assert.equal(actor.getEnd('x'), 2000); 495 | 496 | actor.modifyKeyframeProperty('x', 2000, { millisecond: 500 }); 497 | assert.equal(actor.getEnd('x'), 1000); 498 | 499 | actor.modifyKeyframeProperty('x', 500, { millisecond: 2000 }); 500 | assert.equal(actor.getEnd('x'), 2000); 501 | }); 502 | }); 503 | 504 | describe('#getLength', () => { 505 | it('gets the total time that an actor animates for', () => { 506 | actor 507 | .keyframe(250, { x: 1 }) 508 | .keyframe(1000, { x: 10 }) 509 | .keyframe(2000, { x: 20 }); 510 | 511 | assert.equal(actor.getLength(), 1750); 512 | }); 513 | 514 | it('can scope to a track', () => { 515 | actor 516 | .keyframe(0, { y: 10 }) 517 | .keyframe(250, { x: 1 }) 518 | .keyframe(1000, { x: 10 }) 519 | .keyframe(2000, { y: 20 }); 520 | 521 | assert.equal(actor.getLength('x'), 750); 522 | assert.equal(actor.getLength('y'), 2000); 523 | }); 524 | }); 525 | 526 | describe('#getTrackNames', () => { 527 | it('returns list of track names', () => { 528 | actor.keyframe(0, { a: 1, b: 2, c: 3, d: 4 }); 529 | 530 | const trackNames = actor.getTrackNames().sort(); 531 | 532 | assert.equal(trackNames[0], 'a'); 533 | assert.equal(trackNames[1], 'b'); 534 | assert.equal(trackNames[2], 'c'); 535 | assert.equal(trackNames[3], 'd'); 536 | assert.equal(trackNames.length, 4); 537 | }); 538 | }); 539 | 540 | describe('#exportTimeline', () => { 541 | beforeEach(() => { 542 | actor 543 | .keyframe(0, { x: 1, y: 10 }) 544 | .keyframe(1000, { x: 2, y: 20 }); 545 | }); 546 | 547 | it('exports key data points', () => { 548 | const exportedActorData = actor.exportTimeline(); 549 | 550 | assert.equal(exportedActorData.start, 0); 551 | assert.equal(exportedActorData.end, 1000); 552 | assert(exportedActorData.trackNames.indexOf('x') > -1); 553 | assert(exportedActorData.trackNames.indexOf('y') > -1); 554 | assert.equal(exportedActorData.trackNames.length, 2); 555 | assert.equal(exportedActorData.propertyTracks.x.length, 2); 556 | assert.equal(exportedActorData.propertyTracks.y.length, 2); 557 | assert.equal(typeof exportedActorData.id, 'undefined'); 558 | assert.equal(typeof exportedActorData.propertyTracks.x[0].id, 'undefined'); 559 | }); 560 | 561 | describe('withId: true', () => { 562 | it('includes id properties', () => { 563 | const exportedActorData = actor.exportTimeline({ withId: true }); 564 | assert.equal(typeof exportedActorData.id, 'string'); 565 | assert.equal(typeof exportedActorData.propertyTracks.x[0].id, 'string'); 566 | }); 567 | }); 568 | }); 569 | 570 | describe('#importTimeline', () => { 571 | it('imports data correctly', () => { 572 | actor 573 | .keyframe(0, { x: 1, y: 10 }) 574 | .keyframe(1000, { x: 2, y: 20 }); 575 | 576 | const importActor = new Actor(); 577 | importActor.importTimeline(actor.exportTimeline()); 578 | 579 | const firstImportXKeyProp = importActor.getKeyframeProperty('x', 0); 580 | const firstExportXKeyProp = actor.getKeyframeProperty('x', 0); 581 | assert.equal(firstImportXKeyProp.value, firstExportXKeyProp.value); 582 | assert.equal(firstImportXKeyProp.millisecond, firstExportXKeyProp.millisecond); 583 | 584 | const secondImportXKeyProp = importActor.getKeyframeProperty('x', 1000); 585 | const secondExportXKeyProp = actor.getKeyframeProperty('x', 1000); 586 | assert.equal(secondImportXKeyProp.value, secondExportXKeyProp.value); 587 | assert.equal(secondImportXKeyProp.millisecond, secondExportXKeyProp.millisecond); 588 | 589 | const firstImportYKeyProp = importActor.getKeyframeProperty('y', 0); 590 | const firstExportYKeyProp = actor.getKeyframeProperty('y', 0); 591 | assert.equal(firstImportYKeyProp.value, firstExportYKeyProp.value); 592 | assert.equal(firstImportYKeyProp.millisecond, firstExportYKeyProp.millisecond); 593 | 594 | const secondImportYKeyProp = importActor.getKeyframeProperty('y', 1000); 595 | const secondExportYKeyProp = actor.getKeyframeProperty('y', 1000); 596 | assert.equal(secondImportYKeyProp.value, secondExportYKeyProp.value); 597 | assert.equal(secondImportYKeyProp.millisecond, secondExportYKeyProp.millisecond); 598 | }); 599 | }); 600 | 601 | describe('.context', () => { 602 | it('is inherited from parent Rekapi instance by default', () => { 603 | assert.equal(actor.context, rekapi.context); 604 | }); 605 | }); 606 | 607 | describe('events', () => { 608 | describe('beforeAddKeyframeProperty', () => { 609 | it('when fired, reflects the state of the animation prior to adding the keyframe property', () => { 610 | actor.keyframe(50, { x: 0 }); 611 | 612 | rekapi.on('beforeAddKeyframeProperty', () => { 613 | assert.equal(rekapi.getAnimationLength(), 50); 614 | }); 615 | 616 | actor.keyframe(100, { x: 10 }); 617 | }); 618 | }); 619 | 620 | describe('beforeRemoveKeyframeProperty', () => { 621 | it('when fired, reflects the state of the animation prior to removing the keyframe property', () => { 622 | actor.keyframe(0, { x: 0 }).keyframe(100, { x: 10 }); 623 | 624 | rekapi.on('beforeRemoveKeyframeProperty', () => { 625 | assert.equal(rekapi.getAnimationLength(), 100); 626 | }); 627 | 628 | actor.removeKeyframeProperty('x', 100); 629 | }); 630 | }); 631 | 632 | describe('removeKeyframePropertyComplete', () => { 633 | it('when fired, reflects the new state of the animation', () => { 634 | actor.keyframe(0, { x: 0 }).keyframe(100, { x: 10 }); 635 | 636 | rekapi.on('removeKeyframePropertyComplete', () => { 637 | assert.equal(rekapi.getAnimationLength(), 0); 638 | }); 639 | 640 | actor.removeKeyframeProperty('x', 100); 641 | }); 642 | }); 643 | 644 | describe('removeKeyframePropertyTrack', () => { 645 | it('fires when a track is removed', () => { 646 | let eventWasCalled, removedTrackName; 647 | 648 | actor.keyframe(0, { x: 10 }); 649 | 650 | rekapi.on('removeKeyframePropertyTrack', (rekapi, trackName) => { 651 | eventWasCalled = true; 652 | removedTrackName = trackName; 653 | }); 654 | 655 | actor.removeKeyframe(0); 656 | assert(eventWasCalled); 657 | assert.equal(removedTrackName, 'x'); 658 | }); 659 | }); 660 | }); 661 | 662 | describe('function keyframes', () => { 663 | describe('without other properties', () => { 664 | it('gets called', () => { 665 | let functionWasCalled; 666 | actor.keyframe(0, () => functionWasCalled = true); 667 | 668 | rekapi._loopTimestamp = 0; 669 | Tweenable.now = () => 0; 670 | updateToCurrentMillisecond(rekapi); 671 | 672 | assert(functionWasCalled); 673 | }); 674 | 675 | it('at millisecond 0, is called with each loop iteration', () => { 676 | let callCount = 0; 677 | actor.keyframe(0, () => callCount++); 678 | 679 | rekapi._loopTimestamp = 0; 680 | Tweenable.now = () => 0; 681 | updateToCurrentMillisecond(rekapi); 682 | assert.equal(callCount, 1); 683 | 684 | Tweenable.now = () => 1; 685 | updateToCurrentMillisecond(rekapi); 686 | assert.equal(callCount, 2); 687 | }); 688 | }); 689 | 690 | describe('with other properties', () => { 691 | it('gets called', () => { 692 | let functionWasCalled; 693 | actor.keyframe(10, () => functionWasCalled = true); 694 | actor.keyframe(20, { x: 1 }); 695 | 696 | rekapi._loopTimestamp = 0; 697 | Tweenable.now = () => 5; 698 | updateToCurrentMillisecond(rekapi); 699 | assert(!functionWasCalled); 700 | 701 | // Note: 20 causes the loop to start over and be evaluated as 0 in 702 | // KeyframeProperty#shouldInvokeForMillisecond. 703 | Tweenable.now = () => 19; 704 | updateToCurrentMillisecond(rekapi); 705 | 706 | assert(functionWasCalled); 707 | }); 708 | }); 709 | 710 | describe('callback', () => { 711 | it('receives drift and context', () => { 712 | let receivedDrift, context; 713 | 714 | actor.keyframe(0, function (actor, drift) { 715 | context = actor; 716 | receivedDrift = drift; 717 | }); 718 | 719 | actor.keyframe(20, { x: 1 }); 720 | 721 | rekapi._loopTimestamp = 0; 722 | Tweenable.now = () => 5; 723 | updateToCurrentMillisecond(rekapi); 724 | 725 | assert.equal(receivedDrift, 5); 726 | assert.equal(context, actor); 727 | }); 728 | 729 | it('gets called once per loop iteration', () => { 730 | let functionCalls = 0; 731 | 732 | actor.keyframe(10, () => functionCalls += 1); 733 | actor.keyframe(20, { x: 1 }); 734 | 735 | rekapi._loopTimestamp = 0; 736 | Tweenable.now = () => 12; 737 | updateToCurrentMillisecond(rekapi); 738 | Tweenable.now = () => 13; 739 | updateToCurrentMillisecond(rekapi); 740 | assert.equal(functionCalls, 1); 741 | 742 | // Reset the loop 743 | Tweenable.now = () => 21; 744 | updateToCurrentMillisecond(rekapi); 745 | Tweenable.now = () => 12; 746 | updateToCurrentMillisecond(rekapi); 747 | assert.equal(functionCalls, 2); 748 | }); 749 | 750 | it('does not require non-function keyframes in the timeline to be invoked', () => { 751 | let calledFn1, calledFn2; 752 | 753 | actor.keyframe(10, () => calledFn1 = true); 754 | actor.keyframe(20, () => calledFn2 = true); 755 | 756 | rekapi._loopTimestamp = 0; 757 | Tweenable.now = () => 5; 758 | updateToCurrentMillisecond(rekapi); 759 | assert(!calledFn1); 760 | assert(!calledFn2); 761 | 762 | Tweenable.now = () => 15; 763 | updateToCurrentMillisecond(rekapi); 764 | assert(calledFn1); 765 | assert(!calledFn2); 766 | 767 | Tweenable.now = () => 20; 768 | updateToCurrentMillisecond(rekapi); 769 | assert(calledFn1); 770 | assert(calledFn2); 771 | }); 772 | 773 | it('when at the end of the loop, is called only once', () => { 774 | let callCount = 0; 775 | actor 776 | .keyframe(10, { 777 | 'function': () => callCount++ 778 | }); 779 | 780 | rekapi._timesToIterate = 1; 781 | updateToMillisecond(rekapi, 10); 782 | assert.equal(callCount, 1); 783 | }); 784 | }); 785 | 786 | describe('#_resetFnKeyframesFromMillisecond', () => { 787 | it('resets function keyframes later but not before specified millisecond', () => { 788 | let callCount = 0; 789 | actor 790 | .keyframe(10, { 791 | 'function': () => callCount++ 792 | }) 793 | .keyframe(20, { 794 | 'function': () => callCount++ 795 | }); 796 | 797 | rekapi._timesToIterate = 1; 798 | 799 | updateToMillisecond(rekapi, 5); 800 | assert.equal(callCount, 0); 801 | 802 | updateToMillisecond(rekapi, 15); 803 | assert.equal(callCount, 1); 804 | 805 | actor._resetFnKeyframesFromMillisecond(2); 806 | updateToMillisecond(rekapi, 15); 807 | assert.equal(callCount, 2); 808 | 809 | updateToMillisecond(rekapi, 15); 810 | assert.equal(callCount, 2); 811 | }); 812 | }); 813 | }); 814 | }); 815 | -------------------------------------------------------------------------------- /test/canvas.js: -------------------------------------------------------------------------------- 1 | /* global describe:true, it:true, before:true, beforeEach:true, afterEach:true, after:true */ 2 | import assert from 'assert'; 3 | import { contains } from 'lodash'; 4 | import { 5 | setupTestRekapi, 6 | setupTestActor 7 | } from './test-utils'; 8 | 9 | import { Rekapi, CanvasRenderer } from '../src/main'; 10 | import { 11 | Tweenable, 12 | interpolate, 13 | setBezierFunction, 14 | unsetBezierFunction 15 | } from 'shifty'; 16 | 17 | describe('Canvas renderer', () => { 18 | class CanvasRenderingContext2D {} 19 | let rekapi, actor, actor2; 20 | 21 | before(() => 22 | global.CanvasRenderingContext2D = CanvasRenderingContext2D 23 | ); 24 | 25 | after(() => 26 | delete global.CanvasRenderingContext2D 27 | ); 28 | 29 | beforeEach(() => { 30 | rekapi = setupTestRekapi(new CanvasRenderingContext2D()); 31 | actor = setupTestActor(rekapi); 32 | }); 33 | 34 | describe('constructor', () => { 35 | it('is a function', () => { 36 | assert.equal(typeof CanvasRenderer, 'function'); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/dom.js: -------------------------------------------------------------------------------- 1 | /* global describe:true, it:true, before:true, beforeEach:true, afterEach:true, after:true */ 2 | import assert from 'assert'; 3 | import { contains } from 'lodash'; 4 | import { 5 | setupTestRekapi, 6 | setupTestActor 7 | } from './test-utils'; 8 | 9 | import { Rekapi, DOMRenderer } from '../src/main'; 10 | import { 11 | Tweenable, 12 | interpolate, 13 | setBezierFunction, 14 | unsetBezierFunction 15 | } from 'shifty'; 16 | 17 | describe('DOM renderer', () => { 18 | let rekapi, actor, actor2; 19 | 20 | beforeEach(() => { 21 | rekapi = setupTestRekapi(document.createElement('div')); 22 | actor = setupTestActor(rekapi); 23 | }); 24 | 25 | describe('constructor', () => { 26 | it('is a function', () => { 27 | assert.equal(typeof DOMRenderer, 'function'); 28 | }); 29 | }); 30 | 31 | describe('setting inline styles', () => { 32 | it('interpolates and sets transform styles', () => { 33 | actor 34 | .keyframe(0, { 35 | transform: 'translateX(100px) translateY(100px) rotate(45deg)', 36 | background: '#f00' 37 | }) 38 | .keyframe(1000, { 39 | transform: 'translateX(300px) translateY(300px) rotate(135deg)', 40 | background: '#00f' 41 | }); 42 | 43 | rekapi.update(500); 44 | 45 | assert.equal( 46 | actor.context.style.transform, 47 | 'translateX(200px) translateY(200px) rotate(90deg)' 48 | ); 49 | }); 50 | 51 | it('supports independent transform properties', () => { 52 | actor 53 | .keyframe(0, { 54 | translateX: '0px', 55 | translateY: '0px', 56 | rotate: '100deg', 57 | height: '0px' 58 | }) 59 | .keyframe(2000, { 60 | translateX: '100px', 61 | translateY: '100px', 62 | rotate: '150deg', 63 | height: '50px' 64 | }); 65 | 66 | rekapi.update(1000); 67 | 68 | const { style } = actor.context; 69 | 70 | assert.equal( 71 | style.transform, 72 | 'translateX(50px) translateY(50px) rotate(125deg)' 73 | ); 74 | 75 | assert.equal(style.height, '25px'); 76 | }); 77 | 78 | it('supports translate3d', () => { 79 | actor 80 | .keyframe(0, { 81 | transform: 'translate3d(0, 0, 0)'}) 82 | .keyframe(100, { 83 | transform: 'translate3d(1, 1, 1)' 84 | }, 'linear easeInQuad easeOutQuad'); 85 | 86 | rekapi.update(50); 87 | 88 | const transformChunks = actor.get().transform.match(/(\d|\.)+/g); 89 | 90 | assert.equal( 91 | transformChunks[1], 92 | interpolate({ x: 0 }, { x: 1 }, 0.5, 'linear').x 93 | ); 94 | assert.equal( 95 | transformChunks[2], 96 | interpolate({ x: 0 }, { x: 1 }, 0.5, 'easeInQuad').x 97 | ); 98 | assert.equal( 99 | transformChunks[3], 100 | interpolate({ x: 0 }, { x: 1 }, 0.5, 'easeOutQuad').x 101 | ); 102 | }); 103 | 104 | it('supports "3deg" value', () => { 105 | actor 106 | .keyframe(0, { transform: 'rotate(3deg)' }) 107 | .keyframe(100, { transform: 'rotate(6deg)' }); 108 | 109 | rekapi.update(50); 110 | 111 | assert.equal( 112 | actor.get().transform.match(/(\d|\.)+/g), 113 | interpolate({x:3}, {x:6}, 0.5, 'linear').x 114 | ); 115 | }); 116 | 117 | it('supports decoupled unit-less transform values', () => { 118 | actor 119 | .keyframe(0, { scale: 0 }) 120 | .keyframe(100, { scale: 1 }); 121 | 122 | rekapi.update(0); 123 | 124 | assert.equal( 125 | actor.context.getAttribute('style').match(/transform.*;/)[0], 126 | 'transform: scale(0);' 127 | ); 128 | }); 129 | }); 130 | 131 | describe('#setTransformOrder', () => { 132 | it('throws an exception if given an unknown/unsupported transform function', () => { 133 | assert.throws(() => 134 | rekapi.getRendererInstance(DOMRenderer).setActorTransformOrder(actor, ['foo', 'bar', 'rotate']) 135 | ); 136 | }); 137 | 138 | it('sets a transform property order', () => { 139 | const order = ['rotate', 'translateY', 'translateX']; 140 | rekapi.getRendererInstance(DOMRenderer).setActorTransformOrder(actor, order); 141 | assert.deepEqual(actor._transformOrder, order); 142 | }); 143 | 144 | it('ignores duplicate values passed to setTransformOrder', () => { 145 | const order = ['rotate', 'translateX', 'rotate']; 146 | rekapi.getRendererInstance(DOMRenderer).setActorTransformOrder(actor, order); 147 | assert.deepEqual(actor._transformOrder, ['rotate', 'translateX']); 148 | }); 149 | 150 | it('sets inline styles in specified order', () => { 151 | actor 152 | .keyframe(0, { 153 | translateX: '0px', 154 | translateY: '0px', 155 | rotate: '100deg' 156 | }) 157 | .keyframe(2000, { 158 | translateX: '100px', 159 | translateY: '100px', 160 | rotate: '150deg' 161 | }); 162 | 163 | rekapi.getRendererInstance(DOMRenderer).setActorTransformOrder( 164 | actor, ['rotate', 'translateY', 'translateX'] 165 | ); 166 | 167 | rekapi.update(1000); 168 | 169 | assert.equal( 170 | actor.context.style.transform, 171 | 'rotate(125deg) translateY(50px) translateX(50px)' 172 | ); 173 | }); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /test/get-css.js: -------------------------------------------------------------------------------- 1 | /* global describe:true, it:true, before:true, beforeEach:true, afterEach:true, after:true */ 2 | import assert from 'assert'; 3 | import { contains } from 'lodash'; 4 | import { 5 | setupTestRekapi, 6 | setupTestActor 7 | } from './test-utils'; 8 | 9 | import { 10 | TRANSFORM_TOKEN, 11 | VENDOR_TOKEN, 12 | applyVendorBoilerplates, 13 | applyVendorPropertyPrefixes, 14 | generateBoilerplatedKeyframes, 15 | generateCSSClass, 16 | generateCSSAnimationProperties, 17 | generateActorKeyframes, 18 | generateActorTrackSegment, 19 | combineTransformProperties, 20 | serializeActorStep, 21 | generateAnimationNameProperty, 22 | generateAnimationIterationProperty, 23 | simulateLeadingWait, 24 | simulateTrailingWait, 25 | canOptimizeKeyframeProperty, 26 | canOptimizeAnyKeyframeProperties, 27 | generateOptimizedKeyframeSegment, 28 | getActorCSS, 29 | transformFunctions 30 | } from '../src/renderers/dom'; 31 | 32 | import { Rekapi, Actor, DOMRenderer } from '../src/main'; 33 | import { 34 | Tweenable, 35 | interpolate, 36 | setBezierFunction, 37 | unsetBezierFunction 38 | } from 'shifty'; 39 | 40 | describe('DOMRenderer#getCss', () => { 41 | let rekapi, actor, actor2; 42 | 43 | beforeEach(() => { 44 | rekapi = setupTestRekapi(document.createElement('div')); 45 | actor = setupTestActor(rekapi); 46 | }); 47 | 48 | before(() => { 49 | /** 50 | * This is used to prevent optimization to make certain functions easier to 51 | * test. 52 | */ 53 | Tweenable.formulas.fakeLinear = function (pos) { 54 | return pos; 55 | }; 56 | }); 57 | 58 | after(() => { 59 | delete Tweenable.formulas.fakeLinear; 60 | }); 61 | 62 | describe('private helper methods', () => { 63 | let vendorBoilerplates; 64 | 65 | afterEach(() => { 66 | vendorBoilerplates = undefined; 67 | }); 68 | 69 | describe('applyVendorBoilerplates', () => { 70 | it('applies boilerplate for W3 by default', () => { 71 | vendorBoilerplates = 72 | applyVendorBoilerplates('KEYFRAMES', 'NAME'); 73 | 74 | assert.equal( 75 | vendorBoilerplates, 76 | ['@keyframes NAME-keyframes {', 77 | 'KEYFRAMES', 78 | '}'].join('\n') 79 | ); 80 | }); 81 | 82 | it('applies boilerplate for other vendors', () => { 83 | vendorBoilerplates = applyVendorBoilerplates( 84 | 'KEYFRAMES', 'NAME', 85 | ['w3', 'webkit'] 86 | ); 87 | 88 | assert.equal( 89 | vendorBoilerplates, 90 | ['@keyframes NAME-keyframes {', 91 | 'KEYFRAMES', 92 | '}', 93 | '@-webkit-keyframes NAME-keyframes {', 94 | 'KEYFRAMES', 95 | '}'].join('\n') 96 | ); 97 | }); 98 | }); 99 | 100 | describe('generateCSSAnimationProperties', () => { 101 | it('converts transform token into valid unprefixed property', () => { 102 | const keyframe = 103 | 'from: { ' + TRANSFORM_TOKEN + ': foo; }'; 104 | const vendorBoilerplates = 105 | applyVendorPropertyPrefixes(keyframe, 'w3'); 106 | 107 | assert.equal( 108 | vendorBoilerplates, 109 | 'from: { transform: foo; }' 110 | ); 111 | }); 112 | 113 | it('converts transform token into valid prefixed property', () => { 114 | const keyframe = 'from: { ' + TRANSFORM_TOKEN + ': foo; }'; 115 | const vendorBoilerplates = 116 | applyVendorPropertyPrefixes(keyframe, 'webkit'); 117 | 118 | assert.equal(vendorBoilerplates, 'from: { -webkit-transform: foo; }'); 119 | }); 120 | }); 121 | 122 | describe('generateCSSClass', () => { 123 | it('generates boilerplated class properties for prefix-less class', () => { 124 | actor.keyframe(0, { 'x': 0 }); 125 | 126 | const classProperties = 127 | generateCSSClass(actor, 'ANIM_NAME', false); 128 | 129 | assert.equal( 130 | classProperties, 131 | ['.ANIM_NAME {', 132 | ' animation-name: ANIM_NAME-x-keyframes;', 133 | ' animation-duration: 0ms;', 134 | ' animation-delay: 0ms;', 135 | ' animation-fill-mode: forwards;', 136 | ' animation-timing-function: linear;', 137 | ' animation-iteration-count: infinite;', 138 | '}'].join('\n') 139 | ); 140 | }); 141 | 142 | it('generates boilerplated class properties for an animation with transform properties', () => { 143 | actor.keyframe(0, { rotate: '0deg' }); 144 | 145 | const classProperties = 146 | generateCSSClass(actor, 'ANIM_NAME', false); 147 | 148 | assert.equal( 149 | classProperties, 150 | ['.ANIM_NAME {', 151 | ' animation-name: ANIM_NAME-transform-keyframes;', 152 | ' animation-duration: 0ms;', 153 | ' animation-delay: 0ms;', 154 | ' animation-fill-mode: forwards;', 155 | ' animation-timing-function: linear;', 156 | ' animation-iteration-count: infinite;', 157 | '}'].join('\n') 158 | ); 159 | }); 160 | 161 | it('generates boilerplated class properties for a vendor-prefixed class', () => { 162 | actor.keyframe(0, { 'x': 0 }); 163 | 164 | const classProperties = generateCSSClass( 165 | actor, 166 | 'ANIM_NAME', 167 | false, 168 | ['webkit'] 169 | ); 170 | 171 | assert.equal( 172 | classProperties, 173 | ['.ANIM_NAME {', 174 | ' -webkit-animation-name: ANIM_NAME-x-keyframes;', 175 | ' -webkit-animation-duration: 0ms;', 176 | ' -webkit-animation-delay: 0ms;', 177 | ' -webkit-animation-fill-mode: forwards;', 178 | ' -webkit-animation-timing-function: linear;', 179 | ' -webkit-animation-iteration-count: infinite;', 180 | '}'].join('\n') 181 | ); 182 | }); 183 | }); 184 | 185 | describe('generateBoilerplatedKeyframes', () => { 186 | it('generates boilerplated keyframes', () => { 187 | actor 188 | .keyframe(0, { 'x': 0 }) 189 | .keyframe(1000, { 'x': 100 }, { 'x': 'fakeLinear' }); 190 | 191 | actor._updateState(0); 192 | 193 | const keyframeData = generateBoilerplatedKeyframes( 194 | actor, 195 | 'ANIM_NAME', 196 | 10, 197 | false 198 | ); 199 | 200 | assert.equal( 201 | keyframeData, 202 | ['@keyframes ANIM_NAME-x-keyframes {', 203 | ' 0% {x:0;}', 204 | ' 10% {x:10;}', 205 | ' 20% {x:20;}', 206 | ' 30% {x:30;}', 207 | ' 40% {x:40;}', 208 | ' 50% {x:50;}', 209 | ' 60% {x:60;}', 210 | ' 70% {x:70;}', 211 | ' 80% {x:80;}', 212 | ' 90% {x:90;}', 213 | ' 100% {x:100;}', 214 | '}'].join('\n') 215 | ); 216 | }); 217 | }); 218 | 219 | describe('generateActorKeyframes', () => { 220 | it('can generate un-optimized keyframe data', () => { 221 | actor 222 | .keyframe(0, { 'x': 0 }) 223 | .keyframe(1000, { 'x': 100 }, { 'x': 'fakeLinear' }); 224 | 225 | actor._updateState(0); 226 | 227 | const keyframeData = 228 | generateActorKeyframes(actor, 10, 'x'); 229 | 230 | assert.equal( 231 | keyframeData, 232 | [' 0% {x:0;}', 233 | ' 10% {x:10;}', 234 | ' 20% {x:20;}', 235 | ' 30% {x:30;}', 236 | ' 40% {x:40;}', 237 | ' 50% {x:50;}', 238 | ' 60% {x:60;}', 239 | ' 70% {x:70;}', 240 | ' 80% {x:80;}', 241 | ' 90% {x:90;}', 242 | ' 100% {x:100;}'].join('\n') 243 | ); 244 | 245 | assert.equal(keyframeData.split('\n').length, 11); 246 | }); 247 | 248 | it('can generate un-optimized keyframe data targeting one track', () => { 249 | actor 250 | .keyframe(0, { 'x': 0, 'y': 5 }) 251 | .keyframe(1000, { 'x': 100, 'y': 15 }, { 'x': 'fakeLinear' }); 252 | 253 | actor._updateState(0); 254 | 255 | const keyframeData = 256 | generateActorKeyframes(actor, 10, 'x'); 257 | 258 | assert.equal( 259 | keyframeData, 260 | [' 0% {x:0;}', 261 | ' 10% {x:10;}', 262 | ' 20% {x:20;}', 263 | ' 30% {x:30;}', 264 | ' 40% {x:40;}', 265 | ' 50% {x:50;}', 266 | ' 60% {x:60;}', 267 | ' 70% {x:70;}', 268 | ' 80% {x:80;}', 269 | ' 90% {x:90;}', 270 | ' 100% {x:100;}'].join('\n') 271 | ); 272 | 273 | assert.equal(keyframeData.split('\n').length, 11); 274 | }); 275 | 276 | it('can control granularity of un-optimized keyframe data', () => { 277 | actor 278 | .keyframe(0, { 'x': 0 }) 279 | .keyframe(1000, { 'x': 100 }, { 'x': 'fakeLinear' }); 280 | 281 | actor._updateState(0); 282 | 283 | const keyframeData = 284 | generateActorKeyframes(actor, 100, 'x'); 285 | 286 | assert.equal(keyframeData.split('\n').length, 101); 287 | }); 288 | 289 | it('can mix optimized and un-optimized segments, optimized first', () => { 290 | actor 291 | .keyframe(0, { 'x': 0 }) 292 | .keyframe(500, { 'x': 10 }) 293 | .keyframe(1000, { 'x': 20 }, { 'x': 'fakeLinear' }); 294 | 295 | actor._updateState(0); 296 | 297 | const keyframeData = 298 | generateActorKeyframes(actor, 10, 'x'); 299 | 300 | assert.equal( 301 | keyframeData, 302 | [' 0% {x:0;VENDORanimation-timing-function: cubic-bezier(.25,.25,.75,.75);}', 303 | ' 50% {x:10;}', 304 | ' 60% {x:12;}', 305 | ' 70% {x:14;}', 306 | ' 80% {x:16;}', 307 | ' 90% {x:18;}', 308 | ' 100% {x:20;}'].join('\n') 309 | ); 310 | }); 311 | 312 | it('can mix optimized and un-optimized segments, optimized last', () => { 313 | actor 314 | .keyframe(0, { 'x': 0 }) 315 | .keyframe(500, { 'x': 10 }, { 'x': 'fakeLinear' }) 316 | .keyframe(1000, { 'x': 20 }); 317 | 318 | actor._updateState(0); 319 | 320 | const keyframeData = 321 | generateActorKeyframes(actor, 10, 'x'); 322 | 323 | assert.equal( 324 | keyframeData, 325 | [' 0% {x:0;}', 326 | ' 10% {x:2;}', 327 | ' 20% {x:4;}', 328 | ' 30% {x:6;}', 329 | ' 40% {x:8;}', 330 | ' 50% {x:10;VENDORanimation-timing-function: cubic-bezier(.25,.25,.75,.75);}', 331 | ' 100% {x:20;}'].join('\n') 332 | ); 333 | }); 334 | 335 | it('does not generate redundant optimized keyframes when they are back-to-back', () => { 336 | actor 337 | .keyframe(0, { 'x': 0 }) 338 | .keyframe(500, { 'x': 5 }) 339 | .keyframe(1000, { 'x': 10 }); 340 | 341 | actor._updateState(0); 342 | 343 | const keyframeData = 344 | generateActorKeyframes(actor, 99, 'x'); 345 | 346 | assert.equal( 347 | keyframeData, 348 | [' 0% {x:0;VENDORanimation-timing-function: cubic-bezier(.25,.25,.75,.75);}', 349 | ' 50% {x:5;VENDORanimation-timing-function: cubic-bezier(.25,.25,.75,.75);}', 350 | ' 100% {x:10;}'].join('\n') 351 | ); 352 | }); 353 | 354 | it('simulates a wait for late-starting tracks by duplicating leading keyframe', () => { 355 | actor 356 | .keyframe(0, { 'x': 0 }) 357 | .keyframe(500, { 'y': 0 }, { 'y': 'fakeLinear' }) 358 | .keyframe(1000, { 'x': 10, 'y': 10 }, { 'y': 'fakeLinear' }); 359 | 360 | const keyframeData = 361 | generateActorKeyframes(actor, 10, 'y'); 362 | 363 | assert.equal( 364 | keyframeData, 365 | [' 0% {y:0;}', 366 | ' 50% {y:0;}', 367 | ' 60% {y:2;}', 368 | ' 70% {y:4;}', 369 | ' 80% {y:6;}', 370 | ' 90% {y:8;}', 371 | ' 100% {y:10;}'].join('\n') 372 | ); 373 | }); 374 | 375 | it('generates duplicate trailing keyframe at the end for early-ending track', () => { 376 | actor 377 | .keyframe(0, { 'x': 0, 'y': 0 }) 378 | .keyframe(500, { 'y': 10 }, { 'y': 'fakeLinear' }) 379 | .keyframe(1000, { 'x': 10 }, { 'y': 'fakeLinear' }); 380 | 381 | actor._updateState(0); 382 | 383 | const keyframeData = 384 | generateActorKeyframes(actor, 10, 'y'); 385 | 386 | assert.equal( 387 | keyframeData, 388 | [' 0% {y:0;}', 389 | ' 10% {y:2;}', 390 | ' 20% {y:4;}', 391 | ' 30% {y:6;}', 392 | ' 40% {y:8;}', 393 | ' 50% {y:10;}', 394 | ' 100% {y:10;}'].join('\n') 395 | ); 396 | }); 397 | 398 | describe('with waits', () => { 399 | it('does not generate redundant @keyframes for unoptimized easing curves', () => { 400 | actor 401 | .keyframe(0, { y: 0 }) 402 | .keyframe(500, { y: 5 }, 'fakeLinear') 403 | .wait(1000); 404 | 405 | actor._updateState(0); 406 | 407 | const keyframeData = 408 | generateActorKeyframes(actor, 10, 'y'); 409 | 410 | assert.equal( 411 | keyframeData, 412 | [' 0% {y:0;}', 413 | ' 10% {y:1;}', 414 | ' 20% {y:2;}', 415 | ' 30% {y:3;}', 416 | ' 40% {y:4;}', 417 | ' 50% {y:5;}', 418 | ' 100% {y:5;}'].join('\n') 419 | ); 420 | }); 421 | 422 | it('does not generate redundant @keyframes for optimized easing curves', () => { 423 | actor 424 | .keyframe(0, { y: 0 }) 425 | .keyframe(500, { y: 5 }) 426 | .wait(1000); 427 | 428 | actor._updateState(0); 429 | 430 | const keyframeData = 431 | generateActorKeyframes(actor, 10, 'y'); 432 | 433 | assert.equal( 434 | keyframeData, 435 | [' 0% {y:0;VENDORanimation-timing-function: cubic-bezier(.25,.25,.75,.75);}', 436 | ' 50% {y:5;}', 437 | ' 100% {y:5;}'].join('\n') 438 | ); 439 | }); 440 | 441 | it('does not generate redundant transform @keyframes for unoptimized easing curves', () => { 442 | actor 443 | .keyframe(0, { transform: 'translateX(0px)' }) 444 | .keyframe(500, { transform: 'translateX(5px)' }, 'fakeLinear') 445 | .wait(1000); 446 | 447 | actor._updateState(0); 448 | 449 | const keyframeData = 450 | generateActorKeyframes(actor, 10, 'transform'); 451 | 452 | assert.equal( 453 | keyframeData, 454 | [' 0% {TRANSFORM:translateX(0px);}', 455 | ' 10% {TRANSFORM:translateX(1px);}', 456 | ' 20% {TRANSFORM:translateX(2px);}', 457 | ' 30% {TRANSFORM:translateX(3px);}', 458 | ' 40% {TRANSFORM:translateX(4px);}', 459 | ' 50% {TRANSFORM:translateX(5px);}', 460 | ' 100% {TRANSFORM:translateX(5px);}'].join('\n') 461 | ); 462 | }); 463 | }); 464 | }); 465 | 466 | describe('simulateLeadingWait', () => { 467 | it('can fake the 0% keyframe', () => { 468 | actor 469 | .keyframe(0, { 'x': 0 }) 470 | .keyframe(500, { 'y': 0 }) 471 | .keyframe(1000, { 'x': 10, 'y': 10 }); 472 | 473 | const keyframeStep = simulateLeadingWait(actor, 'y', 0); 474 | 475 | assert.equal(keyframeStep, ' 0% {y:0;}'); 476 | }); 477 | }); 478 | 479 | describe('simulateTrailingWait', () => { 480 | it('can fake the 100% keyframe', () => { 481 | actor 482 | .keyframe(0, { 'x': 0, 'y': 0 }) 483 | .keyframe(500, { 'y': 10 }) 484 | .keyframe(1000, { 'x': 10 }); 485 | 486 | const keyframeStep = simulateTrailingWait( 487 | actor, 'y', actor.getStart(), actor.getEnd() 488 | ); 489 | 490 | assert.equal(keyframeStep, ' 100% {y:10;}'); 491 | }); 492 | }); 493 | 494 | describe('generateActorTrackSegment', () => { 495 | it('can get @keyframes for a three-step track segment', () => { 496 | actor 497 | .keyframe(0, { 'x': 0 }) 498 | .keyframe(1000, { 'x': 100 }) 499 | .keyframe(2000, { 'x': 200 }); 500 | 501 | const serializedSegment = generateActorTrackSegment( 502 | actor, 5, 10, 0, 50, actor._propertyTracks['x'][1] 503 | ); 504 | 505 | assert.deepEqual( 506 | serializedSegment, 507 | [' 50% {x:100;}', 508 | ' 60% {x:120;}', 509 | ' 70% {x:140;}', 510 | ' 80% {x:160;}', 511 | ' 90% {x:180;}'] 512 | ); 513 | }); 514 | 515 | it('can get @keyframes for a five-step track segment', () => { 516 | actor 517 | .keyframe(0, { 'x': 0 }) 518 | .keyframe(1000, { 'x': 100 }) 519 | .keyframe(2000, { 'x': 200 }) 520 | .keyframe(3000, { 'x': 400 }) 521 | .keyframe(4000, { 'x': 600 }); 522 | 523 | const serializedSegment = generateActorTrackSegment( 524 | actor, 5, 5, 0, 25, actor._propertyTracks['x'][1] 525 | ); 526 | 527 | assert.deepEqual( 528 | serializedSegment, 529 | [' 25% {x:100;}', 530 | ' 30% {x:120;}', 531 | ' 35% {x:140;}', 532 | ' 40% {x:160;}', 533 | ' 45% {x:180;}'] 534 | ); 535 | }); 536 | }); 537 | 538 | describe('combineTransformProperties', () => { 539 | it('can combine transform properties into a single property', () => { 540 | const combinedProperty = combineTransformProperties({ 541 | translateX: '10px', 542 | translateY: '20px' 543 | }, transformFunctions); 544 | 545 | const targetObject = { 546 | [TRANSFORM_TOKEN]: 'translateX(10px) translateY(20px)' 547 | }; 548 | 549 | assert.deepEqual( 550 | combinedProperty, 551 | targetObject 552 | ); 553 | }); 554 | 555 | it('can combine transform properties into a single property and leave non-tranform properties unchanged', () => { 556 | const combinedProperty = combineTransformProperties({ 557 | translateX: '10px', 558 | translateY: '20px', 559 | foo: 'bar' 560 | }, transformFunctions); 561 | 562 | const targetObject = { 563 | foo: 'bar', 564 | [TRANSFORM_TOKEN]: 'translateX(10px) translateY(20px)' 565 | }; 566 | 567 | assert.deepEqual( 568 | combinedProperty, 569 | targetObject 570 | ); 571 | }); 572 | }); 573 | 574 | describe('serializeActorStep', () => { 575 | it('can serialize an individual Actor step', () => { 576 | actor 577 | .keyframe(0, { x: 0 }) 578 | .keyframe(1000, { x: 100 }); 579 | 580 | actor._updateState(500); 581 | 582 | assert.equal(serializeActorStep(actor), '{x:50;}'); 583 | }); 584 | 585 | it('rewrites transform properties', () => { 586 | actor 587 | .keyframe(0, { transform: 'rotate(0deg)' }) 588 | .keyframe(1000, { transform: 'rotate(100deg)' }); 589 | 590 | actor._updateState(500); 591 | 592 | assert.equal( 593 | serializeActorStep(actor), 594 | '{' + TRANSFORM_TOKEN + ':rotate(50deg);}' 595 | ); 596 | }); 597 | 598 | it('rewrites decoupled tranform properties', () => { 599 | actor 600 | .keyframe(0, { rotate: '0deg' }) 601 | .keyframe(1000, { rotate: '100deg' }); 602 | 603 | actor._updateState(500); 604 | 605 | assert.equal( 606 | serializeActorStep(actor), 607 | '{' + TRANSFORM_TOKEN + ':rotate(50deg);}' 608 | ); 609 | }); 610 | 611 | it('can target a single property to serialize', () => { 612 | actor 613 | .keyframe(0, { x: 0, y: 50 }) 614 | .keyframe(1000, { x: 10, y: 100 }); 615 | 616 | actor._updateState(500); 617 | 618 | assert.equal( 619 | serializeActorStep(actor, 'x'), 620 | '{x:5;}' 621 | ); 622 | }); 623 | 624 | it('can serialize multiple keyframes into a single step', () => { 625 | actor 626 | .keyframe(0, { 'x': 0, 'y': 0 }) 627 | .keyframe(1000, { 'x': 10, 'y': 20 }, 'fakeLinear'); 628 | 629 | actor._updateState(500); 630 | 631 | assert.equal(serializeActorStep(actor), '{x:5;y:10;}'); 632 | }); 633 | }); 634 | 635 | describe('generateAnimationNameProperty', () => { 636 | it('can generate the CSS name of an animation', () => { 637 | actor 638 | .keyframe(0, { 'x': 0, 'y': 50 }) 639 | .keyframe(1000, { 'x': 10, 'y': 100 }); 640 | 641 | const animName = generateAnimationNameProperty( 642 | actor, 'ANIM_NAME', 'PREFIX', false 643 | ); 644 | 645 | assert.equal( 646 | animName, 647 | ' PREFIXanimation-name: ANIM_NAME-x-keyframes, ANIM_NAME-y-keyframes;' 648 | ); 649 | }); 650 | 651 | it('can generate the CSS name of an animation with multiple properties', () => { 652 | actor 653 | .keyframe(0, { 'x': 0, 'y': 50 }) 654 | .keyframe(1000, { 'x': 10, 'y': 100 }); 655 | 656 | const animName = generateAnimationNameProperty( 657 | actor, 'ANIM_NAME', 'PREFIX', false 658 | ); 659 | 660 | assert.equal( 661 | animName, 662 | ' PREFIXanimation-name: ANIM_NAME-x-keyframes, ANIM_NAME-y-keyframes;' 663 | ); 664 | }); 665 | 666 | it('can generate single CSS name of an animation with combined keyframes', () => { 667 | actor 668 | .keyframe(0, { 'x': 0, 'y': 50 }, 'fakeLinear') 669 | .keyframe(1000, { 'x': 10, 'y': 100 }); 670 | 671 | const animName = generateAnimationNameProperty( 672 | actor, 'ANIM_NAME', 'PREFIX', true 673 | ); 674 | 675 | assert.equal( 676 | animName, 677 | ' PREFIXanimation-name: ANIM_NAME-keyframes;' 678 | ); 679 | }); 680 | }); 681 | 682 | describe('generateAnimationIterationProperty', () => { 683 | afterEach(() => { 684 | rekapi.stop(); 685 | }); 686 | 687 | it('can generate an infinite CSS iteration count an animation', () => { 688 | const animDuration = generateAnimationIterationProperty( 689 | rekapi, 'PREFIX' 690 | ); 691 | 692 | assert.equal( 693 | animDuration, 694 | ' PREFIXanimation-iteration-count: infinite;' 695 | ); 696 | }); 697 | 698 | it('can generate a finite CSS iteration count an animation', () => { 699 | rekapi.play(3); 700 | 701 | const animDuration = generateAnimationIterationProperty( 702 | rekapi, 'PREFIX' 703 | ); 704 | 705 | assert.equal( 706 | animDuration, 707 | ' PREFIXanimation-iteration-count: 3;' 708 | ); 709 | }); 710 | 711 | it('can generate an overridden CSS iteration count an animation', () => { 712 | rekapi.play(3); 713 | 714 | const animDuration = generateAnimationIterationProperty( 715 | rekapi, 'PREFIX', 5 716 | ); 717 | 718 | assert.equal( 719 | animDuration, 720 | ' PREFIXanimation-iteration-count: 5;' 721 | ); 722 | }); 723 | }); 724 | 725 | describe('canOptimizeKeyframeProperty', () => { 726 | it('detects a property that can be optimized', () => { 727 | actor 728 | .keyframe(0, { x: 0 }) 729 | .keyframe(1000, { x: 10 }, { x: 'easeInQuad' }); 730 | 731 | actor._updateState(0); 732 | 733 | assert( 734 | canOptimizeKeyframeProperty(actor.getKeyframeProperty('x', 0)) 735 | ); 736 | }); 737 | 738 | it('detects a property that cannot be optimized', () => { 739 | actor 740 | .keyframe(0, { x: 0 }) 741 | .keyframe(1000, { x: 10 }, { x: 'bounce' }); 742 | 743 | const canBeOptimized = canOptimizeKeyframeProperty( 744 | actor.getKeyframeProperty('x', 0) 745 | ); 746 | 747 | assert.equal(canBeOptimized, false); 748 | }); 749 | 750 | it('detects a transform that can be optimized', () => { 751 | actor 752 | .keyframe(0, { transform: 'translateX(0) translateY(0)' }) 753 | .keyframe(1000, 754 | { transform: 'translateX(10) translateY(10)' }, 755 | { transform: 'linear linear' } 756 | ); 757 | 758 | actor._updateState(0); 759 | 760 | const canBeOptimized = canOptimizeKeyframeProperty( 761 | actor.getKeyframeProperty('transform', 0) 762 | ); 763 | 764 | assert(canBeOptimized); 765 | }); 766 | 767 | it('detects a transform that cannot be optimized', () => { 768 | actor 769 | .keyframe(0, { transform: 'translateX(0) translateY(0)' }) 770 | .keyframe(1000, 771 | { transform: 'translateX(10) translateY(10)' }, 772 | { transform: 'linear easeInQuad' } 773 | ); 774 | 775 | const canBeOptimized = canOptimizeKeyframeProperty( 776 | actor.getKeyframeProperty('transform', 0) 777 | ); 778 | 779 | assert.equal(canBeOptimized, false); 780 | }); 781 | 782 | it('detects that a wait can be optimized', () => { 783 | actor 784 | .keyframe(0, { y: 0 }) 785 | .keyframe(500, { y: 5 }) 786 | .wait(1000); 787 | 788 | actor._updateState(0); 789 | 790 | const canBeOptimized = canOptimizeKeyframeProperty( 791 | actor.getKeyframeProperty('y', 500) 792 | ); 793 | 794 | assert(canBeOptimized); 795 | }); 796 | }); 797 | 798 | describe('canOptimizeAnyKeyframeProperties', () => { 799 | it('detects that all optimizable keyframes can be optimized', () => { 800 | actor 801 | .keyframe(0, { x: 0, y: 0 }) 802 | .keyframe(1000, { x: 10, y: 20 }); 803 | 804 | actor._updateState(0); 805 | 806 | const canBeOptimized = 807 | canOptimizeAnyKeyframeProperties(actor); 808 | 809 | assert(canBeOptimized); 810 | }); 811 | 812 | it('detects that a mixed set of keyframes has some keyframes that can be optimized', () => { 813 | actor 814 | .keyframe(0, { x: 0, y: 0 }) 815 | .keyframe(1000, { x: 10, y: 20 }, { x: 'fakeLinear' }); 816 | 817 | actor._updateState(0); 818 | 819 | const canBeOptimized = 820 | canOptimizeAnyKeyframeProperties(actor); 821 | 822 | assert(canBeOptimized); 823 | }); 824 | 825 | it('detects that all un-optimizable keyframes cannot be optimized', () => { 826 | actor 827 | .keyframe(0, { x: 0, y: 0 }) 828 | .keyframe(1000, { x: 10, y: 20 }, 'fakeLinear'); 829 | 830 | const canBeOptimized = 831 | canOptimizeAnyKeyframeProperties(actor); 832 | 833 | assert.equal(canBeOptimized, false); 834 | }); 835 | }); 836 | 837 | describe('generateOptimizedKeyframeSegment', () => { 838 | it('generates an optimized segment', () => { 839 | actor 840 | .keyframe(0, { x: 0 }) 841 | .keyframe(1000, { x: 10 }, { x: 'easeInQuad' }); 842 | 843 | actor._updateState(0); 844 | 845 | const optimizedSegment = generateOptimizedKeyframeSegment( 846 | actor.getKeyframeProperty('x', 0), 0, 100 847 | ); 848 | 849 | assert.equal( 850 | optimizedSegment, 851 | [' 0% {x:0;VENDORanimation-timing-function: cubic-bezier(.55,.085,.68,.53);}', 852 | ' 100% {x:10;}'].join('\n') 853 | ); 854 | }); 855 | 856 | it('generates an optimized transform segment', () => { 857 | actor 858 | .keyframe(0, { transform: 'translateX(0)' }) 859 | .keyframe(1000, 860 | { transform: 'translateX(10)' }, 861 | { transform: 'easeInQuad easeInQuad' } 862 | ); 863 | 864 | actor._updateState(0); 865 | 866 | const optimizedSegment = generateOptimizedKeyframeSegment( 867 | actor.getKeyframeProperty('transform', 0), 0, 100 868 | ); 869 | 870 | assert.equal( 871 | optimizedSegment, 872 | [' 0% {TRANSFORM:translateX(0);VENDORanimation-timing-function: cubic-bezier(.55,.085,.68,.53);}', 873 | ' 100% {TRANSFORM:translateX(10);}'].join('\n') 874 | ); 875 | }); 876 | }); 877 | 878 | describe('DOMRenderer#getCss', () => { 879 | it('only generates CSS for DOM actors', () => { 880 | rekapi = new Rekapi(document.body); 881 | const testActorEl = document.createElement('div'); 882 | const domActor = new Actor({ context: testActorEl }); 883 | const nonDOMActor = new Actor({ context: {} }); 884 | rekapi.addActor(domActor); 885 | rekapi.addActor(nonDOMActor); 886 | 887 | const css = rekapi.getRendererInstance(DOMRenderer).getCss(); 888 | const singleLineCss = css.split('\n').join(''); 889 | 890 | assert(!!singleLineCss.match('actor-' + domActor.id)); 891 | assert(!singleLineCss.match('actor-' + nonDOMActor.id)); 892 | }); 893 | }); 894 | }); 895 | }); 896 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Rekapi 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import './utils'; 2 | import './rekapi'; 3 | import './actor'; 4 | import './keyframe-property'; 5 | import './canvas'; 6 | import './dom'; 7 | import './get-css'; 8 | -------------------------------------------------------------------------------- /test/keyframe-property.js: -------------------------------------------------------------------------------- 1 | /* global describe:true, it:true, before:true, beforeEach:true, afterEach:true */ 2 | import assert from 'assert'; 3 | import { contains } from 'lodash'; 4 | import { setupTestRekapi, setupTestActor } from './test-utils'; 5 | 6 | import { Rekapi, KeyframeProperty } from '../src/main'; 7 | import { 8 | Tweenable, 9 | interpolate, 10 | setBezierFunction, 11 | unsetBezierFunction 12 | } from 'shifty'; 13 | 14 | describe('KeyframeProperty', () => { 15 | let rekapi, actor; 16 | 17 | beforeEach(() => { 18 | rekapi = setupTestRekapi(); 19 | actor = setupTestActor(rekapi); 20 | }); 21 | 22 | describe('constructor', () => { 23 | it('is a function', () => { 24 | assert.equal(typeof KeyframeProperty, 'function'); 25 | }); 26 | 27 | it('sets key object properties', () => { 28 | const keyframeProperty = new KeyframeProperty(500, 'x', 10, 'easeInQuad'); 29 | 30 | assert.equal(keyframeProperty.millisecond, 500); 31 | assert.equal(keyframeProperty.name, 'x'); 32 | assert.equal(keyframeProperty.value, 10); 33 | assert.equal(keyframeProperty.easing, 'easeInQuad'); 34 | }); 35 | }); 36 | 37 | describe('#modifyWith', () => { 38 | it('modifies properties', function () { 39 | const keyframeProperty = new KeyframeProperty(500, 'x', 10, 'easeInQuad'); 40 | 41 | keyframeProperty.modifyWith({ 42 | millisecond: 1500, 43 | easing: 'bounce', 44 | value: 123456 45 | }); 46 | 47 | assert.equal(keyframeProperty.millisecond, 1500); 48 | assert.equal(keyframeProperty.value, 123456); 49 | assert.equal(keyframeProperty.easing, 'bounce'); 50 | }); 51 | }); 52 | 53 | describe('#linkToNext', () => { 54 | it('links to a given KeyframeProperty instance', () => { 55 | const keyframeProperty = new KeyframeProperty(500, 'x', 10, 'easeInQuad'); 56 | const linkedKeyprop = new KeyframeProperty(1000, 'x', 20, 'swingTo'); 57 | 58 | keyframeProperty.linkToNext(linkedKeyprop); 59 | 60 | assert.equal(keyframeProperty.nextProperty, linkedKeyprop); 61 | }); 62 | }); 63 | 64 | describe('#getValueAt', () => { 65 | it('computes given midpoint between self and linked KeyframeProperty instance', () => { 66 | const keyframeProperty = new KeyframeProperty(500, 'x', 10, 'linear'); 67 | const linkedKeyprop = new KeyframeProperty(1000, 'x', 20, 'linear'); 68 | 69 | keyframeProperty.linkToNext(linkedKeyprop); 70 | const midpoint = keyframeProperty.getValueAt(750); 71 | 72 | assert.equal(midpoint, 15); 73 | }); 74 | }); 75 | 76 | describe('#detach', () => { 77 | it('destroys actor/keyframeProperty association', () => { 78 | actor.keyframe(0, { x: 10 }); 79 | const keyframeProperty = actor.getKeyframeProperty('x', 0); 80 | 81 | assert.equal(actor, keyframeProperty.actor); 82 | keyframeProperty.detach(); 83 | assert.equal(null, keyframeProperty.actor); 84 | }); 85 | }); 86 | 87 | describe('#exportPropertyData', () => { 88 | beforeEach(() => { 89 | actor.keyframe(0, { x: 1 }); 90 | }); 91 | 92 | it('exports key data points', () => { 93 | const exportedProp = actor._propertyTracks.x[0].exportPropertyData(); 94 | assert.equal(typeof exportedProp.millisecond, 'number'); 95 | assert.equal(typeof exportedProp.name, 'string'); 96 | assert.equal(typeof exportedProp.value, 'number'); 97 | assert.equal(typeof exportedProp.easing, 'string'); 98 | assert.equal(typeof exportedProp.id, 'undefined'); 99 | }); 100 | 101 | describe('withId: true', () => { 102 | it('includes id property', () => { 103 | const exportedProp = 104 | actor._propertyTracks.x[0].exportPropertyData({ withId: true }); 105 | assert.equal(typeof exportedProp.id, 'string'); 106 | }); 107 | }); 108 | }); 109 | 110 | describe('removeKeyframeProperty event', () => { 111 | let removeKeyframePropertyWasTriggered, keyframeProperty; 112 | 113 | beforeEach(() => { 114 | removeKeyframePropertyWasTriggered = false; 115 | actor.keyframe(0, { x: 10 }); 116 | keyframeProperty = actor.getKeyframeProperty('x', 0); 117 | rekapi.on('removeKeyframeProperty', () => removeKeyframePropertyWasTriggered = true); 118 | }); 119 | 120 | it('is fired by #detach', () => { 121 | keyframeProperty.detach(); 122 | assert(removeKeyframePropertyWasTriggered); 123 | }); 124 | 125 | it('is fired by Actor#removeKeyframe', () => { 126 | actor.removeKeyframe(0); 127 | assert(removeKeyframePropertyWasTriggered); 128 | }); 129 | 130 | it('is fired by Actor#removeAllKeyframes', () => { 131 | actor.removeAllKeyframes(); 132 | assert(removeKeyframePropertyWasTriggered); 133 | }); 134 | 135 | it('is fired by Actor#removeKeyframeProperty', () => { 136 | actor.removeKeyframeProperty('x', 0); 137 | assert(removeKeyframePropertyWasTriggered); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /test/rekapi.js: -------------------------------------------------------------------------------- 1 | /* global describe:true, it:true, before:true, beforeEach:true, afterEach:true */ 2 | import assert from 'assert'; 3 | import { contains } from 'lodash'; 4 | import { setupTestRekapi, setupTestActor } from './test-utils'; 5 | 6 | import { Rekapi, Actor, DOMRenderer } from '../src/main'; 7 | import { Tweenable, setBezierFunction, unsetBezierFunction } from 'shifty'; 8 | 9 | import { 10 | determineCurrentLoopIteration, 11 | calculateTimeSinceStart, 12 | isAnimationComplete, 13 | updatePlayState, 14 | calculateLoopPosition, 15 | updateToMillisecond, 16 | updateToCurrentMillisecond 17 | } from '../src/rekapi'; 18 | 19 | describe('Rekapi', () => { 20 | let rekapi, actor, actor2; 21 | 22 | beforeEach(() => { 23 | rekapi = setupTestRekapi(); 24 | actor = setupTestActor(rekapi); 25 | }); 26 | 27 | afterEach(() => { 28 | rekapi = undefined; 29 | actor = undefined; 30 | Tweenable.now = () => +(new Date()); 31 | }); 32 | 33 | describe('constructor', () => { 34 | it('is a function', () => { 35 | assert.equal(typeof Rekapi, 'function'); 36 | }); 37 | }); 38 | 39 | describe('#addActor', () => { 40 | it('adds actors', () => { 41 | assert.equal(rekapi._actors[0], actor); 42 | }); 43 | 44 | it('only adds actors once', () => { 45 | rekapi.addActor(actor); 46 | assert.equal(rekapi.getActorCount(), 1); 47 | }); 48 | 49 | it('propagates arguments to instantiated actor', () => { 50 | const actorContext = {}; 51 | rekapi = setupTestRekapi(); 52 | actor = setupTestActor(rekapi, { context: actorContext }); 53 | 54 | assert(actor instanceof Actor); 55 | assert.equal(actorContext, actor.context); 56 | }); 57 | }); 58 | 59 | describe('#getActor', () => { 60 | it('retrieves added actor', () => { 61 | assert.equal(rekapi.getActor(actor.id), actor); 62 | }); 63 | }); 64 | 65 | describe('#removeActor', () => { 66 | it('removes an actor', () => { 67 | rekapi.removeActor(actor); 68 | 69 | assert.equal(rekapi._actors.length, 0); 70 | }); 71 | }); 72 | 73 | describe('#removeAllActors', () => { 74 | it('removes all actors', () => { 75 | setupTestActor(rekapi); 76 | const removedActors = rekapi.removeAllActors(); 77 | 78 | assert.equal(rekapi._actors.length, 0); 79 | assert.equal( 80 | removedActors.filter(actor => actor instanceof Actor).length, 81 | 2 82 | ); 83 | }); 84 | }); 85 | 86 | describe('#getActorIds', () => { 87 | it('gets actor ids', () => { 88 | actor2 = setupTestActor(rekapi); 89 | const ids = rekapi.getActorIds(); 90 | 91 | assert.equal(ids.length, 2); 92 | assert(contains(ids, actor.id)); 93 | assert(contains(ids, actor2.id)); 94 | }); 95 | }); 96 | 97 | describe('#getAllActors', () => { 98 | it('gets all actors', () => { 99 | actor2 = setupTestActor(rekapi); 100 | const actors = rekapi.getAllActors(); 101 | 102 | assert.equal(actors[0], actor); 103 | assert.equal(actors[1], actor2); 104 | }); 105 | }); 106 | 107 | describe('#getActor', () => { 108 | it('gets an actor', () => { 109 | assert.equal(rekapi.getActor(actor.id), actor); 110 | }); 111 | }); 112 | 113 | describe('#getAnimationLength', () => { 114 | describe('single actor', () => { 115 | it('calculates correct animation length', () => { 116 | actor 117 | .keyframe(0, { x: 1 }) 118 | .keyframe(1000, { x: 2 }) 119 | .keyframe(2000, { x: 3 }); 120 | 121 | assert.equal(rekapi.getAnimationLength(), 2000); 122 | }); 123 | }); 124 | 125 | describe('multiple actors', () => { 126 | it('calculates correct animation length', () => { 127 | actor 128 | .keyframe(0, { x: 1 }) 129 | .keyframe(1000, { x: 2 }) 130 | .keyframe(2000, { x: 3 }); 131 | 132 | setupTestActor(rekapi) 133 | .keyframe(0, { x: 1 }) 134 | .keyframe(5000, { x: 2 }); 135 | 136 | assert.equal(rekapi.getAnimationLength(), 5000); 137 | }); 138 | }); 139 | 140 | describe('after adding actors that already have keyframes', () => { 141 | it('returns updated animation length', () => { 142 | actor = new Actor(); 143 | 144 | actor 145 | .keyframe(0, { x: 0 }) 146 | .keyframe(1000, { x: 10 }); 147 | 148 | rekapi = new Rekapi(); 149 | rekapi.addActor(actor); 150 | 151 | assert.equal(rekapi.getAnimationLength(), 1000); 152 | }); 153 | }); 154 | }); 155 | 156 | describe('#exportTimeline', () => { 157 | let exportedTimeline; 158 | 159 | it('exports key data points', () => { 160 | actor.keyframe(0, { 161 | x: 1 162 | }).keyframe(1000, { 163 | x: 2 164 | }); 165 | 166 | exportedTimeline = rekapi.exportTimeline(); 167 | 168 | assert.equal(exportedTimeline.duration, 1000); 169 | assert.deepEqual( 170 | exportedTimeline.actors[0], 171 | actor.exportTimeline() 172 | ); 173 | assert.equal(typeof exportedTimeline.actors[0].id, 'undefined'); 174 | assert.equal( 175 | typeof exportedTimeline.actors[0].propertyTracks.x[0].id, 176 | 'undefined' 177 | ); 178 | }); 179 | 180 | it('exports custom easing curves', () => { 181 | setBezierFunction('custom', 0, 0.25, 0.5, 0.75); 182 | rekapi = setupTestRekapi(); 183 | 184 | exportedTimeline = rekapi.exportTimeline(); 185 | assert.deepEqual( 186 | exportedTimeline.curves, { 187 | custom: { 188 | displayName: 'custom', 189 | x1: 0, 190 | y1: 0.25, 191 | x2: 0.5, 192 | y2: 0.75 193 | } 194 | }); 195 | 196 | // Clean up Tweenable 197 | unsetBezierFunction('custom'); 198 | }); 199 | 200 | describe('withId: true', () => { 201 | beforeEach(() => { 202 | actor.keyframe(0, { 203 | x: 1 204 | }).keyframe(1000, { 205 | x: 2 206 | }); 207 | }); 208 | 209 | it('includes id properties', () => { 210 | exportedTimeline = rekapi.exportTimeline({ withId: true }); 211 | assert.equal(typeof exportedTimeline.actors[0].id, 'string'); 212 | assert.equal( 213 | typeof exportedTimeline.actors[0].propertyTracks.x[0].id, 214 | 'string' 215 | ); 216 | }); 217 | }); 218 | }); 219 | 220 | describe('#importTimeline', () => { 221 | let exportedTimeline, targetRekapi; 222 | 223 | it('imports data correctly', () => { 224 | actor.keyframe(0, { 225 | x: 1 226 | }).keyframe(1000, { 227 | x: 2 228 | }); 229 | 230 | exportedTimeline = rekapi.exportTimeline(); 231 | targetRekapi = new Rekapi(); 232 | targetRekapi.importTimeline(exportedTimeline); 233 | 234 | assert.deepEqual(targetRekapi.exportTimeline(), exportedTimeline); 235 | }); 236 | 237 | it('sets up custom curves correctly', () => { 238 | setBezierFunction('custom', 0, 0.25, 0.5, 0.75); 239 | rekapi = setupTestRekapi(); 240 | 241 | exportedTimeline = rekapi.exportTimeline(); 242 | 243 | // Reset for a clean test 244 | unsetBezierFunction('custom'); 245 | 246 | targetRekapi = new Rekapi(); 247 | targetRekapi.importTimeline(exportedTimeline); 248 | 249 | assert.equal(typeof Tweenable.formulas.custom, 'function'); 250 | assert.equal(Tweenable.formulas.custom.x1, 0); 251 | assert.equal(Tweenable.formulas.custom.y1, 0.25); 252 | assert.equal(Tweenable.formulas.custom.x2, 0.5); 253 | assert.equal(Tweenable.formulas.custom.y2, 0.75); 254 | 255 | // Clean up Tweenable 256 | unsetBezierFunction('custom'); 257 | }); 258 | }); 259 | 260 | describe('#on', () => { 261 | it('fires an event when an actor is added', () => { 262 | rekapi.on('addActor', function(rekapi, addedActor) { 263 | assert.equal(actor, addedActor); 264 | }); 265 | 266 | rekapi.addActor(actor); 267 | }); 268 | 269 | it('fires an event when an actor is removed', () => { 270 | rekapi.on('removeActor', function(rekapi, removedActor) { 271 | assert.equal(actor, removedActor); 272 | }); 273 | 274 | rekapi.removeActor(actor); 275 | }); 276 | }); 277 | 278 | describe('#off', () => { 279 | it('unbinds event handlers', () => { 280 | let handlerWasCalled; 281 | 282 | rekapi.on('addActor', () => handlerWasCalled = true); 283 | rekapi.addActor(actor); 284 | 285 | assert(!handlerWasCalled); 286 | }); 287 | }); 288 | 289 | describe('#trigger', () => { 290 | it('triggers an event', () => { 291 | let eventWasTriggered = false; 292 | let providedData; 293 | 294 | rekapi.on('timelineModified', (_, data) => { 295 | eventWasTriggered = true; 296 | providedData = data; 297 | }); 298 | 299 | rekapi.trigger('timelineModified', 5); 300 | assert(eventWasTriggered); 301 | assert.equal(providedData, 5); 302 | }); 303 | }); 304 | 305 | describe('#getLastPositionUpdated', () => { 306 | it('gets last calculated timeline position as a normalized value', () => { 307 | actor.keyframe(0, { 308 | x: 1 309 | }).keyframe(1000, { 310 | x: 2 311 | }); 312 | 313 | rekapi.update(500); 314 | assert.equal(rekapi.getLastPositionUpdated(), 0.5); 315 | }); 316 | }); 317 | 318 | describe('#getLastMillisecondUpdated', () => { 319 | it('gets last calculated timeline position in milliseconds', () => { 320 | actor.keyframe(0, { 321 | x: 1 322 | }).keyframe(1000, { 323 | x: 2 324 | }); 325 | 326 | rekapi.update(500); 327 | assert.equal(rekapi.getLastMillisecondUpdated(), 500); 328 | }); 329 | }); 330 | 331 | describe('#getActorCount', () => { 332 | it('gets number of actors in timeline', () => { 333 | setupTestActor(rekapi); 334 | setupTestActor(rekapi); 335 | assert.equal(rekapi.getActorCount(), 3); 336 | }); 337 | }); 338 | 339 | describe('#update', () => { 340 | describe('with parameters', () => { 341 | it('causes the actor states to be recalculated', () => { 342 | actor 343 | .keyframe(0, { x: 0 }) 344 | .keyframe(1000, { x: 10 }); 345 | 346 | rekapi.update(500); 347 | assert.equal(actor.get().x, 5); 348 | }); 349 | }); 350 | 351 | describe('with no parameters', () => { 352 | it('causes the animation to update to the last rendered millisecond', () => { 353 | actor 354 | .keyframe(0, { x: 0 }) 355 | .keyframe(1000, { x: 10 }); 356 | 357 | // Simulate the state of rekapi if it was stopped at millisecond 500 358 | rekapi._lastUpdatedMillisecond = 500; 359 | 360 | rekapi.update(); 361 | assert.equal(actor.get().x, 5); 362 | }); 363 | 364 | it('resets function keyframes that come later in the timeline', () => { 365 | let callCount = 0; 366 | actor 367 | .keyframe(10, { 368 | 'function': function () { 369 | callCount++; 370 | }, 371 | x: 0 372 | }) 373 | .keyframe(20, { 374 | x: 10 375 | }); 376 | 377 | rekapi._timesToIterate = 1; 378 | 379 | updateToMillisecond(rekapi, 5); 380 | assert.equal(callCount, 0); 381 | 382 | updateToMillisecond(rekapi, 15); 383 | assert.equal(callCount, 1); 384 | 385 | rekapi.update(5); 386 | updateToMillisecond(rekapi, 15); 387 | assert.equal(callCount, 2); 388 | }); 389 | }); 390 | }); 391 | 392 | describe('#isPlaying', () => { 393 | it('returns the play state of the animation', () => { 394 | rekapi.play(); 395 | assert(rekapi.isPlaying()); 396 | 397 | rekapi.pause(); 398 | assert.equal(rekapi.isPlaying(), false); 399 | 400 | rekapi.stop(); 401 | assert.equal(rekapi.isPlaying(), false); 402 | }); 403 | }); 404 | 405 | describe('#pause', () => { 406 | it('resumes a paused animation', () => { 407 | actor 408 | .keyframe(0, {}) 409 | .keyframe(1000, {}) 410 | .keyframe(2000, {}); 411 | 412 | 413 | Tweenable.now = () => 0; 414 | rekapi.play(); 415 | Tweenable.now = () => 500; 416 | rekapi.pause(); 417 | Tweenable.now = () => 1500; 418 | rekapi.play(); 419 | 420 | assert.equal(rekapi._loopTimestamp, 1000); 421 | }); 422 | }); 423 | 424 | describe('#isPaused', () => { 425 | it('returns the paused state of the animation', function () { 426 | rekapi.play(); 427 | assert.equal(rekapi.isPaused(), false); 428 | 429 | rekapi.pause(); 430 | assert(rekapi.isPaused()); 431 | 432 | rekapi.stop(); 433 | assert.equal(rekapi.isPaused(), false); 434 | }); 435 | }); 436 | 437 | describe('#stop', () => { 438 | it('moves the playhead to the beginning of the timeline', () => { 439 | actor 440 | .keyframe(0, {}) 441 | .keyframe(1000, {}) 442 | .keyframe(2000, {}); 443 | 444 | Tweenable.now = () => 0; 445 | rekapi.play(); 446 | 447 | Tweenable.now = () => 500; 448 | rekapi.stop(); 449 | 450 | Tweenable.now = () => 1500; 451 | rekapi.play(); 452 | 453 | assert.equal(rekapi._loopTimestamp, 1500); 454 | }); 455 | 456 | it('resets function keyframes', () => { 457 | let callCount = 0; 458 | actor 459 | .keyframe(10, { 460 | 'function': () => callCount++ 461 | }) 462 | .keyframe(20, { 463 | 'function': () => callCount++ 464 | }); 465 | 466 | rekapi.play(); 467 | 468 | updateToMillisecond(rekapi, 5); 469 | assert.equal(callCount, 0); 470 | 471 | updateToMillisecond(rekapi, 15); 472 | assert.equal(callCount, 1); 473 | 474 | rekapi.stop(); 475 | 476 | updateToMillisecond(rekapi, 15); 477 | assert.equal(callCount, 2); 478 | }); 479 | }); 480 | 481 | describe('#isStopped', () => { 482 | it('returns the stopped state of the animation', function () { 483 | rekapi.play(); 484 | assert.equal(rekapi.isStopped(), false); 485 | 486 | rekapi.pause(); 487 | assert.equal(rekapi.isStopped(), false); 488 | 489 | rekapi.stop(); 490 | assert(rekapi.isStopped()); 491 | }); 492 | }); 493 | 494 | describe('#playFrom', () => { 495 | it('starts an animation from an arbitrary point on the timeline', () => { 496 | actor 497 | .keyframe(0, {}) 498 | .keyframe(1000, {}); 499 | 500 | Tweenable.now = () => 3000; 501 | rekapi.playFrom(500); 502 | 503 | assert.equal(rekapi._loopTimestamp, 2500); 504 | }); 505 | 506 | it('resets function keyframes after the specified millisecond', () => { 507 | let callCount = 0; 508 | actor 509 | .keyframe(10, { 510 | 'function': () => callCount++ 511 | }) 512 | .keyframe(20, { 513 | 'function': () => callCount++ 514 | }); 515 | 516 | rekapi._timesToIterate = 1; 517 | 518 | updateToMillisecond(rekapi, 5); 519 | assert.equal(callCount, 0); 520 | 521 | updateToMillisecond(rekapi, 15); 522 | assert.equal(callCount, 1); 523 | 524 | rekapi.playFrom(5); 525 | updateToMillisecond(rekapi, 15); 526 | assert.equal(callCount, 2); 527 | }); 528 | }); 529 | 530 | describe('#playFromCurrent', () => { 531 | it('can start playback from an arbitrary point on the timeline', () => { 532 | actor 533 | .keyframe(0, {}) 534 | .keyframe(1000, {}); 535 | 536 | Tweenable.now = () => 3000; 537 | 538 | rekapi.update(500); 539 | rekapi.playFromCurrent(); 540 | 541 | assert.equal(rekapi._loopTimestamp, 2500); 542 | }); 543 | }); 544 | 545 | describe('#getEventNames', () => { 546 | it('returns a list of event names', () => { 547 | assert.deepEqual(rekapi.getEventNames().sort(), Object.keys(rekapi._events).sort()); 548 | }); 549 | }); 550 | 551 | describe('#getRendererInstance', () => { 552 | it('returns an instance of the specified renderer if one was set up', () => { 553 | rekapi = setupTestRekapi(document.createElement('div')); 554 | assert(rekapi.getRendererInstance(DOMRenderer) instanceof DOMRenderer); 555 | }); 556 | 557 | it('returns undefined if no matching instance was found', () => { 558 | assert.equal(rekapi.getRendererInstance(DOMRenderer), undefined); 559 | }); 560 | }); 561 | 562 | describe('private helper methods', () => { 563 | describe('updateToCurrentMillisecond', () => { 564 | it('correctly calculates position based on time in a finite loop', () => { 565 | Tweenable.now = () => 0; 566 | 567 | actor 568 | .keyframe(0, { 569 | x: 0 570 | }) 571 | .keyframe(1000, { 572 | x: 100 573 | }); 574 | 575 | rekapi.play(2); 576 | 577 | Tweenable.now = () => 500; 578 | updateToCurrentMillisecond(rekapi); 579 | assert.equal(actor.get().x, 50); 580 | 581 | Tweenable.now = () => 1500; 582 | updateToCurrentMillisecond(rekapi); 583 | assert.equal(actor.get().x, 50); 584 | 585 | Tweenable.now = () => 2500; 586 | updateToCurrentMillisecond(rekapi); 587 | assert.equal(actor.get().x, 100); 588 | }); 589 | 590 | it('correctly calculates position based on time in an infinite loop', () => { 591 | actor 592 | .keyframe(0, { 593 | x: 0 594 | }) 595 | .keyframe(1000, { 596 | x: 100 597 | }); 598 | 599 | Tweenable.now = () => 0; 600 | 601 | rekapi.play(); 602 | 603 | Tweenable.now = () => 500; 604 | updateToCurrentMillisecond(rekapi); 605 | assert.equal(actor.get().x, 50); 606 | 607 | Tweenable.now = () => 1500; 608 | updateToCurrentMillisecond(rekapi); 609 | assert.equal(actor.get().x, 50); 610 | 611 | Tweenable.now = () => 10000000500; 612 | updateToCurrentMillisecond(rekapi); 613 | assert.equal(actor.get().x, 50); 614 | }); 615 | }); 616 | 617 | describe('calculateLoopPosition', () => { 618 | it('calculates accurate position in the tween', () => { 619 | actor 620 | .keyframe(0, { x: 1 }) 621 | .keyframe(2000, { x: 2 }); 622 | 623 | let calculatedMillisecond = calculateLoopPosition(rekapi, 1000, 0); 624 | 625 | assert.equal(calculatedMillisecond, 1000); 626 | }); 627 | 628 | it('calculates accurate overflow position in the tween', () => { 629 | actor 630 | .keyframe(0, { x: 1 }) 631 | .keyframe(2000, { x: 2 }); 632 | 633 | let calculatedMillisecond = calculateLoopPosition(rekapi, 2500, 1); 634 | 635 | assert.equal(calculatedMillisecond, 500); 636 | }); 637 | }); 638 | 639 | describe('determineCurrentLoopIteration', () => { 640 | it('calculates the iteration of a given loop', () => { 641 | actor 642 | .keyframe(0, { x: 1 }) 643 | .keyframe(2000, { x: 2 }); 644 | 645 | let calculatedIteration = determineCurrentLoopIteration(rekapi, 0); 646 | 647 | assert.equal(calculatedIteration, 0); 648 | 649 | calculatedIteration = determineCurrentLoopIteration(rekapi, 1000); 650 | assert.equal(calculatedIteration, 0); 651 | 652 | 653 | calculatedIteration = determineCurrentLoopIteration(rekapi, 1999); 654 | assert.equal(calculatedIteration, 0); 655 | 656 | calculatedIteration = determineCurrentLoopIteration(rekapi, 4000); 657 | assert.equal(calculatedIteration, 2); 658 | 659 | calculatedIteration = determineCurrentLoopIteration(rekapi, 5000); 660 | assert.equal(calculatedIteration, 2); 661 | 662 | calculatedIteration = determineCurrentLoopIteration(rekapi, 5999); 663 | assert.equal(calculatedIteration, 2); 664 | }); 665 | }); 666 | 667 | describe('calculateTimeSinceStart', () => { 668 | it('calculates the delta of the current time and when the animation began', () => { 669 | actor 670 | .keyframe(0, {}) 671 | .keyframe(2000, {}); 672 | 673 | Tweenable.now = () => 0; 674 | rekapi.play(); 675 | Tweenable.now = () => 500; 676 | const calculatedTime = calculateTimeSinceStart(rekapi); 677 | 678 | assert.equal(calculatedTime, 500); 679 | }); 680 | }); 681 | 682 | describe('isAnimationComplete', () => { 683 | it('determines if the animation has completed in a finite loop', () => { 684 | actor 685 | .keyframe(0, {}) 686 | .keyframe(2000, {}); 687 | 688 | rekapi.play(3); 689 | 690 | assert.equal(isAnimationComplete(rekapi, 1), false); 691 | assert.equal(isAnimationComplete(rekapi, 2), false); 692 | assert.equal(isAnimationComplete(rekapi, 3), true); 693 | }); 694 | 695 | it('determines if the animation has completed in an infinite loop', () => { 696 | actor 697 | .keyframe(0, {}) 698 | .keyframe(2000, {}); 699 | 700 | rekapi.play(); 701 | 702 | assert.equal(isAnimationComplete(rekapi, 1), false); 703 | assert.equal(isAnimationComplete(rekapi, 3), false); 704 | assert.equal(isAnimationComplete(rekapi, 1000), false); 705 | }); 706 | }); 707 | 708 | describe('updatePlayState', () => { 709 | it('determine if the animation\'s internal state is "playing" after evaluating a given iteration', () => { 710 | actor 711 | .keyframe(0, {}) 712 | .keyframe(2000, {}); 713 | 714 | rekapi.play(3); 715 | 716 | updatePlayState(rekapi, 0); 717 | assert.equal(rekapi.isPlaying(), true); 718 | 719 | updatePlayState(rekapi, 2); 720 | assert.equal(rekapi.isPlaying(), true); 721 | 722 | updatePlayState(rekapi, 3); 723 | assert.equal(rekapi.isPlaying(), false); 724 | }); 725 | }); 726 | }); 727 | 728 | describe('multiple actor support', () => { 729 | it('animates multiple actors concurrently', () => { 730 | const testActor2 = setupTestActor(rekapi); 731 | 732 | actor 733 | .keyframe(0, { x: 0 }) 734 | .keyframe(1000, { x: 100 }); 735 | 736 | testActor2 737 | .keyframe(0, { x: 0 }) 738 | .keyframe(500, { x: 100 }); 739 | 740 | rekapi._loopTimestamp = 0; 741 | Tweenable.now = () => 250; 742 | updateToCurrentMillisecond(rekapi); 743 | 744 | assert.equal(actor.get().x, 25); 745 | assert.equal(testActor2.get().x, 50); 746 | 747 | Tweenable.now = () => 750; 748 | updateToCurrentMillisecond(rekapi); 749 | 750 | assert.equal(actor.get().x, 75); 751 | assert.equal(testActor2.get().x, 100); 752 | }); 753 | }); 754 | 755 | describe('#moveActorToPosition', () => { 756 | it('can move actors to the beginning of the list', () => { 757 | actor2 = setupTestActor(rekapi); 758 | rekapi.moveActorToPosition(actor2, 0); 759 | 760 | assert.equal(rekapi._actors[0], actor2); 761 | assert.equal(rekapi._actors[1], actor); 762 | assert.equal(rekapi.getActorCount(), 2); 763 | }); 764 | 765 | it('can move actors to the end of the list', () => { 766 | actor2 = setupTestActor(rekapi); 767 | rekapi.moveActorToPosition(actor, 1); 768 | 769 | assert.equal(rekapi._actors[0], actor2); 770 | assert.equal(rekapi._actors[1], actor); 771 | assert.equal(rekapi.getActorCount(), 2); 772 | }); 773 | }); 774 | }); 775 | -------------------------------------------------------------------------------- /test/test-utils.js: -------------------------------------------------------------------------------- 1 | import { Rekapi, Actor, CanvasRenderer, DOMRenderer } from '../src/main'; 2 | 3 | export const setupTestRekapi = opts => new Rekapi(opts); 4 | 5 | export const setupTestActor = (rekapi, actorArgs) => 6 | rekapi.addActor(new Actor(actorArgs) 7 | ); 8 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | import { 4 | without 5 | } from '../src/utils'; 6 | 7 | describe('utils', () => { 8 | describe('without', () => { 9 | describe('array with no duplicates', () => { 10 | it('returns an array excluding specified values', () => { 11 | assert.deepEqual(without([1, 2, 3], 2), [1, 3]); 12 | }); 13 | }); 14 | 15 | describe('array with duplicates', () => { 16 | it('returns an array excluding specified values', () => { 17 | assert.deepEqual(without([2, 1, 2, 3], 1, 2), [3]); 18 | }); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /tutorials/dom-rendering-in-depth.json: -------------------------------------------------------------------------------- 1 | { 2 | "dom-rendering-in-depth": { 3 | "title": "DOM Rendering in Depth" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tutorials/dom-rendering-in-depth.md: -------------------------------------------------------------------------------- 1 | {@link rekapi.DOMRenderer} allows you to animate DOM elements. This is 2 | achieved either by [CSS `@keyframe` 3 | animations](https://developer.mozilla.org/en-US/docs/Web/CSS/@keyframes), or by 4 | per-frame inline style updates. 5 | 6 | Advantages of playing an animation with CSS `@keyframes`: 7 | 8 | - Generally smoother animations. 9 | - The JavaScript thread is freed from performing animation updates, 10 | making it available for other logic. 11 | 12 | Disadvantages: 13 | 14 | - Limited playback control: You can only play and stop an animation, you 15 | cannot jump to or start from a specific point in the timeline. 16 | - Generating the CSS for `@keyframe` animations can take a noticeable 17 | amount of time. This blocks all other logic, including rendering, so 18 | you may have to be clever with how to spend the cycles to do it. 19 | - No [events]{@link rekapi.Rekapi#on} can be 20 | bound to CSS `@keyframe` animations. 21 | 22 | So, the results are a little more predictable and flexible with inline style 23 | animations, but CSS `@keyframe` may give you better performance. Choose 24 | whichever approach makes the most sense for your needs. 25 | 26 | {@link rekapi.DOMRenderer} can gracefully fall back to an inline style 27 | animation if CSS `@keyframe` animations are not supported by the browser: 28 | 29 | import { Rekapi, DOMRenderer } from 'rekapi'; 30 | 31 | const rekapi = new Rekapi(document.body); 32 | 33 | // Each actor needs a reference to the DOM element it represents 34 | const actor = rekapi.addActor({ 35 | context: document.querySelector('div') 36 | }); 37 | 38 | actor 39 | .keyframe(0, { left: '0px' }) 40 | .keyframe(1000, { left: '250px' }, 'easeOutQuad'); 41 | 42 | // Feature detect for CSS @keyframe support 43 | if (rekapi.renderer.canAnimateWithCSS()) { 44 | // Animate with CSS @keyframes 45 | rekapi.getRendererInstance(DOMRenderer).play(); 46 | } else { 47 | // Animate with inline styles instead 48 | rekapi.play(); 49 | } 50 | 51 | ## `@keyframe` animations work differently than inline style animations 52 | 53 | Inline style animations are compatible with all of the playback and timeline 54 | control methods defined by {@link rekapi.Rekapi}, such as {@link 55 | rekapi.Rekapi#play}, {@link rekapi.Rekapi#playFrom} and {@link 56 | rekapi.Rekapi#update}. CSS `@keyframe` playback cannot be controlled in all 57 | browsers, so {@link rekapi.DOMRenderer} defines analogous, renderer-specific 58 | CSS playback methods that you should use: 59 | 60 | - {@link rekapi.DOMRenderer#play} 61 | - {@link rekapi.DOMRenderer#isPlaying} 62 | - {@link rekapi.DOMRenderer#stop} 63 | 64 |

See the Pen Rekapi demo: Playing many actors by Jeremy Kahn (@jeremyckahn) on CodePen.

65 | 66 | -------------------------------------------------------------------------------- /tutorials/getting-started.json: -------------------------------------------------------------------------------- 1 | { 2 | "getting-started": { 3 | "title": "Getting Started" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tutorials/getting-started.md: -------------------------------------------------------------------------------- 1 | Although Rekapi is renderer-agnostic, it's easiest to get started by making a 2 | simple `` animation. The first step is to make a new {@link 3 | rekapi.Rekapi} instance. Canvas animations require a 2D `` context to 4 | render to, which gets passed to the {@link rekapi.Rekapi} constructor: 5 | 6 | ````javascript 7 | import { Rekapi, Actor } from 'rekapi'; 8 | 9 | const context = document.querySelector('canvas').getContext('2d'); 10 | const rekapi = new Rekapi(context); 11 | ```` 12 | 13 | You now have a {@link rekapi.Rekapi} instance, but it won't do anything until 14 | you define and add some {@link rekapi.Actor}s. 15 | 16 | ## Defining actors 17 | 18 | Here's the boilerplate for a {@link rekapi.Actor}: 19 | 20 | ````javascript 21 | const actor = new Actor({ 22 | 23 | // Called every frame. Receives a reference to the canvas context, and the 24 | // actor's state. 25 | render: (context, state) => { 26 | 27 | } 28 | 29 | }); 30 | ```` 31 | 32 | Here's a more complete example of an actor that renders a circle to the canvas: 33 | 34 | ````javascript 35 | const actor = new Actor({ 36 | render: (context, state) => { 37 | // Rekapi was given a canvas as a context, so `context` here is a 38 | // CanvasRenderingContext2D. 39 | 40 | context.beginPath(); 41 | context.arc( 42 | state.x, 43 | state.y, 44 | 25, 45 | 0, 46 | Math.PI*2, 47 | true 48 | ); 49 | 50 | context.fillStyle = '#f0f'; 51 | context.fill(); 52 | context.closePath(); 53 | } 54 | }); 55 | ```` 56 | 57 | The {@link rekapi.Actor}'s `render` method can be whatever you want — in this 58 | case it's just drawing a circle. The idea is that the `context` and `state` 59 | parameters are provided by Rekapi on every frame update, and then rendered to 60 | the `` by {@link rekapi.Actor#render} method. 61 | 62 | Now that you have a {@link rekapi.Actor} instance, you just need to add it to 63 | animation with {@link rekapi.Rekapi#addActor}: 64 | 65 | ````javascript 66 | rekapi.addActor(actor); 67 | ```` 68 | 69 | Now you can define some keyframes! 70 | 71 | ## Defining keyframes 72 | 73 | A keyframe is a way of saying "at a given point in time, the actor should have 74 | a particular state." Let's begin by giving `actor` a starting keyframe: 75 | 76 | ````javascript 77 | actor.keyframe(0, { 78 | x: 50, 79 | y: 50 80 | }); 81 | ```` 82 | 83 | {@link rekapi.Actor#keyframe} is a method that takes two to three parameters - 84 | 85 | 1. Which millisecond on the animation timeline the keyframe should be placed. 86 | 2. An Object whose properties define the state that the actor should have. 87 | 3. A string that specifies which 88 | [Shifty](https://github.com/jeremyckahn/shifty) easing formula to use - 89 | "linear" is the default. 90 | 91 | The above snippet says, "at zero milliseconds into the animation, place `actor` 92 | at `x` 50, and `y` 50. Continuing with that, animate it to another point on 93 | the canvas: 94 | 95 | ````javascript 96 | actor.keyframe(0, { 97 | x: 50, 98 | y: 50 99 | }) 100 | .keyframe(1000, { 101 | x: 200, 102 | y: 100 103 | }, 'easeOutExpo'); 104 | ```` 105 | 106 | The animation defined here will last one second, as the final keyframe is 107 | set at 1000 milliseconds. It will have a nice `easeOutExpo` ease applied to 108 | it, as you can see in the third parameter. Individual tweens (that is, 109 | keyframed animation segments) get their easing curves from the keyframe they 110 | are animating to, not animating from. 111 | 112 | Rekapi inherits all of [Shifty's easing 113 | functions](https://github.com/jeremyckahn/shifty/blob/master/src/easing-functions.js). 114 | 115 | ## Playing the animation 116 | 117 | So now you've set up a sweet animation! Let's run it and see what it looks 118 | like: 119 | 120 | ````javascript 121 | rekapi.play(); 122 | ```` 123 | 124 | And the animation will just loop continuously. You can also pass a `number` to 125 | {@link rekapi.Rekapi#play} to define how many times to play before stopping, 126 | like so: 127 | 128 | ````javascript 129 | rekapi.play(3); 130 | ```` 131 | 132 | This will play the animation three times and stop. When an animation stops, it 133 | will just sit at the last frame that was rendered. You can control the 134 | animation playback with {@link rekapi.Rekapi#pause} and {@link 135 | rekapi.Rekapi#stop}`. 136 | 137 |

See the Pen Rekapi: Getting started by Jeremy Kahn (@jeremyckahn) on CodePen.

138 | 139 | -------------------------------------------------------------------------------- /tutorials/keyframes-in-depth.json: -------------------------------------------------------------------------------- 1 | { 2 | "keyframes-in-depth": { 3 | "title": "Keyframes in Depth" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tutorials/keyframes-in-depth.md: -------------------------------------------------------------------------------- 1 | ## Keyframe inheritance 2 | 3 | Keyframes always inherit missing properties from the previous keyframe. For 4 | example: 5 | 6 | import { Rekapi, Actor } from 'rekapi'; 7 | 8 | const rekapi = new Rekapi(); 9 | const actor = rekapi.addActor(); 10 | 11 | actor.keyframe(0, { 12 | x: 100 13 | }).keyframe(1000, { 14 | // Implicitly copies the `x: 100` from above 15 | y: 50 16 | }); 17 | 18 | Keyframe `1000` will have a `y` of `50`, and an `x` of `100`, because `x` was 19 | inherited from keyframe `0`. 20 | 21 | ## Function keyframes 22 | 23 | Instead of providing an Object to be used to interpolate state values, you can 24 | provide [a function]{@link rekapi.keyframeFunction} to be called at a specific 25 | point on the timeline. This function does not need to return a value, as it 26 | does not get used to render the actor state. Function keyframes are called 27 | once per animation loop and do not have any tweening relationship with one 28 | another. This is a primarily a mechanism for scheduling arbitrary code to be 29 | executed at specific points in an animation. 30 | 31 | actor.keyframe(1000, actor => console.log(actor)); 32 | 33 | ## Easing 34 | 35 | `easing`, if provided, can be a string or an Object. If `easing` is a string, 36 | all animated properties will have the same easing curve applied to them. For 37 | example: 38 | 39 | actor.keyframe(1000, { x: 100, y: 100 }, 'easeOutSine'); 40 | 41 | Both `x` and `y` will have `easeOutSine` applied to them. You can also specify 42 | multiple easing curves with an Object: 43 | 44 | actor.keyframe(1000, { 45 | x: 100, 46 | y: 100 47 | }, { 48 | x: 'easeinSine', 49 | y: 'easeOutSine' 50 | }); 51 | 52 | `x` will ease with `easeInSine`, and `y` will ease with `easeOutSine`. Any 53 | unspecified properties will ease with `linear`. If `easing` is omitted, all 54 | properties will default to `linear`. 55 | -------------------------------------------------------------------------------- /tutorials/multiple-renderers.json: -------------------------------------------------------------------------------- 1 | { 2 | "multiple-renderers": { 3 | "title": "Multiple Renderers" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tutorials/multiple-renderers.md: -------------------------------------------------------------------------------- 1 | Rekapi supports using multiple renderers in a single animation. Here's how you might do that: 2 | 3 | HTML: 4 | 5 | ```html 6 | 7 |
8 | ``` 9 | 10 | 11 | JavaScript: 12 | 13 | ```javascript 14 | import { Rekapi, Actor, CanvasRenderer, DOMRenderer } from 'rekapi'; 15 | 16 | // Renderer inference by the Rekapi constructor is only practical if there is 17 | // one renderer, but this animation has two, so don't provide a context value 18 | // here 19 | const rekapi = new Rekapi(); 20 | 21 | const canvasContext = document.querySelector('canvas').getContext('2d') 22 | 23 | // Add the renderers manually here 24 | rekapi.renderers.push(new CanvasRenderer(rekapi, canvasContext)); 25 | rekapi.renderers.push(new DOMRenderer(rekapi)); 26 | 27 | const canvasRenderer = rekapi.getRendererInstance(CanvasRenderer); 28 | 29 | // Necessary to prevent the canvas image from getting distorted 30 | canvasRenderer.height(300); 31 | canvasRenderer.width(100); 32 | 33 | const canvasActor = rekapi.addActor({ 34 | context: canvasContext, 35 | render: (context, state) => { 36 | context.beginPath(); 37 | context.arc( 38 | state.x, 39 | state.y, 40 | 25, 41 | 0, 42 | Math.PI*2, 43 | true 44 | ); 45 | context.fillStyle = '#f0f'; 46 | context.fill(); 47 | context.closePath(); 48 | } 49 | }); 50 | 51 | const domActor = rekapi.addActor({ 52 | context: document.querySelector('div') 53 | }); 54 | 55 | canvasActor 56 | .keyframe(0, { 57 | x: 50, 58 | y: 50 59 | }) 60 | .keyframe(1500, { 61 | y: 250 62 | }); 63 | 64 | domActor 65 | .keyframe(0, { 66 | transform: 'translateY(0px)' 67 | }).keyframe(1500, { 68 | transform: 'translateY(200px)' 69 | }); 70 | 71 | rekapi.play(); 72 | ``` 73 | 74 |

See the Pen Rekapi demo: Multiple renderers by Jeremy Kahn (@jeremyckahn) on CodePen.

75 | 76 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const modulePaths = [ 4 | path.join(__dirname, 'node_modules') 5 | ]; 6 | 7 | module.exports = { 8 | devtool: 'source-map', 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.js$/, 13 | use: 'babel-loader' 14 | } 15 | ] 16 | }, 17 | resolve: { 18 | modules: [ 19 | 'node_modules' 20 | ] 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const commonConfig = require('./webpack.common'); 2 | const path = require('path'); 3 | const Webpack = require('webpack'); 4 | 5 | const { version } = require('./package.json'); 6 | 7 | module.exports = Object.assign(commonConfig, { 8 | entry: './src/main.js', 9 | output: { 10 | path: path.join(__dirname, 'dist'), 11 | publicPath: '/assets/', 12 | filename: 'rekapi.js', 13 | library: 'rekapi', 14 | libraryTarget: 'umd', 15 | umdNamedDefine: true 16 | }, 17 | externals: { 18 | shifty: 'shifty' 19 | }, 20 | plugins: [ 21 | new Webpack.optimize.UglifyJsPlugin({ 22 | compress: { 23 | dead_code: true, 24 | unused: true 25 | }, 26 | output: { 27 | comments: false 28 | }, 29 | sourceMap: true 30 | }), 31 | new Webpack.BannerPlugin(version) 32 | ] 33 | }); 34 | -------------------------------------------------------------------------------- /webpack.test.config.js: -------------------------------------------------------------------------------- 1 | const commonConfig = require('./webpack.common'); 2 | const path = require('path'); 3 | 4 | module.exports = Object.assign(commonConfig, { 5 | entry: { 6 | test: './test/index.js' 7 | }, 8 | output: { 9 | path: path.join(__dirname, 'dist'), 10 | publicPath: '/assets/', 11 | filename: '[name].js' 12 | }, 13 | devServer: { 14 | port: 9010 15 | } 16 | }); 17 | --------------------------------------------------------------------------------