编辑脚注

Editing footnotes

这个示例演示了在 ProseMirror 中实现类似脚注功能的一种方式。

脚注看起来应该是带内容的行内节点——它们出现在其他行内内容之间,但它们的內容并不是周围 textblock 的一部分。让我们像这样定义它们:

import {schema} from "prosemirror-schema-basic"
import {Schema} from "prosemirror-model"

const footnoteSpec = {
  group: "inline",
  content: "text*",
  inline: true,
  // This makes the view treat the node as a leaf, even though it
  // technically has content
  atom: true,
  toDOM: () => ["footnote", 0],
  parseDOM: [{tag: "footnote"}]
}

const footnoteSchema = new Schema({
  nodes: schema.spec.nodes.addBefore("image", "footnote", footnoteSpec),
  marks: schema.spec.marks
})

带内容的行内节点没有得到库的良好支持,至少默认情况下没有。你必须为它们编写一个 node view,由它来管理它们在编辑器中的呈现方式。

所以我们就这么做。这个示例中的脚注被绘制为数字。实际上,它们只是 <footnote> 节点,我们依靠 CSS 来添加数字。

import {StepMap} from "prosemirror-transform"
import {keymap} from "prosemirror-keymap"
import {undo, redo} from "prosemirror-history"

class FootnoteView {
  constructor(node, view, getPos) {
    // We'll need these later
    this.node = node
    this.outerView = view
    this.getPos = getPos

    // The node's representation in the editor (empty, for now)
    this.dom = document.createElement("footnote")
    // These are used when the footnote is selected
    this.innerView = null
  }

只有当节点视图被选中时,用户才能看到并与其内容交互(由于我们在 node spec 上设置了 atom 属性,当用户「按方向键」移到它上面时它会被选中)。这两个方法处理节点视图的选中和取消选中。

  selectNode() {
    this.dom.classList.add("ProseMirror-selectednode")
    if (!this.innerView) this.open()
  }

  deselectNode() {
    this.dom.classList.remove("ProseMirror-selectednode")
    if (this.innerView) this.close()
  }

我们要做的是弹出一个小的子编辑器,它本身就是一个 ProseMirror 视图,内容是节点的内容。这个子编辑器中的事务在 dispatchInner 方法中被特殊处理。

Mod-z 和 y 被绑定为在外部编辑器上运行撤销和重做。我们马上会看到为什么这样可行。

  open() {
    // Append a tooltip to the outer node
    let tooltip = this.dom.appendChild(document.createElement("div"))
    tooltip.className = "footnote-tooltip"
    // And put a sub-ProseMirror into that
    this.innerView = new EditorView(tooltip, {
      // You can use any node as an editor document
      state: EditorState.create({
        doc: this.node,
        plugins: [keymap({
          "Mod-z": () => undo(this.outerView.state, this.outerView.dispatch),
          "Mod-y": () => redo(this.outerView.state, this.outerView.dispatch)
        })]
      }),
      // This is the magic part
      dispatchTransaction: this.dispatchInner.bind(this),
      handleDOMEvents: {
        mousedown: () => {
          // Kludge to prevent issues due to the fact that the whole
          // footnote is node-selected (and thus DOM-selected) when
          // the parent editor is focused.
          if (this.outerView.hasFocus()) this.innerView.focus()
        }
      }
    })
  }

  close() {
    this.innerView.destroy()
    this.innerView = null
    this.dom.textContent = ""
  }

当子编辑器的内容变化时应该发生什么?我们可以直接拿它的内容,把外部文档中脚注的内容重置为它,但这与撤销历史或协同编辑配合得不好。

更好的做法是简单地把内部编辑器的 step 加上适当的偏移量,应用到外部文档上。

我们必须小心处理 appended transactions;并且为了能在不造成无限循环的情况下处理来自外部编辑器的更新,代码还要能理解事务标志 "fromOutside",当它存在时禁用传播。

  dispatchInner(tr) {
    let {state, transactions} = this.innerView.state.applyTransaction(tr)
    this.innerView.updateState(state)

    if (!tr.getMeta("fromOutside")) {
      let outerTr = this.outerView.state.tr, offsetMap = StepMap.offset(this.getPos() + 1)
      for (let i = 0; i < transactions.length; i++) {
        let steps = transactions[i].steps
        for (let j = 0; j < steps.length; j++)
          outerTr.step(steps[j].map(offsetMap))
      }
      if (outerTr.docChanged) this.outerView.dispatch(outerTr)
    }
  }

为了能干净地处理来自外部的更新(例如通过协同编辑,或当用户撤销某些由外部编辑器处理的操作时),节点视图的 update 方法会仔细找出它当前内容与新节点内容之间的差异。它只替换发生变化的部分,以便尽可能让光标留在原地。

  update(node) {
    if (!node.sameMarkup(this.node)) return false
    this.node = node
    if (this.innerView) {
      let state = this.innerView.state
      let start = node.content.findDiffStart(state.doc.content)
      if (start != null) {
        let {a: endA, b: endB} = node.content.findDiffEnd(state.doc.content)
        let overlap = start - Math.min(endA, endB)
        if (overlap > 0) { endA += overlap; endB += overlap }
        this.innerView.dispatch(
          state.tr
            .replace(start, endB, node.slice(start, endA))
            .setMeta("fromOutside", true))
      }
    }
    return true
  }

最后,节点视图还要处理销毁,以及回应哪些事件和变更应由外部编辑器处理。

  destroy() {
    if (this.innerView) this.close()
  }

  stopEvent(event) {
    return this.innerView && this.innerView.dom.contains(event.target)
  }

  ignoreMutation() { return true }
}

我们可以像这样启用我们的 schema 和节点视图,来创建一个真正的编辑器。

import {EditorState} from "prosemirror-state"
import {DOMParser} from "prosemirror-model"
import {EditorView} from "prosemirror-view"
import {exampleSetup} from "prosemirror-example-setup"

window.view = new EditorView(document.querySelector("#editor"), {
  state: EditorState.create({
    doc: DOMParser.fromSchema(footnoteSchema).parse(document.querySelector("#content")),
    plugins: exampleSetup({schema: footnoteSchema, menuContent: menu.fullMenu})
  }),
  nodeViews: {
    footnote(node, view, getPos) { return new FootnoteView(node, view, getPos) }
  }
})