扩展(上):keymap,最小的插件

2 分钟阅读
·

核心四包读完,从这篇开始进扩展包。上一篇手写的最小编辑器只接了输入、删除、加粗,加粗靠的是按钮直接派发命令,快捷键怎么处理留给了官方扩展。第一个要读的是 prosemirror-keymap,参考代码是它的 d60e244。整个包只有一个源文件 src/keymap.ts,一百行出头,两个导出函数,是全部官方扩展里最小的一个。拿它开篇有两个原因:它验证了第 22、23 篇讲的插件系统最少需要多少东西;快捷键的匹配逻辑本身也有几处不读源码猜不到的细节。基础扩展这一阶段的读法是自底向上:先 keymap 这个入口,再 commands 看命令本身,然后 history、inputrules,最后落到 schema-basic 和 schema-list 这两个具体文档结构。快捷键绑定的值就是命令,这篇先把「按键怎么找到命令」弄清楚,下一篇的命令签名惯例才有落点。

系列目录

日期 标题
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:中文输入法事件的处理
03-07 NodeView 与 MarkView:把渲染权交给你
03-14 Decoration 体系:不修改文档的视觉标注
03-21 clipboard:复制粘贴的序列化与解析
04-04 domcoords:屏幕坐标与文档位置的双向换算
04-11 browser.ts:浏览器差异补丁集
04-18 view 收官:不用官方扩展,手写一个最小可用编辑器
05-09 扩展(上):keymap,最小的插件(本篇)

一个插件的最小形态

keymap 函数的全部实现:

export function keymap(bindings: {[key: string]: Command}): Plugin {
  return new Plugin({props: {handleKeyDown: keydownHandler(bindings)}})
}

插件规格里只有 props 一项,props 里只有 handleKeyDown 一个函数。没有 StateField,没有 view 规格,没有 appendTransaction。第 23 篇讲过 props 从插件流向 view 的传递链:EditorView 把每个插件的 props 摊平后按名字取,handleKeyDown 是 view 预定义的事件 prop 之一,keydown 到达时由 view 负责逐个调用。这个插件自己不存任何状态,按键来了现查现执行,连 init 和 apply 都不用写。对照第 22 篇列的插件能力面,keymap 证明了 StateField、pluginView、事务过滤这些都是可选项,一个插件可以薄到只挂一个事件回调。

bindings 的值是 Command,签名 (state, dispatch, view) => boolean。返回 true 表示这个按键已处理,view 收到 true 会 preventDefault,浏览器的默认行为不再执行;返回 false 表示绑定了但当前不执行,比如选区里没有可加粗的文本,查找继续向后传递。第三个参数 view 不在命令的正式协议里,src/keymap.ts 的注释写明它是逃生舱,绑定需要直接操作 UI 时才用。命令内部怎么构造 transaction、怎么 dispatch,是下一篇 commands 的内容,keymap 这一层只负责把按键翻译成一次命令调用。

键名规格与归一化

用户写的键名是 "Mod-b""Shift-Ctrl-Enter" 这类字符串,KeyboardEvent 的修饰键却是 altKey、ctrlKey、metaKey、shiftKey 四个布尔位,两边表示不同,查表之前必须统一。normalizeKeyName 负责把规格换算成标准形。

先用 /-(?!$)/ 按连字符切开,最后一段是基础键名。这个负向前瞻放过行尾的连字符,所以 "Mod--" 能切成 ["Mod", "-"],减号键本身可以被绑定。基础键是 "Space" 时换成单字符空格,因为事件一侧空格键的名字就是 " "。前面的段全部按修饰键处理,大小写不敏感,别名给得很宽:cmdmetam 都算 Meta,aalt 算 Alt,cctrlcontrol 算 Ctrl,sshift 算 Shift。mod 单独一条分支:mac 下展开成 Meta,其余平台展开成 Ctrl,这就是规格里 Mod- 前缀的含义,写一次覆盖两个平台的主流快捷键习惯。判断 mac 用的是 navigator.platform 的正则,文件顶部先探了一次存成常量,typeof navigator 的守卫让非浏览器环境不会在求值时炸掉。五类别名都不命中的段直接抛 Unrecognized modifier name,规格里写错修饰键名同样在创建期暴露,不会静默变成一个永远查不到的键。

输出顺序固定为 Alt、Ctrl、Meta、Shift 依次拼前缀,最后接基础键。规格里修饰键顺序随意,"Shift-Ctrl-Enter""Ctrl-Shift-Enter" 归一到同一个标准名 "Ctrl-Shift-Enter"。事件一侧的 modifiers 函数按同样的顺序拼名字,两边才能查上。这就是归一化的意义:所有等价写法在进表之前折成一种,运行期每次按键的匹配退化成一次对象属性访问。

