319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
import { anticipate } from '../../easing/anticipate.mjs';
|
|
import { backInOut } from '../../easing/back.mjs';
|
|
import { circInOut } from '../../easing/circ.mjs';
|
|
import { DOMKeyframesResolver } from '../../render/dom/DOMKeyframesResolver.mjs';
|
|
import { noop } from '../../utils/noop.mjs';
|
|
import { millisecondsToSeconds, secondsToMilliseconds } from '../../utils/time-conversion.mjs';
|
|
import { isGenerator } from '../generators/utils/is-generator.mjs';
|
|
import { BaseAnimation } from './BaseAnimation.mjs';
|
|
import { MainThreadAnimation } from './MainThreadAnimation.mjs';
|
|
import { acceleratedValues } from './utils/accelerated-values.mjs';
|
|
import { startWaapiAnimation } from './waapi/index.mjs';
|
|
import { isWaapiSupportedEasing } from './waapi/easing.mjs';
|
|
import { attachTimeline } from './waapi/utils/attach-timeline.mjs';
|
|
import { getFinalKeyframe } from './waapi/utils/get-final-keyframe.mjs';
|
|
import { supportsLinearEasing } from './waapi/utils/supports-linear-easing.mjs';
|
|
import { supportsWaapi } from './waapi/utils/supports-waapi.mjs';
|
|
|
|
/**
|
|
* 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;
|
|
/**
|
|
* Check if an animation can run natively via WAAPI or requires pregenerated keyframes.
|
|
* WAAPI doesn't support spring or function easings so we run these as JS animation before
|
|
* handing off.
|
|
*/
|
|
function requiresPregeneratedKeyframes(options) {
|
|
return (isGenerator(options.type) ||
|
|
options.type === "spring" ||
|
|
!isWaapiSupportedEasing(options.ease));
|
|
}
|
|
function pregenerateKeyframes(keyframes, options) {
|
|
/**
|
|
* Create a main-thread animation to pregenerate keyframes.
|
|
* We sample this at regular intervals to generate keyframes that we then
|
|
* linearly interpolate between.
|
|
*/
|
|
const sampleAnimation = new MainThreadAnimation({
|
|
...options,
|
|
keyframes,
|
|
repeat: 0,
|
|
delay: 0,
|
|
isGenerator: true,
|
|
});
|
|
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;
|
|
}
|
|
return {
|
|
times: undefined,
|
|
keyframes: pregeneratedKeyframes,
|
|
duration: t - sampleDelta,
|
|
ease: "linear",
|
|
};
|
|
}
|
|
const unsupportedEasingFunctions = {
|
|
anticipate,
|
|
backInOut,
|
|
circInOut,
|
|
};
|
|
function isUnsupportedEase(key) {
|
|
return key in unsupportedEasingFunctions;
|
|
}
|
|
class AcceleratedAnimation extends BaseAnimation {
|
|
constructor(options) {
|
|
super(options);
|
|
const { name, motionValue, element, keyframes } = this.options;
|
|
this.resolver = new DOMKeyframesResolver(keyframes, (resolvedKeyframes, finalKeyframe) => this.onKeyframesResolved(resolvedKeyframes, finalKeyframe), name, motionValue, element);
|
|
this.resolver.scheduleResolve();
|
|
}
|
|
initPlayback(keyframes, finalKeyframe) {
|
|
var _a;
|
|
let { duration = 300, times, ease, type, motionValue, name, startTime, } = this.options;
|
|
/**
|
|
* If element has since been unmounted, return false to indicate
|
|
* the animation failed to initialised.
|
|
*/
|
|
if (!((_a = motionValue.owner) === null || _a === void 0 ? void 0 : _a.current)) {
|
|
return false;
|
|
}
|
|
/**
|
|
* If the user has provided an easing function name that isn't supported
|
|
* by WAAPI (like "anticipate"), we need to provide the corressponding
|
|
* function. This will later get converted to a linear() easing function.
|
|
*/
|
|
if (typeof ease === "string" &&
|
|
supportsLinearEasing() &&
|
|
isUnsupportedEase(ease)) {
|
|
ease = unsupportedEasingFunctions[ease];
|
|
}
|
|
/**
|
|
* If this animation needs pre-generated keyframes then generate.
|
|
*/
|
|
if (requiresPregeneratedKeyframes(this.options)) {
|
|
const { onComplete, onUpdate, motionValue, element, ...options } = this.options;
|
|
const pregeneratedAnimation = pregenerateKeyframes(keyframes, options);
|
|
keyframes = pregeneratedAnimation.keyframes;
|
|
// If this is a very short animation, ensure we have
|
|
// at least two keyframes to animate between as older browsers
|
|
// can't animate between a single keyframe.
|
|
if (keyframes.length === 1) {
|
|
keyframes[1] = keyframes[0];
|
|
}
|
|
duration = pregeneratedAnimation.duration;
|
|
times = pregeneratedAnimation.times;
|
|
ease = pregeneratedAnimation.ease;
|
|
type = "keyframes";
|
|
}
|
|
const animation = startWaapiAnimation(motionValue.owner.current, name, keyframes, { ...this.options, duration, times, ease });
|
|
// Override the browser calculated startTime with one synchronised to other JS
|
|
// and WAAPI animations starting this event loop.
|
|
animation.startTime = startTime !== null && startTime !== void 0 ? startTime : this.calcStartTime();
|
|
if (this.pendingTimeline) {
|
|
attachTimeline(animation, this.pendingTimeline);
|
|
this.pendingTimeline = undefined;
|
|
}
|
|
else {
|
|
/**
|
|
* 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 = () => {
|
|
const { onComplete } = this.options;
|
|
motionValue.set(getFinalKeyframe(keyframes, this.options, finalKeyframe));
|
|
onComplete && onComplete();
|
|
this.cancel();
|
|
this.resolveFinishedPromise();
|
|
};
|
|
}
|
|
return {
|
|
animation,
|
|
duration,
|
|
times,
|
|
type,
|
|
ease,
|
|
keyframes: keyframes,
|
|
};
|
|
}
|
|
get duration() {
|
|
const { resolved } = this;
|
|
if (!resolved)
|
|
return 0;
|
|
const { duration } = resolved;
|
|
return millisecondsToSeconds(duration);
|
|
}
|
|
get time() {
|
|
const { resolved } = this;
|
|
if (!resolved)
|
|
return 0;
|
|
const { animation } = resolved;
|
|
return millisecondsToSeconds(animation.currentTime || 0);
|
|
}
|
|
set time(newTime) {
|
|
const { resolved } = this;
|
|
if (!resolved)
|
|
return;
|
|
const { animation } = resolved;
|
|
animation.currentTime = secondsToMilliseconds(newTime);
|
|
}
|
|
get speed() {
|
|
const { resolved } = this;
|
|
if (!resolved)
|
|
return 1;
|
|
const { animation } = resolved;
|
|
return animation.playbackRate;
|
|
}
|
|
set speed(newSpeed) {
|
|
const { resolved } = this;
|
|
if (!resolved)
|
|
return;
|
|
const { animation } = resolved;
|
|
animation.playbackRate = newSpeed;
|
|
}
|
|
get state() {
|
|
const { resolved } = this;
|
|
if (!resolved)
|
|
return "idle";
|
|
const { animation } = resolved;
|
|
return animation.playState;
|
|
}
|
|
get startTime() {
|
|
const { resolved } = this;
|
|
if (!resolved)
|
|
return null;
|
|
const { animation } = resolved;
|
|
// Coerce to number as TypeScript incorrectly types this
|
|
// as CSSNumberish
|
|
return animation.startTime;
|
|
}
|
|
/**
|
|
* Replace the default DocumentTimeline with another AnimationTimeline.
|
|
* Currently used for scroll animations.
|
|
*/
|
|
attachTimeline(timeline) {
|
|
if (!this._resolved) {
|
|
this.pendingTimeline = timeline;
|
|
}
|
|
else {
|
|
const { resolved } = this;
|
|
if (!resolved)
|
|
return noop;
|
|
const { animation } = resolved;
|
|
attachTimeline(animation, timeline);
|
|
}
|
|
return noop;
|
|
}
|
|
play() {
|
|
if (this.isStopped)
|
|
return;
|
|
const { resolved } = this;
|
|
if (!resolved)
|
|
return;
|
|
const { animation } = resolved;
|
|
if (animation.playState === "finished") {
|
|
this.updateFinishedPromise();
|
|
}
|
|
animation.play();
|
|
}
|
|
pause() {
|
|
const { resolved } = this;
|
|
if (!resolved)
|
|
return;
|
|
const { animation } = resolved;
|
|
animation.pause();
|
|
}
|
|
stop() {
|
|
this.resolver.cancel();
|
|
this.isStopped = true;
|
|
if (this.state === "idle")
|
|
return;
|
|
this.resolveFinishedPromise();
|
|
this.updateFinishedPromise();
|
|
const { resolved } = this;
|
|
if (!resolved)
|
|
return;
|
|
const { animation, keyframes, duration, type, ease, times } = resolved;
|
|
if (animation.playState === "idle" ||
|
|
animation.playState === "finished") {
|
|
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.
|
|
*/
|
|
if (this.time) {
|
|
const { motionValue, onUpdate, onComplete, element, ...options } = this.options;
|
|
const sampleAnimation = new MainThreadAnimation({
|
|
...options,
|
|
keyframes,
|
|
duration,
|
|
type,
|
|
ease,
|
|
times,
|
|
isGenerator: true,
|
|
});
|
|
const sampleTime = secondsToMilliseconds(this.time);
|
|
motionValue.setWithVelocity(sampleAnimation.sample(sampleTime - sampleDelta).value, sampleAnimation.sample(sampleTime).value, sampleDelta);
|
|
}
|
|
const { onStop } = this.options;
|
|
onStop && onStop();
|
|
this.cancel();
|
|
}
|
|
complete() {
|
|
const { resolved } = this;
|
|
if (!resolved)
|
|
return;
|
|
resolved.animation.finish();
|
|
}
|
|
cancel() {
|
|
const { resolved } = this;
|
|
if (!resolved)
|
|
return;
|
|
resolved.animation.cancel();
|
|
}
|
|
static supports(options) {
|
|
const { motionValue, name, repeatDelay, repeatType, damping, type } = options;
|
|
return (supportsWaapi() &&
|
|
name &&
|
|
acceleratedValues.has(name) &&
|
|
motionValue &&
|
|
motionValue.owner &&
|
|
motionValue.owner.current instanceof HTMLElement &&
|
|
/**
|
|
* If we're outputting values to onUpdate then we can't use WAAPI as there's
|
|
* no way to read the value from WAAPI every frame.
|
|
*/
|
|
!motionValue.owner.getProps().onUpdate &&
|
|
!repeatDelay &&
|
|
repeatType !== "mirror" &&
|
|
damping !== 0 &&
|
|
type !== "inertia");
|
|
}
|
|
}
|
|
|
|
export { AcceleratedAnimation };
|