跑通一个最小 ProseMirror:先看文档长什么样

📅
2 分钟阅读
·

前两篇介绍了阅读 ProseMirror 的原因和 22 个包的分工。本文先运行一个编辑器示例,在控制台检查文档 JSON 与一次输入产生的 Transaction。后续阅读 model 和 state 源码时,可以将字段与这些运行结果对应。参考代码版本:prosemirror-example-setup 的 b6fcf7a、prosemirror-model 的 6264de0、prosemirror-state 的 ffad5d9、prosemirror-view 的 ca4c78e、prosemirror-transform 的 662b7a9;schema 使用 prosemirror-schema-basic 的 756726f 和 prosemirror-schema-list 的 1501619。

系列目录

日期标题
05-10ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17ProseMirror 仓库全景:22 个包怎么分工
05-24跑通一个最小 ProseMirror:先看文档长什么样(本篇)

运行编辑器示例

example-setup 是官方提供的插件组合包。调用 exampleSetup({schema}) 会返回一组插件;再配合 EditorState 和 EditorView,即可得到支持输入、撤销和菜单栏的编辑器:

import {EditorState} from "prosemirror-state"
import {EditorView} from "prosemirror-view"
import {Schema} from "prosemirror-model"
import {schema} from "prosemirror-schema-basic"
import {addListNodes} from "prosemirror-schema-list"
import {exampleSetup} from "prosemirror-example-setup"

const mySchema = new Schema({
  nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"),
  marks: schema.spec.marks
})

const state = EditorState.create({
  schema: mySchema,
  plugins: exampleSetup({schema: mySchema})
})

window.view = new EditorView(document.querySelector("#editor"), {
  state,
  dispatchTransaction(tr) {
    console.log(tr)
    this.updateState(this.state.apply(tr))
  }
})

view 挂到 window 后,可在控制台访问它;后文会使用 view.state.doc.toJSON() 检查文档。

Schema 的两行代码定义了文档允许使用的节点和 mark。prosemirror-schema-basic 提供基础节点与 mark 定义,prosemirror-schema-listaddListNodes 将列表节点加入 nodes 集合。其第一个参数为节点集合,第二个参数为列表项的内容表达式,第三个参数为列表所属的 group。Schema 提供文档类型表,JSON 中的每个 type 字段都必须已在此注册;反序列化时找不到类型名会抛错。

EditorState.create 位于 prosemirror-state 的 src/state.ts。配置中的 docschema 二选一:传入 doc 时从 doc 取得 schema。未传 doc 时,state.tsdoc 字段的 init 会调用 config.schema.topNodeType.createAndFill(),按内容表达式创建包含必需子节点的默认文档;最小示例初始的空段落由此产生。plugins 数组由 Configuration 整理,各插件声明的 StateField 也在此初始化。State 包含文档、选区和插件状态;每次 apply 都生成新的 state,原 state 不变。

dispatchTransaction 决定事务如何应用。prosemirror-view 的 EditorView.prototype.dispatchsrc/index.ts)会在 props 提供 dispatchTransaction 时调用它;否则执行 this.updateState(this.state.apply(tr))。示例中的函数保留了默认行为,并在应用前输出 tr。EditorView 的构造函数第一个参数可以是编辑器插入其中的 DOM 节点、一个回调或 {mount} 对象;此处传入选择器查询到的节点。

