import { getContextWindow } from "../../../utils/get-context-window.mjs"; import { addPointerEvent } from "../../../events/add-pointer-event.mjs"; import { PanSession } from "../pan/PanSession.mjs"; import { applyConstraints, calcOrigin, calcRelativeConstraints, calcViewportConstraints, defaultElastic, rebaseAxisConstraints, resolveDragElastic } from "./utils/constraints.mjs"; import { addDomEvent, addValueToWillChange, animateMotionValue, calcLength, convertBoundingBoxToBox, convertBoxToBoundingBox, createBox, eachAxis, frame, isElementTextInput, isHTMLElement, measurePageBox, mixNumber, percent, resize, setDragLock } from "motion-dom"; import { invariant } from "hey-listen"; //#region src/features/gestures/drag/VisualElementDragControls.ts var elementDragControls = /* @__PURE__ */ new WeakMap(); var VisualElementDragControls = class { constructor(state) { this.openGlobalLock = null; this.isDragging = false; this.currentDirection = null; this.originPoint = { x: 0, y: 0 }; this.constraints = false; this.hasMutatedConstraints = false; this.elastic = createBox(); this.latestPointerEvent = null; this.latestPanInfo = null; this.state = state; } get visualElement() { return this.state.visualElement; } start(originEvent, { snapToCursor = false, distanceThreshold } = {}) { /** * Don't start dragging if this component is exiting */ if (this.state.isExiting) return; const onSessionStart = (event) => { if (snapToCursor) this.snapToCursor(event); this.stopAnimation(); }; const onStart = (event, info) => { this.stopAnimation(); const { drag, dragPropagation, onDragStart } = this.getProps(); if (drag && !dragPropagation) { if (this.openGlobalLock) this.openGlobalLock(); this.openGlobalLock = setDragLock(drag); if (!this.openGlobalLock) 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 = void 0; } /** * Record gesture origin */ eachAxis((axis) => { let current = this.getAxisMotionValue(axis).get() || 0; /** * If the MotionValue is a percentage value convert to px */ if (percent.test(current)) { const { projection } = this.visualElement; if (projection && projection.layout) { const measuredAxis = projection.layout.layoutBox[axis]; if (measuredAxis) current = calcLength(measuredAxis) * (parseFloat(current) / 100); } } this.originPoint[axis] = current; }); if (onDragStart) frame.update(() => onDragStart(event, info), false, true); addValueToWillChange(this.visualElement, "transform"); this.state.setActive("whileDrag", true); }; const onMove = (event, info) => { this.latestPointerEvent = event; this.latestPanInfo = info; const { dragPropagation, dragDirectionLock, onDirectionLock, onDrag } = this.getProps(); if (!dragPropagation && !this.openGlobalLock) return; const { offset } = info; if (dragDirectionLock && this.currentDirection === null) { this.currentDirection = getCurrentDirection(offset); if (this.currentDirection !== null) onDirectionLock && onDirectionLock(this.currentDirection); return; } 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) 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.state.element }); } /** * @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) frame.postRender(() => onDragEnd(finalEvent, finalPanInfo)); } /** * @internal */ cancel() { this.isDragging = false; const { projection } = this.visualElement; if (projection) projection.isAnimationBlocked = false; this.endPanSession(); const { dragPropagation } = this.getProps(); if (!dragPropagation && this.openGlobalLock) { this.openGlobalLock(); this.openGlobalLock = null; } this.state.setActive("whileDrag", false); } /** * Clean up the pan session without modifying other drag state. * Used during unmount to remove event listeners without affecting * projection animations or drag locks. * @internal */ endPanSession() { this.panSession && this.panSession.end(); this.panSession = void 0; } updateAxis(axis, _point, offset) { const { drag } = this.getProps(); if (!offset || !shouldDrag(axis, drag, this.currentDirection)) return; const axisValue = this.getAxisMotionValue(axis); let next = this.originPoint[axis] + offset[axis]; 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 && isHTMLElement(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-element) * constraints, as element-based constraints from calcViewportConstraints are already in the * correct coordinate space for the motion value transform offset. */ if (prevConstraints !== this.constraints && !isHTMLElement(dragConstraints) && layout && this.constraints && !this.hasMutatedConstraints) 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 || !isHTMLElement(constraints)) return false; const constraintsElement = constraints; invariant(constraintsElement !== null, "If `dragConstraints` is set as a React ref, that ref must be passed to another component's `ref` prop."); const { projection } = this.visualElement; 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 = void 0; projection.root.updateScroll(); } const constraintsBox = 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(convertBoxToBoundingBox(measuredConstraints)); this.hasMutatedConstraints = !!userConstraints; if (userConstraints) measuredConstraints = convertBoundingBoxToBox(userConstraints); } return measuredConstraints; } startAnimation(velocity) { const { drag, dragMomentum, dragElastic, dragTransition, dragSnapToOrigin, onDragTransitionEnd } = this.getProps(); const constraints = this.constraints || {}; const momentumAnimations = 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 : 1e6; const bounceDamping = dragElastic ? 40 : 1e7; const inertia = { type: "inertia", velocity: dragMomentum ? velocity[axis] : 0, bounceStiffness, bounceDamping, timeConstant: 750, restDelta: 1, restSpeed: 10, ...dragTransition, ...transition }; return this.startAxisValueAnimation(axis, inertia); }); return Promise.all(momentumAnimations).then(onDragTransitionEnd); } startAxisValueAnimation(axis, transition) { const axisValue = this.getAxisMotionValue(axis); addValueToWillChange(this.visualElement, axis); return axisValue.start(animateMotionValue(axis, axisValue, 0, transition, this.visualElement, false)); } stopAnimation() { if (!this.visualElement.projection?.isPresent) return; 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()}`; return this.visualElement.getProps()[dragKey] || this.visualElement.getValue(axis, this.visualElement.latestValues[axis] ?? 0); } /** * Measure the live box - projection.layout may or may not include the * drag transform, depending on when it was measured. */ snapToCursor({ clientX, clientY }) { const { drag } = this.getProps(); const point = { x: clientX, y: clientY }; const cursor = this.visualElement.getTransformPagePoint()?.(point) || point; const box = this.visualElement.measureViewportBox(); eachAxis((axis) => { if (!shouldDrag(axis, drag, this.currentDirection)) return; const axisValue = this.getAxisMotionValue(axis); const { min, max } = box[axis]; axisValue.set((axisValue.get() || 0) + cursor[axis] - mixNumber(min, max, .5)); }); } /** * 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 (!isHTMLElement(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 }; 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.state.element.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. */ eachAxis((axis) => { const axisValue = this.getAxisMotionValue(axis); /** * An element resting at its origin has no offset to preserve, * and re-projecting 0 into the new constraints would move it. */ if (!shouldDrag(axis, drag, null) || !axisValue.get()) return; /** * Calculate a new transform based on the previous box progress */ const { min, max } = this.constraints[axis]; axisValue.set(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.state.element) return; elementDragControls.set(this.visualElement, this); const element = this.state.element; /** * 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 && isElementTextInput(target); if (drag && dragListener && !isClickingTextInputChild) this.start(event); }); /** * If using element-based constraints, observe both the draggable element * and the constraint container for size changes via ResizeObserver. */ let stopResizeObservers; const measureDragConstraints = () => { const { dragConstraints } = this.getProps(); if (isHTMLElement(dragConstraints)) { this.constraints = this.resolveRefConstraints(); if (!stopResizeObservers) stopResizeObservers = startResizeObservers(element, dragConstraints, () => this.scalePositionWithinConstraints()); } }; const { projection } = this.visualElement; const stopMeasureLayoutListener = projection.addEventListener("measure", measureDragConstraints); if (projection && !projection.layout) { projection.root && projection.root.updateScroll(); projection.updateLayout(); } frame.read(measureDragConstraints); /** * Attach a window resize listener to scale the draggable target within its defined * constraints as the window resizes. */ const stopResizeListener = 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) { 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 = resize(element, skipFirstCall(onResize)); const stopContainer = 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; } //#endregion export { VisualElementDragControls, elementDragControls };