ProseMirror 指南
ProseMirror Guide
本指南描述了该库使用的各种概念,以及它们之间的关系。为了对系统有一个完整的认识,建议你按照它所呈现的顺序读下去,至少读到 view 组件这一节。
This guide describes the various concepts used in the library, and how they relate to each other. To get a complete picture of the system, it is recommended to go through it in the order it is presented in, at least up to the view component section.
简介
Introduction
ProseMirror 提供了一套用于构建富文本编辑器的工具和概念,它使用的用户界面灵感来自所见即所得(WYSIWYG),但试图避免那种编辑风格的种种陷阱。
ProseMirror provides a set of tools and concepts for building rich text editors, using a user interface inspired by what-you-see-is-what-you-get, but trying to avoid the pitfalls of that style of editing.
ProseMirror 的主要原则是:你的代码对文档以及发生在它身上的一切拥有完全的控制权。这份文档不是一团 HTML,而是一种自定义的数据结构,它只包含你明确允许它包含的元素,并且这些元素之间保持着你所指定的关系。所有更新都经过一个单一入口,在那里你可以检查它们并对其做出反应。
The main principle of ProseMirror is that your code gets full control over the document and what happens to it. This document isn't a blob of HTML, but a custom data structure that only contains elements that you explicitly allow it to contain, in relations that you specified. All updates go through a single point, where you can inspect them and react to them.
核心库不是一个简单的开箱即用组件——我们把模块化和可定制性放在简单性之上,希望将来人们能基于 ProseMirror 发布开箱即用的编辑器。因此,它更像是一套乐高积木,而不是一辆火柴盒玩具车。
The core library is not an easy drop-in component—we are prioritizing modularity and customizability over simplicity, with the hope that, in the future, people will distribute drop-in editors based on ProseMirror. As such, this is more of a Lego set than a Matchbox car.
有四个核心模块,是进行任何编辑所必需的;此外还有一些由核心团队维护的扩展模块,它们的地位与第三方模块类似——它们提供有用的功能,但你可以省略它们,或用实现类似功能的其他模块来替换它们。
There are four essential modules, which are required to do any editing at all, and a number of extension modules maintained by the core team, which have a status similar to that of 3rd party modules—they provide useful functionality, but you may omit them or replace them with other modules that implement similar functionality.
核心模块如下:
The essential modules are:
-
prosemirror-model定义了编辑器的文档模型,即用于描述编辑器内容的数据结构。prosemirror-modeldefines the editor's document model, the data structure used to describe the content of the editor. -
prosemirror-state提供了描述编辑器整体状态(包括选区)的数据结构,以及从一个状态转换到下一个状态的事务系统。prosemirror-stateprovides the data structure that describes the editor's whole state, including the selection, and a transaction system for moving from one state to the next. -
prosemirror-view实现了一个用户界面组件,它把给定的编辑器状态显示为浏览器中的可编辑元素,并处理用户与该元素的交互。prosemirror-viewimplements a user interface component that shows a given editor state as an editable element in the browser, and handles user interaction with that element. -
prosemirror-transform包含以可记录、可重放的方式修改文档的功能,它是state模块中事务的基础,也使得撤销历史和协同编辑成为可能。prosemirror-transformcontains functionality for modifying documents in a way that can be recorded and replayed, which is the basis for the transactions in thestatemodule, and which makes the undo history and collaborative editing possible.
此外,还有一些模块用于基础编辑命令、按键绑定、撤销历史、输入宏、协同编辑、一个简单的文档 schema,以及 prosemirror 组织下的更多模块。
In addition, there are modules for basic editing commands, binding keys, undo history, input macros, collaborative editing, a simple document schema, and more under the prosemirror organization.
ProseMirror 并不是以一个可直接在浏览器中加载的单一脚本分发的,这意味着你在使用它时很可能需要某种打包工具。打包工具是一种能自动找出脚本依赖,并把它们合并成一个大文件(便于从网页加载)的工具。你可以在网上阅读更多关于打包的内容,例如这里。
The fact that ProseMirror isn't distributed as a single, browser-loadable script means that you'll probably want to use some kind of bundler when using it. A bundler is a tool that automatically finds your script's dependencies, and combines them into a single big file that you can easily load from a web page. You can read more about bundling on the web, for example here.
乐高积木像这样拼在一起,就能创建一个非常简单的编辑器:
The Lego pieces fit together like this to create a very minimal 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 要求你指定一个文档所遵循的 schema,所以这段代码做的第一件事就是导入一个带基础 schema 的模块。
ProseMirror requires you to specify a schema that your document conforms to, so the first thing this does is import a module with a basic schema in it.
然后用这个 schema 创建一个 state,它会生成一个符合该 schema 的空文档,以及位于该文档开头的一个默认选区。最后,为这个 state 创建一个 view,并把它追加到 document.body 上。这会把 state 的文档渲染成一个可编辑的 DOM 节点,并在用户输入时生成状态事务。
That schema is then used to create a state, which will generate an
empty document conforming to the schema, and a default selection at
the start of that document. Finally, a view is created for the state,
and appended to document.body. This will render the state's document
as an editable DOM node, and generate state transactions whenever the
user types into it.
这个编辑器现在还不太好用。例如,如果你按回车,什么也不会发生,因为核心库对回车应该做什么没有任何意见。我们稍后会讲到这一点。
The editor isn't very usable yet. If you press enter, for example, nothing happens, because the core library has no opinion on what enter should do. We'll get to that in a moment.
当用户输入或以其他方式与视图交互时,它会生成「状态事务」。这意味着它并不是就地修改文档并隐式地更新其状态。相反,每次变更都会导致创建一个事务(transaction),它描述了对状态所做的更改,并且可以被应用来创建一个新的状态,这个新状态随后被用来更新视图。
When the user types, or otherwise interacts with the view, it generates ‘state transactions’. What that means is that it does not just modify the document in-place and implicitly update its state in that way. Instead, every change causes a transaction to be created, which describes the changes that are made to the state, and can be applied to create a new state, which is then used to update the view.
默认情况下,这一切都在幕后进行,但你可以通过编写插件或配置你的视图来介入其中。例如,下面这段代码添加了一个 dispatchTransaction prop,每当有事务被创建时它都会被调用:
By default this all happens under the cover, but you can hook into by
writing plugins or configuring your view. For
example, this code adds a
dispatchTransaction
prop, which will be called whenever a
transaction is created:
// (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,而每一次正常的编辑更新都会通过派发一个事务来进行。
Every state update has to go through
updateState, and every normal
editing update will happen by dispatching a transaction.
插件用于以各种方式扩展编辑器和编辑器状态的行为。有些相对简单,比如 keymap 插件把动作绑定到键盘输入上。另一些则更复杂,比如 history 插件通过观察事务并存储它们的逆操作来实现撤销历史,以备用户想要撤销它们。
Plugins are used to extend the behavior of the editor and editor state in various ways. Some are relatively simple, like the keymap plugin that binds actions to keyboard input. Others are more involved, like the history plugin which implements an undo history by observing transactions and storing their inverse in case the user wants to undo them.
让我们把这两个插件加到我们的编辑器中,以获得撤销/重做功能:
Let's add those two plugins to our editor to get undo/redo functionality:
// (Omitted repeated imports)
import {undo, redo, history} from "prosemirror-history"
import {keymap} from "prosemirror-keymap"
let state = EditorState.create({
schema,
plugins: [
history(),
keymap({"Mod-z": undo, "Mod-y": redo})
]
})
let view = new EditorView(document.body, {state})
插件在创建 state 时注册(因为它们需要访问状态事务)。为这个启用了历史记录的状态创建视图之后,你就可以按 Ctrl-Z(或在 OS X 上按 Cmd-Z)来撤销上一步更改。
Plugins are registered when creating a state (because they get access to state transactions). After creating a view for this history-enabled state, you'll be able to press Ctrl-Z (or Cmd-Z on OS X) to undo your last change.
上一个例子绑定到按键上的 undo 和 redo 值,是一种特殊的函数,叫做命令(commands)。大多数编辑操作都被写成命令,命令可以绑定到按键、挂到菜单上,或以其他方式暴露给用户。
The undo and redo values that the previous example bound to keys
are a special kind of function called commands.
Most editing actions are written as commands which can be bound to
keys, hooked up to menus, or otherwise exposed to the user.
prosemirror-commands 包提供了许多基础编辑命令,以及一个最小的 keymap。你很可能想启用这个 keymap,让回车和删除键在你的编辑器中做它们该做的事。
The prosemirror-commands package provides a number of basic editing
commands, along with a minimal keymap that you'll probably want to
enable to have things like enter and delete do the expected thing in
your editor.
// (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})
到这一步,你已经有了一个基本可用的编辑器。
At this point, you have a basically working editor.
要添加菜单、针对 schema 特定内容的额外按键绑定等,你可能想看看 prosemirror-example-setup 包。这个模块为你提供一组插件来搭建一个基础编辑器,但正如其名字所暗示的,它更偏向于示例,而不是生产级库。对于真实世界的部署,你很可能想用自定义代码替换它,按照你想要的方式精确地设置一切。
To add a menu, additional keybindings for schema-specific things, and
so on, you might want to look into the
prosemirror-example-setup
package. This is a module that provides you with an array of plugins
that set up a baseline editor, but as the name suggests, it is meant
more as an example than as a production-level library. For a
real-world deployment, you'll probably want to replace it with custom
code that sets things up exactly the way you want.
state 的文档存放在它的 doc 属性下。这是一个只读的数据结构,把文档表示为一个节点层级,有点像浏览器 DOM。一个简单的文档可能是一个 "doc" 节点,包含两个 "paragraph" 节点,每个段落又包含一个 "text" 节点。
A state's document lives under its doc
property. This is a read-only data structure, representing the
document as a hierarchy of nodes, somewhat like the browser DOM. A
simple document might be a "doc" node containing two "paragraph"
nodes, each containing a single "text" node.
在初始化 state 时,你可以给它一个初始文档来使用。这种情况下,schema 字段是可选的,因为 schema 可以从文档中推断出来。
When initializing a state, you can give it an initial document to use.
In that case, the schema field is optional, since the schema can be
taken from the document.
这里我们通过解析 ID 为 "content" 的 DOM 元素中的内容来初始化一个 state,使用的是 DOM 解析器机制,它利用 schema 提供的关于哪些 DOM 节点映射到 schema 中哪些元素的信息:
Here we initialize a state by parsing the content found in the DOM
element with the ID "content", using the DOM parser mechanism, which
uses information supplied by the schema about which DOM nodes map to
which elements in that schema:
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)
Documents
ProseMirror 定义了自己的数据结构来表示内容文档。由于文档是整个编辑器围绕的中心元素,理解它们是如何工作的会很有帮助。
ProseMirror defines its own data structure to represent content documents. Since documents are the central element around which the rest of the editor is built, it is helpful to understand how they work.
一个 ProseMirror 文档是一个节点(node),它持有一个包含零个或多个子节点的片段(fragment)。
A ProseMirror document is a node, which holds a fragment containing zero or more child nodes.
这很像浏览器 DOM,在于它是递归的、树状的。但它与 DOM 的不同之处在于它存储行内内容的方式。
This is a lot like the browser DOM, in that it is recursive and tree-shaped. But it differs from the DOM in the way it stores inline content.
在 HTML 中,一个带标记的段落被表示为一棵树,像这样:
In HTML, a paragraph with markup is represented as a tree, like this:
<p>This is <strong>strong text with <em>emphasis</em></strong></p>
"This is "
"strong text with "
"emphasis"
而在 ProseMirror 中,行内内容被建模为一个扁平的序列,标记作为元数据附加到节点上:
Whereas in ProseMirror, the inline content is modeled as a flat sequence, with the markup attached as metadata to the nodes:
这更接近我们思考和操作这类文本的方式。它允许我们用字符偏移量(而不是树中的路径)来表示段落中的位置,也使得像拆分或改变内容样式这样的操作更容易进行,而无需进行笨拙的树操作。
This more closely matches the way we tend to think about and work with such text. It allows us to represent positions in a paragraph using a character offset rather than a path in a tree, and makes it easier to perform operations like splitting or changing the style of the content without performing awkward tree manipulation.
这也意味着每个文档都有一种合法的表示。带有相同标记集的相邻文本节点总是会被合并,空文本节点是不允许的。标记出现的顺序由 schema 指定。
This also means each document has one valid representation. Adjacent text nodes with the same set of marks are always combined together, and empty text nodes are not allowed. The order in which marks appear is specified by the schema.
所以,ProseMirror 文档是一棵块节点树,其中大多数叶子节点是textblock,即包含文本的块节点。你也可以有空叶子块,例如水平线或视频元素。
So a ProseMirror document is a tree of block nodes, with most of the leaf nodes being textblocks, which are block nodes that contain text. You can also have leaf blocks that are simply empty, for example a horizontal rule or a video element.
节点对象带有许多属性,反映了它们在文档中所扮演的角色:
Node objects come with a number of properties that reflect the role they play in the document:
isBlockandisInlinetell you whether a given node is a block or inline node.inlineContentis true for nodes that expect inline nodes as content.isTextblockis true for block nodes with inline content.isLeaftells you that a node doesn't allow any content.
所以一个典型的 "paragraph" 节点会是一个 textblock,而 blockquote 可能是一个块元素,其内容由其他块组成。文本、硬换行和行内图片是行内叶子节点,而水平线节点则是块叶子节点的一个例子。
So a typical "paragraph" node will be a textblock, whereas a
blockquote might be a block element whose content consists of other
blocks. Text, hard breaks, and inline images are inline leaf nodes,
and a horizontal rule node would be an example of a block leaf node.
schema 可以指定更精确的约束,规定什么可以出现在哪里——也就是说,即使一个节点允许块内容,也不意味着它允许所有块节点作为内容。
The schema is allowed to specify more precise constraints on what may appear where—i.e. even though a node allows block content, that doesn't mean that it allows all block nodes as content.
DOM 树和 ProseMirror 文档之间的另一个重要区别,是表示节点的对象的行为方式。在 DOM 中,节点是具有身份(identity)的可变对象,这意味着一个节点只能出现在一个父节点中,而且节点对象在更新时会被修改。
Another important difference between a DOM tree and a ProseMirror document is the way the objects that represent nodes behave. In the DOM, nodes are mutable objects with an identity, which means that a node can only appear in one parent node, and that the node object is mutated when it is updated.
而在 ProseMirror 中,节点只是值(values),你应该像对待表示数字 3 的那个值一样对待它们。3 可以同时出现在多个数据结构中,它没有指向它当前所属数据结构的父链接,如果你把它加 1,你会得到一个新的值 4,而不会改变原来的 3 的任何东西。
In ProseMirror, on the other hand, nodes are simply values, and should be approached much as you'd approach the value representing the number 3. 3 can appear in multiple data structures at the same time, it does not have a parent-link to the data structure it is currently part of, and if you add 1 to it, you get a new value, 4, without changing anything about the original 3.
ProseMirror 文档的片段也是如此。它们不改变,但可以作为计算修改后文档片段的起始值。它们不知道自己属于哪些数据结构,但可以成为多个结构的一部分,甚至可以在单个结构中出现多次。它们是值,而不是有状态的对象。
So it is with pieces of ProseMirror documents. They don't change, but can be used as a starting value to compute a modified piece of document. They don't know what data structures they are part of, but can be part of multiple structures, or even occur multiple times in a single structure. They are values, not stateful objects.
这意味着每次更新文档时,你都会得到一个新的文档值。那个文档值会与原文档值共享所有未变化的子节点,使得创建它的成本相对较低。
This means that every time you update a document, you get a new document value. That document value will share all sub-nodes that didn't change with the original document value, making it relatively cheap to create.
这有一大堆好处。它使得编辑器在更新期间不可能处于无效的中间状态,因为带有新文档的新状态可以瞬间被换入。它也使得以某种数学化的方式推理文档变得更容易——如果你的值不断在你脚下变化,这是非常困难的。这有助于实现协同编辑,并让 ProseMirror 通过把上一次绘制到屏幕上的文档与当前文档进行比较,来运行一个非常高效的 DOM 更新算法。
This has a bunch of advantages. It makes it impossible to have an editor in an invalid in-between state during an update, since the new state, with a new document, can be swapped in instantaneously. It also makes it easier to reason about documents in a somewhat mathematical way, which is really hard if your values keep changing underneath you. This helps make collaborative editing possible and allows ProseMirror to run a very efficient DOM update algorithm by comparing the last document it drew to the screen to the current document.
由于这些节点由普通的 JavaScript 对象表示,而显式地冻结它们的属性会损害性能,所以实际上有可能修改它们。但这样做是不受支持的,而且会导致问题,因为它们几乎总是在多个数据结构之间共享。所以要小心!还要注意,对于作为节点对象一部分的数组和普通对象(比如用于存储节点属性的对象,或片段中子节点的数组)也是如此。
Because such nodes are represented by regular JavaScript objects, and explicitly freezing their properties hampers performance, it is actually possible to change them. But doing this is not supported, and will cause things to break, because they are almost always shared between multiple data structures. So be careful! And note that this also holds for the arrays and plain objects that are part of node objects, such as the objects used to store node attributes, or the arrays of child nodes in fragments.
一个文档的对象结构看起来大致是这样:
The object structure for a document looks something like this:
| Node | ||||||
| type: | NodeType |
|||||
| content: | Fragment [ Node ,
Node , ...] |
|||||
| attrs: | Object |
|||||
| marks: | [
|
每个节点都由 Node 类的一个实例表示。它带有一个类型(type)标签,这个类型知道节点的名称、对它有效的属性等等。节点类型(和标记类型)在每个 schema 中创建一次,并且知道它们属于哪个 schema。
Each node is represented by an instance of the Node
class. It is tagged with a type, which knows the
node's name, the attributes that are valid for it, and so on. Node
types (and mark types) are created once per schema, and know which
schema they are part of.
节点的内容存储在一个 Fragment 实例中,它持有一个节点序列。即使对于没有内容或不允许内容的节点,这个字段也会被填充(用共享的空片段)。
The content of a node is stored in an instance of
Fragment, which holds a sequence of nodes. Even
for nodes that don't have or don't allow content, this field is filled
(with the shared empty fragment).
有些节点类型允许属性,即与每个节点一起存储的额外值。例如,图片节点可能用它们来存储其替代文本和图片的 URL。
Some node types allow attributes, which are extra values stored with each node. For example, an image node might use these to store its alt text and the URL of the image.
此外,行内节点持有一组激活的标记——比如强调或作为链接——它们被表示为一个 Mark 实例数组。
In addition, inline nodes hold a set of active marks—things like
emphasis or being a link—which are represented as an array of
Mark instances.
一个完整的文档只是一个节点。文档内容表示为顶层节点的子节点。通常,它会包含一系列块节点,其中一些可能是包含行内内容的 textblock。但顶层节点本身也可以是一个 textblock,这样文档就只包含行内内容。
A full document is just a node. The document content is represented as the top-level node's child nodes. Typically, it'll contain a series of block nodes, some of which may be textblocks that contain inline content. But the top-level node may also be a textblock itself, so that the document contains only inline content.
什么节点可以出现在哪里,由文档的schema 决定。要以编程方式创建节点,你必须通过 schema 来进行,例如使用 node 和 text 方法。
What kind of node is allowed where is determined by the document's
schema. To programmatically create nodes, you must go
through the schema, for example using the
node and text
methods.
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!")])
])
ProseMirror 节点支持两种索引方式——它们可以被当作树来处理,使用各个节点内的偏移量;也可以被当作一个扁平的标记序列来处理。
ProseMirror nodes support two types of indexing—they can be treated as trees, using offsets into individual nodes, or they can be treated as a flat sequence of tokens.
第一种允许你做类似对 DOM 做的事——与单个节点交互,使用 child 方法和 childCount 直接访问子节点,编写递归函数扫描文档(如果你只是想查看所有节点,使用 descendants 或 nodesBetween)。
The first allows you to do things similar to what you'd do with the
DOM—interacting with single nodes, directly accessing child nodes
using the child method and
childCount, writing recursive functions
that scan through a document (if you just want to look at all nodes,
use descendants or
nodesBetween).
第二种在定位文档中某个具体位置时更有用。它允许任何文档位置都用一个整数来表示——即标记序列中的索引。这些标记实际上并不作为对象存在于内存中——它们只是一种计数约定——但文档的树形结构,加上每个节点都知道自己的大小这一事实,使得按位置访问变得很廉价。
The second is more useful when addressing a specific position in the document. It allows any document position to be represented as an integer—the index in the token sequence. These tokens don't actually exist as objects in memory—they are just a counting convention—but the document's tree shape, along with the fact that each node knows its size, is used to make by-position access cheap.
-
文档的开头,也就是第一个内容之前,是位置 0。
The start of the document, right before the first content, is position 0.
-
进入或离开一个非叶子节点(即支持内容的节点)算作一个标记。所以如果文档以一个段落开头,那个段落的开头算作位置 1。
Entering or leaving a node that is not a leaf node (i.e. supports content) counts as one token. So if the document starts with a paragraph, the start of that paragraph counts as position 1.
-
文本节点中的每个字符算作一个标记。所以如果文档开头的段落包含单词“hi”,那么位置 2 在“h”之后,位置 3 在“i”之后,位置 4 在整个段落之后。
Each character in text nodes counts as one token. So if the paragraph at the start of the document contains the word “hi”, position 2 is after the “h”, position 3 after the “i”, and position 4 after the whole paragraph.
-
不允许内容的叶子节点(比如图片)也算作一个标记。
Leaf nodes that do not allow content (such as images) also count as a single token.
所以如果你有一个文档,用 HTML 表示时看起来像这样:
So if you have a document that, when expressed as HTML, would look like this:
<p>One</p>
<blockquote><p>Two<img src="..."></p></blockquote>
标记序列,连同位置,看起来像这样:
The token sequence, with positions, looks like this:
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。
Each node has a nodeSize property that
gives you the size of the entire node, and you can access
.content.size to get the size of the node's
content. Note that for the outer document node, the open and close
tokens are not considered part of the document (because you can't put
your cursor outside of the document), so the size of a document is
doc.content.size, not doc.nodeSize.
手动解读这些位置需要大量的计数工作。你可以调用 Node.resolve 来为一个位置获取更具描述性的数据结构。这个数据结构会告诉你该位置的父节点是什么、它在父节点中的偏移量是多少、父节点有哪些祖先节点,以及其他一些信息。
Interpreting such positions manually involves quite a lot of counting.
You can call Node.resolve to get a more
descriptive data structure for a position. This
data structure will tell you what the parent node of the position is,
what its offset into that parent is, what ancestors the parent has,
and a few other things.
注意区分子索引(按 childCount)、文档范围的位置,以及节点内部的偏移量(有时在递归函数中用来表示当前正在处理的节点中的位置)。
Take care to distinguish between child indices (as per
childCount), document-wide positions, and
node-local offsets (sometimes used in recursive functions to represent
a position into the node that's currently being handled).
为了处理复制粘贴和拖放这类事情,必须能够谈论文档的一个切片,即两个位置之间的内容。这种切片与完整节点或片段的不同之处在于,它开头或结尾的一些节点可能是「打开的」。
To handle things like copy-paste and drag-drop, it is necessary to be able to talk about a slice of document, i.e. the content between two positions. Such a slice differs from a full node or fragment in that some of the nodes at its start or end may be ‘open’.
例如,如果你从一个段落的中间选到下一个段落的中间,你选中的切片里有两个段落,第一个在开头是打开的,第二个在结尾是打开的;而如果你节点选中一个段落,你选中的是一个闭合的节点。如果把这种打开节点中的内容当作节点的完整内容来处理,可能会违反 schema 约束,因为一些必需的节点落在了切片之外。
For example, if you select from the middle of one paragraph to the middle of the next one, the slice you've selected has two paragraphs in it, the first one open at the start, the second open at the end, whereas if you node-select a paragraph, you've selected a closed node. It may be the case that the content in such open nodes violates the schema constraints, if treated like the node's full content, because some required nodes fell outside of the slice.
Slice 数据结构用于表示这种切片。它存储一个片段,以及两侧的打开深度。你可以使用节点上的 slice 方法从文档中切出一个切片。
The Slice data structure is used to represent such
slices. It stores a fragment along with an open
depth on both sides. You can use the
slice method on nodes to cut a slice out of a
document.
// 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
由于节点和片段是持久的(persistent),你应该永远不要修改它们。如果你持有某个文档(或节点、或片段)的句柄,那个对象会保持不变。
Since nodes and fragments are persistent, you should never mutate them. If you have a handle to a document (or node, or fragment) that object will stay the same.
大多数时候,你会使用变换(transformations)来更新文档,而不必直接接触节点。变换还会留下更改记录,这在文档是编辑器状态的一部分时是必需的。
Most of the time, you'll use transformations to update documents, and won't have to directly touch the nodes. These also leave a record of the changes, which is necessary when the document is part of an editor state.
在你确实想「手动」派生一个更新后文档的情况下,Node 和 Fragment 类型上有一些辅助方法可用。要创建整个文档的更新版本,你通常会想使用 Node.replace,它把文档的给定范围替换为一个切片的新内容。要对节点做浅层更新,你可以使用它的 copy 方法,它创建一个内容不同的相似节点。片段也有各种更新方法,比如 replaceChild 或 append。
In cases where you do want to 'manually' derive an updated document,
there are some helper methods available on the Node
and Fragment types. To create an updated version
of a whole document, you'll usually want to use
Node.replace, which replaces a given range
of the document with a slice of new content. To
update a node shallowly, you can use its copy
method, which creates a similar node with new content. Fragments also
have various updating methods, such as
replaceChild or
append.
Schema
Schemas
每个 ProseMirror 文档都关联一个 schema。schema 描述了文档中可能出现的节点类型,以及它们嵌套的方式。例如,它可能规定顶层节点可以包含一个或多个块,而段落节点可以包含任意数量的行内节点,并带有任意应用到它们之上的标记。
Each ProseMirror document has a schema associated with it. The schema describes the kind of nodes that may occur in the document, and the way they are nested. For example, it might say that the top-level node can contain one or more blocks, and that paragraph nodes can contain any number of inline nodes, with any marks applied to them.
有一个提供基础 schema 的包可用,但 ProseMirror 的好处在于它允许你定义自己的 schema。
There is a package with a basic schema available, but the nice thing about ProseMirror is that it allows you to define your own schemas.
文档中的每个节点都有一个类型(type),它代表节点的语义含义和属性,比如它在编辑器中的渲染方式。
Every node in a document has a type, which represents its semantic meaning and its properties, such as the way it is rendered in the editor.
当你定义一个 schema 时,你要枚举其中可能出现的节点类型,用 spec 对象来描述每一种:
When you define a schema, you enumerate the node types that may occur within it, describing each with a spec object:
const trivialSchema = new Schema({
nodes: {
doc: {content: "paragraph+"},
paragraph: {content: "text*"},
text: {inline: true},
/* ... and so on */
}
})
那定义了一个 schema,其中文档可以包含一个或多个段落,每个段落可以包含任意数量的文本。
That defines a schema where the document may contain one or more paragraphs, and each paragraph can contain any amount of text.
每个 schema 至少必须定义一个顶层节点类型(默认名称是 "doc",但你可以配置它),以及一个用于文本内容的 "text" 类型。
Every schema must at least define a top-level node type (which
defaults to the name "doc", but you can
configure that), and a "text" type for
text content.
属于行内的节点必须用 inline 属性来声明(不过对于 text 类型,它按定义就是行内的,你可以省略这一点)。
Nodes that count as inline must declare this with the
inline property (though for the text
type, which is inline by definition, you may omit this).
上面示例 schema 中 content 字段里的字符串叫做内容表达式(content expressions)。它们控制哪些子节点序列对这种节点类型是合法的。
The strings in the content fields in the
example schema above are called content expressions. They control
what sequences of child nodes are valid for this node type.
你可以说,例如 "paragraph" 表示「一个段落」,或 "paragraph+" 表示「一个或多个段落」。类似地,"paragraph*" 表示「零个或多个段落」,"caption?" 表示「零个或一个 caption 节点」。你还可以在节点名后面使用类似正则表达式的范围,比如 {2}(「恰好两个」)、{1, 5}(「一到五个」)或 {2,}(「两个或更多」)。
You can say, for example "paragraph" for “one paragraph”, or
"paragraph+" to express “one or more paragraphs”. Similarly,
"paragraph*" means “zero or more paragraphs” and "caption?" means
“zero or one caption node”. You can also use regular-expression-like
ranges, such as {2} (“exactly two”) {1, 5} (“one to five”) or
{2,} (“two or more”) after node names.
这样的表达式可以组合成序列,例如 "heading paragraph+" 表示「先是一个标题,然后是一个或多个段落」。你也可以使用管道符 | 来表示两个表达式之间的选择,如 "(paragraph | blockquote)+"。
Such expressions can be combined to create a sequence, for example
"heading paragraph+" means ‘first a heading, then one or more
paragraphs’. You can also use the pipe | operator to indicate a
choice between two expressions, as in "(paragraph | blockquote)+".
有些元素类型的组合会在你的 schema 中多次出现——例如你可能有一个「块(block)」节点的概念,它可以出现在顶层,也可以嵌套在 blockquote 里面。你可以给节点 spec 一个 group 属性来创建节点组,然后在表达式中用组名来引用它。
Some groups of element types will appear multiple times in your
schema—for example you might have a concept of “block” nodes, that may
appear at the top level but also nested inside of blockquotes. You can
create a node group by giving your node specs a
group property, and then refer to that
group by its name in your expressions.
const groupSchema = new Schema({
nodes: {
doc: {content: "block+"},
paragraph: {group: "block", content: "text*"},
blockquote: {group: "block", content: "block+"},
text: {}
}
})
这里 "block+" 等价于 "(paragraph | blockquote)+"。
Here "block+" is equivalent to "(paragraph | blockquote)+".
建议在具有块内容的节点(比如上面例子中的 "doc" 和 "blockquote")中始终要求至少一个子节点,因为当节点为空时,浏览器会把它完全折叠掉,使它很难编辑。
It is recommended to always require at least one child node in nodes
that have block content (such as "doc" and "blockquote" in the
example above), because browsers will completely collapse the node
when it's empty, making it rather hard to edit.
节点在 or 表达式中出现的顺序很重要。当为一个非可选节点创建默认实例时(例如,为了确保文档在 replace step 之后仍然符合 schema),会使用表达式中的第一个类型。如果那是一个组,则使用组中的第一个类型(由组成员在你的 nodes map 中出现的顺序决定)。如果我在示例 schema 中交换 "paragraph" 和 "blockquote" 的位置,那么只要编辑器试图创建一个块节点,你就会得到栈溢出——它会创建一个 "blockquote" 节点,它的内容要求至少一个块,于是它又试图创建另一个 "blockquote" 作为内容,如此往复。
The order in which your nodes appear in an or-expression is
significant. When creating a default instance for a non-optional node,
for example to make sure a document still conforms to the schema after
a replace step the first type in the
expression will be used. If that is a group, the first type in the
group (determined by the order in which the group's members appear in
your nodes map) is used. If I switched the positions of
"paragraph" and "blockquote" in the the example schema, you'd get
a stack overflow as soon as the editor tried to create a block
node—it'd create a "blockquote" node, whose content requires at
least one block, so it'd try to create another "blockquote" as
content, and so on.
库中并非每个操作节点的函数都会检查它处理的是否是合法内容——像变换(transforms)这样的高层概念会检查,但原始的节点创建方法通常不检查,而是把提供合理输入的责任交给调用者。完全有可能使用例如 NodeType.create 来创建一个内容非法的节点。对于在切片边缘「打开」的节点来说,这甚至是合理的。有一个单独的 createChecked 方法,以及一个事后使用的 check 方法,可以用来断言给定节点的内容是合法的。
Not every node-manipulating function in the library checks that it is
dealing with valid content—higher level concepts like
transforms do, but primitive node-creation methods
usually don't and instead put the responsibility for providing sane
input on their caller. It is perfectly possible to use, for example
NodeType.create, to create a node with
invalid content. For nodes that are ‘open’ on the edge of
slices, this is even a reasonable thing to do. There
is a separate createChecked
method, as well as an after-the-fact
check method that can be used to assert that a
given node's content is valid.
标记用于给行内内容添加额外的样式或其他信息。schema 必须在其 schema 中声明它允许的所有标记类型。标记类型(Mark types)是类似于节点类型的对象,用于给标记对象打标签并提供关于它们的额外信息。
Marks are used to add extra styling or other information to inline content. A schema must declare all mark types it allows in its schema. Mark types are objects much like node types, used to tag mark objects and provide additional information about them.
默认情况下,带有行内内容的节点允许 schema 中定义的所有标记应用到它们的子节点上。你可以用节点 spec 上的 marks 属性来配置这一点。
By default, nodes with inline content allow all marks defined in the
schema to be applied to their children. You can configure this with
the marks property on your node spec.
这里有一个简单的 schema,支持在段落文本上使用 strong 和 emphasis 标记,但在标题上不支持:
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: {}
}
})
标记集合被解释为一个以空格分隔的标记名称或标记组字符串——"_" 充当通配符,空字符串对应空集合。
The set of marks is interpreted as a space-separated string of mark
names or mark groups—"_" acts as a wildcard, and the empty string
corresponds to the empty set.
文档 schema 还定义了每个节点或标记具有哪些属性(attributes)。如果你的节点类型需要存储额外的、节点特有的信息,比如标题节点的级别,最好用属性来做。
The document schema also defines which attributes each node or mark has. If your node type requires extra node-specific information to be stored, such as the level of a heading node, that is best done with an attribute.
属性集被表示为普通对象,带有一组预定义的(按节点或标记)属性,存放任何可 JSON 序列化的值。要指定它允许哪些属性,在节点或标记 spec 中使用可选的 attrs 字段。
Attribute sets are represented as plain objects with a predefined (per
node or mark) set of properties holding any JSON-serializeable values.
To specify what attributes it allows, use the optional attrs field
in a node or mark spec.
heading: {
content: "text*",
attrs: {level: {default: 1}}
}
在这个 schema 中,heading 节点的每个实例都会在 .attrs.level 下有一个 level 属性。如果在创建节点时没有指定它,它将默认为 1。
In this schema, every instance of the heading node will have a
level attribute under .attrs.level. If it isn't specified when the
node is created, it will default to 1.
当你没有为属性指定默认值时,如果你试图创建这样一个节点而没有指定该属性,就会抛出一个错误。
When you don't give a default value for an attribute, an error will be raised when you attempt to create such a node without specifying that attribute.
这也会使库无法在变换期间或调用 createAndFill 时生成这类节点作为填充物来满足 schema 约束。这就是为什么不允许把这类节点放在 schema 中的必需位置上——为了能够强制执行 schema 约束,编辑器需要能够生成空节点来填补内容中缺失的部分。
That will also make it impossible for the library to generate such
nodes as filler to satisfy schema constraints during a transform or
when calling createAndFill. This
is why you are not allowed to put such nodes in a required position in
the schema—in order to be able to enforce the schema constraints, the
editor needs to be able to generate empty nodes to fill missing pieces
in the content.
为了能在浏览器中编辑它们,必须能够把文档节点表示在浏览器 DOM 中。最简单的方法是用节点 spec 中的 toDOM 字段把每个节点的 DOM 表示信息包含在 schema 中。
In order to be able to edit them in the browser, it must be possible
to represent document nodes in the browser DOM. The easiest way to do
that is to include information about each node's DOM representation in
the schema using the toDOM field in the
node spec.
这个字段应该保存一个函数,当以节点为参数调用它时,返回对该节点 DOM 结构的描述。这可以是一个直接的 DOM 节点,也可以是一个描述它的数组,例如:
This field should hold a function that, when called with the node as argument, returns a description of the DOM structure for that node. This may either be a direct DOM node or an array describing it, for example:
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 表示中不需要洞,因为它们没有内容。
The expression ["p", 0] declares that a paragraph is rendered as an
HTML <p> tag. The zero is the ‘hole’ where its content should be
rendered. You may also include an object with HTML attributes after
the tag name, for example ["div", {class: "c"}, 0]. Leaf nodes don't
need a hole in their DOM representation, since they don't have
content.
标记 spec 允许类似的 toDOM 方法,但它们必须渲染为直接包裹内容的单一标签,所以内容总是直接放在返回的节点里,洞不需要指定。
Mark specs allow a similar toDOM method,
but they are required to render as a single tag that directly wraps
the content, so the content always goes directly in the returned node,
and the hole doesn't need to be specified.
你也经常需要从 DOM 数据中解析文档,例如当用户粘贴或拖拽东西进编辑器时。model 模块也附带了这方面的功能,并且鼓励你用 parseDOM 属性把解析信息直接包含在 schema 中。
You'll also often need to parse a document from DOM data, for
example when the user pastes or drags something into the editor. The
model module also comes with functionality for that, and you are
encouraged to include parsing information directly in your schema with
the parseDOM property.
这里可以列出一个 parse rules 数组,描述映射到给定节点或标记的 DOM 构造。例如,基础 schema 对 emphasis 标记有这样的规则:
This may list an array of parse rules, which describe DOM constructs that map to a given node or mark. For example, the basic schema has these for the emphasis mark:
parseDOM: [
{tag: "em"}, // Match <em> nodes
{tag: "i"}, // and <i> nodes
{style: "font-style=italic"} // and inline 'font-style: italic'
]
在 parse rule 中给 tag 的值可以是一个 CSS 选择器,所以你也可以做 "div.myclass" 这样的事。类似地,style 匹配行内 CSS 样式。
The value given to tag in a parse rule can
be a CSS selector, so you can do thing like "div.myclass" too.
Similarly, style matches inline CSS
styles.
当 schema 包含 parseDOM 注解时,你可以用 DOMParser.fromSchema 为它创建一个 DOMParser 对象。编辑器正是这样创建默认剪贴板解析器的,但你也可以覆盖它。
When a schema includes parseDOM annotations, you can create a
DOMParser object for it with
DOMParser.fromSchema. This is done
by the editor to create the default clipboard parser, but you can
also override that.
文档还带有内置的 JSON 序列化格式。你可以在它们上面调用 toJSON 来获得一个可以安全传给 JSON.stringify 的对象,而 schema 对象有一个 nodeFromJSON 方法可以把这种表示解析回文档。
Documents also come with a built-in JSON serialization format. You can
call toJSON on them to get an object that can
safely be passed to
JSON.stringify,
and schema objects have a nodeFromJSON method
that can parse this representation back into a document.
传给 Schema 构造函数的 nodes 和 marks 选项接受 OrderedMap 对象以及普通 JavaScript 对象。生成的 schema 的 spec.nodes 和 spec.marks 属性总是 OrderedMap,它们可以作为后续 schema 的基础。
The nodes and marks options passed to the Schema
constructor take OrderedMap
objects as well as
plain JavaScript objects. The resulting schema's
spec.nodes and spec.marks properties are
always OrderedMaps, which can be used as the basis for further
schemas.
这样的 map 支持多种方法来方便地创建更新版本。例如你可以说 schema.spec.nodes.remove("blockquote") 来派生一个不含 blockquote 节点的节点集,然后把它作为新 schema 的 nodes 字段传入。
Such maps support a number of methods to conveniently create updated
versions. For example you could say
schema.spec.nodes.remove("blockquote") to derive a set of nodes
without the blockquote node, which can then be passed as the nodes
field for a new schema.
schema-list 模块导出了一个便捷方法,把这些模块导出的节点添加到一个节点集中。
The schema-list module exports a convenience method to add the nodes exported by those modules to a nodeset.
文档变换(Document transformations)
Document transformations
变换(Transforms)是 ProseMirror 工作方式的核心。它们构成事务的基础,也是使历史追踪和协同编辑成为可能的东西。
Transforms are central to the way ProseMirror works. They form the basis for transactions, and are what makes history tracking and collaborative editing possible.
为什么我们不能直接修改文档就完事了呢?或者至少创建一个新版本的文档,然后把它放进编辑器里?
Why can't we just mutate the document and be done with it? Or at least create a new version of a document and just put that into the editor?
有几个原因。一个是代码清晰度。不可变的数据结构确实能带来更简单的代码。但变换系统所做的主要事情是留下一串更新轨迹,以值的形式表示从文档旧版本到新版本所采取的各个步骤。
There are several reasons. One is code clarity. Immutable data structures really do lead to simpler code. But the main thing the transform system does is to leave a trail of updates, in the form of values that represent the individual steps taken to go from an old version of the document to a new one.
撤销历史可以保存这些步骤,并应用它们的逆操作回到过去(ProseMirror 实现了选择性撤销,这比简单地回滚到之前的状态更复杂)。
The undo history can save these steps and apply their inverse to go back in time (ProseMirror implements selective undo, which is more complicated than just rolling back to a previous state).
协同编辑系统把这些步骤发送给其他编辑器,并在必要时对它们重新排序,使每个人最终得到相同的文档。
The collaborative editing system sends these steps to other editors and reorders them if necessary so that everyone ends up with the same document.
更一般地说,编辑器插件能够检查和响应每一个到来的变更,以保持自己的状态与编辑器其余部分的状态一致,这是非常有用的。
More generally, it is very useful for editor plugins to be able to inspect and react to each change as it comes in, in order to keep their own state consistent with the rest of the editor state.
对文档的更新被分解为描述一次更新的步骤(steps)。你通常不需要直接使用它们,但了解它们如何工作是有用的。
Updates to documents are decomposed into steps that describe an update. You usually don't need to work with these directly, but it is useful to know how they work.
步骤的例子有 ReplaceStep(替换文档的一段),或 AddMarkStep(给一个范围添加标记)。
Examples of steps are ReplaceStep to
replace a piece of a document, or
AddMarkStep to add a mark to a given
range.
console.log(myDoc.toString()) // → p("hello")
// A step that deletes the content between positions 3 and 5
let step = new ReplaceStep(3, 5, Slice.empty)
let result = step.apply(myDoc)
console.log(result.doc.toString()) // → p("heo")
应用一个步骤是一个相对直接的过程——它不会做任何聪明的事,比如插入节点来保持 schema 约束,或变换切片使它合适。这意味着应用一个步骤可能会失败,例如,如果你试图只删除一个节点的开始标记,那会让标记失去平衡,这不是一件有意义的事。这就是为什么 apply 返回一个结果对象,它要么持有一个新文档,要么持有一条错误消息。
Applying a step is a relatively straightforward process—it doesn't do
anything clever like inserting nodes to preserve schema constraints,
or transforming the slice to make it fit. That means applying a step
can fail, for example if you try to delete just the opening token of a
node, that would leave the tokens unbalanced, which isn't a meaningful
thing you can do. This is why apply
returns a result object, which holds either
a new document, or an error message.
你通常会让辅助函数为你生成步骤,这样你就不必担心细节。
You'll usually want to let helper functions generate your steps for you, so that you don't have to worry about the details.
一个编辑动作可能产生一个或多个步骤。处理一系列步骤最方便的方式是创建一个 Transform 对象(或者,如果你处理的是完整的编辑器状态,则创建一个 Transaction,它是 Transform 的子类)。
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) // Delete between position 5 and 7
tr.split(5) // Split the parent node at position 5
console.log(tr.doc.toString()) // The modified document
console.log(tr.steps.length) // → 2
大多数变换方法返回变换本身,以便于链式调用(让你可以写 tr.delete(5, 7).split(5))。
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.
当你对文档做一次变更时,指向该文档的位置可能变得无效或改变含义。例如,如果你插入一个字符,所有在该字符之后的位置现在都指向它们旧位置之前一个标记的地方。类似地,如果你删除文档中的所有内容,所有指向那些内容的位置现在都无效了。
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.
我们经常确实需要跨文档变更保留位置,例如选区边界。为了帮助做到这一点,步骤可以给你一个映射(map),它可以在应用步骤前后的文档位置之间进行转换。
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) // Delete 4-5
let map = step.getMap()
console.log(map.map(8)) // → 6
console.log(map.map(2)) // → 2 (nothing changes before the change)
变换对象会自动为其中的步骤累积一组映射,使用的抽象叫做 Mapping,它收集一系列步骤映射,并允许你一次性映射过它们。
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 Transform(myDoc)
tr.split(10) // split a node, +2 tokens at 10
tr.delete(2, 5) // -3 tokens at 2
console.log(tr.mapping.map(15)) // → 14
console.log(tr.mapping.map(6)) // → 3
console.log(tr.mapping.map(10)) // → 9
有些情况下,一个给定位置应该映射到哪里并不完全清楚。考虑上面例子的最后一行。位置 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.
但有时你想要另一种行为,这就是为什么步骤映射和映射上的 map 方法接受第二个参数 bias,你可以把它设为 -1,以便在有内容插入到你的位置之上时让它保持不动。
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)) // → 7
把各个步骤定义为小而直接的东西,原因就在于它使这种映射成为可能,同时还能以无损的方式反转步骤,以及让步骤映射穿过彼此的位置映射。
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.
当你对步骤和位置映射做更复杂的事情时,例如实现你自己的变更追踪,或把某个功能集成到协同编辑中,你可能会遇到需要rebase步骤的情况。
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.
在简单情况下,rebase 是指取两个以同一文档开始的步骤,并变换其中一个,使它能够改为应用到另一个所产生的文档上。用伪代码表示:
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
步骤有一个 map 方法,它接受一个映射,把整个步骤映射过去。这可能会失败,因为有些步骤不再有意义了,比如当它们所应用的内容已经被删除时。但当它成功时,你现在就有了一个指向新文档(即你映射穿过的变更之后的那个文档)的步骤。所以在上面例子中,rebase(stepB, mapA) 可以简单地调用 stepB.map(mapA)。
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).
当你想把一串步骤 rebase 到另一串步骤之上时,事情变得更复杂。
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
我们可以把 stepB1 映射过 stepA1 然后 stepA2,得到 stepB1'。但对于 stepB2——它从 stepB1(doc) 产生的文档开始,而它的映射版本必须应用到 stepB1'(docA) 产生的文档上——事情就更难了。它必须映射过下面这一串映射:
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'])
也就是,首先用 stepB1 映射的逆映射回到原始文档,然后穿过应用 stepA1 和 stepA2 所产生的映射管道,最后穿过把 stepB1' 应用到 docA 所产生的映射。
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.
如果还有一个 stepB3,我们就取上面的管道,在它前面加上 invert(mapB2),并在末尾加上 mapB2',就得到它的管道。以此类推。
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.
但当 stepB1 插入了一些内容,而 stepB2 对那段内容做了一些事情时,把 stepB2 映射过 invert(mapB1) 会返回 null,因为 stepB1 的逆操作会删除它所应用的内容。然而,这段内容会在管道稍后的位置被 mapB1 重新引入。Mapping 抽象提供了一种追踪这类管道(包括其中映射之间的逆关系)的方法。你可以用这种方式把步骤映射过去,使它们能在上述情形中幸存下来。
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.
即使你已经 rebase 了一个步骤,也不能保证它仍然能被合法地应用到当前文档上。例如,如果你的步骤添加一个标记,但另一个步骤把你目标内容的父节点改成了一个不允许标记的节点,那么尝试应用你的步骤就会失败。对此的恰当反应通常就是直接丢弃这个步骤。
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.
编辑器状态(The editor state)
The editor state
编辑器的状态由什么组成?当然有你的文档。还有当前的选区。而且还需要一种方式来存储「当前标记集已经改变」这一事实,比如你禁用或启用了某个标记,但还没有开始用那个标记输入。
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.
这些是 ProseMirror 状态的三个主要组成部分,它们以 doc、selection 和 storedMarks 的形式存在于状态对象上。
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()) // An empty paragraph
console.log(state.selection.from) // 1, the start of the paragraph
但插件也可能需要存储状态——例如,撤销历史必须保存它的变更历史。这就是为什么激活的插件集合也被存储在状态中,而这些插件可以定义额外的槽位来存储它们自己的状态。
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.
ProseMirror 支持多种选区类型(并允许第三方代码定义新的选区类型)。选区由 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.
选区至少有开始(.from)和结束(.to),作为指向当前文档的位置。许多选区类型还区分选区的锚点(anchor)(不可移动的)和头部(head)(可移动的)一侧,所以这些也要求在每一个选区对象上都存在。
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.
最常见的选区类型是文本选区,它用于普通光标(当 anchor 和 head 相同时)或选中的文本。文本选区的两个端点都必须在行内位置,即指向允许行内内容的节点。
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.
核心库还支持节点选区,即选中单个文档节点,例如当你 ctrl/cmd 点击一个节点时就会得到它。这种选区的范围从节点正前方的位置到节点正后方的位置。
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.
在正常编辑过程中,新的状态会从它之前的状态派生出来。在某些情况下,比如加载一个新文档时,你可能想创建一个全新的状态,但这是例外。
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.
状态更新是通过把一个事务(transaction)应用到现有状态、从而产生一个新状态来完成的。从概念上讲,它们是一次性完成的:给定旧状态和事务,为状态的每个组成部分计算一个新值,然后把这些值组装成一个新的状态值。
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) // 25
tr.insertText("hello") // Replaces selection with 'hello'
let newState = state.apply(tr)
console.log(tr.doc.content.size) // 30
Transaction 是 Transform 的子类,继承了它通过把步骤应用到一个初始文档上来构建新文档的方式。除此之外,事务还跟踪选区和其他与状态相关的组成部分,并有一些与选区相关的便捷方法,比如 replaceSelection。
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.
创建事务最简单的方式是使用编辑器状态对象上的 tr getter。它基于那个状态创建一个空事务,然后你可以向其中添加步骤和其他更新。
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.
默认情况下,旧选区会被映射穿过每个步骤来产生新选区,但也可以使用 setSelection 来显式设置一个新选区。
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) // → 10
tr.delete(6, 8)
console.log(tr.selection.from) // → 8 (moved back)
tr.setSelection(TextSelection.create(tr.doc, 3))
console.log(tr.selection.from) // → 3
类似地,激活标记集会在文档或选区变化后自动清除,并可以使用 setStoredMarks 或 ensureMarks 方法来设置。
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.
最后,scrollIntoView 方法可以用来确保下一次绘制状态时选区被滚动到可见区域。对于大多数用户操作,你可能都想这样做。
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.
像 Transform 方法一样,许多 Transaction 方法返回事务本身,以便于链式调用。
Like Transform methods, many Transaction methods return the
transaction itself, for convenient chaining.
当创建一个新状态时,你可以提供一个要使用的插件数组。这些插件会被存储在状态中,以及从它派生出的任何状态中,并且既会影响事务被应用的方式,也会影响基于这个状态的编辑器的行为。
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.
插件是 Plugin 类的实例,可以建模各种各样的功能。最简单的插件只是给编辑器视图添加一些props,例如响应某些事件。更复杂的插件可能会给编辑器添加新状态,并根据事务更新它。
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.
let myPlugin = new Plugin({
props: {
handleKeyDown(view, event) {
console.log("A key was pressed!")
return false // We did not handle this
}
}
})
let state = EditorState.create({schema, plugins: [myPlugin]})
当一个插件需要自己的状态槽位时,用 state 属性来定义:
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)
}
例子中的插件定义了一段非常简单的状态,它只是计数已经应用到某个状态上的事务数量。辅助函数使用插件的 getState 方法,它可以用来从完整的编辑器状态对象中获取插件状态。
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.
因为编辑器状态是一个持久的(不可变的)对象,而插件状态是那个对象的一部分,所以插件状态值必须是不可变的。也就是说,如果它们需要改变,它们的 apply 方法必须返回一个新值,而不是修改旧值,而且其他任何代码都不应该修改它们。
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.
为此,事务允许给它们附加元数据(metadata)。我们可以更新我们的事务计数插件,让它不计数被标记的事务,像这样:
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)
}
元数据属性的键可以是字符串,但为了避免名称冲突,鼓励你使用插件对象。有一些字符串键被库赋予了含义,例如 "addToHistory" 可以设为 false 来阻止一个事务被撤销;在处理粘贴时,编辑器视图会把结果事务上的 "paste" 属性设为 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.
View 组件(The view component)
The view component
ProseMirror 编辑器视图(editor view)是一个用户界面组件,它向用户显示一个编辑器状态,并允许用户对它执行编辑动作。
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.
核心 view 组件对编辑动作的定义相当窄——它处理与编辑表面的直接交互,比如输入、点击、复制、粘贴和拖拽,但仅此而已。这意味着像显示菜单这样的事,甚至提供一整套按键绑定,都超出了核心 view 组件的职责范围,必须通过插件来安排。
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.
浏览器允许我们指定 DOM 的某些部分是可编辑的,其效果是允许它们获得焦点和选区,并且可以在其中输入。视图创建其文档的 DOM 表示(默认使用你 schema 中的 toDOM 方法),并使其可编辑。当可编辑元素获得焦点时,ProseMirror 确保 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.
它还为许多 DOM 事件注册了事件处理器,把这些事件翻译成相应的事务。例如,在粘贴时,粘贴的内容会被解析为 ProseMirror 文档切片,然后插入文档。
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.
许多事件也会被原样放行,只是随后再根据 ProseMirror 的数据模型重新解释。例如,浏览器相当擅长光标和选区的放置(当你考虑双向文本时,这确实是个难题),所以大多数与光标移动相关的按键和鼠标动作都由浏览器处理,之后 ProseMirror 检查当前 DOM 选区对应的是哪种文本选区。如果那个选区与当前选区不同,就派发一个更新选区的事务。
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.
甚至连输入通常也交给浏览器处理,因为干涉它往往会破坏拼写检查、某些移动界面上的自动首字母大写,以及其他原生功能。当浏览器更新 DOM 时,编辑器会注意到,重新解析文档中被改变的部分,并把差异转换成一个事务。
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.
所以编辑器视图显示一个给定的编辑器状态,当发生某些事情时,它创建一个事务并广播它。然后,这个事务通常被用来创建一个新状态,这个新状态通过视图的 updateState 方法交给视图。
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.
这创建了一个直接、循环的数据流,而不是(JavaScript 世界里)经典的一大堆命令式事件处理器的方式,后者往往会产生复杂得多的数据流网络。
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.
可以通过 dispatchTransaction prop 在事务被派发时「拦截」它们,以便把这个循环数据流接入一个更大的循环——如果你的整个应用都在使用这样的数据流模型,比如 Redux 和类似的架构,你可以把 ProseMirror 的事务集成到你的主 action 派发循环中,并把 ProseMirror 的状态保存在你应用的「store」里。
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’.
// The app's state
let appState = {
editor: EditorState.create({schema}),
score: 0
}
let view = new EditorView(document.body, {
state: appState.editor,
dispatchTransaction(transaction) {
update({type: "EDITOR_TRANSACTION", transaction})
}
})
// A crude app state update function, which takes an update object,
// updates the `appState`, and then refreshes the 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()
}
// An even cruder drawing function
function draw() {
document.querySelector("#score").textContent = appState.score
view.updateState(appState.editor)
}
实现 updateState 的一种方式是每次被调用时简单地重绘文档。但对于大文档,那会非常慢。
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.
由于在更新时,视图同时能访问旧文档和新文档,它可以比较它们,让 DOM 中与未变化节点对应的部分保持不变。ProseMirror 正是这样做的,使得它在典型更新中只需要做很少的工作。
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.
在某些情况下,比如对应于已输入文本的更新(这些文本已经由浏览器自己的编辑动作添加到了 DOM 中),确保 DOM 与状态一致完全不需要任何 DOM 变更。(当这样的事务被取消或以某种方式修改时,视图会撤销 DOM 变更,以确保 DOM 和状态保持同步。)
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.)
类似地,DOM 选区只有在它确实与状态中的选区不同步时才会更新,以避免破坏浏览器随选区一起保存的各种「隐藏」状态(比如那个功能:当你向上或向下越过一个短行时,你的水平位置会在进入下一行长行时回到原来的位置)。
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, your horizontal position goes back to where it was when you enter the next long line).
「Props」是一个有用但有些含糊的术语,取自 React。Props 就像 UI 组件的参数。理想情况下,组件获得的那组 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 }, // Enables read-only behavior
handleDoubleClick() { console.log("Double click!") }
})
因此,当前的状态就是一个 prop。其他 props 的值也可以随时间变化,如果控制组件的代码更新了它们,但它们不被视为状态(state),因为组件本身不会改变它们。updateState 方法只是更新 state prop 的一种简写。
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.
插件也允许声明 props,除了 state 和 dispatchTransaction,它们只能直接提供给视图。
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 }
}
})
}
当某个 prop 被声明多次时,如何处理取决于该 prop。一般来说,直接提供的 props 优先,之后每个插件按顺序轮流。对于某些 props,比如 domParser,使用找到的第一个值,其他值被忽略。对于返回布尔值表示是否处理了事件的处理器函数,第一个返回 true 的处理器来处理事件。最后,对于某些 props,比如 attributes(可以用来在可编辑 DOM 节点上设置属性)和 decorations(我们下一节会讲到),使用所有提供值的并集。
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 prop 返回值来创建,分为三种类型:
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)给单个节点的 DOM 表示添加样式或其他 DOM 属性。
Node decorations add styling or other DOM attributes to a single node's DOM representation.
-
Widget 装饰在给定位置插入一个不属于实际文档的 DOM 节点。
Widget decorations insert a DOM node, which isn't part of the actual document, at a given position.
-
行内装饰(Inline decorations)与节点装饰类似,给一个范围内的所有行内节点添加样式或属性。
Inline decorations add styling or attributes, much like node decorations, but to all inline nodes in a given range.
为了能够高效地绘制和比较装饰,它们需要以装饰集(decoration set)的形式提供(它是一种模仿实际文档树形结构的数据结构)。你使用静态的 create 方法来创建一个,提供文档和一个装饰对象数组:
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) }
}
})
这个插件把它的状态初始化为一个装饰集,给每个第 4 个位置添加一个黄色背景的行内装饰。这不是特别有用,但有点类似于高亮搜索匹配或标注区域这样的用例。
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.
当一个事务被应用到状态上时,插件状态的 apply 方法把装饰集向前映射,使装饰保持在原地并「适配」新的文档形状。映射方法(对于典型的局部变更)通过利用装饰集的树形结构而变得高效——只有树中真正被变更触及的部分需要重建。
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.
(在真实世界的插件中,apply 方法也是你基于新事件添加或移除装饰的地方,可能是通过检查事务中的变更,或基于附加到事务上的插件特定元数据。)
(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.)
最后,decorations prop 简单地返回插件状态,使装饰显示在视图中。
Finally, the decorations prop simply returns the plugin state,
causing the decorations to show up in the view.
还有另一种方式可以影响编辑器视图绘制文档的方式。节点视图(Node views)让你可以为文档中的单个节点定义一种微型 UI 组件。它们让你渲染节点的 DOM、定义它们被更新的方式,并编写自定义代码来响应事件。
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) {
// The editor will use this as the node's DOM representation
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 节点的事件。
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.
你通常希望与节点的交互对文档中实际的节点产生某种效果。但要创建一个改变节点的事务,你首先需要知道那个节点在哪里。为了帮助做到这一点,节点视图会被传入一个 getter 函数,可以用来查询它们在文档中的当前位置。让我们修改例子,使点击节点时提示你为图片输入替代文本:
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 是一个方法,可以用来改变给定位置节点的类型或属性集。在这个例子中,我们用 getPos 找到图片的当前位置,并给它一个带有新替代文本的新属性对象。
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.
当一个节点被更新时,默认行为是保持它的外层 DOM 结构不变,并把它的子节点与新的子节点集比较,根据需要更新或替换它们。节点视图可以用自定义行为覆盖这一点,这让我们可以做一些事情,比如根据段落的内容改变它的 class。
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.content.size > 0) this.dom.classList.remove("empty")
else this.dom.classList.add("empty")
return true
}
}
图片从来没有内容,所以在之前的例子里,我们不需要担心内容如何渲染。但段落确实有内容。节点视图支持两种处理内容的方式:你可以让 ProseMirror 库管理它,也可以完全自己管理它。如果你提供一个 contentDOM 属性,库会把节点的内容渲染到其中,并处理内容更新。如果你不提供,内容对编辑器来说就成了一个黑箱,如何显示它以及让用户如何与它交互完全取决于你。
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.
在这个例子中,我们希望段落内容表现得像普通可编辑文本,所以 contentDOM 属性被定义为与 dom 属性相同,因为内容需要直接渲染到外层节点中。
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.
魔法发生在 update 方法里。首先,这个方法负责决定节点视图是否能够被更新来显示新节点。当它不能时,应该返回 false。
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. It should return false
when it cannot.
例子中的 update 方法确保 "empty" class 根据新节点的内容而存在或不存在,并返回 true,表示更新成功(此时节点的内容将被更新)。
The update method in the example 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).
命令(Commands)
Commands
在 ProseMirror 的行话里,命令(command)是一个实现编辑动作的函数,用户可以通过按某个组合键或与菜单交互来执行它。
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.
出于实际原因,命令有一个略微绕弯的接口。在它们的简单形式中,它们是接收一个编辑器状态和一个dispatch 函数(EditorView.dispatch 或其他接受事务的函数)并返回布尔值的函数。这里有一个非常简单的例子:
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
}
当一个命令不可用时,它应该返回 false 并且什么也不做。当它可用时,它应该派发一个事务并返回 true。例如,keymap 插件就用这一点在绑定到该键的命令已被应用时停止进一步处理按键事件。
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.
为了能够查询一个命令对给定状态是否可用而不实际执行它,dispatch 参数是可选的——当命令可用但没有给出 dispatch 参数时,它应该简单地返回 true 而不做任何事。所以示例命令实际上应该像这样:
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
}
要弄清楚当前是否能够删除选区,你会调用 deleteSelection(view.state, null),而要真正执行命令,你会做类似 deleteSelection(view.state, view.dispatch) 的事。菜单栏可以用这个来决定哪些菜单项要置灰。
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.
在这种形式下,命令无法访问实际的编辑器视图——大多数命令不需要它,而且这样它们就可以在没有视图可用的环境中被应用和测试。但有些命令确实需要与 DOM 交互——它们可能需要查询某个位置是否在 textblock 的末尾,或者想打开一个相对于视图定位的对话框。为此,大多数调用命令的插件会给它们第三个参数,即整个视图。
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.
prosemirror-commands 模块提供了许多编辑命令,从简单的(比如 deleteSelection 命令的变体)到相当复杂的(比如 joinBackward,它实现了在 textblock 开头按 backspace 时应该发生的块合并行为)。它还带有一个基础 keymap,把许多与 schema 无关的命令绑定到通常用于它们的按键上。
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.
在可能的情况下,不同的行为,即使通常绑定到单个键,也会放在不同的命令中。工具函数 chainCommands 可以用来组合多个命令——它们会被一个接一个地尝试,直到有一个返回 true。
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.
例如,基础 keymap 把 backspace 绑定到命令链 deleteSelection(在选区非空时生效)、joinBackward(当光标在 textblock 开头时)和 selectNodeBackward(选中选区前的节点,以防 schema 禁止常规的合并行为)。当这些都不适用时,允许浏览器运行它自己的 backspace 行为,这是在 textblock 内部退格删除内容的恰当做法(这样原生拼写检查之类的东西就不会被搞混)。
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).
commands 模块还导出了许多命令构造函数,比如 toggleMark,它接受一个标记类型和可选的一组属性,并返回一个在当前选区上切换该标记的命令函数。
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.
其他一些模块也导出命令函数——例如 history 模块的 undo 和 redo。要自定义你的编辑器,或让用户与自定义文档节点交互,你很可能也想编写自己的自定义命令。
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.
协同编辑(Collaborative editing)
Collaborative editing
实时协同编辑允许多人在同一时间编辑同一份文档。他们所做的更改会立即应用到本地文档,然后发送给对等方,对等方会自动合并这些更改(无需手动解决冲突),使编辑可以不间断地进行,文档也持续收敛。
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.
本指南描述如何接上 ProseMirror 的协同编辑功能。
This guide describes how to wire up ProseMirror's collaborative editing functionality.
ProseMirror 的协同编辑系统采用一个中央权威(central authority)来决定更改以什么顺序应用。如果两个编辑器并发地做出更改,它们都会带着各自的更改去找这个权威。权威会接受其中一方的更改,并把这些更改广播给所有编辑器。另一方的更改不会被接受,当那个编辑器从服务器收到新的更改时,它必须把自己的本地更改rebase到来自另一个编辑器的更改之上,然后再次尝试提交。
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 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
让我们实现一个与编辑器运行在同一个 JavaScript 环境中的简单中央权威。
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
// Apply and accumulate new steps
steps.forEach(step => {
this.doc = step.apply(this.doc).doc
this.steps.push(step)
this.stepClientIDs.push(clientID)
})
// Signal listeners
this.onNewSteps.forEach(function(f) { f() })
}
stepsSince(version) {
return {
steps: this.steps.slice(version),
clientIDs: this.stepClientIDs.slice(version)
}
}
}
当一个编辑器想要尝试把自己的更改提交给权威时,它可以调用它的 receiveSteps,传入它最后收到的版本号、它新添加的更改,以及它的客户端 ID(这是一种让它稍后能识别哪些更改来自它自己的方式)。
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).
当步骤被接受时,客户端会注意到,因为权威会通知它们有新步骤可用,然后给它们它们自己的步骤。在真实实现中,你也可以让 receiveSteps 返回一个状态,并立即确认已发送的步骤,作为一种优化。但这里使用的机制对于在不稳定连接上保证同步是必需的,所以你应该始终把它作为基础情形来使用。
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.
collab 模块导出一个 collab 函数,它返回一个插件,负责跟踪本地更改、接收远程更改,并指出何时有东西需要发送给中央权威。
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
}
collabEditor 函数创建一个加载了 collab 插件的编辑器视图。每当状态更新时,它检查是否有任何东西需要发送给权威。如果有,就发送它。
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.
当一组步骤被权威拒绝时,它们会保持未确认状态,直到(大概很快之后)我们从权威那里收到新步骤。在那之后,因为 onNewSteps 回调调用 dispatch,而 dispatch 会调用我们的 dispatchTransaction 函数,代码就会再次尝试提交它的更改。
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.
这就是全部了。当然,使用异步数据通道(比如collab 演示中的长轮询,或 web socket)时,你会需要更复杂的通信和同步代码。而且你很可能还希望你的权威在某个时刻开始丢弃步骤,这样它的内存消耗就不会无限增长。但总体方法已经被这个小例子完整地描述了。
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.