ProseMirror 指南

ProseMirror Guide

本指南描述了该库使用的各种概念,以及它们之间的关系。为了对系统有一个完整的认识,建议你按照它所呈现的顺序读下去,至少读到 view 组件这一节。

简介

ProseMirror 提供了一套用于构建富文本编辑器的工具和概念,它使用的用户界面灵感来自所见即所得(WYSIWYG),但试图避免那种编辑风格的种种陷阱。

ProseMirror 的主要原则是:你的代码对文档以及发生在它身上的一切拥有完全的控制权。这份文档不是一团 HTML,而是一种自定义的数据结构,它只包含你明确允许它包含的元素,并且这些元素之间保持着你所指定的关系。所有更新都经过一个单一入口,在那里你可以检查它们并对其做出反应。

核心库不是一个简单的开箱即用组件——我们把模块化和可定制性放在简单性之上,希望将来人们能基于 ProseMirror 发布开箱即用的编辑器。因此,它更像是一套乐高积木,而不是一辆火柴盒玩具车。

有四个核心模块,是进行任何编辑所必需的;此外还有一些由核心团队维护的扩展模块,它们的地位与第三方模块类似——它们提供有用的功能,但你可以省略它们,或用实现类似功能的其他模块来替换它们。

核心模块如下:

此外,还有一些模块用于基础编辑命令按键绑定撤销历史输入宏协同编辑、一个简单的文档 schema,以及 prosemirror 组织下的更多模块。

ProseMirror 并不是以一个可直接在浏览器中加载的单一脚本分发的,这意味着你在使用它时很可能需要某种打包工具。打包工具是一种能自动找出脚本依赖,并把它们合并成一个大文件(便于从网页加载)的工具。你可以在网上阅读更多关于打包的内容,例如这里

我的第一个编辑器

乐高积木像这样拼在一起,就能创建一个非常简单的编辑器:

import {schema} from "prosemirror-schema-basic"
import {EditorState} from "prosemirror-state"
import {EditorView} from "prosemirror-view"

let state = EditorState.create({schema})
let view = new EditorView(document.body, {state})

ProseMirror 要求你指定一个文档所遵循的 schema,所以这段代码做的第一件事就是导入一个带基础 schema 的模块。

然后用这个 schema 创建一个 state,它会生成一个符合该 schema 的空文档,以及位于该文档开头的一个默认选区。最后,为这个 state 创建一个 view,并把它追加到 document.body 上。这会把 state 的文档渲染成一个可编辑的 DOM 节点,并在用户输入时生成状态事务。

这个编辑器现在还不太好用。例如,如果你按回车,什么也不会发生,因为核心库对回车应该做什么没有任何意见。我们稍后会讲到这一点。

事务(Transactions)

当用户输入或以其他方式与视图交互时,它会生成「状态事务」。这意味着它并不是就地修改文档并隐式地更新其状态。相反,每次变更都会导致创建一个事务(transaction),它描述了对状态所做的更改,并且可以被应用来创建一个新的状态,这个新状态随后被用来更新视图。

默认情况下,这一切都在幕后进行,但你可以通过编写插件或配置你的视图来介入其中。例如,下面这段代码添加了一个 dispatchTransaction prop,每当有事务被创建时它都会被调用:

// (Imports omitted)

let state = EditorState.create({schema})
let view = new EditorView(document.body, {
  state,
  dispatchTransaction(transaction) {
    console.log("Document size went from", transaction.before.content.size,
                "to", transaction.doc.content.size)
    let newState = view.state.apply(transaction)
    view.updateState(newState)
  }
})

每一次状态更新都必须经过 updateState,而每一次正常的编辑更新都会通过派发一个事务来进行。

插件(Plugins)

插件用于以各种方式扩展编辑器和编辑器状态的行为。有些相对简单,比如 keymap 插件把动作绑定到键盘输入上。另一些则更复杂,比如 history 插件通过观察事务并存储它们的逆操作来实现撤销历史,以备用户想要撤销它们。

让我们把这两个插件加到我们的编辑器中,以获得撤销/重做功能:

// (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)来撤销上一步更改。

命令(Commands)

上一个例子绑定到按键上的 undoredo 值,是一种特殊的函数,叫做命令(commands)。大多数编辑操作都被写成命令,命令可以绑定到按键、挂到菜单上,或以其他方式暴露给用户。

prosemirror-commands 包提供了许多基础编辑命令,以及一个最小的 keymap。你很可能想启用这个 keymap,让回车和删除键在你的编辑器中做它们该做的事。

// (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})

到这一步,你已经有了一个基本可用的编辑器。

要添加菜单、针对 schema 特定内容的额外按键绑定等,你可能想看看 prosemirror-example-setup 包。这个模块为你提供一组插件来搭建一个基础编辑器,但正如其名字所暗示的,它更偏向于示例,而不是生产级库。对于真实世界的部署,你很可能想用自定义代码替换它,按照你想要的方式精确地设置一切。

