UNPKG

@piotrjoniec/motion

Version:

An animation library for JavaScript and React.

650 lines (611 loc) 20.3 kB
'use strict'; 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;