ProseMirror Input Rules 模块

prosemirror

ProseMirror Input Rules 模块

prosemirror-inputrules 模块用于将一些输入规则 input rules 与编辑器绑定,当用户输入特定的内容时编辑器会作出响应,例如对文字内容进行转换

提示

该模块可以用于实现富文本编辑器对 Markdown 语法的兼容性支持,例如在段首输入 ## 并按下空格键就将该段落节点转变为标题节点

InputRule 类

InputRule 类用正则表达式 regular expression 来描述一段文本,当用户输入的内容与之相匹配,就会触发相应的处理函数执行

通过方法 new Input(match, handler, options?) 进行实例化,得到一个 inputrule 对象,以下称作「输入规则」

相关参数的具体说明如下:

  • 第一个参数 match 是一个 RegExp 正则表达式对象,用于匹配文本
    提示

    由于输入规则 input rule 的目的是响应用户输入,所以正则表达式会应用于正处于光标前面的文本内容,相应地正则表达式一般以锚点 $ 结束,以约束匹配的内容是位于文本段落的最后(即由用户输入的)

  • 第二个参数 handler 可以是一个字符串,用于替换匹配的内容(如果正则表达式设置有捕获组 matched group,则替换第一个捕获组的内容);也可以是一个函数 fn 对匹配的内容进行自由度更高的处理
    ts
    // 该函数接受四个参数
    fn(
      state: EditorState, // 编辑器状态对象
      match: RegExpMatchArray, // 正则表达式方法 RegExp.exec() 的结果数组
      // 匹配的开始位置(一般是光标所在的文本节点的开头,遵循 index schema 规则)
      start: number,
      // 匹配的结束位置(光标所在的位置相对于文本节点的开头的偏移量,遵循 index schema 规则)
      end: number
    ): Transaction | null
    // 该处理函数返回一个事务对象 transaction 以表示对编辑器进行转换操作,或返回 null 表示不进行操作
    
  • 第三个(可选)参数 options 是一个对象 {undoable?: boolean, inCode?: boolean | "only"} 用于定制输入规则的一些行为。
    • (可选)属性 undoable 是一个布尔值,表示该输入规则触发的转换是否可以(通过分发 command 指令 undoInputRule)撤销
    • (可选)属性 inCode 是一个布尔值或字符串 "only",表示该输入规则是否应用于带有 code 标识的节点中(默认值为 false,即输入规则不会应用于代码类型的节点中)。可以设置为 true 以扩大该输入规则的适用范围到代码类型的节点中;也可以设置为 "only" 表示该输入规则只针对代码类型的节点

输入规则 inputrule 对象实例具有属性 inCode 以表示该输入规则的适用性(是否可应用于带有 code 标识的节点中,或只针对代码类型的节点)

创建插件

使用该模块所提供的方法 inputRules({rules}) 根据给定的配置参数(一个对象,具有属性 rules 其值为一个数组 InputRule[] 即每个元素都是一个输入规则实例)创建一个插件,将插件注册到编辑器上就可以应用特定的一系列输入规则

该方法最后返回的插件带有自定义的 state {transform: Transaction, from: number, to: number, text: string} | null 其中 transform 是输入规则所触发的转换操作,fromto 记录规则所匹配的文本范围,text 表示进行匹配的原始内容

提示

该插件只是监听了输入单个字符(「合成输入」则可能是一次性输入多个字符)相关的操作,对于回车键的操作并不会响应

如果需要输入规则能够匹配 /n 换行符,则需要在插件的配置对象中设置属性 props,在其中监听所按下的 enter 键,在回调函数中执行正则表达式对输入内容的匹配

可以参考官方的插件源码创建一个自己的插件以添加所需功能,也可以参考这个帖子对官方插件实例对象进行扩展

例如以下代码是基于官方的插件进行扩展,为插件添加自定义 pluginKey

