import { HTMLAttributes, PointerEvent, WheelEvent, useEffect, useRef, useState, } from "react"; import cn from "classnames"; import styles from "./TouchScrollWrapper.module.css"; import { useCssProperty } from "@mt/utils"; const getNumberPxFormatter = (numberStr: string | null) => Number(numberStr?.replace(/px$/, "")); const setNumberPxFormatter = (number: number) => `${number}px`; const { abs, min } = Math; export const TouchScrollWrapper = ({ className, children, ...props }: HTMLAttributes) => { const contentRef = useRef(null); const containerRef = useRef(null); const scrollbarRef = useRef(null); const scrollbarHeight = useCssProperty( "--scrollbar-height", scrollbarRef, setNumberPxFormatter, getNumberPxFormatter ); const scrollbarVisibility = useCssProperty( "--scrollbar-visibility", scrollbarRef ); const scrollbarOffset = useCssProperty( "--scrollbar-offset", scrollbarRef, setNumberPxFormatter ); const [contentHeight, setContentHeight] = useState(0); const [containerHeight, setContainerHeight] = useState(0); const [startSwipeY, setStartSwipeY] = useState(0); const [startContentOffset, setStartContentOffset] = useState(0); const [pointerId, setPointerId] = useState(0); useEffect(() => { const containerEl = containerRef?.current; const contentEl = contentRef?.current; if (!(containerEl && contentEl)) return; const observer = new ResizeObserver(() => { setContainerHeight(containerEl.offsetHeight); setContentHeight(containerEl.scrollHeight); }); observer.observe(containerEl); observer.observe(contentEl); return () => { observer.disconnect(); }; }, []); useEffect(() => { if (containerHeight >= contentHeight) { scrollbarVisibility.value = "hidden"; } else { scrollbarHeight.value = (containerHeight / contentHeight) * containerHeight + 1; scrollbarVisibility.value = "visible"; } }, [contentHeight, containerHeight]); const handlePointerDown = (e: PointerEvent) => { setStartSwipeY(e.clientY); setStartContentOffset(containerRef?.current?.scrollTop || 0); }; const handleTouchScroll = (e: PointerEvent) => { const swipeDistance = startSwipeY - e.clientY; if ( e.pointerType !== "touch" || containerHeight >= contentHeight || (pointerId && pointerId !== e.pointerId) || abs(swipeDistance) < 2 ) { return; } setPointerId(e.pointerId); containerRef?.current?.scrollTo(0, startContentOffset + swipeDistance); }; const handleScroll = (e: WheelEvent) => { if (containerHeight >= contentHeight) { return; } containerRef?.current?.scrollBy(0, e.deltaY); }; const capturePointerUp = (e: PointerEvent) => { if (pointerId) { e.preventDefault(); e.stopPropagation(); setPointerId(0); } }; const captureScroll = () => { const { scrollTop } = containerRef.current as HTMLDivElement; const barHeight = (scrollbarHeight.value as number) + 1; // plus 1 safety px!; const barOffset = (scrollTop * barHeight) / containerHeight; const maxBarOffset = containerHeight - barHeight; scrollbarOffset.value = scrollTop + min(barOffset, maxBarOffset); }; return (
{children}
); };