1166 lines
43 KiB
JavaScript
1166 lines
43 KiB
JavaScript
'use strict';
|
|
|
|
Object.defineProperty(exports, '__esModule', { value: true });
|
|
|
|
var motionDom = require('motion-dom');
|
|
var motionUtils = require('motion-utils');
|
|
|
|
function isDOMKeyframes(keyframes) {
|
|
return typeof keyframes === "object" && !Array.isArray(keyframes);
|
|
}
|
|
|
|
function resolveSubjects(subject, keyframes, scope, selectorCache) {
|
|
if (subject == null) {
|
|
return [];
|
|
}
|
|
if (typeof subject === "string" && isDOMKeyframes(keyframes)) {
|
|
return motionDom.resolveElements(subject, scope, selectorCache);
|
|
}
|
|
else if (subject instanceof NodeList) {
|
|
return Array.from(subject);
|
|
}
|
|
else if (Array.isArray(subject)) {
|
|
return subject.filter((s) => s != null);
|
|
}
|
|
else {
|
|
return [subject];
|
|
}
|
|
}
|
|
|
|
function calculateRepeatDuration(duration, repeat, _repeatDelay) {
|
|
return duration * (repeat + 1);
|
|
}
|
|
|
|
/**
|
|
* Given a absolute or relative time definition and current/prev time state of the sequence,
|
|
* calculate an absolute time for the next keyframes.
|
|
*/
|
|
function calcNextTime(current, next, prev, labels) {
|
|
if (typeof next === "number") {
|
|
return next;
|
|
}
|
|
else if (next.startsWith("-") || next.startsWith("+")) {
|
|
return Math.max(0, current + parseFloat(next));
|
|
}
|
|
else if (next === "<") {
|
|
return prev;
|
|
}
|
|
else if (next.startsWith("<")) {
|
|
return Math.max(0, prev + parseFloat(next.slice(1)));
|
|
}
|
|
else {
|
|
return labels.get(next) ?? current;
|
|
}
|
|
}
|
|
|
|
function eraseKeyframes(sequence, startTime, endTime) {
|
|
for (let i = 0; i < sequence.length; i++) {
|
|
const keyframe = sequence[i];
|
|
if (keyframe.at > startTime && keyframe.at < endTime) {
|
|
motionUtils.removeItem(sequence, keyframe);
|
|
// If we remove this item we have to push the pointer back one
|
|
i--;
|
|
}
|
|
}
|
|
}
|
|
function addKeyframes(sequence, keyframes, easing, offset, startTime, endTime) {
|
|
/**
|
|
* Erase every existing value between currentTime and targetTime,
|
|
* this will essentially splice this timeline into any currently
|
|
* defined ones.
|
|
*/
|
|
eraseKeyframes(sequence, startTime, endTime);
|
|
for (let i = 0; i < keyframes.length; i++) {
|
|
sequence.push({
|
|
value: keyframes[i],
|
|
at: motionDom.mixNumber(startTime, endTime, offset[i]),
|
|
easing: motionUtils.getEasingForSegment(easing, i),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Take an array of times that represent repeated keyframes. For instance
|
|
* if we have original times of [0, 0.5, 1] then our repeated times will
|
|
* be [0, 0.5, 1, 1, 1.5, 2]. Loop over the times and scale them back
|
|
* down to a 0-1 scale.
|
|
*/
|
|
function normalizeTimes(times, repeat) {
|
|
for (let i = 0; i < times.length; i++) {
|
|
times[i] = times[i] / (repeat + 1);
|
|
}
|
|
}
|
|
|
|
function compareByTime(a, b) {
|
|
if (a.at === b.at) {
|
|
if (a.value === null)
|
|
return 1;
|
|
if (b.value === null)
|
|
return -1;
|
|
return 0;
|
|
}
|
|
else {
|
|
return a.at - b.at;
|
|
}
|
|
}
|
|
|
|
const defaultSegmentEasing = "easeInOut";
|
|
const MAX_REPEAT = 20;
|
|
function createAnimationsFromSequence(sequence, { defaultTransition = {}, ...sequenceTransition } = {}, scope, generators) {
|
|
const defaultDuration = defaultTransition.duration || 0.3;
|
|
const animationDefinitions = new Map();
|
|
const sequences = new Map();
|
|
const elementCache = {};
|
|
const timeLabels = new Map();
|
|
let prevTime = 0;
|
|
let currentTime = 0;
|
|
let totalDuration = 0;
|
|
/**
|
|
* Build the timeline by mapping over the sequence array and converting
|
|
* the definitions into keyframes and offsets with absolute time values.
|
|
* These will later get converted into relative offsets in a second pass.
|
|
*/
|
|
for (let i = 0; i < sequence.length; i++) {
|
|
const segment = sequence[i];
|
|
/**
|
|
* If this is a timeline label, mark it and skip the rest of this iteration.
|
|
*/
|
|
if (typeof segment === "string") {
|
|
timeLabels.set(segment, currentTime);
|
|
continue;
|
|
}
|
|
else if (!Array.isArray(segment)) {
|
|
timeLabels.set(segment.name, calcNextTime(currentTime, segment.at, prevTime, timeLabels));
|
|
continue;
|
|
}
|
|
let [subject, keyframes, transition = {}] = segment;
|
|
/**
|
|
* If a relative or absolute time value has been specified we need to resolve
|
|
* it in relation to the currentTime.
|
|
*/
|
|
if (transition.at !== undefined) {
|
|
currentTime = calcNextTime(currentTime, transition.at, prevTime, timeLabels);
|
|
}
|
|
/**
|
|
* Keep track of the maximum duration in this definition. This will be
|
|
* applied to currentTime once the definition has been parsed.
|
|
*/
|
|
let maxDuration = 0;
|
|
const resolveValueSequence = (valueKeyframes, valueTransition, valueSequence, elementIndex = 0, numSubjects = 0) => {
|
|
const valueKeyframesAsList = keyframesAsList(valueKeyframes);
|
|
const { delay = 0, times = motionDom.defaultOffset(valueKeyframesAsList), type = defaultTransition.type || "keyframes", repeat, repeatType, repeatDelay = 0, ...remainingTransition } = valueTransition;
|
|
let { ease = defaultTransition.ease || "easeOut", duration } = valueTransition;
|
|
/**
|
|
* Resolve stagger() if defined.
|
|
*/
|
|
const calculatedDelay = typeof delay === "function"
|
|
? delay(elementIndex, numSubjects)
|
|
: delay;
|
|
/**
|
|
* If this animation should and can use a spring, generate a spring easing function.
|
|
*/
|
|
const numKeyframes = valueKeyframesAsList.length;
|
|
const createGenerator = motionDom.isGenerator(type)
|
|
? type
|
|
: generators?.[type || "keyframes"];
|
|
if (numKeyframes <= 2 && createGenerator) {
|
|
/**
|
|
* As we're creating an easing function from a spring,
|
|
* ideally we want to generate it using the real distance
|
|
* between the two keyframes. However this isn't always
|
|
* possible - in these situations we use 0-100.
|
|
*/
|
|
let absoluteDelta = 100;
|
|
if (numKeyframes === 2 &&
|
|
isNumberKeyframesArray(valueKeyframesAsList)) {
|
|
const delta = valueKeyframesAsList[1] - valueKeyframesAsList[0];
|
|
absoluteDelta = Math.abs(delta);
|
|
}
|
|
const springTransition = {
|
|
...defaultTransition,
|
|
...remainingTransition,
|
|
};
|
|
if (duration !== undefined) {
|
|
springTransition.duration = motionUtils.secondsToMilliseconds(duration);
|
|
}
|
|
const springEasing = motionDom.createGeneratorEasing(springTransition, absoluteDelta, createGenerator);
|
|
ease = springEasing.ease;
|
|
duration = springEasing.duration;
|
|
}
|
|
duration ?? (duration = defaultDuration);
|
|
const startTime = currentTime + calculatedDelay;
|
|
/**
|
|
* If there's only one time offset of 0, fill in a second with length 1
|
|
*/
|
|
if (times.length === 1 && times[0] === 0) {
|
|
times[1] = 1;
|
|
}
|
|
/**
|
|
* Fill out if offset if fewer offsets than keyframes
|
|
*/
|
|
const remainder = times.length - valueKeyframesAsList.length;
|
|
remainder > 0 && motionDom.fillOffset(times, remainder);
|
|
/**
|
|
* If only one value has been set, ie [1], push a null to the start of
|
|
* the keyframe array. This will let us mark a keyframe at this point
|
|
* that will later be hydrated with the previous value.
|
|
*/
|
|
valueKeyframesAsList.length === 1 &&
|
|
valueKeyframesAsList.unshift(null);
|
|
/**
|
|
* Handle repeat options
|
|
*/
|
|
if (repeat) {
|
|
motionUtils.invariant(repeat < MAX_REPEAT, "Repeat count too high, must be less than 20", "repeat-count-high");
|
|
duration = calculateRepeatDuration(duration, repeat);
|
|
const originalKeyframes = [...valueKeyframesAsList];
|
|
const originalTimes = [...times];
|
|
ease = Array.isArray(ease) ? [...ease] : [ease];
|
|
const originalEase = [...ease];
|
|
for (let repeatIndex = 0; repeatIndex < repeat; repeatIndex++) {
|
|
valueKeyframesAsList.push(...originalKeyframes);
|
|
for (let keyframeIndex = 0; keyframeIndex < originalKeyframes.length; keyframeIndex++) {
|
|
times.push(originalTimes[keyframeIndex] + (repeatIndex + 1));
|
|
ease.push(keyframeIndex === 0
|
|
? "linear"
|
|
: motionUtils.getEasingForSegment(originalEase, keyframeIndex - 1));
|
|
}
|
|
}
|
|
normalizeTimes(times, repeat);
|
|
}
|
|
const targetTime = startTime + duration;
|
|
/**
|
|
* Add keyframes, mapping offsets to absolute time.
|
|
*/
|
|
addKeyframes(valueSequence, valueKeyframesAsList, ease, times, startTime, targetTime);
|
|
maxDuration = Math.max(calculatedDelay + duration, maxDuration);
|
|
totalDuration = Math.max(targetTime, totalDuration);
|
|
};
|
|
if (motionDom.isMotionValue(subject)) {
|
|
const subjectSequence = getSubjectSequence(subject, sequences);
|
|
resolveValueSequence(keyframes, transition, getValueSequence("default", subjectSequence));
|
|
}
|
|
else {
|
|
const subjects = resolveSubjects(subject, keyframes, scope, elementCache);
|
|
const numSubjects = subjects.length;
|
|
/**
|
|
* For every element in this segment, process the defined values.
|
|
*/
|
|
for (let subjectIndex = 0; subjectIndex < numSubjects; subjectIndex++) {
|
|
/**
|
|
* Cast necessary, but we know these are of this type
|
|
*/
|
|
keyframes = keyframes;
|
|
transition = transition;
|
|
const thisSubject = subjects[subjectIndex];
|
|
const subjectSequence = getSubjectSequence(thisSubject, sequences);
|
|
for (const key in keyframes) {
|
|
resolveValueSequence(keyframes[key], getValueTransition(transition, key), getValueSequence(key, subjectSequence), subjectIndex, numSubjects);
|
|
}
|
|
}
|
|
}
|
|
prevTime = currentTime;
|
|
currentTime += maxDuration;
|
|
}
|
|
/**
|
|
* For every element and value combination create a new animation.
|
|
*/
|
|
sequences.forEach((valueSequences, element) => {
|
|
for (const key in valueSequences) {
|
|
const valueSequence = valueSequences[key];
|
|
/**
|
|
* Arrange all the keyframes in ascending time order.
|
|
*/
|
|
valueSequence.sort(compareByTime);
|
|
const keyframes = [];
|
|
const valueOffset = [];
|
|
const valueEasing = [];
|
|
/**
|
|
* For each keyframe, translate absolute times into
|
|
* relative offsets based on the total duration of the timeline.
|
|
*/
|
|
for (let i = 0; i < valueSequence.length; i++) {
|
|
const { at, value, easing } = valueSequence[i];
|
|
keyframes.push(value);
|
|
valueOffset.push(motionUtils.progress(0, totalDuration, at));
|
|
valueEasing.push(easing || "easeOut");
|
|
}
|
|
/**
|
|
* If the first keyframe doesn't land on offset: 0
|
|
* provide one by duplicating the initial keyframe. This ensures
|
|
* it snaps to the first keyframe when the animation starts.
|
|
*/
|
|
if (valueOffset[0] !== 0) {
|
|
valueOffset.unshift(0);
|
|
keyframes.unshift(keyframes[0]);
|
|
valueEasing.unshift(defaultSegmentEasing);
|
|
}
|
|
/**
|
|
* If the last keyframe doesn't land on offset: 1
|
|
* provide one with a null wildcard value. This will ensure it
|
|
* stays static until the end of the animation.
|
|
*/
|
|
if (valueOffset[valueOffset.length - 1] !== 1) {
|
|
valueOffset.push(1);
|
|
keyframes.push(null);
|
|
}
|
|
if (!animationDefinitions.has(element)) {
|
|
animationDefinitions.set(element, {
|
|
keyframes: {},
|
|
transition: {},
|
|
});
|
|
}
|
|
const definition = animationDefinitions.get(element);
|
|
definition.keyframes[key] = keyframes;
|
|
/**
|
|
* Exclude `type` from defaultTransition since springs have been
|
|
* converted to duration-based easing functions in resolveValueSequence.
|
|
* Including `type: "spring"` would cause JSAnimation to error when
|
|
* the merged keyframes array has more than 2 keyframes.
|
|
*/
|
|
const { type: _type, ...remainingDefaultTransition } = defaultTransition;
|
|
definition.transition[key] = {
|
|
...remainingDefaultTransition,
|
|
duration: totalDuration,
|
|
ease: valueEasing,
|
|
times: valueOffset,
|
|
...sequenceTransition,
|
|
};
|
|
}
|
|
});
|
|
return animationDefinitions;
|
|
}
|
|
function getSubjectSequence(subject, sequences) {
|
|
!sequences.has(subject) && sequences.set(subject, {});
|
|
return sequences.get(subject);
|
|
}
|
|
function getValueSequence(name, sequences) {
|
|
if (!sequences[name])
|
|
sequences[name] = [];
|
|
return sequences[name];
|
|
}
|
|
function keyframesAsList(keyframes) {
|
|
return Array.isArray(keyframes) ? keyframes : [keyframes];
|
|
}
|
|
function getValueTransition(transition, key) {
|
|
return transition && transition[key]
|
|
? {
|
|
...transition,
|
|
...transition[key],
|
|
}
|
|
: { ...transition };
|
|
}
|
|
const isNumber = (keyframe) => typeof keyframe === "number";
|
|
const isNumberKeyframesArray = (keyframes) => keyframes.every(isNumber);
|
|
|
|
function createDOMVisualElement(element) {
|
|
const options = {
|
|
presenceContext: null,
|
|
props: {},
|
|
visualState: {
|
|
renderState: {
|
|
transform: {},
|
|
transformOrigin: {},
|
|
style: {},
|
|
vars: {},
|
|
attrs: {},
|
|
},
|
|
latestValues: {},
|
|
},
|
|
};
|
|
const node = motionDom.isSVGElement(element) && !motionDom.isSVGSVGElement(element)
|
|
? new motionDom.SVGVisualElement(options)
|
|
: new motionDom.HTMLVisualElement(options);
|
|
node.mount(element);
|
|
motionDom.visualElementStore.set(element, node);
|
|
}
|
|
function createObjectVisualElement(subject) {
|
|
const options = {
|
|
presenceContext: null,
|
|
props: {},
|
|
visualState: {
|
|
renderState: {
|
|
output: {},
|
|
},
|
|
latestValues: {},
|
|
},
|
|
};
|
|
const node = new motionDom.ObjectVisualElement(options);
|
|
node.mount(subject);
|
|
motionDom.visualElementStore.set(subject, node);
|
|
}
|
|
|
|
function isSingleValue(subject, keyframes) {
|
|
return (motionDom.isMotionValue(subject) ||
|
|
typeof subject === "number" ||
|
|
(typeof subject === "string" && !isDOMKeyframes(keyframes)));
|
|
}
|
|
/**
|
|
* Implementation
|
|
*/
|
|
function animateSubject(subject, keyframes, options, scope) {
|
|
const animations = [];
|
|
if (isSingleValue(subject, keyframes)) {
|
|
animations.push(motionDom.animateSingleValue(subject, isDOMKeyframes(keyframes)
|
|
? keyframes.default || keyframes
|
|
: keyframes, options ? options.default || options : options));
|
|
}
|
|
else {
|
|
// Gracefully handle null/undefined subjects (e.g., from querySelector returning null)
|
|
if (subject == null) {
|
|
return animations;
|
|
}
|
|
const subjects = resolveSubjects(subject, keyframes, scope);
|
|
const numSubjects = subjects.length;
|
|
motionUtils.invariant(Boolean(numSubjects), "No valid elements provided.", "no-valid-elements");
|
|
for (let i = 0; i < numSubjects; i++) {
|
|
const thisSubject = subjects[i];
|
|
const createVisualElement = thisSubject instanceof Element
|
|
? createDOMVisualElement
|
|
: createObjectVisualElement;
|
|
if (!motionDom.visualElementStore.has(thisSubject)) {
|
|
createVisualElement(thisSubject);
|
|
}
|
|
const visualElement = motionDom.visualElementStore.get(thisSubject);
|
|
const transition = { ...options };
|
|
/**
|
|
* Resolve stagger function if provided.
|
|
*/
|
|
if ("delay" in transition &&
|
|
typeof transition.delay === "function") {
|
|
transition.delay = transition.delay(i, numSubjects);
|
|
}
|
|
animations.push(...motionDom.animateTarget(visualElement, { ...keyframes, transition }, {}));
|
|
}
|
|
}
|
|
return animations;
|
|
}
|
|
|
|
function animateSequence(sequence, options, scope) {
|
|
const animations = [];
|
|
/**
|
|
* Pre-process: replace function segments with MotionValue segments,
|
|
* subscribe callbacks immediately
|
|
*/
|
|
const processedSequence = sequence.map((segment) => {
|
|
if (Array.isArray(segment) && typeof segment[0] === "function") {
|
|
const callback = segment[0];
|
|
const mv = motionDom.motionValue(0);
|
|
mv.on("change", callback);
|
|
if (segment.length === 1) {
|
|
return [mv, [0, 1]];
|
|
}
|
|
else if (segment.length === 2) {
|
|
return [mv, [0, 1], segment[1]];
|
|
}
|
|
else {
|
|
return [mv, segment[1], segment[2]];
|
|
}
|
|
}
|
|
return segment;
|
|
});
|
|
const animationDefinitions = createAnimationsFromSequence(processedSequence, options, scope, { spring: motionDom.spring });
|
|
animationDefinitions.forEach(({ keyframes, transition }, subject) => {
|
|
animations.push(...animateSubject(subject, keyframes, transition));
|
|
});
|
|
return animations;
|
|
}
|
|
|
|
function isSequence(value) {
|
|
return Array.isArray(value) && value.some(Array.isArray);
|
|
}
|
|
/**
|
|
* Creates an animation function that is optionally scoped
|
|
* to a specific element.
|
|
*/
|
|
function createScopedAnimate(options = {}) {
|
|
const { scope, reduceMotion } = options;
|
|
/**
|
|
* Implementation
|
|
*/
|
|
function scopedAnimate(subjectOrSequence, optionsOrKeyframes, options) {
|
|
let animations = [];
|
|
let animationOnComplete;
|
|
if (isSequence(subjectOrSequence)) {
|
|
animations = animateSequence(subjectOrSequence, reduceMotion !== undefined
|
|
? { reduceMotion, ...optionsOrKeyframes }
|
|
: optionsOrKeyframes, scope);
|
|
}
|
|
else {
|
|
// Extract top-level onComplete so it doesn't get applied per-value
|
|
const { onComplete, ...rest } = options || {};
|
|
if (typeof onComplete === "function") {
|
|
animationOnComplete = onComplete;
|
|
}
|
|
animations = animateSubject(subjectOrSequence, optionsOrKeyframes, (reduceMotion !== undefined
|
|
? { reduceMotion, ...rest }
|
|
: rest), scope);
|
|
}
|
|
const animation = new motionDom.GroupAnimationWithThen(animations);
|
|
if (animationOnComplete) {
|
|
animation.finished.then(animationOnComplete);
|
|
}
|
|
if (scope) {
|
|
scope.animations.push(animation);
|
|
animation.finished.then(() => {
|
|
motionUtils.removeItem(scope.animations, animation);
|
|
});
|
|
}
|
|
return animation;
|
|
}
|
|
return scopedAnimate;
|
|
}
|
|
const animate = createScopedAnimate();
|
|
|
|
function animateElements(elementOrSelector, keyframes, options, scope) {
|
|
// Gracefully handle null/undefined elements (e.g., from querySelector returning null)
|
|
if (elementOrSelector == null) {
|
|
return [];
|
|
}
|
|
const elements = motionDom.resolveElements(elementOrSelector, scope);
|
|
const numElements = elements.length;
|
|
motionUtils.invariant(Boolean(numElements), "No valid elements provided.", "no-valid-elements");
|
|
/**
|
|
* WAAPI doesn't support interrupting animations.
|
|
*
|
|
* Therefore, starting animations requires a three-step process:
|
|
* 1. Stop existing animations (write styles to DOM)
|
|
* 2. Resolve keyframes (read styles from DOM)
|
|
* 3. Create new animations (write styles to DOM)
|
|
*
|
|
* The hybrid `animate()` function uses AsyncAnimation to resolve
|
|
* keyframes before creating new animations, which removes style
|
|
* thrashing. Here, we have much stricter filesize constraints.
|
|
* Therefore we do this in a synchronous way that ensures that
|
|
* at least within `animate()` calls there is no style thrashing.
|
|
*
|
|
* In the motion-native-animate-mini-interrupt benchmark this
|
|
* was 80% faster than a single loop.
|
|
*/
|
|
const animationDefinitions = [];
|
|
/**
|
|
* Step 1: Build options and stop existing animations (write)
|
|
*/
|
|
for (let i = 0; i < numElements; i++) {
|
|
const element = elements[i];
|
|
const elementTransition = { ...options };
|
|
/**
|
|
* Resolve stagger function if provided.
|
|
*/
|
|
if (typeof elementTransition.delay === "function") {
|
|
elementTransition.delay = elementTransition.delay(i, numElements);
|
|
}
|
|
for (const valueName in keyframes) {
|
|
let valueKeyframes = keyframes[valueName];
|
|
if (!Array.isArray(valueKeyframes)) {
|
|
valueKeyframes = [valueKeyframes];
|
|
}
|
|
const valueOptions = {
|
|
...motionDom.getValueTransition(elementTransition, valueName),
|
|
};
|
|
valueOptions.duration && (valueOptions.duration = motionUtils.secondsToMilliseconds(valueOptions.duration));
|
|
valueOptions.delay && (valueOptions.delay = motionUtils.secondsToMilliseconds(valueOptions.delay));
|
|
/**
|
|
* If there's an existing animation playing on this element then stop it
|
|
* before creating a new one.
|
|
*/
|
|
const map = motionDom.getAnimationMap(element);
|
|
const key = motionDom.animationMapKey(valueName, valueOptions.pseudoElement || "");
|
|
const currentAnimation = map.get(key);
|
|
currentAnimation && currentAnimation.stop();
|
|
animationDefinitions.push({
|
|
map,
|
|
key,
|
|
unresolvedKeyframes: valueKeyframes,
|
|
options: {
|
|
...valueOptions,
|
|
element,
|
|
name: valueName,
|
|
allowFlatten: !elementTransition.type && !elementTransition.ease,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
/**
|
|
* Step 2: Resolve keyframes (read)
|
|
*/
|
|
for (let i = 0; i < animationDefinitions.length; i++) {
|
|
const { unresolvedKeyframes, options: animationOptions } = animationDefinitions[i];
|
|
const { element, name, pseudoElement } = animationOptions;
|
|
if (!pseudoElement && unresolvedKeyframes[0] === null) {
|
|
unresolvedKeyframes[0] = motionDom.getComputedStyle(element, name);
|
|
}
|
|
motionDom.fillWildcards(unresolvedKeyframes);
|
|
motionDom.applyPxDefaults(unresolvedKeyframes, name);
|
|
/**
|
|
* If we only have one keyframe, explicitly read the initial keyframe
|
|
* from the computed style. This is to ensure consistency with WAAPI behaviour
|
|
* for restarting animations, for instance .play() after finish, when it
|
|
* has one vs two keyframes.
|
|
*/
|
|
if (!pseudoElement && unresolvedKeyframes.length < 2) {
|
|
unresolvedKeyframes.unshift(motionDom.getComputedStyle(element, name));
|
|
}
|
|
animationOptions.keyframes = unresolvedKeyframes;
|
|
}
|
|
/**
|
|
* Step 3: Create new animations (write)
|
|
*/
|
|
const animations = [];
|
|
for (let i = 0; i < animationDefinitions.length; i++) {
|
|
const { map, key, options: animationOptions } = animationDefinitions[i];
|
|
const animation = new motionDom.NativeAnimation(animationOptions);
|
|
map.set(key, animation);
|
|
animation.finished.finally(() => map.delete(key));
|
|
animations.push(animation);
|
|
}
|
|
return animations;
|
|
}
|
|
|
|
const createScopedWaapiAnimate = (scope) => {
|
|
function scopedAnimate(elementOrSelector, keyframes, options) {
|
|
return new motionDom.GroupAnimationWithThen(animateElements(elementOrSelector, keyframes, options, scope));
|
|
}
|
|
return scopedAnimate;
|
|
};
|
|
const animateMini = /*@__PURE__*/ createScopedWaapiAnimate();
|
|
|
|
/**
|
|
* A time in milliseconds, beyond which we consider the scroll velocity to be 0.
|
|
*/
|
|
const maxElapsed = 50;
|
|
const createAxisInfo = () => ({
|
|
current: 0,
|
|
offset: [],
|
|
progress: 0,
|
|
scrollLength: 0,
|
|
targetOffset: 0,
|
|
targetLength: 0,
|
|
containerLength: 0,
|
|
velocity: 0,
|
|
});
|
|
const createScrollInfo = () => ({
|
|
time: 0,
|
|
x: createAxisInfo(),
|
|
y: createAxisInfo(),
|
|
});
|
|
const keys = {
|
|
x: {
|
|
length: "Width",
|
|
position: "Left",
|
|
},
|
|
y: {
|
|
length: "Height",
|
|
position: "Top",
|
|
},
|
|
};
|
|
function updateAxisInfo(element, axisName, info, time) {
|
|
const axis = info[axisName];
|
|
const { length, position } = keys[axisName];
|
|
const prev = axis.current;
|
|
const prevTime = info.time;
|
|
axis.current = element[`scroll${position}`];
|
|
axis.scrollLength = element[`scroll${length}`] - element[`client${length}`];
|
|
axis.offset.length = 0;
|
|
axis.offset[0] = 0;
|
|
axis.offset[1] = axis.scrollLength;
|
|
axis.progress = motionUtils.progress(0, axis.scrollLength, axis.current);
|
|
const elapsed = time - prevTime;
|
|
axis.velocity =
|
|
elapsed > maxElapsed
|
|
? 0
|
|
: motionUtils.velocityPerSecond(axis.current - prev, elapsed);
|
|
}
|
|
function updateScrollInfo(element, info, time) {
|
|
updateAxisInfo(element, "x", info, time);
|
|
updateAxisInfo(element, "y", info, time);
|
|
info.time = time;
|
|
}
|
|
|
|
function calcInset(element, container) {
|
|
const inset = { x: 0, y: 0 };
|
|
let current = element;
|
|
while (current && current !== container) {
|
|
if (motionDom.isHTMLElement(current)) {
|
|
inset.x += current.offsetLeft;
|
|
inset.y += current.offsetTop;
|
|
current = current.offsetParent;
|
|
}
|
|
else if (current.tagName === "svg") {
|
|
/**
|
|
* This isn't an ideal approach to measuring the offset of <svg /> tags.
|
|
* It would be preferable, given they behave like HTMLElements in most ways
|
|
* to use offsetLeft/Top. But these don't exist on <svg />. Likewise we
|
|
* can't use .getBBox() like most SVG elements as these provide the offset
|
|
* relative to the SVG itself, which for <svg /> is usually 0x0.
|
|
*/
|
|
const svgBoundingBox = current.getBoundingClientRect();
|
|
current = current.parentElement;
|
|
const parentBoundingBox = current.getBoundingClientRect();
|
|
inset.x += svgBoundingBox.left - parentBoundingBox.left;
|
|
inset.y += svgBoundingBox.top - parentBoundingBox.top;
|
|
}
|
|
else if (current instanceof SVGGraphicsElement) {
|
|
const { x, y } = current.getBBox();
|
|
inset.x += x;
|
|
inset.y += y;
|
|
let svg = null;
|
|
let parent = current.parentNode;
|
|
while (!svg) {
|
|
if (parent.tagName === "svg") {
|
|
svg = parent;
|
|
}
|
|
parent = current.parentNode;
|
|
}
|
|
current = svg;
|
|
}
|
|
else {
|
|
break;
|
|
}
|
|
}
|
|
return inset;
|
|
}
|
|
|
|
const namedEdges = {
|
|
start: 0,
|
|
center: 0.5,
|
|
end: 1,
|
|
};
|
|
function resolveEdge(edge, length, inset = 0) {
|
|
let delta = 0;
|
|
/**
|
|
* If we have this edge defined as a preset, replace the definition
|
|
* with the numerical value.
|
|
*/
|
|
if (edge in namedEdges) {
|
|
edge = namedEdges[edge];
|
|
}
|
|
/**
|
|
* Handle unit values
|
|
*/
|
|
if (typeof edge === "string") {
|
|
const asNumber = parseFloat(edge);
|
|
if (edge.endsWith("px")) {
|
|
delta = asNumber;
|
|
}
|
|
else if (edge.endsWith("%")) {
|
|
edge = asNumber / 100;
|
|
}
|
|
else if (edge.endsWith("vw")) {
|
|
delta = (asNumber / 100) * document.documentElement.clientWidth;
|
|
}
|
|
else if (edge.endsWith("vh")) {
|
|
delta = (asNumber / 100) * document.documentElement.clientHeight;
|
|
}
|
|
else {
|
|
edge = asNumber;
|
|
}
|
|
}
|
|
/**
|
|
* If the edge is defined as a number, handle as a progress value.
|
|
*/
|
|
if (typeof edge === "number") {
|
|
delta = length * edge;
|
|
}
|
|
return inset + delta;
|
|
}
|
|
|
|
const defaultOffset = [0, 0];
|
|
function resolveOffset(offset, containerLength, targetLength, targetInset) {
|
|
let offsetDefinition = Array.isArray(offset) ? offset : defaultOffset;
|
|
let targetPoint = 0;
|
|
let containerPoint = 0;
|
|
if (typeof offset === "number") {
|
|
/**
|
|
* If we're provided offset: [0, 0.5, 1] then each number x should become
|
|
* [x, x], so we default to the behaviour of mapping 0 => 0 of both target
|
|
* and container etc.
|
|
*/
|
|
offsetDefinition = [offset, offset];
|
|
}
|
|
else if (typeof offset === "string") {
|
|
offset = offset.trim();
|
|
if (offset.includes(" ")) {
|
|
offsetDefinition = offset.split(" ");
|
|
}
|
|
else {
|
|
/**
|
|
* If we're provided a definition like "100px" then we want to apply
|
|
* that only to the top of the target point, leaving the container at 0.
|
|
* Whereas a named offset like "end" should be applied to both.
|
|
*/
|
|
offsetDefinition = [offset, namedEdges[offset] ? offset : `0`];
|
|
}
|
|
}
|
|
targetPoint = resolveEdge(offsetDefinition[0], targetLength, targetInset);
|
|
containerPoint = resolveEdge(offsetDefinition[1], containerLength);
|
|
return targetPoint - containerPoint;
|
|
}
|
|
|
|
const ScrollOffset = {
|
|
Enter: [
|
|
[0, 1],
|
|
[1, 1],
|
|
],
|
|
Exit: [
|
|
[0, 0],
|
|
[1, 0],
|
|
],
|
|
Any: [
|
|
[1, 0],
|
|
[0, 1],
|
|
],
|
|
All: [
|
|
[0, 0],
|
|
[1, 1],
|
|
],
|
|
};
|
|
|
|
const point = { x: 0, y: 0 };
|
|
function getTargetSize(target) {
|
|
return "getBBox" in target && target.tagName !== "svg"
|
|
? target.getBBox()
|
|
: { width: target.clientWidth, height: target.clientHeight };
|
|
}
|
|
function resolveOffsets(container, info, options) {
|
|
const { offset: offsetDefinition = ScrollOffset.All } = options;
|
|
const { target = container, axis = "y" } = options;
|
|
const lengthLabel = axis === "y" ? "height" : "width";
|
|
const inset = target !== container ? calcInset(target, container) : point;
|
|
/**
|
|
* Measure the target and container. If they're the same thing then we
|
|
* use the container's scrollWidth/Height as the target, from there
|
|
* all other calculations can remain the same.
|
|
*/
|
|
const targetSize = target === container
|
|
? { width: container.scrollWidth, height: container.scrollHeight }
|
|
: getTargetSize(target);
|
|
const containerSize = {
|
|
width: container.clientWidth,
|
|
height: container.clientHeight,
|
|
};
|
|
/**
|
|
* Reset the length of the resolved offset array rather than creating a new one.
|
|
* TODO: More reusable data structures for targetSize/containerSize would also be good.
|
|
*/
|
|
info[axis].offset.length = 0;
|
|
/**
|
|
* Populate the offset array by resolving the user's offset definition into
|
|
* a list of pixel scroll offets.
|
|
*/
|
|
let hasChanged = !info[axis].interpolate;
|
|
const numOffsets = offsetDefinition.length;
|
|
for (let i = 0; i < numOffsets; i++) {
|
|
const offset = resolveOffset(offsetDefinition[i], containerSize[lengthLabel], targetSize[lengthLabel], inset[axis]);
|
|
if (!hasChanged && offset !== info[axis].interpolatorOffsets[i]) {
|
|
hasChanged = true;
|
|
}
|
|
info[axis].offset[i] = offset;
|
|
}
|
|
/**
|
|
* If the pixel scroll offsets have changed, create a new interpolator function
|
|
* to map scroll value into a progress.
|
|
*/
|
|
if (hasChanged) {
|
|
info[axis].interpolate = motionDom.interpolate(info[axis].offset, motionDom.defaultOffset(offsetDefinition), { clamp: false });
|
|
info[axis].interpolatorOffsets = [...info[axis].offset];
|
|
}
|
|
info[axis].progress = motionUtils.clamp(0, 1, info[axis].interpolate(info[axis].current));
|
|
}
|
|
|
|
function measure(container, target = container, info) {
|
|
/**
|
|
* Find inset of target within scrollable container
|
|
*/
|
|
info.x.targetOffset = 0;
|
|
info.y.targetOffset = 0;
|
|
if (target !== container) {
|
|
let node = target;
|
|
while (node && node !== container) {
|
|
info.x.targetOffset += node.offsetLeft;
|
|
info.y.targetOffset += node.offsetTop;
|
|
node = node.offsetParent;
|
|
}
|
|
}
|
|
info.x.targetLength =
|
|
target === container ? target.scrollWidth : target.clientWidth;
|
|
info.y.targetLength =
|
|
target === container ? target.scrollHeight : target.clientHeight;
|
|
info.x.containerLength = container.clientWidth;
|
|
info.y.containerLength = container.clientHeight;
|
|
/**
|
|
* In development mode ensure scroll containers aren't position: static as this makes
|
|
* it difficult to measure their relative positions.
|
|
*/
|
|
if (process.env.NODE_ENV !== "production") {
|
|
if (container && target && target !== container) {
|
|
motionUtils.warnOnce(getComputedStyle(container).position !== "static", "Please ensure that the container has a non-static position, like 'relative', 'fixed', or 'absolute' to ensure scroll offset is calculated correctly.");
|
|
}
|
|
}
|
|
}
|
|
function createOnScrollHandler(element, onScroll, info, options = {}) {
|
|
return {
|
|
measure: (time) => {
|
|
measure(element, options.target, info);
|
|
updateScrollInfo(element, info, time);
|
|
if (options.offset || options.target) {
|
|
resolveOffsets(element, info, options);
|
|
}
|
|
},
|
|
notify: () => onScroll(info),
|
|
};
|
|
}
|
|
|
|
const scrollListeners = new WeakMap();
|
|
const resizeListeners = new WeakMap();
|
|
const onScrollHandlers = new WeakMap();
|
|
const scrollSize = new WeakMap();
|
|
const dimensionCheckProcesses = new WeakMap();
|
|
const getEventTarget = (element) => element === document.scrollingElement ? window : element;
|
|
function scrollInfo(onScroll, { container = document.scrollingElement, trackContentSize = false, ...options } = {}) {
|
|
if (!container)
|
|
return motionUtils.noop;
|
|
let containerHandlers = onScrollHandlers.get(container);
|
|
/**
|
|
* Get the onScroll handlers for this container.
|
|
* If one isn't found, create a new one.
|
|
*/
|
|
if (!containerHandlers) {
|
|
containerHandlers = new Set();
|
|
onScrollHandlers.set(container, containerHandlers);
|
|
}
|
|
/**
|
|
* Create a new onScroll handler for the provided callback.
|
|
*/
|
|
const info = createScrollInfo();
|
|
const containerHandler = createOnScrollHandler(container, onScroll, info, options);
|
|
containerHandlers.add(containerHandler);
|
|
/**
|
|
* Check if there's a scroll event listener for this container.
|
|
* If not, create one.
|
|
*/
|
|
if (!scrollListeners.has(container)) {
|
|
const measureAll = () => {
|
|
for (const handler of containerHandlers) {
|
|
handler.measure(motionDom.frameData.timestamp);
|
|
}
|
|
motionDom.frame.preUpdate(notifyAll);
|
|
};
|
|
const notifyAll = () => {
|
|
for (const handler of containerHandlers) {
|
|
handler.notify();
|
|
}
|
|
};
|
|
const listener = () => motionDom.frame.read(measureAll);
|
|
scrollListeners.set(container, listener);
|
|
const target = getEventTarget(container);
|
|
window.addEventListener("resize", listener, { passive: true });
|
|
if (container !== document.documentElement) {
|
|
resizeListeners.set(container, motionDom.resize(container, listener));
|
|
}
|
|
target.addEventListener("scroll", listener, { passive: true });
|
|
listener();
|
|
}
|
|
/**
|
|
* Enable content size tracking if requested and not already enabled.
|
|
*/
|
|
if (trackContentSize && !dimensionCheckProcesses.has(container)) {
|
|
const listener = scrollListeners.get(container);
|
|
// Store initial scroll dimensions (object is reused to avoid allocation)
|
|
const size = {
|
|
width: container.scrollWidth,
|
|
height: container.scrollHeight,
|
|
};
|
|
scrollSize.set(container, size);
|
|
// Add frame-based scroll dimension checking to detect content changes
|
|
const checkScrollDimensions = () => {
|
|
const newWidth = container.scrollWidth;
|
|
const newHeight = container.scrollHeight;
|
|
if (size.width !== newWidth || size.height !== newHeight) {
|
|
listener();
|
|
size.width = newWidth;
|
|
size.height = newHeight;
|
|
}
|
|
};
|
|
// Schedule with keepAlive=true to run every frame
|
|
const dimensionCheckProcess = motionDom.frame.read(checkScrollDimensions, true);
|
|
dimensionCheckProcesses.set(container, dimensionCheckProcess);
|
|
}
|
|
const listener = scrollListeners.get(container);
|
|
motionDom.frame.read(listener, false, true);
|
|
return () => {
|
|
motionDom.cancelFrame(listener);
|
|
/**
|
|
* Check if we even have any handlers for this container.
|
|
*/
|
|
const currentHandlers = onScrollHandlers.get(container);
|
|
if (!currentHandlers)
|
|
return;
|
|
currentHandlers.delete(containerHandler);
|
|
if (currentHandlers.size)
|
|
return;
|
|
/**
|
|
* If no more handlers, remove the scroll listener too.
|
|
*/
|
|
const scrollListener = scrollListeners.get(container);
|
|
scrollListeners.delete(container);
|
|
if (scrollListener) {
|
|
getEventTarget(container).removeEventListener("scroll", scrollListener);
|
|
resizeListeners.get(container)?.();
|
|
window.removeEventListener("resize", scrollListener);
|
|
}
|
|
// Clean up scroll dimension checking
|
|
const dimensionCheckProcess = dimensionCheckProcesses.get(container);
|
|
if (dimensionCheckProcess) {
|
|
motionDom.cancelFrame(dimensionCheckProcess);
|
|
dimensionCheckProcesses.delete(container);
|
|
}
|
|
scrollSize.delete(container);
|
|
};
|
|
}
|
|
|
|
function canUseNativeTimeline(target) {
|
|
return (typeof window !== "undefined" && !target && motionDom.supportsScrollTimeline());
|
|
}
|
|
|
|
const timelineCache = new Map();
|
|
function scrollTimelineFallback(options) {
|
|
const currentTime = { value: 0 };
|
|
const cancel = scrollInfo((info) => {
|
|
currentTime.value = info[options.axis].progress * 100;
|
|
}, options);
|
|
return { currentTime, cancel };
|
|
}
|
|
function getTimeline({ source, container, ...options }) {
|
|
const { axis } = options;
|
|
if (source)
|
|
container = source;
|
|
const containerCache = timelineCache.get(container) ?? new Map();
|
|
timelineCache.set(container, containerCache);
|
|
const targetKey = options.target ?? "self";
|
|
const targetCache = containerCache.get(targetKey) ?? {};
|
|
const axisKey = axis + (options.offset ?? []).join(",");
|
|
if (!targetCache[axisKey]) {
|
|
targetCache[axisKey] =
|
|
canUseNativeTimeline(options.target)
|
|
? new ScrollTimeline({ source: container, axis })
|
|
: scrollTimelineFallback({ container, ...options });
|
|
}
|
|
return targetCache[axisKey];
|
|
}
|
|
|
|
function attachToAnimation(animation, options) {
|
|
const timeline = getTimeline(options);
|
|
return animation.attachTimeline({
|
|
timeline: options.target ? undefined : timeline,
|
|
observe: (valueAnimation) => {
|
|
valueAnimation.pause();
|
|
return motionDom.observeTimeline((progress) => {
|
|
valueAnimation.time =
|
|
valueAnimation.iterationDuration * progress;
|
|
}, timeline);
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* If the onScroll function has two arguments, it's expecting
|
|
* more specific information about the scroll from scrollInfo.
|
|
*/
|
|
function isOnScrollWithInfo(onScroll) {
|
|
return onScroll.length === 2;
|
|
}
|
|
function attachToFunction(onScroll, options) {
|
|
if (isOnScrollWithInfo(onScroll)) {
|
|
return scrollInfo((info) => {
|
|
onScroll(info[options.axis].progress, info);
|
|
}, options);
|
|
}
|
|
else {
|
|
return motionDom.observeTimeline(onScroll, getTimeline(options));
|
|
}
|
|
}
|
|
|
|
function scroll(onScroll, { axis = "y", container = document.scrollingElement, ...options } = {}) {
|
|
if (!container)
|
|
return motionUtils.noop;
|
|
const optionsWithDefaults = { axis, container, ...options };
|
|
return typeof onScroll === "function"
|
|
? attachToFunction(onScroll, optionsWithDefaults)
|
|
: attachToAnimation(onScroll, optionsWithDefaults);
|
|
}
|
|
|
|
const thresholds = {
|
|
some: 0,
|
|
all: 1,
|
|
};
|
|
function inView(elementOrSelector, onStart, { root, margin: rootMargin, amount = "some" } = {}) {
|
|
const elements = motionDom.resolveElements(elementOrSelector);
|
|
const activeIntersections = new WeakMap();
|
|
const onIntersectionChange = (entries) => {
|
|
entries.forEach((entry) => {
|
|
const onEnd = activeIntersections.get(entry.target);
|
|
/**
|
|
* If there's no change to the intersection, we don't need to
|
|
* do anything here.
|
|
*/
|
|
if (entry.isIntersecting === Boolean(onEnd))
|
|
return;
|
|
if (entry.isIntersecting) {
|
|
const newOnEnd = onStart(entry.target, entry);
|
|
if (typeof newOnEnd === "function") {
|
|
activeIntersections.set(entry.target, newOnEnd);
|
|
}
|
|
else {
|
|
observer.unobserve(entry.target);
|
|
}
|
|
}
|
|
else if (typeof onEnd === "function") {
|
|
onEnd(entry);
|
|
activeIntersections.delete(entry.target);
|
|
}
|
|
});
|
|
};
|
|
const observer = new IntersectionObserver(onIntersectionChange, {
|
|
root,
|
|
rootMargin,
|
|
threshold: typeof amount === "number" ? amount : thresholds[amount],
|
|
});
|
|
elements.forEach((element) => observer.observe(element));
|
|
return () => observer.disconnect();
|
|
}
|
|
|
|
const distance = (a, b) => Math.abs(a - b);
|
|
function distance2D(a, b) {
|
|
// Multi-dimensional
|
|
const xDelta = distance(a.x, b.x);
|
|
const yDelta = distance(a.y, b.y);
|
|
return Math.sqrt(xDelta ** 2 + yDelta ** 2);
|
|
}
|
|
|
|
Object.defineProperty(exports, "delay", {
|
|
enumerable: true,
|
|
get: function () { return motionDom.delayInSeconds; }
|
|
});
|
|
exports.animate = animate;
|
|
exports.animateMini = animateMini;
|
|
exports.createScopedAnimate = createScopedAnimate;
|
|
exports.distance = distance;
|
|
exports.distance2D = distance2D;
|
|
exports.inView = inView;
|
|
exports.scroll = scroll;
|
|
exports.scrollInfo = scrollInfo;
|
|
Object.keys(motionDom).forEach(function (k) {
|
|
if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, {
|
|
enumerable: true,
|
|
get: function () { return motionDom[k]; }
|
|
});
|
|
});
|
|
Object.keys(motionUtils).forEach(function (k) {
|
|
if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, {
|
|
enumerable: true,
|
|
get: function () { return motionUtils[k]; }
|
|
});
|
|
});
|
|
//# sourceMappingURL=dom.js.map
|