fix(core): robust scroll sync — proportional ratio + bidirectional

Replace heading-based _syncScrollByHeading with solid proportional sync:
- _syncScroll(): uses scrollTop/scrollHeight ratio for both gutter and preview
- Prevents sync loops with _syncing flag and rAF debounce
- Bidirectional: preview scroll also syncs back to source textarea
- Ratio-based approach handles height mismatches (e.g. word-wrap in textarea)
- Remove fragile heading detection and scrollIntoView calls

Also fix setValue and _applyHistory to reset scroll positions consistently
This commit is contained in:
2026-07-24 16:22:41 +08:00
parent 2b4d19afc7
commit c075621535
+48 -41
View File
@@ -113,6 +113,7 @@ class MarkdownEditor {
this._outlineTimer = null; // 大纲防抖计时器
this._zenMode = false; // v0.1.12: Zen 模式
this._wordWrap = true; // v0.1.12: 自动换行
this._syncing = false; // 滚动同步锁
// 渲染函数:自定义覆盖内置解析器
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
@@ -357,14 +358,28 @@ class MarkdownEditor {
ta.addEventListener('keyup', onCursorActivity);
const onScroll = () => {
if (this.gutter) this.gutter.scrollTop = ta.scrollTop;
// v0.1.12: 标题感知同步
if (this.config.syncScroll && this._mode === 'split') {
this._syncScrollByHeading();
}
this._syncScroll();
};
ta.addEventListener('scroll', onScroll);
// v0.1.12: 预览区滚轮反向同步到源码
const onPreviewScroll = () => {
if (!this.config.syncScroll || this._mode !== 'split') return;
if (this._syncing) return; // 防止循环触发
this._syncing = true;
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
if (pmax <= 0) { this._syncing = false; return; }
const ratio = this.previewPane.scrollTop / pmax;
const tmax = ta.scrollHeight - ta.clientHeight;
ta.scrollTop = ratio * tmax;
if (this.gutter) {
const gmax = this.gutter.scrollHeight - this.gutter.clientHeight;
this.gutter.scrollTop = gmax > 0 ? ratio * gmax : ratio * tmax;
}
requestAnimationFrame(() => { this._syncing = false; });
};
this.previewPane.addEventListener('scroll', onPreviewScroll);
const onFocus = () => {
this._emit('focus');
if (typeof this.config.onFocus === 'function') {
@@ -406,6 +421,7 @@ class MarkdownEditor {
ta.removeEventListener('keydown', onKeydown);
ta.removeEventListener('keypress', onKeypress);
ta.removeEventListener('scroll', onScroll);
this.previewPane.removeEventListener('scroll', onPreviewScroll);
ta.removeEventListener('click', onCursorActivity);
ta.removeEventListener('keyup', onCursorActivity);
ta.removeEventListener('focus', onFocus);
@@ -874,8 +890,9 @@ class MarkdownEditor {
this._render();
this._renderGutter();
this._updateWordCount();
// 同步行号滚动位置
// 重置滚动
if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop;
if (this.previewPane && this.config.syncScroll) this.previewPane.scrollTop = 0;
this._emit('change', this._value);
if (typeof this.config.onChange === 'function') {
try { this.config.onChange(this._value, this); } catch (e) { console.error(e); }
@@ -1236,46 +1253,35 @@ class MarkdownEditor {
}
}
// ============ 滚动同步 v2 — 标题感知v0.1.12 ============
// ============ 滚动同步(v0.1.12 重写 — 比例同步 + 双向联动 ============
_syncScrollByHeading() {
if (!this.config.syncScroll || this._mode !== 'split') return;
/**
* 源码滚动 → 同步行号 + 预览
* 使用比例而非直接 scrollTop,兼容不同内容高度
*/
_syncScroll() {
if (this._syncing) return;
this._syncing = true;
const ta = this.textarea;
if (!ta) return;
if (!ta) { this._syncing = false; 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;
const tmax = ta.scrollHeight - ta.clientHeight;
if (tmax <= 0) { this._syncing = false; return; }
const ratio = ta.scrollTop / tmax;
// 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) { found = el; break; }
// 同步行号(比例方式,不受内容高度差异影响)
if (this.gutter) {
const gmax = this.gutter.scrollHeight - this.gutter.clientHeight;
this.gutter.scrollTop = gmax > 0 ? ratio * gmax : ta.scrollTop;
}
if (found) {
found.scrollIntoView({ block: 'start', behavior: 'instant' });
// 同步预览(比例方式)
if (this.config.syncScroll && this._mode === 'split' && this.previewPane) {
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
if (pmax > 0) this.previewPane.scrollTop = ratio * pmax;
}
requestAnimationFrame(() => { this._syncing = false; });
}
_updateWordCount() {
@@ -1325,8 +1331,9 @@ class MarkdownEditor {
this._renderGutter();
this._updateWordCount();
this._updateOutline();
// 同步行号滚动
// 重置滚动位置到顶部
if (this.gutter) this.gutter.scrollTop = 0;
this.textarea.scrollTop = 0;
if (!opts.silent) {
this._emit('change', this._value);
if (typeof this.config.onChange === 'function') {