选区同步:state 选区与 DOM 选区的双向对齐

3 分钟阅读
·

上一篇看了内容方向的读回:浏览器改了 DOM,readDOMChange 把变化反推成 transaction。内容之外,编辑器里还有一样东西同时存在两份,就是选区。state.selection 用文档位置表示,anchor 和 head 是文档里的数字;DOM Selection 用节点加偏移表示,anchorNode、anchorOffset、focusNode、focusOffset,由浏览器持有,用户动一下鼠标就会变。这两份数据必须保持一致:state 变了要写进 DOM,不然用户看到的光标和实际编辑位置对不上;DOM 变了要读回 state,不然插件拿到的选区是旧的。src/selection.ts 就是干这个的文件,两百来行,两个方向的入口各一个,selectionToDOM 负责写,selectionFromDOM 负责读,剩下的全是边界处理。这篇把这个文件拆开看。参考代码是 prosemirror-view 的 ca4c78e;涉及 Selection 体系的部分参考 prosemirror-state 的 ffad5d9,提到表格包的一处 prop 参考 prosemirror-tables 的 eb522f2。

系列目录

日期 标题
05-10 ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17 ProseMirror 仓库全景:22 个包怎么分工
05-24 跑通一个最小 ProseMirror:先看文档长什么样
06-07 ProseMirror model(上):Node 与 Fragment,文档树的骨架
06-14 ProseMirror model(中):Mark,内联格式怎么挂在文本上
06-21 ProseMirror model(下):Schema 与 content expression,文档的类型系统
07-05 ResolvedPos:一个数字位置怎么变成路径
07-12 Slice 与 replace:切一块文档出来再塞回去
07-19 DOMSerializer:文档怎么变成 DOM 和 HTML
08-02 DOMParser:parseDOM 规则与 HTML 解析
08-09 findDiffStart / findDiffEnd:两份文档怎么求差
08-16 model 收官:Node 上的辅助方法与位置约定总结
09-06 ProseMirror transform(上):Step 抽象,所有修改的最小单位
09-20 ProseMirror transform(下):ReplaceStep 与 Fitter,最复杂的一步
10-03 StepMap:一步修改怎么映射每个位置
10-11 Mapping:多步映射的链式合并,rebase 的地基
10-18 structure.ts:split/join/lift/wrap 的可达性判断
10-25 Transform 类:构建修改的 API 层
11-08 ProseMirror state(上):EditorState,不可变编辑器状态
11-15 Selection 体系:四种选区与选区书签
11-22 Transaction:Transform 加上状态语义
12-06 Plugin 系统(上):StateField 与插件状态
12-13 Plugin 系统(下):props、appendTransaction 与 filterTransaction
12-20 state 收官:动手写三个插件验证理解
01-03 ProseMirror view(上):EditorView,状态与 DOM 之间的桥
01-10 ViewDesc(上):文档到 DOM 的描述树
01-17 ViewDesc(下):增量更新怎么做到只改动的部分
02-07 DOMObserver 与 readDOMChange:浏览器改了 DOM,怎么读回文档
02-14 input.ts:从 keydown 到 dispatchTransaction 的输入管线
02-21 选区同步:state 选区与 DOM 选区的双向对齐(本篇)

两个方向的触发点

写方向的触发点集中在 src/index.ts 的 updateStateInner。文档或选区有变化时 updateSel 置真,走完 ViewDesc 更新后调 selectionToDOM,把新 state 的选区写进 DOM。另外两个入口是 focus()(聚焦时把选区摆上)和 capturekeys.ts 里某些按键命令执行后的补写。updateStateInner 里还有一个不写的分支:鼠标按住拖动选择的过程中,如果 DOM 选区和缓存一致且 anchorInRightPlace 成立,只调 syncNodeSelection 和 setCurSelection,跳过重设,避免打扰浏览器自己的拖选。

读方向的触发点是 document 上的 selectionchange 事件,注册在 DOMObserver 上(connectSelection),onSelectionChange 过几道闸门后汇到 flush(),flush 发现 DOM 选区和缓存不一致又没有内容变化时,readDOMChange 的 from 小于 0 分支调 selectionFromDOM 读回,构造一个只改选区的 transaction 派发出去。

选区的双向同步路径

两个方向各有闸门。写之前先 disconnectSelection,摘掉 selectionchange 监听,自己的写入不会被当成用户操作读回来;读完再 connectSelection 挂回去。读之前先比对 DOMObserver 里的 currentSelection 缓存,没变化就不读。这两个闸门保证了任一时刻只有一个方向在搬运数据。

selectionToDOM:主动写

