From 660c0320272d8e971a7f17a3c97060301fb18b0f Mon Sep 17 00:00:00 2001 From: Vitaliy Date: Sat, 15 Jun 2024 23:32:08 +0600 Subject: [PATCH 1/3] add hook --- src/hooks/useSwipe/useSwipe.ts | 246 +++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 src/hooks/useSwipe/useSwipe.ts diff --git a/src/hooks/useSwipe/useSwipe.ts b/src/hooks/useSwipe/useSwipe.ts new file mode 100644 index 00000000..93f9162c --- /dev/null +++ b/src/hooks/useSwipe/useSwipe.ts @@ -0,0 +1,246 @@ +import React from 'react'; + +type UseSwipeTarget = React.RefObject | (() => Element) | Element; +export type UseSwipeDirection = 'up' | 'down' | 'left' | 'right' | 'none'; +export type UseSwipeHandledEvents = React.MouseEvent | TouchEvent | MouseEvent; +export type UseSwipeCallback = (value: UseSwipeReturn) => void; +export type UseSwipePosition = { x: number; y: number }; + +export type UseSwipeReturn = { + direction: UseSwipeDirection; + isSwiping: boolean; + deltaX: number; + deltaY: number; + percent: number; + coordsStart: UseSwipePosition; + coordsEnd: UseSwipePosition; +}; + +export type UseSwipeReturnActions = { + reset: () => void; +}; + +export type UseSwipeActions = { + onSwipeStart?: UseSwipeCallback; + onSwiping?: UseSwipeCallback; + onSwiped?: UseSwipeCallback; + onSwipedLeft?: UseSwipeCallback; + onSwipedRight?: UseSwipeCallback; + onSwipedUp?: UseSwipeCallback; + onSwipedDown?: UseSwipeCallback; +}; + +export type UseSwipeOptions = { + /** Min distance(px) before a swipe starts. **Default**: `10` */ + threshold: number; + /** Prevents scroll during swipe in most cases. **Default**: `false` */ + preventScrollOnSwipe: boolean; + /** Track mouse input. **Default**: `true` */ + trackMouse: boolean; + /** Track touch input. **Default**: `true` */ + trackTouch: boolean; +} & UseSwipeActions; + +export type UseSwipe = { + ( + target: Target, + options?: Partial + ): UseSwipeReturn & UseSwipeReturnActions; + + ( + options?: Partial, + target?: never + ): UseSwipeReturn & { ref: React.RefObject } & UseSwipeReturnActions; +}; + +const USE_SWIPE_DEFAULT_OPTIONS: UseSwipeOptions = { + threshold: 10, + preventScrollOnSwipe: false, + trackMouse: true, + trackTouch: true +}; + +const USE_SWIPE_DEFAULT_STATE: UseSwipeReturn = { + isSwiping: false, + direction: 'none', + coordsStart: { x: 0, y: 0 }, + coordsEnd: { x: 0, y: 0 }, + deltaX: 0, + deltaY: 0, + percent: 0 +}; + +const getElement = (target: UseSwipeTarget) => { + if (typeof target === 'function') { + return target(); + } + + if (target instanceof Element) { + return target; + } + + return target.current; +}; + +const getUseSwipeOptions = (options: UseSwipeOptions) => { + return { + ...USE_SWIPE_DEFAULT_OPTIONS, + ...options + }; +}; + +const getSwipeDirection = (deltaX: number, deltaY: number): UseSwipeDirection => { + if (Math.abs(deltaX) > Math.abs(deltaY)) { + return deltaX > 0 ? 'left' : 'right'; + } + return deltaY > 0 ? 'up' : 'down'; +}; + +export const useSwipe = ((...params: any[]) => { + const target = (typeof params[1] === 'undefined' ? undefined : params[0]) as + | UseSwipeTarget + | undefined; + const userOptions = (target ? params[1] : params[0]) as UseSwipeOptions; + + const options = getUseSwipeOptions(userOptions); + const internalRef = React.useRef(null); + + const [value, setValue] = React.useState(USE_SWIPE_DEFAULT_STATE); + + const reset = () => { + setValue({ ...USE_SWIPE_DEFAULT_STATE }); + }; + + // looks bullshit need some rework + const getSwipePositions = (event: UseSwipeHandledEvents) => { + const element = target ? getElement(target) : internalRef.current; + if (!element) return { x: 0, y: 0 }; // ? + + const isTouch = 'touches' in event; + const { clientX, clientY } = isTouch ? event.touches[0] : event; + const boundingRect = element.getBoundingClientRect(); + const x = Math.round(clientX - boundingRect.left); + const y = Math.round(clientY - boundingRect.top); + return { x, y }; + }; + + const getPercent = (deltaX: number, deltaY: number): Record => { + const element = target ? getElement(target) : internalRef.current; + if (!element) return { none: 0, down: 0, left: 0, right: 0, up: 0 }; // ? + const { width, height } = element.getBoundingClientRect(); + return { + none: 0, + left: Math.round((Math.abs(deltaX) * 100) / width), + right: Math.round((Math.abs(deltaX) * 100) / width), + up: Math.round((Math.abs(deltaY) * 100) / height), + down: Math.round((Math.abs(deltaY) * 100) / height) + }; + }; + + const onMove = (event: UseSwipeHandledEvents) => { + setValue((prevValue) => { + if (options.preventScrollOnSwipe) { + event.preventDefault(); + event.stopPropagation(); + } + + const { x, y } = getSwipePositions(event); + const deltaX = Math.round(prevValue.coordsStart.x - x); + const deltaY = Math.round(prevValue.coordsStart.y - y); + const isThresholdExceeded = Math.max(Math.abs(deltaX), Math.abs(deltaY)) >= options.threshold; + const isSwiping = prevValue.isSwiping || isThresholdExceeded; + const direction = isSwiping ? getSwipeDirection(deltaX, deltaY) : 'none'; + const percent = getPercent(deltaX, deltaY); + const newValue: UseSwipeReturn = { + ...prevValue, + isSwiping, + direction, + coordsEnd: { x, y }, + deltaX, + deltaY, + percent: percent[direction] + }; + + options?.onSwiping?.(newValue); + return newValue; + }); + }; + + const onFinish = () => { + setValue((prevValue) => { + const newValue: UseSwipeReturn = { + ...prevValue, + isSwiping: false + }; + + options?.onSwiped?.(newValue); + + const directionCallbacks = { + left: options.onSwipedLeft, + right: options.onSwipedRight, + up: options.onSwipedUp, + down: options.onSwipedDown + }; + + if (newValue.direction === 'none') return newValue; + + const callback = directionCallbacks[newValue.direction]; + callback?.(newValue); + + return newValue; + }); + + document.removeEventListener('mousemove', onMove); + document.removeEventListener('touchmove', onMove); + }; + const onStart = (event: UseSwipeHandledEvents) => { + event.preventDefault(); // prevent text selection + + const isTouch = 'touches' in event; + if (options.trackMouse && !isTouch) { + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onFinish, { once: true }); + } + if (options.trackTouch && isTouch) { + document.addEventListener('touchmove', onMove); + document.addEventListener('touchend', onFinish, { once: true }); + } + + const { x, y } = getSwipePositions(event); + + setValue((prevValue) => { + const newValue: UseSwipeReturn = { + ...prevValue, + coordsStart: { x, y }, + direction: 'none' + }; + options?.onSwipeStart?.(newValue); + return newValue; + }); + }; + + React.useEffect(() => { + const element = target ? getElement(target) : internalRef.current; + if (!element) return; + + // @ts-ignore + // element.addEventListener('mousedown', (event) <-- has Event type, not MouseEvent + if (options.trackMouse) element.addEventListener('mousedown', onStart); + // @ts-ignore + if (options.trackTouch) element.addEventListener('touchstart', onStart); + + return () => { + // @ts-ignore + element.removeEventListener('mousedown', onStart); + // @ts-ignore + element.removeEventListener('touchstart', onStart); + document.removeEventListener('mousemove', onMove); + document.removeEventListener('touchmove', onMove); + document.removeEventListener('mouseup', onFinish); + document.removeEventListener('touchend', onFinish); + }; + }, []); + + if (target) return { ...value, reset }; + return { ...value, reset, ref: internalRef }; +}) as UseSwipe; From 18ee4c3e59f6c2f6e0638cab9f7cbaee3d6d8844 Mon Sep 17 00:00:00 2001 From: Vitaliy Date: Sun, 16 Jun 2024 23:11:54 +0600 Subject: [PATCH 2/3] add demo --- src/hooks/useSwipe/useSwipe.demo.tsx | 88 ++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/hooks/useSwipe/useSwipe.demo.tsx diff --git a/src/hooks/useSwipe/useSwipe.demo.tsx b/src/hooks/useSwipe/useSwipe.demo.tsx new file mode 100644 index 00000000..53d84eb8 --- /dev/null +++ b/src/hooks/useSwipe/useSwipe.demo.tsx @@ -0,0 +1,88 @@ +import { useRef, useState } from 'react'; + +import { useSwipe } from './useSwipe'; + +const Demo = () => { + const ref = useRef(null); + const [offset, setOffset] = useState(0); + const [isSwiped, setIsSwiped] = useState(false); + + const { isSwiping, percent } = useSwipe(ref, { + threshold: 10, + directions: ['right'], + onSwiping: ({ percent }) => { + setOffset(percent); + }, + onSwiped: ({ percent }) => { + if (percent > 80) { + setOffset(100); + setIsSwiped(true); + } else { + setOffset(0); + } + } + }); + + const reset = () => { + setOffset(0); + setIsSwiped(false); + }; + + return ( + <> +

swipe more than 80%

+
+
+ Swipe right +
+ +
+

+ isSwiping: {isSwiping ? 'swiping' : 'not swiping'} +

+

+ percent: {percent}% +

+

+ isSwiped: {isSwiped ? 'swiped' : 'not swiped'} +

+ + ); +}; + +export default Demo; From a4f09f71964bf21d3f1ecc7a202173bd35a385aa Mon Sep 17 00:00:00 2001 From: Vitaliy Date: Sun, 16 Jun 2024 23:12:51 +0600 Subject: [PATCH 3/3] add jsdoc --- src/hooks/useSwipe/useSwipe.ts | 193 +++++++++++++++++++++++---------- 1 file changed, 137 insertions(+), 56 deletions(-) diff --git a/src/hooks/useSwipe/useSwipe.ts b/src/hooks/useSwipe/useSwipe.ts index 93f9162c..42f1e06e 100644 --- a/src/hooks/useSwipe/useSwipe.ts +++ b/src/hooks/useSwipe/useSwipe.ts @@ -9,15 +9,12 @@ export type UseSwipePosition = { x: number; y: number }; export type UseSwipeReturn = { direction: UseSwipeDirection; isSwiping: boolean; - deltaX: number; - deltaY: number; + distanceX: number; + distanceY: number; percent: number; - coordsStart: UseSwipePosition; - coordsEnd: UseSwipePosition; -}; - -export type UseSwipeReturnActions = { - reset: () => void; + posStart: UseSwipePosition; + posEnd: UseSwipePosition; + event: UseSwipeHandledEvents | null; }; export type UseSwipeActions = { @@ -33,41 +30,49 @@ export type UseSwipeActions = { export type UseSwipeOptions = { /** Min distance(px) before a swipe starts. **Default**: `10` */ threshold: number; - /** Prevents scroll during swipe in most cases. **Default**: `false` */ + /** Prevents scroll during swipe. **Default**: `false` */ preventScrollOnSwipe: boolean; - /** Track mouse input. **Default**: `true` */ - trackMouse: boolean; - /** Track touch input. **Default**: `true` */ - trackTouch: boolean; + /** Track inputs. **Default**: ['mouse', 'touch'] */ + track: ['mouse', 'touch']; + /** Direction(s) to track. **Default**: `['left', 'right', 'up', 'down']` */ + directions: UseSwipeDirection[]; } & UseSwipeActions; export type UseSwipe = { + (target: Target, callback?: UseSwipeCallback): UseSwipeReturn; + ( target: Target, options?: Partial - ): UseSwipeReturn & UseSwipeReturnActions; + ): UseSwipeReturn; ( - options?: Partial, + callback: UseSwipeCallback, target?: never - ): UseSwipeReturn & { ref: React.RefObject } & UseSwipeReturnActions; + ): UseSwipeReturn & { ref: React.RefObject }; + + ( + options: Partial, + target?: never + ): UseSwipeReturn & { ref: React.RefObject }; }; const USE_SWIPE_DEFAULT_OPTIONS: UseSwipeOptions = { threshold: 10, preventScrollOnSwipe: false, - trackMouse: true, - trackTouch: true + track: ['mouse', 'touch'], + directions: ['left', 'right', 'up', 'down'] }; const USE_SWIPE_DEFAULT_STATE: UseSwipeReturn = { isSwiping: false, direction: 'none', - coordsStart: { x: 0, y: 0 }, - coordsEnd: { x: 0, y: 0 }, - deltaX: 0, - deltaY: 0, - percent: 0 + posStart: { x: 0, y: 0 }, + posEnd: { x: 0, y: 0 }, + distanceX: 0, + distanceY: 0, + percent: 0, + event: null }; const getElement = (target: UseSwipeTarget) => { @@ -82,7 +87,7 @@ const getElement = (target: UseSwipeTarget) => { return target.current; }; -const getUseSwipeOptions = (options: UseSwipeOptions) => { +const getUseSwipeOptions = (options: UseSwipeOptions | undefined) => { return { ...USE_SWIPE_DEFAULT_OPTIONS, ...options @@ -96,22 +101,84 @@ const getSwipeDirection = (deltaX: number, deltaY: number): UseSwipeDirection => return deltaY > 0 ? 'up' : 'down'; }; +/** + * @name useSwipe + * @description - Hook that manages a swipe event + * + * @overload + * @template Target The target element + * @param {Target} target The target element to be swiped + * @param {() => void} [callback] The callback function to be invoked on swipe end + * @returns {UseSwipeReturn} The state of the swipe + * + * @example + * const { isSwiping, direction} = useSwipe(ref, (data) => console.log(data)); + * + * @overload + * @template Target The target element + * @param {Target} target The target element to be swiped + * @param {UseSwipeOptions} options An object containing the swipe options + * + * @example + * const {isSwiping, direction} = useSwipe(ref, { + * directions: ['left'], + * threshold: 20, + * preventScrollOnSwipe: true, + * track: ['mouse'], + * onSwiped: () => console.log('onSwiped'), + * onSwiping: () => console.log('onSwiping'), + * onSwipedLeft: () => console.log('onSwipedLeft'), + * onSwipedRight: () => console.log('onSwipedRight'), + * onSwipedUp: () => console.log('onSwipedUp'), + * onSwipedDown: () => console.log('onSwipedDown'), + * }); + * + * @overload + * @template Target The target element + * @param {() => void} [callback] The callback function to be invoked on swipe end + * @returns {UseSwipeReturn & { ref: React.RefObject }} The state of the swipe + * + * @example + * const { ref, isSwiping, direction} = useSwipe((data) => console.log(data)); + * + * @overload + * @template Target The target element + * @param {UseSwipeOptions} options An object containing the swipe options + * + * @example + * const {ref, isSwiping, direction} = useSwipe({ + * directions: ['left'], + * threshold: 20, + * preventScrollOnSwipe: true, + * track: ['mouse'], + * onSwiped: () => console.log('onSwiped'), + * onSwiping: () => console.log('onSwiping'), + * onSwipedLeft: () => console.log('onSwipedLeft'), + * onSwipedRight: () => console.log('onSwipedRight'), + * onSwipedUp: () => console.log('onSwipedUp'), + * onSwipedDown: () => console.log('onSwipedDown'), + * }); + */ + export const useSwipe = ((...params: any[]) => { - const target = (typeof params[1] === 'undefined' ? undefined : params[0]) as - | UseSwipeTarget - | undefined; - const userOptions = (target ? params[1] : params[0]) as UseSwipeOptions; + const target = ( + params[0] instanceof Function || !('current' in params[0]) ? undefined : params[0] + ) as UseSwipeTarget | undefined; + const userOptions = ( + target + ? typeof params[1] === 'object' + ? params[1] + : { onSwiped: params[1] } + : typeof params[0] === 'object' + ? params[0] + : { onSwiped: params[0] } + ) as UseSwipeOptions | undefined; const options = getUseSwipeOptions(userOptions); const internalRef = React.useRef(null); const [value, setValue] = React.useState(USE_SWIPE_DEFAULT_STATE); - const reset = () => { - setValue({ ...USE_SWIPE_DEFAULT_STATE }); - }; - - // looks bullshit need some rework const getSwipePositions = (event: UseSwipeHandledEvents) => { const element = target ? getElement(target) : internalRef.current; if (!element) return { x: 0, y: 0 }; // ? @@ -130,10 +197,10 @@ export const useSwipe = ((...params: any[]) => { const { width, height } = element.getBoundingClientRect(); return { none: 0, - left: Math.round((Math.abs(deltaX) * 100) / width), - right: Math.round((Math.abs(deltaX) * 100) / width), - up: Math.round((Math.abs(deltaY) * 100) / height), - down: Math.round((Math.abs(deltaY) * 100) / height) + left: Math.min(Math.round((Math.abs(deltaX) * 100) / width), 100), + right: Math.min(Math.round((Math.abs(deltaX) * 100) / width), 100), + up: Math.min(Math.round((Math.abs(deltaY) * 100) / height), 100), + down: Math.min(Math.round((Math.abs(deltaY) * 100) / height), 100) }; }; @@ -145,31 +212,38 @@ export const useSwipe = ((...params: any[]) => { } const { x, y } = getSwipePositions(event); - const deltaX = Math.round(prevValue.coordsStart.x - x); - const deltaY = Math.round(prevValue.coordsStart.y - y); - const isThresholdExceeded = Math.max(Math.abs(deltaX), Math.abs(deltaY)) >= options.threshold; + const distanceX = Math.round(prevValue.posStart.x - x); + const distanceY = Math.round(prevValue.posStart.y - y); + const absX = Math.abs(distanceX); + const absY = Math.abs(distanceY); + const isThresholdExceeded = Math.max(absX, absY) >= options.threshold; const isSwiping = prevValue.isSwiping || isThresholdExceeded; - const direction = isSwiping ? getSwipeDirection(deltaX, deltaY) : 'none'; - const percent = getPercent(deltaX, deltaY); + const direction = isSwiping ? getSwipeDirection(distanceX, distanceY) : 'none'; + if (!options.directions.includes(direction)) return prevValue; + + const percent = getPercent(distanceX, distanceY); const newValue: UseSwipeReturn = { ...prevValue, isSwiping, direction, - coordsEnd: { x, y }, - deltaX, - deltaY, + event, + distanceX, + distanceY, + posEnd: { x, y }, percent: percent[direction] }; options?.onSwiping?.(newValue); + return newValue; }); }; - const onFinish = () => { + const onFinish = (event: UseSwipeHandledEvents) => { setValue((prevValue) => { const newValue: UseSwipeReturn = { ...prevValue, + event, isSwiping: false }; @@ -193,15 +267,16 @@ export const useSwipe = ((...params: any[]) => { document.removeEventListener('mousemove', onMove); document.removeEventListener('touchmove', onMove); }; + const onStart = (event: UseSwipeHandledEvents) => { event.preventDefault(); // prevent text selection const isTouch = 'touches' in event; - if (options.trackMouse && !isTouch) { + if (options?.track.includes('mouse') && !isTouch) { document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onFinish, { once: true }); } - if (options.trackTouch && isTouch) { + if (options?.track.includes('touch') && isTouch) { document.addEventListener('touchmove', onMove); document.addEventListener('touchend', onFinish, { once: true }); } @@ -211,7 +286,8 @@ export const useSwipe = ((...params: any[]) => { setValue((prevValue) => { const newValue: UseSwipeReturn = { ...prevValue, - coordsStart: { x, y }, + event, + posStart: { x, y }, direction: 'none' }; options?.onSwipeStart?.(newValue); @@ -223,11 +299,16 @@ export const useSwipe = ((...params: any[]) => { const element = target ? getElement(target) : internalRef.current; if (!element) return; - // @ts-ignore - // element.addEventListener('mousedown', (event) <-- has Event type, not MouseEvent - if (options.trackMouse) element.addEventListener('mousedown', onStart); - // @ts-ignore - if (options.trackTouch) element.addEventListener('touchstart', onStart); + if (options?.track.includes('mouse')) { + // @ts-ignore + // element.addEventListener('mousedown', (event) <-- has Event type, not MouseEvent + element.addEventListener('mousedown', onStart); + } + + if (options?.track.includes('touch')) { + // @ts-ignore + element.addEventListener('touchstart', onStart); + } return () => { // @ts-ignore @@ -241,6 +322,6 @@ export const useSwipe = ((...params: any[]) => { }; }, []); - if (target) return { ...value, reset }; - return { ...value, reset, ref: internalRef }; + if (target) return { ...value }; + return { ...value, ref: internalRef }; }) as UseSwipe;