import { useCallback, useMemo, useRef, useState } from "react"; import { PanResponder, Platform, Pressable, View, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { computeScrollOffsetFromDragDelta, computeVerticalScrollbarGeometry, } from "./web-desktop-scrollbar.math"; const METRICS_EPSILON = 0.5; function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } type ScrollbarMetrics = { offset: number; viewportSize: number; contentSize: number; }; function areMetricsEqual(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 ); } export function useWebDesktopScrollbarMetrics() { const [metrics, setMetrics] = useState({ offset: 0, viewportSize: 0, contentSize: 0, }); const setMetricsIfChanged = useCallback((next: ScrollbarMetrics) => { setMetrics((previous) => (areMetricsEqual(previous, next) ? previous : next)); }, []); const onScroll = useCallback( (event: NativeSyntheticEvent) => { const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent; setMetricsIfChanged({ offset: Math.max(0, contentOffset.y), viewportSize: Math.max(0, layoutMeasurement.height), contentSize: Math.max(0, contentSize.height), }); }, [setMetricsIfChanged] ); const onLayout = useCallback( (event: LayoutChangeEvent) => { const viewportSize = Math.max(0, event.nativeEvent.layout.height); setMetrics((previous) => { const next = { ...previous, viewportSize }; return areMetricsEqual(previous, next) ? previous : next; }); }, [] ); const onContentSizeChange = useCallback((_width: number, height: number) => { const contentSize = Math.max(0, height); setMetrics((previous) => { const next = { ...previous, contentSize }; return areMetricsEqual(previous, next) ? previous : next; }); }, []); const setOffset = useCallback((offset: number) => { const clampedOffset = Math.max(0, offset); setMetrics((previous) => { const next = { ...previous, offset: clampedOffset }; return areMetricsEqual(previous, next) ? previous : next; }); }, []); return { ...metrics, onScroll, onLayout, onContentSizeChange, setOffset, }; } type WebDesktopScrollbarOverlayProps = { enabled: boolean; metrics: ScrollbarMetrics; onScrollToOffset: (offset: number) => void; inverted?: boolean; }; export function WebDesktopScrollbarOverlay({ enabled, metrics, onScrollToOffset, inverted = false, }: WebDesktopScrollbarOverlayProps) { const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); const [isDragging, setIsDragging] = useState(false); const dragStartOffsetRef = useRef(0); const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize); const normalizedOffset = inverted ? Math.max(0, maxScrollOffset - clamp(metrics.offset, 0, maxScrollOffset)) : clamp(metrics.offset, 0, maxScrollOffset); const geometry = useMemo( () => computeVerticalScrollbarGeometry({ viewportSize: metrics.viewportSize, contentSize: metrics.contentSize, offset: normalizedOffset, }), [metrics.contentSize, metrics.viewportSize, normalizedOffset] ); const panResponder = useMemo( () => PanResponder.create({ onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true, onPanResponderGrant: () => { dragStartOffsetRef.current = normalizedOffset; setIsDragging(true); }, onPanResponderMove: (_event, gestureState) => { const nextNormalizedOffset = computeScrollOffsetFromDragDelta({ startOffset: dragStartOffsetRef.current, dragDelta: gestureState.dy, maxScrollOffset: geometry.maxScrollOffset, maxHandleOffset: geometry.maxHandleOffset, }); const nextOffset = inverted ? geometry.maxScrollOffset - nextNormalizedOffset : nextNormalizedOffset; onScrollToOffset(nextOffset); }, onPanResponderRelease: () => { setIsDragging(false); }, onPanResponderTerminate: () => { setIsDragging(false); }, }), [ geometry.maxHandleOffset, geometry.maxScrollOffset, inverted, normalizedOffset, onScrollToOffset, ] ); if (!enabled || !geometry.isVisible) { return null; } const handleOpacity = isDragging ? 0.52 : isHovered ? 0.4 : 0.28; const handleColor = isDragging || isHovered ? theme.colors.foreground : theme.colors.foregroundMuted; return ( setIsHovered(true)} onHoverOut={() => setIsHovered(false)} {...panResponder.panHandlers} /> ); } const styles = StyleSheet.create(() => ({ overlay: { position: "absolute", top: 0, right: 0, bottom: 0, width: 12, alignItems: "center", justifyContent: "flex-start", zIndex: 10, }, handle: { position: "absolute", width: 6, borderRadius: 999, }, }));