内容(Content)

state 的文档存放在它的 doc 属性下。这是一个只读的数据结构,把文档表示为一个节点层级,有点像浏览器 DOM。一个简单的文档可能是一个 "doc" 节点,包含两个 "paragraph" 节点,每个段落又包含一个 "text" 节点。

在初始化 state 时,你可以给它一个初始文档来使用。这种情况下,schema 字段是可选的,因为 schema 可以从文档中推断出来。

这里我们通过解析 ID 为 "content" 的 DOM 元素中的内容来初始化一个 state,使用的是 DOM 解析器机制,它利用 schema 提供的关于哪些 DOM 节点映射到 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)

ProseMirror 定义了自己的数据结构来表示内容文档。由于文档是整个编辑器围绕的中心元素,理解它们是如何工作的会很有帮助。

结构(Structure)

一个 ProseMirror 文档是一个节点(node),它持有一个包含零个或多个子节点的片段(fragment)

这很像浏览器 DOM,在于它是递归的、树状的。但它与 DOM 的不同之处在于它存储行内内容的方式。

在 HTML 中,一个带标记的段落被表示为一棵树,像这样:

<p>This is <strong>strong text with <em>emphasis</em></strong></p>
p
"This is "
strong
"strong text with "
em
"emphasis"

而在 ProseMirror 中,行内内容被建模为一个扁平的序列,标记作为元数据附加到节点上:

paragraph
"This is "
"strong text with "
strong
"emphasis"
strong
em

这更接近我们思考和操作这类文本的方式。它允许我们用字符偏移量(而不是树中的路径)来表示段落中的位置,也使得像拆分或改变内容样式这样的操作更容易进行,而无需进行笨拙的树操作。

这也意味着每个文档都有一种合法的表示。带有相同标记集的相邻文本节点总是会被合并,空文本节点是不允许的。标记出现的顺序由 schema 指定。

所以,ProseMirror 文档是一棵块节点树,其中大多数叶子节点是textblock,即包含文本的块节点。你也可以有空叶子块,例如水平线或视频元素。

节点对象带有许多属性,反映了它们在文档中所扮演的角色:

所以一个典型的 "paragraph" 节点会是一个 textblock,而 blockquote 可能是一个块元素,其内容由其他块组成。文本、硬换行和行内图片是行内叶子节点,而水平线节点则是块叶子节点的一个例子。

schema 可以指定更精确的约束,规定什么可以出现在哪里——也就是说,即使一个节点允许块内容,也不意味着它允许所有块节点作为内容。

身份与持久性(Identity and persistence)

DOM 树和 ProseMirror 文档之间的另一个重要区别,是表示节点的对象的行为方式。在 DOM 中,节点是具有身份(identity)的可变对象,这意味着一个节点只能出现在一个父节点中,而且节点对象在更新时会被修改。

而在 ProseMirror 中,节点只是值(values),你应该像对待表示数字 3 的那个值一样对待它们。3 可以同时出现在多个数据结构中,它没有指向它当前所属数据结构的父链接,如果你把它加 1,你会得到一个新的值 4,而不会改变原来的 3 的任何东西。

ProseMirror 文档的片段也是如此。它们不改变,但可以作为计算修改后文档片段的起始值。它们不知道自己属于哪些数据结构,但可以成为多个结构的一部分,甚至可以在单个结构中出现多次。它们是,而不是有状态的对象。

这意味着每次更新文档时,你都会得到一个新的文档值。那个文档值会与原文档值共享所有未变化的子节点,使得创建它的成本相对较低。

这有一大堆好处。它使得编辑器在更新期间不可能处于无效的中间状态,因为带有新文档的新状态可以瞬间被换入。它也使得以某种数学化的方式推理文档变得更容易——如果你的值不断在你脚下变化,这是非常困难的。这有助于实现协同编辑,并让 ProseMirror 通过把上一次绘制到屏幕上的文档与当前文档进行比较,来运行一个非常高效的 DOM 更新算法。

由于这些节点由普通的 JavaScript 对象表示,而显式地冻结它们的属性会损害性能,所以实际上有可能修改它们。但这样做是不受支持的,而且会导致问题,因为它们几乎总是在多个数据结构之间共享。所以要小心!还要注意,对于作为节点对象一部分的数组和普通对象(比如用于存储节点属性的对象,或片段中子节点的数组)也是如此。

数据结构(Data structures)

一个文档的对象结构看起来大致是这样:

Node
type:
NodeType
content:
Fragment
[
Node
,
Node
, ...]
attrs:
Object
marks: [
Mark
type:
MarkType
attrs:
Object
, ...]

每个节点都由 Node 类的一个实例表示。它带有一个类型(type)标签,这个类型知道节点的名称、对它有效的属性等等。节点类型(和标记类型)在每个 schema 中创建一次,并且知道它们属于哪个 schema。

