UNPKG

framer-motion

Version:

An open source and production-ready motion library for React on the web

367 lines (319 loc) • 9.14 kB
import { EventInfo } from "../events/types" import { isTouchEvent, isMouseEvent } from "./utils/event-type" import { extractEventInfo } from "../events/event-info" import sync, { getFrameData, cancelSync } from "framesync" import { secondsToMilliseconds } from "../utils/time-conversion" import { addPointerEvent } from "../events/use-pointer-event" import { distance, pipe } from "popmotion" import { Point2D, TransformPoint2D } from "../types/geometry" export type AnyPointerEvent = MouseEvent | TouchEvent | PointerEvent /** * Passed in to pan event handlers like `onPan` the `PanInfo` object contains * information about the current state of the tap gesture such as its * `point`, `delta`, `offset` and `velocity`. * * @library * * ```jsx * function onPan(event, info) { * console.log(info.point.x, info.point.y) * } * * <Frame onPan={onPan} /> * ``` * * @motion * * ```jsx * <motion.div onPan={(event, info) => { * console.log(info.point.x, info.point.y) * }} /> * ``` * * @public */ export interface PanInfo { /** * Contains `x` and `y` values for the current pan position relative * to the device or page. * * @library * * ```jsx * function onPan(event, info) { * console.log(info.point.x, info.point.y) * } * * <Frame onPan={onPan} /> * ``` * * @motion * * ```jsx * function onPan(event, info) { * console.log(info.point.x, info.point.y) * } * * <motion.div onPan={onPan} /> * ``` * * @public */ point: Point2D /** * Contains `x` and `y` values for the distance moved since * the last event. * * @library * * ```jsx * function onPan(event, info) { * console.log(info.delta.x, info.delta.y) * } * * <Frame onPan={onPan} /> * ``` * * @motion * * ```jsx * function onPan(event, info) { * console.log(info.delta.x, info.delta.y) * } * * <motion.div onPan={onPan} /> * ``` * * @public */ delta: Point2D /** * Contains `x` and `y` values for the distance moved from * the first pan event. * * @library * * ```jsx * function onPan(event, info) { * console.log(info.offset.x, info.offset.y) * } * * <Frame onPan={onPan} /> * ``` * * @motion * * ```jsx * function onPan(event, info) { * console.log(info.offset.x, info.offset.y) * } * * <motion.div onPan={onPan} /> * ``` * * @public */ offset: Point2D /** * Contains `x` and `y` values for the current velocity of the pointer. * * @library * * ```jsx * function onPan(event, info) { * console.log(info.velocity.x, info.velocity.y) * } * * <Frame onPan={onPan} /> * ``` * * @motion * * ```jsx * function onPan(event, info) { * console.log(info.velocity.x, info.velocity.y) * } * * <motion.div onPan={onPan} /> * ``` * * @public */ velocity: Point2D } export type PanHandler = (event: Event, info: PanInfo) => void interface PanSessionHandlers { onSessionStart: PanHandler onStart: PanHandler onMove: PanHandler onEnd: PanHandler } interface PanSessionOptions { transformPagePoint?: TransformPoint2D } interface TimestampedPoint extends Point2D { timestamp: number } /** * @internal */ export class PanSession { /** * @internal */ private history: TimestampedPoint[] /** * @internal */ private startEvent: AnyPointerEvent | null = null /** * @internal */ private lastMoveEvent: AnyPointerEvent | null = null /** * @internal */ private lastMoveEventInfo: EventInfo | null = null /** * @internal */ private transformPagePoint?: TransformPoint2D /** * @internal */ private handlers: Partial<PanSessionHandlers> = {} /** * @internal */ private removeListeners: Function constructor( event: AnyPointerEvent, handlers: Partial<PanSessionHandlers>, { transformPagePoint }: PanSessionOptions = {} ) { // If we have more than one touch, don't start detecting this gesture if (isTouchEvent(event) && event.touches.length > 1) return this.handlers = handlers this.transformPagePoint = transformPagePoint const info = extractEventInfo(event) const initialInfo = transformPoint(info, this.transformPagePoint) const { point } = initialInfo const { timestamp } = getFrameData() this.history = [{ ...point, timestamp }] const { onSessionStart } = handlers onSessionStart && onSessionStart(event, getPanInfo(initialInfo, this.history)) this.removeListeners = pipe( addPointerEvent(window, "pointermove", this.handlePointerMove), addPointerEvent(window, "pointerup", this.handlePointerUp), addPointerEvent(window, "pointercancel", this.handlePointerUp) ) } private 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 cursoe. const isDistancePastThreshold = distance(info.offset, { x: 0, y: 0 }) >= 3 if (!isPanStarted && !isDistancePastThreshold) return const { point } = info const { timestamp } = getFrameData() 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) } private handlePointerMove = (event: AnyPointerEvent, info: EventInfo) => { this.lastMoveEvent = event this.lastMoveEventInfo = transformPoint(info, this.transformPagePoint) // Because Safari doesn't trigger mouseup events when it's above a `<select>` if (isMouseEvent(event) && event.buttons === 0) { this.handlePointerUp(event, info) return } // Throttle mouse move event to once per frame sync.update(this.updatePoint, true) } private handlePointerUp = (event: AnyPointerEvent, info: EventInfo) => { this.end() const { onEnd } = this.handlers if (!onEnd) return const panInfo = getPanInfo( transformPoint(info, this.transformPagePoint), this.history ) onEnd && onEnd(event, panInfo) } updateHandlers(handlers: Partial<PanSessionHandlers>) { this.handlers = handlers } end() { this.removeListeners && this.removeListeners() cancelSync.update(this.updatePoint) } } function transformPoint( info: EventInfo, transformPagePoint?: (point: Point2D) => Point2D ) { return transformPagePoint ? { point: transformPagePoint(info.point) } : info } function subtractPoint(a: Point2D, b: Point2D): Point2D { return { x: a.x - b.x, y: a.y - b.y } } function getPanInfo({ point }: EventInfo, history: TimestampedPoint[]) { return { point, delta: subtractPoint(point, lastDevicePoint(history)), offset: subtractPoint(point, startDevicePoint(history)), velocity: getVelocity(history, 0.1), } } function startDevicePoint(history: TimestampedPoint[]): TimestampedPoint { return history[0] } function lastDevicePoint(history: TimestampedPoint[]): TimestampedPoint { return history[history.length - 1] } function getVelocity(history: TimestampedPoint[], timeDelta: number): Point2D { if (history.length < 2) { return { x: 0, y: 0 } } let i = history.length - 1 let timestampedPoint: TimestampedPoint | null = 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 = (lastPoint.timestamp - timestampedPoint.timestamp) / 1000 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 }