1. 了解编辑器
1.1 分类
编辑器目前可以分为以下三类:
-
textArea 远古时代的编辑器实现,起始阶段,最简单的如多行文本
-
contentEditable 浏览器提供了基础功能
- draftjs (react)
- quilljs (vue)
- prosemirror(util)
-
脱离浏览器自带编辑能力,独立做光标和排版引擎
- office 、wps、 google doc
1.2 优缺点
-
第一类,利用了textarea标签。
优点:textarea支持多行文本输入,满足了我们编辑的很大需求。
缺点:然而,textarea不能像div一样高度自适应,高度保持不变,内容大于高度时就会出现滚动条。而且textarea只支持文本输入,随着现在越来越关注用户体验,需求也越来越多,很多时候我们需要在编辑区域插入图片,链接,视频。
-
第二类,利用了浏览器自带的contenteditable属性。
优点:在HTML中,任何标签使用了contenteditable为true的属性后,都可以被编辑,就可以在编辑区域插入图片,链接,视频了。并且调用window.getSelection()就可以得到页面中的文本选区对象,插入光标的位置也可通过 Selection 获取。
缺点:但基于contenteditable没办法控制用户的所有输入,例如光标定位问题(无法移动到空标签里等等),需要特殊问题特殊处理,第二类编辑器都会内部封装好这些问题的处理方法,第三方输入法、壳浏览器会让用户输入不可控,而且浏览器的排版有限制。
-
第三类,不用浏览器自带的contenteditable属性,而是自主实现光标、选区。
优点:自主实现光标、选区,是将光标位置放textarea接受键盘输入,输入完成后变更数据、渲染视图(换言之,知道用户输入了什么,如何排版自主控制),如此实现各种个性化的文字排版,图文布局,突破浏览器排版限制。
缺点:优点很完美,但技术难度很高,相当于自研浏览器、数据库。
2. contenteditable
1.1 使用
<div id="contentEditable" contenteditable="true">这里可编辑</div>
通过设置一个dom的contentediable属性为true,可以让这个dom变得可编辑,鼠标点击在dom的位置,光标随之出现。
1.2 关于document.execComand
当一个HTML元素的contenteditable属性被设置为true时,document.execCommand() 方法便可使用。通过该方法,你可以运行相关commands 来操作可编辑区域的内容。其中大多数命令都会影响文档的选择,例如,给文本提供一个样式(加粗,倾斜等)、插入新元素(如增加一个链接)、影响一整行文本(如缩进排版)。当使用contentEditable后,调用execCommand()方法将影响当前处于活动状态的可编辑元素。
也就是说,document.execComand提供了从外部修改DOM的能力。例如:
const editor = document.getElementById("contentEditable");
document.execCommand("formatBlock", false, "h1"); // 格式化内容为标题
document.execCommand("bold", false); // 加粗选中文字
但目前document.execCommand()方法已废除
1.2.1 废除主要原因
- 第一个就是安全问题,在用户未经授权的情况下就可以执行一些敏感操作,这就很恐怖了;
- 第二个问题是因为这是一个同步方法,而且操作了 DOM 对象,会阻塞页面渲染和脚本执行,因当初还没 Promise,所以没设计成异步,挖坑了。
- 此外,因为各个浏览器在标记生成上的不同,因此跨浏览器使用
contenteditable一直以来都是痛点,例如一些看起来十分简单的事情,如: 当你按下Enter/Return键在可编辑区域中创建一个新的文本行时,不同主流浏览器对此有不同处理(Firefox 插入<br>、IE/Opera将使用<p>、 Chrome/Safari 将使用<div>。
所以,第二类编辑器通常都是自主实现从外部修改DOM的功能,而不用document.execCommand()。
3. prosemirror
前面提到,contentEditable有一些不合理之处,而且使用document.execCommand方法操作可编辑区域的内容也有问题。因此prosemirror 在contentEditable 的基础上,在 DOM之上加一层文档模型的抽象。contentEditable负责各浏览器上表现一致的操作,表现不一致的通过抽象层来处理。同时文档也具备了状态,所有修改编辑器内容的操作都是可记录的,通过状态的变更,来反映到视图的更新,而无需通过document.execCommand方法来更新可编辑区内容。
3.1 文档模型设计
类比于浏览器的DOM,编辑器维护了自己的一套节点层级结构。 prosemirror不是直接操作DOM对象的,而是通过js对象来进行dom树的抽象,形成一份文档模型。它有个很好的特点:尽量扁平化。
举个例子:
<p>This is <strong>strong text with <em>emphasis</em></strong></p>
如果要修改DOM元素,首先操作DOM树很耗费性能,而且操作DOM树是一件很复杂的事情,所以应该通过js对象来进行dom树的抽象,形成一份文档模型。如下所示:
const model = {
type: 'document',
content: [
{
type: 'paragraph',
content: [
{
text: 'This is',
type: 'text'
},
{
type: 'strong',
content: [
{
type: 'text',
text: 'strong text with'
},
{
type: 'em',
content: [
{
text: 'emphasis',
type: 'text'
}
]
}
]
}
]
}
]
};
那么有了这个模型对象,存在以下问题:
问题1
我们可以通过定位到具体某一段文本,假设定位到斜体emphasis的那段文本,如下:
model.content[0].content[1].content[2].content[0].text
又假设我们要定位到'strong text with'这段文本,我们又得重新更新路径为:
model.content[0].content[1].content[1].text
问题2
当我们考虑标签嵌套的这样一种情况:
<p>This is <strong>strong text with</strong><strong><em>emphasis</em></strong></p>
<p>This is <strong>strong text with<em><strong>emphasis</strong></em></strong></p>
两者在浏览器上展示的UI都是相同的,但是我们根据模型对象去定位到加粗斜体的文本,两者的路径却不一样。
于是prosemirror的文档模型做了一个优化。用下面的对象来解释上面这段文本
const model = {
type: 'document',
content: [
{
type: 'paragraph',
content: [
{
text: 'This is',
type: 'text'
},
{
type: 'text',
text: 'strong text with',
marks: [{ type: 'strong' }]
},
{
type: 'text',
text: 'emphasis',
marks: [{ type: 'strong' }, { type: 'em' }]
}
]
}
]
};
上面model对象引入了 marks(标记),marks数组里的type都是一些内联元素,来表示附加到节点上的额外信息。这样寻找emphasis 的路径也就固定下来了,并且它允许我们使用数组下标的偏移量而不是一个树节点的路径来表示其所处段落中的位置。
当我们要改变内容的style操作就变得很容易,只需要从marks数组上插入或删除,同时splitting 内容也变得很容易了,而不是以一种笨拙的树的操作来修改内容。
于是prosemirror的一个文档数据结构看起来像下面这样
const Node = {
type: NodeType {
name: String, // 该node的名称
schema: Schema { // 该node所属的schema指针
nodes: Object<NodeType>, // 一个 schema 中节点名和节点类型对象的键值对映射
marks: Object<MarkType>, // 一个 mark 名和 mark 类型对象的键值对映射
...
},
spec: NodeSpec, // 当前类型的配置对象
attrs: Object, // 允许的attributes
...
},
// 一个 fragment 表示了节点的子节点集合
content: Fragment {
content: [Node, Node,...], // 子节点数组
size: Number, // fragment 的大小
...
},
attrs: Object, // 一个键值对,在该node上的attributes。允许的和需要的 attributes 取决于 节点类型
marks: [Mark, Mark, ...], // 应用到当前节点的 marks,也就是内联节点
...
}
3.2 schema
每个 Prosemirror document 都有一个与之相关的 schema. 一般情况下就只有一个document。这个 schema 描述了存在于 document 中的 nodes 类型,和 nodes 们的嵌套关系。Node types(和 mark types) 只会被每个 schema 创建一次,它们知道自己是属于哪个 schema。
例如:
const schema = new Schema({
// 键是节点名,对象的键是对应的 NodeSpec
nodes: {
doc: {content: "paragraph+"},
paragraph: {content: "text*"},
text: {inline: true},
...
},
marks: {...}
})
上述代码定义了一个允许 document 包含一个或更多 paragraphs 的 schema, 每个 paragraph 又能包含任意数量的 text.
3.4 模块间的关系
了解过prosemirror都知道,prosemirror主要的模块有4个,分别是prosemirror-model、prosemirror-transform、prosemirror-state、prosemirror-view。
-
prosemirror-model:定义了 ProseMirror 的内容模型,也就是schema的定义,并定义了如何根据schema生成文档结构。
-
prosemirror-transform:这个模块定义了一种修改文档的方式,以允许修改被记录、回放、重新排序。但在我们实际写代码过程中却一般不会用到prosemirror-transform,而是使用Transaction,Transaction是Transform的子类,继承了prosemirror-transform中的所有功能,因为在实际场景下不仅需要追踪对文档的修改,还要追踪 state 的其他变化, 比如选区更新以及 storedmarks 的调整。此外,你还可以在 transaction 中储存 metadata 信息, metadata 信息是一种很有用的信息形式以告诉客户端代码或者该 transaction 所代表的含义,因为可以依靠metadata信息找到该transaction并获取到该transaction携带的信息,然后它们以此来相应的更新它们自己的 state。正因为要更新state,所以transaction在prosemirror-state包里。
-
prosemirror-state:为了允许修改被记录、回放、重新排序,每次transaction就会生成一个新的state,state可理解为当前编辑器的一些状态,包括当前文档结构、selection选区、plugin插件系统, 因此为了实现撤回、协同合作等功能,state应是不可变的。每次只能通过触发事务(transaction)创建新的state,描述一个新的编辑器状态,随后用新状态来更新视图view。
(为啥plugin放state里?因为plugin system则为编辑器提供了额外功能如键盘绑定等等,通常依赖transaction做一些更新页面内容、选区更新等等操作,从而更新当前state)
-
prosemirror-view:很明显就是根据文档结构生成页面内容,并且根据编辑器的state更新页面内容,同时处理用户触发的事件。Decorations装饰器因为是用来影响文档的展现但是又不实际改变文档内容的一种方式,能影响当前view视图,所以也放在了prosemirror-view包里。
4. 后记
说实话一开始看prosemirror确实很抽象,文档很苍白。这时候很推荐实战,如果是react选手,推荐学习仓库rich-markdown-editor,一边看代码一边看文档,能对使用prosemirror搭建一个编辑器有个整体认识,不过目前仓库已经是read-only了,(发现了一个bug,可惜fork不了)有问题可以一起交流~