@rafaelaugusto/jolt 0.1.0-beta.0 → 0.1.0-beta.2

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 (58) hide show
  1. package/README.md +134 -14
  2. package/dist/animate.d.ts +3 -3
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +40 -19
  5. package/dist/animate.js.map +1 -1
  6. package/dist/driver/ios.d.ts.map +1 -1
  7. package/dist/driver/ios.js +19 -3
  8. package/dist/driver/ios.js.map +1 -1
  9. package/dist/engine.d.ts +7 -3
  10. package/dist/engine.d.ts.map +1 -1
  11. package/dist/engine.js +28 -5
  12. package/dist/engine.js.map +1 -1
  13. package/dist/haptics.d.ts +18 -0
  14. package/dist/haptics.d.ts.map +1 -0
  15. package/dist/haptics.js +96 -0
  16. package/dist/haptics.js.map +1 -0
  17. package/dist/index.d.ts +9 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +4 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/particles.d.ts +39 -0
  22. package/dist/particles.d.ts.map +1 -0
  23. package/dist/particles.js +264 -0
  24. package/dist/particles.js.map +1 -0
  25. package/dist/presets.d.ts +16 -1
  26. package/dist/presets.d.ts.map +1 -1
  27. package/dist/presets.js +24 -6
  28. package/dist/presets.js.map +1 -1
  29. package/dist/press.d.ts.map +1 -1
  30. package/dist/press.js +3 -1
  31. package/dist/press.js.map +1 -1
  32. package/dist/sequence.d.ts +3 -2
  33. package/dist/sequence.d.ts.map +1 -1
  34. package/dist/sequence.js +6 -5
  35. package/dist/sequence.js.map +1 -1
  36. package/dist/shimmer.d.ts +12 -0
  37. package/dist/shimmer.d.ts.map +1 -0
  38. package/dist/shimmer.js +160 -0
  39. package/dist/shimmer.js.map +1 -0
  40. package/dist/symbol-effect.d.ts +23 -0
  41. package/dist/symbol-effect.d.ts.map +1 -0
  42. package/dist/symbol-effect.js +186 -0
  43. package/dist/symbol-effect.js.map +1 -0
  44. package/dist/types.d.ts +5 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +6 -1
  47. package/src/animate.ts +64 -19
  48. package/src/driver/ios.ts +23 -3
  49. package/src/engine.ts +26 -6
  50. package/src/haptics.ts +125 -0
  51. package/src/index.ts +13 -2
  52. package/src/particles.ts +323 -0
  53. package/src/presets.ts +31 -7
  54. package/src/press.ts +7 -1
  55. package/src/sequence.ts +8 -5
  56. package/src/shimmer.ts +179 -0
  57. package/src/symbol-effect.ts +225 -0
  58. package/src/types.ts +5 -0