normalize 把整张 bindings 表过一遍,结果存进 Object.create(null) 建的对象,用空原型对象当纯字典,避免 "constructor" 这类键名撞上原型链上的属性。归一之后撞键直接抛错:"Mod-b""Ctrl-b" 在非 Mac 平台是同一个键,同一张表里都写属于配置错误,建插件时就炸,比运行时静默覆盖一个好。

归一化在 keydownHandler(bindings) 被调用时执行一次,也就是插件创建时。之后每次 keydown 用的都是这张现成的表。

一次按键,最多查三次表

keydownHandler 返回的处理函数是匹配逻辑的主体。用 w3c-keyname 包提供的 keyName(event) 从事件算出键名:优先取 event.key,取不到或不可信时按 keyCode 回退查表。字母键不按 Shift 时 event.key 就是小写,所以规格里字母用小写;想绑 Shift 加字母,规格要写大写形式。算出名字后 modifiers(name, event) 按事件的四个修饰键布尔位拼上前缀,拿这个名字去 map 里查。命中并且命令返回 true,处理完毕。

查不到,或者命令返回 false,还有两个兜底,都只在单字符键上启用(空格被单独排除),Enter、方向键这类功能键只有第一次查找的机会。

keydownHandler 的三次查表

第二次查找针对 Shift。美式键盘上按 Shift-= 产出的是 "+"keyName 给出 "+"modifiers 拼出 "Shift-+"。规格有个约定:由 Shift 产出的字符,绑定直接写那个字符,不要加 Shift- 前缀,也就是应该绑 "+"。直接查 "Shift-+" 查不到,所以单字符且 shiftKey 按下时,把 Shift- 前缀去掉再查一次,"+" 命中。反方向的写法也覆盖:如果规格偏要写 "Shift-+",第一次查找时 modifiers 拼出的名字正好带上 Shift-,一样命中。两种写法都能查到,是这段代码存在的全部理由。

第三次查找针对键盘布局差异。按住 Ctrl 或 Alt 时,不少布局下 event.key 会变成另一个字符,比如某些拉丁系布局里修饰键加字母产出的是带附加符号的字符,但物理键位没变。这时用 w3c-keyname 的 base 表按 event.keyCode 反查未修饰时的键名,拼上修饰前缀再查一次。守卫条件有三个:必须有 Alt、Ctrl、Meta 至少一个按下;Windows 上 Ctrl 和 Alt 同时按下时跳过,因为那个组合在很多布局里是 AltGr,是正常输入字符的方式,劫持它去触发快捷键会挡掉正常输入;反查出的名字和 keyName 的结果相同也不必再查,名字一样查出来的还是同一个空结果。

三次都落空,返回 false。keydownHandler 也单独导出,不走插件形式、想自己控制挂载位置的代码可以直接拿它当 handleKeyDown 用,keymap 函数本身只是它的一个薄封装。

拿一张具体的表走一遍

把前面的规则合起来,看一张具体绑定表上的四次按键:

keymap({
  "Mod-b": toggleBold,
  "Shift-Enter": insertBreak,
  "+": zoomIn,
})

第一次,Mac 上按 Cmd+B。keyName 给出 "b"modifiers 拼成 "Meta-b"。规格里的 "Mod-b" 在 Mac 平台归一化时展开成 Meta,表里存的键正是 "Meta-b",查找 1 命中。同一个规格在 Windows 上归一化时存的是 "Ctrl-b",Windows 下按 Ctrl+B 拼出的也是 "Ctrl-b"。一份规格在两个平台归一到两个不同的键,各自的快捷键习惯都照顾到。

第二次,Shift+Enter。Enter 是功能键,keyName 直接给出 "Enter",拼上 Shift 前缀查 "Shift-Enter",规格原样归一后也是这个名字,查找 1 命中。功能键没有后面两次兜底,规格写错名字就是绑不上,没有补救。

第三次,美式键盘上按 Shift+=。等号键 Shift 之后产出 "+",keyName 给出 "+",查找 1 查 "Shift-+",表里没有。单字符且 shiftKey 按下,进入查找 2,去掉 Shift- 前缀查 "+",命中 zoomIn。如果规格当初写成 "Shift-+",查找 1 就直接命中了,两种写法都能查到。

第四次,德式布局上按住 Ctrl 再按 ß 键。这个键位和美式布局的减号键是同一个物理键,keyCode 相同,但按住 Ctrl 时 event.key 给出的是 "ß"。查找 1 查 "Ctrl-ß" 落空;带 Ctrl 修饰且不是 Windows 的 Ctrl+Alt 组合,进入查找 3,base[event.keyCode] 反查出这个键码未修饰时的名字 "-",拼成 "Ctrl--" 再查。规格里如果绑了 "Mod--",在非 Mac 平台归一化后正是 "Ctrl--",命中。没有查找 3,这类布局下所有绑定在字符键上的快捷键都会因为修饰键改变了 event.key 而失灵。