exampleSetupexampleSetup 函数(src/index.ts)只组合插件,不处理编辑逻辑。插件按以下顺序加入:

  • buildInputRules(schema):输入规则。src/inputrules.ts 定义了 smartQuotes、ellipsis、emDash;schema 含有 blockquote 时注册 /^\s*>\s$/ 包裹规则,含有 code_block 时注册 /^```$/ 转换规则,含有 heading 时以 #{1,6} 后接空格转换为标题。schema 未定义的节点不会生成对应规则。
  • keymap(buildKeymap(schema, mapKeys)):根据 schema 生成快捷键。src/keymap.ts 将 Mod-b 绑定到 toggleMark(strong)、Mod-i 绑定到 em、Mod-z 绑定到 undo、Shift-Mod-z 绑定到 redo;非 Mac 平台还绑定 Mod-y。mapKeys 可以重绑某个键,或以 false 禁用它。
  • keymap(baseKeymap):prosemirror-commands 提供的基础按键处理,包括 Enter、Backspace。
  • dropCursor()gapCursor():拖拽指示线和块级位置光标。
  • menuBar({...}):菜单栏。menuBar: false 可关闭它,floatingMenu 控制是否浮动;默认菜单内容来自 buildMenuItems(schema).fullMenu
  • history():撤销和重做;history: false 可关闭它。

最后会追加一个 Plugin,为编辑器 DOM 添加 ProseMirror-example-setup-style class,以启用包内样式表。插件数组顺序影响按键处理:按数组顺序匹配,先匹配的插件先消费事件。第 22、23 篇会讨论插件系统;这里需要区分核心的 state/view 与由插件提供的功能。

文档的 JSON 表示

编辑器启动后,在控制台执行 view.state.doc.toJSON()。ProseMirror 文档是带方法的类实例,console.log 直接输出 Node 对象时主要显示内部字段;toJSON 产生的纯数据适合存储、接口传输和快照测试。以下是一份包含二级标题和加粗文本的文档:

{
  "type": "doc",
  "content": [
    {
      "type": "heading",
      "attrs": {"level": 2},
      "content": [{"type": "text", "text": "标题"}]
    },
    {
      "type": "paragraph",
      "content": [
        {"type": "text", "text": "前面"},
        {"type": "text", "marks": [{"type": "strong"}], "text": "加粗"},
        {"type": "text", "text": "后面"}
      ]
    }
  ]
}

树节点使用 typeattrscontentmarks 四种字段,文本节点额外使用 text。prosemirror-model 的 src/node.tsNode.prototype.toJSON 的处理如下:

  • 节点先写入 {type: this.type.name},类型名为字符串。
  • 仅当 attrs 存在键时写入 attrs。实现使用 for (let _ in this.attrs) { obj.attrs = this.attrs; break } 判断对象是否为空。
  • 仅当 content 非空(this.content.size 为真)时写入 content;其值为 Fragment 的 toJSON 结果,即子节点数组。
  • 仅当 marks 数组非空时写入 marks。
  • TextNode 覆写 toJSON,在父类结果上补充 text 字段。

因此,没有 attrs 的 paragraph 不包含 attrs,没有内容的节点不包含 content。heading 的 attrs 是 {level: 2}。schema-basic 中 heading 的 attrs 声明了 default: 1;节点创建时会填入默认值,因此序列化结果总会包含 level。toJSON 直接引用 attrs 对象,不进行值转换。attrs 若包含函数、DOM 节点等不可序列化值,后续存储会失败。

反序列化使用同一文件的 Node.fromJSON(schema, json)。当 type 为 "text" 时,它先验证 text 字段为字符串,再调用 schema.text(json.text, marks);其他类型先通过 Fragment.fromJSON 递归恢复子节点数组,再用 schema.nodeType(json.type) 查找类型并创建节点,最后由 checkAttrs 校验属性。Mark 的序列化位于 src/mark.ts,仅包含 type 和可选 attrs。Mark.fromJSONschema.marks 中按名称查找类型;未找到时抛出 There is no mark type ... in this schema。schema 实例还提供 nodeFromJSONmarkFromJSONsrc/schema.ts),文档存取可以写为:

const json = state.doc.toJSON()
// 入库、传接口,随你处置
const doc = Node.fromJSON(mySchema, json) // 或 mySchema.nodeFromJSON(json)

子节点数组由 Fragment 处理(src/fragment.ts)。Fragment.toJSON 在数组为空时返回 null,Node 的 toJSON 又只在 content 非空时写入该字段,因此空节点的 JSON 只有 typeFragment.fromJSON 收到空值时返回 Fragment.empty,收到数组时调用 Fragment.fromArray。后者会合并相邻且 marks 相同的文本节点。手动构造 JSON 时,连续的无 marks text 节点会在 fromJSON 后合并;再次 toJSON 得到的结构可能与输入不同。TextNode 构造函数禁止空字符串,JSON 中的 "text": "" 在恢复时会抛出 Empty text nodes are not allowed。外部拼接的存储数据需要处理这两项限制。

marks 不在文档树中嵌套。加粗文本不会表示为 strong 节点包裹 text 节点,而是将加粗信息写在 text 节点的 marks 数组中。Mark 附着在 inline 节点上,不是文档树节点;第 5 篇会说明 model 层的这一设计。

也可以从控制台反向构造文档。schema 实例提供工厂方法:mySchema.text("hello", [mySchema.marks.strong.create()]) 创建带 mark 的文本,mySchema.nodes.heading.create({level: 3}, 内容) 创建节点。比较其 toJSON 结果可以检查构造的结构。fromJSON 内部也使用同一组工厂方法(schema.textschema.nodeType(name).create),JSON 是这组 API 的外部表示之一。

输入一个字符产生的 Transaction

示例中的 console.log 会输出每次修改对应的 Transaction。假设当前文档为 doc(paragraph("hell")),光标位于末尾并输入字母 "o",可检查以下字段:

tr.steps.length          // 1
tr.steps[0]              // ReplaceStep {from: 5, to: 5, slice: Slice}
tr.steps[0].slice.toJSON()
// {content: [{type: "text", text: "o"}]}
tr.before.toJSON()       // 修改前:doc > paragraph > "hell"
tr.doc.toJSON()          // 修改后:doc > paragraph > "hello"
tr.selection             // TextSelection {anchor: 6, head: 6}

Transaction 继承 prosemirror-transform 的 Transform。一次输入产生的修改记录为 Step 数组。输入一个字符对应一个 ReplaceStep:fromto 相等表示插入,slice 包含插入内容;删除一个字符时 fromto 相差 1,slice 为空。replace_step.tstoJSON 先判断 this.slice.size,slice 为空时省略 slice 字段,结果为 {stepType: "replace", from: 4, to: 5}。Step 也可序列化:tr.steps[0].toJSON() 的结果是 {stepType: "replace", from: 5, to: 5, slice: {...}}。每种 Step 类通过 Step.jsonID 在 prosemirror-transform 的 src/step.ts 中注册字符串 id;Step.fromJSON 根据 stepType 查找对应类。Slice 的 toJSONopenStartopenEnd 为 0 时省略这两个字段,空 slice 序列化为 null(prosemirror-model src/replace.tsSlice.toJSON)。这两个字段记录切片两端的打开深度,粘贴跨段落内容时可能非零,第 8 篇会说明。协作编辑向服务端发送本地修改时使用 steps 的 JSON,第 47 篇讨论该流程。

Transform 在 src/transform.ts 中维护两个平行数组:steps 记录每一步,docs 记录每步应用前的文档。before 返回 docs[0]doc 为全部步骤应用后的文档。文档对象不可变,beforedoc 为不同引用,未修改的子树可被两者共享。Step 记录了变更过程,可用作修改记录。EditorState.apply 进入 applyInnersrc/state.ts)时会检查 tr.before.eq(this.doc),不一致则抛出 Applying a mismatched transaction。事务只能应用到创建它时的文档:dispatchTransaction 应使用 view 当前的 state;将同一个 tr 连续 apply 两次时,第二次会抛错,因为第一次应用后 state.doc 已更新,而 tr.before 仍指向旧文档。

Transaction 在 Transform 基础上加入选区状态(src/transaction.ts)。内部的 curSelection 随 steps 更新:访问 tr.selection 时,若 curSelectionFor < this.steps.length,会先将选区映射过新增步骤。上述示例中光标从 5 移到 6 即为该映射结果。事务应用后,这个 selection 会成为新 state 的选区。TextSelection 包含 anchorhead,分别为锚点和活动端;无拖选时两者相等,选中文本时它们的大小关系表示方向。位置编号中,每个开闭 token 和每个字符都占一个位置;段落文本从 1 开始计数,"hell" 末尾的位置为 5。第 7 篇 ResolvedPos 会给出完整规则。

本例未使用 meta 字段。Transaction 可以附加任意 meta 数据,插件可借此传递信息;history 插件以 meta 区分用户输入和 undo 产生的修改,第 21 篇会展开。

Transform 还有 mappingsrc/transform.ts),类型为 Mapping。它将每一步的位置映射按顺序组成映射链,用于将旧文档的位置换算到新文档。tr.selection 的自动更新、协作 rebase 与历史回放都使用它。示例中可执行 tr.mapping.map(5),结果为 6,与选区移动一致。

选中一个词后按 Mod-b 加粗,steps 中会出现 AddMarkStep(prosemirror-transform 的 src/mark_step.ts)。其 toJSON 结果为 {stepType: "addMark", mark: {type: "strong"}, from: ..., to: ...}:文档内容不变,仅为 from 到 to 范围的文本添加 mark。比较 beforedoc 的 JSON,可以看到对应 text 节点的 marks 数组新增 {type: "strong"}。插入、删除、加粗和修改属性分别由相应 Step 类型表示;Step 类型及其注册表定义了可枚举的文档修改操作。

dispatchTransaction 中的两行代码构成一次更新:接收 tr,通过 apply 生成新 state,再用 updateState 更新 view。view 负责将浏览器事件转换为事务并渲染 state;事务的应用由 state 的入口处理。

后续阅读需要的对象关系

本文涉及的对象及关系如下:

  • 文档是不可变 Node 树;JSON 使用 typeattrscontentmarkstext 五种键,序列化和反序列化依赖 schema 的类型表。
  • Transaction 由 Step 数组和选区组成,beforedoc 分别对应修改前后的文档。
  • 编辑器由 state 和 view 组成:state 持有文档、选区与插件状态,view 渲染 DOM 并将浏览器事件转换为事务。
  • example-setup 通过向 state 加入插件提供输入规则、快捷键、菜单和历史记录,不改变核心 state/view 的职责。

下一篇阅读 model 包中的 Node 和 Fragment:Fragment 不直接使用数组的原因,以及 nodeSize 的计数约定如何支持位置编号。


667 字 · 46 段落
ximing

Follow onGitHub

相关文章