可折叠节点

Folding Nodes

这个示例展示如何使用节点装饰(node decorations)来影响节点视图的行为。具体来说,我们会定义一个插件,允许用户折叠某些节点(隐藏它们的内容)。

我们先修改基础 schema,使顶层由一系列 section 组成,每个 section 必须包含一个标题,后面跟着一些任意的块。

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 属性时,它就认为自己是折叠状态,这体现在按钮上显示的箭头类型以及内容是被隐藏还是可见上。

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,我们稍后定义它。

对于这种功能,不使用装饰、只把折叠状态保存在节点视图的实例属性里,大体上也能工作。不过这种做法有两个缺点:第一,节点视图可能因为多种原因被重新创建(例如它的 DOM 被意外修改,或者视图更新算法把它关联到了错误的 section 节点上),从而导致其内部状态丢失。第二,在编辑器层面显式维护这类状态,使得你可以从编辑器外部影响它、检查它或序列化它。

因此,这里用插件来跟踪状态。这个插件的职责是跟踪折叠装饰的集合,并安装上面那个节点视图。

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 更新后的位置对齐。

然后它检查事务中是否包含指示它添加或移除某个折叠节点的元数据。我们用插件本身作为元数据标签。如果它存在,就会持有一个 {pos: number, fold: boolean} 对象。根据 fold 的值,代码会在给定位置添加或移除一个节点装饰。

setFolding 函数派发这类事务。此外,它会尽可能把选区移出被折叠的节点。

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 的编辑器。

(要让它们真正可用,你还需要某种命令来创建和合并 section,但这超出了本示例的范围。)