import { useCallback, useEffect, useRef, useState } from "react"; import type { CartesianSpace, Range, Setter } from "@/types"; import { Direction } from "@/types"; import { chooseValueByDirection, extractEventCoordinates, isLeftMouseButton, isTouchEvent, minmax, positionToValue, valueToPosition, } from "@/util"; import { useSmoothAnimation } from "./animation"; import { useScroll } from "./scroll"; if (typeof TouchEvent === "undefined") { // @ts-expect-error - intentionally creating global window.TouchEvent = window.MouseEvent; } function extractEventCoordinateByDirection( event: MouseEvent | TouchEvent, direction: Direction, ): number { const { clientX, clientY } = extractEventCoordinates(event); return chooseValueByDirection(direction, clientX, clientY); } export function useSlider({ direction, origin, dimensions, valueRange, value, setValue, invert = false, }: { direction: Direction; origin: CartesianSpace; dimensions: CartesianSpace; valueRange: Range; value: number; setValue: Setter; invert?: boolean; }) { const [isDragging, setIsDragging] = useState(false); const sliderRef = useRef(null); // Slider UI refs const directionRef = useRef(direction); const originRef = useRef(origin); const dimensionsRef = useRef(dimensions); // Slider value refs const setValueRef = useRef(setValue); const valueRangeRef = useRef(valueRange); const maxPosition = useRef(0); // Internal position management const position = valueToPosition( value, chooseValueByDirection(direction, dimensions.x, dimensions.y), valueRange, ); const positionRef = useRef(position); // Hooks const smoothAnimation = useSmoothAnimation(); useEffect(() => { directionRef.current = direction; originRef.current = origin; dimensionsRef.current = dimensions; maxPosition.current = chooseValueByDirection( direction, dimensions.x, dimensions.y, ); positionRef.current = valueToPosition( value, maxPosition.current, valueRangeRef.current, ); }, [direction, origin, dimensions, value]); useEffect(() => { valueRangeRef.current = valueRange; }, [valueRange, valueRangeRef]); useEffect(() => { setValueRef.current = setValue; }, [setValue]); useEffect(() => { positionRef.current = position; }, [position]); // Setup drag handlers const calculatePosition = useCallback( (event: MouseEvent | TouchEvent) => { const dir = directionRef.current; const orig = originRef.current; const dims = dimensionsRef.current; const clientCoord = extractEventCoordinateByDirection(event, dir); const newPosition = minmax( clientCoord - chooseValueByDirection(dir, orig.x, orig.y), 0, chooseValueByDirection(dir, dims.x, dims.y), ); let newValue = positionToValue( newPosition, maxPosition.current, valueRangeRef.current, ); if (invert) { newValue = valueRangeRef.current.max - newValue; } setValueRef.current(newValue); }, [invert], ); const handleMove = useCallback( (event: MouseEvent | TouchEvent) => { event.preventDefault(); smoothAnimation(() => calculatePosition(event)); }, [calculatePosition, smoothAnimation], ); const handleEnd = useCallback(() => { document.removeEventListener("mousemove", handleMove); document.removeEventListener("mouseup", handleEnd); document.removeEventListener("touchmove", handleMove); document.removeEventListener("touchend", handleEnd); document.removeEventListener("touchcancel", handleEnd); setIsDragging(false); }, [handleMove]); const handleStart = useCallback( (event: MouseEvent | TouchEvent) => { if (!isTouchEvent(event) && !isLeftMouseButton(event.buttons)) { return; } event.preventDefault(); calculatePosition(event); setIsDragging(true); document.addEventListener("mousemove", handleMove); document.addEventListener("mouseup", handleEnd, { passive: true }); document.addEventListener("touchmove", handleMove); document.addEventListener("touchend", handleEnd, { passive: true }); document.addEventListener("touchcancel", handleEnd, { passive: true }); }, [calculatePosition, handleMove, handleEnd], ); // Setup scroll handlers const handleScrollUp = useCallback(() => { const dir = directionRef.current; const dims = dimensionsRef.current; const inc = chooseValueByDirection(dir, 1, -1); const newPosition = minmax( positionRef.current + inc, 0, chooseValueByDirection(dir, dims.x, dims.y), ); const newValue = positionToValue( newPosition, maxPosition.current, valueRangeRef.current, ); setValueRef.current(newValue); }, []); const handleScrollDown = useCallback(() => { const dir = directionRef.current; const dims = dimensionsRef.current; const inc = chooseValueByDirection(dir, -1, 1); const newPosition = minmax( positionRef.current + inc, 0, chooseValueByDirection(dir, dims.x, dims.y), ); const newValue = positionToValue( newPosition, maxPosition.current, valueRangeRef.current, ); setValueRef.current(newValue); }, []); const { addScrollListener, removeScrollListener } = useScroll({ targetRef: sliderRef, onScrollUp: handleScrollUp, onScrollDown: handleScrollDown, }); // Set up entry listeners useEffect(() => { const currentRef = sliderRef.current; if (currentRef) { addScrollListener(); currentRef.addEventListener("mousedown", handleStart); currentRef.addEventListener("touchstart", handleStart); } return () => { if (currentRef) { removeScrollListener(); currentRef.removeEventListener("mousedown", handleStart); currentRef.removeEventListener("touchstart", handleStart); } document.removeEventListener("mousemove", handleMove); document.removeEventListener("mouseup", handleEnd); document.removeEventListener("touchmove", handleMove); document.removeEventListener("touchend", handleEnd); document.removeEventListener("touchcancel", handleEnd); }; }, [ addScrollListener, removeScrollListener, handleStart, handleMove, handleEnd, ]); return { sliderRef, isDragging }; }