本文用到的名词解释可以参照 这个中文翻译 的开头部分, 本文与上述中文翻译内容相同,只是纯翻译,以及为了在 API 手册 中方便的引用该地址。

ProseMirror 中文指南

本指南介绍了在该库中使用的各种概念,以及它们是如何相互关联的。 为了让你对系统整体有一个印象, 推荐读者按本文的文档顺序阅读, 或者至少(如果你没有耐心而只是想大概了解的话),读完 View 组件的那一块。

介绍

ProseMirror 提供了一整套构建富文本编辑器的工具和概念,它使用的用户界面受 所见即所得 概念的启发, 但是尽量避免陷入它样式编辑的天坑。

Prosemirror 的基本概念是,你和你的代码对文档和文档的变化拥有绝对的控制权。 这里的文档不是 HTML 里的那一大坨杂乱无章的代码,而是一个只包含那些你明确指定允许它包含的元素和它们之间的你指定的关系的自定义数据结构(意思就是什么元素可以出现, 元素之间的关系,都在你的掌控之下——译者注)。所有的文档更新操作都从一个点出发,方便你对更新做处理。

Prosemirror 的核心模块并不是开箱即用的,在开发这个库的时候,我们坚持它的模块化和自定义程度的优先级高于简洁性。 当然,我们希望将来有人能开发一个基于 Prosemirror 的开箱即用的编辑器。这种感觉打个比喻来说就是,Prosemirror 是一个乐高积木, 拿到后需要你手动拼装,而不是像一个火柴盒一样,打开就能使用。

Prosemirror 有四个必要的模块,任何操作都需要这四个模块,另外还有很多 Prosemirror 核心团队维护的扩展模块, 它们(这些扩展模块)像一些提供了很多有用功能的第三方模块一样,都能被实现了相同功能的其他模块所取代。

上述的四个必要模块有:

除此之外,还有一些模块如 基本编辑命令快捷键绑定撤销历史宏命令协同编辑,和一个简单的文档 Schema等等。更多模块可以在 Github 上的 Prosemirror 组织 中发现。

注: 相应模块的中文版在 这里

Prosemirror 并不是一个浏览器可直接加载的脚本,这意味着你需要使用一些打包工具才能使用它。 打包工具就是一个自动寻找你脚本声明的依赖,然后合并它们到一个单独的脚本文件,以便你能够在浏览器中方便的加载它。 你可以自己去看看更多关于 Web 打包方面的东西,比如 这里

My first editor

下面的代码像乐高积木一样的摞在一起创建了一个最简单的编辑器:

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

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

Prosemirror 需要你手动指定一个 document 需要遵守的 Schema (来规定哪些元素能包含哪些不能包含以及元素之间的关系), 为了达成这个目的,上述代码做的第一件事就是先导入一个基本的 schema(通常情况下 schema 是你自己写的,这里作者拿了一个现成的包含基本元素的 schema 做示例——译者注)。

之后,这个基础 schema 被用来创建一个 state,该 state 会生成一个遵守 schema 约束的一个空的文档, 以及一个默认的选区在这个文档的开头(这个选区是空的,因此这里指的是光标)。最终,这个 state 会生成一个 view 被 append 到 document.body。 上述的 state 的文档最终将被渲染成一个可编辑的 DOM 节点(就是 contenteditable 的节点——译者注) 和一个会对用户输入做出反应的 state transaction。

(不幸的是)到目前为止这个编辑器还不能用. 例如, 如果你在刚刚的编辑器中按 Enter 键, 则什么也不会发生, 因为上述提到的四个核心模块并不知道输入 Enter 之后应该做什么, 我们将在稍后告诉它如何响应各种输入行为.

Transactions

当用户输入的时候, 或者更广泛的说, 当用户与页面的 view 进行交互的时候, prosemirror 会产生 ‘state transactions’. 这意味着每当用户输入后, prosemirror 不仅仅只修改 document 内容, 同时还会在背后更新 state. 也就是说, 每一个变化都会有一个 transaction 被创建, 它描述了 state 被应用的变化, 这些变化可以被用来创建一个新的 state, 然后这个新的 state 被用来更新 view.

默认情况下, 上述的这些变化是框架进行的, 你无需关注. 不过你可以通过写一个 plugins 或者自定义你的 view 的方式, 来往这个变化的过程中挂载一些 hook. 举个例子, 下面的代码增加了一个 dispatchTransactionprop, 它在每一个 transaction 被创建的时候调用:

// (Imports omitted)

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

每次 的 state 更新最终都需要执行 updateState 方法, 而且每 dispatching 一个 transaction 一般情况下都会触发一个编辑状态的更新.

Plugins

Plugins 被用来以多种不同的方式扩展编辑行为和编辑状态. 一些插件比较简单, 比如 keymap 插件, 它用来绑定键盘输入的 actions. 还有些插件相对复杂一点, 比如 history 插件, 它通过监视 transactions 和按照相反的顺序存储它们以便用户想要撤销 一个 transactions 来实现一个 undo/redo 的功能.

让我们先增加下面两个 plugin 以获得 undo/redo 的功能:

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

Plugins 会在创建 state 的时候被注册(因为它们需要访问 state 的 transactions 的权限). 在给这个可撤销/重做的 state 创建一个 view 之后, 你将能够通过按 Ctrl+Z(或者 Mac 下 Cmd+Z) 撤销上一步操作.

Commands

上面示例中, 被绑定到相关键盘按键的的特殊的函数叫做 commands. 大多数的编辑行为都会被写成 commands 的形式, 因此可以被绑定到特定的键上, 以供编辑菜单调用, 或者暴露给用户来操作.

prosemirror-commands 这个包提供了很多基本的编辑 commands, 包括在编辑器中按照你的期望映射 enter 和 delete 按键的行为.

// (忽略无关代码)
import {baseKeymap} from "prosemirror-commands"

let state = EditorState.create({
  schema,
  plugins: [
    history(),
    keymap({"Mod-z": undo, "Mod-y": redo}),
    keymap(baseKeymap)
  ]
})
let view = new EditorView(document.body, {state})

到此为止, 你应该有了一个基本能 work 的编辑器了.

如果还想增加一个菜单方便编辑操作, 或者想增加一些 schema 允许的按键绑定, 诸如此类的东西, 那么你可能想要看下 prosemirror-example-setup 这个包. 这个包提供了实现一个基本编辑器的一系列设置好的插件, 不过就像这个包名所表示的含义那样, 它仅仅是用来示例一些 API 的用法, 而不是一个可以用在生产环境的包. 对于一个真实的开发环境, 你可能想要用自己的代码替换其中的一些内容, 以精确实现你想要的效果.

Content

一个 state 的 document 对象存储在 doc 属性上, 它是一个只读类型的数据结构, 用一系列的不同层级的节点表示, 这些节点的层级结构有点类似于浏览器中的 DOM 节点. 一个简单的 document 可能有一个 “doc” 节点, 它包含两个 “paragraph” 节点, 每个 “prragraph” 节点又包含一个 “text” 节点. 你可以在 guide 中读到更多关于 document 数据结构的信息.

