UNPKG

motion

Version:

An animation library for JavaScript and React.

1,564 lines (1,503 loc) 227 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); function addUniqueItem(arr, item) { if (arr.indexOf(item) === -1) arr.push(item); } function removeItem(arr, item) { const index = arr.indexOf(item); if (index > -1) arr.splice(index, 1); } let warning = () => { }; exports.invariant = () => { }; if (process.env.NODE_ENV !== "production") { warning = (check, message) => { if (!check && typeof console !== "undefined") { console.warn(message); } }; exports.invariant = (check, message) => { if (!check) { throw new Error(message); } }; } const MotionGlobalConfig = { skipAnimations: false, useManualTiming: false, }; /*#__NO_SIDE_EFFECTS__*/ function memo(callback) { let result; return () => { if (result === undefined) result = callback(); return result; }; } /*#__NO_SIDE_EFFECTS__*/ const noop = (any) => any; /* Progress within given range Given a lower limit and an upper limit, we return the progress (expressed as a number 0-1) represented by the given value, and limit that progress to within 0-1. @param [number]: Lower limit @param [number]: Upper limit @param [number]: Value to find progress within given range @return [number]: Progress of value within range as expressed 0-1 */ /*#__NO_SIDE_EFFECTS__*/ const progress = (from, to, value) => { const toFromDifference = to - from; return toFromDifference === 0 ? 1 : (value - from) / toFromDifference; }; class SubscriptionManager { constructor() { this.subscriptions = []; } add(handler) { addUniqueItem(this.subscriptions, handler); return () => removeItem(this.subscriptions, handler); } notify(a, b, c) { const numSubscriptions = this.subscriptions.length; if (!numSubscriptions) return; if (numSubscriptions === 1) { /** * If there's only a single handler we can just call it without invoking a loop. */ this.subscriptions[0](a, b, c); } else { for (let i = 0; i < numSubscriptions; i++) { /** * Check whether the handler exists before firing as it's possible * the subscriptions were modified during this loop running. */ const handler = this.subscriptions[i]; handler && handler(a, b, c); } } } getSize() { return this.subscriptions.length; } clear() { this.subscriptions.length = 0; } } /** * 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; /* Convert velocity into velocity per second @param [number]: Unit per frame @param [number]: Frame duration in ms */ function velocityPerSecond(velocity, frameDuration) { return frameDuration ? velocity * (1000 / frameDuration) : 0; } const warned = new Set(); function warnOnce(condition, message, element) { if (condition || warned.has(message)) return; console.warn(message); if (element) console.warn(element); warned.add(message); } 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$1 = (value) => value !== null; function getFinalKeyframe$1(keyframes, { repeat, repeatType = "loop" }, finalKeyframe) { const resolvedKeyframes = keyframes.filter(isNotNull$1); 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 statsBuffer = { value: null, addProjectionMetrics: null, }; 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); exports.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$1(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$1(transition, key) { return (transition?.[key] ?? transition?.["default"] ?? transition); } /** * Implement a practical max duration for keyframe generation * to prevent infinite loops */ const maxGeneratorDuration = 20000; function calcGeneratorDuration(generator) { let duration = 0; const timeStep = 50; let state = generator.next(duration); while (!state.done && duration < maxGeneratorDuration) { duration += timeStep; state = generator.next(duration); } return duration >= maxGeneratorDuration ? Infinity : duration; } /** * Create a progress => progress easing function from a generator. */ function createGeneratorEasing(options, scale = 100, createGenerator) { const generator = createGenerator({ ...options, keyframes: [0, scale] }); const duration = Math.min(calcGeneratorDuration(generator), maxGeneratorDuration); return { type: "keyframes", ease: (progress) => { return generator.next(duration * progress).value / scale; }, duration: millisecondsToSeconds(duration), }; } function isWaapiSupportedEasing(easing) { return Boolean((typeof easing === "function" && supportsLinearEasing()) || !easing || (typeof easing === "string" && (easing in supportedWaapiEasing || supportsLinearEasing())) || isBezierDefinition(easing) || (Array.isArray(easing) && easing.every(isWaapiSupportedEasing))); } function attachTimeline(animation, timeline) { animation.timeline = timeline; animation.onfinish = null; } const stepsOrder = [ "read", // Read "resolveKeyframes", // Write/Read/Write/Read "update", // Compute "preRender", // Compute "render", // Write "postRender", // Compute ]; function createRenderStep(runNextFrame, stepName) { /** * We create and reuse two queues, one to queue jobs for the current frame * and one for the next. We reuse to avoid triggering GC after x frames. */ let thisFrame = new Set(); let nextFrame = new Set(); /** * Track whether we're currently processing jobs in this step. This way * we can decide whether to schedule new jobs for this frame or next. */ let isProcessing = false; let flushNextFrame = false; /** * A set of processes which were marked keepAlive when scheduled. */ const toKeepAlive = new WeakSet(); let latestFrameData = { delta: 0.0, timestamp: 0.0, isProcessing: false, }; let numCalls = 0; function triggerCallback(callback) { if (toKeepAlive.has(callback)) { step.schedule(callback); runNextFrame(); } numCalls++; callback(latestFrameData); } const step = { /** * Schedule a process to run on the next frame. */ schedule: (callback, keepAlive = false, immediate = false) => { const addToCurrentFrame = immediate && isProcessing; const queue = addToCurrentFrame ? thisFrame : nextFrame; if (keepAlive) toKeepAlive.add(callback); if (!queue.has(callback)) queue.add(callback); return callback; }, /** * Cancel the provided callback from running on the next frame. */ cancel: (callback) => { nextFrame.delete(callback); toKeepAlive.delete(callback); }, /** * Execute all schedule callbacks. */ process: (frameData) => { latestFrameData = frameData; /** * If we're already processing we've probably been triggered by a flushSync * inside an existing process. Instead of executing, mark flushNextFrame * as true and ensure we flush the following frame at the end of this one. */ if (isProcessing) { flushNextFrame = true; return; } isProcessing = true; [thisFrame, nextFrame] = [nextFrame, thisFrame]; // Execute this frame thisFrame.forEach(triggerCallback); /** * If we're recording stats then */ if (stepName && statsBuffer.value) { statsBuffer.value.frameloop[stepName].push(numCalls); } numCalls = 0; // Clear the frame so no callbacks remain. This is to avoid // memory leaks should this render step not run for a while. thisFrame.clear(); isProcessing = false; if (flushNextFrame) { flushNextFrame = false; step.process(frameData); } }, }; return step; } const maxElapsed$1 = 40; function createRenderBatcher(scheduleNextBatch, allowKeepAlive) { let runNextFrame = false; let useDefaultElapsed = true; const state = { delta: 0.0, timestamp: 0.0, isProcessing: false, }; const flagRunNextFrame = () => (runNextFrame = true); const steps = stepsOrder.reduce((acc, key) => { acc[key] = createRenderStep(flagRunNextFrame, allowKeepAlive ? key : undefined); return acc; }, {}); const { read, resolveKeyframes, update, preRender, render, postRender } = steps; const processBatch = () => { const timestamp = performance.now(); runNextFrame = false; { state.delta = useDefaultElapsed ? 1000 / 60 : Math.max(Math.min(timestamp - state.timestamp, maxElapsed$1), 1); } state.timestamp = timestamp; state.isProcessing = true; // Unrolled render loop for better per-frame performance read.process(state); resolveKeyframes.process(state); update.process(state); preRender.process(state); render.process(state); postRender.process(state); state.isProcessing = false; if (runNextFrame && allowKeepAlive) { useDefaultElapsed = false; scheduleNextBatch(processBatch); } }; const wake = () => { runNextFrame = true; useDefaultElapsed = true; if (!state.isProcessing) { scheduleNextBatch(processBatch); } }; const schedule = stepsOrder.reduce((acc, key) => { const step = steps[key]; acc[key] = (process, keepAlive = false, immediate = false) => { if (!runNextFrame) wake(); return step.schedule(process, keepAlive, immediate); }; return acc; }, {}); const cancel = (process) => { for (let i = 0; i < stepsOrder.length; i++) { steps[stepsOrder[i]].cancel(process); } }; return { schedule, cancel, state, steps }; } const { schedule: frame, cancel: cancelFrame, state: frameData, steps: frameSteps, } = /* @__PURE__ */ createRenderBatcher(typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : noop, true); let now; function clearTime() { now = undefined; } /** * An eventloop-synchronous alternative to performance.now(). * * Ensures that time measurements remain consistent within a synchronous context. * Usually calling performance.now() twice within the same synchronous context * will return different values which isn't useful for animations when we're usually * trying to sync animations to the same frame. */ const time = { now: () => { if (now === undefined) { time.set(frameData.isProcessing || MotionGlobalConfig.useManualTiming ? frameData.timestamp : performance.now()); } return now; }, set: (newTime) => { now = newTime; queueMicrotask(clearTime); }, }; const isDragging = { x: false, y: false, }; function isDragActive() { return isDragging.x || isDragging.y; } 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 setupGesture(elementOrSelector, options) { const elements = resolveElements(elementOrSelector); const gestureAbortController = new AbortController(); const eventOptions = { passive: true, ...options, signal: gestureAbortController.signal, }; const cancel = () => gestureAbortController.abort(); return [elements, eventOptions, cancel]; } function isValidHover(event) { return !(event.pointerType === "touch" || isDragActive()); } /** * Create a hover gesture. hover() is different to .addEventListener("pointerenter") * in that it has an easier syntax, filters out polyfilled touch events, interoperates * with drag gestures, and automatically removes the "pointerennd" event listener when the hover ends. * * @public */ function hover(elementOrSelector, onHoverStart, options = {}) { const [elements, eventOptions, cancel] = setupGesture(elementOrSelector, options); const onPointerEnter = (enterEvent) => { if (!isValidHover(enterEvent)) return; const { target } = enterEvent; const onHoverEnd = onHoverStart(target, enterEvent); if (typeof onHoverEnd !== "function" || !target) return; const onPointerLeave = (leaveEvent) => { if (!isValidHover(leaveEvent)) return; onHoverEnd(leaveEvent); target.removeEventListener("pointerleave", onPointerLeave); }; target.addEventListener("pointerleave", onPointerLeave, eventOptions); }; elements.forEach((element) => { element.addEventListener("pointerenter", onPointerEnter, eventOptions); }); return cancel; } /** * Recursively traverse up the tree to check whether the provided child node * is the parent or a descendant of it. * * @param parent - Element to find * @param child - Element to test against parent */ const isNodeOrChild = (parent, child) => { if (!child) { return false; } else if (parent === child) { return true; } else { return isNodeOrChild(parent, child.parentElement); } }; const isPrimaryPointer = (event) => { if (event.pointerType === "mouse") { return typeof event.button !== "number" || event.button <= 0; } else { /** * isPrimary is true for all mice buttons, whereas every touch point * is regarded as its own input. So subsequent concurrent touch points * will be false. * * Specifically match against false here as incomplete versions of * PointerEvents in very old browser might have it set as undefined. */ return event.isPrimary !== false; } }; const focusableElements = new Set([ "BUTTON", "INPUT", "SELECT", "TEXTAREA", "A", ]); function isElementKeyboardAccessible(element) { return (focusableElements.has(element.tagName) || element.tabIndex !== -1); } const isPressing = new WeakSet(); /** * Filter out events that are not "Enter" keys. */ function filterEvents(callback) { return (event) => { if (event.key !== "Enter") return; callback(event); }; } function firePointerEvent(target, type) { target.dispatchEvent(new PointerEvent("pointer" + type, { isPrimary: true, bubbles: true })); } const enableKeyboardPress = (focusEvent, eventOptions) => { const element = focusEvent.currentTarget; if (!element) return; const handleKeydown = filterEvents(() => { if (isPressing.has(element)) return; firePointerEvent(element, "down"); const handleKeyup = filterEvents(() => { firePointerEvent(element, "up"); }); const handleBlur = () => firePointerEvent(element, "cancel"); element.addEventListener("keyup", handleKeyup, eventOptions); element.addEventListener("blur", handleBlur, eventOptions); }); element.addEventListener("keydown", handleKeydown, eventOptions); /** * Add an event listener that fires on blur to remove the keydown events. */ element.addEventListener("blur", () => element.removeEventListener("keydown", handleKeydown), eventOptions); }; /** * Filter out events that are not primary pointer events, or are triggering * while a Motion gesture is active. */ function isValidPressEvent(event) { return isPrimaryPointer(event) && !isDragActive(); } /** * Create a press gesture. * * Press is different to `"pointerdown"`, `"pointerup"` in that it * automatically filters out secondary pointer events like right * click and multitouch. * * It also adds accessibility support for keyboards, where * an element with a press gesture will receive focus and * trigger on Enter `"keydown"` and `"keyup"` events. * * This is different to a browser's `"click"` event, which does * respond to keyboards but only for the `"click"` itself, rather * than the press start and end/cancel. The element also needs * to be focusable for this to work, whereas a press gesture will * make an element focusable by default. * * @public */ function press(targetOrSelector, onPressStart, options = {}) { const [targets, eventOptions, cancelEvents] = setupGesture(targetOrSelector, options); const startPress = (startEvent) => { const target = startEvent.currentTarget; if (!isValidPressEvent(startEvent) || isPressing.has(target)) return; isPressing.add(target); const onPressEnd = onPressStart(target, startEvent); const onPointerEnd = (endEvent, success) => { window.removeEventListener("pointerup", onPointerUp); window.removeEventListener("pointercancel", onPointerCancel); if (!isValidPressEvent(endEvent) || !isPressing.has(target)) { return; } isPressing.delete(target); if (typeof onPressEnd === "function") { onPressEnd(endEvent, { success }); } }; const onPointerUp = (upEvent) => { onPointerEnd(upEvent, target === window || target === document || options.useGlobalTarget || isNodeOrChild(target, upEvent.target)); }; const onPointerCancel = (cancelEvent) => { onPointerEnd(cancelEvent, false); }; window.addEventListener("pointerup", onPointerUp, eventOptions); window.addEventListener("pointercancel", onPointerCancel, eventOptions); }; targets.forEach((target) => { const pointerDownTarget = options.useGlobalTarget ? window : target; pointerDownTarget.addEventListener("pointerdown", startPress, eventOptions); if (target instanceof HTMLElement) { target.addEventListener("focus", (event) => enableKeyboardPress(event, eventOptions)); if (!isElementKeyboardAccessible(target) && !target.hasAttribute("tabindex")) { target.tabIndex = 0; } } }); return cancelEvents; } /** * Maximum time between the value of two frames, beyond which we * assume the velocity has since been 0. */ const MAX_VELOCITY_DELTA = 30; const isFloat = (value) => { return !isNaN(parseFloat(value)); }; /** * `MotionValue` is used to track the state and velocity of motion values. * * @public */ class MotionValue { /** * @param init - The initiating value * @param config - Optional configuration options * * - `transformer`: A function to transform incoming values with. */ constructor(init, options = {}) { /** * This will be replaced by the build step with the latest version number. * When MotionValues are provided to motion components, warn if versions are mixed. */ this.version = "12.6.3"; /** * Tracks whether this value can output a velocity. Currently this is only true * if the value is numerical, but we might be able to widen the scope here and support * other value types. * * @internal */ this.canTrackVelocity = null; /** * An object containing a SubscriptionManager for each active event. */ this.events = {}; this.updateAndNotify = (v, render = true) => { const currentTime = time.now(); /** * If we're updating the value during another frame or eventloop * than the previous frame, then the we set the previous frame value * to current. */ if (this.updatedAt !== currentTime) { this.setPrevFrameValue(); } this.prev = this.current; this.setCurrent(v); // Update update subscribers if (this.current !== this.prev && this.events.change) { this.events.change.notify(this.current); } // Update render subscribers if (render && this.events.renderRequest) { this.events.renderRequest.notify(this.current); } }; this.hasAnimated = false; this.setCurrent(init); this.owner = options.owner; } setCurrent(current) { this.current = current; this.updatedAt = time.now(); if (this.canTrackVelocity === null && current !== undefined) { this.canTrackVelocity = isFloat(this.current); } } setPrevFrameValue(prevFrameValue = this.current) { this.prevFrameValue = prevFrameValue; this.prevUpdatedAt = this.updatedAt; } /** * Adds a function that will be notified when the `MotionValue` is updated. * * It returns a function that, when called, will cancel the subscription. * * When calling `onChange` inside a React component, it should be wrapped with the * `useEffect` hook. As it returns an unsubscribe function, this should be returned * from the `useEffect` function to ensure you don't add duplicate subscribers.. * * ```jsx * export const MyComponent = () => { * const x = useMotionValue(0) * const y = useMotionValue(0) * const opacity = useMotionValue(1) * * useEffect(() => { * function updateOpacity() { * const maxXY = Math.max(x.get(), y.get()) * const newOpacity = transform(maxXY, [0, 100], [1, 0]) * opacity.set(newOpacity) * } * * const unsubscribeX = x.on("change", updateOpacity) * const unsubscribeY = y.on("change", updateOpacity) * * return () => { * unsubscribeX() * unsubscribeY() * } * }, []) * * return <motion.div style={{ x }} /> * } * ``` * * @param subscriber - A function that receives the latest value. * @returns A function that, when called, will cancel this subscription. * * @deprecated */ onChange(subscription) { if (process.env.NODE_ENV !== "production") { warnOnce(false, `value.onChange(callback) is deprecated. Switch to value.on("change", callback).`); } return this.on("change", subscription); } on(eventName, callback) { if (!this.events[eventName]) { this.events[eventName] = new SubscriptionManager(); } const unsubscribe = this.events[eventName].add(callback); if (eventName === "change") { return () => { unsubscribe(); /** * If we have no more change listeners by the start * of the next frame, stop active animations. */ frame.read(() => { if (!this.events.change.getSize()) { this.stop(); } }); }; } return unsubscribe; } clearListeners() { for (const eventManagers in this.events) { this.events[eventManagers].clear(); } } /** * Attaches a passive effect to the `MotionValue`. */ attach(passiveEffect, stopPassiveEffect) { this.passiveEffect = passiveEffect; this.stopPassiveEffect = stopPassiveEffect; } /** * Sets the state of the `MotionValue`. * * @remarks * * ```jsx * const x = useMotionValue(0) * x.set(10) * ``` * * @param latest - Latest value to set. * @param render - Whether to notify render subscribers. Defaults to `true` * * @public */ set(v, render = true) { if (!render || !this.passiveEffect) { this.updateAndNotify(v, render); } else { this.passiveEffect(v, this.updateAndNotify); } } setWithVelocity(prev, current, delta) { this.set(current); this.prev = undefined; this.prevFrameValue = prev; this.prevUpdatedAt = this.updatedAt - delta; } /** * Set the state of the `MotionValue`, stopping any active animations, * effects, and resets velocity to `0`. */ jump(v, endAnimation = true) { this.updateAndNotify(v); this.prev = v; this.prevUpdatedAt = this.prevFrameValue = undefined; endAnimation && this.stop(); if (this.stopPassiveEffect) this.stopPassiveEffect(); } /** * Returns the latest state of `MotionValue` * * @returns - The latest state of `MotionValue` * * @public */ get() { return this.current; } /** * @public */ getPrevious() { return this.prev; } /** * Returns the latest velocity of `MotionValue` * * @returns - The latest velocity of `MotionValue`. Returns `0` if the state is non-numerical. * * @public */ getVelocity() { const currentTime = time.now(); if (!this.canTrackVelocity || this.prevFrameValue === undefined || currentTime - this.updatedAt > MAX_VELOCITY_DELTA) { return 0; } const delta = Math.min(this.updatedAt - this.prevUpdatedAt, MAX_VELOCITY_DELTA); // Casts because of parseFloat's poor typing return velocityPerSecond(parseFloat(this.current) - parseFloat(this.prevFrameValue), delta); } /** * Registers a new animation to control this `MotionValue`. Only one * animation can drive a `MotionValue` at one time. * * ```jsx * value.start() * ``` * * @param animation - A function that starts the provided animation */ start(startAnimation) { this.stop(); return new Promise((resolve) => { this.hasAnimated = true; this.animation = startAnimation(resolve); if (this.events.animationStart) { this.events.animationStart.notify(); } }).then(() => { if (this.events.animationComplete) { this.events.animationComplete.notify(); } this.clearAnimation(); }); } /** * Stop the currently active animation. * * @public */ stop() { if (this.animation) { this.animation.stop(); if (this.events.animationCancel) { this.events.animationCancel.notify(); } } this.clearAnimation(); } /** * Returns `true` if this value is currently animating. * * @public */ isAnimating() { return !!this.animation; } clearAnimation() { delete this.animation; } /** * Destroy and clean up subscribers to this `MotionValue`. * * The `MotionValue` hooks like `useMotionValue` and `useTransform` automatically * handle the lifecycle of the returned `MotionValue`, so this method is only necessary if you've manually * created a `MotionValue` via the `motionValue` function. * * @public */ destroy() { this.clearListeners(); this.stop(); if (this.stopPassiveEffect) { this.stopPassiveEffect(); } } } function motionValue(init, options) { return new MotionValue(init, options); } const clamp = (min, max, v) => { if (v > max) return max; if (v < min) return min; return v; }; const velocitySampleDuration = 5; // ms function calcGeneratorVelocity(resolveValue, t, current) { const prevT = Math.max(t - velocitySampleDuration, 0); return velocityPerSecond(current - resolveValue(prevT), t - prevT); } const springDefaults = { // Default spring physics stiffness: 100, damping: 10, mass: 1.0, velocity: 0.0, // Default duration/bounce-based options duration: 800, // in ms bounce: 0.3, visualDuration: 0.3, // in seconds // Rest thresholds restSpeed: { granular: 0.01, default: 2, }, restDelta: { granular: 0.005, default: 0.5, }, // Limits minDuration: 0.01, // in seconds maxDuration: 10.0, // in seconds minDamping: 0.05, maxDamping: 1, }; const safeMin = 0.001; function findSpring({ duration = springDefaults.duration, bounce = springDefaults.bounce, velocity = springDefaults.velocity, mass = springDefaults.mass, }) { let envelope; let derivative; warning(duration <= secondsToMilliseconds(springDefaults.maxDuration), "Spring duration must be 10 seconds or less"); let dampingRatio = 1 - bounce; /** * Restrict dampingRatio and duration to within acceptable ranges. */ dampingRatio = clamp(springDefaults.minDamping, springDefaults.maxDamping, dampingRatio); duration = clamp(springDefaults.minDuration, springDefaults.maxDuration, millisecondsToSeconds(duration)); if (dampingRatio < 1) { /** * Underdamped spring */ envelope = (undampedFreq) => { const exponentialDecay = undampedFreq * dampingRatio; const delta = exponentialDecay * duration; const a = exponentialDecay - velocity; const b = calcAngularFreq(undampedFreq, dampingRatio); const c = Math.exp(-delta); return safeMin - (a / b) * c; }; derivative = (undampedFreq) => { const exponentialDecay = undampedFreq * dampingRatio; const delta = exponentialDecay * duration; const d = delta * velocity + velocity; const e = Math.pow(dampingRatio, 2) * Math.pow(undampedFreq, 2) * duration; const f = Math.exp(-delta); const g = calcAngularFreq(Math.pow(undampedFreq, 2), dampingRatio); const factor = -envelope(undampedFreq) + safeMin > 0 ? -1 : 1; return (factor * ((d - e) * f)) / g; }; } else { /** * Critically-damped spring */ envelope = (undampedFreq) => { const a = Math.exp(-undampedFreq * duration); const b = (undampedFreq - velocity) * duration + 1; return -safeMin + a * b; }; derivative = (undampedFreq) => { const a = Math.exp(-undampedFreq * duration); const b = (velocity - undampedFreq) * (duration * duration); return a * b; }; } const initialGuess = 5 / duration; const undampedFreq = approximateRoot(envelope, derivative, initialGuess); duration = secondsToMilliseconds(duration); if (isNaN(undampedFreq)) { return { stiffness: springDefaults.stiffness, damping: springDefaults.damping, duration, }; } else { const stiffness = Math.pow(undampedFreq, 2) * mass; return { stiffness, damping: dampingRatio * 2 * Math.sqrt(mass * stiffness), duration, }; } } const rootIterations = 12; function approximateRoot(envelope, derivative, initialGuess) { let result = initialGuess; for (let i = 1; i < rootIterations; i++) { result = result - envelope(result) / derivative(result); } return result; } function calcAngularFreq(undampedFreq, dampingRatio) { return undampedFreq * Math.sqrt(1 - dampingRatio * dampingRatio); } const durationKeys = ["duration", "bounce"]; const physicsKeys = ["stiffness", "damping", "mass"]; function isSpringType(options, keys) { return keys.some((key) => options[key] !== undefined); } function getSpringOptions(options) { let springOptions = { velocity: springDefaults.velocity, stiffness: springDefaults.stiffness, damping: springDefaults.damping, mass: springDefaults.mass, isResolvedFromDuration: false, ...options, }; // stiffness/damping/mass overrides duration/bounce if (!isSpringType(options, physicsKeys) && isSpringType(options, durationKeys)) { if (options.visualDuration) { const visualDuration = options.visualDuration; const root = (2 * Math.PI) / (visualDuration * 1.2); const stiffness = root * root; const damping = 2 * clamp(0.05, 1, 1 - (options.bounce || 0)) * Math.sqrt(stiffness); springOptions = { ...springOptions, mass: springDefaults.mass, stiffness, damping, }; } else { const derived = findSpring(options); springOptions = { ...springOptions, ...derived, mass: springDefaults.mass, }; springOptions.isResolvedFromDuration = true; } } return springOptions; } function spring(optionsOrVisualDuration = springDefaults.visualDuration, bounce = springDefaults.bounce) { const options = typeof optionsOrVisualDuration !== "object" ? { visualDuration: optionsOrVisualDuration, keyframes: [0, 1], bounce, } : optionsOrVisualDuration; let { restSpeed, restDelta } = options; const origin = options.keyframes[0]; const target = options.keyframes[options.keyframes.length - 1]; /** * This is the Iterator-spec return value. We ensure it's mutable rather than using a generator * to reduce GC during animation. */ const state = { done: false, value: origin }; const { stiffness, damping, mass, duration, velocity, isResolvedFromDuration, } = getSpringOptions({ ...options, velocity: -millisecondsToSeconds(options.velocity || 0), }); const initialVelocity = velocity || 0.0; const dampingRatio = damping / (2 * Math.sqrt(stiffness * mass)); const initialDelta = target - origin; const undampedAngularFreq = millisecondsToSeconds(Math.sqrt(stiffness / mass)); /** * If we're working on a granular scale, use smaller defaults for determining * when the spring is finished. * * These defaults have been selected emprically based on what strikes a good * ratio between feeling good and finishing as soon as changes are imperceptible. */ const isGranularScale = Math.abs(initialDelta) < 5; restSpeed || (restSpeed = isGranularScale ? springDefaults.restSpeed.granular : springDefaults.restSpeed.default); restDelta || (restDelta = isGranularScale ? springDefaults.restDelta.granular : springDefaults.restDelta.default); let resolveSpring; if (dampingRatio < 1) { const angularFreq = calcAngularFreq(undampedAngularFreq, dampingRatio); // Underdamped spring resolveSpring = (t) => { const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t); return (target - envelope * (((initialVelocity + dampingRatio * undampedAngularFreq * initialDelta) / angularFreq) * Math.sin(angularFreq * t) + initialDelta * Math.cos(angularFreq * t))); }; } else if (dampingRatio === 1) { // Critically damped spring resolveSpring = (t) => target - Math.exp(-undampedAngularFreq * t) * (initialDelta + (ini