节点的内容存储在一个 Fragment 实例中,它持有一个节点序列。即使对于没有内容或不允许内容的节点,这个字段也会被填充(用共享的空片段)。

有些节点类型允许属性,即与每个节点一起存储的额外值。例如,图片节点可能用它们来存储其替代文本和图片的 URL。

此外,行内节点持有一组激活的标记——比如强调或作为链接——它们被表示为一个 Mark 实例数组。

一个完整的文档只是一个节点。文档内容表示为顶层节点的子节点。通常,它会包含一系列块节点,其中一些可能是包含行内内容的 textblock。但顶层节点本身也可以是一个 textblock,这样文档就只包含行内内容。

什么节点可以出现在哪里,由文档的schema 决定。要以编程方式创建节点,你必须通过 schema 来进行,例如使用 nodetext 方法。

import {schema} from "prosemirror-schema-basic"

// (The null arguments are where you can specify attributes, if necessary.)
let doc = schema.node("doc", null, [
  schema.node("paragraph", null, [schema.text("One.")]),
  schema.node("horizontal_rule"),
  schema.node("paragraph", null, [schema.text("Two!")])
])

索引(Indexing)

ProseMirror 节点支持两种索引方式——它们可以被当作树来处理,使用各个节点内的偏移量;也可以被当作一个扁平的标记序列来处理。

第一种允许你做类似对 DOM 做的事——与单个节点交互,使用 child 方法childCount 直接访问子节点,编写递归函数扫描文档(如果你只是想查看所有节点,使用 descendantsnodesBetween)。

第二种在定位文档中某个具体位置时更有用。它允许任何文档位置都用一个整数来表示——即标记序列中的索引。这些标记实际上并不作为对象存在于内存中——它们只是一种计数约定——但文档的树形结构,加上每个节点都知道自己的大小这一事实,使得按位置访问变得很廉价。

所以如果你有一个文档,用 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)

为了处理复制粘贴和拖放这类事情,必须能够谈论文档的一个切片,即两个位置之间的内容。这种切片与完整节点或片段的不同之处在于,它开头或结尾的一些节点可能是「打开的」。

例如,如果你从一个段落的中间选到下一个段落的中间,你选中的切片里有两个段落,第一个在开头是打开的,第二个在结尾是打开的;而如果你节点选中一个段落,你选中的是一个闭合的节点。如果把这种打开节点中的内容当作节点的完整内容来处理,可能会违反 schema 约束,因为一些必需的节点落在了切片之外。

Slice 数据结构用于表示这种切片。它存储一个片段,以及两侧的打开深度。你可以使用节点上的 slice 方法从文档中切出一个切片。

// doc holds two paragraphs, containing text "a" and "b"
let slice1 = doc.slice(0, 3) // The first paragraph
console.log(slice1.openStart, slice1.openEnd) // → 0 0
let slice2 = doc.slice(1, 5) // From start of first paragraph
                             // to end of second
console.log(slice2.openStart, slice2.openEnd) // → 1 1

修改(Changing)

由于节点和片段是持久的(persistent),你应该永远不要修改它们。如果你持有某个文档(或节点、或片段)的句柄,那个对象会保持不变。

大多数时候,你会使用变换(transformations)来更新文档,而不必直接接触节点。变换还会留下更改记录,这在文档是编辑器状态的一部分时是必需的。

在你确实想「手动」派生一个更新后文档的情况下,NodeFragment 类型上有一些辅助方法可用。要创建整个文档的更新版本,你通常会想使用 Node.replace,它把文档的给定范围替换为一个切片的新内容。要对节点做浅层更新,你可以使用它的 copy 方法,它创建一个内容不同的相似节点。片段也有各种更新方法,比如 replaceChildappend

Schema

每个 ProseMirror 文档都关联一个 schema。schema 描述了文档中可能出现的节点类型,以及它们嵌套的方式。例如,它可能规定顶层节点可以包含一个或多个块,而段落节点可以包含任意数量的行内节点,并带有任意应用到它们之上的标记

有一个提供基础 schema 的包可用,但 ProseMirror 的好处在于它允许你定义自己的 schema。

节点类型(Node Types)

文档中的每个节点都有一个类型(type),它代表节点的语义含义和属性,比如它在编辑器中的渲染方式。

当你定义一个 schema 时,你要枚举其中可能出现的节点类型,用 spec 对象来描述每一种:

const trivialSchema = new Schema({
  nodes: {
    doc: {content: "paragraph+"},
    paragraph: {content: "text*"},
    text: {inline: true},
    /* ... and so on */
  }
})

那定义了一个 schema,其中文档可以包含一个或多个段落,每个段落可以包含任意数量的文本。

每个 schema 至少必须定义一个顶层节点类型(默认名称是 "doc",但你可以配置它),以及一个用于文本内容的 "text" 类型。

属于行内的节点必须用 inline 属性来声明(不过对于 text 类型,它按定义就是行内的,你可以省略这一点)。

