- 集成 MetonaToast v2.0.0 替换自研 Toast 组件(右上角、进度条、自动关闭、主题联动) - 修复 sidebar resize 未持久化到 IndexedDB - 修复 Ctrl+Tab MRU 切换逻辑与文档不一致 - 完善 WYSIWYG 模式标签切换选区恢复 - 清理死代码(useActiveHeading observerRef、旧 Toast 样式、重复滚动条样式、SearchReplace 重复检查) - 同步 DESIGN.md / docs 文档(CodeMirror→Milkdown、stores 计数、editorStore 字段) - 新增 .npmrc.example 模板 - 更新关于对话框与文档远程地址为 Gitea - 版本号 0.3.12 → 0.3.13
80 lines
2.5 KiB
TypeScript
80 lines
2.5 KiB
TypeScript
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<HTMLElement | null>,
|
||
headings: { level: number; text: string }[]
|
||
): number | null {
|
||
const [activeIndex, setActiveIndex] = useState<number | null>(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
|
||
}
|