UNPKG

vzcode

Version:
329 lines (295 loc) 9 kB
import { useRef, useCallback, useState, useEffect, } from 'react'; /** * Auto-scroll state machine with simplified states */ type AutoScrollState = 'AUTO_SCROLL_ON' | 'AUTO_SCROLL_OFF'; /** * Check if the container is at the bottom with optional slack */ function isAtBottom(el: HTMLElement, slack = 2): boolean { return ( el.scrollTop + el.clientHeight >= el.scrollHeight - slack ); } /** * Wait for scroll position to settle after programmatic scrolling */ function waitForScrollSettle( el: HTMLElement, { epsilon = 1, // px change to consider "no movement" stableFrames = 3, // frames in a row with no movement maxWaitMs = 1000, // safety timeout } = {}, ): Promise<void> { return new Promise<void>((resolve) => { let lastY = el.scrollTop; let stable = 0; let rafId = 0; const start = performance.now(); const tick = () => { const nowY = el.scrollTop; const moved = Math.abs(nowY - lastY) > epsilon; if (!moved) stable += 1; else stable = 0; lastY = nowY; const timedOut = performance.now() - start > maxWaitMs; if ( (stable >= stableFrames && isAtBottom(el)) || timedOut ) { cancelAnimationFrame(rafId); resolve(); return; } rafId = requestAnimationFrame(tick); }; rafId = requestAnimationFrame(tick); }); } /** * Hook options for customizing behavior */ interface UseAutoScrollOptions { /** Threshold for "at bottom" detection in pixels (default: 24) */ threshold?: number; } /** * Hook return type with methods and state */ interface UseAutoScrollReturn { /** Ref to attach to the scrollable container */ containerRef: React.RefObject<HTMLDivElement>; /** Current auto-scroll state */ autoScrollState: AutoScrollState; /** Whether to show the "jump to latest" button */ showJumpButton: boolean; /** Function to handle new content/events */ onNewEvent: (targetElement?: HTMLElement) => void; /** Function to handle user clicking jump to latest button */ onJumpToLatest: (targetElement?: HTMLElement) => void; /** Function to call before rendering new content (for anchoring) */ beforeRender: () => number; /** Function to call after rendering new content (for anchoring) */ afterRender: (prevScrollHeight: number) => void; } /** * Simplified auto-scroll hook implementing the state machine from the issue * * States: AUTO_SCROLL_ON, AUTO_SCROLL_OFF * Transitions: * - onNewEvent: if AUTO_SCROLL_ON → scrollToBottom(), if AUTO_SCROLL_OFF → no scroll * - onUserScroll: if isAtBottom(el) → AUTO_SCROLL_ON; hide button, else → AUTO_SCROLL_OFF; show button * - onJumpToLatestClick → AUTO_SCROLL_ON + scrollToBottom(); hide button */ export const useAutoScroll = ( options: UseAutoScrollOptions = {}, ): UseAutoScrollReturn => { const { threshold = 24 } = options; const containerRef = useRef<HTMLDivElement>(null); const [autoScrollState, setAutoScrollState] = useState<AutoScrollState>('AUTO_SCROLL_ON'); const [showJumpButton, setShowJumpButton] = useState(false); const rafIdRef = useRef<number>(); const isProgrammaticScrollRef = useRef(false); /** * Check if the container is at the bottom */ const isAtBottom = useCallback( (el: HTMLElement): boolean => { return ( el.scrollHeight - el.clientHeight - el.scrollTop <= threshold ); }, [threshold], ); /** * Update jump button visibility */ const updateJumpButtonVisibility = useCallback(() => { const container = containerRef.current; if (!container) { setShowJumpButton(false); return; } // Only show when AUTO_SCROLL_OFF AND not at bottom const shouldShow = autoScrollState === 'AUTO_SCROLL_OFF' && !isAtBottom(container); setShowJumpButton(shouldShow); }, [autoScrollState, isAtBottom]); /** * Handle scroll events to detect user manual scrolling */ const handleScroll = useCallback(() => { // Ignore scroll events during programmatic scrolling if (isProgrammaticScrollRef.current) return; const container = containerRef.current; if (!container) return; if (isAtBottom(container)) { // User scrolled back to bottom - enable auto-scroll and hide button setAutoScrollState('AUTO_SCROLL_ON'); } else { // User scrolled up - disable auto-scroll and show button setAutoScrollState('AUTO_SCROLL_OFF'); } }, [isAtBottom]); /** * Attach scroll listener to container */ useEffect(() => { const container = containerRef.current; if (!container) return; container.addEventListener('scroll', handleScroll, { passive: true, }); return () => { container.removeEventListener('scroll', handleScroll); }; }, [handleScroll]); /** * Update jump button visibility when state changes */ useEffect(() => { updateJumpButtonVisibility(); }, [updateJumpButtonVisibility]); /** * Handle new event/message render */ const onNewEvent = useCallback( (targetElement?: HTMLElement) => { if (autoScrollState === 'AUTO_SCROLL_ON') { // Use requestAnimationFrame for batched updates if (rafIdRef.current) { cancelAnimationFrame(rafIdRef.current); } rafIdRef.current = requestAnimationFrame( async () => { const container = containerRef.current; if (container) { // Set flag to ignore scroll events during programmatic scroll isProgrammaticScrollRef.current = true; // Perform smooth scroll if (targetElement) { // Scroll to specific element const containerRect = container.getBoundingClientRect(); const targetRect = targetElement.getBoundingClientRect(); const scrollTop = targetRect.top - containerRect.top + container.scrollTop; container.scrollTo({ top: scrollTop, behavior: 'smooth', }); } else { // Scroll to bottom container.scrollTo({ top: container.scrollHeight, behavior: 'smooth', }); } // Wait for scroll to settle before re-enabling scroll listener await waitForScrollSettle(container); isProgrammaticScrollRef.current = false; } }, ); } // If AUTO_SCROLL_OFF, do nothing (no scroll) }, [autoScrollState], ); /** * Handle jump to latest button click */ const onJumpToLatest = useCallback( async (targetElement?: HTMLElement) => { const container = containerRef.current; if (!container) return; // Set flag to ignore scroll events during programmatic scroll isProgrammaticScrollRef.current = true; // Set state to AUTO_SCROLL_ON setAutoScrollState('AUTO_SCROLL_ON'); // Perform smooth scroll if (targetElement) { // Scroll to specific element const containerRect = container.getBoundingClientRect(); const targetRect = targetElement.getBoundingClientRect(); const scrollTop = targetRect.top - containerRect.top + container.scrollTop; container.scrollTo({ top: scrollTop, behavior: 'smooth', }); } else { // Scroll to bottom container.scrollTo({ top: container.scrollHeight, behavior: 'smooth', }); } // Wait for scroll to settle before re-enabling scroll listener await waitForScrollSettle(container); isProgrammaticScrollRef.current = false; }, [], ); /** * Get scroll height before rendering (for anchoring) */ const beforeRender = useCallback((): number => { const container = containerRef.current; return container ? container.scrollHeight : 0; }, []); /** * Adjust scroll position after rendering (for anchoring when OFF) */ const afterRender = useCallback( (prevScrollHeight: number) => { if (autoScrollState === 'AUTO_SCROLL_OFF') { const container = containerRef.current; if (container) { const delta = container.scrollHeight - prevScrollHeight; container.scrollTop += delta; // anchor view } } }, [autoScrollState], ); /** * Clean up animation frame on unmount */ useEffect(() => { return () => { if (rafIdRef.current) { cancelAnimationFrame(rafIdRef.current); } }; }, []); return { containerRef, autoScrollState, showJumpButton, onNewEvent, onJumpToLatest, beforeRender, afterRender, }; };