From 27160ef560045f184743e05c939e54b77a6c3f7a Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Mon, 17 Aug 2026 16:36:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E5=9B=9E=E5=88=B0=E9=A1=B6?= =?UTF-8?q?=E9=83=A8=E6=94=B9=E7=94=A8=20rAF=20=E8=87=AA=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=E5=8A=A8=E7=94=BB=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=A4=9A=E6=97=B6=E5=B9=B3=E6=BB=91=E6=BB=9A=E5=8A=A8=E8=A2=AB?= =?UTF-8?q?=20virtualizer=20=E7=BA=A0=E6=AD=A3=E5=86=99=E5=85=A5=E6=89=93?= =?UTF-8?q?=E6=96=AD=E5=81=9C=E5=9C=A8=E5=8D=8A=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/Chat/MessageList.tsx | 58 ++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/web/src/components/Chat/MessageList.tsx b/web/src/components/Chat/MessageList.tsx index 1dedf7d..6b7fbd8 100644 --- a/web/src/components/Chat/MessageList.tsx +++ b/web/src/components/Chat/MessageList.tsx @@ -27,6 +27,7 @@ export function MessageList({ const containerRef = useRef(null); const isAtBottomRef = useRef(true); const prevShowBottomRef = useRef(false); + const scrollTopRafRef = useRef(0); const prevViewKeyRef = useRef(viewKey); const viewKeyRef = useRef(viewKey); viewKeyRef.current = viewKey; @@ -65,8 +66,16 @@ export function MessageList({ // ---- scroll helpers ---- + const stopScrollTopAnimation = useCallback(() => { + if (scrollTopRafRef.current !== 0) { + cancelAnimationFrame(scrollTopRafRef.current); + scrollTopRafRef.current = 0; + } + }, []); + const scrollToBottom = useCallback( (behavior: ScrollBehavior = 'smooth') => { + stopScrollTopAnimation(); isAtBottomRef.current = true; setShowScrollToBottom(false); setNewMessageCount(0); @@ -74,12 +83,30 @@ export function MessageList({ virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior }); } }, - [virtualizer, messages.length], + [virtualizer, messages.length, stopScrollTopAnimation], ); + // 长距离滚回顶部不能用原生 behavior:'smooth':滚动途中上方行首次被测量时, + // virtualizer 会写 scrollTop 做偏移纠正(resizeItem → applyScrollAdjustment), + // 程序化 scrollTop 写入会直接打断浏览器平滑滚动动画,消息越多越容易停在半路。 + // 改为 rAF 自驱动动画,每帧覆写 scrollTop,纠正写入下一帧即被覆盖,必达顶部。 const scrollToTop = useCallback(() => { - containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); - }, []); + const el = containerRef.current; + if (!el) return; + stopScrollTopAnimation(); + const from = el.scrollTop; + if (from <= 0) return; + const duration = Math.min(800, 250 + from / 8); + const start = performance.now(); + const easeInOutCubic = (t: number) => + t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + const step = (now: number) => { + const p = Math.min(1, (now - start) / duration); + el.scrollTop = from * (1 - easeInOutCubic(p)); + scrollTopRafRef.current = p < 1 ? requestAnimationFrame(step) : 0; + }; + scrollTopRafRef.current = requestAnimationFrame(step); + }, [stopScrollTopAnimation]); // ---- scroll event: track whether user is at bottom ---- @@ -126,6 +153,7 @@ export function MessageList({ if (viewChanged) { // View switched (e.g. breadcrumb navigation): restore saved scroll position + stopScrollTopAnimation(); prevMessageCountRef.current = messages.length; const key = viewKey ?? ''; const savedPos = scrollPositionsRef.current.get(key); @@ -148,6 +176,7 @@ export function MessageList({ prevMessageCountRef.current = messages.length; if (lastMessage.role === 'user' || isAtBottomRef.current) { + stopScrollTopAnimation(); virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' }); // 用户自己发消息或已在底部时,不需要计数 if (newCount > 0) { @@ -157,7 +186,7 @@ export function MessageList({ // 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数) setNewMessageCount((prev) => prev + newCount); } - }, [messages, viewKey, virtualizer]); + }, [messages, viewKey, virtualizer, stopScrollTopAnimation]); // ---- mount: always scroll to bottom if messages already loaded ---- @@ -175,6 +204,7 @@ export function MessageList({ const idx = messageIdToIndex.get(highlightedMessageId); if (idx === undefined) return; + stopScrollTopAnimation(); virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' }); // 高亮 class 需等 DOM 渲染后操作 @@ -188,7 +218,25 @@ export function MessageList({ targetElement.classList.remove('todo-highlight'); }, 2000); }); - }, [highlightedMessageId, messageIdToIndex, virtualizer]); + }, [highlightedMessageId, messageIdToIndex, virtualizer, stopScrollTopAnimation]); + + // ---- 滚顶动画生命周期 ---- + + // 用户手动滚动(滚轮/触摸)时打断滚顶动画,交还滚动控制权 + const hasMessages = messages.length > 0; + useEffect(() => { + const el = containerRef.current; + if (!el) return; + el.addEventListener('wheel', stopScrollTopAnimation, { passive: true }); + el.addEventListener('touchstart', stopScrollTopAnimation, { passive: true }); + return () => { + el.removeEventListener('wheel', stopScrollTopAnimation); + el.removeEventListener('touchstart', stopScrollTopAnimation); + }; + }, [hasMessages, stopScrollTopAnimation]); + + // 卸载时取消未完成的动画 + useEffect(() => stopScrollTopAnimation, [stopScrollTopAnimation]); // ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)---- // 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度;