feat: 重写滚动同步为 VS Code 方案(data-line + 二分查找)

核心变更:
- 新增 rehypeSourceLine 插件:利用 unified AST position 信息,
  给渲染后的块级元素注入 data-line 属性,标记源文件行号
- 重写 scrollSync.ts:用二分查找替代正则匹配 identifyBlockStarts,
  精确映射行号→预览 DOM 位置,线性插值填充行间偏移
- useCodeMirror.ts:onScroll 从 ratio 改为行号派发,
  使用 CodeMirror 的 lineBlockAtHeight 精确计算当前行
- Preview.tsx:双向同步(编辑器→预览 + 预览→编辑器)
- Editor.tsx:监听 preview-scroll 事件实现反向同步

原理:与 VS Code Markdown 预览滚动同步方案一致
- 渲染时:AST position → data-line 属性
- 滚动时:二分查找 [data-line] 元素 → 线性插值滚动位置
This commit is contained in:
thzxx
2026-05-28 11:09:58 +08:00
parent 70566efc4a
commit ddbe814238
7 changed files with 258 additions and 114 deletions
+31 -9
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { renderMarkdown } from '../../lib/markdown'
import { scrollPreviewToLine, getLineAtScrollOffset, invalidateScrollCache } from '../../lib/scrollSync'
export function Preview() {
const [html, setHtml] = useState('')
@@ -23,28 +24,49 @@ export function Preview() {
renderMarkdown(activeTab.content, activeTab.filePath).then(result => {
if (requestId === requestIdRef.current) {
setHtml(result)
invalidateScrollCache()
}
})
}, [activeTabId, activeTab?.content])
// 滚动同步:监听编辑器滚动事件
// 滚动同步:监听编辑器滚动事件(VS Code 方案:行号 → 二分查找)
useEffect(() => {
const handleEditorScroll = (e: Event) => {
const ratio = (e as CustomEvent).detail?.ratio ?? 0
const panel = previewRef.current?.parentElement
if (!panel || isSyncingRef.current) return
const line = (e as CustomEvent).detail?.line
if (typeof line !== 'number') return
isSyncingRef.current = true
const maxScroll = panel.scrollHeight - panel.clientHeight
panel.scrollTop = ratio * maxScroll
// 下一帧重置标志
requestAnimationFrame(() => { isSyncingRef.current = false })
const container = previewRef.current?.parentElement
if (!container || isSyncingRef.current) return
scrollPreviewToLine(line, container, isSyncingRef)
}
window.addEventListener('editor-scroll', handleEditorScroll)
return () => window.removeEventListener('editor-scroll', handleEditorScroll)
}, [])
// 反向同步:预览滚动 → 通知编辑器
useEffect(() => {
const container = previewRef.current?.parentElement
if (!container) return
const handleScroll = () => {
if (isSyncingRef.current) return
const line = getLineAtScrollOffset(container.scrollTop, container)
if (line !== null) {
isSyncingRef.current = true
window.dispatchEvent(new CustomEvent('preview-scroll', { detail: { line } }))
requestAnimationFrame(() => {
isSyncingRef.current = false
})
}
}
container.addEventListener('scroll', handleScroll, { passive: true })
return () => container.removeEventListener('scroll', handleScroll)
}, [activeTabId])
// 拦截链接点击
const handleClick = useCallback((e: React.MouseEvent) => {
const link = (e.target as HTMLElement).closest('a')