inputrules:「# 空格」变成标题是怎么实现的

6 分钟阅读
·

上一篇看了 history 的 undo/redo 栈,这篇看 inputrules。Markdown 风格的编辑体验里最常见的一组功能,空行敲「# 空格」段落变标题、敲「> 空格」变引用块、连打两个减号变破折号、直引号自动变弯引号,全都出自这个包。参考代码是 prosemirror-inputrules 的 e3e5545。包很小:src/inputrules.ts 是核心,一百七十行上下;src/rulebuilders.ts 提供两个规则构造器;src/rules.ts 是几条现成的标点规则。整篇读完估计比 view 篇的任何一节都快,但它把前面讲过的插件系统、handleTextInput、findWrapping、canJoin 全部串了起来,值得过一遍。

系列目录

日期 标题
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,最小的插件
05-16 commands:命令的签名约定与组合器
05-23 history:undo/redo 栈与 rebasing
06-06 inputrules:「# 空格」变成标题是怎么实现的(本篇)

InputRule 的构成

InputRule 类(src/inputrules.ts)有两个关键字段。match 是一个正则,约定以 $ 结尾,因为匹配目标是光标前面那一小段文本,规则关心的是刚敲进去的字符让这段文本的结尾变成了什么。handler 决定命中之后做什么,接受两种形态:

  • 字符串:把匹配文本替换成这个字符串,内部由 stringHandler 转成函数。
  • 函数:签名是 (state, match, start, end) => Transaction | null。拿到正则的 match 数组和匹配区间 [start, end),返回一个描述修改的 transaction;返回 null 表示这条规则放弃,框架继续试下一条。

handler 只构造 transaction,不负责 dispatch。这个分工让 handler 保持在命令的 dry-run 风格里:它声明修改,执行权在框架。

