├── .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 | 
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 |
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 |
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 |
11 |
12 |
13 |
14 | const ControlBar = ({
15 | handlePlayButtonClick,
16 | handlePauseButtonClick,
17 | handleStopButtonClick,
18 | isPlaying
19 | }) =>
20 |
21 | {isPlaying ?
22 | :
26 |
30 | }
31 |
35 |
36 |
37 | const ScrubberScale = ({
38 | timelineScale = 0,
39 | handleTimelineScaleChange = () => {}
40 | }) =>
41 |
42 | Timeline zoom:
43 |
50 |
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 |
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 |
14 |
15 |
16 |
17 | const DeleteButton = ({ handleDeleteKeyframeButtonClick }) =>
18 |
23 |
24 |
25 |
26 | const MillisecondInput = ({
27 | handleMillisecondInputChange,
28 | millisecond = ''
29 | }) =>
30 |
31 | Millisecond:
32 |
40 |
41 |
42 | const ValueInput = ({
43 | handleValueInputChange = () => {},
44 | value = ''
45 | }) =>
46 |
47 | Value:
48 |
55 |
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 |
65 | Easing:
66 |
71 | {easing && easingCurves.map(
72 | easingCurve => {easingCurve}
73 | )}
74 |
75 |
76 |
77 | const Details = ({
78 | easingCurves = [],
79 | handleAddKeyframeButtonClick,
80 | handleDeleteKeyframeButtonClick,
81 | handleEasingSelectChange,
82 | handleMillisecondInputChange,
83 | handleValueInputChange,
84 | keyframeProperty = {}
85 | }) => (
86 |
87 |
88 |
89 |
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 |
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 |
148 |
149 |
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 |
--------------------------------------------------------------------------------