内容表达式(Content Expressions)

上面示例 schema 中 content 字段里的字符串叫做内容表达式(content expressions)。它们控制哪些子节点序列对这种节点类型是合法的。

你可以说,例如 "paragraph" 表示「一个段落」,或 "paragraph+" 表示「一个或多个段落」。类似地,"paragraph*" 表示「零个或多个段落」,"caption?" 表示「零个或一个 caption 节点」。你还可以在节点名后面使用类似正则表达式的范围,比如 {2}(「恰好两个」)、{1, 5}(「一到五个」)或 {2,}(「两个或更多」)。

这样的表达式可以组合成序列,例如 "heading paragraph+" 表示「先是一个标题,然后是一个或多个段落」。你也可以使用管道符 | 来表示两个表达式之间的选择,如 "(paragraph | blockquote)+"

有些元素类型的组合会在你的 schema 中多次出现——例如你可能有一个「块(block)」节点的概念,它可以出现在顶层,也可以嵌套在 blockquote 里面。你可以给节点 spec 一个 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")中始终要求至少一个子节点,因为当节点为空时,浏览器会把它完全折叠掉,使它很难编辑。

节点在 or 表达式中出现的顺序很重要。当为一个非可选节点创建默认实例时(例如,为了确保文档在 replace step 之后仍然符合 schema),会使用表达式中的第一个类型。如果那是一个组,则使用组中的第一个类型(由组成员在你的 nodes map 中出现的顺序决定)。如果我在示例 schema 中交换 "paragraph""blockquote" 的位置,那么只要编辑器试图创建一个块节点,你就会得到栈溢出——它会创建一个 "blockquote" 节点,它的内容要求至少一个块,于是它又试图创建另一个 "blockquote" 作为内容,如此往复。

库中并非每个操作节点的函数都会检查它处理的是否是合法内容——像变换(transforms)这样的高层概念会检查,但原始的节点创建方法通常不检查,而是把提供合理输入的责任交给调用者。完全有可能使用例如 NodeType.create 来创建一个内容非法的节点。对于在切片边缘「打开」的节点来说,这甚至是合理的。有一个单独的 createChecked 方法,以及一个事后使用的 check 方法,可以用来断言给定节点的内容是合法的。

标记(Marks)

标记用于给行内内容添加额外的样式或其他信息。schema 必须在其 schema 中声明它允许的所有标记类型。标记类型(Mark types)是类似于节点类型的对象,用于给标记对象打标签并提供关于它们的额外信息。

默认情况下,带有行内内容的节点允许 schema 中定义的所有标记应用到它们的子节点上。你可以用节点 spec 上的 marks 属性来配置这一点。

这里有一个简单的 schema,支持在段落文本上使用 strong 和 emphasis 标记,但在标题上不支持:

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)

文档 schema 还定义了每个节点或标记具有哪些属性(attributes)。如果你的节点类型需要存储额外的、节点特有的信息,比如标题节点的级别,最好用属性来做。

属性集被表示为普通对象,带有一组预定义的(按节点或标记)属性,存放任何可 JSON 序列化的值。要指定它允许哪些属性,在节点或标记 spec 中使用可选的 attrs 字段。

  heading: {
    content: "text*",
    attrs: {level: {default: 1}}
  }

在这个 schema 中,heading 节点的每个实例都会在 .attrs.level 下有一个 level 属性。如果在创建节点时没有指定它,它将默认为 1。

当你没有为属性指定默认值时,如果你试图创建这样一个节点而没有指定该属性,就会抛出一个错误。

这也会使库无法在变换期间或调用 createAndFill 时生成这类节点作为填充物来满足 schema 约束。这就是为什么不允许把这类节点放在 schema 中的必需位置上——为了能够强制执行 schema 约束,编辑器需要能够生成空节点来填补内容中缺失的部分。

序列化与解析(Serialization and Parsing)

为了能在浏览器中编辑它们,必须能够把文档节点表示在浏览器 DOM 中。最简单的方法是用节点 spec 中的 toDOM 字段把每个节点的 DOM 表示信息包含在 schema 中。

这个字段应该保存一个函数,当以节点为参数调用它时,返回对该节点 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 表示中不需要洞,因为它们没有内容。

标记 spec 允许类似的 toDOM 方法,但它们必须渲染为直接包裹内容的单一标签,所以内容总是直接放在返回的节点里,洞不需要指定。

你也经常需要从 DOM 数据中解析文档,例如当用户粘贴或拖拽东西进编辑器时。model 模块也附带了这方面的功能,并且鼓励你用 parseDOM 属性把解析信息直接包含在 schema 中。

这里可以列出一个 parse rules 数组,描述映射到给定节点或标记的 DOM 构造。例如,基础 schema 对 emphasis 标记有这样的规则:

  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 样式。

