132 lines
4.6 KiB
TypeScript
132 lines
4.6 KiB
TypeScript
/**
|
|
* useVirtualMessages — 虚拟化消息列表 Hook (P3 虚拟滚动层)
|
|
*
|
|
* 封装 @tanstack/react-virtual 的 Dynamic 模式:
|
|
* - Dynamic 模式:渲染后由 measureElement 修正行高,适配气泡高度差异大的场景;
|
|
* - overscan=15:视口外预渲染 15 条,保证快速滚动时无白帧;
|
|
* - scrollToMessageId:按消息 ID 精准定位(anchorNavigator 快/慢路径使用);
|
|
* - isAtBottom:替代原 scrollTop 判断,决定新消息到达时是否自动跟随;
|
|
* - onNearTop:替代 MessageStream 内部的 scrollTop < 120 判断,触顶回调。
|
|
*/
|
|
|
|
import { useRef, useCallback, useEffect } from 'react';
|
|
import { useVirtualizer, ReactVirtualizer } from '@tanstack/react-virtual';
|
|
import { ChatMessage, ChatDayBucket } from '../types/workbench';
|
|
|
|
export interface VirtualScrollHandle {
|
|
/** 按消息 serverId 滚动到该消息,居中显示 */
|
|
scrollToMessageId: (messageId: number) => void;
|
|
/** 当前视口是否在底部附近(用于新消息自动跟随判断) */
|
|
isAtBottom: () => boolean;
|
|
/** 跳转到最底部(切换会话 / 首屏落底) */
|
|
scrollToBottom: () => void;
|
|
}
|
|
|
|
export interface UseVirtualMessagesOptions {
|
|
/** 平铺的消息列表(跨所有 dayBucket 合并后的有序列表) */
|
|
flatMessages: ChatMessage[];
|
|
/** 容器 ref(必须是 overflow:auto 的滚动容器) */
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
/** 触顶回调(替代 scrollTop < 120 判断) */
|
|
onNearTop?: () => void;
|
|
/** 估计行高(默认 120px,由 measureElement 在渲染后修正) */
|
|
estimatedSize?: number;
|
|
}
|
|
|
|
export interface UseVirtualMessagesReturn {
|
|
virtualizer: ReactVirtualizer<HTMLDivElement, Element>;
|
|
handle: VirtualScrollHandle;
|
|
}
|
|
|
|
export function useVirtualMessages({
|
|
flatMessages,
|
|
containerRef,
|
|
onNearTop,
|
|
estimatedSize = 120,
|
|
}: UseVirtualMessagesOptions): UseVirtualMessagesReturn {
|
|
const onNearTopRef = useRef(onNearTop);
|
|
useEffect(() => {
|
|
onNearTopRef.current = onNearTop;
|
|
}, [onNearTop]);
|
|
|
|
const virtualizer = useVirtualizer<HTMLDivElement, Element>({
|
|
count: flatMessages.length,
|
|
getScrollElement: () => containerRef.current,
|
|
estimateSize: () => estimatedSize,
|
|
overscan: 15,
|
|
measureElement:
|
|
typeof window !== 'undefined'
|
|
? (el) => el.getBoundingClientRect().height
|
|
: undefined,
|
|
});
|
|
|
|
// 监听滚动,触发触顶回调
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
|
|
const handleScroll = () => {
|
|
if (el.scrollTop < 120) {
|
|
onNearTopRef.current?.();
|
|
}
|
|
};
|
|
|
|
el.addEventListener('scroll', handleScroll, { passive: true });
|
|
return () => el.removeEventListener('scroll', handleScroll);
|
|
}, [containerRef]);
|
|
|
|
const handle: VirtualScrollHandle = {
|
|
scrollToMessageId: useCallback(
|
|
(messageId: number) => {
|
|
const idx = flatMessages.findIndex(
|
|
(m) => (m.serverId ?? 0) === messageId
|
|
);
|
|
if (idx === -1) return;
|
|
const container = containerRef.current;
|
|
const viewportHeight = container?.clientHeight || 600;
|
|
const offsetInfo = virtualizer.getOffsetForIndex(idx, 'start');
|
|
if (offsetInfo) {
|
|
const [startOffset] = offsetInfo;
|
|
// 让目标消息起始位置位于中间栏垂直居中偏上位置,防止长文本头部被截断在视口外
|
|
const targetOffset = Math.max(0, startOffset - viewportHeight / 2);
|
|
virtualizer.scrollToOffset(targetOffset, { align: 'start', behavior: 'auto' });
|
|
} else {
|
|
virtualizer.scrollToIndex(idx, { align: 'start', behavior: 'auto' });
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[flatMessages, virtualizer, containerRef]
|
|
),
|
|
|
|
isAtBottom: useCallback(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return true;
|
|
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
}, [containerRef]),
|
|
|
|
scrollToBottom: useCallback(() => {
|
|
if (flatMessages.length === 0) return;
|
|
virtualizer.scrollToIndex(flatMessages.length - 1, {
|
|
align: 'end',
|
|
behavior: 'auto',
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [flatMessages.length, virtualizer]),
|
|
};
|
|
|
|
return { virtualizer, handle };
|
|
}
|
|
|
|
/**
|
|
* 工具函数:将 dayStack 展平为有序消息列表(保留 dayKey 引用)
|
|
*/
|
|
export function flattenDayStack(dayStack: ChatDayBucket[]): ChatMessage[] {
|
|
const result: ChatMessage[] = [];
|
|
for (const bucket of dayStack) {
|
|
for (const msg of bucket.messages) {
|
|
result.push(msg);
|
|
}
|
|
}
|
|
return result;
|
|
}
|