import { HTMLAttributes, useEffect, useMemo, useRef, useState } from 'react' import { focusWithoutScrolling } from '../../libs/dom-utils' import { useListeners } from '../../libs/useListeners' import type { BasePressEvent, PressSource } from '../../shared/types' import { PressProps } from './types' import { createPressEvent } from './utils/create-press-event' import { isTargetContainsPoint } from './utils/detect-overlap' import { isCheckableInput, isValidKeyboardEvent } from './utils/keyboard-event' import { disableTextSelection, restoreTextSelection } from './utils/text-selection' import { getTouchById, getTouchFromEvent } from './utils/touch-event' export interface UsePressResult { isPressed: boolean pressProps: HTMLAttributes } type PressCache = { currentPointerId: number | null currentPointerTarget: T isPressed: boolean isPressStarted: boolean } export function usePress( props: PressProps, ): UsePressResult { const { preventFocusOnPress } = props const { addListener, removeAllListeners } = useListeners() const [isPressed, setPressed] = useState(false) const cacheRef = useRef>({ currentPointerId: null, // Expect that the currentTarget is always exists currentPointerTarget: null as unknown as T, isPressed: false, isPressStarted: false, }) const propsRef = useRef>({}) // Use ref as cache for reuse props inside memo hook. propsRef.current = { disabled: props.disabled, onPressStart: props.onPressStart, onPressUp: props.onPressUp, onPressEnd: props.onPressEnd, onPress: props.onPress, } const pressProps = useMemo(() => { const cache = cacheRef.current const props: HTMLAttributes = { onKeyDown: (event) => { if (isValidKeyboardEvent(event.nativeEvent)) { // Use preventDefault for all elements except checkbox and radiobox inputs, // because input should trigger onChange after keydown. if (!isCheckableInput(event.target as HTMLElement)) { // Use preventDefault for stop document scroll for interactive elements. event.preventDefault() } event.stopPropagation() if (!cache.isPressed && !event.repeat) { triggerPressStart(createPressEvent(event, event.currentTarget, 'keyboard')) } } }, // TODO: Register as global listener after keydown. onKeyUp: (event) => { if (isValidKeyboardEvent(event.nativeEvent) && !event.repeat) { triggerPressUp(createPressEvent(event, event.currentTarget, 'keyboard')) triggerPressEnd(createPressEvent(event, event.currentTarget, 'keyboard')) } }, } const triggerPressStart = (event: BasePressEvent) => { const { disabled, onPressStart } = propsRef.current if (disabled || cache.isPressStarted) { return } setPressed(true) cache.isPressStarted = true onPressStart?.({ ...event, type: 'pressstart' }) } const triggerPressUp = (event: BasePressEvent) => { const { disabled, onPressUp } = propsRef.current if (disabled) { return } onPressUp?.({ ...event, type: 'pressup' }) } const triggerPressEnd = (event: BasePressEvent, triggerOnPress = true) => { const { onPress, onPressEnd } = propsRef.current if (!cache.isPressStarted) { return } setPressed(false) cache.isPressStarted = false onPressEnd?.({ ...event, type: 'pressend' }) if (triggerOnPress) { onPress?.({ ...event, type: 'press' }) } } const attach = (target: T, id: number) => { cache.currentPointerTarget = target cache.currentPointerId = id cache.isPressed = true disableTextSelection() setPressed(true) } const detach = () => { if (cache.isPressed) { cache.isPressed = false restoreTextSelection() setPressed(false) removeAllListeners() } } if (typeof PointerEvent !== 'undefined') { const onPointerMove = (event: PointerEvent) => { const pointerType = event.pointerType as PressSource if (isTargetContainsPoint(cache.currentPointerTarget, event)) { triggerPressStart(createPressEvent(event, cache.currentPointerTarget, pointerType)) } else { triggerPressEnd(createPressEvent(event, cache.currentPointerTarget, pointerType), false) } } const onPointerUp = (event: PointerEvent) => { // Dispose press only if down and up pointer ids are matches. if (event.pointerId === cache.currentPointerId) { detach() if (isTargetContainsPoint(cache.currentPointerTarget, event)) { const pointerType = event.pointerType as PressSource triggerPressUp(createPressEvent(event, cache.currentPointerTarget, pointerType)) triggerPressEnd(createPressEvent(event, cache.currentPointerTarget, pointerType)) } } } // Cancel event can be fired while scroll. const onPointerCancel = (event: PointerEvent) => { if (cache.isPressed) { const pointerType = event.pointerType as PressSource triggerPressEnd(createPressEvent(event, cache.currentPointerTarget, pointerType), false) } detach() } props.onPointerDown = (event) => { const { disabled } = propsRef.current // Handle only left clicks. if (event.button !== 0) { return } event.preventDefault() event.stopPropagation() if (!cache.isPressed && !disabled) { if (!preventFocusOnPress) { focusWithoutScrolling(event.currentTarget) } attach(event.currentTarget, event.pointerId) triggerPressStart(createPressEvent(event, event.currentTarget, event.pointerType)) addListener(document, 'pointermove', onPointerMove, false) addListener(document, 'pointerup', onPointerUp, false) addListener(document, 'pointercancel', onPointerCancel, false) } } } else { const onTouchMove = (event: TouchEvent) => { const touch = getTouchById(event, cache.currentPointerId) if (touch) { if (isTargetContainsPoint(cache.currentPointerTarget, touch)) { triggerPressStart(createPressEvent(event, cache.currentPointerTarget, 'touch')) } else { triggerPressEnd(createPressEvent(event, cache.currentPointerTarget, 'touch'), false) } } } const onTouchEnd = (event: TouchEvent) => { const touch = getTouchById(event, cache.currentPointerId) // Dispose press only if down and up pointer ids are matches. if (touch?.identifier === cache.currentPointerId) { detach() if (isTargetContainsPoint(cache.currentPointerTarget, touch)) { triggerPressUp(createPressEvent(event, cache.currentPointerTarget, 'touch')) triggerPressEnd(createPressEvent(event, cache.currentPointerTarget, 'touch')) } } } // Cancel event can be fired while scroll. const onTouchCancel = (event: TouchEvent) => { if (cache.isPressed) { triggerPressEnd(createPressEvent(event, cache.currentPointerTarget, 'touch'), false) } detach() } props.onTouchStart = (event) => { const { disabled } = propsRef.current event.preventDefault() event.stopPropagation() const touch = getTouchFromEvent(event.nativeEvent) if (touch && !cache.isPressed && !disabled) { if (!preventFocusOnPress) { focusWithoutScrolling(event.currentTarget) } attach(event.currentTarget, touch.identifier) triggerPressStart(createPressEvent(event, event.currentTarget, 'touch')) addListener(document, 'touchmove', onTouchMove, false) addListener(document, 'touchend', onTouchEnd, false) addListener(document, 'touchcancel', onTouchCancel, false) } } } return props }, [addListener, preventFocusOnPress, removeAllListeners]) useEffect(() => { return restoreTextSelection }, []) return { isPressed, pressProps, } }