四次按键覆盖了三次查找各自的典型触发路径。可以看到兜底的分工:查找 2 修的是 Shift 与字符的耦合,查找 3 修的是布局与字符的耦合,两个问题都只在单字符键上存在,所以功能键被排除在外。

它在输入管线的哪一站

第 29 篇梳理 输入管线 时提过 handleKeyDown 这一站,这里接上细节。prosemirror-view 的 editHandlers.keydown(参考代码是 prosemirror-view 的 ca4c78e)里,keydown 到达后先记下 shiftKey;composition 期间直接返回,输入法拼字过程中的按键不会触发快捷键;然后记下 lastKeyCode 和按键时间,供粘贴判断和读回用,接着进入主分支:

if (view.someProp("handleKeyDown", f => f(view, event)) || captureKeyDown(view, event)) {
  event.preventDefault()
} else {
  setSelectionOrigin(view, "key")
}

someProp 按顺序问:先看 view 的直接 props,再看 state.plugins 数组里的插件,第一个返回 true 的获胜,后面的这次按键就没机会了。多个 keymap 插件组合时,插件数组里靠前的优先级高,src/keymap.ts 的文档注释把这条写成了使用约定:想覆盖已有快捷键,把自己的 keymap 排在前面;想让内置绑定先生效、自己的做补充,排在后面。单个 keymap 内部没有顺序问题,一张表一个键只对应一个命令,撞键在 normalize 阶段已经抛错了。

这个顺序语义在实际装配里天天用到。扩展包通常各自导出自己的 keymap 插件,history 导出撤销重做的绑定,列表相关的绑定跟着列表命令走,应用层再补自己的快捷键。它们合并进同一个插件数组,谁前谁后就决定了同一个按键归谁。常见的做法是把应用自定义的 keymap 放最前,让默认绑定做兜底;撤销重做这类基础绑定放中间;Enter、Backspace 这种带一长串条件命令的表放后面,因为它们的命令内部会逐个尝试、全部不适用才返回 false,放前面也抢不走别的键,放后面可以保证更具体的绑定先被问到。命令返回 false 继续向下问的机制,让「专用的在前、通用的在后」这个排序原则能正常工作,不会出现通用绑定把按键吃掉、专用绑定永远等不到的情况。

handleKeyDown 全部返回 false 之后还有 captureKeyDown 兜底,方向键跨越不可编辑节点、Mod-b 这类危险按键的压制在那里。再拦不住才放行给浏览器,浏览器改了 DOM,由 DOMObserver 读回对齐。一个按键从进来到落地,keymap 挂的 handleKeyDown 是语义最高的一站:handleDOMEvents 虽然更靠前,拿到的是裸 DOM 事件;handleKeyDown 这里命令拿到的是 state 和 dispatch,产出的是 transaction,之后走的还是 dispatchTransaction、apply、updateState 那条老路,和鼠标点按钮没有任何区别。

还有几层门控容易忽略。事件进 editHandlers 之前先过 eventBelongsToView,NodeView 用 stopEvent 拦下的按键、已经 defaultPrevented 的按键,根本到不了 keymap。editHandlers.keydown 本身又在 editable 检查之内,只读编辑器里 keydown 的编辑分支不执行,快捷键自然不会触发。keymap 选 handleKeyDown 挂载点,顺带继承了归属判断、composition 保护和只读门控,这三件事插件自己一行代码都不用写。反过来这也意味着 keymap 拦不住所有按键:想在归属判断之前动手,得用 handleDOMEvents 自己挂 keydown,那是第 29 篇讲过的更靠前的一站。

收尾

这个包可以带走三个结论。插件可以只有 props 一项规格,keymap 是插件系统最小用法的实例。归一化要在边界做完,键名规格对用户宽容,别名、任意修饰顺序、Mod 平台抽象全在进表前折成标准形,运行期查找只剩一次属性访问。组合顺序即优先级,多 keymap 的覆盖关系不需要单独的配置项,someProp 的遍历顺序就是规则本身。另外值得记住的是失败方式集中在创建期:修饰键名写错、同一键重复绑定,都在 new Plugin 那一刻抛错,运行期只剩下查表和执行两条路径。配置类代码把校验前置到装配阶段,后面 commands、inputrules 里会看到同样的习惯。

下一篇看 prosemirror-commands:命令的签名惯例与 dry-run 探查、chainCommands 的短路组合,以及 baseKeymap 里 Enter 和 Backspace 默认行为的完整实现,那些命令最终会挂进今天这张表里。


877 字 · 36 段落
xi ming

Written by xi mingFollow onGitHub