fix(web): 回到顶部改用 rAF 自驱动动画,修复消息多时平滑滚动被 virtualizer 纠正写入打断停在半路
This commit is contained in:
parent
af344b7087
commit
27160ef560
@ -27,6 +27,7 @@ export function MessageList({
|
||||
const containerRef = useRef<HTMLDivElement>(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 更新并复用缓存高度;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user