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)