view 收官:不用官方扩展,手写一个最小可用编辑器

3 分钟阅读
·

view 阶段从 EditorView 总览讲到浏览器差异补丁,十二篇机制把核心四包全部过了一遍。这篇收官换个做法:动手。只用 prosemirror-model、prosemirror-transform、prosemirror-state、prosemirror-view 四个核心包,拼一个最小可用编辑器,keymap、commands、history 这些官方扩展一概不用。功能目标定得很小:能输入、能删除、能把选中文字加粗。目标小是故意的,功能越少,每一行代码对应哪一篇讲的机制就越清楚。参考代码是 prosemirror-model 的 6264de0、prosemirror-transform 的 662b7a9、prosemirror-state 的 ffad5d9、prosemirror-view 的 ca4c78e。

系列目录

日期 标题
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 收官:不用官方扩展,手写一个最小可用编辑器(本篇)

先确认哪些行为是免费的

装配 example-setup 时要引入 keymap、history、inputrules、dropcursor、gapcursor 一堆扩展包。全部砍掉之后核心层还剩多少能力,这是动笔前要先想清楚的。先把四个核心包的分工摆出来:model 定义文档结构和校验规则,transform 提供修改文档的最小单位与组合 API,state 把文档、选区、插件状态合成一个不可变的整体,view 负责渲染和事件。更新循环本身(state 加 tr 等于新 state,新 state 驱动 DOM 补丁)是核心层闭合的,不依赖任何扩展。

逐项核对三个目标行为。输入:免费。view 的 DOMObserver 会把浏览器对 contenteditable 的改动读回成 transaction(第 28 篇),这条被动路径在 EditorView 的构造函数里就挂好了。删除:免费,同一条路径。Enter:不免费。第 29 篇提过 captureKeyDown 这道兜底,看 src/capturekeys.ts 的 captureKeyDown 会发现 Enter(keyCode 13)和 Esc 被无条件拦下,函数直接返回 true,keydown 处理器跟着 preventDefault。拦它的原因是 Enter 的浏览器默认行为差异太大,有的拆段落、有的插 div、有的只插 br,读回都未必对得齐。代价是:没有任何 handleKeyDown 处理 Enter 时,它就是一颗死键。所以真正要手写的有四样:Schema、装配代码、加粗命令、Enter 拆段。

Schema:三个节点一个 mark

import {Schema} from "prosemirror-model"

const schema = new Schema({
  nodes: {
    doc: {content: "paragraph+"},
    paragraph: {
      content: "text*",
      toDOM() { return ["p", 0] },
      parseDOM: [{tag: "p"}]
    },
    text: {}
  },
  marks: {
    bold: {
      toDOM() { return ["strong", 0] },
      parseDOM: [{tag: "strong"}, {tag: "b"}]
    }
  }
})

new Schema 做的是编译(src/schema.ts 的 Schema 类):节点和 mark 的规格各存一份,content: "paragraph+" 这样的内容表达式在第 6 篇讲过,会编成一台小型自动机,之后每次建节点都拿它校验。paragraph 的 toDOM 返回 ["p", 0],那个 0 是内容洞,子节点渲染进洞里(第 9 篇)。bold 的 parseDOM 给了两条规则,strong 和 b 两种标签都认,粘贴带格式的文本时会用到(第 10 篇)。

两个细节值得说明。text 节点的规格是个空对象:内联叶子节点不需要 content 表达式,也没写 marks 字段。schema.ts 里 markSet 的默认逻辑是 marks 缺省时允许挂所有 mark,所以 bold 直接可用;如果哪天加了斜体又想限制代码文本不挂格式,就得回来给这个字段写排除规则。doc 的规格里没有 toDOM,顶层的渲染由 view 接管,view.dom 本身就是 doc 的容器(第 26 篇的 topNode 与 docViewDesc)。

装配:state 进 view 出

import {EditorState} from "prosemirror-state"
import {EditorView} from "prosemirror-view"

const state = EditorState.create({
  schema,
  doc: schema.node("doc", null, [
    schema.node("paragraph", null, [
      schema.text("选中我,按 Mod-B")
    ])
  ])
})

