@rafaelaugusto/jolt 0.1.0-beta.1 → 0.1.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +367 -18
- package/dist/animate.d.ts +19 -3
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +186 -14
- package/dist/animate.js.map +1 -1
- package/dist/color.d.ts +11 -0
- package/dist/color.d.ts.map +1 -0
- package/dist/color.js +73 -0
- package/dist/color.js.map +1 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +73 -0
- package/dist/config.js.map +1 -0
- package/dist/decay.d.ts +11 -0
- package/dist/decay.d.ts.map +1 -0
- package/dist/decay.js +22 -0
- package/dist/decay.js.map +1 -0
- package/dist/decoration.d.ts +18 -0
- package/dist/decoration.d.ts.map +1 -0
- package/dist/decoration.js +155 -0
- package/dist/decoration.js.map +1 -0
- package/dist/drag-input.d.ts +15 -0
- package/dist/drag-input.d.ts.map +1 -0
- package/dist/drag-input.js +66 -0
- package/dist/drag-input.js.map +1 -0
- package/dist/drag-physics.d.ts +20 -0
- package/dist/drag-physics.d.ts.map +1 -0
- package/dist/drag-physics.js +75 -0
- package/dist/drag-physics.js.map +1 -0
- package/dist/drag.d.ts +58 -0
- package/dist/drag.d.ts.map +1 -0
- package/dist/drag.js +324 -0
- package/dist/drag.js.map +1 -0
- package/dist/driver/android-color.d.ts +20 -0
- package/dist/driver/android-color.d.ts.map +1 -0
- package/dist/driver/android-color.js +136 -0
- package/dist/driver/android-color.js.map +1 -0
- package/dist/driver/android-decoration.d.ts +22 -0
- package/dist/driver/android-decoration.d.ts.map +1 -0
- package/dist/driver/android-decoration.js +139 -0
- package/dist/driver/android-decoration.js.map +1 -0
- package/dist/driver/android-playback.d.ts +13 -0
- package/dist/driver/android-playback.d.ts.map +1 -0
- package/dist/driver/android-playback.js +141 -0
- package/dist/driver/android-playback.js.map +1 -0
- package/dist/driver/android.d.ts +1 -1
- package/dist/driver/android.d.ts.map +1 -1
- package/dist/driver/android.js +165 -13
- package/dist/driver/android.js.map +1 -1
- package/dist/driver/index.d.ts +25 -2
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/ios-color.d.ts +16 -0
- package/dist/driver/ios-color.d.ts.map +1 -0
- package/dist/driver/ios-color.js +392 -0
- package/dist/driver/ios-color.js.map +1 -0
- package/dist/driver/ios-decoration.d.ts +17 -0
- package/dist/driver/ios-decoration.d.ts.map +1 -0
- package/dist/driver/ios-decoration.js +271 -0
- package/dist/driver/ios-decoration.js.map +1 -0
- package/dist/driver/ios-layout.d.ts +9 -0
- package/dist/driver/ios-layout.d.ts.map +1 -0
- package/dist/driver/ios-layout.js +52 -0
- package/dist/driver/ios-layout.js.map +1 -0
- package/dist/driver/ios-playback.d.ts +10 -0
- package/dist/driver/ios-playback.d.ts.map +1 -0
- package/dist/driver/ios-playback.js +47 -0
- package/dist/driver/ios-playback.js.map +1 -0
- package/dist/driver/ios-shadow.d.ts +6 -0
- package/dist/driver/ios-shadow.d.ts.map +1 -0
- package/dist/driver/ios-shadow.js +52 -0
- package/dist/driver/ios-shadow.js.map +1 -0
- package/dist/driver/ios.d.ts.map +1 -1
- package/dist/driver/ios.js +163 -28
- package/dist/driver/ios.js.map +1 -1
- package/dist/engine.d.ts +75 -5
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +293 -23
- package/dist/engine.js.map +1 -1
- package/dist/haptics.d.ts +21 -0
- package/dist/haptics.d.ts.map +1 -0
- package/dist/haptics.js +120 -0
- package/dist/haptics.js.map +1 -0
- package/dist/image-effect-native.d.ts +26 -0
- package/dist/image-effect-native.d.ts.map +1 -0
- package/dist/image-effect-native.js +73 -0
- package/dist/image-effect-native.js.map +1 -0
- package/dist/image-effect.d.ts +50 -0
- package/dist/image-effect.d.ts.map +1 -0
- package/dist/image-effect.js +290 -0
- package/dist/image-effect.js.map +1 -0
- package/dist/image-owner.d.ts +3 -0
- package/dist/image-owner.d.ts.map +1 -0
- package/dist/image-owner.js +11 -0
- package/dist/image-owner.js.map +1 -0
- package/dist/index.d.ts +43 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -1
- package/dist/index.js.map +1 -1
- package/dist/interaction-surface.d.ts +12 -0
- package/dist/interaction-surface.d.ts.map +1 -0
- package/dist/interaction-surface.js +36 -0
- package/dist/interaction-surface.js.map +1 -0
- package/dist/items.d.ts +20 -0
- package/dist/items.d.ts.map +1 -0
- package/dist/items.js +109 -0
- package/dist/items.js.map +1 -0
- package/dist/keyboard.d.ts +41 -0
- package/dist/keyboard.d.ts.map +1 -0
- package/dist/keyboard.js +256 -0
- package/dist/keyboard.js.map +1 -0
- package/dist/label.d.ts +7 -0
- package/dist/label.d.ts.map +1 -0
- package/dist/label.js +60 -0
- package/dist/label.js.map +1 -0
- package/dist/layout.d.ts +13 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +348 -0
- package/dist/layout.js.map +1 -0
- package/dist/liquid-grid.d.ts +3 -0
- package/dist/liquid-grid.d.ts.map +1 -0
- package/dist/liquid-grid.js +24 -0
- package/dist/liquid-grid.js.map +1 -0
- package/dist/liquid.d.ts +18 -0
- package/dist/liquid.d.ts.map +1 -0
- package/dist/liquid.js +234 -0
- package/dist/liquid.js.map +1 -0
- package/dist/loader.d.ts +19 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +173 -0
- package/dist/loader.js.map +1 -0
- package/dist/motion-scope.d.ts +39 -0
- package/dist/motion-scope.d.ts.map +1 -0
- package/dist/motion-scope.js +238 -0
- package/dist/motion-scope.js.map +1 -0
- package/dist/motion-states.d.ts +36 -0
- package/dist/motion-states.d.ts.map +1 -0
- package/dist/motion-states.js +165 -0
- package/dist/motion-states.js.map +1 -0
- package/dist/particles.d.ts +39 -0
- package/dist/particles.d.ts.map +1 -0
- package/dist/particles.js +265 -0
- package/dist/particles.js.map +1 -0
- package/dist/path.d.ts +12 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +90 -0
- package/dist/path.js.map +1 -0
- package/dist/playback-group.d.ts +6 -0
- package/dist/playback-group.d.ts.map +1 -0
- package/dist/playback-group.js +141 -0
- package/dist/playback-group.js.map +1 -0
- package/dist/playback.d.ts +25 -0
- package/dist/playback.d.ts.map +1 -0
- package/dist/playback.js +35 -0
- package/dist/playback.js.map +1 -0
- package/dist/press.d.ts.map +1 -1
- package/dist/press.js +23 -7
- package/dist/press.js.map +1 -1
- package/dist/properties.d.ts +6 -3
- package/dist/properties.d.ts.map +1 -1
- package/dist/properties.js +26 -2
- package/dist/properties.js.map +1 -1
- package/dist/reveal.d.ts +25 -0
- package/dist/reveal.d.ts.map +1 -0
- package/dist/reveal.js +294 -0
- package/dist/reveal.js.map +1 -0
- package/dist/roll-diff.d.ts +22 -0
- package/dist/roll-diff.d.ts.map +1 -0
- package/dist/roll-diff.js +36 -0
- package/dist/roll-diff.js.map +1 -0
- package/dist/roll.d.ts +29 -0
- package/dist/roll.d.ts.map +1 -0
- package/dist/roll.js +442 -0
- package/dist/roll.js.map +1 -0
- package/dist/scroll-linked.d.ts +10 -0
- package/dist/scroll-linked.d.ts.map +1 -0
- package/dist/scroll-linked.js +58 -0
- package/dist/scroll-linked.js.map +1 -0
- package/dist/sequence.d.ts +10 -5
- package/dist/sequence.d.ts.map +1 -1
- package/dist/sequence.js +208 -14
- package/dist/sequence.js.map +1 -1
- package/dist/shimmer.d.ts +12 -0
- package/dist/shimmer.d.ts.map +1 -0
- package/dist/shimmer.js +161 -0
- package/dist/shimmer.js.map +1 -0
- package/dist/svg-path.d.ts +14 -0
- package/dist/svg-path.d.ts.map +1 -0
- package/dist/svg-path.js +281 -0
- package/dist/svg-path.js.map +1 -0
- package/dist/symbol-effect.d.ts +23 -0
- package/dist/symbol-effect.d.ts.map +1 -0
- package/dist/symbol-effect.js +187 -0
- package/dist/symbol-effect.js.map +1 -0
- package/dist/text-layout.d.ts +30 -0
- package/dist/text-layout.d.ts.map +1 -0
- package/dist/text-layout.js +185 -0
- package/dist/text-layout.js.map +1 -0
- package/dist/text.d.ts +17 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +263 -0
- package/dist/text.js.map +1 -0
- package/dist/track.d.ts +3 -1
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +266 -13
- package/dist/track.js.map +1 -1
- package/dist/transition.d.ts +17 -1
- package/dist/transition.d.ts.map +1 -1
- package/dist/transition.js +41 -6
- package/dist/transition.js.map +1 -1
- package/dist/types.d.ts +48 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vue/index.d.ts +95 -0
- package/dist/vue/index.d.ts.map +1 -0
- package/dist/vue/index.js +163 -0
- package/dist/vue/index.js.map +1 -0
- package/dist/zoom-native.d.ts +30 -0
- package/dist/zoom-native.d.ts.map +1 -0
- package/dist/zoom-native.js +56 -0
- package/dist/zoom-native.js.map +1 -0
- package/dist/zoomable.d.ts +48 -0
- package/dist/zoomable.d.ts.map +1 -0
- package/dist/zoomable.js +249 -0
- package/dist/zoomable.js.map +1 -0
- package/package.json +29 -5
- package/platforms/android/include.gradle +7 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/ImageEffect.java +288 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardObserver.java +97 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/PhotoZoom.java +250 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
- package/platforms/android/proguard-rules.pro +19 -0
- package/platforms/ios/build.xcconfig +1 -0
- package/platforms/ios/src/JoltImageEffect.h +25 -0
- package/platforms/ios/src/JoltImageEffect.mm +242 -0
- package/platforms/ios/src/JoltInteractiveAnimation.h +18 -0
- package/platforms/ios/src/JoltInteractiveAnimation.m +123 -0
- package/platforms/ios/src/JoltPhotoZoom.h +23 -0
- package/platforms/ios/src/JoltPhotoZoom.m +229 -0
- package/platforms/ios/src/module.modulemap +6 -0
- package/src/android-plugin.d.ts +155 -0
- package/src/animate.ts +249 -36
- package/src/color.ts +101 -0
- package/src/config.ts +84 -0
- package/src/decay.ts +45 -0
- package/src/decoration.ts +180 -0
- package/src/drag-input.ts +74 -0
- package/src/drag-physics.ts +110 -0
- package/src/drag.ts +395 -0
- package/src/driver/android-color.ts +170 -0
- package/src/driver/android-decoration.ts +182 -0
- package/src/driver/android-playback.ts +157 -0
- package/src/driver/android.ts +208 -20
- package/src/driver/index.ts +37 -2
- package/src/driver/ios-color.ts +441 -0
- package/src/driver/ios-decoration.ts +332 -0
- package/src/driver/ios-layout.ts +63 -0
- package/src/driver/ios-playback.ts +51 -0
- package/src/driver/ios-shadow.ts +50 -0
- package/src/driver/ios.ts +193 -33
- package/src/engine.ts +321 -27
- package/src/haptics.ts +148 -0
- package/src/image-effect-native.ts +129 -0
- package/src/image-effect.ts +336 -0
- package/src/image-owner.ts +12 -0
- package/src/index.ts +59 -2
- package/src/interaction-surface.ts +41 -0
- package/src/ios-plugin.d.ts +81 -0
- package/src/items.ts +140 -0
- package/src/keyboard.ts +291 -0
- package/src/label.ts +76 -0
- package/src/layout.ts +408 -0
- package/src/liquid-grid.ts +28 -0
- package/src/liquid.ts +287 -0
- package/src/loader.ts +202 -0
- package/src/motion-scope.ts +279 -0
- package/src/motion-states.ts +215 -0
- package/src/particles.ts +323 -0
- package/src/path.ts +124 -0
- package/src/playback-group.ts +143 -0
- package/src/playback.ts +62 -0
- package/src/press.ts +26 -10
- package/src/properties.ts +32 -6
- package/src/reveal.ts +374 -0
- package/src/roll-diff.ts +57 -0
- package/src/roll.ts +515 -0
- package/src/scroll-linked.ts +73 -0
- package/src/sequence.ts +204 -14
- package/src/shimmer.ts +180 -0
- package/src/svg-path.ts +355 -0
- package/src/symbol-effect.ts +226 -0
- package/src/text-layout.ts +228 -0
- package/src/text.ts +279 -0
- package/src/track.ts +347 -12
- package/src/transition.ts +63 -6
- package/src/types.ts +49 -1
- package/src/vue/index.ts +231 -0
- package/src/zoom-native.ts +124 -0
- package/src/zoomable.ts +330 -0
package/src/particles.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { Application, Color, isIOS, type View } from '@nativescript/core'
|
|
2
|
+
import { toView } from './animate.js'
|
|
3
|
+
import type { Target } from './types.js'
|
|
4
|
+
import { reducedMotion } from './config.js'
|
|
5
|
+
|
|
6
|
+
export interface ConfettiOptions {
|
|
7
|
+
/** Approximate number of particles in the burst. Defaults to 80. */
|
|
8
|
+
count?: number
|
|
9
|
+
colors?: readonly string[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ParticleOptions {
|
|
13
|
+
colors?: readonly string[]
|
|
14
|
+
shape?: 'circle' | 'rectangle' | 'ring'
|
|
15
|
+
/** Center point, or a line across the top/bottom of the target. */
|
|
16
|
+
source?: 'center' | 'top' | 'bottom'
|
|
17
|
+
/** Total particles per second, shared across all colors. Defaults to 20. */
|
|
18
|
+
rate?: number
|
|
19
|
+
/** Particle lifetime in milliseconds. Defaults to 3000. */
|
|
20
|
+
lifetime?: number
|
|
21
|
+
/** Longest side in DIPs. Defaults to 6. */
|
|
22
|
+
size?: number
|
|
23
|
+
/** DIPs per second. Defaults to 60. */
|
|
24
|
+
velocity?: number
|
|
25
|
+
velocityRange?: number
|
|
26
|
+
/** Degrees clockwise from right: -90 is up, 90 is down. Defaults to -90. */
|
|
27
|
+
angle?: number
|
|
28
|
+
/** Emission cone in degrees, from 0 to 360. Defaults to 30. */
|
|
29
|
+
spread?: number
|
|
30
|
+
/** Downward acceleration in DIPs per second squared. Defaults to 0. */
|
|
31
|
+
gravity?: number
|
|
32
|
+
/** Degrees per second. Defaults to 0. */
|
|
33
|
+
spin?: number
|
|
34
|
+
spinRange?: number
|
|
35
|
+
/** Fade each particle over its lifetime. Defaults to true. */
|
|
36
|
+
fadeOut?: boolean
|
|
37
|
+
/** Emit for this many milliseconds, then let particles expire. Omit to keep emitting. */
|
|
38
|
+
duration?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Settings = Required<Omit<ParticleOptions, 'duration'>> & { duration?: number }
|
|
42
|
+
const bursts = new WeakMap<View, () => void>()
|
|
43
|
+
const emitters = new WeakMap<View, () => void>()
|
|
44
|
+
const palette = ['#facc15', '#ef4444', '#3b82f6', '#22c55e', '#a855f7']
|
|
45
|
+
const textures = new Map<Settings['shape'], UIImage>()
|
|
46
|
+
const radians = (degrees: number) => (degrees * Math.PI) / 180
|
|
47
|
+
const BURST_DURATION = 200
|
|
48
|
+
|
|
49
|
+
/** Bursts from the target's center into its window. iOS only; returns immediate cleanup. */
|
|
50
|
+
export function confetti(target: Target, options: ConfettiOptions = {}): () => void {
|
|
51
|
+
const count = number('count', options.count ?? 80, 0, 10000)
|
|
52
|
+
if (!Number.isInteger(count)) throw new RangeError('Particle count must be an integer.')
|
|
53
|
+
const settings = resolve(
|
|
54
|
+
{
|
|
55
|
+
colors: options.colors ?? palette,
|
|
56
|
+
shape: 'rectangle',
|
|
57
|
+
rate: count / (BURST_DURATION / 1000),
|
|
58
|
+
duration: BURST_DURATION,
|
|
59
|
+
lifetime: 2200,
|
|
60
|
+
size: 8,
|
|
61
|
+
velocity: 260,
|
|
62
|
+
velocityRange: 100,
|
|
63
|
+
angle: -90,
|
|
64
|
+
spread: 100,
|
|
65
|
+
gravity: 420,
|
|
66
|
+
spinRange: 360,
|
|
67
|
+
},
|
|
68
|
+
true,
|
|
69
|
+
)
|
|
70
|
+
return start(target, settings, true)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Adds a native particle emitter to the target. iOS only; returns immediate cleanup. */
|
|
74
|
+
export function particles(target: Target, options: ParticleOptions = {}): () => void {
|
|
75
|
+
return start(target, resolve(options), false)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function start(target: Target, settings: Settings, overlay: boolean): () => void {
|
|
79
|
+
const view = toView(target)
|
|
80
|
+
const running = overlay ? bursts : emitters
|
|
81
|
+
running.get(view)?.()
|
|
82
|
+
if (!isIOS || settings.rate === 0 || reducedMotion()) return () => {}
|
|
83
|
+
return new Emitter(view, settings, overlay, running).stop
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolve(options: ParticleOptions, burst = false): Settings {
|
|
87
|
+
const colors = options.colors ?? ['#ffffff']
|
|
88
|
+
if (
|
|
89
|
+
!Array.isArray(colors) ||
|
|
90
|
+
colors.length === 0 ||
|
|
91
|
+
colors.length > 32 ||
|
|
92
|
+
Array.from(colors).some((color) => typeof color !== 'string' || !Color.isValid(color))
|
|
93
|
+
)
|
|
94
|
+
throw new TypeError('Particles need 1 to 32 valid color strings.')
|
|
95
|
+
const shape = options.shape ?? 'circle'
|
|
96
|
+
const source = options.source ?? 'center'
|
|
97
|
+
if (!['circle', 'rectangle', 'ring'].includes(shape))
|
|
98
|
+
throw new TypeError('Unknown particle shape.')
|
|
99
|
+
if (!['center', 'top', 'bottom'].includes(source))
|
|
100
|
+
throw new TypeError('Unknown particle source.')
|
|
101
|
+
const lifetime = number('lifetime', options.lifetime ?? 3000, 1, 60000)
|
|
102
|
+
const rate = number('rate', options.rate ?? 20, 0, burst ? 50000 : 10000)
|
|
103
|
+
if (!burst && (rate * lifetime) / 1000 > 10000)
|
|
104
|
+
throw new RangeError('Particle rate × lifetime must not exceed 10000 live particles.')
|
|
105
|
+
const duration =
|
|
106
|
+
options.duration === undefined
|
|
107
|
+
? undefined
|
|
108
|
+
: number('duration', options.duration, 1, 2147483647 - lifetime - 50)
|
|
109
|
+
const fadeOut = options.fadeOut ?? true
|
|
110
|
+
if (typeof fadeOut !== 'boolean')
|
|
111
|
+
throw new TypeError('Particle fadeOut must be a boolean.')
|
|
112
|
+
return {
|
|
113
|
+
colors: [...colors],
|
|
114
|
+
shape,
|
|
115
|
+
source,
|
|
116
|
+
lifetime,
|
|
117
|
+
rate,
|
|
118
|
+
duration,
|
|
119
|
+
fadeOut,
|
|
120
|
+
size: number('size', options.size ?? 6, 1, 128),
|
|
121
|
+
velocity: number('velocity', options.velocity ?? 60, 0, 10000),
|
|
122
|
+
velocityRange: number('velocityRange', options.velocityRange ?? 0, 0, 10000),
|
|
123
|
+
angle: number('angle', options.angle ?? -90, -360, 360),
|
|
124
|
+
spread: number('spread', options.spread ?? 30, 0, 360),
|
|
125
|
+
gravity: number('gravity', options.gravity ?? 0, -10000, 10000),
|
|
126
|
+
spin: number('spin', options.spin ?? 0, -3600, 3600),
|
|
127
|
+
spinRange: number('spinRange', options.spinRange ?? 0, 0, 3600),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function number(name: string, value: number, min: number, max: number): number {
|
|
132
|
+
if (!Number.isFinite(value) || value < min || value > max)
|
|
133
|
+
throw new RangeError(`Particle ${name} must be between ${min} and ${max}.`)
|
|
134
|
+
return value
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
class Emitter {
|
|
138
|
+
private stopped = false
|
|
139
|
+
private queued = false
|
|
140
|
+
private layer: CAEmitterLayer | undefined
|
|
141
|
+
private native: UIView | undefined
|
|
142
|
+
private window: UIWindow | undefined
|
|
143
|
+
private timer: ReturnType<typeof setTimeout> | undefined
|
|
144
|
+
private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
|
|
145
|
+
|
|
146
|
+
constructor(
|
|
147
|
+
private readonly view: View,
|
|
148
|
+
private readonly settings: Settings,
|
|
149
|
+
private readonly overlay: boolean,
|
|
150
|
+
private readonly running: WeakMap<View, () => void>,
|
|
151
|
+
) {
|
|
152
|
+
running.set(view, this.stop)
|
|
153
|
+
view.on('loaded', this.schedule)
|
|
154
|
+
view.on('layoutChanged', this.schedule)
|
|
155
|
+
view.on('unloaded', this.stop)
|
|
156
|
+
this.schedule()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
stop = () => {
|
|
160
|
+
if (this.stopped) return
|
|
161
|
+
this.stopped = true
|
|
162
|
+
this.view.off('loaded', this.schedule)
|
|
163
|
+
this.view.off('layoutChanged', this.schedule)
|
|
164
|
+
this.view.off('unloaded', this.stop)
|
|
165
|
+
if (this.running.get(this.view) === this.stop) this.running.delete(this.view)
|
|
166
|
+
if (this.timer !== undefined) clearTimeout(this.timer)
|
|
167
|
+
if (this.observer)
|
|
168
|
+
Application.ios.removeNotificationObserver(
|
|
169
|
+
this.observer,
|
|
170
|
+
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
171
|
+
)
|
|
172
|
+
this.layer?.removeAllAnimations()
|
|
173
|
+
this.layer?.removeFromSuperlayer()
|
|
174
|
+
this.layer = this.native = this.window = undefined
|
|
175
|
+
this.timer = this.observer = undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private schedule = () => {
|
|
179
|
+
if (this.stopped || this.queued) return
|
|
180
|
+
this.queued = true
|
|
181
|
+
void Promise.resolve().then(() => {
|
|
182
|
+
this.queued = false
|
|
183
|
+
if (this.stopped) return
|
|
184
|
+
try {
|
|
185
|
+
this.update()
|
|
186
|
+
} catch (error) {
|
|
187
|
+
this.stop()
|
|
188
|
+
throw error
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private motionChanged = () => {
|
|
194
|
+
if (reducedMotion()) this.stop()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private update() {
|
|
198
|
+
if (!this.view.isLoaded) return
|
|
199
|
+
if (reducedMotion()) return this.stop()
|
|
200
|
+
const native = this.view.nativeViewProtected as UIView | undefined
|
|
201
|
+
const window = native?.window
|
|
202
|
+
if (this.layer && (!native || !window)) return this.stop()
|
|
203
|
+
if (!native || !window) return
|
|
204
|
+
if (this.native && (this.native !== native || this.window !== window))
|
|
205
|
+
return this.stop()
|
|
206
|
+
const bounds = native.layer.bounds
|
|
207
|
+
if (bounds.size.width <= 0 || bounds.size.height <= 0) {
|
|
208
|
+
if (this.layer) this.stop()
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
CATransaction.begin()
|
|
212
|
+
CATransaction.setDisableActions(true)
|
|
213
|
+
try {
|
|
214
|
+
if (!this.layer) {
|
|
215
|
+
this.native = native
|
|
216
|
+
this.window = window
|
|
217
|
+
const host = this.overlay ? window.layer : native.layer
|
|
218
|
+
const layer = (this.layer = CAEmitterLayer.new())
|
|
219
|
+
layer.name = this.overlay ? 'jolt:confetti' : 'jolt:particles'
|
|
220
|
+
layer.frame = host.bounds
|
|
221
|
+
layer.emitterShape =
|
|
222
|
+
this.settings.source === 'center' ? kCAEmitterLayerPoint : kCAEmitterLayerLine
|
|
223
|
+
layer.emitterCells = NSArray.arrayWithArray(this.cells())
|
|
224
|
+
host.addSublayer(layer)
|
|
225
|
+
// Start from an empty emitter, even if its parent has been alive for hours.
|
|
226
|
+
layer.beginTime = host.convertTimeFromLayer(CACurrentMediaTime(), null)
|
|
227
|
+
if (this.overlay)
|
|
228
|
+
layer.emitterPosition = native.convertPointToView(
|
|
229
|
+
CGPointMake(
|
|
230
|
+
bounds.origin.x + bounds.size.width / 2,
|
|
231
|
+
bounds.origin.y + bounds.size.height / 2,
|
|
232
|
+
),
|
|
233
|
+
window,
|
|
234
|
+
)
|
|
235
|
+
this.observer = Application.ios.addNotificationObserver(
|
|
236
|
+
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
237
|
+
this.motionChanged,
|
|
238
|
+
)
|
|
239
|
+
if (this.settings.duration !== undefined) {
|
|
240
|
+
// The render server stops emission even if JS is blocked. The timer
|
|
241
|
+
// only removes the empty layer after the last particles have expired.
|
|
242
|
+
layer.birthRate = 0
|
|
243
|
+
const emission = CABasicAnimation.animationWithKeyPath('birthRate')
|
|
244
|
+
emission.fromValue = 1
|
|
245
|
+
emission.toValue = 1
|
|
246
|
+
emission.duration = this.settings.duration / 1000
|
|
247
|
+
layer.addAnimationForKey(emission, 'jolt:emission')
|
|
248
|
+
this.timer = setTimeout(
|
|
249
|
+
this.stop,
|
|
250
|
+
this.settings.duration + this.settings.lifetime + 50,
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (!this.overlay) {
|
|
255
|
+
const layer = this.layer
|
|
256
|
+
layer.frame = bounds
|
|
257
|
+
layer.emitterSize = CGSizeMake(bounds.size.width, 0)
|
|
258
|
+
layer.emitterPosition = CGPointMake(
|
|
259
|
+
bounds.size.width / 2,
|
|
260
|
+
this.settings.source === 'top'
|
|
261
|
+
? 0
|
|
262
|
+
: this.settings.source === 'bottom'
|
|
263
|
+
? bounds.size.height
|
|
264
|
+
: bounds.size.height / 2,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
} finally {
|
|
268
|
+
CATransaction.commit()
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private cells(): CAEmitterCell[] {
|
|
273
|
+
const options = this.settings
|
|
274
|
+
const image = texture(options.shape)
|
|
275
|
+
return options.colors.map((color) => {
|
|
276
|
+
const cell = CAEmitterCell.emitterCell()
|
|
277
|
+
cell.contents = image.CGImage
|
|
278
|
+
cell.color = new Color(color).ios.CGColor
|
|
279
|
+
cell.birthRate = options.rate / options.colors.length
|
|
280
|
+
cell.lifetime = options.lifetime / 1000
|
|
281
|
+
cell.scale = options.size / 32
|
|
282
|
+
cell.velocity = options.velocity
|
|
283
|
+
cell.velocityRange = options.velocityRange
|
|
284
|
+
// Horizontal line emitters point upward at longitude zero; point
|
|
285
|
+
// emitters point right. Keep the public angle in view coordinates.
|
|
286
|
+
cell.emissionLongitude = radians(
|
|
287
|
+
options.angle + (options.source === 'center' ? 0 : 90),
|
|
288
|
+
)
|
|
289
|
+
cell.emissionRange = radians(options.spread)
|
|
290
|
+
cell.yAcceleration = options.gravity
|
|
291
|
+
cell.spin = radians(options.spin)
|
|
292
|
+
cell.spinRange = radians(options.spinRange)
|
|
293
|
+
cell.alphaSpeed = options.fadeOut ? -1 / cell.lifetime : 0
|
|
294
|
+
return cell
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function texture(shape: Settings['shape']): UIImage {
|
|
300
|
+
const cached = textures.get(shape)
|
|
301
|
+
if (cached) return cached
|
|
302
|
+
const format = UIGraphicsImageRendererFormat.defaultFormat()
|
|
303
|
+
format.scale = 1
|
|
304
|
+
format.opaque = false
|
|
305
|
+
const height = shape === 'rectangle' ? 18 : 32
|
|
306
|
+
const renderer = UIGraphicsImageRenderer.alloc().initWithSizeFormat(
|
|
307
|
+
CGSizeMake(32, height),
|
|
308
|
+
format,
|
|
309
|
+
)
|
|
310
|
+
const image = renderer.imageWithActions((context) => {
|
|
311
|
+
const cg = context.CGContext
|
|
312
|
+
CGContextSetFillColorWithColor(cg, UIColor.whiteColor.CGColor)
|
|
313
|
+
if (shape === 'rectangle') CGContextFillRect(cg, CGRectMake(0, 0, 32, height))
|
|
314
|
+
else if (shape === 'circle') CGContextFillEllipseInRect(cg, CGRectMake(0, 0, 32, 32))
|
|
315
|
+
else {
|
|
316
|
+
CGContextSetStrokeColorWithColor(cg, UIColor.whiteColor.CGColor)
|
|
317
|
+
CGContextSetLineWidth(cg, 3)
|
|
318
|
+
CGContextStrokeEllipseInRect(cg, CGRectMake(2, 2, 28, 28))
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
textures.set(shape, image)
|
|
322
|
+
return image
|
|
323
|
+
}
|
package/src/path.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { parsePath, type MotionPath } from './svg-path.js'
|
|
2
|
+
import { reducedMotion, timeScale } from './config.js'
|
|
3
|
+
import { createTrack, instantTrack, type Track } from './track.js'
|
|
4
|
+
import {
|
|
5
|
+
mergeTransitions,
|
|
6
|
+
resolveTransition,
|
|
7
|
+
type Transition,
|
|
8
|
+
type ResolvedTransition,
|
|
9
|
+
} from './transition.js'
|
|
10
|
+
import type { Keyframes, KeyframeProperty } from './types.js'
|
|
11
|
+
|
|
12
|
+
export type PathProperty = 'x' | 'y' | 'rotate'
|
|
13
|
+
export interface PreparedPath {
|
|
14
|
+
readonly properties: readonly PathProperty[]
|
|
15
|
+
readonly starts: ReadonlyMap<PathProperty, number>
|
|
16
|
+
readonly transition: ResolvedTransition
|
|
17
|
+
track(property: PathProperty, offset: number, delay: number): Track
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function preparePath(
|
|
21
|
+
keyframes: Keyframes,
|
|
22
|
+
base: Transition,
|
|
23
|
+
overrides: Partial<Record<KeyframeProperty | 'path', Transition>>,
|
|
24
|
+
): PreparedPath | undefined {
|
|
25
|
+
if (keyframes.orient !== undefined && typeof keyframes.orient !== 'boolean')
|
|
26
|
+
throw new TypeError('Path orient must be a boolean.')
|
|
27
|
+
if (keyframes.path === undefined) {
|
|
28
|
+
if (keyframes.orient !== undefined) throw new TypeError('orient requires a path.')
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
if (keyframes.x !== undefined || keyframes.y !== undefined)
|
|
32
|
+
throw new TypeError('A path owns x and y; omit explicit x/y keyframes.')
|
|
33
|
+
if (keyframes.orient && keyframes.rotate !== undefined)
|
|
34
|
+
throw new TypeError('An oriented path owns rotate; omit explicit rotate keyframes.')
|
|
35
|
+
if (overrides.x || overrides.y || (keyframes.orient && overrides.rotate))
|
|
36
|
+
throw new TypeError('Use the path transition to time its coordinates together.')
|
|
37
|
+
const geometry = parsePath(keyframes.path)
|
|
38
|
+
const orient = keyframes.orient === true
|
|
39
|
+
const transition = resolveTransition(
|
|
40
|
+
overrides.path ? mergeTransitions(base, overrides.path) : base,
|
|
41
|
+
'opacity',
|
|
42
|
+
2,
|
|
43
|
+
)
|
|
44
|
+
if (
|
|
45
|
+
!Number.isFinite(transition.curve.type === 'tween' ? transition.curve.duration : 0) ||
|
|
46
|
+
!Number.isFinite(transition.delay)
|
|
47
|
+
)
|
|
48
|
+
throw new RangeError('Path duration and delay must be finite.')
|
|
49
|
+
const properties: readonly PathProperty[] = orient ? ['x', 'y', 'rotate'] : ['x', 'y']
|
|
50
|
+
const first = geometry.at(0)
|
|
51
|
+
let motion: ReturnType<typeof sample> | undefined
|
|
52
|
+
return {
|
|
53
|
+
properties,
|
|
54
|
+
starts: new Map(properties.map((property) => [property, first[property]])),
|
|
55
|
+
transition,
|
|
56
|
+
track(property, offset, requested) {
|
|
57
|
+
motion ??= sample(geometry, transition, orient)
|
|
58
|
+
const { progress, times, frames } = motion
|
|
59
|
+
const to = geometry.at(progress.to)[property] + offset
|
|
60
|
+
if (reducedMotion()) return instantTrack(property, to)
|
|
61
|
+
const delay = requested / timeScale()
|
|
62
|
+
const shift = delay - progress.delay
|
|
63
|
+
const pose = (time: number) =>
|
|
64
|
+
geometry.at(progress.valueAt(time - shift))[property] + offset
|
|
65
|
+
return {
|
|
66
|
+
...progress,
|
|
67
|
+
property,
|
|
68
|
+
delay,
|
|
69
|
+
end: progress.end + shift,
|
|
70
|
+
arrival: progress.arrival + shift,
|
|
71
|
+
times,
|
|
72
|
+
values: offset
|
|
73
|
+
? frames[property].map((value) => value + offset)
|
|
74
|
+
: frames[property],
|
|
75
|
+
to,
|
|
76
|
+
valueAt: pose,
|
|
77
|
+
velocityAt(time) {
|
|
78
|
+
if (time <= delay || time >= progress.end + shift) return 0
|
|
79
|
+
const position = progress.valueAt(time - shift)
|
|
80
|
+
if (position < 0 || position > 1) return 0
|
|
81
|
+
const epsilon = Math.min(1e-5, 0.01 / Math.max(1, geometry.length))
|
|
82
|
+
const a = Math.max(0, position - epsilon),
|
|
83
|
+
b = Math.min(1, position + epsilon)
|
|
84
|
+
return (
|
|
85
|
+
((geometry.at(b)[property] - geometry.at(a)[property]) / (b - a)) *
|
|
86
|
+
progress.velocityAt(time - shift)
|
|
87
|
+
)
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Sample scalar timing once, then project all coordinates through the same path. */
|
|
95
|
+
function sample(geometry: MotionPath, transition: ResolvedTransition, orient: boolean) {
|
|
96
|
+
const progress = createTrack('opacity', [0, 1], transition, 0)
|
|
97
|
+
const count = Math.max(
|
|
98
|
+
1,
|
|
99
|
+
Math.min(
|
|
100
|
+
6000,
|
|
101
|
+
Math.max(
|
|
102
|
+
Math.ceil((progress.duration / 1000) * 60),
|
|
103
|
+
Math.ceil(geometry.length / 2),
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
const times = Array.from({ length: count + 1 }, (_, i) => i / count)
|
|
108
|
+
const frames: Record<PathProperty, number[]> = { x: [], y: [], rotate: [] }
|
|
109
|
+
for (const time of times) {
|
|
110
|
+
const position =
|
|
111
|
+
time === 0
|
|
112
|
+
? 0
|
|
113
|
+
: time === 1
|
|
114
|
+
? 1
|
|
115
|
+
: progress.valueAt(progress.delay + time * progress.duration)
|
|
116
|
+
if (!Number.isFinite(position))
|
|
117
|
+
throw new RangeError('Path easing must return finite progress.')
|
|
118
|
+
const pose = geometry.at(position)
|
|
119
|
+
frames.x.push(pose.x)
|
|
120
|
+
frames.y.push(pose.y)
|
|
121
|
+
if (orient) frames.rotate.push(pose.rotate)
|
|
122
|
+
}
|
|
123
|
+
return { progress, times, frames }
|
|
124
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import {
|
|
2
|
+
arrivalOf,
|
|
3
|
+
arrivalTimingOf,
|
|
4
|
+
setArrival,
|
|
5
|
+
setArrivalTiming,
|
|
6
|
+
type ViewAnimation,
|
|
7
|
+
} from './engine.js'
|
|
8
|
+
import { getDriver } from './driver/index.js'
|
|
9
|
+
import { createClock, type PlaybackClock } from './playback.js'
|
|
10
|
+
import { validateProgress, type PlaybackAnimation } from './playback.js'
|
|
11
|
+
|
|
12
|
+
/** Staggered views share a finite time range. Shorter children remain seekable
|
|
13
|
+
* until the whole group settles, unless another animation has taken ownership. */
|
|
14
|
+
export function playbackGroup(children: readonly ViewAnimation[]): PlaybackAnimation {
|
|
15
|
+
let paused = false
|
|
16
|
+
let settled = false
|
|
17
|
+
let finalProgress: number | undefined
|
|
18
|
+
let clock: PlaybackClock | undefined
|
|
19
|
+
let pendingProgress: number | undefined
|
|
20
|
+
const duration = () => Math.max(0, ...children.map((child) => child.duration))
|
|
21
|
+
const ensureClock = () => {
|
|
22
|
+
if (clock || settled || children.some((child) => !child.ready)) return
|
|
23
|
+
const driver = getDriver()
|
|
24
|
+
clock = driver.clock?.(duration()) ?? createClock(() => driver.now())
|
|
25
|
+
clock.seek(Math.max(0, ...children.map((child) => child.currentTime)))
|
|
26
|
+
if (paused) clock.pause()
|
|
27
|
+
}
|
|
28
|
+
const finishIfIdle = () => {
|
|
29
|
+
if (settled || paused || children.some((child) => !child.idle || child.paused)) return
|
|
30
|
+
settled = true
|
|
31
|
+
clock?.cancel()
|
|
32
|
+
for (const child of children) child.releaseGroup()
|
|
33
|
+
}
|
|
34
|
+
const seek = (value: number) => {
|
|
35
|
+
const total = duration()
|
|
36
|
+
validateProgress(value, total)
|
|
37
|
+
if (settled) return
|
|
38
|
+
if (children.some((child) => !child.ready)) {
|
|
39
|
+
pendingProgress = value
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
pendingProgress = undefined
|
|
43
|
+
ensureClock()
|
|
44
|
+
clock?.seek(total * value)
|
|
45
|
+
for (const child of children) child.seekTime(total * value)
|
|
46
|
+
finishIfIdle()
|
|
47
|
+
}
|
|
48
|
+
for (const child of children) {
|
|
49
|
+
child.onIdle(finishIfIdle)
|
|
50
|
+
child.onReady(() => {
|
|
51
|
+
ensureClock()
|
|
52
|
+
if (pendingProgress !== undefined && children.every((child) => child.ready))
|
|
53
|
+
seek(pendingProgress)
|
|
54
|
+
finishIfIdle()
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
const finished = Promise.all(children.map((child) => child.finished)).then(
|
|
58
|
+
(results) => {
|
|
59
|
+
finalProgress ??= results.every(Boolean)
|
|
60
|
+
? 1
|
|
61
|
+
: duration()
|
|
62
|
+
? Math.min(1, (clock?.time() ?? 0) / duration())
|
|
63
|
+
: 0
|
|
64
|
+
settled = true
|
|
65
|
+
clock?.cancel()
|
|
66
|
+
return results.every(Boolean)
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
const animation: PlaybackAnimation = {
|
|
70
|
+
finished,
|
|
71
|
+
get duration() {
|
|
72
|
+
return duration()
|
|
73
|
+
},
|
|
74
|
+
get paused() {
|
|
75
|
+
return paused
|
|
76
|
+
},
|
|
77
|
+
get progress() {
|
|
78
|
+
if (!Number.isFinite(duration()))
|
|
79
|
+
throw new RangeError('Progress requires a finite animation.')
|
|
80
|
+
if (pendingProgress !== undefined) return pendingProgress
|
|
81
|
+
return (
|
|
82
|
+
finalProgress ??
|
|
83
|
+
(settled ? 1 : duration() ? Math.min(1, (clock?.time() ?? 0) / duration()) : 0)
|
|
84
|
+
)
|
|
85
|
+
},
|
|
86
|
+
set progress(value) {
|
|
87
|
+
seek(value)
|
|
88
|
+
},
|
|
89
|
+
pause() {
|
|
90
|
+
if (!settled && !paused) {
|
|
91
|
+
paused = true
|
|
92
|
+
clock?.pause()
|
|
93
|
+
children.forEach((child) => child.pause())
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
play() {
|
|
97
|
+
if (!settled && paused) {
|
|
98
|
+
paused = false
|
|
99
|
+
clock?.play()
|
|
100
|
+
children.forEach((child) => child.play())
|
|
101
|
+
finishIfIdle()
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
stop() {
|
|
105
|
+
finalProgress = duration() ? Math.min(1, (clock?.time() ?? 0) / duration()) : 0
|
|
106
|
+
settled = true
|
|
107
|
+
clock?.cancel()
|
|
108
|
+
children.forEach((child) => child.stop())
|
|
109
|
+
},
|
|
110
|
+
then: (onfulfilled, onrejected) => finished.then(onfulfilled, onrejected),
|
|
111
|
+
}
|
|
112
|
+
setArrival(animation, Promise.all(children.map(arrivalOf)))
|
|
113
|
+
const listeners = new Set<() => void>()
|
|
114
|
+
const timings = children.map((child) => arrivalTimingOf(child)!)
|
|
115
|
+
const notify = () => {
|
|
116
|
+
for (const callback of [...listeners]) callback()
|
|
117
|
+
}
|
|
118
|
+
const unsubscribe = timings.map((timing) => timing.subscribe(notify))
|
|
119
|
+
setArrivalTiming(animation, {
|
|
120
|
+
read() {
|
|
121
|
+
const states = timings.map((timing) => timing.read())
|
|
122
|
+
return {
|
|
123
|
+
arrived: settled || states.every((state) => state.arrived),
|
|
124
|
+
paused: paused || states.some((state) => state.paused && !state.arrived),
|
|
125
|
+
remaining: states.some((state) => !state.arrived && state.remaining === undefined)
|
|
126
|
+
? undefined
|
|
127
|
+
: Math.max(0, ...states.map((state) => (state.arrived ? 0 : state.remaining!))),
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
subscribe(callback) {
|
|
131
|
+
listeners.add(callback)
|
|
132
|
+
return () => listeners.delete(callback)
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
void finished.then(() => {
|
|
136
|
+
unsubscribe.forEach((off) => off())
|
|
137
|
+
notify()
|
|
138
|
+
listeners.clear()
|
|
139
|
+
})
|
|
140
|
+
ensureClock()
|
|
141
|
+
finishIfIdle()
|
|
142
|
+
return animation
|
|
143
|
+
}
|
package/src/playback.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Animation } from './engine.js'
|
|
2
|
+
|
|
3
|
+
/** Controls returned by animate() and decay(). Effects expose the base Animation. */
|
|
4
|
+
export interface PlaybackAnimation extends Animation {
|
|
5
|
+
/** Pauses only the properties owned by this animation. */
|
|
6
|
+
pause(): void
|
|
7
|
+
/** Continues from the current position. Completed/stopped animations remain terminal. */
|
|
8
|
+
play(): void
|
|
9
|
+
readonly paused: boolean
|
|
10
|
+
/** Total milliseconds, including delays and repeats. Infinity for endless loops. */
|
|
11
|
+
readonly duration: number
|
|
12
|
+
/** Finite timeline position, from 0 to 1. Seeking preserves the playing/paused state. */
|
|
13
|
+
progress: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type PlaybackCommand = 'pause' | 'play' | 'seek'
|
|
17
|
+
|
|
18
|
+
export interface PlaybackClock {
|
|
19
|
+
time(): number
|
|
20
|
+
pause(): void
|
|
21
|
+
play(): void
|
|
22
|
+
seek(ms: number): void
|
|
23
|
+
cancel(): void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Read only on user commands; there is no frame loop in JavaScript. */
|
|
27
|
+
export function createClock(now: () => number, initial = 0): PlaybackClock {
|
|
28
|
+
let offset = initial
|
|
29
|
+
let anchor = now()
|
|
30
|
+
let paused = false
|
|
31
|
+
const time = () => offset + (paused ? 0 : now() - anchor)
|
|
32
|
+
return {
|
|
33
|
+
time,
|
|
34
|
+
pause() {
|
|
35
|
+
offset = time()
|
|
36
|
+
paused = true
|
|
37
|
+
},
|
|
38
|
+
play() {
|
|
39
|
+
if (paused) {
|
|
40
|
+
anchor = now()
|
|
41
|
+
paused = false
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
seek(ms) {
|
|
45
|
+
offset = ms
|
|
46
|
+
anchor = now()
|
|
47
|
+
},
|
|
48
|
+
cancel() {
|
|
49
|
+
offset = time()
|
|
50
|
+
paused = true
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validateProgress(value: number, duration: number) {
|
|
56
|
+
if (!Number.isFinite(value) || value < 0 || value > 1)
|
|
57
|
+
throw new RangeError('Animation progress must be between 0 and 1.')
|
|
58
|
+
if (!Number.isFinite(duration))
|
|
59
|
+
throw new RangeError(
|
|
60
|
+
'Progress requires a finite animation; endless loops can pause and play.',
|
|
61
|
+
)
|
|
62
|
+
}
|