/
motion.coffee.erb
652 lines (523 loc) · 24.7 KB
/
motion.coffee.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
###**
Animation
=========
Whenever you [update a page fragment](/up.link) you can animate the change.
Let's say you are using an [`up-target`](/a-up-target) link to update an element
with content from the server. You can add an attribute [`up-transition`](/a-up-target#up-transition)
to smoothly fade out the old element while fading in the new element:
<a href="/users" up-target=".list" up-transition="cross-fade">Show users</a>
\#\#\# Transitions vs. animations
When we morph between an old and a new element, we call it a *transition*.
In contrast, when we animate a new element without simultaneously removing an
old element, we call it an *animation*.
An example for an animation is opening a new dialog. We can animate the appearance
of the dialog by adding an [`[up-animation]`](/a-up-modal#up-animation) attribute to the opening link:
<a href="/users" up-modal=".list" up-animation="move-from-top">Show users</a>
\#\#\# Which animations are available?
Unpoly ships with a number of [predefined transitions](/up.morph#named-transitions)
and [predefined animations](/up.animate#named-animations).
You can define custom animations using [`up.transition()`](/up.transition) and
[`up.animation()`](/up.animation).
@class up.motion
###
up.motion = (($) ->
u = up.util
namedAnimations = {}
defaultNamedAnimations = {}
namedTransitions = {}
defaultNamedTransitions = {}
motionTracker = new up.MotionTracker('motion')
###**
Sets default options for animations and transitions.
@property up.motion.config
@param {number} [config.duration=300]
The default duration for all animations and transitions (in milliseconds).
@param {number} [config.delay=0]
The default delay for all animations and transitions (in milliseconds).
@param {string} [config.easing='ease']
The default timing function that controls the acceleration of animations and transitions.
See [W3C documentation](http://www.w3.org/TR/css3-transitions/#transition-timing-function)
for a list of pre-defined timing functions.
@param {boolean} [config.enabled=true]
Whether animation is enabled.
Set this to `false` to disable animation globally.
This can be useful in full-stack integration tests like a Selenium test suite.
Regardless of this setting, all animations will be skipped on browsers
that do not support [CSS transitions](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Transitions/Using_CSS_transitions).
@stable
###
config = u.config
duration: 300
delay: 0
easing: 'ease'
enabled: true
reset = ->
motionTracker.reset()
namedAnimations = u.copy(defaultNamedAnimations)
namedTransitions = u.copy(defaultNamedTransitions)
config.reset()
###**
Returns whether Unpoly will perform animations.
Set [`up.motion.config.enabled`](/up.motion.config) `false` in order to disable animations globally.
@function up.motion.isEnabled
@return {boolean}
@stable
###
isEnabled = ->
config.enabled
###**
Applies the given animation to the given element.
\#\#\# Example
up.animate('.warning', 'fade-in');
You can pass additional options:
up.animate('warning', '.fade-in', {
delay: 1000,
duration: 250,
easing: 'linear'
});
\#\#\# Named animations
The following animations are pre-defined:
| `fade-in` | Changes the element's opacity from 0% to 100% |
| `fade-out` | Changes the element's opacity from 100% to 0% |
| `move-to-top` | Moves the element upwards until it exits the screen at the top edge |
| `move-from-top` | Moves the element downwards from beyond the top edge of the screen until it reaches its current position |
| `move-to-bottom` | Moves the element downwards until it exits the screen at the bottom edge |
| `move-from-bottom` | Moves the element upwards from beyond the bottom edge of the screen until it reaches its current position |
| `move-to-left` | Moves the element leftwards until it exists the screen at the left edge |
| `move-from-left` | Moves the element rightwards from beyond the left edge of the screen until it reaches its current position |
| `move-to-right` | Moves the element rightwards until it exists the screen at the right edge |
| `move-from-right` | Moves the element leftwards from beyond the right edge of the screen until it reaches its current position |
| `none` | An animation that has no visible effect. Sounds useless at first, but can save you a lot of `if` statements. |
You can define additional named animations using [`up.animation()`](/up.animation).
\#\#\# Animating CSS properties directly
By passing an object instead of an animation name, you can animate
the CSS properties of the given element:
var $warning = $('.warning');
$warning.css({ opacity: 0 });
up.animate($warning, { opacity: 1 });
\#\#\# Multiple animations on the same element
Unpoly doesn't allow more than one concurrent animation on the same element.
If you attempt to animate an element that is already being animated,
the previous animation will instantly jump to its last frame before
the new animation begins.
@function up.animate
@param {Element|jQuery|string} elementOrSelector
The element to animate.
@param {string|Function|Object} animation
Can either be:
- The animation's name
- A function performing the animation
- An object of CSS attributes describing the last frame of the animation
@param {number} [options.duration=300]
The duration of the animation, in milliseconds.
@param {number} [options.delay=0]
The delay before the animation starts, in milliseconds.
@param {string} [options.easing='ease']
The timing function that controls the animation's acceleration.
See [W3C documentation](http://www.w3.org/TR/css3-transitions/#transition-timing-function)
for a list of pre-defined timing functions.
@return {Promise}
A promise for the animation's end.
@stable
###
animate = (elementOrSelector, animation, options) ->
$element = $(elementOrSelector)
options = animateOptions(options)
animationFn = findAnimationFn(animation)
willRun = willAnimate($element, animation, options)
if willRun
runNow = -> animationFn($element, options)
motionTracker.claim($element, runNow, options)
else
skipAnimate($element, animation)
willAnimate = ($elements, animationOrTransition, options) ->
options = animateOptions(options)
isEnabled() && !isNone(animationOrTransition) && options.duration > 0 && !isSingletonElement($elements)
isSingletonElement = ($element) ->
# jQuery's is() returns true if at least one element in the collection matches the selector
$element.is('body')
skipAnimate = ($element, animation) ->
if u.isOptions(animation)
# If we are given the final animation frame as an object of CSS properties,
# the best we can do is to set the final frame without animation.
u.writeInlineStyle($element, animation)
# Signal that the animation is already done.
Promise.resolve()
animCount = 0
###**
Animates the given element's CSS properties using CSS transitions.
Does not track the animation, nor does it finishes existing animations
(use `up.motion.animate()` for that). It does, however, listen to the motionTracker's
finish event.
@function animateNow
@param {Element|jQuery|string} elementOrSelector
The element to animate.
@param {Object} lastFrame
The CSS properties that should be transitioned to.
@param {number} [options.duration=300]
The duration of the animation, in milliseconds.
@param {number} [options.delay=0]
The delay before the animation starts, in milliseconds.
@param {string} [options.easing='ease']
The timing function that controls the animation's acceleration.
See [W3C documentation](http://www.w3.org/TR/css3-transitions/#transition-timing-function)
for a list of pre-defined timing functions.
@return {Promise}
A promise that fulfills when the animation ends.
@internal
###
animateNow = ($element, lastFrame, options) ->
options = u.merge(options, finishEvent: motionTracker.finishEvent)
cssTransition = new up.CssTransition($element, lastFrame, options)
return cssTransition.start()
###**
Extracts animation-related options from the given options hash.
If `$element` is given, also inspects the element for animation-related
attributes like `up-easing` or `up-duration`.
@function up.motion.animateOptions
@internal
###
animateOptions = (args...) ->
userOptions = args.shift() || {}
$element = if u.isJQuery(args[0]) then args.shift() else u.nullJQuery()
moduleDefaults = if u.isObject(args[0]) then args.shift() else {}
consolidatedOptions = {}
consolidatedOptions.easing = u.option(userOptions.easing, u.presentAttr($element, 'up-easing'), moduleDefaults.easing, config.easing)
consolidatedOptions.duration = Number(u.option(userOptions.duration, u.presentAttr($element, 'up-duration'), moduleDefaults.duration, config.duration))
consolidatedOptions.delay = Number(u.option(userOptions.delay, u.presentAttr($element, 'up-delay'), moduleDefaults.delay, config.delay))
consolidatedOptions.trackMotion = userOptions.trackMotion # required by up.MotionTracker
consolidatedOptions
findNamedAnimation = (name) ->
namedAnimations[name] or up.fail("Unknown animation %o", name)
###**
Completes [animations](/up.animate) and [transitions](/up.morph).
If called without arguments, all animations on the screen are completed.
If given an element (or selector), animations on that element and its children
are completed.
Animations are completed by jumping to the last animation frame instantly.
Does nothing if there are no animation to complete.
@function up.motion.finish
@param {Element|jQuery|string} [elementOrSelector]
@return {Promise}
A promise that fulfills when animations and transitions have finished.
@stable
###
finish = (elementOrSelector) ->
motionTracker.finish(elementOrSelector)
###**
Performs an animated transition between the `source` and `target` elements.
Transitions are implement by performing two animations in parallel,
causing `source` to disappear and the `target` to appear.
- `target` is [inserted before](https://developer.mozilla.org/en-US/docs/Web/API/Node/insertBefore) `source`
- `source` is removed from the [document flow](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Positioning) with `position: absolute`.
It will be positioned over its original place in the flow that is now occupied by `target`.
- Both `source` and `target` are animated in parallel
- `source` is removed from the DOM
\#\#\# Named transitions
The following transitions are pre-defined:
| `cross-fade` | Fades out the first element. Simultaneously fades in the second element. |
| `move-up` | Moves the first element upwards until it exits the screen at the top edge. Simultaneously moves the second element upwards from beyond the bottom edge of the screen until it reaches its current position. |
| `move-down` | Moves the first element downwards until it exits the screen at the bottom edge. Simultaneously moves the second element downwards from beyond the top edge of the screen until it reaches its current position. |
| `move-left` | Moves the first element leftwards until it exists the screen at the left edge. Simultaneously moves the second element leftwards from beyond the right edge of the screen until it reaches its current position. |
| `move-right` | Moves the first element rightwards until it exists the screen at the right edge. Simultaneously moves the second element rightwards from beyond the left edge of the screen until it reaches its current position. |
| `none` | A transition that has no visible effect. Sounds useless at first, but can save you a lot of `if` statements. |
You can define additional named transitions using [`up.transition()`](/up.transition).
You can also compose a transition from two [named animations](/named-animations).
separated by a slash character (`/`):
- `move-to-bottom/fade-in`
- `move-to-left/move-from-top`
\#\#\# Implementation details
During a transition both the old and new element occupy
the same position on the screen.
Since the CSS layout flow will usually not allow two elements to
overlay the same space, Unpoly:
- The old and new elements are cloned
- The old element is removed from the layout flow using `display: hidden`
- The new element is hidden, but still leaves space in the layout flow by setting `visibility: hidden`
- The clones are [absolutely positioned](https://developer.mozilla.org/en-US/docs/Web/CSS/position#Absolute_positioning)
over the original elements.
- The transition is applied to the cloned elements.
At no point will the hidden, original elements be animated.
- When the transition has finished, the clones are removed from the DOM and the new element is shown.
The old element remains hidden in the DOM.
@function up.morph
@param {Element|jQuery|string} source
@param {Element|jQuery|string} target
@param {Function|string} transitionOrName
@param {number} [options.duration=300]
The duration of the animation, in milliseconds.
@param {number} [options.delay=0]
The delay before the animation starts, in milliseconds.
@param {string} [options.easing='ease']
The timing function that controls the transition's acceleration.
See [W3C documentation](http://www.w3.org/TR/css3-transitions/#transition-timing-function)
for a list of pre-defined timing functions.
@param {boolean} [options.reveal=false]
Whether to reveal the new element by scrolling its parent viewport.
@return {Promise}
A promise that fulfills when the transition ends.
@experimental
###
morph = (source, target, transitionObject, options) ->
options = u.options(options)
options = u.assign(options, animateOptions(options))
$old = $(source)
$new = $(target)
$both = $old.add($new)
transitionFn = findTransitionFn(transitionObject)
willMorph = willAnimate($old, transitionFn, options)
# Remove callbacks from our options hash in case transitionFn calls morph() recursively.
# If we passed on these callbacks, we might call destructors, events, etc. multiple times.
beforeStart = u.pluckKey(options, 'beforeStart') || u.noop
afterInsert = u.pluckKey(options, 'afterInsert') || u.noop
beforeDetach = u.pluckKey(options, 'beforeDetach') || u.noop
afterDetach = u.pluckKey(options, 'afterDetach') || u.noop
beforeStart()
scrollNew = ->
# Don't animate the scrolling. The { duration } option was meant for the transition.
scrollOptions = u.merge(options, duration: 0)
# Scroll $new into position before we start the enter animation.
up.layout.revealOrRestoreScroll($new, scrollOptions)
if willMorph
if motionTracker.isActive($old) && options.trackMotion is false
return transitionFn($old, $new, options)
up.puts 'Morphing %o to %o with transition %o', $old.get(0), $new.get(0), transitionObject
$viewport = up.layout.viewportOf($old)
scrollTopBeforeReveal = $viewport.scrollTop()
oldRemote = up.layout.absolutize $old,
# Because the insertion will shift elements visually, we must delay insertion
# until absolutize() has measured the bounding box of the old element.
afterMeasure: ->
$new.insertBefore($old)
afterInsert()
trackable = ->
# Scroll $new into position before we start the enter animation.
promise = scrollNew()
promise = promise.then ->
# Since we have scrolled the viewport (containing both $old and $new),
# we must shift the old copy so it looks like it it is still sitting
# in the same position.
scrollTopAfterReveal = $viewport.scrollTop()
oldRemote.moveTop(scrollTopAfterReveal - scrollTopBeforeReveal)
transitionFn($old, $new, options)
promise = promise.then ->
beforeDetach()
$old.detach()
oldRemote.$bounds.remove()
afterDetach()
return promise
motionTracker.claim($both, trackable, options)
else
beforeDetach()
# Swapping the elements directly with replaceWith() will cause
# jQuery to remove all data attributes, which we use to store destructors
swapElementsDirectly($old, $new)
afterInsert()
afterDetach()
promise = scrollNew()
return promise
findTransitionFn = (object) ->
if isNone(object)
undefined
else if u.isFunction(object)
object
else if u.isArray(object)
composeTransitionFn(object...)
else if u.isString(object)
if object.indexOf('/') >= 0 # Compose a transition from two animation names
composeTransitionFn(object.split('/')...)
else if namedTransition = namedTransitions[object]
findTransitionFn(namedTransition)
else
up.fail("Unknown transition %o", object)
composeTransitionFn = (oldAnimation, newAnimation) ->
if isNone(oldAnimation) && isNone(oldAnimation)
# A composition of two null-animations is a null-transform
# and should be skipped.
undefined
else
oldAnimationFn = findAnimationFn(oldAnimation) || u.asyncNoop
newAnimationFn = findAnimationFn(newAnimation) || u.asyncNoop
($old, $new, options) ->
Promise.all([
oldAnimationFn($old, options),
newAnimationFn($new, options)
])
findAnimationFn = (object) ->
if isNone(object)
undefined
else if u.isFunction(object)
object
else if u.isString(object)
findNamedAnimation(object)
else if u.isOptions(object)
($element, options) -> animateNow($element, object, options)
else
up.fail('Unknown animation %o', object)
swapElementsDirectly = ($old, $new) ->
# jQuery will actually let us .insertBefore the new <body> tag,
# but that's probably bad Karma.
$old.replaceWith($new)
###**
Defines a named transition.
Here is the definition of the pre-defined `cross-fade` animation:
up.transition('cross-fade', ($old, $new, options) ->
up.motion.when(
up.animate($old, 'fade-out', options),
up.animate($new, 'fade-in', options)
)
)
It is recommended that your transitions use [`up.animate()`](/up.animate),
passing along the `options` that were passed to you.
If you choose to *not* use `up.animate()` and roll your own
logic instead, your code must honor the following contract:
1. It must honor the options `{ delay, duration, easing }` if given
2. It must *not* remove any of the given elements from the DOM.
3. It returns a promise that is fulfilled when the transition has ended
4. If during the animation an event `up:motion:finish` is emitted on
the given element, the transition instantly jumps to the last frame
and resolves the returned promise.
Calling [`up.animate()`](/up.animate) with an object argument
will take care of all these points.
@function up.transition
@param {string} name
@param {Function} transition
@stable
###
registerTransition = (name, transition) ->
namedTransitions[name] = findTransitionFn(transition)
###**
Defines a named animation.
Here is the definition of the pre-defined `fade-in` animation:
up.animation('fade-in', function($element, options) {
$element.css(opacity: 0);
up.animate($element, { opacity: 1 }, options);
})
It is recommended that your definitions always end by calling
calling [`up.animate()`](/up.animate) with an object argument, passing along
the `options` that were passed to you.
If you choose to *not* use `up.animate()` and roll your own
animation code instead, your code must honor the following contract:
1. It must honor the options `{ delay, duration, easing }` if given
2. It must *not* remove any of the given elements from the DOM.
3. It returns a promise that is fulfilled when the transition has ended
4. If during the animation an event `up:motion:finish` is emitted on
the given element, the transition instantly jumps to the last frame
and resolves the returned promise.
Calling [`up.animate()`](/up.animate) with an object argument
will take care of all these points.
@function up.animation
@param {string} name
@param {Function} animation
@stable
###
registerAnimation = (name, animation) ->
namedAnimations[name] = findAnimationFn(animation)
snapshot = ->
defaultNamedAnimations = u.copy(namedAnimations)
defaultNamedTransitions = u.copy(namedTransitions)
###**
Returns whether the given animation option will cause the animation
to be skipped.
@function up.motion.isNone
@internal
###
isNone = (animationOrTransition) ->
# false, undefined, '', null and the string "none" are all ways to skip animations
!animationOrTransition || animationOrTransition == 'none' || u.isBlank(animationOrTransition)
registerAnimation('fade-in', ($element, options) ->
u.writeInlineStyle($element, opacity: 0)
animateNow($element, { opacity: 1 }, options)
)
registerAnimation('fade-out', ($element, options) ->
u.writeInlineStyle($element, opacity: 1)
animateNow($element, { opacity: 0 }, options)
)
translateCss = (x, y) ->
{ transform: "translate(#{x}px, #{y}px)" }
registerAnimation('move-to-top', ($element, options) ->
u.writeInlineStyle($element, translateCss(0, 0))
box = u.measure($element)
travelDistance = box.top + box.height
animateNow($element, translateCss(0, -travelDistance), options)
)
registerAnimation('move-from-top', ($element, options) ->
u.writeInlineStyle($element, translateCss(0, 0))
box = u.measure($element)
travelDistance = box.top + box.height
u.writeInlineStyle($element, translateCss(0, -travelDistance))
animateNow($element, translateCss(0, 0), options)
)
registerAnimation('move-to-bottom', ($element, options) ->
u.writeInlineStyle($element, translateCss(0, 0))
box = u.measure($element)
travelDistance = u.clientSize().height - box.top
animateNow($element, translateCss(0, travelDistance), options)
)
registerAnimation('move-from-bottom', ($element, options) ->
u.writeInlineStyle($element, translateCss(0, 0))
box = u.measure($element)
travelDistance = u.clientSize().height - box.top
u.writeInlineStyle($element, translateCss(0, travelDistance))
animateNow($element, translateCss(0, 0), options)
)
registerAnimation('move-to-left', ($element, options) ->
u.writeInlineStyle($element, translateCss(0, 0))
box = u.measure($element)
travelDistance = box.left + box.width
animateNow($element, translateCss(-travelDistance, 0), options)
)
registerAnimation('move-from-left', ($element, options) ->
u.writeInlineStyle($element, translateCss(0, 0))
box = u.measure($element)
travelDistance = box.left + box.width
u.writeInlineStyle($element, translateCss(-travelDistance, 0))
animateNow($element, translateCss(0, 0), options)
)
registerAnimation('move-to-right', ($element, options) ->
u.writeInlineStyle($element, translateCss(0, 0))
box = u.measure($element)
travelDistance = u.clientSize().width - box.left
animateNow($element, translateCss(travelDistance, 0), options)
)
registerAnimation('move-from-right', ($element, options) ->
u.writeInlineStyle($element, translateCss(0, 0))
box = u.measure($element)
travelDistance = u.clientSize().width - box.left
u.writeInlineStyle($element, translateCss(travelDistance, 0))
animateNow($element, translateCss(0, 0), options)
)
registerAnimation('roll-down', ($element, options) ->
fullHeight = $element.height()
styleMemo = u.writeTemporaryStyle($element,
height: '0px'
overflow: 'hidden'
)
deferred = animate($element, { height: "#{fullHeight}px" }, options)
deferred.then(styleMemo)
deferred
)
registerTransition('move-left', ['move-to-left', 'move-from-right'])
registerTransition('move-right', ['move-to-right', 'move-from-left'])
registerTransition('move-up', ['move-to-top', 'move-from-bottom'])
registerTransition('move-down', ['move-to-bottom', 'move-from-top'])
registerTransition('cross-fade', ['fade-out', 'fade-in'])
up.on 'up:framework:booted', snapshot
up.on 'up:framework:reset', reset
<% if ENV['JS_KNIFE'] %>knife: eval(Knife.point)<% end %>
morph: morph
animate: animate
animateOptions: animateOptions
willAnimate: willAnimate
finish: finish
finishCount: -> motionTracker.finishCount
transition: registerTransition
animation: registerAnimation
config: config
isEnabled: isEnabled
isNone: isNone
)(jQuery)
up.transition = up.motion.transition
up.animation = up.motion.animation
up.morph = up.motion.morph
up.animate = up.motion.animate