import type React from 'react'; import { useMemo, useRef } from 'react'; import { useEffectEvent } from './useEffectEvent'; interface PartialPointerEvent extends Pick< React.PointerEvent, 'clientX' | 'clientY' | 'pointerId' | 'screenX' | 'screenY' > {} export interface MoveData { clientX: number; clientY: number; lastClientX: number; lastClientY: number; movementX: number; movementY: number; startClientX: number; startClientY: number; type: 'click' | 'move' | 'moveend' | 'movestart' | 'trace'; } export type MoveEventHandler = ( evt: React.PointerEvent, data: Readonly, ) => void; export type MoveStopButtonHandler = ( button: React.PointerEvent['button'], ) => boolean; export type MoveStopHandler = (evt: React.PointerEvent) => boolean; export type MoveNoticeEventHandler = React.PointerEventHandler; export interface MoveOptions { // The max number of pixels a user can shift the mouse pointer during a click for it to be considered a valid click (as opposed to a mouse drag). clickTolerance?: number; moveFinish?: MoveNoticeEventHandler; // Callbacks called before and after the move interaction. Used to control the mouse cursor. movePrepare?: MoveNoticeEventHandler; moveStop?: MoveStopHandler; // Condition to inhibit move interaction. For example, right drag can be prohibited. moveStopButton?: MoveStopButtonHandler; // Handler that is called when the element is moved. onMove?: MoveEventHandler; // Handler that is called when a move interaction ends. onMoveEnd?: MoveEventHandler; // Handler that is called when a move interaction starts. onMoveStart?: MoveEventHandler; // Handler that is called when the pointer is clicked. When this handler is called, no move interaction occurs. onPureClick?: MoveEventHandler; // Handler that is called when the pointer is moved. In other words, this is onPointerMove that can be used together. onTraceMove?: MoveEventHandler; onTraceMoveCapture?: MoveEventHandler; } export interface MoveProps { onPointerCancel?: React.PointerEventHandler; onPointerCancelCapture?: React.PointerEventHandler; onPointerDown?: React.PointerEventHandler; onPointerDownCapture?: React.PointerEventHandler; onPointerMove?: React.PointerEventHandler; onPointerMoveCapture?: React.PointerEventHandler; onPointerUp?: React.PointerEventHandler; onPointerUpCapture?: React.PointerEventHandler; } export interface MoveResult { // Props to spread on the target element. moveProps: MoveProps; } function createPartialPointerEvent({ clientX, clientY, pointerId, screenX, screenY, }: React.PointerEvent): PartialPointerEvent { return { clientX, clientY, pointerId, screenX, screenY }; } function createMoveData( type: MoveData['type'], evt: PartialPointerEvent, startEvt: PartialPointerEvent, lastEvt: PartialPointerEvent, ): MoveData { return { clientX: evt.clientX, clientY: evt.clientY, lastClientX: lastEvt.clientX, lastClientY: lastEvt.clientY, movementX: evt.screenX - lastEvt.screenX, movementY: evt.screenY - lastEvt.screenY, startClientX: startEvt.clientX, startClientY: startEvt.clientY, type, }; } function defaultMoveStopButton(button: number): boolean { return button !== 0; } function defaultMoveStop(): boolean { return false; } function defaultMovePrepare(evt: React.PointerEvent): void { evt.preventDefault(); } function defaultMoveFinish(): void { // noop } const warnToCallStopPropagation = < // eslint-disable-next-line @typescript-eslint/no-explicit-any F extends (evt: React.PointerEvent, ...args: never[]) => unknown, >( name: string, fn: F, ): F => ((...args) => { const evt = args[0]; const isPropagationStopped = evt.isPropagationStopped(); const ret = fn(...args); if (!isPropagationStopped && evt.isPropagationStopped()) { console.error(`Calling stopPropagation in "${name}" is deprecated.`); } return ret; }) as F; export function useMove({ clickTolerance, moveFinish: rawMoveFinish = defaultMoveFinish, movePrepare: rawMovePrepare = defaultMovePrepare, moveStop: rawMoveStop = defaultMoveStop, moveStopButton: rawMoveStopButton = defaultMoveStopButton, onMove: rawOnMove, onMoveEnd: rawOnMoveEnd, onMoveStart: rawOnMoveStart, onPureClick: rawOnPureClick, onTraceMove: rawOnTraceMove, onTraceMoveCapture: rawOnTraceMoveCapture, }: MoveOptions): MoveResult { const DEV = process.env.NODE_ENV !== 'production'; const state = useRef<{ lastEvent: PartialPointerEvent | null; lastMoveCaptureEvent: PartialPointerEvent | null; lastMoveEvent: PartialPointerEvent | null; movePropagationStopped: boolean; moveStarted: boolean; moveStopped: boolean; pointerDowned: boolean; startEvent: PartialPointerEvent | null; }>({ lastEvent: null, lastMoveCaptureEvent: null, lastMoveEvent: null, movePropagationStopped: false, moveStarted: false, moveStopped: false, pointerDowned: false, startEvent: null, }); const moveStopButton = useEffectEvent(rawMoveStopButton); const moveStop = useEffectEvent( DEV ? warnToCallStopPropagation('moveStop', rawMoveStop) : rawMoveStop, ); const movePrepare = useEffectEvent( DEV ? warnToCallStopPropagation('movePrepare', rawMovePrepare) : rawMovePrepare, ); const moveFinish = useEffectEvent( DEV ? warnToCallStopPropagation('moveFinish', rawMoveFinish) : rawMoveFinish, ); const onMoveStart = useEffectEvent(rawOnMoveStart); const onMove = useEffectEvent(rawOnMove); const onMoveEnd = useEffectEvent(rawOnMoveEnd); const onTraceMoveCapture = useEffectEvent( DEV && rawOnTraceMoveCapture ? warnToCallStopPropagation('onTraceMoveCapture', rawOnTraceMoveCapture) : rawOnTraceMoveCapture, ); const onTraceMove = useEffectEvent(rawOnTraceMove); const onPureClick = useEffectEvent(rawOnPureClick); const moveProps = useMemo(() => { const onPointerDownCapture: React.PointerEventHandler = (evt) => { (evt.target as Element).setPointerCapture(evt.pointerId); const startEvt = createPartialPointerEvent(evt); state.current.pointerDowned = true; state.current.moveStopped = onMoveStart || onMove || onMoveEnd ? moveStopButton(evt.button) || moveStop(evt) : true; state.current.moveStarted = false; state.current.movePropagationStopped = false; state.current.startEvent = startEvt; state.current.lastEvent = startEvt; if (!state.current.moveStopped) movePrepare(evt); }; const onPointerDown: React.PointerEventHandler = (evt): void => { if ( state.current.pointerDowned && state.current.startEvent && state.current.startEvent.pointerId === evt.pointerId ) { state.current.moveStarted = true; if (!state.current.moveStopped && onMoveStart) onMoveStart(evt, createMoveData('movestart', evt, evt, evt)); state.current.movePropagationStopped = evt.isPropagationStopped(); } }; const onPointerMoveCapture: React.PointerEventHandler = (evt) => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion onTraceMoveCapture!( evt, createMoveData('trace', evt, evt, state.current.lastMoveCaptureEvent ?? evt), ); state.current.lastMoveCaptureEvent = createPartialPointerEvent(evt); }; const onPointerMove: React.PointerEventHandler = (evt) => { if ( state.current.pointerDowned && state.current.startEvent && state.current.startEvent.pointerId === evt.pointerId ) { const moveData = createMoveData( 'move', evt, state.current.startEvent, state.current.lastEvent!, // eslint-disable-line @typescript-eslint/no-non-null-assertion ); if (state.current.moveStarted) { if (state.current.movePropagationStopped) evt.stopPropagation(); if (!state.current.moveStopped && onMove) onMove(evt, moveData); } else { if ( clickTolerance && Math.hypot(moveData.movementX, moveData.movementY) < clickTolerance ) { evt.stopPropagation(); return; } state.current.moveStarted = true; if (!state.current.moveStopped && onMoveStart) onMoveStart(evt, { ...moveData, type: 'movestart' }); state.current.movePropagationStopped = evt.isPropagationStopped(); } state.current.lastEvent = createPartialPointerEvent(evt); } if (onTraceMove) { onTraceMove(evt, createMoveData('trace', evt, evt, state.current.lastMoveEvent ?? evt)); state.current.lastMoveEvent = createPartialPointerEvent(evt); } }; const onPointerUpCapture: React.PointerEventHandler = (evt) => { state.current.pointerDowned = false; if (!state.current.moveStopped) moveFinish(evt); }; const onPointerUp: React.PointerEventHandler = (evt) => { if (state.current.startEvent && state.current.startEvent.pointerId === evt.pointerId) { const moveData = createMoveData( 'moveend', evt, state.current.startEvent, state.current.lastEvent!, // eslint-disable-line @typescript-eslint/no-non-null-assertion ); if (state.current.moveStarted) { if (state.current.movePropagationStopped) evt.stopPropagation(); if (!state.current.moveStopped && onMoveEnd) onMoveEnd(evt, moveData); } else if (onPureClick) { onPureClick(evt, { ...moveData, type: 'click' }); } state.current.startEvent = null; state.current.lastEvent = null; } }; return { ...(onTraceMoveCapture ? { onPointerMoveCapture } : {}), ...(onMoveStart || onMove || onMoveEnd || onTraceMove || onPureClick ? { onPointerCancel: onPointerUp, onPointerCancelCapture: onPointerUpCapture, onPointerDownCapture, onPointerMove, onPointerUp, onPointerUpCapture, } : {}), ...(onMoveStart && !onPureClick && !clickTolerance ? { onPointerDown } : {}), }; }, [ moveStopButton, moveStop, movePrepare, moveFinish, onMoveStart, onMove, onMoveEnd, onTraceMoveCapture, onTraceMove, onPureClick, clickTolerance, ]); return { moveProps }; }