ts
import { Plugin } from 'prosemirror-state';
import type { EditorState, PluginKey, TextSelection, Transaction } from 'prosemirror-state';
import type { EditorView } from 'prosemirror-view';

// refer to https://github.com/ProseMirror/prosemirror-inputrules/blob/master/src/inputrules.ts

type PluginState = { transform: Transaction; from: number; to: number; text: string } | null;

declare class InputRule {
  match: RegExp;
  handler: (state: EditorState, match: RegExpMatchArray, start: number, end: number) => Transaction | null;
  undoable: boolean;
  inCode: boolean | 'only';
}

export function inputRulePlugin({ rules, key }: { rules: readonly InputRule[]; key: PluginKey }) {
  const plugin: Plugin<PluginState> = new Plugin<PluginState>({
    // 该插件的 key
    key,
    // 该插件自定义的状态
    // 其值可能是 null 或是 {transform: tr, from, to, text}
    // 用于记录最近的一次由于输入规则成功匹配而触发的 transform 操作及相关信息,便于实现 undoInputRule 指令(撤销由于满足输入规则导致的文档更新)
    state: {
      // 初始状态是 null
      init() { return null; },
      // 更新状态(将给定的事务 tr 应用到插件的状态,以生成一个新的状态)
      // prev 是插件的当前状态值(即旧的状态值,还没有根据 tr 进行更新)
      apply(tr, prev) {
        // 获取存储在当前事务 tr 中的元信息,键名为 plugin
        const stored = tr.getMeta(plugin);
        // 如果存在相应的元信息,将其返回作为该插件的新状态
        // 该值的类型是 {transform: tr, from, to, text} 它是在用户输入文本时,如果与(通过该插件所有添加的)任何一个输入规则相匹配,就会执行预设的操作,并分发事务 tr 来将更改应用到编辑器,而在该事务 tr 中就添加了上述元信息
        // 具体逻辑可以查看后面的函数 run
        if (stored) return stored;
        // 如果当前事务 tr 中没有找到相应的元信息,而且该事务导致文档选区或内容的改变,则返回 null(以清空存储在插件的状态中的,最近一次由于输入规则所导致的文档变更信息)
        return tr.selectionSet || tr.docChanged ? null : prev;
      },
    },
    // 监听相关输入操作,以对输入的内容进行模式匹配,具体操作封装在 run 函数中
    props: {
      handleTextInput(view, from, to, text) {
        return run(view, from, to, text, rules, plugin);
      },
      handleDOMEvents: {
        compositionend: (view) => {
          setTimeout(() => {
            const { $cursor } = view.state.selection as TextSelection;
            if ($cursor) run(view, $cursor.pos, $cursor.pos, '', rules, plugin);
          });
        },
      },
    },

    // 为该插件所自定义的属性,以表示该插件是为编辑器添加输入规则的
    isInputRules: true,
  });

  return plugin;
};

const MAX_MATCH = 500; // 设置所需匹配的最大字符数量

