'use strict'; var motionDom = require('motion-dom'); var React = require('react'); var jsxRuntime = require('react/jsx-runtime'); var motionUtils = require('motion-utils'); const LayoutGroupContext = React.createContext({}); /** * 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; } const isBrowser = typeof window !== "undefined"; const useIsomorphicLayoutEffect = isBrowser ? React.useLayoutEffect : React.useEffect; /** * @public */ const PresenceContext = /* @__PURE__ */ React.createContext(null); /** * @public */ const MotionConfigContext = React.createContext({ transformPagePoint: (p) => p, isStatic: false, reducedMotion: "never", }); /** * When a component is the child of `AnimatePresence`, it can use `usePresence` * to access information about whether it's still present in the React tree. * * ```jsx * import { usePresence } from "framer-motion" * * export const Component = () => { * const [isPresent, safeToRemove] = usePresence() * * useEffect(() => { * !isPresent && setTimeout(safeToRemove, 1000) * }, [isPresent]) * * return
* } * ``` * * If `isPresent` is `false`, it means that a component has been removed from the tree, * but `AnimatePresence` won't really remove it until `safeToRemove` has been called. * * @public */ function usePresence(subscribe = true) { const context = React.useContext(PresenceContext); if (context === null) return [true, null]; const { isPresent, onExitComplete, register } = context; // It's safe to call the following hooks conditionally (after an early return) because the context will always // either be null or non-null for the lifespan of the component. const id = React.useId(); React.useEffect(() => { if (subscribe) { return register(id); } }, [subscribe]); const safeToRemove = React.useCallback(() => subscribe && onExitComplete && onExitComplete(id), [id, onExitComplete, subscribe]); return !isPresent && onExitComplete ? [false, safeToRemove] : [true]; } /** * Similar to `usePresence`, except `useIsPresent` simply returns whether or not the component is present. * There is no `safeToRemove` function. * * ```jsx * import { useIsPresent } from "framer-motion" * * export const Component = () => { * const isPresent = useIsPresent() * * useEffect(() => { * !isPresent && console.log("I've been removed!") * }, [isPresent]) * * return
* } * ``` * * @public */ function useIsPresent() { return isPresent(React.useContext(PresenceContext)); } function isPresent(context) { return context === null ? true : context.isPresent; } const LazyContext = React.createContext({ strict: false }); const featureProps = { animation: [ "animate", "variants", "whileHover", "whileTap", "exit", "whileInView", "whileFocus", "whileDrag", ], exit: ["exit"], drag: ["drag", "dragControls"], focus: ["whileFocus"], hover: ["whileHover", "onHoverStart", "onHoverEnd"], tap: ["whileTap", "onTap", "onTapStart", "onTapCancel"], pan: ["onPan", "onPanStart", "onPanSessionStart", "onPanEnd"], inView: ["whileInView", "onViewportEnter", "onViewportLeave"], layout: ["layout", "layoutId"], }; let isInitialized = false; /** * Initialize feature definitions with isEnabled checks. * This must be called before any motion components are rendered. */ function initFeatureDefinitions() { if (isInitialized) return; const initialFeatureDefinitions = {}; for (const key in featureProps) { initialFeatureDefinitions[key] = { isEnabled: (props) => featureProps[key].some((name) => !!props[name]), }; } motionDom.setFeatureDefinitions(initialFeatureDefinitions); isInitialized = true; } /** * Get the current feature definitions, initializing if needed. */ function getInitializedFeatureDefinitions() { initFeatureDefinitions(); return motionDom.getFeatureDefinitions(); } function loadFeatures(features) { const featureDefinitions = getInitializedFeatureDefinitions(); for (const key in features) { featureDefinitions[key] = { ...featureDefinitions[key], ...features[key], }; } motionDom.setFeatureDefinitions(featureDefinitions); } /** * A list of all valid MotionProps. * * @privateRemarks * This doesn't throw if a `MotionProp` name is missing - it should. */ const validMotionProps = new Set([ "animate", "exit", "variants", "initial", "style", "values", "variants", "transition", "transformTemplate", "custom", "inherit", "onBeforeLayoutMeasure", "onAnimationStart", "onAnimationComplete", "onUpdate", "onDragStart", "onDrag", "onDragEnd", "onMeasureDragConstraints", "onDirectionLock", "onDragTransitionEnd", "_dragX", "_dragY", "onHoverStart", "onHoverEnd", "onViewportEnter", "onViewportLeave", "globalTapTarget", "propagate", "ignoreStrict", "viewport", ]); /** * Check whether a prop name is a valid `MotionProp` key. * * @param key - Name of the property to check * @returns `true` is key is a valid `MotionProp`. * * @public */ function isValidMotionProp(key) { return (key.startsWith("while") || (key.startsWith("drag") && key !== "draggable") || key.startsWith("layout") || key.startsWith("onTap") || key.startsWith("onPan") || key.startsWith("onLayout") || validMotionProps.has(key)); } let shouldForward = (key) => !isValidMotionProp(key); function loadExternalIsValidProp(isValidProp) { if (typeof isValidProp !== "function") return; // Explicitly filter our events shouldForward = (key) => key.startsWith("on") ? !isValidMotionProp(key) : isValidProp(key); } /** * Emotion and Styled Components both allow users to pass through arbitrary props to their components * to dynamically generate CSS. They both use the `@emotion/is-prop-valid` package to determine which * of these should be passed to the underlying DOM node. * * However, when styling a Motion component `styled(motion.div)`, both packages pass through *all* props * as it's seen as an arbitrary component rather than a DOM node. Motion only allows arbitrary props * passed through the `custom` prop so it doesn't *need* the payload or computational overhead of * `@emotion/is-prop-valid`, however to fix this problem we need to use it. * * By making it an optionalDependency we can offer this functionality only in the situations where it's * actually required. */ try { /** * We attempt to import this package but require won't be defined in esm environments, in that case * isPropValid will have to be provided via `MotionContext`. In a 6.0.0 this should probably be removed * in favour of explicit injection. */ loadExternalIsValidProp(require("@emotion/is-prop-valid").default); } catch { // We don't need to actually do anything here - the fallback is the existing `isPropValid`. } function filterProps(props, isDom, forwardMotionProps) { const filteredProps = {}; for (const key in props) { /** * values is considered a valid prop by Emotion, so if it's present * this will be rendered out to the DOM unless explicitly filtered. * * We check the type as it could be used with the `feColorMatrix` * element, which we support. */ if (key === "values" && typeof props.values === "object") continue; if (shouldForward(key) || (forwardMotionProps === true && isValidMotionProp(key)) || (!isDom && !isValidMotionProp(key)) || // If trying to use native HTML drag events, forward drag listeners (props["draggable"] && key.startsWith("onDrag"))) { filteredProps[key] = props[key]; } } return filteredProps; } /** * We keep these listed separately as we use the lowercase tag names as part * of the runtime bundle to detect SVG components */ const lowercaseSVGElements = [ "animate", "circle", "defs", "desc", "ellipse", "g", "image", "line", "filter", "marker", "mask", "metadata", "path", "pattern", "polygon", "polyline", "rect", "stop", "switch", "symbol", "svg", "text", "tspan", "use", "view", ]; function isSVGComponent(Component) { if ( /** * If it's not a string, it's a custom React component. Currently we only support * HTML custom React components. */ typeof Component !== "string" || /** * If it contains a dash, the element is a custom HTML webcomponent. */ Component.includes("-")) { return false; } else if ( /** * If it's in our list of lowercase SVG tags, it's an SVG component */ lowercaseSVGElements.indexOf(Component) > -1 || /** * If it contains a capital letter, it's an SVG component */ /[A-Z]/u.test(Component)) { return true; } return false; } const createDomVisualElement = (Component, options) => { /** * Use explicit isSVG override if provided, otherwise auto-detect */ const isSVG = options.isSVG ?? isSVGComponent(Component); return isSVG ? new motionDom.SVGVisualElement(options) : new motionDom.HTMLVisualElement(options, { allowProjection: Component !== React.Fragment, }); }; const MotionContext = /* @__PURE__ */ React.createContext({}); function getCurrentTreeVariants(props, context) { if (motionDom.isControllingVariants(props)) { const { initial, animate } = props; return { initial: initial === false || motionDom.isVariantLabel(initial) ? initial : undefined, animate: motionDom.isVariantLabel(animate) ? animate : undefined, }; } return props.inherit !== false ? context : {}; } function useCreateMotionContext(props) { const { initial, animate } = getCurrentTreeVariants(props, React.useContext(MotionContext)); return React.useMemo(() => ({ initial, animate }), [variantLabelsAsDependency(initial), variantLabelsAsDependency(animate)]); } function variantLabelsAsDependency(prop) { return Array.isArray(prop) ? prop.join(" ") : prop; } const createHtmlRenderState = () => ({ style: {}, transform: {}, transformOrigin: {}, vars: {}, }); function copyRawValuesOnly(target, source, props) { for (const key in source) { if (!motionDom.isMotionValue(source[key]) && !motionDom.isForcedMotionValue(key, props)) { target[key] = source[key]; } } } function useInitialMotionValues({ transformTemplate }, visualState) { return React.useMemo(() => { const state = createHtmlRenderState(); motionDom.buildHTMLStyles(state, visualState, transformTemplate); return Object.assign({}, state.vars, state.style); }, [visualState]); } function useStyle(props, visualState) { const styleProp = props.style || {}; const style = {}; /** * Copy non-Motion Values straight into style */ copyRawValuesOnly(style, styleProp, props); Object.assign(style, useInitialMotionValues(props, visualState)); return style; } function useHTMLProps(props, visualState) { // The `any` isn't ideal but it is the type of createElement props argument const htmlProps = {}; const style = useStyle(props, visualState); if (props.drag && props.dragListener !== false) { // Disable the ghost element when a user drags htmlProps.draggable = false; // Disable text selection style.userSelect = style.WebkitUserSelect = style.WebkitTouchCallout = "none"; // Disable scrolling on the draggable direction style.touchAction = props.drag === true ? "none" : `pan-${props.drag === "x" ? "y" : "x"}`; } if (props.tabIndex === undefined && (props.onTap || props.onTapStart || props.whileTap)) { htmlProps.tabIndex = 0; } htmlProps.style = style; return htmlProps; } const createSvgRenderState = () => ({ ...createHtmlRenderState(), attrs: {}, }); function useSVGProps(props, visualState, _isStatic, Component) { const visualProps = React.useMemo(() => { const state = createSvgRenderState(); motionDom.buildSVGAttrs(state, visualState, motionDom.isSVGTag(Component), props.transformTemplate, props.style); return { ...state.attrs, style: { ...state.style }, }; }, [visualState]); if (props.style) { const rawStyles = {}; copyRawValuesOnly(rawStyles, props.style, props); visualProps.style = { ...rawStyles, ...visualProps.style }; } return visualProps; } function useRender(Component, props, ref, { latestValues, }, isStatic, forwardMotionProps = false, isSVG) { const useVisualProps = (isSVG ?? isSVGComponent(Component)) ? useSVGProps : useHTMLProps; const visualProps = useVisualProps(props, latestValues, isStatic, Component); const filteredProps = filterProps(props, typeof Component === "string", forwardMotionProps); const elementProps = Component !== React.Fragment ? { ...filteredProps, ...visualProps, ref } : {}; /** * If component has been handed a motion value as its child, * memoise its initial value and render that. Subsequent updates * will be handled by the onChange handler */ const { children } = props; const renderedChildren = React.useMemo(() => (motionDom.isMotionValue(children) ? children.get() : children), [children]); return React.createElement(Component, { ...elementProps, children: renderedChildren, }); } function makeState({ scrapeMotionValuesFromProps, createRenderState, }, props, context, presenceContext) { const state = { latestValues: makeLatestValues(props, context, presenceContext, scrapeMotionValuesFromProps), renderState: createRenderState(), }; return state; } function makeLatestValues(props, context, presenceContext, scrapeMotionValues) { const values = {}; const motionValues = scrapeMotionValues(props, {}); for (const key in motionValues) { values[key] = motionDom.resolveMotionValue(motionValues[key]); } let { initial, animate } = props; const isControllingVariants = motionDom.isControllingVariants(props); const isVariantNode = motionDom.isVariantNode(props); if (context && isVariantNode && !isControllingVariants && props.inherit !== false) { if (initial === undefined) initial = context.initial; if (animate === undefined) animate = context.animate; } let isInitialAnimationBlocked = presenceContext ? presenceContext.initial === false : false; isInitialAnimationBlocked = isInitialAnimationBlocked || initial === false; const variantToSet = isInitialAnimationBlocked ? animate : initial; if (variantToSet && typeof variantToSet !== "boolean" && !motionDom.isAnimationControls(variantToSet)) { const list = Array.isArray(variantToSet) ? variantToSet : [variantToSet]; for (let i = 0; i < list.length; i++) { const resolved = motionDom.resolveVariantFromProps(props, list[i]); if (resolved) { const { transitionEnd, transition, ...target } = resolved; for (const key in target) { let valueTarget = target[key]; if (Array.isArray(valueTarget)) { /** * Take final keyframe if the initial animation is blocked because * we want to initialise at the end of that blocked animation. */ const index = isInitialAnimationBlocked ? valueTarget.length - 1 : 0; valueTarget = valueTarget[index]; } if (valueTarget !== null) { values[key] = valueTarget; } } for (const key in transitionEnd) { values[key] = transitionEnd[key]; } } } } return values; } const makeUseVisualState = (config) => (props, isStatic) => { const context = React.useContext(MotionContext); const presenceContext = React.useContext(PresenceContext); const make = () => makeState(config, props, context, presenceContext); return isStatic ? make() : useConstant(make); }; const useHTMLVisualState = /*@__PURE__*/ makeUseVisualState({ scrapeMotionValuesFromProps: motionDom.scrapeHTMLMotionValuesFromProps, createRenderState: createHtmlRenderState, }); const useSVGVisualState = /*@__PURE__*/ makeUseVisualState({ scrapeMotionValuesFromProps: motionDom.scrapeSVGMotionValuesFromProps, createRenderState: createSvgRenderState, }); const motionComponentSymbol = Symbol.for("motionComponentSymbol"); /** * Creates a ref function that, when called, hydrates the provided * external ref and VisualElement. */ function useMotionRef(visualState, visualElement, externalRef) { /** * Store externalRef in a ref to avoid including it in the useCallback * dependency array. Including externalRef in dependencies causes issues * with libraries like Radix UI that create new callback refs on each render * when using asChild - this would cause the callback to be recreated, * triggering element remounts and breaking AnimatePresence exit animations. */ const externalRefContainer = React.useRef(externalRef); React.useInsertionEffect(() => { externalRefContainer.current = externalRef; }); // Store cleanup function returned by callback refs (React 19 feature) const refCleanup = React.useRef(null); return React.useCallback((instance) => { if (instance) { visualState.onMount?.(instance); } if (visualElement) { instance ? visualElement.mount(instance) : visualElement.unmount(); } const ref = externalRefContainer.current; if (typeof ref === "function") { if (instance) { const cleanup = ref(instance); if (typeof cleanup === "function") { refCleanup.current = cleanup; } } else if (refCleanup.current) { refCleanup.current(); refCleanup.current = null; } else { ref(instance); } } else if (ref) { ref.current = instance; } }, [visualElement]); } /** * Internal, exported only for usage in Framer */ const SwitchLayoutGroupContext = React.createContext({}); function isRefObject(ref) { return (ref && typeof ref === "object" && Object.prototype.hasOwnProperty.call(ref, "current")); } function useVisualElement(Component, visualState, props, createVisualElement, ProjectionNodeConstructor, isSVG) { const { visualElement: parent } = React.useContext(MotionContext); const lazyContext = React.useContext(LazyContext); const presenceContext = React.useContext(PresenceContext); const motionConfig = React.useContext(MotionConfigContext); const reducedMotionConfig = motionConfig.reducedMotion; const skipAnimations = motionConfig.skipAnimations; const visualElementRef = React.useRef(null); /** * Track whether the component has been through React's commit phase. * Used to detect when LazyMotion features load after the component has mounted. */ const hasMountedOnce = React.useRef(false); /** * If we haven't preloaded a renderer, check to see if we have one lazy-loaded */ createVisualElement = createVisualElement || lazyContext.renderer; if (!visualElementRef.current && createVisualElement) { visualElementRef.current = createVisualElement(Component, { visualState, parent, props, presenceContext, blockInitialAnimation: presenceContext ? presenceContext.initial === false : false, reducedMotionConfig, skipAnimations, isSVG, }); /** * If the component has already mounted before features loaded (e.g. via * LazyMotion with async feature loading), we need to force the initial * animation to run. Otherwise state changes that occurred before features * loaded will be lost and the element will snap to its final state. */ if (hasMountedOnce.current && visualElementRef.current) { visualElementRef.current.manuallyAnimateOnMount = true; } } const visualElement = visualElementRef.current; /** * Load Motion gesture and animation features. These are rendered as renderless * components so each feature can optionally make use of React lifecycle methods. */ const initialLayoutGroupConfig = React.useContext(SwitchLayoutGroupContext); if (visualElement && !visualElement.projection && ProjectionNodeConstructor && (visualElement.type === "html" || visualElement.type === "svg")) { createProjectionNode(visualElementRef.current, props, ProjectionNodeConstructor, initialLayoutGroupConfig); } const isMounted = React.useRef(false); React.useInsertionEffect(() => { /** * Check the component has already mounted before calling * `update` unnecessarily. This ensures we skip the initial update. */ if (visualElement && isMounted.current) { visualElement.update(props, presenceContext); } }); /** * Cache this value as we want to know whether HandoffAppearAnimations * was present on initial render - it will be deleted after this. */ const optimisedAppearId = props[motionDom.optimizedAppearDataAttribute]; const wantsHandoff = React.useRef(Boolean(optimisedAppearId) && !window.MotionHandoffIsComplete?.(optimisedAppearId) && window.MotionHasOptimisedAnimation?.(optimisedAppearId)); useIsomorphicLayoutEffect(() => { /** * Track that this component has mounted. This is used to detect when * LazyMotion features load after the component has already committed. */ hasMountedOnce.current = true; if (!visualElement) return; isMounted.current = true; window.MotionIsMounted = true; visualElement.updateFeatures(); visualElement.scheduleRenderMicrotask(); /** * Ideally this function would always run in a useEffect. * * However, if we have optimised appear animations to handoff from, * it needs to happen synchronously to ensure there's no flash of * incorrect styles in the event of a hydration error. * * So if we detect a situtation where optimised appear animations * are running, we use useLayoutEffect to trigger animations. */ if (wantsHandoff.current && visualElement.animationState) { visualElement.animationState.animateChanges(); } }); React.useEffect(() => { if (!visualElement) return; if (!wantsHandoff.current && visualElement.animationState) { visualElement.animationState.animateChanges(); } if (wantsHandoff.current) { // This ensures all future calls to animateChanges() in this component will run in useEffect queueMicrotask(() => { window.MotionHandoffMarkAsComplete?.(optimisedAppearId); }); wantsHandoff.current = false; } /** * Now we've finished triggering animations for this element we * can wipe the enteringChildren set for the next render. */ visualElement.enteringChildren = undefined; }); return visualElement; } function createProjectionNode(visualElement, props, ProjectionNodeConstructor, initialPromotionConfig) { const { layoutId, layout, drag, dragConstraints, layoutScroll, layoutRoot, layoutCrossfade, } = props; visualElement.projection = new ProjectionNodeConstructor(visualElement.latestValues, props["data-framer-portal-id"] ? undefined : getClosestProjectingNode(visualElement.parent)); visualElement.projection.setOptions({ layoutId, layout, alwaysMeasureLayout: Boolean(drag) || (dragConstraints && isRefObject(dragConstraints)), visualElement, /** * TODO: Update options in an effect. This could be tricky as it'll be too late * to update by the time layout animations run. * We also need to fix this safeToRemove by linking it up to the one returned by usePresence, * ensuring it gets called if there's no potential layout animations. * */ animationType: typeof layout === "string" ? layout : "both", initialPromotionConfig, crossfade: layoutCrossfade, layoutScroll, layoutRoot, }); } function getClosestProjectingNode(visualElement) { if (!visualElement) return undefined; return visualElement.options.allowProjection !== false ? visualElement.projection : getClosestProjectingNode(visualElement.parent); } /** * Create a `motion` component. * * This function accepts a Component argument, which can be either a string (ie "div" * for `motion.div`), or an actual React component. * * Alongside this is a config option which provides a way of rendering the provided * component "offline", or outside the React render cycle. */ function createMotionComponent(Component, { forwardMotionProps = false, type } = {}, preloadedFeatures, createVisualElement) { preloadedFeatures && loadFeatures(preloadedFeatures); /** * Determine whether to use SVG or HTML rendering based on: * 1. Explicit `type` option (highest priority) * 2. Auto-detection via `isSVGComponent` */ const isSVG = type ? type === "svg" : isSVGComponent(Component); const useVisualState = isSVG ? useSVGVisualState : useHTMLVisualState; function MotionDOMComponent(props, externalRef) { /** * If we need to measure the element we load this functionality in a * separate class component in order to gain access to getSnapshotBeforeUpdate. */ let MeasureLayout; const configAndProps = { ...React.useContext(MotionConfigContext), ...props, layoutId: useLayoutId(props), }; const { isStatic } = configAndProps; const context = useCreateMotionContext(props); const visualState = useVisualState(props, isStatic); if (!isStatic && isBrowser) { useStrictMode(configAndProps, preloadedFeatures); const layoutProjection = getProjectionFunctionality(configAndProps); MeasureLayout = layoutProjection.MeasureLayout; /** * Create a VisualElement for this component. A VisualElement provides a common * interface to renderer-specific APIs (ie DOM/Three.js etc) as well as * providing a way of rendering to these APIs outside of the React render loop * for more performant animations and interactions */ context.visualElement = useVisualElement(Component, visualState, configAndProps, createVisualElement, layoutProjection.ProjectionNode, isSVG); } /** * The mount order and hierarchy is specific to ensure our element ref * is hydrated by the time features fire their effects. */ return (jsxRuntime.jsxs(MotionContext.Provider, { value: context, children: [MeasureLayout && context.visualElement ? (jsxRuntime.jsx(MeasureLayout, { visualElement: context.visualElement, ...configAndProps })) : null, useRender(Component, props, useMotionRef(visualState, context.visualElement, externalRef), visualState, isStatic, forwardMotionProps, isSVG)] })); } MotionDOMComponent.displayName = `motion.${typeof Component === "string" ? Component : `create(${Component.displayName ?? Component.name ?? ""})`}`; const ForwardRefMotionComponent = React.forwardRef(MotionDOMComponent); ForwardRefMotionComponent[motionComponentSymbol] = Component; return ForwardRefMotionComponent; } function useLayoutId({ layoutId }) { const layoutGroupId = React.useContext(LayoutGroupContext).id; return layoutGroupId && layoutId !== undefined ? layoutGroupId + "-" + layoutId : layoutId; } function useStrictMode(configAndProps, preloadedFeatures) { const isStrict = React.useContext(LazyContext).strict; /** * If we're in development mode, check to make sure we're not rendering a motion component * as a child of LazyMotion, as this will break the file-size benefits of using it. */ if (process.env.NODE_ENV !== "production" && preloadedFeatures && isStrict) { const strictMessage = "You have rendered a `motion` component within a `LazyMotion` component. This will break tree shaking. Import and render a `m` component instead."; configAndProps.ignoreStrict ? motionUtils.warning(false, strictMessage, "lazy-strict-mode") : motionUtils.invariant(false, strictMessage, "lazy-strict-mode"); } } function getProjectionFunctionality(props) { const featureDefinitions = getInitializedFeatureDefinitions(); const { drag, layout } = featureDefinitions; if (!drag && !layout) return {}; const combined = { ...drag, ...layout }; return { MeasureLayout: drag?.isEnabled(props) || layout?.isEnabled(props) ? combined.MeasureLayout : undefined, ProjectionNode: combined.ProjectionNode, }; } class AnimationFeature extends motionDom.Feature { /** * We dynamically generate the AnimationState manager as it contains a reference * to the underlying animation library. We only want to load that if we load this, * so people can optionally code split it out using the `m` component. */ constructor(node) { super(node); node.animationState || (node.animationState = motionDom.createAnimationState(node)); } updateAnimationControlsSubscription() { const { animate } = this.node.getProps(); if (motionDom.isAnimationControls(animate)) { this.unmountControls = animate.subscribe(this.node); } } /** * Subscribe any provided AnimationControls to the component's VisualElement */ mount() { this.updateAnimationControlsSubscription(); } update() { const { animate } = this.node.getProps(); const { animate: prevAnimate } = this.node.prevProps || {}; if (animate !== prevAnimate) { this.updateAnimationControlsSubscription(); } } unmount() { this.node.animationState.reset(); this.unmountControls?.(); } } let id = 0; class ExitAnimationFeature extends motionDom.Feature { constructor() { super(...arguments); this.id = id++; } update() { if (!this.node.presenceContext) return; const { isPresent, onExitComplete } = this.node.presenceContext; const { isPresent: prevIsPresent } = this.node.prevPresenceContext || {}; if (!this.node.animationState || isPresent === prevIsPresent) { return; } const exitAnimation = this.node.animationState.setActive("exit", !isPresent); if (onExitComplete && !isPresent) { exitAnimation.then(() => { onExitComplete(this.id); }); } } mount() { const { register, onExitComplete } = this.node.presenceContext || {}; if (onExitComplete) { onExitComplete(this.id); } if (register) { this.unmount = register(this.id); } } unmount() { } } const animations = { animation: { Feature: AnimationFeature, }, exit: { Feature: ExitAnimationFeature, }, }; function extractEventInfo(event) { return { point: { x: event.pageX, y: event.pageY, }, }; } const addPointerInfo = (handler) => { return (event) => motionDom.isPrimaryPointer(event) && handler(event, extractEventInfo(event)); }; function addPointerEvent(target, eventName, handler, options) { return motionDom.addDomEvent(target, eventName, addPointerInfo(handler), options); } // Fixes https://github.com/motiondivision/motion/issues/2270 const getContextWindow = ({ current }) => { return current ? current.ownerDocument.defaultView : null; }; const distance = (a, b) => Math.abs(a - b); function distance2D(a, b) { // Multi-dimensional const xDelta = distance(a.x, b.x); const yDelta = distance(a.y, b.y); return Math.sqrt(xDelta ** 2 + yDelta ** 2); } const overflowStyles = /*#__PURE__*/ new Set(["auto", "scroll"]); /** * @internal */ class PanSession { constructor(event, handlers, { transformPagePoint, contextWindow = window, dragSnapToOrigin = false, distanceThreshold = 3, element, } = {}) { /** * @internal */ this.startEvent = null; /** * @internal */ this.lastMoveEvent = null; /** * @internal */ this.lastMoveEventInfo = null; /** * @internal */ this.handlers = {}; /** * @internal */ this.contextWindow = window; /** * Scroll positions of scrollable ancestors and window. * @internal */ this.scrollPositions = new Map(); /** * Cleanup function for scroll listeners. * @internal */ this.removeScrollListeners = null; this.onElementScroll = (event) => { this.handleScroll(event.target); }; this.onWindowScroll = () => { this.handleScroll(window); }; this.updatePoint = () => { if (!(this.lastMoveEvent && this.lastMoveEventInfo)) return; const info = getPanInfo(this.lastMoveEventInfo, this.history); const isPanStarted = this.startEvent !== null; // Only start panning if the offset is larger than 3 pixels. If we make it // any larger than this we'll want to reset the pointer history // on the first update to avoid visual snapping to the cursor. const isDistancePastThreshold = distance2D(info.offset, { x: 0, y: 0 }) >= this.distanceThreshold; if (!isPanStarted && !isDistancePastThreshold) return; const { point } = info; const { timestamp } = motionDom.frameData; this.history.push({ ...point, timestamp }); const { onStart, onMove } = this.handlers; if (!isPanStarted) { onStart && onStart(this.lastMoveEvent, info); this.startEvent = this.lastMoveEvent; } onMove && onMove(this.lastMoveEvent, info); }; this.handlePointerMove = (event, info) => { this.lastMoveEvent = event; this.lastMoveEventInfo = transformPoint(info, this.transformPagePoint); // Throttle mouse move event to once per frame motionDom.frame.update(this.updatePoint, true); }; this.handlePointerUp = (event, info) => { this.end(); const { onEnd, onSessionEnd, resumeAnimation } = this.handlers; // Resume animation if dragSnapToOrigin is set OR if no drag started (user just clicked) // This ensures constraint animations continue when interrupted by a click if (this.dragSnapToOrigin || !this.startEvent) { resumeAnimation && resumeAnimation(); } if (!(this.lastMoveEvent && this.lastMoveEventInfo)) return; const panInfo = getPanInfo(event.type === "pointercancel" ? this.lastMoveEventInfo : transformPoint(info, this.transformPagePoint), this.history); if (this.startEvent && onEnd) { onEnd(event, panInfo); } onSessionEnd && onSessionEnd(event, panInfo); }; // If we have more than one touch, don't start detecting this gesture if (!motionDom.isPrimaryPointer(event)) return; this.dragSnapToOrigin = dragSnapToOrigin; this.handlers = handlers; this.transformPagePoint = transformPagePoint; this.distanceThreshold = distanceThreshold; this.contextWindow = contextWindow || window; const info = extractEventInfo(event); const initialInfo = transformPoint(info, this.transformPagePoint); const { point } = initialInfo; const { timestamp } = motionDom.frameData; this.history = [{ ...point, timestamp }]; const { onSessionStart } = handlers; onSessionStart && onSessionStart(event, getPanInfo(initialInfo, this.history)); this.removeListeners = motionUtils.pipe(addPointerEvent(this.contextWindow, "pointermove", this.handlePointerMove), addPointerEvent(this.contextWindow, "pointerup", this.handlePointerUp), addPointerEvent(this.contextWindow, "pointercancel", this.handlePointerUp)); // Start scroll tracking if element provided if (element) { this.startScrollTracking(element); } } /** * Start tracking scroll on ancestors and window. */ startScrollTracking(element) { // Store initial scroll positions for scrollable ancestors let current = element.parentElement; while (current) { const style = getComputedStyle(current); if (overflowStyles.has(style.overflowX) || overflowStyles.has(style.overflowY)) { this.scrollPositions.set(current, { x: current.scrollLeft, y: current.scrollTop, }); } current = current.parentElement; } // Track window scroll this.scrollPositions.set(window, { x: window.scrollX, y: window.scrollY, }); // Capture listener catches element scroll events as they bubble window.addEventListener("scroll", this.onElementScroll, { capture: true, passive: true, }); // Direct window scroll listener (window scroll doesn't bubble) window.addEventListener("scroll", this.onWindowScroll, { passive: true, }); this.removeScrollListeners = () => { window.removeEventListener("scroll", this.onElementScroll, { capture: true, }); window.removeEventListener("scroll", this.onWindowScroll); }; } /** * Handle scroll compensation during drag. * * For element scroll: adjusts history origin since pageX/pageY doesn't change. * For window scroll: adjusts lastMoveEventInfo since pageX/pageY would change. */ handleScroll(target) { const initial = this.scrollPositions.get(target); if (!initial) return; const isWindow = target === window; const current = isWindow ? { x: window.scrollX, y: window.scrollY } : { x: target.scrollLeft, y: target.scrollTop, }; const delta = { x: current.x - initial.x, y: current.y - initial.y }; if (delta.x === 0 && delta.y === 0) return; if (isWindow) { // Window scroll: pageX/pageY changes, so update lastMoveEventInfo if (this.lastMoveEventInfo) { this.lastMoveEventInfo.point.x += delta.x; this.lastMoveEventInfo.point.y += delta.y; } } else { // Element scroll: pageX/pageY unchanged, so adjust history origin if (this.history.length > 0) { this.history[0].x -= delta.x; this.history[0].y -= delta.y; } } this.scrollPositions.set(target, current); motionDom.frame.update(this.updatePoint, true); } updateHandlers(handlers) { this.handlers = handlers; } end() { this.removeListeners && this.removeListeners(); this.removeScrollListeners && this.removeScrollListeners(); this.scrollPositions.clear(); motionDom.cancelFrame(this.updatePoint); } } function transformPoint(info, transformPagePoint) { return transformPagePoint ? { point: transformPagePoint(info.point) } : info; } function subtractPoint(a, b) { return { x: a.x - b.x, y: a.y - b.y }; } function getPanInfo({ point }, history) { return { point, delta: subtractPoint(point, lastDevicePoint(history)), offset: subtractPoint(point, startDevicePoint(history)), velocity: getVelocity(history, 0.1), }; } function startDevicePoint(history) { return history[0]; } function lastDevicePoint(history) { return history[history.length - 1]; } function getVelocity(history, timeDelta) { if (history.length < 2) { return { x: 0, y: 0 }; } let i = history.length - 1; let timestampedPoint = null; const lastPoint = lastDevicePoint(history); while (i >= 0) { timestampedPoint = history[i]; if (lastPoint.timestamp - timestampedPoint.timestamp > motionUtils.secondsToMilliseconds(timeDelta)) { break; } i--; } if (!timestampedPoint) { return { x: 0, y: 0 }; } /** * If the selected point is the pointer-down origin (history[0]), * there are better movement points available, and the time gap * is suspiciously large (>2x timeDelta), use the next point instead. * This prevents stale pointer-down points from diluting velocity * in hold-then-flick gestures. */ if (timestampedPoint === history[0] && history.length > 2 && lastPoint.timestamp - timestampedPoint.timestamp > motionUtils.secondsToMilliseconds(timeDelta) * 2) { timestampedPoint = history[1]; } const time = motionUtils.millisecondsToSeconds(lastPoint.timestamp - timestampedPoint.timestamp); if (time === 0) { return { x: 0, y: 0 }; } const currentVelocity = { x: (lastPoint.x - timestampedPoint.x) / time, y: (lastPoint.y - timestampedPoint.y) / time, }; if (currentVelocity.x === Infinity) { currentVelocity.x = 0; } if (currentVelocity.y === Infinity) { currentVelocity.y = 0; } return currentVelocity; } /** * Apply constraints to a point. These constraints are both physical along an * axis, and an elastic factor that determines how much to constrain the point * by if it does lie outside the defined parameters. */ function applyConstraints(point, { min, max }, elastic) { if (min !== undefined && point < min) { // If we have a min point defined, and this is outside of that, constrain point = elastic ? motionDom.mixNumber(min, point, elastic.min) : Math.max(point, min); } else if (max !== undefined && point > max) { // If we have a max point defined, and this is outside of that, constrain point = elastic ? motionDom.mixNumber(max, point, elastic.max) : Math.min(point, max); } return point; } /** * Calculate constraints in terms of the viewport when defined relatively to the * measured axis. This is measured from the nearest edge, so a max constraint of 200 * on an axis with a max value of 300 would return a constraint of 500 - axis length */ function calcRelativeAxisConstraints(axis, min, max) { return { min: min !== undefined ? axis.min + min : undefined, max: max !== undefined ? axis.max + max - (axis.max - axis.min) : undefined, }; } /** * Calculate constraints in terms of the viewport when * defined relatively to the measured bounding box. */ function calcRelativeConstraints(layoutBox, { top, left, bottom, right }) { return { x: calcRelativeAxisConstraints(layoutBox.x, left, right), y: calcRelativeAxisConstraints(layoutBox.y, top, bottom), }; } /** * Calculate viewport constraints when defined as another viewport-relative axis */ function calcViewportAxisConstraints(layoutAxis, constraintsAxis) { let min = constraintsAxis.min - layoutAxis.min; let max = constraintsAxis.max - layoutAxis.max; // If the constraints axis is actually smaller than the layout axis then we can // flip the constraints if (constraintsAxis.max - constraintsAxis.min < layoutAxis.max - layoutAxis.min) { [min, max] = [max, min]; } return { min, max }; } /** * Calculate viewport constraints when defined as another viewport-relative box */ function calcViewportConstraints(layoutBox, constraintsBox) { return { x: calcViewportAxisConstraints(layoutBox.x, constraintsBox.x), y: calcViewportAxisConstraints(layoutBox.y, constraintsBox.y), }; } /** * Calculate a transform origin relative to the source axis, between 0-1, that results * in an asthetically pleasing scale/transform needed to project from source to target. */ function calcOrigin(source, target) { let origin = 0.5; const sourceLength = motionDom.calcLength(source); const targetLength = motionDom.calcLength(target); if (targetLength > sourceLength) { origin = motionUtils.progress(target.min, target.max - sourceLength, source.min); } else if (sourceLength > targetLength) { origin = motionUtils.progress(source.min, source.max - targetLength, target.min); } return motionUtils.clamp(0, 1, origin); } /** * Rebase the calculated viewport constraints relative to the layout.min point. */ function rebaseAxisConstraints(layout, constraints) { const relativeConstraints = {}; if (constraints.min !== undefined) { relativeConstraints.min = constraints.min - layout.min; } if (constraints.max !== undefined) { relativeConstraints.max = constraints.max - layout.min; } return relativeConstraints; } const defaultElastic = 0.35; /** * Accepts a dragElastic prop and returns resolved elastic values for each axis. */ function resolveDragElastic(dragElastic = defaultElastic) { if (dragElastic === false) { dragElastic = 0; } else if (dragElastic === true) { dragElastic = defaultElastic; } return { x: resolveAxisElastic(dragElastic, "left", "right"), y: resolveAxisElastic(dragElastic, "top", "bottom"), }; } function resolveAxisElastic(dragElastic, minLabel, maxLabel) { return { min: resolvePointElastic(dragElastic, minLabel), max: resolvePointElastic(dragElastic, maxLabel), }; } function resolvePointElastic(dragElastic, label) { return typeof dragElastic === "number" ? dragElastic : dragElastic[label] || 0; } const elementDragControls = new WeakMap(); class VisualElementDragControls { constructor(visualElement) { this.openDragLock = null; this.isDragging = false; this.currentDirection = null; this.originPoint = { x: 0, y: 0 }; /** * The permitted boundaries of travel, in pixels. */ this.constraints = false; this.hasMutatedConstraints = false; /** * The per-axis resolved elastic values. */ this.elastic = motionDom.createBox(); /** * The latest pointer event. Used as fallback when the `cancel` and `stop` functions are called without arguments. */ this.latestPointerEvent = null; /** * The latest pan info. Used as fallback when the `cancel` and `stop` functions are called without arguments. */ this.latestPanInfo = null; this.visualElement = visualElement; } start(originEvent, { snapToCursor = false, distanceThreshold } = {}) { /** * Don't start dragging if this component is exiting */ const { presenceContext } = this.visualElement; if (presenceContext && presenceContext.isPresent === false) return; const onSessionStart = (event) => { if (snapToCursor) { this.snapToCursor(extractEventInfo(event).point); } this.stopAnimation(); }; const onStart = (event, info) => { // Attempt to grab the global drag gesture lock - maybe make this part of PanSession const { drag, dragPropagation, onDragStart } = this.getProps(); if (drag && !dragPropagation) { if (this.openDragLock) this.openDragLock(); this.openDragLock = motionDom.setDragLock(drag); // If we don 't have the lock, don't start dragging if (!this.openDragLock) return; } this.latestPointerEvent = event; this.latestPanInfo = info; this.isDragging = true; this.currentDirection = null; this.resolveConstraints(); if (this.visualElement.projection) { this.visualElement.projection.isAnimationBlocked = true; this.visualElement.projection.target = undefined; } /** * Record gesture origin and pointer offset */ motionDom.eachAxis((axis) => { let current = this.getAxisMotionValue(axis).get() || 0; /** * If the MotionValue is a percentage value convert to px */ if (motionDom.percent.test(current)) { const { projection } = this.visualElement; if (projection && projection.layout) { const measuredAxis = projection.layout.layoutBox[axis]; if (measuredAxis) { const length = motionDom.calcLength(measuredAxis); current = length * (parseFloat(current) / 100); } } } this.originPoint[axis] = current; }); // Fire onDragStart event if (onDragStart) { motionDom.frame.update(() => onDragStart(event, info), false, true); } motionDom.addValueToWillChange(this.visualElement, "transform"); const { animationState } = this.visualElement; animationState && animationState.setActive("whileDrag", true); }; const onMove = (event, info) => { this.latestPointerEvent = event; this.latestPanInfo = info; const { dragPropagation, dragDirectionLock, onDirectionLock, onDrag, } = this.getProps(); // If we didn't successfully receive the gesture lock, early return. if (!dragPropagation && !this.openDragLock) return; const { offset } = info; // Attempt to detect drag direction if directionLock is true if (dragDirectionLock && this.currentDirection === null) { this.currentDirection = getCurrentDirection(offset); // If we've successfully set a direction, notify listener if (this.currentDirection !== null) { onDirectionLock && onDirectionLock(this.currentDirection); } return; } // Update each point with the latest position this.updateAxis("x", info.point, offset); this.updateAxis("y", info.point, offset); /** * Ideally we would leave the renderer to fire naturally at the end of * this frame but if the element is about to change layout as the result * of a re-render we want to ensure the browser can read the latest * bounding box to ensure the pointer and element don't fall out of sync. */ this.visualElement.render(); /** * This must fire after the render call as it might trigger a state * change which itself might trigger a layout update. */ if (onDrag) { motionDom.frame.update(() => onDrag(event, info), false, true); } }; const onSessionEnd = (event, info) => { this.latestPointerEvent = event; this.latestPanInfo = info; this.stop(event, info); this.latestPointerEvent = null; this.latestPanInfo = null; }; const resumeAnimation = () => { const { dragSnapToOrigin: snap } = this.getProps(); if (snap || this.constraints) { this.startAnimation({ x: 0, y: 0 }); } }; const { dragSnapToOrigin } = this.getProps(); this.panSession = new PanSession(originEvent, { onSessionStart, onStart, onMove, onSessionEnd, resumeAnimation, }, { transformPagePoint: this.visualElement.getTransformPagePoint(), dragSnapToOrigin, distanceThreshold, contextWindow: getContextWindow(this.visualElement), element: this.visualElement.current, }); } /** * @internal */ stop(event, panInfo) { const finalEvent = event || this.latestPointerEvent; const finalPanInfo = panInfo || this.latestPanInfo; const isDragging = this.isDragging; this.cancel(); if (!isDragging || !finalPanInfo || !finalEvent) return; const { velocity } = finalPanInfo; this.startAnimation(velocity); const { onDragEnd } = this.getProps(); if (onDragEnd) { motionDom.frame.postRender(() => onDragEnd(finalEvent, finalPanInfo)); } } /** * @internal */ cancel() { this.isDragging = false; const { projection, animationState } = this.visualElement; if (projection) { projection.isAnimationBlocked = false; } this.endPanSession(); const { dragPropagation } = this.getProps(); if (!dragPropagation && this.openDragLock) { this.openDragLock(); this.openDragLock = null; } animationState && animationState.setActive("whileDrag", false); } /** * Clean up the pan session without modifying other drag state. * This is used during unmount to ensure event listeners are removed * without affecting projection animations or drag locks. * @internal */ endPanSession() { this.panSession && this.panSession.end(); this.panSession = undefined; } updateAxis(axis, _point, offset) { const { drag } = this.getProps(); // If we're not dragging this axis, do an early return. if (!offset || !shouldDrag(axis, drag, this.currentDirection)) return; const axisValue = this.getAxisMotionValue(axis); let next = this.originPoint[axis] + offset[axis]; // Apply constraints if (this.constraints && this.constraints[axis]) { next = applyConstraints(next, this.constraints[axis], this.elastic[axis]); } axisValue.set(next); } resolveConstraints() { const { dragConstraints, dragElastic } = this.getProps(); const layout = this.visualElement.projection && !this.visualElement.projection.layout ? this.visualElement.projection.measure(false) : this.visualElement.projection?.layout; const prevConstraints = this.constraints; if (dragConstraints && isRefObject(dragConstraints)) { if (!this.constraints) { this.constraints = this.resolveRefConstraints(); } } else { if (dragConstraints && layout) { this.constraints = calcRelativeConstraints(layout.layoutBox, dragConstraints); } else { this.constraints = false; } } this.elastic = resolveDragElastic(dragElastic); /** * If we're outputting to external MotionValues, we want to rebase the measured constraints * from viewport-relative to component-relative. This only applies to relative (non-ref) * constraints, as ref-based constraints from calcViewportConstraints are already in the * correct coordinate space for the motion value transform offset. */ if (prevConstraints !== this.constraints && !isRefObject(dragConstraints) && layout && this.constraints && !this.hasMutatedConstraints) { motionDom.eachAxis((axis) => { if (this.constraints !== false && this.getAxisMotionValue(axis)) { this.constraints[axis] = rebaseAxisConstraints(layout.layoutBox[axis], this.constraints[axis]); } }); } } resolveRefConstraints() { const { dragConstraints: constraints, onMeasureDragConstraints } = this.getProps(); if (!constraints || !isRefObject(constraints)) return false; const constraintsElement = constraints.current; motionUtils.invariant(constraintsElement !== null, "If `dragConstraints` is set as a React ref, that ref must be passed to another component's `ref` prop.", "drag-constraints-ref"); const { projection } = this.visualElement; // TODO if (!projection || !projection.layout) return false; const constraintsBox = motionDom.measurePageBox(constraintsElement, projection.root, this.visualElement.getTransformPagePoint()); let measuredConstraints = calcViewportConstraints(projection.layout.layoutBox, constraintsBox); /** * If there's an onMeasureDragConstraints listener we call it and * if different constraints are returned, set constraints to that */ if (onMeasureDragConstraints) { const userConstraints = onMeasureDragConstraints(motionDom.convertBoxToBoundingBox(measuredConstraints)); this.hasMutatedConstraints = !!userConstraints; if (userConstraints) { measuredConstraints = motionDom.convertBoundingBoxToBox(userConstraints); } } return measuredConstraints; } startAnimation(velocity) { const { drag, dragMomentum, dragElastic, dragTransition, dragSnapToOrigin, onDragTransitionEnd, } = this.getProps(); const constraints = this.constraints || {}; const momentumAnimations = motionDom.eachAxis((axis) => { if (!shouldDrag(axis, drag, this.currentDirection)) { return; } let transition = (constraints && constraints[axis]) || {}; if (dragSnapToOrigin) transition = { min: 0, max: 0 }; /** * Overdamp the boundary spring if `dragElastic` is disabled. There's still a frame * of spring animations so we should look into adding a disable spring option to `inertia`. * We could do something here where we affect the `bounceStiffness` and `bounceDamping` * using the value of `dragElastic`. */ const bounceStiffness = dragElastic ? 200 : 1000000; const bounceDamping = dragElastic ? 40 : 10000000; const inertia = { type: "inertia", velocity: dragMomentum ? velocity[axis] : 0, bounceStiffness, bounceDamping, timeConstant: 750, restDelta: 1, restSpeed: 10, ...dragTransition, ...transition, }; // If we're not animating on an externally-provided `MotionValue` we can use the // component's animation controls which will handle interactions with whileHover (etc), // otherwise we just have to animate the `MotionValue` itself. return this.startAxisValueAnimation(axis, inertia); }); // Run all animations and then resolve the new drag constraints. return Promise.all(momentumAnimations).then(onDragTransitionEnd); } startAxisValueAnimation(axis, transition) { const axisValue = this.getAxisMotionValue(axis); motionDom.addValueToWillChange(this.visualElement, axis); return axisValue.start(motionDom.animateMotionValue(axis, axisValue, 0, transition, this.visualElement, false)); } stopAnimation() { motionDom.eachAxis((axis) => this.getAxisMotionValue(axis).stop()); } /** * Drag works differently depending on which props are provided. * * - If _dragX and _dragY are provided, we output the gesture delta directly to those motion values. * - Otherwise, we apply the delta to the x/y motion values. */ getAxisMotionValue(axis) { const dragKey = `_drag${axis.toUpperCase()}`; const props = this.visualElement.getProps(); const externalMotionValue = props[dragKey]; return externalMotionValue ? externalMotionValue : this.visualElement.getValue(axis, (props.initial ? props.initial[axis] : undefined) || 0); } snapToCursor(point) { motionDom.eachAxis((axis) => { const { drag } = this.getProps(); // If we're not dragging this axis, do an early return. if (!shouldDrag(axis, drag, this.currentDirection)) return; const { projection } = this.visualElement; const axisValue = this.getAxisMotionValue(axis); if (projection && projection.layout) { const { min, max } = projection.layout.layoutBox[axis]; /** * The layout measurement includes the current transform value, * so we need to add it back to get the correct snap position. * This fixes an issue where elements with initial coordinates * would snap to the wrong position on the first drag. */ const current = axisValue.get() || 0; axisValue.set(point[axis] - motionDom.mixNumber(min, max, 0.5) + current); } }); } /** * When the viewport resizes we want to check if the measured constraints * have changed and, if so, reposition the element within those new constraints * relative to where it was before the resize. */ scalePositionWithinConstraints() { if (!this.visualElement.current) return; const { drag, dragConstraints } = this.getProps(); const { projection } = this.visualElement; if (!isRefObject(dragConstraints) || !projection || !this.constraints) return; /** * Stop current animations as there can be visual glitching if we try to do * this mid-animation */ this.stopAnimation(); /** * Record the relative position of the dragged element relative to the * constraints box and save as a progress value. */ const boxProgress = { x: 0, y: 0 }; motionDom.eachAxis((axis) => { const axisValue = this.getAxisMotionValue(axis); if (axisValue && this.constraints !== false) { const latest = axisValue.get(); boxProgress[axis] = calcOrigin({ min: latest, max: latest }, this.constraints[axis]); } }); /** * Update the layout of this element and resolve the latest drag constraints */ const { transformTemplate } = this.visualElement.getProps(); this.visualElement.current.style.transform = transformTemplate ? transformTemplate({}, "") : "none"; projection.root && projection.root.updateScroll(); projection.updateLayout(); /** * Reset constraints so resolveConstraints() will recalculate them * with the freshly measured layout rather than returning the cached value. */ this.constraints = false; this.resolveConstraints(); /** * For each axis, calculate the current progress of the layout axis * within the new constraints. */ motionDom.eachAxis((axis) => { if (!shouldDrag(axis, drag, null)) return; /** * Calculate a new transform based on the previous box progress */ const axisValue = this.getAxisMotionValue(axis); const { min, max } = this.constraints[axis]; axisValue.set(motionDom.mixNumber(min, max, boxProgress[axis])); }); /** * Flush the updated transform to the DOM synchronously to prevent * a visual flash at the element's CSS layout position (0,0) when * the transform was stripped for measurement. */ this.visualElement.render(); } addListeners() { if (!this.visualElement.current) return; elementDragControls.set(this.visualElement, this); const element = this.visualElement.current; /** * Attach a pointerdown event listener on this DOM element to initiate drag tracking. */ const stopPointerListener = addPointerEvent(element, "pointerdown", (event) => { const { drag, dragListener = true } = this.getProps(); const target = event.target; /** * Only block drag if clicking on a text input child element * (input, textarea, select, contenteditable) where users might * want to select text or interact with the control. * * Buttons and links don't block drag since they don't have * click-and-move actions of their own. */ const isClickingTextInputChild = target !== element && motionDom.isElementTextInput(target); if (drag && dragListener && !isClickingTextInputChild) { this.start(event); } }); /** * If using ref-based constraints, observe both the draggable element * and the constraint container for size changes via ResizeObserver. * Setup is deferred because dragConstraints.current is null when * addListeners first runs (React hasn't committed the ref yet). */ let stopResizeObservers; const measureDragConstraints = () => { const { dragConstraints } = this.getProps(); if (isRefObject(dragConstraints) && dragConstraints.current) { this.constraints = this.resolveRefConstraints(); if (!stopResizeObservers) { stopResizeObservers = startResizeObservers(element, dragConstraints.current, () => this.scalePositionWithinConstraints()); } } }; const { projection } = this.visualElement; const stopMeasureLayoutListener = projection.addEventListener("measure", measureDragConstraints); if (projection && !projection.layout) { projection.root && projection.root.updateScroll(); projection.updateLayout(); } motionDom.frame.read(measureDragConstraints); /** * Attach a window resize listener to scale the draggable target within its defined * constraints as the window resizes. */ const stopResizeListener = motionDom.addDomEvent(window, "resize", () => this.scalePositionWithinConstraints()); /** * If the element's layout changes, calculate the delta and apply that to * the drag gesture's origin point. */ const stopLayoutUpdateListener = projection.addEventListener("didUpdate", (({ delta, hasLayoutChanged }) => { if (this.isDragging && hasLayoutChanged) { motionDom.eachAxis((axis) => { const motionValue = this.getAxisMotionValue(axis); if (!motionValue) return; this.originPoint[axis] += delta[axis].translate; motionValue.set(motionValue.get() + delta[axis].translate); }); this.visualElement.render(); } })); return () => { stopResizeListener(); stopPointerListener(); stopMeasureLayoutListener(); stopLayoutUpdateListener && stopLayoutUpdateListener(); stopResizeObservers && stopResizeObservers(); }; } getProps() { const props = this.visualElement.getProps(); const { drag = false, dragDirectionLock = false, dragPropagation = false, dragConstraints = false, dragElastic = defaultElastic, dragMomentum = true, } = props; return { ...props, drag, dragDirectionLock, dragPropagation, dragConstraints, dragElastic, dragMomentum, }; } } function skipFirstCall(callback) { let isFirst = true; return () => { if (isFirst) { isFirst = false; return; } callback(); }; } function startResizeObservers(element, constraintsElement, onResize) { const stopElement = motionDom.resize(element, skipFirstCall(onResize)); const stopContainer = motionDom.resize(constraintsElement, skipFirstCall(onResize)); return () => { stopElement(); stopContainer(); }; } function shouldDrag(direction, drag, currentDirection) { return ((drag === true || drag === direction) && (currentDirection === null || currentDirection === direction)); } /** * Based on an x/y offset determine the current drag direction. If both axis' offsets are lower * than the provided threshold, return `null`. * * @param offset - The x/y offset from origin. * @param lockThreshold - (Optional) - the minimum absolute offset before we can determine a drag direction. */ function getCurrentDirection(offset, lockThreshold = 10) { let direction = null; if (Math.abs(offset.y) > lockThreshold) { direction = "y"; } else if (Math.abs(offset.x) > lockThreshold) { direction = "x"; } return direction; } class DragGesture extends motionDom.Feature { constructor(node) { super(node); this.removeGroupControls = motionUtils.noop; this.removeListeners = motionUtils.noop; this.controls = new VisualElementDragControls(node); } mount() { // If we've been provided a DragControls for manual control over the drag gesture, // subscribe this component to it on mount. const { dragControls } = this.node.getProps(); if (dragControls) { this.removeGroupControls = dragControls.subscribe(this.controls); } this.removeListeners = this.controls.addListeners() || motionUtils.noop; } update() { const { dragControls } = this.node.getProps(); const { dragControls: prevDragControls } = this.node.prevProps || {}; if (dragControls !== prevDragControls) { this.removeGroupControls(); if (dragControls) { this.removeGroupControls = dragControls.subscribe(this.controls); } } } unmount() { this.removeGroupControls(); this.removeListeners(); /** * In React 19, during list reorder reconciliation, components may * briefly unmount and remount while the drag is still active. If we're * actively dragging, we should NOT end the pan session - it will * continue tracking pointer events via its window-level listeners. * * The pan session will be properly cleaned up when: * 1. The drag ends naturally (pointerup/pointercancel) * 2. The component is truly removed from the DOM */ if (!this.controls.isDragging) { this.controls.endPanSession(); } } } const asyncHandler = (handler) => (event, info) => { if (handler) { motionDom.frame.update(() => handler(event, info), false, true); } }; class PanGesture extends motionDom.Feature { constructor() { super(...arguments); this.removePointerDownListener = motionUtils.noop; } onPointerDown(pointerDownEvent) { this.session = new PanSession(pointerDownEvent, this.createPanHandlers(), { transformPagePoint: this.node.getTransformPagePoint(), contextWindow: getContextWindow(this.node), }); } createPanHandlers() { const { onPanSessionStart, onPanStart, onPan, onPanEnd } = this.node.getProps(); return { onSessionStart: asyncHandler(onPanSessionStart), onStart: asyncHandler(onPanStart), onMove: asyncHandler(onPan), onEnd: (event, info) => { delete this.session; if (onPanEnd) { motionDom.frame.postRender(() => onPanEnd(event, info)); } }, }; } mount() { this.removePointerDownListener = addPointerEvent(this.node.current, "pointerdown", (event) => this.onPointerDown(event)); } update() { this.session && this.session.updateHandlers(this.createPanHandlers()); } unmount() { this.removePointerDownListener(); this.session && this.session.end(); } } /** * Track whether we've taken any snapshots yet. If not, * we can safely skip notification of didUpdate. * * Difficult to capture in a test but to prevent flickering * we must set this to true either on update or unmount. * Running `next-env/layout-id` in Safari will show this behaviour if broken. */ let hasTakenAnySnapshot = false; class MeasureLayoutWithContext extends React.Component { /** * This only mounts projection nodes for components that * need measuring, we might want to do it for all components * in order to incorporate transforms */ componentDidMount() { const { visualElement, layoutGroup, switchLayoutGroup, layoutId } = this.props; const { projection } = visualElement; if (projection) { if (layoutGroup.group) layoutGroup.group.add(projection); if (switchLayoutGroup && switchLayoutGroup.register && layoutId) { switchLayoutGroup.register(projection); } if (hasTakenAnySnapshot) { projection.root.didUpdate(); } projection.addEventListener("animationComplete", () => { this.safeToRemove(); }); projection.setOptions({ ...projection.options, layoutDependency: this.props.layoutDependency, onExitComplete: () => this.safeToRemove(), }); } motionDom.globalProjectionState.hasEverUpdated = true; } getSnapshotBeforeUpdate(prevProps) { const { layoutDependency, visualElement, drag, isPresent } = this.props; const { projection } = visualElement; if (!projection) return null; /** * TODO: We use this data in relegate to determine whether to * promote a previous element. There's no guarantee its presence data * will have updated by this point - if a bug like this arises it will * have to be that we markForRelegation and then find a new lead some other way, * perhaps in didUpdate */ projection.isPresent = isPresent; if (prevProps.layoutDependency !== layoutDependency) { projection.setOptions({ ...projection.options, layoutDependency, }); } hasTakenAnySnapshot = true; if (drag || prevProps.layoutDependency !== layoutDependency || layoutDependency === undefined || prevProps.isPresent !== isPresent) { projection.willUpdate(); } else { this.safeToRemove(); } if (prevProps.isPresent !== isPresent) { if (isPresent) { projection.promote(); } else if (!projection.relegate()) { /** * If there's another stack member taking over from this one, * it's in charge of the exit animation and therefore should * be in charge of the safe to remove. Otherwise we call it here. */ motionDom.frame.postRender(() => { const stack = projection.getStack(); if (!stack || !stack.members.length) { this.safeToRemove(); } }); } } return null; } componentDidUpdate() { const { projection } = this.props.visualElement; if (projection) { projection.root.didUpdate(); motionDom.microtask.postRender(() => { if (!projection.currentAnimation && projection.isLead()) { this.safeToRemove(); } }); } } componentWillUnmount() { const { visualElement, layoutGroup, switchLayoutGroup: promoteContext, } = this.props; const { projection } = visualElement; hasTakenAnySnapshot = true; if (projection) { projection.scheduleCheckAfterUnmount(); if (layoutGroup && layoutGroup.group) layoutGroup.group.remove(projection); if (promoteContext && promoteContext.deregister) promoteContext.deregister(projection); } } safeToRemove() { const { safeToRemove } = this.props; safeToRemove && safeToRemove(); } render() { return null; } } function MeasureLayout(props) { const [isPresent, safeToRemove] = usePresence(); const layoutGroup = React.useContext(LayoutGroupContext); return (jsxRuntime.jsx(MeasureLayoutWithContext, { ...props, layoutGroup: layoutGroup, switchLayoutGroup: React.useContext(SwitchLayoutGroupContext), isPresent: isPresent, safeToRemove: safeToRemove })); } const drag = { pan: { Feature: PanGesture, }, drag: { Feature: DragGesture, ProjectionNode: motionDom.HTMLProjectionNode, MeasureLayout, }, }; function handleHoverEvent(node, event, lifecycle) { const { props } = node; if (node.animationState && props.whileHover) { node.animationState.setActive("whileHover", lifecycle === "Start"); } const eventName = ("onHover" + lifecycle); const callback = props[eventName]; if (callback) { motionDom.frame.postRender(() => callback(event, extractEventInfo(event))); } } class HoverGesture extends motionDom.Feature { mount() { const { current } = this.node; if (!current) return; this.unmount = motionDom.hover(current, (_element, startEvent) => { handleHoverEvent(this.node, startEvent, "Start"); return (endEvent) => handleHoverEvent(this.node, endEvent, "End"); }); } unmount() { } } class FocusGesture extends motionDom.Feature { constructor() { super(...arguments); this.isActive = false; } onFocus() { let isFocusVisible = false; /** * If this element doesn't match focus-visible then don't * apply whileHover. But, if matches throws that focus-visible * is not a valid selector then in that browser outline styles will be applied * to the element by default and we want to match that behaviour with whileFocus. */ try { isFocusVisible = this.node.current.matches(":focus-visible"); } catch (e) { isFocusVisible = true; } if (!isFocusVisible || !this.node.animationState) return; this.node.animationState.setActive("whileFocus", true); this.isActive = true; } onBlur() { if (!this.isActive || !this.node.animationState) return; this.node.animationState.setActive("whileFocus", false); this.isActive = false; } mount() { this.unmount = motionUtils.pipe(motionDom.addDomEvent(this.node.current, "focus", () => this.onFocus()), motionDom.addDomEvent(this.node.current, "blur", () => this.onBlur())); } unmount() { } } function handlePressEvent(node, event, lifecycle) { const { props } = node; if (node.current instanceof HTMLButtonElement && node.current.disabled) { return; } if (node.animationState && props.whileTap) { node.animationState.setActive("whileTap", lifecycle === "Start"); } const eventName = ("onTap" + (lifecycle === "End" ? "" : lifecycle)); const callback = props[eventName]; if (callback) { motionDom.frame.postRender(() => callback(event, extractEventInfo(event))); } } class PressGesture extends motionDom.Feature { mount() { const { current } = this.node; if (!current) return; const { globalTapTarget, propagate } = this.node.props; this.unmount = motionDom.press(current, (_element, startEvent) => { handlePressEvent(this.node, startEvent, "Start"); return (endEvent, { success }) => handlePressEvent(this.node, endEvent, success ? "End" : "Cancel"); }, { useGlobalTarget: globalTapTarget, stopPropagation: propagate?.tap === false, }); } unmount() { } } /** * Map an IntersectionHandler callback to an element. We only ever make one handler for one * element, so even though these handlers might all be triggered by different * observers, we can keep them in the same map. */ const observerCallbacks = new WeakMap(); /** * Multiple observers can be created for multiple element/document roots. Each with * different settings. So here we store dictionaries of observers to each root, * using serialised settings (threshold/margin) as lookup keys. */ const observers = new WeakMap(); const fireObserverCallback = (entry) => { const callback = observerCallbacks.get(entry.target); callback && callback(entry); }; const fireAllObserverCallbacks = (entries) => { entries.forEach(fireObserverCallback); }; function initIntersectionObserver({ root, ...options }) { const lookupRoot = root || document; /** * If we don't have an observer lookup map for this root, create one. */ if (!observers.has(lookupRoot)) { observers.set(lookupRoot, {}); } const rootObservers = observers.get(lookupRoot); const key = JSON.stringify(options); /** * If we don't have an observer for this combination of root and settings, * create one. */ if (!rootObservers[key]) { rootObservers[key] = new IntersectionObserver(fireAllObserverCallbacks, { root, ...options }); } return rootObservers[key]; } function observeIntersection(element, options, callback) { const rootInteresectionObserver = initIntersectionObserver(options); observerCallbacks.set(element, callback); rootInteresectionObserver.observe(element); return () => { observerCallbacks.delete(element); rootInteresectionObserver.unobserve(element); }; } const thresholdNames = { some: 0, all: 1, }; class InViewFeature extends motionDom.Feature { constructor() { super(...arguments); this.hasEnteredView = false; this.isInView = false; } startObserver() { this.unmount(); const { viewport = {} } = this.node.getProps(); const { root, margin: rootMargin, amount = "some", once } = viewport; const options = { root: root ? root.current : undefined, rootMargin, threshold: typeof amount === "number" ? amount : thresholdNames[amount], }; const onIntersectionUpdate = (entry) => { const { isIntersecting } = entry; /** * If there's been no change in the viewport state, early return. */ if (this.isInView === isIntersecting) return; this.isInView = isIntersecting; /** * Handle hasEnteredView. If this is only meant to run once, and * element isn't visible, early return. Otherwise set hasEnteredView to true. */ if (once && !isIntersecting && this.hasEnteredView) { return; } else if (isIntersecting) { this.hasEnteredView = true; } if (this.node.animationState) { this.node.animationState.setActive("whileInView", isIntersecting); } /** * Use the latest committed props rather than the ones in scope * when this observer is created */ const { onViewportEnter, onViewportLeave } = this.node.getProps(); const callback = isIntersecting ? onViewportEnter : onViewportLeave; callback && callback(entry); }; return observeIntersection(this.node.current, options, onIntersectionUpdate); } mount() { this.startObserver(); } update() { if (typeof IntersectionObserver === "undefined") return; const { props, prevProps } = this.node; const hasOptionsChanged = ["amount", "margin", "root"].some(hasViewportOptionChanged(props, prevProps)); if (hasOptionsChanged) { this.startObserver(); } } unmount() { } } function hasViewportOptionChanged({ viewport = {} }, { viewport: prevViewport = {} } = {}) { return (name) => viewport[name] !== prevViewport[name]; } const gestureAnimations = { inView: { Feature: InViewFeature, }, tap: { Feature: PressGesture, }, focus: { Feature: FocusGesture, }, hover: { Feature: HoverGesture, }, }; const layout = { layout: { ProjectionNode: motionDom.HTMLProjectionNode, MeasureLayout, }, }; const featureBundle = { ...animations, ...gestureAnimations, ...drag, ...layout, }; exports.LayoutGroupContext = LayoutGroupContext; exports.LazyContext = LazyContext; exports.MotionConfigContext = MotionConfigContext; exports.MotionContext = MotionContext; exports.PresenceContext = PresenceContext; exports.SwitchLayoutGroupContext = SwitchLayoutGroupContext; exports.addPointerEvent = addPointerEvent; exports.addPointerInfo = addPointerInfo; exports.animations = animations; exports.createDomVisualElement = createDomVisualElement; exports.createMotionComponent = createMotionComponent; exports.distance = distance; exports.distance2D = distance2D; exports.drag = drag; exports.featureBundle = featureBundle; exports.filterProps = filterProps; exports.gestureAnimations = gestureAnimations; exports.isBrowser = isBrowser; exports.isValidMotionProp = isValidMotionProp; exports.layout = layout; exports.loadExternalIsValidProp = loadExternalIsValidProp; exports.loadFeatures = loadFeatures; exports.makeUseVisualState = makeUseVisualState; exports.motionComponentSymbol = motionComponentSymbol; exports.useConstant = useConstant; exports.useIsPresent = useIsPresent; exports.useIsomorphicLayoutEffect = useIsomorphicLayoutEffect; exports.usePresence = usePresence; //# sourceMappingURL=feature-bundle-Cb13qNcx.js.map