当 schema 包含 parseDOM 注解时,你可以用 DOMParser.fromSchema 为它创建一个 DOMParser 对象。编辑器正是这样创建默认剪贴板解析器的,但你也可以覆盖它。

文档还带有内置的 JSON 序列化格式。你可以在它们上面调用 toJSON 来获得一个可以安全传给 JSON.stringify 的对象,而 schema 对象有一个 nodeFromJSON 方法可以把这种表示解析回文档。

扩展 Schema(Extending a schema)

传给 Schema 构造函数nodesmarks 选项接受 OrderedMap 对象以及普通 JavaScript 对象。生成的 schema 的 spec.nodesspec.marks 属性总是 OrderedMap,它们可以作为后续 schema 的基础。

这样的 map 支持多种方法来方便地创建更新版本。例如你可以说 schema.spec.nodes.remove("blockquote") 来派生一个不含 blockquote 节点的节点集,然后把它作为新 schema 的 nodes 字段传入。

schema-list 模块导出了一个便捷方法,把这些模块导出的节点添加到一个节点集中。

文档变换(Document transformations)

变换(Transforms)是 ProseMirror 工作方式的核心。它们构成事务的基础,也是使历史追踪和协同编辑成为可能的东西。

为什么?(Why?)

为什么我们不能直接修改文档就完事了呢?或者至少创建一个新版本的文档,然后把它放进编辑器里?

有几个原因。一个是代码清晰度。不可变的数据结构确实能带来更简单的代码。但变换系统所做的主要事情是留下一串更新轨迹,以值的形式表示从文档旧版本到新版本所采取的各个步骤。

撤销历史可以保存这些步骤,并应用它们的逆操作回到过去(ProseMirror 实现了选择性撤销,这比简单地回滚到之前的状态更复杂)。

协同编辑系统把这些步骤发送给其他编辑器,并在必要时对它们重新排序,使每个人最终得到相同的文档。

更一般地说,编辑器插件能够检查和响应每一个到来的变更,以保持自己的状态与编辑器其余部分的状态一致,这是非常有用的。

步骤(Steps)

对文档的更新被分解为描述一次更新的步骤(steps)。你通常不需要直接使用它们,但了解它们如何工作是有用的。

步骤的例子有 ReplaceStep(替换文档的一段),或 AddMarkStep(给一个范围添加标记)。

一个步骤可以被应用到一个文档上,产生一个新文档。

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 返回一个结果对象,它要么持有一个新文档,要么持有一条错误消息。

你通常会让辅助函数为你生成步骤,这样你就不必担心细节。

变换(Transforms)

一个编辑动作可能产生一个或多个步骤。处理一系列步骤最方便的方式是创建一个 Transform 对象(或者,如果你处理的是完整的编辑器状态,则创建一个 Transaction,它是 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))。

有用于删除替换的变换方法,用于添加移除标记的方法,用于树操作的 拆分合并提升包裹,等等。

映射(Mapping)

当你对文档做一次变更时,指向该文档的位置可能变得无效或改变含义。例如,如果你插入一个字符,所有在该字符之后的位置现在都指向它们旧位置之前一个标记的地方。类似地,如果你删除文档中的所有内容,所有指向那些内容的位置现在都无效了。

我们经常确实需要跨文档变更保留位置,例如选区边界。为了帮助做到这一点,步骤可以给你一个映射(map),它可以在应用步骤前后的文档位置之间进行转换。

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,它收集一系列步骤映射,并允许你一次性映射过它们。

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 正好指向我们拆分节点、插入两个标记的那个点。它应该映射到插入内容之后的位置,还是留在它前面?在这个例子中,它显然被移到了插入的标记之后。

但有时你想要另一种行为,这就是为什么步骤映射和映射上的 map 方法接受第二个参数 bias,你可以把它设为 -1,以便在有内容插入到你的位置之上时让它保持不动。

console.log(tr.mapping.map(10, -1)) // → 7

把各个步骤定义为小而直接的东西,原因就在于它使这种映射成为可能,同时还能以无损的方式反转步骤,以及让步骤映射穿过彼此的位置映射。

Rebase

当你对步骤和位置映射做更复杂的事情时,例如实现你自己的变更追踪,或把某个功能集成到协同编辑中,你可能会遇到需要rebase步骤的情况。

在你确定自己需要它之前,你可能不想费心研究这些。

在简单情况下,rebase 是指取两个以同一文档开始的步骤,并变换其中一个,使它能够改为应用到另一个所产生的文档上。用伪代码表示:

stepA(doc) = docA
stepB(doc) = docB
stepB(docA) = MISMATCH!
rebase(stepB, mapA) = stepB'
stepB'(docA) = docAB

步骤有一个 map 方法,它接受一个映射,把整个步骤映射过去。这可能会失败,因为有些步骤不再有意义了,比如当它们所应用的内容已经被删除时。但当它成功时,你现在就有了一个指向新文档(即你映射穿过的变更之后的那个文档)的步骤。所以在上面例子中,rebase(stepB, mapA) 可以简单地调用 stepB.map(mapA)