const view = new EditorView(document.querySelector("#editor"), {
  state,
  dispatchTransaction(tr) {
    this.updateState(this.state.apply(tr))
  },
  handleKeyDown(view, event) {
    if ((event.metaKey || event.ctrlKey) && event.key == "b") {
      return toggleBold(view.state, view.dispatch)
    }
    if (event.key == "Enter") {
      return splitParagraph(view.state, view.dispatch)
    }
    return false
  }
})

EditorState.create(src/state.ts)按 schema 和初始 doc 建出第一份状态。没传 plugins,状态字段只有 doc、selection、storedMarks 等内置的几个。schema.nodeschema.text 是 Schema 上的工厂方法,最终走 NodeType.createChecked 校验(第 6 篇),内容表达式不满足会直接抛错。

EditorView 的构造函数做的几件事在前面各篇都拆过:按 props 算出 editable,用 docViewDesc 把初始文档渲染成 ViewDesc 树铺进 view.dom(第 26 篇),new 一个 DOMObserver 并 start(第 28 篇),initInput 注册全部事件处理器(第 29 篇),最后 updatePluginViews 挂插件视图。没有插件时最后一步是空转,其余四步就是这个最小编辑器全部的运行时。

dispatchTransaction 是第 25 篇讲的惯例:view 内部所有修改都汇到这一个出口。view.dispatch 里用 dispatchTransaction.call(this, tr) 调用,所以函数体里的 this 就是 view 本身。函数体只有一行,把 tr 应用成新 state 再交还给 view。state.apply 内部是两阶段(第 19 篇):先把 transaction 里的 step 逐个应用出新 doc,再跑一遍所有状态字段的 apply。

handleKeyDown 直接挂在 EditorView 的 props 上。第 29 篇讲过 someProp 的查找顺序:先查构造时传入的直接 props,再查直接插件,最后查 state 里的插件。keymap 插件做的事就是把这个 prop 换成插件形式注册,这里直接挂构造 props,不走插件。返回 true 表示已处理,view 会调 preventDefault 拦住浏览器的默认行为;返回 false 事件继续往下走,走到底还有 captureKeyDown 兜底。这里挂了两个键:Mod-B 走 toggleBold,Enter 走 splitParagraph,两个命令的写法下面分别说。

加粗:手写一个 toggleBold

官方的 toggleMark 在 prosemirror-commands 里,这里不用它,直接用 Transform API 写一个。命令签名借用 commands 的约定:(state, dispatch),dispatch 不传就是干跑,只回答这个命令当前可不可用。

function toggleBold(state, dispatch) {
  const bold = schema.marks.bold
  const {empty, from, to} = state.selection

  if (empty) {
    // 光标情形:切换 storedMarks,下一个输入的字符生效
    const active = bold.isInSet(state.storedMarks || state.selection.$from.marks())
    if (dispatch) {
      const tr = active
        ? state.tr.removeStoredMark(bold)
        : state.tr.addStoredMark(bold.create())
      dispatch(tr)
    }
    return true
  }

  const has = state.doc.rangeHasMark(from, to, bold)
  if (dispatch) {
    const tr = has
      ? state.tr.removeMark(from, to, bold)
      : state.tr.addMark(from, to, bold.create())
    dispatch(tr.scrollIntoView())
  }
  return true
}

两个分支各对应一组前面讲过的机制。

光标分支操作 storedMarks。这是 state 上的一个内置字段(第 21 篇),表示光标停在这里、下一个字符该带哪些 mark。addStoredMarkremoveStoredMark 在 src/transaction.ts 里,前者收 Mark 实例,后者 Mark 实例和 MarkType 都收。判断当前是否已激活用 bold.isInSet(...)(src/mark.ts,第 5 篇):storedMarks 为空时退到 $from.marks(),也就是光标所在位置实际带有的 mark。storedMarks 的存活规则写在 state.ts 这个内置字段的 apply 里:新选区是 $cursor(折叠的 TextSelection)就保留 tr.storedMarks,否则清成 null。所以方向键或点击移动光标不会丢 pending 状态,拖出一段选区或者选中节点才会清掉,这也是普通编辑器里加粗按钮跟着光标走的行为来源。

