@rafaelaugusto/jolt 0.1.0-beta.0 → 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.
Files changed (307) hide show
  1. package/README.md +441 -25
  2. package/dist/animate.d.ts +21 -5
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +205 -25
  5. package/dist/animate.js.map +1 -1
  6. package/dist/color.d.ts +11 -0
  7. package/dist/color.d.ts.map +1 -0
  8. package/dist/color.js +73 -0
  9. package/dist/color.js.map +1 -0
  10. package/dist/config.d.ts +31 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +73 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/decay.d.ts +11 -0
  15. package/dist/decay.d.ts.map +1 -0
  16. package/dist/decay.js +22 -0
  17. package/dist/decay.js.map +1 -0
  18. package/dist/decoration.d.ts +18 -0
  19. package/dist/decoration.d.ts.map +1 -0
  20. package/dist/decoration.js +155 -0
  21. package/dist/decoration.js.map +1 -0
  22. package/dist/drag-input.d.ts +15 -0
  23. package/dist/drag-input.d.ts.map +1 -0
  24. package/dist/drag-input.js +66 -0
  25. package/dist/drag-input.js.map +1 -0
  26. package/dist/drag-physics.d.ts +20 -0
  27. package/dist/drag-physics.d.ts.map +1 -0
  28. package/dist/drag-physics.js +75 -0
  29. package/dist/drag-physics.js.map +1 -0
  30. package/dist/drag.d.ts +58 -0
  31. package/dist/drag.d.ts.map +1 -0
  32. package/dist/drag.js +324 -0
  33. package/dist/drag.js.map +1 -0
  34. package/dist/driver/android-color.d.ts +20 -0
  35. package/dist/driver/android-color.d.ts.map +1 -0
  36. package/dist/driver/android-color.js +136 -0
  37. package/dist/driver/android-color.js.map +1 -0
  38. package/dist/driver/android-decoration.d.ts +22 -0
  39. package/dist/driver/android-decoration.d.ts.map +1 -0
  40. package/dist/driver/android-decoration.js +139 -0
  41. package/dist/driver/android-decoration.js.map +1 -0
  42. package/dist/driver/android-playback.d.ts +13 -0
  43. package/dist/driver/android-playback.d.ts.map +1 -0
  44. package/dist/driver/android-playback.js +141 -0
  45. package/dist/driver/android-playback.js.map +1 -0
  46. package/dist/driver/android.d.ts +1 -1
  47. package/dist/driver/android.d.ts.map +1 -1
  48. package/dist/driver/android.js +165 -13
  49. package/dist/driver/android.js.map +1 -1
  50. package/dist/driver/index.d.ts +25 -2
  51. package/dist/driver/index.d.ts.map +1 -1
  52. package/dist/driver/index.js.map +1 -1
  53. package/dist/driver/ios-color.d.ts +16 -0
  54. package/dist/driver/ios-color.d.ts.map +1 -0
  55. package/dist/driver/ios-color.js +392 -0
  56. package/dist/driver/ios-color.js.map +1 -0
  57. package/dist/driver/ios-decoration.d.ts +17 -0
  58. package/dist/driver/ios-decoration.d.ts.map +1 -0
  59. package/dist/driver/ios-decoration.js +271 -0
  60. package/dist/driver/ios-decoration.js.map +1 -0
  61. package/dist/driver/ios-layout.d.ts +9 -0
  62. package/dist/driver/ios-layout.d.ts.map +1 -0
  63. package/dist/driver/ios-layout.js +52 -0
  64. package/dist/driver/ios-layout.js.map +1 -0
  65. package/dist/driver/ios-playback.d.ts +10 -0
  66. package/dist/driver/ios-playback.d.ts.map +1 -0
  67. package/dist/driver/ios-playback.js +47 -0
  68. package/dist/driver/ios-playback.js.map +1 -0
  69. package/dist/driver/ios-shadow.d.ts +6 -0
  70. package/dist/driver/ios-shadow.d.ts.map +1 -0
  71. package/dist/driver/ios-shadow.js +52 -0
  72. package/dist/driver/ios-shadow.js.map +1 -0
  73. package/dist/driver/ios.d.ts.map +1 -1
  74. package/dist/driver/ios.js +164 -20
  75. package/dist/driver/ios.js.map +1 -1
  76. package/dist/engine.d.ts +76 -5
  77. package/dist/engine.d.ts.map +1 -1
  78. package/dist/engine.js +303 -23
  79. package/dist/engine.js.map +1 -1
  80. package/dist/haptics.d.ts +21 -0
  81. package/dist/haptics.d.ts.map +1 -0
  82. package/dist/haptics.js +120 -0
  83. package/dist/haptics.js.map +1 -0
  84. package/dist/image-effect-native.d.ts +26 -0
  85. package/dist/image-effect-native.d.ts.map +1 -0
  86. package/dist/image-effect-native.js +73 -0
  87. package/dist/image-effect-native.js.map +1 -0
  88. package/dist/image-effect.d.ts +50 -0
  89. package/dist/image-effect.d.ts.map +1 -0
  90. package/dist/image-effect.js +290 -0
  91. package/dist/image-effect.js.map +1 -0
  92. package/dist/image-owner.d.ts +3 -0
  93. package/dist/image-owner.d.ts.map +1 -0
  94. package/dist/image-owner.js +11 -0
  95. package/dist/image-owner.js.map +1 -0
  96. package/dist/index.d.ts +45 -4
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +21 -2
  99. package/dist/index.js.map +1 -1
  100. package/dist/interaction-surface.d.ts +12 -0
  101. package/dist/interaction-surface.d.ts.map +1 -0
  102. package/dist/interaction-surface.js +36 -0
  103. package/dist/interaction-surface.js.map +1 -0
  104. package/dist/items.d.ts +20 -0
  105. package/dist/items.d.ts.map +1 -0
  106. package/dist/items.js +109 -0
  107. package/dist/items.js.map +1 -0
  108. package/dist/keyboard.d.ts +41 -0
  109. package/dist/keyboard.d.ts.map +1 -0
  110. package/dist/keyboard.js +256 -0
  111. package/dist/keyboard.js.map +1 -0
  112. package/dist/label.d.ts +7 -0
  113. package/dist/label.d.ts.map +1 -0
  114. package/dist/label.js +60 -0
  115. package/dist/label.js.map +1 -0
  116. package/dist/layout.d.ts +13 -0
  117. package/dist/layout.d.ts.map +1 -0
  118. package/dist/layout.js +348 -0
  119. package/dist/layout.js.map +1 -0
  120. package/dist/liquid-grid.d.ts +3 -0
  121. package/dist/liquid-grid.d.ts.map +1 -0
  122. package/dist/liquid-grid.js +24 -0
  123. package/dist/liquid-grid.js.map +1 -0
  124. package/dist/liquid.d.ts +18 -0
  125. package/dist/liquid.d.ts.map +1 -0
  126. package/dist/liquid.js +234 -0
  127. package/dist/liquid.js.map +1 -0
  128. package/dist/loader.d.ts +19 -0
  129. package/dist/loader.d.ts.map +1 -0
  130. package/dist/loader.js +173 -0
  131. package/dist/loader.js.map +1 -0
  132. package/dist/motion-scope.d.ts +39 -0
  133. package/dist/motion-scope.d.ts.map +1 -0
  134. package/dist/motion-scope.js +238 -0
  135. package/dist/motion-scope.js.map +1 -0
  136. package/dist/motion-states.d.ts +36 -0
  137. package/dist/motion-states.d.ts.map +1 -0
  138. package/dist/motion-states.js +165 -0
  139. package/dist/motion-states.js.map +1 -0
  140. package/dist/particles.d.ts +39 -0
  141. package/dist/particles.d.ts.map +1 -0
  142. package/dist/particles.js +265 -0
  143. package/dist/particles.js.map +1 -0
  144. package/dist/path.d.ts +12 -0
  145. package/dist/path.d.ts.map +1 -0
  146. package/dist/path.js +90 -0
  147. package/dist/path.js.map +1 -0
  148. package/dist/playback-group.d.ts +6 -0
  149. package/dist/playback-group.d.ts.map +1 -0
  150. package/dist/playback-group.js +141 -0
  151. package/dist/playback-group.js.map +1 -0
  152. package/dist/playback.d.ts +25 -0
  153. package/dist/playback.d.ts.map +1 -0
  154. package/dist/playback.js +35 -0
  155. package/dist/playback.js.map +1 -0
  156. package/dist/presets.d.ts +16 -1
  157. package/dist/presets.d.ts.map +1 -1
  158. package/dist/presets.js +24 -6
  159. package/dist/presets.js.map +1 -1
  160. package/dist/press.d.ts.map +1 -1
  161. package/dist/press.js +25 -7
  162. package/dist/press.js.map +1 -1
  163. package/dist/properties.d.ts +6 -3
  164. package/dist/properties.d.ts.map +1 -1
  165. package/dist/properties.js +26 -2
  166. package/dist/properties.js.map +1 -1
  167. package/dist/reveal.d.ts +25 -0
  168. package/dist/reveal.d.ts.map +1 -0
  169. package/dist/reveal.js +294 -0
  170. package/dist/reveal.js.map +1 -0
  171. package/dist/roll-diff.d.ts +22 -0
  172. package/dist/roll-diff.d.ts.map +1 -0
  173. package/dist/roll-diff.js +36 -0
  174. package/dist/roll-diff.js.map +1 -0
  175. package/dist/roll.d.ts +29 -0
  176. package/dist/roll.d.ts.map +1 -0
  177. package/dist/roll.js +442 -0
  178. package/dist/roll.js.map +1 -0
  179. package/dist/scroll-linked.d.ts +10 -0
  180. package/dist/scroll-linked.d.ts.map +1 -0
  181. package/dist/scroll-linked.js +58 -0
  182. package/dist/scroll-linked.js.map +1 -0
  183. package/dist/sequence.d.ts +13 -7
  184. package/dist/sequence.d.ts.map +1 -1
  185. package/dist/sequence.js +214 -19
  186. package/dist/sequence.js.map +1 -1
  187. package/dist/shimmer.d.ts +12 -0
  188. package/dist/shimmer.d.ts.map +1 -0
  189. package/dist/shimmer.js +161 -0
  190. package/dist/shimmer.js.map +1 -0
  191. package/dist/svg-path.d.ts +14 -0
  192. package/dist/svg-path.d.ts.map +1 -0
  193. package/dist/svg-path.js +281 -0
  194. package/dist/svg-path.js.map +1 -0
  195. package/dist/symbol-effect.d.ts +23 -0
  196. package/dist/symbol-effect.d.ts.map +1 -0
  197. package/dist/symbol-effect.js +187 -0
  198. package/dist/symbol-effect.js.map +1 -0
  199. package/dist/text-layout.d.ts +30 -0
  200. package/dist/text-layout.d.ts.map +1 -0
  201. package/dist/text-layout.js +185 -0
  202. package/dist/text-layout.js.map +1 -0
  203. package/dist/text.d.ts +17 -0
  204. package/dist/text.d.ts.map +1 -0
  205. package/dist/text.js +263 -0
  206. package/dist/text.js.map +1 -0
  207. package/dist/track.d.ts +3 -1
  208. package/dist/track.d.ts.map +1 -1
  209. package/dist/track.js +266 -13
  210. package/dist/track.js.map +1 -1
  211. package/dist/transition.d.ts +17 -1
  212. package/dist/transition.d.ts.map +1 -1
  213. package/dist/transition.js +41 -6
  214. package/dist/transition.js.map +1 -1
  215. package/dist/types.d.ts +50 -1
  216. package/dist/types.d.ts.map +1 -1
  217. package/dist/vue/index.d.ts +95 -0
  218. package/dist/vue/index.d.ts.map +1 -0
  219. package/dist/vue/index.js +163 -0
  220. package/dist/vue/index.js.map +1 -0
  221. package/dist/zoom-native.d.ts +30 -0
  222. package/dist/zoom-native.d.ts.map +1 -0
  223. package/dist/zoom-native.js +56 -0
  224. package/dist/zoom-native.js.map +1 -0
  225. package/dist/zoomable.d.ts +48 -0
  226. package/dist/zoomable.d.ts.map +1 -0
  227. package/dist/zoomable.js +249 -0
  228. package/dist/zoomable.js.map +1 -0
  229. package/package.json +33 -5
  230. package/platforms/android/include.gradle +7 -0
  231. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
  232. package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
  233. package/platforms/android/java/org/rafaelaugusto/jolt/ImageEffect.java +288 -0
  234. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
  235. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardObserver.java +97 -0
  236. package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
  237. package/platforms/android/java/org/rafaelaugusto/jolt/PhotoZoom.java +250 -0
  238. package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
  239. package/platforms/android/proguard-rules.pro +19 -0
  240. package/platforms/ios/build.xcconfig +1 -0
  241. package/platforms/ios/src/JoltImageEffect.h +25 -0
  242. package/platforms/ios/src/JoltImageEffect.mm +242 -0
  243. package/platforms/ios/src/JoltInteractiveAnimation.h +18 -0
  244. package/platforms/ios/src/JoltInteractiveAnimation.m +123 -0
  245. package/platforms/ios/src/JoltPhotoZoom.h +23 -0
  246. package/platforms/ios/src/JoltPhotoZoom.m +229 -0
  247. package/platforms/ios/src/module.modulemap +6 -0
  248. package/src/android-plugin.d.ts +155 -0
  249. package/src/animate.ts +278 -38
  250. package/src/color.ts +101 -0
  251. package/src/config.ts +84 -0
  252. package/src/decay.ts +45 -0
  253. package/src/decoration.ts +180 -0
  254. package/src/drag-input.ts +74 -0
  255. package/src/drag-physics.ts +110 -0
  256. package/src/drag.ts +395 -0
  257. package/src/driver/android-color.ts +170 -0
  258. package/src/driver/android-decoration.ts +182 -0
  259. package/src/driver/android-playback.ts +157 -0
  260. package/src/driver/android.ts +208 -20
  261. package/src/driver/index.ts +37 -2
  262. package/src/driver/ios-color.ts +441 -0
  263. package/src/driver/ios-decoration.ts +332 -0
  264. package/src/driver/ios-layout.ts +63 -0
  265. package/src/driver/ios-playback.ts +51 -0
  266. package/src/driver/ios-shadow.ts +50 -0
  267. package/src/driver/ios.ts +194 -25
  268. package/src/engine.ts +329 -27
  269. package/src/haptics.ts +148 -0
  270. package/src/image-effect-native.ts +129 -0
  271. package/src/image-effect.ts +336 -0
  272. package/src/image-owner.ts +12 -0
  273. package/src/index.ts +61 -4
  274. package/src/interaction-surface.ts +41 -0
  275. package/src/ios-plugin.d.ts +81 -0
  276. package/src/items.ts +140 -0
  277. package/src/keyboard.ts +291 -0
  278. package/src/label.ts +76 -0
  279. package/src/layout.ts +408 -0
  280. package/src/liquid-grid.ts +28 -0
  281. package/src/liquid.ts +287 -0
  282. package/src/loader.ts +202 -0
  283. package/src/motion-scope.ts +279 -0
  284. package/src/motion-states.ts +215 -0
  285. package/src/particles.ts +323 -0
  286. package/src/path.ts +124 -0
  287. package/src/playback-group.ts +143 -0
  288. package/src/playback.ts +62 -0
  289. package/src/presets.ts +31 -7
  290. package/src/press.ts +32 -10
  291. package/src/properties.ts +32 -6
  292. package/src/reveal.ts +374 -0
  293. package/src/roll-diff.ts +57 -0
  294. package/src/roll.ts +515 -0
  295. package/src/scroll-linked.ts +73 -0
  296. package/src/sequence.ts +212 -19
  297. package/src/shimmer.ts +180 -0
  298. package/src/svg-path.ts +355 -0
  299. package/src/symbol-effect.ts +226 -0
  300. package/src/text-layout.ts +228 -0
  301. package/src/text.ts +279 -0
  302. package/src/track.ts +347 -12
  303. package/src/transition.ts +63 -6
  304. package/src/types.ts +51 -1
  305. package/src/vue/index.ts +231 -0
  306. package/src/zoom-native.ts +124 -0
  307. package/src/zoomable.ts +330 -0
@@ -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
+ }
@@ -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
+ }