diff --git a/packages/app/src/app/+html.tsx b/packages/app/src/app/+html.tsx index a5cd89c5a..bea8c752d 100644 --- a/packages/app/src/app/+html.tsx +++ b/packages/app/src/app/+html.tsx @@ -20,6 +20,22 @@ const webEcosystemStyles = /* css */ ` -webkit-user-select: text; user-select: text; } + + [data-testid="sidebar-agent-list-scroll"], + [data-testid="agent-chat-scroll"], + [data-testid="git-diff-scroll"], + [data-testid="file-explorer-tree-scroll"] { + scrollbar-width: none; + -ms-overflow-style: none; + } + + [data-testid="sidebar-agent-list-scroll"]::-webkit-scrollbar, + [data-testid="agent-chat-scroll"]::-webkit-scrollbar, + [data-testid="git-diff-scroll"]::-webkit-scrollbar, + [data-testid="file-explorer-tree-scroll"]::-webkit-scrollbar { + width: 0; + height: 0; + } `; function WebRespectfulStyleReset() { diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index a2d093029..2bcfb6b54 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -50,6 +50,10 @@ import type { DaemonClient } from "@server/client/daemon-client"; import { ToolCallDetailsContent } from "./tool-call-details"; import { QuestionFormCard } from "./question-form-card"; import { ToolCallSheetProvider } from "./tool-call-sheet"; +import { + WebDesktopScrollbarOverlay, + useWebDesktopScrollbarMetrics, +} from "./web-desktop-scrollbar"; import { createMarkdownStyles } from "@/styles/markdown-styles"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; @@ -95,12 +99,16 @@ export function AgentStreamView({ }: AgentStreamViewProps) { const flatListRef = useRef>(null); const { theme } = useUnistyles(); + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const insets = useSafeAreaInsets(); const [isNearBottom, setIsNearBottom] = useState(true); const hasScrolledInitially = useRef(false); const hasAutoScrolledOnce = useRef(false); const isNearBottomRef = useRef(true); const streamItemCountRef = useRef(0); + const streamScrollbarMetrics = useWebDesktopScrollbarMetrics(); const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState>(new Set()); const openFileExplorer = usePanelStore((state) => state.openFileExplorer); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); @@ -184,8 +192,12 @@ export function AgentStreamView({ isNearBottomRef.current = nearBottom; setIsNearBottom(nearBottom); } + + if (showDesktopWebScrollbar) { + streamScrollbarMetrics.onScroll(event); + } }, - [insets.bottom] + [insets.bottom, showDesktopWebScrollbar, streamScrollbarMetrics] ); const scrollToBottomInternal = useCallback( @@ -647,14 +659,25 @@ export function AgentStreamView({ data={flatListData} renderItem={renderStreamItem} keyExtractor={(item) => item.id} + testID="agent-chat-scroll" ListHeaderComponentStyle={headerGapStyle} contentContainerStyle={{ paddingVertical: 0, flexGrow: 1, }} style={stylesheet.list} + onLayout={ + showDesktopWebScrollbar + ? streamScrollbarMetrics.onLayout + : undefined + } onScroll={handleScroll} scrollEventThrottle={16} + onContentSizeChange={ + showDesktopWebScrollbar + ? streamScrollbarMetrics.onContentSizeChange + : undefined + } ListEmptyComponent={listEmptyComponent} ListHeaderComponent={listHeaderComponent} extraData={flatListExtraData} @@ -667,9 +690,22 @@ export function AgentStreamView({ initialNumToRender={12} windowSize={10} scrollEnabled={Platform.OS !== "web" || expandedInlineToolCallIds.size === 0} + showsVerticalScrollIndicator={!showDesktopWebScrollbar} inverted /> + { + flatListRef.current?.scrollToOffset({ + offset: nextOffset, + animated: false, + }); + streamScrollbarMetrics.setOffset(nextOffset); + }} + /> {/* Scroll to bottom button */} {!isNearBottom && ( diff --git a/packages/app/src/components/draggable-list.native.tsx b/packages/app/src/components/draggable-list.native.tsx index 99671635c..05432c039 100644 --- a/packages/app/src/components/draggable-list.native.tsx +++ b/packages/app/src/components/draggable-list.native.tsx @@ -18,10 +18,12 @@ export function DraggableList({ onDragEnd, style, contentContainerStyle, + testID, ListFooterComponent, ListHeaderComponent, ListEmptyComponent, showsVerticalScrollIndicator = true, + enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false, refreshing, onRefresh, simultaneousGestureRef, @@ -69,6 +71,7 @@ export function DraggableList({ return ( { onDragEnd: (data: T[]) => void; style?: StyleProp; contentContainerStyle?: StyleProp; + testID?: string; ListFooterComponent?: ReactElement | null; ListHeaderComponent?: ReactElement | null; ListEmptyComponent?: ReactElement | null; showsVerticalScrollIndicator?: boolean; + enableDesktopWebScrollbar?: boolean; refreshing?: boolean; onRefresh?: () => void; /** Fill remaining space when content is smaller than container */ diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index 74631db43..2f545e51f 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -1,5 +1,5 @@ -import { useCallback, useState, useRef, type ReactElement } from "react"; -import { View, ScrollView } from "react-native"; +import { useCallback, useRef, useState, type ReactElement } from "react"; +import { ScrollView, View } from "react-native"; import { DndContext, closestCenter, @@ -23,6 +23,10 @@ import type { DraggableListProps, DraggableRenderItemInfo, } from "./draggable-list.types"; +import { + WebDesktopScrollbarOverlay, + useWebDesktopScrollbarMetrics, +} from "./web-desktop-scrollbar"; export type { DraggableListProps, DraggableRenderItemInfo }; @@ -99,14 +103,18 @@ export function DraggableList({ onDragEnd, style, contentContainerStyle, + testID, ListFooterComponent, ListHeaderComponent, ListEmptyComponent, showsVerticalScrollIndicator = true, + enableDesktopWebScrollbar = false, // simultaneousGestureRef is native-only, ignored on web }: DraggableListProps) { const [activeId, setActiveId] = useState(null); const [items, setItems] = useState(data); + const scrollViewRef = useRef(null); + const scrollbarMetrics = useWebDesktopScrollbarMetrics(); // Sync items with data prop if (data !== items && !activeId) { @@ -151,39 +159,60 @@ export function DraggableList({ ); const ids = items.map((item, index) => keyExtractor(item, index)); + const showCustomScrollbar = enableDesktopWebScrollbar; return ( - - {ListHeaderComponent} - {items.length === 0 && ListEmptyComponent} - + - - {items.map((item, index) => { - const id = keyExtractor(item, index); - return ( - - ); - })} - - - {ListFooterComponent} - + {ListHeaderComponent} + {items.length === 0 && ListEmptyComponent} + + + {items.map((item, index) => { + const id = keyExtractor(item, index); + return ( + + ); + })} + + + {ListFooterComponent} + + { + scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false }); + scrollbarMetrics.setOffset(nextOffset); + }} + /> + ); } diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index e75b6a0dd..7a87e4fc7 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -5,6 +5,9 @@ import { FlatList, Image as RNImage, ListRenderItemInfo, + type LayoutChangeEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, Pressable, ScrollView as RNScrollView, Text, @@ -63,6 +66,10 @@ import { type SortOption, } from "@/stores/panel-store"; import { formatTimeAgo } from "@/utils/time"; +import { + WebDesktopScrollbarOverlay, + useWebDesktopScrollbarMetrics, +} from "@/components/web-desktop-scrollbar"; const SORT_OPTIONS: { value: SortOption; label: string }[] = [ { value: "name", label: "Name" }, @@ -86,6 +93,7 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const { connectionStates } = useDaemonConnections(); const daemonProfile = connectionStates.get(serverId)?.daemon; @@ -136,6 +144,8 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { const [expandedPaths, setExpandedPaths] = useState>(() => new Set(["."])); const [containerWidth, setContainerWidth] = useState(0); const wasInlinePreviewVisibleRef = useRef(false); + const treeListRef = useRef>(null); + const treeScrollbarMetrics = useWebDesktopScrollbarMetrics(); // Bottom sheet for file preview (mobile) const previewSheetRef = useRef(null); @@ -592,6 +602,24 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { ); } + const handleTreeListScroll = useCallback( + (event: NativeSyntheticEvent) => { + if (showDesktopWebScrollbar) { + treeScrollbarMetrics.onScroll(event); + } + }, + [showDesktopWebScrollbar, treeScrollbarMetrics] + ); + + const handleTreeListLayout = useCallback( + (event: LayoutChangeEvent) => { + if (showDesktopWebScrollbar) { + treeScrollbarMetrics.onLayout(event); + } + }, + [showDesktopWebScrollbar, treeScrollbarMetrics] + ); + return ( row.entry.path} + testID="file-explorer-tree-scroll" contentContainerStyle={styles.entriesContent} + onLayout={ + showDesktopWebScrollbar ? handleTreeListLayout : undefined + } + onScroll={ + showDesktopWebScrollbar ? handleTreeListScroll : undefined + } + onContentSizeChange={ + showDesktopWebScrollbar + ? treeScrollbarMetrics.onContentSizeChange + : undefined + } + scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined} + showsVerticalScrollIndicator={!showDesktopWebScrollbar} initialNumToRender={24} maxToRenderPerBatch={40} windowSize={12} /> + { + treeListRef.current?.scrollToOffset({ + offset: nextOffset, + animated: false, + }); + treeScrollbarMetrics.setOffset(nextOffset); + }} + /> ) : ( @@ -741,15 +795,41 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { row.entry.path} + testID="file-explorer-tree-scroll" contentContainerStyle={styles.entriesContent} + onLayout={ + showDesktopWebScrollbar ? handleTreeListLayout : undefined + } + onScroll={ + showDesktopWebScrollbar ? handleTreeListScroll : undefined + } + onContentSizeChange={ + showDesktopWebScrollbar + ? treeScrollbarMetrics.onContentSizeChange + : undefined + } + scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined} + showsVerticalScrollIndicator={!showDesktopWebScrollbar} initialNumToRender={24} maxToRenderPerBatch={40} windowSize={12} /> + { + treeListRef.current?.scrollToOffset({ + offset: nextOffset, + animated: false, + }); + treeScrollbarMetrics.setOffset(nextOffset); + }} + /> )} diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 0043e1747..750727258 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -12,7 +12,7 @@ import { type NativeScrollEvent, } from "react-native"; import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { Archive, @@ -50,6 +50,10 @@ import { type ActionStatus, } from "@/components/ui/dropdown-menu"; import { GitHubIcon } from "@/components/icons/github-icon"; +import { + WebDesktopScrollbarOverlay, + useWebDesktopScrollbarMetrics, +} from "@/components/web-desktop-scrollbar"; import { buildHostAgentDraftRoute } from "@/utils/host-routes"; import { openExternalUrl } from "@/utils/open-external-url"; @@ -469,6 +473,9 @@ type DiffFlatItem = export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { const { theme } = useUnistyles(); + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const router = useRouter(); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [actionError, setActionError] = useState(null); @@ -517,6 +524,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { const [isManualRefresh, setIsManualRefresh] = useState(false); const [expandedByPath, setExpandedByPath] = useState>({}); const diffListRef = useRef>(null); + const diffScrollbarMetrics = useWebDesktopScrollbarMetrics(); const diffListScrollOffsetRef = useRef(0); const diffListViewportHeightRef = useRef(0); const headerHeightByPathRef = useRef>({}); @@ -623,17 +631,29 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { bodyHeightByPathRef.current[path] = height; }, []); - const handleDiffListScroll = useCallback((event: NativeSyntheticEvent) => { - diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; - }, []); + const handleDiffListScroll = useCallback( + (event: NativeSyntheticEvent) => { + diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; + if (showDesktopWebScrollbar) { + diffScrollbarMetrics.onScroll(event); + } + }, + [diffScrollbarMetrics, showDesktopWebScrollbar] + ); - const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => { - const height = event.nativeEvent.layout.height; - if (!Number.isFinite(height) || height <= 0) { - return; - } - diffListViewportHeightRef.current = height; - }, []); + const handleDiffListLayout = useCallback( + (event: LayoutChangeEvent) => { + const height = event.nativeEvent.layout.height; + if (!Number.isFinite(height) || height <= 0) { + return; + } + diffListViewportHeightRef.current = height; + if (showDesktopWebScrollbar) { + diffScrollbarMetrics.onLayout(event); + } + }, + [diffScrollbarMetrics, showDesktopWebScrollbar] + ); const computeHeaderOffset = useCallback( (path: string): number => { @@ -956,7 +976,13 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { testID="git-diff-scroll" onLayout={handleDiffListLayout} onScroll={handleDiffListScroll} + onContentSizeChange={ + showDesktopWebScrollbar + ? diffScrollbarMetrics.onContentSizeChange + : undefined + } scrollEventThrottle={16} + showsVerticalScrollIndicator={!showDesktopWebScrollbar} onRefresh={handleRefresh} refreshing={isManualRefresh && isDiffFetching} // Mixed-height rows (header + potentially very large body) are prone to clipping artifacts. @@ -1331,7 +1357,20 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { {prErrorMessage} ) : null} - {bodyContent} + + {bodyContent} + { + diffListRef.current?.scrollToOffset({ + offset: nextOffset, + animated: false, + }); + diffScrollbarMetrics.setOffset(nextOffset); + }} + /> + ); } @@ -1531,6 +1570,7 @@ const styles = StyleSheet.create((theme) => ({ diffContainer: { flex: 1, minHeight: 0, + position: "relative", }, scrollView: { flex: 1, diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx index 1d5d394e0..8488d1048 100644 --- a/packages/app/src/components/sidebar-agent-list.tsx +++ b/packages/app/src/components/sidebar-agent-list.tsx @@ -16,7 +16,11 @@ import { type MutableRefObject, } from "react"; import { router, usePathname } from "expo-router"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { + StyleSheet, + UnistylesRuntime, + useUnistyles, +} from "react-native-unistyles"; import { type GestureType } from "react-native-gesture-handler"; import { Archive, Check, ChevronDown } from "lucide-react-native"; import { @@ -434,6 +438,9 @@ export function SidebarAgentList({ }: SidebarAgentListProps) { const { theme } = useUnistyles(); const pathname = usePathname(); + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const [isProjectFilterOpen, setIsProjectFilterOpen] = useState(false); const projectFilterAnchorRef = useRef(null); @@ -820,10 +827,12 @@ export function SidebarAgentList({ styles.listContent, isSelectionMode ? styles.listContentSelectionMode : null, ]} + testID="sidebar-agent-list-scroll" keyExtractor={keyExtractor} renderItem={renderRow} onDragEnd={() => {}} showsVerticalScrollIndicator={false} + enableDesktopWebScrollbar={showDesktopWebScrollbar} ListFooterComponent={listFooterComponent} refreshing={isRefreshing} onRefresh={onRefresh} diff --git a/packages/app/src/components/web-desktop-scrollbar.math.ts b/packages/app/src/components/web-desktop-scrollbar.math.ts new file mode 100644 index 000000000..fbb6d7683 --- /dev/null +++ b/packages/app/src/components/web-desktop-scrollbar.math.ts @@ -0,0 +1,81 @@ +const DEFAULT_MIN_HANDLE_SIZE = 36; + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export type VerticalScrollbarGeometryInput = { + viewportSize: number; + contentSize: number; + offset: number; + minHandleSize?: number; +}; + +export type VerticalScrollbarGeometry = { + isVisible: boolean; + maxScrollOffset: number; + handleSize: number; + handleOffset: number; + maxHandleOffset: number; +}; + +export function computeVerticalScrollbarGeometry( + input: VerticalScrollbarGeometryInput +): VerticalScrollbarGeometry { + const viewportSize = Number.isFinite(input.viewportSize) + ? Math.max(0, input.viewportSize) + : 0; + const contentSize = Number.isFinite(input.contentSize) + ? Math.max(0, input.contentSize) + : 0; + const minHandleSize = Number.isFinite(input.minHandleSize) + ? Math.max(0, input.minHandleSize ?? DEFAULT_MIN_HANDLE_SIZE) + : DEFAULT_MIN_HANDLE_SIZE; + + const maxScrollOffset = Math.max(0, contentSize - viewportSize); + if (maxScrollOffset <= 0 || viewportSize <= 0 || contentSize <= 0) { + return { + isVisible: false, + maxScrollOffset: 0, + handleSize: 0, + handleOffset: 0, + maxHandleOffset: 0, + }; + } + + const rawHandleSize = (viewportSize * viewportSize) / contentSize; + const handleSize = clamp(rawHandleSize, minHandleSize, viewportSize); + const maxHandleOffset = Math.max(0, viewportSize - handleSize); + const clampedOffset = clamp(input.offset, 0, maxScrollOffset); + const handleOffset = + maxScrollOffset > 0 + ? (clampedOffset / maxScrollOffset) * maxHandleOffset + : 0; + + return { + isVisible: true, + maxScrollOffset, + handleSize, + handleOffset, + maxHandleOffset, + }; +} + +export type ScrollOffsetFromDragDeltaInput = { + startOffset: number; + dragDelta: number; + maxScrollOffset: number; + maxHandleOffset: number; +}; + +export function computeScrollOffsetFromDragDelta( + input: ScrollOffsetFromDragDeltaInput +): number { + if (input.maxScrollOffset <= 0 || input.maxHandleOffset <= 0) { + return clamp(input.startOffset, 0, Math.max(0, input.maxScrollOffset)); + } + + const scrollPerPixel = input.maxScrollOffset / input.maxHandleOffset; + const nextOffset = input.startOffset + input.dragDelta * scrollPerPixel; + return clamp(nextOffset, 0, input.maxScrollOffset); +} diff --git a/packages/app/src/components/web-desktop-scrollbar.test.ts b/packages/app/src/components/web-desktop-scrollbar.test.ts new file mode 100644 index 000000000..91bcafbab --- /dev/null +++ b/packages/app/src/components/web-desktop-scrollbar.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { + computeScrollOffsetFromDragDelta, + computeVerticalScrollbarGeometry, +} from "./web-desktop-scrollbar.math"; + +describe("computeVerticalScrollbarGeometry", () => { + it("returns hidden geometry when content does not overflow", () => { + const geometry = computeVerticalScrollbarGeometry({ + viewportSize: 500, + contentSize: 500, + offset: 0, + minHandleSize: 36, + }); + + expect(geometry).toEqual({ + isVisible: false, + maxScrollOffset: 0, + handleSize: 0, + handleOffset: 0, + maxHandleOffset: 0, + }); + }); + + it("computes visible geometry when content overflows", () => { + const geometry = computeVerticalScrollbarGeometry({ + viewportSize: 500, + contentSize: 2000, + offset: 375, + minHandleSize: 36, + }); + + expect(geometry).toEqual({ + isVisible: true, + maxScrollOffset: 1500, + handleSize: 125, + handleOffset: 93.75, + maxHandleOffset: 375, + }); + }); + + it("clamps handle size to min and offset to bounds", () => { + const geometry = computeVerticalScrollbarGeometry({ + viewportSize: 100, + contentSize: 10000, + offset: 99999, + minHandleSize: 24, + }); + + expect(geometry).toEqual({ + isVisible: true, + maxScrollOffset: 9900, + handleSize: 24, + handleOffset: 76, + maxHandleOffset: 76, + }); + }); +}); + +describe("computeScrollOffsetFromDragDelta", () => { + it("maps drag distance proportionally to scroll offset", () => { + const nextOffset = computeScrollOffsetFromDragDelta({ + startOffset: 250, + dragDelta: 50, + maxScrollOffset: 1000, + maxHandleOffset: 200, + }); + + expect(nextOffset).toBe(500); + }); + + it("clamps to scroll bounds", () => { + const nextOffset = computeScrollOffsetFromDragDelta({ + startOffset: 900, + dragDelta: 1000, + maxScrollOffset: 1000, + maxHandleOffset: 200, + }); + + expect(nextOffset).toBe(1000); + }); +}); diff --git a/packages/app/src/components/web-desktop-scrollbar.tsx b/packages/app/src/components/web-desktop-scrollbar.tsx new file mode 100644 index 000000000..4f97a221b --- /dev/null +++ b/packages/app/src/components/web-desktop-scrollbar.tsx @@ -0,0 +1,214 @@ +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, + }, +}));