This commit is contained in:
Iliyan Angelov
2025-09-14 23:24:25 +03:00
commit c67067a2a4
71311 changed files with 6800714 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
import { animateValue } from './js/index.mjs';
import { noop } from '../../utils/noop.mjs';
function createInstantAnimation({ keyframes, delay, onUpdate, onComplete, }) {
const setValue = () => {
onUpdate && onUpdate(keyframes[keyframes.length - 1]);
onComplete && onComplete();
/**
* TODO: As this API grows it could make sense to always return
* animateValue. This will be a bigger project as animateValue
* is frame-locked whereas this function resolves instantly.
* This is a behavioural change and also has ramifications regarding
* assumptions within tests.
*/
return {
time: 0,
speed: 1,
duration: 0,
play: (noop),
pause: (noop),
stop: (noop),
then: (resolve) => {
resolve();
return Promise.resolve();
},
cancel: (noop),
complete: (noop),
};
};
return delay
? animateValue({
keyframes: [0, 1],
duration: 0,
delay,
onComplete: setValue,
})
: setValue();
}
export { createInstantAnimation };

View File

@@ -0,0 +1,16 @@
import { frame, cancelFrame, frameData } from '../../../frameloop/frame.mjs';
const frameloopDriver = (update) => {
const passTimestamp = ({ timestamp }) => update(timestamp);
return {
start: () => frame.update(passTimestamp, true),
stop: () => cancelFrame(passTimestamp),
/**
* If we're processing this frame we can use the
* framelocked timestamp to keep things in sync.
*/
now: () => frameData.isProcessing ? frameData.timestamp : performance.now(),
};
};
export { frameloopDriver };

View File

