input.ts:从 keydown 到 dispatchTransaction 的输入管线

3 分钟阅读
·

上一篇看了被动的一侧:浏览器改了 DOM 之后,DOMObserver 怎么把变化读回文档。读回是兜底,事件进来时 view 先有机会主动处理。这篇看主动的一侧:src/input.ts 的事件注册机制、keydown 从进入编辑器到变成 transaction(或者被放行)的完整路径,以及 src/capturekeys.ts 的兜底过滤。参考代码是 prosemirror-view 的 ca4c78e。

系列目录

日期 标题
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 的输入管线(本篇)

两张注册表:handlers 与 editHandlers

src/input.ts 文件顶部有两张表。handlers 装所有 DOM 事件的回调,editHandlers 装其中的编辑类事件:keydown/keyup/keypress、composition 三件套、cut/paste、dragover/dragenter/drop。文件末尾一行 for (let prop in editHandlers) handlers[prop] = editHandlers[prop] 把 editHandlers 全量并进 handlers,注册时只遍历 handlers 这一张表。

分两张表的意义在 initInput 的门控条件里:

if (eventBelongsToView(view, event) && !runCustomHandler(view, event) &&
    (view.editable || !(event.type in editHandlers)))
  handler(view, event)

view.editable || !(event.type in editHandlers) 表示:只读模式下编辑类事件的回调不执行,其余事件照跑。一个具体的差异:cut 在 editHandlers 里,copy 只在 handlers 里,所以只读编辑器可以复制、不能剪切。focus、blur、mousedown 这些和编辑无关的事件在只读时也要工作,它们不进 editHandlers。

initInput 给每种事件挂的监听器也不是原始回调本身,外面统一套了上面这个包装函数,按顺序做三件事。第一件,eventBelongsToView 判断事件归属:不冒泡的事件(focus/blur)直接算自己的;已经 defaultPrevented 的不碰;从 event.target 沿父链向上走到 view.dom,途中遇到 document fragment(Shadow DOM 场景),或者某个节点的 pmViewDesc.stopEvent(event) 返回 true,事件就不归编辑器管。stopEvent 是 NodeView 拦截事件的入口,widget 装饰在 src/viewdesc.ts 里也有自己的实现。第二件,runCustomHandler 跑插件的 handleDOMEvents,下一节展开。第三件就是上面的 editable 门控。全过了才调 handlers 里的内置函数。

注册时还有两个小分支。passiveHandlers 单独记了 touchstart 和 touchmove,这两个事件的监听器带 {passive: true},它们的回调只记 lastTouch 时间戳和选区来源(touchstart 另外做一次 forceDOMFlush),不调 preventDefault,符合 passive 监听器的约束。另一个是 Safari 特判:额外挂一个什么都不做的 input 监听器,注释说这个空 handler 能绕开组合输入按 Enter 时组合内容消失的问题,原因不明,照实记录。

插件可以声明核心没有监听的事件类型,这部分由 ensureListeners 补挂:遍历所有插件的 handleDOMEvents,遇到没注册过的类型就补一个只跑 runCustomHandler 的监听器。updateState 之后插件集可能变化,会再调一次。另外 input.ts 还导出一个 dispatchEvent(EditorView 上同名方法转发到这里),让外部代码把事件手动灌进同一条管线。它跳过 eventBelongsToView(调用方自己保证事件属于编辑器),但保留 runCustomHandler 和 editable 门控,NodeView 或测试代码想触发编辑器的事件处理时用它。

someProp:所有事件 prop 的统一入口

runCustomHandler 的核心是一行 view.someProp("handleDOMEvents", ...)。someProp 定义在 src/index.ts,查找顺序固定:直接传给 EditorView 的 props、构造时的 directPlugins、state.plugins,逐层找第一个非 undefined 的值,交给回调 f 执行,f 返回 truthy 就停,把结果返回。

事件相关的 prop 全部走这个入口。handleKeyDown、handleKeyPress、handleTextInput、handleClickOn、handlePaste、handleDrop 的语义一致:插件数组的顺序就是优先级,靠前的插件先看到事件,返回 true 事件就被吃掉。keymap 插件把快捷键表展开成 handleKeyDown,inputrules 挂在 handleTextInput 上,都是这个约定的消费者,后面写扩展的几篇会反复见到。

runCustomHandler 里有个细节:handler(view, event) || event.defaultPrevented。handler 返回 falsy 但顺手调了 preventDefault,也算处理过。插件想「不拦截处理权、只阻止浏览器默认行为」时不用硬返回 true。

到这里三层处理顺序就清楚了:handleDOMEvents 最先,语义化 prop(handleKeyDown 等)其次,内置兜底(captureKeyDown)最后。每一层都有机会 preventDefault 终止事件。

keydown 的完整路径

editHandlers.keydown 是这条管线上分支最多的函数,按顺序做这些事。

先记 shiftKey:keyCode 16(Shift 自身)或 event.shiftKey 都置真,keyup 里清掉。这个标志后面被粘贴逻辑消费,Shift 按住时粘贴按纯文本处理。

