motion
Version:
An animation library for JavaScript and React.
573 lines (538 loc) • 17.5 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 supportsScrollTimeline = /* @__PURE__ */ memo(() => window.ScrollTimeline !== undefined);
class GroupAnimation {
constructor(animations) {
// Bound to accomodate 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, fallback) {
const subscriptions = this.animations.map((animation) => {
if (supportsScrollTimeline() && animation.attachTimeline) {
return animation.attachTimeline(timeline);
}
else if (typeof fallback === "function") {
return fallback(animation);
}
});
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 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]());
}
flatten() {
this.runAll("flatten");
}
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 isCSSVar = (name) => name.startsWith("--");
const style = {
set: (element, name, value) => {
isCSSVar(name)
? element.style.setProperty(name, value)
: (element.style[name] = value);
},
get: (element, name) => {
return isCSSVar(name)
? element.style.getPropertyValue(name)
: element.style[name];
},
};
const isNotNull = (value) => value !== null;
function getFinalKeyframe(keyframes, { repeat, repeatType = "loop" }, finalKeyframe) {
const resolvedKeyframes = keyframes.filter(isNotNull);
const index = repeat && repeatType !== "loop" && repeat % 2 === 1
? 0
: resolvedKeyframes.length - 1;
return !index || finalKeyframe === undefined
? resolvedKeyframes[index]
: finalKeyframe;
}
const supportsPartialKeyframes = /*@__PURE__*/ memo(() => {
try {
document.createElement("div").animate({ opacity: [1] });
}
catch (e) {
return false;
}
return true;
});
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 hydrateKeyframes(element, name, keyframes, pseudoElement) {
if (!Array.isArray(keyframes)) {
keyframes = [keyframes];
}
for (let i = 0; i < keyframes.length; i++) {
if (keyframes[i] === null) {
keyframes[i] =
i === 0 && !pseudoElement
? style.get(element, name)
: keyframes[i - 1];
}
if (typeof keyframes[i] === "number" && pxValues.has(name)) {
keyframes[i] = keyframes[i] + "px";
}
}
if (!pseudoElement && !supportsPartialKeyframes() && keyframes.length < 2) {
keyframes.unshift(style.get(element, name));
}
return keyframes;
}
const isBezierDefinition = (easing) => Array.isArray(easing) && typeof easing[0] === "number";
/**
* 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 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 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" && supportsLinearEasing()) {
return generateLinearEasing(easing, duration);
}
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 = "easeInOut", 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 animation = element.animate(keyframeOptions, {
delay,
duration,
easing: !Array.isArray(easing) ? easing : "linear",
fill: "both",
iterations: repeat + 1,
direction: repeatType === "reverse" ? "alternate" : "normal",
pseudoElement,
});
return animation;
}
function isGenerator(type) {
return typeof type === "function" && "applyToOptions" in type;
}
function applyGeneratorOptions({ type, ...options }) {
if (isGenerator(type)) {
return type.applyToOptions(options);
}
else {
options.duration ?? (options.duration = 300);
options.ease ?? (options.ease = "easeOut");
}
return options;
}
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;
}
/**
* NativeAnimation implements AnimationPlaybackControls for the browser's Web Animations API.
*/
class NativeAnimation {
constructor(options) {
/**
* If we already have an animation, we don't need to instantiate one
* and can just use this as a controls interface.
*/
if ("animation" in options) {
this.animation = options.animation;
return;
}
const { element, name, keyframes: unresolvedKeyframes, pseudoElement, allowFlatten = false, } = options;
let { transition } = options;
this.allowFlatten = allowFlatten;
/**
* Stop any existing animations on the element before reading existing keyframes.
*
* TODO: Check for VisualElement before using animation state. This is a fallback
* for mini animate(). Do this when implementing NativeAnimationExtended.
*/
const animationMap = getAnimationMap(element);
const key = animationMapKey(name, pseudoElement || "");
const currentAnimation = animationMap.get(key);
currentAnimation && currentAnimation.stop();
/**
* TODO: If these keyframes aren't correctly hydrated then we want to throw
* run an instant animation.
*/
const keyframes = hydrateKeyframes(element, name, unresolvedKeyframes, pseudoElement);
invariant(typeof transition.type !== "string", `animateMini doesn't support "type" as a string. Did you mean to import { spring } from "motion"?`);
transition = applyGeneratorOptions(transition);
this.animation = startWaapiAnimation(element, name, keyframes, transition, pseudoElement);
if (transition.autoplay === false) {
this.animation.pause();
}
this.removeAnimation = () => animationMap.delete(key);
this.animation.onfinish = () => {
if (!pseudoElement) {
style.set(element, name, getFinalKeyframe(keyframes, transition));
}
else {
this.commitStyles();
}
this.cancel();
};
/**
* TODO: Check for VisualElement before using animation state.
*/
animationMap.set(key, this);
}
play() {
this.animation.play();
}
pause() {
this.animation.pause();
}
complete() {
this.animation.finish();
}
cancel() {
try {
this.animation.cancel();
}
catch (e) { }
this.removeAnimation();
}
stop() {
const { state } = this;
if (state === "idle" || state === "finished") {
return;
}
this.commitStyles();
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() {
this.animation.commitStyles?.();
}
get duration() {
console.log(this.animation.effect?.getComputedTiming());
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.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) {
this.animation.playbackRate = newSpeed;
}
get state() {
return this.animation.playState;
}
get startTime() {
return Number(this.animation.startTime);
}
get finished() {
return this.animation.finished;
}
flatten() {
if (this.allowFlatten) {
this.animation.effect?.updateTiming({ easing: "linear" });
}
}
/**
* Attaches a timeline to the animation, for instance the `ScrollTimeline`.
*/
attachTimeline(timeline) {
this.animation.timeline = timeline;
this.animation.onfinish = null;
return noop;
}
/**
* Allows the animation to be awaited.
*
* @deprecated Use `finished` instead.
*/
then(onResolve, onReject) {
return this.finished.then(onResolve).catch(onReject);
}
}
function getValueTransition(transition, key) {
return (transition?.[key] ??
transition?.["default"] ??
transition);
}
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 animateElements(elementOrSelector, keyframes, options, scope) {
const elements = resolveElements(elementOrSelector, scope);
const numElements = elements.length;
invariant(Boolean(numElements), "No valid element provided.");
const animations = [];
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) {
const valueKeyframes = keyframes[valueName];
const valueOptions = {
...getValueTransition(elementTransition, valueName),
};
valueOptions.duration && (valueOptions.duration = secondsToMilliseconds(valueOptions.duration));
valueOptions.delay && (valueOptions.delay = secondsToMilliseconds(valueOptions.delay));
animations.push(new NativeAnimation({
element,
name: valueName,
keyframes: valueKeyframes,
transition: valueOptions,
allowFlatten: !elementTransition.type && !elementTransition.ease,
}));
}
}
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;