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:
@@ -32,6 +32,7 @@
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.1.0",
|
||||
"zustand": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -7,6 +7,7 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
import rehypeStringify from 'rehype-stringify'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import type { Element, Root } from 'hast'
|
||||
import { rehypeSourceLine } from './rehypeSourceLine'
|
||||
|
||||
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径
|
||||
function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
||||
@@ -53,10 +54,11 @@ export async function renderMarkdown(content: string, filePath?: string | null):
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
img: [...(defaultSchema.attributes?.img ?? []), ['src']]
|
||||
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
|
||||
}
|
||||
})
|
||||
.use(rehypeFixImages(filePath ?? null))
|
||||
.use(rehypeSourceLine)
|
||||
.use(rehypeHighlight)
|
||||
.use(rehypeStringify)
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { visit } from 'unist-util-visit'
|
||||
import type { Root, Element } from 'hast'
|
||||
|
||||
/**
|
||||
* Rehype 插件:给块级元素注入 data-line 属性
|
||||
*
|
||||
* 利用 unified/remark 管线自动维护的 position 信息,
|
||||
* 将源文件行号附加到渲染后的 HTML 元素上,
|
||||
* 用于编辑器↔预览的滚动同步(VS Code 方案)。
|
||||
*/
|
||||
export function rehypeSourceLine() {
|
||||
return (tree: Root) => {
|
||||
visit(tree, 'element', (node: Element) => {
|
||||
const pos = node.position
|
||||
if (!pos?.start?.line) return
|
||||
|
||||
// position.start.line 是 1-indexed,转为 0-indexed
|
||||
const line = pos.start.line - 1
|
||||
|
||||
node.properties = {
|
||||
...node.properties,
|
||||
'data-line': String(line),
|
||||
}
|
||||
|
||||
// 追加 code-line class(保留原有 class)
|
||||
const existing = node.properties?.class
|
||||
if (typeof existing === 'string') {
|
||||
node.properties.class = `${existing} code-line`
|
||||
} else if (Array.isArray(existing)) {
|
||||
node.properties.class = [...existing, 'code-line']
|
||||
} else {
|
||||
node.properties.class = 'code-line'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+146
-93
@@ -1,111 +1,164 @@
|
||||
// 滚动同步算法 — 基于块级元素 DOM 位置映射
|
||||
/**
|
||||
* 滚动同步 — VS Code 方案
|
||||
*
|
||||
* 核心思路:
|
||||
* 1. 渲染时 rehypeSourceLine 插件给块级元素注入 data-line 属性
|
||||
* 2. 预览侧收集所有 [data-line] 元素,按行号排序
|
||||
* 3. 编辑器派发当前行号 → 二分查找对应元素 → 线性插值滚动
|
||||
* 4. 预览侧反向映射:像素偏移 → 行号 → 通知编辑器
|
||||
*/
|
||||
|
||||
export interface ScrollMap {
|
||||
lineToPreviewMap: Float32Array
|
||||
interface CodeLineElement {
|
||||
element: HTMLElement
|
||||
line: number
|
||||
top: number
|
||||
height: number
|
||||
}
|
||||
|
||||
// B-06: 用 NaN 标记未映射状态(0 是合法的 offsetTop)
|
||||
const UNMAPPED = NaN
|
||||
let cachedElements: CodeLineElement[] | null = null
|
||||
let cachedHTML = ''
|
||||
|
||||
// 识别块级元素起始行
|
||||
function identifyBlockStarts(lines: string[]): Set<number> {
|
||||
const starts = new Set<number>()
|
||||
let inCodeBlock = false
|
||||
/**
|
||||
* 收集并缓存所有带 data-line 的元素,按行号排序
|
||||
*/
|
||||
function getCodeLineElements(container: HTMLElement): CodeLineElement[] {
|
||||
const html = container.innerHTML
|
||||
if (cachedElements && cachedHTML === html) return cachedElements
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trimStart()
|
||||
cachedHTML = html
|
||||
const els = container.querySelectorAll<HTMLElement>('[data-line]')
|
||||
const result: CodeLineElement[] = []
|
||||
|
||||
// 围栏代码块边界(仅匹配 ``` 开头,不匹配 `````` 等)
|
||||
if (/^`{3,}(?!`)/.test(line)) {
|
||||
if (!inCodeBlock) starts.add(i)
|
||||
inCodeBlock = !inCodeBlock
|
||||
continue
|
||||
}
|
||||
if (inCodeBlock) continue
|
||||
|
||||
// 块级元素起始行
|
||||
if (
|
||||
line.startsWith('#') ||
|
||||
line.startsWith('- ') ||
|
||||
line.startsWith('* ') ||
|
||||
line.startsWith('+ ') ||
|
||||
/^\d+\.\s/.test(line) ||
|
||||
line.startsWith('> ') ||
|
||||
line.startsWith('|') ||
|
||||
line.startsWith('---') ||
|
||||
line.startsWith('***') ||
|
||||
line.startsWith('___') ||
|
||||
(line === '' && i > 0 && lines[i - 1].trim() === '')
|
||||
) {
|
||||
starts.add(i)
|
||||
}
|
||||
for (const el of els) {
|
||||
const line = parseInt(el.getAttribute('data-line')!, 10)
|
||||
if (isNaN(line)) continue
|
||||
// 跳过不可见元素
|
||||
if (el.offsetHeight === 0) continue
|
||||
result.push({
|
||||
element: el,
|
||||
line,
|
||||
top: el.offsetTop,
|
||||
height: el.offsetHeight,
|
||||
})
|
||||
}
|
||||
|
||||
return starts
|
||||
result.sort((a, b) => a.line - b.line)
|
||||
cachedElements = result
|
||||
return result
|
||||
}
|
||||
|
||||
export function buildScrollMap(
|
||||
markdown: string,
|
||||
previewContainer: HTMLElement
|
||||
): ScrollMap {
|
||||
const lines = markdown.split('\n')
|
||||
const totalLines = lines.length
|
||||
const lineToPreviewMap = new Float32Array(totalLines).fill(UNMAPPED)
|
||||
|
||||
// 1. 识别块级元素起始行
|
||||
const blockStarts = identifyBlockStarts(lines)
|
||||
|
||||
// 2. 获取预览 DOM 元素位置
|
||||
const previewPositions: number[] = []
|
||||
for (let i = 0; i < previewContainer.children.length; i++) {
|
||||
previewPositions.push((previewContainer.children[i] as HTMLElement).offsetTop)
|
||||
/**
|
||||
* 使缓存失效(内容变化后调用)
|
||||
*/
|
||||
export function invalidateScrollCache(): void {
|
||||
cachedElements = null
|
||||
cachedHTML = ''
|
||||
}
|
||||
|
||||
if (previewPositions.length === 0 || blockStarts.size === 0) {
|
||||
// fallback: 线性映射
|
||||
const lastPos = previewPositions[previewPositions.length - 1] || 0
|
||||
for (let i = 0; i < totalLines; i++) {
|
||||
lineToPreviewMap[i] = (i / Math.max(1, totalLines - 1)) * lastPos
|
||||
}
|
||||
return { lineToPreviewMap }
|
||||
}
|
||||
/**
|
||||
* 二分查找:找到目标行对应的元素(精确匹配或前后夹逼)
|
||||
*/
|
||||
function findElementsForLine(
|
||||
elements: CodeLineElement[],
|
||||
targetLine: number,
|
||||
): { previous: CodeLineElement; next: CodeLineElement | null } {
|
||||
let lo = 0
|
||||
let hi = elements.length - 1
|
||||
|
||||
// 3. 块起始行 → 预览位置映射
|
||||
const uniqueStartsArr = [...blockStarts].sort((a, b) => a - b)
|
||||
const uniqueStartsSet = new Set(uniqueStartsArr)
|
||||
const domCount = previewPositions.length
|
||||
|
||||
for (let i = 0; i < uniqueStartsArr.length; i++) {
|
||||
const lineIdx = uniqueStartsArr[i]
|
||||
const domIdx = Math.min(Math.floor((i / uniqueStartsArr.length) * domCount), domCount - 1)
|
||||
lineToPreviewMap[lineIdx] = previewPositions[domIdx]
|
||||
}
|
||||
|
||||
// 4. 线性插值填充所有行
|
||||
for (let i = 0; i < totalLines; i++) {
|
||||
if (!isNaN(lineToPreviewMap[i]) && uniqueStartsSet.has(i)) continue
|
||||
|
||||
// 找到前后最近的已映射行
|
||||
let prevLine = -1
|
||||
let nextLine = -1
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
if (!isNaN(lineToPreviewMap[j]) || uniqueStartsSet.has(j)) { prevLine = j; break }
|
||||
}
|
||||
for (let j = i + 1; j < totalLines; j++) {
|
||||
if (!isNaN(lineToPreviewMap[j]) || uniqueStartsSet.has(j)) { nextLine = j; break }
|
||||
}
|
||||
|
||||
if (prevLine === -1 && nextLine === -1) {
|
||||
lineToPreviewMap[i] = 0
|
||||
} else if (prevLine === -1) {
|
||||
lineToPreviewMap[i] = lineToPreviewMap[nextLine]
|
||||
} else if (nextLine === -1) {
|
||||
lineToPreviewMap[i] = lineToPreviewMap[prevLine]
|
||||
// 二分查找最后一个 line <= targetLine 的元素
|
||||
while (lo < hi) {
|
||||
const mid = Math.floor((lo + hi + 1) / 2)
|
||||
if (elements[mid].line <= targetLine) {
|
||||
lo = mid
|
||||
} else {
|
||||
const ratio = (i - prevLine) / (nextLine - prevLine)
|
||||
lineToPreviewMap[i] = lineToPreviewMap[prevLine] + ratio * (lineToPreviewMap[nextLine] - lineToPreviewMap[prevLine])
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
return { lineToPreviewMap }
|
||||
const previous = elements[lo]
|
||||
const next = lo < elements.length - 1 ? elements[lo + 1] : null
|
||||
|
||||
return { previous, next }
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器滚动 → 预览滚动
|
||||
*
|
||||
* @param line 编辑器当前可见区域顶部对应的源文件行号(0-indexed,支持小数表示行内偏移)
|
||||
* @param previewContainer 预览面板的滚动容器(#preview-panel)
|
||||
* @param isSyncingRef 双向同步锁,防止循环触发
|
||||
*/
|
||||
export function scrollPreviewToLine(
|
||||
line: number,
|
||||
previewContainer: HTMLElement,
|
||||
isSyncingRef: { current: boolean },
|
||||
): void {
|
||||
const elements = getCodeLineElements(previewContainer)
|
||||
if (elements.length === 0) return
|
||||
|
||||
isSyncingRef.current = true
|
||||
|
||||
const lineNumber = Math.floor(line)
|
||||
const fraction = line - lineNumber
|
||||
|
||||
const { previous, next } = findElementsForLine(elements, lineNumber)
|
||||
|
||||
let scrollTo: number
|
||||
|
||||
if (previous.line === lineNumber) {
|
||||
// 精确匹配:滚动到该元素顶部
|
||||
scrollTo = previous.top + fraction * previous.height
|
||||
} else if (next && next.line !== previous.line) {
|
||||
// 在两个元素之间:线性插值
|
||||
const progress = (lineNumber - previous.line + fraction) / (next.line - previous.line)
|
||||
const gapTop = previous.top + previous.height
|
||||
const gapHeight = next.top - gapTop
|
||||
scrollTo = gapTop + progress * (previous.height + gapHeight)
|
||||
} else {
|
||||
// 最后一个元素之后:按行内比例偏移
|
||||
scrollTo = previous.top + fraction * previous.height
|
||||
}
|
||||
|
||||
previewContainer.scrollTo(0, Math.max(0, scrollTo))
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
isSyncingRef.current = false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览滚动 → 编辑器行号(用于反向同步)
|
||||
*
|
||||
* @param scrollTop 预览面板的 scrollTop
|
||||
* @param previewContainer 预览面板的滚动容器
|
||||
* @returns 对应的源文件行号(0-indexed),无元素时返回 null
|
||||
*/
|
||||
export function getLineAtScrollOffset(
|
||||
scrollTop: number,
|
||||
previewContainer: HTMLElement,
|
||||
): number | null {
|
||||
const elements = getCodeLineElements(previewContainer)
|
||||
if (elements.length === 0) return null
|
||||
|
||||
const position = scrollTop
|
||||
|
||||
// 二分查找最后一个 top <= position 的元素
|
||||
let lo = 0
|
||||
let hi = elements.length - 1
|
||||
while (lo < hi) {
|
||||
const mid = Math.floor((lo + hi + 1) / 2)
|
||||
if (elements[mid].top <= position) {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
const el = elements[lo]
|
||||
if (el.height === 0) return el.line
|
||||
|
||||
const progress = Math.min(1, Math.max(0, (position - el.top) / el.height))
|
||||
const nextLine = lo < elements.length - 1 ? elements[lo + 1].line : el.line + 1
|
||||
|
||||
return el.line + progress * (nextLine - el.line)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user