
先说结论:你说得对,这不是扩展的问题,是 CodeMirror 6 在拖选时的一个正反馈循环,而且可以用一段很轻的"护栏"代码消除。我在同一手势下做了开启/关闭的实测对比。
一、现象(和你的描述一致)
在编辑器里按住鼠标向下拖选,当指针停在可视区底部附近时:
- 文字会从指针下面"滑走",选区感觉很不稳(抖动);
- 有时向下滚一点,却直接选中到页面最下方;
- 有时向下选择却向上跳选并高亮;
- 一路跑到文档末尾后,会"啪"地一下被拽回顶部锚点,然后循环重来。
用 44 步标准手势复现,纯应用(不依赖任何扩展)下:
- scrollTop 被写入 316 次,其中 >120px 的跳变 10 次,最大一次 −17287px(直接回弹到顶);
- 选区头与指针偏差最坏 8 行。
二、原因
关键事实:每一次 scrollTop 写入都来自 CodeMirror 自己的 measure → scrollIntoView(scrollRectIntoView),而且写入那一刻,选区头其实已经在视口内了------不是因为没滚到位才补滚。
正反馈循环是这样的:
- 指针停在底部边带,CM6 在 measure 阶段调用
scrollRectIntoView,按"底距留白"把视图往下滚一屏(约 +3000px/次); - 视图一滚,指针正下方变成更靠后的某一行;
- 选区头(head)被指针位置带着前进约 8 行;
- 下一帧 measure 又发现 head 在底带 → 再次
scrollRectIntoView。
增益≈1,永不衰减,所以会以帧率一路跑飞,直到文档末尾;末尾那次 measure 把视图拽回锚点(−17287px),再重来。
旁证:整个过程
contentHeight恒定(spread 0),offsetHeight/clientHeight = 1,可以排除"高度表失效"或"缩放补偿系数错"这类猜想。
三、修复用的关键护栏代码
思路:不去跟 CM6 的 measure 较劲,只在拖选进行中、只对编辑器滚动容器 .cm-scroller 的 scrollTop 写入做约束。正常点击、正常滚动、拖选中滚轮都不受影响。
js
// 在编辑器页面里,拖选进行中给 scrollTop 写入加一道护栏
(function () {
'use strict';
if (window.__zcDragGuardInstalled) return;
window.__zcDragGuardInstalled = 1;
var S = window.__zcDragGuard = {
on: true, budget: 24, freezeInside: true,
active: false, dir: 0, lastY: 0, ptrY: 0,
rejected: 0, clamped: 0, frozen: 0, passed: 0, wheelUntil: 0
};
var bucket = 0, used = 0;
var d = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop');
if (!d || !d.set || !d.get) return;
function now() {
return (window.performance && performance.now) ? performance.now() : Date.now();
}
Object.defineProperty(Element.prototype, 'scrollTop', {
configurable: true, enumerable: d.enumerable, get: d.get,
set: function (x) {
// 非拖选 / 非编辑器容器 / 滚轮旁路:原样放行
if (!S.on || !S.active || !this.classList || !this.classList.contains('cm-scroller'))
return d.set.call(this, x);
if (now() < S.wheelUntil) { S.passed++; return d.set.call(this, x); }
var before = d.get.call(this);
var delta = x - before;
if (!delta) return;
// 1) 反向滚动直接丢弃(消灭跑到末尾后的 -17287 回弹)
if (S.dir > 0 && delta < -4) { S.rejected++; return; }
if (S.dir < 0 && delta > 4) { S.rejected++; return; }
// 2) 指针已在视口内(离上下边 >24px)时完全冻结:
// 这时 head 就在指针下,没有要揭示的内容,这里滚动只会让文字从指针下滑走
if (S.freezeInside) {
var r = this.getBoundingClientRect();
var band = 24;
if (S.ptrY >= r.top + band && S.ptrY <= r.bottom - band) { S.frozen++; return; }
}
// 3) 每帧预算:其余滚动按 24px/帧(≈1400px/s,正常自动滚速度)截断
var t = now();
if (t - bucket > 16) { bucket = t; used = 0; }
var room = S.budget - used;
if (room < 0) room = 0;
var mag = delta < 0 ? -delta : delta;
if (mag > room) { mag = room; S.clamped++; }
if (!mag) return;
used += mag;
d.set.call(this, delta < 0 ? before - mag : before + mag);
}
});
// 拖选开始:记录方向、指针位置;松手/失焦:解除
document.addEventListener('pointerdown', function (e) {
var t = e.target;
if (!t || !t.closest || !t.closest('.cm-content')) return;
S.active = true; S.dir = 0; S.lastY = S.ptrY = e.clientY;
bucket = 0; used = 0;
}, true);
document.addEventListener('pointermove', function (e) {
if (!S.active) return;
if (e.buttons === 0) { S.active = false; return; }
S.ptrY = e.clientY;
if (e.clientY > S.lastY + 1) S.dir = 1;
else if (e.clientY < S.lastY - 1) S.dir = -1;
S.lastY = e.clientY;
}, true);
function release() { S.active = false; }
document.addEventListener('pointerup', release, true);
document.addEventListener('pointercancel', release, true);
window.addEventListener('blur', release, true);
// 拖选中滚轮永远可用(这才是延长选区的正道),180ms 内不受护栏约束
document.addEventListener('wheel', function () { S.wheelUntil = now() + 180; },
{ capture: true, passive: true });
})();
(上面省略了可配置的 localStorage 读取部分,需要的话可以加一个开关项。)
实测同一 44 步手势,开启护栏后:
- scrollTop 写入 316 → 38 次,跳变 >120px 10 → 0 ,最大 −17287 → 0;
- 选区头与指针偏差 8 行 → 0 行;
- 编辑器 DOM 变更 6158 → 147 次(顺带大幅减少重排)。
- 点选落点、拖选锚点、拖选中滚轮、松手后正常滚动等日常交互回归全部正常。
四、可行性与取舍(供评估)
- 是否可行:可行。这段是纯浏览器侧的"结果约束",不侵入 CM6 内部状态,不改动选区逻辑,风险面很小,适合作为临时修复或灰度开关。
- 一个有意取舍 :护栏不再提供"指针停在底边持续自动滚"------那个循环正是跑飞本体。要选到视口外,保留两种手段:①把指针拖到编辑器边带的"自动滚热区"继续移动;②拖选中用滚轮延长选区(已被旁路放行)。我选了"可预测 > 快"。
- 更干净的原生方案(建议长期做) :CM6 本身提供了
EditorView.scrollMargins/ 自定义scrollIntoView行为,可在拖选时把触底自动滚的留白调小、或拖选期间临时降低scrollMargin,从根源上让scrollRectIntoView不每帧触发。护栏代码只是先把现象压住并定位到 measure 阶段,便于在 CM6 配置层做根治。 - 开关建议 :挂一个偏好项(如
dragGuard,默认开),值为off即恢复原生行为,便于 A/B 对比。
如果方便,我可以把护栏封装成一个可开关的偏好,或者直接给一版基于 EditorView.scrollMargins 的 CM6 原生修复。