From dd6a632bc5cb6841890856e721e2b36e3dc695c5 Mon Sep 17 00:00:00 2001 From: c_w_xiaohei <1641233466@qq.com> Date: Sun, 23 Aug 2026 18:34:12 +0800 Subject: [PATCH] perf(ui): streamline overlay scrollbar updates --- .../ui/src/components/chat/ChatContainer.tsx | 8 +- .../components/ui/OverlayScrollbar.test.tsx | 285 +++++++ .../ui/src/components/ui/OverlayScrollbar.tsx | 693 ++++++++++-------- packages/ui/src/index.css | 16 +- 4 files changed, 670 insertions(+), 332 deletions(-) create mode 100644 packages/ui/src/components/ui/OverlayScrollbar.test.tsx diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index a6384531..d1d828e5 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -391,7 +391,13 @@ const ChatViewport = React.memo(({ - + {showPromptNavigator && promptTurnIds.length >= 2 ? ( { + let windowInstance: Window; + let host: HTMLDivElement; + let scroller: HTMLDivElement; + let root: Root; + let containerRef: React.RefObject; + let scrollTop: number; + let scrollbarCommits: number; + let verticalLayoutReads: number; + let horizontalLayoutReads: number; + let nextFrameId: number; + let pendingFrames: Map; + + const flushFrames = async () => { + await act(async () => { + const frames = Array.from(pendingFrames.values()); + pendingFrames.clear(); + frames.forEach((callback) => callback(0)); + }); + }; + + const renderScrollbar = async (props: Partial> = {}) => { + await act(async () => { + root.render( + { scrollbarCommits += 1; }}> + + , + ); + }); + await flushFrames(); + }; + + beforeEach(() => { + windowInstance = new Window(); + pendingFrames = new Map(); + nextFrameId = 1; + TestResizeObserver.instances = []; + + Object.assign(globalThis, { + window: windowInstance, + document: windowInstance.document, + HTMLElement: windowInstance.HTMLElement, + Element: windowInstance.Element, + Node: windowInstance.Node, + PointerEvent: windowInstance.PointerEvent, + ResizeObserver: TestResizeObserver, + IS_REACT_ACT_ENVIRONMENT: true, + requestAnimationFrame: (callback: FrameRequestCallback) => { + const id = nextFrameId++; + pendingFrames.set(id, callback); + return id; + }, + cancelAnimationFrame: (id: number) => { + pendingFrames.delete(id); + }, + }); + + host = document.createElement('div'); + scroller = document.createElement('div'); + document.body.append(host, scroller); + root = createRoot(host); + containerRef = { current: scroller }; + scrollTop = 0; + scrollbarCommits = 0; + verticalLayoutReads = 0; + horizontalLayoutReads = 0; + + Object.defineProperties(scroller, { + clientHeight: { + configurable: true, + get: () => { + verticalLayoutReads += 1; + return 100; + }, + }, + scrollHeight: { + configurable: true, + get: () => { + verticalLayoutReads += 1; + return 500; + }, + }, + scrollTop: { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = value; + }, + }, + clientWidth: { + configurable: true, + get: () => { + horizontalLayoutReads += 1; + return 100; + }, + }, + scrollWidth: { + configurable: true, + get: () => { + horizontalLayoutReads += 1; + return 100; + }, + }, + }); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + windowInstance.close(); + }); + + test('moves the thumb without rereading layout during steady scrolling', async () => { + await renderScrollbar(); + scrollbarCommits = 0; + verticalLayoutReads = 0; + horizontalLayoutReads = 0; + + scrollTop = 200; + scroller.dispatchEvent(new window.Event('scroll')); + await flushFrames(); + + const thumb = host.querySelector('[data-overlay-scrollbar-thumb="vertical"]'); + expect(thumb?.style.transform).toBe('translate3d(0, 34px, 0)'); + expect(thumb?.style.top).toBe(''); + expect(verticalLayoutReads).toBe(0); + expect(horizontalLayoutReads).toBe(0); + expect(scrollbarCommits).toBe(0); + }); + + test('coalesces scroll and resize updates into one animation frame', async () => { + await renderScrollbar(); + + scroller.dispatchEvent(new window.Event('scroll')); + TestResizeObserver.instances[0]?.trigger(); + + expect(pendingFrames.size).toBe(1); + }); + + test('reads both axes before writing updated thumb sizes', async () => { + let clientHeight = 100; + let verticalHeightDuringHorizontalRead = ''; + let captureHorizontalRead = false; + Object.defineProperties(scroller, { + clientHeight: { configurable: true, get: () => clientHeight }, + clientWidth: { + configurable: true, + get: () => { + if (captureHorizontalRead) { + verticalHeightDuringHorizontalRead = host.querySelector( + '[data-overlay-scrollbar-thumb="vertical"]', + )?.style.height ?? ''; + } + return 100; + }, + }, + scrollWidth: { configurable: true, get: () => 300 }, + }); + await renderScrollbar({ disableHorizontal: false }); + + clientHeight = 200; + captureHorizontalRead = true; + TestResizeObserver.instances[0]?.trigger(); + await flushFrames(); + + const verticalThumb = host.querySelector('[data-overlay-scrollbar-thumb="vertical"]'); + expect(verticalHeightDuringHorizontalRead).toBe('32px'); + expect(verticalThumb?.style.height).toBe('73.6px'); + }); + + test('keeps the resize observer connected when visibility is suppressed', async () => { + await renderScrollbar({ suppressVisibility: false }); + expect(TestResizeObserver.instances).toHaveLength(1); + const disconnectCount = TestResizeObserver.instances[0]?.disconnectCount; + + await renderScrollbar({ suppressVisibility: true }); + + expect(TestResizeObserver.instances).toHaveLength(1); + expect(TestResizeObserver.instances[0]?.disconnectCount).toBe(disconnectCount); + }); + + test('uses the rendered thumb travel when dragging', async () => { + await renderScrollbar(); + const thumb = host.querySelector('[data-overlay-scrollbar-thumb="vertical"]'); + if (!thumb) throw new Error('OverlayScrollbar did not render its vertical thumb'); + + thumb.setPointerCapture = () => {}; + thumb.releasePointerCapture = () => {}; + thumb.dispatchEvent(new window.PointerEvent('pointerdown', { + bubbles: true, + clientY: 0, + pointerId: 1, + })); + thumb.dispatchEvent(new window.PointerEvent('pointermove', { + bubbles: true, + clientY: 26, + pointerId: 1, + })); + + expect(scrollTop).toBe(200); + thumb.dispatchEvent(new window.PointerEvent('pointerup', { + bubbles: true, + clientY: 26, + pointerId: 1, + })); + thumb.dispatchEvent(new window.PointerEvent('pointermove', { + bubbles: true, + clientY: 52, + pointerId: 1, + })); + expect(scrollTop).toBe(200); + }); + + test('keeps the minimum thumb size within a short track', async () => { + Object.defineProperty(scroller, 'clientHeight', { + configurable: true, + get: () => 40, + }); + + await renderScrollbar(); + + const thumb = host.querySelector('[data-overlay-scrollbar-thumb="vertical"]'); + expect(thumb?.style.height).toBe('24px'); + }); + + test('applies visibility suppression without reconnecting the scrollbar', async () => { + await renderScrollbar({ suppressVisibility: false }); + const scrollbar = host.querySelector('.overlay-scrollbar'); + if (!scrollbar) throw new Error('OverlayScrollbar did not render its container'); + + scroller.dispatchEvent(new window.Event('scroll')); + expect(scrollbar.dataset.visible).toBe('true'); + + await renderScrollbar({ suppressVisibility: true }); + + expect(scrollbar.dataset.visible).toBe('false'); + }); + + test('shows user-intent-only scrollbars for user scrolling but not programmatic scrolling', async () => { + await renderScrollbar({ userIntentOnly: true }); + const scrollbar = host.querySelector('.overlay-scrollbar'); + if (!scrollbar) throw new Error('OverlayScrollbar did not render its container'); + + scroller.dispatchEvent(new window.Event('scroll')); + expect(scrollbar.dataset.visible).toBe('false'); + + scroller.dispatchEvent(new window.WheelEvent('wheel')); + scroller.dispatchEvent(new window.Event('scroll')); + expect(scrollbar.dataset.visible).toBe('true'); + }); + +}); diff --git a/packages/ui/src/components/ui/OverlayScrollbar.tsx b/packages/ui/src/components/ui/OverlayScrollbar.tsx index d1026abb..a11a5c19 100644 --- a/packages/ui/src/components/ui/OverlayScrollbar.tsx +++ b/packages/ui/src/components/ui/OverlayScrollbar.tsx @@ -2,30 +2,340 @@ import React from "react"; import { cn } from "@/lib/utils"; type OverlayScrollbarProps = { + /** The authoritative scrolling element. Its identity must stay stable while mounted. */ containerRef: React.RefObject; + /** Minimum thumb length in CSS pixels, capped to the available track. */ minThumbSize?: number; + /** Time in milliseconds before an inactive thumb fades out. */ hideDelayMs?: number; className?: string; + /** Skips horizontal geometry reads and keeps the horizontal thumb hidden. */ disableHorizontal?: boolean; + /** Tracks direct-child replacement so newly mounted content remains size-observed. */ observeMutations?: boolean; + /** Hides the scrollbar during programmatic motion, except while the user is dragging it. */ suppressVisibility?: boolean; + /** Shows the scrollbar only after recent wheel, touch, keyboard, or thumb input. */ userIntentOnly?: boolean; }; -type ThumbMetrics = { - length: number; - offset: number; -}; +type ScrollbarOptions = Required>; -const USER_SCROLL_INTENT_WINDOW_MS = 1000; -const METRIC_EPSILON = 0.5; -const EMPTY_THUMB: ThumbMetrics = { length: 0, offset: 0 }; +// The inset is part of both rendering and drag math; changing it must preserve that shared track. +const TRACK_INSET = 8; +const USER_INTENT_DURATION_MS = 1000; +const SCROLL_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "]); -const isSameThumbMetrics = (a: ThumbMetrics, b: ThumbMetrics): boolean => { - return Math.abs(a.length - b.length) < METRIC_EPSILON && Math.abs(a.offset - b.offset) < METRIC_EPSILON; -}; +function calculateThumb(viewportLength: number, contentLength: number, minThumbSize: number) { + const trackLength = Math.max(viewportLength - TRACK_INSET * 2, 0); + const scrollRange = Math.max(contentLength - viewportLength, 0); + if (trackLength === 0 || scrollRange === 0) return null; -const OverlayScrollbarComponent: React.FC = ({ + const length = Math.min( + trackLength, + Math.max(minThumbSize, (viewportLength / contentLength) * trackLength), + ); + return { + length, + thumbPixelsPerScrollPixel: (trackLength - length) / scrollRange, + }; +} + +function bindScrollbar( + container: HTMLElement, + root: HTMLDivElement, + verticalThumb: HTMLDivElement, + horizontalThumb: HTMLDivElement, + initialOptions: ScrollbarOptions, +) { + let options = initialOptions; + + // One frame coalesces scroll, resize, and mutation signals. Measurement is requested only when sizes may change. + let frameId: number | null = null; + let needsMeasurement = true; + + // Cached unit conversions let steady scrolling avoid layout-dependent size reads. + let verticalThumbPixelsPerScrollPixel = 0; + let horizontalThumbPixelsPerScrollPixel = 0; + + // A single moving deadline avoids clearing and recreating a timer on every scroll event. + let hideTimerId: ReturnType | null = null; + let hideDeadlineMs = 0; + let lastUserIntentTimeMs = Number.NEGATIVE_INFINITY; + let pointerOverThumb = false; + + // Drag state is the minimum snapshot needed to convert pointer travel back into a scroll offset. + let drag: { + axis: "vertical" | "horizontal"; + pointerId: number; + pointerStartPx: number; + scrollStartPx: number; + } | null = null; + + // Visibility is an imperative presentation detail; no React consumer needs it as state. + // React owns the stable elements; this binding exclusively owns their dynamic attributes and styles. + const setVisible = (visible: boolean) => { + const value = String(visible); + if (root.dataset.visible !== value) root.dataset.visible = value; + }; + + const scheduleHide = () => { + if (pointerOverThumb || drag || hideTimerId !== null) return; + + const hide = () => { + hideTimerId = null; + if (pointerOverThumb || drag) return; + const delay = hideDeadlineMs - performance.now(); + if (delay > 0) { + hideTimerId = setTimeout(hide, delay); + } else { + setVisible(false); + } + }; + + hideTimerId = setTimeout(hide, Math.max(hideDeadlineMs - performance.now(), 0)); + }; + + // Measurement is the cold path. Read every axis before writing either thumb to avoid forced layout. + const measureThumbs = () => { + const clientHeight = container.clientHeight; + const scrollHeight = container.scrollHeight; + const clientWidth = options.disableHorizontal ? 0 : container.clientWidth; + const scrollWidth = options.disableHorizontal ? 0 : container.scrollWidth; + const vertical = calculateThumb(clientHeight, scrollHeight, options.minThumbSize); + const horizontal = options.disableHorizontal + ? null + : calculateThumb(clientWidth, scrollWidth, options.minThumbSize); + + if (vertical) { + verticalThumbPixelsPerScrollPixel = vertical.thumbPixelsPerScrollPixel; + verticalThumb.hidden = false; + verticalThumb.style.height = `${vertical.length}px`; + } else { + verticalThumbPixelsPerScrollPixel = 0; + verticalThumb.hidden = true; + } + + if (horizontal) { + horizontalThumbPixelsPerScrollPixel = horizontal.thumbPixelsPerScrollPixel; + horizontalThumb.hidden = false; + horizontalThumb.style.width = `${horizontal.length}px`; + } else { + horizontalThumbPixelsPerScrollPixel = 0; + horizontalThumb.hidden = true; + } + }; + + const positionThumbs = () => { + // This is the hot path: cached scales leave only scroll offsets to read and transforms to write. + if (!verticalThumb.hidden) { + verticalThumb.style.transform = `translate3d(0, ${TRACK_INSET + container.scrollTop * verticalThumbPixelsPerScrollPixel}px, 0)`; + } + if (!horizontalThumb.hidden) { + horizontalThumb.style.transform = `translate3d(${TRACK_INSET + container.scrollLeft * horizontalThumbPixelsPerScrollPixel}px, 0, 0)`; + } + }; + + const scheduleUpdate = (measure = false) => { + needsMeasurement ||= measure; + if (frameId !== null) return; + + // All event sources share one read-before-write pass per frame. + frameId = requestAnimationFrame(() => { + frameId = null; + if (needsMeasurement) { + needsMeasurement = false; + measureThumbs(); + } + positionThumbs(); + }); + }; + + const markUserIntent = () => { + lastUserIntentTimeMs = performance.now(); + }; + + // Scroll visibility is separate from positioning so hidden programmatic scrolling does no DOM work. + const onScroll = () => { + const shouldShow = drag + || (!options.suppressVisibility + && (!options.userIntentOnly + || performance.now() - lastUserIntentTimeMs <= USER_INTENT_DURATION_MS)); + + if (!shouldShow) { + setVisible(false); + return; + } + + scheduleUpdate(); + hideDeadlineMs = performance.now() + options.hideDelayMs; + setVisible(true); + scheduleHide(); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (SCROLL_KEYS.has(event.key)) { + markUserIntent(); + } + }; + + const getThumbAxis = (target: EventTarget | null) => { + if (target === verticalThumb) return "vertical"; + if (target === horizontalThumb) return "horizontal"; + return null; + }; + + // Both thumbs delegate pointer interaction through the stable overlay root. + const onPointerDown = (event: PointerEvent) => { + const axis = getThumbAxis(event.target); + if (!axis) return; + const thumb = axis === "vertical" ? verticalThumb : horizontalThumb; + drag = { + axis, + pointerId: event.pointerId, + pointerStartPx: axis === "vertical" ? event.clientY : event.clientX, + scrollStartPx: axis === "vertical" ? container.scrollTop : container.scrollLeft, + }; + markUserIntent(); + hideDeadlineMs = Number.POSITIVE_INFINITY; + if (hideTimerId !== null) { + clearTimeout(hideTimerId); + hideTimerId = null; + } + setVisible(true); + thumb.setPointerCapture(event.pointerId); + }; + + const onPointerMove = (event: PointerEvent) => { + if (!drag || event.pointerId !== drag.pointerId) return; + const pointer = drag.axis === "vertical" ? event.clientY : event.clientX; + const scale = drag.axis === "vertical" + ? verticalThumbPixelsPerScrollPixel + : horizontalThumbPixelsPerScrollPixel; + if (scale <= 0) return; + const scrollOffset = drag.scrollStartPx + (pointer - drag.pointerStartPx) / scale; + if (drag.axis === "vertical") container.scrollTop = scrollOffset; + else container.scrollLeft = scrollOffset; + }; + + const onPointerEnd = (event: PointerEvent) => { + if (!drag || event.pointerId !== drag.pointerId) return; + const thumb = drag.axis === "vertical" ? verticalThumb : horizontalThumb; + drag = null; + if (thumb.hasPointerCapture(event.pointerId)) thumb.releasePointerCapture(event.pointerId); + hideDeadlineMs = performance.now() + options.hideDelayMs; + scheduleHide(); + }; + + const onPointerOver = (event: PointerEvent) => { + if (!getThumbAxis(event.target)) return; + pointerOverThumb = true; + if (hideTimerId !== null) { + clearTimeout(hideTimerId); + hideTimerId = null; + } + }; + + const onPointerOut = (event: PointerEvent) => { + if (!getThumbAxis(event.target)) return; + pointerOverThumb = false; + hideDeadlineMs = performance.now() + options.hideDelayMs; + scheduleHide(); + }; + + container.addEventListener("scroll", onScroll, { passive: true }); + root.addEventListener("pointerdown", onPointerDown); + root.addEventListener("pointermove", onPointerMove); + root.addEventListener("pointerup", onPointerEnd); + root.addEventListener("pointercancel", onPointerEnd); + root.addEventListener("pointerover", onPointerOver); + root.addEventListener("pointerout", onPointerOut); + + // ResizeObserver invalidates measurements; MutationObserver only keeps direct-child observation current. + const resizeObserver = globalThis.ResizeObserver + ? new ResizeObserver(() => scheduleUpdate(true)) + : null; + + const observeSizes = () => { + if (!resizeObserver) return; + resizeObserver.disconnect(); + resizeObserver.observe(container); + for (const child of container.children) { + resizeObserver.observe(child); + } + }; + + observeSizes(); + + const mutationObserver = globalThis.MutationObserver + ? new MutationObserver(() => { + observeSizes(); + scheduleUpdate(true); + }) + : null; + + const setMutationObservation = (enabled: boolean) => { + mutationObserver?.disconnect(); + if (enabled) mutationObserver?.observe(container, { childList: true }); + }; + + const setUserIntentListeners = (enabled: boolean) => { + if (enabled) { + container.addEventListener("wheel", markUserIntent, { passive: true }); + container.addEventListener("touchstart", markUserIntent, { passive: true }); + container.addEventListener("touchmove", markUserIntent, { passive: true }); + container.addEventListener("keydown", onKeyDown); + } else { + container.removeEventListener("wheel", markUserIntent); + container.removeEventListener("touchstart", markUserIntent); + container.removeEventListener("touchmove", markUserIntent); + container.removeEventListener("keydown", onKeyDown); + } + }; + + setMutationObservation(options.observeMutations); + setUserIntentListeners(options.userIntentOnly); + root.dataset.visible = "false"; + scheduleUpdate(true); + + // Props update policy in place; only mounting and unmounting bind browser resources. + return { + update(nextOptions: ScrollbarOptions) { + const mustMeasure = nextOptions.disableHorizontal !== options.disableHorizontal + || nextOptions.minThumbSize !== options.minThumbSize; + if (nextOptions.observeMutations !== options.observeMutations) { + setMutationObservation(nextOptions.observeMutations); + if (nextOptions.observeMutations) observeSizes(); + } + if (nextOptions.userIntentOnly !== options.userIntentOnly) { + setUserIntentListeners(nextOptions.userIntentOnly); + } + options = nextOptions; + if (mustMeasure) scheduleUpdate(true); + if (options.disableHorizontal) horizontalThumb.hidden = true; + if (options.suppressVisibility && !drag) setVisible(false); + }, + disconnect() { + if (frameId !== null) cancelAnimationFrame(frameId); + if (hideTimerId !== null) clearTimeout(hideTimerId); + container.removeEventListener("scroll", onScroll); + setUserIntentListeners(false); + root.removeEventListener("pointerdown", onPointerDown); + root.removeEventListener("pointermove", onPointerMove); + root.removeEventListener("pointerup", onPointerEnd); + root.removeEventListener("pointercancel", onPointerEnd); + root.removeEventListener("pointerover", onPointerOver); + root.removeEventListener("pointerout", onPointerOut); + resizeObserver?.disconnect(); + mutationObserver?.disconnect(); + }, + }; +} + +export const OverlayScrollbar: React.FC = ({ containerRef, minThumbSize = 32, hideDelayMs = 1000, @@ -35,328 +345,63 @@ const OverlayScrollbarComponent: React.FC = ({ suppressVisibility = false, userIntentOnly = false, }) => { - const [visible, setVisible] = React.useState(false); - const [vertical, setVertical] = React.useState({ length: 0, offset: 0 }); - const [horizontal, setHorizontal] = React.useState({ length: 0, offset: 0 }); - const hideTimeoutRef = React.useRef | null>(null); - const frameRef = React.useRef(null); - const metricsFrameRef = React.useRef(null); - const isDraggingRef = React.useRef(false); - const isHoveringRef = React.useRef(false); - const lastUserIntentAtRef = React.useRef(0); - const dragStartRef = React.useRef<{ - pointerX: number; - pointerY: number; - scrollTop: number; - scrollLeft: number; - }>({ pointerX: 0, pointerY: 0, scrollTop: 0, scrollLeft: 0 }); - const dragAxisRef = React.useRef<"vertical" | "horizontal" | null>(null); - const observedElementsRef = React.useRef>(new Set()); + const rootRef = React.useRef(null); + const verticalThumbRef = React.useRef(null); + const horizontalThumbRef = React.useRef(null); + const bindingRef = React.useRef | null>(null); + const initialOptionsRef = React.useRef({ + minThumbSize, + hideDelayMs, + disableHorizontal, + observeMutations, + suppressVisibility, + userIntentOnly, + }); - const updateMetrics = React.useCallback(() => { + React.useLayoutEffect(() => { const container = containerRef.current; - if (!container) return; - - const { scrollHeight, clientHeight, scrollTop, scrollWidth, clientWidth, scrollLeft } = container; - const trackInset = 8; - - let nextVertical: ThumbMetrics = EMPTY_THUMB; - if (scrollHeight > clientHeight) { - const trackLength = Math.max(clientHeight - trackInset * 2, 0); - const rawThumb = (clientHeight / scrollHeight) * trackLength; - const length = Math.max(minThumbSize, Math.min(trackLength, rawThumb)); - const maxOffset = Math.max(trackLength - length, 0); - const maxScroll = Math.max(scrollHeight - clientHeight, 1); - const offset = (scrollTop / maxScroll) * maxOffset; - nextVertical = { length, offset }; - } - setVertical((prev) => (isSameThumbMetrics(prev, nextVertical) ? prev : nextVertical)); - - let nextHorizontal: ThumbMetrics = EMPTY_THUMB; - if (!disableHorizontal && scrollWidth > clientWidth) { - const trackLength = Math.max(clientWidth - trackInset * 2, 0); - const rawThumb = (clientWidth / scrollWidth) * trackLength; - const length = Math.max(minThumbSize, Math.min(trackLength, rawThumb)); - const maxOffset = Math.max(trackLength - length, 0); - const maxScroll = Math.max(scrollWidth - clientWidth, 1); - const offset = (scrollLeft / maxScroll) * maxOffset; - nextHorizontal = { length, offset }; - } - setHorizontal((prev) => (isSameThumbMetrics(prev, nextHorizontal) ? prev : nextHorizontal)); - }, [containerRef, minThumbSize, disableHorizontal]); - - const scheduleMetricsUpdate = React.useCallback(() => { - if (metricsFrameRef.current !== null) return; - metricsFrameRef.current = requestAnimationFrame(() => { - metricsFrameRef.current = null; - updateMetrics(); - }); - }, [updateMetrics]); - - const syncObservedElements = React.useCallback((container: HTMLElement, resizeObserver: ResizeObserver | null) => { - if (!resizeObserver) { - observedElementsRef.current.clear(); - return; - } - - const nextObserved = new Set(); - nextObserved.add(container); - Array.from(container.children).forEach((child) => { - nextObserved.add(child); - }); - - observedElementsRef.current.forEach((element) => { - if (!nextObserved.has(element)) { - resizeObserver.unobserve(element); - } - }); - - nextObserved.forEach((element) => { - if (!observedElementsRef.current.has(element)) { - resizeObserver.observe(element); - } - }); - - observedElementsRef.current = nextObserved; - }, []); - - const scheduleHide = React.useCallback(() => { - if (hideTimeoutRef.current) { - clearTimeout(hideTimeoutRef.current); - } - // Don't schedule hide if hovering over the thumb - if (isHoveringRef.current) { - return; - } - hideTimeoutRef.current = setTimeout(() => setVisible(false), hideDelayMs); - }, [hideDelayMs]); - - const markUserIntent = React.useCallback(() => { - lastUserIntentAtRef.current = Date.now(); - }, []); - - const handleScroll = React.useCallback(() => { - if (frameRef.current) { - cancelAnimationFrame(frameRef.current); - } - frameRef.current = requestAnimationFrame(() => { - updateMetrics(); - if (suppressVisibility && !isDraggingRef.current) { - setVisible(false); - return; - } - if (userIntentOnly && !isDraggingRef.current) { - const hasRecentUserIntent = Date.now() - lastUserIntentAtRef.current <= USER_SCROLL_INTENT_WINDOW_MS; - if (!hasRecentUserIntent) { - setVisible(false); - return; - } - } - setVisible(true); - scheduleHide(); - }); - }, [scheduleHide, suppressVisibility, updateMetrics, userIntentOnly]); - - React.useEffect(() => { - const container = containerRef.current; - if (!container) return; - - updateMetrics(); - setVisible(false); - - const onScroll = () => handleScroll(); - const onKeyDown = (event: KeyboardEvent) => { - if ( - event.key === 'ArrowUp' - || event.key === 'ArrowDown' - || event.key === 'PageUp' - || event.key === 'PageDown' - || event.key === 'Home' - || event.key === 'End' - || event.key === ' ' - ) { - markUserIntent(); - } - }; - - container.addEventListener("scroll", onScroll, { passive: true }); - if (userIntentOnly) { - container.addEventListener("wheel", markUserIntent, { passive: true }); - container.addEventListener("touchstart", markUserIntent, { passive: true }); - container.addEventListener("touchmove", markUserIntent, { passive: true }); - container.addEventListener("keydown", onKeyDown); - } - - const resizeObserver = - typeof ResizeObserver !== "undefined" - ? new ResizeObserver(() => { - scheduleMetricsUpdate(); - }) - : null; - syncObservedElements(container, resizeObserver); - - const mutationObserver = - observeMutations && typeof MutationObserver !== "undefined" - ? new MutationObserver(() => { - syncObservedElements(container, resizeObserver); - scheduleMetricsUpdate(); - }) - : null; - mutationObserver?.observe(container, { childList: true }); - - const onInput = () => scheduleMetricsUpdate(); - const onLoad = () => scheduleMetricsUpdate(); - container.addEventListener("input", onInput, true); - container.addEventListener("load", onLoad, true); + const root = rootRef.current; + const verticalThumb = verticalThumbRef.current; + const horizontalThumb = horizontalThumbRef.current; + if (!container || !root || !verticalThumb || !horizontalThumb) return; + const binding = bindScrollbar(container, root, verticalThumb, horizontalThumb, initialOptionsRef.current); + bindingRef.current = binding; return () => { - container.removeEventListener("scroll", onScroll); - container.removeEventListener("input", onInput, true); - container.removeEventListener("load", onLoad, true); - if (userIntentOnly) { - container.removeEventListener("wheel", markUserIntent); - container.removeEventListener("touchstart", markUserIntent); - container.removeEventListener("touchmove", markUserIntent); - container.removeEventListener("keydown", onKeyDown); - } - resizeObserver?.disconnect(); - mutationObserver?.disconnect(); - observedElementsRef.current.clear(); - if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current); - if (frameRef.current) cancelAnimationFrame(frameRef.current); - if (metricsFrameRef.current) cancelAnimationFrame(metricsFrameRef.current); + bindingRef.current = null; + binding.disconnect(); }; - }, [containerRef, handleScroll, markUserIntent, observeMutations, scheduleMetricsUpdate, syncObservedElements, updateMetrics, userIntentOnly]); + }, [containerRef]); - React.useEffect(() => { - if (!suppressVisibility) { - return; - } - if (isDraggingRef.current) { - return; - } - setVisible(false); - if (hideTimeoutRef.current) { - clearTimeout(hideTimeoutRef.current); - hideTimeoutRef.current = null; - } - }, [suppressVisibility]); - - const handlePointerDown = (event: React.PointerEvent, axis: "vertical" | "horizontal") => { - const container = containerRef.current; - if (!container) return; - - isDraggingRef.current = true; - dragStartRef.current = { - pointerX: event.clientX, - pointerY: event.clientY, - scrollTop: container.scrollTop, - scrollLeft: container.scrollLeft, - }; - dragAxisRef.current = axis; - markUserIntent(); - setVisible(true); - if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current); - event.currentTarget.setPointerCapture(event.pointerId); - }; - - const handlePointerMove = (event: React.PointerEvent) => { - if (!isDraggingRef.current) return; - const container = containerRef.current; - if (!container) return; - - const axis = dragAxisRef.current; - if (axis === "vertical") { - const { pointerY, scrollTop } = dragStartRef.current; - const delta = event.clientY - pointerY; - const trackLength = container.clientHeight; - const thumbTravel = Math.max(trackLength - vertical.length, 1); - const maxScroll = Math.max(container.scrollHeight - container.clientHeight, 1); - const scrollDelta = (delta / thumbTravel) * maxScroll; - container.scrollTop = scrollTop + scrollDelta; - } else if (axis === "horizontal") { - const { pointerX, scrollLeft } = dragStartRef.current; - const delta = event.clientX - pointerX; - const trackLength = container.clientWidth; - const thumbTravel = Math.max(trackLength - horizontal.length, 1); - const maxScroll = Math.max(container.scrollWidth - container.clientWidth, 1); - const scrollDelta = (delta / thumbTravel) * maxScroll; - container.scrollLeft = scrollLeft + scrollDelta; - } - }; - - const handlePointerUp = (event: React.PointerEvent) => { - if (!isDraggingRef.current) return; - isDraggingRef.current = false; - event.currentTarget.releasePointerCapture(event.pointerId); - scheduleHide(); - }; - - const handleThumbMouseEnter = React.useCallback(() => { - isHoveringRef.current = true; - // Cancel any pending hide when hovering - if (hideTimeoutRef.current) { - clearTimeout(hideTimeoutRef.current); - hideTimeoutRef.current = null; - } - }, []); - - const handleThumbMouseLeave = React.useCallback(() => { - isHoveringRef.current = false; - // Schedule hide when leaving the thumb - scheduleHide(); - }, [scheduleHide]); - - const showVertical = vertical.length > 0; - const showHorizontal = horizontal.length > 0; - if (!showVertical && !showHorizontal) return null; - - const trackInset = 8; + React.useLayoutEffect(() => { + bindingRef.current?.update({ + minThumbSize, + hideDelayMs, + disableHorizontal, + observeMutations, + suppressVisibility, + userIntentOnly, + }); + }, [disableHorizontal, hideDelayMs, minThumbSize, observeMutations, suppressVisibility, userIntentOnly]); return ( -