UNPKG

motion

Version:

An animation library for JavaScript and React.

573 lines (538 loc) 17.5 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 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;