当你想把一串步骤 rebase 到另一串步骤之上时,事情变得更复杂。

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 映射的逆映射回到原始文档,然后穿过应用 stepA1stepA2 所产生的映射管道,最后穿过把 stepB1' 应用到 docA 所产生的映射。

如果还有一个 stepB3,我们就取上面的管道,在它前面加上 invert(mapB2),并在末尾加上 mapB2',就得到它的管道。以此类推。

但当 stepB1 插入了一些内容,而 stepB2 对那段内容做了一些事情时,把 stepB2 映射过 invert(mapB1) 会返回 null,因为 stepB1 的逆操作会删除它所应用的内容。然而,这段内容会在管道稍后的位置被 mapB1 重新引入。Mapping 抽象提供了一种追踪这类管道(包括其中映射之间的逆关系)的方法。你可以用这种方式把步骤映射过去,使它们能在上述情形中幸存下来。

即使你已经 rebase 了一个步骤,也不能保证它仍然能被合法地应用到当前文档上。例如,如果你的步骤添加一个标记,但另一个步骤把你目标内容的父节点改成了一个不允许标记的节点,那么尝试应用你的步骤就会失败。对此的恰当反应通常就是直接丢弃这个步骤。

编辑器状态(The editor state)

编辑器的状态由什么组成?当然有你的文档。还有当前的选区。而且还需要一种方式来存储「当前标记集已经改变」这一事实,比如你禁用或启用了某个标记,但还没有开始用那个标记输入。

这些是 ProseMirror 状态的三个主要组成部分,它们以 docselectionstoredMarks 的形式存在于状态对象上。

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

但插件也可能需要存储状态——例如,撤销历史必须保存它的变更历史。这就是为什么激活的插件集合也被存储在状态中,而这些插件可以定义额外的槽位来存储它们自己的状态。

选区(Selection)

ProseMirror 支持多种选区类型(并允许第三方代码定义新的选区类型)。选区由 Selection 类的(子类的)实例表示。像文档和其他与状态相关的值一样,它们是不可变的——要改变选区,你创建一个新的选区对象和一个新的状态来持有它。

选区至少有开始(.from)和结束(.to),作为指向当前文档的位置。许多选区类型还区分选区的锚点(anchor)(不可移动的)和头部(head)(可移动的)一侧,所以这些也要求在每一个选区对象上都存在。

最常见的选区类型是文本选区,它用于普通光标(当 anchorhead 相同时)或选中的文本。文本选区的两个端点都必须在行内位置,即指向允许行内内容的节点。

核心库还支持节点选区,即选中单个文档节点,例如当你 ctrl/cmd 点击一个节点时就会得到它。这种选区的范围从节点正前方的位置到节点正后方的位置。

事务(Transactions)

在正常编辑过程中,新的状态会从它之前的状态派生出来。在某些情况下,比如加载一个新文档时,你可能想创建一个全新的状态,但这是例外。

状态更新是通过把一个事务(transaction)应用到现有状态、从而产生一个新状态来完成的。从概念上讲,它们是一次性完成的:给定旧状态和事务,为状态的每个组成部分计算一个新值,然后把这些值组装成一个新的状态值。

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

TransactionTransform 的子类,继承了它通过把步骤应用到一个初始文档上来构建新文档的方式。除此之外,事务还跟踪选区和其他与状态相关的组成部分,并有一些与选区相关的便捷方法,比如 replaceSelection

创建事务最简单的方式是使用编辑器状态对象上的 tr getter。它基于那个状态创建一个空事务,然后你可以向其中添加步骤和其他更新。

默认情况下,旧选区会被映射穿过每个步骤来产生新选区,但也可以使用 setSelection 来显式设置一个新选区。

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

类似地,激活标记集会在文档或选区变化后自动清除,并可以使用 setStoredMarksensureMarks 方法来设置。

最后,scrollIntoView 方法可以用来确保下一次绘制状态时选区被滚动到可见区域。对于大多数用户操作,你可能都想这样做。

Transform 方法一样,许多 Transaction 方法返回事务本身,以便于链式调用。

插件(Plugins)

创建一个新状态时,你可以提供一个要使用的插件数组。这些插件会被存储在状态中,以及从它派生出的任何状态中,并且既会影响事务被应用的方式,也会影响基于这个状态的编辑器的行为。

插件是 Plugin的实例,可以建模各种各样的功能。最简单的插件只是给编辑器视图添加一些props,例如响应某些事件。更复杂的插件可能会给编辑器添加新状态,并根据事务更新它。

创建插件时,你传入一个对象来指定它的行为:

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 属性来定义:

let transactionCounter = new Plugin({
  state: {
    init() { return 0 },
    apply(tr, value) { return value + 1 }
  }
})

function getTransactionCount(state) {
  return transactionCounter.getState(state)
}

