Editing footnotes

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

脚注似乎应该是与内容内联的节点——它们出现在其他内联内容之间,但它们的内容并不真正属于它们周围的文本块。让我们这样定义它们:

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

const footnoteSpec = {
  group: "inline",
  content: "text*",
  inline: true,
  // 这使得视图将节点视为叶子,即使它
  // 技术上有内容
  atom: true,
  toDOM: () => ["footnote", 0],
  parseDOM: [{tag: "footnote"}]
}

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

库默认情况下不能很好地处理带有内容的内联节点。你需要为它们编写一个节点视图,以某种方式管理它们在编辑器中的显示。

所以这就是我们要做的。在这个例子中,脚注被绘制为数字。事实上,它们只是<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

    // 节点在编辑器中的表示(目前为空)
    this.dom = document.createElement("footnote")
    // 这些在选择脚注时使用
    this.innerView = null
  }

只有在选择节点视图时,用户才能看到并与其内容交互(当用户“箭头”指向它时,它将被选中,因为我们在节点规范上设置了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() {
    // 将工具提示附加到外部节点
    let tooltip = this.dom.appendChild(document.createElement("div"))
    tooltip.className = "footnote-tooltip"
    // 并将一个子ProseMirror放入其中
    this.innerView = new EditorView(tooltip, {
      // 您可以使用任何节点作为编辑器文档,直接输出翻译结果,不要添加任何额外的文本。记住,保留所有HTML标签和属性,只翻译内容!
      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)
        })]
      }),
      // 这是神奇的部分,直接输出翻译内容,不要任何额外的文本。记住,保留所有HTML标签和属性,只翻译内容!
      dispatchTransaction: this.dispatchInner.bind(this),
      handleDOMEvents: {
        mousedown: () => {
          // 权宜之计以防止由于整个事实而导致的问题
          // 脚注是节点选择的(因此也是DOM选择的)当
          // 父编辑器已聚焦。
          if (this.outerView.hasFocus()) this.innerView.focus()
        }
      }
    })
  }

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

当子编辑器的内容发生变化时应该怎么办?我们可以直接获取其内容并将外部文档中脚注的内容重置为它,但这对撤销历史或协作编辑不利。

一种更好的方法是简单地将内部编辑器中的步骤应用到外部文档中,并使用适当的偏移量。

我们必须小心处理附加的事务,并且为了能够处理来自外部编辑器的更新而不创建无限循环,代码还理解事务标志"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
  }

最后,nodeview 必须处理销毁以及关于哪些事件和变更应由外部编辑器处理的查询。

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

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

  ignoreMutation() { return true }
}

我们可以像这样启用我们的模式和节点视图,以创建一个实际的编辑器。

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