selectionToDOM 的第一件事和选区写入无关:syncNodeSelection(view, sel),把节点选中的视觉状态先同步掉,下一节单独说。然后过 editorOwnsSelection 闸门:editable 状态下要求 view.hasFocus(),非 editable 状态下退化为「DOM 选区在编辑器内且 activeElement 包含编辑器」。编辑器不持有焦点时不写选区,免得把用户在外面的选择抢走。

闸门之后是一个 Chrome 拖选的特例:鼠标按下且允许默认行为时,如果 DOM 选区和缓存一致,标记 delayedSelectionSync 并推迟写入,mouseDown 结束时再补一次 selectionToDOM(src/input.ts 的 MouseDown.done)。拖选过程中重设选区会打断浏览器的拖选状态,所以拖的时候让浏览器自己管。

写之前还有一层「要不要强制重设」的判断,在 updateStateInner 里完成。文档更新和选区更新同时发生时,Chrome、IE、Edge 在改动选区附近的 DOM 之后,会把 Selection 对象报进一种自相矛盾的状态,用户看到的选区和 Selection 报出来的不一致。代码用 selectionContextChanged 比较新旧选区共享深度上的起点,选区所在的文本块变了就置 forceSelUpdate。Chrome 下另有一道 trackWrites 保险:更新前记下 DOM 选区的 focusNode,ViewDesc 更新结束后检查这个节点是否还留在编辑器 DOM 里,被写没了同样强制重设。force 一路传到 docView.setSelection,跳过后面要说的等价短路。

正式进入写入前,view.domObserver.disconnectSelection() 摘掉 selectionchange 监听。写入本体分两条路。view.cursorWrapper 存在时走 selectCursorWrapper:cursorWrapper 是光标落在一个「DOM 表达不了正确 marks」的位置时插入的假 img(markCursor 机制,输入法组合前由 src/input.ts 设置),DOM 选区直接 collapse 到这个 img 后面。普通路径调 view.docView.setSelection(anchor, head, view, force),写完再按 sel.visible 决定加不加 ProseMirror-hideselection 类,最后 setCurSelection 更新缓存、connectSelection 挂回监听。

setSelection 在 src/viewdesc.ts 的 ViewDesc 基类上,先尝试下放:选区完全落在某个子 desc 内部就递归下去,自定义 NodeView 可以借 setSelection 规格接管自己内部的选区设置。下放到头之后,用 domFromPos 把 anchor 和 head 各自换算成 DOM 节点加偏移,bias 取 -1 或 1。接下来是一个重要的短路:换算结果和当前 DOM 选区等价(isEquivalentPosition 双向扫描判定)且没有 force 时直接返回。能不动就不动,因为每一次重设 DOM 选区都有代价,输入法组合和拖选都可能被打断。

真要设的时候有两套写法。优先 collapse 加 extend:collapse(anchorDOM) 再 extend(headDOM),这样能表达 focus 在 anchor 之前的反向选区。extend 抛异常的浏览器退回 Range 方案,createRange、setStart、setEnd、removeAllRanges、addRange,这条路径表达不了方向,anchor 大于 head 时交换两端。中间夹着两处浏览器对策:Firefox 和 Safari 把光标放在 BR 后面不可靠(brKludge),Safari 下识别出来就跳过等价短路强制重设,Firefox 下则放弃 extend 走 Range 路径;Firefox 的 focus 落在不可编辑节点前面时行为异常,检测到就把 force 置真。这段代码的注释里挂着一串 issue 编号,每一条对应一个真实的浏览器 bug。

sel.visible 为 false 时(NodeSelection 的 visible 就是 false)给编辑器根节点加 ProseMirror-hideselection 类,配套的 CSS 把编辑器内的选区背景和光标颜色设成透明,用户看到的是节点选中态而不是一块系统高亮。但用户接着拖动鼠标重新选择时要把这个类摘掉,removeClassOnSelectionChange 挂一个一次性的 selectionchange 监听,发现 DOM 选区真的变了之后延迟 20 毫秒检查:编辑器仍持有选区且 state 选区仍不可见就不动,否则摘类。加类和摘类都围绕「当前谁说了算」展开,细节里全是时序。

syncNodeSelection:节点选中态

NodeSelection 在 DOM 里没有对应的范围选区,图片被选中时浏览器不会替你画出选中效果,得自己画。syncNodeSelection 的实现很直接:选区是 NodeSelection 时,用 docView.descAt(sel.from) 找到对应的 ViewDesc,和 view.lastSelectedViewDesc 比较,变了就先 clearNodeSelection 清掉旧的,再对新 desc 调 selectNode,记下 lastSelectedViewDesc;选区不是 NodeSelection 就只清不画。

