import * as React from "react"; import { AttachTouch, SwipeDirections, DOWN, SwipeEventData, HandledEvents, LEFT, RIGHT, Setter, ConfigurationOptions, SwipeableDirectionCallbacks, SwipeableHandlers, SwipeableProps, SwipeablePropsWithDefaultOptions, SwipeableState, SwipeCallback, TapCallback, UP, Vector2, Tuple, Point, } from "./types"; const defaultProps: ConfigurationOptions = { delta: 10, preventScrollOnSwipe: false, rotationAngle: 0, trackMouse: false, trackTouch: true, swipeDuration: Infinity, touchEventOptions: { passive: true }, }; const initialState: SwipeableState = { first: true, initial: [0, 0], start: 0, swiping: false, xy: [0, 0], lastDistance: 0, pinching: false, fingers: 0, lastTouchStart: 0, isDoubleTap: false, startTouches: [], interaction: null, }; const mouseMove = "mousemove"; const mouseUp = "mouseup"; const touchEnd = "touchend"; const touchMove = "touchmove"; const touchStart = "touchstart"; function getDirection( absX: number, absY: number, deltaX: number, deltaY: number ): SwipeDirections { if (absX > absY) { if (deltaX > 0) { return RIGHT; } return LEFT; } else if (deltaY > 0) { return DOWN; } return UP; } function getDistance(p1: Point, p2: Point): number { return Math.hypot(p2.x - p1.x, p2.y - p1.y); } const cancelEvent = (event: any): void => { event.stopPropagation(); event.preventDefault(); }; function getXYfromEvent(event: TouchEvent): Tuple { return [...event.touches] .map((touch) => ({ x: touch.pageX, y: touch.pageY }) ) as Tuple; } function getMiddleSegment(p1: Point, p2: Point): Point { return { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2, } } const getPointByPageCoordinates = (touch: Touch): Point => ({ x: touch.pageX, y: touch.pageY, }); function rotateXYByAngle(pos: Vector2, angle: number): Vector2 { if (angle === 0) return pos; const angleInRadians = (Math.PI / 180) * angle; const x = pos[0] * Math.cos(angleInRadians) + pos[1] * Math.sin(angleInRadians); const y = pos[1] * Math.cos(angleInRadians) - pos[0] * Math.sin(angleInRadians); return [x, y]; } function getHandlers( set: Setter, handlerProps: { trackMouse: boolean | undefined } ): [ { ref: (element: HTMLElement | null) => void; onMouseDown?: (event: React.MouseEvent) => void; }, AttachTouch ] { const onStart = (event: HandledEvents) => { const isTouch = "touches" in event; event.stopPropagation(); if (!isTouch) return set((state, props) => { // setup mouse listeners on document to track swipe since swipe can leave container // if (props.trackMouse && !isTouch) { // document.addEventListener(mouseMove, onMove); // document.addEventListener(mouseUp, onUp); // } const { clientX, clientY } = isTouch ? event.touches[0] : event; const xy = rotateXYByAngle([clientX, clientY], props.rotationAngle); props.onTouchStartOrOnMouseDown && props.onTouchStartOrOnMouseDown({ event }); const time = new Date().getTime(); if (event.touches.length > 1) { state.lastTouchStart = 0 } if (time - state.lastTouchStart < 300) { state.isDoubleTap = true; props.onDoubleTap?.(event); console.log("Double Click"); } else { state.isDoubleTap = false; } if (event.touches.length === 1) { state.lastTouchStart = time; } return { ...state, ...initialState, lastTouchStart: state.lastTouchStart, initial: xy.slice() as Vector2, xy, start: event.timeStamp || 0, fingers: isTouch ? event.touches.length : 0, isDoubleTap: state.isDoubleTap }; }); }; const onMove = (event: HandledEvents) => { set((state, props) => { const isTouch = "touches" in event; if (state.isDoubleTap || !isTouch) { return state } if (state.first) { state.startTouches = [...event.touches].map(touch => getPointByPageCoordinates(touch)); const fingers = event.touches.length; if (fingers === 2) { state.interaction = "zoom"; } else if (fingers === 1) { state.interaction = "drag"; } else { state.interaction = null; } } // Discount a swipe if additional touches are present after // a swipe has started. if (isTouch && event.touches.length > 1) { if (event.touches.length === 2) { const touchFist = event.touches[0]; const touchSecond = event.touches[1]; const move = getXYfromEvent(event); const middleSegment = getMiddleSegment( { x: touchFist.clientX, y: touchFist.clientY }, { x: touchSecond.clientX, y: touchSecond.clientY } ) const distance = getDistance( { x: touchFist.clientX, y: touchFist.clientY }, { x: touchSecond.clientX, y: touchSecond.clientY } ) if (state.lastDistance === 0) { state.lastDistance = distance; } const scale = distance / state.lastDistance; // console.log("move", move); props.onZoom?.({ event, scale, middleSegment }); return { ...state, first: false, lastDistance: distance, } } return state; } // if swipe has exceeded duration stop tracking if (event.timeStamp - state.start > props.swipeDuration) { return state.swiping ? { ...state, swiping: false } : state; } const { clientX, clientY } = isTouch ? event.touches[0] : event; const [x, y] = rotateXYByAngle([clientX, clientY], props.rotationAngle); const deltaX = x - state.xy[0]; const deltaY = y - state.xy[1]; const absX = Math.abs(deltaX); const absY = Math.abs(deltaY); const time = (event.timeStamp || 0) - state.start; const velocity = Math.sqrt(absX * absX + absY * absY) / (time || 1); const vxvy: Vector2 = [deltaX / (time || 1), deltaY / (time || 1)]; const dir = getDirection(absX, absY, deltaX, deltaY); // if swipe is under delta and we have not started to track a swipe: skip update const delta = typeof props.delta === "number" ? props.delta : props.delta[dir.toLowerCase() as Lowercase] || defaultProps.delta; if (absX < delta && absY < delta && !state.swiping) return state; const eventData = { absX, absY, deltaX, deltaY, dir, event, first: state.first, initial: state.initial, velocity, vxvy, piching: state.pinching, }; // call onSwipeStart if present and is first swipe event eventData.first && props.onSwipeStart && props.onSwipeStart(eventData); // call onSwiping if present props.onSwiping && props.onSwiping(eventData); // track if a swipe is cancelable (handler for swiping or swiped(dir) exists) // so we can call preventDefault if needed let cancelablePageSwipe = false; if ( props.onSwiping || props.onSwiped || props[`onSwiped${dir}` as keyof SwipeableDirectionCallbacks] ) { cancelablePageSwipe = true; } if ( cancelablePageSwipe && props.preventScrollOnSwipe && props.trackTouch && event.cancelable ) { event.preventDefault(); } return { ...state, // first is now always false first: false, eventData, swiping: true, }; }); }; const onEnd = (event: HandledEvents) => { set((state, props) => { let eventData: SwipeEventData | undefined; if (state.swiping && state.eventData) { // if swipe is less than duration fire swiped callbacks if (event.timeStamp - state.start < props.swipeDuration) { eventData = { ...state.eventData, event }; props.onSwiped && props.onSwiped(eventData); const onSwipedDir = props[ `onSwiped${eventData.dir}` as keyof SwipeableDirectionCallbacks ]; onSwipedDir && onSwipedDir(eventData); } } else { props.onTap && props.onTap({ event }); } props.onTouchEndOrOnMouseUp && props.onTouchEndOrOnMouseUp({ event }); return { ...state, ...initialState, eventData, lastTouchStart: state.lastTouchStart }; }); }; const cleanUpMouse = () => { // safe to just call removeEventListener document.removeEventListener(mouseMove, onMove); document.removeEventListener(mouseUp, onUp); }; const onUp = (e: HandledEvents) => { cleanUpMouse(); onEnd(e); }; /** * The value of passive on touchMove depends on `preventScrollOnSwipe`: * - true => { passive: false } * - false => { passive: true } // Default * * NOTE: When preventScrollOnSwipe is true, we attempt to call preventDefault to prevent scroll. * * props.touchEventOptions can also be set for all touch event listeners, * but for `touchmove` specifically when `preventScrollOnSwipe` it will * supersede and force passive to false. * */ const attachTouch: AttachTouch = (el, props) => { let cleanup = () => { }; if (el && el.addEventListener) { const baseOptions = { ...defaultProps.touchEventOptions, ...props.touchEventOptions, }; // attach touch event listeners and handlers const tls: [ typeof touchStart | typeof touchMove | typeof touchEnd, (e: HandledEvents) => void, { passive: boolean } ][] = [ [touchStart, onStart, baseOptions], // preventScrollOnSwipe option supersedes touchEventOptions.passive [ touchMove, onMove, { ...baseOptions, ...(props.preventScrollOnSwipe ? { passive: false } : {}), }, ], [touchEnd, onEnd, baseOptions], ]; tls.forEach(([e, h, o]) => el.addEventListener(e, h, o)); // return properly scoped cleanup method for removing listeners, options not required cleanup = () => tls.forEach(([e, h]) => el.removeEventListener(e, h)); } return cleanup; }; const onRef = (el: HTMLElement | null) => { // "inline" ref functions are called twice on render, once with null then again with DOM element // ignore null here if (el === null) return; set((state, props) => { // if the same DOM el as previous just return state if (state.el === el) return state; const addState: { cleanUpTouch?: () => void } = {}; // if new DOM el clean up old DOM and reset cleanUpTouch if (state.el && state.el !== el && state.cleanUpTouch) { state.cleanUpTouch(); addState.cleanUpTouch = void 0; } // only attach if we want to track touch if (props.trackTouch && el) { addState.cleanUpTouch = attachTouch(el, props); } // store event attached DOM el for comparison, clean up, and re-attachment return { ...state, el, ...addState }; }); }; // set ref callback to attach touch event listeners const output: { ref: typeof onRef; onMouseDown?: typeof onStart } = { ref: onRef, }; // if track mouse attach mouse down listener if (handlerProps.trackMouse) { output.onMouseDown = onStart; } return [output, attachTouch]; } function updateTransientState( state: SwipeableState, props: SwipeablePropsWithDefaultOptions, previousProps: SwipeablePropsWithDefaultOptions, attachTouch: AttachTouch ) { // if trackTouch is off or there is no el, then remove handlers if necessary and exit if (!props.trackTouch || !state.el) { if (state.cleanUpTouch) { state.cleanUpTouch(); } return { ...state, cleanUpTouch: undefined, }; } // trackTouch is on, so if there are no handlers attached, attach them and exit if (!state.cleanUpTouch) { return { ...state, cleanUpTouch: attachTouch(state.el, props), }; } // trackTouch is on and handlers are already attached, so if preventScrollOnSwipe changes value, // remove and reattach handlers (this is required to update the passive option when attaching // the handlers) if ( props.preventScrollOnSwipe !== previousProps.preventScrollOnSwipe || props.touchEventOptions.passive !== previousProps.touchEventOptions.passive ) { state.cleanUpTouch(); return { ...state, cleanUpTouch: attachTouch(state.el, props), }; } return state; } export function useSwipeable(options) { const { trackMouse } = options; const transientState = React.useRef({ ...initialState }); const transientProps = React.useRef({ ...defaultProps, }); // track previous rendered props const previousProps = React.useRef({ ...transientProps.current, }); previousProps.current = { ...transientProps.current }; // update current render props & defaults transientProps.current = { ...defaultProps, ...options, }; // Force defaults for config properties let defaultKey: keyof ConfigurationOptions; for (defaultKey in defaultProps) { if (transientProps.current[defaultKey] === void 0) { (transientProps.current[defaultKey] as any) = defaultProps[defaultKey]; } } const [handlers, attachTouch] = React.useMemo( () => getHandlers( (stateSetter) => (transientState.current = stateSetter( transientState.current, transientProps.current )), { trackMouse } ), [trackMouse] ); transientState.current = updateTransientState( transientState.current, transientProps.current, previousProps.current, attachTouch ); return handlers; }