@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.
@@ -0,0 +1,781 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ // \0rolldown/runtime.js
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
+ get: ((k) => from[k]).bind(null, key),
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
20
+ value: mod,
21
+ enumerable: true
22
+ }) : target, mod));
23
+ let parse_unit = require("parse-unit");
24
+ parse_unit = __toESM(parse_unit, 1);
25
+ let eases = require("eases");
26
+ eases = __toESM(eases, 1);
27
+ // lib/buttery.js
28
+ /**
29
+ * Buttery - scroll animation library
30
+ * Performance focused, lightweight scroll animation library
31
+ *
32
+ * A fork of basicScroll.js by Tobias Reich (@electerious)
33
+ * Created and maintained by Mark Croxton (@croxton)
34
+ *
35
+ * - Uses native scroll-driven animations (ScrollTimeline + Web Animations) when supported,
36
+ * so values update in sync with scrolling without per-frame JavaScript.
37
+ * - Falls back to an event-driven requestAnimationFrame loop otherwise.
38
+ * - Instances with `inside`/`outside` callbacks use the fallback by default (callbacks need JS per frame).
39
+ * - Clamps scrollTop to avoid rubber-band (overscroll) bounce affecting animations
40
+ * - Uses a stable viewport height that doesn't change when the iOS toolbar collapses/expands
41
+ * - Ignores height-only resize events (iOS address bar show/hide) for recalculation
42
+ * - Skips redundant style writes
43
+ */
44
+ var instances = [];
45
+ var frameId = null;
46
+ var pendingForce = false;
47
+ var init = false;
48
+ var previousScrollTop = null;
49
+ var maxScrollTop = null;
50
+ var viewportHeight = null;
51
+ var viewportWidth = null;
52
+ var listeners = null;
53
+ var resizeObserver = null;
54
+ var timelineFrameId = null;
55
+ var lastTimelineMax = null;
56
+ var scrollTimeline = null;
57
+ var timelineSupport = null;
58
+ var settings = {
59
+ recalculateOnResize: false,
60
+ timeline: true
61
+ };
62
+ var TIMELINE_SAMPLES = 30;
63
+ var styleCache = /* @__PURE__ */ new WeakMap();
64
+ var registeredProperties = /* @__PURE__ */ new Map();
65
+ /**
66
+ * Debounces a function that will be triggered many times.
67
+ * @param {Function} fn
68
+ * @param {Number} duration
69
+ * @returns {Function}
70
+ */
71
+ var debounce = function(fn, duration) {
72
+ let timeout = null;
73
+ const debounced = (...args) => {
74
+ clearTimeout(timeout);
75
+ timeout = setTimeout(() => fn(...args), duration);
76
+ };
77
+ debounced.cancel = () => clearTimeout(timeout);
78
+ return debounced;
79
+ };
80
+ /**
81
+ * Returns all active instances that are driven by the JS loop.
82
+ * @param {Array} instances
83
+ * @returns {Array} instances
84
+ */
85
+ var getLoopInstances = function(instances) {
86
+ return instances.filter((instance) => instance != null && instance.isActive() && instance.usesTimeline() === false);
87
+ };
88
+ /**
89
+ * Returns all tracked instances from an array.
90
+ * @param {Array} instances
91
+ * @returns {Array} instances - Tracked instances.
92
+ */
93
+ var getTrackedInstances = function(instances) {
94
+ return instances.filter((instance) => instance != null && instance.getData().track);
95
+ };
96
+ /**
97
+ * Checks if native scroll-driven animations can be used.
98
+ * @returns {Boolean}
99
+ */
100
+ var supportsTimeline = function() {
101
+ if (timelineSupport == null) timelineSupport = typeof window !== "undefined" && typeof window.ScrollTimeline === "function" && typeof window.Animation === "function" && typeof window.KeyframeEffect === "function" && window.CSS != null && typeof window.CSS.registerProperty === "function";
102
+ return timelineSupport;
103
+ };
104
+ /**
105
+ * Returns the scrolling element.
106
+ * @returns {Element}
107
+ */
108
+ var getScrollingElement = function() {
109
+ return document.scrollingElement || document.documentElement;
110
+ };
111
+ /**
112
+ * Returns the shared document scroll timeline.
113
+ * @returns {ScrollTimeline}
114
+ */
115
+ var getScrollTimeline = function() {
116
+ if (scrollTimeline == null) scrollTimeline = new ScrollTimeline({
117
+ source: getScrollingElement(),
118
+ axis: "block"
119
+ });
120
+ return scrollTimeline;
121
+ };
122
+ /**
123
+ * Returns the raw number of scrolled pixels.
124
+ * Can be negative or exceed the max scroll position during iOS rubber-band overscroll.
125
+ * Use this for measuring element positions (it stays consistent with getBoundingClientRect).
126
+ * @returns {Number} scrollTop
127
+ */
128
+ var getRawScrollTop = function() {
129
+ return getScrollingElement().scrollTop;
130
+ };
131
+ /**
132
+ * Returns the maximum scrollable distance.
133
+ * @returns {Number}
134
+ */
135
+ var getMaxScrollTop = function() {
136
+ const elem = getScrollingElement();
137
+ return Math.max(0, elem.scrollHeight - elem.clientHeight);
138
+ };
139
+ /**
140
+ * Returns the number of scrolled pixels, clamped to the scrollable range.
141
+ * This prevents iOS rubber-band overscroll from making animations bounce.
142
+ * @returns {Number} scrollTop
143
+ */
144
+ var getScrollTop = function() {
145
+ const scrollTop = getRawScrollTop();
146
+ if (scrollTop <= 0) return 0;
147
+ if (maxScrollTop == null || scrollTop > maxScrollTop) maxScrollTop = getMaxScrollTop();
148
+ return Math.min(scrollTop, maxScrollTop);
149
+ };
150
+ /**
151
+ * Returns a stable height of the viewport.
152
+ * documentElement.clientHeight doesn't change when the iOS Safari toolbar
153
+ * collapses/expands, unlike window.innerHeight, so calculated positions stay put.
154
+ * @returns {Number} viewportHeight
155
+ */
156
+ var getViewportHeight = function() {
157
+ if (viewportHeight == null) viewportHeight = document.documentElement.clientHeight || window.innerHeight || window.outerHeight;
158
+ return viewportHeight;
159
+ };
160
+ /**
161
+ * Returns the width of the viewport.
162
+ * @returns {Number} viewportWidth
163
+ */
164
+ var getViewportWidth = function() {
165
+ return document.documentElement.clientWidth || window.innerWidth;
166
+ };
167
+ /**
168
+ * Checks if a value is absolute.
169
+ * An absolute value must have a value that's not NaN.
170
+ * @param {String|Integer} value
171
+ * @returns {Boolean} isAbsolute
172
+ */
173
+ var isAbsoluteValue = function(value) {
174
+ return isNaN((0, parse_unit.default)(value)[0]) === false;
175
+ };
176
+ /**
177
+ * Parses an absolute value.
178
+ * @param {String|Integer} value
179
+ * @returns {Object} value - Parsed value.
180
+ */
181
+ var parseAbsoluteValue = function(value) {
182
+ const parsedValue = (0, parse_unit.default)(value);
183
+ return {
184
+ value: parsedValue[0],
185
+ unit: parsedValue[1]
186
+ };
187
+ };
188
+ /**
189
+ * Checks if a value is relative.
190
+ * A relative value must start and end with [a-z] and needs a '-' in the middle.
191
+ * @param {String|Integer} value
192
+ * @returns {Boolean} isRelative
193
+ */
194
+ var isRelativeValue = function(value) {
195
+ return String(value).match(/^[a-z0-9%]+-[a-z0-9%]+$/) !== null;
196
+ };
197
+ /**
198
+ * Returns the property that should be used according to direct.
199
+ * @param {Boolean|Node} direct
200
+ * @param {Object} properties
201
+ * @returns {*}
202
+ */
203
+ var mapDirectToProperty = function(direct, properties) {
204
+ if (direct === true) return properties.elem;
205
+ if (direct instanceof HTMLElement) return properties.direct;
206
+ return properties.global;
207
+ };
208
+ /**
209
+ * Returns the element styles should be applied to.
210
+ * @param {Object} data
211
+ * @returns {?Element}
212
+ */
213
+ var getElem = function(data) {
214
+ return mapDirectToProperty(data.direct, {
215
+ global: document.documentElement,
216
+ elem: data.elem,
217
+ direct: data.direct
218
+ });
219
+ };
220
+ /**
221
+ * Converts a relative value to an absolute value.
222
+ * @param {String} value
223
+ * @param {Node} elem - Anchor of the relative value.
224
+ * @param {?Integer} scrollTop - Pixels scrolled in document (raw, unclamped).
225
+ * @param {?Integer} vh - Height of the viewport.
226
+ * @returns {String} value - Absolute value.
227
+ */
228
+ var relativeToAbsoluteValue = function(value, elem, scrollTop = getRawScrollTop(), vh = getViewportHeight()) {
229
+ const elemSize = elem.getBoundingClientRect();
230
+ const elemAnchor = value.match(/^[a-z0-9%]+/)[0];
231
+ const viewportAnchor = value.match(/[a-z0-9%]+$/)[0];
232
+ const elemTop = elemSize.top + scrollTop;
233
+ let y = 0;
234
+ if (viewportAnchor.match(/%$/) !== null) {
235
+ const percent = parseFloat(viewportAnchor.replace("%", "")) / 100;
236
+ y -= vh * percent;
237
+ } else {
238
+ if (viewportAnchor === "top") y -= 0;
239
+ if (viewportAnchor === "middle") y -= vh / 2;
240
+ if (viewportAnchor === "bottom") y -= vh;
241
+ }
242
+ if (elemAnchor.match(/%$/) !== null) {
243
+ const percent = parseFloat(elemAnchor.replace("%", "")) / 100;
244
+ y += elemTop + elemSize.height * percent;
245
+ } else {
246
+ if (elemAnchor === "top") y += elemTop;
247
+ if (elemAnchor === "middle") y += elemTop + elemSize.height / 2;
248
+ if (elemAnchor === "bottom") y += elemTop + elemSize.height;
249
+ }
250
+ return `${y}px`;
251
+ };
252
+ /**
253
+ * Validates data and sets defaults for undefined properties.
254
+ * @param {?Object} data
255
+ * @returns {Object} data - Validated data.
256
+ */
257
+ var validate = function(data = {}) {
258
+ data = Object.assign({}, data);
259
+ data.hasCallbacks = data.inside != null || data.outside != null;
260
+ if (data.inside == null) data.inside = () => {};
261
+ if (data.outside == null) data.outside = () => {};
262
+ if (data.direct == null) data.direct = false;
263
+ if (data.track == null) data.track = true;
264
+ if (data.props == null) data.props = {};
265
+ if (data.timeline == null) data.timeline = "auto";
266
+ if (data.from == null) throw new Error("Missing property `from`");
267
+ if (data.to == null) throw new Error("Missing property `to`");
268
+ if (typeof data.inside !== "function") throw new Error("Property `inside` must be undefined or a function");
269
+ if (typeof data.outside !== "function") throw new Error("Property `outside` must be undefined or a function");
270
+ if (typeof data.direct !== "boolean" && !(data.direct instanceof HTMLElement)) throw new Error("Property `direct` must be undefined, a boolean or a DOM element/node");
271
+ if (data.direct === true && data.elem == null) throw new Error("Property `elem` is required when `direct` is true");
272
+ if (typeof data.track !== "boolean") throw new Error("Property `track` must be undefined or a boolean");
273
+ if (typeof data.props !== "object") throw new Error("Property `props` must be undefined or an object");
274
+ if (data.timeline !== "auto" && typeof data.timeline !== "boolean") throw new Error("Property `timeline` must be undefined, a boolean or 'auto'");
275
+ if (data.elem == null) {
276
+ if (isAbsoluteValue(data.from) === false) throw new Error("Property `from` must be a absolute value when no `elem` has been provided");
277
+ if (isAbsoluteValue(data.to) === false) throw new Error("Property `to` must be a absolute value when no `elem` has been provided");
278
+ } else {
279
+ if (isRelativeValue(data.from) === true) data.from = relativeToAbsoluteValue(data.from, data.elem);
280
+ if (isRelativeValue(data.to) === true) data.to = relativeToAbsoluteValue(data.to, data.elem);
281
+ }
282
+ data.from = parseAbsoluteValue(data.from);
283
+ data.to = parseAbsoluteValue(data.to);
284
+ data.props = Object.keys(data.props).reduce((acc, key) => {
285
+ const prop = Object.assign({}, data.props[key]);
286
+ if (isAbsoluteValue(prop.from) === false) throw new Error("Property `from` of prop must be a absolute value");
287
+ if (isAbsoluteValue(prop.to) === false) throw new Error("Property `to` of prop must be a absolute value");
288
+ if (prop.via != null && isAbsoluteValue(prop.via) === false) throw new Error("Property `via` of prop must be a absolute value");
289
+ prop.from = parseAbsoluteValue(prop.from);
290
+ prop.to = parseAbsoluteValue(prop.to);
291
+ if (prop.via != null) prop.via = parseAbsoluteValue(prop.via);
292
+ if (prop.timing == null) prop.timing = eases.default["linear"];
293
+ if (typeof prop.timing !== "string" && typeof prop.timing !== "function") throw new Error("Property `timing` of prop must be undefined, a string or a function");
294
+ if (typeof prop.timing === "string" && eases.default[prop.timing] == null) throw new Error("Unknown timing for property `timing` of prop");
295
+ if (typeof prop.timing === "string") prop.timing = eases.default[prop.timing];
296
+ acc[key] = prop;
297
+ return acc;
298
+ }, {});
299
+ return data;
300
+ };
301
+ /**
302
+ * Calculates prop values for a given scroll position. Pure: no callbacks, no DOM writes.
303
+ * @param {Object} data - Validated data.
304
+ * @param {Number} scrollTop - Pixels scrolled in document (clamped).
305
+ * @returns {Object} precisePercentage and props
306
+ */
307
+ var computeProps = function(data, scrollTop) {
308
+ const total = data.to.value - data.from.value;
309
+ const current = scrollTop - data.from.value;
310
+ const precisePercentage = total === 0 ? current >= 0 ? 100 : 0 : current / (total / 100);
311
+ const normalizedTime = Math.min(Math.max(precisePercentage, 0), 100) / 100;
312
+ const props = {};
313
+ for (const key of Object.keys(data.props)) {
314
+ const prop = data.props[key];
315
+ const unit = prop.from.unit || prop.to.unit;
316
+ const time = prop.timing(normalizedTime);
317
+ let value;
318
+ if (prop.via != null) {
319
+ const midpoint = .5;
320
+ if (time <= midpoint) {
321
+ const localTime = time / midpoint;
322
+ const diff = prop.from.value - prop.via.value;
323
+ value = prop.from.value - diff * localTime;
324
+ } else {
325
+ const localTime = (time - midpoint) / midpoint;
326
+ const diff = prop.via.value - prop.to.value;
327
+ value = prop.via.value - diff * localTime;
328
+ }
329
+ } else {
330
+ const diff = prop.from.value - prop.to.value;
331
+ value = prop.from.value - diff * time;
332
+ }
333
+ props[key] = Math.round(value * 1e4) / 1e4 + unit;
334
+ }
335
+ return {
336
+ precisePercentage,
337
+ props
338
+ };
339
+ };
340
+ /**
341
+ * Calculates the props of an instance and executes its callbacks.
342
+ * @param {Object} instance
343
+ * @param {?Integer} scrollTop - Pixels scrolled in document (clamped).
344
+ * @returns {Object} Calculated props and the element to apply styles to.
345
+ */
346
+ var getProps = function(instance, scrollTop = getScrollTop()) {
347
+ const data = instance.getData();
348
+ const { precisePercentage, props } = computeProps(data, scrollTop);
349
+ if ((precisePercentage >= 0 && precisePercentage <= 100) === true) data.inside(instance, precisePercentage, props);
350
+ else data.outside(instance, precisePercentage, props);
351
+ return {
352
+ elem: getElem(data),
353
+ props
354
+ };
355
+ };
356
+ /**
357
+ * Adds properties to a given style object, skipping values that haven't changed.
358
+ * @param {Node} elem - Styles will be applied to this element.
359
+ * @param {Object} props - Object of props.
360
+ * @param {?Object} cache - Last applied values.
361
+ */
362
+ var setProps = function(elem, props, cache = null) {
363
+ if (elem == null) return;
364
+ for (const key in props) {
365
+ const value = props[key];
366
+ if (cache != null) {
367
+ if (cache[key] === value) continue;
368
+ cache[key] = value;
369
+ }
370
+ elem.style.setProperty(key, value);
371
+ }
372
+ };
373
+ /**
374
+ * Returns the style cache of an instance.
375
+ * @param {Object} instance
376
+ * @returns {Object}
377
+ */
378
+ var getStyleCache = function(instance) {
379
+ let cache = styleCache.get(instance);
380
+ if (cache == null) {
381
+ cache = {};
382
+ styleCache.set(instance, cache);
383
+ }
384
+ return cache;
385
+ };
386
+ /**
387
+ * Returns a subset of props.
388
+ * @param {Object} props
389
+ * @param {Array} keys
390
+ * @returns {Object}
391
+ */
392
+ var pickProps = function(props, keys) {
393
+ return keys.reduce((acc, key) => {
394
+ acc[key] = props[key];
395
+ return acc;
396
+ }, {});
397
+ };
398
+ /**
399
+ * Returns the keys of props whose value actually changes while scrolling.
400
+ * @param {Object} data
401
+ * @returns {Array}
402
+ */
403
+ var getAnimatedKeys = function(data) {
404
+ return Object.keys(data.props).filter((key) => {
405
+ const prop = data.props[key];
406
+ return (prop.from.value === prop.to.value && (prop.via == null || prop.via.value === prop.from.value)) === false;
407
+ });
408
+ };
409
+ /**
410
+ * Returns the CSS syntax and initial value to register a custom property with.
411
+ * @param {String} unit
412
+ * @returns {Object}
413
+ */
414
+ var getSyntaxForUnit = function(unit) {
415
+ if (unit === "") return {
416
+ syntax: "<number>",
417
+ initialValue: "0"
418
+ };
419
+ if ([
420
+ "deg",
421
+ "grad",
422
+ "rad",
423
+ "turn"
424
+ ].includes(unit)) return {
425
+ syntax: "<angle>",
426
+ initialValue: "0deg"
427
+ };
428
+ if (["s", "ms"].includes(unit)) return {
429
+ syntax: "<time>",
430
+ initialValue: "0s"
431
+ };
432
+ return {
433
+ syntax: "<length-percentage>",
434
+ initialValue: "0px"
435
+ };
436
+ };
437
+ /**
438
+ * Registers a custom property so it can be interpolated by the animation engine.
439
+ * @param {String} name
440
+ * @param {String} unit
441
+ * @returns {Boolean} Whether the property can be animated smoothly.
442
+ */
443
+ var registerCustomProperty = function(name, unit) {
444
+ const { syntax, initialValue } = getSyntaxForUnit(unit);
445
+ if (registeredProperties.has(name)) {
446
+ const existing = registeredProperties.get(name);
447
+ return existing === null || existing === syntax;
448
+ }
449
+ try {
450
+ CSS.registerProperty({
451
+ name,
452
+ syntax,
453
+ inherits: true,
454
+ initialValue
455
+ });
456
+ registeredProperties.set(name, syntax);
457
+ return true;
458
+ } catch (error) {
459
+ if (error != null && error.name === "InvalidModificationError") {
460
+ registeredProperties.set(name, null);
461
+ return true;
462
+ }
463
+ return false;
464
+ }
465
+ };
466
+ /**
467
+ * Converts a CSS property name to a keyframe key.
468
+ * @param {String} key
469
+ * @returns {String}
470
+ */
471
+ var toKeyframeKey = function(key) {
472
+ if (key.startsWith("--")) return key;
473
+ return key.replace(/-([a-z])/g, (match, char) => char.toUpperCase());
474
+ };
475
+ /**
476
+ * Checks if an instance can use a native scroll timeline.
477
+ * @param {Object} data
478
+ * @param {Array} animatedKeys
479
+ * @returns {Boolean}
480
+ */
481
+ var canUseTimeline = function(data, animatedKeys) {
482
+ if (settings.timeline === false) return false;
483
+ if (data.timeline === false) return false;
484
+ if (data.timeline === "auto" && data.hasCallbacks === true) return false;
485
+ if (supportsTimeline() === false) return false;
486
+ return animatedKeys.every((key) => {
487
+ if (key.startsWith("--") === false) return true;
488
+ const prop = data.props[key];
489
+ return registerCustomProperty(key, prop.from.unit || prop.to.unit);
490
+ });
491
+ };
492
+ /**
493
+ * Builds keyframes mapped onto the document scroll range (0 → max).
494
+ * Easing and `via` are sampled so the result matches the JS calculation.
495
+ * @param {Object} data
496
+ * @param {Array} keys - Animated prop keys.
497
+ * @param {Number} max - Max scroll distance.
498
+ * @returns {Array} keyframes
499
+ */
500
+ var buildKeyframes = function(data, keys, max) {
501
+ const from = data.from.value;
502
+ const to = data.to.value;
503
+ const isLinear = keys.every((key) => data.props[key].timing === eases.default["linear"]);
504
+ const hasVia = keys.some((key) => data.props[key].via != null);
505
+ const segments = isLinear ? hasVia ? 2 : 1 : TIMELINE_SAMPLES;
506
+ const positions = [0, max];
507
+ for (let i = 0; i <= segments; i++) positions.push(from + (to - from) * (i / segments));
508
+ return [...new Set(positions.filter((position) => position >= 0 && position <= max))].sort((a, b) => a - b).map((position) => {
509
+ const { props } = computeProps(data, position);
510
+ const keyframe = { offset: position / max };
511
+ for (const key of keys) keyframe[toKeyframeKey(key)] = props[key];
512
+ return keyframe;
513
+ });
514
+ };
515
+ /**
516
+ * Gets and sets new props of all active loop instances.
517
+ * Runs inside requestAnimationFrame. Make sure it's performant as hell.
518
+ * @param {Boolean} force - Update even when scrollTop hasn't changed.
519
+ */
520
+ var update = function(force = false) {
521
+ const loopInstances = getLoopInstances(instances);
522
+ if (loopInstances.length === 0) return;
523
+ const scrollTop = getScrollTop();
524
+ if (force === false && previousScrollTop === scrollTop) return;
525
+ previousScrollTop = scrollTop;
526
+ const results = loopInstances.map((instance) => ({
527
+ instance,
528
+ ...getProps(instance, scrollTop)
529
+ }));
530
+ for (const { instance, elem, props } of results) setProps(elem, props, getStyleCache(instance));
531
+ };
532
+ /**
533
+ * Schedules an update for the next animation frame.
534
+ * Multiple calls within the same frame are batched.
535
+ * @param {Boolean} force
536
+ */
537
+ var requestUpdate = function(force = false) {
538
+ if (init === false) return;
539
+ pendingForce = pendingForce || force;
540
+ if (frameId != null) return;
541
+ frameId = requestAnimationFrame(() => {
542
+ const shouldForce = pendingForce;
543
+ frameId = null;
544
+ pendingForce = false;
545
+ update(shouldForce);
546
+ });
547
+ };
548
+ /**
549
+ * Schedules a keyframe refresh of timeline instances when the scroll range changes.
550
+ */
551
+ var requestTimelineRefresh = function() {
552
+ if (timelineFrameId != null) return;
553
+ timelineFrameId = requestAnimationFrame(() => {
554
+ timelineFrameId = null;
555
+ maxScrollTop = null;
556
+ const max = getMaxScrollTop();
557
+ if (max === lastTimelineMax) return;
558
+ lastTimelineMax = max;
559
+ for (const instance of instances) if (instance != null && instance.usesTimeline()) instance.syncTimeline();
560
+ });
561
+ };
562
+ /**
563
+ * Recalculates all tracked instances (e.g. after a layout change).
564
+ */
565
+ var recalculateAll = function() {
566
+ getTrackedInstances(instances).forEach((instance) => instance.calculate());
567
+ };
568
+ /**
569
+ * Handles resize events.
570
+ * Height-only changes (iOS Safari toolbar collapsing/expanding) don't trigger
571
+ * a recalculation, so animations don't jump while scrolling.
572
+ */
573
+ var handleResize = debounce(() => {
574
+ const width = getViewportWidth();
575
+ maxScrollTop = null;
576
+ requestTimelineRefresh();
577
+ if (width === viewportWidth) return;
578
+ viewportWidth = width;
579
+ viewportHeight = null;
580
+ if (settings.recalculateOnResize === true) recalculateAll();
581
+ requestUpdate(true);
582
+ }, 150);
583
+ /**
584
+ * Creates a new instance.
585
+ * @param {Object} data
586
+ * @returns {Object} instance
587
+ */
588
+ var create = function(data) {
589
+ let _data = null;
590
+ let active = false;
591
+ let timelineMode = false;
592
+ let animatedKeys = [];
593
+ let animation = null;
594
+ let animationTarget = null;
595
+ const _isActive = () => {
596
+ return active;
597
+ };
598
+ const _getData = function() {
599
+ return _data;
600
+ };
601
+ const _usesTimeline = () => {
602
+ return timelineMode;
603
+ };
604
+ const _cancelAnimation = () => {
605
+ if (animation != null) {
606
+ animation.cancel();
607
+ animation = null;
608
+ animationTarget = null;
609
+ }
610
+ };
611
+ const _fallback = () => {
612
+ _cancelAnimation();
613
+ timelineMode = false;
614
+ styleCache.delete(instance);
615
+ if (active) requestUpdate(true);
616
+ };
617
+ const _syncTimeline = () => {
618
+ if (timelineMode === false) return;
619
+ const elem = getElem(_data);
620
+ if (elem == null) return;
621
+ const cache = getStyleCache(instance);
622
+ const { props } = computeProps(_data, getScrollTop());
623
+ setProps(elem, pickProps(props, Object.keys(props).filter((key) => animatedKeys.includes(key) === false)), cache);
624
+ if (active === false && animation == null) return;
625
+ const max = getMaxScrollTop();
626
+ if (animatedKeys.length === 0 || max <= 0) {
627
+ _cancelAnimation();
628
+ setProps(elem, pickProps(props, animatedKeys), cache);
629
+ return;
630
+ }
631
+ const keyframes = buildKeyframes(_data, animatedKeys, max);
632
+ try {
633
+ if (animation != null && animationTarget === elem) animation.effect.setKeyframes(keyframes);
634
+ else {
635
+ _cancelAnimation();
636
+ const effect = new KeyframeEffect(elem, keyframes, {
637
+ fill: "both",
638
+ easing: "linear"
639
+ });
640
+ animation = new Animation(effect, getScrollTimeline());
641
+ animationTarget = elem;
642
+ }
643
+ if (active) {
644
+ if (animation.playState !== "running") animation.play();
645
+ } else animation.pause();
646
+ } catch (error) {
647
+ _fallback();
648
+ }
649
+ };
650
+ const _calculate = function() {
651
+ viewportHeight = null;
652
+ maxScrollTop = null;
653
+ _data = validate(data);
654
+ styleCache.delete(instance);
655
+ animatedKeys = getAnimatedKeys(_data);
656
+ timelineMode = canUseTimeline(_data, animatedKeys);
657
+ if (timelineMode) _syncTimeline();
658
+ else _cancelAnimation();
659
+ };
660
+ const _update = () => {
661
+ if (timelineMode) {
662
+ _syncTimeline();
663
+ const { props } = getProps(instance);
664
+ return props;
665
+ }
666
+ const { elem, props } = getProps(instance);
667
+ setProps(elem, props, getStyleCache(instance));
668
+ return props;
669
+ };
670
+ const _start = () => {
671
+ active = true;
672
+ if (timelineMode) _syncTimeline();
673
+ else requestUpdate(true);
674
+ };
675
+ const _stop = () => {
676
+ active = false;
677
+ if (animation != null) animation.pause();
678
+ };
679
+ const _destroy = () => {
680
+ _cancelAnimation();
681
+ instances[index] = void 0;
682
+ styleCache.delete(instance);
683
+ };
684
+ const instance = {
685
+ isActive: _isActive,
686
+ getData: _getData,
687
+ usesTimeline: _usesTimeline,
688
+ syncTimeline: _syncTimeline,
689
+ calculate: _calculate,
690
+ update: _update,
691
+ start: _start,
692
+ stop: _stop,
693
+ destroy: _destroy
694
+ };
695
+ const index = instances.push(instance) - 1;
696
+ instance.calculate();
697
+ return instance;
698
+ };
699
+ /**
700
+ * Starts listening for scroll changes.
701
+ * @param {?Object} options
702
+ * @param {?Boolean} options.recalculateOnResize - Recalculate tracked instances when the viewport width changes.
703
+ * @param {?Boolean} options.timeline - Allow native scroll-driven animations (default true).
704
+ */
705
+ var mount = function(options = {}) {
706
+ if (init) return;
707
+ init = true;
708
+ settings = Object.assign({}, settings, options);
709
+ viewportWidth = getViewportWidth();
710
+ viewportHeight = null;
711
+ maxScrollTop = null;
712
+ previousScrollTop = null;
713
+ lastTimelineMax = null;
714
+ const onScroll = () => requestUpdate(false);
715
+ const onPageShow = () => {
716
+ maxScrollTop = null;
717
+ lastTimelineMax = null;
718
+ requestTimelineRefresh();
719
+ requestUpdate(true);
720
+ };
721
+ listeners = {
722
+ onScroll,
723
+ onPageShow
724
+ };
725
+ window.addEventListener("scroll", onScroll, { passive: true });
726
+ window.addEventListener("resize", handleResize, { passive: true });
727
+ window.addEventListener("orientationchange", handleResize, { passive: true });
728
+ window.addEventListener("pageshow", onPageShow);
729
+ if (typeof ResizeObserver !== "undefined") {
730
+ resizeObserver = new ResizeObserver(() => {
731
+ maxScrollTop = null;
732
+ requestTimelineRefresh();
733
+ });
734
+ resizeObserver.observe(document.documentElement);
735
+ }
736
+ requestUpdate(true);
737
+ };
738
+ /**
739
+ * Stops listening and cleans up instances that are no longer in the DOM.
740
+ */
741
+ var unmount = function() {
742
+ if (init === false) return;
743
+ let instancesInDom = false;
744
+ for (const instance of instances) {
745
+ if (instance == null) continue;
746
+ const elem = instance.getData().elem;
747
+ if (elem == null || document.body.contains(elem) === false) instance.destroy();
748
+ else instancesInDom = true;
749
+ }
750
+ if (instancesInDom === false) instances = [];
751
+ if (listeners != null) {
752
+ window.removeEventListener("scroll", listeners.onScroll);
753
+ window.removeEventListener("resize", handleResize);
754
+ window.removeEventListener("orientationchange", handleResize);
755
+ window.removeEventListener("pageshow", listeners.onPageShow);
756
+ listeners = null;
757
+ }
758
+ handleResize.cancel();
759
+ if (resizeObserver != null) {
760
+ resizeObserver.disconnect();
761
+ resizeObserver = null;
762
+ }
763
+ if (frameId != null) {
764
+ cancelAnimationFrame(frameId);
765
+ frameId = null;
766
+ }
767
+ if (timelineFrameId != null) {
768
+ cancelAnimationFrame(timelineFrameId);
769
+ timelineFrameId = null;
770
+ }
771
+ pendingForce = false;
772
+ previousScrollTop = null;
773
+ lastTimelineMax = null;
774
+ init = false;
775
+ };
776
+ exports.create = create;
777
+ exports.mount = mount;
778
+ exports.supportsTimeline = supportsTimeline;
779
+ exports.unmount = unmount;
780
+
781
+ //# sourceMappingURL=buttery.cjs.map