143 lines
3.8 KiB
TypeScript
143 lines
3.8 KiB
TypeScript
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<HTMLDivElement>) => {
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const scrollbarRef = useRef<HTMLDivElement>(null);
|
|
|
|
const scrollbarHeight = useCssProperty<number>(
|
|
"--scrollbar-height",
|
|
scrollbarRef,
|
|
setNumberPxFormatter,
|
|
getNumberPxFormatter
|
|
);
|
|
const scrollbarVisibility = useCssProperty<string>(
|
|
"--scrollbar-visibility",
|
|
scrollbarRef
|
|
);
|
|
const scrollbarOffset = useCssProperty<number>(
|
|
"--scrollbar-offset",
|
|
scrollbarRef,
|
|
setNumberPxFormatter
|
|
);
|
|
|
|
const [contentHeight, setContentHeight] = useState<number>(0);
|
|
const [containerHeight, setContainerHeight] = useState<number>(0);
|
|
const [startSwipeY, setStartSwipeY] = useState<number>(0);
|
|
const [startContentOffset, setStartContentOffset] = useState<number>(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 (
|
|
<div
|
|
className={cn(styles.root, className)}
|
|
ref={containerRef}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handleTouchScroll}
|
|
onPointerUpCapture={capturePointerUp}
|
|
onWheel={handleScroll}
|
|
onScroll={captureScroll}
|
|
{...props}
|
|
>
|
|
<div className={styles.scrollbar} ref={scrollbarRef} />
|
|
|
|
<div ref={contentRef}>{children}</div>
|
|
</div>
|
|
);
|
|
};
|