From a91f79053c6abc700f2db273202ac2390226f4bd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 2 Apr 2026 10:51:00 +0700 Subject: [PATCH] fix: themed scrollbar on message input and reusable scrollbar hooks Add useWebElementScrollbar (for DOM elements) and useWebScrollViewScrollbar (for RN ScrollView/FlatList) hooks that return renderable overlays, replacing manual WebDesktopScrollbarOverlay wiring across all consumers. Apply the themed scrollbar to the message input textarea. Tint the dark-mode scrollbar handle to match the teal-tinted dark theme. Closes #174 --- .../app/src/components/draggable-list.web.tsx | 26 ++- .../app/src/components/file-explorer-pane.tsx | 51 +----- packages/app/src/components/file-pane.tsx | 73 ++------- packages/app/src/components/git-diff-pane.tsx | 36 ++-- packages/app/src/components/message-input.tsx | 101 +++++++----- .../src/components/stream-strategy-web.tsx | 68 +------- .../app/src/components/use-web-scrollbar.tsx | 155 ++++++++++++++++++ .../src/components/web-desktop-scrollbar.tsx | 2 +- packages/app/src/styles/theme.ts | 2 +- 9 files changed, 274 insertions(+), 240 deletions(-) create mode 100644 packages/app/src/components/use-web-scrollbar.tsx diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index c2e435826..260e9388b 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -20,7 +20,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types"; -import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar"; +import { useWebScrollViewScrollbar } from "./use-web-scrollbar"; export type { DraggableListProps, DraggableRenderItemInfo }; @@ -133,8 +133,11 @@ export function DraggableList({ const [activeId, setActiveId] = useState(null); const [dragItems, setDragItems] = useState(null); const items = dragItems ?? data; + const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled; const scrollViewRef = useRef(null); - const scrollbarMetrics = useWebDesktopScrollbarMetrics(); + const scrollbar = useWebScrollViewScrollbar(scrollViewRef, { + enabled: showCustomScrollbar, + }); const sensors = useSensors( useSensor(PointerSensor, { @@ -177,7 +180,6 @@ export function DraggableList({ ); const ids = items.map((item, index) => keyExtractor(item, index)); - const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled; const wrapperStyle = [ { position: "relative" as const }, scrollEnabled ? { flex: 1, minHeight: 0 } : null, @@ -193,12 +195,10 @@ export function DraggableList({ style={style} contentContainerStyle={contentContainerStyle} showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator} - onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined} - onContentSizeChange={ - showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined - } - onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined} - scrollEventThrottle={showCustomScrollbar ? 16 : undefined} + onLayout={scrollbar.onLayout} + onContentSizeChange={scrollbar.onContentSizeChange} + onScroll={scrollbar.onScroll} + scrollEventThrottle={16} > {ListHeaderComponent} {items.length === 0 && ListEmptyComponent} @@ -259,13 +259,7 @@ export function DraggableList({ {ListFooterComponent} )} - { - scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false }); - }} - /> + {scrollbar.overlay} ); } diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 5c0632072..08a6dd284 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -4,9 +4,6 @@ import { ActivityIndicator, FlatList, ListRenderItemInfo, - type LayoutChangeEvent, - type NativeScrollEvent, - type NativeSyntheticEvent, Pressable, Text, View, @@ -53,10 +50,7 @@ import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-action import { usePanelStore, type SortOption } from "@/stores/panel-store"; import { formatTimeAgo } from "@/utils/time"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; -import { - WebDesktopScrollbarOverlay, - useWebDesktopScrollbarMetrics, -} from "@/components/web-desktop-scrollbar"; +import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; const SORT_OPTIONS: { value: SortOption; label: string }[] = [ { value: "name", label: "Name" }, @@ -152,7 +146,9 @@ export function FileExplorerPane({ const [expandedPaths, setExpandedPaths] = useState>(() => new Set(["."])); const treeListRef = useRef>(null); - const treeScrollbarMetrics = useWebDesktopScrollbarMetrics(); + const scrollbar = useWebScrollViewScrollbar(treeListRef, { + enabled: showDesktopWebScrollbar, + }); const hasInitializedRef = useRef(false); @@ -502,24 +498,6 @@ export function FileExplorerPane({ }); }, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]); - const handleTreeListScroll = useCallback( - (event: NativeSyntheticEvent) => { - if (showDesktopWebScrollbar) { - treeScrollbarMetrics.onScroll(event); - } - }, - [showDesktopWebScrollbar, treeScrollbarMetrics], - ); - - const handleTreeListLayout = useCallback( - (event: LayoutChangeEvent) => { - if (showDesktopWebScrollbar) { - treeScrollbarMetrics.onLayout(event); - } - }, - [showDesktopWebScrollbar, treeScrollbarMetrics], - ); - if (!hasWorkspaceScope) { return ( @@ -598,27 +576,16 @@ export function FileExplorerPane({ keyExtractor={(row) => 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} + onLayout={scrollbar.onLayout} + onScroll={scrollbar.onScroll} + onContentSizeChange={scrollbar.onContentSizeChange} + scrollEventThrottle={16} showsVerticalScrollIndicator={!showDesktopWebScrollbar} initialNumToRender={24} maxToRenderPerBatch={40} windowSize={12} /> - { - treeListRef.current?.scrollToOffset({ - offset: nextOffset, - animated: false, - }); - }} - /> + {scrollbar.overlay} )} diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 2f375cf60..b92e204e3 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef } from "react"; +import React, { useMemo, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { ActivityIndicator, @@ -7,17 +7,11 @@ import { Text, View, Platform, - type LayoutChangeEvent, - type NativeScrollEvent, - type NativeSyntheticEvent, } from "react-native"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; -import { - WebDesktopScrollbarOverlay, - useWebDesktopScrollbarMetrics, -} from "@/components/web-desktop-scrollbar"; +import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { highlightCode, darkHighlightColors, @@ -123,9 +117,10 @@ function FilePreviewBody({ const colorMap = isDark ? darkHighlightColors : lightHighlightColors; const baseColor = isDark ? "#c9d1d9" : "#24292f"; - const enablePreviewDesktopScrollbar = showDesktopWebScrollbar; const previewScrollRef = useRef(null); - const previewScrollbarMetrics = useWebDesktopScrollbarMetrics(); + const scrollbar = useWebScrollViewScrollbar(previewScrollRef, { + enabled: showDesktopWebScrollbar, + }); const highlightedLines = useMemo(() => { if (!preview || preview.kind !== "text") { @@ -140,24 +135,6 @@ function FilePreviewBody({ return lineNumberGutterWidth(highlightedLines.length); }, [highlightedLines]); - const handlePreviewScroll = useCallback( - (event: NativeSyntheticEvent) => { - if (enablePreviewDesktopScrollbar) { - previewScrollbarMetrics.onScroll(event); - } - }, - [enablePreviewDesktopScrollbar, previewScrollbarMetrics], - ); - - const handlePreviewLayout = useCallback( - (event: LayoutChangeEvent) => { - if (enablePreviewDesktopScrollbar) { - previewScrollbarMetrics.onLayout(event); - } - }, - [enablePreviewDesktopScrollbar, previewScrollbarMetrics], - ); - if (isLoading && !preview) { return ( @@ -197,13 +174,11 @@ function FilePreviewBody({ {isMobile ? ( {codeLines} @@ -218,13 +193,7 @@ function FilePreviewBody({ )} - { - previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false }); - }} - /> + {scrollbar.overlay} ); } @@ -236,13 +205,11 @@ function FilePreviewBody({ ref={previewScrollRef} style={styles.previewContent} contentContainerStyle={styles.previewImageScrollContent} - onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined} - onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined} - onContentSizeChange={ - enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined - } - scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined} - showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar} + onLayout={scrollbar.onLayout} + onScroll={scrollbar.onScroll} + onContentSizeChange={scrollbar.onContentSizeChange} + scrollEventThrottle={16} + showsVerticalScrollIndicator={!showDesktopWebScrollbar} > - { - previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false }); - }} - /> + {scrollbar.overlay} ); } diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 12a85fe3a..07968af47 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -62,10 +62,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip import { GitHubIcon } from "@/components/icons/github-icon"; import { buildGitActions, type GitActions } from "@/components/git-actions-policy"; import { lineNumberGutterWidth } from "@/components/code-insets"; -import { - WebDesktopScrollbarOverlay, - useWebDesktopScrollbarMetrics, -} from "@/components/web-desktop-scrollbar"; +import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { openExternalUrl } from "@/utils/open-external-url"; import { GitActionsSplitButton } from "@/components/git-actions-split-button"; @@ -429,7 +426,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const [isManualRefresh, setIsManualRefresh] = useState(false); const [expandedByPath, setExpandedByPath] = useState>({}); const diffListRef = useRef>(null); - const diffScrollbarMetrics = useWebDesktopScrollbarMetrics(); + const scrollbar = useWebScrollViewScrollbar(diffListRef, { + enabled: showDesktopWebScrollbar, + }); const diffListScrollOffsetRef = useRef(0); const diffListViewportHeightRef = useRef(0); const headerHeightByPathRef = useRef>({}); @@ -515,11 +514,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const handleDiffListScroll = useCallback( (event: NativeSyntheticEvent) => { diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; - if (showDesktopWebScrollbar) { - diffScrollbarMetrics.onScroll(event); - } + scrollbar.onScroll(event); }, - [diffScrollbarMetrics, showDesktopWebScrollbar], + [scrollbar.onScroll], ); const handleDiffListLayout = useCallback( @@ -529,11 +526,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi return; } diffListViewportHeightRef.current = height; - if (showDesktopWebScrollbar) { - diffScrollbarMetrics.onLayout(event); - } + scrollbar.onLayout(event); }, - [diffScrollbarMetrics, showDesktopWebScrollbar], + [scrollbar.onLayout], ); const computeHeaderOffset = useCallback( @@ -844,9 +839,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi testID="git-diff-scroll" onLayout={handleDiffListLayout} onScroll={handleDiffListScroll} - onContentSizeChange={ - showDesktopWebScrollbar ? diffScrollbarMetrics.onContentSizeChange : undefined - } + onContentSizeChange={scrollbar.onContentSizeChange} scrollEventThrottle={16} showsVerticalScrollIndicator={!showDesktopWebScrollbar} onRefresh={handleRefresh} @@ -1085,16 +1078,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi {bodyContent} - { - diffListRef.current?.scrollToOffset({ - offset: nextOffset, - animated: false, - }); - }} - /> + {hasChanges ? scrollbar.overlay : null} ); diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index cd500c1d7..4794ea5e1 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -12,7 +12,15 @@ import { Platform, BackHandler, } from "react-native"; -import { useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef } from "react"; +import { + useState, + useRef, + useCallback, + useEffect, + useLayoutEffect, + useImperativeHandle, + forwardRef, +} from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native"; import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated"; @@ -33,6 +41,7 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur import { focusWithRetries } from "@/utils/web-focus"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; +import { useWebElementScrollbar } from "@/components/use-web-scrollbar"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { @@ -570,6 +579,18 @@ export const MessageInput = forwardRef(funct return null; }, []); + const webTextareaRef = useRef(null); + + useLayoutEffect(() => { + if (IS_WEB) { + webTextareaRef.current = getWebTextArea() as HTMLElement | null; + } + }, [getWebTextArea]); + + const inputScrollbar = useWebElementScrollbar(webTextareaRef, { + enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT, + }); + const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => { const ref = target === "root" ? rootRef.current : inputWrapperRef.current; if (!ref) return null; @@ -911,42 +932,45 @@ export const MessageInput = forwardRef(funct )} {/* Text input */} - { - isInputFocusedRef.current = true; - onFocusChange?.(true); - }} - onBlur={() => { - isInputFocusedRef.current = false; - onFocusChange?.(false); - }} - style={[ - styles.textInput, - IS_WEB - ? { - height: inputHeight, - minHeight: MIN_INPUT_HEIGHT, - maxHeight: MAX_INPUT_HEIGHT, - } - : { - minHeight: MIN_INPUT_HEIGHT, - maxHeight: MAX_INPUT_HEIGHT, - }, - ]} - multiline - scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true} - onContentSizeChange={handleContentSizeChange} - editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} - onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} - onSelectionChange={handleSelectionChange} - autoFocus={IS_WEB && autoFocus} - /> + + { + isInputFocusedRef.current = true; + onFocusChange?.(true); + }} + onBlur={() => { + isInputFocusedRef.current = false; + onFocusChange?.(false); + }} + style={[ + styles.textInput, + IS_WEB + ? { + height: inputHeight, + minHeight: MIN_INPUT_HEIGHT, + maxHeight: MAX_INPUT_HEIGHT, + } + : { + minHeight: MIN_INPUT_HEIGHT, + maxHeight: MAX_INPUT_HEIGHT, + }, + ]} + multiline + scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true} + onContentSizeChange={handleContentSizeChange} + editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} + onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} + onSelectionChange={handleSelectionChange} + autoFocus={IS_WEB && autoFocus} + /> + {inputScrollbar} + {/* Button row */} @@ -1187,6 +1211,9 @@ const styles = StyleSheet.create(((theme: any) => ({ removeImageButtonVisible: { opacity: 1, }, + textInputScrollWrapper: { + position: "relative", + }, textInput: { width: "100%", color: theme.colors.foreground, diff --git a/packages/app/src/components/stream-strategy-web.tsx b/packages/app/src/components/stream-strategy-web.tsx index 3768bb898..2189a67b6 100644 --- a/packages/app/src/components/stream-strategy-web.tsx +++ b/packages/app/src/components/stream-strategy-web.tsx @@ -23,21 +23,7 @@ const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200; const USER_SCROLL_DELTA_EPSILON = 1; const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1; -const WEB_STREAM_SCROLLBAR_STYLE_ID = "web-stream-viewport-scrollbar-style"; -const WEB_STREAM_SCROLLBAR_STYLE = ` - #agent-chat-scroll-web-dom-scroll, - #agent-chat-scroll-web-dom-virtualized { - scrollbar-width: none; - -ms-overflow-style: none; - } - - #agent-chat-scroll-web-dom-scroll::-webkit-scrollbar, - #agent-chat-scroll-web-dom-virtualized::-webkit-scrollbar { - display: none; - width: 0; - height: 0; - } -`; +import { useWebElementScrollbar } from "./use-web-scrollbar"; function logWebStickyBottom(_event: string, _details: Record): void { // Intentionally disabled: this path is too noisy during voice debugging. @@ -119,8 +105,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scrollEnabled, isMobileBreakpoint, } = props; - const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } = - require("./web-desktop-scrollbar") as typeof import("./web-desktop-scrollbar"); const scrollContainerRef = useRef(null); const contentRef = useRef(null); const [followOutput, setFollowOutputr] = useState(true); @@ -142,8 +126,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const lastTouchClientYRef = useRef(null); const pendingAutoScrollFrameRef = useRef(null); const pendingAutoScrollTimeoutRef = useRef(null); - const streamScrollbarMetrics = useWebDesktopScrollbarMetrics(); const showDesktopWebScrollbar = !isMobileBreakpoint; + const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, { + enabled: showDesktopWebScrollbar, + contentRef, + }); const shouldUseVirtualizer = segments.historyVirtualized.length > 0; const { renderHistoryVirtualizedRow, @@ -271,33 +258,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool onNearBottomChange(true); return; } - streamScrollbarMetrics.onContentSizeChange( - scrollContainer.clientWidth, - scrollContainer.scrollHeight, - ); - streamScrollbarMetrics.onLayout({ - nativeEvent: { - layout: { - width: scrollContainer.clientWidth, - height: scrollContainer.clientHeight, - x: 0, - y: 0, - }, - }, - } as never); - streamScrollbarMetrics.onScroll({ - nativeEvent: { - contentOffset: { x: 0, y: scrollContainer.scrollTop }, - contentSize: { - width: scrollContainer.clientWidth, - height: scrollContainer.scrollHeight, - }, - layoutMeasurement: { - width: scrollContainer.clientWidth, - height: scrollContainer.clientHeight, - }, - }, - } as never); syncNearBottom(scrollContainer, onNearBottomChange); const currentMetrics = { scrollTop: scrollContainer.scrollTop, @@ -323,7 +283,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ...currentMetrics, }); } - }, [onNearBottomChange, props.agentId, streamScrollbarMetrics]); + }, [onNearBottomChange, props.agentId]); const handleDomScroll = useCallback(() => { const scrollContainer = scrollContainerRef.current; @@ -711,7 +671,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return ( <> -
{ scrollContainerRef.current = node; @@ -759,20 +718,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool {shouldRenderEmpty ? listEmptyComponent : null}
- { - const scrollContainer = scrollContainerRef.current; - if (!scrollContainer) { - return; - } - scrollContainer.scrollTo({ top: nextOffset, behavior: "auto" }); - lastKnownScrollTopRef.current = scrollContainer.scrollTop; - updateScrollMetrics(); - }} - /> + {scrollbarOverlay} ); } diff --git a/packages/app/src/components/use-web-scrollbar.tsx b/packages/app/src/components/use-web-scrollbar.tsx new file mode 100644 index 000000000..5b4f95948 --- /dev/null +++ b/packages/app/src/components/use-web-scrollbar.tsx @@ -0,0 +1,155 @@ +import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react"; +import { + Platform, + type FlatList, + type LayoutChangeEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, + type ScrollView, +} from "react-native"; +import { + WebDesktopScrollbarOverlay, + useWebDesktopScrollbarMetrics, + type ScrollbarMetrics, +} from "./web-desktop-scrollbar"; + +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]::-webkit-scrollbar { 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, + options?: { + enabled?: boolean; + contentRef?: RefObject; + }, +): ReactNode { + const isWeb = Platform.OS === "web"; + const enabled = (options?.enabled ?? true) && isWeb; + const contentRef = options?.contentRef; + + const [metrics, setMetrics] = useState({ + offset: 0, + viewportSize: 0, + contentSize: 0, + }); + + useEffect(() => { + if (!enabled) return; + const element = elementRef.current; + if (!element) return; + + element.setAttribute("data-hide-scrollbar", ""); + (element.style as any).scrollbarWidth = "none"; + (element.style as any).msOverflowStyle = "none"; + 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 }); + element.addEventListener("input", 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); + element.removeEventListener("input", update); + resizeObserver.disconnect(); + element.removeAttribute("data-hide-scrollbar"); + (element.style as any).scrollbarWidth = ""; + (element.style as any).msOverflowStyle = ""; + }; + }, [contentRef, elementRef, enabled]); + + const onScrollToOffset = useCallback( + (offset: number) => { + elementRef.current?.scrollTo({ top: offset, behavior: "auto" }); + }, + [elementRef], + ); + + if (!enabled) return null; + + return ; +} + +// ── 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) => void; + onLayout: (event: LayoutChangeEvent) => void; + onContentSizeChange: (width: number, height: number) => void; + overlay: ReactNode; +} + +export function useWebScrollViewScrollbar( + scrollableRef: RefObject, + options?: { enabled?: boolean }, +): WebScrollViewScrollbar { + const isWeb = Platform.OS === "web"; + const enabled = (options?.enabled ?? true) && isWeb; + const metricsHook = useWebDesktopScrollbarMetrics(); + + const onScrollToOffset = useCallback( + (offset: number) => { + const scrollable = scrollableRef.current; + if (!scrollable) return; + if ("scrollToOffset" in scrollable) { + (scrollable as FlatList).scrollToOffset({ offset, animated: false }); + } else { + (scrollable as ScrollView).scrollTo({ y: offset, animated: false }); + } + }, + [scrollableRef], + ); + + const overlay: ReactNode = enabled ? ( + + ) : null; + + return { + onScroll: metricsHook.onScroll, + onLayout: metricsHook.onLayout, + onContentSizeChange: metricsHook.onContentSizeChange, + overlay, + }; +} diff --git a/packages/app/src/components/web-desktop-scrollbar.tsx b/packages/app/src/components/web-desktop-scrollbar.tsx index 62f7b063b..48a2211ef 100644 --- a/packages/app/src/components/web-desktop-scrollbar.tsx +++ b/packages/app/src/components/web-desktop-scrollbar.tsx @@ -37,7 +37,7 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } -type ScrollbarMetrics = { +export type ScrollbarMetrics = { offset: number; viewportSize: number; contentSize: number; diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index a38b15958..027f0eca9 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -193,7 +193,7 @@ const darkSemanticColors = { foregroundMuted: "#A1A5A4", // Controls - scrollbarHandle: "#71717a", // zinc-500 + scrollbarHandle: "#717574", // zinc-500 w/ teal tint // Borders border: "#252B2A",