关于富文本编辑器,不少同窗没用过也听过了。是你们都不想去踩的坑。到底有多坑呢?javascript
我这里摘了一部分一位大哥在知乎上的回答,若是有兴趣,能够去看看。 要让一款编辑器达到商业级质量,从目前接触到主要的例子来看,独立开发时间太长:java
Quill
从 2012 年收到第一个 Issue 到 2016 年发布 1.0 版本,已通过去了四年。Prosemirror
做者在 2015 年正式开源前筹款维护时已经开发了半年,而到发布 1.0 版本时,已通过去了接近三年。上面这几个单人主导的编辑器项目要达到稳定质量,时间是以年为单位来计算的。考虑到目前互联网“下周上线”的节奏,动辄几年的时间是不划算的。因此在人力,时间合理性各方面的约束下,使用开源框架是最好的选择。node
想要一款配置性强,模块化的编辑器,这就决定了这不是一个开箱即用的应用,而Quill
集成了许多样式和交互逻辑,已经算是一个应用了,有时一些制定需求不能彻底知足。Slate
是基于的React
视图层的,咱们的技术栈是Vue
,就不作考虑了,之后有机会能够研究一下,因此最后选择了prosemirror
,但另外两款依然是很强大值得去学习的编辑器框架。git
因为prosemirror
目前使用搜索引擎能搜出来的中文资料几乎没有,遇到问题也只能去论坛
,issue
里面搜,或者向做者提问。如下的内容是从官网,加上本身在使用过程当中对它的理解简化出来的。但愿看完后,能让你对prosemirror
产生兴趣,并从做者的设计思路中,学到东西,一块儿分享。github
A toolkit for building rich-text editors on the webweb
prosemirror
的做者 Marijn 是 codemirror
编辑器和 acorn
解释器的做者,前者已经在 Chrome
和 Firefox
自带的调试工具里使用了,后者则是 babel
的依赖。正则表达式
prosemirror
不是一个大而全的框架, 它是由无数个小的模块组成,它就像乐高同样是一个堆叠出来的编辑器。数组
它的核心库有:浏览器
prosemirror-model
: 定义编辑器的文档模型,用来描述编辑器内容的数据结构prosemirror-state
: 提供描述编辑器整个状态的数据结构,包括selection
(选择),以及从一个状态到下一个状态的transaction
(事务)prosemirror-view
: 实现一个在浏览器中将给定编辑器状态显示为可编辑元素,而且处理用户交互的用户界面组件prosemirror-transform
: 包括以记录和重放的方式修改文档的功能,这是state
模块中transaction
(事务)的基础,而且它使得撤销和协做编辑成为可能。此外,prosemirror
还提供了许多的模块,如prosemirror-commands
基本编辑命令,prosemirror-keymap
键绑定,prosemirror-history
历史记录,prosemirror-inputrules
输入宏,prosemirror-collab
协做编辑,prosemirror-schema-basic
简单文档模式等。bash
如今你应该大概了解了它们各自的做用,它们是整个编辑器的基础。
import { schema } from "prosemirror-schema-basic"
import { EditorState } from "prosemirror-state"
import { EditorView } from "prosemirror-view"
let state = EditorState.create({ schema })
let view = new EditorView(document.body, { state })
复制代码
咱们来看看上面的代码干了什么事,从第一行开始。prosemirror
要求指定一个文档符合的模式。因此从prosemirror-schema-basic
引入了一个基本的schema
。那么这个schema
是什么呢?
由于prosemirror
定义了本身的数据结构来表示文档内容。在prosemirror结构
与HTML的Dom结构
之间,须要一次解析与转化,这二者间相互转化的桥梁,就是咱们的schema
,因此要先了解一下prosemirror
的文档结构。
prosemirror
的文档是一个Node
,它包含零个或多个child Nodes
的Fragment(片断)
。
有点相似浏览器DOM
的递归和树形的结构。但它在存储内联内容方式上有所不同。
<p>This is <strong>strong text with <em>emphasis</em></strong></p>
复制代码
在HTML
中,是这样的树结构:
p //"this is "
strong //"strong text with "
em //"emphasis"
复制代码
在prosemirror
中,内联内容被建模为平面的序列,strong、em(Mark)
做为paragraph(Node)
的附加数据:
"paragraph(Node)"
// "this is " | "strong text with" | "emphasis"
"strong(Mark)" "strong(Mark)", "em(Mark)"
复制代码
prosemirror
的文档的对象结构以下
Node:
type: NodeType //包含了Node的名字与属性等
content: Fragment //包含多个Node
attrs: Object //自定义属性,image能够用来存储src等。
marks: [Mark, Mark...] // 包含一组Mark实例的数组,例如em和strong
复制代码
Mark:
type: MarkType //包含Mark的名字与属性等
attrs: Object //自定义属性
复制代码
prosemirror
提供了两种类型的索引
dom结构
类似,你能够利用child
或者childCount
等方法直接访问到子节点image
)也算一个标记例若有一个HTML
片断为
<p>One</p>
<blockquote><p>Two<img src="..."></p></blockquote>
复制代码
则计数标记为
0 1 2 3 4 5
<p> O n e </p>
5 6 7 8 9 10 11 12 13
<blockquote> <p> T w o <img> </p> </blockquote>
复制代码
每一个节点都有一个nodeSize
属性表示整个节点的大小。手动解析这些位置涉及到至关多的计数,prosemirror
为咱们提供了Node.resolve
方法来解析这些位置,而且可以获取关于这个位置更多的信息,例如父节点是什么,与父节点的偏移量,父节点的祖先是什么等一些其它信息。
了解了prosemirror
的数据结构,知道了schema
是两种文档间转化的模式,回到刚才的地方,咱们从prosemirror-schema-basic
中引入了一个基本的schema
,那么这个基本的schema
长什么样呢?经过查看源码最后一行
export const schema = new Schema({nodes, marks})
复制代码
schema
是Schema
经过传入的nodes
, marks
生成的实例。 而在实例以前的代码,都是在定义nodes
和marks
,将代码折叠一下,发现nodes
是
{
doc: {...} // 顶级文档
blockquote: {...} //<blockquote>
code_block: {...} //<pre>
hard_break: {...} //<br>
heading: {...} //<h1>..<h6>
horizontal_rule: {...} //<hr>
image: {...} //<img>
paragraph: {...} //<p>
text: {...} //文本
}
复制代码
marks
是
{
em: {...} //<em>
link: {...} //<a>
strong: {...} //<strong>
code: {...} //<code>
}
复制代码
它们表示编辑器中可能会出现的节点类型以及它们嵌套的方式。它们每一个都包含着一套规则,用来描述prosemirror文档
和Dom文档
之间的关联,如何把Dom
转化为Node
或者Node
转化为Dom
。文档中的每一个节点都有一个对应的类型。 从最上面开始doc
开始看:
doc: {
content: "block+"
}
复制代码
每一个schema
必须定义一个顶层节点,即doc
。content
控制子节点的哪些序列对此节点类型有效。 例如"paragraph"
表示一个段落,"paragraph+"
表示一个或多个段落,"paragraph*"
表示零个或多个段落,你能够在名称后使用相似正则表达式的范围。同时你也能够用组合表达式例如"heading paragraph+"
,"{paragraph | blockquote}+"
。这里"block+"
表示"(paragraph | blockquote)+"
。 接着看看em
:
em: {
parseDOM: [
{ tag: "i" },
{ tag: "em" },
{ style: "font-style=italic" }
],
toDOM: function() {
return ["em"]
}
}
复制代码
parseDOM
与toDOM
表示文档间的相互转化,上面的代码有三条解析规则:
<i>
标签<em>
标签font-style=italic
的样式当匹配到一条规则时,就呈现为HTML
的<em>
结构。
同理,咱们能够实现一个下划线的mark
:
underline: {
parseDOM: [
{ tag: 'u' },
{ style: 'text-decoration:underline' }
],
toDOM: function() {
return ['span', { style: 'text-decoration:underline' }]
}
}
复制代码
Node
和Mark
均可以使用attrs
来存储自定义属性,好比image
,能够在attrs
中存储src
,alt
, title
。
回到刚才
import { schema } from "prosemirror-schema-basic"
import { EditorState } from "prosemirror-state"
import { EditorView } from "prosemirror-view"
let state = EditorState.create({ schema })
let view = new EditorView(document.body, { state })
复制代码
咱们使用EditorState.create
经过基础规则schema
建立了编辑器的状态state
。接着,为状态state
建立了编辑器的视图,并附加到了document.body
。这会将咱们的状态state
呈现为可编辑的dom节点
,并在用户键入时产生transaction
。
当用户键入或者其余方式与视图交互时,都会产生transaction
。描述对state
所作的更改,而且能够用来建立新的state
,而后更新视图。
下图是prosemirror
简单的循环数据流data flow
:编辑器视图显示给定的state
,当发生某些event
时,它会建立一个transaction
并broadcast
它。而后,此transaction
一般用于建立新state
,该state
使用updateState
方法提供给视图 。
DOM event
↗ ↘
EditorView Transaction
↖ ↙
new EditorState
复制代码
默认状况下,state
的更新都发生在底层,可是,你能够编写插件plugin
或者配置视图来实现。例如咱们修改下上面建立视图的代码:
// (Imports omitted)
let state = EditorState.create({schema})
let view = new EditorView(document.body, {
state,
dispatchTransaction(transaction) {
console.log("create new transaction")
let newState = view.state.apply(transaction)
view.updateState(newState)
}
})
复制代码
为EditorView
添加了一个dispatchTransaction
的prop
,每次建立了一个transaction
,就会调用该函数。 这样写的话,每一个state
更新都必须手动调用updateState
。
prosemirror
的数据结构是immutable
的,不可变的,你不能直接去赋值它,你只能经过相应的API
去建立新的引用。可是在不一样的引用之间,相同的部分是共享的。这就比如,有一颗基于immutable
的嵌套复杂很深的文档树,即便你只改变了某个地方的叶子节点,也会生成一棵新树,但这棵新树,除了刚才更改的叶子节点外,其他部分和原有树是共享的。有了immutable
,当每次键入编辑器都会产生新的state
,你在每种不一样的state
之间来回切换,就能实现撤销重作操做。同时,更新state
重绘文档也变得更高效了。
是什么构成了prosemirror
的state
呢?state
有三个主要组成部分:你的文档doc
, 当前选择selection
和当前存储的mark
集storedMarks
。
初始化state
时,你能够经过doc
属性为其提供要使用的初始文档。这里咱们可使用id
为content
下的 dom结构
做为编辑器的初始文档。Dom解析器
将Dom结构
经过咱们的解析模式schema
将其转化为prosemirror结构
。
import { DOMParser } from "prosemirror-model"
import { EditorState } from "prosemirror-state"
import { schema } from "prosemirror-schema-basic"
let state = EditorState.create({
doc: DOMParser.fromSchema(schema).parse(document.querySelector("#content"))
})
复制代码
prosemirror
支持多种类型的selection
(并容许第三方代码定义新的选择类型,注:任何一个新的类型都须要继承自Selection
)。selection
与文档和其余与state
相关的值同样,也是immutable
的 ,更改selection
,就要建立新的selection
和保持它的新state
。selection
至少具备from
和to
指向当前文档的位置来表示选择的范围。最多见的选择类型是TextSelection
,用于游标或选定文本。prosemirror
还支持NodeSelection
,例如,当你按ctrl / cmd
单击某个Node
时。会选择范围从节点以前的位置到其后的位置。 storedMarks
则表示须要应用于下一次输入时的一组Mark
。
plugin
以各类方式扩展编辑器和编辑器state
。当建立一个新的state
,你能够向其提供一系列的plugin
,这些将会保存在此state
和由此state
派生的任何state
中。而且能够影响transaction
的应用方式以及基于此state
的编辑器的行为方式。 建立plugin
时,会向其传递一个指定其行为的对象。
let myPlugin = new Plugin({
props: {
handleKeyDown(view, event) {
//当收到keydown事件时调用
console.log("A key was pressed!")
return false // We did not handle this
}
}
})
let state = EditorState.create({schema, plugins: [myPlugin]})
复制代码
当插件须要本身的plugin state
时,能够经过state
属性来定义。
let transactionCounter = new Plugin({
state: {
init() { return 0 },
apply(tr, value) { return value + 1 }
}
})
function getTransactionCount(state) {
return transactionCounter.getState(state)
}
复制代码
上面这个插件定义了一个简单的plugin state
,它对已经应用于state
的transaction
进行计数。 下面有个辅助函数,它调用了plugin
的getState
方法,从完整的编辑器的state
中获取了plugin
的state
。
由于编辑器的state
是immutable
的,并且plugin state
是该state
的一部分,因此plugin state
也是immutable
的,即它们的apply
方法必须返回一个新值,而不是修改旧值。 plugin
一般能够给transaction
添加一些额外信息metadata
。例如,在撤销历史操做时,会标记生成的transaction
,当plugin
看到时,他不会向普通的transaction
同样处理它,它会特殊处理它:从撤销堆栈顶部删除,将该transaction
放入重作堆栈。
回到最初的例子,咱们能够将command
绑定到键盘输入的keymap plugin
,同时还有history plugin
,其经过观察transaction
来实现撤销和重作。
// (Omitted repeated imports)
import { undo, redo, history } from "prosemirror-history"
import { keymap } from "prosemirror-keymap"
let state = EditorState.create({
schema,
plugins: [
history(),
keymap({"Mod-z": undo, "Mod-y": redo})
]
})
let view = new EditorView(document.body, {state})
复制代码
建立state
时会注册plugin
,经过这个state
建立的视图你将可以按Ctrl-Z
(或OS X
上的Cmd-Z
)来撤消上次更改。
上面的undo
, redo
是一种command
,大多数的编辑操做都被视为command
。它能够绑定到菜单或者键上,或者其余方式暴露给用户。在prosemirror
中,command
是实现编辑操做的功能,它们大可能是采用编辑器state
和dispatch
函数(EditorView.dispatch
或者一些其余采用了transaction
的函数)完成的。下面是一个简单的例子:
function deleteSelection(state, dispatch) {
if (state.selection.empty) return false
if (dispatch) dispatch(state.tr.deleteSelection())
return true
}
复制代码
当command
不适用时,应该返回false
或者什么也不作。若是适用,则须要dispatch
一个transaction
而后返回true
,为了可以查询command
是否适用于给定state
而不实际执行它,dispatch
参数是可选的,当没有传入dispatch
时,command
应该只返回true
,而不执行任何操做,这个能够用来使你的菜单栏变灰来表示当前command
不可执行。 一些command
可能须要与dom
交互,你能够为他传递第三个参数view
,即整个编辑器的视图。 prosemirror-commands
提供了许多的编辑command
,从简单到复杂。还同时附带一个基础的keymap
, 可以给编辑器使用的键绑定来使编辑器可以执行输入与删除等操做,它将许多与schema
无关的command
绑定到一般用于它们的键。它还导出了许多command
的构造函数,例如toggleMark
,它传入一个mark
类型和自定义属性attrs
,返回一个command
函数,用于切换当前selection
上的该mark
类型。 要自定义编辑器,或容许用户与Node
进行交互,你能够编写本身的command
。 例如一个简单的清除样式的格式刷command
:
function clear(state, dispatch) {
if (state.selection.empty) return false;
const { $from, $to } = state.selection;
if (dispatch) dispatch(state.tr.removeMark($from.pos, $to.pos, null));
return true
}
复制代码
上述介绍能够做为对prosemirror
的一个简单的认识,了解了它的运做原理,避免你第一次接触它的时候,看到它的这么多库,不知道从哪上手。prosemirror
除了上面介绍的概念之外,还有Decorations
,NodeViews
等,它们使你能够控制视图绘制文档的方式。若是你还想继续深刻的了解prosemirror
,能够前往它的官网和论坛,但愿你能成为它的贡献者。