UNPKG

framer-motion

Version:

A simple and powerful JavaScript animation library

1,269 lines (1,253 loc) 63.6 kB
'use strict'; var motionDom = require('motion-dom'); var React = require('react'); var index = require('./index-6W16WHlG.js'); var motionUtils = require('motion-utils'); var jsxRuntime = require('react/jsx-runtime'); /** * 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 from the tree, * but `AnimatePresence` won't really remove it until `safeToRemove` has been called. * * @public */ function usePresence(subscribe = true) { const context = React.useContext(index.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(index.PresenceContext)); } function isPresent(context) { return context === null ? true : context.isPresent; } const createDomVisualElement = (Component, options) => { /** * Use explicit isSVG override if provided, otherwise auto-detect */ const isSVG = options.isSVG ?? index.isSVGComponent(Component); return isSVG ? new motionDom.SVGVisualElement(options) : new motionDom.HTMLVisualElement(options, { allowProjection: Component !== React.Fragment, }); }; 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++; this.isExitComplete = false; } 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; } if (isPresent && prevIsPresent === false) { /** * When re-entering, if the exit animation already completed * (element is at rest), reset to initial values so the enter * animation replays from the correct position. */ if (this.isExitComplete) { const { initial, custom } = this.node.getProps(); if (typeof initial === "string" || (typeof initial === "object" && initial !== null && !Array.isArray(initial))) { const resolved = motionDom.resolveVariant(this.node, initial, custom); if (resolved) { const { transition, transitionEnd, ...target } = resolved; for (const key in target) { this.node .getValue(key) ?.jump(target[key]); } } } this.node.animationState.reset(); this.node.animationState.animateChanges(); } else { this.node.animationState.setActive("exit", false); } this.isExitComplete = false; return; } const exitAnimation = this.node.animationState.setActive("exit", !isPresent); if (onExitComplete && !isPresent) { exitAnimation.then(() => { this.isExitComplete = true; 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) => (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; /** * Raw (untransformed) event info, re-transformed each frame * so transformPagePoint sees the current parent matrix. * @internal */ this.lastRawMoveEventInfo = 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; // Re-transform raw point through current transformPagePoint so // animated parent transforms (e.g. rotation) are picked up each frame if (this.lastRawMoveEventInfo) { this.lastMoveEventInfo = transformPoint(this.lastRawMoveEventInfo, this.transformPagePoint); } 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.lastRawMoveEventInfo = info; 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, }); // Direct window scroll listener (window scroll doesn't bubble) window.addEventListener("scroll", this.onWindowScroll); 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 && index.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 && !index.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 || !index.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; /** * Refresh the root scroll offset so the constraint's viewport box * translates to correct page coordinates. The scroll captured at * drag mount can be stale if the document was scrolled afterwards — * e.g. via the browser restoring scroll on refresh, or an ancestor * layout effect running after this element's mount (#2829). * * Clear the cached scroll first so `updateScroll` bypasses its * per-animationId cache and re-reads the live value. */ if (projection.root) { projection.root.scroll = undefined; projection.root.updateScroll(); } 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 === true || dragSnapToOrigin === axis) 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, this.visualElement.latestValues[axis] ?? 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 (!index.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 (index.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 noti