import { useEffect, useState, useCallback } from 'react' /** * D4: 基于视口的活跃标题追踪 hook * 在 preview 模式下监听目标容器的滚动事件, * 根据文档中标题元素的 offsetTop 判断当前可见的标题索引。 * * 仅在目标容器 ref 存在时生效(preview 面板挂载后)。 * * @param containerRef - 包含 Markdown 渲染结果的 DOM 元素 ref * @param headings - 解析出的标题列表 * @returns - 当前活跃标题的索引(null 表示无法判定或不在范围内) */ export function useActiveHeading( containerRef: React.RefObject, headings: { level: number; text: string }[] ): number | null { const [activeIndex, setActiveIndex] = useState(null) const handleScroll = useCallback(() => { const container = containerRef.current if (!container || headings.length === 0) { setActiveIndex(null) return } // 收集容器内所有 h1-h6 元素的 offsetTop const headingElements = Array.from( container.querySelectorAll('h1, h2, h3, h4, h5, h6') ) as HTMLElement[] if (headingElements.length === 0) { setActiveIndex(null) return } const scrollTop = container.scrollTop const containerHeight = container.clientHeight const threshold = scrollTop + containerHeight * 0.3 // 上方 30% 位置视为"到达" let bestIndex: number | null = null for (let i = 0; i < headingElements.length; i++) { const el = headingElements[i] // 使用容器顶部的相对偏移而非 getBoundingClientRect(滚动容器不是 window) const top = el.offsetTop - (container.offsetTop || 0) if (top <= threshold) { // 找到 headings 中匹配的索引 const text = el.textContent?.trim() ?? '' const matchIdx = headings.findIndex( h => h.text.trim() === text && el.tagName.slice(-1) === String(h.level) ) if (matchIdx >= 0) bestIndex = matchIdx } } setActiveIndex(bestIndex) }, [containerRef, headings]) useEffect(() => { const container = containerRef.current if (!container || headings.length === 0) { setActiveIndex(null) return } // 监听滚动事件 container.addEventListener('scroll', handleScroll, { passive: true }) // 初始计算 handleScroll() return () => { container.removeEventListener('scroll', handleScroll) } }, [containerRef, headings, handleScroll]) return activeIndex }