diff --git a/package.json b/package.json index d56a93e..72cd50c 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 695e044..520c401 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -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 diff --git a/src/renderer/components/Editor/useCodeMirror.ts b/src/renderer/components/Editor/useCodeMirror.ts index 55b0f2c..fece3cd 100644 --- a/src/renderer/components/Editor/useCodeMirror.ts +++ b/src/renderer/components/Editor/useCodeMirror.ts @@ -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 } } diff --git a/src/renderer/components/Preview/Preview.tsx b/src/renderer/components/Preview/Preview.tsx index 1d44e4a..ac9f96a 100644 --- a/src/renderer/components/Preview/Preview.tsx +++ b/src/renderer/components/Preview/Preview.tsx @@ -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') diff --git a/src/renderer/lib/markdown.ts b/src/renderer/lib/markdown.ts index 924f2ec..4eb8ebd 100644 --- a/src/renderer/lib/markdown.ts +++ b/src/renderer/lib/markdown.ts @@ -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) diff --git a/src/renderer/lib/rehypeSourceLine.ts b/src/renderer/lib/rehypeSourceLine.ts new file mode 100644 index 0000000..104157d --- /dev/null +++ b/src/renderer/lib/rehypeSourceLine.ts @@ -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' + } + }) + } +} diff --git a/src/renderer/lib/scrollSync.ts b/src/renderer/lib/scrollSync.ts index 673e864..c21651b 100644 --- a/src/renderer/lib/scrollSync.ts +++ b/src/renderer/lib/scrollSync.ts @@ -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 { - const starts = new Set() - 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('[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) +/** + * 使缓存失效(内容变化后调用) + */ +export function invalidateScrollCache(): void { + cachedElements = null + cachedHTML = '' +} - // 1. 识别块级元素起始行 - const blockStarts = identifyBlockStarts(lines) +/** + * 二分查找:找到目标行对应的元素(精确匹配或前后夹逼) + */ +function findElementsForLine( + elements: CodeLineElement[], + targetLine: number, +): { previous: CodeLineElement; next: CodeLineElement | null } { + let lo = 0 + let hi = elements.length - 1 - // 2. 获取预览 DOM 元素位置 - const previewPositions: number[] = [] - for (let i = 0; i < previewContainer.children.length; i++) { - previewPositions.push((previewContainer.children[i] as HTMLElement).offsetTop) - } - - 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 } - } - - // 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) }