前端

Odoo 编辑器光标为什么不会一遇到组件就乱飞:SelectionPlugin、光标归一化与选区保活讲透

很多人觉得富文本编辑器里“光标别乱跳”只是体验细节,但从 `selection_plugin.js` 与 `utils/selection.js` 看,Odoo HTML Editor 为此单独维护了一整层选区系统:记录 editable 内外选区、识别保护节点、归一化深层光标位置、在节点移动/删除/解包时同步修正 cursor,并在滚动容器里主动把选区滚进可视区。本文讲清编辑器为什么不能把浏览器原生 Selection 当成最终答案。

前端
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

只要做过富文本编辑器,你迟早会意识到一件事:

  • 真正难的不是“把文字显示出来”;
  • 也不是“加粗、斜体、插图片”;
  • 而是光标和选区在各种奇怪 DOM 结构里别失控。

用户看到的症状往往很日常:

  • 光标突然跳到组件外面;
  • 退格删东西时选区跑偏;
  • 点进一个不可编辑块后键盘方向键行为很奇怪;
  • 文本明明选中了,滚动却把选区藏到可视区外;
  • 插入节点后 undo/redo 边界一团乱。

如果你读 addons/html_editor/static/src/core/selection_plugin.jsutils/selection.js,就会发现 Odoo 对此的判断很明确:

浏览器原生 Selection 只是底层信号,不是编辑器最终可信的选区模型。

所以官方在 HTML Editor 里单独抽出了一整层 SelectionPlugin

一、为什么浏览器原生 Selection 不够用

浏览器当然提供了 SelectionRange,看起来好像已经够强。

但问题在于,编辑器不是普通文档。

它会同时遇到:

  • contenteditable 与不可编辑节点混排;
  • media、HR 这类“看起来占位但不能把光标放进去”的元素;
  • fake BR、self-closing element;
  • 保护节点、受保护节点、可解除保护节点;
  • DOM 被插件不断重写、合并、拆分、移动;
  • 双击、三击、键盘方向键、拖选、撤销重做混在一起。

在这种场景下,如果你只拿浏览器此刻给你的 anchor/focus 当绝对真相,迟早崩。

二、SelectionPlugin 关注的不只是“当前选中了什么”,而是“当前选区是不是合法”

SelectionPlugin 暴露了一整组 shared API:

  • getSelectionData
  • getEditableSelection
  • setSelection
  • setCursorStart
  • setCursorEnd
  • preserveSelection
  • modifySelection
  • rectifySelection
  • isSelectionInEditable
  • selectAroundNonEditable

光看名字就能明白,它不是单纯把 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 那组工具:

  • remove
  • before
  • after
  • append
  • prepend
  • unwrap
  • merge

它们对应的不是“怎么画光标”,而是:

当 DOM 发生结构性变化时,光标应该跟着怎么迁移。

比如:

  • 删除一个包含当前 cursor 的节点;
  • 把节点移动到别的父节点;
  • 解包一个节点(unwrap);
  • 把一个节点 merge 到前一个兄弟里。

如果没有这层映射,编辑器一旦做格式化、拆块、合并段落、删除结构节点,光标就会立刻丢。

所以稳定编辑器的关键,从来不是“操作完再猜一个新位置”,而是在操作前就定义好 cursor 如何随结构变更迁移。

五、SelectionPlugin 还把滚动可视性纳入选区系统

selection_plugin.js 里还有一个特别实用的细节:scrollToSelection()

逻辑并不复杂,但意义很大:

  • 取当前 range 的可视矩形;
  • 找最近的纵向可滚动容器;
  • 如果选区已经在可视区域,不动;
  • 如果在上方或下方,就把容器滚到最近边缘。

这看起来像“体验优化”,其实是编辑器选区系统的一部分。

因为从用户角度看,所谓“光标还在”并不只是内存里还保存了一个 Selection,而是:

  • 光标语义正确;
  • DOM 位置合法;
  • 用户眼睛也能看见它。

看不见的光标,和丢了的光标差不多。

六、双击、三击、方向键为什么都要进 SelectionPlugin

源码里 SelectionPlugin 还监听:

  • selectionchange
  • mousedown(双击/三击)
  • 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

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。