只要做过富文本编辑器,你迟早会意识到一件事:
- 真正难的不是“把文字显示出来”;
- 也不是“加粗、斜体、插图片”;
- 而是光标和选区在各种奇怪 DOM 结构里别失控。
用户看到的症状往往很日常:
- 光标突然跳到组件外面;
- 退格删东西时选区跑偏;
- 点进一个不可编辑块后键盘方向键行为很奇怪;
- 文本明明选中了,滚动却把选区藏到可视区外;
- 插入节点后 undo/redo 边界一团乱。
如果你读 addons/html_editor/static/src/core/selection_plugin.js 和 utils/selection.js,就会发现 Odoo 对此的判断很明确:
浏览器原生 Selection 只是底层信号,不是编辑器最终可信的选区模型。
所以官方在 HTML Editor 里单独抽出了一整层 SelectionPlugin。
一、为什么浏览器原生 Selection 不够用
浏览器当然提供了 Selection 和 Range,看起来好像已经够强。
但问题在于,编辑器不是普通文档。
它会同时遇到:
contenteditable与不可编辑节点混排;- media、HR 这类“看起来占位但不能把光标放进去”的元素;
- fake BR、self-closing element;
- 保护节点、受保护节点、可解除保护节点;
- DOM 被插件不断重写、合并、拆分、移动;
- 双击、三击、键盘方向键、拖选、撤销重做混在一起。
在这种场景下,如果你只拿浏览器此刻给你的 anchor/focus 当绝对真相,迟早崩。
二、SelectionPlugin 关注的不只是“当前选中了什么”,而是“当前选区是不是合法”
SelectionPlugin 暴露了一整组 shared API:
getSelectionDatagetEditableSelectionsetSelectionsetCursorStartsetCursorEndpreserveSelectionmodifySelectionrectifySelectionisSelectionInEditableselectAroundNonEditable
光看名字就能明白,它不是单纯把 document.getSelection() 包了一层,而是在维护一个更高阶的问题:
当前选区相对于 editable 结构到底是否成立,以及在 DOM 变化后怎样继续成立。
这比“读一下浏览器的当前 range”复杂太多,也正是编辑器稳定性的来源。
三、归一化逻辑说明:可见位置不等于合法位置
utils/selection.js 里有几个函数非常能体现编辑器的真实难度:
normalizeSelfClosingElement()normalizeNotEditableNode()normalizeFakeBR()normalizeDeepCursorPosition()
这些函数共同说明一个事实:
用户想停留的位置,往往不是浏览器真正允许或最稳定的位置。
举几个例子。
1)自闭合元素与媒体节点
像图片、HR、某些 media 节点,从视觉上看是“一个块”。用户可能觉得光标能停在里面某个点,但技术上不行。
源码的处理很直接:
- 如果命中了 self-closing element,就把光标归一化到它的左右边界。
2)不可编辑节点
如果当前节点不在可编辑链路里,normalizeNotEditableNode() 会一路把光标推到最近合法边界。
这特别重要,因为编辑器里经常有“看得到、选得到一部分,但不能直接编辑”的结构。用户体验应该是“光标贴边绕开”,而不是直接卡死。
3)深层光标归一化
normalizeDeepCursorPosition() 做得更细:
- 如果你把光标落在元素边界;
- 它会尽量往左右找最近的 inline leaf;
- 决定是进入文字叶子节点的开头/结尾,还是停在可见空节点的左右。
这就是为什么在很多复杂内联结构里,Odoo 的光标看起来会“自动吸附到合理位置”。
这不是偶然,是主动归一化的结果。
四、光标保活的难点不在读取,而在 DOM 改动前同步修正
真正体现工程功力的,是 callbacksForCursorUpdate 那组工具:
removebeforeafterappendprependunwrapmerge
它们对应的不是“怎么画光标”,而是:
当 DOM 发生结构性变化时,光标应该跟着怎么迁移。
比如:
- 删除一个包含当前 cursor 的节点;
- 把节点移动到别的父节点;
- 解包一个节点(unwrap);
- 把一个节点 merge 到前一个兄弟里。
如果没有这层映射,编辑器一旦做格式化、拆块、合并段落、删除结构节点,光标就会立刻丢。
所以稳定编辑器的关键,从来不是“操作完再猜一个新位置”,而是在操作前就定义好 cursor 如何随结构变更迁移。
五、SelectionPlugin 还把滚动可视性纳入选区系统
selection_plugin.js 里还有一个特别实用的细节:scrollToSelection()。
逻辑并不复杂,但意义很大:
- 取当前 range 的可视矩形;
- 找最近的纵向可滚动容器;
- 如果选区已经在可视区域,不动;
- 如果在上方或下方,就把容器滚到最近边缘。
这看起来像“体验优化”,其实是编辑器选区系统的一部分。
因为从用户角度看,所谓“光标还在”并不只是内存里还保存了一个 Selection,而是:
- 光标语义正确;
- DOM 位置合法;
- 用户眼睛也能看见它。
看不见的光标,和丢了的光标差不多。
六、双击、三击、方向键为什么都要进 SelectionPlugin
源码里 SelectionPlugin 还监听:
selectionchangemousedown(双击/三击)keydown(左右、带 shift 的方向键)
这说明官方没有把这些行为分别拆到完全无关的插件里,而是认为:
- 双击、三击决定块级或词级选区边界;
- 方向键会穿过可编辑与不可编辑节点边界;
- 原生 selectionchange 只是事件,不负责矫正。
把这些都纳进 SelectionPlugin,本质上是在集中维护“选区语义”。
编辑器领域里,集中维护边界语义,往往比“每个插件各改一点”更稳。
七、为什么还要区分 document selection 与 editable selection
文档里并不是所有选区都属于当前 editable。
所以源码会区分:
- document level 的选区;
- editable 内部的选区;
- deep editable selection;
- 当前选区是否落在 editable 内;
- 当前选区是否触及 protected / protecting 节点。
这层区分非常重要。否则一个外部弹层、工具条、嵌入组件拿走焦点时,编辑器内部很容易把“文档当前选区”误当“仍可继续编辑的选区”。
结果就是:
- 命令应用到错误位置;
- 恢复焦点后插入点乱掉;
- 外部交互把内部状态悄悄污染。
八、二开编辑器时最容易犯的错
误区 1:直接操作 document.getSelection() 就开干
短期能跑,复杂结构一多就不稳。
误区 2:DOM 变更后再想办法猜光标落点
应该在操作语义里就定义 cursor update 规则,而不是事后碰运气。
误区 3:忽略不可编辑节点与假换行
这类节点正是选区最容易错位的地方。
误区 4:认为“滚动跟选区无关”
对用户来说,看不见的插入点就约等于插入点丢了。
九、结论
Odoo HTML Editor 里的光标稳定,不是浏览器帮你免费送的。
从源码看,官方额外做了这些事情:
- 把 document selection 重新解释成 editor selection;
- 区分 editable 内外、保护节点与普通节点;
- 把不合法位置归一化为可编辑边界;
- 在 remove / move / unwrap / merge 之前同步修正 cursor;
- 在滚动容器里主动让选区保持可见;
- 把双击、三击、方向键都纳入同一套选区语义系统。
所以更准确的理解应该是:
Odoo 的
SelectionPlugin不是“帮忙读一下选区”的工具,而是 HTML Editor 里负责让光标在复杂 DOM 里持续合法、持续可见、持续可恢复的核心基础设施。
理解了这一层,你以后再做编辑器插件,就不会再把“光标偶尔乱跳”当成小 bug,而会知道:那通常是在提醒你,选区模型本身还没设计完整。
DISCUSSION
评论区