UNPKG

framer-motion

Version:

A simple and powerful JavaScript animation library

258 lines (255 loc) • 9.73 kB
import { frame, isPrimaryPointer, cancelFrame, frameData } from 'motion-dom'; import { pipe, secondsToMilliseconds, millisecondsToSeconds } from 'motion-utils'; import { addPointerEvent } from '../../events/add-pointer-event.mjs'; import { extractEventInfo } from '../../events/event-info.mjs'; import { distance2D } from '../../utils/distance.mjs'; 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 } = 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 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 (!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 } = frameData; this.history = [{ ...point, timestamp }]; const { onSessionStart } = handlers; onSessionStart && onSessionStart(event, getPanInfo(initialInfo, this.history)); this.removeListeners = 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); frame.update(this.updatePoint, true); } updateHandlers(handlers) { this.handlers = handlers; } end() { this.removeListeners && this.removeListeners(); this.removeScrollListeners && this.removeScrollListeners(); this.scrollPositions.clear(); 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 > secondsToMilliseconds(timeDelta)) { break; } i--; } if (!timestampedPoint) { return { x: 0, y: 0 }; } const time = 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; } export { PanSession }; //# sourceMappingURL=PanSession.mjs.map