WhiteNightsAdminPanel/src/preview/components/TouchScrollWrapper/TouchScrollWrapper.tsx
2025-04-15 21:12:43 +03:00

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>
);
};