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