import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { computeLogWindow, isNearBottom } from '../lib/logWindow' /** 每行固定高度(与 CSS 中的 .log-line 一致),虚拟滚动据此换算位置。 */ export const LOG_ROW_HEIGHT = 20 const LOG_VERTICAL_PADDING = 6 const OVERSCAN_ROWS = 24 const FOLLOW_THRESHOLD_PX = 24 interface LogViewerProps { /** 已经切好的日志行;内容会被原地追加,靠 version 触发重渲染。 */ lines: readonly string[] /** 每次内容变化递增,用于在"跟随中"时滚到底部。 */ version: number emptyLabel?: string } /** * 虚拟滚动的日志面板:只渲染视口附近的行,长日志下 DOM 节点数量恒定。 * * 行高固定、不自动换行(长行横向滚动),这样滚动位置可以直接由行号换算, * 不需要逐行测量高度。用户往上翻时停止跟随新日志,并给出"回到最新"按钮。 */ export function LogViewer({ lines, version, emptyLabel = '等待日志输出...', }: LogViewerProps) { const viewportRef = useRef(null) const frameRef = useRef(0) const followingRef = useRef(true) const [following, setFollowing] = useState(true) const [metrics, setMetrics] = useState({ scrollTop: 0, height: 0 }) useEffect(() => { const node = viewportRef.current if (!node) { return } const sync = () => { setMetrics((previous) => previous.height === node.clientHeight ? previous : { ...previous, height: node.clientHeight }, ) } sync() if (typeof ResizeObserver === 'undefined') { return } const observer = new ResizeObserver(sync) observer.observe(node) return () => observer.disconnect() }, []) useEffect(() => { return () => { if (frameRef.current) { cancelAnimationFrame(frameRef.current) } } }, []) const handleScroll = useCallback(() => { if (frameRef.current) { return } frameRef.current = requestAnimationFrame(() => { frameRef.current = 0 const node = viewportRef.current if (!node) { return } const atBottom = isNearBottom( node.scrollTop, node.clientHeight, node.scrollHeight, FOLLOW_THRESHOLD_PX, ) followingRef.current = atBottom setFollowing(atBottom) setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight }) }) }, []) // 有新内容时贴住底部;同步更新滚动位置,避免窗口还停在旧位置闪一下。 useLayoutEffect(() => { if (!followingRef.current) { return } const node = viewportRef.current if (!node) { return } node.scrollTop = node.scrollHeight setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight }) }, [version]) const jumpToLatest = useCallback(() => { const node = viewportRef.current if (!node) { return } node.scrollTop = node.scrollHeight followingRef.current = true setFollowing(true) setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight }) }, []) const { start, end } = computeLogWindow( lines.length, metrics.scrollTop, metrics.height, LOG_ROW_HEIGHT, OVERSCAN_ROWS, ) const canvasHeight = lines.length * LOG_ROW_HEIGHT + LOG_VERTICAL_PADDING * 2 return (
{lines.length === 0 ? (

{emptyLabel}

) : (
{lines.slice(start, end).map((line, index) => (
{line}
))}
)}
{lines.length > 0 && !following ? ( ) : null}
) }