Folding Nodes
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).
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
})
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)
}
}
The mouse handler for the button just calls setFolding
, which we
will define in a moment.
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)}
}
})
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.
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.
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)
}
}
Loading this plugin alongside a schema that has sections will give you
an editor with foldable sections.
(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.)