ProseMirror 编程式操作文档

prosemirror

ProseMirror 编程式操作文档

一般用户需要与编辑器的在页面的视图直接交互(光标聚焦到文档上,然后通过敲击键盘)才可以修改文档内容

而将 prosemirror-command 模块与 prosemirror-keymap 模块相结合,就可以实现编程式的方式来操作修改文档,例如通过将指令与菜单 UI 或快捷键相绑定,则可以实现点击按钮或按下快捷键来一键修改文档内容

ProseMirror Commands 模块

ProseMirror 的指令 command 是一个函数对各种编辑操作 editing action 进行封装,可以实现以编程式/命令式地修改文档。

指令 command 是一个函数 fn(state, dispatch?, view?) 它可以接受三个参数:

  • 第一个参数 state 是一个编辑器对象
  • 第二个(可选)参数 dispatch 是一个函数,用来分发一个 transaction 事务对象(对文档进行操作)
    提示

    当没有设置 dispatch 参数时,表示该命令只是一个 dry run 「试运行」,即进行模拟或预演操作(以判断它是否应该被执行),但不对文档进行实际修改

  • 第三个(可选)参数 view 是一个编辑器视图对象

该函数最后应该返回一个布尔值,以表示该操作是否可以执行(即是否已经执行成功)

prosemirror-commands 模块主要提供了一系列用于创建 commands 指令的函数,以及一些预设的 command 指令(以变量的形式提供):

  • deleteSelection 变量:表示一个 command 指令。调用该指令删除选区内容
  • joinBackward 变量:表示一个 command 指令。调用该指令(满足特定条件时)尝试减小当前节点与它前面节点之间的距离
    如果选区内容为空(处于光标状态),且位于一个 textblock 开头位置,调用指令会尝试减小当前节点与它前面节点之间的距离(文档中的位置距离是根据 index schema 规则而定的)
    如果当前节点与前一个节点可以连接起来 joinable,则通过将它们相融合为一个节点,来减小距离;如果前后节点不可连接,则会尝试提升 lift 当前节点的层级(将它移出父节点),或将它移入到前一个节点的父节点里,来减小与前一个节点之间的距离
    如果调用该指令时传递了第三个(可选)参数 view 则编辑器视图对象会用于更准确地判断文本块 textblock 的方向(bidi-aware 识别编辑器的书写的方向是从左往右,还是从右往左)
    textblock 文本块

    文本块 textblock 所指的节点为 block 块级类型,且它的 content 内容由 inline 行内类型的节点构成(相当于节点的配置对象的属性 isBlockinlineContent 均为 true

    一般是指以文本作为内容的节点,例如 paragraph 段落节点

    使用场景

    该指令常见的应用场景是在文本块的开头按下 Backspace 键删除与前一个节点之间的「间隔」,将它们融合为一个节点

  • joinTextblockBackward 属性:表示一个 command 指令,调用该指令(满足特定条件时)尝试当前节点与它前面节点相融合为一个节点
    如果选区内容为空(处于光标状态),且位于一个 textblock 开头位置,如果当前节点与前一个节点可以连接起来 joinable,调用该指令则会将它们相融合为一个节点
    提示

    该指令相当于 joinBackward 指令 的特殊版本,它只会尝试将前后两个节点相融合,而不会尝试提升 lift 节点的层级

  • selectNodeBackward 属性:表示一个 command 指令,调用该指令(满足特定条件时)选择前一个节点
    如果选区内容为空(处于光标状态),且位于一个 textblock 开头位置,调用该指令会尝试选择前一个节点
    使用场景

    该指令常与 Backspace 键相绑定

    如果在文本块的开头按下 Backspace 键,但是(可能由于 schema 的约束,无法在该位置执行删除操作)无法执行 joinBackward 指令(或其他与删除内容相关的指令),那么该指令会作为 fallback 回退「保底」操作,将选区向前移动,选中前一个节点

  • joinForward 变量:表示一个 command 指令。与 joinBackward 指令类似,调用该指令(满足特定条件时)尝试减小当前节点与它后面节点之间的距离
    如果选区内容为空(处于光标状态),且位于一个 textblock 结尾位置,调用指令会尝试减小当前节点与它后面节点之间的距离(文档中的位置距离是根据 index schema 规则而定的)
    使用场景

    该指令常见的应用场景是在文本块的结尾按下 Delete 键删除与后一个节点之间的「间隔」,将它们融合为一个节点

  • joinTextblockForward 变量:表示一个 command 指令。与 joinTextblockBackward 指令类似,调用该指令(满足特定条件时)尝试当前节点与它后面节点相融合为一个节点
    提示

    该指令相当于 joinForward 指令 的特殊版本,它只会尝试将前后两个节点相融合,而不会尝试提升 lift 节点的层级

  • selectNodeForward 变量:表示一个 command 指令,与 selectNodeBackward 指令类似,调用该指令(满足特定条件时)选择后一个节点
    使用场景

    该指令常与 Delete 键相绑定,(如果无法执行删除操作时)指令会一般作为 fallback 回退「保底」操作,将选区向后移动,选中后一个节点

  • joinUp 变量:表示一个 command 指令,将当前 block 块节点与前面相邻的兄弟节点相融合
    如果当前选区并不是一个节点选区,而是 text selection 文本选区,则调用该指令时,会沿着节点树向上寻找最近的祖先节点,可与其兄弟节点进行融合
    使用场景

    该命令通常用于将两个相邻块合并为一个,例如撤销分段操作,即在一个段落中按回车键会将当前段落一分为二,此时可以使用该指令将它们重新合并

  • joinDown 变量:表示一个 command 指令,与 joinUp 指令类似,将当前 block 块节点与后面相邻的兄弟节点相融合
  • lift 变量:表示一个 command 指令,提升 lift 当前选中节点的层级(将它移出父节点)
    如果当前选区并不是一个节点选区,而是 text selection 文本选区,则调用该指令时,会沿着节点树向上寻找最近的祖先节点,它可以执行提升 lift 操作
    使用场景

    该指令常与 Tab 键相绑定

  • newlineInCode 变量:表示一个 command 指令,如果选区在代码类型的节点中(节点的配置对象的属性 code 是真值),调用该指令会用一个 \n 换行符替换当前选区内容,在视觉上相当于换行(但不是创建一个新的 block 类型的节点),并移动选区(光标状态)到换行符后的位置
    使用场景

    该指令常在代码块中与 Enter 键相绑定

  • exitCode 变量:表示一个 command 指令,如果选区在代码类型的节点中(节点的配置对象的属性 code 是真值),调用该指令会在该节点后创建一个默认的 block 块类型的节点(一般是段落节点),并将选区移到新建的节点里(其效果相当于将光标移除代码块)
  • createParagraphNear 变量:表示一个 command 指令,如果选中一个 block 块类型的节点,调用该指令会在它的附近创建一个空的段落节点
    如果当前选中的节点是(相对于其父节点)第一个子节点,则在它前面创建一个空的段落节点;否则在它的后面创建一个空的段落节点
  • liftEmptyBlock 变量:表示一个 command 指令,如果选区(光标状态)在一个空的 textblock 里,调用该指令可以将它提升 lift 一个层级(从其父节点提取出来,并将其移动到文档树的上一层)
  • splitBlock 变量:表示一个 command 指令,调用该指令会在选区处对其所在节点的父级节点进行分割。如果当前选区是一个文本选区,还会同时删除选区内容
  • splitBlockKeepMarks 变量:表示一个 command 指令,与 splitBlock 指令类似,但是分割后没有重置光标的 stored marks 预设样式标记(预设样式标记是指即将应用到下一次输入的内容的样式标记)
  • splitBlockKeepMarks(splitNode?) 方法:通过调用该方法创建一个 command 指令,执行该指令会在选区处对其所在节点的父级节点进行分割,与 splitBlock 指令类似(相当于它的特殊版本),但是分割生成的节点可以进行自定义
    该方法接受一个(可选)参数 splitNode 它是一个函数 fn(node: Node, atEnd: boolean) → {type: NodeType, attrs⁠?: Attrs} 该函数的第一个参数 node 是需要分割的原始节点对象 ❓ ;第二个参数 atEnd 是一个布尔值,表示选区是否在节点的结尾。该函数返回一个对象 {type: NodeType, attrs⁠?: Attrs} 描述分割所生成的节点
    使用场景

    如果光标位于段落中并按下 Enter 键时,通常通过 splitBlock 指令将当前的段落分割成两个段落

    如果需要定制 block 块类型的节点分割操作的结果,例如希望新的分割块不仅仅是一个新的段落,而是一个具有特定属性的节点,则可以使用 splitBlockAs 指令,可以更灵活地控制和自定义块的分割行为。

    调用该指令进行分割操作时,会调用该指令所接受的参数 splitNode(一个函数),根据返回结果来创建新的节点(而不是默认的段落)

  • selectParentNode 变量:表示一个 command 指令,调用该指令将当前选区移至其父节点(如果父节点是可选中的,但是并不会选中文档的根节点)
    使用场景

    当需要快速扩大选区,从选择文档中较小的内容(比如单个字符或者单词)变成包含整个父级结构(比如整个段落或者列表项)的时候,可以使用该指令

  • selectAll(state, dispatch?) 方法:该方法就是一个指令,调用该指令以全选文档。第一个参数 state编辑器状态对象,第二个(可选)参数 dispatch 是一个函数 fn(tr) 它接受一个事务对象以修改选区。该方法最后返回一个布尔值,以表示指令是否执行成功。
  • wrapIn(nodeType, attr?) 方法:通过调用该方法创建一个 command 指令,执行该指令会创建一个给定类型 nodeType 的节点对象,它具有特定的属性 attr,用该节点来包裹选区。
    该方法的第一个参数 nodeType 是一个 nodeType 对象,用于指定所需创建的节点的类型;第二个(可选)参数 attr 是一个对象,用于设置节点需要具有哪些属性
  • setBlockType(nodeType, attrs?) 方法:通过调用该方法创建一个 command 指令,执行该指令会将选区所在的 textblock 文本块转换为给定类型的节点
    该方法的第一个参数 nodeType 是一个 nodeType 对象,用于指定所需创建的节点的类型;第二个(可选)参数 attrs 是一个对象,用于设置节点需要具有哪些属性
    textblock 文本块

    文本块 textblock 所指的节点为 block 块级类型,且它的 content 内容由 inline 行内类型的节点构成(相当于节点的配置对象的属性 isBlockinlineContent 均为 true

    一般是指以文本作为内容的节点,例如 paragraph 段落节点

  • toggleMark(markType, attrs) 方法:通过调用该方法创建一个 command 指令,执行该指令会对给定类型 markType 样式标记进行切换。
    该方法的第一个参数 markType 是一个 markType 对象,用于指定所需切换的样式标记类型;第二个(可选)参数 attrs 是一个对象,用于设置样式标记需要具有哪些属性
    执行指令的结果有多种情况:
    • 如果当前选区已经存在给定类型 markType 的样式标记,则会将它移除;否则会将其添加到选区内容上
    • 如果选区为空(光标状态)则会将该样式标记添加到光标的 stored marks 预设样式标记(预设样式标记是指即将应用到下一次输入的内容的样式标记)
    • 如果当前选区不支持该类型 markType 的样式标记,则指令最后会 false
  • autoJoin(command, isJoinable) 方法:通过调用该方法,对给定的指令 command 进行封装,返回一个新的指令,正如该方法的名称,返回的指令可以实现自动将两个节点融合的操作。
    如果给定的指令 command 所触发的转换 transform 最终会在文档中生成两个相邻可连接 joinable 的节点,那么经过该方法对给定的指令进行封装,就会得到一个新的指令,它除了可以完成原有指令 command 的操作,还会进一步自动将原有指令生成的两个节点进行融合
    该方法的第一个参数 command 是原有的需要封装的指令。第二个参数 isJoinable 可以是一个函数 fn(before: Node, after: Node) → boolean 它接受两个参数 beforeafter 都是 node 节点对象,表示相邻的两个节点,返回布尔值以判断是否它们可以合并;也可以是一个数组,它的元素是字符串以表示节点名称,在该数组中的节点类型是可以合并的
    可融合的节点

    可融合的 joinable 节点是需要具有相同的节点类型

    应用场景

    这个方法对原有的指令进行封装,可以使得它们更「智能」,让文档编辑过程中的节点合并操作自动化完成,提升了编辑效率和用户体验。

    例如在编辑列表或段落时,当用户执行某些操作(删除操作)导致两个列表或段落紧挨在一起时,这两个节点可以合并(比如它们都是同类型的列表),如果经过 autoJoin 对这些操作进行封装得到新的指令,则调用新的指令除了完成原有的操作,还会自动合并这两个节点,而不需要用户进行额外的操作。

该模块还提供了一个方法 chainCommands(...commands) 用于将给定的一系列指令封装为一个新的指令,调用该指令会依次执行 ...commands 传入的指令,直到其中一个指令返回值为 true 为止(则停止执行后续的指令)

该模块还提供了一个对象 baseKeymap 以键值对的形式将常见的按键和指令进行匹配,键名是字符串(以出现在按键事件对象属性 KeyEvent.key)来表示按下什么按键(也支持组合键,具体规则参考 prosemirror-keymap 模块的介绍),值是对应的指令

提示

其实 baseKeymap 会基于编辑器所运行的系统环境,提供不同的按键与指令的匹配方案,(基于浏览器的 navigator 接口)如果侦测到系统环境是 MacOS 就会采用 macBaseKeymap,否则就采用 pcBaseKeymap

ts
// 基于浏览器的 navigation 或 Node.js 的 os 模块判断用户的操作系统
const mac = typeof navigator != "undefined" ? /Mac|iP(hone|[oa]d)/.test(navigator.platform) : typeof os != "undefined" && os.platform ? os.platform() == "darwin" : false

/// Depending on the detected platform, this will hold
/// [`pcBasekeymap`](#commands.pcBaseKeymap) or
/// [`macBaseKeymap`](#commands.macBaseKeymap).
export const baseKeymap: {[key: string]: Command} = mac ? macBaseKeymap : pcBaseKeymap

这两种按键与指令的匹配方案具体如下

pcBaseKeymap 适用于 Linux 和 Windows

ts
export const pcBaseKeymap: {[key: string]: Command} = {
  // 将 Enter 键与一系列指令相绑定
  // 该链式指令的效果是在光标位置分割 textblock 文本块,并创建新行
  // 如果所在的文本块内容为空,则将光标跳出当前的层级(向上提升,将光标移到父节点外)
  "Enter": chainCommands(newlineInCode, createParagraphNear, liftEmptyBlock, splitBlock),
  // 将 Mod+Enter 组合键与 exitCode 指令相绑定
  "Mod-Enter": exitCode,
  // 将 Backspace 键与一系列指令相绑定(该链式指令的具体如下)
  // let backspace = chainCommands(deleteSelection, joinBackward, selectNodeBackward)
  // 该链式指令的效果是将光标向前回退,删除前面的内容
  // 如果在文本块的开头,则将该文本块向上提升跳出父节点
  "Backspace": backspace,
  // 将 Mod+Backspace 组合键与一系列指令相绑定
  "Mod-Backspace": backspace,
  // 将 Shift+Backspace 组合键与一系列指令相绑定
  "Shift-Backspace": backspace,
  // 将 Delete 键与一系列指令相绑定(该链式指令的具体如下)
  // let del = chainCommands(deleteSelection, joinForward, selectNodeForward)
  // 该链式指令的效果是将光标后面的内容删除
  "Delete": del,
  // 将 Mod+Delete 组合键与一系列指令相绑定
  "Mod-Delete": del,
  // 将 Mod+A 组合键与 selectAll 指令相绑定
  // 该指令的效果是全选文档
  "Mod-a": selectAll
}

macBaseKeymap 适用于 Mac

ts
// 复用 pcBaseKeymap 的按键与指令的映射关系,并且将一些指令再额外绑定其他的快捷键
export const macBaseKeymap: {[key: string]: Command} = {
  // 将 Ctrl+H 组合键也绑定到 Backspace 键所相关的指令
  "Ctrl-h": pcBaseKeymap["Backspace"],
  // 将 Alt+Backspace 键也绑定到 Mode+Backspace 组合键所相关的指令
  "Alt-Backspace": pcBaseKeymap["Mod-Backspace"],
  // 将 Ctrl+D 组合键也绑定到 Delete 键所相关的指令
  "Ctrl-d": pcBaseKeymap["Delete"],
  // 将 Ctrl+Alt+Backspace 组合键也绑定到 Mod+Delete 组合键所相关的指令
  "Ctrl-Alt-Backspace": pcBaseKeymap["Mod-Delete"],
  // 将 Alt+Delete 组合键也绑定到 Mod+Delete 组合键所相关的指令
  "Alt-Delete": pcBaseKeymap["Mod-Delete"],
  // 将 Alt+D 组合键也绑定到 Mod+Delete 组合键所相关的指令
  "Alt-d": pcBaseKeymap["Mod-Delete"],
  // 将 Ctrl+A 组合键与 selectTextBlockStart 指令相绑定
  // 该指令的效果是将光标移动到文本块的开头
  "Ctrl-a": selectTextblockStart,
  // 将 Ctrl+E 组合键与 selectTextblockEnd 指令相绑定
  // 该指令的效果是将光标移动到文本块的结尾
  "Ctrl-e": selectTextblockEnd
}
// 遍历 pcBaseKeymap 对象的属性,将它们复用到 macBaseKeymap 对象中
for (let key in pcBaseKeymap) (macBaseKeymap as any)[key] = pcBaseKeymap[key]
使用方式

前面所述对象 baseKeymap(也包括 pcBaseKeymapmacBaseKeymap 对象)只是存储了按键与指令的匹配信息,如果要将它们应用到编辑器中,还需要使用方法 keymap(bindings)

这些对象作为 方法 keymap(bindings) 的参数,调用该方法会返回一个浏览器插件,然后将插件注册到编辑器上,就可以实现按键与指令的绑定

具体可以参考官方所提供的基础编辑器样例

prosemirror-example-setup
ts
// refer to https://github.com/ProseMirror/prosemirror-example-setup/
// ...
import {baseKeymap} from "prosemirror-commands"
// ...

export function exampleSetup(options){
  // ...
  let plugins = [
    buildInputRules(options.schema),
    keymap(buildKeymap(options.schema, options.mapKeys)),
    keymap(baseKeymap),
    dropCursor(),
    gapCursor()
  ]

  // ...
  return plugins.concat(new Plugin({
    props: {
      attributes: {class: "ProseMirror-example-setup-style"}
    }
  }))
}
ts
import {EditorState} from "prosemirror-state"
import {EditorView} from "prosemirror-view"
import {Schema, DOMParser} from "prosemirror-model"
import {schema} from "prosemirror-schema-basic"
import {addListNodes} from "prosemirror-schema-list"
import {exampleSetup} from "prosemirror-example-setup"

// Mix the nodes from prosemirror-schema-list into the basic schema to
// create a schema with list support.
const mySchema = new Schema({
  nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"),
  marks: schema.spec.marks
})

window.view = new EditorView(document.querySelector("#editor"), {
  state: EditorState.create({
    doc: DOMParser.fromSchema(mySchema).parse(document.querySelector("#content")),
    plugins: exampleSetup({schema: mySchema})
  })
})

ProseMirror Keymap 模块

prosemirror-keymap 模块提供了一些辅助函数可以将 command 指令与按键相绑定,以实现编辑器的 shortcut 快捷键功能

该模块导出了两个方法

  • keymap(bindings) 方法:基于给定的 bindings 对象,创建一个插件(将插件注册到编辑器上,就可以实现按键与指令的绑定)
    该方法所接受的参数 bindings 是一个对象,以键值对的形式将常见的按键和指令进行匹配,键名是字符串来表示按下什么按键,值是对应的 command 指令
    键名的规则

    采用出现在按键事件对象属性 KeyEvent.key来表示按下什么按键

    💡 可以在该网页查看所有键值列表

    使用小写字母来表示只按下某个字母相关的键,使用大写字母来表示同时按下 Shift 键(不需要再显式地添加 Shift- 前缀,因为 ProseMirror 会隐式地添加了该判别条件)

    使用 Space 来表示按下 " " 空格键

    键名也可以是组合键,支持在按键前面添加相应的 modifiers 修饰键(用 - 连字符相隔,多个修饰键的先后次序并无所谓),例如 Shift-Ctrl-Enter。修饰键还支持别名:

    • Shift-s-
    • Alt-a-
    • Ctrl-c-Control
    • Cmd-m-Meta-

    也可以使用 Mod- 来作为 MacOS 系统上 Cmd- 与其他系统上的 Ctrl- 的通用别名

    说明

    可以为编辑器添加多个由 keymap 生成的创建,根据插件的注册顺序来决定相应指令的执行优先次序

    当其中一个指令返回 true 则表示对按键所分发的事件已经处理完成(之后的指令就不再执行了)

  • keydownHandler(bindings) 分发:基于给定的 bindings 对象,创建一个事件处理函数 ⁠fn(view, event)(当编辑器接收到 keydown 事件时会调用该处理函数)
    该方法所接受的参数 bindings 是一个对象,以键值对的形式将常见的按键和指令进行匹配,键名是字符串来表示按下什么按键,值是对应的 command 指令
    在哪里使用

    该方法所返回的事件处理函数,是作为 new EditorView(place, props) 实例化编辑器视图对象时,配置对象的属性 props.handleKeyDown 的值

    该事件处理函数接受两个参数,第一个参数 view 是编辑器视图对象,第二个参数 event 是 KeyboardEvent 键盘事件对象。最后返回一个布尔值或 undefined,以表示该处理函数是否已经响应/处理完成了该事件


Copyright © 2025 Ben

Theme BlogiNote

Icons from Icônes