├── .babelrc ├── .gitignore ├── .jsdoc ├── .npmignore ├── README.md ├── demo └── index.js ├── index.html ├── package.json ├── src ├── bottom-frame.js ├── constants.js ├── details.js ├── event-handlers.js ├── index.js ├── rekapi-timeline.js ├── styles │ ├── actor-tracks.sass │ ├── animation-tracks.sass │ ├── container.sass │ ├── control-bar.sass │ ├── details.sass │ ├── index.sass │ ├── keyframe-property-detail.sass │ ├── keyframe-property-track.sass │ ├── keyframe-property.sass │ ├── scrubber-detail.sass │ ├── scrubber.sass │ ├── timeline.sass │ └── variables.sass ├── timeline.js └── utils.js ├── test ├── fixtures │ ├── basic-rekapi-export.js │ ├── decoupled-rekapi-number-export.js │ └── decoupled-rekapi-string-export.js ├── index.html ├── index.js └── setup.js ├── webpack.common.config.js ├── webpack.config.js ├── webpack.extras.config.js └── webpack.test.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "babel-preset-react", "babel-preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist 3 | node_modules 4 | -------------------------------------------------------------------------------- /.jsdoc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["plugins/markdown"], 3 | "opts": { 4 | "destination": "dist/doc", 5 | "template": "node_modules/@jeremyckahn/minami", 6 | "readme": "README.md" 7 | }, 8 | "templates": { 9 | "cleverLinks": true, 10 | "useLongnameInNav": false, 11 | "showInheritedInNav": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .git* 2 | .babelrc 3 | node_modules 4 | index.html 5 | index.js 6 | README.md 7 | webpack.config* 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rekapi-timline 2 | 3 | ## A graphical control for [Rekapi](http://jeremyckahn.github.io/rekapi/doc/) animations 4 | 5 | ![rekapi-timeline screenshot](https://gist.githubusercontent.com/jeremyckahn/0b93cf96db45bf0722b82eef17e7c5a6/raw/3dbef48b78842cbdde9f47a2eb8862b25023890f/rekapi-timeline-screenshot.png) 6 | 7 | rekapi-timeline is a general-purpose timeline-editing interface for [Rekapi](http://rekapi.com/) meant to be integrated into graphical applications. It is designed to be feature-rich but flexible. rekapi-timeline is not intended to be used as a standalone application. A practical example of it in use is [Mantra](http://jeremyckahn.github.io/mantra/). 8 | 9 | Version 0.7.x and above is built with React and is a ground-up rewrite from 0.6.x and earlier versions, which were built with Backbone. The library dependencies are excluded from the production build artifacts, so you will need to manage that in your project. Please see the `dependencies` field in `package.json` for an up-to-date-list of runtime dependencies. For an example of how to load rekapi-timeline in a browser without any complex build infrastructure, please [see this CodePen](https://codepen.io/jeremyckahn/pen/NXjVOm). 10 | 11 | ## Usage 12 | 13 | Install the package: 14 | 15 | ``` 16 | npm install rekapi-timeline 17 | ``` 18 | 19 | Minimal bootstrap: 20 | 21 | ```html 22 |
23 |
24 |
25 |
26 | ``` 27 | 28 | ```javascript 29 | import React from 'react'; 30 | import ReactDOM from 'react-dom'; 31 | import { Rekapi } from 'rekapi'; 32 | import { RekapiTimeline } from 'rekapi-timeline'; 33 | 34 | const rekapi = new Rekapi(document.body); 35 | const actor = rekapi.addActor({ 36 | context: document.getElementById('actor-1') 37 | }); 38 | 39 | ReactDOM.render( 40 | , 43 | document.getElementById('rekapi-timeline') 44 | ); 45 | ``` 46 | 47 | ## Running tests (written in Mocha) 48 | 49 | ``` 50 | # run tests in the CLI 51 | npm test 52 | ``` 53 | 54 | ``` 55 | # run tests in the CLI with a watcher that will re-run tests 56 | # when you make a code change 57 | npm run test:watch 58 | ``` 59 | 60 | ## Debugging 61 | 62 | This project configures Webpack to generate [source maps](https://www.html5rocks.com/en/tutorials/developertools/sourcemaps/) so you can use your browser's dev tools to debug your ES6 code just as easily as you would with ES5. 63 | 64 | ``` 65 | # run the tests in your browser 66 | npm start 67 | ``` 68 | 69 | From here, you can fire up your browser's dev tools and set breakpoints, step through code, etc. You can run the demo app at http://localhost:9123, or run the tests at http://localhost:9123/test/. 70 | 71 | ## Building 72 | 73 | ``` 74 | npm run build 75 | ``` 76 | 77 | Your compiled code will wind up in the `dist` directory. 78 | 79 | ## Documentation 80 | 81 | You should make sure to update the [JSDoc](http://usejsdoc.org/) annotations as you work. To view the formatted documentation in your browser: 82 | 83 | ``` 84 | npm run doc 85 | npm run doc:view 86 | ``` 87 | 88 | This will generate the docs and run them in your browser. If you would like this to update automatically as you work, run this task: 89 | 90 | ``` 91 | npm run doc:live 92 | ``` 93 | 94 | ## Releasing 95 | 96 | ``` 97 | npm version patch # Or "minor," or "major" 98 | ``` 99 | 100 | ## License 101 | 102 | MIT. 103 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Rekapi } from 'rekapi'; 4 | 5 | import { RekapiTimeline } from '../src/index.js'; 6 | 7 | const rekapi = new Rekapi(document.body); 8 | 9 | const actor = rekapi.addActor({ 10 | context: document.querySelector('#actor-1') 11 | }); 12 | 13 | actor 14 | .keyframe(0, { 15 | translateX: '0px', 16 | translateY: '0px', 17 | rotate: '0deg', 18 | scaleX: 1, 19 | scaleY: 1 20 | }) 21 | .keyframe(1000, { 22 | translateX: '150px', 23 | translateY: '100px' 24 | }); 25 | 26 | ReactDOM.render( 27 | , 30 | document.getElementById('rekapi-timeline') 31 | ); 32 | 33 | rekapi.play(1); 34 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | RekapiTimeline 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rekapi-timeline", 3 | "version": "0.7.1", 4 | "description": "A demonstration of modern JavaScript project structure", 5 | "main": "dist/rekapi-timeline.js", 6 | "scripts": { 7 | "build": "npm run build:dist && npm run build:extras", 8 | "build:dist": "webpack --config webpack.config.js", 9 | "build:extras": "webpack --config webpack.extras.config.js", 10 | "start": "webpack-dev-server --config webpack.test.config.js", 11 | "test": "mocha -r jsdom-global/register ./node_modules/babel-core/register.js test/setup.js test/index.js", 12 | "test:watch": "nodemon --exec \"npm test\" --watch src --watch test", 13 | "preversion": "npm test", 14 | "postversion": "git push && git push --tags && npm run deploy && npm publish", 15 | "doc": "jsdoc -c .jsdoc src", 16 | "doc:view": "live-server dist/doc --port=9124", 17 | "doc:watch": "nodemon --exec \"npm run doc\" --watch src --watch ./ --ext js,md --ignore dist", 18 | "doc:live": "concurrently --kill-others \"npm run doc:watch\" \"npm run doc:view\"", 19 | "deploy": "npm run build && gh-pages -d dist" 20 | }, 21 | "author": "Jeremy Kahn ", 22 | "license": "MIT", 23 | "devDependencies": { 24 | "@jeremyckahn/minami": "^1.3.1", 25 | "babel-core": "^6.23.1", 26 | "babel-loader": "^7.1.2", 27 | "babel-preset-env": "^1.6.1", 28 | "babel-preset-es2015": "^6.22.0", 29 | "babel-preset-react": "^6.24.1", 30 | "bootstrap-sass": "^3.3.7", 31 | "clean-webpack-plugin": "^0.1.17", 32 | "compass-mixins": "^0.12.10", 33 | "concurrently": "^3.5.0", 34 | "copy-webpack-plugin": "^4.3.1", 35 | "css-loader": "^0.28.7", 36 | "enzyme": "^3.1.0", 37 | "enzyme-adapter-react-16": "^1.0.3", 38 | "gh-pages": "^0.12.0", 39 | "jsdoc": "^3.5.5", 40 | "jsdom": "^11.3.0", 41 | "jsdom-global": "^3.0.2", 42 | "live-server": "^1.2.0", 43 | "mocha": "^3.2.0", 44 | "node-sass": "^4.7.2", 45 | "nodemon": "^1.11.0", 46 | "raf": "^3.4.0", 47 | "react-test-renderer": "^16.0.0", 48 | "sass-loader": "^6.0.6", 49 | "sinon": "^4.1.1", 50 | "style-loader": "^0.19.0", 51 | "url-loader": "^0.6.2", 52 | "webpack": "^3.10.0", 53 | "webpack-dashboard": "^1.0.2", 54 | "webpack-dev-server": "^2.9.7" 55 | }, 56 | "dependencies": { 57 | "react": "^16.0.0", 58 | "react-dom": "^16.0.0", 59 | "react-draggable": "^3.0.4", 60 | "rekapi": "^2.2.0", 61 | "shifty": "^2.3.1" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/bottom-frame.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | defaultTimelineScale 4 | } from './constants'; 5 | 6 | const Button = ({ name, handleClick }) => 7 | 13 | 14 | const ControlBar = ({ 15 | handlePlayButtonClick, 16 | handlePauseButtonClick, 17 | handleStopButtonClick, 18 | isPlaying 19 | }) => 20 |
21 | {isPlaying ? 22 |
36 | 37 | const ScrubberScale = ({ 38 | timelineScale = 0, 39 | handleTimelineScaleChange = () => {} 40 | }) => 41 | 51 | 52 | const PositionMonitor = ({ 53 | animationLength, 54 | currentPosition 55 | }) => 56 |

57 | 58 | {Math.floor(animationLength * currentPosition) || 0} 59 | ms 60 | {' / '} 61 | 62 | {animationLength} 63 | ms 64 |

65 | 66 | const BottomFrame = ({ 67 | handlePlayButtonClick, 68 | handlePauseButtonClick, 69 | handleStopButtonClick, 70 | handleTimelineScaleChange, 71 | isPlaying, 72 | timelineScale, 73 | currentPosition, 74 | animationLength 75 | }) => ( 76 |
77 | 83 |
84 | 88 | 92 |
93 |
94 | ); 95 | 96 | export default BottomFrame; 97 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const newPropertyMillisecondBuffer = 500; 2 | 3 | export const defaultTimelineScale = 0.5; 4 | 5 | // Synced to Sass constant $SCRUBBER_HANDLE_DIMENSIONS + 1 6 | export const propertyTrackHeight = 21; 7 | -------------------------------------------------------------------------------- /src/details.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Header = ({ name }) => 4 |

5 | { name || 'Details' } 6 |

7 | 8 | const AddButton = ({ handleAddKeyframeButtonClick }) => 9 | 16 | 17 | const DeleteButton = ({ handleDeleteKeyframeButtonClick }) => 18 | 25 | 26 | const MillisecondInput = ({ 27 | handleMillisecondInputChange, 28 | millisecond = '' 29 | }) => 30 | 41 | 42 | const ValueInput = ({ 43 | handleValueInputChange = () => {}, 44 | value = '' 45 | }) => 46 | 56 | 57 | const EasingSelect = ({ 58 | easing = '', 59 | easingCurves, 60 | // handleEasingSelectChange must be defaulted to a noop here to prevent 61 | // spurious warnings in the unit tests 62 | handleEasingSelectChange = () => {} 63 | }) => 64 | 76 | 77 | const Details = ({ 78 | easingCurves = [], 79 | handleAddKeyframeButtonClick, 80 | handleDeleteKeyframeButtonClick, 81 | handleEasingSelectChange, 82 | handleMillisecondInputChange, 83 | handleValueInputChange, 84 | keyframeProperty = {} 85 | }) => ( 86 |
87 |
88 |
89 |
90 | 91 | 92 |
93 | 97 | 101 | 106 |
107 |
108 | ); 109 | 110 | export default Details; 111 | -------------------------------------------------------------------------------- /src/event-handlers.js: -------------------------------------------------------------------------------- 1 | import { 2 | newPropertyMillisecondBuffer 3 | } from './constants'; 4 | 5 | import { 6 | computeHighlightedKeyframe, 7 | computeDescaledPixelPosition 8 | } from './utils'; 9 | 10 | /** 11 | * @module eventHandlers 12 | */ 13 | 14 | /** 15 | * @param {string} numberString 16 | * @returns {string} 17 | * @private 18 | */ 19 | const sanitizeDanglingDecimals = 20 | numberString => numberString.replace(/\d+\.(?=\D)/g, 21 | match => `${match}0` 22 | ); 23 | 24 | export default { 25 | /** 26 | * @method module:eventHandlers.handleAddKeyframeButtonClick 27 | * @returns {undefined} 28 | */ 29 | handleAddKeyframeButtonClick () { 30 | const { props, state } = this; 31 | const { propertyCursor } = state; 32 | 33 | if (!Object.keys(propertyCursor).length) { 34 | return; 35 | } 36 | 37 | const keyframeProperty = computeHighlightedKeyframe( 38 | props.rekapi, 39 | propertyCursor 40 | ); 41 | 42 | const newPropertyMillisecond = 43 | propertyCursor.millisecond + newPropertyMillisecondBuffer; 44 | 45 | this.getActor().keyframe( 46 | newPropertyMillisecond, 47 | { 48 | [propertyCursor.property]: keyframeProperty.value 49 | } 50 | ); 51 | 52 | this.setState({ 53 | propertyCursor: { 54 | property: propertyCursor.property, 55 | millisecond: newPropertyMillisecond 56 | } 57 | }); 58 | }, 59 | 60 | /** 61 | * @method module:eventHandlers.handleDeleteKeyframeButtonClick 62 | * @returns {undefined} 63 | */ 64 | handleDeleteKeyframeButtonClick () { 65 | const { millisecond, property } = this.state.propertyCursor; 66 | 67 | const priorProperty = this.getActor().getPropertiesInTrack(property).find( 68 | ({ nextProperty }) => 69 | nextProperty && nextProperty.millisecond === millisecond 70 | ); 71 | 72 | this.getActor().removeKeyframeProperty( 73 | property, 74 | millisecond 75 | ); 76 | 77 | this.setState({ 78 | propertyCursor: (priorProperty ? 79 | { 80 | property: priorProperty.name, 81 | millisecond: priorProperty.millisecond 82 | } : 83 | {} 84 | ) 85 | }); 86 | }, 87 | 88 | /** 89 | * @method module:eventHandlers.handleEasingSelectChange 90 | * @param {external:React.SyntheticEvent} e 91 | * @returns {undefined} 92 | */ 93 | handleEasingSelectChange (e) { 94 | const { value: easing } = e.target; 95 | const { propertyCursor: { property, millisecond } } = this.state; 96 | 97 | this.getActor().modifyKeyframeProperty( 98 | property, 99 | millisecond, 100 | { easing } 101 | ); 102 | }, 103 | 104 | /** 105 | * @method module:eventHandlers.handleMillisecondInputChange 106 | * @param {external:React.SyntheticEvent} e 107 | * @returns {undefined} 108 | */ 109 | handleMillisecondInputChange (e) { 110 | const { value } = e.target; 111 | const { property, millisecond } = this.state.propertyCursor; 112 | 113 | if (!this.getActor().getKeyframeProperty(property, millisecond)) { 114 | return; 115 | } 116 | 117 | // Modify the property through the actor so that actor-level cleanup is 118 | // performed 119 | this.getActor().modifyKeyframeProperty( 120 | property, 121 | millisecond, 122 | { millisecond: value } 123 | ); 124 | 125 | this.setState({ 126 | propertyCursor: { 127 | property, 128 | millisecond: value 129 | } 130 | }); 131 | }, 132 | 133 | /** 134 | * @method module:eventHandlers.handleValueInputChange 135 | * @param {external:React.SyntheticEvent} e 136 | * @returns {undefined} 137 | */ 138 | handleValueInputChange (e) { 139 | // TODO: A quirk of this logic is that invalid inputs reset the cursor 140 | // position, which is a pretty bad UX. This can be addressed with 141 | // something like the sample provided here: 142 | // https://github.com/facebook/react/issues/955#issuecomment-160831548 143 | // 144 | // This may also be good solution: 145 | // https://github.com/text-mask/text-mask/tree/master/react#readme 146 | const { value } = e.target; 147 | const { property, millisecond } = this.state.propertyCursor; 148 | const currentProperty = 149 | this.getActor().getKeyframeProperty(property, millisecond); 150 | 151 | // Deliberate "==" 152 | const coercedValue = String(value) == Number(value) ? 153 | Number(value) : 154 | value; 155 | 156 | const sanitizedInput = typeof coercedValue === 'string' ? 157 | sanitizeDanglingDecimals(coercedValue) : 158 | coercedValue; 159 | 160 | if (!this.isNewPropertyValueValid(currentProperty, sanitizedInput)) { 161 | return; 162 | } 163 | 164 | // Modify the property through the actor so that actor-level cleanup is 165 | // performed 166 | this.getActor().modifyKeyframeProperty( 167 | property, 168 | millisecond, 169 | { value: sanitizedInput } 170 | ); 171 | }, 172 | 173 | /** 174 | * @method module:eventHandlers.handlePlayButtonClick 175 | * @returns {undefined} 176 | */ 177 | handlePlayButtonClick () { 178 | this.props.rekapi.playFromCurrent(); 179 | }, 180 | 181 | /** 182 | * @method module:eventHandlers.handlePauseButtonClick 183 | * @returns {undefined} 184 | */ 185 | handlePauseButtonClick () { 186 | this.props.rekapi.pause(); 187 | }, 188 | 189 | /** 190 | * @method module:eventHandlers.handleStopButtonClick 191 | * @returns {undefined} 192 | */ 193 | handleStopButtonClick () { 194 | this.props.rekapi 195 | .stop() 196 | .update(0); 197 | }, 198 | 199 | /** 200 | * @method module:eventHandlers.handleTimelineScaleChange 201 | * @param {external:React.SyntheticEvent} e 202 | * @returns {undefined} 203 | */ 204 | handleTimelineScaleChange (e) { 205 | const { value } = e.target; 206 | 207 | this.setState({ 208 | timelineScale: Math.abs(Number(value) / 100) 209 | }); 210 | }, 211 | 212 | /** 213 | * @method module:eventHandlers.handleScrubberDrag 214 | * @param {number} x 215 | * @returns {undefined} 216 | */ 217 | handleScrubberDrag (x) { 218 | this.updateToRawX(x); 219 | }, 220 | 221 | /** 222 | * @method module:eventHandlers.handleScrubberBarClick 223 | * @param {external:React.SyntheticEvent} e 224 | * @returns {undefined} 225 | */ 226 | handleScrubberBarClick (e) { 227 | // Some child elements' drag events propagate through as click events to 228 | // this handler, so check for that and bail out early if the user actually 229 | // clicked on the scrubber and not the scrubber bar. 230 | if (e.target !== e.currentTarget) { 231 | return; 232 | } 233 | 234 | const x = e.nativeEvent.offsetX; 235 | 236 | this.props.rekapi.pause(); 237 | this.updateToRawX(x); 238 | }, 239 | 240 | /** 241 | * @method module:eventHandlers.handlePropertyDrag 242 | * @param {number} x Target raw, unscaled target x value 243 | * @param {string} propertyName 244 | * @param {number} propertyMillisecond Current property millisecond 245 | * @returns {undefined} 246 | */ 247 | handlePropertyDrag (x, propertyName, propertyMillisecond) { 248 | const millisecond = computeDescaledPixelPosition( 249 | this.state.timelineScale, 250 | x 251 | ); 252 | 253 | const actor = this.getActor(); 254 | 255 | if (actor.hasKeyframeAt(millisecond, propertyName)) { 256 | // This early return is necessary because react-draggable will fire the 257 | // onDrag handler for mouse movement along the Y axis (even when 258 | // configured to be restricted to the X axis). This would effectively 259 | // cause the property to moved onto itself, which causes a Rekapi error. 260 | return; 261 | } 262 | 263 | actor.modifyKeyframeProperty(propertyName, propertyMillisecond, { 264 | millisecond 265 | }); 266 | 267 | this.setState({ 268 | propertyCursor: { 269 | property: propertyName, 270 | millisecond 271 | } 272 | }); 273 | }, 274 | 275 | /** 276 | * @param {external:Rekapi.KeyframeProperty} property 277 | */ 278 | handlePropertyClick (property) { 279 | const { millisecond, name } = property; 280 | 281 | this.setState({ propertyCursor: { millisecond, property: name } }); 282 | }, 283 | 284 | /** 285 | * @method module:eventHandlers.handlePropertyTrackDoubleClick 286 | * @param {external:React.SyntheticEvent} e 287 | * @param {string} trackName 288 | * @returns {undefined} 289 | */ 290 | handlePropertyTrackDoubleClick (e, trackName) { 291 | const targetMillisecond = computeDescaledPixelPosition( 292 | this.state.timelineScale, 293 | e.nativeEvent.offsetX 294 | ); 295 | 296 | const properties = this.getActor().getPropertiesInTrack(trackName); 297 | const priorProperty = properties.slice().reverse().find( 298 | property => property.millisecond < targetMillisecond 299 | ) || properties[0]; 300 | 301 | this.getActor().keyframe( 302 | targetMillisecond, 303 | { [trackName]: priorProperty.value } 304 | ); 305 | 306 | this.setState({ 307 | propertyCursor: { 308 | millisecond: targetMillisecond, 309 | property: trackName 310 | } 311 | }); 312 | }, 313 | 314 | /** 315 | * @method module:eventHandlers.handleChangeNewTrackName 316 | * @param {external:React.SyntheticEvent} e 317 | * @returns {undefined} 318 | */ 319 | handleChangeNewTrackName (e) { 320 | const { value: newTrackName } = e.target; 321 | 322 | this.setState({ newTrackName }); 323 | }, 324 | 325 | /** 326 | * @method module:eventHandlers.handleKeyDownNewTrackName 327 | * @param {external:React.SyntheticEvent} e 328 | * @returns {undefined} 329 | */ 330 | handleKeyDownNewTrackName (e) { 331 | if (e.nativeEvent.keyCode !== 13) { // 13 == enter key 332 | return; 333 | } 334 | 335 | this.addNewTrack(); 336 | }, 337 | 338 | /** 339 | * @method module:eventHandlers.handleClickNewTrackButton 340 | * @returns {undefined} 341 | */ 342 | handleClickNewTrackButton () { 343 | this.addNewTrack(); 344 | } 345 | }; 346 | 347 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './styles/index.sass'; 2 | export { RekapiTimeline } from './rekapi-timeline'; 3 | -------------------------------------------------------------------------------- /src/rekapi-timeline.js: -------------------------------------------------------------------------------- 1 | // TODO: This code depends on some ES6 built-ins so document the need for this 2 | // environment dependency: https://babeljs.io/docs/usage/polyfill/ 3 | 4 | import { Rekapi } from 'rekapi'; 5 | import { Tweenable } from 'shifty'; 6 | import React, { Component } from 'react'; 7 | import Details from './details'; 8 | import Timeline from './timeline'; 9 | import BottomFrame from './bottom-frame'; 10 | import eventHandlers from './event-handlers'; 11 | import { 12 | computeHighlightedKeyframe, 13 | computeTimelineWidth, 14 | computeScrubberPixelPosition, 15 | computeScaledPixelPosition, 16 | computeDescaledPixelPosition, 17 | } from './utils'; 18 | 19 | import { 20 | defaultTimelineScale 21 | } from './constants'; 22 | 23 | /** 24 | * @typedef RekapiTimeline.propertyCursor 25 | * @type {Object} 26 | * @property {string} property 27 | * @property {number} millisecond 28 | */ 29 | 30 | /** 31 | * @typedef RekapiTimeline.props 32 | * @type {Object} 33 | * @property {external:rekapi.Rekapi} rekapi 34 | */ 35 | 36 | /** 37 | * @typedef RekapiTimeline.state 38 | * @type {Object} 39 | * @property {external:rekapi.timelineData} rekapi 40 | * @property {RekapiTimeline.propertyCursor|{}} propertyCursor 41 | * @property {Array.} easingCurves 42 | */ 43 | 44 | const rTokenStringChunks = /([^(-?\d)]+)/g; 45 | const rTokenNumberChunks = /\d+(\.\d+)?/g; 46 | 47 | const exportTimelineOptions = { withId: true }; 48 | 49 | export class RekapiTimeline extends Component { 50 | /** 51 | * @param {RekapiTimeline.props} props 52 | * @constructs RekapiTimeline 53 | */ 54 | constructor ({ rekapi = new Rekapi() }) { 55 | super(...arguments); 56 | 57 | this.bindMethods(); 58 | 59 | const timeline = rekapi.exportTimeline(exportTimelineOptions); 60 | 61 | this.state = { 62 | rekapi: timeline, 63 | actor: timeline.actors[0], 64 | propertyCursor: {}, 65 | easingCurves: Object.keys(Tweenable.formulas), 66 | isPlaying: rekapi.isPlaying(), 67 | timelineScale: defaultTimelineScale, 68 | animationLength: rekapi.getAnimationLength(), 69 | currentPosition: rekapi.getLastPositionUpdated(), 70 | newTrackName: 'newTrack' 71 | }; 72 | 73 | rekapi 74 | .on('timelineModified', this.onRekapiTimelineModified.bind(this)) 75 | .on('playStateChange', () => { 76 | this.setState({ 77 | isPlaying: rekapi.isPlaying() 78 | }); 79 | }) 80 | .on('afterUpdate', () => { 81 | this.setState({ 82 | currentPosition: rekapi.getLastPositionUpdated() 83 | }); 84 | }); 85 | } 86 | 87 | /** 88 | * @method RekapiTimeline#bindMethods 89 | * @returns {undefined} 90 | * @private 91 | */ 92 | bindMethods () { 93 | Object.keys(eventHandlers).forEach( 94 | method => this[method] = eventHandlers[method].bind(this) 95 | ); 96 | } 97 | 98 | /** 99 | * @method RekapiTimeline#onRekapiTimelineModified 100 | * @returns {undefined} 101 | */ 102 | onRekapiTimelineModified () { 103 | const { rekapi } = this.props; 104 | const timeline = rekapi.exportTimeline(exportTimelineOptions); 105 | 106 | const oldLength = this.state.animationLength; 107 | const animationLength = timeline.duration; 108 | 109 | this.setState({ 110 | animationLength, 111 | rekapi: timeline, 112 | actor: timeline.actors[0] 113 | }); 114 | 115 | if (!rekapi.isPlaying()) { 116 | if ((rekapi.getLastPositionUpdated() * oldLength) > animationLength) { 117 | rekapi.update(animationLength); 118 | } else { 119 | rekapi.update(); 120 | } 121 | } 122 | } 123 | 124 | /** 125 | * Method to be called after {@link external:shifty.setBezierFunction} and 126 | * {@link external:shifty.unsetBezierFunction} are called. This is needed to 127 | * update the easing list after {@link external:shifty.Tweenable.formulas} is 128 | * modified which cannot be done automatically in a cross-browser compatible, 129 | * performant way. 130 | * @method RekapiTimeline#updateEasingList 131 | * @returns {undefined} 132 | */ 133 | updateEasingList () { 134 | this.setState({ 135 | easingCurves: Object.keys(Tweenable.formulas) 136 | }); 137 | } 138 | 139 | /** 140 | * Returns the current {@link external:rekapi.Actor}. 141 | * @method RekapiTimeline#getActor 142 | * @returns {external:rekapi.Actor|undefined} 143 | * @private 144 | */ 145 | getActor () { 146 | return this.props.rekapi.getAllActors()[0]; 147 | } 148 | 149 | /** 150 | * @method RekapiTimeline#updateToRawX 151 | * @param {number} rawX Raw pixel value to be scaled against 152 | * `this.state.timelineScale` before being passed to 153 | * {@link external:Rekapi.rekapi#update}. 154 | * @returns {undefined} 155 | */ 156 | updateToRawX (rawX) { 157 | const { 158 | props: { rekapi }, 159 | state: { timelineScale} 160 | } = this; 161 | 162 | rekapi.update( 163 | computeDescaledPixelPosition(timelineScale, rawX) 164 | ); 165 | } 166 | 167 | /** 168 | * @method RekapiTimeline#isNewPropertyValueValid 169 | * @param {external:rekapi.KeyframeProperty} keyframeProperty 170 | * @param {number|string} newValue 171 | * @returns {boolean} 172 | */ 173 | isNewPropertyValueValid (keyframeProperty, newValue) { 174 | const { value: currentValue } = keyframeProperty; 175 | const typeOfNewValue = typeof newValue; 176 | 177 | if ( 178 | this.getActor().getPropertiesInTrack(keyframeProperty.name).length === 1 179 | ) { 180 | return true; 181 | } 182 | 183 | if (typeof currentValue !== typeOfNewValue) { 184 | return false; 185 | } 186 | 187 | if (typeOfNewValue === 'string') { 188 | const currentTokenChunks = currentValue.match(rTokenStringChunks); 189 | const newTokenChunks = newValue.match(rTokenStringChunks); 190 | 191 | if (currentTokenChunks.join('') !== newTokenChunks.join('')) { 192 | return false; 193 | } 194 | 195 | const currentNumberChunks = currentValue.match(rTokenNumberChunks); 196 | const newNumberChunks = newValue.match(rTokenNumberChunks); 197 | 198 | if (!currentNumberChunks 199 | || !newNumberChunks 200 | || currentNumberChunks.length !== newNumberChunks.length 201 | ) { 202 | return false; 203 | } 204 | } 205 | 206 | return true; 207 | } 208 | 209 | /** 210 | * @method RekapiTimeline#addNewTrack 211 | * @returns {boolean} 212 | */ 213 | addNewTrack () { 214 | const { newTrackName } = this.state; 215 | const actor = this.getActor(); 216 | 217 | if (actor.getTrackNames().indexOf(newTrackName) > -1) { 218 | return; 219 | } 220 | 221 | actor.keyframe(0, { [newTrackName]: 0 }); 222 | } 223 | 224 | render () { 225 | const { 226 | props: { rekapi }, 227 | state: { 228 | actor, 229 | animationLength, 230 | currentPosition, 231 | easingCurves, 232 | isPlaying, 233 | propertyCursor, 234 | timelineScale, 235 | newTrackName 236 | } 237 | } = this; 238 | 239 | const keyframeProperty = rekapi ? 240 | computeHighlightedKeyframe(rekapi, propertyCursor) : 241 | {}; 242 | 243 | const isAnyKeyframeHighlighted = !!Object.keys(keyframeProperty).length; 244 | 245 | const timelineWrapperWidth = (rekapi && this.getActor()) ? 246 | computeTimelineWidth(rekapi, timelineScale) : 247 | 1; 248 | 249 | const propertyTracks = actor ? 250 | actor.propertyTracks : 251 | {}; 252 | 253 | const scrubberPosition = rekapi ? 254 | computeScrubberPixelPosition(rekapi, timelineScale) : 255 | 0; 256 | 257 | const timelineScaleConverter = 258 | computeScaledPixelPosition.bind(null, timelineScale); 259 | 260 | return ( 261 |
262 |
271 | 287 | 297 |
298 | ); 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /src/styles/actor-tracks.sass: -------------------------------------------------------------------------------- 1 | .actor-tracks 2 | position: relative 3 | width: 100% 4 | -------------------------------------------------------------------------------- /src/styles/animation-tracks.sass: -------------------------------------------------------------------------------- 1 | .animation-tracks 2 | clear: both 3 | float: left 4 | overflow: hidden 5 | width: 100% 6 | -------------------------------------------------------------------------------- /src/styles/container.sass: -------------------------------------------------------------------------------- 1 | @import compass/css3/user-interface 2 | @import variables 3 | 4 | .rekapi-timeline-container 5 | background: #1a1a1a 6 | border: solid 1px #000 7 | color: #fefefe 8 | font-family: sans-serif 9 | height: $DEFAULT_CONTAINER_HEGHT 10 | position: relative 11 | 12 | p, 13 | h1 14 | color: #fefefe 15 | 16 | p, 17 | h1, 18 | i, 19 | select 20 | +user-select(none) 21 | 22 | p 23 | font-size: $FONT_SIZE 24 | 25 | input 26 | background: #555 27 | border-radius: 2px 28 | border: none 29 | // box-sizing is needed for environments that do not specify a doctype: 30 | // https://stackoverflow.com/a/8331164 31 | box-sizing: content-box 32 | color: #ddd 33 | font-size: 0.8em 34 | height: 10px 35 | padding: 4px 36 | position: relative 37 | top: -1px 38 | 39 | &:invalid 40 | background-color: $INPUT_ERROR_BACKGROUND_COLOR 41 | color: $INPUT_ERROR_TEXT_COLOR 42 | 43 | .icon-button 44 | background: none 45 | border: none 46 | 47 | i.glyphicon 48 | color: #aaa 49 | cursor: pointer 50 | 51 | select 52 | background: #666 53 | border: solid 1px #333 54 | color: #fff 55 | font-size: 1em 56 | 57 | .fill 58 | bottom: 0 59 | left: 0 60 | position: absolute 61 | right: 0 62 | top: 0 63 | 64 | p 65 | line-height: 18px 66 | margin: 0 67 | padding: 2px 0 68 | 69 | .label-input-pair 70 | font-size: $FONT_SIZE 71 | 72 | p 73 | display: inline 74 | padding-right: $CHROME_PANEL_PADDING 75 | 76 | .fill.bottom-frame 77 | top: auto 78 | height: $BOTTOM_FRAME_HEIGHT 79 | -------------------------------------------------------------------------------- /src/styles/control-bar.sass: -------------------------------------------------------------------------------- 1 | @import variables 2 | 3 | .control-bar 4 | 5 | .icon-button 6 | height: $PLAYBACK_ICON_DIMENSIONS 7 | float: left 8 | padding: 0 9 | width: $PLAYBACK_ICON_DIMENSIONS 10 | 11 | i 12 | color: #000 13 | font-size: 1.5em 14 | 15 | &:active 16 | color: #888 17 | -------------------------------------------------------------------------------- /src/styles/details.sass: -------------------------------------------------------------------------------- 1 | @import variables 2 | 3 | .details 4 | background: $CONTROL_PANEL_BACKGROUND_COLOR 5 | bottom: 0 6 | position: absolute 7 | padding: $CHROME_PANEL_PADDING 8 | top: 0 9 | width: $KEYFRAME_PROPERTY_DETAIL_INNER_WIDTH 10 | 11 | label.row 12 | float: left 13 | clear: both 14 | margin-bottom: 12px 15 | 16 | 17 | label.row, 18 | .field-button 19 | cursor: pointer 20 | 21 | 22 | .field-button 23 | background: #222 24 | border-radius: 2px 25 | border: solid 1px #888 26 | color: #ddd 27 | font-size: 0.7em 28 | margin: 0 6px 0 4px 29 | padding: 5px 12px 30 | 31 | 32 | .label-input-pair 33 | float: left 34 | 35 | p, 36 | input 37 | float: left 38 | height: 12px 39 | line-height: 12px 40 | width: 72px 41 | 42 | &.select-container p 43 | height: 20px 44 | line-height: 20px 45 | 46 | select 47 | width: 115px 48 | 49 | -------------------------------------------------------------------------------- /src/styles/index.sass: -------------------------------------------------------------------------------- 1 | $icon-font-path: '../../node_modules/bootstrap-sass/assets/fonts/bootstrap/' 2 | 3 | @import '../../node_modules/bootstrap-sass/assets/stylesheets/bootstrap/normalize' 4 | @import '../../node_modules/bootstrap-sass/assets/stylesheets/bootstrap/variables' 5 | @import '../../node_modules/bootstrap-sass/assets/stylesheets/bootstrap/glyphicons' 6 | 7 | @import 'variables' 8 | 9 | @import 'container' 10 | @import 'control-bar' 11 | @import 'details' 12 | @import 'keyframe-property-detail' 13 | @import 'scrubber' 14 | @import 'scrubber-detail' 15 | @import 'timeline' 16 | @import 'animation-tracks' 17 | @import 'actor-tracks' 18 | @import 'keyframe-property-track' 19 | @import 'keyframe-property' 20 | -------------------------------------------------------------------------------- /src/styles/keyframe-property-detail.sass: -------------------------------------------------------------------------------- 1 | @import variables 2 | 3 | .keyframe-property-detail 4 | overflow: auto 5 | padding: $CHROME_PANEL_PADDING 6 | position: relative 7 | 8 | h1 9 | font-size: 14px 10 | line-height: 16px 11 | margin: 6px 0 14px 0 12 | 13 | .field-button 14 | margin: 4px 0 15 | width: 100% 16 | 17 | .add-delete-wrapper 18 | position: absolute 19 | right: $CHROME_PANEL_PADDING 20 | top: $CHROME_PANEL_PADDING 21 | -------------------------------------------------------------------------------- /src/styles/keyframe-property-track.sass: -------------------------------------------------------------------------------- 1 | @import variables 2 | 3 | .keyframe-property-track 4 | border-bottom: solid 1px #555 5 | color: #ddd 6 | height: $KEYFRAME_PROPERTY_DIMENSION 7 | position: relative 8 | 9 | &:before 10 | // I can't believe this actually works. http://stackoverflow.com/a/5734583 11 | content: attr(data-track-name) 12 | font-size: 0.75em 13 | padding-left: 12px 14 | 15 | &.active 16 | background: $KEYFRAME_PROPERTY_TRACK_ACTIVE_COLOR 17 | -------------------------------------------------------------------------------- /src/styles/keyframe-property.sass: -------------------------------------------------------------------------------- 1 | @import compass/css3/border-radius 2 | @import compass/css3/transform 3 | @import compass/css3/user-interface 4 | @import variables 5 | 6 | .keyframe-property-wrapper 7 | height: $KEYFRAME_PROPERTY_DIMENSION 8 | position: absolute 9 | top: 0 10 | width: $KEYFRAME_PROPERTY_DIMENSION 11 | z-index: 20 12 | +user-select(none) 13 | 14 | &.active 15 | z-index: 40 16 | 17 | .keyframe-property 18 | background-color: $KEYFRAME_PROPERTY_SLIDER_ACTIVE_COLOR 19 | opacity: 1 20 | outline: none 21 | 22 | .keyframe-property 23 | background-color: $KEYFRAME_PROPERTY_SLIDER_COLOR 24 | border: none 25 | cursor: move 26 | height: 60% 27 | left: -5px 28 | opacity: 0.5 29 | position: relative 30 | top: 4px 31 | width: 60% 32 | +transform(rotate(45deg)) 33 | -------------------------------------------------------------------------------- /src/styles/scrubber-detail.sass: -------------------------------------------------------------------------------- 1 | @import variables 2 | 3 | .scrubber-detail 4 | background: $CONTROL_PANEL_BACKGROUND_COLOR 5 | bottom: 0 6 | left: $KEYFRAME_PROPERTY_DETAIL_OUTER_WIDTH 7 | padding: $CHROME_PANEL_PADDING 8 | position: absolute 9 | right: 0 10 | z-index: 20 11 | 12 | label 13 | 14 | span 15 | font-size: .8em 16 | 17 | &:after 18 | content: "%" 19 | line-height: 20px 20 | padding-left: 4px 21 | 22 | // The extra .row specificity is needed for compatibility within AEnima 23 | // environments 24 | .row.scrubber-scale 25 | float: left 26 | padding-bottom: 0 27 | 28 | input 29 | width: 40px 30 | 31 | .position-monitor 32 | float: right 33 | margin: 3px 0 0 0 34 | -------------------------------------------------------------------------------- /src/styles/scrubber.sass: -------------------------------------------------------------------------------- 1 | @import variables 2 | @import 'compass/css3/transform' 3 | 4 | .scrubber 5 | background: #000 6 | clear: both 7 | float: left 8 | width: 100% 9 | 10 | .scrubber-wrapper 11 | background: $SCRUBBER_BACKGROUND_COLOR 12 | cursor: pointer 13 | height: $SCRUBBER_HANDLE_DIMENSIONS 14 | padding-right: 1px 15 | 16 | .scrubber-handle 17 | float: left 18 | font-size: $SCRUBBER_HANDLE_DIMENSIONS 19 | height: $SCRUBBER_HANDLE_DIMENSIONS 20 | position: absolute 21 | width: 0 22 | z-index: 20 23 | 24 | i.glyphicon.scrubber-icon, 25 | .scrubber-guide 26 | +transform(translateX(-$SCRUBBER_HANDLE_DIMENSIONS / 2)) 27 | 28 | i.glyphicon.scrubber-icon 29 | color: #eee 30 | cursor: ew-resize 31 | 32 | .scrubber-guide 33 | background: #f00 34 | float: left 35 | left: ($SCRUBBER_HANDLE_DIMENSIONS / 2) - 1 36 | margin: 0 37 | position: absolute 38 | top: $SCRUBBER_HANDLE_DIMENSIONS 39 | width: 2px 40 | z-index: 20 41 | -------------------------------------------------------------------------------- /src/styles/timeline.sass: -------------------------------------------------------------------------------- 1 | @import variables 2 | 3 | .timeline 4 | overflow: auto 5 | position: relative 6 | width: calc(100% - #{$KEYFRAME_PROPERTY_DETAIL_OUTER_WIDTH}) 7 | 8 | &.fill, 9 | &.aenima .fill 10 | left: auto 11 | right: 0 12 | 13 | .timeline-wrapper 14 | overflow: auto 15 | padding-bottom: 32px // Works out to be the height of .scrubber-detail, which this is accounting for 16 | 17 | .new-track-name-input-wrapper 18 | float: left 19 | overflow: auto 20 | -------------------------------------------------------------------------------- /src/styles/variables.sass: -------------------------------------------------------------------------------- 1 | $FONT_SIZE: 12px 2 | $CONTROL_PANEL_BACKGROUND_COLOR: #353535 3 | $KEYFRAME_PROPERTY_SLIDER_COLOR: #fff 4 | $KEYFRAME_PROPERTY_SLIDER_ACTIVE_COLOR: #78b7ea 5 | $KEYFRAME_PROPERTY_TRACK_ACTIVE_COLOR: $CONTROL_PANEL_BACKGROUND_COLOR 6 | $SCRUBBER_BACKGROUND_COLOR: #555 7 | $DEFAULT_CONTAINER_HEGHT: 240px 8 | $KEYFRAME_PROPERTY_DIMENSION: 20px 9 | 10 | // Synced to JS constant propertyTrackHeight 11 | $SCRUBBER_HANDLE_DIMENSIONS: 20px 12 | $KEYFRAME_PROPERTY_DETAIL_INNER_WIDTH: 200px 13 | $CHROME_PANEL_PADDING: 6px 14 | $KEYFRAME_PROPERTY_DETAIL_OUTER_WIDTH: $KEYFRAME_PROPERTY_DETAIL_INNER_WIDTH + ($CHROME_PANEL_PADDING * 2) 15 | $PLAYBACK_ICON_DIMENSIONS: 30px 16 | $BOTTOM_FRAME_HEIGHT: 34px 17 | $PLAYBACK_CONTROL_BAR_WIDTH: $PLAYBACK_ICON_DIMENSIONS * 2 18 | $CONTROL_BAR_VIEW: $PLAYBACK_ICON_DIMENSIONS * 2 19 | $INPUT_ERROR_BACKGROUND_COLOR: #ff8 20 | $INPUT_ERROR_TEXT_COLOR: #f00 21 | -------------------------------------------------------------------------------- /src/timeline.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Draggable from 'react-draggable'; 3 | 4 | import { propertyTrackHeight } from './constants'; 5 | 6 | const Scrubber = ({ 7 | timelineWrapperWidth, 8 | scrubberPosition, 9 | handleScrubberDrag, 10 | handleScrubberBarClick, 11 | propertyTracks 12 | }) => 13 |
14 |
19 | handleScrubberDrag(x) } 24 | > 25 |
28 | 29 |   30 | 31 |
37 |
38 |
39 |
40 |
41 | 42 | const Property = ({ 43 | timelineScaleConverter, 44 | property, 45 | propertyCursor, 46 | handlePropertyDrag, 47 | handlePropertyClick 48 | }) => 49 | handlePropertyDrag(x, property.name, property.millisecond)} 54 | onStart={() => handlePropertyClick(property)} 55 | > 56 |
63 |
 
66 |
67 |
68 | 69 | const AnimationTracks = ({ 70 | handlePropertyClick, 71 | handlePropertyDrag, 72 | handlePropertyTrackDoubleClick, 73 | propertyCursor, 74 | propertyTracks, 75 | timelineScaleConverter 76 | }) => 77 |
78 |
79 | {Object.keys(propertyTracks).map(trackName => ( 80 |
handlePropertyTrackDoubleClick(e, trackName)} 87 | > 88 | {propertyTracks[trackName].map((property, i) => 89 | 97 | )} 98 |
99 | ))} 100 |
101 |
102 | 103 | const Timeline = ({ 104 | timelineWrapperWidth, 105 | scrubberPosition, 106 | handleScrubberDrag, 107 | handleScrubberBarClick, 108 | propertyTracks = {}, 109 | timelineScaleConverter = () => {}, 110 | handlePropertyDrag, 111 | handlePropertyClick, 112 | handlePropertyTrackDoubleClick, 113 | propertyCursor = {}, 114 | 115 | // FIXME: These are unimplemented and untested 116 | newTrackName, 117 | handleChangeNewTrackName = () => {}, 118 | handleKeyDownNewTrackName = () => {}, 119 | handleClickNewTrackButton = () => {} 120 | }) => 121 |
122 |
126 | 133 | 141 | 142 |
143 | 150 | 156 |
157 | 158 |
159 |
160 | 161 | export default Timeline; 162 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // TODO: The JSDoc namepaths are all off for this file. The functions defined 2 | // here should be namepath-ed under a "utils" module. 3 | 4 | /** 5 | * @module utils 6 | */ 7 | 8 | /** 9 | * Compute a {@link external:rekapi.propertyData} from a 10 | * {@link RekapiTimeline.propertyCursor} and a 11 | * {@link external:rekapi.Rekapi}. 12 | * @method module:utils.computeHighlightedKeyframe 13 | * @param {external:rekapi.Rekapi} rekapi 14 | * @param {RekapiTimeline.propertyCursor} propertyCursor 15 | * @returns {external:rekapi.propertyData|{}} Is `{}` if the 16 | * {@link external:rekapi.KeyframeProperty} referenced by `propertyCursor` 17 | * cannot be found. 18 | * @static 19 | */ 20 | export const computeHighlightedKeyframe = (rekapi, { property, millisecond }) => { 21 | const [ actor ] = rekapi.getAllActors(); 22 | 23 | if (!actor 24 | || property === undefined 25 | || millisecond === undefined 26 | || !actor.getPropertiesInTrack(property).length 27 | ) { 28 | return {}; 29 | } 30 | 31 | const keyframeProperty = actor.getKeyframeProperty(property, millisecond); 32 | return keyframeProperty ? keyframeProperty.exportPropertyData() : {}; 33 | }; 34 | 35 | /** 36 | * @method module:utils.computeTimelineWidth 37 | * @param {external:rekapi.Rekapi} rekapi 38 | * @param {number} timelineScale A normalized scalar value 39 | * @returns {number} 40 | * @static 41 | */ 42 | export const computeTimelineWidth = (rekapi, timelineScale) => 43 | rekapi.getAnimationLength() * timelineScale; 44 | 45 | /** 46 | * @method module:utils.computeScrubberPixelPosition 47 | * @param {external:rekapi.Rekapi} rekapi 48 | * @param {number} timelineScale A normalized scalar value 49 | * @returns {number} 50 | * @static 51 | */ 52 | export const computeScrubberPixelPosition = (rekapi, timelineScale) => 53 | computeScaledPixelPosition( 54 | timelineScale, 55 | rekapi.getLastPositionUpdated() * rekapi.getAnimationLength() 56 | ); 57 | 58 | /** 59 | * @method module:utils.computeScaledPixelPosition 60 | * @param {number} timelineScale A normalized scalar value 61 | * @param {number} rawPixel The pixel value to scale 62 | * @returns {number} 63 | * @static 64 | */ 65 | export const computeScaledPixelPosition = 66 | (timelineScale, rawPixel) => timelineScale * rawPixel; 67 | 68 | /** 69 | * @method module:utils.computeDescaledPixelPosition 70 | * @param {number} timelineScale A normalized scalar value 71 | * @param {number} scaledPixel A pixel value that has already been scaled 72 | * @returns {number} 73 | * @static 74 | */ 75 | export const computeDescaledPixelPosition = 76 | (timelineScale, scaledPixel) => Math.floor(scaledPixel / timelineScale); 77 | -------------------------------------------------------------------------------- /test/fixtures/basic-rekapi-export.js: -------------------------------------------------------------------------------- 1 | export const basicRekapiExport = { 2 | "duration": 1000, 3 | "actors": [ 4 | { 5 | "start": 0, 6 | "end": 1000, 7 | "trackNames": [ 8 | "transform" 9 | ], 10 | "propertyTracks": { 11 | "transform": [ 12 | { 13 | "millisecond": 0, 14 | "name": "transform", 15 | "value": "translate(50px, 683px) scale(1) rotateX(0deg) rotateY(0deg) rotateZ(0deg) translate(-50%, -50%)", 16 | "easing": "linear linear linear linear linear linear" 17 | }, 18 | { 19 | "millisecond": 1000, 20 | "name": "transform", 21 | "value": "translate(250px, 683px) scale(1) rotateX(0deg) rotateY(0deg) rotateZ(0deg) translate(-50%, -50%)", 22 | "easing": "linear linear linear linear linear linear" 23 | } 24 | ] 25 | } 26 | } 27 | ], 28 | "curves": { 29 | "customCurve1": { 30 | "displayName": "customCurve1", 31 | "x1": 0.25, 32 | "y1": 0.5, 33 | "x2": 0.75, 34 | "y2": 0.5 35 | } 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /test/fixtures/decoupled-rekapi-number-export.js: -------------------------------------------------------------------------------- 1 | export const decoupledRekapiNumberExport = { 2 | "duration": 1000, 3 | "actors": [ 4 | { 5 | "start": 0, 6 | "end": 1000, 7 | "trackNames": [ 8 | "translateX", 9 | "translateY", 10 | "rotateZ" 11 | ], 12 | "propertyTracks": { 13 | "translateX": [ 14 | { 15 | "millisecond": 0, 16 | "name": "translateX", 17 | "value": 100, 18 | "easing": "linear" 19 | }, 20 | { 21 | "millisecond": 1000, 22 | "name": "translateX", 23 | "value": 400, 24 | "easing": "linear" 25 | } 26 | ], 27 | "translateY": [ 28 | { 29 | "millisecond": 0, 30 | "name": "translateY", 31 | "value": 100, 32 | "easing": "linear" 33 | }, 34 | { 35 | "millisecond": 1000, 36 | "name": "translateY", 37 | "value": 100, 38 | "easing": "linear" 39 | } 40 | ], 41 | "rotateZ": [ 42 | { 43 | "millisecond": 0, 44 | "name": "rotateZ", 45 | "value": "0deg", 46 | "easing": "linear" 47 | }, 48 | { 49 | "millisecond": 1000, 50 | "name": "rotateZ", 51 | "value": "0deg", 52 | "easing": "linear" 53 | } 54 | ] 55 | } 56 | } 57 | ], 58 | "curves": { 59 | "customCurve1": { 60 | "displayName": "customCurve1", 61 | "x1": 0.25, 62 | "y1": 0.5, 63 | "x2": 0.75, 64 | "y2": 0.5 65 | }, 66 | "customCurve2": { 67 | "displayName": "customCurve2", 68 | "x1": 0.25, 69 | "y1": 0.5, 70 | "x2": 0.75, 71 | "y2": 0.5 72 | } 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /test/fixtures/decoupled-rekapi-string-export.js: -------------------------------------------------------------------------------- 1 | export const decoupledRekapiStringExport = { 2 | "duration": 1000, 3 | "actors": [ 4 | { 5 | "start": 0, 6 | "end": 1000, 7 | "trackNames": [ 8 | "translateX", 9 | "translateY", 10 | "rotateZ" 11 | ], 12 | "propertyTracks": { 13 | "translateX": [ 14 | { 15 | "millisecond": 0, 16 | "name": "translateX", 17 | "value": "100px", 18 | "easing": "linear" 19 | }, 20 | { 21 | "millisecond": 1000, 22 | "name": "translateX", 23 | "value": "400px", 24 | "easing": "linear" 25 | } 26 | ], 27 | "translateY": [ 28 | { 29 | "millisecond": 0, 30 | "name": "translateY", 31 | "value": "100px", 32 | "easing": "linear" 33 | }, 34 | { 35 | "millisecond": 1000, 36 | "name": "translateY", 37 | "value": "100px", 38 | "easing": "linear" 39 | } 40 | ], 41 | "rotateZ": [ 42 | { 43 | "millisecond": 0, 44 | "name": "rotateZ", 45 | "value": "0deg", 46 | "easing": "linear" 47 | }, 48 | { 49 | "millisecond": 1000, 50 | "name": "rotateZ", 51 | "value": "0deg", 52 | "easing": "linear" 53 | } 54 | ] 55 | } 56 | } 57 | ], 58 | "curves": { 59 | "customCurve1": { 60 | "displayName": "customCurve1", 61 | "x1": 0.25, 62 | "y1": 0.5, 63 | "x2": 0.75, 64 | "y2": 0.5 65 | }, 66 | "customCurve2": { 67 | "displayName": "customCurve2", 68 | "x1": 0.25, 69 | "y1": 0.5, 70 | "x2": 0.75, 71 | "y2": 0.5 72 | } 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Modern JavaScript Project 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Enzyme, { mount, shallow } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | import sinon from 'sinon'; 5 | import assert from 'assert'; 6 | 7 | import { Rekapi } from 'rekapi'; 8 | import { 9 | setBezierFunction, 10 | unsetBezierFunction 11 | } from 'shifty'; 12 | 13 | import { RekapiTimeline } from '../src/rekapi-timeline'; 14 | import Details from '../src/details'; 15 | import Timeline from '../src/timeline'; 16 | import BottomFrame from '../src/bottom-frame'; 17 | import { 18 | computeHighlightedKeyframe, 19 | computeTimelineWidth, 20 | computeScrubberPixelPosition, 21 | computeScaledPixelPosition, 22 | computeDescaledPixelPosition, 23 | } from '../src/utils'; 24 | 25 | import { 26 | newPropertyMillisecondBuffer, 27 | defaultTimelineScale, 28 | propertyTrackHeight 29 | } from '../src/constants'; 30 | 31 | import { basicRekapiExport } from './fixtures/basic-rekapi-export' 32 | import { 33 | decoupledRekapiStringExport 34 | } from './fixtures/decoupled-rekapi-string-export' 35 | import { 36 | decoupledRekapiNumberExport 37 | } from './fixtures/decoupled-rekapi-number-export' 38 | 39 | Enzyme.configure({ adapter: new Adapter() }); 40 | 41 | const exportTimelineOptions = { withId: true }; 42 | 43 | const [ 44 | basicProperty1, 45 | basicProperty2 46 | ] = basicRekapiExport.actors[0].propertyTracks.transform; 47 | 48 | const [ 49 | translateXStringProperty1 50 | ] = decoupledRekapiStringExport.actors[0].propertyTracks.translateX; 51 | 52 | let rekapi; 53 | let component; 54 | 55 | const getActor = () => rekapi.getAllActors()[0]; 56 | 57 | const getPropertyTracks = () => 58 | rekapi.exportTimeline(exportTimelineOptions).actors[0].propertyTracks; 59 | 60 | describe('utils', () => { 61 | describe('computeHighlightedKeyframe', () => { 62 | beforeEach(() => { 63 | rekapi = new Rekapi(); 64 | }); 65 | 66 | it('is a function', () => { 67 | assert(computeHighlightedKeyframe instanceof Function); 68 | }); 69 | 70 | describe('return values', () => { 71 | describe('when propertyCursor is empty', () => { 72 | it('returns empty object', () => { 73 | assert.deepEqual( 74 | computeHighlightedKeyframe(rekapi, {}), 75 | {} 76 | ); 77 | }); 78 | }); 79 | 80 | describe('when rekapi has an actor and propertyCursor is empty', () => { 81 | beforeEach(() => { 82 | rekapi.addActor(); 83 | }); 84 | 85 | it('returns empty object', () => { 86 | assert.deepEqual( 87 | computeHighlightedKeyframe(rekapi, {}), 88 | {} 89 | ); 90 | }); 91 | }); 92 | 93 | describe('when propertyCursor references a property track that does not exist', () => { 94 | beforeEach(() => { 95 | rekapi.addActor().keyframe(0, { x: 5 }); 96 | }); 97 | 98 | it('returns empty object', () => { 99 | assert.deepEqual( 100 | computeHighlightedKeyframe( 101 | rekapi, 102 | { property: 'y', millisecond: 0 } 103 | ), 104 | {} 105 | ); 106 | }); 107 | }); 108 | 109 | describe('when propertyCursor references a property that does not exist', () => { 110 | it('returns empty object', () => { 111 | assert.deepEqual( 112 | computeHighlightedKeyframe( 113 | rekapi, 114 | { property: 'x', millisecond: 0 } 115 | ), 116 | {} 117 | ); 118 | }); 119 | }); 120 | 121 | describe('when propertyCursor references a property that does exist', () => { 122 | beforeEach(() => { 123 | rekapi.addActor().keyframe(0, { x: 5 }); 124 | }); 125 | 126 | it('returns KeyframeProperty data', () => { 127 | assert.deepEqual( 128 | computeHighlightedKeyframe( 129 | rekapi, 130 | { property: 'x', millisecond: 0 } 131 | ), 132 | { 133 | value: 5, 134 | name: 'x', 135 | easing: 'linear', 136 | millisecond: 0 137 | } 138 | ); 139 | }); 140 | }); 141 | }); 142 | }); 143 | 144 | describe('computeTimelineWidth', () => { 145 | beforeEach(() => { 146 | rekapi = new Rekapi(); 147 | rekapi.addActor().keyframe(0, { x: 1 }).keyframe(1000, { x: 1 }); 148 | }); 149 | 150 | it('is a function', () => { 151 | assert(computeTimelineWidth instanceof Function); 152 | }); 153 | 154 | describe('return values', () => { 155 | it('applies timelineScale=.5 to animation length', () => { 156 | assert.equal( 157 | computeTimelineWidth(rekapi, .5), 158 | 500 159 | ); 160 | }); 161 | 162 | it('applies timelineScale=1 to animation length', () => { 163 | assert.equal( 164 | computeTimelineWidth(rekapi, 1), 165 | 1000 166 | ); 167 | }); 168 | 169 | it('applies timelineScale=2 to animation length', () => { 170 | assert.equal( 171 | computeTimelineWidth(rekapi, 2), 172 | 2000 173 | ); 174 | }); 175 | }); 176 | }); 177 | 178 | describe('computeScrubberPixelPosition', () => { 179 | beforeEach(() => { 180 | rekapi = new Rekapi(); 181 | rekapi.addActor().keyframe(0, { x: 1 }).keyframe(1000, { x: 1 }); 182 | }); 183 | 184 | it('is a function', () => { 185 | assert(computeScrubberPixelPosition instanceof Function); 186 | }); 187 | 188 | describe('return values', () => { 189 | describe('animation position === 0 (default)', () => { 190 | it('applies timelineScale=.5 to animation position', () => { 191 | assert.equal( 192 | computeScrubberPixelPosition(rekapi, .5), 193 | 0 194 | ); 195 | }); 196 | 197 | it('applies timelineScale=1 to animation position', () => { 198 | assert.equal( 199 | computeScrubberPixelPosition(rekapi, 1), 200 | 0 201 | ); 202 | }); 203 | 204 | it('applies timelineScale=2 to animation position', () => { 205 | assert.equal( 206 | computeScrubberPixelPosition(rekapi, 2), 207 | 0 208 | ); 209 | }); 210 | }); 211 | 212 | describe('animation position === 500', () => { 213 | beforeEach(() => { 214 | rekapi.update(500); 215 | }); 216 | 217 | it('applies timelineScale=.5 to animation position', () => { 218 | assert.equal( 219 | computeScrubberPixelPosition(rekapi, .5), 220 | 250 221 | ); 222 | }); 223 | 224 | it('applies timelineScale=1 to animation position', () => { 225 | assert.equal( 226 | computeScrubberPixelPosition(rekapi, 1), 227 | 500 228 | ); 229 | }); 230 | 231 | it('applies timelineScale=2 to animation position', () => { 232 | assert.equal( 233 | computeScrubberPixelPosition(rekapi, 2), 234 | 1000 235 | ); 236 | }); 237 | }); 238 | 239 | describe('animation position === 1000', () => { 240 | beforeEach(() => { 241 | rekapi.update(1000); 242 | }); 243 | 244 | it('applies timelineScale=.5 to animation position', () => { 245 | assert.equal( 246 | computeScrubberPixelPosition(rekapi, .5), 247 | 500 248 | ); 249 | }); 250 | 251 | it('applies timelineScale=1 to animation position', () => { 252 | assert.equal( 253 | computeScrubberPixelPosition(rekapi, 1), 254 | 1000 255 | ); 256 | }); 257 | 258 | it('applies timelineScale=2 to animation position', () => { 259 | assert.equal( 260 | computeScrubberPixelPosition(rekapi, 2), 261 | 2000 262 | ); 263 | }); 264 | }); 265 | }); 266 | }); 267 | 268 | describe('computeScaledPixelPosition', () => { 269 | it('scales a pixel value against a normalized value', () => { 270 | assert.equal(computeScaledPixelPosition(1.5, 10), 15); 271 | }); 272 | }); 273 | 274 | describe('computeDescaledPixelPosition', () => { 275 | it('de-scales a scaled pixel value against a normalized value', () => { 276 | assert.equal(computeDescaledPixelPosition(2, 10), 5); 277 | }); 278 | 279 | it('rounds returned values down', () => { 280 | assert.equal(computeDescaledPixelPosition(1.9, 10), 5); 281 | }); 282 | }); 283 | }); 284 | 285 | describe('eventHandlers', () => { 286 | beforeEach(() => { 287 | component = shallow(); 288 | }); 289 | 290 | describe('handleAddKeyframeButtonClick', () => { 291 | beforeEach(() => { 292 | rekapi = new Rekapi(); 293 | component = shallow(); 294 | }); 295 | 296 | describe('with propertyCursor that does not reference a keyframeProperty', () => { 297 | beforeEach(() => { 298 | rekapi.importTimeline(basicRekapiExport); 299 | component.instance().handleAddKeyframeButtonClick(); 300 | }); 301 | 302 | it('does not add a new keyframe property', () => { 303 | assert.equal( 304 | getActor().getPropertiesInTrack('transform').length, 305 | 2 306 | ); 307 | }); 308 | 309 | it('does not add an "undefined" keyframe property', () => { 310 | assert.equal( 311 | getActor().getPropertiesInTrack('undefined').length, 312 | 0 313 | ); 314 | }); 315 | }); 316 | 317 | describe('with propertyCursor that does reference a keyframeProperty', () => { 318 | let currentKeyframeProperty, newKeyframeProperty; 319 | beforeEach(() => { 320 | rekapi.importTimeline(basicRekapiExport); 321 | currentKeyframeProperty = getActor().getKeyframeProperty('transform', 0) 322 | component.setState({ 323 | propertyCursor: { property: 'transform', millisecond: 0 } 324 | }); 325 | 326 | component.instance().handleAddKeyframeButtonClick(); 327 | 328 | newKeyframeProperty = getActor().getPropertiesInTrack('transform').find( 329 | property => property.millisecond === newPropertyMillisecondBuffer 330 | ); 331 | }); 332 | 333 | it('does add a new keyframe property', () => { 334 | assert.equal( 335 | getActor().getPropertiesInTrack('transform').length, 336 | 3 337 | ); 338 | }); 339 | 340 | it('the new property is placed after the current property', () => { 341 | assert.equal( 342 | newKeyframeProperty.millisecond, 343 | currentKeyframeProperty.millisecond + newPropertyMillisecondBuffer 344 | ); 345 | }); 346 | 347 | it('the new property has the same value as the current property', () => { 348 | assert.equal( 349 | newKeyframeProperty.value, 350 | currentKeyframeProperty.value 351 | ); 352 | }); 353 | 354 | it('sets state.propertyCursor to the newly created property', () => { 355 | assert.deepEqual( 356 | component.state().propertyCursor, 357 | { 358 | property: 'transform', 359 | millisecond: newPropertyMillisecondBuffer 360 | } 361 | ); 362 | }); 363 | }); 364 | }); 365 | 366 | describe('handleDeleteKeyframeButtonClick', () => { 367 | beforeEach(() => { 368 | rekapi = new Rekapi(); 369 | component = shallow(); 370 | }); 371 | 372 | describe('with propertyCursor that does not reference a keyframeProperty', () => { 373 | beforeEach(() => { 374 | rekapi.importTimeline(basicRekapiExport); 375 | component.instance().handleDeleteKeyframeButtonClick(); 376 | }); 377 | 378 | it('does not remove a new keyframe property', () => { 379 | assert.equal( 380 | getActor().getPropertiesInTrack('transform').length, 381 | 2 382 | ); 383 | }); 384 | }); 385 | 386 | describe('with propertyCursor that does reference a keyframeProperty', () => { 387 | beforeEach(() => { 388 | rekapi.importTimeline(basicRekapiExport); 389 | component.setState({ 390 | propertyCursor: { property: 'transform', millisecond: 0 } 391 | }); 392 | 393 | component.instance().handleDeleteKeyframeButtonClick(); 394 | }); 395 | 396 | it('does remove a new keyframe property', () => { 397 | assert.equal( 398 | getActor().getPropertiesInTrack('transform').length, 399 | 1 400 | ); 401 | }); 402 | 403 | describe('updating state.propertyCursor', () => { 404 | describe('when there is not a property that comes before the removed property', () => { 405 | it('state.propertyCursor is set emptied', () => { 406 | assert.deepEqual(component.state().propertyCursor, {}); 407 | }); 408 | }); 409 | 410 | describe('when there is a property that comes before the removed property', () => { 411 | beforeEach(() => { 412 | rekapi = new Rekapi(); 413 | component = shallow(); 414 | rekapi.importTimeline(basicRekapiExport); 415 | component.setState({ 416 | propertyCursor: { 417 | property: 'transform', 418 | millisecond: basicProperty2.millisecond 419 | } 420 | }); 421 | 422 | component.instance().handleDeleteKeyframeButtonClick(); 423 | }); 424 | 425 | it('state.propertyCursor is set to the prior property', () => { 426 | assert.deepEqual( 427 | component.state().propertyCursor, 428 | { 429 | property: 'transform', 430 | millisecond: 0 431 | } 432 | ); 433 | }); 434 | }); 435 | }); 436 | }); 437 | }); 438 | 439 | describe('handleEasingSelectChange', () => { 440 | beforeEach(() => { 441 | rekapi = new Rekapi(); 442 | rekapi.importTimeline(basicRekapiExport); 443 | component = shallow(); 444 | 445 | component.setState({ 446 | propertyCursor: { property: 'transform', millisecond: 0 } 447 | }); 448 | 449 | component.instance().handleEasingSelectChange({ 450 | target: { value: 'easeInQuad' } 451 | }); 452 | }); 453 | 454 | it('sets the new easing upon the highlighted property', () => { 455 | assert.equal( 456 | getActor().getKeyframeProperty('transform', 0).easing, 457 | 'easeInQuad' 458 | ); 459 | }); 460 | }); 461 | 462 | describe('handleMillisecondInputChange', () => { 463 | beforeEach(() => { 464 | rekapi = new Rekapi(); 465 | component = shallow(); 466 | }); 467 | 468 | describe('with propertyCursor that does reference a keyframeProperty', () => { 469 | let keyframeProperty; 470 | beforeEach(() => { 471 | rekapi.importTimeline(basicRekapiExport); 472 | component.setState({ 473 | propertyCursor: { property: 'transform', millisecond: 0 } 474 | }); 475 | 476 | keyframeProperty = getActor().getKeyframeProperty('transform', 0); 477 | 478 | component.instance().handleMillisecondInputChange({ 479 | target: { value: 5 } 480 | }); 481 | }); 482 | 483 | it('sets the current property millisecond to the indicated number', () => { 484 | assert.equal(keyframeProperty.millisecond, 5); 485 | }); 486 | 487 | it('updates the propertyCursor', () => { 488 | assert.equal(component.state().propertyCursor.millisecond, 5); 489 | }); 490 | }); 491 | }); 492 | 493 | describe('handleValueInputChange', () => { 494 | beforeEach(() => { 495 | rekapi = new Rekapi(); 496 | component = shallow(); 497 | }); 498 | 499 | describe('with propertyCursor that does reference a keyframeProperty', () => { 500 | let keyframeProperty; 501 | 502 | describe('weakly-typed values', () => { 503 | beforeEach(() => { 504 | rekapi.addActor().keyframe(0, { x: 1 }); 505 | component.setState({ 506 | propertyCursor: { property: 'x', millisecond: 0 } 507 | }); 508 | 509 | keyframeProperty = getActor().getKeyframeProperty('x', 0); 510 | 511 | component.instance().handleValueInputChange({ 512 | target: { value: '5abc' } 513 | }); 514 | }); 515 | 516 | it('allows value types to change if there is only on property in a track', () => { 517 | assert.equal(keyframeProperty.value, '5abc'); 518 | }); 519 | }); 520 | 521 | describe('number values', () => { 522 | describe('valid values', () => { 523 | beforeEach(() => { 524 | rekapi.importTimeline(decoupledRekapiNumberExport); 525 | component.setState({ 526 | propertyCursor: { property: 'translateX', millisecond: 0 } 527 | }); 528 | 529 | keyframeProperty = getActor().getKeyframeProperty('translateX', 0); 530 | 531 | component.instance().handleValueInputChange({ 532 | target: { value: 5 } 533 | }); 534 | }); 535 | 536 | it('sets the current property value to the indicated number', () => { 537 | assert.equal(keyframeProperty.value, 5); 538 | }); 539 | }); 540 | 541 | describe('type coercion', () => { 542 | describe('strings to number', () => { 543 | beforeEach(() => { 544 | rekapi.importTimeline(decoupledRekapiNumberExport); 545 | component.setState({ 546 | propertyCursor: { property: 'translateX', millisecond: 0 } 547 | }); 548 | 549 | keyframeProperty = getActor().getKeyframeProperty('translateX', 0); 550 | 551 | component.instance().handleValueInputChange({ 552 | target: { value: '5' } 553 | }); 554 | }); 555 | 556 | it('converts a string value to its number equivalent', () => { 557 | assert.equal(keyframeProperty.value, 5); 558 | }); 559 | }); 560 | }); 561 | 562 | describe('invalid values', () => { 563 | describe('type mismatch', () => { 564 | beforeEach(() => { 565 | rekapi.importTimeline(decoupledRekapiNumberExport); 566 | component.setState({ 567 | propertyCursor: { property: 'rotateZ', millisecond: 0 } 568 | }); 569 | 570 | keyframeProperty = getActor().getKeyframeProperty('rotateZ', 0); 571 | 572 | component.instance().handleValueInputChange({ 573 | target: { value: '5' } 574 | }); 575 | }); 576 | 577 | it('does not set the current property value to the indicated string', () => { 578 | assert.equal( 579 | keyframeProperty.value, 580 | decoupledRekapiNumberExport.actors[0].propertyTracks.rotateZ[0].value 581 | ); 582 | }); 583 | }); 584 | }); 585 | }); 586 | 587 | describe('string values', () => { 588 | describe('valid values', () => { 589 | describe('straight token match', () => { 590 | beforeEach(() => { 591 | rekapi.importTimeline(decoupledRekapiStringExport); 592 | component.setState({ 593 | propertyCursor: { property: 'translateX', millisecond: 0 } 594 | }); 595 | 596 | keyframeProperty = getActor().getKeyframeProperty('translateX', 0); 597 | 598 | component.instance().handleValueInputChange({ 599 | target: { value: '5px' } 600 | }); 601 | }); 602 | 603 | it('sets the current property value to the indicated string', () => { 604 | assert.equal(keyframeProperty.value, '5px'); 605 | }); 606 | }); 607 | 608 | describe('negative numbers within strings', () => { 609 | beforeEach(() => { 610 | rekapi.importTimeline(decoupledRekapiStringExport); 611 | component.setState({ 612 | propertyCursor: { property: 'translateX', millisecond: 0 } 613 | }); 614 | 615 | keyframeProperty = getActor().getKeyframeProperty('translateX', 0); 616 | 617 | component.instance().handleValueInputChange({ 618 | target: { value: '-5px' } 619 | }); 620 | }); 621 | 622 | it('sets the current property value to the indicated string', () => { 623 | assert.equal(keyframeProperty.value, '-5px'); 624 | }); 625 | }); 626 | 627 | describe('floating point numbers within strings', () => { 628 | beforeEach(() => { 629 | rekapi.importTimeline(decoupledRekapiStringExport); 630 | component.setState({ 631 | propertyCursor: { property: 'translateX', millisecond: 0 } 632 | }); 633 | 634 | keyframeProperty = getActor().getKeyframeProperty('translateX', 0); 635 | 636 | component.instance().handleValueInputChange({ 637 | target: { value: '5.px' } 638 | }); 639 | }); 640 | 641 | it('sets the current property value to the indicated string', () => { 642 | assert.equal(keyframeProperty.value, '5.0px'); 643 | }); 644 | }); 645 | }); 646 | 647 | describe('invalid values', () => { 648 | describe('type mismatch', () => { 649 | beforeEach(() => { 650 | rekapi.importTimeline(decoupledRekapiStringExport); 651 | component.setState({ 652 | propertyCursor: { property: 'translateX', millisecond: 0 } 653 | }); 654 | 655 | keyframeProperty = getActor().getKeyframeProperty('translateX', 0); 656 | 657 | component.instance().handleValueInputChange({ 658 | target: { value: 5 } 659 | }); 660 | }); 661 | 662 | it('does not set the current property value to the indicated string', () => { 663 | assert.equal(keyframeProperty.value, translateXStringProperty1.value); 664 | }); 665 | }); 666 | 667 | describe('token mismatches', () => { 668 | beforeEach(() => { 669 | rekapi.importTimeline(decoupledRekapiStringExport); 670 | component.setState({ 671 | propertyCursor: { property: 'translateX', millisecond: 0 } 672 | }); 673 | 674 | keyframeProperty = getActor().getKeyframeProperty('translateX', 0); 675 | }); 676 | 677 | describe('extra spaces', () => { 678 | describe('leading spaces', () => { 679 | beforeEach(() => { 680 | component.instance().handleValueInputChange({ 681 | target: { value: ' 5px' } 682 | }); 683 | }); 684 | 685 | it('does not set the current property value to the indicated string', () => { 686 | assert.equal(keyframeProperty.value, translateXStringProperty1.value); 687 | }); 688 | }); 689 | 690 | describe('spaces in the middle', () => { 691 | beforeEach(() => { 692 | component.instance().handleValueInputChange({ 693 | target: { value: '5 px' } 694 | }); 695 | }); 696 | 697 | it('does not set the current property value to the indicated string', () => { 698 | assert.equal(keyframeProperty.value, translateXStringProperty1.value); 699 | }); 700 | }); 701 | 702 | describe('trailing spaces', () => { 703 | beforeEach(() => { 704 | component.instance().handleValueInputChange({ 705 | target: { value: '5px ' } 706 | }); 707 | }); 708 | 709 | it('does not set the current property value to the indicated string', () => { 710 | assert.equal(keyframeProperty.value, translateXStringProperty1.value); 711 | }); 712 | }); 713 | }); 714 | 715 | describe('missing string components', () => { 716 | beforeEach(() => { 717 | component.instance().handleValueInputChange({ 718 | target: { value: '5x' } 719 | }); 720 | }); 721 | 722 | it('does not set the current property value to the indicated string', () => { 723 | assert.equal(keyframeProperty.value, translateXStringProperty1.value); 724 | }); 725 | }); 726 | 727 | describe('missing number components', () => { 728 | beforeEach(() => { 729 | component.instance().handleValueInputChange({ 730 | target: { value: 'px' } 731 | }); 732 | }); 733 | 734 | it('does not set the current property value to the indicated string', () => { 735 | assert.equal(keyframeProperty.value, translateXStringProperty1.value); 736 | }); 737 | }); 738 | }); 739 | }); 740 | }); 741 | }); 742 | }); 743 | 744 | describe('handlePlayButtonClick', () => { 745 | beforeEach(() => { 746 | rekapi = new Rekapi(); 747 | sinon.spy(rekapi, 'play'); 748 | component = shallow(); 749 | 750 | component.instance().handlePlayButtonClick(); 751 | }); 752 | 753 | it('plays the animation', () => { 754 | assert(rekapi.play.called); 755 | }); 756 | }); 757 | 758 | describe('handlePauseButtonClick', () => { 759 | beforeEach(() => { 760 | rekapi = new Rekapi(); 761 | sinon.spy(rekapi, 'pause'); 762 | component = shallow(); 763 | 764 | component.instance().handlePauseButtonClick(); 765 | }); 766 | 767 | it('pauses the animation', () => { 768 | assert(rekapi.pause.called); 769 | }); 770 | }); 771 | 772 | describe('handleStopButtonClick', () => { 773 | beforeEach(() => { 774 | rekapi = new Rekapi(); 775 | sinon.spy(rekapi, 'stop'); 776 | component = shallow(); 777 | 778 | rekapi.addActor().keyframe(1000, { x: 1 }); 779 | rekapi.update(500); 780 | 781 | component.instance().handleStopButtonClick(); 782 | }); 783 | 784 | it('stop the animation', () => { 785 | assert(rekapi.stop.called); 786 | }); 787 | 788 | it('resets the animation', () => { 789 | assert.equal(rekapi.getLastPositionUpdated(), 0); 790 | }); 791 | }); 792 | 793 | describe('handleTimelineScaleChange', () => { 794 | beforeEach(() => { 795 | rekapi = new Rekapi(); 796 | component = shallow(); 797 | 798 | component.instance().handleTimelineScaleChange( 799 | { target: { value: '50' } } 800 | ); 801 | }); 802 | 803 | it('updates timelineScale state', () => { 804 | assert.equal(component.state().timelineScale, .5); 805 | }); 806 | 807 | describe('invalid inputs', () => { 808 | describe('negative numbers', () => { 809 | beforeEach(() => { 810 | rekapi = new Rekapi(); 811 | component = shallow(); 812 | 813 | component.instance().handleTimelineScaleChange( 814 | { target: { value: '-50' } } 815 | ); 816 | }); 817 | 818 | it('converts number to positive value', () => { 819 | assert.equal(component.state().timelineScale, .5); 820 | }); 821 | }); 822 | }); 823 | }); 824 | 825 | describe('handleScrubberDrag', () => { 826 | beforeEach(() => { 827 | rekapi = new Rekapi(); 828 | component = shallow(); 829 | rekapi.addActor().keyframe(0, { x: 0 }).keyframe(1000, { x: 1 }); 830 | }); 831 | 832 | describe('timelineScale === .5', () => { 833 | beforeEach(() => { 834 | component.setState({ timelineScale: .5 }); 835 | component.instance().handleScrubberDrag(100); 836 | }); 837 | 838 | it('sets the scaled timeline position', () => { 839 | assert.equal(rekapi.getLastPositionUpdated(), .2); 840 | }); 841 | }); 842 | 843 | describe('timelineScale === 1', () => { 844 | beforeEach(() => { 845 | component.setState({ timelineScale: 1 }); 846 | component.instance().handleScrubberDrag(100); 847 | }); 848 | 849 | it('sets the scaled timeline position', () => { 850 | assert.equal(rekapi.getLastPositionUpdated(), .1); 851 | }); 852 | }); 853 | 854 | describe('timelineScale === 2', () => { 855 | beforeEach(() => { 856 | component.setState({ timelineScale: 2 }); 857 | component.instance().handleScrubberDrag(100); 858 | }); 859 | 860 | it('sets the scaled timeline position', () => { 861 | assert.equal(rekapi.getLastPositionUpdated(), .05); 862 | }); 863 | }); 864 | }); 865 | 866 | describe('handleScrubberBarClick', () => { 867 | // target and currentTarget need to be equal to get past a target check in 868 | // handleScrubberBarClick's implementation 869 | const e = { 870 | target: 1, 871 | currentTarget: 1, 872 | nativeEvent: { 873 | offsetX: 100 874 | } 875 | }; 876 | 877 | beforeEach(() => { 878 | rekapi = new Rekapi(); 879 | component = shallow(); 880 | rekapi.addActor().keyframe(0, { x: 0 }).keyframe(1000, { x: 1 }); 881 | }); 882 | 883 | describe('timelineScale === .5', () => { 884 | beforeEach(() => { 885 | component.setState({ timelineScale: .5 }); 886 | component.instance().handleScrubberBarClick(e); 887 | }); 888 | 889 | it('sets the scaled timeline position', () => { 890 | assert.equal(rekapi.getLastPositionUpdated(), .2); 891 | }); 892 | }); 893 | 894 | describe('timelineScale === 1', () => { 895 | beforeEach(() => { 896 | component.setState({ timelineScale: 1 }); 897 | component.instance().handleScrubberBarClick(e); 898 | }); 899 | 900 | it('sets the scaled timeline position', () => { 901 | assert.equal(rekapi.getLastPositionUpdated(), .1); 902 | }); 903 | }); 904 | 905 | describe('timelineScale === 2', () => { 906 | beforeEach(() => { 907 | component.setState({ timelineScale: 2 }); 908 | component.instance().handleScrubberBarClick(e); 909 | }); 910 | 911 | it('sets the scaled timeline position', () => { 912 | assert.equal(rekapi.getLastPositionUpdated(), .05); 913 | }); 914 | }); 915 | }); 916 | 917 | describe('handlePropertyDrag', () => { 918 | beforeEach(() => { 919 | rekapi = new Rekapi(); 920 | rekapi.addActor() 921 | .keyframe(0, { x: 0 }) 922 | .keyframe(1000, { x: 1 }) 923 | .keyframe(1500, { x: 1500 }); 924 | 925 | component = shallow(); 926 | }); 927 | 928 | describe('timelineScale === 0.5', () => { 929 | beforeEach(() => { 930 | component.setState({ timelineScale: 0.5 }); 931 | }); 932 | 933 | describe('basic usage', () => { 934 | beforeEach(() => { 935 | component.instance().handlePropertyDrag(250, 'x', 1000); 936 | }); 937 | 938 | it('updates the specified property to have the new scaled millisecond value', () => { 939 | assert(getActor().hasKeyframeAt(500, 'x')); 940 | }); 941 | }); 942 | 943 | describe('property millisecond collisons', () => { 944 | beforeEach(() => { 945 | component.instance().handlePropertyDrag(500, 'x', 750); 946 | }); 947 | 948 | it('does not update the dragged property', () => { 949 | assert(getActor().hasKeyframeAt(1500, 'x')); 950 | }); 951 | }); 952 | }); 953 | 954 | describe('timelineScale === 1', () => { 955 | beforeEach(() => { 956 | component.setState({ timelineScale: 1 }); 957 | }); 958 | 959 | describe('basic usage', () => { 960 | beforeEach(() => { 961 | component.instance().handlePropertyDrag(500, 'x', 1000); 962 | }); 963 | 964 | it('updates the specified property to have the new millisecond value', () => { 965 | assert(getActor().hasKeyframeAt(500, 'x')); 966 | }); 967 | }); 968 | 969 | describe('property millisecond collisons', () => { 970 | beforeEach(() => { 971 | component.instance().handlePropertyDrag(1000, 'x', 1500); 972 | }); 973 | 974 | it('does not update the dragged property', () => { 975 | assert(getActor().hasKeyframeAt(1500, 'x')); 976 | }); 977 | }); 978 | }); 979 | 980 | describe('timelineScale === 2', () => { 981 | beforeEach(() => { 982 | component.setState({ timelineScale: 2 }); 983 | }); 984 | 985 | describe('basic usage', () => { 986 | beforeEach(() => { 987 | component.instance().handlePropertyDrag(1000, 'x', 1000); 988 | }); 989 | 990 | it('updates the specified property to have the new scaled millisecond value', () => { 991 | assert(getActor().hasKeyframeAt(500, 'x')); 992 | }); 993 | }); 994 | 995 | describe('property millisecond collisons', () => { 996 | beforeEach(() => { 997 | component.instance().handlePropertyDrag(2000, 'x', 3000); 998 | }); 999 | 1000 | it('does not update the dragged property', () => { 1001 | assert(getActor().hasKeyframeAt(1500, 'x')); 1002 | }); 1003 | }); 1004 | }); 1005 | 1006 | describe('propertyCursor updating', () => { 1007 | beforeEach(() => { 1008 | component.setState({ timelineScale: 1 }); 1009 | component.instance().handlePropertyDrag(500, 'x', 1000); 1010 | }); 1011 | 1012 | it('updates propertyCursor', () => { 1013 | assert.deepEqual( 1014 | component.state().propertyCursor, 1015 | { property: 'x', millisecond: 500 } 1016 | ); 1017 | }); 1018 | }); 1019 | }); 1020 | 1021 | describe('handlePropertyClick', () => { 1022 | beforeEach(() => { 1023 | rekapi = new Rekapi(); 1024 | rekapi.addActor().keyframe(0, { x: 0 }).keyframe(1000, { x: 1 }); 1025 | component = shallow(); 1026 | component.instance().handlePropertyClick(getPropertyTracks().x[1]); 1027 | }); 1028 | 1029 | it('updates propertyCursor', () => { 1030 | assert.deepEqual( 1031 | component.state().propertyCursor, 1032 | { property: 'x', millisecond: 1000 } 1033 | ); 1034 | }); 1035 | }); 1036 | 1037 | describe('handlePropertyTrackDoubleClick', () => { 1038 | describe('with no prior properties', () => { 1039 | beforeEach(() => { 1040 | rekapi = new Rekapi(); 1041 | rekapi.addActor().keyframe(0, { x: 1 }).keyframe(1000, { x: 2 }); 1042 | component = shallow(); 1043 | 1044 | const e = { nativeEvent: { offsetX: 500 } }; 1045 | 1046 | component.setState({ timelineScale: 1 }); 1047 | component.instance().handlePropertyTrackDoubleClick(e, 'x'); 1048 | }); 1049 | 1050 | it('creates a new property at the specified location', () => { 1051 | assert(getActor().hasKeyframeAt(500, 'x')); 1052 | }); 1053 | 1054 | it('creates a new property with the previous property\'s value', () => { 1055 | assert.equal(getActor().getKeyframeProperty('x', 500).value, 1); 1056 | }); 1057 | 1058 | it('updates the propertyCursor', () => { 1059 | assert.deepEqual( 1060 | component.state().propertyCursor, 1061 | { property: 'x', millisecond: 500 } 1062 | ); 1063 | }); 1064 | }); 1065 | 1066 | describe('with no prior properties', () => { 1067 | beforeEach(() => { 1068 | rekapi = new Rekapi(); 1069 | rekapi.addActor().keyframe(750, { x: 2 }).keyframe(1000, { x: 3 }); 1070 | component = shallow(); 1071 | 1072 | const e = { nativeEvent: { offsetX: 500 } }; 1073 | 1074 | component.setState({ timelineScale: 1 }); 1075 | component.instance().handlePropertyTrackDoubleClick(e, 'x'); 1076 | }); 1077 | 1078 | it('uses the value from the first property in the track', () => { 1079 | assert.equal(getActor().getKeyframeProperty('x', 500).value, 2); 1080 | }); 1081 | }); 1082 | }); 1083 | 1084 | describe('handleChangeNewTrackName', () => { 1085 | beforeEach(() => { 1086 | component = shallow(); 1087 | component.instance().handleChangeNewTrackName({ target: { value: 'foo' } }); 1088 | }); 1089 | 1090 | it('sets the newTrackName state', () => { 1091 | assert.equal(component.state().newTrackName, 'foo'); 1092 | }); 1093 | }); 1094 | 1095 | describe('handleKeyDownNewTrackName', () => { 1096 | beforeEach(() => { 1097 | rekapi = new Rekapi(); 1098 | rekapi.addActor(); 1099 | component = shallow(); 1100 | component.setState({ newTrackName: 'x' }); 1101 | }); 1102 | 1103 | describe('user pressed enter key', () => { 1104 | beforeEach(() => { 1105 | component.instance().handleKeyDownNewTrackName({ 1106 | nativeEvent: { keyCode: 13 } 1107 | }); 1108 | }); 1109 | 1110 | it('adds the track name specified by newTrackName', () => { 1111 | assert(getActor().hasKeyframeAt(0, 'x')); 1112 | }); 1113 | }); 1114 | 1115 | describe('user pressed any other key', () => { 1116 | beforeEach(() => { 1117 | component.instance().handleKeyDownNewTrackName({ 1118 | nativeEvent: { keyCode: 42 } 1119 | }); 1120 | }); 1121 | 1122 | it('does nothing', () => { 1123 | assert(!getActor().hasKeyframeAt(0, 'x')); 1124 | }); 1125 | }); 1126 | }); 1127 | 1128 | describe('handleClickNewTrackButton', () => { 1129 | beforeEach(() => { 1130 | rekapi = new Rekapi(); 1131 | rekapi.addActor(); 1132 | component = shallow(); 1133 | component.setState({ newTrackName: 'x' }); 1134 | }); 1135 | 1136 | describe('specified track does not exist', () => { 1137 | beforeEach(() => { 1138 | component.instance().handleClickNewTrackButton(); 1139 | }); 1140 | 1141 | it('adds the track name specified by newTrackName', () => { 1142 | assert(getActor().hasKeyframeAt(0, 'x')); 1143 | }); 1144 | 1145 | it('gives the new property a default value', () => { 1146 | assert.equal(getActor().getKeyframeProperty('x', 0).value, 0); 1147 | }); 1148 | }); 1149 | 1150 | describe('specified track does exist', () => { 1151 | beforeEach(() => { 1152 | getActor().keyframe(1000, { x: 5 }); 1153 | component.instance().handleClickNewTrackButton(); 1154 | }); 1155 | 1156 | it('does not add new property', () => { 1157 | assert(!getActor().hasKeyframeAt(0, 'x')); 1158 | }); 1159 | }); 1160 | }); 1161 | }); 1162 | 1163 | describe('', () => { 1164 | beforeEach(() => { 1165 | component = shallow(); 1166 | }); 1167 | 1168 | it('is a react component', () => { 1169 | assert.equal(component.length, 1); 1170 | }); 1171 | 1172 | describe('props', () => { 1173 | describe('rekapi', () => { 1174 | beforeEach(() => { 1175 | rekapi = new Rekapi(); 1176 | component = mount(); 1177 | }); 1178 | 1179 | it('accepts and stores a rekapi', () => { 1180 | assert(component.props().rekapi instanceof Rekapi); 1181 | }); 1182 | 1183 | describe('timeline modification', () => { 1184 | beforeEach(() => { 1185 | rekapi.addActor().keyframe(0, { x: 0 }); 1186 | }); 1187 | 1188 | it('updates rekapi state when rekapi prop is modified', () => { 1189 | assert.deepEqual(component.state().rekapi, rekapi.exportTimeline(exportTimelineOptions)); 1190 | }); 1191 | }); 1192 | }); 1193 | }); 1194 | 1195 | describe('state', () => { 1196 | describe('rekapi', () => { 1197 | beforeEach(() => { 1198 | rekapi = new Rekapi(); 1199 | component = mount(); 1200 | }); 1201 | 1202 | it('is a basic rekapi export by default', () => { 1203 | assert.deepEqual(component.state().rekapi, rekapi.exportTimeline(exportTimelineOptions)); 1204 | }); 1205 | }); 1206 | 1207 | describe('actor', () => { 1208 | beforeEach(() => { 1209 | rekapi = new Rekapi(); 1210 | rekapi.addActor(); 1211 | component = mount(); 1212 | }); 1213 | 1214 | it('is a basic actor export by default', () => { 1215 | assert.deepEqual(component.state().actor, getActor().exportTimeline(exportTimelineOptions)); 1216 | }); 1217 | }); 1218 | 1219 | describe('propertyCursor', () => { 1220 | beforeEach(() => { 1221 | rekapi = new Rekapi(); 1222 | component = mount(); 1223 | }); 1224 | 1225 | it('is empty by default', () => { 1226 | assert.deepEqual(component.state().propertyCursor, {}); 1227 | }); 1228 | }); 1229 | 1230 | describe('isPlaying', () => { 1231 | describe('when animation is not playing', () => { 1232 | it('reflects Rekapi#isPlaying', () => { 1233 | assert.equal(component.state().isPlaying, false); 1234 | }); 1235 | }); 1236 | 1237 | describe('when animation is playing', () => { 1238 | beforeEach(() => { 1239 | rekapi = new Rekapi(); 1240 | component = mount(); 1241 | 1242 | rekapi.play(); 1243 | }); 1244 | 1245 | afterEach(() => { 1246 | rekapi.stop(); 1247 | }); 1248 | 1249 | it('reflects Rekapi#isPlaying', () => { 1250 | assert.equal(component.state().isPlaying, true); 1251 | }); 1252 | }); 1253 | }); 1254 | 1255 | describe('timelineScale', () => { 1256 | beforeEach(() => { 1257 | rekapi = new Rekapi(); 1258 | component = mount(); 1259 | }); 1260 | 1261 | it('gets a default value', () => { 1262 | assert.equal(component.state().timelineScale, defaultTimelineScale); 1263 | }); 1264 | }); 1265 | 1266 | describe('timelineScale', () => { 1267 | beforeEach(() => { 1268 | rekapi = new Rekapi(); 1269 | component = mount(); 1270 | }); 1271 | 1272 | it('gets a default value', () => { 1273 | assert.equal(component.state().newTrackName, 'newTrack'); 1274 | }); 1275 | }); 1276 | 1277 | describe('currentPosition', () => { 1278 | beforeEach(() => { 1279 | rekapi = new Rekapi(); 1280 | component = mount(); 1281 | 1282 | rekapi.addActor().keyframe(1000, { x: 1 }); 1283 | }); 1284 | 1285 | it('reflects the current animation position', () => { 1286 | assert.equal(component.state().currentPosition, 0); 1287 | }); 1288 | 1289 | describe('position changes', () => { 1290 | beforeEach(() => { 1291 | rekapi.update(500); 1292 | }); 1293 | 1294 | it('reflects the animation position changed', () => { 1295 | assert.equal(component.state().currentPosition, .5); 1296 | }); 1297 | }); 1298 | }); 1299 | 1300 | describe('animationLength', () => { 1301 | let actor; 1302 | beforeEach(() => { 1303 | rekapi = new Rekapi(); 1304 | component = mount(); 1305 | 1306 | actor = rekapi.addActor().keyframe(1000, { x: 1 }); 1307 | }); 1308 | 1309 | it('reflects the animation length', () => { 1310 | assert.equal(component.state().animationLength, 1000); 1311 | }); 1312 | 1313 | describe('timeline changes', () => { 1314 | beforeEach(() => { 1315 | actor.keyframe(2000, { x: 2 }); 1316 | }); 1317 | 1318 | it('reflects animation length changes', () => { 1319 | assert.equal(component.state().animationLength, 2000); 1320 | }); 1321 | }); 1322 | }); 1323 | }); 1324 | 1325 | describe('RekapiTimeline#updateEasingList', () => { 1326 | beforeEach(() => { 1327 | setBezierFunction('testCurve', 0, 0, 0, 0); 1328 | component.instance().updateEasingList(); 1329 | }); 1330 | 1331 | afterEach(() => { 1332 | unsetBezierFunction('testCurve'); 1333 | }); 1334 | 1335 | it('is a function', () => { 1336 | assert(component.instance().updateEasingList instanceof Function); 1337 | }); 1338 | 1339 | it('reflects changes to Tweenable.formulas', () => { 1340 | assert(component.state().easingCurves.indexOf('testCurve') > -1); 1341 | }); 1342 | }); 1343 | 1344 | describe('RekapiTimeline#onRekapiTimelineModified', () => { 1345 | beforeEach(() => { 1346 | rekapi = new Rekapi(); 1347 | rekapi.addActor().keyframe(0, { x: 0 }).keyframe(1000, { x: 1 }); 1348 | component = shallow(); 1349 | }); 1350 | 1351 | describe('timeline syncing', () => { 1352 | beforeEach(() => { 1353 | getActor().modifyKeyframeProperty('x', 1000, { value: 5 }); 1354 | }); 1355 | 1356 | it('updates the timeline', () => { 1357 | assert.deepEqual( 1358 | component.state().rekapi, 1359 | rekapi.exportTimeline(exportTimelineOptions) 1360 | ); 1361 | }); 1362 | }); 1363 | 1364 | describe('scrubber syncing', () => { 1365 | describe('when timeline has been shortened to less than previous update', () => { 1366 | beforeEach(() => { 1367 | rekapi.update(950); 1368 | getActor().modifyKeyframeProperty('x', 1000, { millisecond: 500 }); 1369 | }); 1370 | 1371 | it('updates to the end of the animation', () => { 1372 | assert.equal(rekapi.getLastPositionUpdated(), 1); 1373 | }); 1374 | }); 1375 | }); 1376 | }); 1377 | }); 1378 | 1379 | describe('
', () => { 1380 | beforeEach(() => { 1381 | component = mount(
); 1382 | }); 1383 | 1384 | it('is a react component', () => { 1385 | assert.equal(component.length, 1); 1386 | }); 1387 | 1388 | describe('title', () => { 1389 | let title; 1390 | describe('no keyframe property focused', () => { 1391 | it('has default content', () => { 1392 | title = component.find('.keyframe-property-name'); 1393 | assert.equal(title.text(), 'Details'); 1394 | }); 1395 | }); 1396 | 1397 | describe('with keyframe property focused', () => { 1398 | beforeEach(() => { 1399 | component = mount(
); 1400 | }); 1401 | 1402 | it('displays the property name', () => { 1403 | title = component.find('.keyframe-property-name'); 1404 | assert.equal(title.text(), basicProperty1.name); 1405 | }); 1406 | }); 1407 | }); 1408 | 1409 | describe('add button', () => { 1410 | let handleAddKeyframeButtonClick; 1411 | 1412 | beforeEach(() => { 1413 | handleAddKeyframeButtonClick = sinon.spy(); 1414 | component = mount( 1415 |
1416 | ); 1417 | 1418 | component.find('.icon-button.add').simulate('click'); 1419 | }); 1420 | 1421 | it('fires handleAddKeyframeButtonClick', () => { 1422 | assert(handleAddKeyframeButtonClick.called); 1423 | }); 1424 | }); 1425 | 1426 | describe('delete button', () => { 1427 | let handleDeleteKeyframeButtonClick; 1428 | 1429 | beforeEach(() => { 1430 | handleDeleteKeyframeButtonClick = sinon.spy(); 1431 | component = mount( 1432 |
1433 | ); 1434 | 1435 | component.find('.icon-button.delete').simulate('click'); 1436 | }); 1437 | 1438 | it('fires handleDeleteKeyframeButtonClick', () => { 1439 | assert(handleDeleteKeyframeButtonClick.called); 1440 | }); 1441 | }); 1442 | 1443 | describe('millisecond field', () => { 1444 | let millisecondInput; 1445 | describe('no keyframe property focused', () => { 1446 | it('has default content', () => { 1447 | millisecondInput = component.find('.property-millisecond'); 1448 | assert.strictEqual(millisecondInput.props().value, ''); 1449 | }); 1450 | }); 1451 | 1452 | describe('with keyframe property focused', () => { 1453 | beforeEach(() => { 1454 | component = mount(
); 1455 | }); 1456 | 1457 | it('displays the property millisecond', () => { 1458 | millisecondInput = component.find('.property-millisecond'); 1459 | assert.strictEqual(millisecondInput.props().value, basicProperty1.millisecond); 1460 | }); 1461 | }); 1462 | 1463 | describe('handleMillisecondInputChange', () => { 1464 | let handleMillisecondInputChange; 1465 | beforeEach(() => { 1466 | handleMillisecondInputChange = sinon.spy(); 1467 | 1468 | component = mount( 1469 |
1470 | ); 1471 | 1472 | millisecondInput = component.find('.property-millisecond'); 1473 | millisecondInput.simulate('change', { target: { value: 5 } }); 1474 | }); 1475 | 1476 | it('fires handleMillisecondInputChange', () => { 1477 | assert(handleMillisecondInputChange.called); 1478 | }); 1479 | }); 1480 | }); 1481 | 1482 | describe('value field', () => { 1483 | let valueInput; 1484 | describe('no keyframe property focused', () => { 1485 | it('has default content', () => { 1486 | valueInput = component.find('.property-value'); 1487 | assert.strictEqual(valueInput.props().value, ''); 1488 | }); 1489 | }); 1490 | 1491 | describe('with keyframe property focused', () => { 1492 | beforeEach(() => { 1493 | component = mount(
); 1494 | }); 1495 | 1496 | it('displays the property value', () => { 1497 | valueInput = component.find('.property-value'); 1498 | assert.strictEqual(valueInput.props().value, basicProperty1.value); 1499 | }); 1500 | }); 1501 | 1502 | describe('handleValueInputChange', () => { 1503 | let handleValueInputChange; 1504 | beforeEach(() => { 1505 | handleValueInputChange = sinon.spy(); 1506 | 1507 | component = mount( 1508 |
1509 | ); 1510 | 1511 | valueInput = component.find('.property-value'); 1512 | valueInput.simulate('change', { target: { value: '5' } }); 1513 | }); 1514 | 1515 | it('fires handleValueInputChange', () => { 1516 | assert(handleValueInputChange.called); 1517 | }); 1518 | }); 1519 | }); 1520 | 1521 | describe('easing selector', () => { 1522 | let easingCurves; 1523 | 1524 | beforeEach(() => { 1525 | easingCurves = ['ease1', 'ease2', 'ease3']; 1526 | }); 1527 | 1528 | 1529 | describe('no keyframe property focused', () => { 1530 | beforeEach(() => { 1531 | component = mount(
); 1534 | }); 1535 | 1536 | it('does not render a list of easings', () => { 1537 | assert.equal( 1538 | component.find('.keyframe-property-easing select option').length, 1539 | 0 1540 | ); 1541 | }); 1542 | }); 1543 | 1544 | describe('with keyframe property focused', () => { 1545 | beforeEach(() => { 1546 | component = mount(
); 1550 | }); 1551 | 1552 | it('renders a list of easings', () => { 1553 | assert.equal( 1554 | component.find('.keyframe-property-easing select option').length, 1555 | easingCurves.length 1556 | ); 1557 | }); 1558 | 1559 | describe('property has non-default easing', () => { 1560 | beforeEach(() => { 1561 | component = mount(
); 1565 | }); 1566 | 1567 | it('matches the internal state', () => { 1568 | assert.equal( 1569 | component.find('.keyframe-property-easing select').prop('value'), 1570 | 'ease2' 1571 | ); 1572 | }); 1573 | }); 1574 | }); 1575 | 1576 | describe('handleEasingSelectChange', () => { 1577 | let handleEasingSelectChange; 1578 | 1579 | beforeEach(() => { 1580 | handleEasingSelectChange = sinon.spy(); 1581 | component = mount( 1582 |
1583 | ); 1584 | 1585 | let select = component.find('.keyframe-property-easing select'); 1586 | select.simulate('change', { target: { value: 5 } }); 1587 | }); 1588 | 1589 | it('fires handleEasingSelectChange', () => { 1590 | assert(handleEasingSelectChange.called); 1591 | }); 1592 | }); 1593 | }); 1594 | }); 1595 | 1596 | describe('', () => { 1597 | beforeEach(() => { 1598 | component = shallow(); 1599 | }); 1600 | 1601 | it('is a react component', () => { 1602 | assert.equal(component.length, 1); 1603 | }); 1604 | 1605 | describe('timeline wrapper', () => { 1606 | beforeEach(() => { 1607 | component = shallow(); 1608 | }); 1609 | 1610 | describe('width', () => { 1611 | it('is determined by timelineWrapperWidth prop', () => { 1612 | assert.equal( 1613 | component.find('.timeline-wrapper').props().style.width, 1614 | 'calc(100% + 1000px)' 1615 | ); 1616 | }); 1617 | }); 1618 | }); 1619 | 1620 | describe('scrubber wrapper', () => { 1621 | beforeEach(() => { 1622 | component = mount(); 1623 | }); 1624 | 1625 | describe('width', () => { 1626 | it('is determined by timelineWrapperWidth prop', () => { 1627 | assert.equal( 1628 | component.find('.scrubber-wrapper').props().style.width, 1629 | 1000 1630 | ); 1631 | }); 1632 | }); 1633 | }); 1634 | 1635 | describe('scrubber position', () => { 1636 | beforeEach(() => { 1637 | component = mount(); 1638 | }); 1639 | 1640 | describe('draggable position', () => { 1641 | it('is determined by scrubberPosition prop', () => { 1642 | assert.equal( 1643 | component.find('Draggable').props().position.x, 1644 | 500 1645 | ); 1646 | }); 1647 | }); 1648 | }); 1649 | 1650 | describe('scrubber guide', () => { 1651 | describe('dynamic styling', () => { 1652 | beforeEach(() => { 1653 | rekapi = new Rekapi(); 1654 | rekapi.addActor().keyframe(0, { x: 0, y: 0, z: 0}); 1655 | component = mount(); 1658 | }); 1659 | 1660 | it('has the correct height', () => { 1661 | assert.equal( 1662 | component.find('.scrubber-guide').props().style.height, 1663 | propertyTrackHeight * 3 1664 | ); 1665 | }); 1666 | }); 1667 | }); 1668 | 1669 | describe('property tracks', () => { 1670 | beforeEach(() => { 1671 | rekapi = new Rekapi(); 1672 | rekapi.addActor().keyframe(0, { x: 0, y: 0 }); 1673 | component = mount( 1674 | 1677 | ); 1678 | }); 1679 | 1680 | it('renders all keyframe tracks', () => { 1681 | assert.equal(component.find('.keyframe-property-track').length, 2); 1682 | }); 1683 | }); 1684 | 1685 | describe('keyframe properties', () => { 1686 | describe('property wrapper', () => { 1687 | beforeEach(() => { 1688 | rekapi = new Rekapi(); 1689 | rekapi.addActor().keyframe(0, { x: 0 }).keyframe(1000, { x: 1 }); 1690 | component = mount( 1691 | 1694 | ); 1695 | }); 1696 | 1697 | it('renders all .keyframe-property-wrapper elements', () => { 1698 | assert.equal(component.find('.keyframe-property-wrapper').length, 2); 1699 | }); 1700 | }); 1701 | 1702 | describe('property element', () => { 1703 | beforeEach(() => { 1704 | rekapi = new Rekapi(); 1705 | rekapi.addActor().keyframe(0, { x: 0 }).keyframe(1000, { x: 1 }); 1706 | component = mount( 1707 | 1710 | ); 1711 | }); 1712 | 1713 | it('renders all .keyframe-property elements', () => { 1714 | assert.equal( 1715 | component.find('.keyframe-property-wrapper .keyframe-property').length, 1716 | 2 1717 | ); 1718 | }); 1719 | }); 1720 | }); 1721 | 1722 | describe('active classes', () => { 1723 | beforeEach(() => { 1724 | rekapi = new Rekapi(); 1725 | rekapi.addActor() 1726 | .keyframe(0, { x: 0, y: 1 }) 1727 | .keyframe(1000, { x: 2, y: 3 }); 1728 | 1729 | component = mount( 1730 | 1734 | ); 1735 | }); 1736 | 1737 | describe('active property track', () => { 1738 | it('gives active class to active property track', () => { 1739 | assert( 1740 | component.find( 1741 | '.keyframe-property-track' 1742 | ).get(1).props.className.includes('active') 1743 | ); 1744 | }); 1745 | 1746 | it('does not give active class to inactive property track', () => { 1747 | assert( 1748 | !component.find( 1749 | '.keyframe-property-track' 1750 | ).get(0).props.className.includes('active') 1751 | ); 1752 | }); 1753 | }); 1754 | 1755 | describe('active property', () => { 1756 | it('gives active class to active property', () => { 1757 | assert( 1758 | component.find( 1759 | '[data-track-name="y"] .keyframe-property-wrapper' 1760 | ).get(1).props.className.includes('active') 1761 | ); 1762 | }); 1763 | 1764 | it('does not give active class to inactive property track', () => { 1765 | assert( 1766 | !component.find( 1767 | '[data-track-name="y"] .keyframe-property-wrapper' 1768 | ).get(0).props.className.includes('active') 1769 | ); 1770 | }); 1771 | }); 1772 | }); 1773 | }); 1774 | 1775 | describe('', () => { 1776 | beforeEach(() => { 1777 | component = mount(); 1778 | }); 1779 | 1780 | it('is a react component', () => { 1781 | assert.equal(component.length, 1); 1782 | }); 1783 | 1784 | describe('control bar', () => { 1785 | it('is a react component', () => { 1786 | assert(component.find('.control-bar').length); 1787 | }); 1788 | 1789 | describe('play button', () => { 1790 | it('is a react component', () => { 1791 | assert(component.find('.control-bar .play').length); 1792 | }); 1793 | 1794 | describe('handlePlayButtonClick', () => { 1795 | let handlePlayButtonClick; 1796 | 1797 | beforeEach(() => { 1798 | handlePlayButtonClick = sinon.spy(); 1799 | component = mount( 1800 | 1801 | ); 1802 | 1803 | component.find('.control-bar .play').simulate('click'); 1804 | }); 1805 | 1806 | it('fires', () => { 1807 | assert(handlePlayButtonClick.called); 1808 | }); 1809 | }); 1810 | 1811 | describe('when animation is not playing', () => { 1812 | beforeEach(() => { 1813 | component = mount( 1814 | 1815 | ); 1816 | }); 1817 | 1818 | it('is shown', () => { 1819 | assert.equal(component.find('.control-bar .play').length, 1); 1820 | }); 1821 | }); 1822 | 1823 | describe('when animation is playing', () => { 1824 | beforeEach(() => { 1825 | component = mount( 1826 | 1827 | ); 1828 | }); 1829 | 1830 | it('is not shown', () => { 1831 | assert.equal(component.find('.control-bar .play').length, 0); 1832 | }); 1833 | }); 1834 | }); 1835 | 1836 | describe('pause button', () => { 1837 | describe('handlePauseButtonClick', () => { 1838 | let handlePauseButtonClick; 1839 | 1840 | beforeEach(() => { 1841 | handlePauseButtonClick = sinon.spy(); 1842 | component = mount( 1843 | 1847 | ); 1848 | 1849 | component.find('.control-bar .pause').simulate('click'); 1850 | }); 1851 | 1852 | it('fires', () => { 1853 | assert(handlePauseButtonClick.called); 1854 | }); 1855 | }); 1856 | 1857 | describe('when animation is not playing', () => { 1858 | beforeEach(() => { 1859 | component = mount( 1860 | 1861 | ); 1862 | }); 1863 | 1864 | it('is not shown', () => { 1865 | assert.equal(component.find('.control-bar .pause').length, 0); 1866 | }); 1867 | }); 1868 | 1869 | describe('when animation is playing', () => { 1870 | beforeEach(() => { 1871 | component = mount( 1872 | 1873 | ); 1874 | }); 1875 | 1876 | it('is shown', () => { 1877 | assert.equal(component.find('.control-bar .pause').length, 1); 1878 | }); 1879 | }); 1880 | }); 1881 | 1882 | describe('stop button', () => { 1883 | it('is a react component', () => { 1884 | assert(component.find('.control-bar .stop').length); 1885 | }); 1886 | 1887 | describe('handleStopButtonClick', () => { 1888 | let handleStopButtonClick; 1889 | 1890 | beforeEach(() => { 1891 | handleStopButtonClick = sinon.spy(); 1892 | component = mount( 1893 | 1894 | ); 1895 | 1896 | component.find('.control-bar .stop').simulate('click'); 1897 | }); 1898 | 1899 | it('fires', () => { 1900 | assert(handleStopButtonClick.called); 1901 | }); 1902 | }); 1903 | }); 1904 | }); 1905 | 1906 | describe('scrubber detail', () => { 1907 | describe('scrubber scale', () => { 1908 | beforeEach(() => { 1909 | component = mount(); 1910 | }); 1911 | 1912 | it('reflects timelineScale prop', () => { 1913 | assert.equal( 1914 | component.find('.scrubber-scale input').props().value, 1915 | String(defaultTimelineScale * 100) 1916 | ); 1917 | }); 1918 | 1919 | describe('handleTimelineScaleChange', () => { 1920 | let handleTimelineScaleChange; 1921 | beforeEach(() => { 1922 | handleTimelineScaleChange = sinon.spy(); 1923 | component = mount( 1924 | 1925 | ); 1926 | 1927 | component.find('.scrubber-scale input') 1928 | .simulate('change', { target: { value: '5' } }); 1929 | }); 1930 | 1931 | it('fires', () => { 1932 | assert(handleTimelineScaleChange.called); 1933 | }); 1934 | }); 1935 | }); 1936 | 1937 | describe('position monitor', () => { 1938 | describe('currentPosition', () => { 1939 | beforeEach(() => { 1940 | component = mount( 1944 | ); 1945 | }); 1946 | 1947 | it('displays the current millisecond', () => { 1948 | assert.equal(component.find('.current-position').text(), '500'); 1949 | }); 1950 | }); 1951 | 1952 | describe('animationLength', () => { 1953 | beforeEach(() => { 1954 | component = mount(); 1955 | }); 1956 | 1957 | it('displays the animationLength', () => { 1958 | assert.equal(component.find('.animation-length').text(), '100'); 1959 | }); 1960 | }); 1961 | }); 1962 | }); 1963 | }); 1964 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import raf from 'raf'; 2 | 3 | // Fixes https://github.com/facebook/jest/issues/4545 4 | raf.polyfill(global); 5 | -------------------------------------------------------------------------------- /webpack.common.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | devtool: 'source-map', 5 | module: { 6 | rules: [ 7 | { 8 | test: /\.js$/, 9 | use: 'babel-loader', 10 | exclude: path.join(__dirname, 'node_modules') 11 | }, { 12 | test: /\.(png|jpg|gif|svg|eot|ttf|woff|woff2)$/, 13 | use: [{ 14 | loader: 'url-loader' 15 | }] 16 | }, { 17 | test: /\.(sass|scss|css)$/, 18 | use: [{ 19 | loader: 'style-loader' 20 | }, { 21 | loader: 'css-loader' 22 | }, { 23 | loader: 'sass-loader', 24 | options: { 25 | sourceMap: true, 26 | includePaths: [ 27 | path.resolve(__dirname, './node_modules/compass-mixins/lib') 28 | ] 29 | } 30 | }] 31 | } 32 | ] 33 | }, 34 | resolve: { 35 | modules: [ 36 | 'node_modules' 37 | ], 38 | alias: { 39 | shifty: path.resolve(__dirname, './node_modules/rekapi/node_modules/shifty/dist/shifty.js') 40 | }, 41 | // Uncomment this when testing symlinked dependencies 42 | //symlinks: false 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const commonConfig = require('./webpack.common.config'); 2 | const path = require('path'); 3 | const Webpack = require('webpack'); 4 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 5 | 6 | const { name, version } = require('./package.json'); 7 | 8 | const dist = 'dist'; 9 | 10 | module.exports = Object.assign(commonConfig, { 11 | entry: { 12 | 'rekapi-timeline': './src/index.js' 13 | }, 14 | output: { 15 | path: path.join(__dirname, `${dist}`), 16 | filename: '[name].js', 17 | library: 'rekapi-timeline', 18 | libraryTarget: 'umd', 19 | umdNamedDefine: true 20 | }, 21 | externals : { 22 | react: { 23 | root: 'React', 24 | commonjs: 'react', 25 | commonjs2: 'react', 26 | amd: 'react' 27 | }, 28 | 'react-dom': { 29 | root: 'ReactDOM', 30 | commonjs: 'react-dom', 31 | commonjs2: 'react-dom', 32 | amd: 'react-dom' 33 | }, 34 | 'react-draggable': { 35 | root: 'ReactDraggable', 36 | commonjs: 'react-draggable', 37 | commonjs2: 'react-draggable', 38 | amd: 'react-draggable' 39 | }, 40 | rekapi: 'rekapi', 41 | shifty: 'shifty' 42 | }, 43 | plugins: [ 44 | new CleanWebpackPlugin([ dist ]), 45 | new Webpack.DefinePlugin({ 46 | 'process.env': { 47 | NODE_ENV: JSON.stringify('production') 48 | } 49 | }), 50 | new Webpack.optimize.UglifyJsPlugin({ 51 | compress: { 52 | dead_code: true, 53 | unused: true 54 | }, 55 | output: { 56 | comments: false 57 | } 58 | }), 59 | new Webpack.BannerPlugin(version) 60 | ] 61 | }); 62 | -------------------------------------------------------------------------------- /webpack.extras.config.js: -------------------------------------------------------------------------------- 1 | const commonConfig = require('./webpack.common.config'); 2 | const path = require('path'); 3 | const Webpack = require('webpack'); 4 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 5 | 6 | const { name, version } = require('./package.json'); 7 | 8 | const dist = 'dist'; 9 | 10 | module.exports = Object.assign(commonConfig, { 11 | entry: { 12 | demo: './demo/index.js' 13 | }, 14 | output: { 15 | path: path.join(__dirname, `${dist}`), 16 | filename: '[name].js', 17 | library: 'rekapi-timeline', 18 | libraryTarget: 'umd', 19 | umdNamedDefine: true 20 | }, 21 | plugins: [ 22 | new Webpack.optimize.UglifyJsPlugin({ 23 | compress: { 24 | dead_code: true, 25 | unused: true 26 | }, 27 | output: { 28 | comments: false 29 | } 30 | }), 31 | new CopyWebpackPlugin([ 32 | { from: 'index.html' } 33 | ]) 34 | ] 35 | }); 36 | -------------------------------------------------------------------------------- /webpack.test.config.js: -------------------------------------------------------------------------------- 1 | const commonConfig = require('./webpack.common.config'); 2 | const path = require('path'); 3 | const DashboardPlugin = require('webpack-dashboard/plugin'); 4 | 5 | module.exports = Object.assign(commonConfig, { 6 | entry: { 7 | tests: './test/index.js', 8 | demo: './demo/index.js' 9 | }, 10 | output: { 11 | path: path.join(__dirname, 'dist'), 12 | publicPath: '/', 13 | filename: '[name].js' 14 | }, 15 | devtool: 'source-map', 16 | devServer: { 17 | port: 9123 18 | }, 19 | plugins: [ 20 | new DashboardPlugin() 21 | ] 22 | }); 23 | --------------------------------------------------------------------------------