UNPKG

motion-dom

Version:
269 lines (266 loc) • 11.4 kB
import { addDomEvent } from '../../events/add-dom-event.mjs'; import { addPointerEvent } from '../../events/add-pointer-event.mjs'; import { extractEventInfo } from '../../events/event-info.mjs'; import { frame, cancelFrame } from '../../frameloop/frame.mjs'; import { time } from '../../frameloop/sync-time.mjs'; import { distance2D } from '../../utils/distance.mjs'; import { isPrimaryPointer } from '../utils/is-primary-pointer.mjs'; import { pipe, secondsToMilliseconds, millisecondsToSeconds } from 'motion-utils'; const overflowStyles = /*#__PURE__*/ new Set(["auto", "scroll"]); class PanSession { constructor(event, handlers, { transformPagePoint, contextWindow = window, dragSnapToOrigin = false, distanceThreshold = 3, element, immediate, } = {}) { this.startEvent = null; this.lastMoveEvent = null; this.lastMoveEventInfo = null; /** * Raw (untransformed) event info, re-transformed each frame * so transformPagePoint sees the current parent matrix. */ this.lastRawMoveEventInfo = null; this.handlers = {}; this.contextWindow = window; /** * Scroll positions of scrollable ancestors and window. */ this.scrollPositions = new Map(); this.onElementScroll = (event) => { this.handleScroll(event.target); }; this.onWindowScroll = () => { this.handleScroll(window); }; this.updatePoint = () => { if (!(this.lastMoveEvent && this.lastMoveEventInfo)) return; this.hasPendingMove = false; // 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; this.history.push({ ...point, timestamp: time.now() }); const { onStart, onMove } = this.handlers; if (!isPanStarted) { onStart?.(this.lastMoveEvent, info); this.startEvent = this.lastMoveEvent; } onMove?.(this.lastMoveEvent, info); }; this.handlePointerMove = (event, info) => { /** * This event's pageX/pageY already include any window scroll that * hasn't fired its scroll event yet. Settle it now, so that scroll * event doesn't add it to the new point a second time. */ this.handleScroll(window); this.lastMoveEvent = event; this.lastRawMoveEventInfo = info; this.lastMoveEventInfo = transformPoint(info, this.transformPagePoint); this.hasPendingMove = true; // Throttle mouse move event to once per frame frame.update(this.updatePoint, true); }; this.handlePointerUp = (event, info) => { // Browsers flush a coalesced pointermove immediately before // pointerup, so the final move can still be waiting for a frame. this.hasPendingMove && this.updatePoint(); 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?.(); } if (!(this.lastMoveEvent && this.lastMoveEventInfo)) return; const panInfo = getPanInfo(event.type === "pointercancel" ? this.lastMoveEventInfo : transformPoint(info, this.transformPagePoint), this.history); this.startEvent && onEnd?.(event, panInfo); 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 = immediate ? 0 : distanceThreshold; this.contextWindow = contextWindow || window; const info = extractEventInfo(event); const initialInfo = transformPoint(info, this.transformPagePoint); const { point } = initialInfo; /** * Stamp the origin with time.now(), not frameData.timestamp: when * the frameloop is idle, frameData.timestamp is from the last frame * that ran, which can be seconds old, and a fast flick from a * standstill would then report near-zero velocity. See #1747. */ this.history = [{ ...point, timestamp: time.now() }]; const { onSessionStart } = handlers; onSessionStart && onSessionStart(event, getPanInfo(initialInfo, this.history)); // Listen in the capture phase so a descendant calling // stopPropagation() (e.g. in its own pointerup handler) can't // prevent the gesture from ending. See #2794. const eventOptions = { passive: true, capture: true }; this.removeListeners = pipe(addPointerEvent(this.contextWindow, "pointermove", this.handlePointerMove, eventOptions), addPointerEvent(this.contextWindow, "pointerup", this.handlePointerUp, eventOptions), addPointerEvent(this.contextWindow, "pointercancel", this.handlePointerUp, eventOptions)); // Start scroll tracking if element provided if (element) { this.startScrollTracking(element); } if (immediate) { this.handlePointerMove(event, info); this.updatePoint(); } } /** * 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, getScroll(current)); } current = current.parentElement; } // Track window scroll this.scrollPositions.set(window, getScroll(window)); this.removeScrollListeners = pipe( // Capture listener catches element scroll events as they bubble addDomEvent(window, "scroll", this.onElementScroll, { capture: true, }), // Direct window scroll listener (window scroll doesn't bubble) addDomEvent(window, "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 = getScroll(target); 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.removeScrollListeners?.(); this.scrollPositions.clear(); cancelFrame(this.updatePoint); } } const getScroll = (target) => target === window ? { x: window.scrollX, y: window.scrollY } : { x: target.scrollLeft, y: target.scrollTop, }; 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 }; } /** * 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 > secondsToMilliseconds(timeDelta) * 2) { timestampedPoint = history[1]; } const seconds = millisecondsToSeconds(lastPoint.timestamp - timestampedPoint.timestamp); if (seconds === 0) { return { x: 0, y: 0 }; } const currentVelocity = { x: (lastPoint.x - timestampedPoint.x) / seconds, y: (lastPoint.y - timestampedPoint.y) / seconds, }; if (currentVelocity.x === Infinity) { currentVelocity.x = 0; } if (currentVelocity.y === Infinity) { currentVelocity.y = 0; } return currentVelocity; } export { PanSession }; //# sourceMappingURL=PanSession.mjs.map