ProseMirror Basic Example
ProseMirror 在官方文档中演示了如何制作一个基础编辑器,完整的源码可以查看相关的 Github 仓库
该示例使用了两个 ProseMirror 的非核心模块:
Prosemirror Example Setup 模块
prosemirror-example-setup 模块提供了示例代码,如何将 ProseMirror 各个模块「粘合」起来以构建一个编辑器
注意
它并不是 ProseMirror 的核心模块,其代码设计并不是为了可高度重用,只是为了快速构建一个基础编辑器以作演示
可以作为学习例子,但如果要用于生产环境中需要谨慎
该模块导出了一些辅助函数,便于生成一系列菜单项、应用输入规则 input rules、绑定快捷键
buildMenuItems(schema)方法:基于给定的 schema(查看支持哪些类型的 nodes 和 marks)返回一个对象,以创建合适的菜单项buildKeymap(schema, mapKeys?)方法:基于给定的 schema(查看支持哪些类型的 nodes 和 marks),从预设的快捷键挑选出合适的绑定到编辑器上。
第二个(可选)参数mapKeys是一个对象{[key: string]: false | string}用于调整快捷键。该模块预设了一些快捷键,通过该属性可以进行调整。属性的键名是所需调整的快捷键名称;键值如果是false表示不需要该快捷键,也可以是字符串表示将原有的快捷键更改为绑定其他键buildInputRules(schema)方法:返回一个插件实例,为编辑器应用一系列的输入规则,以创建基础的 blockquote 引文节点、lists 列表节点、code block 代码块节点、heading 标题节点
以上的辅助函数都整合到下面的 exampleSetup 方法中,所以使用该模块时可以只调用该方法
exampleSetup(potions)方法:基于配置对象options创建一系列插件(以数组的形式,每个元素都是一个 Plugin 实例)
参数options是一个对象,可以包含以下属性schema属性:一个 Schema 数据约束对象mapKeys(可选)属性:一个对象{[key: string]: false | string}用于调整快捷键
该模块预设了一些快捷键,通过该属性可以进行调整。
属性的键名是所需调整的快捷键名称;键值如果是false表示不需要该快捷键,也可以是字符串表示将原有的快捷键更改为绑定其他键menuBar(可选)属性:一个布尔值,以设置是否为编辑器添加菜单栏history(可选)属性:一个布尔值,以设置是否为编辑器添加 history 插件(支持撤销、重做功能)floatingMenu(可选)属性:一个布尔值,以设置是否为编辑器应用浮动菜单menuContent(可选)属性:一个数组,用于调整菜单项的内容
插件分类
按照功能分类所创建的插件类型如下
- 应用一系列的输入规则,让富文本编辑器支持部分 Markdown 语法,即输入特定的字符以创建相应的节点(例如
>加上空格键,创建一个 blockquote 引文节点) - 为编辑器绑定快捷键,以创建或操作节点
- 将 prosemirror-commands 模块所提供的常见/通用快捷键绑定到编辑器上
- 添加 undo 撤销功能
- 添加 drop cursor 指针
- 添加 gap cursor 指针
- 为菜单栏添加菜单项
- 添加 CSS 类名,以应用模块所提供的
style/style.css样式文件
以下是exampleSetup辅助函数的源码tsexport function exampleSetup(options: { /// The schema to generate key bindings and menu items for. schema: Schema /// Can be used to [adjust](#example-setup.buildKeymap) the key bindings created. mapKeys?: {[key: string]: string | false} /// Set to false to disable the menu bar. menuBar?: boolean /// Set to false to disable the history plugin. history?: boolean /// Set to false to make the menu bar non-floating. floatingMenu?: boolean /// Can be used to override the menu content. menuContent?: MenuElement[][] }) { // 创建一系列插件 let plugins = [ // 使用辅助函数 buildInputRules 创建一个插件,将输入规则应用到编辑器(让富文本编辑器支持部分 Markdown 语法) buildInputRules(options.schema), // 使用方法 keymap() 创建一个插件,为编辑器绑定快捷键,这些快捷键主要关于创建或操作节点 keymap(buildKeymap(options.schema, options.mapKeys)), // 使用方法 keymap() 创建一个插件,为编辑器绑定快捷键,这些快捷键主要关于通用的操作(由 prosemirror-commands 模块所提供) keymap(baseKeymap), // 使用方法 dropCursor() 创建一个插件,为编辑器添加 drop cursor 指针 dropCursor(), // 使用方法 gapCursor() 创建一个插件,为编辑器添加 gap cursor 指针 gapCursor() ] // 使用方法 menuBar() 创建一个插件,在编辑器的顶部添加菜单栏 if (options.menuBar !== false) plugins.push(menuBar({floating: options.floatingMenu !== false, content: options.menuContent || buildMenuItems(options.schema).fullMenu})) // 使用方法 history() 创建一个插件,为编辑器实现历史记录功能 if (options.history !== false) plugins.push(history()) // 最后创建一个插件为(编辑器所对应的 contenteditable)DOM 元素添加 class 类名 `"ProseMirror-example-setup-style"` // 返回一个数组,包含一系列 plugin 插件 return plugins.concat(new Plugin({ props: { attributes: {class: "ProseMirror-example-setup-style"} } })) }
下文对这些辅助函数进行解读
添加输入规则
构建插件添加输入规则,让富文本编辑器支持部分 Markdown 语法,输入规则的核心是正则表达式(用于匹配输入的内容,以触发相应的处理函数)
源文件
解读代码源自 prosemirror-example-setup 模块的 inputrules.ts 文件
其中主要使用以下两个函数,以快速构建输入规则实例
wrappingInputRule(regexp, nodeType, getAttrs?, joinPredicate?)辅助函数:它会将光标所在的 textblock 文本块包裹在给定类型nodeType的节点中textblockTypeInputRule(regexp, nodeType, getAttrs?)辅助函数:它会将光标所在的 textblock 文本块转换为给定类型nodeType的节点
匹配的符号会被删掉
在 ProseMirror 预设 handler 处理函数的 input rules 中(例如使用辅助函数 wrappingInputRule 或 textblockTypeInputRule 所构建的输入规则实例),都会将匹配到的特殊符号删掉,因为它们一般仅起到 hint 提示/触发执行转换的作用
针对一些节点所设置的输入规则:
提示
由于输入规则 input rule 的目的是响应用户输入,所以正则表达式会应用于正处于光标前面的文本内容,相应地正则表达式一般以锚点 $ 结束,以约束匹配的内容是位于文本段落的最后(即由用户输入的)
- blockquote 引文节点

blockquote input rule tsexport function blockQuoteRule(nodeType: NodeType) { // 使用给定类型的节点包裹光标所在的文本块 return wrappingInputRule( // 匹配规则:在文本块的(使用边界词 `^` 表示)开头位置是 `>` 大于号(它的前面可以有零个或多个空格,使用 `*` 量词表示) // 然后在后面要跟着有一个空格(并且使用边界词 `$` 表示该空格是在最后的)之后紧跟着就是光标 /^\s*>\s$/, // 容器节点是引文节点 nodeType ) } - order list 有序列表节点

order list tsexport function orderedListRule(nodeType: NodeType) { // 使用给定类型的节点包裹光标所在的文本块 return wrappingInputRule( // 匹配规则:在文本块的(使用边界词 `^` 表示)开头位置是数字(一个或多个,使用 `+` 量词表示),后面要跟着有一个点 `.` // 然后在后面要跟着有一个空格(并且使用边界词 `$` 表示该空格是在最后的)之后紧跟着就是光标 /^(\d+)\.\s$/, // 容器节点是有序列表节点 nodeType, // 设置列表节点的 attribute,返回一个对象 {order: number} 以设置有序列表的开始计数的数值 // 这里采用正则表达式匹配结果(一个数组)的第二个元素,即第一个捕获组(转换为数字)作为有序列表的开始数值 match => ({order: +match[1]}), // 第四个参数是一个函数,返回一个布尔值,以判断是否与前一个相邻节点合并 // 该函数的第一个参数是正则表达式匹配结果(一个数组),第二个参数是前一个相邻节点 // 当新生成的节点(容器节点)与前一个相邻的节点类型相同时,默认进行合并 // 这里对同类型节点合并进行灵活度更高的定制操作 // 前一个相邻节点除了是有序列表以外,还需要满足列表的序号可以「续上」 // node.attrs.order 是前一个相邻有序列表的开始数值,node.childCount 是该有序列表拥有的子节点(列表项)数量 // 则 node.childCount + node.attrs.order 就是新增列表项的序号,如果等于新生成的有序列表的开始数值,则两个有序列表可以合并 (match, node) => node.childCount + node.attrs.order == +match[1] ) } - bullet list 无序列表节点

bullet list tsexport function bulletListRule(nodeType: NodeType) { // 使用给定类型的节点包裹光标所在的文本块 return wrappingInputRule( // 匹配规则:在文本块的(使用边界词 `^` 表示)开头位置可以是 `-`(连字符)、`+`(加号)、`*`(星号)这三种符号之一(它的前面可以有零个或多个空格,使用 `*` 量词表示) // 然后在后面要跟着有一个空格,(并且使用边界词 `$` 表示该空格是在最后的)之后紧跟着就是光标 /^\s*([-+*])\s$/, // 容器节点是无序列表节点 nodeType ) } - code block 代码块节点

code block tsexport function codeBlockRule(nodeType: NodeType) { // 使用给定类型的节点替换光标所在的文本块 return textblockTypeInputRule( // 匹配规则:在文本块的(使用边界词 `^` 表示)开头位置是 ```(三个反引号),之后紧跟着就是光标(使用边界词 `$` 表示是在最后) /^```$/, // 转变的类型是代码块节点 nodeType ) } - heading 标题节点ts
// 参数 maxLevel 表示 # 井号的最多重复次数(一般是 6,即六级标题) export function headingRule(nodeType: NodeType, maxLevel: number) { // 使用给定类型的节点替换光标所在的文本块 return textblockTypeInputRule( // 使用构造函数 new RegExp() 可以构建(基于参数)动态的正则表达式 // 这里会使用参数 maxLevel(一个数字,表示标题的级别)设置所需匹配的模式种 `#` 井号所需重复的数量 // 匹配规则,在文本块的(使用边界词 `^` 表示)开头位置是一些 #(井号,重复的数量范围是 1 到 maxLevel,使用量词 {1, maxLevel} 表示) // 然后在后面要跟着有一个空格,(并且使用边界词 `$` 表示该空格是在最后的)之后紧跟着就是光标 // ⚠️ 使用 new RegExp() 创建正则表达式时,如果使用 \s(表示空格),需要对其添加额外的转义符 \ 具体说明参数 https://javascript.info/regexp-escaping#new-regexp new RegExp("^(#{1," + maxLevel + "})\\s$"), // 转变的类型是标题节点 nodeType, // 设置标题节点的 attribute,返回一个对象 {level: number} 以设置标题节点的层级 // 这里采用正则表达式匹配结果(一个数组)的第二个元素,即第一个捕获组,其内容(字符串,一堆 # 井号)的长度作为标题的级别 match => ({level: match[1].length}) ) } - 特殊符号的转换ts
// emDash 是将两个短斜杠转换为一个长破折号的输入规则实例 // ellipsis 是将三个点转换为一个省略号输入规则实例 // smartQuotes 变量由 prosemirror-inputrules 模块导出,它是一个数组,包含 4 种与引号相关的输入规则实例 // 具体参考 https://github.com/ProseMirror/prosemirror-inputrules/blob/master/src/rules.ts#L17 // 这里将 ellipsis 和 emDash 整合进 smartQuotes 数组中 let rules = smartQuotes.concat(ellipsis, emDash)
最后使用方法 inputRules({rules}) 创建一个 plugin 插件(将插件注册到编辑器上,就可以应用特定的一系列输入规则)。其中参数 {rules} 表示它是一个对象,具有属性 rules 其值为一个数组 InputRule[]
// 该辅助函数最后返回一个 plugin 插件
export function buildInputRules(schema: Schema) {
// 将所创建的输入规则实例「整合」在一个数组 rules 里面
let rules = smartQuotes.concat(ellipsis, emDash), type
// 这里需要判断 schema 里是否有提供相应的节点类型,才在 rules 数组里添加相应的输入规则
if (type = schema.nodes.blockquote) rules.push(blockQuoteRule(type))
if (type = schema.nodes.ordered_list) rules.push(orderedListRule(type))
if (type = schema.nodes.bullet_list) rules.push(bulletListRule(type))
if (type = schema.nodes.code_block) rules.push(codeBlockRule(type))
if (type = schema.nodes.heading) rules.push(headingRule(type, 6))
// 最后使用方法 inputRules({rules}) 创建一个 plugin 插件
return inputRules({rules})
}
绑定快捷键
源文件
解读代码源自 prosemirror-example-setup 模块的 keymap.ts 文件
构建一个对象 {[key: string]: Command} 以键值对的形式将按键和指令进行匹配
常见通用的快捷键
在 prosemirror-commands 模块也有提供了一个对象 baseKeymap,以键值对的形式将常见/通用的编辑器快捷键和指令进行匹配,具体可以参考源码
export function buildKeymap(schema: Schema, mapKeys?: {[key: string]: false | string}) {
// 初始化一个空的对象 keys,以键值对的形式将按键和指令进行匹配
let keys: {[key: string]: Command} = {}, type
// 函数 bind 将快捷键名称 key 和指令 cmd 相「绑定」
function bind(key: string, cmd: Command) {
// 会根据参数 mapKeys 判断是否需要对当前的快捷键进行调整
if (mapKeys) {
// 当 mapKeys 中包含该快捷键 key
let mapped = mapKeys[key]
// 如果该快捷键在 mapKeys 中映射值为 false,则表示取消快捷键,则直接返回,不执行后续的绑定操作
if (mapped === false) return
// 如果该快捷键在 mapKeys 中映射为其他快捷键,则调整快捷键名称
if (mapped) key = mapped
}
// 为对象 keys 添加该快捷键与指令的映射关系
keys[key] = cmd
}
// 💡 使用 Mod- 作为 MacOS 系统上 Cmd- 与其他系统上的 Ctrl- 的通用别名
// 将 Mod-z 快捷键绑定 undo 指令(撤销修改)
bind("Mod-z", undo)
// 将 Shift-Mod-z 快捷键绑定 redo 指令(重做修改)
bind("Shift-Mod-z", redo)
// 将 Backspace 快捷键绑定 undoInputRule 指令(以撤销由输入规则所触发的转换操作,而且该操作是编辑器的最后一次执行的转换操作)
bind("Backspace", undoInputRule)
// 如果用户的系统不是 Mac,则将 Mod-y 快捷键绑定 redo 指令(重做修改)
if (!mac) bind("Mod-y", redo)
// 将 Alt-ArrowUp 快捷键绑定 joinUp 指令(将当前 block 块节点与前面相邻的兄弟节点相融合)
bind("Alt-ArrowUp", joinUp)
// 将 Alt-ArrowDown 快捷键绑定 joinDown 指令(将当前 block 块节点与后面相邻的兄弟节点相融合)
bind("Alt-ArrowDown", joinDown)
// 将 Mod-BracketLeft 快捷键绑定 lift 指令(提升 lift 当前选中节点的层级,将它移出父节点)
// 💡 BracketLeft 是指键盘上的左方括号键 [
bind("Mod-BracketLeft", lift)
// 将 Escape 快捷键绑定 selectParentNode 指令
bind("Escape", selectParentNode)
if (type = schema.marks.strong) {
// 如果 schema 支持 strong 加粗样式标记
// 则将 Mod-b 和 Mod-B 快捷键绑定 toggleMark(schema.marks.strong) 指令(切换加粗样式)
bind("Mod-b", toggleMark(type))
bind("Mod-B", toggleMark(type))
}
if (type = schema.marks.em) {
// 如果 schema 支持 em 斜体样式标记
// 则将 Mod-i 和 Mod-I 快捷键绑定 toggleMark(schema.marks.em) 指令(切换斜体样式)
bind("Mod-i", toggleMark(type))
bind("Mod-I", toggleMark(type))
}
// 如果 schema 支持 code 代码样式标记
// 则将 Mod-` 快捷键绑定 toggleMark(schema.marks.code) 指令(切换代码样式)
if (type = schema.marks.code)
bind("Mod-`", toggleMark(type))
// 如果 schema 支持 bullet_list 无序列表节点
// 则将 Shift-Ctrl-8 快捷键绑定 wrapInList(schema.nodes.bullet_list) 指令(将用户选中的内容所在的块节点包裹进无序列表节点里)
if (type = schema.nodes.bullet_list)
bind("Shift-Ctrl-8", wrapInList(type))
// 如果 schema 支持 ordered_list 有序列表节点
// 则将 Shift-Ctrl-9 快捷键绑定 wrapInList(schema.nodes.ordered_list) 指令(将用户选中的内容所在的块节点包裹进有序列表节点里)
if (type = schema.nodes.ordered_list)
bind("Shift-Ctrl-9", wrapInList(type))
// 如果 schema 支持 blockquote 引文节点
// 则将 Ctrl-> 快捷键绑定 wrapIn(schema.nodes.blockquote) 指令(将用户选中的内容所在的块节点包裹进引文节点里)
// ⚠️ 在 Windows 系统中该快捷键是 Shift-Ctrl->
if (type = schema.nodes.blockquote)
bind("Ctrl->", wrapIn(type))
if (type = schema.nodes.hard_break) {
// 如果 schema 支持 hard_break 换行节点
let br = type;
// 使用方法 chainCommands 将给定的一系列指令封装为一个新的指令
// 依次执行 exitCode 指令(由于在代码类型的节点中,不能插入换行节点,需要先跳出该节点),然后将选区内容替换为新建的换行节点,最后将光标滚动到视图中
let cmd = chainCommands(exitCode, (state, dispatch) => {
if (dispatch) dispatch(state.tr.replaceSelectionWith(br.create()).scrollIntoView())
return true
})
// 则将 Mod-Enter 和 Shift-Enter(如果用户的系统是 Mac 还将 Ctrl-Enter)快捷键绑定前面所构建的 cmd 指令(插入 <br/> 进行硬换行)
bind("Mod-Enter", cmd)
bind("Shift-Enter", cmd)
if (mac) bind("Ctrl-Enter", cmd)
}
if (type = schema.nodes.list_item) {
// 如果 schema 支持 list_item 列表项节点
// 则将 Enter 快捷键绑定 splitListItem(schema.nodes.list_item) 指令(分割列表项的直接子节点的内容)
bind("Enter", splitListItem(type))
// 则将 Mod-[ 快捷键绑定 liftListItem(schema.nodes.list_item) 指令(将选中的列表项的内容往上提升一级,跳出当前列表)
bind("Mod-[", liftListItem(type))
// 则将 Mod-] 快捷键绑定 sinkListItem(schema.nodes.list_item) 指令(将选中的列表项内容往内缩进一级,到一个嵌套列表中去)
bind("Mod-]", sinkListItem(type))
}
// 如果 schema 支持 paragraph 段落节点
// 则将 Shift-Ctrl-0 快捷键绑定 setBlockType(schema.nodes.paragraph) 指令(将选区所在的 textblock 文本块转换为段落节点)
// ⚠️ 在 Windows 系统中似乎无效
if (type = schema.nodes.paragraph)
bind("Shift-Ctrl-0", setBlockType(type))
// 如果 schema 支持 code_block 代码块节点
// 则将 Shift-Ctrl-\\ 快捷键绑定 setBlockType(schema.nodes.code_block) 指令(将选区所在的 textblock 文本块转换为代码块节点)
// 💡 \\ 第一个 \ 符号是转义符 ❓ 第二个 \ 符号表示反斜杠
if (type = schema.nodes.code_block)
bind("Shift-Ctrl-\\", setBlockType(type))
// 如果 schema 支持 heading 标题节点
// 则依次将 Shift-Ctrl-i(其中 i 表示与标题级别相应的数字)快捷键绑定 setBlockType(type, {level: i}) 指令(将选区所在的 textblock 文本块转换为相应级别的标题节点)
if (type = schema.nodes.heading)
for (let i = 1; i <= 6; i++) bind("Shift-Ctrl-" + i, setBlockType(type, {level: i}))
// 如果 schema 支持 horizontal_rule 水平线节点
if (type = schema.nodes.horizontal_rule) {
let hr = type
// 则将 Mod-_ 快捷键绑定到所构造的指令(将选区内容替代为新建的水平线节点,然后将光标滚动到视图中)
bind("Mod-_", (state, dispatch) => {
if (dispatch) dispatch(state.tr.replaceSelectionWith(hr.create()).scrollIntoView())
return true
})
}
// 最后返回对象 keys,它以键值对的形式将按键和指令进行匹配
return keys
}
创建插件
方法 buildKeymap() 只是构建一个对象,不能直接使用。还需要使用方法 keymap(bindings) 构建一个 plugin 插件,将插件注册到编辑器上,就可以实现按键与指令的绑定
其中参数 bindings 是一个对象,以键值对的形式将按键和指令进行匹配。在 exampleSetup() 辅助函数里就是前面介绍的方法 buildKeymap() 构建出一个对象
菜单
源文件
解读代码源自 prosemirror-example-setup 模块的 menu.ts 文件
主要是基于 prosemirror-menu 模块来构建菜单栏
prosemirror-menu 模块用于构建编辑器的菜单栏,但它并不是核心模块,仅为了快速构建编辑器示例(以进行演示)
可以作为学习例子(如果要用于实际生产环境中则可能需要进行更多深度定制)
- 基于 editorView 编辑器视图(其中的 editorState 是编辑器状态)实现菜单项的动态渲染
菜单项的多种状态
菜单项除了可以响应用户点击(以执行相应的操作),还可以根据不同的场景设置不同的状态(对应于不同的 CSS 样式):
- 显示/隐藏:例如当光标在 code block 代码块里,则菜单栏与样式标记相关的菜单项应该隐藏掉(也可以设置为 disable 失活状态),不允许用户对代码块的文本设置加粗、斜体等样式
- 激活:例如光标所在的文本已经被加粗,则加粗按钮应该高亮显示,以表示该样式标记已激活
- 失活:例如当选区为空时,添加链接的按钮应该以浅色显示,表示该按钮当前不可用
具体实现可参考 class
MenuItem类实例化
MenuItem类时,该类的实例化对象的方法render负责在页面渲染出菜单项,以及更新菜单项的状态render接收 editorView 作为参数,这样菜单项就可以获取编辑器的状态,最后返回一个对象{dom, update},其中属性dom(菜单项所对应的 DOM 元素),属性update方法(更新菜单项的状态)其中方法
update根据编辑器当前 editorState 编辑器状态动态更新菜单项:- 其中方法
spec.select(state)返回一个布尔值,以控制该菜单项的显示或隐藏(通过设置 CSS 的display属性) - 其中方法
spec.enable(state)返回一个布尔值,以表示该菜单项是否失活(通过为 DOM 设置 class 类名disabled) - 其中方法
spec.active(state)返回一个布尔值,以表示该菜单项是否激活(需要同时满足enable为真,通过为 DOM 设置 class 类名active,)
- 要实现点击菜单项执行特定操作的功能,可以监听 DOM 鼠标事件(
click或mousedown),在事件处理函数中执行相关操作
具体可参考 classMenuItem类 - 如果要判断一个 command 指令是否可用,可以使用
command(state)只传递第一个参数,根据返回的布尔值来判断当前状态下该指令是否可用
指令 command 是一个函数fn(state, dispatch?, view?)它可以接受三个参数,当没有设置第二个参数dispatch时,表示该命令只是一个 dry run 「试运行」,即进行模拟或预演操作(以判断它是否应该被执行),但不对文档进行实际修改 - 判断用户的系统是否为 iOSts
function isIOS() { if (typeof navigator == "undefined") return false let agent = navigator.userAgent return !/Edge\/\d/.test(agent) && /AppleWebKit/.test(agent) && /Mobile\/\w+/.test(agent) } - 创建插件将菜单栏插入到编辑器里