NodeViewDesc 的默认 selectNode 做两件事:给 nodeDOM 加 ProseMirror-selectednode 类,选中样式由 CSS 主题负责;同时按条件补上 draggable 属性,让选中的节点可以被拖动。deselectNode 对称地摘类、摘 draggable。自定义 NodeView 可以在 spec 里写自己的 selectNode 和 deselectNode 覆盖默认行为,CustomNodeViewDesc 优先调 spec 的实现。lastSelectedViewDesc 这个缓存保证重复同步同一个节点时不会反复加类摘类。

selectionToDOM 里还有一段和节点选中相关的怪癖补丁。brokenSelectBetweenUneditable(Safari,以及 chrome_version 小于 63 的 Chrome)不允许选区的端点落在两个不可编辑块之间。对策是 temporarilyEditableNear:在目标位置旁边找一个元素,临时把它的 contentEditable 设成 true,设完选区再用 resetEditable 改回去;Safari 下如果这个元素本来可拖,还要顺手关掉 draggable 事后再恢复。选区设置完成后这两个元素恢复原状,用户感知不到,但浏览器就是在这一瞬间的可编辑状态下才肯接受这个选区。

selectionchange:被动读

读方向从 DOMObserver.onSelectionChange 开始,它在 ownerDocument 上监听 selectionchange。处理函数开头是三道闸门:hasFocusAndSelection 不成立直接忽略,焦点不在编辑器里时这个事件和自己无关;suppressingSelectionUpdates 为真时不读反写,调 selectionToDOM 把 state 选区重新拍回 DOM,这个 50 毫秒的抑制窗口(suppressSelectionUpdates)专门留给「知道浏览器马上要乱报选区」的场景;IE11 的删除操作事件顺序是反的,selectionchange 先于 DOM 更新到达,检测到折叠选区且 state 选区非空时改走 flushSoon,等 20 毫秒再处理。

抑制窗口的一个具体用例在 readDOMChange 里:IE11 在文本块开头退格删掉第一个元素后,会自己把 DOM 选区挪到奇怪的位置,处理这类删除时先 suppressSelectionUpdates,再延迟 20 毫秒调 selectionToDOM 把正确选区拍回去。

过了闸门的请求汇到 flush()。flush 里 newSel 的判定条件有四个同时成立:不在抑制窗口内、DOM 选区和 currentSelection 缓存不等、hasFocusAndSelection 成立、ignoreSelectionChange 不拦截。ignoreSelectionChange 找出 anchor 和 focus 的最近公共祖先,问它所属的 ViewDesc 是否 ignoreMutation({type: “selection”, target})。NodeView 可以用同一个 ignoreMutation 实现同时管住内容读回和选区读回,编辑中的嵌入组件想自己持有焦点时,对 selection 类型返回 true,框架就不再碰选区。

没有内容变化(from 小于 0)只有选区变化时,readDOMChange 走 selectionFromDOM 读回,和 state.selection 用 eq 比较,不同才 dispatch 一个 tr.setSelection。选区的来源会附带上去:lastSelectionOrigin 是 pointer 就打 pointer meta,是 key 就 scrollIntoView,来源记录超过 50 毫秒按无来源处理。flush 里还有一条反向分支值得记住:有些浏览器 focus 之后会把 DOM 选区重置到文档开头,flush 识别出「无内容变化、刚聚焦 200 毫秒内、点击和触摸都发生在 300 毫秒之前、读回的选区折叠且等于文档起点」时,判定这是误重置,反过来调 selectionToDOM 把 state 选区写回去。读方向的代码里也有写操作。

selectionFromDOM:DOM 位置怎么读回文档位置

selectionFromDOM 拿到的原料是 view.domSelectionRange(),一个 {focusNode, focusOffset, anchorNode, anchorOffset} 四元组。focusNode 为空说明 DOM 里没选区,返回 null。

head 先定:docView.posFromDOM(focusNode, focusOffset, 1) 把 DOM 位置换算成文档位置,bias 为 1。然后分三种情况。

折叠选区(selectionCollapsed,内部用 isEquivalentPosition 判定)可能是光标,也可能是节点选中。沿 focusNode 找到最近的带 node 的 ViewDesc,如果那个节点是 atom 且 NodeSelection.isSelectable,并且不是「inline 原子节点边缘的普通光标」(isOnEdge 排除),就构造 NodeSelection。点了一下图片,DOM 上报的往往只是图片容器里一个折叠光标,这段代码负责把它升级成节点选中。

