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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const isAtBottomRef = useRef(true);
|
const isAtBottomRef = useRef(true);
|
||||||
const prevShowBottomRef = useRef(false);
|
const prevShowBottomRef = useRef(false);
|
||||||
|
const scrollTopRafRef = useRef(0);
|
||||||
const prevViewKeyRef = useRef(viewKey);
|
const prevViewKeyRef = useRef(viewKey);
|
||||||
const viewKeyRef = useRef(viewKey);
|
const viewKeyRef = useRef(viewKey);
|
||||||
viewKeyRef.current = viewKey;
|
viewKeyRef.current = viewKey;
|
||||||
@ -65,8 +66,16 @@ export function MessageList({
|
|||||||
|
|
||||||
// ---- scroll helpers ----
|
// ---- scroll helpers ----
|
||||||
|
|
||||||
|
const stopScrollTopAnimation = useCallback(() => {
|
||||||
|
if (scrollTopRafRef.current !== 0) {
|
||||||
|
cancelAnimationFrame(scrollTopRafRef.current);
|
||||||
|
scrollTopRafRef.current = 0;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(
|
const scrollToBottom = useCallback(
|
||||||
(behavior: ScrollBehavior = 'smooth') => {
|
(behavior: ScrollBehavior = 'smooth') => {
|
||||||
|
stopScrollTopAnimation();
|
||||||
isAtBottomRef.current = true;
|
isAtBottomRef.current = true;
|
||||||
setShowScrollToBottom(false);
|
setShowScrollToBottom(false);
|
||||||
setNewMessageCount(0);
|
setNewMessageCount(0);
|
||||||
@ -74,12 +83,30 @@ export function MessageList({
|
|||||||
virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior });
|
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(() => {
|
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 ----
|
// ---- scroll event: track whether user is at bottom ----
|
||||||
|
|
||||||
@ -126,6 +153,7 @@ export function MessageList({
|
|||||||
|
|
||||||
if (viewChanged) {
|
if (viewChanged) {
|
||||||
// View switched (e.g. breadcrumb navigation): restore saved scroll position
|
// View switched (e.g. breadcrumb navigation): restore saved scroll position
|
||||||
|
stopScrollTopAnimation();
|
||||||
prevMessageCountRef.current = messages.length;
|
prevMessageCountRef.current = messages.length;
|
||||||
const key = viewKey ?? '';
|
const key = viewKey ?? '';
|
||||||
const savedPos = scrollPositionsRef.current.get(key);
|
const savedPos = scrollPositionsRef.current.get(key);
|
||||||
@ -148,6 +176,7 @@ export function MessageList({
|
|||||||
prevMessageCountRef.current = messages.length;
|
prevMessageCountRef.current = messages.length;
|
||||||
|
|
||||||
if (lastMessage.role === 'user' || isAtBottomRef.current) {
|
if (lastMessage.role === 'user' || isAtBottomRef.current) {
|
||||||
|
stopScrollTopAnimation();
|
||||||
virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' });
|
virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' });
|
||||||
// 用户自己发消息或已在底部时,不需要计数
|
// 用户自己发消息或已在底部时,不需要计数
|
||||||
if (newCount > 0) {
|
if (newCount > 0) {
|
||||||
@ -157,7 +186,7 @@ export function MessageList({
|
|||||||
// 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数)
|
// 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数)
|
||||||
setNewMessageCount((prev) => prev + newCount);
|
setNewMessageCount((prev) => prev + newCount);
|
||||||
}
|
}
|
||||||
}, [messages, viewKey, virtualizer]);
|
}, [messages, viewKey, virtualizer, stopScrollTopAnimation]);
|
||||||
|
|
||||||
// ---- mount: always scroll to bottom if messages already loaded ----
|
// ---- mount: always scroll to bottom if messages already loaded ----
|
||||||
|
|
||||||
@ -175,6 +204,7 @@ export function MessageList({
|
|||||||
const idx = messageIdToIndex.get(highlightedMessageId);
|
const idx = messageIdToIndex.get(highlightedMessageId);
|
||||||
if (idx === undefined) return;
|
if (idx === undefined) return;
|
||||||
|
|
||||||
|
stopScrollTopAnimation();
|
||||||
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
|
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
|
||||||
|
|
||||||
// 高亮 class 需等 DOM 渲染后操作
|
// 高亮 class 需等 DOM 渲染后操作
|
||||||
@ -188,7 +218,25 @@ export function MessageList({
|
|||||||
targetElement.classList.remove('todo-highlight');
|
targetElement.classList.remove('todo-highlight');
|
||||||
}, 2000);
|
}, 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 陈旧高度导致行重叠)----
|
// ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)----
|
||||||
// 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度;
|
// 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user