选区分支分两步。先用 doc.rangeHasMark(from, to, bold)(src/node.ts)判断选区里是不是已经有 bold,有就 removeMark,没有就 addMark。这两个方法在 src/transform.ts(第 18 篇),内部遍历范围内的内联节点:addMark 会跳过父节点不允许挂这个 mark 的节点,mark.addToSet 把互斥的 mark 挤掉时会补发对应的 RemoveMarkStep;removeMark 传 MarkType 时会把范围内该类型的 mark 全部清掉。结尾的 tr.scrollIntoView() 打一个内置标记,view 应用后把选区滚进可视区(第 21 篇)。

dry-run 语义值得保留。dispatch 为空时不构造 transaction,直接返回可行性。将来加工具栏按钮,按钮的禁用态就可以用 toggleBold(state) 干跑出来。这个编辑器里还用不上,但签名先留对,接到 keymap 或菜单上不用改。

Enter:手写一个 splitParagraph

前面说过 Enter 被 captureKeyDown 拦死,所以拆段也要自己写。最小版本只处理光标情形:

function splitParagraph(state, dispatch) {
  const {$from, empty} = state.selection
  if (!empty) return false // 选区情形要先删再拆,留给读者
  if (dispatch) {
    const tr = state.tr.split($from.pos).scrollIntoView()
    dispatch(tr)
  }
  return true
}

tr.split(src/transform.ts)是 Transform 上的结构修改方法,签名是 split(pos, depth = 1, typesAfter?),默认在指定位置把父节点拆成两个。它的实现(structure.ts 的 split)不预判合法性:把位置两侧各包一层父节点,拼成 openStart、openEnd 都等于 depth 的 Slice,用一个带 structure 标记的 ReplaceStep 塞回去。内容不合法会在 step 应用时失败,Transform.step 直接抛 TransformError,所以正式命令会先用 structure.ts 的 canSplit(第 17 篇)预判再动手。这个 schema 只有 paragraph 一种块,光标在段落文本内任何位置都能拆。选区非空时返回 false 是把难题推掉了:正式实现要先 deleteRange 删掉选区再拆,baseKeymap 里的 splitBlock 还要处理代码块末尾跳出、列表项提升等一堆情形,那是 commands 包的篇幅。

到这里主动路径的两个命令都齐了。整个 handleKeyDown 加起来不到二十行,已经覆盖了这个编辑器的全部键盘语义。

输入和删除走被动路径

加粗走的是主动路径,输入和删除走另一条。用户敲一个字符,浏览器直接改 contenteditable 里的 DOM,编辑器此刻并不知情。MutationObserver 触发后 DOMObserver 择机 flush,readDOMChange 拿当前 DOM 和内存里的文档做对齐,用 findDiffStart 和 findDiffEnd(第 11 篇)圈出变化区间,把变化处的 DOM 解析成 Slice,生成 transaction,最后一样走 dispatchTransaction。这套读回逻辑在第 28 篇完整讲过,composition 期间的抑制在第 31 篇,浏览器差异补丁在第 36 篇。三篇机制合起来,才换来这里一行代码都不用写。

删除同理,Backspace 在浏览器侧删掉字符或节点,读回对齐成 ReplaceStep。输入还有一条小的直接路径:editHandlers.keypress 里,当选区不是同一父节点下的 TextSelection 时(比如光标跨在两个段落边界上),view 不等浏览器动手,直接 dispatch 一个 tr.insertText(text).scrollIntoView() 并 preventDefault,省掉一次读回。同段落内的普通输入不走这条,还是浏览器先改 DOM、再被动对齐。

被动读回有一个前提:view 渲染出的 DOM 结构必须能被自己的 DOMParser 读回来,所以前面 Schema 里 paragraph 的 toDOM 和 parseDOM 必须配对,写岔了会出现输入一个字符、视图重建一片的症状。

按键到 DOM 的全链路