当初始化一个 state 的时候, 你可以传给它一个初始 document. 在这种情况下, schema 字段就是可选的, 因为 schema 可以从 document 中获取.

下面的示例我们通过 DOM 格式化的机制去格式化 DOM 中 id 为 “content” 的元素来初始化一个 state, 这个 state 使用的 schema 信息是由 DOM 节点格式化后映射到相应元素上获得的(意思就是 DOM 节点包含哪些元素, 格式化后被对应成 schema 的形式供 state 使用, 因此 schema 信息可以从格式化 DOM 的信息中获取而不用手动指定——译者注).

import {DOMParser} from "prosemirror-model"
import {EditorState} from "prosemirror-state"
import {schema} from "prosemirror-schema-basic"

let content = document.getElementById("content")
let state = EditorState.create({
  doc: DOMParser.fromSchema(schema).parse(content)
})

文档

Prosemirror 定义了它自己的 data structure 来表示 document 内容. 因为 document 是构建一个编辑器的核心元素, 因此理解 document 是如何工作的很有必要.

Structure

一个 Porsemirror 的 document 是一个 node 类型, 它含有一个 fragment 对象, fragment 对象又包含了 0 个或更多子 node.

这看起来很像 浏览器 DOM 结构, 因为 Prosemirror 跟 DOM 一样是递归的树状结构. 不过, Prosemirror 在存储内联元素的方式上跟 DOM 有点不同.

在 HTML 中, 一个 paragraph 及其中包含的标记, 表现形式就像一个树, 比如有以下 HTML 结构:

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

然而在 Prosemirror 中, 内联元素被表示成一个扁平的模型, 他们的节点标记被作为 metadata 信息附加到相应 node 上:

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

这种数据结构显然更符合我们心中的这类文本该有的样子. 它允许我们使用字符的偏移量而不是一个树节点的路径来表示其所处段落中的位置, 并且使一些诸如 splitting 内容或者改变内容 style 的操作变得很容易, 而不是以一种笨拙的树的操作来修改内容.

这也意味着, 每个 document 只有 一种 数据结构表示方式. 文本节点中相邻且相同的 marks 被合并在一起, 而且不允许空文本节点. marks 的顺序在 schema 中指定.

因此, 一个 Prosemirror document 就是一颗 block nodes 的树, 它的大多数 leaf nodes 是 textblock 类型, 该节点是包含 text 的 block nodes.你也可以有一些内容为空的简单的 leaf nodes, 比如一个水平分隔线 hr 元素, 或者一个 video 元素.

Node 对象有一系列属性来表示他在文档中的角色:

因此, 一个典型的 "paragraph" node 是一个 textblock 类型的节点, 然后一个 blockquote(引用元素)则是一个可能由其他 block 元素构成其内容的 block 元素. Text 节点, 回车, 和 inline 的 images 都是 inline leaf nodes, 而水平分隔线(hr 元素)节点是一个典型的 block leaf nodes.(leaf nodes 翻译成 叶节点, 表示其不能再含有子节点; leaf nodes 如上所说, 可能是 inline 的, 也可能是 block 的——译者注).

schema 允许你可以对诸如”哪些元素允许出现在哪些地方”这种问题指定更多的约束条件. 例如, 即使一个 node 允许 block content, 那也不意味着它允许 所有的 block nodes 作为 content(你可以通过 schema 手动指定例外——译者注).

Identity and persistence

DOM 树与 ProseMirror document 的另一个不同是他们对 nodes 对象的表示方式. 在 DOM 中, nodes 是带有 identity 的 mutable 对象(不知道 mutable 对象是啥的可以搜索下), 这意味着一个 node 只能出现在它的父级 node 下(如果它出现在别处, 那它在此处就没了, 因为有 identity, 所以唯一——译者注), 当这个 node 更新的时候, 它就 mutated 了(node 更新是在原来的 node上更新, 此谓之 mutated 即突变. 表示在原有基础上修改, 修改前后始终是一个对象——译者注).

而在 Prosemirror 中却不同, nodes 仅仅是 values(区别于 DOM 的 mutable, values 是 unmutable 的), 表示一个节点就像表示一个数字 3 一样. 3 可以同时出现在不同的数据结构中, 它不跟当前的数据结构绑定, 如果你对它增加 1, 你将会得到一个新的 value: 4 而不用对原始的 3 做任何修改.

所以这就是 Prosemirror document 的机制. 它的值不会改变, 而且可以被当做一个原始值去计算一个新的 document. 这些 document 的 nodes 们不知道它所处的数据结构是什么, 因为它们可以存在于多个结构中, 甚至可以在一个结构中重复多次. 它们是 values, 不是拥有状态的对象.

这意味着每次你更新 document, 你就会得到一个新的 document. 这个新的 document 共享旧的 document 的所有没有在这次更新中改变的子 nodes 的 value, 这让新建一个 document 变得很廉价.

这种机制有很多优点. 它让当 state 更新的时候编辑器始终可用, 因为新的 state 就代表了新的 document(如果更新未完成, 则 state 不会出现, 因此 document 也没有, 编辑器仍然是之前的 state + document——译者注), 新旧状态可以瞬间切换(而没有中间状态). 这种状态切换更可以以一种简单的数学推理的方式完成——而如果你的值在背后不断变化(指像 DOM 的节点一样突变——译者注), 这种推理将非常困难. Prosemirror 的这种机制使得协同编辑成为可能, 而且能够通过比较之前绘制在屏幕上的 document 和当前的 document 算法来非常高效的 update DOM.

因为 nodes 都被表示为正常的 JavaScript 对象, 而明确 freezing 他们的属性(防止 mutate)非常影响性能, 因此事实上虽然 Prosemirror 的 document 以一种非突变的机制运行, 但是你还是能够手动修改他们. 只是 Prosemirror 不支持这么做, 如果你强行 mutate 这些数据结构的话, 编辑器可能会崩溃, 因为这些数据结构总是在多处共享使用(修改一处, 影响其他你不知道的地方——译者注). 因此, 务必小心!!! 同时记住, 这个道理对一些 node 对象上存储的数组和对象同样适用, 比如 node attributes 对象, 或者存在 fragments 上的子 nodes.

Data structures

一个 document 的数据结构看起来像下面这样:

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

每个 node 都是一个 Node 类的实例. 它们用 type 属性进行归类, 通过 type 属性可以知道 node 的名字, 它可以使用的 attributes, 诸如此类的信息. Node types(和 mark types) 只会被每个 schema 创建一次, 它们知道自己是属于哪个 schema.

node 的 content 被存储在一个指向 Fragment 实例的字段上, 它的内容是一个 nodes 数组. 即使那些没有 content 或者不允许有 content 的 nodes 也是如此, 这些不许或没有 content 的节点被共享的 empty fragment 替代.

一些 nodes 类型允许有 attributes, 它们在每个 nodes 上以(不同于 content 的)额外的值存储着. 例如, 一个 image node 可能使用 attributes 存储 alt 文本信息和 URL 信息.