@@ -0,0 +1,302 @@
import { keyframes } from '../../generators/keyframes.mjs';
import { spring } from '../../generators/spring/index.mjs';
import { inertia } from '../../generators/inertia.mjs';
import { frameloopDriver } from './driver-frameloop.mjs';
import { interpolate } from '../../../utils/interpolate.mjs';
import { clamp } from '../../../utils/clamp.mjs';
import { millisecondsToSeconds, secondsToMilliseconds } from '../../../utils/time-conversion.mjs';
import { calcGeneratorDuration } from '../../generators/utils/calc-duration.mjs';
import { invariant } from '../../../utils/errors.mjs';
const types = {
decay: inertia,
inertia,
tween: keyframes,
keyframes: keyframes,
spring,
};
/**
* Animate a single value on the main thread.
*
* This function is written, where functionality overlaps,
* to be largely spec-compliant with WAAPI to allow fungibility
* between the two.
*/
function animateValue({ autoplay = true, delay = 0, driver = frameloopDriver, keyframes: keyframes$1, type = "keyframes", repeat = 0, repeatDelay = 0, repeatType = "loop", onPlay, onStop, onComplete, onUpdate, ...options }) {
let speed = 1;
let hasStopped = false;
let resolveFinishedPromise;
let currentFinishedPromise;
/**
* Resolve the current Promise every time we enter the
* finished state. This is WAAPI-compatible behaviour.
*/
const updateFinishedPromise = () => {
currentFinishedPromise = new Promise((resolve) => {
resolveFinishedPromise = resolve;
});
};
// Create the first finished promise
updateFinishedPromise();
let animationDriver;
const generatorFactory = types[type] || keyframes;
/**
* If this isn't the keyframes generator and we've been provided
* strings as keyframes, we need to interpolate these.
*/
let mapNumbersToKeyframes;
if (generatorFactory !== keyframes &&
typeof keyframes$1[0] !== "number") {
if (process.env.NODE_ENV !== "production") {
invariant(keyframes$1.length === 2, `Only two keyframes currently supported with spring and inertia animations. Trying to animate ${keyframes$1}`);
}
mapNumbersToKeyframes = interpolate([0, 100], keyframes$1, {
clamp: false,
});
keyframes$1 = [0, 100];
}
const generator = generatorFactory({ ...options, keyframes: keyframes$1 });
let mirroredGenerator;
if (repeatType === "mirror") {
mirroredGenerator = generatorFactory({
...options,
keyframes: [...keyframes$1].reverse(),
velocity: -(options.velocity || 0),
});
}
let playState = "idle";
let holdTime = null;
let startTime = null;
let cancelTime = null;
/**
* If duration is undefined and we have repeat options,
* we need to calculate a duration from the generator.
*
* We set it to the generator itself to cache the duration.
* Any timeline resolver will need to have already precalculated
* the duration by this step.
*/
if (generator.calculatedDuration === null && repeat) {
generator.calculatedDuration = calcGeneratorDuration(generator);
}
const { calculatedDuration } = generator;
let resolvedDuration = Infinity;
let totalDuration = Infinity;
if (calculatedDuration !== null) {
resolvedDuration = calculatedDuration + repeatDelay;
totalDuration = resolvedDuration * (repeat + 1) - repeatDelay;
}
let currentTime = 0;
const tick = (timestamp) => {
if (startTime === null)
return;
/**
* requestAnimationFrame timestamps can come through as lower than
* the startTime as set by performance.now(). Here we prevent this,
* though in the future it could be possible to make setting startTime
* a pending operation that gets resolved here.
*/
if (speed > 0)
startTime = Math.min(startTime, timestamp);
if (speed < 0)
startTime = Math.min(timestamp - totalDuration / speed, startTime);
if (holdTime !== null) {
currentTime = holdTime;
}
else {
// Rounding the time because floating point arithmetic is not always accurate, e.g. 3000.367 - 1000.367 =
// 2000.0000000000002. This is a problem when we are comparing the currentTime with the duration, for
// example.
currentTime = Math.round(timestamp - startTime) * speed;
}
// Rebase on delay
const timeWithoutDelay = currentTime - delay * (speed >= 0 ? 1 : -1);
const isInDelayPhase = speed >= 0 ? timeWithoutDelay < 0 : timeWithoutDelay > totalDuration;
currentTime = Math.max(timeWithoutDelay, 0);
/**
* If this animation has finished, set the current time
* to the total duration.
*/
if (playState === "finished" && holdTime === null) {
currentTime = totalDuration;
}
let elapsed = currentTime;
let frameGenerator = generator;
if (repeat) {
/**
* Get the current progress (0-1) of the animation. If t is >
* than duration we'll get values like 2.5 (midway through the
* third iteration)
*/
const progress = Math.min(currentTime, totalDuration) / resolvedDuration;
/**
* Get the current iteration (0 indexed). For instance the floor of
* 2.5 is 2.
*/
let currentIteration = Math.floor(progress);
/**
* Get the current progress of the iteration by taking the remainder
* so 2.5 is 0.5 through iteration 2
*/
let iterationProgress = progress % 1.0;
/**
* If iteration progress is 1 we count that as the end
* of the previous iteration.
*/
if (!iterationProgress && progress >= 1) {
iterationProgress = 1;
}
iterationProgress === 1 && currentIteration--;
currentIteration = Math.min(currentIteration, repeat + 1);
/**
* Reverse progress if we're not running in "normal" direction
*/
const isOddIteration = Boolean(currentIteration % 2);
if (isOddIteration) {
if (repeatType === "reverse") {
iterationProgress = 1 - iterationProgress;
if (repeatDelay) {
iterationProgress -= repeatDelay / resolvedDuration;
}
}
else if (repeatType === "mirror") {
frameGenerator = mirroredGenerator;
}
}
elapsed = clamp(0, 1, iterationProgress) * resolvedDuration;
}
/**
* If we're in negative time, set state as the initial keyframe.
* This prevents delay: x, duration: 0 animations from finishing
* instantly.
*/
const state = isInDelayPhase
? { done: false, value: keyframes$1[0] }
: frameGenerator.next(elapsed);
if (mapNumbersToKeyframes) {
state.value = mapNumbersToKeyframes(state.value);
}
let { done } = state;
if (!isInDelayPhase && calculatedDuration !== null) {
done = speed >= 0 ? currentTime >= totalDuration : currentTime <= 0;
}
const isAnimationFinished = holdTime === null &&
(playState === "finished" || (playState === "running" && done));
if (onUpdate) {
onUpdate(state.value);
}
if (isAnimationFinished) {
finish();
}
return state;
};
const stopAnimationDriver = () => {
animationDriver && animationDriver.stop();
animationDriver = undefined;
};
const cancel = () => {
playState = "idle";
stopAnimationDriver();
resolveFinishedPromise();
updateFinishedPromise();
startTime = cancelTime = null;
};
const finish = () => {
playState = "finished";
onComplete && onComplete();
stopAnimationDriver();
resolveFinishedPromise();
};
const play = () => {
if (hasStopped)
return;
if (!animationDriver)
animationDriver = driver(tick);
const now = animationDriver.now();
onPlay && onPlay();
if (holdTime !== null) {
startTime = now - holdTime;
}
else if (!startTime || playState === "finished") {
startTime = now;
}
if (playState === "finished") {
updateFinishedPromise();
}
cancelTime = startTime;
holdTime = null;
/**
* Set playState to running only after we've used it in
* the previous logic.
*/
playState = "running";
animationDriver.start();
};
if (autoplay) {
play();
}
const controls = {
then(resolve, reject) {
return currentFinishedPromise.then(resolve, reject);
},
get time() {
return millisecondsToSeconds(currentTime);
},
set time(newTime) {
newTime = secondsToMilliseconds(newTime);
currentTime = newTime;
if (holdTime !== null || !animationDriver || speed === 0) {
holdTime = newTime;
}
else {
startTime = animationDriver.now() - newTime / speed;
}
},
get duration() {
const duration = generator.calculatedDuration === null
? calcGeneratorDuration(generator)
: generator.calculatedDuration;
return millisecondsToSeconds(duration);
},
get speed() {
return speed;
},
set speed(newSpeed) {
if (newSpeed === speed || !animationDriver)
return;
speed = newSpeed;
controls.time = millisecondsToSeconds(currentTime);
},
get state() {
return playState;
},
play,
pause: () => {
playState = "paused";
holdTime = currentTime;
},
stop: () => {
hasStopped = true;
if (playState === "idle")
return;
playState = "idle";
onStop && onStop();
cancel();
},
cancel: () => {
if (cancelTime !== null)
tick(cancelTime);
cancel();
},
complete: () => {
playState = "finished";
},
sample: (elapsed) => {
startTime = 0;
return tick(elapsed);
},
};
return controls;
}
export { animateValue };