图上两条路径在 dispatchTransaction 汇合。这个汇合点是编辑器唯一的更新入口,所有修改无论来源,最后都是旧 state 加 tr 等于新 state。汇合之后的半段(ViewDesc 增量更新、DOM 补丁、选区回写)两条路径也完全共用。以 Mod-B 为例顺一遍全链路:keydown 到达 view.dom,input.ts 的事件管线先记下 shiftKey、lastKeyCode 等输入状态,再调 props 上的 handleKeyDown;toggleBold 构造出带 AddMarkStep 的 transaction 交给 view.dispatch;dispatchTransaction 里 state.apply 算出新 state;updateState 驱动 ViewDesc 树做增量更新,matchesNode 判定复用,只有 dirty 的子树重绘;最后 strong 标签落进 DOM,selectionToDOM 把光标写回去。Enter 走同一条主动路径,区别只是 transaction 里装的是带 structure 标记的 ReplaceStep。

跑起来之后验证什么

代码跑起来,开控制台做三个检查,把前面的机制对照一遍。第一,输入一个字符后看 view.state.doc.toJSON(),文档 JSON 里多出来的就是这个字符,确认读回路径工作正常。第二,在 dispatchTransaction 里打一行 console.log(tr.steps):普通输入产生的是 ReplaceStep,加粗产生的是 AddMarkStep,Enter 拆段产生的是带 structure 标记的 ReplaceStep,step 家族的分类对应第 13、14 篇。第三,选中一段文字按 Mod-B,再检查 view.state.storedMarks 是 null、对应文本节点的 marks 数组里有 bold,确认走的是选区分支而不是 storedMarks 分支。三个检查都过了,说明这条最小链路每一环都按预期工作。

核心层 API 速查

四包在这篇里用到的,加上前面三十六篇覆盖的主要 API,收在一张表里:

API 位置 作用
model new Schema(spec) src/schema.ts 编译节点与 mark 规格
model schema.node / text / mark src/schema.ts 按规格建节点,走 createChecked 校验
model doc.resolve(pos) src/resolvedpos.ts 扁平位置转路径(第 7 篇)
model doc.rangeHasMark src/node.ts 范围内 mark 存在性判定
model markType.isInSet / create src/mark.ts mark 集合判定与构造
model DOMSerializer / DOMParser src/to_dom.ts / from_dom.ts 文档与 DOM、HTML 互转
model findDiffStart / findDiffEnd src/diff.ts 两份文档求差
transform tr.replace / delete / insert src/transform.ts 内容修改,攒 ReplaceStep
transform tr.addMark / removeMark src/transform.ts 范围 mark 修改
transform tr.split / join / lift / wrap src/transform.ts 结构修改,消费 structure.ts
transform Step 族 / StepMap / Mapping src/step.ts / map.ts 可逆步骤与位置映射
state EditorState.create src/state.ts 装配初始状态
state state.tr / state.apply src/state.ts 开 transaction,应用出新 state
state tr.addStoredMark / removeStoredMark src/transaction.ts 光标处的 pending mark
state tr.setSelection / scrollIntoView src/transaction.ts 选区与滚动标记
state TextSelection / Selection.near src/selection.ts 选区构造
state Plugin / PluginKey / StateField src/plugin.ts 扩展点三件套
view new EditorView(place, props) src/index.ts 挂载编辑器
view dispatchTransaction prop src/index.ts 唯一更新出口
view handleKeyDown 等事件 props src/index.ts 事件拦截点,someProp 分发
view view.updateState / dispatch src/index.ts 状态进出
view nodeViews / decorations props src/index.ts 渲染扩展(第 32、33 篇)
view posAtCoords / coordsAtPos src/domcoords.ts 坐标换算(第 35 篇)

缺的东西

这个编辑器能跑,离好用还有距离,缺的每一块正好对应一个扩展包。没有 undo:Step 的可逆性(第 13 篇)已经在核心层里,但栈的管理、事件分组、远程修改时的 rebase 都在 prosemirror-history。快捷键只有两个 if 判断,不成体系:按键规格、Mod 前缀的跨平台处理、多个 keymap 的组合顺序,这些是 keymap 插件要解决的。Enter 只处理了光标情形,选区、空段落、嵌套块的分支在 baseKeymap 里还有一长串。粘贴能工作,但只有 parseDOM 规则匹配这一层收敛,粘贴进来的样式、图片、外部 HTML 都没有处理。输入规则、占位符、协同这些更谈不上。核心层负责把更新循环闭合,好用这件事归扩展层。下一阶段从 keymap 开始,把官方扩展逐个拆开。


1204 字 · 37 段落
xi ming

Written by xi mingFollow onGitHub