除此之外, inline nodes 含有一些激活的 marks——marks 就是指那些像 emphasis 或者 一个 link 的东西——它们被表示成 Mark 实例.

整个 document 都是一个 node. document 的 content 作为顶级 node 的子 nodes. 通常上来说, 这些顶级 node 的子 node 是一系列的 block nodes, 这些 block nodes 中有些可能包含 textblocks, 这些 textblocks 有包含 inline content. 不过, 顶级 node 也可以只是一个 textblock, 这样的话整个 document 就只包含 inline content.

哪些 node 被允许出现在哪些位置是由 document 的 schema 决定的. 为了用编程的方式(而不是直接对编辑器输入内容的方式——译者注)创建 nodes, 你必须遍历 schema, 比如下面的使用 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 nodes 支持两种类型的 indexing——它们既可以被当成树类型, 因为它们使用 offsets 来区别每个 nodes; 也可以被当成一个具有一系列 token 的扁平的结构(token 可以理解为一个计数单位).

第一种 index 允许你像在 DOM 中那样, 与单个 nodes 进行交互, 使用 child methodchildCount 直接访问 child nodes, 写递归函数去遍历 document(如果你想遍历所有的 nodes, 使用 descendantsnodesBetween).

第二种 index 当在文档定位一个指定的 position 的时候更有用. 它可以以一个整数表示文档中的任意位置——这个整数是 token 的顺序. 这些 token 对象在内存中其实并不存在——它们只是用来计数方便——不过 document 的树状结构以及每个 node 都知道它们自己的大小尺寸使得按位置访问它们变得廉价.

因此, 如果你有一个 document, 表示成 HTML 就像下面这样:

<p>One</p>
<blockquote><p>Two<img src="..."></p></blockquote>

Token 顺序和 position 则看起来像下面这样:

0   1 2 3 4    5
 <p> O n e </p>

5            6   7 8 9 10    11   12            13
 <blockquote> <p> T w o <img> </p> </blockquote>

每个 node 都有一个 nodeSize 属性表示整个 node 的尺寸大小, 你还可以通过 .content.size 获得 node 的 content 的尺寸大小. 需要注意的是对于 document 的外层节点(即 DOM 中 contenteditable 属性所处的节点, 是整个 document 的根节点——译者注)来说, 开始和关闭 token 不被认为是 document 的一部分(因为你无法将光标放到 document 的外面), 因此 document 的尺寸是 doc.content.size, 而不是 doc.nodeSize(虽然 document 的开关标签不被认为是 document 的一部分, 但是仍然计数. 后者始终比前者大2——译者注).

如果手动计算这些位置涉及到相当数量的计算工作. (因此)你可以通过调用 Node.resolve 来获得一个 position 的更多数据结构的描述. 这个 数据结构 将会告诉你当前 position 的父级 node 是什么, 它在父级 node 中的偏移量是多少, 它的父级 node 的祖先 nodes 有哪些, 和其他一些信息.

一定要注意区分子 node 的 index(比如每个 childCount), document 范围的 position, 和 node 的偏移(有时候这个偏移会用在一个递归函数表示当前处理的 node 的位置, 此时就涉及到 node 的偏移)之间的区别.

Slices

对于用户的复制粘贴和拖拽之类的操作, 涉及到一个叫做 slice of document 的概念(文档片段——译者注), 例如在两个 position 之间的 content 就是一个 slice. 这种 slice 与一个完整的 node 或者 fragment 不同, slice 可能是 “open”(意思即一个 slice 包含的标签可能没有关闭, 比如

123

456

中, 一个 slice 可能是 23

45 ——译者注).

例如, 如果你用光标选择从一个段落的中间到另一个段落的中间, 那么你选择的 slice 就是含有两个段落, 第一个在开始的地方 open, 第二个在结束的地方 open, 然后如果你使用接口(而不是通过与 view 交互——译者注)选择了一个段落 node, 那你就选择了一个 close 的 node. 如果对待 slice 像普通的 node content 一样的话, 它的 content 可能不符合 schema 的约束, 因为某些所需要的 nodes(如使 slice content 是一个完整的 node 的标签, 如上例中的开始部分的

和结束部分的

) 落在了 slice 之外.

Slice 数据结构就是被用来表示这种的数据的. 它存储了一个含有两侧 open depth (意思就是相对于根节点的层级深度——译者注)信息的 fragment. 你可以在 nodes 上使用 slice 方法 来从 document 上 “切” 出去一片 “slice”.

// 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

因为 nodes 和 fragment 是一种 持久化 的数据结构(意即 immutable ——译者注), 你绝对不应该直接修改他们. 如果你需要操作 document, 那么它就应该一直不变(操作后产生新的 document, 旧的 document 一直不变——译者注).

大多数情况下, 你需要使用 transformations 去更新 document 而不用直接修改 nodes. 这也方便留下一个变化的记录, 变化的记录对作为编辑器 state 一部分的 document 是必要的.

如果你非要去手动更新 document, Prosemirror 在 NodeFragment 上提供了一些有用的辅助函数去新建一个 document 的全新版本. 你可能会常常用到 Node.replace 方法, 该方法用一个含有新的 content 的 slice 替换指定 document 的 range 内的内容. 如果想要浅更新一个 node, 你可以使用 copy 方法, 该方法新建了一个相同的 node, 不过为这个相同的新 node 可以指定新的 content. Fragments 也有一些更新 document 的方法, 比如 replaceChildappend.

文档骨架

每个 Prosemirror document 都有一个与之相关的 schema. 这个 schema 描述了存在于 document 中的 nodes 类型, 和 nodes 们的嵌套关系. 例如, schema 可以规定, 顶级节点可以包含一个或者更多的 blocks, 同时段落 paragraph nodes 可以包含含有任意数量的 inline nodes, 这些 inline nodes 可以含有任意数量的 marks.

关于 schema 的用法, 这里有一个 basic schema 的包可以作为示例看一下, 不过 Prosemirror 有个比较棒的点在于它允许你定义你自己的 schemas.

Node Types

在 document 中的每个节点都有一个 type, 它代表了一个 node 的语义化上意思和 node 的属性, 这些属性包括在编辑器中的渲染方式.

当你定义一个 schema 的时候, 你需要列举每一个用到的 node types, 用一个 spec object 描述它们:

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

上述代码定义了一个允许 document 包含一个或更多 paragraphs 的 schema, 每个 paragraph 又能包含任意数量的 text.

每个 schema 至少得定义顶级 node 的 type(顶级 node 的名字默认是 “doc”, 不过你可以 configure 它), 和规定 text content 的 “text” type.

作为 inline 类型来计算 index 等的 nodes 必须声明它的 inline 属性(回想一下 text 类型, 它就被定义成 inline 了——这一点你可能忽略了)

Content Expressions

上面 schema 示例代码中的 content 字段的字符串值被叫做 content expressions. 他们控制着对于当前 type 的 node 来说, 哪些 child nodes 类型可用.

