diff --git a/src/core.js b/src/core.js index 27bc4ef..708c8a8 100644 --- a/src/core.js +++ b/src/core.js @@ -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') {