可折叠节点
Folding Nodes
这个示例展示如何使用节点装饰(node decorations)来影响节点视图的行为。具体来说,我们会定义一个插件,允许用户折叠某些节点(隐藏它们的内容)。
This example shows how to use node decorations to influence the behavior of node views. Specifically, we'll define a plugin that allows the user to fold some nodes (hiding their content).
我们先修改基础 schema,使顶层由一系列 section 组成,每个 section 必须包含一个标题,后面跟着一些任意的块。
We start by modifying the basic schema so that the top level consists of a sequence of sections, each of which must contain a heading followed by some arbitrary blocks.
import {Schema} from "prosemirror-model"
import {schema as basicSchema} from "prosemirror-schema-basic"
const schema = new Schema({
nodes: basicSchema.spec.nodes.append({
doc: {
content: "section+"
},
section: {
content: "heading block+",
parseDOM: [{tag: "section"}],
toDOM() { return ["section", 0] }
}
}),
marks: basicSchema.spec.marks
})
为了显示这些 section,我们会使用一个节点视图,它显示一个不可编辑的小标题,里面有一个按钮。它会检查自己收到的直接装饰,当其中某个装饰的 spec 里带有 foldSection 属性时,它就认为自己是折叠状态,这体现在按钮上显示的箭头类型以及内容是被隐藏还是可见上。
To display these sections, we'll use a node view that shows a little
uneditable header with a button in it. It looks through the direct
decorations that it receives, and when one of those has the
foldSection property in its spec, it considers itself folded, which
is reflected in the type of arrow shown on the button and whether the
content is hidden or visible.
class SectionView {
constructor(node, view, getPos, deco) {
this.dom = document.createElement("section")
this.header = this.dom.appendChild(document.createElement("header"))
this.header.contentEditable = "false"
this.foldButton = this.header.appendChild(document.createElement("button"))
this.foldButton.title = "Toggle section folding"
this.foldButton.onmousedown = e => this.foldClick(view, getPos, e)
this.contentDOM = this.dom.appendChild(document.createElement("div"))
this.setFolded(deco.some(d => d.spec.foldSection))
}
setFolded(folded) {
this.folded = folded
this.foldButton.textContent = folded ? "▿" : "▵"
this.contentDOM.style.display = folded ? "none" : ""
}
update(node, deco) {
if (node.type.name != "section") return false
let folded = deco.some(d => d.spec.foldSection)
if (folded != this.folded) this.setFolded(folded)
return true
}
foldClick(view, getPos, event) {
event.preventDefault()
setFolding(view, getPos(), !this.folded)
}
}
按钮的鼠标处理器只是调用 setFolding,我们稍后定义它。
The mouse handler for the button just calls setFolding, which we
will define in a moment.
对于这种功能,不使用装饰、只把折叠状态保存在节点视图的实例属性里,大体上也能工作。不过这种做法有两个缺点:第一,节点视图可能因为多种原因被重新创建(例如它的 DOM 被意外修改,或者视图更新算法把它关联到了错误的 section 节点上),从而导致其内部状态丢失。第二,在编辑器层面显式维护这类状态,使得你可以从编辑器外部影响它、检查它或序列化它。
It would mostly work to avoid using decorations for a feature like this, and just keep folding status in an instance property in the node view. There are two downsides to this approach, though: Firstly, node views may get recreated for a number of reasons (when their DOM gets unexpectedly mutated, or when the view update algorithm associates them with the wrong section node), which causes their internal state to be lost. Secondly, maintaining this kind of state explicitly on the editor level makes it possible to influence it from outside the editor, inspect it, or serialize it.
因此,这里用插件来跟踪状态。这个插件的职责是跟踪折叠装饰的集合,并安装上面那个节点视图。
Thus, here the state is tracked with a plugin. The role of this plugin is to track the set of folding decorations and to install the above node view.
import {Plugin} from "prosemirror-state"
import {Decoration, DecorationSet} from "prosemirror-view"
const foldPlugin = new Plugin({
state: {
init() { return DecorationSet.empty },
apply(tr, value) {
value = value.map(tr.mapping, tr.doc)
let update = tr.getMeta(foldPlugin)
if (update && update.fold) {
let node = tr.doc.nodeAt(update.pos)
if (node && node.type.name == "section")
value = value.add(tr.doc, [Decoration.node(update.pos, update.pos + node.nodeSize, {}, {foldSection: true})])
} else if (update) {
let found = value.find(update.pos + 1, update.pos + 1)
if (found.length) value = value.remove(found)
}
return value
}
},
props: {
decorations: state => foldPlugin.getState(state),
nodeViews: {section: (node, view, getPos, decorations) => new SectionView(node, view, getPos, decorations)}
}
})
这段代码的核心是状态更新方法。它首先把折叠装饰通过事务向前映射,使它们继续与 section 更新后的位置对齐。
The substance of this code is the state update method. It starts by mapping the fold decorations forward through the transaction, so that they continue to be aligned to the section's updated positions.
然后它检查事务中是否包含指示它添加或移除某个折叠节点的元数据。我们用插件本身作为元数据标签。如果它存在,就会持有一个 {pos: number, fold: boolean} 对象。根据 fold 的值,代码会在给定位置添加或移除一个节点装饰。
And then it checks whether the transaction contains metadata that
instructs it to add or remove a folded node. We use the plugin itself
as metadata label. If this is present, it will hold a {pos: number, fold: boolean} object. Depending on the value of fold, the code
adds or removes a node decoration at the given position.
setFolding 函数派发这类事务。此外,它会尽可能把选区移出被折叠的节点。
The setFolding function dispatches these kinds of transactions. In
addition, it makes sure to push the selection out of the folded node,
if possible.
import {Selection} from "prosemirror-state"
function setFolding(view, pos, fold) {
let section = view.state.doc.nodeAt(pos)
if (section && section.type.name == "section") {
let tr = view.state.tr.setMeta(foldPlugin, {pos, fold})
let {from, to} = view.state.selection, endPos = pos + section.nodeSize
if (from < endPos && to > pos) {
let newSel = Selection.findFrom(view.state.doc.resolve(endPos), 1) ||
Selection.findFrom(view.state.doc.resolve(pos), -1)
if (newSel) tr.setSelection(newSel)
}
view.dispatch(tr)
}
}
把这个插件和一个带 section 的 schema 一起加载,你就会得到一个带可折叠 section 的编辑器。
Loading this plugin alongside a schema that has sections will give you an editor with foldable sections.
(要让它们真正可用,你还需要某种命令来创建和合并 section,但这超出了本示例的范围。)
(To make them usable, you'd also need some kind of commands to create and join sections, but that is left out of the scope of this example.)