From 36fde0c1e6f922834de0c4c6f5c88a3fdd3e97a5 Mon Sep 17 00:00:00 2001 From: xiepengfei Date: Mon, 28 Sep 2026 12:13:26 +0800 Subject: [PATCH] fix: fix md scroll sync drift with anchor map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Root cause: pure ratio-based scroll mapping assumed source line position equals preview position, which breaks with dense tables where rendered height differs from source line height 2. Fix: replace linear ratio mapping with anchor-based line mapping, building source-line-to-preview-Y anchor table after render, using binary search and linear interpolation within anchor segments 3. Impact: dual-pane scroll sync is now accurate for table-heavy docs, with graceful fallback to ratio mapping when anchors unavailable Log: Fixed markdown dual-pane scroll sync drift on table-heavy documents Influence: 1. Test scroll sync with markdown containing multiple tables 2. Test bidirectional scroll sync between editor and preview panes 3. Test scroll sync with progressive rendering on large documents 4. Verify no regression on plain text markdown scroll sync fix: 修复Markdown双栏滚动同步漂移问题 1. 根因:纯比例滚动映射假设源码位置等于预览位置,表格密集场景下 表格渲染高度与源码行高不一致导致累积漂移 2. 方案:将线性比例映射替换为基于源码行锚点的映射,渲染后构建 源码行号到预览像素Y的锚点表,通过二分查找和段内线性插值定位 3. 影响:双栏滚动同步在表格密集文档下更准确,锚点不可用时降级 为比例映射保证不退化 Log: 修复Markdown双栏滚动同步在表格密集文档下的漂移问题 Influence: 1. 测试含多个表格的Markdown文档滚动同步 2. 测试编辑栏与预览栏双向滚动同步 3. 测试大文档渐进渲染时的滚动同步 4. 验证纯文本Markdown滚动同步无回归 PMS: BUG-378227 --- src/editor/markdown/web/main.js | 122 +++++++++++++++++++++++++++++--- 1 file changed, 112 insertions(+), 10 deletions(-) diff --git a/src/editor/markdown/web/main.js b/src/editor/markdown/web/main.js index df97961c..f952a7d6 100644 --- a/src/editor/markdown/web/main.js +++ b/src/editor/markdown/web/main.js @@ -39,6 +39,13 @@ let lastRequestedRatio = 0; // 最近一次 C++ 请求的滚动比例;重渲 // 语义边界情况:跨块的引用式链接定义、松散列表会被就近截断(预览可接受的误差)。 const PROGRESSIVE_THRESHOLD = 64 * 1024; // 触发渐进的最小字符数(约 1~2 屏正文) let renderGeneration = 0; // 代际号:新内容到达时作废进行中的渐进渲染 +let anchorMap = null; // [{sourceLine, previewY}, ...]:锚点映射表;null=未构建/降级 + +// —— 锚点映射(anchor-based line mapping,PMS 378227 修复)—— +// 纯比例映射假设"源码第 N% 位置 = 预览第 N% 位置",表格密集场景下该假设不成立: +// 表格渲染高度(padding/border/折行/NodeView 包裹)≠ 源码行高,累积漂移可达数屏。 +// 改为锚点映射:渲染后枚举 .ProseMirror 子节点建立 {源码行号→预览像素Y} 锚点表, +// 滚动时通过锚点段内线性插值定位,C++ 侧协议不变(仍收发 ratio)。 // —— 渐进构建期的滚动比例补偿 —— // 比例式同步假设两栏代表同一篇完整文档;渐进填充期间右栏仅有部分内容, @@ -55,6 +62,7 @@ function renderMarkdown(md) { lastValue = normalized; renderGeneration++; buildProgress = null; + anchorMap = null; // 旧锚点表作废,待 reapplyScroll/buildAnchorMap 重建 userScrolledDuringBuild = false; if (normalized.length <= PROGRESSIVE_THRESHOLD) { editor.action(replaceAll(normalized)); @@ -127,6 +135,7 @@ function renderProgressively(md, gen) { }, 0); } else { appendChunk(chunk); + buildAnchorMap(); } buildProgress.fraction = renderedChars / totalChars; if (index < chunks.length) { @@ -135,7 +144,10 @@ function renderProgressively(md, gen) { // 构建完成:f=1,后续同步恢复精确比例。若用户未动过右栏, // 按左栏最近请求做最终对齐(消除补偿近似误差);动过则尊重其位置。 buildProgress = null; - if (!userScrolledDuringBuild) reapplyScroll(); + if (!userScrolledDuringBuild) { + buildAnchorMap(); + reapplyScroll(); + } } }; step(); // 首块同步渲染,抢最快首屏 @@ -157,10 +169,90 @@ function toRenderedRatio(ratio) { return Math.max(0, Math.min(1, ratio / buildProgress.fraction)); } +// 构建锚点映射表:将源码行号与预览像素 Y 对应 +// 用 splitTopLevelBlocks(md, 0) 将源码按空行切分为顶层块,记录每块起始行号; +// 渲染后枚举 .ProseMirror 直接子节点,取 offsetTop 作为预览 Y;按序匹配块→DOM 节点。 +// 锚点表为空(无内容/DOM 未就绪)时返回 null,调用方降级为纯比例映射。 +function buildAnchorMap() { + const pm = document.querySelector(".ProseMirror"); + if (!pm || !lastValue) { anchorMap = null; return; } + const children = Array.from(pm.children); + if (children.length === 0) { anchorMap = null; return; } + const chunks = splitTopLevelBlocks(lastValue, 0); + const anchors = []; + let lineOffset = 0; + const pairCount = Math.min(children.length, chunks.length); + for (let i = 0; i < pairCount; i++) { + anchors.push({ sourceLine: lineOffset, previewY: children[i].offsetTop }); + lineOffset += chunks[i].split("\n").length - (chunks[i].endsWith("\n") ? 1 : 0); + } + // 哨兵:文档末尾(源码行号=已渲染块行数,预览Y=最后一个子节点底部) + const lastChild = children[children.length - 1]; + anchors.push({ sourceLine: lineOffset, previewY: lastChild.offsetTop + lastChild.offsetHeight }); + anchorMap = anchors.length >= 2 ? anchors : null; +} + +// 源码行号 → 预览像素 Y(二分查找锚点段,段内线性插值) +// 锚点表不可用时降级为纯比例映射 +function ratioToPreviewY(ratio) { + const max = document.documentElement.scrollHeight - window.innerHeight; + if (!anchorMap || anchorMap.length < 2) { + return ratio * max; + } + const totalLines = lastValue ? lastValue.split("\n").length : 1; + const sourceLine = ratio * totalLines; + let lo = 0, hi = anchorMap.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (anchorMap[mid].sourceLine <= sourceLine) lo = mid; + else hi = mid; + } + const a0 = anchorMap[lo]; + const a1 = anchorMap[Math.min(lo + 1, anchorMap.length - 1)]; + const lineSpan = a1.sourceLine - a0.sourceLine; + if (lineSpan <= 0) return a0.previewY; + let t = (sourceLine - a0.sourceLine) / lineSpan; + t = Math.max(0, Math.min(1, t)); + return a0.previewY + t * (a1.previewY - a0.previewY); +} + +// 预览像素 Y → 滚动比例(二分查找锚点段,段内线性插值得源码行号,再除以总行数) +// 锚点表不可用时降级为纯比例映射 +function previewYToRatio(scrollY) { + const max = document.documentElement.scrollHeight - window.innerHeight; + if (!anchorMap || anchorMap.length < 2) { + return max > 0 ? scrollY / max : 0; + } + let lo = 0, hi = anchorMap.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (anchorMap[mid].previewY <= scrollY) lo = mid; + else hi = mid; + } + const a0 = anchorMap[lo]; + const a1 = anchorMap[Math.min(lo + 1, anchorMap.length - 1)]; + const ySpan = a1.previewY - a0.previewY; + let sourceLine; + if (ySpan <= 0) { + sourceLine = a0.sourceLine; + } else { + let t = (scrollY - a0.previewY) / ySpan; + t = Math.max(0, Math.min(1, t)); + sourceLine = a0.sourceLine + t * (a1.sourceLine - a0.sourceLine); + } + const totalLines = lastValue ? lastValue.split("\n").length : 1; + return totalLines > 0 ? sourceLine / totalLines : 0; +} + function reapplyScroll() { + buildAnchorMap(); const max = document.documentElement.scrollHeight - window.innerHeight; if (max > 0) { - applyProgrammaticScroll(toRenderedRatio(lastRequestedRatio) * max); + if (anchorMap && anchorMap.length >= 2) { + applyProgrammaticScroll(ratioToPreviewY(lastRequestedRatio)); + } else { + applyProgrammaticScroll(toRenderedRatio(lastRequestedRatio) * max); + } } } @@ -210,10 +302,14 @@ function scrollToRatio(ratio) { // 根因修复:ProseMirror 元素本身不可滚动(无 overflow:auto),页面滚动在 window/documentElement。 ratio = Math.max(0, Math.min(1, ratio)); lastRequestedRatio = ratio; - // 渐进构建期:ratio 是全文档比例,先补偿映射到已渲染范围 - const target = toRenderedRatio(ratio); const max = document.documentElement.scrollHeight - window.innerHeight; - if (max > 0) { + if (max <= 0) return; + if (anchorMap && anchorMap.length >= 2) { + // 锚点映射:ratio 解释为源码行位置,通过锚点表转为预览像素位置 + applyProgrammaticScroll(ratioToPreviewY(ratio)); + } else { + // 降级:纯比例映射(渐进构建期先补偿到已渲染范围) + const target = toRenderedRatio(ratio); applyProgrammaticScroll(target * max); } } @@ -228,12 +324,18 @@ window.addEventListener("scroll", () => { __lastProgrammaticY = null; // 用户已接管滚动位置 const max = document.documentElement.scrollHeight - window.innerHeight; if (max <= 0) return; - let ratio = Math.max(0, Math.min(1, window.scrollY / max)); - // 渐进构建期:右栏内比例换算回全文档比例(f<1),左栏才能落在对应位置 - if (buildProgress) { - ratio = Math.min(1, ratio * buildProgress.fraction); - userScrolledDuringBuild = true; + let ratio; + if (anchorMap && anchorMap.length >= 2) { + // 锚点映射:预览像素 Y → 源码行号 → ratio(渐进期已内置,无需额外补偿) + ratio = previewYToRatio(window.scrollY); + } else { + // 降级:纯比例映射 + ratio = Math.max(0, Math.min(1, window.scrollY / max)); + if (buildProgress) { + ratio = Math.min(1, ratio * buildProgress.fraction); + } } + if (buildProgress) userScrolledDuringBuild = true; // 去抖阈值须像素感知:固定 0.001 比例对超大文档(30 万行级)相当于数屏死区, // 用户翻页会被吞掉;构建期补偿后的通知值更小,同样会被吞。任一维度显著变化即通知。 if (Math.abs(window.scrollY - __lastNotifiedY) < 40 && Math.abs(ratio - __lastNotifiedRatio) < 0.001)