这份指南描述了库中使用的各种概念,以及它们之间的关系。为了获得系统的完整印象,建议按照呈现的顺序通读全文,至少需要阅读到Component部分为止。
介绍
ProseMirror为构建富文本编辑器提供了一套工具和概念,使用受WYSWIG(what-you-see-is-what-you-get)启发的用户界面,但是避免了这种编辑风格的陷阱。
ProseMirror的主要原理是您的代码可以完全控制文档以及文档的处理方式。这里的文档不是指HTML,而是一个自定义数据结构,其中仅包含了您允许包含的元素(以您指定的关系)。所有更新都经过一处地方,你可以在这里对它们检查以及做出反应。
核心库不是一个简单的嵌入式组件——我们优先考虑模块化和可定制性,而不是难易程度,我们希望在未来,有人会发布基于ProseMirror的嵌入式编辑器。因此,这比起Matchbox car来说更像是一个乐高玩具。
有四个基本的模块,它们是进行任何编辑所需的,以及许多由官方团队维护的扩展,和第三方模块的状态类似——它们提供了实用的功能,但是你可以选择忽略它们或者使用其他具有类似功能的扩展替换。
这四个模块是:
此外,在Github ProseMirror的组织下,还有一些用于基本编辑命令,快捷键绑定,撤销记录,输入宏,协作编辑,简单文档结构的模块
事实就是ProseMirror不是分发为一个浏览器能加载的脚本,这意味着你可能需要一些打包工具才能使用它。打包工具可以自动的发现你的脚本依赖,并把他们合并成一个大文件以便于在网页中载入。
我的第一个编辑器
乐高积木以这种方式组合在一起,以创建一个简单的编辑器:
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要求你指定一个符合你文档架构,所以第一件事就是导入一个包含基本架构的模块
然后用该架构创建了一个状态,该状态将生成一个符合这个架构的文档,并在该文档开头生成一个默认的选区。最终,为这个状态生成一个视图,并插入到document.body
中。这将把状态的文档渲染成一个可编辑的DOM节点,并且当用户键入文档的时候生成状态的transaction
。
这个编辑器现在还不是很有用。如果你键入enter
,则什么也不会发生,因为核心库不知道键入enter
的时候该做什么。稍后我们将解决这个问题。
Transactions
当用户输入时,或者与视图进行交互时,这会产生state transactions
。这意味着它并不是就地修改文档并以这种方式隐式更新其状态。与之相反的是,每个变化都会创建一个transaction
,它描述了对状态所做的变化,并且可被用来创建一个新的状态,这个状态可以用于更新视图。
默认情况下,所有这些操作都是在后台进行的,但是您可以通过编写插件或配置视图的钩子。举例来说,这段代码添加了一个dispatchTransaction prop
,这会在transaction
被创建的时候调用。
// (Imports omitted)
let state = EditorState.create({schema})
let view = new EditorView(document.body, {
state,
dispatchTransaction(transaction) {
console.log("Document size went from", transaction.before.content.size,
"to", transaction.doc.content.size)
let newState = view.state.apply(transaction)
view.updateState(newState)
}
})
每个状态更新都必须通过updateState
,并且每个正常的编辑更新都将通过调度事务来进行。
Plugins
插件被用于以各种方式扩展编辑器的行为和编辑器的状态。有一些相对来说比较简单,就像keymap
插件绑定动作到键盘输入。其他涉及的更多,例如历史记录插件,该插件通过观察事务并存储它们的反向操作来实现撤消历史记录,以防用户想要撤消它们。
让我们添加这两个插件到我们的编辑器以实现撤销和重做的功能:
// (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})
插件在创建状态的时候注册(因为插件需要访问状态事务)。在为这个开启历史记录的状态创建视图之后,你将可以通过按下Ctrl-Z
(或者在macOS里是Cmd-Z
)来撤销上一次改动。
Commands
上一个例子使用的undo
和redo
是一种被叫做命令的特殊方法。大多数编辑动作可以被写成命令以用于绑定到按键,通过菜单调用,或者暴露给用户。
prosemirror-commands
包提供了一些基础的编辑命令,以及最小可用的按键映射,您可能希望启用该键盘映射以便enter
和delete
这样的动作可以在编辑器中生效。
// (Omitted repeated imports)
import {baseKeymap} from "prosemirror-commands"
let state = EditorState.create({
schema,
plugins: [
history(),
keymap({"Mod-z": undo, "Mod-y": redo}),
keymap(baseKeymap)
]
})
let view = new EditorView(document.body, {state})
至此,您已经有了一个基本可用的编辑器。
要添加菜单,用于架构特定内容的其他键绑定等,您可能需要查看prosemirror-example-setup
程序包。这个模块提供给你一系列用于构建基础编辑器的插件,但是就像它的名字建议的一样,这意味着这更多的是一个例子而不是一个可用于生产环境的库。对于实际部署,你可能需要用自定义代码替换它,以完全按照所需的方式进行设置。
Content
状态的文档在它的下面的doc
属性中。这是一个只读的数据结构,把文档表示为节点的层次结构,有点像浏览器的DOM。一个简单的文档可能是一个doc
节点包含两个p
节点,每个p
节点还包含一个简单的text
节点。你可以在指南中阅读更多关于文档数据结构的内容。
当初始化一个状态,你可以给它一个初始化的文档。在这中情况下,schema
是可选的,因为可以从文档中获取schema
在这里我们通过解析在DOM中ID名为content的初始化状态,通过使用DOM解析机制,该机制使用该schema
提供的有关DOM节点映射的信息。
import {DOMParser} from "prosemirror-model"
import {EditorState} from "prosemirror-state"
import {schema} from "prosemirror-schema-basic"
let content = document.getElementById("content")
let state = EditorState.create({
doc: DOMParser.fromSchema(schema).parse(content)
})
Documents | 文档
ProseMirror定义了自己的数据结构来表示文档内容。由于文档是构建编辑器所依赖的中心元素,因此了解文档的工作方式将很有帮助。
Structure
ProseMirror的文档是一个节点,其中有一个包含零个或更多节点的片段。
这很像浏览器的DOM,因为它是递归的并且是树形的。但是和DOM不同的地方在于它存储内联内容的方式。
在HTML里,一个带标记的段落被呈现为一个树形,像这样:
<p>This is <strong>strong text with <em>emphasis</em></strong></p>
!https://s3-us-west-2.amazonaws.com/secure.notion-static.com/21ffe68d-3aac-42cd-a4fa-4f886e0ca1fb/Screen_Shot_2021-05-05_at_22.31.53.png
然而在ProseMirror里,内联内容被建模为平面序列,并将标记作为元数据附加到节点:
!https://s3-us-west-2.amazonaws.com/secure.notion-static.com/d3800986-36bd-42f7-ad3b-c12eb26d73e2/Screen_Shot_2021-05-05_at_22.34.01.png
这与我们倾向于思考和处理此类文本的方式更加接近。这允许我们通过字符的偏移量表达段落中的位置而不是通过树中的路径,并且使执行类似分割内容或者更改内容的样式更简单,而不需要通过操纵树。
这也意味着每个文档只有一个合法的呈现。邻近的拥有相同样式的文本节点总是会合并在一起,并且不允许空白的文本节点。标记的显示顺序由schema
来决定
所以一个ProseMirror文档是一棵充满块节点的树,并且大多数叶子节点是被称作文本块的包含文本的块节点。你也可以有空的叶子节点,例如一个水平横条或者一个视频元素。
节点对象具有许多属性,这些属性反映了它们在文档中扮演的角色:
isBlock
和 isInline
告诉你是内联还是块节点
- 对于希望将内联节点作为内容的节点
inlineContent
是true
- 块节点包含内联内容的节点
isTextblock
是true
isLeaf
为true
告诉你节点不允许有任何内容
所以一个典型的p
节点将是一个文本节点,因此一个引用将是一个由其他块组成的块元素。文本,hard breaks,内联图像都是内联叶子节点,水平横条节点将是一个块叶子节点
schema
允许对可能出现在何处的内容指定更多精确的约束—i.e.尽管一个节点允许块内容,但那不意味着允许所有的块节点作为内容
Identity and persistence身份和持久化
DOM树和ProseMirror文档之间另一个重要的区别是节点的表现行为。在DOM树中,节点是具有身份的可变对象,这意味着一个节点只能有一个父节点,并且在对象更新的时候,节点会发生变化。
在ProseMirror中,反过来说,节点是简单的值,应该像处理代表数字3的值那样处理节点。数字3可以同时在多个数据结构中出现,它没有父链接到它所属的数据结构,并且如果你给它加1,你会获得一个新的值4,原来的3不会有任何改变。
对于ProseMirror的文档来说也是如此。它们不会改变,但是可以用来作为计算修改后文档的起点。它们不知道它们属于什么数据结构,它们可以是多个数据结构的一部分,甚至可以在一个数据结构中出现多次。它们是值,而不是有状态的对象。
这意味着你每次更新文档,你都会得到一个新的文档(值)。这个文档会共享所有没有发生变化的子节点,这使创建新的文档相对更快。
这样做有很多优势。由于在新状态下可以立即交换带有新文档的新状态,因此在更新期间使编辑器处于无效的中间状态是不可能的。这也使以某种数学方式对文档进行推理变得更加容易,如果你的文档(值)不断变化,这将变得很难。这有助于使协作编辑成为可能,并允许ProseMirror通过将其绘制到屏幕上的最后一个文档与当前文档进行比较来运行非常有效的DOM更新算法。
由于此类节点由常规JavaScript对象表示,并且显式冻结其属性会影响性能,因此实际上可以更改它们。但是ProseMirror不支持你这么做,如果你这么做,会导致发生崩溃,因为它们通常会在许多不同的数据结构之间共享。所以要小心!并且注意,这也同样适用于属于节点对象的数组和普通对象,例如用于存储节点属性的对象或片段中的子节点数组。
Data structures 数据结构
文档的对象结构长得像这样:
!https://s3-us-west-2.amazonaws.com/secure.notion-static.com/5212f8b2-d159-4266-8e82-415854588a24/WX20210506-2310482x.png
每个节点表示为一个Node类的实例。它们用type
属性进行归类, 通过type
属性可以知道节点的名字, 它可以使用的attributes
,诸如此类的信息。Node Types(Mark Types)只会被每个schema
创建一次,并且知道它们属于哪个schema
。
节点的内容被存储在一个叫Fragment
的实例中,里面保存了一系列的节点。哪怕节点没有内容或者不允许有内容,这个字段也会被填充(被填充为一个共享的空 Fragment
)
一些节点允许存储额外的属性。例如,一个图片节点需要使用属性保存alt文本和图片URL。
此外,内联节点保存了一系列激活的 mark
——像强调或者链接——它们被保存在一个包含 mark
实例的数组里面。
完整的文档只是一个节点。文档内容由顶级节点的子节点表示。通常来说,它会包含一系列的块节点,其中一些可能是包含内联节点的textblock
。但是顶级节点也可以是一个 textblock
,所以这个文档只包含内联节点。
一个文档可以包含什么类型的节点由文档的 schema
决定。想要用编程的方式创建节点,你必须要通过 schema
,例如使用 node
或者 text
方法。
import {schema} from "prosemirror-schema-basic"
// (The null arguments are where you can specify attributes, if necessary.)
let doc = schema.node("doc", null, [
schema.node("paragraph", null, [schema.text("One.")]),
schema.node("horizontal_rule"),
schema.node("paragraph", null, [schema.text("Two!")])
])
Indexing
ProseMirror支持两种索引方式——以树的方式处理,通过偏移量访问每一个节点,或者处理为一个扁平的 token
序列。
第一种和你处理DOM的方式很像——和单个节点交互,通过 child method
和 childCount
直接访问子节点,通过编写递归方法扫描整个文档(如果你想访问所有节点,使用descendants
或者nodesBetween
)
当你在访问一个特定位置的时候第二种更有用。它允许把文档中的所有位置表示为一个整数—— token
索引。这些标记实际上不作为内存中的对象存在——它们只是一种计数约定——但是文档的树形形状以及每个节点知道其大小使得按位置来访问他们变得很容易。
- 文档的开头,在第一个元素前面的位置是0
- 进入或离开不是叶节点(i.e.没有内容的节点)的节点都算一个
token
。如果一个文档以 paragraph
开始,这个段落的开头被计数为1
- 文本节点中的每个字符都算一个
token
。如果在开头的段落内容是”hi“,那么位置2在”h“后面,位置3在”i“后面,位置4在整个段落后面。
- 不允许有内容的叶节点(比如图片节点)计数为一个
token
如果你有一个文档,表示为html是这样的:
<p>One</p>
<blockquote><p>Two<img src="..."></p></blockquote>
这个token序列的位置是这样的:
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
属性可以告诉你整个节点的大小,并且你可以通过访问 .content.size
或者这个节点内容的大小。请注意,对于外部文档节点,打开和关闭的token
不被算作文档的一部分(因为你不能把光标放在文档外面),所以文档的大小是 doc.content.size
,而不是 doc.nodeSize
手动解释这些位置需要大量的计算。你可以调用 Node.resolve
获得一个位置更具描述性的 data structure
。这个数据结构会告诉你父节点的位置,它在父节点里面的位置是多少,父节点有什么祖先,以及更多其他的东西。
注意区分子索引(childCount
),全文范围的位置和节点局部的偏移(有时在递归函数中使用,以表示当前正在处理的节点中的位置)
Slices
为了像复制-粘贴以及拖-放一样处理,有必要讨论一下文档的切片问题,i.e. 两个位置之间的内容。这样的slice
和一个完整的节点或者节点中的部分片段不同,因为这个切片有可能包含未闭合的节点。
例如,如果你从一个段落的中间开始选择到下一个段落的中间,你选择的slice
中有两个段落,第一个段落缺少头标签,第二个段落缺少尾标签,然后如果你根据节点去选择一个段落,你选择的是一个完整的闭合的节点。如果将此类不完整节点中的内容视为该节点的全部内容,则可能会违反schema
约束,因为某些必需的节点不在slice
之内。
Slice
的数据结构就是为了表示这种slice
。它用fragment
结构保存了两侧的 open depth
。你可以在节点上用 slice method
把slice
从文档中取出来。
// doc holds two paragraphs, containing text "a" and "b"
let slice1 = doc.slice(0, 3) // The first paragraph
console.log(slice1.openStart, slice1.openEnd) // → 0 0
let slice2 = doc.slice(1, 5) // From start of first paragraph
// to end of second
console.log(slice2.openStart, slice2.openEnd) // → 1 1
Changing
由于node
和fragment
是持久性的,因此您绝对不应对其进行改变。如果你有一个文档的句柄(一个node
或者fragment
),它永远不会发生改变。
大多数时候,你会使用transformations
去更新文档,并且不会直接接触节点。这会留下一个变更的记录,这对一个编辑器的state
来说是必需的。
如果你想手动的导出更新后的文档,那么你可能会需要Node
和Fragment
上一些方法的帮助。为了创建一个更新版本的文档,你可能需要使用Node.replace
,它用一个新的slice
中的内容替换了给定范围的文档。你可以使用copy
方法浅度的更新一个节点,它会创建一个拥有新内容的相似的节点。Fragments还有很多其他用于更新的方法,比如replaceChild
或append