WYSISYN编辑器 Prosemirror 入门

5,105 阅读9分钟

为什么选择prosemirror

编辑器一向是前端领域的一个难点,一款成熟的编辑器,需要涉及许多方面的东西。

到底有多少东西...这个可以看看掘金上一位大哥在知乎上的回答

至于为什么要踩这个天坑,是公司想要一个所见即所得的markdown编辑器,不需要markdown源码,要有用markdown语法一样的输入规则,最后还需要输出markdown文档作为存储,在次之上还需要一些制定的需求。这就要求这个选型应该是一个灵活,可配置模块化编辑器框架,而不是一个开箱即可用的一个应用

在选型的时候,之前公司已经有人用prosemirror进行一些特殊编辑器的开发(然而那位同事在我没来之前就走了),同时考虑的还有slate.js,上面那位大哥也有在掘金上发布过一篇文章。那为什么不选择slate.js呢(另外还有个Draft.js没有去了解过)。原因很简单,就是因为我们的技术栈是Vue而不是Reactslate.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是什么?

我们来看看上面那段代码做了什么事情。首先,预定了一个conetentid的内容,这个在最后展示是不可见的,为的是把已有的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也有自己一套高效的更新算法来转化NodeDom

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换成defaultMarkdownParserplugins用默认的设置就可以了,然后再用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