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

5 分钟阅读
·

上一篇看了选区同步,state 选区和 DOM 选区怎么双向对齐。这篇处理一个会让前面机制集体失效的场景:中文、日文输入法。拼音敲下「nihao」到上屏「你好」之间,浏览器进入 composition 状态,候选拼音直接写在 contenteditable 的文本节点里,带下划线,旁边挂着候选窗。这段时间 DOM 不归编辑器管,编辑器要是按自己的节奏重绘这个节点,组合会话立刻被掐断,候选窗消失,输入中断。

浏览器给这个过程配了三个事件:组合开始 fire compositionstart,候选每变一次 fire compositionupdate,上屏或取消时 fire compositionend。问题在两个方面。一是事件本身不可靠,移动端丢 compositionend、桌面端事件顺序错乱都是常态;二是组合期间编辑器自己的更新模型还在转,协作端的远端修改、插件的 appendTransaction 随时可能要求重绘 DOM,而重绘不能碰输入法正在用的那块区域。prosemirror-view 的应对可以拆成四块:compositionstart 的进场准备,组合期间对写入方向的保护,compositionend 的读回收尾,以及 compositionID 这个给撤销分组用的编号。参考代码是 prosemirror-view 的 ca4c78e,主要看 src/input.ts 的三个事件处理器,配合 src/domobserver.tssrc/domchange.tssrc/viewdesc.ts 里的相关分支。

系列目录

日期 标题
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 选区的双向对齐
02-28 Composition 与 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,排了就直接返回,等那一拍自己跑;然后 forceFlush 读回积压、clearComposition 清掉组合状态;最后如果 restarting 或 docView 标了脏,从 DOM 读一次选区,和 state 选区不一致就 dispatch 一个 setSelection;选区一致但落在非 inline 节点上、且是 markCursor 或 restarting 触发的重绘,就 dispatch 一个 deleteSelection;其余情况跑 updateState(view.state) 让 DOM 按当前 state 重画一遍。它返回一个布尔值表示有没有真的做过对齐,handlers.mousedown 会把这个值传进 MouseDown,影响后面点击选区的判定。

进场流程都做完,view.input.composing 置 true。之后每次 compositionupdate 只跑一件事:scheduleComposeEnd。桌面上这个定时器不启用(delay 是 -1),Android 上是 5000 毫秒:移动端的组合事件经常丢 compositionend,五秒没有后续事件就当组合已结束,强制收尾。

还有一个和按键打交道的细节。组合进行中 keydown 会被 inOrNearComposition 提前挡掉;组合状态之外,editHandlers.keydown 在处理按键前会先 view.domObserver.forceFlush() 把积压变化读回,唯独 keyCode 229 跳过。229 是浏览器在 IME 占用键盘期间上报的虚拟键码,也是组合进行中的信号,这时 DOM 归输入法,不做抢先读回。

组合进行中:写入方向怎么绕开组合节点

读回方向在组合期间不受限制,MutationObserver 照常触发 flush,readDOMChange 照常把 DOM 里的拼音读进 state,所以输入过程中 state 是实时跟着变的。需要保护的是另一个方向:组合期间恰好来了一个 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,组合输入是让浏览器改、写入方向绕着走、结束再对账。进场时把 state 和 DOM 对齐,并把光标包进正确的 mark;进行中用 CompositionViewDesc 把组合节点从重绘路径里摘出去;结束时读回剩余变化,用 compositionID 把同一次会话的读回串成一组交给 history。view 阶段还剩 NodeView、Decoration、剪贴板、坐标换算几块,下一篇看 NodeView 与 MarkView,自定义渲染怎么挂进 ViewDesc 树。


970 字 · 43 段落
xi ming

Written by xi ming You should follow him on Github