比如说, (content 字段的内容是) "paragraph" 意思就是 “一个 paragraph”, "paragraph+" 意思就是 “一个或者更多 paragraph”.与此相似, "paragraph*" 意思就是 “0 个或者更多 paragraph”, "caption?" 意思就是 “0 个或者 1 个 caption node”. 你也可以在 node 名字之后使用类似于正则表达式中表示范围含义的表达式, 比如 {2}(正好 2 个), {1, 5}(1 个到 5 个), 或者{2,}(两个或更多).

这种表达式可以被联合起来创建一个系列, 例如 "heading paragraph+" 表示 “开头一个 heading, 之后一个或更多 paragraphs”. 你也可以使用管道符号 | 操作符来表示在两个表达式中选择一个, 比如 "(paragraph | blockquote)+".

一些元素 type 的 group 可能在你的 schema 会出现多次, 比如你有一个 “block” 概念的 nodes, 他们可以出现在顶级元素之下, 也可以嵌套进 blockquote 类型的 node 内. 你可以通过指定 schema 的 group 属性来创建一个 node group, 然后在你的其他表达式中填 group 的名字即可:

const groupSchema = new Schema({
  nodes: {
    doc: {content: "block+"},
    paragraph: {group: "block", content: "text*"},
    blockquote: {group: "block", content: "block+"},
    text: {}
  }
})

上面示例中, "block+" 等价于 "(paragraph | blockquote)+".

