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:
+48
-41
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user