fix(core): scroll sync v2 — use scrollTop instead of selectionStart

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.
This commit is contained in:
2026-07-24 16:16:17 +08:00
parent 4e63870180
commit 2b4d19afc7
+20 -18
View File
@@ -1243,37 +1243,39 @@ class MarkdownEditor {
const ta = this.textarea; const ta = this.textarea;
if (!ta) return; if (!ta) return;
// 找到光标所在位置最近的标题 // 1. 先做比例同步(基础行为,保证平滑)
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; const max = ta.scrollHeight - ta.clientHeight;
if (max <= 0) return; if (max <= 0) return;
const ratio = ta.scrollTop / max; const ratio = ta.scrollTop / max;
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight; const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
this.previewPane.scrollTop = ratio * pmax; this.previewPane.scrollTop = ratio * pmax;
return;
}
// 取最后一个标题(光标之前最近的标题 // 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 lastH = headings[headings.length - 1];
const headingText = lastH[2].trim(); const headingText = lastH[2].trim();
// 在预览区查找对应标题元素 // 在预览区查找对应标题
const previewEls = this.previewEl.querySelectorAll('h1, h2, h3, h4, h5, h6'); const previewEls = this.previewEl.querySelectorAll('h1, h2, h3, h4, h5, h6');
let found;
for (const el of previewEls) { for (const el of previewEls) {
if (el.textContent.trim() === headingText) { if (el.textContent.trim() === headingText) { found = el; break; }
el.scrollIntoView({ block: 'start', behavior: 'instant' });
return;
} }
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() { _updateWordCount() {