// 当用户输入内容时会运行以下函数,对输入的文字(及其前方的部分内容)进行正则表达式的匹配
// 在用户每次敲击键盘输入时,handleTextInput 都会触发 run 运行,所传入的参数会根据输入的情况而不同:
// * 若采用的是「合成输入」法,则输入过程中,参数 from 和 to 是相同的,都是输入字符后光标所在的位置,参数 text 是键盘当前所按下的单个字符;而输入结束时,参数 from 和 to 是这一次「合成输入」所累计键入的字符片段的开头和结尾的位置,参数 text 则是最终所「合成」的文字片段(所以 from 和 to 并不一定是最终显示在页面上的内容的开始和结束位置)
// * 若采用用普通的输入法,则参数 from 和 to 总是相同的,都是输入字符后光标所在的位置,参数 text 是键盘当前所按下的单个字符
// 而 compositionend 触发 run 运行只会发生在「合成输入」结束时,但是参数 from 和 to 是相同的,都是「合成输入」最后键入的字符的右侧(该位置并不一定是最终显示在页面上的内容的右侧位置)
function run(view: EditorView, from: number, to: number, text: string, rules: readonly InputRule[], plugin: Plugin) {
  if (view.composing) return false; // 如果当前处于「合成输入」,则不进行处理

  const state = view.state;
  const $from = state.doc.resolve(from);

  // 获取所需匹配的字符串,包括当前输入的内容 text,以及从它的前方所截取的部分内容
  // 从输入开始位置的前面截取内容:
  // * 起始位置:$from.parentOffset(输入开始时相对于父节点内容开头的位置)向前 MAX_MATCH 个字符的位置,但是最小值是 0(即该父节点内容的开始位置)
  // * 结束位置:输入开始时的位置
  // 如果截取内容中包含非文本的叶子节点(例如图片),则使用 '\ufffc'(对象替代字符)将其转译为字符
  const textBefore = $from.parent.textBetween(Math.max(0, $from.parentOffset - MAX_MATCH), $from.parentOffset, null, '\ufffc') + text;

  // 遍历该插件所添加的输入规则 rules,对光标前方的 textBefore 字符串进行匹配
  for (let i = 0; i < rules.length; i++) {
    const rule = rules[i];

    // 需要考虑所需匹配的字符串是否包含 code 代码,以及当前所遍历的输入规则 rule 是否适用于对代码进行匹配
    if ($from.parent.type.spec.code) {
      if (!rule.inCode) continue;
    }
    else if (rule.inCode === 'only') {
      continue;
    }

    // 使用当前所遍历的输入规则 rule 的正则表达式 match 对字符串进行匹配
    const match = rule.match.exec(textBefore);

    // 如果匹配成功,则调用当前所遍历的输入规则 rule 所预设的处理器 handler
    // 如果返回一个事务 tr 则后续可以通过编辑器视图进行分发,来触发编辑器更新
    const tr = match && rule.handler(state, match, from - (match[0].length - text.length), to);
    if (!tr) continue;

    // 如果该输入规则对文档的处理是可撤销 undoable
    // 则为即将分发的事务 tr 添加元信息(将该操作存储到该插件的自定义状态中)
    if (rule.undoable) tr.setMeta(plugin, { transform: tr, from, to, text });

    // 分发事务
    view.dispatch(tr);
    return true;
  }
  return false;
}

撤销操作

该模块导出了一个 command 指令 undoInputRule 用于撤销由输入规则所触发的转换操作(如果该操作是编辑器的最后一次执行的转换操作)

预设输入规则

