| 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 |