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
+17 -3
View File
@@ -22,7 +22,8 @@ export function Editor({ darkMode }: EditorProps) {
getScrollTop,
setScrollTop,
getSelection,
setSelection
setSelection,
setLineAtTop
} = useCodeMirror({
content: activeTab?.content ?? '',
onChange: useCallback((value: string) => {
@@ -31,11 +32,24 @@ export function Editor({ darkMode }: EditorProps) {
setModified(activeTabId, true)
}, [activeTabId, updateTabContent, setModified]),
darkMode,
onScroll: useCallback((ratio: number) => {
window.dispatchEvent(new CustomEvent('editor-scroll', { detail: { ratio } }))
onScroll: useCallback((line: number) => {
// 派发行号给预览侧做滚动同步
window.dispatchEvent(new CustomEvent('editor-scroll', { detail: { line } }))
}, [])
})
// 反向同步:监听预览滚动事件
useEffect(() => {
const handlePreviewScroll = (e: Event) => {
const line = (e as CustomEvent).detail?.line
if (typeof line === 'number') {
setLineAtTop(Math.floor(line) + 1) // setLineAtTop 是 1-indexed
}
}
window.addEventListener('preview-scroll', handlePreviewScroll)
return () => window.removeEventListener('preview-scroll', handlePreviewScroll)
}, [setLineAtTop])
// 切换标签时加载内容
useEffect(() => {
if (!activeTab) return
@@ -11,7 +11,7 @@ interface UseCodeMirrorOptions {
content: string
onChange: (value: string) => void
darkMode: boolean
onScroll?: (ratio: number) => void
onScroll?: (line: number) => void
}
export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCodeMirrorOptions) {
@@ -64,13 +64,17 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode
viewRef.current = view
// 滚动事件监听
// 滚动事件监听:派发行号(支持小数表示行内偏移)
const handleScroll = () => {
if (onScroll) {
const dom = view.scrollDOM
const maxScroll = dom.scrollHeight - dom.clientHeight
const ratio = maxScroll > 0 ? dom.scrollTop / maxScroll : 0
onScroll(ratio)
const scrollTop = dom.scrollTop
const block = view.lineBlockAtHeight(scrollTop)
const line = view.state.doc.lineAt(block.from)
const fraction = block.height > 0
? (scrollTop - block.top) / block.height
: 0
onScroll(line.number - 1 + Math.max(0, fraction)) // 0-indexed + 行内比例
}
}
view.scrollDOM.addEventListener('scroll', handleScroll)
@@ -127,13 +131,24 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode
view.focus()
}, [])
// 滚动到指定位置
// 滚动到指定
const scrollTo = useCallback((pos: number) => {
const view = viewRef.current
if (!view) return
view.dispatch({ effects: EditorView.scrollIntoView(pos, { y: 'center' }) })
}, [])
// 滚动使指定行出现在编辑器顶部(反向同步用)
const setLineAtTop = useCallback((lineNumber: number) => {
const view = viewRef.current
if (!view) return
const line = Math.max(1, Math.min(lineNumber, view.state.doc.lines))
const lineInfo = view.state.doc.line(line)
const block = view.lineBlockAt(lineInfo.from)
const currentTop = view.lineBlockAtHeight(view.scrollDOM.scrollTop).top
view.scrollDOM.scrollTop += block.top - currentTop
}, [])
return {
containerRef,
viewRef,
@@ -143,6 +158,7 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode
setScrollTop,
getSelection,
setSelection,
scrollTo
scrollTo,
setLineAtTop
}
}