非折叠选区先看是不是多 range。Firefox 支持一个 Selection 里多个 range(表格里按列选择就是这种情况),遍历 rangeCount 求出所有 range 的 min 和 max,再和当前 state 选区的 anchor 比较,决定哪个端点是 anchor 哪个是 head,保住方向。单 range 就简单,anchor 用 posFromDOM(anchorNode, anchorOffset, 1) 换算。

最后构造选区走 selectionBetween:先问 createSelectionBetween 这个 prop,表格的 CellSelection 就靠它接管,没有就用 TextSelection.between(head, bias)。bias 的取法:指针来源取 1;否则新 head 在旧 head 之后(选区向后移动)且不在 widget 里取 1,其余取 -1。它影响 TextSelection.between 在端点位置非法时往哪边找合法位置。

边界工具函数

文件尾部是一组小函数,每个都对应一类浏览器状况。

  • editorOwnsSelection:editable 时等价于 hasFocus;非 editable 时要求 DOM 选区在编辑器内,且 activeElement 存在并包含编辑器根节点。
  • hasFocus(src/index.ts):普通浏览器就是 root.activeElement == this.dom。IE 的 resize 手柄会让 activeElement 停在编辑器内部某个元素上,实现改为从 activeElement 向上走,走到 this.dom 且中途不经过 contentEditable 为 false 的元素就算聚焦。
  • hasFocusAndSelection:onSelectionChange 和 flush 共用的前置检查,editable 时先查焦点,再查 hasSelection。
  • hasSelection:判断 DOM 选区的 anchor 是否落在 view.dom 内,文本节点要取 parentNode 再 contains。整个判定包在 try/catch 里,注释写明 Firefox 访问 CSS 生成元素里的 anchorNode 属性会抛 permission denied,捕获后按没有选区处理。
  • anchorInRightPlace:把 state 选区的 anchor 用 domFromPos 换算后,和 DOM 选区的 anchor 用 isEquivalentPosition 比较,updateStateInner 用它决定拖选过程中要不要跳过重设。
  • domSelectionRange(src/index.ts):普通情况就是 root.getSelection() 的四元组;Safari 加 shadow DOM 时(root.nodeType 为 11)原生 Selection 对象拿不到影子根里的选区,deepActiveElement 确认焦点确实在编辑器内后,走 safariShadowSelectionRange 用 getComposedRanges 或一次 beforeinput 探测重算。

isEquivalentPosition(src/dom.ts)本身也值得一看。同一个文档位置在 DOM 里可能有多个等价的表示,段落开头既可以表示成「p 元素的 0 偏移」也可以表示成「内部文本节点的 0 偏移」。它从给定节点偏移出发向前后两个方向扫描,遇到块级边界、原子元素、不可编辑元素就停,能走到目标位置就算等价。选区代码里所有「DOM 选区和预期位置一不一样」的比较都走它,直接比较节点加偏移会误报。

为什么选区同步最烦

通读这个文件,真正的机制代码不多:domFromPos、posFromDOM、collapse、extend,加起来几十行。剩下全是防御。烦的根源有三个。

第一,DOM 选区是浏览器持有的活状态,没有事务语义。用户拖选、输入法组合、点击外部、程序自己写 DOM,都会改它,有些改还伴随事件,有些没有,有些事件顺序还是错的。state 选区是不可变数据,改一次就是一次 transaction。把活状态和不可变数据绑在一起,同步代码只能到处加闸门和缓存,currentSelection 缓存、suppressSelectionUpdates 抑制窗口、disconnectSelection 配对,都是为了让两个模型在各自的节奏里不失控。

第二,读写都有损。读的时候,DOM 位置到文档位置是多对一,isOnEdge、isEquivalentPosition 这些扫描函数存在的意义就是消解歧义;点图片报回来的折叠光标要升级成 NodeSelection,多 range 要保住方向。写的时候,重设 DOM 选区本身会干扰浏览器正在进行的行为,所以能跳过就跳过,不能跳过就挑代价小的写法,collapse 加 extend 表达方向,Range 兜底放弃方向,每一步都要绕开浏览器正在做的事。

第三,失败模式分散且不可枚举。focus 后选区被重置到文档开头、删除事件乱序、BR 旁边的光标放不住、不可编辑块之间不接受选区端点、shadow DOM 里 Selection 报不准,每一个都只在特定浏览器特定场景出现。这个文件里的注释引用了一串 issue 编号,几乎每个 if 分支背后都是一个被踩出来的坑。选区同步没法一次写完就不管,防御清单只能随踩坑持续积累,这是编辑器开发里最难收尾的部分。

下一篇看输入法和组合事件:compositionstart 到 compositionend 之间,视图层怎么冻结更新、等组合结束再一次性读回。


1174 字 · 46 段落
xi ming

Written by xi mingFollow onGitHub