motion-dom
Version:
429 lines (426 loc) • 16.6 kB
JavaScript
import { invariant, pipe, clamp, millisecondsToSeconds, secondsToMilliseconds } from 'motion-utils';
import { time } from '../frameloop/sync-time.mjs';
import { mix } from '../utils/mix/index.mjs';
import { frameloopDriver } from './drivers/frame.mjs';
import { inertia } from './generators/inertia.mjs';
import { keyframes } from './generators/keyframes.mjs';
import { calcGeneratorDuration } from './generators/utils/calc-duration.mjs';
import { calcGeneratorVelocity } from './generators/utils/velocity.mjs';
import { getFinalKeyframe } from './keyframes/get-final.mjs';
import { replaceTransitionType } from './utils/replace-transition-type.mjs';
import { resolveStartTime } from './utils/resolve-start-time.mjs';
import { notifyAnimationStart } from './utils/notify-inspector.mjs';
import { WithPromise } from './utils/WithPromise.mjs';
const percentToProgress = (percent) => percent / 100;
const round = (value) => Math.round(value * 10000) / 10000;
class JSAnimation extends WithPromise {
constructor(options) {
super();
this.state = "idle";
this.startTime = null;
this.isStopped = false;
/**
* The current time of the animation.
*/
this.currentTime = 0;
/**
* The time at which the animation was paused.
*/
this.holdTime = null;
/**
* Playback speed as a factor. 0 would be stopped, -1 reverse and 2 double speed.
*/
this.playbackSpeed = 1;
/**
* Reusable state object for the delay phase to avoid
* allocating a new object every frame.
*/
this.delayState = {
done: false,
value: undefined,
};
/**
* This method is bound to the instance to fix a pattern where
* animation.stop is returned as a reference from a useEffect.
*/
this.stop = () => {
const { motionValue } = this.options;
const now = time.now();
if (motionValue && motionValue.updatedAt !== now) {
/**
* Catch the value up to now, but measure its velocity from the
* previous frame: marking it as updated now keeps the value's
* previous-frame sample. This tick is usually less than a frame
* after the last, and the animation's time is rounded to whole
* milliseconds, so measured from the last frame alone the
* velocity an interrupting animation inherits could read as 0,
* or a multiple of the real one.
*/
this.tick((motionValue.updatedAt = now));
}
this.isStopped = true;
if (this.state === "idle")
return;
this.teardown();
this.options.onStop?.();
};
this.options = options;
this.initAnimation();
this.play();
if (options.autoplay === false)
this.pause();
notifyAnimationStart(this, this.options);
}
initAnimation() {
const { options } = this;
replaceTransitionType(options);
const { type = keyframes, repeat = 0, repeatDelay = 0, repeatType, velocity = 0, } = options;
let { keyframes: keyframes$1 } = options;
const generatorFactory = type || keyframes;
if (process.env.NODE_ENV !== "production" &&
generatorFactory !== keyframes) {
invariant(keyframes$1.length <= 2, `Only two keyframes currently supported with spring and inertia animations. Trying to animate ${keyframes$1}`, "spring-two-frames");
}
/**
* This runs again when a spring is flattened to attach it to a
* timeline, and the keyframes generator blends values itself.
*/
this.mixKeyframes = undefined;
if (generatorFactory !== keyframes &&
typeof keyframes$1[0] !== "number") {
this.mixKeyframes = pipe(percentToProgress, options.mixer?.(keyframes$1[0], keyframes$1[1]) ||
mix(keyframes$1[0], keyframes$1[1]));
keyframes$1 = [0, 100];
}
const generator = generatorFactory(keyframes$1 === options.keyframes
? options
: { ...options, keyframes: keyframes$1 });
/**
* If we have a mirror repeat type we need to create a second generator that outputs the
* mirrored (not reversed) animation and later ping pong between the two generators.
*/
if (repeatType === "mirror") {
this.mirroredGenerator = generatorFactory({
...options,
keyframes: [...keyframes$1].reverse(),
velocity: -velocity,
});
}
/**
* 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) {
generator.calculatedDuration = calcGeneratorDuration(generator);
}
const { calculatedDuration } = generator;
this.calculatedDuration = calculatedDuration;
this.resolvedDuration = calculatedDuration + repeatDelay;
this.totalDuration = this.resolvedDuration * (repeat + 1) - repeatDelay;
this.generator = generator;
}
updateTime(timestamp) {
// 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.
this.currentTime =
this.holdTime ??
Math.round(timestamp - this.startTime) * this.playbackSpeed;
}
tick(timestamp, sample = false) {
const { generator, totalDuration, calculatedDuration } = this;
if (this.startTime === null)
return generator.next(0);
const { delay = 0, keyframes, type, onUpdate, finalKeyframe, } = this.options;
if (this.startTime === this.pendingStartTime) {
this.startTime = resolveStartTime(this.startTime, timestamp);
}
this.pendingStartTime = undefined;
/**
* 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 (this.speed > 0) {
this.startTime = Math.min(this.startTime, timestamp);
}
else if (this.speed < 0) {
this.startTime = Math.min(timestamp - totalDuration / this.speed, this.startTime);
}
if (sample) {
this.currentTime = timestamp;
}
else {
this.updateTime(timestamp);
}
// Rebase on delay
const timeWithoutDelay = this.currentTime - delay * (this.playbackSpeed >= 0 ? 1 : -1);
const isInDelayPhase = this.playbackSpeed >= 0
? timeWithoutDelay < 0
: timeWithoutDelay > totalDuration;
this.currentTime = Math.max(timeWithoutDelay, 0);
// If this animation has finished, set the current time to the total duration.
if (this.state === "finished" && this.holdTime === null) {
this.currentTime = totalDuration;
}
const state = this.generate(this.currentTime, isInDelayPhase);
let { done } = state;
/**
* A generator that takes longer than maxGeneratorDuration to finish
* has an Infinity duration, so played forwards it finishes when it
* says it's done.
*/
if (!isInDelayPhase) {
done =
this.playbackSpeed >= 0
? calculatedDuration < Infinity
? this.currentTime >= totalDuration
: done
: this.currentTime <= 0;
}
const isAnimationFinished = this.holdTime === null &&
(this.state === "finished" || (this.state === "running" && done));
// TODO: The exception for inertia could be cleaner here
if (isAnimationFinished && type !== inertia) {
state.value = getFinalKeyframe(keyframes, this.options, finalKeyframe, this.speed);
}
if (onUpdate) {
onUpdate(state.value);
}
if (isAnimationFinished) {
this.finish();
}
return state;
}
/**
* This animation's time, including delay, at a timestamp.
*/
at(timestamp) {
return (this.holdTime ??
round((timestamp - this.startTime) * this.playbackSpeed));
}
/**
* The value at a timestamp, without changing this animation's state.
* If resolved, it ends at the last resolved keyframe, which is in the
* same unit as the others, rather than at finalKeyframe.
*/
sampleAt(timestamp, resolved) {
const { options, totalDuration } = this;
const t = this.at(timestamp) - (options.delay || 0);
return t >= totalDuration && options.type !== inertia
? getFinalKeyframe(options.keyframes, options, resolved ? undefined : options.finalKeyframe)
: this.generate(Math.max(t, 0), t < 0).value;
}
/**
* The generator's state `currentTime` ms after the delay, without
* changing the animation's own state.
*/
generate(currentTime, isInDelayPhase) {
const { generator, totalDuration, mixKeyframes, mirroredGenerator, resolvedDuration, } = this;
const { keyframes, repeat, repeatType, repeatDelay } = this.options;
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.
*/
let state;
if (isInDelayPhase) {
this.delayState.value = keyframes[0];
state = this.delayState;
}
else {
state = frameGenerator.next(elapsed);
}
if (mixKeyframes && !isInDelayPhase) {
state.value = mixKeyframes(state.value);
}
return state;
}
get duration() {
return millisecondsToSeconds(this.calculatedDuration);
}
get iterationDuration() {
return this.duration + millisecondsToSeconds(this.options.delay ?? 0);
}
get time() {
return millisecondsToSeconds(this.currentTime);
}
set time(newTime) {
/**
* Seeking out of "finished" leaves the animation paused, so play()
* won't renew finished. Renew it here.
*/
if (this.state === "finished")
this.updateFinished();
newTime = secondsToMilliseconds(newTime);
this.currentTime = newTime;
if (this.startTime === null ||
this.holdTime !== null ||
this.playbackSpeed === 0) {
this.holdTime = newTime;
}
else if (this.driver) {
this.startTime = this.driver.now() - newTime / this.playbackSpeed;
}
if (this.driver) {
this.driver.start(false);
}
else {
this.startTime = 0;
this.state = "paused";
this.holdTime = newTime;
this.tick(newTime);
}
}
/**
* Returns the generator's velocity at the current time in units/second.
* Uses the analytical derivative when available (springs), avoiding
* the MotionValue's frame-dependent velocity estimation.
*/
getGeneratorVelocity() {
return calcGeneratorVelocity(this.generator, this.currentTime, this.options.velocity);
}
get speed() {
return this.playbackSpeed;
}
set speed(newSpeed) {
const hasChanged = this.playbackSpeed !== newSpeed;
if (hasChanged && this.driver) {
this.updateTime(time.now());
}
this.playbackSpeed = newSpeed;
if (hasChanged && this.driver) {
this.time = millisecondsToSeconds(this.currentTime);
}
}
play() {
if (this.isStopped)
return;
const { driver = frameloopDriver, startTime } = this.options;
if (!this.driver) {
this.driver = driver((timestamp) => this.tick(timestamp));
}
this.options.onPlay?.();
const now = this.driver.now();
if (this.state === "finished") {
this.updateFinished();
this.startTime = now;
}
else if (this.holdTime !== null) {
this.startTime = now - this.holdTime / (this.playbackSpeed || 1);
}
else if (!this.startTime) {
this.startTime = startTime ?? (this.pendingStartTime = now);
}
if (this.state === "finished" && this.speed < 0) {
this.startTime += this.calculatedDuration;
}
this.holdTime = null;
/**
* Set playState to running only after we've used it in
* the previous logic.
*/
this.state = "running";
this.driver.start();
}
pause() {
this.state = "paused";
this.updateTime(time.now());
this.holdTime = this.currentTime;
}
complete() {
if (this.state !== "running") {
this.play();
}
this.state = "finished";
this.holdTime = null;
}
finish() {
this.notifyFinished();
this.teardown();
this.state = "finished";
this.options.onComplete?.();
}
cancel() {
this.holdTime = null;
this.startTime = 0;
this.tick(0);
this.teardown();
this.options.onCancel?.();
}
teardown() {
this.state = "idle";
this.driver?.stop();
this.driver = undefined;
this.startTime = this.holdTime = null;
}
sample(sampleTime) {
this.startTime = 0;
return this.tick(sampleTime, true);
}
attachTimeline(timeline) {
if (this.options.allowFlatten) {
this.options.type = "keyframes";
this.options.ease = "linear";
this.initAnimation();
}
this.driver?.stop();
return timeline.observe(this);
}
}
// Legacy function support
function animateValue(options) {
return new JSAnimation(options);
}
export { JSAnimation, animateValue, round };
//# sourceMappingURL=JSAnimation.mjs.map