@croxton/buttery 0.0.0-stage → 1.0.0

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/lib/buttery.js ADDED
@@ -0,0 +1,1148 @@
1
+ /**
2
+ * Buttery - scroll animation library
3
+ * Performance focused, lightweight scroll animation library
4
+ *
5
+ * A fork of basicScroll.js by Tobias Reich (@electerious)
6
+ * Created and maintained by Mark Croxton (@croxton)
7
+ *
8
+ * - Uses native scroll-driven animations (ScrollTimeline + Web Animations) when supported,
9
+ * so values update in sync with scrolling without per-frame JavaScript.
10
+ * - Falls back to an event-driven requestAnimationFrame loop otherwise.
11
+ * - Instances with `inside`/`outside` callbacks use the fallback by default (callbacks need JS per frame).
12
+ * - Clamps scrollTop to avoid rubber-band (overscroll) bounce affecting animations
13
+ * - Uses a stable viewport height that doesn't change when the iOS toolbar collapses/expands
14
+ * - Ignores height-only resize events (iOS address bar show/hide) for recalculation
15
+ * - Skips redundant style writes
16
+ */
17
+
18
+ import parseUnit from 'parse-unit'
19
+ import eases from 'eases'
20
+
21
+ let instances = []
22
+ let frameId = null
23
+ let pendingForce = false
24
+ let init = false
25
+ let previousScrollTop = null
26
+ let maxScrollTop = null
27
+ let viewportHeight = null
28
+ let viewportWidth = null
29
+ let listeners = null
30
+ let resizeObserver = null
31
+ let timelineFrameId = null
32
+ let lastTimelineMax = null
33
+ let scrollTimeline = null
34
+ let timelineSupport = null
35
+ let settings = {
36
+ recalculateOnResize: false,
37
+ timeline: true
38
+ }
39
+
40
+ // Number of samples used to approximate non-linear easing in timeline keyframes
41
+ const TIMELINE_SAMPLES = 30
42
+
43
+ // Last applied style values per instance, used to avoid redundant writes
44
+ const styleCache = new WeakMap()
45
+
46
+ // Custom properties registered by this library: name => syntax (null = registered elsewhere)
47
+ const registeredProperties = new Map()
48
+
49
+ /**
50
+ * Debounces a function that will be triggered many times.
51
+ * @param {Function} fn
52
+ * @param {Number} duration
53
+ * @returns {Function}
54
+ */
55
+ const debounce = function(fn, duration) {
56
+
57
+ let timeout = null
58
+
59
+ const debounced = (...args) => {
60
+ clearTimeout(timeout)
61
+ timeout = setTimeout(() => fn(...args), duration)
62
+ }
63
+
64
+ debounced.cancel = () => clearTimeout(timeout)
65
+
66
+ return debounced
67
+
68
+ }
69
+
70
+ /**
71
+ * Returns all active instances that are driven by the JS loop.
72
+ * @param {Array} instances
73
+ * @returns {Array} instances
74
+ */
75
+ const getLoopInstances = function(instances) {
76
+
77
+ return instances.filter((instance) => instance != null && instance.isActive() && instance.usesTimeline() === false)
78
+
79
+ }
80
+
81
+ /**
82
+ * Returns all tracked instances from an array.
83
+ * @param {Array} instances
84
+ * @returns {Array} instances - Tracked instances.
85
+ */
86
+ const getTrackedInstances = function(instances) {
87
+
88
+ return instances.filter((instance) => instance != null && instance.getData().track)
89
+
90
+ }
91
+
92
+ /**
93
+ * Checks if native scroll-driven animations can be used.
94
+ * @returns {Boolean}
95
+ */
96
+ export const supportsTimeline = function() {
97
+
98
+ if (timelineSupport == null) {
99
+ timelineSupport = typeof window !== 'undefined'
100
+ && typeof window.ScrollTimeline === 'function'
101
+ && typeof window.Animation === 'function'
102
+ && typeof window.KeyframeEffect === 'function'
103
+ && window.CSS != null
104
+ && typeof window.CSS.registerProperty === 'function'
105
+ }
106
+
107
+ return timelineSupport
108
+
109
+ }
110
+
111
+ /**
112
+ * Returns the scrolling element.
113
+ * @returns {Element}
114
+ */
115
+ const getScrollingElement = function() {
116
+
117
+ return document.scrollingElement || document.documentElement
118
+
119
+ }
120
+
121
+ /**
122
+ * Returns the shared document scroll timeline.
123
+ * @returns {ScrollTimeline}
124
+ */
125
+ const getScrollTimeline = function() {
126
+
127
+ if (scrollTimeline == null) {
128
+ scrollTimeline = new ScrollTimeline({
129
+ source: getScrollingElement(),
130
+ axis: 'block'
131
+ })
132
+ }
133
+
134
+ return scrollTimeline
135
+
136
+ }
137
+
138
+ /**
139
+ * Returns the raw number of scrolled pixels.
140
+ * Can be negative or exceed the max scroll position during iOS rubber-band overscroll.
141
+ * Use this for measuring element positions (it stays consistent with getBoundingClientRect).
142
+ * @returns {Number} scrollTop
143
+ */
144
+ const getRawScrollTop = function() {
145
+
146
+ return getScrollingElement().scrollTop
147
+
148
+ }
149
+
150
+ /**
151
+ * Returns the maximum scrollable distance.
152
+ * @returns {Number}
153
+ */
154
+ const getMaxScrollTop = function() {
155
+
156
+ const elem = getScrollingElement()
157
+
158
+ return Math.max(0, elem.scrollHeight - elem.clientHeight)
159
+
160
+ }
161
+
162
+ /**
163
+ * Returns the number of scrolled pixels, clamped to the scrollable range.
164
+ * This prevents iOS rubber-band overscroll from making animations bounce.
165
+ * @returns {Number} scrollTop
166
+ */
167
+ const getScrollTop = function() {
168
+
169
+ const scrollTop = getRawScrollTop()
170
+
171
+ if (scrollTop <= 0) return 0
172
+
173
+ // Only re-read the (layout dependent) max when it might be stale
174
+ if (maxScrollTop == null || scrollTop > maxScrollTop) maxScrollTop = getMaxScrollTop()
175
+
176
+ return Math.min(scrollTop, maxScrollTop)
177
+
178
+ }
179
+
180
+ /**
181
+ * Returns a stable height of the viewport.
182
+ * documentElement.clientHeight doesn't change when the iOS Safari toolbar
183
+ * collapses/expands, unlike window.innerHeight, so calculated positions stay put.
184
+ * @returns {Number} viewportHeight
185
+ */
186
+ const getViewportHeight = function() {
187
+
188
+ if (viewportHeight == null) {
189
+ viewportHeight = document.documentElement.clientHeight || window.innerHeight || window.outerHeight
190
+ }
191
+
192
+ return viewportHeight
193
+
194
+ }
195
+
196
+ /**
197
+ * Returns the width of the viewport.
198
+ * @returns {Number} viewportWidth
199
+ */
200
+ const getViewportWidth = function() {
201
+
202
+ return document.documentElement.clientWidth || window.innerWidth
203
+
204
+ }
205
+
206
+ /**
207
+ * Checks if a value is absolute.
208
+ * An absolute value must have a value that's not NaN.
209
+ * @param {String|Integer} value
210
+ * @returns {Boolean} isAbsolute
211
+ */
212
+ const isAbsoluteValue = function(value) {
213
+
214
+ return isNaN(parseUnit(value)[0]) === false
215
+
216
+ }
217
+
218
+ /**
219
+ * Parses an absolute value.
220
+ * @param {String|Integer} value
221
+ * @returns {Object} value - Parsed value.
222
+ */
223
+ const parseAbsoluteValue = function(value) {
224
+
225
+ const parsedValue = parseUnit(value)
226
+
227
+ return {
228
+ value: parsedValue[0],
229
+ unit: parsedValue[1]
230
+ }
231
+
232
+ }
233
+
234
+ /**
235
+ * Checks if a value is relative.
236
+ * A relative value must start and end with [a-z] and needs a '-' in the middle.
237
+ * @param {String|Integer} value
238
+ * @returns {Boolean} isRelative
239
+ */
240
+ const isRelativeValue = function(value) {
241
+
242
+ return String(value).match(/^[a-z0-9%]+-[a-z0-9%]+$/) !== null
243
+
244
+ }
245
+
246
+ /**
247
+ * Returns the property that should be used according to direct.
248
+ * @param {Boolean|Node} direct
249
+ * @param {Object} properties
250
+ * @returns {*}
251
+ */
252
+ const mapDirectToProperty = function(direct, properties) {
253
+
254
+ if (direct === true) return properties.elem
255
+ if (direct instanceof HTMLElement) return properties.direct
256
+
257
+ return properties.global
258
+
259
+ }
260
+
261
+ /**
262
+ * Returns the element styles should be applied to.
263
+ * @param {Object} data
264
+ * @returns {?Element}
265
+ */
266
+ const getElem = function(data) {
267
+
268
+ return mapDirectToProperty(data.direct, {
269
+ global: document.documentElement,
270
+ elem: data.elem,
271
+ direct: data.direct
272
+ })
273
+
274
+ }
275
+
276
+ /**
277
+ * Converts a relative value to an absolute value.
278
+ * @param {String} value
279
+ * @param {Node} elem - Anchor of the relative value.
280
+ * @param {?Integer} scrollTop - Pixels scrolled in document (raw, unclamped).
281
+ * @param {?Integer} vh - Height of the viewport.
282
+ * @returns {String} value - Absolute value.
283
+ */
284
+ const relativeToAbsoluteValue = function(value, elem, scrollTop = getRawScrollTop(), vh = getViewportHeight()) {
285
+
286
+ const elemSize = elem.getBoundingClientRect()
287
+
288
+ const elemAnchor = value.match(/^[a-z0-9%]+/)[0]
289
+ const viewportAnchor = value.match(/[a-z0-9%]+$/)[0]
290
+
291
+ // Absolute document position of the element
292
+ const elemTop = elemSize.top + scrollTop
293
+
294
+ let y = 0
295
+
296
+ if (viewportAnchor.match(/%$/) !== null) {
297
+ const percent = parseFloat(viewportAnchor.replace('%', '')) / 100
298
+ y -= vh * percent
299
+ } else {
300
+ if (viewportAnchor === 'top') y -= 0
301
+ if (viewportAnchor === 'middle') y -= vh / 2
302
+ if (viewportAnchor === 'bottom') y -= vh
303
+ }
304
+
305
+ if (elemAnchor.match(/%$/) !== null) {
306
+ const percent = parseFloat(elemAnchor.replace('%', '')) / 100
307
+ y += elemTop + elemSize.height * percent
308
+ } else {
309
+ if (elemAnchor === 'top') y += elemTop
310
+ if (elemAnchor === 'middle') y += elemTop + elemSize.height / 2
311
+ if (elemAnchor === 'bottom') y += elemTop + elemSize.height
312
+ }
313
+
314
+ return `${ y }px`
315
+
316
+ }
317
+
318
+ /**
319
+ * Validates data and sets defaults for undefined properties.
320
+ * @param {?Object} data
321
+ * @returns {Object} data - Validated data.
322
+ */
323
+ const validate = function(data = {}) {
324
+
325
+ // Copy root object to avoid changes by reference
326
+ data = Object.assign({}, data)
327
+
328
+ // Remember if callbacks were provided, before defaults are applied
329
+ data.hasCallbacks = data.inside != null || data.outside != null
330
+
331
+ if (data.inside == null) data.inside = () => {}
332
+ if (data.outside == null) data.outside = () => {}
333
+ if (data.direct == null) data.direct = false
334
+ if (data.track == null) data.track = true
335
+ if (data.props == null) data.props = {}
336
+ if (data.timeline == null) data.timeline = 'auto'
337
+
338
+ if (data.from == null) throw new Error('Missing property `from`')
339
+ if (data.to == null) throw new Error('Missing property `to`')
340
+ if (typeof data.inside !== 'function') throw new Error('Property `inside` must be undefined or a function')
341
+ if (typeof data.outside !== 'function') throw new Error('Property `outside` must be undefined or a function')
342
+ if (typeof data.direct !== 'boolean' && !(data.direct instanceof HTMLElement)) throw new Error('Property `direct` must be undefined, a boolean or a DOM element/node')
343
+ if (data.direct === true && data.elem == null) throw new Error('Property `elem` is required when `direct` is true')
344
+ if (typeof data.track !== 'boolean') throw new Error('Property `track` must be undefined or a boolean')
345
+ if (typeof data.props !== 'object') throw new Error('Property `props` must be undefined or an object')
346
+ if (data.timeline !== 'auto' && typeof data.timeline !== 'boolean') throw new Error('Property `timeline` must be undefined, a boolean or \'auto\'')
347
+
348
+ if (data.elem == null) {
349
+
350
+ if (isAbsoluteValue(data.from) === false) throw new Error('Property `from` must be a absolute value when no `elem` has been provided')
351
+ if (isAbsoluteValue(data.to) === false) throw new Error('Property `to` must be a absolute value when no `elem` has been provided')
352
+
353
+ } else {
354
+
355
+ if (isRelativeValue(data.from) === true) data.from = relativeToAbsoluteValue(data.from, data.elem)
356
+ if (isRelativeValue(data.to) === true) data.to = relativeToAbsoluteValue(data.to, data.elem)
357
+
358
+ }
359
+
360
+ data.from = parseAbsoluteValue(data.from)
361
+ data.to = parseAbsoluteValue(data.to)
362
+
363
+ // Create a new props object to avoid changes by reference
364
+ data.props = Object.keys(data.props).reduce((acc, key) => {
365
+
366
+ // Copy prop object to avoid changes by reference
367
+ const prop = Object.assign({}, data.props[key])
368
+
369
+ if (isAbsoluteValue(prop.from) === false) throw new Error('Property `from` of prop must be a absolute value')
370
+ if (isAbsoluteValue(prop.to) === false) throw new Error('Property `to` of prop must be a absolute value')
371
+ if (prop.via != null && isAbsoluteValue(prop.via) === false) throw new Error('Property `via` of prop must be a absolute value')
372
+
373
+ prop.from = parseAbsoluteValue(prop.from)
374
+ prop.to = parseAbsoluteValue(prop.to)
375
+ if (prop.via != null) prop.via = parseAbsoluteValue(prop.via)
376
+
377
+ if (prop.timing == null) prop.timing = eases['linear']
378
+
379
+ if (typeof prop.timing !== 'string' && typeof prop.timing !== 'function') throw new Error('Property `timing` of prop must be undefined, a string or a function')
380
+
381
+ if (typeof prop.timing === 'string' && eases[prop.timing] == null) throw new Error('Unknown timing for property `timing` of prop')
382
+ if (typeof prop.timing === 'string') prop.timing = eases[prop.timing]
383
+
384
+ acc[key] = prop
385
+
386
+ return acc
387
+
388
+ }, {})
389
+
390
+ return data
391
+
392
+ }
393
+
394
+ /**
395
+ * Calculates prop values for a given scroll position. Pure: no callbacks, no DOM writes.
396
+ * @param {Object} data - Validated data.
397
+ * @param {Number} scrollTop - Pixels scrolled in document (clamped).
398
+ * @returns {Object} precisePercentage and props
399
+ */
400
+ const computeProps = function(data, scrollTop) {
401
+
402
+ // 100% in pixel
403
+ const total = data.to.value - data.from.value
404
+
405
+ // Pixel scrolled
406
+ const current = scrollTop - data.from.value
407
+
408
+ // Percent scrolled (guard against division by zero)
409
+ const precisePercentage = total === 0
410
+ ? (current >= 0 ? 100 : 0)
411
+ : current / (total / 100)
412
+ const normalizedPercentage = Math.min(Math.max(precisePercentage, 0), 100)
413
+
414
+ // All easing functions only remap a time value, and all have the same signature.
415
+ const normalizedTime = normalizedPercentage / 100
416
+
417
+ const props = {}
418
+
419
+ for (const key of Object.keys(data.props)) {
420
+
421
+ const prop = data.props[key]
422
+
423
+ // Use the unit of from OR to. It's valid to animate from '0' to '100px' and
424
+ // '0' should be treated as 'px', too. Unit will be an empty string when no unit given.
425
+ const unit = prop.from.unit || prop.to.unit
426
+
427
+ const time = prop.timing(normalizedTime)
428
+
429
+ let value
430
+
431
+ if (prop.via != null) {
432
+ const midpoint = 0.5
433
+
434
+ if (time <= midpoint) {
435
+ const localTime = time / midpoint
436
+ const diff = prop.from.value - prop.via.value
437
+
438
+ value = prop.from.value - diff * localTime
439
+ } else {
440
+ const localTime = (time - midpoint) / midpoint
441
+ const diff = prop.via.value - prop.to.value
442
+
443
+ value = prop.via.value - diff * localTime
444
+ }
445
+ } else {
446
+ const diff = prop.from.value - prop.to.value
447
+
448
+ value = prop.from.value - diff * time
449
+ }
450
+
451
+ // Round to avoid unprecise values.
452
+ const rounded = Math.round(value * 10000) / 10000
453
+
454
+ props[key] = rounded + unit
455
+
456
+ }
457
+
458
+ return {
459
+ precisePercentage,
460
+ props
461
+ }
462
+
463
+ }
464
+
465
+ /**
466
+ * Calculates the props of an instance and executes its callbacks.
467
+ * @param {Object} instance
468
+ * @param {?Integer} scrollTop - Pixels scrolled in document (clamped).
469
+ * @returns {Object} Calculated props and the element to apply styles to.
470
+ */
471
+ const getProps = function(instance, scrollTop = getScrollTop()) {
472
+
473
+ const data = instance.getData()
474
+
475
+ const { precisePercentage, props } = computeProps(data, scrollTop)
476
+
477
+ // Use precise percentage to check if the viewport is between from and to.
478
+ const isInside = (precisePercentage >= 0 && precisePercentage <= 100)
479
+
480
+ // Execute callbacks
481
+ if (isInside === true) data.inside(instance, precisePercentage, props)
482
+ else data.outside(instance, precisePercentage, props)
483
+
484
+ return {
485
+ elem: getElem(data),
486
+ props
487
+ }
488
+
489
+ }
490
+
491
+ /**
492
+ * Adds properties to a given style object, skipping values that haven't changed.
493
+ * @param {Node} elem - Styles will be applied to this element.
494
+ * @param {Object} props - Object of props.
495
+ * @param {?Object} cache - Last applied values.
496
+ */
497
+ const setProps = function(elem, props, cache = null) {
498
+
499
+ if (elem == null) return
500
+
501
+ for (const key in props) {
502
+
503
+ const value = props[key]
504
+
505
+ if (cache != null) {
506
+ if (cache[key] === value) continue
507
+ cache[key] = value
508
+ }
509
+
510
+ elem.style.setProperty(key, value)
511
+
512
+ }
513
+
514
+ }
515
+
516
+ /**
517
+ * Returns the style cache of an instance.
518
+ * @param {Object} instance
519
+ * @returns {Object}
520
+ */
521
+ const getStyleCache = function(instance) {
522
+
523
+ let cache = styleCache.get(instance)
524
+
525
+ if (cache == null) {
526
+ cache = {}
527
+ styleCache.set(instance, cache)
528
+ }
529
+
530
+ return cache
531
+
532
+ }
533
+
534
+ /**
535
+ * Returns a subset of props.
536
+ * @param {Object} props
537
+ * @param {Array} keys
538
+ * @returns {Object}
539
+ */
540
+ const pickProps = function(props, keys) {
541
+
542
+ return keys.reduce((acc, key) => {
543
+ acc[key] = props[key]
544
+ return acc
545
+ }, {})
546
+
547
+ }
548
+
549
+ /**
550
+ * Returns the keys of props whose value actually changes while scrolling.
551
+ * @param {Object} data
552
+ * @returns {Array}
553
+ */
554
+ const getAnimatedKeys = function(data) {
555
+
556
+ return Object.keys(data.props).filter((key) => {
557
+
558
+ const prop = data.props[key]
559
+ const isConstant = prop.from.value === prop.to.value && (prop.via == null || prop.via.value === prop.from.value)
560
+
561
+ return isConstant === false
562
+
563
+ })
564
+
565
+ }
566
+
567
+ /**
568
+ * Returns the CSS syntax and initial value to register a custom property with.
569
+ * @param {String} unit
570
+ * @returns {Object}
571
+ */
572
+ const getSyntaxForUnit = function(unit) {
573
+
574
+ if (unit === '') return { syntax: '<number>', initialValue: '0' }
575
+ if (['deg', 'grad', 'rad', 'turn'].includes(unit)) return { syntax: '<angle>', initialValue: '0deg' }
576
+ if (['s', 'ms'].includes(unit)) return { syntax: '<time>', initialValue: '0s' }
577
+
578
+ return { syntax: '<length-percentage>', initialValue: '0px' }
579
+
580
+ }
581
+
582
+ /**
583
+ * Registers a custom property so it can be interpolated by the animation engine.
584
+ * @param {String} name
585
+ * @param {String} unit
586
+ * @returns {Boolean} Whether the property can be animated smoothly.
587
+ */
588
+ const registerCustomProperty = function(name, unit) {
589
+
590
+ const { syntax, initialValue } = getSyntaxForUnit(unit)
591
+
592
+ if (registeredProperties.has(name)) {
593
+ const existing = registeredProperties.get(name)
594
+
595
+ // null = registered elsewhere (e.g. @property in CSS), trust it
596
+ return existing === null || existing === syntax
597
+ }
598
+
599
+ try {
600
+ CSS.registerProperty({ name, syntax, inherits: true, initialValue })
601
+ registeredProperties.set(name, syntax)
602
+ return true
603
+ } catch (error) {
604
+ if (error != null && error.name === 'InvalidModificationError') {
605
+ // Already registered, e.g. with @property in CSS
606
+ registeredProperties.set(name, null)
607
+ return true
608
+ }
609
+ return false
610
+ }
611
+
612
+ }
613
+
614
+ /**
615
+ * Converts a CSS property name to a keyframe key.
616
+ * @param {String} key
617
+ * @returns {String}
618
+ */
619
+ const toKeyframeKey = function(key) {
620
+
621
+ if (key.startsWith('--')) return key
622
+
623
+ return key.replace(/-([a-z])/g, (match, char) => char.toUpperCase())
624
+
625
+ }
626
+
627
+ /**
628
+ * Checks if an instance can use a native scroll timeline.
629
+ * @param {Object} data
630
+ * @param {Array} animatedKeys
631
+ * @returns {Boolean}
632
+ */
633
+ const canUseTimeline = function(data, animatedKeys) {
634
+
635
+ if (settings.timeline === false) return false
636
+ if (data.timeline === false) return false
637
+
638
+ // Callbacks need to run per frame, which requires the JS loop
639
+ if (data.timeline === 'auto' && data.hasCallbacks === true) return false
640
+
641
+ if (supportsTimeline() === false) return false
642
+
643
+ return animatedKeys.every((key) => {
644
+ if (key.startsWith('--') === false) return true
645
+
646
+ const prop = data.props[key]
647
+
648
+ return registerCustomProperty(key, prop.from.unit || prop.to.unit)
649
+ })
650
+
651
+ }
652
+
653
+ /**
654
+ * Builds keyframes mapped onto the document scroll range (0 → max).
655
+ * Easing and `via` are sampled so the result matches the JS calculation.
656
+ * @param {Object} data
657
+ * @param {Array} keys - Animated prop keys.
658
+ * @param {Number} max - Max scroll distance.
659
+ * @returns {Array} keyframes
660
+ */
661
+ const buildKeyframes = function(data, keys, max) {
662
+
663
+ const from = data.from.value
664
+ const to = data.to.value
665
+
666
+ const isLinear = keys.every((key) => data.props[key].timing === eases['linear'])
667
+ const hasVia = keys.some((key) => data.props[key].via != null)
668
+ const segments = isLinear ? (hasVia ? 2 : 1) : TIMELINE_SAMPLES
669
+
670
+ const positions = [0, max]
671
+
672
+ for (let i = 0; i <= segments; i++) {
673
+ positions.push(from + (to - from) * (i / segments))
674
+ }
675
+
676
+ // Keyframe offsets must be within 0–1, values outside the scroll range are
677
+ // represented by the exact values at the start/end of the scroll range.
678
+ const uniquePositions = [...new Set(positions.filter((position) => position >= 0 && position <= max))]
679
+ .sort((a, b) => a - b)
680
+
681
+ return uniquePositions.map((position) => {
682
+
683
+ const { props } = computeProps(data, position)
684
+ const keyframe = { offset: position / max }
685
+
686
+ for (const key of keys) {
687
+ keyframe[toKeyframeKey(key)] = props[key]
688
+ }
689
+
690
+ return keyframe
691
+
692
+ })
693
+
694
+ }
695
+
696
+ /**
697
+ * Gets and sets new props of all active loop instances.
698
+ * Runs inside requestAnimationFrame. Make sure it's performant as hell.
699
+ * @param {Boolean} force - Update even when scrollTop hasn't changed.
700
+ */
701
+ const update = function(force = false) {
702
+
703
+ const loopInstances = getLoopInstances(instances)
704
+
705
+ // Only continue when active instances available
706
+ if (loopInstances.length === 0) return
707
+
708
+ // Read phase
709
+ const scrollTop = getScrollTop()
710
+
711
+ // Only continue when scrollTop has changed
712
+ if (force === false && previousScrollTop === scrollTop) return
713
+ previousScrollTop = scrollTop
714
+
715
+ // Calculate everything first, then write, to avoid layout thrashing
716
+ const results = loopInstances.map((instance) => ({
717
+ instance,
718
+ ...getProps(instance, scrollTop)
719
+ }))
720
+
721
+ // Write phase
722
+ for (const { instance, elem, props } of results) {
723
+ setProps(elem, props, getStyleCache(instance))
724
+ }
725
+
726
+ }
727
+
728
+ /**
729
+ * Schedules an update for the next animation frame.
730
+ * Multiple calls within the same frame are batched.
731
+ * @param {Boolean} force
732
+ */
733
+ const requestUpdate = function(force = false) {
734
+
735
+ if (init === false) return
736
+
737
+ pendingForce = pendingForce || force
738
+
739
+ if (frameId != null) return
740
+
741
+ frameId = requestAnimationFrame(() => {
742
+
743
+ const shouldForce = pendingForce
744
+
745
+ frameId = null
746
+ pendingForce = false
747
+
748
+ update(shouldForce)
749
+
750
+ })
751
+
752
+ }
753
+
754
+ /**
755
+ * Schedules a keyframe refresh of timeline instances when the scroll range changes.
756
+ */
757
+ const requestTimelineRefresh = function() {
758
+
759
+ if (timelineFrameId != null) return
760
+
761
+ timelineFrameId = requestAnimationFrame(() => {
762
+
763
+ timelineFrameId = null
764
+ maxScrollTop = null
765
+
766
+ const max = getMaxScrollTop()
767
+
768
+ if (max === lastTimelineMax) return
769
+ lastTimelineMax = max
770
+
771
+ for (const instance of instances) {
772
+ if (instance != null && instance.usesTimeline()) instance.syncTimeline()
773
+ }
774
+
775
+ })
776
+
777
+ }
778
+
779
+ /**
780
+ * Recalculates all tracked instances (e.g. after a layout change).
781
+ */
782
+ const recalculateAll = function() {
783
+
784
+ getTrackedInstances(instances).forEach((instance) => instance.calculate())
785
+
786
+ }
787
+
788
+ /**
789
+ * Handles resize events.
790
+ * Height-only changes (iOS Safari toolbar collapsing/expanding) don't trigger
791
+ * a recalculation, so animations don't jump while scrolling.
792
+ */
793
+ const handleResize = debounce(() => {
794
+
795
+ const width = getViewportWidth()
796
+
797
+ // The scrollable distance might have changed in any case
798
+ maxScrollTop = null
799
+ requestTimelineRefresh()
800
+
801
+ if (width === viewportWidth) return
802
+
803
+ // A real resize (width change / orientation change)
804
+ viewportWidth = width
805
+ viewportHeight = null
806
+
807
+ if (settings.recalculateOnResize === true) recalculateAll()
808
+
809
+ requestUpdate(true)
810
+
811
+ }, 150)
812
+
813
+ /**
814
+ * Creates a new instance.
815
+ * @param {Object} data
816
+ * @returns {Object} instance
817
+ */
818
+ export const create = function(data) {
819
+
820
+ // Store the parsed data
821
+ let _data = null
822
+
823
+ // Store if instance is started or stopped
824
+ let active = false
825
+
826
+ // Native scroll-driven animation state
827
+ let timelineMode = false
828
+ let animatedKeys = []
829
+ let animation = null
830
+ let animationTarget = null
831
+
832
+ // Returns if instance is started or stopped
833
+ const _isActive = () => {
834
+
835
+ return active
836
+
837
+ }
838
+
839
+ // Returns the parsed and calculated data
840
+ const _getData = function() {
841
+
842
+ return _data
843
+
844
+ }
845
+
846
+ // Returns if the instance is driven by a native scroll timeline
847
+ const _usesTimeline = () => {
848
+
849
+ return timelineMode
850
+
851
+ }
852
+
853
+ // Cancels the native animation
854
+ const _cancelAnimation = () => {
855
+
856
+ if (animation != null) {
857
+ animation.cancel()
858
+ animation = null
859
+ animationTarget = null
860
+ }
861
+
862
+ }
863
+
864
+ // Falls back to the JS loop
865
+ const _fallback = () => {
866
+
867
+ _cancelAnimation()
868
+ timelineMode = false
869
+ styleCache.delete(instance)
870
+
871
+ if (active) requestUpdate(true)
872
+
873
+ }
874
+
875
+ // Creates or refreshes the native animation
876
+ const _syncTimeline = () => {
877
+
878
+ if (timelineMode === false) return
879
+
880
+ const elem = getElem(_data)
881
+
882
+ if (elem == null) return
883
+
884
+ const cache = getStyleCache(instance)
885
+ const { props } = computeProps(_data, getScrollTop())
886
+
887
+ // Props that never change are simply set once
888
+ const staticKeys = Object.keys(props).filter((key) => animatedKeys.includes(key) === false)
889
+ setProps(elem, pickProps(props, staticKeys), cache)
890
+
891
+ // Not started yet: create the animation on start()
892
+ if (active === false && animation == null) return
893
+
894
+ const max = getMaxScrollTop()
895
+
896
+ // Nothing to animate, or the page can't scroll
897
+ if (animatedKeys.length === 0 || max <= 0) {
898
+ _cancelAnimation()
899
+ setProps(elem, pickProps(props, animatedKeys), cache)
900
+ return
901
+ }
902
+
903
+ const keyframes = buildKeyframes(_data, animatedKeys, max)
904
+
905
+ try {
906
+
907
+ if (animation != null && animationTarget === elem) {
908
+ animation.effect.setKeyframes(keyframes)
909
+ } else {
910
+ _cancelAnimation()
911
+
912
+ const effect = new KeyframeEffect(elem, keyframes, {
913
+ fill: 'both',
914
+ easing: 'linear'
915
+ })
916
+
917
+ animation = new Animation(effect, getScrollTimeline())
918
+ animationTarget = elem
919
+ }
920
+
921
+ if (active) {
922
+ if (animation.playState !== 'running') animation.play()
923
+ } else {
924
+ animation.pause()
925
+ }
926
+
927
+ } catch (error) {
928
+ // Something isn't supported after all, use the JS loop instead
929
+ _fallback()
930
+ }
931
+
932
+ }
933
+
934
+ // Parses and calculates data
935
+ const _calculate = function() {
936
+
937
+ // Refresh cached measurements, as layout may have changed
938
+ viewportHeight = null
939
+ maxScrollTop = null
940
+
941
+ _data = validate(data)
942
+
943
+ // Styles may have been reset externally, so don't trust the cache
944
+ styleCache.delete(instance)
945
+
946
+ animatedKeys = getAnimatedKeys(_data)
947
+ timelineMode = canUseTimeline(_data, animatedKeys)
948
+
949
+ if (timelineMode) _syncTimeline()
950
+ else _cancelAnimation()
951
+
952
+ }
953
+
954
+ // Update props
955
+ const _update = () => {
956
+
957
+ if (timelineMode) {
958
+
959
+ // The native animation applies the styles, just refresh it
960
+ _syncTimeline()
961
+
962
+ // Still return props (and run callbacks) for consumers relying on it
963
+ const { props } = getProps(instance)
964
+
965
+ return props
966
+
967
+ }
968
+
969
+ // Get new props
970
+ const { elem, props } = getProps(instance)
971
+
972
+ // Set new props
973
+ setProps(elem, props, getStyleCache(instance))
974
+
975
+ return props
976
+
977
+ }
978
+
979
+ // Starts to animate
980
+ const _start = () => {
981
+
982
+ active = true
983
+
984
+ if (timelineMode) _syncTimeline()
985
+ else requestUpdate(true)
986
+
987
+ }
988
+
989
+ // Stops to animate
990
+ const _stop = () => {
991
+
992
+ active = false
993
+
994
+ // Freeze the native animation at its current state
995
+ if (animation != null) animation.pause()
996
+
997
+ }
998
+
999
+ // Destroys the instance
1000
+ const _destroy = () => {
1001
+
1002
+ _cancelAnimation()
1003
+
1004
+ // Replace instance instead of deleting the item to avoid
1005
+ // that the index of other instances changes.
1006
+ instances[index] = undefined
1007
+ styleCache.delete(instance)
1008
+
1009
+ }
1010
+
1011
+ // Assign instance to a variable so the instance can be used
1012
+ // elsewhere in the current function.
1013
+ const instance = {
1014
+ isActive: _isActive,
1015
+ getData: _getData,
1016
+ usesTimeline: _usesTimeline,
1017
+ syncTimeline: _syncTimeline,
1018
+ calculate: _calculate,
1019
+ update: _update,
1020
+ start: _start,
1021
+ stop: _stop,
1022
+ destroy: _destroy
1023
+ }
1024
+
1025
+ // Store instance in global array and save the index
1026
+ const index = instances.push(instance) - 1
1027
+
1028
+ // Calculate data for the first time
1029
+ instance.calculate()
1030
+
1031
+ return instance
1032
+
1033
+ }
1034
+
1035
+ /**
1036
+ * Starts listening for scroll changes.
1037
+ * @param {?Object} options
1038
+ * @param {?Boolean} options.recalculateOnResize - Recalculate tracked instances when the viewport width changes.
1039
+ * @param {?Boolean} options.timeline - Allow native scroll-driven animations (default true).
1040
+ */
1041
+ export const mount = function(options = {}) {
1042
+
1043
+ // Guard against multiple calls
1044
+ if (init) {
1045
+ return
1046
+ }
1047
+ init = true
1048
+
1049
+ settings = Object.assign({}, settings, options)
1050
+
1051
+ viewportWidth = getViewportWidth()
1052
+ viewportHeight = null
1053
+ maxScrollTop = null
1054
+ previousScrollTop = null
1055
+ lastTimelineMax = null
1056
+
1057
+ const onScroll = () => requestUpdate(false)
1058
+ const onPageShow = () => {
1059
+ // Restored from bfcache: positions may be stale
1060
+ maxScrollTop = null
1061
+ lastTimelineMax = null
1062
+ requestTimelineRefresh()
1063
+ requestUpdate(true)
1064
+ }
1065
+
1066
+ listeners = { onScroll, onPageShow }
1067
+
1068
+ window.addEventListener('scroll', onScroll, { passive: true })
1069
+ window.addEventListener('resize', handleResize, { passive: true })
1070
+ window.addEventListener('orientationchange', handleResize, { passive: true })
1071
+ window.addEventListener('pageshow', onPageShow)
1072
+
1073
+ // React to document height changes (lazy images etc.)
1074
+ if (typeof ResizeObserver !== 'undefined') {
1075
+ resizeObserver = new ResizeObserver(() => {
1076
+ maxScrollTop = null
1077
+ requestTimelineRefresh()
1078
+ })
1079
+ resizeObserver.observe(document.documentElement)
1080
+ }
1081
+
1082
+ // Initial update
1083
+ requestUpdate(true)
1084
+
1085
+ }
1086
+
1087
+ /**
1088
+ * Stops listening and cleans up instances that are no longer in the DOM.
1089
+ */
1090
+ export const unmount = function() {
1091
+
1092
+ if (init === false) {
1093
+ return
1094
+ }
1095
+
1096
+ let instancesInDom = false
1097
+
1098
+ for (const instance of instances) {
1099
+ // Check element is still in DOM
1100
+ if (instance == null) continue
1101
+
1102
+ const elem = instance.getData().elem
1103
+
1104
+ if (elem == null || document.body.contains(elem) === false) {
1105
+ // Destroy instance
1106
+ instance.destroy()
1107
+ } else {
1108
+ instancesInDom = true
1109
+ }
1110
+ }
1111
+
1112
+ // Do we have instances left (have all running instances been destroyed)?
1113
+ // If so, it's safe to reset the instances array
1114
+ if (instancesInDom === false) {
1115
+ instances = []
1116
+ }
1117
+
1118
+ if (listeners != null) {
1119
+ window.removeEventListener('scroll', listeners.onScroll)
1120
+ window.removeEventListener('resize', handleResize)
1121
+ window.removeEventListener('orientationchange', handleResize)
1122
+ window.removeEventListener('pageshow', listeners.onPageShow)
1123
+ listeners = null
1124
+ }
1125
+
1126
+ handleResize.cancel()
1127
+
1128
+ if (resizeObserver != null) {
1129
+ resizeObserver.disconnect()
1130
+ resizeObserver = null
1131
+ }
1132
+
1133
+ if (frameId != null) {
1134
+ cancelAnimationFrame(frameId)
1135
+ frameId = null
1136
+ }
1137
+
1138
+ if (timelineFrameId != null) {
1139
+ cancelAnimationFrame(timelineFrameId)
1140
+ timelineFrameId = null
1141
+ }
1142
+
1143
+ pendingForce = false
1144
+ previousScrollTop = null
1145
+ lastTimelineMax = null
1146
+ init = false
1147
+
1148
+ }