然后 inOrNearComposition 命中就直接返回。组合输入进行中不处理按键。还有一个边界:Safari 的日文输入法在 compositionend 之后会紧跟一个 Enter 的 keydown,这个 keydown 是确认上屏用的,不该换行。判定方式是事件时间戳和 compositionEndedAt 相差 500 毫秒以内,吞掉,且只吞一次,第二次 Enter 正常换行。

接着记 lastKeyCodelastKeyCodeTime。上一篇的 readDOMChange 会读这两个字段:Android Chrome 上按键 100 毫秒内 keyCode 是 13 时,读回把这次变化改判成 Enter,重发 handleKeyDown 走命令路径;keyCode 是 8 时把 diff 的锚点定在选区末尾,domchange.ts 里的注释写明这是 Backspace 的特判。input.ts 记账,domchange.ts 消费,两个文件靠 InputState 上的字段对齐。

两个平台特判之后才能往下走。Android Chrome 的 Enter 直接返回,注释说这个平台上 Enter 常混在一串混乱的 composition 事件里,抢着处理会破坏输入。keyCode 不等于 229(IME 组合中的约定值)时调 view.domObserver.forceFlush():按键处理前先把积压的 DOM 变化读回,保证 state 和 DOM 同步,后面 handleKeyDown 里的命令拿到的 selection 是最新的。

iOS 的 Enter 单独一套 hack。preventDefault Enter 会让 iOS 虚拟键盘状态错乱,所以不拦,改为记 lastIOSEnter 时间戳,放行让浏览器换行;读回路径看到 lastIOSEnter 在 225 毫秒内,就把这次 DOM 变化撤销,改发 handleKeyDown 重走命令路径(domchange.ts 里两个引用 lastIOSEnter 的分支)。keydown 这边还挂了一个 200 毫秒的兜底定时器:到点时 lastIOSEnter 没被读回路径消费掉,就自己补发一次 handleKeyDown。

最后才是语义化处理:view.someProp("handleKeyDown", f => f(view, event)) 逐插件问,全部返回 false 再调 captureKeyDown。任一层返回 true 就 preventDefault,事件到此为止。都没处理则 setSelectionOrigin(view, "key") 放行,浏览器执行默认行为,产生的 DOM 变化走上篇的读回路径。读回选区时看到这个来源,会给 transaction 补上 scrollIntoView。

keydown 的处理顺序

keypress 顺带看。函数开头先过滤一批情况:组合输入进行中、没有 charCode(功能键)、按了 Ctrl 而没按 Alt、Mac 上按了 Cmd,这些都直接返回不管。剩下的才是字符输入,handleKeyPress prop 先行,返回 true 就 preventDefault。之后按选区形状分流:选区不是 TextSelection,或者 from 和 to 不在同一个父节点里,视图不信任浏览器能正确替换这段选区,自己处理:先给 handleTextInput prop 一次机会(inputrules 的挂载点),没人接就 dispatch 默认的 insertText transaction。选区是同一文本块内的普通光标时放行,浏览器插字符,读回补齐。handleTextInput 收到的参数是 (from, to, text, deflt),deflt 是构造默认 transaction 的函数,插件可以完全自己处理,也可以在 deflt() 的结果上再加步骤,默认行为被参数化交出去了。还有一个保护:text 里含换行符时既不问 handleTextInput 也不 dispatch,事件直接被 preventDefault 吞掉,换行不允许从 keypress 这条路进文档。

captureKeys:危险按键的兜底过滤

src/capturekeys.ts 导出唯一函数 captureKeyDown。文件头注释写明了职责:有危险默认行为的按键,即使命令返回 false 也要压掉;光标移动键要保证按下去落在文本光标上。它在 handleKeyDown 全部返回 false 之后运行,是放行前的最后一道拦截。getMods 先把修饰键压成字符串:ctrl→c、meta→m、alt→a、shift→s,后面用 mods.indexOf("s") > -1 这样的方式判断。Mac 上 Emacs 风格的 Ctrl-h/d/b/f/p/n 被映射成 Backspace/Delete/方向键的等价分支。

逐类按键看它的处理。

Backspace 和 Delete 走 stopNativeHorizontalDelete:选区不是 TextSelection、选区跨节点、光标在文本块边界,这三种情况都返回 true 直接拦下。原因相同:这些场景的删除必须走命令路径,浏览器默认删除会做出 schema 不允许的结构。光标旁边是非文本的内联节点时,函数自己 dispatch 一个 delete transaction 把节点删掉。没拦下的部分交给 skipIgnoredNodes:把 DOM 光标从 widget、不可编辑节点旁边挪开,防止浏览器的删除逻辑被这些零尺寸节点搞乱。

Enter 和 Esc 无条件返回 true。Enter 在没有任何插件处理时宁可吞掉,也不让浏览器换行,浏览器换出来的 br 或 div 结构 schema 未必认。这也意味着裸编辑器不配 keymap 插件时 Enter 什么都不发生,这是设计好的行为。

