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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user