@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/LICENSE +22 -0
- package/README.md +290 -2
- package/dist/buttery.cjs +781 -0
- package/dist/buttery.cjs.map +1 -0
- package/dist/buttery.js +754 -0
- package/dist/buttery.js.map +1 -0
- package/dist/buttery.min.js +2 -0
- package/dist/buttery.min.js.map +1 -0
- package/lib/buttery.js +1148 -0
- package/package.json +29 -4
package/dist/buttery.cjs
ADDED
|
@@ -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
|