建议在允许 block content 的 nodes(在示例中就是 "doc""blockquote")中设置为至少有一个 child node, 因为如果 node 为空的话浏览器将折叠它, 使它无法编辑(这句话的意思是, 如果上述 doc 或者 blockquote 的 content 设置为 block* 而不是 block+ 就表示允许不存在 child nodes 存在的情况(它沿用了通用的正则符号: * 表示0个或更多,

在 schema 中, nodes 的书写顺序很重要. 当对一个必选的 node 新建一个默认实例的时候, 比如在应用了一个 replace step 之后, 为了保持当前文档仍然符合 schema 的约束, 会使用能满足 schema 约束的第一个 node 的 expression. 如果 node 的 expression 是一个 group, 则这个 group 的第一个 node type(决定于当前 group 的成员 node 出现在 schema 的 nodes 中的顺序)将被使用. 如果我在上述的 schema 示例中调换了 "paragraph""blockquote" 的顺序, 当编辑器试图新建一个 block node 的时候将会报 stack overflow——因为编辑器会首先尝试新建一个 "blockquote" node, 但是这个 node 需要至少一个 block node, 于是它就首先又需要创建一个 "blockquote" node 作为内容, 以此往复.

不是每个 Prosemirror 库中的 node 操作函数都会检查它当前处理 content 的可用性——高级概念例如 transforms 会检查, 但是底层的 node 新建方法通常不会, 这些底层方法通常将可用性检查交给它们的调用者. 它们(即使当前操作的 content 不可用, 但是这些底层方法也)完全可能可用, 比如, NodeType.create, 它会创建一个含有不可用 content 的节点. 对于在一个 slices 的 “open” 一边的 node 而言, 这甚至是情有可原的(因为 slice 不是一个可用的节点, 但是又需要直接操作 slice ——总不能让用户手动补全吧?——译者注). 有一个 createChecked method 方法可以检查给定 content 是否符合 schema, 也有一个 check method 方法来 assert 给定的 content 是否可用.

Marks

Marks 通常被用来对 inline content 增加额外的样式和其他信息. schema 必须声明当前 document 允许的所有 schema(就像声明 nodes 那样——译者注). Mark types 是一个有点像 node types 的对象, 它用来给不同的 mark 分类和提供额外的信息.

默认情况下, 允许有 inline content 的 nodes 允许所有的定义在 schema 的 marks 应用于它的 child nodes. 你可以在 node spec 中的 marks 字段配置之.

下面是一个简单的 schema 示例, 支持在 paragraphs 中设置文本的 strong 和 emphasis marks, 不过 heading 则不允许设置这两种 marks.

const markSchema = new Schema({
  nodes: {
    doc: {content: "block+"},
    paragraph: {group: "block", content: "text*", marks: "_"},
    heading: {group: "block", content: "text*", marks: ""},
    text: {inline: true}
  },
  marks: {
    strong: {},
    em: {}
  }
})

marks 字段的值可以写成用逗号分隔开的 marks 名字, 或者 mark groups——"_", 它是通配符的意思, 允许所有的 marks. 空字符串表示不允许任何 marks.

Attributes

Document 的 schema 也定义了 node 和 mark 允许有哪些 attributes. 如果你的 node type 需要外的 node 专属的信息, 比如 heading node 的 level 信息(H1, H2等等——译者注), 此时适合使用 attribute.

Attribute 是一个普通的纯对象, 它有一些预先定义好的(在每个 node 或 mark 上)属性, 指向可以被 JSON 序列化的值. 为了指定哪些 attributes 被允许出现, 可以在 node spec 和 mark 的 spec 中使用可选的 attr 属性:

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

在上面这个 schema 中, 每个 heading node 实例都有一个 level 属性通过 .attrs.level 访问. 如果在 created heading 的时候没有指定, level 默认是 1.

如果你在定义 node 的时候没有给一个 attribute 默认值的话, 当新建这个 node 的时候, 如果没有显式传入 attribute 就会报错.

这也让 Prosemirror 在调用一些接口如 createAndFill 来生成满足 schema 约束的 node 的时候变得不可能. 这就是为什么你不能将这样的节点放到一个必须的位置,因为编辑器需要能够生成一个空的节点以填充缺失的内容部分。

Serialization and Parsing

为了能在浏览器中编辑元素, 就必须使 document nodes 以 DOM 的形式展示出来. 最简单的方式就是在 schema 中对每个 node 注明如何在 DOM 中显示. 这可以在 schema 的每个 node spec 中指定 toDOM 字段 来实现.

这个字段应该指向一个函数, 这个函数将当前 node 作为参数, 返回 node 的 DOM 结构描述. 这可以直接是一个 DOM node, 或者一个 array 描述它, 例如:

const schema = new Schema({
  nodes: {
    doc: {content: "paragraph+"},
    paragraph: {
      content: "text*",
      toDOM(node) { return ["p", 0] }
    },
    text: {}
  }
})

上面示例中, ["p", 0] 的含义是 paragraph 节点在 HTML 中被渲染成 <p> 标签. 0 代表一个 “hole”, 表示该 node 的内容应该被渲染的地方(意思就是如果这个节点预期是有内容的, 就应该在数组最后写上 0). 你也可以在标签后面加上一个对象表示 HTML 的 attributes, 例如 ["div", {class: "c"}, 0]. leaf nodes 不需要 “hole” 在它们的 DOM 中, 因为他们没有内容.

Mark 的 specs 有一个跟 nodes 相似的 toDOM 方法, 不同的是他们需要渲染成单独的标签去直接包裹着 content, 所以这些 content 直接在返回的 node 中, 所以上面的 “hole” 就不用专门指定了.

你也会经常 格式化 HTML DOM 的内容为 Prosemirror 识别的 document. 例如, 当用户粘贴或者拖拽东西到编辑器中的时候. Prosemirror-model 模块有些函数来处理这些事情, 不过你也应该有勇气在 schema 中的 parseDOM 属性 中直接包含如何格式化的信息.

这里列出了一组 parse rules, 描述了 DOM 如何映射成 node 或者 mark. 例如, 基本的 schema 对于 emphasis mark 写成下面这样:

  parseDOM: [
    {tag: "em"},                 // Match <em> nodes
    {tag: "i"},                  // and <i> nodes
    {style: "font-style=italic"} // and inline 'font-style: italic'
  ]

上面中的 parse rule 的 tag 字段也可以是一个 CSS selector, 所以你也可以传入类似于 "div.myclass" 这种的字符串. 与此相似, style 字段匹配行内 CSS 样式.

当一个 schema 包含 parseDOM 字段时, 你可以使用 DOMParser.fromSchema 创建一个 DOMParser 对象. 编辑器在新建默认的剪切板内容 parser 的时候就是这么干的, 不过你可以 override 它.

Document 也有一个内置的 JSON 序列化方式. 你可以在 node 上调用 toJSON 来生成一个可以安全地传给 JSON.stringify 函数的对象(感觉这个目的是为了方便调试?——译者注), 此外 schema 对象有一个 nodeFromJSON 方法 可以将 toJSON 的结果再转回原始的 node.

Extending a schema

传给 Schema constructor 构造器来设置 nodesmarks 选项的参数可以是 OrderedMap objects 类型的对象, 也可以是纯 JavaScript 对象. 生成的 schema 上的 spec.nodes.spec.marks 属性则总是 OrderedMaps, 它可以被用来作为其他 schemes 的基础.

OrderedMaps 这种 map 支持很多方法去方便的新建新的 schema. 比如, 你可以通过调用 schema.markSpec.remove("blockquote") 后, 将调用结果传给 Schema 构造器的参数的 nodes 字段, 来生成一个没有 blockquote node 的 schema.

schema-list 模块导出了一个 很方便的方法 以添加由该模块导出的 nodes 到一个 node 集合中。

文档转换

Transforms 是 Prosemirror 的核心工作方式. 它是 transactions 的基础, 其使得编辑历史跟踪和协同编辑成为可能.

Why?

为什么我们不能直接对 document 进行修改(突变 mutate)? 或者至少新建一个全新版本的 document 然后将其放到编辑器中去呢?

有好几个原因. 其中之一就是代码清晰度. Immutable 数据结构确实可以造就简单的代码. 而且 transform 系统做的主要工作就是保留了 document 更新的 痕迹, transform 的一系列值代表了从旧的 document 到新的 document 的每一个 steps 记录.

undo history 可以保存这些 steps 然后在需要的时候反过来应用这些 steps ( Prosemirror 实现了可选择的 undo, 这比仅仅回滚之前的 state 状态更为复杂)

collaborative editing (协同编辑)系统发送这些 steps, 并在必要的时候记录这些 steps, 以便每个 document 编辑者都能够有相同的 document.

在大多数情况下, 能够对每个 document 改变(无论是来自自己还是来自协同编辑)做出相应反应对 editor plugin 来说是很有用的, 这始终能够让插件保持与 editor 的 state 同样的状态.

Steps

对于 document 的更新会被分解成一个个的 steps, 它描述了一个更新. 你一般情况下不需要直接与它打交道, 不过知道它们如何工作的原理是很有必要的.

Steps 的一个例子就是 ReplaReplaceStepceStep, 它可以替换 document 的一小部分, 或者 AddMarkStep, 可以对一个 range 应用 Mark.

一个 Step 可以被 applied 到一个 document, 然后产生一个新的 document

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")

应用一个 step 想对来说是比较简单的过程——它不做一些诸如插入 nodes 以保持 schema 的约束, 或者转换 slice 让其去适应 schema 之类的操作. 这意味着应用一个 setp 可能会失败. 比如如果你试图删除一个 node 的其中一个 token(就是一个 node 的开或关标签——译者注), 这将会使该 node 的另一个 token 未正确关闭, 这么做对你来说是没什么意义的. 这也就是为什么 apply 方法返回一个 result object, (如果 step apply 成功则)保持对新的 document 的引用, 或者(失败的时候)包含一个错误信息.

你通常想要让 helper functions 去为你生成 steps, 这样你就不用担心一些细节.

Transforms

An editing action may produce one or more steps. The most convenient way to work with a sequence of steps is to create a Transform object (or, if you're working with a full editor state, a Transaction, which is a subclass of Transform).

let tr = new Transform(myDoc)
tr.delete(5, 7) // 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

Most transform methods return the transform itself, for convenient chaining (allowing you to do tr.delete(5, 7).split(5)).

There are transform methods for deleting and replacing, for adding and removing marks, for performing tree manipulation like splitting, joining, lifting, and wrapping, and more.

Mapping

When you make a change to a document, positions pointing into that document may become invalid or change meaning. For example, if you insert a character, all positions after that character now point one token before their old position. Similarly, if you delete all the content in a document, all positions pointing into that content are now invalid.

We often do need to preserve positions across document changes, for example the selection boundaries. To help with this, steps can give you a map that can convert between positions in the document before and after applying the step.

let step = new ReplaceStep(4, 6, Slice.empty) // Delete 4-5
let map = step.getMap()
console.log(map.map(8)) // → 6
console.log(map.map(2)) // → 2 (nothing changes before the change)

Transform objects automatically accumulate a set of maps for the steps in them, using an abstraction called Mapping, which collects a series of step maps and allows you to map through them in one go.

let tr = new Transaction(myDoc)
tr.split(10)    // 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

There are cases where it's not entirely clear what a given position should be mapped to. Consider the last line of the example above. Position 10 points precisely at the point where we split a node, inserting two tokens. Should it be mapped to the position after the inserted content, or stay in front of it? In the example, it is apparently moved after the inserted tokens.

But sometimes you want the other behavior, which is why the map method on step maps and mappings accepts a second parameter, bias, which you can set to -1 to keep your position in place when content is inserted on top of it.

console.log(tr.mapping.map(10, -1)) // → 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.

Rebasing

When doing more complicated things with steps and position maps, for example to implement your own change tracking, or to integrate some feature with collaborative editing, you might run into the need to rebase steps.

You might not want to bother studying this until you are sure you need it.

Rebasing, in the simple case, is the process of taking two steps that start with the same document, and transform one of them so that it can be applied to the document created by the other instead. In pseudocode:

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

Steps have a map method, which, given a mapping, maps the whole step through it. This can fail, since some steps don't make sense anymore when, for example, the content they applied to has been deleted. But when it succeeds, you now have a step pointing into a new document, i.e. the one after the changes that you mapped through. So in the above example, rebase(stepB, mapA) can simply call stepB.map(mapA).

Things get more complicated when you want to rebase a chain of steps over another chain of steps.

stepA2(stepA1(doc)) = docA
stepB2(stepB1(doc)) = docB
???(docA) = docAB

We can map stepB1 over stepA1 and then stepA2, to get stepB1'. But with stepB2, which starts at the document produced by stepB1(doc), and whose mapped version must apply to the document produced by stepB1'(docA), things get more difficult. It must be mapped over the following chain of maps:

rebase(stepB2, [invert(mapB1), mapA1, mapA2, mapB1'])

I.e. first the inverse of the map for stepB1 to get back to the original document, then through the pipeline of maps produced by applying stepA1 and stepA2, and finally through the map produced by applying stepB1' to docA.

If there was a stepB3, we'd get the pipeline for that one by taking the one above, prefixing it with invert(mapB2) and adding mapB2' to the end. And so on.

But when stepB1 inserted some content, and stepB2 did something to that content, then mapping stepB2 through invert(mapB1) will return null, because the inverse of stepB1 deletes the content to which it applies. However, this content is reintroduced later in the pipeline, by mapB1. The Mapping abstraction provides a way to track such pipelines, including the inverse relations between the maps in it. You can map steps through it in such a way that they survive situations like the one above.

Even if you have rebased a step, there is no guarantee that it can still be validly applied to the current document. For example, if your step adds a mark, but another step changed the parent node of your target content to be a node that doesn't allow marks, trying to apply your step will fail. The appropriate response to this is usually just to drop the step.

编辑器状态

What makes up the state of an editor? You have your document, of course. And also the current selection. And there needs to be a way to store the fact that the current set of marks has changed, when you for example disable or enable a mark but haven't started typing with that mark yet.

Those are the three main components of a ProseMirror state, and exist on state objects as doc, selection, and storedMarks.

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

let state = EditorState.create({schema})
console.log(state.doc.toString()) // 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.

Selection

ProseMirror supports several types of selection (and allows 3rd-party code to define new selection types). Selections are represented by instances of (subclasses of) the Selection class. Like documents and other state-related values, they are immutable—to change the selection, you create a new selection object and a new state to hold it.

Selections have, at the very least, a start (.from) and an end (.to), as positions pointing into the current document. Many selection types also distinguish between the anchor (unmoveable) and head (moveable) side of the selection, so those are also required to exist on every selection object.

The most common type of selection is a text selection, which is used for regular cursors (when anchor and head are the same) or selected text. Both endpoints of a text selection are required to be in inline positions, i.e. pointing into nodes that allow inline content.

The core library also supports node selections, where a single document node is selected, which you get, for example, when you ctrl/cmd-click a node. Such a selection ranges from the position directly before the node to the position directly after it.

Transactions

During normal editing, new states will be derived from the state before them. You may in some situations, such as loading a new document, want to create a completely new state, but this is the exception.

State updates happen by applying a transaction to an existing state, producing a new state. Conceptually, they happen in a single shot: given the old state and the transaction, a new value is computed for each component of the state, and those are put together in a new state value.

let tr = state.tr
console.log(tr.doc.content.size) // 25
tr.insertText("hello") // Replaces selection with 'hello'
let newState = state.apply(tr)
console.log(tr.doc.content.size) // 30

Transaction is a subclass of Transform, and inherits the way it builds up a new document by applying steps to an initial document. In addition to this, transactions track selection and other state-related components, and get some selection-related convenience methods such as replaceSelection.

The easiest way to create a transaction is with the tr getter on an editor state object. This creates an empty transaction based on that state, to which you can then add steps and other updates.

By default, the old selection is mapped through each step to produce a new selection, but it is possible to use setSelection to explicitly set a new selection.

let tr = state.tr
console.log(tr.selection.from) // → 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

Similarly, the set of active marks is automatically cleared after a document or selection change, and can be set using the setStoredMarks or ensureMarks methods.

Finally, the scrollIntoView method can be used to ensure that, the next time the state is drawn, the selection is scrolled into view. You probably want to do that for most user actions.

Like Transform methods, many Transaction methods return the transaction itself, for convenient chaining.

Plugins

When creating a new state, you can provide an array of plugins to use. These will be stored in the state and any state that is derived from it, and can influence both the way transactions are applied and the way an editor based on this state behaves.

Plugins are instances of the Plugin class, and can model a wide variety of features. The simplest ones just add some props to the editor view, for example to respond to certain events. More complicated ones might add new state to the editor and update it based on transactions.

When creating a plugin, you pass it an object specifying its behavior:

let myPlugin = new Plugin({
  props: {
    handleKeyDown(view, event) {
      console.log("A key was pressed!")
      return false // We did not handle this
    }
  }
})

let state = EditorState.create({schema, plugins: [myPlugin]})

When a plugin needs its own state slot, that is defined with a state property:

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

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

The plugin in the example defines a very simple piece of state that simply counts the number of transactions that have been applied to a state. The helper function uses the plugin's getState method, which can be used to fetch the plugin state from a full editor state object.

Because the editor state is a persistent (immutable) object, and plugin state is part of that object, plugin state values must be immutable. I.e. their apply method must return a new value, rather than changing the old, if they need to change, and no other code should change them.

It is often useful for plugins to add some extra information to a transaction. For example, the undo history, when performing an actual undo, will mark the resulting transaction, so that when the plugin sees it, instead of doing the thing it normally does with changes (adding them to the undo stack), it treats it specially, removing the top item from the undo stack and adding this transaction to the redo stack instead.

For this purpose, transactions allow metadata to be attached to them. We could update our transaction counter plugin to not count transactions that are marked, like this:

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

function markAsUncounted(tr) {
  tr.setMeta(transactionCounter, true)
}

Keys for metadata properties can be strings, but to avoid name collisions, you are encouraged to use plugin objects. There are some string keys that are given a meaning by the library, for example "addToHistory" can be set to false to prevent a transaction from being undoable, and when handling a paste, the editor view will set the "paste" property on the resulting transaction to true.

视图组件

A ProseMirror editor view is a user interface component that displays an editor state to the user, and allows them to perform editing actions on it.

The definition of editing actions used by the core view component is rather narrow—it handles direct interaction with the editing surface, such as typing, clicking, copying, pasting, and dragging, but not much beyond that. This means that things like displaying a menu, or even providing a full set of key bindings, lie outside of the responsibility of the core view component, and have to be arranged through plugins.

Editable DOM

Browsers allow us to specify that some parts of the DOM are editable, which has the effect of allowing focus and a selection in them, and making it possible to type into them. The view creates a DOM representation of its document (using your schema's toDOM methods by default), and makes it editable. When the editable element is focused, ProseMirror makes sure that the DOM selection corresponds to the selection in the editor state.

It also registers event handlers for many DOM events, which translate the events into the appropriate transactions. For example, when pasting, the pasted content is parsed as a ProseMirror document slice, and then inserted into the document.

Many events are also let through as they are, and only then reinterpreted in terms of ProseMirror's data model. The browser is quite good at cursor and selection placement for example (which is a really difficult problem when you factor in bidirectional text), so most cursor-motion related keys and mouse actions are handled by the browser, after which ProseMirror checks what kind of text selection the current DOM selection would correspond to. If that selection is different from the current selection, a transaction that updates the selection is dispatched.

Even typing is usually left to the browser, because interfering with that tends to break spell-checking, autocapitalizing on some mobile interfaces, and other native features. When the browser updates the DOM, the editor notices, re-parses the changed part of the document, and translates the difference into a transaction.

Data flow

So the editor view displays a given editor state, and when something happens, it creates a transaction and broadcasts this. This transaction is then, typically, used to create a new state, which is given to the view using its updateState method.

DOM event
EditorView
Transaction
new EditorState

This creates a straightforward, cyclic data flow, as opposed to the classic approach (in the JavaScript world) of a host of imperative event handlers, which tends to create a much more complex web of data flows.

It is possible to ‘intercept’ transactions as they are dispatched with the dispatchTransaction prop, in order to wire this cyclic data flow into a larger cycle—if your whole app is using a data flow model like this, as with Redux and similar architectures, you can integrate ProseMirror's transactions in your main action-dispatching cycle, and keep ProseMirror's state in your application ‘store’.

// 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

One way to implement updateState would be to simply redraw the document every time it is called. But for large documents, that would be really slow.

Since, at the time of updating, the view has access to both the old document and the new, it can compare them, and leave the parts of the DOM that correspond to unchanged nodes alone. ProseMirror does this, allowing it to do very little work for typical updates.

In some cases, like updates that correspond to typed text, which was already added to the DOM by the browser's own editing actions, ensuring the DOM and state are coherent doesn't require any DOM changes at all. (When such a transaction is canceled or modified somehow, the view will undo the DOM change to make sure the DOM and the state remain synchronized.)

Similarly, the DOM selection is only updated when it is actually out of sync with the selection in the state, to avoid disrupting the various pieces of ‘hidden’ state that browsers keep along with the selection (such as that feature where when you arrow down or up past a short line, you horizontal position goes back to where it was when you enter the next long line).

Props

‘Props’ is a useful, if somewhat vague, term taken from React. Props are like parameters to a UI component. Ideally, the set of props that the component gets completely defines its behavior.

let view = new EditorView({
  state: myState,
  editable() { return false }, // Enables read-only behavior
  handleDoubleClick() { console.log("Double click!") }
})

As such, the current state is one prop. The value of other props can also vary over time, if the code that controls the component updates them, but aren't considered state, because the component itself won't change them. The updateState method is just a shorthand to updating the state prop.

Plugins are also allowed to declare props, except for state and dispatchTransaction, which can only be provided directly to the view.

function maxSizePlugin(max) {
  return new Plugin({
    props: {
      editable(state) { return state.doc.content.size < max }
    }
  })
}

When a given prop is declared multiple times, how it is handled depends on the prop. In general, directly provided props take precedence, after which each plugin gets a turn, in order. For some props, such as domParser, the first value that is found is used, and others are ignored. For handler functions that return a boolean to indicate whether they handled the event, the first one that returns true gets to handle the event. And finally, for some props, such as attributes (which can be used to set attributes on the editable DOM node) and decorations (which we'll get to in the next section), the union of all provided values is used.

Decorations

Decorations give you some control over the way the view draws your document. They are created by returning values from the decorations prop, and come in three types:

In order to be able to efficiently draw and compare decorations, they need to be provided as a decoration set (which is a data structure that mimics the tree shape of the actual document). You create one using the static create method, providing the document and an array of decoration objects:

let purplePlugin = new Plugin({
  props: {
    decorations(state) {
      return DecorationSet.create(state.doc, [
        Decoration.inline(0, state.doc.content.size, {style: "color: purple"})
      ])
    }
  }
})

When you have a lot of decorations, recreating the set on the fly for every redraw is likely to be too expensive. In such cases, the recommended way to maintain your decorations is to put the set in your plugin's state, map it forward through changes, and only change it when you need to.

let specklePlugin = new Plugin({
  state: {
    init(_, {doc}) {
      let speckles = []
      for (let pos = 1; pos < doc.content.size; pos += 4)
        speckles.push(Decoration.inline(pos - 1, pos, {style: "background: yellow"}))
      return DecorationSet.create(doc, speckles)
    },
    apply(tr, set) { return set.map(tr.mapping, tr.doc) }
  },
  props: {
    decorations(state) { return specklePlugin.getState(state) }
  }
})

This plugin initializes its state to a decoration set that adds a yellow-background inline decoration to every 4th position. That's not terribly useful, but sort of resembles use cases like highlighting search matches or annotated regions.

When a transaction is applied to the state, the plugin state's apply method maps the decoration set forward, causing the decorations to stay in place and ‘fit’ the new document shape. The mapping method is (for typical, local changes) made efficient by exploiting the tree shape of the decoration set—only the parts of the tree that are actually touched by the changes need to be rebuilt.

(In a real-world plugin, the apply method would also be the place where you add or remove decorations based on new events, possibly by inspecting the changes in the transaction, or based on plugin-specific metadata attached to the transaction.)

Finally, the decorations prop simply returns the plugin state, causing the decorations to show up in the view.

Node views

There is one more way in which you can influence the way the editor view draws your document. Node views make it possible to define a sort of miniature UI components for individual nodes in your document. They allow you to render their DOM, define the way they are updated, and write custom code to react to events.

let view = new EditorView({
  state,
  nodeViews: {
    image(node) { return new ImageView(node) }
  }
})

class ImageView {
  constructor(node) {
    // 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 }
}

The view object that the example defines for image nodes creates its own custom DOM node for the image, with an event handler added, and declares, with a stopEvent method, that ProseMirror should ignore events coming from that DOM node.

You'll often want interaction with the node to have some effect on the actual node in the document. But to create a transaction that changes a node, you first need to know where that node is. To help with that, node views get passed a getter function that can be used to query their current position in the document. Let's modify the example so that clicking on the node queries you to enter an alt text for the image:

let view = new EditorView({
  state,
  nodeViews: {
    image(node, view, getPos) { return new ImageView(node, view, getPos) }
  }
})

class ImageView {
  constructor(node, view, getPos) {
    this.dom = document.createElement("img")
    this.dom.src = node.attrs.src
    this.dom.alt = node.attrs.alt
    this.dom.addEventListener("click", e => {
      e.preventDefault()
      let alt = prompt("New alt text:", "")
      if (alt) view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, {
        src: node.attrs.src,
        alt
      }))
    })
  }

  stopEvent() { return true }
}

setNodeMarkup is a method that can be used to change the type or set of attributes for the node at a given position. In the example, we use getPos to find our image's current position, and give it a new attribute object with the new alt text.

When a node is updated, the default behavior is to leave its outer DOM structure intact and compare its children to the new set of children, updating or replacing those as needed. A node view can override this with custom behavior, which allows us to do something like changing the class of a paragraph based on its content.

let view = new EditorView({
  state,
  nodeViews: {
    paragraph(node) { return new ParagraphView(node) }
  }
})

class ParagraphView {
  constructor(node) {
    this.dom = this.contentDOM = document.createElement("p")
    if (node.content.size == 0) this.dom.classList.add("empty")
  }

  update(node) {
    if (node.type.name != "paragraph") return false
    if (node.content.size > 0) this.dom.classList.remove("empty")
    else this.dom.classList.add("empty")
    return true
  }
}

Images never have content, so in our previous example, we didn't need to worry about how that would be rendered. But paragraphs do have content. Node views support two approaches to handling content: you can let the ProseMirror library manage it, or you can manage it entirely yourself. If you provide a contentDOM property, the library will render the node's content into that, and handle content updates. If you don't, the content becomes a black box to the editor, and how you display it and let the user interact with it is entirely up to you.

In this case, we want paragraph content to behave like regular editable text, so the contentDOM property is defined to be the same as the dom property, since the content needs to be rendered directly into the outer node.

The magic happens in the update method. Firstly, this method is responsible for deciding whether the node view can be updated to show the new node at all. This new node may be anything that the editor's update algorithm might try to draw here, so you must verify that this is a node that this node view can handle.

The update method in the example first checks whether the new node is a paragraph, and bails out if that's not the case. Then it makes sure that the "empty" class is present or absent, depending on the content of the new node, and returns true, to indicate that the update succeeded (at which point the node's content will be updated).

命令

In ProseMirror jargon, a command is a function that implements an editing action, which the user can perform by pressing some key combination or interacting with the menu.

For practical reasons, commands have a slightly convoluted interface. In their simple form, they are functions taking an editor state and a dispatch function (EditorView.dispatch or some other function that takes transactions), and return a boolean. Here's a very simple example:

function deleteSelection(state, dispatch) {
  if (state.selection.empty) return false
  dispatch(state.tr.deleteSelection())
  return true
}

When a command isn't applicable, it should return false and do nothing. When it is, it should dispatch a transaction and return true. This is used, for example, by the keymap plugin to stop further handling of key events when the command bound to that key has been applied.

To be able to query whether a command is applicable for a given state, without actually executing it, the dispatch argument is optional—commands should simply return true without doing anything when they are applicable but no dispatch argument is given. So the example command should actually look like this:

function deleteSelection(state, dispatch) {
  if (state.selection.empty) return false
  if (dispatch) dispatch(state.tr.deleteSelection())
  return true
}

To figure out whether a selection can currently be deleted, you'd call deleteSelection(view.state, null), whereas to actually execute the command, you'd do something like deleteSelection(view.state, view.dispatch). A menu bar could use this to determine which menu items to gray out.

In this form, commands do not get access to the actual editor view—most commands don't need that, and in this way they can be applied and tested in settings that don't have a view available. But some commands do need to interact with the DOM—they might need to query whether a given position is at the end of a textblock, or want to open a dialog positioned relative to the view. For this purpose, most plugins that call commands will give them a third argument, which is the whole view.

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

That (rather useless) example shows that commands don't have to dispatch a transaction—they are called for their side effect, which is usually to dispatch a transaction, but may also be something else, such as popping up a dialog.

The prosemirror-commands module provides a number of editing commands, from simple ones such as a variant of the deleteSelection command, to rather complicated ones such as joinBackward, which implements the block-joining behavior that should happen when you press backspace at the start of a textblock. It also comes with a basic keymap that binds a number of schema-agnostic commands to the keys that are usually used for them.

When possible, different behavior, even when usually bound to a single key, is put in different commands. The utility function chainCommands can be used to combine a number of commands—they will be tried one after the other until one return true.

For example, the base keymap binds backspace to the command chain deleteSelection (which kicks in when the selection isn't empty), joinBackward (when the cursor is at the start of a textblock), and selectNodeBackward (which selects the node before the selection, in case the schema forbids the regular joining behavior). When none of these apply, the browser is allowed to run its own backspace behavior, which is the appropriate thing for backspacing things out inside a textblock (so that native spell-check and such don't get confused).

The commands module also exports a number of command constructors, such as toggleMark, which takes a mark type and optionally a set of attributes, and returns a command function that toggles that mark on the current selection.

Some other modules also export command functions—for example undo and redo from the history module. To customize your editor, or to allow users to interact with custom document nodes, you'll likely want to write your own custom commands as well.

协同编辑

Real-time collaborative editing allows multiple people to edit the same document at the same time. Changes they make are applied immediately to their local document, and then sent to peers, which merge in these changes automatically (without manual conflict resolution), so that editing can proceed uninterrupted, and the documents keep converging.

This guide describes how to wire up ProseMirror's collaborative editing functionality.

Algorithm

ProseMirror's collaborative editing system employs a central authority which determines in which order changes are applied. If two editors make changes concurrently, they will both go to this authority with their changes. The authority will accept the changes from one of them, and broadcast these changes to all editors. The other's changes will not be accepted, and when that editor receives new changes from the server, it'll have to rebase its local changes on top of those from the other editor, and try to submit them again.

The Authority

The role of the central authority is actually rather simple. It must...

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

When an editor wants to try and submit their changes to the authority, they can call receiveSteps on it, passing the last version number they received, along with the new changes they added, and their client ID (which is a way for them to later recognize which changes came from them).

When the steps are accepted, the client will notice because the authority notifies them that new steps are available, and then give them their own steps. In a real implementation, you could also have receiveSteps return a status, and immediately confirm the sent steps, as an optimization. But the mechanism used here is necessary to guarantee synchronization on unreliable connections, so you should always use it as the base case.

This implementation of an authority keeps an endlessly growing array of steps, the length of which denotes its current version.

The collab Module

The collab module exports a collab function which returns a plugin that takes care of tracking local changes, receiving remote changes, and indicating when something has to be sent to the central authority.

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

function collabEditor(authority, place) {
  let view = new EditorView(place, {
    state: EditorState.create({
      doc: authority.doc,
      plugins: [collab.collab({version: authority.steps.length})]
    }),
    dispatchTransaction(transaction) {
      let newState = view.state.apply(transaction)
      view.updateState(newState)
      let sendable = collab.sendableSteps(newState)
      if (sendable)
        authority.receiveSteps(sendable.version, sendable.steps,
                               sendable.clientID)
    }
  })

  authority.onNewSteps.push(function() {
    let newData = authority.stepsSince(collab.getVersion(view.state))
    view.dispatch(
      collab.receiveTransaction(view.state, newData.steps, newData.clientIDs))
  })

  return view
}

The collabEditor function creates an editor view that has the collab plugin loaded. Whenever the state is updated, it checks whether there is anything to send to the authority. If so, it sends it.

It also registers a function that the authority should call when new steps are available, and which creates a transaction that updates our local editor state to reflect those steps.

When a set of steps gets rejected by the authority, they will remain unconfirmed until, supposedly soon after, we receive new steps from the authority. After that happens, because the onNewSteps callback calls dispatch, which will call our dispatchTransaction function, the code will try to submit its changes again.

That's all there is to it. Of course, with asynchronous data channels (such as long polling in the collab demo or web sockets), you'll need somewhat more complicated communication and synchronization code. And you'll probably also want your authority to start throwing away steps at some point, so that its memory consumption doesn't grow without bound. But the general approach is fully described by this little example.