为什么选择prosemirror
编辑器一向是前端领域的一个难点,一款成熟的编辑器,需要涉及许多方面的东西。
到底有多少东西...这个可以看看掘金上一位大哥在知乎上的回答
至于为什么要踩这个天坑,是公司想要一个所见即所得的markdown编辑器,不需要markdown源码,要有用markdown语法一样的输入规则,最后还需要输出markdown文档作为存储,在次之上还需要一些制定的需求。这就要求这个选型应该是一个灵活,可配置模块化编辑器框架,而不是一个开箱即可用
的一个应用
。
在选型的时候,之前公司已经有人用prosemirror
进行一些特殊编辑器的开发(然而那位同事在我没来之前就走了),同时考虑的还有slate.js
,上面那位大哥也有在掘金上发布过一篇文章。那为什么不选择slate.js
呢(另外还有个Draft.js
没有去了解过)。原因很简单,就是因为我们的技术栈是Vue
而不是React
。slate.js
依赖于React
作为视图层,作为一个Vue
应用,还是不想再专门引入一个React
来为slate.js
服务。
综上的原因,就踩上了这个天坑。虽然我没有用过slate.js
,但是根据热度以及在github上的star也好,活跃度也好,我觉得应该不会比slate.js
小,但是它能产出的编辑器,不会比slate.js
差。
但正因为活跃度等原因,你在谷歌或者百度上搜索,是没有关于prosemirror
的任何中文资料
的,我一度认为这个框架在国内就没人用,直到有一天在discuss看到了上面说的那位大佬的头像,我才知道原来国内还是有人用的。理所当然的,也不会有对应的中文文档,踩了坑也只能上discuss或者issue
搜索提问。但万幸的是,作者非常热心,几乎每一个问题都会回答你,就算是非常入门级的问题,这一点在开发上帮了我很多忙。
以下的内容,几乎是官网的文档,通过自己理解和简化写下来的,有兴趣的可以去官网了解更加详细的内容。
prosemirror简介
如果你觉得prosemirror
很陌生,那你也许听过大名鼎鼎的codemirror
。对,就是那个在浏览器上的代码编辑器,两个是同个作者,一位非常有实力的德国人Marijn。上面说到的slate
也是有些核心的概念例如schema
是来自于prosemirror
的。
prosemirror
不是一个大而全的框架,甚至于你去npm上搜索prosemirror
压根没有这个包。
prosemirror
由无数个小的模块组成,正如它官网上说的类似于乐高一样堆叠成一个健壮编辑器
The core library is not an easy drop-in component—we are prioritizing modularity and customizeability over simplicity, with the hope that, in the future, people will distribute drop-in editors based on ProseMirror. As such, this is more of a lego set than a matchbox car.
它的核心库有
-
prosemirror-model
:定义编辑器的文档模型,用来描述编辑器内容的数据结构 -
prosemirror-state
:提供描述编辑器整个状态的数据结构,包括选择,以及从一个状态转移到下一个状态的事务处理系统。 -
prosemirror-view
:实现一个用户界面组件,该组件在浏览器中将给定的编辑器状态显示为可编辑元素,并处理用户与该元素的交互。 -
prosemirror-transform
:包含以可记录和重放的方式修改文档的功能,这是state模块中事务的基础,并使撤消历史记录和协作编辑成为可能。
看完这些描述是不是感觉很熟悉,一个非常像React
的一组核心库。他们构成了整个编辑器的基础。当然,除了核心库,还需要各种各样的库来实现快捷键prosemirror-commands
、编辑历史prosemirror-history
等等。
实现一个小编辑器
这是一个功能非常有限的,只有一些基本的按键(例如enter换行
、bacakspace删除
)等,然后我们再加上一个ctrl-z
撤回和ctrl-y
重做。
一开始觉得是个小demo,就用了
parcel
打包,发现会报错,第一次用parcel
,不知道是我问题还是parcel
问题。
import {EditorState} from "prosemirror-state"
import {EditorView} from "prosemirror-view"
// schama,校验规则
import {schema} from "prosemirror-schema-basic"
// 历史记录以及撤回重做
import {undo, redo, history} from "prosemirror-history"
// 一个
import {keymap} from "prosemirror-keymap"
import {baseKeymap} from "prosemirror-commands"
//
let content = document.getElementById("content")
// 生成一个state
let state = EditorState.create({
doc: DOMParser.fromSchema(schema).parse(content),
schema,
plugins: [
history(),
keymap(baseKeymap),
keymap({"Mod-z": undo, "Mod-y": redo})
]
})
// 生成视图
let view = new EditorView(document.getElementById('prosemirror'), {state})
复制代码
这段代码,把content的内容转化为编辑器的初始文本,作为初始的编辑状态。只能够做简单的编辑,例如删除、撤回、换行等。
parser是什么?
我们来看看上面那段代码做了什么事情。首先,预定了一个conetent
id的内容,这个在最后展示是不可见的,为的是把已有的html文档
先存在dom里。紧接着,通过DOMParse
解析顺着schema
(下面会说这是什么)这个html文本
,获得一个Node
类型的对象,这个对象就可以传入doc属性
作为一个初始的文本数据渲染成编辑器的可编辑文本。
这里的DOMParse
就是一个作为把DOM渲染成Node
对象的一个解析器。除了DOMParse
,还有一个解析器就是MarkdownParser
,专门把markdown文档转化为Node
类数据。
那么有解析器,就有对应的序列器,调用EditorState.JSON()
可以把当前状态的doc
序列化成JSON格式,便于存储。
schema是什么?
schema
是一套描述文档和Dom之间的关联的一套转化规则,如何把DOm转化为Node
或者说Node
转化为Dom,这是个关键,下面是一个基本的标题的schema
// heading的schema
heading: {
// 可选的属性
attrs: {level: {default: 1}},
// 节点内容的类型,是行还是块
content: "inline*",
// 自身的类型,是行还是块
group: "block",
// 解析Dom的规则以及属性
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}}],、
// 生成Dom的规则
toDOM(node) { return ["h" + node.attrs.level, 0] }
},
复制代码
这样就是一个描述一个标题的文本规则,不过没有这个文本规则,解析器或者序列器不知道如何去解析。任何一个在编辑器中出现的Dom以及任何一个需要转化成Dom的节点类型,都需要有一个对应的schema
否则无法编译。
schema
可以自行创建或者在现有的schema
上进行添加。一个健壮的schema
对每一个属性的设置都有较高的要求,在这里不举例子了,免得带偏,可以自行上官网学习。
Node是什么?
Node
类构成了Prosemirror文档的节点树,它的子节点也是Node
类。Node
类并不能直接被改变,是一个持久的数据结构,类似于React
中的state
,需要通过apply
一个transaction
类才能够改变doc
的结构。而Node
的结构又非常像Virtual Dom
,都具有树型和递归,通过实例解构来描述Dom,而且prosemirror
也有自己一套高效的更新算法来转化Node
和Dom
Node
的属性非常多,比如在文档的位置、子节点的数量、节点大小、文本内容等等等等,在许多情况下,这些属性都为实现某些特定的功能提供了非常大的帮助。
Transaction是什么?
transaction
是一个描述编辑器状态改变的一个数据类型。在Prosemirror
中,调用EditorView.updateState
可以更新整个编辑器的状态,就算是敲打一个空格,都必须要通过state进行更新。那么,如果每次都用DOMParse
创建新的Node
来形成新的state
,历史记录等东西必然不会保留,而且在Prosemirror
中,到真正调用EditorState.apply
的过程中,会经过很多的Plugins
(如果有的话)去加工这个transaction
,所以一定要经过EditorState.apply
去应用一个transaction
生成一个新的state
,接着调用,才可以真正改变整个编辑器的状态,并保存好整个的状态,在编辑的时候也是如此。我们可以先看看一个例子
let view = new EditorView(document.body, {
state,
// 这是一个钩子函数,最后应用transaction的函数
dispatchTransaction(transaction) {
console.log("Document size went from", transaction.before.content.size,
"to", transaction.doc.content.size)
// 应用transaction,并生成一个新的state
let newState = view.state.apply(transaction)
// 更新state
view.updateState(newState)
}
})
复制代码
dispatchTransaction
实际上是在调用EditorState.apply
前的最后一个方法,这里也可以不调用dispatchTransaction
,默认进行了更新。在这里的作用是,每次更新(不管是编辑还是插入删除等操作)都会log一段文字,仅此而已。如果不进行apply和update的操作,将会报错。可以通过Editor.tr
获取实时的transaction
。
keymap、历史记录
keymap
是键盘输入规则的插件,history是历史记录的插件,这个略过。
核心内容总结
到此为止,核心内容就已经介绍完毕,当然,核心内容只能作为对prosemirror
的一个浅显认知,好让我们在后续的编辑器开发的时候,不会不明白它到底是怎么的一个运作原理。
现在缺少的有一些输入规则,有这些输入规则,才能像写markdown一样实现WYSIWYN编辑器,还有顶部的操作栏等等。这些都是编辑器的一部分,不过因为不是核心库,这里就不讲了。官方有一个example-setup
一个设置样例,官方同样推荐通过这个样例来改造成符合我们需求的设置
接下来,就让我们偷懒地实现一个markdown的编辑器。例子同样是来自于官网。
实现一个markdown编辑器
很简单,只需要把parser换成defaultMarkdownParser
,plugins
用默认的设置就可以了,然后再用prosemirror-example-setup
的默认样式,一个WYSIWYN编辑器就完成了。
class ProseMirrorView {
constructor(target, content) {
this.view = new EditorView(target, {
state: EditorState.create({
// 用默认的markdown parser解析markdown文档
doc: defaultMarkdownParser.parse(content),
// 设置样例
plugins: exampleSetup({schema})
})
})
}
// 暴露两个常用方法,便于调用
focus() { this.view.focus() }
destroy() { this.view.destroy() }
}
new ProseMirrorView(document.getElementById('prosemirror'), '# hello')
复制代码
当然这只是一个非常简单的markdown编辑器,官方给出的defaultMarkdownParser
只是用的CommonMark
标准,很多的常用markdown语法都没有。我们可以从中进行非常多的自定义。
defaultMarkdownParser的markdown解析器是用markdown-it的,原理是解析成token后,通过schema再进行转化。所以如果想要拓展markdown,需要懂得markdown-it或者其他的markdown解析器。
总结
本篇文章简略地介绍了prosemirror
的一些思想和核心内容,这只是涉及一些皮毛,并不是完全展现其魅力。在它的论坛上,有许多的开发者贡献了许多令人拍案叫好的插件或者成熟的编辑器,都非常值得去学习借鉴。希望能更加深入理解篇prosemirror
。