编辑脚注

这个示例演示了如何在 ProseMirror 中实现类似脚注一样的东西:

Remix on Glitch

脚注看起来应该被实现为一种带有内容的内联节点--他们出现在其他内联内容之间,但是它的内容并不是它外层文本 block 的内容。 因此让我们先像下面一样定义它们:

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

const footnoteSpec = {
  group: "inline",
  content: "inline*",
  inline: true,
  // 这个设置让 view 将该节点当成是一个叶子节点对待,即使它从技术上讲,是有内容的
  atom: true,
  toDOM: () => ["footnote", 0],
  parseDOM: [{tag: "footnote"}]
}

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

对于有内容的内联节点,ProseMirror 处理的并不太好,至少默认并不支持这种类型。 所以你需要为这种类型的节点写一个 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) {
    // 我们后面需要这些
    this.node = node
    this.outerView = view
    this.getPos = getPos

    // 这个是该节点在编辑器中的 DOM 结构(目前为止是空的)
    this.dom = document.createElement("footnote")
    // 这个是当脚注被选中的时候有用
    this.innerView = null
  }

只有当 node view 被选中的时候,用户才可以与它的内容做交互(它将在用户的光标放到上去的时候或者鼠标点击的时候才会被选中, 因为我们对这种类型的节点设置了 atom 属性)。下面这两种方法处理 node view 被选中和取消选中时候的逻辑:

  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 view,内容是节点的内容。在子编辑器中的 Transaction 被特殊的由父编辑器的 dispatchInner 方法处理。

Mod-z 和 y 按键被绑定到 父编辑器 的 undo 和 redo 功能上。我们一会儿再来看它是如何做到的:

  open() {
    // 附加一个 tooltip 到外部节点
    let tooltip = this.dom.appendChild(document.createElement("div"))
    tooltip.className = "footnote-tooltip"
    // 然后在其内添加一个子 ProseMirror 编辑器
    this.innerView = new EditorView(tooltip, {
      // 你可以用任何节点作为这个子编辑器的 doc 节点
      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)
        })]
      }),
      // 魔法发生在这个地方
      dispatchTransaction: this.dispatchInner.bind(this),
      handleDOMEvents: {
        mousedown: () => {
          // 为了避免出现问题,当父编辑器 focus 的时候,脚注的编辑器也要 focus。
          if (this.outerView.hasFocus()) this.innerView.focus()
        }
      }
    })
  }

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

当子编辑器的内容改变的时候应该如何处理?我们可以仅仅是拿到内容,然后将在外部编辑器的脚注的内容给重置为该内容,但是这对于 undo 历史和协同编辑来说并不可行。

一个更好的实现是简单的将自于子编辑器的 setps,加上合适的偏移位置,应用到外部文档中去。

我们需要小心的处理 appended transactions,同时需要能够处理来自外部编辑器的更新而不造成一个无限循环, 下面代码也同样理解 transaction 的 「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)
    }
  }

为了能够干净的处理来自外部编辑器的更新(比如协同编辑或者由外部编辑器处理的用户 undo 的一些操作的时候),node view 的 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
  }

最后,nodevidw 需要处理销毁事件,以及告诉外部编辑器应该处理哪些来自于 node view 的事件和变化:

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

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

  ignoreMutation() { return true }
}

我们可以像下面这样启用 schema 和 node view,以创建一个真实编辑器:

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