返回 DeepSeek-Reasonix
useScrollManager.ts
根目录 / desktop / frontend / src / lib / useScrollManager.ts
1 import { useCallback, useEffect, useRef, useState } from "react";
2 import type { KeyboardEvent as ReactKeyboardEvent, TouchEvent as ReactTouchEvent, WheelEvent as ReactWheelEvent } from "react";
3 import gsap from "gsap";
4 import { DUR_FAST, EASE_OUT, prefersReducedMotion } from "./gsapAnimations";
5 import { isEditableTarget } from "./keyboardShortcuts";
6
7 type GsapModule = typeof gsap & { gsap?: typeof gsap };
8
9 const BOTTOM_THRESHOLD_PX = 80;
10 const TOUCH_SCROLL_THRESHOLD_PX = 2;
11 const SCROLL_BREAK_KEYS = new Set([
12 "ArrowUp",
13 "PageUp",
14 "Home",
15 ]);
16 const CONDITIONAL_SCROLL_KEYS = new Set([
17 "ArrowDown",
18 "PageDown",
19 "End",
20 " ",
21 "Spacebar",
22 ]);
23
24 function isNearBottom(el: HTMLElement): boolean {
25 return el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD_PX;
26 }
27
28 function isScrollable(el: HTMLElement): boolean {
29 return el.scrollHeight - el.clientHeight > 1;
30 }
31
32 const gsapApi = typeof gsap.killTweensOf === "function"
33 ? gsap
34 : ((gsap as GsapModule).gsap ?? gsap);
35
36 /**
37 * useScrollManager — GSAP-driven auto-scroll for the transcript container.
38 *
39 * - Auto-pins to the bottom when content is near the edge.
40 * - Smooth scroll for jump-to-question navigation.
41 * - Uses gsap.scrollTo for layout-safe scrolling (avoids layout thrashing).
42 * - Batches ResizeObserver callbacks into a single GSAP tween.
43 */
44 export function useScrollManager() {
45 const scrollRef = useRef<HTMLDivElement>(null);
46 const stick = useRef(true);
47 const gsapCtx = useRef<gsap.Context | null>(null);
48 const prevQuestionsLen = useRef(0);
49 const resizeFrame = useRef<number | null>(null);
50 const repinFrame = useRef<number | null>(null);
51 const pendingRepinHeightDelta = useRef(0);
52 const layoutScrollFrames = useRef<number[]>([]);
53 const touchStartY = useRef<number | null>(null);
54 const lastClientHeight = useRef<number | null>(null);
55 const lastFooterHeight = useRef<number | null>(null);
56 const [isAtBottom, setIsAtBottom] = useState(true);
57
58 // Kill any lingering tweens on unmount.
59 useEffect(() => {
60 return () => {
61 gsapCtx.current?.revert();
62 if (resizeFrame.current !== null) cancelAnimationFrame(resizeFrame.current);
63 if (repinFrame.current !== null) cancelAnimationFrame(repinFrame.current);
64 for (const frame of layoutScrollFrames.current) cancelAnimationFrame(frame);
65 layoutScrollFrames.current = [];
66 };
67 }, []);
68
69 const updateBottomState = useCallback((el: HTMLElement) => {
70 const atBottom = isNearBottom(el);
71 stick.current = atBottom;
72 setIsAtBottom(atBottom);
73 return atBottom;
74 }, []);
75
76 const cancelPendingBottomScroll = useCallback(() => {
77 if (resizeFrame.current !== null) {
78 cancelAnimationFrame(resizeFrame.current);
79 resizeFrame.current = null;
80 }
81 if (repinFrame.current !== null) {
82 cancelAnimationFrame(repinFrame.current);
83 repinFrame.current = null;
84 }
85 pendingRepinHeightDelta.current = 0;
86 for (const frame of layoutScrollFrames.current) cancelAnimationFrame(frame);
87 layoutScrollFrames.current = [];
88 }, []);
89
90 const releaseAutoScroll = useCallback(() => {
91 const el = scrollRef.current;
92 if (el) gsapApi.killTweensOf(el);
93 cancelPendingBottomScroll();
94 stick.current = false;
95 setIsAtBottom(false);
96 }, [cancelPendingBottomScroll]);
97
98 const onWheelIntent = useCallback((event: ReactWheelEvent<HTMLElement>) => {
99 const el = scrollRef.current;
100 // ctrlKey marks a pinch-zoom gesture synthesized as a wheel event (trackpads on
101 // macOS/Chrome), not a scroll — treating it as scroll intent would release
102 // tail-follow on a zoom that never actually moved scrollTop.
103 if (!el || !isScrollable(el) || event.ctrlKey || event.deltaY === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return false;
104 if (event.deltaY < 0 || !isNearBottom(el)) {
105 releaseAutoScroll();
106 return true;
107 }
108 return false;
109 }, [releaseAutoScroll]);
110
111 const onTouchStartIntent = useCallback((event: ReactTouchEvent<HTMLElement>) => {
112 touchStartY.current = event.touches[0]?.clientY ?? null;
113 }, []);
114
115 const onTouchMoveIntent = useCallback((event: ReactTouchEvent<HTMLElement>) => {
116 const el = scrollRef.current;
117 const startY = touchStartY.current;
118 const currentY = event.touches[0]?.clientY;
119 if (!el || !isScrollable(el) || startY === null || currentY === undefined) return false;
120 const deltaY = currentY - startY;
121 if (Math.abs(deltaY) < TOUCH_SCROLL_THRESHOLD_PX) return false;
122 if (deltaY > 0 || !isNearBottom(el)) {
123 releaseAutoScroll();
124 return true;
125 }
126 return false;
127 }, [releaseAutoScroll]);
128
129 const onKeyScrollIntent = useCallback((event: ReactKeyboardEvent<HTMLElement>) => {
130 const el = scrollRef.current;
131 // The transcript's scroll keys (Home/End/arrows/space/page keys) are also
132 // ordinary text-editing keys. This listener runs on the capture phase, ahead
133 // of a nested message-edit textarea's own key handling, so without this guard
134 // moving the cursor while editing an earlier message would release tail-follow
135 // on a completely unrelated stream, even though nothing was scrolled.
136 if (!el || !isScrollable(el) || isEditableTarget(event.target)) return false;
137 if (SCROLL_BREAK_KEYS.has(event.key) || (CONDITIONAL_SCROLL_KEYS.has(event.key) && !isNearBottom(el))) {
138 releaseAutoScroll();
139 return true;
140 }
141 return false;
142 }, [releaseAutoScroll]);
143
144 const onScroll = useCallback(() => {
145 const el = scrollRef.current;
146 if (el) updateBottomState(el);
147 }, [updateBottomState]);
148
149 /** Scroll smoothly to a specific element. Used by the JumpBar. */
150 const smoothScrollTo = useCallback((element: HTMLElement, offset = 12) => {
151 const el = scrollRef.current;
152 if (!el) return;
153 stick.current = false;
154 setIsAtBottom(false);
155 if (resizeFrame.current !== null) {
156 cancelAnimationFrame(resizeFrame.current);
157 resizeFrame.current = null;
158 }
159 const rect = element.getBoundingClientRect();
160 const containerRect = el.getBoundingClientRect();
161 const top = el.scrollTop + rect.top - containerRect.top - offset;
162 const reduced = prefersReducedMotion();
163 gsapApi.to(el, {
164 scrollTo: { y: Math.max(0, top) },
165 duration: reduced ? 0.001 : DUR_FAST * 2,
166 ease: EASE_OUT,
167 onComplete: () => updateBottomState(el),
168 });
169 }, [updateBottomState]);
170
171 /** Force-scroll to the bottom — used when a new question is sent. */
172 const scrollToBottom = useCallback((force = false) => {
173 const el = scrollRef.current;
174 if (!el) return;
175 if (force) {
176 stick.current = true;
177 setIsAtBottom(true);
178 }
179 if (!stick.current && !force) return;
180 if (resizeFrame.current !== null) {
181 cancelAnimationFrame(resizeFrame.current);
182 resizeFrame.current = null;
183 }
184 resizeFrame.current = requestAnimationFrame(() => {
185 resizeFrame.current = null;
186 if (!stick.current && !force) return;
187 if (force) {
188 stick.current = true;
189 setIsAtBottom(true);
190 }
191 const reduced = prefersReducedMotion();
192 gsapApi.to(el, {
193 scrollTo: { y: "max" },
194 duration: reduced ? 0.001 : DUR_FAST,
195 ease: "none",
196 overwrite: "auto",
197 onComplete: () => {
198 stick.current = true;
199 setIsAtBottom(true);
200 },
201 });
202 });
203 }, []);
204
205 const snapToBottom = useCallback(() => {
206 const el = scrollRef.current;
207 if (!el) return;
208 if (resizeFrame.current !== null) {
209 cancelAnimationFrame(resizeFrame.current);
210 resizeFrame.current = null;
211 }
212 gsapApi.killTweensOf(el);
213 stick.current = true;
214 el.scrollTop = el.scrollHeight;
215 setIsAtBottom(true);
216 }, []);
217
218 const scrollToBottomAfterLayout = useCallback((frames = 4) => {
219 for (const frame of layoutScrollFrames.current) cancelAnimationFrame(frame);
220 layoutScrollFrames.current = [];
221 snapToBottom();
222 let remaining = Math.max(0, frames);
223 const tick = () => {
224 if (remaining <= 0) return;
225 const frame = requestAnimationFrame(() => {
226 layoutScrollFrames.current = layoutScrollFrames.current.filter((id) => id !== frame);
227 snapToBottom();
228 remaining -= 1;
229 tick();
230 });
231 layoutScrollFrames.current.push(frame);
232 };
233 tick();
234 }, [snapToBottom]);
235
236 /** Call when a new question is submitted — overrides stick state. */
237 const onNewQuestion = useCallback(() => {
238 stick.current = true;
239 scrollToBottom(true);
240 }, [scrollToBottom]);
241
242 /**
243 * Refresh pin state on resize — call from a ResizeObserver on the container.
244 */
245 const repinIfWasPinned = useCallback(
246 (containerHeightDelta: number) => {
247 const el = scrollRef.current;
248 if (!el) return;
249 const bottomDistance = el.scrollHeight - el.scrollTop - el.clientHeight;
250 if (!stick.current && bottomDistance + containerHeightDelta >= BOTTOM_THRESHOLD_PX) return;
251 stick.current = true;
252 setIsAtBottom(true);
253 scrollToBottom();
254 },
255 [scrollToBottom],
256 );
257
258 const scheduleRepinIfWasPinned = useCallback(
259 (containerHeightDelta: number) => {
260 pendingRepinHeightDelta.current += containerHeightDelta;
261 if (repinFrame.current !== null) return;
262 repinFrame.current = requestAnimationFrame(() => {
263 repinFrame.current = null;
264 const delta = pendingRepinHeightDelta.current;
265 pendingRepinHeightDelta.current = 0;
266 repinIfWasPinned(delta);
267 });
268 },
269 [repinIfWasPinned],
270 );
271
272 /**
273 * Track question count changes to call onNewQuestion.
274 * Returns the previous length ref for useEffect comparison.
275 */
276 const trackQuestions = useCallback(
277 (questionsLen: number) => {
278 if (questionsLen > prevQuestionsLen.current) {
279 onNewQuestion();
280 }
281 prevQuestionsLen.current = questionsLen;
282 },
283 [onNewQuestion],
284 );
285
286 return {
287 scrollRef,
288 stick,
289 onScroll,
290 onWheelIntent,
291 onTouchStartIntent,
292 onTouchMoveIntent,
293 onKeyScrollIntent,
294 isAtBottom,
295 smoothScrollTo,
296 scrollToBottom,
297 scrollToBottomAfterLayout,
298 onNewQuestion,
299 repinIfWasPinned,
300 scheduleRepinIfWasPinned,
301 trackQuestions,
302 resizeFrame,
303 lastClientHeight,
304 lastFooterHeight,
305 };
306 }
307
307 lines TYPESCRIPT