该模块导出了一些输入规则(一般针对英文输入环境),可以直接使用官方内置的这些输入规则,也可以参考学习其源码以创建适合自己场景的输入规则

  • emDash 变量:一个输入规则实例,将两个短斜杠 double dash 转换为一个长破折号 emdash
  • ellipsis 变量:一个输入规则实例,将三个点 three dots 转换为一个省略号 ellipsis
  • openDoubleQuote 变量:一个输入规则实例,将输入的一系列符号(例如 " 普通双引号)「智能」地转换为 左弯(开放)双引号 opening double quote
    openDoubleQuote
    openDoubleQuote
  • closeDoubleQuote 变量:一个输入规则的实例,将输入的 "(普通双引号)符号「智能」地转换为右弯(关闭)双引号 closing double quote
  • openSingleQuote 变量:一个输入规则的实例,将输入的一系列符号(例如 ' 普通单引号)「智能」地转换为左弯(开放)单引号 opening single quote
    openSingleQuote
    openSingleQuote
  • closeSingleQuote 变量:一个输入规则的实例,将输入的 '(普通单引号)符号「智能」地转换为右弯(关闭)单引号 closing single quote
  • smartQuotes 变量:一个数组(每个元素都是一个输入规则的实例),包含是上述与引号相关的 4 种输入规则
    ts
    // 源码
    export const smartQuotes: readonly InputRule[] = [openDoubleQuote, closeDoubleQuote, openSingleQuote, closeSingleQuote]
    

其他

该模块还提供了一些辅助函数,用于更快速地构建输入规则实例

  • wrappingInputRule(regexp, nodeType, getAttrs?, joinPredicate?) 方法:根据传入的参数创建一个输入规则实例,(符合匹配条件时)它将光标所在的 textblock 文本块包裹在给定类型的节点中(作为该节点的内容)
    各参数的具体说明如下:
    • 第一个参数 regexp 是一个 RegExp 正则表达式对象,用于匹配文本。在该方法的内部这个参数会直接传递给 class InputRule 的构造函数,以用作实例化。如果希望将整个 textblock 文本节点都被包裹在给定的节点中,可以在正则表达式中使用锚点 ^ 作为开始,以表示要在文本的开头就开始匹配
    • 第二个参数 nodeType节点类型对象,以表示使用该类型的节点来包裹匹配的文本
    • 第三个(可选)参数 getAttrs 用于设置节点的 attributes 相关属性(默认值是 null 表示不为节点设置 attribute)。它可以是一个对象包含节点 attributes 相关属性;也可以是一个函数 fn(match: RegExpMatchArray)(其返回值是一个对象或 null)以动态的形式设置节点的 attributes 相关属性,它接受正则表达式(通过方法 RegExp.exec())的匹配结果数组作为入参
    • 第四个(可选)参数 joinPredicate 是一个函数 fn(match, node) 它返回一个布尔值,以表示如果新生成的节点与前一个相邻的节点类型相同时,是否将两者进行合并(默认值为 true,即进行合并)。通过该方法可以对同类型节点合并进行灵活度更高的定制操作。
      该函数接受的参数具体说明如下:
      • 第一个参数 match 是正则表达式(通过方法 RegExp.exec())的匹配结果数组
      • 第二个参数 node 是包裹节点的前一个(同类型)节点实例
  • textblockTypeInputRule(regexp, nodeType, getAttrs?) 方法:根据传入的参数创建一个输入规则实例,(符合匹配条件时)它将光标所在的 textblock 文本块的类型转换为给定类型的节点
    各参数的具体说明如下:
    • 第一个参数 regexp 是一个 RegExp 正则表达式对象,用于匹配文本。如果希望将整个 textblock 文本节点都被包裹在给定的节点中,可以在正则表达式中使用锚点 ^ 作为开始,以表示要在文本的开头就开始匹配
    • 第二个参数 nodeType节点类型对象,以表示将使用该类型的节点实例来替换匹配的文本
    • 第三个(可选)参数 getAttrs 用于设置节点的 attributes 相关属性(默认值是 null 表示不为节点设置 attribute)。它可以是一个对象包含节点 attributes 相关属性;也可以是一个函数 fn(match: RegExpMatchArray)(其返回值是一个对象或 null)以动态的形式设置节点的 attributes 相关属性,它接受正则表达式(通过方法 RegExp.exec())的匹配结果数组作为入参
注意

在 ProseMirror 预设 handler 处理函数的 input rules 中,都会将匹配到的特殊符号删掉,因为它们一般仅起到 hint 提示/触发执行转换的作用

  • 实例化输入规则 new Input(match, handler, options?) 如果处理函数 handler 使用字符串,则使用给出的字符串替换匹配的内容(也相当于删掉匹配的符号)
    而如果在实例化输入规则 new Input(match, handler, options?) 参数 handler 是函数,则可以自定义如何处理匹配的内容
  • 使用辅助函数 wrappingInputRule(regexp, nodeType, getAttrs?, joinPredicate?) 匹配的内容会被删掉,然后将所光标所在的 textblock 文本块包裹在指定类型的节点里
  • 使用辅助函数 textblockTypeInputRule(regexp, nodeType, getAttrs?) 匹配的内容会被删掉,它将光标所在的 textblock 文本块的类型转换为给定类型的节点

Copyright © 2025 Ben

Theme BlogiNote

Icons from Icônes