From 2b4d19afc727e1ecc01f25a1d4b6befeba984647 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Fri, 24 Jul 2026 16:16:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(core):=20scroll=20sync=20v2=20=E2=80=94=20u?= =?UTF-8?q?se=20scrollTop=20instead=20of=20selectionStart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: _syncScrollByHeading used cursor position (selectionStart) to find nearest heading, but cursor doesn't move during scroll. Preview was stuck at the same heading regardless of scroll position. Fix: base heading detection on estimated viewport top line from scrollTop + lineHeight. Always run proportional sync first for smooth scrolling, then fine-tune to nearest heading at viewport top. --- src/core.js | 50 ++++++++++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/src/core.js b/src/core.js index efc5d01..27bc4ef 100644 --- a/src/core.js +++ b/src/core.js @@ -1243,37 +1243,39 @@ class MarkdownEditor { const ta = this.textarea; if (!ta) return; - // 找到光标所在位置最近的标题 - const pos = ta.selectionStart; - const before = this._value.substring(0, pos); - const headings = [...before.matchAll(/^(#{1,6})\s+(.+)$/gm)]; - if (!headings.length) { - // 无标题,回退到比例同步 - const max = ta.scrollHeight - ta.clientHeight; - if (max <= 0) return; - const ratio = ta.scrollTop / max; - const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight; - this.previewPane.scrollTop = ratio * pmax; - return; - } + // 1. 先做比例同步(基础行为,保证平滑) + const max = ta.scrollHeight - ta.clientHeight; + if (max <= 0) return; + const ratio = ta.scrollTop / max; + const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight; + this.previewPane.scrollTop = ratio * pmax; + + // 2. 如果有标题,微调到最近的标题位置 + const val = this._value; + if (!val) return; + + // 估计 textarea 视口顶部的行号 + const lineHeight = 13.5 * 1.7; // font-size * line-height + const scrollLines = Math.floor(ta.scrollTop / lineHeight); + const beforeLines = val.split('\n').slice(0, Math.max(0, scrollLines)); + const visibleText = beforeLines.join('\n'); + + // 找到视口顶部之前最近的标题 + const headings = [...visibleText.matchAll(/^(#{1,6})\s+(.+)$/gm)]; + if (!headings.length) return; - // 取最后一个标题(光标之前最近的标题) const lastH = headings[headings.length - 1]; const headingText = lastH[2].trim(); - // 在预览区查找对应标题元素 + // 在预览区查找对应标题 const previewEls = this.previewEl.querySelectorAll('h1, h2, h3, h4, h5, h6'); + let found; for (const el of previewEls) { - if (el.textContent.trim() === headingText) { - el.scrollIntoView({ block: 'start', behavior: 'instant' }); - return; - } + if (el.textContent.trim() === headingText) { found = el; break; } + } + if (found) { + found.scrollIntoView({ block: 'start', behavior: 'instant' }); } - - // fallback - const max = ta.scrollHeight - ta.clientHeight; - if (max <= 0) return; - this.previewPane.scrollTop = (ta.scrollTop / max) * (this.previewPane.scrollHeight - this.previewPane.clientHeight); } _updateWordCount() {