fix(chat): 修复视图切换滚动位置丢失与顶部按钮无法滚到首条消息
动态行高缓存按消息 id 键控,避免子智能体视图高度污染主视图;恢复后两帧 rAF 内不回写保存位置。 顶部按钮链式贴顶:动画落定显式续链,分页 prepend 时贴到新顶部,直到真正首条消息;关闭 virtualizer 行高修正的 scrollTop 自动补偿。 用户滚动输入/回底/待办高亮/视图切换/分页耗尽时清除贴顶意图。
This commit is contained in:
parent
6ac653bf51
commit
028f60557e
@ -47,6 +47,13 @@ export const MessageList = memo(function MessageList({
|
|||||||
const onLoadOlderRef = useRef(onLoadOlder);
|
const onLoadOlderRef = useRef(onLoadOlder);
|
||||||
onLoadOlderRef.current = onLoadOlder;
|
onLoadOlderRef.current = onLoadOlder;
|
||||||
|
|
||||||
|
// hasMoreOlder 镜像:供 layout effect 的 prepend 分支判断分页是否耗尽。
|
||||||
|
// 不进依赖数组——否则 hasMoreOlder 单独变化会重跑 effect,落入同视图
|
||||||
|
// 分支的滚底逻辑(lastMessage 为 user 时 scrollToIndex 到底),把
|
||||||
|
// 正在顶部浏览历史的用户拽到底部。
|
||||||
|
const hasMoreOlderRef = useRef(hasMoreOlder);
|
||||||
|
hasMoreOlderRef.current = hasMoreOlder;
|
||||||
|
|
||||||
// 追踪上次的消息条数,用于计算真正新增的消息数(而非 messages 引用变化次数)。
|
// 追踪上次的消息条数,用于计算真正新增的消息数(而非 messages 引用变化次数)。
|
||||||
// 流式输出时每个 delta 都会产生新的 messages 数组引用,但消息条数不变,
|
// 流式输出时每个 delta 都会产生新的 messages 数组引用,但消息条数不变,
|
||||||
// 不应计入 newMessageCount。
|
// 不应计入 newMessageCount。
|
||||||
@ -55,6 +62,17 @@ export const MessageList = memo(function MessageList({
|
|||||||
// Per-view scroll position memory
|
// Per-view scroll position memory
|
||||||
const scrollPositionsRef = useRef<Map<string, number>>(new Map());
|
const scrollPositionsRef = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
|
// 滚动位置恢复期标志:恢复 scrollTop 后 virtualizer 会对可见行重测并做
|
||||||
|
// 滚动纠正,期间的 scroll 事件不应回写 scrollPositions(否则中间值会
|
||||||
|
// 覆盖掉原始保存位置,下次切回时锚点已丢失)。
|
||||||
|
const restoringRef = useRef(false);
|
||||||
|
|
||||||
|
// "顶部"按钮的链式贴顶意图:滚到已加载内容的顶部只是中间态——触顶
|
||||||
|
// 加载的历史消息 prepend 后不做锚定补偿,而是继续贴到新顶部,链式
|
||||||
|
// 加载直到真正首条消息(hasMoreOlder 耗尽)。用户滚动输入或视图切换
|
||||||
|
// 即取消意图,恢复正常锚定语义。
|
||||||
|
const scrollToTopIntentRef = useRef(false);
|
||||||
|
|
||||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||||
const [newMessageCount, setNewMessageCount] = useState(0);
|
const [newMessageCount, setNewMessageCount] = useState(0);
|
||||||
|
|
||||||
@ -65,12 +83,24 @@ export const MessageList = memo(function MessageList({
|
|||||||
getScrollElement: () => containerRef.current,
|
getScrollElement: () => containerRef.current,
|
||||||
estimateSize: () => 120,
|
estimateSize: () => 120,
|
||||||
overscan: 6,
|
overscan: 6,
|
||||||
|
// 动态高度缓存按消息 id(而非 index)存取:主/子智能体视图共用同一个
|
||||||
|
// virtualizer 实例,若按 index 键控,访问子智能体视图会用其行高污染主
|
||||||
|
// 视图的高度缓存,返回主视图恢复 scrollTop 时像素偏移无法映射回原消息。
|
||||||
|
getItemKey: (i) => messages[i]?.id ?? i,
|
||||||
measureElement:
|
measureElement:
|
||||||
typeof window !== 'undefined' && navigator.userAgent.includes('Firefox')
|
typeof window !== 'undefined' && navigator.userAgent.includes('Firefox')
|
||||||
? (el) => el.getBoundingClientRect().height
|
? (el) => el.getBoundingClientRect().height
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 禁用 virtualizer 在行高实测修正时对 scrollTop 的自动补偿写入
|
||||||
|
// (__resizeItem → applyScrollAdjustment):该写入会打断滚顶动画、把链式
|
||||||
|
// 贴顶从 scrollTop=0 推离,且其触发的 scroll 事件会被误判为用户接管。
|
||||||
|
// scrollTop 的写手全部由本组件显式管理:prepend 锚定(+=)、链式贴顶
|
||||||
|
// (=0)、滚底(scrollToIndex)。注意:该开关是实例属性而非 options
|
||||||
|
// 字段(virtual-core 3.x 类型声明),故在此赋值。
|
||||||
|
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false;
|
||||||
|
|
||||||
// 消息 id → virtualizer index 映射,用于 highlight 滚动定位。
|
// 消息 id → virtualizer index 映射,用于 highlight 滚动定位。
|
||||||
// useMemo 化:仅在 messages 变化时重建,而非每次渲染(流式期间每帧一次)都全量重建。
|
// useMemo 化:仅在 messages 变化时重建,而非每次渲染(流式期间每帧一次)都全量重建。
|
||||||
const messageIdToIndex = useMemo(() => {
|
const messageIdToIndex = useMemo(() => {
|
||||||
@ -91,6 +121,7 @@ export const MessageList = memo(function MessageList({
|
|||||||
const scrollToBottom = useCallback(
|
const scrollToBottom = useCallback(
|
||||||
(behavior: ScrollBehavior = 'smooth') => {
|
(behavior: ScrollBehavior = 'smooth') => {
|
||||||
stopScrollTopAnimation();
|
stopScrollTopAnimation();
|
||||||
|
scrollToTopIntentRef.current = false;
|
||||||
isAtBottomRef.current = true;
|
isAtBottomRef.current = true;
|
||||||
setShowScrollToBottom(false);
|
setShowScrollToBottom(false);
|
||||||
setNewMessageCount(0);
|
setNewMessageCount(0);
|
||||||
@ -109,8 +140,13 @@ export const MessageList = memo(function MessageList({
|
|||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
stopScrollTopAnimation();
|
stopScrollTopAnimation();
|
||||||
|
scrollToTopIntentRef.current = true;
|
||||||
const from = el.scrollTop;
|
const from = el.scrollTop;
|
||||||
if (from <= 0) return;
|
if (from <= 0) {
|
||||||
|
// 已在已加载内容的顶部:直接补触发一次分页(有更早消息则进入链式贴顶)
|
||||||
|
onLoadOlderRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const duration = Math.min(800, 250 + from / 8);
|
const duration = Math.min(800, 250 + from / 8);
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const easeInOutCubic = (t: number) =>
|
const easeInOutCubic = (t: number) =>
|
||||||
@ -118,7 +154,16 @@ export const MessageList = memo(function MessageList({
|
|||||||
const step = (now: number) => {
|
const step = (now: number) => {
|
||||||
const p = Math.min(1, (now - start) / duration);
|
const p = Math.min(1, (now - start) / duration);
|
||||||
el.scrollTop = from * (1 - easeInOutCubic(p));
|
el.scrollTop = from * (1 - easeInOutCubic(p));
|
||||||
scrollTopRafRef.current = p < 1 ? requestAnimationFrame(step) : 0;
|
if (p < 1) {
|
||||||
|
scrollTopRafRef.current = requestAnimationFrame(step);
|
||||||
|
} else {
|
||||||
|
scrollTopRafRef.current = 0;
|
||||||
|
// 陷阱:ease 末段每帧位移是亚像素,浏览器将 scrollTop snap 到 0 后,
|
||||||
|
// 最终的 scrollTop = 0 是无变化赋值,不派发 scroll 事件 → 触顶加载
|
||||||
|
// 永远不会被 handleScroll 触发。动画落定后必须显式续链。
|
||||||
|
// (与 scroll 事件路径可能双发一次:requestLoadOlder 内部幂等,无害)
|
||||||
|
if (scrollToTopIntentRef.current) onLoadOlderRef.current?.();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
scrollTopRafRef.current = requestAnimationFrame(step);
|
scrollTopRafRef.current = requestAnimationFrame(step);
|
||||||
}, [stopScrollTopAnimation]);
|
}, [stopScrollTopAnimation]);
|
||||||
@ -129,6 +174,12 @@ export const MessageList = memo(function MessageList({
|
|||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
|
// 注意:此处不做"scrollTop>0 即取消贴顶意图"的启发式判断——程序化
|
||||||
|
// scrollTop 写入(锚定补偿、virtualizer 纠正残留)也会产生离开顶部的
|
||||||
|
// scroll 事件,启发式无法区分用户与程序,会误杀链式贴顶。意图的取消
|
||||||
|
// 只由显式用户输入事件(wheel/touchstart/pointerdown,见下方监听)、
|
||||||
|
// scrollToBottom、待办高亮、视图切换与分页耗尽负责。
|
||||||
|
|
||||||
// 触顶加载更早历史:滚顶动画运行中不触发(动画会持续覆写 scrollTop,
|
// 触顶加载更早历史:滚顶动画运行中不触发(动画会持续覆写 scrollTop,
|
||||||
// 与 prepend 锚定补偿互相打架);动画落定在 scrollTop=0 时会发出最后一次
|
// 与 prepend 锚定补偿互相打架);动画落定在 scrollTop=0 时会发出最后一次
|
||||||
// scroll 事件,此刻 rafRef 已清零,可正常触发。重复触发由 hook 内
|
// scroll 事件,此刻 rafRef 已清零,可正常触发。重复触发由 hook 内
|
||||||
@ -141,8 +192,9 @@ export const MessageList = memo(function MessageList({
|
|||||||
const nearBottom = distanceFromBottom < 120;
|
const nearBottom = distanceFromBottom < 120;
|
||||||
|
|
||||||
// Save scroll position for current view
|
// Save scroll position for current view
|
||||||
|
// 恢复期不回写:此时 scrollTop 是刚恢复的旧值或重测纠正的中间值
|
||||||
const key = viewKeyRef.current;
|
const key = viewKeyRef.current;
|
||||||
if (key) {
|
if (key && !restoringRef.current) {
|
||||||
scrollPositionsRef.current.set(key, el.scrollTop);
|
scrollPositionsRef.current.set(key, el.scrollTop);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,8 +245,21 @@ export const MessageList = memo(function MessageList({
|
|||||||
const prevH = prevScrollHeightRef.current;
|
const prevH = prevScrollHeightRef.current;
|
||||||
const newH = el!.scrollHeight;
|
const newH = el!.scrollHeight;
|
||||||
if (newH > prevH) {
|
if (newH > prevH) {
|
||||||
|
if (scrollToTopIntentRef.current) {
|
||||||
|
// "顶部"链式贴顶:不做锚定补偿,直接贴到新顶部
|
||||||
|
const wasAtTop = el!.scrollTop === 0;
|
||||||
|
el!.scrollTop = 0;
|
||||||
|
// 陷阱:scrollTop 已为 0 时赋值不触发 scroll 事件,触顶加载链会
|
||||||
|
// 在首批后静默中断。此处显式续链(loading/hasMore 守卫在
|
||||||
|
// requestLoadOlder 内部吸收);wasAtTop=false 时赋值会派发
|
||||||
|
// scroll 事件,走 handleScroll 正常续链,避免双重请求。
|
||||||
|
if (wasAtTop && hasMoreOlderRef.current) onLoadOlderRef.current?.();
|
||||||
|
} else {
|
||||||
el!.scrollTop += newH - prevH;
|
el!.scrollTop += newH - prevH;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// 分页耗尽:贴顶意图已达成,清除标志恢复正常锚定语义
|
||||||
|
if (!hasMoreOlderRef.current) scrollToTopIntentRef.current = false;
|
||||||
prevScrollHeightRef.current = newH;
|
prevScrollHeightRef.current = newH;
|
||||||
prevMessageCountRef.current = messages.length;
|
prevMessageCountRef.current = messages.length;
|
||||||
return;
|
return;
|
||||||
@ -204,10 +269,19 @@ export const MessageList = memo(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();
|
stopScrollTopAnimation();
|
||||||
|
scrollToTopIntentRef.current = false;
|
||||||
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);
|
||||||
if (savedPos !== undefined && containerRef.current) {
|
if (savedPos !== undefined && containerRef.current) {
|
||||||
|
// 恢复期跨越两帧 rAF:第一帧完成布局与可见行重测,第二帧让
|
||||||
|
// virtualizer 的滚动纠正落定,期间 handleScroll 不回写保存位置
|
||||||
|
restoringRef.current = true;
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
restoringRef.current = false;
|
||||||
|
}),
|
||||||
|
);
|
||||||
containerRef.current.scrollTop = savedPos;
|
containerRef.current.scrollTop = savedPos;
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
@ -267,6 +341,7 @@ export const MessageList = memo(function MessageList({
|
|||||||
if (idx === undefined) return;
|
if (idx === undefined) return;
|
||||||
|
|
||||||
stopScrollTopAnimation();
|
stopScrollTopAnimation();
|
||||||
|
scrollToTopIntentRef.current = false;
|
||||||
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
|
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
|
||||||
|
|
||||||
// 高亮 class 需等 DOM 渲染后操作
|
// 高亮 class 需等 DOM 渲染后操作
|
||||||
@ -284,18 +359,26 @@ export const MessageList = memo(function MessageList({
|
|||||||
|
|
||||||
// ---- 滚顶动画生命周期 ----
|
// ---- 滚顶动画生命周期 ----
|
||||||
|
|
||||||
// 用户手动滚动(滚轮/触摸)时打断滚顶动画,交还滚动控制权
|
// 用户手动滚动输入(滚轮/触摸/点击)时打断滚顶动画并取消链式贴顶意图,
|
||||||
|
// 交还滚动控制权
|
||||||
|
const handleUserScrollInput = useCallback(() => {
|
||||||
|
stopScrollTopAnimation();
|
||||||
|
scrollToTopIntentRef.current = false;
|
||||||
|
}, [stopScrollTopAnimation]);
|
||||||
|
|
||||||
const hasMessages = messages.length > 0;
|
const hasMessages = messages.length > 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.addEventListener('wheel', stopScrollTopAnimation, { passive: true });
|
el.addEventListener('wheel', handleUserScrollInput, { passive: true });
|
||||||
el.addEventListener('touchstart', stopScrollTopAnimation, { passive: true });
|
el.addEventListener('touchstart', handleUserScrollInput, { passive: true });
|
||||||
|
el.addEventListener('pointerdown', handleUserScrollInput, { passive: true });
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener('wheel', stopScrollTopAnimation);
|
el.removeEventListener('wheel', handleUserScrollInput);
|
||||||
el.removeEventListener('touchstart', stopScrollTopAnimation);
|
el.removeEventListener('touchstart', handleUserScrollInput);
|
||||||
|
el.removeEventListener('pointerdown', handleUserScrollInput);
|
||||||
};
|
};
|
||||||
}, [hasMessages, stopScrollTopAnimation]);
|
}, [hasMessages, handleUserScrollInput]);
|
||||||
|
|
||||||
// 卸载时取消未完成的动画
|
// 卸载时取消未完成的动画
|
||||||
useEffect(() => stopScrollTopAnimation, [stopScrollTopAnimation]);
|
useEffect(() => stopScrollTopAnimation, [stopScrollTopAnimation]);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user