import {useLayoutEffect, useRef} from 'react' const noop = () => {} function createCursorLock(cursor: string) { const el = document.createElement('div') el.style.cssText = ` position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 9999999;` el.style.cursor = cursor document.body.appendChild(el) const relinquish = () => { document.body.removeChild(el) } return relinquish } export type UseDragOpts = { disabled?: boolean dontBlockMouseDown?: boolean lockCursorTo?: string onDragStart?: (event: MouseEvent) => void | false onDragEnd?: (dragHappened: boolean) => void onDrag: (dx: number, dy: number, event: MouseEvent) => void } export default function useDrag( target: HTMLElement | undefined | null, opts: UseDragOpts, ) { const optsRef = useRef(opts) optsRef.current = opts const modeRef = useRef<'dragStartCalled' | 'dragging' | 'notDragging'>( 'notDragging', ) const stateRef = useRef<{ dragHappened: boolean startPos: { x: number y: number } }>({dragHappened: false, startPos: {x: 0, y: 0}}) useLayoutEffect(() => { if (!target) return const getDistances = (event: MouseEvent): [number, number] => { const {startPos} = stateRef.current return [event.screenX - startPos.x, event.screenY - startPos.y] } let relinquishCursorLock = noop const dragHandler = (event: MouseEvent) => { if (!stateRef.current.dragHappened && optsRef.current.lockCursorTo) { relinquishCursorLock = createCursorLock(optsRef.current.lockCursorTo) } if (!stateRef.current.dragHappened) stateRef.current.dragHappened = true modeRef.current = 'dragging' const deltas = getDistances(event) optsRef.current.onDrag(deltas[0], deltas[1], event) } const dragEndHandler = () => { removeDragListeners() modeRef.current = 'notDragging' optsRef.current.onDragEnd && optsRef.current.onDragEnd(stateRef.current.dragHappened) relinquishCursorLock() relinquishCursorLock = noop } const addDragListeners = () => { document.addEventListener('mousemove', dragHandler) document.addEventListener('mouseup', dragEndHandler) } const removeDragListeners = () => { document.removeEventListener('mousemove', dragHandler) document.removeEventListener('mouseup', dragEndHandler) } const preventUnwantedClick = (event: MouseEvent) => { if (optsRef.current.disabled) return if (stateRef.current.dragHappened) { if ( !optsRef.current.dontBlockMouseDown && modeRef.current !== 'notDragging' ) { event.stopPropagation() event.preventDefault() } stateRef.current.dragHappened = false } } const dragStartHandler = (event: MouseEvent) => { const opts = optsRef.current if (opts.disabled === true) return if (event.button !== 0) return const resultOfStart = opts.onDragStart && opts.onDragStart(event) if (resultOfStart === false) return if (!opts.dontBlockMouseDown) { event.stopPropagation() event.preventDefault() } modeRef.current = 'dragStartCalled' const {screenX, screenY} = event stateRef.current.startPos = {x: screenX, y: screenY} stateRef.current.dragHappened = false addDragListeners() } const onMouseDown = (e: MouseEvent) => { dragStartHandler(e) } target.addEventListener('mousedown', onMouseDown) target.addEventListener('click', preventUnwantedClick) return () => { removeDragListeners() target.removeEventListener('mousedown', onMouseDown) target.removeEventListener('click', preventUnwantedClick) relinquishCursorLock() if (modeRef.current !== 'notDragging') { optsRef.current.onDragEnd && optsRef.current.onDragEnd(modeRef.current === 'dragging') } modeRef.current = 'notDragging' } }, [target]) }