返回 AiToEarn
useScrollControl.ts
根目录 / project / aitoearn-web / src / app / [lng] / chat / [taskId] / hooks / useScrollControl.ts
1 /**
2 * 滚动控制 Hook
3 * 管理消息列表的智能滚动行为
4 */
5 import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
6
7 export interface IScrollControlOptions {
8 /** 判断为"底部附近"的阈值(px) */
9 nearBottomThreshold?: number
10 /** 显示回到底部按钮的阈值(px) */
11 showButtonThreshold?: number
12 /** 滚动状态重置延迟(ms) */
13 scrollResetDelay?: number
14 }
15
16 export interface IScrollControlReturn {
17 /** 消息容器 ref */
18 containerRef: React.RefObject<HTMLDivElement>
19 /** 消息底部 ref(用于滚动到底部) */
20 bottomRef: React.RefObject<HTMLDivElement>
21 /** 用户是否在底部附近 */
22 isNearBottom: boolean
23 /** 是否显示回到底部按钮 */
24 showScrollButton: boolean
25 /** 滚动到底部 */
26 scrollToBottom: (force?: boolean) => void
27 /** 处理滚动事件(绑定到容器) */
28 handleScroll: () => void
29 /** 首次加载完成后调用,确保滚动到底部 */
30 onContentReady: () => void
31 }
32
33 /**
34 * 滚动控制 Hook
35 * @param options 配置选项
36 */
37 export function useScrollControl(options: IScrollControlOptions = {}): IScrollControlReturn {
38 const { nearBottomThreshold = 150, showButtonThreshold = 300, scrollResetDelay = 150 } = options
39
40 // Refs
41 const containerRef = useRef<HTMLDivElement>(null)
42 const bottomRef = useRef<HTMLDivElement>(null)
43 const isUserScrollingRef = useRef(false)
44 const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null)
45 const hasInitialScrolledRef = useRef(false)
46 const pendingScrollRef = useRef(false)
47
48 // State
49 const [isNearBottom, setIsNearBottom] = useState(true)
50 const [showScrollButton, setShowScrollButton] = useState(false)
51
52 /** 检查是否在底部附近 */
53 const checkIfNearBottom = useCallback(() => {
54 const container = containerRef.current
55 if (!container)
56 return true
57
58 const { scrollTop, scrollHeight, clientHeight } = container
59 return scrollHeight - scrollTop - clientHeight < nearBottomThreshold
60 }, [nearBottomThreshold])
61
62 /** 直接设置滚动位置到底部(无动画,避免闪烁) */
63 const scrollToBottomImmediate = useCallback(() => {
64 const container = containerRef.current
65 if (container) {
66 container.scrollTop = container.scrollHeight
67 }
68 setIsNearBottom(true)
69 setShowScrollButton(false)
70 }, [])
71
72 /** 滚动到底部 */
73 const scrollToBottom = useCallback((force = false) => {
74 if (force) {
75 isUserScrollingRef.current = false
76 setIsNearBottom(true)
77 setShowScrollButton(false)
78 }
79 bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
80 }, [])
81
82 /** 首次加载完成后标记需要滚动 */
83 const onContentReady = useCallback(() => {
84 if (hasInitialScrolledRef.current)
85 return
86 pendingScrollRef.current = true
87 }, [])
88
89 /** 使用 useLayoutEffect 在 DOM 更新后同步滚动,避免闪烁 */
90 useLayoutEffect(() => {
91 if (pendingScrollRef.current && !hasInitialScrolledRef.current) {
92 hasInitialScrolledRef.current = true
93 pendingScrollRef.current = false
94 scrollToBottomImmediate()
95 }
96 })
97
98 /** 处理滚动事件 */
99 const handleScroll = useCallback(() => {
100 // 标记用户正在滚动
101 isUserScrollingRef.current = true
102
103 // 清除之前的定时器
104 if (scrollTimeoutRef.current) {
105 clearTimeout(scrollTimeoutRef.current)
106 }
107
108 // 延迟重置滚动状态
109 scrollTimeoutRef.current = setTimeout(() => {
110 isUserScrollingRef.current = false
111 }, scrollResetDelay)
112
113 const nearBottom = checkIfNearBottom()
114 setIsNearBottom(nearBottom)
115
116 // 根据距离底部的距离决定是否显示按钮
117 const container = containerRef.current
118 if (container) {
119 const { scrollTop, scrollHeight, clientHeight } = container
120 const distanceFromBottom = scrollHeight - scrollTop - clientHeight
121 setShowScrollButton(distanceFromBottom > showButtonThreshold)
122 }
123 }, [checkIfNearBottom, scrollResetDelay, showButtonThreshold])
124
125 /** 清理定时器 */
126 useEffect(() => {
127 return () => {
128 if (scrollTimeoutRef.current) {
129 clearTimeout(scrollTimeoutRef.current)
130 }
131 }
132 }, [])
133
134 return {
135 containerRef,
136 bottomRef,
137 isNearBottom,
138 showScrollButton,
139 scrollToBottom,
140 handleScroll,
141 onContentReady,
142 }
143 }
144
144 lines TYPESCRIPT