View File

@@ -0,0 +1,202 @@
import { animateStyle } from './index.mjs';
import { isWaapiSupportedEasing } from './easing.mjs';
import { getFinalKeyframe } from './utils/get-final-keyframe.mjs';
import { animateValue } from '../js/index.mjs';
import { millisecondsToSeconds, secondsToMilliseconds } from '../../../utils/time-conversion.mjs';
import { memo } from '../../../utils/memo.mjs';
import { noop } from '../../../utils/noop.mjs';
import { frame, cancelFrame } from '../../../frameloop/frame.mjs';
const supportsWaapi = memo(() => Object.hasOwnProperty.call(Element.prototype, "animate"));
/**
* A list of values that can be hardware-accelerated.
*/
const acceleratedValues = new Set([
"opacity",
"clipPath",
"filter",
"transform",
"backgroundColor",
]);
/**
* 10ms is chosen here as it strikes a balance between smooth
* results (more than one keyframe per frame at 60fps) and
* keyframe quantity.
*/
const sampleDelta = 10; //ms
/**
* Implement a practical max duration for keyframe generation
* to prevent infinite loops
*/
const maxDuration = 20000;
const requiresPregeneratedKeyframes = (valueName, options) => options.type === "spring" ||
valueName === "backgroundColor" ||
!isWaapiSupportedEasing(options.ease);
function createAcceleratedAnimation(value, valueName, { onUpdate, onComplete, ...options }) {
const canAccelerateAnimation = supportsWaapi() &&
acceleratedValues.has(valueName) &&
!options.repeatDelay &&
options.repeatType !== "mirror" &&
options.damping !== 0 &&
options.type !== "inertia";
if (!canAccelerateAnimation)
return false;
/**
* TODO: Unify with js/index
*/
let hasStopped = false;
let resolveFinishedPromise;
let currentFinishedPromise;
/**
* Cancelling an animation will write to the DOM. For safety we want to defer
* this until the next `update` frame lifecycle. This flag tracks whether we
* have a pending cancel, if so we shouldn't allow animations to finish.
*/
let pendingCancel = false;
/**
* Resolve the current Promise every time we enter the
* finished state. This is WAAPI-compatible behaviour.
*/
const updateFinishedPromise = () => {
currentFinishedPromise = new Promise((resolve) => {
resolveFinishedPromise = resolve;
});
};
// Create the first finished promise
updateFinishedPromise();
let { keyframes, duration = 300, ease, times } = options;
/**
* If this animation needs pre-generated keyframes then generate.
*/
if (requiresPregeneratedKeyframes(valueName, options)) {
const sampleAnimation = animateValue({
...options,
repeat: 0,
delay: 0,
});
let state = { done: false, value: keyframes[0] };
const pregeneratedKeyframes = [];
/**
* Bail after 20 seconds of pre-generated keyframes as it's likely
* we're heading for an infinite loop.
*/
let t = 0;
while (!state.done && t < maxDuration) {
state = sampleAnimation.sample(t);
pregeneratedKeyframes.push(state.value);
t += sampleDelta;
}
times = undefined;
keyframes = pregeneratedKeyframes;
duration = t - sampleDelta;
ease = "linear";
}
const animation = animateStyle(value.owner.current, valueName, keyframes, {
...options,
duration,
/**
* This function is currently not called if ease is provided
* as a function so the cast is safe.
*
* However it would be possible for a future refinement to port
* in easing pregeneration from Motion One for browsers that
* support the upcoming `linear()` easing function.
*/
ease: ease,
times,
});
const cancelAnimation = () => {
pendingCancel = false;
animation.cancel();
};
const safeCancel = () => {
pendingCancel = true;
frame.update(cancelAnimation);
resolveFinishedPromise();
updateFinishedPromise();
};
/**
* Prefer the `onfinish` prop as it's more widely supported than
* the `finished` promise.
*
* Here, we synchronously set the provided MotionValue to the end
* keyframe. If we didn't, when the WAAPI animation is finished it would
* be removed from the element which would then revert to its old styles.
*/
animation.onfinish = () => {
if (pendingCancel)
return;
value.set(getFinalKeyframe(keyframes, options));
onComplete && onComplete();
safeCancel();
};
/**
* Animation interrupt callback.
*/
const controls = {
then(resolve, reject) {
return currentFinishedPromise.then(resolve, reject);
},
attachTimeline(timeline) {
animation.timeline = timeline;
animation.onfinish = null;
return noop;
},
get time() {
return millisecondsToSeconds(animation.currentTime || 0);
},
set time(newTime) {
animation.currentTime = secondsToMilliseconds(newTime);
},
get speed() {
return animation.playbackRate;
},
set speed(newSpeed) {
animation.playbackRate = newSpeed;
},
get duration() {
return millisecondsToSeconds(duration);
},
play: () => {
if (hasStopped)
return;
animation.play();
/**
* Cancel any pending cancel tasks
*/
cancelFrame(cancelAnimation);
},
pause: () => animation.pause(),
stop: () => {
hasStopped = true;
if (animation.playState === "idle")
return;
/**
* WAAPI doesn't natively have any interruption capabilities.
*
* Rather than read commited styles back out of the DOM, we can
* create a renderless JS animation and sample it twice to calculate
* its current value, "previous" value, and therefore allow
* Motion to calculate velocity for any subsequent animation.
*/
const { currentTime } = animation;
if (currentTime) {
const sampleAnimation = animateValue({
...options,
autoplay: false,
});
value.setWithVelocity(sampleAnimation.sample(currentTime - sampleDelta).value, sampleAnimation.sample(currentTime).value, sampleDelta);
}
safeCancel();
},
complete: () => {
if (pendingCancel)
return;
animation.finish();
},
cancel: safeCancel,
};
return controls;
}
export { createAcceleratedAnimation };

