核心四包读完,从这篇开始进扩展包。上一篇手写的最小编辑器只接了输入、删除、加粗,加粗靠的是按钮直接派发命令,快捷键怎么处理留给了官方扩展。第一个要读的是 prosemirror-keymap,参考代码是它的 d60e244。整个包只有一个源文件 src/keymap.ts,一百行出头,两个导出函数,是全部官方扩展里最小的一个。拿它开篇有两个原因:它验证了第 22、23 篇讲的插件系统最少需要多少东西;快捷键的匹配逻辑本身也有几处不读源码猜不到的细节。基础扩展这一阶段的读法是自底向上:先 keymap 这个入口,再 commands 看命令本身,然后 history、inputrules,最后落到 schema-basic 和 schema-list 这两个具体文档结构。快捷键绑定的值就是命令,这篇先把「按键怎么找到命令」弄清楚,下一篇的命令签名惯例才有落点。
系列目录
一个插件的最小形态
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" 时换成单字符空格,因为事件一侧空格键的名字就是 " "。前面的段全部按修饰键处理,大小写不敏感,别名给得很宽:cmd、meta、m 都算 Meta,a、alt 算 Alt,c、ctrl、control 算 Ctrl,s、shift 算 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、方向键这类功能键只有第一次查找的机会。
第二次查找针对 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 默认行为的完整实现,那些命令最终会挂进今天这张表里。

