@piotrjoniec/motion
Version:
An animation library for JavaScript and React.
650 lines (611 loc) • 20.3 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
var react = require('react');
/**
* Creates a constant value over the lifecycle of a component.
*
* Even if `useMemo` is provided an empty array as its final argument, it doesn't offer
* a guarantee that it won't re-run for performance reasons later on. By using `useConstant`
* you can ensure that initialisers don't execute twice or more.
*/
function useConstant(init) {
const ref = react.useRef(null);
if (ref.current === null) {
ref.current = init();
}
return ref.current;
}
function useUnmountEffect(callback) {
return react.useEffect(() => () => callback(), []);
}
let invariant = () => { };
if (process.env.NODE_ENV !== "production") {
invariant = (check, message) => {
if (!check) {
throw new Error(message);
}
};
}
/*#__NO_SIDE_EFFECTS__*/
function memo(callback) {
let result;
return () => {
if (result === undefined)
result = callback();
return result;
};
}
/*#__NO_SIDE_EFFECTS__*/
const noop = (any) => any;
/**
* Converts seconds to milliseconds
*
* @param seconds - Time in seconds.
* @return milliseconds - Converted time in milliseconds.
*/
/*#__NO_SIDE_EFFECTS__*/
const secondsToMilliseconds = (seconds) => seconds * 1000;
/*#__NO_SIDE_EFFECTS__*/
const millisecondsToSeconds = (milliseconds) => milliseconds / 1000;
const isBezierDefinition = (easing) => Array.isArray(easing) && typeof easing[0] === "number";
const generateLinearEasing = (easing, duration, // as milliseconds
resolution = 10 // as milliseconds
) => {
let points = "";
const numPoints = Math.max(Math.round(duration / resolution), 2);
for (let i = 0; i < numPoints; i++) {
points += easing(i / (numPoints - 1)) + ", ";
}
return `linear(${points.substring(0, points.length - 2)})`;
};
const isNotNull = (value) => value !== null;
function getFinalKeyframe(keyframes, { repeat, repeatType = "loop" }, finalKeyframe, speed = 1) {
const resolvedKeyframes = keyframes.filter(isNotNull);
const useFirstKeyframe = speed < 0 || (repeat && repeatType !== "loop" && repeat % 2 === 1);
const index = useFirstKeyframe ? 0 : resolvedKeyframes.length - 1;
return !index || finalKeyframe === undefined
? resolvedKeyframes[index]
: finalKeyframe;
}
class WithPromise {
constructor() {
this.updateFinished();
}
get finished() {
return this._finished;
}
updateFinished() {
this._finished = new Promise((resolve) => {
this.resolve = resolve;
});
}
notifyFinished() {
this.resolve();
}
/**
* Allows the animation to be awaited.
*
* @deprecated Use `finished` instead.
*/
then(onResolve, onReject) {
return this.finished.then(onResolve, onReject);
}
}
function fillWildcards(keyframes) {
for (let i = 1; i < keyframes.length; i++) {
keyframes[i] ?? (keyframes[i] = keyframes[i - 1]);
}
}
const isCSSVar = (name) => name.startsWith("--");
function setStyle(element, name, value) {
isCSSVar(name)
? element.style.setProperty(name, value)
: (element.style[name] = value);
}
const supportsScrollTimeline = /* @__PURE__ */ memo(() => window.ScrollTimeline !== undefined);
/**
* Add the ability for test suites to manually set support flags
* to better test more environments.
*/
const supportsFlags = {};
function memoSupports(callback, supportsFlag) {
const memoized = memo(callback);
return () => supportsFlags[supportsFlag] ?? memoized();
}
const supportsLinearEasing = /*@__PURE__*/ memoSupports(() => {
try {
document
.createElement("div")
.animate({ opacity: 0 }, { easing: "linear(0, 1)" });
}
catch (e) {
return false;
}
return true;
}, "linearEasing");
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: /*@__PURE__*/ cubicBezierAsString([0, 0.65, 0.55, 1]),
circOut: /*@__PURE__*/ cubicBezierAsString([0.55, 0, 1, 0.45]),
backIn: /*@__PURE__*/ cubicBezierAsString([0.31, 0.01, 0.66, -0.59]),
backOut: /*@__PURE__*/ cubicBezierAsString([0.33, 1.53, 0.69, 0.99]),
};
function mapEasingToNativeEasing(easing, duration) {
if (!easing) {
return undefined;
}
else if (typeof easing === "function") {
return supportsLinearEasing()
? generateLinearEasing(easing, duration)
: "ease-out";
}
else if (isBezierDefinition(easing)) {
return cubicBezierAsString(easing);
}
else if (Array.isArray(easing)) {
return easing.map((segmentEasing) => mapEasingToNativeEasing(segmentEasing, duration) ||
supportedWaapiEasing.easeOut);
}
else {
return supportedWaapiEasing[easing];
}
}
function startWaapiAnimation(element, valueName, keyframes, { delay = 0, duration = 300, repeat = 0, repeatType = "loop", ease = "easeOut", times, } = {}, pseudoElement = undefined) {
const keyframeOptions = {
[valueName]: keyframes,
};
if (times)
keyframeOptions.offset = times;
const easing = mapEasingToNativeEasing(ease, duration);
/**
* If this is an easing array, apply to keyframes, not animation as a whole
*/
if (Array.isArray(easing))
keyframeOptions.easing = easing;
const options = {
delay,
duration,
easing: !Array.isArray(easing) ? easing : "linear",
fill: "both",
iterations: repeat + 1,
direction: repeatType === "reverse" ? "alternate" : "normal",
};
if (pseudoElement)
options.pseudoElement = pseudoElement;
const animation = element.animate(keyframeOptions, options);
return animation;
}
function isGenerator(type) {
return typeof type === "function" && "applyToOptions" in type;
}
function applyGeneratorOptions({ type, ...options }) {
if (isGenerator(type) && supportsLinearEasing()) {
return type.applyToOptions(options);
}
else {
options.duration ?? (options.duration = 300);
options.ease ?? (options.ease = "easeOut");
}
return options;
}
/**
* NativeAnimation implements AnimationPlaybackControls for the browser's Web Animations API.
*/
class NativeAnimation extends WithPromise {
constructor(options) {
super();
this.finishedTime = null;
this.isStopped = false;
if (!options)
return;
const { element, name, keyframes, pseudoElement, allowFlatten = false, finalKeyframe, onComplete, } = options;
this.isPseudoElement = Boolean(pseudoElement);
this.allowFlatten = allowFlatten;
this.options = options;
invariant(typeof options.type !== "string", `animateMini doesn't support "type" as a string. Did you mean to import { spring } from "motion"?`);
const transition = applyGeneratorOptions(options);
this.animation = startWaapiAnimation(element, name, keyframes, transition, pseudoElement);
if (transition.autoplay === false) {
this.animation.pause();
}
this.animation.onfinish = () => {
this.finishedTime = this.time;
if (!pseudoElement) {
const keyframe = getFinalKeyframe(keyframes, this.options, finalKeyframe, this.speed);
if (this.updateMotionValue) {
this.updateMotionValue(keyframe);
}
else {
/**
* If we can, we want to commit the final style as set by the user,
* rather than the computed keyframe value supplied by the animation.
*/
setStyle(element, name, keyframe);
}
this.animation.cancel();
}
onComplete?.();
this.notifyFinished();
};
}
play() {
if (this.isStopped)
return;
this.animation.play();
if (this.state === "finished") {
this.updateFinished();
}
}
pause() {
this.animation.pause();
}
complete() {
this.animation.finish?.();
}
cancel() {
try {
this.animation.cancel();
}
catch (e) { }
}
stop() {
if (this.isStopped)
return;
this.isStopped = true;
const { state } = this;
if (state === "idle" || state === "finished") {
return;
}
if (this.updateMotionValue) {
this.updateMotionValue();
}
else {
this.commitStyles();
}
if (!this.isPseudoElement)
this.cancel();
}
/**
* WAAPI doesn't natively have any interruption capabilities.
*
* In this method, we commit styles back to the DOM before cancelling
* the animation.
*
* This is designed to be overridden by NativeAnimationExtended, which
* will create a renderless JS animation and sample it twice to calculate
* its current value, "previous" value, and therefore allow
* Motion to also correctly calculate velocity for any subsequent animation
* while deferring the commit until the next animation frame.
*/
commitStyles() {
if (!this.isPseudoElement) {
this.animation.commitStyles?.();
}
}
get duration() {
const duration = this.animation.effect?.getComputedTiming?.().duration || 0;
return millisecondsToSeconds(Number(duration));
}
get time() {
return millisecondsToSeconds(Number(this.animation.currentTime) || 0);
}
set time(newTime) {
this.finishedTime = null;
this.animation.currentTime = secondsToMilliseconds(newTime);
}
/**
* The playback speed of the animation.
* 1 = normal speed, 2 = double speed, 0.5 = half speed.
*/
get speed() {
return this.animation.playbackRate;
}
set speed(newSpeed) {
// Allow backwards playback after finishing
if (newSpeed < 0)
this.finishedTime = null;
this.animation.playbackRate = newSpeed;
}
get state() {
return this.finishedTime !== null
? "finished"
: this.animation.playState;
}
get startTime() {
return Number(this.animation.startTime);
}
set startTime(newStartTime) {
this.animation.startTime = newStartTime;
}
/**
* Attaches a timeline to the animation, for instance the `ScrollTimeline`.
*/
attachTimeline({ timeline, observe }) {
if (this.allowFlatten) {
this.animation.effect?.updateTiming({ easing: "linear" });
}
this.animation.onfinish = null;
if (timeline && supportsScrollTimeline()) {
this.animation.timeline = timeline;
return noop;
}
else {
return observe(this);
}
}
}
class GroupAnimation {
constructor(animations) {
// Bound to accomadate common `return animation.stop` pattern
this.stop = () => this.runAll("stop");
this.animations = animations.filter(Boolean);
}
get finished() {
return Promise.all(this.animations.map((animation) => animation.finished));
}
/**
* TODO: Filter out cancelled or stopped animations before returning
*/
getAll(propName) {
return this.animations[0][propName];
}
setAll(propName, newValue) {
for (let i = 0; i < this.animations.length; i++) {
this.animations[i][propName] = newValue;
}
}
attachTimeline(timeline) {
const subscriptions = this.animations.map((animation) => animation.attachTimeline(timeline));
return () => {
subscriptions.forEach((cancel, i) => {
cancel && cancel();
this.animations[i].stop();
});
};
}
get time() {
return this.getAll("time");
}
set time(time) {
this.setAll("time", time);
}
get speed() {
return this.getAll("speed");
}
set speed(speed) {
this.setAll("speed", speed);
}
get state() {
return this.getAll("state");
}
get startTime() {
return this.getAll("startTime");
}
get duration() {
let max = 0;
for (let i = 0; i < this.animations.length; i++) {
max = Math.max(max, this.animations[i].duration);
}
return max;
}
runAll(methodName) {
this.animations.forEach((controls) => controls[methodName]());
}
play() {
this.runAll("play");
}
pause() {
this.runAll("pause");
}
cancel() {
this.runAll("cancel");
}
complete() {
this.runAll("complete");
}
}
class GroupAnimationWithThen extends GroupAnimation {
then(onResolve, _onReject) {
return this.finished.finally(onResolve).then(() => { });
}
}
const animationMaps = new WeakMap();
const animationMapKey = (name, pseudoElement = "") => `${name}:${pseudoElement}`;
function getAnimationMap(element) {
const map = animationMaps.get(element) || new Map();
animationMaps.set(element, map);
return map;
}
function getValueTransition(transition, key) {
return (transition?.[key] ??
transition?.["default"] ??
transition);
}
const pxValues = new Set([
// Border props
"borderWidth",
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"borderRadius",
"radius",
"borderTopLeftRadius",
"borderTopRightRadius",
"borderBottomRightRadius",
"borderBottomLeftRadius",
// Positioning props
"width",
"maxWidth",
"height",
"maxHeight",
"top",
"right",
"bottom",
"left",
// Spacing props
"padding",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"margin",
"marginTop",
"marginRight",
"marginBottom",
"marginLeft",
// Misc
"backgroundPositionX",
"backgroundPositionY",
]);
function applyPxDefaults(keyframes, name) {
for (let i = 0; i < keyframes.length; i++) {
if (typeof keyframes[i] === "number" && pxValues.has(name)) {
keyframes[i] = keyframes[i] + "px";
}
}
}
function resolveElements(elementOrSelector, scope, selectorCache) {
if (elementOrSelector instanceof EventTarget) {
return [elementOrSelector];
}
else if (typeof elementOrSelector === "string") {
let root = document;
if (scope) {
root = scope.current;
}
const elements = selectorCache?.[elementOrSelector] ??
root.querySelectorAll(elementOrSelector);
return elements ? Array.from(elements) : [];
}
return Array.from(elementOrSelector);
}
function getComputedStyle(element, name) {
const computedStyle = window.getComputedStyle(element);
return isCSSVar(name)
? computedStyle.getPropertyValue(name)
: computedStyle[name];
}
function animateElements(elementOrSelector, keyframes, options, scope) {
const elements = resolveElements(elementOrSelector, scope);
const numElements = elements.length;
invariant(Boolean(numElements), "No valid element provided.");
/**
* 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 = {
...getValueTransition(elementTransition, valueName),
};
valueOptions.duration && (valueOptions.duration = secondsToMilliseconds(valueOptions.duration));
valueOptions.delay && (valueOptions.delay = secondsToMilliseconds(valueOptions.delay));
/**
* If there's an existing animation playing on this element then stop it
* before creating a new one.
*/
const map = getAnimationMap(element);
const key = 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] = getComputedStyle(element, name);
}
fillWildcards(unresolvedKeyframes);
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(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 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 GroupAnimationWithThen(animateElements(elementOrSelector, keyframes, options, scope));
}
return scopedAnimate;
};
function useAnimateMini() {
const scope = useConstant(() => ({
current: null, // Will be hydrated by React
animations: [],
}));
const animate = useConstant(() => createScopedWaapiAnimate(scope));
useUnmountEffect(() => {
scope.animations.forEach((animation) => animation.stop());
});
return [scope, animate];
}
exports.useAnimate = useAnimateMini;