例子中的插件定义了一段非常简单的状态,它只是计数已经应用到某个状态上的事务数量。辅助函数使用插件的 getState 方法,它可以用来从完整的编辑器状态对象中获取插件状态。

因为编辑器状态是一个持久的(不可变的)对象,而插件状态是那个对象的一部分,所以插件状态值必须是不可变的。也就是说,如果它们需要改变,它们的 apply 方法必须返回一个新值,而不是修改旧值,而且其他任何代码都不应该修改它们。

插件常常需要给事务添加一些额外信息。例如,撤销历史在执行真正的撤销时,会给结果事务做标记,这样当插件看到它时,就不是做它通常对变更做的事(把变更加入撤销栈),而是特殊处理它:从撤销栈移除顶部项,并把该事务加入重做栈。

为此,事务允许给它们附加元数据(metadata)。我们可以更新我们的事务计数插件,让它不计数被标记的事务,像这样:

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。

View 组件(The view component)

ProseMirror 编辑器视图(editor view)是一个用户界面组件,它向用户显示一个编辑器状态,并允许用户对它执行编辑动作。

核心 view 组件对编辑动作的定义相当窄——它处理与编辑表面的直接交互,比如输入、点击、复制、粘贴和拖拽,但仅此而已。这意味着像显示菜单这样的事,甚至提供一整套按键绑定,都超出了核心 view 组件的职责范围,必须通过插件来安排。

可编辑 DOM(Editable DOM)

浏览器允许我们指定 DOM 的某些部分是可编辑的,其效果是允许它们获得焦点和选区,并且可以在其中输入。视图创建其文档的 DOM 表示(默认使用你 schema 中的 toDOM 方法),并使其可编辑。当可编辑元素获得焦点时,ProseMirror 确保 DOM 选区与编辑器状态中的选区一致。

它还为许多 DOM 事件注册了事件处理器,把这些事件翻译成相应的事务。例如,在粘贴时,粘贴的内容会被解析为 ProseMirror 文档切片,然后插入文档。

许多事件也会被原样放行,只是随后再根据 ProseMirror 的数据模型重新解释。例如,浏览器相当擅长光标和选区的放置(当你考虑双向文本时,这确实是个难题),所以大多数与光标移动相关的按键和鼠标动作都由浏览器处理,之后 ProseMirror 检查当前 DOM 选区对应的是哪种文本选区。如果那个选区与当前选区不同,就派发一个更新选区的事务。

甚至连输入通常也交给浏览器处理,因为干涉它往往会破坏拼写检查、某些移动界面上的自动首字母大写,以及其他原生功能。当浏览器更新 DOM 时,编辑器会注意到,重新解析文档中被改变的部分,并把差异转换成一个事务。

数据流(Data flow)

所以编辑器视图显示一个给定的编辑器状态,当发生某些事情时,它创建一个事务并广播它。然后,这个事务通常被用来创建一个新状态,这个新状态通过视图的 updateState 方法交给视图。

DOM event
EditorView
Transaction
new EditorState

这创建了一个直接、循环的数据流,而不是(JavaScript 世界里)经典的一大堆命令式事件处理器的方式,后者往往会产生复杂得多的数据流网络。

可以通过 dispatchTransaction prop 在事务被派发时「拦截」它们,以便把这个循环数据流接入一个更大的循环——如果你的整个应用都在使用这样的数据流模型,比如 Redux 和类似的架构,你可以把 ProseMirror 的事务集成到你的主 action 派发循环中,并把 ProseMirror 的状态保存在你应用的「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)
}

高效更新(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 }, // Enables read-only behavior
  handleDoubleClick() { console.log("Double click!") }
})

因此,当前的状态就是一个 prop。其他 props 的值也可以随时间变化,如果控制组件的代码更新了它们,但它们不被视为状态(state),因为组件本身不会改变它们。updateState 方法只是更新 state prop 的一种简写。

插件也允许声明 props,除了 statedispatchTransaction,它们只能直接提供给视图。

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 prop 返回值来创建,分为三种类型:

为了能够高效地绘制和比较装饰,它们需要以装饰集(decoration set)的形式提供(它是一种模仿实际文档树形结构的数据结构)。你使用静态的 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 prop 简单地返回插件状态,使装饰显示在视图中。

节点视图(Node views)

还有另一种方式可以影响编辑器视图绘制文档的方式。节点视图(Node views)让你可以为文档中的单个节点定义一种微型 UI 组件。它们让你渲染节点的 DOM、定义它们被更新的方式,并编写自定义代码来响应事件。

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 节点的事件。

你通常希望与节点的交互对文档中实际的节点产生某种效果。但要创建一个改变节点的事务,你首先需要知道那个节点在哪里。为了帮助做到这一点,节点视图会被传入一个 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 找到图片的当前位置,并给它一个带有新替代文本的新属性对象。

