UNPKG

framer-motion

Version:

A simple and powerful JavaScript animation library

1,378 lines (1,335 loc) • 85.2 kB
'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 <div /> * } * ``` * * If `isPresent` is `false`, it means that a component has been removed 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 <div /> * } * ``` * * @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", "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 reducedMotionConfig = React.useContext(MotionConfigContext).reducedMotion; const visualElementRef = React.useRef(null); /** * 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, isSVG, }); } 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(() => { 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 }; } 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) => { // Stop or pause animations based on context: // - snapToCursor: stop because we'll set new position values // - otherwise: pause to allow resume if no drag starts (for constraint animations) if (snapToCursor) { this.stopAnimation(); this.snapToCursor(extractEventInfo(event).point); } else { this.pauseAnimation(); } }; const onStart = (event, info) => { // Stop any paused animation so motion values reflect true current position // (pauseAnimation was called in onSessionStart to allow resume if no drag started) this.stopAnimation(); // 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 offs