本手册/文档采用 GPT-4o + 人工方式翻译。每周检查一次原仓库或手动更新,欢迎 Star 和 PR。译者前言:
- 鼠标悬浮在中文上会出现英文原文,方便读者在觉得翻译质量不行的时候直接查看原文(欢迎 PR 更好的翻译)。
- 因为有些接口需要上下文,因此译者的增加了注释以对此进行额外的说明,以灰色背景块显示出来,代表了译者对某个接口的理解。
- 如果你觉得我的工作有帮助,可以 赏杯咖啡钱 。
- 欢迎关注我的技术/生活公众号「开二度魔法」,id:CoderXheldon
ProseMirror Guide
本指南描述了库中使用的各种概念,以及它们之间的关系。为了全面了解系统,建议按照呈现的顺序进行阅读,至少要阅读到视图组件部分。
Introduction
ProseMirror 提供了一套工具和概念,用于构建富文本编辑器,使用的用户界面受“所见即所得”启发,但试图避免这种编辑风格的陷阱。
ProseMirror 的主要原则是您的代码可以完全控制文档及其发生的事情。这个文档不是一个 HTML 的 blob,而是一个自定义的数据结构,只包含您明确允许它包含的元素,并且在您指定的关系中。所有更新都通过一个单一的点,您可以在此检查并对其做出反应。
核心库并不是一个易于插入的组件——我们优先考虑模块化和可定制性,而不是简单性,希望将来人们会基于ProseMirror分发易于插入的编辑器。因此,这更像是一个乐高积木套装,而不是一个火柴盒汽车。
有四个基本模块是进行任何编辑所必需的,还有一些由核心团队维护的扩展模块,其状态类似于第三方模块——它们提供有用的功能,但您可以省略它们或用其他实现类似功能的模块替换它们。
基本模块包括:
-
prosemirror-model
定义了编辑器的文档模型,用于描述编辑器内容的数据结构。 -
prosemirror-state
提供了描述编辑器整体状态的数据结构,包括选择,以及从一个状态移动到下一个状态的事务系统。 -
prosemirror-view
实现了一个用户界面组件,在浏览器中将给定的编辑器状态显示为可编辑元素,并处理用户与该元素的交互。 -
prosemirror-transform
包含用于修改文档的功能,这些修改可以被记录和重放,这是state
模块中事务的基础,并且使撤销历史和协作编辑成为可能。
此外,还有基本编辑命令、绑定键、撤销历史、输入宏、协作编辑、简单文档模式等模块,更多内容请访问GitHub prosemirror组织。
ProseMirror 不是作为一个单一的、浏览器可加载的脚本分发的,这意味着在使用它时,你可能需要使用某种打包工具。打包工具是一种自动查找脚本依赖项并将它们组合成一个大文件的工具,你可以很容易地从网页加载这个文件。你可以在网上阅读更多关于打包的信息,例如这里。
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.body
。这将把状态的文档渲染为一个可编辑的DOM节点,并在用户输入时生成状态事务。
编辑器还不太好用。例如,如果你按下回车键,什么都不会发生,因为核心库对回车键应该做什么没有意见。我们稍后会讨论这个问题。
Transactions
当用户输入或以其他方式与视图交互时,它会生成“状态事务”。这意味着它不仅仅是就地修改文档并以这种方式隐式更新其状态。相反,每次更改都会创建一个事务,该事务描述对状态所做的更改,并可以应用于创建一个新状态,然后用于更新视图。
默认情况下,这一切都在后台进行,但您可以通过编写插件或配置视图来进行挂钩。例如,这段代码添加了一个dispatchTransaction
属性,每当创建事务时都会调用它:
// (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
插件用于以各种方式扩展编辑器和编辑器状态的行为。有些相对简单,比如将键映射插件将操作绑定到键盘输入。其他的则更复杂,比如历史插件通过观察事务并存储它们的逆操作来实现撤销历史,以防用户想要撤销它们。
让我们将这两个插件添加到我们的编辑器中以获得撤销/重做功能:
// (省略重复的导入)
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(或在 OS X 上按 Cmd-Z)来撤销您上次的更改。
Commands
undo
和 redo
值在前面的例子中绑定到键上,是一种特殊的函数,称为命令。大多数编辑操作都写成可以绑定到键、连接到菜单或以其他方式向用户公开的命令。
prosemirror-commands
包提供了一些基本的编辑命令,以及一个最小的键映射,你可能希望启用它,以便在编辑器中实现预期的回车和删除功能。
// (省略重复的导入)
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
一个 state's document 存在于其 doc
属性下。这是一个只读数据结构,表示文档为一个节点层次结构,有点像浏览器的 DOM。一个简单的文档可能是一个 "doc"
节点,包含两个 "paragraph"
节点,每个节点包含一个 "text"
节点。
在初始化状态时,可以给它一个初始文档使用。在这种情况下,schema
字段是可选的,因为可以从文档中获取模式。
在这里,我们通过使用 DOM 解析器机制解析在 ID 为 "content"
的 DOM 元素中找到的内容来初始化状态,该机制使用架构提供的信息来确定哪些 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>
"This is "
"strong text with "
"emphasis"
在ProseMirror中,内联内容被建模为一个平坦的序列,标记作为元数据附加到节点上:
这更接近我们思考和处理此类文本的方式。它允许我们使用字符偏移而不是树中的路径来表示段落中的位置,并且使得执行诸如拆分或更改内容样式之类的操作变得更加容易,而无需进行笨拙的树操作。
这也意味着每个文档有一个有效的表示。具有相同标记集的相邻文本节点总是组合在一起,不允许空文本节点。标记出现的顺序由模式指定。
所以 ProseMirror 文档是一个块节点的树,其中大多数叶节点是文本块,即包含文本的块节点。你也可以有简单的空叶块,例如水平线或视频元素。
Node 对象带有许多属性,这些属性反映了它们在文档中所扮演的角色:
isBlock
andisInline
tell you whether a given node is a block or inline node.inlineContent
is true for nodes that expect inline nodes as content.isTextblock
is true for block nodes with inline content.isLeaf
tells you that a node doesn't allow any content.
所以一个典型的"paragraph"
节点将是一个文本块,而一个blockquote可能是一个其内容由其他块组成的块元素。文本、硬换行和内联图像是内联叶节点,而水平线节点将是块叶节点的一个例子。
模式允许对可能出现的位置指定更精确的约束——即使一个节点允许块内容,也不意味着它允许所有块节点作为内容。
Identity and persistence
另一个重要的区别是DOM树和ProseMirror文档中表示节点的对象的行为方式。在DOM中,节点是具有身份的可变对象,这意味着一个节点只能出现在一个父节点中,并且节点对象在更新时会被修改。
在ProseMirror中,节点只是值,应该像对待表示数字3的值一样对待它们。3可以同时出现在多个数据结构中,它没有指向当前所属数据结构的父链接,如果你在它上面加1,你会得到一个新的值4,而不会改变原来的3。
因此,ProseMirror 文档的片段也是如此。它们不会改变,但可以用作计算修改后的文档片段的起始值。它们不知道自己是哪些数据结构的一部分,但可以是多个结构的一部分,甚至可以在单个结构中多次出现。它们是值,而不是有状态的对象。
这意味着每次更新文档时,您都会获得一个新的文档值。该文档值将与原始文档值共享所有未更改的子节点,从而使其创建成本相对较低。
这有很多优点。它使得在更新期间编辑器不可能处于无效的中间状态,因为新的状态,带有新文档,可以瞬间替换。这也使得以某种数学方式推理文档变得更容易,如果你的值不断变化,这真的很难。这有助于实现协作编辑,并允许ProseMirror通过将最后绘制到屏幕上的文档与当前文档进行比较,运行一个非常高效的DOM 更新算法。
因为这些节点是由常规的JavaScript对象表示的,并且 冻结 它们的属性会影响性能,所以实际上可以更改它们。但是这样做是不被支持的,并且会导致问题,因为它们几乎总是被多个数据结构共享。所以要小心!并且请注意,这也适用于作为节点对象一部分的数组和普通对象,例如用于存储节点属性的对象或片段中的子节点数组。
Data structures
文档的对象结构看起来像这样:
Node | ||||||
type: | NodeType |
|||||
content: | Fragment [ Node ,
Node , ...] |
|||||
attrs: | Object |
|||||
marks: | [
|
每个节点由Node
类的一个实例表示。它被标记为类型,该类型知道节点的名称、对其有效的属性等。节点类型(和标记类型)在每个模式中创建一次,并且知道它们是哪个模式的一部分。
节点的内容存储在Fragment
的实例中,它包含一系列节点。即使对于没有或不允许内容的节点,此字段也会被填充(使用共享的空片段)。
某些节点类型允许属性,这是存储在每个节点中的额外值。例如,图像节点可能会使用这些属性来存储其替代文本和图像的URL。
此外,内联节点包含一组活动标记——例如强调或作为链接——这些标记表示为一个Mark
实例的数组。
一份完整的文档只是一个节点。文档内容表示为顶级节点的子节点。通常,它会包含一系列块节点,其中一些可能是包含内联内容的文本块。但顶级节点本身也可能是一个文本块,因此文档只包含内联内容。
什么类型的节点被允许在哪里是由文档的模式决定的。要以编程方式创建节点,必须通过模式,例如使用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 节点支持两种类型的索引——它们可以被视为树,使用单个节点的偏移量,或者它们可以被视为一系列平坦的标记。
第一个允许你做类似于在DOM中所做的事情——与单个节点交互,直接访问子节点,使用child
方法和childCount
,编写递归函数扫描文档(如果你只想查看所有节点,使用descendants
或nodesBetween
)。
第二种方法在处理文档中的特定位置时更有用。它允许将文档中的任何位置表示为一个整数——即标记序列中的索引。这些标记实际上并不存在于内存中——它们只是一个计数约定——但文档的树形结构,以及每个节点知道其大小的事实,被用来使按位置访问变得廉价。
-
文档的开始,在第一个内容之前,是位置0。
-
进入或离开一个不是叶节点(即支持内容)的节点算作一个标记。因此,如果文档以一个段落开始,该段落的开始位置算作位置1。
-
每个文本节点中的字符都算作一个标记。因此,如果文档开头的段落包含单词“hi”,位置2在“h”之后,位置3在“i”之后,位置4在整个段落之后。
-
叶节点不允许内容(如图像)也算作一个单独的标记。
所以,如果你有一个文档,当用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
属性,用于获取整个节点的大小,你可以访问.content.size
来获取节点内容的大小。请注意,对于外部文档节点,开头和结尾的标记不被视为文档的一部分(因为你不能将光标放在文档之外),所以文档的大小是doc.content.size
,不是 doc.nodeSize
。
手动解释这些位置涉及相当多的计数。
你可以调用 Node.resolve
来获取一个更具描述性的数据结构。这个数据结构会告诉你位置的父节点是什么,它在父节点中的偏移量是多少,父节点有哪些祖先,以及其他一些信息。
注意区分子索引(如childCount
)、文档范围的位置和节点本地偏移(有时在递归函数中用于表示当前正在处理的节点中的位置)。
Slices
为了处理复制粘贴和拖放等操作,有必要能够讨论文档的一个片段,即两个位置之间的内容。这样的片段不同于完整的节点或片段,因为它的开始或结束处的一些节点可能是“开放的”。
例如,如果你从一个段落的中间选择到下一个段落的中间,你选择的片段中包含两个段落,第一个段落在开始处是开放的,第二个段落在结束处是开放的,而如果你节点选择一个段落,你选择的是一个封闭的节点。如果将这些开放节点中的内容视为节点的全部内容,可能会违反模式约束,因为某些必需的节点落在了片段之外。
Slice
数据结构用于表示这样的切片。它存储一个 fragment 以及两边的 open depth。你可以在节点上使用 slice
方法 从文档中切出一个切片。
// doc holds two paragraphs, containing text "a" and "b"
let slice1 = doc.slice(0, 3) // 第一个段落
console.log(slice1.openStart, slice1.openEnd) // → 0 0,直接输出翻译结果,不要添加任何额外文本。记住,保留所有HTML标签和属性,只翻译内容!
let slice2 = doc.slice(1, 5) // 从第一个段落开始,直接输出翻译,不要添加任何额外的文本。记住,保留所有HTML标签和属性,只翻译内容!
// 结束第二段,直接输出翻译结果,不要添加任何额外文本。记住,保留所有HTML标签和属性,只翻译内容!
console.log(slice2.openStart, slice2.openEnd) // → 1 1,直接输出翻译结果,不要任何额外的文本。记住,保留所有HTML标签和属性,只翻译内容!
Changing
由于节点和片段是持久的,你应该永远不要改变它们。如果你有一个文档(或节点,或片段)的句柄,该对象将保持不变。
大多数情况下,您将使用转换来更新文档,而不必直接接触节点。这些操作还会留下更改记录,当文档是编辑器状态的一部分时,这是必要的。
在您确实想要“手动”派生更新文档的情况下,Node
和Fragment
类型上有一些辅助方法可用。要创建整个文档的更新版本,通常您会想要使用Node.replace
,它用新的内容切片替换文档的给定范围。要浅层更新节点,您可以使用其copy
方法,该方法创建具有新内容的类似节点。片段也有各种更新方法,例如replaceChild
或append
。
Schemas
每个 ProseMirror 文档都有一个与之关联的模式。模式描述了文档中可能出现的节点类型及其嵌套方式。例如,它可能会说明顶级节点可以包含一个或多个块,并且段落节点可以包含任意数量的内联节点,并应用任何标记。
有一个带有基本模式的包可用,但ProseMirror的好处是它允许你定义自己的模式。
Node Types
文档中的每个节点都有一个类型,它表示其语义意义及其属性,例如在编辑器中的呈现方式。
当你定义一个模式时,你需要列举可能出现在其中的节点类型,并使用spec 对象描述每个节点类型:
const trivialSchema = new Schema({
nodes: {
doc: {content: "paragraph+"},
paragraph: {content: "text*"},
text: {inline: true},
/* ... 等等 */
}
})
这定义了一个模式,其中文档可以包含一个或多个段落,每个段落可以包含任意数量的文本。
每个模式至少必须定义一个顶级节点类型(默认为名称"doc"
,但你可以配置),以及一个用于文本内容的"text"
类型。
作为内联的节点必须通过inline
属性声明这一点(尽管对于text
类型,它本身就是内联的,你可以省略这一点)。
Content Expressions
在上面的示例模式中,content
字段中的字符串被称为内容表达式。它们控制此节点类型的有效子节点序列。
你可以说,例如"paragraph"
表示“一个段落”,或者"paragraph+"
表示“一个或多个段落”。类似地,"paragraph*"
表示“零个或多个段落”,而"caption?"
表示“零个或一个标题节点”。你也可以使用类似正则表达式的范围,例如{2}
(“正好两个”){1, 5}
(“一到五个”)或{2,}
(“两个或更多”)在节点名称之后。
这样的表达式可以组合成一个序列,例如"heading paragraph+"
表示‘先是一个标题,然后是一个或多个段落’。你也可以使用管道|
运算符来表示在两个表达式之间进行选择,如"(paragraph | blockquote)+"
。
某些元素类型组将在您的模式中多次出现——例如,您可能有“块”节点的概念,它们可能出现在顶层,但也嵌套在引用块内。您可以通过给节点规范一个group
属性来创建一个节点组,然后在表达式中通过其名称引用该组。
const groupSchema = new Schema({
nodes: {
doc: {content: "block+"},
paragraph: {group: "block", content: "text*"},
blockquote: {group: "block", content: "block+"},
text: {}
}
})
这里"block+"
相当于"(paragraph | blockquote)+"
。
建议在具有块内容的节点(例如上面示例中的"doc"
和"blockquote"
)中始终至少要求一个子节点,因为浏览器在节点为空时会完全折叠节点,使其相当难以编辑。
节点在或表达式中出现的顺序是重要的。例如,当为一个非可选节点创建默认实例时,为了确保文档在替换步骤后仍然符合模式,将使用表达式中的第一个类型。如果这是一个组,则使用组中的第一个类型(由组成员在nodes
映射中出现的顺序决定)。如果我在示例模式中交换"paragraph"
和"blockquote"
的位置,当编辑器尝试创建一个块节点时,你会立即遇到堆栈溢出——它会创建一个"blockquote"
节点,其内容至少需要一个块,因此它会尝试创建另一个"blockquote"
作为内容,依此类推。
库中的并非每个节点操作函数都会检查其处理的内容是否有效——更高级别的概念如转换会进行检查,但原始的节点创建方法通常不会,而是将提供合理输入的责任交给调用者。例如,使用NodeType.create
创建一个具有无效内容的节点是完全可能的。对于在切片边缘“开放”的节点,这甚至是合理的做法。还有一个单独的createChecked
方法,以及一个事后的check
方法,可以用来断言给定节点的内容是有效的。
Marks
标记用于为内联内容添加额外的样式或其他信息。模式必须在其模式中声明所有允许的标记类型。标记类型是类似于节点类型的对象,用于标记标记对象并提供有关它们的附加信息。
默认情况下,具有内联内容的节点允许在其子节点上应用架构中定义的所有标记。您可以通过节点规范中的marks
属性来配置此项。
Here's a simple schema that supports strong and emphasis marks on text in paragraphs, but not in headings:
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: {}
}
})
这组标记被解释为一个以空格分隔的标记名称或标记组的字符串—"_"
充当通配符,空字符串对应于空集。
Attributes
文档模式还定义了每个节点或标记具有哪些属性。如果您的节点类型需要存储额外的节点特定信息,例如标题节点的级别,最好使用属性来完成。
属性集表示为具有预定义(每个节点或标记)属性集的普通对象,这些属性集包含任何可JSON序列化的值。要指定它允许的属性,请在节点或标记规范中使用可选的attrs
字段。
heading: {
content: "text*",
attrs: {level: {default: 1}}
}
在这个模式中,每个 heading
节点实例将具有 .attrs.level
下的 level
属性。如果在节点 创建 时未指定,它将默认为 1。
当你不给属性提供默认值时,当你尝试创建这样的节点而不指定该属性时,将会引发错误。
这也将使得库在转换期间或调用createAndFill
时无法生成此类节点作为填充以满足模式约束。这就是为什么不允许在模式中的必需位置放置此类节点的原因——为了能够强制执行模式约束,编辑器需要能够生成空节点来填补内容中的缺失部分。
Serialization and Parsing
为了能够在浏览器中编辑它们,必须能够在浏览器 DOM 中表示文档节点。最简单的方法是在模式中使用节点规范中的toDOM
字段包含有关每个节点的 DOM 表示的信息。
该字段应包含一个函数,当以节点作为参数调用时,返回该节点的DOM结构描述。这可以是一个直接的DOM节点或一个描述它的数组,例如:
const schema = new Schema({
nodes: {
doc: {content: "paragraph+"},
paragraph: {
content: "text*",
toDOM(node) { return ["p", 0] }
},
text: {}
}
})
表达式["p", 0]
声明一个段落被渲染为HTML<p>
标签。零是其内容应渲染的“孔”。您还可以在标签名称后包含一个带有HTML属性的对象,例如["div", {class: "c"}, 0]
。叶节点在其DOM表示中不需要孔,因为它们没有内容。
Mark 规格允许使用类似的 toDOM
方法,
但它们需要渲染为直接包裹内容的单个标签,因此内容总是直接放在返回的节点中,
不需要指定孔。
您还经常需要从 DOM 数据中解析文档,例如当用户将某些内容粘贴或拖入编辑器时。模型模块也提供了相应的功能,并且建议您在模式中直接包含解析信息,使用parseDOM
属性。
这可能列出一系列解析规则,这些规则描述了映射到给定节点或标记的DOM结构。例如,基本模式对强调标记有这些规则:
parseDOM: [
{tag: "em"}, // 匹配 <em> 节点,直接输出翻译结果,不要添加任何额外文本。记住,保留所有 HTML 标签和属性,只翻译内容!
{tag: "i"}, // 和 <i> 节点
{style: "font-style=italic"} // 和内联 'font-style: italic'
]
给tag
的值可以是一个CSS选择器,所以你也可以做类似"div.myclass"
的事情。
同样,style
匹配内联CSS样式。
当一个模式包含parseDOM
注释时,你可以使用DOMParser
对象通过DOMParser.fromSchema
为其创建。这是编辑器用来创建默认剪贴板解析器的方法,但你也可以覆盖它。
文档还带有内置的 JSON 序列化格式。你可以对它们调用 toJSON
来获取一个可以安全传递给 JSON.stringify
的对象,并且 schema 对象有一个 nodeFromJSON
方法,可以将这种表示解析回文档。
Extending a schema
nodes
和 marks
选项传递给 Schema
构造函数时,可以是 OrderedMap
对象 以及普通的 JavaScript 对象。生成的 schema 的 spec
.nodes
和 spec.marks
属性始终是 OrderedMap
,可以用作进一步 schema 的基础。
这样的映射支持多种方法来方便地创建更新的版本。例如,你可以说schema.spec.nodes.remove("blockquote")
来派生一组没有blockquote
节点的节点,然后可以将其作为新模式的nodes
字段传递。
schema-list 模块导出一个 便捷方法,将这些模块导出的节点添加到节点集。
Document transformations
转换 是 ProseMirror 工作方式的核心。它们构成了 事务 的基础,并且使历史跟踪和协作编辑成为可能。
Why?
为什么我们不能直接修改文档然后完成它?或者至少创建一个新版本的文档并将其放入编辑器中?
有几个原因。一个是代码清晰度。不可变的数据结构确实会导致更简单的代码。但主要的是转换系统会留下一个更新的痕迹,以表示从旧版本文档到新版本文档的各个步骤的值的形式。
撤销历史可以保存这些步骤并应用它们的逆操作来回到过去(ProseMirror 实现了选择性撤销,这比仅仅回滚到先前状态更复杂)。
协同编辑系统将这些步骤发送给其他编辑,并在必要时重新排序,以便每个人最终得到相同的文档。
更一般地说,编辑器插件能够在每次更改到来时检查并做出反应是非常有用的,以便使它们自己的状态与编辑器的其余状态保持一致。
Steps
文档的更新被分解为步骤,这些步骤描述了一个更新。你通常不需要直接处理这些步骤,但了解它们的工作原理是有用的。
示例步骤包括ReplaceStep
用于替换文档的一部分,或AddMarkStep
用于在给定范围内添加标记。
一个步骤可以应用到文档上以生成一个新文档。
console.log(myDoc.toString()) // → p("你好")
// 删除位置3和5之间内容的步骤
let step = new ReplaceStep(3, 5, Slice.empty)
let result = step.apply(myDoc)
console.log(result.doc.toString()) // → p("嘿哦")
应用一个步骤是一个相对简单的过程——它不会做任何聪明的事情,比如插入节点以保持模式约束,或转换切片以使其适应。这意味着应用一个步骤可能会失败,例如,如果你试图只删除一个节点的开头标记,这会导致标记不平衡,这是你不能做的有意义的事情。这就是为什么apply
返回一个结果对象,它包含一个新文档,或一条错误信息。
通常情况下,您会希望让辅助函数为您生成步骤,这样您就不必担心细节问题。
Transforms
编辑操作可能会产生一个或多个步骤。处理步骤序列的最方便方法是创建一个Transform
对象(或者,如果您正在处理完整的编辑器状态,可以创建一个Transaction
,它是Transform
的子类)。
let tr = new Transform(myDoc)
tr.delete(5, 7) // 删除位置5到7之间
tr.split(5) // 在位置5拆分父节点
console.log(tr.doc.toString()) // 修改后的文档
console.log(tr.steps.length) // → 2,直接输出翻译结果,不要任何额外的文本。记住,保留所有HTML标签和属性,只翻译内容!
大多数转换方法返回转换本身,以方便链接(允许您执行tr.delete(5, 7).split(5)
)。
有转换方法用于删除和替换,用于添加和移除标记,用于执行树操作如拆分、合并、提升和包裹,等等。
Mapping
当您对文档进行更改时,指向该文档的位置可能会变得无效或改变含义。例如,如果您插入一个字符,则该字符之后的所有位置现在都指向其旧位置之前的一个标记。同样,如果您删除文档中的所有内容,则指向该内容的所有位置现在都无效。
我们经常需要在文档更改过程中保留位置,例如选择边界。为此,步骤可以为您提供一个映射,该映射可以在应用步骤之前和之后在文档中转换位置。
let step = new ReplaceStep(4, 6, Slice.empty) // 删除 4-5
let map = step.getMap()
console.log(map.map(8)) // → 6,直接输出翻译结果,不要添加任何额外文本。记住,保留所有HTML标签和属性,只翻译内容!
console.log(map.map(2)) // → 2(在更改之前没有任何变化)
自动转换对象
累积一组步骤映射,使用一种称为
Mapping
的抽象,它收集一系列步骤映射
并允许你一次性映射它们。
let tr = new Transform(myDoc)
tr.split(10) // 拆分一个节点,+2 个标记在 10
tr.delete(2, 5) // -3 tokens at 2,直接输出翻译结果,不要添加任何额外文本。记住,保留所有HTML标签和属性,只翻译内容!
console.log(tr.mapping.map(15)) // → 14,直接输出翻译结果,不要添加任何额外的文本。记住,保留所有HTML标签和属性,只翻译内容!
console.log(tr.mapping.map(6)) // → 3,直接输出翻译结果,不要添加任何额外文本。记住,保留所有HTML标签和属性,只翻译内容!
console.log(tr.mapping.map(10)) // → 9,直接输出翻译结果,不要添加任何额外文本。记住,保留所有HTML标签和属性,只翻译内容!
有些情况下,给定位置应该映射到哪里并不完全清楚。考虑上面示例的最后一行。位置10正好指向我们拆分节点并插入两个标记的点。它应该映射到插入内容之后的位置,还是保持在它前面?在示例中,它显然被移动到插入标记之后。
但有时你会想要另一种行为,这就是为什么map
方法在步骤映射和映射上接受第二个参数bias
,你可以将其设置为-1,以在内容插入其上时保持你的位置不变。
console.log(tr.mapping.map(10, -1)) // → 7,直接输出翻译结果,不要添加任何额外文本。记住,保留所有HTML标签和属性,只翻译内容!
个别步骤被定义为小而简单的原因是,这使得这种映射成为可能,以及无损地反转步骤,并通过彼此的位置映射映射步骤。
Rebasing
当进行更复杂的操作时,例如实现自己的更改跟踪,或将某些功能与协作编辑集成时,您可能会遇到需要重新定位步骤的情况。
在确定需要之前,你可能不想费心学习这个。
重新定位,在简单情况下,是指从同一个文档开始,进行两个步骤,并将其中一个步骤转换,以便可以应用于另一个步骤创建的文档。在伪代码中:
stepA(doc) = docA
stepB(doc) = docB
stepB(docA) = MISMATCH!
rebase(stepB, mapA) = stepB'
stepB'(docA) = docAB
步骤有一个map
方法,给定一个映射,通过它映射整个步骤。这可能会失败,因为当应用的内容被删除时,有些步骤不再有意义。但是当它成功时,你现在有一个指向新文档的步骤,即你通过映射后的更改后的文档。所以在上面的例子中,rebase(stepB, mapA)
可以简单地调用stepB.map(mapA)
。
当你想要将一系列步骤变基到另一系列步骤上时,事情会变得更加复杂。
stepA2(stepA1(doc)) = docA
stepB2(stepB1(doc)) = docB
???(docA) = docAB
我们可以将stepB1
映射到stepA1
,然后stepA2
,以获得stepB1'
。
但是对于stepB2
,它从stepB1(doc)
生成的文档开始,其映射版本必须应用于stepB1'(docA)
生成的文档,事情变得更加困难。它必须映射到以下映射链:
rebase(stepB2, [invert(mapB1), mapA1, mapA2, mapB1'])
即首先对stepB1
的映射求逆以返回到原始文档,然后通过应用stepA1
和stepA2
生成的映射管道,最后通过应用stepB1'
到docA
生成的映射。
如果有一个stepB3
,我们可以通过获取上面的管道,前缀加上invert(mapB2)
并在末尾添加mapB2'
来得到它的管道。依此类推。
但是当stepB1
插入了一些内容,并且stepB2
对该内容做了一些处理时,通过invert(mapB1)
映射stepB2
将返回null
,因为stepB1
的逆操作删除了它所应用的内容。然而,这些内容稍后会通过mapB1
重新引入到管道中。Mapping
抽象提供了一种方法来跟踪这样的管道,包括其中映射的逆关系。你可以通过它映射步骤,使它们在上述情况下得以保留。
即使你已经重新定位了一个步骤,也不能保证它仍然可以有效地应用于当前文档。例如,如果你的步骤添加了一个标记,但另一个步骤将目标内容的父节点更改为不允许标记的节点,尝试应用你的步骤将会失败。对此的适当响应通常是直接丢弃该步骤。
The editor state
编辑器的状态由什么组成?当然有你的文档。还有当前的选择。还需要有一种方法来存储当前标记集已更改的事实,例如当你禁用或启用标记但尚未开始使用该标记输入时。
那些是 ProseMirror 状态的三个主要组成部分,并且在状态对象上以 doc
、selection
和 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) // 1,段落的开始
但是插件可能也需要存储状态——例如,撤销历史必须保留其更改历史。这就是为什么活动插件集也存储在状态中,这些插件可以定义额外的插槽来存储它们自己的状态。
Selection
ProseMirror 支持多种类型的选区(并允许第三方代码定义新的选区类型)。选区由 Selection
类的实例(子类)表示。像文档和其他与状态相关的值一样,它们是不可变的——要更改选区,您需要创建一个新的选区对象和一个新的状态来保存它。
Selections 至少有一个开始(.from
)和一个结束(.to
),作为指向当前文档的位置。许多选择类型还区分选择的锚点(不可移动)和头部(可移动)两侧,因此这些也需要存在于每个选择对象上。
最常见的选择类型是文本选择,用于常规光标(当anchor
和head
相同时)或选中文本。文本选择的两个端点都需要位于内联位置,即指向允许内联内容的节点。
核心库还支持节点选择,即选择单个文档节点,例如,当你按住ctrl/cmd键点击一个节点时。这样的选择范围从节点前的位置到节点后的位置。
Transactions
在正常编辑过程中,新状态将从之前的状态派生。在某些情况下,例如加载新文档时,您可能希望创建一个全新的状态,但这是例外。
状态更新通过应用一个事务到现有状态来发生,从而产生一个新状态。从概念上讲,它们是一次性发生的:给定旧状态和事务,为状态的每个组件计算一个新值,并将这些新值组合成一个新的状态值。
let tr = state.tr
console.log(tr.doc.content.size) // 25,直接输出翻译结果,不要添加任何额外文本。记住,保留所有HTML标签和属性,只翻译内容!
tr.insertText("hello") // 用'hello'替换选择内容
let newState = state.apply(tr)
console.log(tr.doc.content.size) // 30,直接输出翻译内容,不要任何附加文本。记住,保留所有HTML标签和属性,只翻译内容!
Transaction
是 Transform
的子类,并继承了通过对初始文档应用 步骤 来构建新文档的方式。除此之外,事务还跟踪选择和其他与状态相关的组件,并获得一些与选择相关的便捷方法,例如 replaceSelection
。
创建事务的最简单方法是使用编辑器状态对象上的tr
获取器。这会基于该状态创建一个空事务,然后您可以向其中添加步骤和其他更新。
默认情况下,旧选择会通过每一步映射来生成新选择,但也可以使用setSelection
显式设置新选择。
let tr = state.tr
console.log(tr.selection.from) // → 10,直接输出翻译结果,不要任何额外的文本。记住,保留所有HTML标签和属性,只翻译内容!
tr.delete(6, 8)
console.log(tr.selection.from) // → 8(移回)
tr.setSelection(TextSelection.create(tr.doc, 3))
console.log(tr.selection.from) // → 3,直接输出翻译结果,不要添加任何额外文本。记住,保留所有HTML标签和属性,只翻译内容!
类似地,活动标记集会在文档或选择更改后自动清除,并且可以使用setStoredMarks
或ensureMarks
方法进行设置。
最后,scrollIntoView
方法可以用来确保下次绘制状态时,选区会滚动到可视范围内。对于大多数用户操作,你可能都希望这样做。
像Transform
方法一样,许多Transaction
方法返回事务本身,以便于链式调用。
Plugins
当创建一个新状态时,你可以提供一个插件数组来使用。这些插件将存储在状态中以及从其派生的任何状态中,并且可以影响事务的应用方式以及基于此状态的编辑器的行为。
插件是Plugin
类的实例,可以建模各种各样的功能。最简单的插件只是向编辑器视图添加一些props,例如响应某些事件。更复杂的插件可能会向编辑器添加新的状态,并根据事务更新它。
在创建插件时,你需要传递一个对象来指定其行为:
let myPlugin = new Plugin({
props: {
handleKeyDown(view, event) {
console.log("A key was pressed!")
return false // 我们没有处理这个
}
}
})
let state = EditorState.create({schema, plugins: [myPlugin]})
当插件需要自己的状态槽时,可以使用state
属性来定义:
let transactionCounter = new Plugin({
state: {
init() { return 0 },
apply(tr, value) { return value + 1 }
}
})
function getTransactionCount(state) {
return transactionCounter.getState(state)
}
该示例中的插件定义了一段非常简单的状态,它只是计算已应用于状态的事务数量。辅助函数使用插件的getState
方法,该方法可用于从完整的编辑器状态对象中获取插件状态。
由于编辑器状态是一个持久(不可变)的对象,并且插件状态是该对象的一部分,因此插件状态值必须是不可变的。也就是说,如果它们需要更改,它们的apply
方法必须返回一个新值,而不是更改旧值,并且不应由其他代码更改它们。
插件通常会为事务添加一些额外的信息是很有用的。例如,当执行实际撤销操作时,撤销历史记录会标记结果事务,这样当插件看到它时,不是像通常那样处理更改(将它们添加到撤销堆栈),而是特别处理它,移除撤销堆栈的顶部项目,并将此事务添加到重做堆栈中。
为此,交易允许 元数据附加到它们。我们 可以更新我们的交易计数器插件,以不计算标记的交易,如下所示:
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)
}
元数据属性的键可以是字符串,但为了避免名称冲突,建议使用插件对象。有些字符串键被库赋予了特定含义,例如"addToHistory"
可以设置为false
以防止事务可撤销,并且在处理粘贴时,编辑器视图会将结果事务的"paste"
属性设置为true。
The view component
ProseMirror 编辑器视图 是一个用户界面组件,用于向用户显示 编辑器状态,并允许他们在其上执行编辑操作。
核心视图组件使用的编辑操作的定义相当狭窄——它处理与编辑界面的直接交互,例如打字、点击、复制、粘贴和拖动,但除此之外并不多。这意味着显示菜单或甚至提供完整的键绑定集等事情不在核心视图组件的责任范围内,必须通过插件来安排。
Editable DOM
浏览器允许我们指定 DOM 的某些部分是
可编辑的,
这使得它们可以获得焦点和选择,并且可以在其中输入内容。视图创建其文档的 DOM 表示(默认情况下使用您的模式的toDOM
方法),并使其可编辑。
当可编辑元素获得焦点时,ProseMirror 确保
DOM 选择
与编辑器状态中的选择相对应。
它还为许多 DOM 事件注册了事件处理程序,这些事件会转换为相应的事务。例如,在粘贴时,粘贴的内容会作为 ProseMirror 文档片段解析,然后插入到文档中。
许多事件也会原样通过,只有然后才根据ProseMirror的数据模型重新解释。例如,浏览器在光标和选择位置方面做得很好(当你考虑到双向文本时,这是一个非常困难的问题),所以大多数与光标移动相关的键和鼠标操作都由浏览器处理,之后ProseMirror会检查当前DOM选择对应的文本选择类型。如果该选择与当前选择不同,则会调度一个更新选择的事务。
即使是打字通常也留给浏览器,因为干扰它往往会破坏拼写检查、某些移动界面的自动大写和其他本机功能。当浏览器更新DOM时,编辑器会注意到,重新解析文档的更改部分,并将差异转换为事务。
Data flow
因此,编辑器视图显示给定的编辑器状态,当发生某些事情时,它会创建一个事务并广播该事务。然后,通常使用此事务创建一个新状态,并使用其updateState
方法将新状态提供给视图。
这创建了一个简单的循环数据流,而不是经典的方法(在JavaScript世界中)一大堆命令式事件处理程序,这往往会创建一个更复杂的数据流网络。
可以通过拦截事务
dispatchTransaction
属性,
将这种循环数据流接入到更大的循环中——如果你的整个应用程序使用类似的数据流模型,如
Redux和类似的架构,
你可以将ProseMirror的事务集成到你的主要动作分发循环中,并将ProseMirror的状态保存在你的应用程序‘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})
}
})
// 一个粗略的应用状态更新函数,它接受一个更新对象,
// 更新 `appState`,然后刷新 UI。
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
一种实现updateState
的方法是每次调用时简单地重绘文档。但是对于大型文档,这样会非常慢。
由于在更新时,视图可以同时访问旧文档和新文档,因此它可以比较它们,并保留与未更改节点对应的DOM部分不变。ProseMirror就是这样做的,使其在典型更新中只需做很少的工作。
在某些情况下,比如与键入文本相对应的更新,这些文本已经通过浏览器自身的编辑操作添加到DOM中,确保DOM和状态一致根本不需要进行任何DOM更改。(当这样的事务被取消或以某种方式修改时,视图将撤销DOM更改以确保DOM和状态保持同步。)
同样,只有当 DOM 选择实际与状态中的选择不同步时才会更新,以避免干扰浏览器与选择一起保留的各种“隐藏”状态(例如,当你向下或向上箭头经过一条短线时,你的水平位置会回到进入下一条长线时的位置)。
Props
‘Props’ 是一个有用但有些模糊的术语,取自 React。 Props 就像 UI 组件的参数。理想情况下,组件获得的 props 集合完全定义了其行为。
let view = new EditorView({
state: myState,
editable() { return false }, // 启用只读行为
handleDoubleClick() { console.log("Double click!") }
})
因此,当前的state是一个prop。如果控制组件的代码更新了其他prop的值,它们也可能随时间变化,但不被视为state,因为组件本身不会更改它们。updateState
方法只是更新state
prop的简写。
插件也允许声明属性,
除了state
和
dispatchTransaction
,
这些只能直接提供给视图。
function maxSizePlugin(max) {
return new Plugin({
props: {
editable(state) { return state.doc.content.size < max }
}
})
}
当一个给定的 prop 被多次声明时,它的处理方式取决于该 prop。一般来说,直接提供的 props 优先,然后每个插件依次处理。对于某些 props,例如 domParser
,使用找到的第一个值,其他的会被忽略。对于返回布尔值以指示是否处理事件的处理函数,第一个返回 true 的函数将处理该事件。最后,对于某些 props,例如 attributes
(可用于设置可编辑 DOM 节点上的属性)和 decorations
(我们将在下一节中讨论),使用所有提供值的并集。
Decorations
装饰让你可以在一定程度上控制视图绘制文档的方式。它们通过从decorations
属性返回值来创建,并且有三种类型:
-
节点装饰 为单个节点的 DOM 表现添加样式或其他 DOM 属性。
-
小部件装饰 插入 一个 DOM 节点,该节点不是实际文档的一部分,而是位于给定位置。
-
内联装饰 添加样式或属性,就像节点装饰一样,但应用于给定范围内的所有内联节点。
为了能够高效地绘制和比较装饰,它们需要作为装饰集提供(这是一种模仿实际文档树形结构的数据结构)。你可以使用静态create
方法创建一个,提供文档和一个装饰对象数组:
let purplePlugin = new Plugin({
props: {
decorations(state) {
return DecorationSet.create(state.doc, [
Decoration.inline(0, state.doc.content.size, {style: "color: purple"})
])
}
}
})
当你有很多装饰时,每次重绘时即时重建这些装饰可能会过于昂贵。在这种情况下,推荐的维护装饰的方法是将装饰集放入插件的状态中,通过更改映射它,并且只有在需要时才更改它。
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) }
}
})
此插件将其状态初始化为一个装饰集,该装饰集在每第4个位置添加一个黄色背景的内联装饰。这不是特别有用,但有点类似于突出显示搜索匹配项或注释区域的用例。
当一个事务被应用到状态时,插件状态的
apply
方法 将装饰集合向前映射,使装饰保持在原位并“适应”新文档的形状。映射方法(对于典型的局部更改)通过利用装饰集合的树形结构来提高效率——只有实际受到更改影响的树的部分需要重建。
在实际的插件中,apply
方法也是你根据新事件添加或移除装饰的地方,可能通过检查事务中的更改,或基于附加到事务的插件特定元数据。
最后,decorations
属性只是返回插件状态,导致装饰出现在视图中。
Node views
还有一种方法可以影响编辑器视图绘制文档的方式。节点视图使得可以为文档中的单个节点定义一种微型UI组件。它们允许你渲染它们的DOM,定义它们的更新方式,并编写自定义代码来响应事件。
let view = new EditorView({
state,
nodeViews: {
image(node) { return new ImageView(node) }
}
})
class ImageView {
constructor(node) {
// 编辑器将使用此作为节点的DOM表示,直接输出翻译结果,不要添加任何额外文本。记住,保留所有HTML标签和属性,只翻译内容!
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 }
}
示例为图像节点定义的视图对象为图像创建了自己的自定义DOM节点,添加了一个事件处理程序,并通过stopEvent
方法声明ProseMirror应忽略来自该DOM节点的事件。
您通常希望与节点的交互对文档中的实际节点产生一些影响。但是要创建更改节点的事务,首先需要知道该节点的位置。为此,节点视图会传递一个 getter 函数,该函数可用于查询它们在文档中的当前位置。让我们修改示例,使点击节点时查询您输入图像的替代文本:
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
是一种方法,可以用来更改给定位置节点的类型或属性集。在这个例子中,我们使用 getPos
来找到图像的当前位置,并给它一个带有新 alt 文本的新属性对象。
当节点更新时,默认行为是保持其外部DOM结构不变,并将其子节点与新的一组子节点进行比较,根据需要更新或替换这些子节点。节点视图可以通过自定义行为覆盖此行为,这使我们能够根据段落的内容更改其类。
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
}
}
图像从来没有内容,所以在我们之前的例子中,我们不需要担心它将如何被渲染。但是段落确实有内容。节点视图支持两种处理内容的方法:你可以让ProseMirror库管理它,或者你可以完全自己管理它。如果你提供一个contentDOM
属性,库将会把节点的内容渲染到那里,并处理内容更新。如果你不提供,内容对编辑器来说就变成了一个黑箱,如何显示它以及让用户与之交互完全取决于你。
在这种情况下,我们希望段落内容表现得像常规的可编辑文本,因此contentDOM
属性被定义为与dom
属性相同,因为内容需要直接渲染到外部节点中。
魔法发生在update
方法中。首先,此方法负责决定节点视图是否可以更新以显示新节点。这个新节点可能是编辑器的更新算法可能尝试在此处绘制的任何内容,因此您必须验证这是此节点视图可以处理的节点。
示例中的update
方法首先检查新节点是否是段落,如果不是则退出。然后它确保根据新节点的内容,"empty"
类存在或不存在,并返回true,以表明更新成功(此时节点的内容将被更新)。
Commands
在ProseMirror术语中,命令是实现编辑操作的函数,用户可以通过按下某些键组合或与菜单交互来执行该操作。
出于实际原因,命令有一个稍微复杂的接口。它们的简单形式是函数,接受一个编辑器状态和一个分发函数(EditorView.dispatch
或其他接受事务的函数),并返回一个布尔值。这里有一个非常简单的例子:
function deleteSelection(state, dispatch) {
if (state.selection.empty) return false
dispatch(state.tr.deleteSelection())
return true
}
当命令不适用时,它应返回 false 并且不执行任何操作。当适用时,它应分派一个事务并返回 true。例如,keymap 插件 使用它来在绑定到该键的命令已被应用时停止进一步处理键事件。
为了能够查询某个命令是否适用于给定状态,而不实际执行它,dispatch
参数是可选的——当命令适用但没有给定 dispatch
参数时,命令应简单地返回 true 而不做任何事情。所以示例命令实际上应该是这样的:
function deleteSelection(state, dispatch) {
if (state.selection.empty) return false
if (dispatch) dispatch(state.tr.deleteSelection())
return true
}
要确定当前是否可以删除选定内容,可以调用deleteSelection(view.state, null)
,而要实际执行命令,可以这样做deleteSelection(view.state, view.dispatch)
。菜单栏可以使用此方法来确定哪些菜单项需要变灰。
以这种形式,命令无法访问实际的编辑器视图——大多数命令不需要这样做,这样它们可以在没有视图的设置中应用和测试。但是有些命令确实需要与 DOM 交互——它们可能需要查询给定位置是否在文本块的末尾,或者希望打开相对于视图定位的对话框。为此,大多数调用命令的插件会给它们一个第三个参数,即整个视图。
function blinkView(_state, dispatch, view) {
if (dispatch) {
view.dom.style.background = "yellow"
setTimeout(() => view.dom.style.background = "", 1000)
}
return true
}
那个(相当无用的)例子表明命令不必须分派一个事务——它们是为了它们的副作用而被调用的,这通常是为了分派一个事务,但也可能是其他事情,比如弹出一个对话框。
prosemirror-commands
模块提供了许多编辑命令,从简单的命令如deleteSelection
命令的变体,到相当复杂的命令如joinBackward
,它实现了当你在文本块的开头按下退格键时应该发生的块连接行为。它还附带了一个基本键映射,将许多与模式无关的命令绑定到通常用于它们的键上。
如果可能,即使通常绑定到单个键的不同行为也会放在不同的命令中。实用函数chainCommands
可以用来组合多个命令——它们会一个接一个地尝试,直到一个返回true。
例如,基本键映射将退格键绑定到命令链
deleteSelection
(当选择不为空时生效),joinBackward
(当光标位于文本块的开头时生效),以及
selectNodeBackward
(在选择之前选择节点,以防架构禁止常规的
连接行为)。当这些都不适用时,浏览器可以运行自己的退格行为,这对于在文本块内退格是合适的(这样本地拼写检查等不会混淆)。
命令模块还导出许多命令构造函数,例如toggleMark
,它接受一个标记类型和可选的一组属性,并返回一个命令函数,该函数在当前选择上切换该标记。
一些其他模块也导出命令函数——例如撤销
和重做
来自历史模块。要自定义您的编辑器,或允许用户与自定义文档节点交互,您可能也需要编写自己的自定义命令。
Collaborative editing
实时协作编辑允许多个人同时编辑同一个文档。他们所做的更改会立即应用到他们的本地文档中,然后发送给其他人,这些更改会自动合并(无需手动解决冲突),以便编辑可以不间断地进行,并且文档保持一致。
本指南介绍了如何连接ProseMirror的协作编辑功能。
Algorithm
ProseMirror 的协作编辑系统采用一个中央权威来确定更改的应用顺序。如果两个编辑者同时进行更改,他们都会将更改提交给这个权威。权威会接受其中一个编辑者的更改,并将这些更改广播给所有编辑者。另一个编辑者的更改将不会被接受,当该编辑者从服务器接收到新的更改时,它将不得不在其他编辑者的更改之上重新基准化其本地更改,并尝试再次提交。
The Authority
中央权力的角色实际上相当简单。它必须...
-
跟踪当前文档版本
-
接受编辑的更改,并在可以应用这些更改时,将它们添加到其更改列表中
-
为编辑提供一种接收自给定版本以来更改的方法
让我们实现一个在与编辑器相同的JavaScript环境中运行的简单中央权限。
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)
})
// 信号监听器,直接输出翻译,不要任何额外的文本。记住,保留所有HTML标签和属性,只翻译内容!
this.onNewSteps.forEach(function(f) { f() })
}
stepsSince(version) {
return {
steps: this.steps.slice(version),
clientIDs: this.stepClientIDs.slice(version)
}
}
}
当编辑想要尝试提交他们的更改给权威时,他们可以调用receiveSteps
,传递他们收到的最后一个版本号,以及他们添加的新更改和他们的客户端ID(这是一种让他们以后识别哪些更改来自他们的方法)。
当步骤被接受时,客户端会注意到,因为权威通知他们有新的步骤可用,然后给他们他们自己的步骤。在实际实现中,你也可以让receiveSteps
返回一个状态,并立即确认发送的步骤,作为一种优化。但这里使用的机制是保证在不可靠连接上同步所必需的,所以你应该始终将其用作基本情况。
此实现的权限保留了一个不断增长的步骤数组,其长度表示其当前版本。
The collab
Module
collab
模块导出一个 collab
函数,该函数返回一个插件,用于跟踪本地更改、接收远程更改,并指示何时需要将某些内容发送到中央机构。
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
}
collabEditor
函数创建了一个加载了 collab
插件的编辑器视图。每当状态更新时,它会检查是否有任何内容需要发送给权限。如果有,它会发送。
它还注册了一个函数,当有新步骤可用时,权限应调用该函数,并创建一个事务,该事务更新我们的本地编辑器状态以反映这些步骤。
当一组步骤被权威机构拒绝时,它们将保持未确认状态,直到我们从权威机构收到新步骤(应该很快)。在那之后,因为onNewSteps
回调调用了dispatch
,这将调用我们的dispatchTransaction
函数,代码将尝试再次提交其更改。
就是这样。当然,对于异步数据通道(例如协作演示中的长轮询或Web套接字),您将需要更复杂的通信和同步代码。而且您可能还希望您的服务器在某个时候开始丢弃步骤,以防止内存消耗无限增长。但总体方法已经完全由这个小例子描述。