当一个节点被更新时,默认行为是保持它的外层 DOM 结构不变,并把它的子节点与新的子节点集比较,根据需要更新或替换它们。节点视图可以用自定义行为覆盖这一点,这让我们可以做一些事情,比如根据段落的内容改变它的 class。

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 属性,库会把节点的内容渲染到其中,并处理内容更新。如果你不提供,内容对编辑器来说就成了一个黑箱,如何显示它以及让用户如何与它交互完全取决于你。

在这个例子中,我们希望段落内容表现得像普通可编辑文本,所以 contentDOM 属性被定义为与 dom 属性相同,因为内容需要直接渲染到外层节点中。

魔法发生在 update 方法里。首先,这个方法负责决定节点视图是否能够被更新来显示新节点。当它不能时,应该返回 false。

例子中的 update 方法确保 "empty" class 根据新节点的内容而存在或不存在,并返回 true,表示更新成功(此时节点的内容将被更新)。

命令(Commands)

在 ProseMirror 的行话里,命令(command)是一个实现编辑动作的函数,用户可以通过按某个组合键或与菜单交互来执行它。

出于实际原因,命令有一个略微绕弯的接口。在它们的简单形式中,它们是接收一个编辑器状态和一个dispatch 函数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 交互——它们可能需要查询某个位置是否在 textblock 的末尾,或者想打开一个相对于视图定位的对话框。为此,大多数调用命令的插件会给它们第三个参数,即整个视图。

function blinkView(_state, dispatch, view) {
  if (dispatch) {
    view.dom.style.background = "yellow"
    setTimeout(() => view.dom.style.background = "", 1000)
  }
  return true
}

那个(相当没用的)例子表明命令不必派发事务——它们是为了副作用而被调用的,这个副作用通常是派发一个事务,但也可能是别的事情,比如弹出一个对话框。

prosemirror-commands 模块提供了许多编辑命令,从简单的(比如 deleteSelection 命令的变体)到相当复杂的(比如 joinBackward,它实现了在 textblock 开头按 backspace 时应该发生的块合并行为)。它还带有一个基础 keymap,把许多与 schema 无关的命令绑定到通常用于它们的按键上。

在可能的情况下,不同的行为,即使通常绑定到单个键,也会放在不同的命令中。工具函数 chainCommands 可以用来组合多个命令——它们会被一个接一个地尝试,直到有一个返回 true。

例如,基础 keymap 把 backspace 绑定到命令链 deleteSelection(在选区非空时生效)、joinBackward(当光标在 textblock 开头时)和 selectNodeBackward(选中选区前的节点,以防 schema 禁止常规的合并行为)。当这些都不适用时,允许浏览器运行它自己的 backspace 行为,这是在 textblock 内部退格删除内容的恰当做法(这样原生拼写检查之类的东西就不会被搞混)。

commands 模块还导出了许多命令构造函数,比如 toggleMark,它接受一个标记类型和可选的一组属性,并返回一个在当前选区上切换该标记的命令函数。

其他一些模块也导出命令函数——例如 history 模块的 undoredo。要自定义你的编辑器,或让用户与自定义文档节点交互,你很可能也想编写自己的自定义命令。

协同编辑(Collaborative editing)

实时协同编辑允许多人在同一时间编辑同一份文档。他们所做的更改会立即应用到本地文档,然后发送给对等方,对等方会自动合并这些更改(无需手动解决冲突),使编辑可以不间断地进行,文档也持续收敛。

本指南描述如何接上 ProseMirror 的协同编辑功能。

算法(Algorithm)

ProseMirror 的协同编辑系统采用一个中央权威(central authority)来决定更改以什么顺序应用。如果两个编辑器并发地做出更改,它们都会带着各自的更改去找这个权威。权威会接受其中一方的更改,并把这些更改广播给所有编辑器。另一方的更改不会被接受,当那个编辑器从服务器收到新的更改时,它必须把自己的本地更改rebase到来自另一个编辑器的更改之上,然后再次尝试提交。

权威(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

    // 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(这是一种让它稍后能识别哪些更改来自它自己的方式)。

当步骤被接受时,客户端会注意到,因为权威会通知它们有新步骤可用,然后给它们它们自己的步骤。在真实实现中,你也可以让 receiveSteps 返回一个状态,并立即确认已发送的步骤,作为一种优化。但这里使用的机制对于在不稳定连接上保证同步是必需的,所以你应该始终把它作为基础情形来使用。

这个权威实现维护一个不断增长的步骤数组,其长度表示它当前的版本。

collab 模块

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,而 dispatch 会调用我们的 dispatchTransaction 函数,代码就会再次尝试提交它的更改。

这就是全部了。当然,使用异步数据通道(比如collab 演示中的长轮询,或 web socket)时,你会需要更复杂的通信和同步代码。而且你很可能还希望你的权威在某个时刻开始丢弃步骤,这样它的内存消耗就不会无限增长。但总体方法已经被这个小例子完整地描述了。