183 lines
5.5 KiB
TypeScript
183 lines
5.5 KiB
TypeScript
import { useCallback, useLayoutEffect, useState, type ReactNode, type RefObject } from "react";
|
|
import {
|
|
type FlatList,
|
|
type LayoutChangeEvent,
|
|
type NativeScrollEvent,
|
|
type NativeSyntheticEvent,
|
|
type ScrollView,
|
|
} from "react-native";
|
|
import {
|
|
WebDesktopScrollbarOverlay,
|
|
useWebDesktopScrollbarMetrics,
|
|
type ScrollbarMetrics,
|
|
} from "./web-desktop-scrollbar";
|
|
import { isWeb as platformIsWeb } from "@/constants/platform";
|
|
|
|
const METRICS_EPSILON = 0.5;
|
|
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
|
|
|
|
function ensureHideScrollbarStyle(): void {
|
|
if (typeof document === "undefined") return;
|
|
if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return;
|
|
const style = document.createElement("style");
|
|
style.id = HIDE_SCROLLBAR_STYLE_ID;
|
|
style.textContent = `
|
|
[data-hide-scrollbar] {
|
|
scrollbar-width: none;
|
|
-ms-overflow-style: none;
|
|
scrollbar-gutter: auto;
|
|
}
|
|
|
|
[data-hide-scrollbar]::-webkit-scrollbar {
|
|
display: none;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
[data-hide-scrollbar]::-webkit-scrollbar-button {
|
|
display: none;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function metricsChanged(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean {
|
|
return (
|
|
Math.abs(a.offset - b.offset) > METRICS_EPSILON ||
|
|
Math.abs(a.viewportSize - b.viewportSize) > METRICS_EPSILON ||
|
|
Math.abs(a.contentSize - b.contentSize) > METRICS_EPSILON
|
|
);
|
|
}
|
|
|
|
// ── DOM element scrollbar ────────────────────────────────────────────
|
|
// Fully automatic: listens to scroll/input/resize events on the element,
|
|
// hides the native scrollbar, and returns a themed overlay or null.
|
|
|
|
export function useWebElementScrollbar(
|
|
elementRef: RefObject<HTMLElement | null>,
|
|
options?: {
|
|
enabled?: boolean;
|
|
contentRef?: RefObject<HTMLElement | null>;
|
|
},
|
|
): ReactNode {
|
|
const enabled = (options?.enabled ?? true) && platformIsWeb;
|
|
const contentRef = options?.contentRef;
|
|
|
|
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
|
|
offset: 0,
|
|
viewportSize: 0,
|
|
contentSize: 0,
|
|
});
|
|
|
|
useLayoutEffect(() => {
|
|
if (!enabled) return;
|
|
const element = elementRef.current;
|
|
if (!element) return;
|
|
|
|
type ScrollbarStyle = CSSStyleDeclaration & {
|
|
scrollbarWidth: string;
|
|
msOverflowStyle: string;
|
|
scrollbarGutter: string;
|
|
};
|
|
const style = element.style as ScrollbarStyle;
|
|
const previousScrollbarWidth = style.scrollbarWidth;
|
|
const previousMsOverflowStyle = style.msOverflowStyle;
|
|
const previousScrollbarGutter = style.scrollbarGutter;
|
|
|
|
element.setAttribute("data-hide-scrollbar", "");
|
|
style.scrollbarWidth = "none";
|
|
style.msOverflowStyle = "none";
|
|
style.scrollbarGutter = "auto";
|
|
ensureHideScrollbarStyle();
|
|
|
|
function update() {
|
|
const el = elementRef.current;
|
|
if (!el) return;
|
|
const next: ScrollbarMetrics = {
|
|
offset: el.scrollTop,
|
|
viewportSize: el.clientHeight,
|
|
contentSize: el.scrollHeight,
|
|
};
|
|
setMetrics((prev) => (metricsChanged(prev, next) ? next : prev));
|
|
}
|
|
|
|
element.addEventListener("scroll", update, { passive: true });
|
|
|
|
const resizeObserver = new ResizeObserver(update);
|
|
resizeObserver.observe(element);
|
|
const contentElement = contentRef?.current;
|
|
if (contentElement) {
|
|
resizeObserver.observe(contentElement);
|
|
}
|
|
|
|
update();
|
|
|
|
return () => {
|
|
element.removeEventListener("scroll", update);
|
|
resizeObserver.disconnect();
|
|
element.removeAttribute("data-hide-scrollbar");
|
|
style.scrollbarWidth = previousScrollbarWidth;
|
|
style.msOverflowStyle = previousMsOverflowStyle;
|
|
style.scrollbarGutter = previousScrollbarGutter;
|
|
};
|
|
}, [contentRef, elementRef, enabled]);
|
|
|
|
const onScrollToOffset = useCallback(
|
|
(offset: number) => {
|
|
elementRef.current?.scrollTo({ top: offset, behavior: "auto" });
|
|
},
|
|
[elementRef],
|
|
);
|
|
|
|
if (!enabled) return null;
|
|
|
|
return (
|
|
<WebDesktopScrollbarOverlay enabled metrics={metrics} onScrollToOffset={onScrollToOffset} />
|
|
);
|
|
}
|
|
|
|
// ── RN ScrollView / FlatList scrollbar ───────────────────────────────
|
|
// Returns event handlers to wire onto your ScrollView/FlatList plus
|
|
// a renderable overlay. The overlay is null when disabled.
|
|
|
|
interface WebScrollViewScrollbar {
|
|
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
|
onLayout: (event: LayoutChangeEvent) => void;
|
|
onContentSizeChange: (width: number, height: number) => void;
|
|
overlay: ReactNode;
|
|
}
|
|
|
|
export function useWebScrollViewScrollbar(
|
|
scrollableRef: RefObject<ScrollView | FlatList | null>,
|
|
options?: { enabled?: boolean },
|
|
): WebScrollViewScrollbar {
|
|
const enabled = (options?.enabled ?? true) && platformIsWeb;
|
|
const metricsHook = useWebDesktopScrollbarMetrics();
|
|
|
|
const onScrollToOffset = useCallback(
|
|
(offset: number) => {
|
|
const scrollable = scrollableRef.current;
|
|
if (!scrollable) return;
|
|
if ("scrollToOffset" in scrollable) {
|
|
scrollable.scrollToOffset({ offset, animated: false });
|
|
} else {
|
|
scrollable.scrollTo({ y: offset, animated: false });
|
|
}
|
|
},
|
|
[scrollableRef],
|
|
);
|
|
|
|
const overlay: ReactNode = enabled ? (
|
|
<WebDesktopScrollbarOverlay enabled metrics={metricsHook} onScrollToOffset={onScrollToOffset} />
|
|
) : null;
|
|
|
|
return {
|
|
onScroll: metricsHook.onScroll,
|
|
onLayout: metricsHook.onLayout,
|
|
onContentSizeChange: metricsHook.onContentSizeChange,
|
|
overlay,
|
|
};
|
|
}
|