方向键走 selectHorizontallyselectVertically,只在需要跨节点移动时接管:光标在文本块边缘、下一步是可选中的节点时,手动造 NodeSelection 或 TextSelection dispatch 出去;普通行内移动返回 false 交给浏览器。还有两种小情形也在这里处理:Shift 加方向键且光标旁边是叶子节点时,把选区的 head 扩到节点另一侧;当前是内联节点上的 NodeSelection 时,左右方向键先把它折回节点边缘的 TextSelection。findDirection 处理双向文本:非 Chrome 非 Windows 的环境下用 coordsAtPos 比较前后位置的坐标,判断这段文本的书写方向,左箭头在 rtl 文本里应该向右走。skipIgnoredNodes 在方向键路径上同样收尾,把 DOM 光标从零尺寸节点旁边挪开。Safari 还有一个专门的补丁 safariDownArrowBug:光标在文本块开头且后面紧跟不可编辑节点时,下方向键行为异常,处理方式是临时把那个节点的 contentEditable 置 true,20 毫秒后改回来。

Mod-b/i/y/z(keyCode 66/73/89/90)无条件 true。contenteditable 自带的加粗、斜体、重做、撤销不可控,压掉等 keymap 插件处理;插件不存在时这几个键什么都不发生,好过浏览器做出和文档模型脱节的格式。

返回 true 的语义在四条分支里是统一的:keydown 包装层拿到 true 就 preventDefault。注意 captureKeyDown 里很多分支实际上已经 dispatch 了 transaction(selectHorizontally 里的 apply、stopNativeHorizontalDelete 里的 delete),返回 true 是为了阻止浏览器再执行一次默认行为,避免同一个按键生效两次。

beforeinput:只用来打补丁的事件

handlers.beforeinput 注册了,但函数开头的注释明说:beforeinput 的浏览器支持太零散,先观望它能发展成什么样。当前它只有一个用途,一个具体的 Chrome Android 补丁:光标在不可编辑节点之后时,deleteContentBackward 有时不生效。

处理方式是先 domObserver.flushSoon() 排一次读回,记下当时的 domChangeCount,50 毫秒后检查:计数变了说明这次删除实际生效了,不用管;没变说明浏览器把事件吞了,于是 blur 再 focus(这个 bug 会顺手关掉虚拟键盘,重新聚焦把它拉回来),接着补发一次 handleKeyDown 模拟 Backspace 按键,还没人处理的话,在 $cursor 位置手动 dispatch 一个 delete 删掉前一个字符。

这个函数展示了管线的降级思路。beforeinput 不参与正常输入,正常输入靠 keydown/keypress 加读回;只有特定平台的默认行为失灵时,才在这个更早的事件里检测失灵,然后逐层降级补偿:先还原成 keydown 语义问一遍插件,插件不接就手动发 transaction。keypress 里 handleTextInput 的 deflt 参数是同一种思路的正向版本:把默认行为做成可以调用的函数,插件决定接管还是委托。

其余事件的分工

mousedown 起一套 MouseDown 对象(input.ts 内部的类),用 lastClick 记录上一次点击,500 毫秒内、位移平方小于 100(10 像素半径)、同一按键,判定为连击,singleClick 升 doubleClick 再升 tripleClick。按住修饰键(Mac 上是 Cmd,其他平台是 Ctrl)的点击不参与连击升级,这个修饰键被保留给「点选节点」语义。单击在 mouseup 里走 handleSingleClick:先 runHandlerOnContext 从点击位置的最深节点向外逐层调 handleClickOn,再调 handleClick,最后是默认行为 selectClickedNode 或 selectClickedLeaf,把点击处可选中的节点选成 NodeSelection。runHandlerOnContext 的逐层外扩相当于文档树版本的事件冒泡。

MouseDown 还背着拖拽的准备工作。按下的目标节点声明了 draggable(或者点击落在已有 NodeSelection 范围内)时记一个 mightDrag:目标 DOM 上没有 draggable 属性就临时加上,Gecko 下还要临时补 contentEditable=false,这些改动在 mouseup 或 dragstart 进来时还原。

其余事件各归各的篇。copy/cut/paste、dragstart/drop 走剪贴板序列化和 parseFromClipboard,留给剪贴板一篇。composition 三件套和 IME 的交互留给输入法一篇。focus/blur 维护 ProseMirror-focused class 和 focused 标志,focus 后 20 毫秒检查一次 DOM 选区,和缓存不一致就 selectionToDOM 纠正,选区同步是下一篇的主题。

InputState 这一包字段值得单独提一句,它是事件之间的通信渠道:shiftKey 给粘贴用,lastKeyCode/lastKeyCodeTime 给读回用,lastClick 给连击判定用,lastSelectionOrigin 给选区读回打 meta 用。事件回调之间互不调用,全靠这张状态表对齐上下文。

input.ts 和上一篇的读回路径合起来是完整的一进一出。进来的方向分三层拦截:handleDOMEvents 最先、语义化 prop 其次、captureKeyDown 兜底;拦不住或不该拦的放行给浏览器,MutationObserver 在后面兜底对齐。编辑器因此既不用重新实现全套编辑行为,也不会漏掉结构性按键。下一篇看选区同步,state 里的选区和 DOM 里的选区两个方向怎么对齐。


1046 字 · 43 段落
xi ming

Written by xi mingFollow onGitHub