上一篇讨论 state 选区与 DOM 选区的双向同步。本篇讨论中文、日文输入法的 composition 场景。从输入「nihao」到上屏「你好」期间,浏览器处于 composition 状态,候选拼音直接写入 contenteditable 的文本节点,并显示下划线和候选窗。此时若编辑器重绘输入法使用的节点,组合会话会中断,候选窗消失,输入无法继续。
浏览器为该过程提供三个事件:组合开始触发 compositionstart,候选内容变化触发 compositionupdate,上屏或取消触发 compositionend。处理难点包括两个方面:移动端可能缺少 compositionend,桌面端事件顺序也可能异常;组合期间协作端远端修改或插件 appendTransaction 仍可能要求重绘 DOM,但重绘不能修改输入法使用的区域。prosemirror-view 的处理包含四部分:compositionstart 的准备、组合期间对 DOM 写入的保护、compositionend 的读回处理,以及供撤销分组使用的 compositionID。参考代码是 prosemirror-view 的 ca4c78e,主要涉及 src/input.ts 的三个事件处理器,以及 src/domobserver.ts、src/domchange.ts、src/viewdesc.ts 的相关分支。
系列目录
compositionstart 的准备
editHandlers.compositionstart 和 editHandlers.compositionupdate 指向同一个函数(src/input.ts)。首次触发时 view.composing 为 false,因此会执行初始化分支。
第一件事是 view.domObserver.flush(),把 MutationObserver 队列里积压的变化全部读回 state。组合开始前必须保证 state 和 DOM 一致,否则后面的位置换算全是错的。
接着处理一个格式问题。光标处在 storedMarks 待命状态(比如刚按了加粗快捷键还没打字),或者光标紧跟在一个 inclusive === false 的 mark 后面时,直接把 DOM 交给输入法,新插入的文字会落在错误的 mark 上下文里。处理方式是给光标包一层:view.markCursor 记下该有的 mark 集合,然后以 restarting 模式调 endComposition(view, true) 触发一次 state 到 DOM 的重绘,updateCursorWrapper(src/index.ts)会借此在光标处插一个包着这些 mark 的占位 widget,输入法往里写字就落在正确的格式里,之后 markCursor 清空。Chrome Windows 上光标紧邻不可编辑元素时(selectionBeforeUneditable)也走同一条路。Firefox 是另一个变体:光标在带 mark 节点之后、但在节点之外时,插入的文本不继承 mark,这段代码直接把 DOM 选区挪进前面文本节点的末尾。
普通路径根据选区是否为空调用 endComposition;非空选区使用 restarting 分支,强制执行一次对齐重绘。endComposition 负责组合状态的收尾:在 Android 上先检查 domObserver 是否已安排 flushSoon,已安排则直接返回并等待该 flush;随后通过 forceFlush 读回积压变化,并由 clearComposition 清除组合状态。若 restarting 为真或 docView 被标脏,则从 DOM 读取选区,和 state 选区不一致时 dispatch 一个 setSelection;选区一致但落在非 inline 节点上,且重绘由 markCursor 或 restarting 触发时,dispatch 一个 deleteSelection;其余情况调用 updateState(view.state),按当前 state 重画 DOM。函数以布尔值表示是否执行过对齐,handlers.mousedown 将该值传给 MouseDown,影响点击选区的判定。
初始化完成后,view.input.composing 置为 true。之后每次 compositionupdate 只调用 scheduleComposeEnd。桌面端不启用该定时器(delay 为 -1);Android 的延迟为 5000 毫秒。移动端可能缺少 compositionend,因此五秒内没有后续事件时会视为组合结束并执行 endComposition。
还有一个和按键打交道的细节。组合进行中 keydown 会被 inOrNearComposition 提前挡掉;组合状态之外,editHandlers.keydown 在处理按键前会先 view.domObserver.forceFlush() 把积压变化读回,唯独 keyCode 229 跳过。229 是浏览器在 IME 占用键盘期间上报的虚拟键码,也是组合进行中的信号,这时 DOM 归输入法,不做抢先读回。
组合期间如何保护组合节点
组合期间仍会执行读回:MutationObserver 触发 flush,readDOMChange 将 DOM 中的拼音读入 state,因此输入过程中的 state 会持续更新。需要保护的是 DOM 写入方向。组合期间若收到 transaction,例如协作端远端修改或插件 appendTransaction,updateStateInner 需要重绘 DOM,但不能移除输入法使用的文本节点。停止全部 DOM 更新也不可行,因为远端修改和插件的视觉效果无法显示。代码因此仅保护包含组合内容的节点,其余区域仍按常规路径更新。
保护从定位开始。updateStateInner(src/index.ts)在 updateDoc 之前执行 if (this.composing) this.input.compositionNode = findCompositionNode(this)。这里的 view.composing 是 EditorView 上的公开 getter,插件可以用它判断当前是否在组合中。
findCompositionNode(src/input.ts)先读取 DOM 选区,取得焦点前后的两个文本节点 textBefore 和 textAfter。两者都存在且不是同一节点时,光标位于两个文本节点之间,需要确定组合节点:先检查 domObserver.lastChangedTextNode(最近发生 characterData 变化的节点),如果它是其中之一则选用它;否则检查 textAfter 的 ViewDesc 是否仍能对应当前节点,desc 不存在或 desc 中的文本与节点当前值不一致时,将 textAfter 视为新生成的组合节点;另一个分支处理 textAfter 已记录为组合节点的情况,并反向检查 textBefore 的 desc 状态。无法确定时,使用 textBefore 或 textAfter 中存在的节点。整个过程不依赖事件参数,而是依据 DOM 状态和 domObserver 记录判断。
真正消费这个名字的是 updateChildren(src/viewdesc.ts)。composing 时它先调 localCompositionInfo:要求选区是 TextSelection 且落在本节点范围内,组合节点还在本节点的 DOM 里。节点是 inline 内容时,用 findTextInFragment 在文档内容里找这段文本,找不到说明内容已经被别的途径改过,放弃保护,照常重绘;找到了就把文本位置交给 protectLocalComposition:如果该节点已经有 ViewDesc 管理,什么都不做;否则沿祖先链爬到 contentDOM,把路径上多余的兄弟节点删掉、清掉 pmViewDesc 标记,造一个 CompositionViewDesc 包上去,再用 replaceNodes 把这个 desc 插进 children 数组顶替原文档对应的位置,同时记进 view.input.compositionNodes 数组。节点不是 inline 内容时(组合发生在某个子块里),localCompositionInfo 返回 pos 为 -1 的结果,updateChildren 走另一条分支:在 ViewTreeUpdater 里用 findIndexWithChild 找到装着组合节点的那个子 desc,只对它调 updateNodeAt,其余子节点照常匹配。
CompositionViewDesc 是个极简 desc:size 就是文本长度,localPosFromDOM 和 domFromPos 只做文本内的偏移换算,ignoreMutation 只忽略「值没变」的 characterData 记录。它的作用是让 renderDescs 重排 children 时把组合节点当作已就位的内容保留下来,DOM 原样留在原地,输入法不受打扰。replaceNodes 的源码注释也提到这个函数跟整个 viewdesc 的设计相悖(别处都是一次建好正确形状,只有这里事后改数组),但组合 hack 需要它。
组合结束的清理在 clearComposition:composing 置回 false,compositionNodes 里每个 desc 调 markParentsDirty 把祖先链标脏,下一次更新会把这些区域按文档内容重绘一遍,临时包的 CompositionViewDesc 随之消失。另外 updateStateInner 开头有一条:新 state 带着 storedMarks 且正在组合,直接 clearComposition 中止组合。源码注释给的理由是 stored marks 需要被显示出来。
compositionend 的读回处理
editHandlers.compositionend 做五件事。
- composing 置 false,
compositionEndedAt记下事件的 timeStamp。 - 查
domObserver.pendingRecords():compositionend 触发时若还有没交付的 mutation 记录,把当前 compositionID 存进compositionPendingChanges,表示这次组合还有账没读完。 - 如果之前 domobserver 标了
badSafariComposition(Safari 在表格单元格里结束组合时会往 TR 里乱插节点),直接 forceFlush;否则有积压记录就把 flush 推到 microtask 里跑,等这一拍的事件都落定再读。 - compositionID 加一,下一次组合拿到新编号。
scheduleComposeEnd(view, 20):20 毫秒后再次调用endComposition,处理事件顺序异常的情况。
compositionEndedAt 这个时间戳在 inOrNearComposition 里消费。Safari 上用回车确认候选时,compositionend 之后紧跟一个 keydown,这个 keydown 不能再走正常处理,否则会多插一个换行。判定方式是 keydown 的 timeStamp 与 compositionEndedAt 相差 500 毫秒以内就吞掉,且只吞一次(判定后把时间戳重置为 -2e8),第二次回车正常换行。clearComposition 也要写这个字段,但代码路径里没有真实事件可拿,于是 timestampFromCustomEvent 造一个 dummy event,只为取一个和真实事件同时钟的 timeStamp。
compositionend 之外,handlers.mousedown、touchstart、contextmenu 也会先调用 forceDOMFlush,其内部调用 endComposition:用户在组合过程中点击其他位置时,先将组合内容读回并提交,再处理当前事件。destroyInput 会清除 composingTimeout。
compositionID 与撤销分组
compositionID 从 1 开始,每次 compositionend 加一,等效于给每次 IME 会话一个编号。它的消费方在 readDOMChange(src/domchange.ts)开头:
let compositionID = view.input.compositionPendingChanges || (view.composing ? view.input.compositionID : 0)
view.input.compositionPendingChanges = 0组合进行中读回产生的 transaction,以及 compositionend 之后补读的那一笔,都会 tr.setMeta("composition", compositionID),同一次会话里的多笔读回带同一个编号。
编号的目的在 prosemirror-history 的 445409b,src/history.ts 的 apply 分支读 tr.getMeta("composition")。history 本来的分组依据是时间和位置:相邻两笔 transaction 间隔不超过 newGroupDelay、且改动位置相接,就并入同一组,否则开新组。composition 编号参与的方式是,开新组的条件里包含 history.prevComposition != composition:编号相同时,时间和位置检查被整条跳过,直接并入当前组。效果是「ni hao」上屏过程中产生的好几笔读回 transaction,不管间隔多久、位置是否相接,撤销时都算一组,一次 undo 整段退回,不会逐笔回放拼音。另外 prevComposition 的更新也有讲究:transaction 没带 composition meta 时保留旧值,普通编辑不会把上一次组合的编号冲掉。view 层其他代码都不读这个编号,history 是它唯一的服务对象。
Chrome 组合输入的两个补丁
组合期间的读回走 readDOMChange 主路径,但 Chrome 在组合里有两个反复出现的毛病,代码里各留了一个计数器。
第一个是「整段删了再插」。Chrome 偶尔会在组合中把整段组合文本删掉再立刻插回来,diff 出来的变更是一次纯删除(change.endB == change.start)。readDOMChange 检测到这种情况就把 view.input.lastChromeDelete 记为当前时间。
第二个是选区误报。构造 transaction 的 mkTr 里有一段组合特判:Chrome 且正在组合、解析出的选区是光标、且光标位置刚好落在变更边界上时,放弃这次选区更新,不 tr.setSelection。注释说明 Chrome 在组合中会报告错误位置的选区。判定条件里还引用了上面的 lastChromeDelete:100 毫秒内发生过「整段删了再插」且变更长度为零时,反而放行这次选区更新,因为那种情况下选区恰好是可信的。两个补丁一个记账一个消费,都只在 Chrome 组合期间生效。
移动端与浏览器差异
composition 相关的补丁按浏览器分散在各处,这里归拢一下。
Android Chrome 的补丁最多。keydown 里的 Enter 直接不处理,注释说明它常是一串混乱组合事件的一部分,急着处理会弄坏输入;组合五秒无事件强制收尾前面说过;endComposition 开头还有一条,domObserver 已经排了 flushSoon 就直接返回,等那一拍 flush 自己跑。粘贴相反:桌面上 composing 时 paste 事件放行原生(浏览器对组合期间的脚本粘贴处理得很差),Android 上照常拦截,因为移动端几乎永远在组合状态。readDOMChange 里还有一处 Android 专判:虚拟键盘「回车加选词」会先发 DOM 变化再移选区,代码把新段落从本次变更里减掉,20 毫秒后补发一个模拟 Enter 的 handleKeyDown。退格也有补丁,handlers.beforeinput 里检测 Android Chrome 的 deleteContentBackward:先 flushSoon,50 毫秒后如果发现这次事件没产生任何 DOM 变化(domChangeCount 没动),就 blur 再 focus 把关闭的虚拟键盘拉回来,然后补发一次 Backspace 的 handleKeyDown,没人接就自己删一个字符。
iOS 的补丁围绕 Enter。preventDefault 回车会让虚拟键盘错乱,所以 keydown 里不拦,只记 lastIOSEnter 时间戳,同时挂一个 200 毫秒的兜底定时器;readDOMChange 看到 225 毫秒内的 Enter 痕迹,就把这次 DOM 变化当成回车,改发 handleKeyDown,让语义层决定怎么分段。
Safari 两处。一是 initInput 里给 view.dom 多挂一个空的 input 监听,注释写明原因不明:不加它,组合中按回车整个组合会消失。二是表格场景:组合在空单元格结束时 Safari 会把文本挪到 TR 下,domobserver 的 mutation 回调里检测 target 是 TR 的 childList 记录,标 badSafariComposition 并 flushSoon,flush 时 fixUpBadSafariComposition(src/domobserver.ts)把节点挪回下一个单元格的开头,没有可挪的单元格就删掉。
Composition 处理流程
普通输入和组合输入采用不同的 DOM 更新方式。普通输入中的部分事件会被拦截并由编辑器发起 transaction;组合输入期间由浏览器修改 DOM,视图更新则避开组合节点,并在结束后读回剩余变化。compositionstart 时,代码对齐 state 与 DOM,并在需要时为光标创建带正确 mark 的占位 widget;组合期间,CompositionViewDesc 使组合节点不参与重绘;结束时读回变化,并用 compositionID 将同一次会话产生的 transaction 交给 history 分组。下一篇讨论 NodeView 与 MarkView 如何接入 ViewDesc 树以实现自定义渲染。
