Composition 与 IME:中文输入法事件的处理

📅
2 分钟阅读
·

上一篇讨论 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.tssrc/domchange.tssrc/viewdesc.ts 的相关分支。

系列目录

日期标题
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 选区的双向对齐
02-28Composition 与 IME:中文输入法事件的处理(本篇)
一次 IME 组合的生命周期

compositionstart 的准备

editHandlers.compositionstarteditHandlers.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 的重绘,updateCursorWrappersrc/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 更新也不可行,因为远端修改和插件的视觉效果无法显示。代码因此仅保护包含组合内容的节点,其余区域仍按常规路径更新。

保护从定位开始。updateStateInnersrc/index.ts)在 updateDoc 之前执行 if (this.composing) this.input.compositionNode = findCompositionNode(this)。这里的 view.composing 是 EditorView 上的公开 getter,插件可以用它判断当前是否在组合中。

findCompositionNodesrc/input.ts)先读取 DOM 选区,取得焦点前后的两个文本节点 textBefore 和 textAfter。两者都存在且不是同一节点时,光标位于两个文本节点之间,需要确定组合节点:先检查 domObserver.lastChangedTextNode(最近发生 characterData 变化的节点),如果它是其中之一则选用它;否则检查 textAfter 的 ViewDesc 是否仍能对应当前节点,desc 不存在或 desc 中的文本与节点当前值不一致时,将 textAfter 视为新生成的组合节点;另一个分支处理 textAfter 已记录为组合节点的情况,并反向检查 textBefore 的 desc 状态。无法确定时,使用 textBefore 或 textAfter 中存在的节点。整个过程不依赖事件参数,而是依据 DOM 状态和 domObserver 记录判断。

真正消费这个名字的是 updateChildrensrc/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 就是文本长度,localPosFromDOMdomFromPos 只做文本内的偏移换算,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.mousedowntouchstartcontextmenu 也会先调用 forceDOMFlush,其内部调用 endComposition:用户在组合过程中点击其他位置时,先将组合内容读回并提交,再处理当前事件。destroyInput 会清除 composingTimeout。

compositionID 与撤销分组

compositionID 从 1 开始,每次 compositionend 加一,等效于给每次 IME 会话一个编号。它的消费方在 readDOMChangesrc/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 时 fixUpBadSafariCompositionsrc/domobserver.ts)把节点挪回下一个单元格的开头,没有可挪的单元格就删掉。

Composition 处理流程

普通输入和组合输入采用不同的 DOM 更新方式。普通输入中的部分事件会被拦截并由编辑器发起 transaction;组合输入期间由浏览器修改 DOM,视图更新则避开组合节点,并在结束后读回剩余变化。compositionstart 时,代码对齐 state 与 DOM,并在需要时为光标创建带正确 mark 的占位 widget;组合期间,CompositionViewDesc 使组合节点不参与重绘;结束时读回变化,并用 compositionID 将同一次会话产生的 transaction 交给 history 分组。下一篇讨论 NodeView 与 MarkView 如何接入 ViewDesc 树以实现自定义渲染。


957 字 · 43 段落
ximing

Follow onGitHub

相关文章