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

selectionToDOM 首先调用 syncNodeSelection(view, sel),同步节点选中的视觉状态。随后检查 editorOwnsSelection:editable 状态下要求 view.hasFocus();非 editable 状态下要求 DOM 选区位于编辑器内,且 activeElement 包含编辑器。编辑器未持有焦点时不会写入选区,以避免影响编辑器外的选择。

通过前置检查后,还会处理 Chrome 拖选的特例:鼠标按下且允许默认行为时,如果 DOM 选区与缓存一致,则标记 delayedSelectionSync 并推迟写入,在 mouseDown 结束时由 src/input.ts 的 MouseDown.done 再调用一次 selectionToDOM。拖选过程中重设选区会中断浏览器的拖选状态,因此该期间由浏览器维护选区。

写之前还有一层「要不要强制重设」的判断,在 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:将 DOM 选区读回 state

读方向从 DOMObserver.onSelectionChange 开始,它在 ownerDocument 上监听 selectionchange。处理函数首先检查三项条件:hasFocusAndSelection 不成立时忽略事件;suppressingSelectionUpdates 为真时调用 selectionToDOM 将 state 选区重新写入 DOM,该 50 毫秒的 suppressSelectionUpdates 窗口用于已知浏览器可能错误上报选区的场景;IE11 删除操作的 selectionchange 早于 DOM 更新到达,检测到折叠选区且 state 选区非空时改用 flushSoon,20 毫秒后再处理。

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

通过这些检查的请求会进入 flush()。newSel 要求四个条件同时成立:不在抑制窗口内、DOM 选区与 currentSelection 缓存不等、hasFocusAndSelection 成立,且 ignoreSelectionChange 未拦截。ignoreSelectionChange 找出 anchor 和 focus 的最近公共祖先,并检查所属 ViewDesc 是否对 ignoreMutation({type: “selection”, target}) 返回 true。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 选区由浏览器维护,不具备 transaction 语义。用户拖选、输入法组合、点击编辑器外部和程序写入 DOM 都可能修改它,且事件是否触发和触发顺序并不一致。state 选区则是不可变数据,每次修改都对应一个 transaction。currentSelection 缓存、suppressSelectionUpdates 抑制窗口和 disconnectSelection/connectSelection 配对用于协调两种更新方式。

DOM 位置到文档位置可能存在多种等价表示。isOnEdge 和 isEquivalentPosition 用于消除这些歧义;图片点击得到的折叠光标需要转换为 NodeSelection,多 range 选区需要保留方向。反向写入时,重设 DOM 选区可能影响浏览器当前的拖选或输入法行为,因此在位置等价时跳过写入;需要写入时优先使用 collapse 加 extend 表达方向,Range 作为不支持 extend 时的后备方案。

浏览器差异还会产生特定问题,例如 focus 后选区重置到文档开头、删除事件乱序、BR 旁的光标定位失败、不可编辑块之间不接受选区端点,以及 shadow DOM 中 Selection 信息不准确。源码中的条件分支和 issue 注释对应这些具体场景,后续浏览器问题仍需要以相同方式增加处理条件。

下一篇讨论输入法和组合事件:compositionstart 到 compositionend 期间,视图层如何避免重绘组合节点,并在组合结束后读回变化。


1159 字 · 46 段落
ximing

Follow onGitHub

相关文章