stringHandler 值得看一眼。没有捕获组时它就是一句 state.tr.insertText(string, start, end),整条匹配文本被替换。有捕获组时它假设组外的前缀只是语境,用 match[0].lastIndexOf(match[1]) 找到组的起点,把 start 推进到组起点,再把组后面的字符拼在替换字符串之后,保证只换掉组内那一段。弯引号规则靠的就是这个:开引号规则的正则是 /(?:^|[\s\{\[\(\<'"‘“])(")$/,前缀部分只用来判断语境(行首、空白或开括号之后才算开引号),真正被替换的只有捕获组里那一个直引号。stringHandler 里还有一段 cutOff 处理,应付组起点落在替换区间之外的边界情况:把 start 钳回 end,把中间的字符补进 insert,避免 start 大于 end 的非法替换。

构造函数另有三个选项。undoable 默认 true,关掉后 undoInputRule 对这条规则无效。inCode 默认 false,规则在 code 节点内不触发;设 true 允许,设 "only" 则只在 code 节点内触发。inCodeMark 默认 true,设 false 后规则在 code mark 覆盖的文本内不触发。

挂载点 handleTextInput

inputRules({rules}) 返回一个插件,挂在 view 的 handleTextInput prop 上(参考代码是 prosemirror-view 的 ca4c78e)。handleTextInput 走 someProp 机制,多个插件都实现它时按插件顺序逐个问,先返回 true 的拦下这次输入。这个 prop 在 view 里有两个调用点:

  • src/input.ts 的 keypress 处理器:进处理器先过一串前置过滤,组合输入期间、没有 charCode、按住 Ctrl(Mac 上是 Cmd)的按键都直接返回,handleKeyPress 也先于 handleTextInput 被询问。还有一个容易看漏的限制:只有选区不是 TextSelection、或者选区两端不在同一个父节点时,才会真正问到 handleTextInput,from/to 是当前选区,text 是即将输入的字符。有插件返回 true,默认的 insertText 就不执行。普通光标下的按键不进这个分支,keypress 被直接放行,浏览器自己把字符写进 DOM。
  • src/domchange.ts 的读回路径:普通光标下的输入走的就是这条路。浏览器改了 DOM,readDOMChange 比对新旧内容,发现只是同一文本节点内的插入,先过 handleTextInput。此时 text 已经落在 DOM 里,但 state 还没更新,handler 拿到的 state.doc 同样不含这次的输入。

两条路径的参数语义一致,text 都是这次的输入文本,规则的匹配都把它算进去,所以 inputrules 不需要分辨自己跑在哪条路径上。view 调用时还会传第四个参数 deflt,一个产出默认插入 transaction 的工厂,inputrules 没有用它:规则的 tr 由插件自己 dispatch,然后返回 true 把默认行为关掉。

插件的 state 字段存一份 undo 信息,形状是 {transform, from, to, text} | null。apply 的逻辑:transaction 带了以插件自己为 key 的 meta 就存下;selectionSet 或 docChanged 就清空;其余情况原样保留。也就是说 undo 信息只在「规则刚触发、之后没有任何其他修改」的窗口里有效,光标动一下就失效。插件规格上还打了一个 isInputRules: true 的标记,undoInputRule 靠它认出这类插件。

另有一个 IME 补丁。run 开头检查 view.composing,组合输入期间直接返回 false,不做匹配。插件在 handleDOMEvents 里挂了 compositionend:组合结束后 setTimeout 一拍,如果选区是光标,就以空 text 在光标位置跑一次 run。为什么要推迟一拍:插件的 handleDOMEvents 在 view 里先于内置处理器执行(第 29 篇拆过 dispatchEvent 的顺序),插件收到 compositionend 时组合状态还没解除、上屏文本也还没读回 state;setTimeout 把匹配推迟到内置的 compositionend 处理和 DOM 读回完成之后,run 才能在完整文本上跑。这样中文输入法上屏的字符也能触发规则,先敲「#」再用空格上屏,标题规则照常生效。

run 的匹配过程

核心函数 run(src/inputrules.ts)的流程:

run 的匹配路径

先拼上下文:$from.parent.textBetween(...) 取光标所在文本块内、光标前最多 MAX_MATCH(500)个字符,末尾加上刚输入的 text,得到 textBefore。第三个参数块分隔符传了 null,第四个参数传了 (U+FFFC,对象替换字符),行内图片这类不进文本的叶子节点在窗口里就表现为这么一个字符。500 这个上限把每次按键的匹配成本压在常数级,段落再长也只扫一个固定窗口。一个推论:以 ^ 开头的规则锚定的是窗口起点,窗口落在文本块内部,所以行首规则对文本块前 500 字符之外的位置自然失效,这正是期望行为。

然后按数组顺序逐条试规则,每条要过几个检查:

  1. inCodeMark 为 false,且光标处的 marks 里有 spec.code 为 true 的 mark,跳过。
  2. 父节点是 code 节点时,inCode 为 false 跳过;父节点不是 code 节点时,inCode 为 "only" 跳过。两个条件合起来覆盖四种组合。
  3. rule.match.exec(textBefore),没命中跳过。match[0].length < text.length 也跳过:匹配长度必须覆盖刚输入的文本,否则说明命中的是旧文本里的残余,这条规则在上一次输入时就该触发,现在触发属于误判。text 多数时候是一个字符,中文输入法一次上屏或者 domchange 路径读回时可能是多个,这个比较对两种路径都成立。
  4. 由 match 长度反推匹配起点:startPos = from - (match[0].length - text.length)
  5. inCodeMark 为 false 时再用 nodesBetween 扫一遍 [startPos, $from.pos],区间内有带 code mark 的内联节点就跳过。光标位置不带 code mark 不等于匹配区间里没有,所以除了第 1 个检查还要看整段区间。

全过之后调 handler。返回 null 继续下一条;返回 tr 时,若规则 undoable,先 tr.setMeta(plugin, {transform: tr, from, to, text}) 把 undo 信息塞进 meta,再 view.dispatch(tr),run 返回 true。

规则按数组顺序匹配,先命中先执行,数组顺序就是优先级。后面会看到 example-setup 把 smartQuotes 排在规则数组最前面。

两个规则构造器

src/rulebuilders.ts 导出两个工厂,把常见的块级操作包成 handler。

textblockTypeInputRule(regexp, nodeType, getAttrs) 把当前文本块改成另一种类型。handler 先 resolve start,用 $start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), nodeType) 问父节点在这个位置能不能容纳目标类型,不能就返回 null;能就 delete(start, end) 删掉触发文本,再 setBlockType(start, start, nodeType, attrs)。depth 取 -1 指当前文本块的直接父级。

「# 空格」变标题就是它的应用。example-setup(参考代码是 prosemirror-example-setup 的 b6fcf7a)里的 headingRule(src/inputrules.ts):

textblockTypeInputRule(new RegExp("^(#{1," + maxLevel + "})\\s$"),
                       nodeType, match => ({level: match[1].length}))

正则从行首抓 1 到 6 个 # 加一个空格,attrs 由捕获组的长度算出来,敲「## 空格」得到二级标题。getAttrs 可以传死对象,也可以传函数,函数形态正好消费正则捕获的信息。同一个文件里 codeBlockRule 用 /^```$/ 把三个反引号变成代码块。

wrappingInputRule(regexp, nodeType, getAttrs, joinPredicate) 把当前文本块包进一层节点。handler 先 delete 掉触发文本,然后 $start.blockRange() 拿块范围,findWrapping 算出包进 nodeType 需要的节点序列(transform 篇拆过这个函数),算不出来返回 null;算出来就 tr.wrap(range, wrapping)。之后还有一步自动拼接:resolve start - 1 看前邻节点,类型相同且 canJoin 通过就 tr.join(start - 1) 并进去。连续两行都敲「> 」会合成一个引用块而不是两个并排引用块,靠的就是这一步。joinPredicate 可以否决拼接:orderedListRule 传了 (match, node) => node.childCount + node.attrs.order == +match[1],含义是前一个有序列表的末项编号加一正好等于这次敲的编号才合并。前一个列表是「1. 2.」,敲「3. 」拼进去,敲「1. 」起一个新列表。orderedListRule 还用 getAttrs 把敲入的数字写进列表的 order 属性(match => ({order: +match[1]})),从「3. 」起手的列表起始编号就是 3。

内置的标点规则

src/rules.ts 给了一组现成的规则:emDash 把两个减号变成破折号(/--$/ 换成 ),ellipsis 把三个点变成省略号(/\.\.\.$/ 换成 ),smartQuotes 是开闭双引号、开闭单引号四条。全部用字符串 handler,全部设了 inCodeMark: false:代码 span 里敲两个减号不该变破折号。开闭引号的区分完全靠正则语境,开引号要求前面是行首、空白或开括号类字符,其余情况一律按闭引号处理。这组规则同时也是 stringHandler 捕获组语义的实际用例。

smartQuotes 数组内部的顺序也有讲究:[openDoubleQuote, closeDoubleQuote, openSingleQuote, closeSingleQuote],开引号规则排在闭引号前面。空白之后敲一个直引号时,开引号规则和闭引号规则(/"$/,任何结尾的直引号都命中)都能匹配,数组顺序保证先按开引号处理;前面是普通字符时开引号规则的正则挂不上,自然落到闭引号。

这组规则和前面两个构造器在 example-setup 的 buildInputRules(src/inputrules.ts)里组装成一个插件:smartQuotes.concat(ellipsis, emDash) 打底,然后按 schema 里有没有对应节点依次追加 blockQuoteRule、orderedListRule、bulletListRule、codeBlockRule、headingRule。标点规则排在所有块级规则前面,块级规则的正则都以 ^ 开头、要求空格结尾,和标点规则没有交集,顺序更多是一种组织习惯。blockQuoteRule 的正则是 /^\s*>\s$/> 前面允许任意空白,缩进后敲「> 」同样变引用块。

undo 与输入规则的交互

undoInputRule 是一个 Command,作用是把最近一次输入规则的效果退回,并把用户原本敲的文本还原。实现分三步:遍历 state.plugins,找 spec 上有 isInputRules 标记且插件状态非空的那个;把存下的 transform 的 steps 逆序逐个 step.invert(docs[j]) 应用到新 transaction 上,这依赖 transform 篇讲过的 Step 可逆接口,每步的 invert 拿当时应用前的文档 docs[j] 算反操作;最后在反转后的文档里 resolve from 位置取出该处的 marks,把触发时输入的 text 带这些 marks 插回 [from, to],text 为空就 delete 这段区间。marks 从反转后的文档取,规则如果在加粗文本里触发过,还原出来的字符仍然是加粗的。

它和 history 的 undo 是两层东西。history 的 undo 撤销整个 transaction 组,用户敲的字符和规则引起的修改一起消失,文档退回「还没敲」的状态。undoInputRule 只反转规则自己产生的那几步,再把敲的字符放回去,效果是规则没有触发过。「# 空格」变成标题之后按一下 Backspace,看到「# 」原文回来,体验上就是规则被当场撤回。

example-setup 在 src/keymap.ts 的 buildKeymap 里把 Backspace 绑成 undoInputRule,而它所在的 keymap 插件排在 keymap(baseKeymap) 前面(src/index.ts 的插件数组顺序),所以 Backspace 先问 undoInputRule;没有可撤的规则时它返回 false,按键落到 baseKeymap 的删除链上。undo 信息的有效期由插件 state 的 apply 控制:规则触发后任何一次 docChanged 或 selectionSet 都会把记录清掉,所以这个 Backspace 只对刚触发的这条规则有效,隔一次操作就恢复成普通删除。undoable 选项在这里收口:undoable 为 false 的规则触发时不写 meta,undoInputRule 找不到记录,Backspace 直接是普通删除。

小结

inputrules 的机制可以压成三句话。规则是「$ 结尾的正则加上一个返回 transaction 的 handler」;插件挂在 handleTextInput 上,每次文本输入对光标前 500 字符的窗口做一次顺序匹配,命中就 dispatch 规则给出的 transaction;undo 靠触发时塞进 meta 的记录,undoInputRule 反转它并还原输入文本。块级规则的难点不在匹配,而在 handler 里的结构操作,findWrapping、canJoin、canReplaceWith 这些函数在 transform 篇都拆过,这里只是消费。下一篇看 schema-basic,官方那套基础文档结构怎么定义。


1005 字 · 45 段落
xi ming

Written by xi ming You should follow him on Github