1. 计划:从第一个 prosemirror 案例开始认识它
说实话,在开始书写本文时,想了很久,不知道如何开这个头,才能将 prosemirror 逐步给讲解清楚,现有的官方文档实在是太生涩了,虽然有人翻译了中文版,但想要根据文档直接搞清楚 prosemirror,还是有很大挑战。并且社区资料也不多,大多数还是照搬文档,并没有太多有营养的内容。由于 prosemirror 的涉及到的内容太多,还是决定直接从一个最简单的 demo 入手,从它的使用开始逐步深入了解 prosemirror。
关于 prosemirror 的文章预计将会是一个系列的文章,保存在 富文本编辑器专栏,有需要的同学可以关注一下。
2. 计划实施:你的第一个 prosemirror 编辑器
在开始之前,可以简单介绍一下 prosemirror 的几个核心模块,prosemirror 将一个编辑器 Editor
的实现拆分为了 4 个核心模块,分别是:
prosemirror-model
prosemirror-state
prosemirror-view
prosemirror-transform
它们有点像传统开发的 MVC 模式,model 模块主要用来定义数据,state 是数据层,是 model 中定义数据模型实例化后的数据,view 即编辑器视图,transform 类似 controller 控制器,处理数据后,提交更新 view 视图层。可能比喻不太恰当,但可以帮助快速理解四个模块的用途,这 4 个模块是 prosemirror 中必要的的模块。一般不会显示使用 prosemirror-transform 这个模块,而是会使用在 state 内部的 tr,在 prosemirror-state 模块有个有个 Transaction,继承了 Transform,通常我们操作文档都会使用 state.tr
获取到 Transaction 实例对文档进行操作后,将 tr 提交触发视图更新。
以下对比 MVC 模式快速帮助大家理解 prosemirror 核心模块之间的关系:
2.1 项目初始化
创建项目并安装核心模块:根据上述描述,暂不安装 prosemirror-transform
shell
# 创建一个 vite 项目,选择 vanilla (Typescript) 即可, 之后删除模板中的 counter demo
npm create vite@lates
# 安装 四个核心模块
npm i prosemirror-model prosemirror-state prosemirror-view
2.2 重中之重: Schema 的定义
在开发 prosemirror 项目中,第一件最重要的事情是先定义自己的数据模型,即 Schema,它代表了你的编辑器内容的结构,其中你可以规定编辑器中能够存在哪些节点,以及不同节点之间的关系。例如一个定义段落节点,它渲染成 html 时候使用 p 标签,从别处复制过来带 p 标签的,给他序列化为段落节点,段落节点中可以包含 0 个或多个 文本节点。
ts
// model.ts 文件命名暂时还是以 mvc 模式命名,方便理解,实际中 命名为 schema.ts 更好
import { Schema } from 'prosemirror-model';
export const schema = new Schema({
nodes: {
// 整个文档
doc: {
// 文档内容规定必须是 block 类型的节点(block 与 HTML 中的 block 概念差不多) `+` 号代表可以有一个或多个(规则类似正则)
content: 'block+'
},
// 文档段落
paragraph: {
// 段落内容规定必须是 inline 类型的节点(inline 与 HTML 中 inline 概念差不多), `*` 号代表可以有 0 个或多个(规则类似正则)
content: 'inline*',
// 分组:当前节点所在的分组为 block,意味着它是个 block 节点
group: 'block',
// 渲染为 html 时候,使用 p 标签渲染,第二个参数 0 念做 "洞",类似 vue 中 slot 插槽的概念,
// 证明它有子节点,以后子节点就填充在 p 标签中
toDOM: () => {
return ['p', 0]
},
// 从别处复制过来的富文本,如果包含 p 标签,将 p 标签序列化为当前的 p 节点后进行展示
parseDOM: [{
tag: 'p'
}]
},
// 段落中的文本
text: {
// 当前处于 inline 分株,意味着它是个 inline 节点。代表输入的文本
group: 'inline'
},
// 1-6 级标题
heading: {
// attrs 与 vue/react 组件中 props 的概念类似,代表定义当前节点有哪些属性,这里定义了 level 属性,默认值 1
attrs: {
level: {
default: 1
}
},
// 当前节点内容可以是 0 个或多个 inline 节点
content: 'inline*',
// 当前节点分组为 block 分组
group: 'block',
// defining: 特殊属性,为 true 代表如果在当前标签内(以 h1 为例),全选内容,直接粘贴新的内容后,这些内容还会被 h1 标签包裹
// 如果为 false, 整个 h1 标签(包括内容与标签本身)将会被替换为其他内容,删除亦如此。
// 还有其他的特殊属性,后续细说
defining: true,
// 转为 html 标签时,根据当前的 level 属性,生成对应的 h1 - h6 标签,节点的内容填充在 h 标签中("洞"在)。
toDOM(node) {
const tag = `h${node.attrs.level}`
return [tag, 0]
},
// 从别处复制进来的富文本内容,根据标签序列化为当前 heading 节点,并填充对应的 level 属性
parseDOM: [
{tag: "h1", attrs: {level: 1}},
{tag: "h2", attrs: {level: 2}},
{tag: "h3", attrs: {level: 3}},
{tag: "h4", attrs: {level: 4}},
{tag: "h5", attrs: {level: 5}},
{tag: "h6", attrs: {level: 6}}
],
}
},
// 除了上面定义 node 节点,一些富文本样式,可以通过 marks 定义
marks: {
// 文本加粗
strong: {
// 对于加粗的部分,使用 strong 标签包裹,加粗的内容位于 strong 标签内(这里定义的 0 与上面一致,也念做 "洞",也类似 vue 中的 slot)
toDOM() {
return ['strong', 0]
},
// 从别的地方复制过来的富文本,如果有 strong 标签,则被解析为一个 strong mark
parseDOM: [
{ tag: 'strong' },
],
}
}
})
这里在定义 schema 的时候,我们定义了两部分内容,nodes
与 marks
,简单理解两者的关系,node 代表文档中的某种节点,主要用来渲染内容,mark 更多为一些附加样式的定义,如上面的文本加粗,以及文本颜色,背景色,是否斜体等,这些 marks 是可以附加在 node 上的。向一段文本,可以同时是 斜体,加粗,红色,蓝色背景(即同时有 4 种 mark),但一个节点,它不可能同时既是一个段落节点,又是一个标题节点,不过节点之间可以嵌套,你可以定义在一个段落节点中,内容可以是 heading,但此时也只是包含关系,不是"既是A也是B"的关系。这就是他们的区别,后续到 Schema 会详细说明。
2.3 Prosemirror 中的数据与视图
还是对应到 mvc 的概念,有了 Model,要想在视图上展示一些内容,还需要有数据与视图
ts
// view.ts
import { EditorView } from 'prosemirror-view'
import { EditorState } from 'prosemirror-state'
import { schema } from './model'
export const setupEditor = (el: HTMLElement | null) => {
if (!el) return;
// 根据 schema 定义,创建 editorState 数据实例
const editorState = EditorState.create({
schema,
})
// 创建编辑器视图实例,并挂在到 el 上
const editorView = new EditorView(el, {
state: editorState
})
console.log('editorView', editorView)
}
// main.ts
document.querySelector<HTMLDivElement>('#app')!.innerHTML = /*html*/`
<div>
<h3>从第一个 prosemirror 案例开始认识它</h3>
<div id="editorContainer"></div>
</div>
`
// 在 main.ts 中,调用 stetupEditor,将编辑器 view 挂在在 editorContainer 中
setupEditor(document.querySelector('#editorContainer'))
css
/* style.css 稍微对编辑器增加点样式 */
#editorContainer {
border: 1px solid #646cffaa;
padding: 0px 12px;
}
#editorContainer .ProseMirror {
outline: none;
caret-color: #646cffaa;
}
在上述代码中,通过 EditorState.create
静态方法,创建了一个以 schema
为数据规范的 state 实例,state 中默认数据是空数据,但这些数据必须符合 shema 的规定。
通过创建 EditorView
实例,将数据绑定到视图上,并且将视图挂在到 el
元素上,视图会根据 schema 的规定以及 state 中的数据定义,将数据按 schema 的规定渲染为 html,最终挂在到 el 元素上。
在掘金上随便复制点文本,粘贴到我们的编辑器中,我们发现,h3, h4 标签内容成功复制进来,p 标签以及其中内容也成功复制进来,这就是我们定义的 schema 发挥了作用,我们 schema 中定义了段落节点与标题节点,以及他们从粘贴进来的富文本中应该如何解析,渲染后应该渲染成什么标签。其中我们没有定义的内容,如 focusNode
与 anchorNode
,他们本身是用 code
标签包裹的,因为我们没有定义对应的 mark 或 node (应该是 mark)来规定复制进来的 code 标签应该如何解析,所以,这里就过滤掉了它,只剩下了纯文本。
如果复制寄哪里的内容带图片,我们会发现,这里图片也没了,被转为了一个空段落,这也是因为我们没有定义图片对应的 node,所以编辑器不知道如何解析它。
到这里,我们可以大致明白 prosemirror 中的几个核心概念,schema 是用来规定编辑器文档内容展示的一系列规则,它会影响到内容的渲染,state 是编辑器视图数据,它是受 schema 约束的,任何输入进来内容进来都会根据 schema 的约束,规范化为规定格式的数据。EditorView 可以根据 schema 的规定以及给定的 state 数据,将内容渲染为 html,展示给用户。
小疑问:有人可能会问,不会是所有的节点都要定义 schema 吧,节点的种类那么多。很遗憾地告诉你,没错,所有的内容都需要定义 schema 或 marks,但通常一些常见的,基础的 schema可以靠 npm 包来,如果是自定义的一些块,还是需要自己完全定义 schema。
2.3 神操作:prosemirror 的插件系统
除了上面的核心模块,prosemirror 中还存在一个超级无敌核心的机制,插件系统。有了插件系统,prosemirror 的功能才得以强化,我们可以编写不同的插件,增加编辑器的交互性。插件的详细内容后续会专门写文章来说明,此处我们需要了解有这么一个插件机制。
在上面实现的编辑器中,你可能会发现,按下回车,无法换行,从别的地方复制粘贴进来的多行内容,只能删除某行的文本,删除到开头时,这一行删不了,使用 ctrl/cmd + z
, shift + ctrl/cmd + z
,也无法进行 undo, redo 撤销和重做。这是因为 prosemirror 的核心模块只关心核心的 schema 定义,数据解析,简单的输入等,对于特殊按键,有什么功能,它完全不关关心,这些功能需要通过插件实现。实现机制以很简单,拦截用户按键,如果按下的是特殊的键,就执行不同的功能,如按下回车,就进行换行等。
shell
# 安装 prosemirror-keymap, prosemirror-commands, prosemirror-history
npm i prosemirror-keymap prosemirror-commands prosemirror-history
修改 view.ts 代码
ts
import { EditorView } from 'prosemirror-view'
import { EditorState } from 'prosemirror-state'
import { schema } from './model'
// 新增以下导入
import { keymap } from 'prosemirror-keymap'
// baseKeymap 定义了对于很多基础按键按下后的功能,例如回车换行,删除键等。
import { baseKeymap } from 'prosemirror-commands'
// history 是操作历史,提供了对保存操作历史以及恢复等功能,undo,redo 函数对应为进行 undo 操作与 redo 操作,恢复历史数据
import { history, undo, redo } from 'prosemirror-history'
export const setupEditor = (el: HTMLElement | null) => {
if (!el) return;
// 根据 schema 定义,创建 editorState 数据实例
const editorState = EditorState.create({
schema,
// 新增 keymap 插件。
plugins: [
// 这里 keymap 是个函数,运行后,会生成一个插件,插件功能即将基础按键绑定到对应的功能上,例如回车换行,删除键等。
keymap(baseKeymap),
// 接入 history 插件,提供输入历史栈功能
history(),
// 将组合按键 ctrl/cmd + z, ctrl/cmd + y 分别绑定到 undo, redo 功能上
keymap({"Mod-z": undo, "Mod-y": redo}),
]
})
// 创建编辑器视图实例,并挂在到 el 上
const editorView = new EditorView(el, {
state: editorState
})
console.log('editorView', editorView)
}
此时刷新页面,你会发现,能够正常换行了,也能够正常进行删除行了,对于操作历史 通过 ctrl/cmd + z
, ctrl/cmd + y
也可以进行undo redo。这就是 prosemirror 的插件系统,通过插件,我们可以对 prosemirror 进行各种功能的扩展。假如你不想用 prosemirror-keymap,你也可以自己开发一个 keymap 的插件,这些都是完全可拔插的,任何功能都可以自行替换。
3. 小结
本文旨在通过从 prosemirror 的顶层设计入手,以一个小案例,拉通 prosemirror 的核心概念,并理清其中的关系,帮助大家快速认识和入门 prosemirror,对其中的核心概念未做深刻细致讲解,它的细节将在后续内容中不断给出。同时也是对之前使用的一些小总结,也方便自己深入理解 prosemirror。
关于评论区:没有足够时间进行评论查看与解答,如有问题,可查阅官方文档。
关于更新计划:没有固定时间,有空就会总结,按自己节奏来。篇幅会控制尽量不要太长,方便阅读。