package/src/haptics.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { isIOS, type View } from '@nativescript/core'
2
+ import type { Driver } from './driver/index.js'
3
+ import type { Track } from './track.js'
4
+
5
+ export type HapticStyle = 'light' | 'medium' | 'heavy'
6
+
7
+ export interface HapticOptions {
8
+ style: HapticStyle
9
+ /** Points from 0 to 1 over the finite animation, excluding its initial delay. Defaults to arrival. */
10
+ times?: readonly number[]
11
+ }
12
+
13
+ export type Haptic = HapticStyle | HapticOptions | false
14
+
15
+ /** Validate before any running animation is interrupted. */
16
+ export function resolveHaptic(haptic: Haptic | undefined): HapticOptions | undefined {
17
+ if (haptic === undefined || haptic === false) return undefined
18
+ const options = typeof haptic === 'string' ? { style: haptic } : haptic
19
+ if (!options || !['light', 'medium', 'heavy'].includes(options.style))
20
+ throw new TypeError('Haptic style must be light, medium or heavy.')
21
+ const times = options.times
22
+ if (
23
+ times !== undefined &&
24
+ (!Array.isArray(times) ||
25
+ times.length === 0 ||
26
+ Array.from(times).some(
27
+ (time, i) =>
28
+ !Number.isFinite(time) ||
29
+ time <= 0 ||
30
+ time > 1 ||
31
+ (i > 0 && time <= times[i - 1]!),
32
+ ))
33
+ )
34
+ throw new RangeError(
35
+ 'Haptic times must be increasing points greater than 0 and at most 1.',
36
+ )
37
+ return { style: options.style, times: times && [...times] }
38
+ }
39
+
40
+ const PREPARE_AHEAD = 100
41
+ const MAX_LATENESS = 100
42
+ const COMPLETION_TOLERANCE = 20
43
+
44
+ /** Discrete feedback only: animation playback remains entirely native. */
45
+ export function playHaptics(
46
+ view: View,
47
+ tracks: readonly Track[],
48
+ options: HapticOptions | undefined,
49
+ driver: Driver,
50
+ startedAt: number,
51
+ ) {
52
+ if (!isIOS || !options || typeof UIImpactFeedbackGenerator === 'undefined') return
53
+ const moving = tracks.filter(
54
+ (track) =>
55
+ track.duration > 0 && track.values.some((value) => value !== track.values[0]),
56
+ )
57
+ if (!moving.length) return
58
+ const end = Math.max(...tracks.map((track) => track.end))
59
+ if (!Number.isFinite(end)) return
60
+ const delay = Math.min(...moving.map((track) => track.delay))
61
+ const offsets = options.times
62
+ ? options.times.map((time) => delay + (end - delay) * time)
63
+ : [Math.max(...tracks.map((track) => track.arrival))]
64
+ const styles = {
65
+ light: 0,
66
+ medium: 1,
67
+ heavy: 2,
68
+ } satisfies Record<HapticStyle, UIImpactFeedbackStyle>
69
+ let generator: UIImpactFeedbackGenerator | undefined =
70
+ UIImpactFeedbackGenerator.alloc().initWithStyle(styles[options.style])
71
+ const native = view.nativeViewProtected as UIView
72
+ let next = 0
73
+ let cancelPrepare: (() => void) | undefined
74
+ let cancelImpact: (() => void) | undefined
75
+
76
+ const active = () =>
77
+ view.isLoaded &&
78
+ view.nativeViewProtected === native &&
79
+ !!native.window &&
80
+ UIApplication.sharedApplication.applicationState === 0 // UIApplicationState.Active
81
+
82
+ const cancel = () => {
83
+ cancelPrepare?.()
84
+ cancelImpact?.()
85
+ cancelPrepare = cancelImpact = undefined
86
+ generator = undefined
87
+ view.off('unloaded', cancel)
88
+ }
89
+
90
+ const fire = (tolerance = 0) => {
91
+ const elapsed = driver.now() - startedAt
92
+ // A busy UI thread can delay timers. Drop stale impacts instead of replaying
93
+ // several old taps at once when it becomes responsive again.
94
+ while (next < offsets.length && offsets[next]! <= elapsed + tolerance) {
95
+ const offset = offsets[next++]!
96
+ if (active() && elapsed - offset <= MAX_LATENESS) generator?.impactOccurred()
97
+ }
98
+ }
99
+
100
+ const schedule = () => {
101
+ if (!generator || next === offsets.length) return cancel()
102
+ const remaining = startedAt + offsets[next]! - driver.now()
103
+ if (active()) generator.prepare()
104
+ if (remaining > PREPARE_AHEAD) {
105
+ cancelPrepare = driver.after(remaining - PREPARE_AHEAD, () => {
106
+ if (active()) generator?.prepare()
107
+ })
108
+ }
109
+ cancelImpact = driver.after(Math.max(0, remaining), () => {
110
+ fire()
111
+ schedule()
112
+ })
113
+ }
114
+
115
+ view.on('unloaded', cancel)
116
+ schedule()
117
+ return {
118
+ cancel,
119
+ finish() {
120
+ // Native completion may beat the timer at the final keyframe by a frame.
121
+ fire(COMPLETION_TOLERANCE)
122
+ cancel()
123
+ },
124
+ }
125
+ }
package/src/index.ts CHANGED
@@ -2,8 +2,19 @@ export { animate, stop } from './animate.js'
2
2
  export { sequence } from './sequence.js'
