Files
paseo/packages/app/src/components/web-desktop-scrollbar.tsx
T

215 lines
5.9 KiB
TypeScript

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<ScrollbarMetrics>({
offset: 0,
viewportSize: 0,
contentSize: 0,
});
const setMetricsIfChanged = useCallback((next: ScrollbarMetrics) => {
setMetrics((previous) => (areMetricsEqual(previous, next) ? previous : next));
}, []);
const onScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
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 (
<View style={styles.overlay} pointerEvents="box-none">
<Pressable
style={[
styles.handle,
{
top: geometry.handleOffset,
height: geometry.handleSize,
backgroundColor: handleColor,
opacity: handleOpacity,
},
Platform.OS === "web" &&
({ cursor: "grab", touchAction: "none", userSelect: "none" } as any),
]}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
{...panResponder.panHandlers}
/>
</View>
);
}
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,
},
}));