View File

@@ -0,0 +1,31 @@
import { isBezierDefinition } from '../../../easing/utils/is-bezier-definition.mjs';
function isWaapiSupportedEasing(easing) {
return Boolean(!easing ||
(typeof easing === "string" && supportedWaapiEasing[easing]) ||
isBezierDefinition(easing) ||
(Array.isArray(easing) && easing.every(isWaapiSupportedEasing)));
}
const cubicBezierAsString = ([a, b, c, d]) => `cubic-bezier(${a}, ${b}, ${c}, ${d})`;
const supportedWaapiEasing = {
linear: "linear",
ease: "ease",
easeIn: "ease-in",
easeOut: "ease-out",
easeInOut: "ease-in-out",
circIn: cubicBezierAsString([0, 0.65, 0.55, 1]),
circOut: cubicBezierAsString([0.55, 0, 1, 0.45]),
backIn: cubicBezierAsString([0.31, 0.01, 0.66, -0.59]),
backOut: cubicBezierAsString([0.33, 1.53, 0.69, 0.99]),
};
function mapEasingToNativeEasing(easing) {
if (!easing)
return undefined;
return isBezierDefinition(easing)
? cubicBezierAsString(easing)
: Array.isArray(easing)
? easing.map(mapEasingToNativeEasing)
: supportedWaapiEasing[easing];
}
export { cubicBezierAsString, isWaapiSupportedEasing, mapEasingToNativeEasing, supportedWaapiEasing };

View File

@@ -0,0 +1,23 @@
import { mapEasingToNativeEasing } from './easing.mjs';
function animateStyle(element, valueName, keyframes, { delay = 0, duration, repeat = 0, repeatType = "loop", ease, times, } = {}) {
const keyframeOptions = { [valueName]: keyframes };
if (times)
keyframeOptions.offset = times;
const easing = mapEasingToNativeEasing(ease);
/**
* If this is an easing array, apply to keyframes, not animation as a whole
*/
if (Array.isArray(easing))
keyframeOptions.easing = easing;
return element.animate(keyframeOptions, {
delay,
duration,
easing: !Array.isArray(easing) ? easing : "linear",
fill: "both",
iterations: repeat + 1,
direction: repeatType === "reverse" ? "alternate" : "normal",
});
}
export { animateStyle };

View File

@@ -0,0 +1,8 @@
function getFinalKeyframe(keyframes, { repeat, repeatType = "loop" }) {
const index = repeat && repeatType !== "loop" && repeat % 2 === 1
? 0
: keyframes.length - 1;
return keyframes[index];
}
export { getFinalKeyframe };