3
3
  export type { Step } from './sequence.js'
4
4
  export { press } from './press.js'
5
- export { presets } from './presets.js'
6
- export type { PresetName } from './presets.js'
5
+ export type { Haptic, HapticOptions, HapticStyle } from './haptics.js'
6
+ export { confetti, particles } from './particles.js'
7
+ export type { ConfettiOptions, ParticleOptions } from './particles.js'
8
+ export { shimmer } from './shimmer.js'
9
+ export type { ShimmerOptions } from './shimmer.js'
10
+ export { symbolEffect } from './symbol-effect.js'
11
+ export type {
12
+ SymbolEffect,
13
+ SymbolEffectName,
14
+ SymbolEffectOptions,
15
+ } from './symbol-effect.js'
16
+ export { definePreset, presets } from './presets.js'
17
+ export type { Preset, PresetName, Presets } from './presets.js'
7
18
  export { springs } from './transition.js'
8
19
  export type { Spring, SpringName, Timing, Transition, Tween } from './transition.js'
9
20
  export { cubicBezier } from './easing.js'
@@ -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
+
5
+ export interface ConfettiOptions {
6
+ /** Approximate number of particles in the burst. Defaults to 80. */
7
+ count?: number
8
+ colors?: readonly string[]
9
+ }
10
+
11
+ export interface ParticleOptions {
12
+ colors?: readonly string[]
13
+ shape?: 'circle' | 'rectangle' | 'ring'
14
+ /** Center point, or a line across the top/bottom of the target. */
15
+ source?: 'center' | 'top' | 'bottom'
16
+ /** Total particles per second, shared across all colors. Defaults to 20. */
17
+ rate?: number
18
+ /** Particle lifetime in milliseconds. Defaults to 3000. */
19
+ lifetime?: number
20
+ /** Longest side in DIPs. Defaults to 6. */
21
+ size?: number
22
+ /** DIPs per second. Defaults to 60. */
23
+ velocity?: number
24
+ velocityRange?: number
25
+ /** Degrees clockwise from right: -90 is up, 90 is down. Defaults to -90. */
26
+ angle?: number
27
+ /** Emission cone in degrees, from 0 to 360. Defaults to 30. */
28
+ spread?: number
29
+ /** Downward acceleration in DIPs per second squared. Defaults to 0. */
30
+ gravity?: number
31
+ /** Degrees per second. Defaults to 0. */
32
+ spin?: number
33
+ spinRange?: number
34
+ /** Fade each particle over its lifetime. Defaults to true. */
35
+ fadeOut?: boolean
36
+ /** Emit for this many milliseconds, then let particles expire. Omit to keep emitting. */
37
+ duration?: number
38
+ }
39
+
40
+ type Settings = Required<Omit<ParticleOptions, 'duration'>> & { duration?: number }
41
+ const bursts = new WeakMap<View, () => void>()
42
+ const emitters = new WeakMap<View, () => void>()
43
+ const palette = ['#facc15', '#ef4444', '#3b82f6', '#22c55e', '#a855f7']
44
+ const textures = new Map<Settings['shape'], UIImage>()
45
+ const radians = (degrees: number) => (degrees * Math.PI) / 180
46
+ const BURST_DURATION = 200
47
+
48
+ /** Bursts from the target's center into its window. iOS only; returns immediate cleanup. */
49
+ export function confetti(target: Target, options: ConfettiOptions = {}): () => void {
50
+ const count = number('count', options.count ?? 80, 0, 10000)
51
+ if (!Number.isInteger(count)) throw new RangeError('Particle count must be an integer.')
52
+ const settings = resolve(
53
+ {
54
+ colors: options.colors ?? palette,
55
+ shape: 'rectangle',
56
+ rate: count / (BURST_DURATION / 1000),
57
+ duration: BURST_DURATION,
58
+ lifetime: 2200,
59
+ size: 8,
60
+ velocity: 260,
61
+ velocityRange: 100,
62
+ angle: -90,
63
+ spread: 100,
64
+ gravity: 420,
65
+ spinRange: 360,
66
+ },
67
+ true,
68
+ )
69
+ return start(target, settings, true)
70
+ }
71
+
72
+ /** Adds a native particle emitter to the target. iOS only; returns immediate cleanup. */
73
+ export function particles(target: Target, options: ParticleOptions = {}): () => void {
74
+ return start(target, resolve(options), false)
75
+ }
76
+
77
+ function start(target: Target, settings: Settings, overlay: boolean): () => void {
78
+ const view = toView(target)
79
+ const running = overlay ? bursts : emitters
80
+ running.get(view)?.()
81
+ if (!isIOS || settings.rate === 0 || UIAccessibilityIsReduceMotionEnabled())
82
+ 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 (UIAccessibilityIsReduceMotionEnabled()) this.stop()
195
+ }
196
+
197
+ private update() {
198
+ if (!this.view.isLoaded) return
199
+ if (UIAccessibilityIsReduceMotionEnabled()) 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/presets.ts CHANGED
@@ -5,6 +5,9 @@ export interface Preset {
5
5
  transition: AnimateOptions
6
6
  }
7
7
 
8
+ /** Add custom preset names through module augmentation, with `true` as each value. */
9
+ export interface Presets {}
10
+
8
11
  const fadeIn = { duration: 300, ease: 'easeOut' } as const
9
12
  const fadeOut = { duration: 200, ease: 'easeIn' } as const
10
13
  const enter = { type: 'spring', duration: 500, bounce: 0 } as const
@@ -77,28 +80,30 @@ export const presets = {
77
80
 
78
81
  pulse: {
79
82
  keyframes: { scale: [1, 1.06, 1] },
80
- transition: { duration: 500, ease: 'easeInOut' },
83
+ transition: { duration: 500, ease: 'easeInOut', relative: true },
81
84
  },
82
85
  bounce: {
83
86
  keyframes: { y: [0, -18, 0, -8, 0] },
84
87
  transition: {
85
88
  duration: 650,
89
+ relative: true,
86
90
  times: [0, 0.3, 0.6, 0.8, 1],
87
91
  ease: ['easeOut', 'easeIn', 'easeOut', 'easeIn'],
88
92
  },
89
93
  },
90
94
  shake: {
91
95
  keyframes: { x: [0, -10, 10, -8, 8, -4, 4, 0] },
92
- transition: { duration: 500, ease: 'easeInOut' },
96
+ transition: { duration: 500, ease: 'easeInOut', relative: true },
93
97
  },
94
98
  wiggle: {
95
99
  keyframes: { rotate: [0, -8, 8, -5, 5, 0] },
96
- transition: { duration: 500, ease: 'easeInOut' },
100
+ transition: { duration: 500, ease: 'easeInOut', relative: true },
97
101
  },
98
102
  heartbeat: {
99
103
  keyframes: { scale: [1, 1.15, 1, 1.15, 1, 1] },
100
104
  transition: {
101
105
  duration: 1000,
106
+ relative: true,
102
107
  times: [0, 0.14, 0.28, 0.42, 0.7, 1],
103
108
  ease: 'easeInOut',
104
109
  },
@@ -110,25 +115,44 @@ export const presets = {
110
115
  },
111
116
  transition: {
112
117
  duration: 800,
118
+ relative: true,
113
119
  times: [0, 0.3, 0.4, 0.5, 0.65, 0.75, 1],
114
120
  ease: 'easeInOut',
115
121
  },
116
122
  },
117
123
  flash: {
118
124
  keyframes: { opacity: [1, 0, 1, 0, 1] },
119
- transition: { duration: 800, ease: 'easeInOut' },
125
+ transition: { duration: 800, ease: 'easeInOut', relative: true },
120
126
  },
121
127
  tada: {
122
128
  keyframes: {
123
129
  scale: [1, 0.9, 0.9, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1],
124
130
  rotate: [0, -3, -3, 3, -3, 3, -3, 3, -3, 3, 0],
125
131
  },
126
- transition: { duration: 1000, ease: 'easeInOut' },
132
+ transition: { duration: 1000, ease: 'easeInOut', relative: true },
127
133
  },
128
134
  spin: {
129
135
  keyframes: { rotate: [0, 360] },
130
- transition: { duration: 1000, ease: 'linear' },
136
+ transition: { duration: 1000, ease: 'linear', relative: true },
131
137
  },
132
138
  } satisfies Record<string, Preset>
133
139
 
134
- export type PresetName = keyof typeof presets
140
+ export type PresetName = keyof typeof presets | Extract<keyof Presets, string>
141
+
142
+ const customPresets = new Map<string, Preset>()
143
+
144
+ /** Registers a custom preset. Registering it again replaces it for future animations. */
145
+ export function definePreset(name: PresetName, preset: Preset): void {
146
+ if (Object.hasOwn(presets, name)) {
147
+ throw new Error(`Preset "${name}" is built in and cannot be replaced.`)
148
+ }
149
+ customPresets.set(name, preset)
150
+ }
151
+
152
+ export function getPreset(name: PresetName): Preset {
153
+ const preset =
154
+ customPresets.get(name) ??
155
+ (Object.hasOwn(presets, name) ? presets[name as keyof typeof presets] : undefined)
156
+ if (!preset) throw new Error(`Unknown preset "${name}".`)
157
+ return preset
158
+ }
package/src/press.ts CHANGED
@@ -33,7 +33,13 @@ export function press(
33
33
  rest = Object.fromEntries(properties.map((p) => [p, readValue(view, p)]))
34
34
  animate(view, pressed, options ?? pressIn)
35
35
  } else if ((action === 'up' || action === 'cancel') && rest) {
36
- animate(view, rest, options ?? release)
36
+ animate(
37
+ view,
38
+ rest,
39
+ typeof options === 'object'
40
+ ? { ...options, relative: false }
41
+ : (options ?? release),
42
+ )
37
43
  rest = undefined
38
44
  }
39
45
  }
