本文用到的名词解释可以参照 这个中文翻译 的开头部分,
本文与上述中文翻译内容相同,只是纯翻译,以及为了在 API 手册 中方便的引用该地址。
ProseMirror 中文指南
本指南介绍了在该库中使用的各种概念,以及它们是如何相互关联的。
为了让你对系统整体有一个印象, 推荐读者按本文的文档顺序阅读,
或者至少(如果你没有耐心而只是想大概了解的话),读完 View 组件的那一块。
介绍
ProseMirror 提供了一整套构建富文本编辑器的工具和概念,它使用的用户界面受 所见即所得 概念的启发,
但是尽量避免陷入它样式编辑的天坑。
Prosemirror 的基本概念是,你和你的代码对文档和文档的变化拥有绝对的控制权。
这里的文档不是 HTML 里的那一大坨杂乱无章的代码,而是一个只包含那些你明确指定允许它包含的元素和它们之间的你指定的关系的自定义数据结构(意思就是什么元素可以出现,
元素之间的关系,都在你的掌控之下——译者注)。所有的文档更新操作都从一个点出发,方便你对更新做处理。
Prosemirror 的核心模块并不是开箱即用的,在开发这个库的时候,我们坚持它的模块化和自定义程度的优先级高于简洁性。
当然,我们希望将来有人能开发一个基于 Prosemirror 的开箱即用的编辑器。这种感觉打个比喻来说就是,Prosemirror 是一个乐高积木,
拿到后需要你手动拼装,而不是像一个火柴盒一样,打开就能使用。
Prosemirror 有四个必要的模块,任何操作都需要这四个模块,另外还有很多 Prosemirror 核心团队维护的扩展模块,
它们(这些扩展模块)像一些提供了很多有用功能的第三方模块一样,都能被实现了相同功能的其他模块所取代。
上述的四个必要模块有:
除此之外,还有一些模块如 基本编辑命令,快捷键绑定,撤销历史,
宏命令,协同编辑,和一个简单的文档 Schema等等。更多模块可以在 Github 上的 Prosemirror 组织 中发现。
注: 相应模块的中文版在 这里
Prosemirror 并不是一个浏览器可直接加载的脚本,这意味着你需要使用一些打包工具才能使用它。
打包工具就是一个自动寻找你脚本声明的依赖,然后合并它们到一个单独的脚本文件,以便你能够在浏览器中方便的加载它。
你可以自己去看看更多关于 Web 打包方面的东西,比如 这里 。
My first editor
下面的代码像乐高积木一样的摞在一起创建了一个最简单的编辑器:
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 需要遵守的 Schema (来规定哪些元素能包含哪些不能包含以及元素之间的关系),
为了达成这个目的,上述代码做的第一件事就是先导入一个基本的 schema(通常情况下 schema 是你自己写的,这里作者拿了一个现成的包含基本元素的 schema 做示例——译者注)。
之后,这个基础 schema 被用来创建一个 state,该 state 会生成一个遵守 schema 约束的一个空的文档,
以及一个默认的选区在这个文档的开头(这个选区是空的,因此这里指的是光标)。最终,这个 state 会生成一个 view 被 append 到 document.body。
上述的 state 的文档最终将被渲染成一个可编辑的 DOM 节点(就是 contenteditable 的节点——译者注) 和一个会对用户输入做出反应的 state transaction。
(不幸的是)到目前为止这个编辑器还不能用. 例如, 如果你在刚刚的编辑器中按 Enter 键,
则什么也不会发生, 因为上述提到的四个核心模块并不知道输入 Enter 之后应该做什么, 我们将在稍后告诉它如何响应各种输入行为.
Transactions
当用户输入的时候, 或者更广泛的说, 当用户与页面的 view 进行交互的时候, prosemirror 会产生 ‘state transactions’.
这意味着每当用户输入后, prosemirror 不仅仅只修改 document 内容, 同时还会在背后更新 state.
也就是说, 每一个变化都会有一个 transaction 被创建, 它描述了 state 被应用的变化,
这些变化可以被用来创建一个新的 state, 然后这个新的 state 被用来更新 view.
默认情况下, 上述的这些变化是框架进行的, 你无需关注. 不过你可以通过写一个 plugins 或者自定义你的 view 的方式,
来往这个变化的过程中挂载一些 hook. 举个例子, 下面的代码增加了一个 dispatchTransaction
的 prop,
它在每一个 transaction 被创建的时候调用:
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)
}
})
每次 的 state 更新最终都需要执行 updateState
方法,
而且每 dispatching 一个 transaction 一般情况下都会触发一个编辑状态的更新.
Plugins
Plugins 被用来以多种不同的方式扩展编辑行为和编辑状态.
一些插件比较简单, 比如 keymap 插件, 它用来绑定键盘输入的 actions. 还有些插件相对复杂一点,
比如 history 插件, 它通过监视 transactions 和按照相反的顺序存储它们以便用户想要撤销
一个 transactions 来实现一个 undo/redo 的功能.
让我们先增加下面两个 plugin 以获得 undo/redo 的功能:
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})
Plugins 会在创建 state 的时候被注册(因为它们需要访问 state 的 transactions 的权限).
在给这个可撤销/重做的 state 创建一个 view 之后, 你将能够通过按 Ctrl+Z(或者 Mac 下 Cmd+Z) 撤销上一步操作.
Commands
上面示例中, 被绑定到相关键盘按键的的特殊的函数叫做 commands. 大多数的编辑行为都会被写成 commands 的形式,
因此可以被绑定到特定的键上, 以供编辑菜单调用, 或者暴露给用户来操作.
prosemirror-commands
这个包提供了很多基本的编辑 commands, 包括在编辑器中按照你的期望映射 enter 和 delete 按键的行为.
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})
到此为止, 你应该有了一个基本能 work 的编辑器了.
如果还想增加一个菜单方便编辑操作, 或者想增加一些 schema 允许的按键绑定, 诸如此类的东西,
那么你可能想要看下 prosemirror-example-setup
这个包.
这个包提供了实现一个基本编辑器的一系列设置好的插件, 不过就像这个包名所表示的含义那样, 它仅仅是用来示例一些 API 的用法,
而不是一个可以用在生产环境的包. 对于一个真实的开发环境, 你可能想要用自己的代码替换其中的一些内容, 以精确实现你想要的效果.
Content
一个 state 的 document 对象存储在 doc
属性上, 它是一个只读类型的数据结构, 用一系列的不同层级的节点表示,
这些节点的层级结构有点类似于浏览器中的 DOM 节点. 一个简单的 document 可能有一个 “doc” 节点,
它包含两个 “paragraph” 节点, 每个 “prragraph” 节点又包含一个 “text” 节点.
你可以在 guide 中读到更多关于 document 数据结构的信息.
当初始化一个 state 的时候, 你可以传给它一个初始 document. 在这种情况下,
schema 字段就是可选的, 因为 schema 可以从 document 中获取.
下面的示例我们通过 DOM 格式化的机制去格式化 DOM 中 id 为 “content” 的元素来初始化一个 state,
这个 state 使用的 schema 信息是由 DOM 节点格式化后映射到相应元素上获得的(意思就是 DOM 节点包含哪些元素,
格式化后被对应成 schema 的形式供 state 使用, 因此 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)
})
文档
Prosemirror 定义了它自己的 data structure 来表示 document 内容. 因为 document 是构建一个编辑器的核心元素,
因此理解 document 是如何工作的很有必要.
Structure
一个 Porsemirror 的 document 是一个 node 类型, 它含有一个 fragment 对象,
fragment 对象又包含了 0 个或更多子 node.
这看起来很像 浏览器 DOM 结构,
因为 Prosemirror 跟 DOM 一样是递归的树状结构. 不过, Prosemirror 在存储内联元素的方式上跟 DOM 有点不同.
在 HTML 中, 一个 paragraph 及其中包含的标记, 表现形式就像一个树, 比如有以下 HTML 结构:
<p>This is <strong>strong text with <em>emphasis</em></strong></p>
然而在 Prosemirror 中, 内联元素被表示成一个扁平的模型, 他们的节点标记被作为 metadata 信息附加到相应 node 上:
这种数据结构显然更符合我们心中的这类文本该有的样子. 它允许我们使用字符的偏移量而不是一个树节点的路径来表示其所处段落中的位置,
并且使一些诸如 splitting 内容或者改变内容 style 的操作变得很容易, 而不是以一种笨拙的树的操作来修改内容.
这也意味着, 每个 document 只有 一种 数据结构表示方式. 文本节点中相邻且相同的 marks 被合并在一起,
而且不允许空文本节点. marks 的顺序在 schema 中指定.
因此, 一个 Prosemirror document 就是一颗 block nodes 的树, 它的大多数 leaf nodes 是 textblock 类型,
该节点是包含 text 的 block nodes.你也可以有一些内容为空的简单的 leaf nodes, 比如一个水平分隔线 hr 元素, 或者一个 video 元素.
Node 对象有一系列属性来表示他在文档中的角色:
-
isBlock
和 isInline
告诉你这个 node 是一个 block 类型的 node(类似 div)还是一个 inline 的 node(类似 span).
-
inlineContent
为 true 表示该 node 只接受 inline 元素作为 content(可以通过判断此节点来决定下一步是否往里面加 inline node or not——译者注)
-
isTextBlock
为 true 表示这个 node 是个含有 inline content 的 block nodes.
-
isLeaf
为 true 表示该 node 不允许含有任何 content.
因此, 一个典型的 "paragraph"
node 是一个 textblock 类型的节点,
然后一个 blockquote(引用元素)则是一个可能由其他 block 元素构成其内容的 block 元素.
Text 节点, 回车, 和 inline 的 images 都是 inline leaf nodes,
而水平分隔线(hr 元素)节点是一个典型的 block leaf nodes.(leaf nodes 翻译成 叶节点,
表示其不能再含有子节点; leaf nodes 如上所说, 可能是 inline 的, 也可能是 block 的——译者注).
schema 允许你可以对诸如”哪些元素允许出现在哪些地方”这种问题指定更多的约束条件.
例如, 即使一个 node 允许 block content, 那也不意味着它允许 所有的 block nodes 作为
content(你可以通过 schema 手动指定例外——译者注).
Identity and persistence
DOM 树与 ProseMirror document 的另一个不同是他们对 nodes 对象的表示方式.
在 DOM 中, nodes 是带有 identity 的 mutable 对象(不知道 mutable 对象是啥的可以搜索下),
这意味着一个 node 只能出现在它的父级 node 下(如果它出现在别处, 那它在此处就没了, 因为有 identity,
所以唯一——译者注), 当这个 node 更新的时候, 它就 mutated 了(node 更新是在原来的 node上更新,
此谓之 mutated 即突变. 表示在原有基础上修改, 修改前后始终是一个对象——译者注).
而在 Prosemirror 中却不同, nodes 仅仅是 values(区别于 DOM 的 mutable, values 是 unmutable 的),
表示一个节点就像表示一个数字 3 一样. 3 可以同时出现在不同的数据结构中, 它不跟当前的数据结构绑定, 如果你对它增加 1,
你将会得到一个新的 value: 4 而不用对原始的 3 做任何修改.
所以这就是 Prosemirror document 的机制. 它的值不会改变, 而且可以被当做一个原始值去计算一个新的 document.
这些 document 的 nodes 们不知道它所处的数据结构是什么, 因为它们可以存在于多个结构中, 甚至可以在一个结构中重复多次.
它们是 values, 不是拥有状态的对象.
这意味着每次你更新 document, 你就会得到一个新的 document. 这个新的 document 共享旧的 document 的所有没有在这次更新中改变的子 nodes 的 value, 这让新建一个 document 变得很廉价.
这种机制有很多优点. 它让当 state 更新的时候编辑器始终可用, 因为新的 state 就代表了新的 document(如果更新未完成, 则 state 不会出现,
因此 document 也没有, 编辑器仍然是之前的 state + document——译者注), 新旧状态可以瞬间切换(而没有中间状态).
这种状态切换更可以以一种简单的数学推理的方式完成——而如果你的值在背后不断变化(指像 DOM 的节点一样突变——译者注),
这种推理将非常困难. Prosemirror 的这种机制使得协同编辑成为可能, 而且能够通过比较之前绘制在屏幕上的 document 和当前的 document 算法来非常高效的 update DOM.
因为 nodes 都被表示为正常的 JavaScript 对象, 而明确 freezing 他们的属性(防止 mutate)非常影响性能,
因此事实上虽然 Prosemirror 的 document 以一种非突变的机制运行, 但是你还是能够手动修改他们.
只是 Prosemirror 不支持这么做, 如果你强行 mutate 这些数据结构的话, 编辑器可能会崩溃, 因为这些数据结构总是在多处共享使用(修改一处, 影响其他你不知道的地方——译者注). 因此, 务必小心!!! 同时记住, 这个道理对一些 node 对象上存储的数组和对象同样适用, 比如 node attributes 对象, 或者存在 fragments 上的子 nodes.
Data structures
一个 document 的数据结构看起来像下面这样:
Node |
type: |
NodeType |
content: |
Fragment
[ Node ,
Node , ...] |
attrs: |
Object |
marks: |
[
Mark |
type: |
MarkType |
attrs: |
Object |
, ...] |
每个 node 都是一个 Node
类的实例. 它们用 type 属性进行归类, 通过 type 属性可以知道 node 的名字, 它可以使用的 attributes, 诸如此类的信息. Node types(和 mark types) 只会被每个 schema 创建一次, 它们知道自己是属于哪个 schema.
node 的 content 被存储在一个指向 Fragment
实例的字段上, 它的内容是一个 nodes 数组.
即使那些没有 content 或者不允许有 content 的 nodes 也是如此, 这些不许或没有 content 的节点被共享的 empty fragment 替代.
一些 nodes 类型允许有 attributes, 它们在每个 nodes 上以(不同于 content 的)额外的值存储着. 例如, 一个 image node 可能使用 attributes 存储 alt 文本信息和 URL 信息.
除此之外, inline nodes 含有一些激活的 marks——marks 就是指那些像 emphasis 或者 一个 link 的东西——它们被表示成 Mark
实例.
整个 document 都是一个 node. document 的 content 作为顶级 node 的子 nodes.
通常上来说, 这些顶级 node 的子 node 是一系列的 block nodes, 这些 block nodes 中有些可能包含 textblocks,
这些 textblocks 有包含 inline content. 不过, 顶级 node 也可以只是一个 textblock,
这样的话整个 document 就只包含 inline content.
哪些 node 被允许出现在哪些位置是由 document 的 schema 决定的. 为了用编程的方式(而不是直接对编辑器输入内容的方式——译者注)创建 nodes,
你必须遍历 schema, 比如下面的使用 node
和 text
方法.
import {schema} from "prosemirror-schema-basic"
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 nodes 支持两种类型的 indexing——它们既可以被当成树类型, 因为它们使用 offsets 来区别每个 nodes; 也可以被当成一个具有一系列 token 的扁平的结构(token 可以理解为一个计数单位).
第一种 index 允许你像在 DOM 中那样, 与单个 nodes 进行交互, 使用 child
method 和
childCount
直接访问 child nodes, 写递归函数去遍历 document(如果你想遍历所有的 nodes,
使用 descendants
和 nodesBetween
).
第二种 index 当在文档定位一个指定的 position 的时候更有用. 它可以以一个整数表示文档中的任意位置——这个整数是 token 的顺序.
这些 token 对象在内存中其实并不存在——它们只是用来计数方便——不过 document 的树状结构以及每个 node 都知道它们自己的大小尺寸使得按位置访问它们变得廉价.
-
Document 的起始位置, 在所有 content 的开头, 位置是 0.
-
进入或者离开不是 leaf node 的节点(比如能够包含内容的节点, 都算是非 leaf node)计为 1 个 token. 所以如果 document 以一个 paragraph(标签是 p) 开头, 在段落开头的 position 是 1(即
之后的位置——译者注)
-
Text nodes 的每个字符记为 1 个 token. 所以如果在 document 的开头的 paragraph 包含单词 “hi”, 那么 position 2 在 “h” 之后, position 3 在 “i” 之后, position 4 在整个段落之后(即
之后——译者注)
-
Leaf nodes 如果不允许 content 的(比如图片节点), 计做 1 个 token.
因此, 如果你有一个 document, 表示成 HTML 就像下面这样:
<p>One</p>
<blockquote><p>Two<img src="..."></p></blockquote>
Token 顺序和 position 则看起来像下面这样:
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>
每个 node 都有一个 nodeSize
属性表示整个 node 的尺寸大小, 你还可以通过 .content.size
获得 node 的 content 的尺寸大小.
需要注意的是对于 document 的外层节点(即 DOM 中 contenteditable 属性所处的节点, 是整个 document 的根节点——译者注)来说,
开始和关闭 token 不被认为是 document 的一部分(因为你无法将光标放到 document 的外面), 因此 document 的尺寸是 doc.content.size
,
而不是 doc.nodeSize
(虽然 document 的开关标签不被认为是 document 的一部分, 但是仍然计数. 后者始终比前者大2——译者注).
如果手动计算这些位置涉及到相当数量的计算工作. (因此)你可以通过调用 Node.resolve
来获得一个 position 的更多数据结构的描述.
这个 数据结构 将会告诉你当前 position 的父级 node 是什么, 它在父级 node 中的偏移量是多少, 它的父级 node 的祖先 nodes 有哪些, 和其他一些信息.
一定要注意区分子 node 的 index(比如每个 childCount
), document 范围的 position,
和 node 的偏移(有时候这个偏移会用在一个递归函数表示当前处理的 node 的位置, 此时就涉及到 node 的偏移)之间的区别.
Slices
对于用户的复制粘贴和拖拽之类的操作, 涉及到一个叫做 slice of document 的概念(文档片段——译者注),
例如在两个 position 之间的 content 就是一个 slice. 这种 slice 与一个完整的 node 或者 fragment 不同,
slice 可能是 “open”(意思即一个 slice 包含的标签可能没有关闭, 比如
123
456
中, 一个 slice 可能是 2345 ——译者注).
例如, 如果你用光标选择从一个段落的中间到另一个段落的中间, 那么你选择的 slice 就是含有两个段落,
第一个在开始的地方 open, 第二个在结束的地方 open, 然后如果你使用接口(而不是通过与 view 交互——译者注)选择了一个段落 node,
那你就选择了一个 close 的 node. 如果对待 slice 像普通的 node content 一样的话,
它的 content 可能不符合 schema 的约束, 因为某些所需要的 nodes(如使 slice content 是一个完整的 node 的标签,
如上例中的开始部分的
和结束部分的
) 落在了 slice 之外.
Slice
数据结构就是被用来表示这种的数据的.
它存储了一个含有两侧 open depth (意思就是相对于根节点的层级深度——译者注)信息的 fragment. 你可以在 nodes 上使用 slice
方法 来从 document 上 “切” 出去一片 “slice”.
let slice1 = doc.slice(0, 3)
console.log(slice1.openStart, slice1.openEnd)
let slice2 = doc.slice(1, 5)
console.log(slice2.openStart, slice2.openEnd)
Changing
因为 nodes 和 fragment 是一种 持久化 的数据结构(意即 immutable ——译者注),
你绝对不应该直接修改他们. 如果你需要操作 document, 那么它就应该一直不变(操作后产生新的 document,
旧的 document 一直不变——译者注).
大多数情况下, 你需要使用 transformations 去更新 document 而不用直接修改 nodes.
这也方便留下一个变化的记录, 变化的记录对作为编辑器 state 一部分的 document 是必要的.
如果你非要去手动更新 document, Prosemirror 在 Node
和 Fragment
上提供了一些有用的辅助函数去新建一个 document 的全新版本. 你可能会常常用到 Node.replace
方法,
该方法用一个含有新的 content 的 slice 替换指定 document 的 range 内的内容.
如果想要浅更新一个 node, 你可以使用 copy
方法, 该方法新建了一个相同的 node,
不过为这个相同的新 node 可以指定新的 content. Fragments 也有一些更新 document 的方法,
比如 replaceChild
和 append
.
文档骨架
每个 Prosemirror document 都有一个与之相关的 schema. 这个 schema 描述了存在于 document 中的 nodes 类型,
和 nodes 们的嵌套关系. 例如, schema 可以规定, 顶级节点可以包含一个或者更多的 blocks,
同时段落 paragraph nodes 可以包含含有任意数量的 inline nodes, 这些 inline nodes 可以含有任意数量的 marks.
关于 schema 的用法, 这里有一个 basic schema 的包可以作为示例看一下,
不过 Prosemirror 有个比较棒的点在于它允许你定义你自己的 schemas.
Node Types
在 document 中的每个节点都有一个 type, 它代表了一个 node 的语义化上意思和 node 的属性, 这些属性包括在编辑器中的渲染方式.
当你定义一个 schema 的时候, 你需要列举每一个用到的 node types, 用一个 spec object 描述它们:
const trivialSchema = new Schema({
nodes: {
doc: {content: "paragraph+"},
paragraph: {content: "text*"},
text: {inline: true},
}
})
上述代码定义了一个允许 document 包含一个或更多 paragraphs 的 schema, 每个 paragraph 又能包含任意数量的 text.
每个 schema 至少得定义顶级 node 的 type(顶级 node 的名字默认是 “doc”, 不过你可以 configure 它), 和规定 text content 的 “text” type.
作为 inline 类型来计算 index 等的 nodes 必须声明它的 inline
属性(回想一下 text 类型, 它就被定义成 inline 了——这一点你可能忽略了)
Content Expressions
上面 schema 示例代码中的 content
字段的字符串值被叫做 content expressions. 他们控制着对于当前 type 的 node 来说, 哪些 child nodes 类型可用.
比如说, (content 字段的内容是) "paragraph"
意思就是 “一个 paragraph”,
"paragraph+"
意思就是 “一个或者更多 paragraph”.与此相似, "paragraph*"
意思就是 “0 个或者更多 paragraph”,
"caption?"
意思就是 “0 个或者 1 个 caption node”. 你也可以在 node 名字之后使用类似于正则表达式中表示范围含义的表达式,
比如 {2}
(正好 2 个), {1, 5}
(1 个到 5 个), 或者{2,}
(两个或更多).
这种表达式可以被联合起来创建一个系列, 例如 "heading paragraph+"
表示 “开头一个 heading,
之后一个或更多 paragraphs”. 你也可以使用管道符号 |
操作符来表示在两个表达式中选择一个, 比如 "(paragraph | blockquote)+"
.
一些元素 type 的 group 可能在你的 schema 会出现多次, 比如你有一个 “block” 概念的 nodes,
他们可以出现在顶级元素之下, 也可以嵌套进 blockquote 类型的 node 内. 你可以通过指定 schema 的 group
属性来创建一个 node group, 然后在你的其他表达式中填 group 的名字即可:
const groupSchema = new Schema({
nodes: {
doc: {content: "block+"},
paragraph: {group: "block", content: "text*"},
blockquote: {group: "block", content: "block+"},
text: {}
}
})
上面示例中, "block+"
等价于 "(paragraph | blockquote)+"
.
建议在允许 block content 的 nodes(在示例中就是 "doc"
和 "blockquote"
)中设置为至少有一个 child node,
因为如果 node 为空的话浏览器将折叠它, 使它无法编辑(这句话的意思是, 如果上述 doc 或者 blockquote 的 content
设置为 block* 而不是 block+ 就表示允许不存在 child nodes 存在的情况(它沿用了通用的正则符号: * 表示0个或更多,
- 表示1个或更多), 那么此时编辑的话浏览器输入的是 text node, 是 inline 节点, 导致无法输入, 读者可以试试——译者注).
在 schema 中, nodes 的书写顺序很重要. 当对一个必选的 node 新建一个默认实例的时候,
比如在应用了一个 replace step 之后, 为了保持当前文档仍然符合 schema 的约束,
会使用能满足 schema 约束的第一个 node 的 expression. 如果 node 的 expression 是一个 group,
则这个 group 的第一个 node type(决定于当前 group 的成员 node 出现在 schema 的 nodes
中的顺序)将被使用.
如果我在上述的 schema 示例中调换了 "paragraph"
和 "blockquote"
的顺序,
当编辑器试图新建一个 block node 的时候将会报 stack overflow——因为编辑器会首先尝试新建一个 "blockquote"
node,
但是这个 node 需要至少一个 block node, 于是它就首先又需要创建一个 "blockquote"
node 作为内容, 以此往复.
不是每个 Prosemirror 库中的 node 操作函数都会检查它当前处理 content 的可用性——高级概念例如 transforms 会检查,
但是底层的 node 新建方法通常不会, 这些底层方法通常将可用性检查交给它们的调用者. 它们(即使当前操作的 content 不可用,
但是这些底层方法也)完全可能可用, 比如, NodeType.create
, 它会创建一个含有不可用 content 的节点.
对于在一个 slices 的 “open” 一边的 node 而言, 这甚至是情有可原的(因为 slice 不是一个可用的节点,
但是又需要直接操作 slice ——总不能让用户手动补全吧?——译者注). 有一个 createChecked
method 方法可以检查给定 content
是否符合 schema, 也有一个 check
method 方法来 assert 给定的 content 是否可用.
Marks
Marks 通常被用来对 inline content 增加额外的样式和其他信息.
schema 必须声明当前 document 允许的所有 schema(就像声明 nodes 那样——译者注).
Mark types 是一个有点像 node types 的对象, 它用来给不同的 mark 分类和提供额外的信息.
默认情况下, 允许有 inline content 的 nodes 允许所有的定义在 schema 的 marks 应用于它的 child nodes.
你可以在 node spec 中的 marks
字段配置之.
下面是一个简单的 schema 示例, 支持在 paragraphs 中设置文本的 strong 和 emphasis marks,
不过 heading 则不允许设置这两种 marks.
const markSchema = new Schema({
nodes: {
doc: {content: "block+"},
paragraph: {group: "block", content: "text*", marks: "_"},
heading: {group: "block", content: "text*", marks: ""},
text: {inline: true}
},
marks: {
strong: {},
em: {}
}
})
marks 字段的值可以写成用逗号分隔开的 marks 名字, 或者 mark groups——"_"
, 它是通配符的意思,
允许所有的 marks. 空字符串表示不允许任何 marks.
Attributes
Document 的 schema 也定义了 node 和 mark 允许有哪些 attributes. 如果你的 node type 需要外的 node 专属的信息, 比如 heading node 的 level 信息(H1, H2等等——译者注), 此时适合使用 attribute.
Attribute 是一个普通的纯对象, 它有一些预先定义好的(在每个 node 或 mark 上)属性, 指向可以被 JSON 序列化的值. 为了指定哪些 attributes 被允许出现, 可以在 node spec 和 mark 的 spec 中使用可选的 attr 属性:
heading: {
content: "text*",
attrs: {level: {default: 1}}
}
在上面这个 schema 中, 每个 heading
node 实例都有一个 level
属性通过 .attrs.level
访问.
如果在 created heading 的时候没有指定, level 默认是 1.
如果你在定义 node 的时候没有给一个 attribute 默认值的话, 当新建这个 node 的时候, 如果没有显式传入 attribute 就会报错.
这也让 Prosemirror 在调用一些接口如 createAndFill
来生成满足 schema 约束的 node 的时候变得不可能.
这就是为什么你不能将这样的节点放到一个必须的位置,因为编辑器需要能够生成一个空的节点以填充缺失的内容部分。
Serialization and Parsing
为了能在浏览器中编辑元素, 就必须使 document nodes 以 DOM 的形式展示出来.
最简单的方式就是在 schema 中对每个 node 注明如何在 DOM 中显示.
这可以在 schema 的每个 node spec 中指定 toDOM
字段 来实现.
这个字段应该指向一个函数, 这个函数将当前 node 作为参数, 返回 node 的 DOM 结构描述.
这可以直接是一个 DOM node, 或者一个 array 描述它, 例如:
const schema = new Schema({
nodes: {
doc: {content: "paragraph+"},
paragraph: {
content: "text*",
toDOM(node) { return ["p", 0] }
},
text: {}
}
})
上面示例中, ["p", 0]
的含义是 paragraph 节点在 HTML 中被渲染成 <p>
标签.
0 代表一个 “hole”, 表示该 node 的内容应该被渲染的地方(意思就是如果这个节点预期是有内容的,
就应该在数组最后写上 0). 你也可以在标签后面加上一个对象表示 HTML 的 attributes, 例如 ["div", {class: "c"}, 0]
. leaf nodes 不需要 “hole” 在它们的 DOM 中, 因为他们没有内容.
Mark 的 specs 有一个跟 nodes 相似的 toDOM
方法,
不同的是他们需要渲染成单独的标签去直接包裹着 content, 所以这些 content 直接在返回的 node 中, 所以上面的 “hole” 就不用专门指定了.
你也会经常 格式化 HTML DOM 的内容为 Prosemirror 识别的 document. 例如, 当用户粘贴或者拖拽东西到编辑器中的时候.
Prosemirror-model 模块有些函数来处理这些事情, 不过你也应该有勇气在 schema 中的 parseDOM
属性 中直接包含如何格式化的信息.
这里列出了一组 parse rules, 描述了 DOM 如何映射成 node 或者 mark. 例如, 基本的 schema 对于 emphasis mark 写成下面这样:
parseDOM: [
{tag: "em"},
{tag: "i"},
{style: "font-style=italic"}
]
上面中的 parse rule 的 tag
字段也可以是一个 CSS selector,
所以你也可以传入类似于 "div.myclass"
这种的字符串. 与此相似, style
字段匹配行内 CSS 样式.
当一个 schema 包含 parseDOM
字段时, 你可以使用 DOMParser.fromSchema
创建一个 DOMParser
对象.
编辑器在新建默认的剪切板内容 parser 的时候就是这么干的, 不过你可以 override 它.
Document 也有一个内置的 JSON 序列化方式. 你可以在 node 上调用 toJSON
来生成一个可以安全地传给 JSON.stringify
函数的对象(感觉这个目的是为了方便调试?——译者注), 此外 schema 对象有一个 nodeFromJSON
方法 可以将 toJSON 的结果再转回原始的 node.
Extending a schema
传给 Schema
constructor 构造器来设置 nodes
和 marks
选项的参数可以是
OrderedMap
objects 类型的对象, 也可以是纯 JavaScript 对象.
生成的 schema 上的 spec
.nodes
和 .spec.marks
属性则总是 OrderedMap
s, 它可以被用来作为其他 schemes 的基础.
OrderedMaps 这种 map 支持很多方法去方便的新建新的 schema. 比如, 你可以通过调用
schema.markSpec.remove("blockquote")
后, 将调用结果传给 Schema 构造器的参数的 nodes
字段,
来生成一个没有 blockquote
node 的 schema.
schema-list 模块导出了一个 很方便的方法 以添加由该模块导出的 nodes 到一个 node 集合中。
Transforms 是 Prosemirror 的核心工作方式. 它是 transactions 的基础, 其使得编辑历史跟踪和协同编辑成为可能.
Why?
为什么我们不能直接对 document 进行修改(突变 mutate)? 或者至少新建一个全新版本的 document 然后将其放到编辑器中去呢?
有好几个原因. 其中之一就是代码清晰度. Immutable 数据结构确实可以造就简单的代码.
而且 transform 系统做的主要工作就是保留了 document 更新的 痕迹, transform 的一系列值代表了从旧的
document 到新的 document 的每一个 steps 记录.
undo history 可以保存这些 steps 然后在需要的时候反过来应用这些 steps ( Prosemirror 实现了可选择的 undo, 这比仅仅回滚之前的 state 状态更为复杂)
collaborative editing (协同编辑)系统发送这些 steps, 并在必要的时候记录这些 steps, 以便每个 document 编辑者都能够有相同的 document.
在大多数情况下, 能够对每个 document 改变(无论是来自自己还是来自协同编辑)做出相应反应对 editor plugin 来说是很有用的, 这始终能够让插件保持与 editor 的 state 同样的状态.
Steps
对于 document 的更新会被分解成一个个的 steps, 它描述了一个更新. 你一般情况下不需要直接与它打交道, 不过知道它们如何工作的原理是很有必要的.
Steps 的一个例子就是 ReplaReplaceStep
ceStep,
它可以替换 document 的一小部分, 或者 AddMarkStep
, 可以对一个 range 应用 Mark.
一个 Step 可以被 applied 到一个 document, 然后产生一个新的 document
console.log(myDoc.toString())
let step = new ReplaceStep(3, 5, Slice.empty)
let result = step.apply(myDoc)
console.log(result.doc.toString())
应用一个 step 想对来说是比较简单的过程——它不做一些诸如插入 nodes 以保持 schema 的约束,
或者转换 slice 让其去适应 schema 之类的操作. 这意味着应用一个 setp 可能会失败.
比如如果你试图删除一个 node 的其中一个 token(就是一个 node 的开或关标签——译者注),
这将会使该 node 的另一个 token 未正确关闭, 这么做对你来说是没什么意义的.
这也就是为什么 apply
方法返回一个 result object, (如果 step apply 成功则)保持对新的 document 的引用,
或者(失败的时候)包含一个错误信息.
你通常想要让 helper functions 去为你生成 steps, 这样你就不用担心一些细节.
Transforms
An editing action may produce one or more steps. The most convenient
way to work with a sequence of steps is to create a Transform
object (or, if you're working with a full
editor state, a Transaction
, which is a
subclass of Transform
).
let tr = new Transform(myDoc)
tr.delete(5, 7)
tr.split(5)
console.log(tr.doc.toString())
console.log(tr.steps.length)
Most transform methods return the transform itself, for convenient
chaining (allowing you to do tr.delete(5, 7).split(5)
).
There are transform methods for
deleting and
replacing, for
adding and removing
marks, for performing tree
manipulation like splitting,
joining,
lifting, and
wrapping, and more.
Mapping
When you make a change to a document, positions pointing into that
document may become invalid or change meaning. For example, if you
insert a character, all positions after that character now point one
token before their old position. Similarly, if you delete all the
content in a document, all positions pointing into that content are
now invalid.
We often do need to preserve positions across document changes, for
example the selection boundaries. To help with this, steps can give
you a map that can convert between positions
in the document before and after applying the step.
let step = new ReplaceStep(4, 6, Slice.empty)
let map = step.getMap()
console.log(map.map(8))
console.log(map.map(2))
Transform objects automatically
accumulate a set of maps for the
steps in them, using an abstraction called
Mapping
, which collects a series of step maps
and allows you to map through them in one go.
let tr = new Transaction(myDoc)
tr.split(10)
tr.delete(2, 5)
console.log(tr.mapping.map(15))
console.log(tr.mapping.map(6))
console.log(tr.mapping.map(10))
There are cases where it's not entirely clear what a given position
should be mapped to. Consider the last line of the example above.
Position 10 points precisely at the point where we split a node,
inserting two tokens. Should it be mapped to the position after the
inserted content, or stay in front of it? In the example, it is
apparently moved after the inserted tokens.
But sometimes you want the other behavior, which is why the map
method on step maps and mappings accepts a
second parameter, bias
, which you can set to -1 to keep your
position in place when content is inserted on top of it.
console.log(tr.mapping.map(10, -1))
The reason that individual steps are defined as small, straightforward
things is that it makes this kind of mapping possible, along with
inverting steps in a lossless way, and
mapping steps through each other's position maps.
Rebasing
When doing more complicated things with steps and position maps, for
example to implement your own change tracking, or to integrate some
feature with collaborative editing, you might run into the need to
rebase steps.
You might not want to bother studying this until you are sure you need
it.
Rebasing, in the simple case, is the process of taking two steps that
start with the same document, and transform one of them so that it can
be applied to the document created by the other instead. In pseudocode:
stepA(doc) = docA
stepB(doc) = docB
stepB(docA) = MISMATCH!
rebase(stepB, mapA) = stepB'
stepB'(docA) = docAB
Steps have a map
method, which, given a
mapping, maps the whole step through it. This can fail, since some
steps don't make sense anymore when, for example, the content they
applied to has been deleted. But when it succeeds, you now have a step
pointing into a new document, i.e. the one after the changes that you
mapped through. So in the above example, rebase(stepB, mapA)
can
simply call stepB.map(mapA)
.
Things get more complicated when you want to rebase a chain of steps
over another chain of steps.
stepA2(stepA1(doc)) = docA
stepB2(stepB1(doc)) = docB
???(docA) = docAB
We can map stepB1
over stepA1
and then stepA2
, to get stepB1'
.
But with stepB2
, which starts at the document produced by
stepB1(doc)
, and whose mapped version must apply to the document
produced by stepB1'(docA)
, things get more difficult. It must be
mapped over the following chain of maps:
rebase(stepB2, [invert(mapB1), mapA1, mapA2, mapB1'])
I.e. first the inverse of the map for stepB1
to get back to the
original document, then through the pipeline of maps produced by
applying stepA1
and stepA2
, and finally through the map produced
by applying stepB1'
to docA
.
If there was a stepB3
, we'd get the pipeline for that one by taking
the one above, prefixing it with invert(mapB2)
and adding mapB2'
to the end. And so on.
But when stepB1
inserted some content, and stepB2
did something to
that content, then mapping stepB2
through invert(mapB1)
will
return null
, because the inverse of stepB1
deletes the content
to which it applies. However, this content is reintroduced later in
the pipeline, by mapB1
. The Mapping
abstraction provides a way to track such pipelines, including the
inverse relations between the maps in it. You can map steps through it
in such a way that they survive situations like the one above.
Even if you have rebased a step, there is no guarantee that it can
still be validly applied to the current document. For example, if your
step adds a mark, but another step changed the parent node of your
target content to be a node that doesn't allow marks, trying to apply
your step will fail. The appropriate response to this is usually just
to drop the step.
编辑器状态
What makes up the state of an editor? You have your document, of
course. And also the current selection. And there needs to be a way to
store the fact that the current set of marks has changed, when you for
example disable or enable a mark but haven't started typing with that
mark yet.
Those are the three main components of a ProseMirror state, and exist
on state objects as doc
,
selection
, and
storedMarks
.
import {schema} from "prosemirror-schema-basic"
import {EditorState} from "prosemirror-state"
let state = EditorState.create({schema})
console.log(state.doc.toString())
console.log(state.selection.from)
But plugins may also need to store state—for example, the undo history
has to keep its history of changes. This is why the set of active
plugins is also stored in the state, and these plugins can define
additional slots for storing their own state.
Selection
ProseMirror supports several types of selection (and allows 3rd-party
code to define new selection types). Selections are represented by
instances of (subclasses of) the Selection
class. Like documents and other state-related values, they are
immutable—to change the selection, you create a new selection object
and a new state to hold it.
Selections have, at the very least, a start
(.from
) and an end
(.to
), as positions pointing into the
current document. Many selection types also distinguish between the
anchor (unmoveable) and
head (moveable) side of the selection, so
those are also required to exist on every selection object.
The most common type of selection is a text
selection, which is used for regular cursors
(when anchor
and head
are the same) or selected text. Both
endpoints of a text selection are required to be in inline positions,
i.e. pointing into nodes that allow inline content.
The core library also supports node
selections, where a single document node is
selected, which you get, for example, when you ctrl/cmd-click a node.
Such a selection ranges from the position directly before the node to
the position directly after it.
Transactions
During normal editing, new states will be derived from the state
before them. You may in some situations, such as loading a new
document, want to create a completely new state, but this is the
exception.
State updates happen by applying a
transaction to an existing state, producing a
new state. Conceptually, they happen in a single shot: given the old
state and the transaction, a new value is computed for each component
of the state, and those are put together in a new state value.
let tr = state.tr
console.log(tr.doc.content.size)
tr.insertText("hello")
let newState = state.apply(tr)
console.log(tr.doc.content.size)
Transaction
is a subclass of
Transform
, and inherits the way it builds
up a new document by applying steps to an initial
document. In addition to this, transactions track selection and other
state-related components, and get some selection-related convenience
methods such as
replaceSelection
.
The easiest way to create a transaction is with the tr
getter on an editor state object. This
creates an empty transaction based on that state, to which you can
then add steps and other updates.
By default, the old selection is mapped
through each step to produce a new selection, but it is possible to
use setSelection
to explicitly
set a new selection.
let tr = state.tr
console.log(tr.selection.from)
tr.delete(6, 8)
console.log(tr.selection.from)
tr.setSelection(TextSelection.create(tr.doc, 3))
console.log(tr.selection.from)
Similarly, the set of active marks
is automatically cleared after a document or selection change, and can
be set using the
setStoredMarks
or
ensureMarks
methods.
Finally, the scrollIntoView
method can be used to ensure that, the next time the state is drawn,
the selection is scrolled into view. You probably want to do that for
most user actions.
Like Transform
methods, many Transaction
methods return the
transaction itself, for convenient chaining.
Plugins
When creating a new state, you can
provide an array of plugins to use. These will be stored in the state
and any state that is derived from it, and can influence both the way
transactions are applied and the way an editor based on this state
behaves.
Plugins are instances of the Plugin
class, and can
model a wide variety of features. The simplest ones just add some
props to the editor view, for example to respond
to certain events. More complicated ones might add new state to the
editor and update it based on transactions.
When creating a plugin, you pass it an object
specifying its behavior:
let myPlugin = new Plugin({
props: {
handleKeyDown(view, event) {
console.log("A key was pressed!")
return false
}
}
})
let state = EditorState.create({schema, plugins: [myPlugin]})
When a plugin needs its own state slot, that is defined with a
state
property:
let transactionCounter = new Plugin({
state: {
init() { return 0 },
apply(tr, value) { return value + 1 }
}
})
function getTransactionCount(state) {
return transactionCounter.getState(state)
}
The plugin in the example defines a very simple piece of state that
simply counts the number of transactions that have been applied to a
state. The helper function uses the plugin's
getState
method, which can be used to
fetch the plugin state from a full editor state object.
Because the editor state is a persistent (immutable) object, and
plugin state is part of that object, plugin state values must be
immutable. I.e. their apply
method must return a new value, rather
than changing the old, if they need to change, and no other code
should change them.
It is often useful for plugins to add some extra information to a
transaction. For example, the undo history, when performing an actual
undo, will mark the resulting transaction, so that when the plugin
sees it, instead of doing the thing it normally does with changes
(adding them to the undo stack), it treats it specially, removing the
top item from the undo stack and adding this transaction to the redo
stack instead.
For this purpose, transactions allow
metadata to be attached to them. We
could update our transaction counter plugin to not count transactions
that are marked, like this:
let transactionCounter = new Plugin({
state: {
init() { return 0 },
apply(tr, value) {
if (tr.getMeta(transactionCounter)) return value
else return value + 1
}
}
})
function markAsUncounted(tr) {
tr.setMeta(transactionCounter, true)
}
Keys for metadata properties can be strings, but to avoid name
collisions, you are encouraged to use plugin objects. There are some
string keys that are given a meaning by the library, for example
"addToHistory"
can be set to false
to prevent a transaction from
being undoable, and when handling a paste, the editor view will set
the "paste"
property on the resulting transaction to true.
视图组件
A ProseMirror editor view is a user interface
component that displays an editor state to the user, and
allows them to perform editing actions on it.
The definition of editing actions used by the core view component is
rather narrow—it handles direct interaction with the editing surface,
such as typing, clicking, copying, pasting, and dragging, but not much
beyond that. This means that things like displaying a menu, or even
providing a full set of key bindings, lie outside of the
responsibility of the core view component, and have to be arranged
through plugins.
Editable DOM
Browsers allow us to specify that some parts of the DOM are
editable,
which has the effect of allowing focus and a selection in them, and
making it possible to type into them. The view creates a DOM
representation of its document (using your schema's toDOM
methods by default), and makes it editable.
When the editable element is focused, ProseMirror makes sure that the
DOM
selection
corresponds to the selection in the editor state.
It also registers event handlers for many DOM events, which translate
the events into the appropriate transactions.
For example, when pasting, the pasted content is
parsed as a ProseMirror document
slice, and then inserted into the document.
Many events are also let through as they are, and only then
reinterpreted in terms of ProseMirror's data model. The browser is
quite good at cursor and selection placement for example (which is a
really difficult problem when you factor in bidirectional text), so
most cursor-motion related keys and mouse actions are handled by the
browser, after which ProseMirror checks what kind of text
selection the current DOM selection would
correspond to. If that selection is different from the current
selection, a transaction that updates the selection is dispatched.
Even typing is usually left to the browser, because interfering with
that tends to break spell-checking, autocapitalizing on some mobile
interfaces, and other native features. When the browser updates the
DOM, the editor notices, re-parses the changed part of the document,
and translates the difference into a transaction.
Data flow
So the editor view displays a given editor state, and when something
happens, it creates a transaction and broadcasts this. This
transaction is then, typically, used to create a new state, which is
given to the view using its
updateState
method.
DOM event
↗↘
↖↙
new EditorState
This creates a straightforward, cyclic data flow, as opposed to the
classic approach (in the JavaScript world) of a host of imperative
event handlers, which tends to create a much more complex web of data
flows.
It is possible to ‘intercept’ transactions as they are
dispatched with the
dispatchTransaction
prop,
in order to wire this cyclic data flow into a larger cycle—if your
whole app is using a data flow model like this, as with
Redux and similar architectures,
you can integrate ProseMirror's transactions in your main
action-dispatching cycle, and keep ProseMirror's state in your
application ‘store’.
let appState = {
editor: EditorState.create({schema}),
score: 0
}
let view = new EditorView(document.body, {
state: appState.editor,
dispatchTransaction(transaction) {
update({type: "EDITOR_TRANSACTION", transaction})
}
})
function update(event) {
if (event.type == "EDITOR_TRANSACTION")
appState.editor = appState.editor.apply(event.transaction)
else if (event.type == "SCORE_POINT")
appState.score++
draw()
}
function draw() {
document.querySelector("#score").textContent = appState.score
view.updateState(appState.editor)
}
Efficient updating
One way to implement updateState
would be to simply redraw the document every time it is called. But
for large documents, that would be really slow.
Since, at the time of updating, the view has access to both the old
document and the new, it can compare them, and leave the parts of the
DOM that correspond to unchanged nodes alone. ProseMirror does this,
allowing it to do very little work for typical updates.
In some cases, like updates that correspond to typed text, which was
already added to the DOM by the browser's own editing actions,
ensuring the DOM and state are coherent doesn't require any DOM
changes at all. (When such a transaction is canceled or modified
somehow, the view will undo the DOM change to make sure the DOM and
the state remain synchronized.)
Similarly, the DOM selection is only updated when it is actually out
of sync with the selection in the state, to avoid disrupting the
various pieces of ‘hidden’ state that browsers keep along with the
selection (such as that feature where when you arrow down or up past a
short line, you horizontal position goes back to where it was when you
enter the next long line).
Props
‘Props’ is a useful, if somewhat vague, term taken from
React.
Props are like parameters to a UI component. Ideally, the set of props
that the component gets completely defines its behavior.
let view = new EditorView({
state: myState,
editable() { return false },
handleDoubleClick() { console.log("Double click!") }
})
As such, the current state is one
prop. The value of other props can also vary over time, if the code
that controls the component updates
them, but aren't considered state, because the component itself
won't change them. The updateState
method is just a shorthand to updating the state
prop.
Plugins are also allowed to declare props,
except for state
and
dispatchTransaction
,
which can only be provided directly to the view.
function maxSizePlugin(max) {
return new Plugin({
props: {
editable(state) { return state.doc.content.size < max }
}
})
}
When a given prop is declared multiple times, how it is handled
depends on the prop. In general, directly provided props take
precedence, after which each plugin gets a turn, in order. For some
props, such as domParser
, the first
value that is found is used, and others are ignored. For handler
functions that return a boolean to indicate whether they handled the
event, the first one that returns true gets to handle the event. And
finally, for some props, such as
attributes
(which can be used to
set attributes on the editable DOM node) and
decorations
(which we'll get to in
the next section), the union of all provided values is used.
Decorations
Decorations give you some control over the way the view draws your
document. They are created by returning values from the decorations
prop, and come in three types:
-
Node decorations add styling or other DOM
attributes to a single node's DOM representation.
-
Widget decorations insert a DOM node,
which isn't part of the actual document, at a given position.
-
Inline decorations add styling or
attributes, much like node decorations, but to all inline nodes in
a given range.
In order to be able to efficiently draw and compare decorations, they
need to be provided as a decoration set (which
is a data structure that mimics the tree shape of the actual
document). You create one using the static create
method, providing the document and an
array of decoration objects:
let purplePlugin = new Plugin({
props: {
decorations(state) {
return DecorationSet.create(state.doc, [
Decoration.inline(0, state.doc.content.size, {style: "color: purple"})
])
}
}
})
When you have a lot of decorations, recreating the set on the fly for
every redraw is likely to be too expensive. In such cases, the
recommended way to maintain your decorations is to put the set in your
plugin's state, map it forward through
changes, and only change it when you need to.
let specklePlugin = new Plugin({
state: {
init(_, {doc}) {
let speckles = []
for (let pos = 1; pos < doc.content.size; pos += 4)
speckles.push(Decoration.inline(pos - 1, pos, {style: "background: yellow"}))
return DecorationSet.create(doc, speckles)
},
apply(tr, set) { return set.map(tr.mapping, tr.doc) }
},
props: {
decorations(state) { return specklePlugin.getState(state) }
}
})
This plugin initializes its state to a decoration set that adds a
yellow-background inline decoration to every 4th position. That's not
terribly useful, but sort of resembles use cases like highlighting
search matches or annotated regions.
When a transaction is applied to the state, the plugin state's
apply
method maps the decoration set
forward, causing the decorations to stay in place and ‘fit’ the new
document shape. The mapping method is (for typical, local changes)
made efficient by exploiting the tree shape of the decoration set—only
the parts of the tree that are actually touched by the changes need to
be rebuilt.
(In a real-world plugin, the apply
method would also be the place
where you add or
remove decorations based on new events,
possibly by inspecting the changes in the transaction, or based on
plugin-specific metadata attached to the transaction.)
Finally, the decorations
prop simply returns the plugin state,
causing the decorations to show up in the view.
Node views
There is one more way in which you can influence the way the editor
view draws your document. Node views make it
possible to define a sort of miniature
UI components for individual nodes in your document. They allow you to
render their DOM, define the way they are updated, and write custom
code to react to events.
let view = new EditorView({
state,
nodeViews: {
image(node) { return new ImageView(node) }
}
})
class ImageView {
constructor(node) {
this.dom = document.createElement("img")
this.dom.src = node.attrs.src
this.dom.addEventListener("click", e => {
console.log("You clicked me!")
e.preventDefault()
})
}
stopEvent() { return true }
}
The view object that the example defines for image nodes creates its
own custom DOM node for the image, with an event handler added, and
declares, with a stopEvent
method, that ProseMirror should ignore
events coming from that DOM node.
You'll often want interaction with the node to have some effect on the
actual node in the document. But to create a transaction that changes
a node, you first need to know where that node is. To help with that,
node views get passed a getter function that can be used to query
their current position in the document. Let's modify the example so
that clicking on the node queries you to enter an alt text for the
image:
let view = new EditorView({
state,
nodeViews: {
image(node, view, getPos) { return new ImageView(node, view, getPos) }
}
})
class ImageView {
constructor(node, view, getPos) {
this.dom = document.createElement("img")
this.dom.src = node.attrs.src
this.dom.alt = node.attrs.alt
this.dom.addEventListener("click", e => {
e.preventDefault()
let alt = prompt("New alt text:", "")
if (alt) view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, {
src: node.attrs.src,
alt
}))
})
}
stopEvent() { return true }
}
setNodeMarkup
is a method that
can be used to change the type or set of attributes for the node at a
given position. In the example, we use getPos
to find our image's
current position, and give it a new attribute object with the new alt
text.
When a node is updated, the default behavior is to leave its outer DOM
structure intact and compare its children to the new set of children,
updating or replacing those as needed. A node view can override this
with custom behavior, which allows us to do something like changing
the class of a paragraph based on its content.
let view = new EditorView({
state,
nodeViews: {
paragraph(node) { return new ParagraphView(node) }
}
})
class ParagraphView {
constructor(node) {
this.dom = this.contentDOM = document.createElement("p")
if (node.content.size == 0) this.dom.classList.add("empty")
}
update(node) {
if (node.type.name != "paragraph") return false
if (node.content.size > 0) this.dom.classList.remove("empty")
else this.dom.classList.add("empty")
return true
}
}
Images never have content, so in our previous example, we didn't need
to worry about how that would be rendered. But paragraphs do have
content. Node views support two approaches to handling content: you
can let the ProseMirror library manage it, or you can manage it
entirely yourself. If you provide a contentDOM
property, the library will render the
node's content into that, and handle content updates. If you don't,
the content becomes a black box to the editor, and how you display it
and let the user interact with it is entirely up to you.
In this case, we want paragraph content to behave like regular
editable text, so the contentDOM
property is defined to be the same
as the dom
property, since the content needs to be rendered directly
into the outer node.
The magic happens in the update
method.
Firstly, this method is responsible for deciding whether the node view
can be updated to show the new node at all. This new node may be
anything that the editor's update algorithm might try to draw here, so
you must verify that this is a node that this node view can handle.
The update
method in the example first checks whether the new node
is a paragraph, and bails out if that's not the case. Then it makes
sure that the "empty"
class is present or absent, depending on the
content of the new node, and returns true, to indicate that the update
succeeded (at which point the node's content will be updated).
命令
In ProseMirror jargon, a command is a function that implements an
editing action, which the user can perform by pressing some key
combination or interacting with the menu.
For practical reasons, commands have a slightly convoluted interface.
In their simple form, they are functions taking an editor
state and a dispatch function
(EditorView.dispatch
or some other
function that takes transactions), and return a boolean. Here's a
very simple example:
function deleteSelection(state, dispatch) {
if (state.selection.empty) return false
dispatch(state.tr.deleteSelection())
return true
}
When a command isn't applicable, it should return false and do
nothing. When it is, it should dispatch a transaction and return true.
This is used, for example, by the keymap plugin to stop
further handling of key events when the command bound to that key has
been applied.
To be able to query whether a command is applicable for a given state,
without actually executing it, the dispatch
argument is
optional—commands should simply return true without doing anything
when they are applicable but no dispatch
argument is given. So the
example command should actually look like this:
function deleteSelection(state, dispatch) {
if (state.selection.empty) return false
if (dispatch) dispatch(state.tr.deleteSelection())
return true
}
To figure out whether a selection can currently be deleted, you'd call
deleteSelection(view.state, null)
, whereas to actually execute the
command, you'd do something like deleteSelection(view.state, view.dispatch)
. A menu bar could use this to determine which menu
items to gray out.
In this form, commands do not get access to the actual editor
view—most commands don't need that, and in this way they can be
applied and tested in settings that don't have a view available. But
some commands do need to interact with the DOM—they might need to
query whether a given position is
at the end of a textblock, or want to open a dialog positioned
relative to the view. For this purpose, most plugins that call
commands will give them a third argument, which is the whole view.
function blinkView(_state, dispatch, view) {
if (dispatch) {
view.dom.style.background = "yellow"
setTimeout(() => view.dom.style.background = "", 1000)
}
return true
}
That (rather useless) example shows that commands don't have to
dispatch a transaction—they are called for their side effect, which is
usually to dispatch a transaction, but may also be something else,
such as popping up a dialog.
The prosemirror-commands
module provides a number of
editing commands, from simple ones such as a variant of the
deleteSelection
command, to rather
complicated ones such as joinBackward
,
which implements the block-joining behavior that should happen when
you press backspace at the start of a textblock. It also comes with a
basic keymap that binds a number of
schema-agnostic commands to the keys that are usually used for them.
When possible, different behavior, even when usually bound to a single
key, is put in different commands. The utility function
chainCommands
can be used to combine a
number of commands—they will be tried one after the other until one
return true.
For example, the base keymap binds backspace to the command chain
deleteSelection
(which kicks in when
the selection isn't empty), joinBackward
(when the cursor is at the start of a textblock), and
selectNodeBackward
(which selects
the node before the selection, in case the schema forbids the regular
joining behavior). When none of these apply, the browser is allowed to
run its own backspace behavior, which is the appropriate thing for
backspacing things out inside a textblock (so that native spell-check
and such don't get confused).
The commands module also exports a number of command constructors,
such as toggleMark
, which takes a mark type
and optionally a set of attributes, and returns a command function
that toggles that mark on the current selection.
Some other modules also export command functions—for example
undo
and redo
from the history
module. To customize your editor, or to allow users to interact with
custom document nodes, you'll likely want to write your own custom
commands as well.
协同编辑
Real-time collaborative editing allows multiple people to edit the
same document at the same time. Changes they make are applied
immediately to their local document, and then sent to peers, which
merge in these changes automatically (without manual conflict
resolution), so that editing can proceed uninterrupted, and the
documents keep converging.
This guide describes how to wire up ProseMirror's collaborative
editing functionality.
Algorithm
ProseMirror's collaborative editing system employs a central authority
which determines in which order changes are applied. If two editors
make changes concurrently, they will both go to this authority with
their changes. The authority will accept the changes from one of them,
and broadcast these changes to all editors. The other's changes will
not be accepted, and when that editor receives new changes from the
server, it'll have to rebase its local
changes on top of those from the other editor, and try to submit them
again.
The Authority
The role of the central authority is actually rather simple. It must...
-
Track a current document version
-
Accept changes from editors, and when these can be applied, add
them to its list of changes
-
Provide a way for editors to receive changes since a given version
Let's implement a trivial central authority that runs in the same
JavaScript environment as the editors.
class Authority {
constructor(doc) {
this.doc = doc
this.steps = []
this.stepClientIDs = []
this.onNewSteps = []
}
receiveSteps(version, steps, clientID) {
if (version != this.steps.length) return
steps.forEach(step => {
this.doc = step.apply(this.doc).doc
this.steps.push(step)
this.stepClientIDs.push(clientID)
})
this.onNewSteps.forEach(function(f) { f() })
}
stepsSince(version) {
return {
steps: this.steps.slice(version),
clientIDs: this.stepClientIDs.slice(version)
}
}
}
When an editor wants to try and submit their changes to the authority,
they can call receiveSteps
on it, passing the last version number
they received, along with the new changes they added, and their client
ID (which is a way for them to later recognize which changes came from
them).
When the steps are accepted, the client will notice because the
authority notifies them that new steps are available, and then give
them their own steps. In a real implementation, you could also have
receiveSteps
return a status, and immediately confirm the sent
steps, as an optimization. But the mechanism used here is necessary to
guarantee synchronization on unreliable connections, so you should
always use it as the base case.
This implementation of an authority keeps an endlessly growing array
of steps, the length of which denotes its current version.
The collab
Module
The collab
module exports a collab
function which returns a plugin that takes care of tracking local
changes, receiving remote changes, and indicating when something has
to be sent to the central authority.
import {EditorState} from "prosemirror-state"
import {EditorView} from "prosemirror-view"
import {schema} from "prosemirror-schema-basic"
import collab from "prosemirror-collab"
function collabEditor(authority, place) {
let view = new EditorView(place, {
state: EditorState.create({
doc: authority.doc,
plugins: [collab.collab({version: authority.steps.length})]
}),
dispatchTransaction(transaction) {
let newState = view.state.apply(transaction)
view.updateState(newState)
let sendable = collab.sendableSteps(newState)
if (sendable)
authority.receiveSteps(sendable.version, sendable.steps,
sendable.clientID)
}
})
authority.onNewSteps.push(function() {
let newData = authority.stepsSince(collab.getVersion(view.state))
view.dispatch(
collab.receiveTransaction(view.state, newData.steps, newData.clientIDs))
})
return view
}
The collabEditor
function creates an editor view that has the
collab
plugin loaded. Whenever the state is updated, it checks
whether there is anything to send to the authority. If so, it sends
it.
It also registers a function that the authority should call when new
steps are available, and which creates a transaction
that updates our local editor state to reflect those steps.
When a set of steps gets rejected by the authority, they will remain
unconfirmed until, supposedly soon after, we receive new steps from
the authority. After that happens, because the onNewSteps
callback
calls dispatch
, which will call our dispatchTransaction
function,
the code will try to submit its changes again.
That's all there is to it. Of course, with asynchronous data channels
(such as long polling in
the collab demo
or web sockets), you'll need somewhat more complicated communication
and synchronization code. And you'll probably also want your authority
to start throwing away steps at some point, so that its memory
consumption doesn't grow without bound. But the general approach is
fully described by this little example.