package/src/sequence.ts CHANGED
@@ -17,8 +17,9 @@ export type Step =
17
17
  /**
18
18
  * Plays steps one after the other. A step starts once the previous one looks
19
19
  * done: a spring is close to its target, even if it hasn't fully settled.
20
- * Views in later steps take their first keyframe right away, so a title that
21
- * fades in third doesn't show up before its turn.
20
+ * Views in later steps take their first absolute keyframe right away, so a title
21
+ * that fades in third doesn't show up before its turn. Relative and null keyframes
22
+ * read the current value when their step starts.
22
23
  *
23
24
  * ```ts
24
25
  * await sequence([
@@ -64,14 +65,16 @@ function applyStartingValues(steps: readonly Step[]) {
64
65
  const claimed = new Map<View, Set<Property>>()
65
66
  for (const step of steps) {
66
67
  if (typeof step === 'function') continue
67
- const [target, animation] = step
68
- const starts = startingValues(animation)
68
+ const [target, animation, options] = step
69
+ const starts = startingValues(animation, options)
69
70
 
70
71
  for (const view of toViews(target)) {
71
72
  let properties = claimed.get(view)
72
73
  if (!properties) claimed.set(view, (properties = new Set()))
73
74
 
74
- const fresh = [...starts].filter(([property]) => !properties.has(property))
75
+ const fresh = [...starts].filter(
76
+ ([property, value]) => !properties.has(property) && value !== null,
77
+ )
75
78
  stopView(
76
79
  view,
77
80
  fresh.map(([property]) => property),