嵌入一个代码编辑器

某些节点以一个编辑器内嵌的文档节点来表示可能会比较有用,比如代码块、数学公式,或者甚至是图片,以展示专门针对此类节点的自定义控制组件。 Node views 就是 ProseMirror 用来实现此类效果的一个 feature。

将 node view 和 keymap 放到一个编辑器中的效果就像下面这样:

在这个示例中,我们设置了一个代码块,它们在 basic schema 中已经提供了。 它被渲染成一个 CodeMirror 的实例,也即一个代码编辑器组件。大致思路与 footnote example 类似, 不过区别是代码块不用用户选择某个节点,它总是会显示出来。

将 CodeMirror 放到 ProseMirror 的 node view 中的适配代码其实有点复杂,因为我们需要在两种不同文档中相互转换--ProseMirror 的树状文档与 CodeMirror 的纯文本文档:

import CodeMirror from "codemirror"
import {exitCode} from "prosemirror-commands"
import {undo, redo} from "prosemirror-history"

class CodeBlockView {
  constructor(node, view, getPos) {
    // Store for later
    this.node = node
    this.view = view
    this.getPos = getPos
    this.incomingChanges = false

    // 新建一个 CodeMirror 实例
    this.cm = new CodeMirror(null, {
      value: this.node.textContent,
      lineNumbers: true,
      extraKeys: this.codeMirrorKeymap()
    })

    // 代码编辑器的最外层节点是就是我们代码块的的 DOM 节点
    this.dom = this.cm.getWrapperElement()
    // CodeMirror 需要在 DOM 中被合适的初始化,因此设置个定时器让它更新自身
    setTimeout(() => this.cm.refresh(), 20)
    // 这个标记用来避免在外部编辑器和内部编辑器之间的循环更新
    this.updating = false
    // 追踪是否改变已经发生,但是还没有传递出去
    this.cm.on("beforeChange", () => this.incomingChanges = true)
    // 将代码编辑器的更新传递给外层的 ProseMirror
    this.cm.on("cursorActivity", () => {
      if (!this.updating && !this.incomingChanges) this.forwardSelection()
    })
    this.cm.on("changes", () => {
      if (!this.updating) {
        this.valueChanged()
        this.forwardSelection()
      }
      this.incomingChanges = false
    })
    this.cm.on("focus", () => this.forwardSelection())
  }

当代码编辑器被 focus 的时候,我们可以将外部编辑器的选区与内部的代码编辑器选区保持同步,这样我们在外部编辑器执行任何命令的话就能看到一个正确的选区:

  forwardSelection() {
    if (!this.cm.hasFocus()) return
    let state = this.view.state
    let selection = this.asProseMirrorSelection(state.doc)
    if (!selection.eq(state.selection))
      this.view.dispatch(state.tr.setSelection(selection))
  }

辅助函数负责将 CodeMirror 的选区转换成 ProseMirror 的选区。因为 CodeMirror 使用一个基于行/列的索引系统,因此 indexFromPos 被用来将其转换成 ProseMirror 的字符索引:

  asProseMirrorSelection(doc) {
    let offset = this.getPos() + 1
    let anchor = this.cm.indexFromPos(this.cm.getCursor("anchor")) + offset
    let head = this.cm.indexFromPos(this.cm.getCursor("head")) + offset
    return TextSelection.create(doc, anchor, head)
  }

选区也可以以另一种方式同步,比如将 ProseMirror 的选区转换成 CodeMirror 的选区,这时要使用 setSelection 方法来实现:

  setSelection(anchor, head) {
    this.cm.focus()
    this.updating = true
    this.cm.setSelection(this.cm.posFromIndex(anchor),
                         this.cm.posFromIndex(head))
    this.updating = false
  }

当代码编辑器的内容发生变化的时候,在 node view 的构造函数中注册了该变化的事件处理函数将会被调用。 它将会对代码块节点的当前值和编辑器中的值进行比较,如果有不同,则会 dispatch 一个 transaction:

  valueChanged() {
    let change = computeChange(this.node.textContent, this.cm.getValue())
    if (change) {
      let start = this.getPos() + 1
      let tr = this.view.state.tr.replaceWith(
        start + change.from, start + change.to,
        change.text ? schema.text(change.text) : null)
      this.view.dispatch(tr)
    }
  }

像这样的嵌套编辑器,比较棘手的地方是处理光标在内部编辑器边缘移动的情况。node view 必须允许用户能够将光标移出代码编辑器。 为了实现这个目的,它设置了一个按键映射以绑定方向键处理函数,以检查是否用户进一步的操作将会「脱离」代码编辑器的控制,如果是的话,返回这个选区,然后 focus 外部编辑器。

上述的按键映射也同样绑定了撤销和重做,外部编辑器将会处理该事件。对于 ctrl-enter 按键来说,在 ProseMirror 的基础按键绑定中,将会在代码块后面创建一个新的段落节点。

  codeMirrorKeymap() {
    let view = this.view
    let mod = /Mac/.test(navigator.platform) ? "Cmd" : "Ctrl"
    return CodeMirror.normalizeKeyMap({
      Up: () => this.maybeEscape("line", -1),
      Left: () => this.maybeEscape("char", -1),
      Down: () => this.maybeEscape("line", 1),
      Right: () => this.maybeEscape("char", 1),
      "Ctrl-Enter": () => {
        if (exitCode(view.state, view.dispatch)) view.focus()
      },
      [`${mod}-Z`]: () => undo(view.state, view.dispatch),
      [`Shift-${mod}-Z`]: () => redo(view.state, view.dispatch),
      [`${mod}-Y`]: () => redo(view.state, view.dispatch),
    })
  }

  maybeEscape(unit, dir) {
    let pos = this.cm.getCursor()
    if (this.cm.somethingSelected() ||
        pos.line != (dir < 0 ? this.cm.firstLine() : this.cm.lastLine()) ||
        (unit == "char" &&
         pos.ch != (dir < 0 ? 0 : this.cm.getLine(pos.line).length)))
      return CodeMirror.Pass
    this.view.focus()
    let targetPos = this.getPos() + (dir < 0 ? 0 : this.node.nodeSize)
    let selection = Selection.near(this.view.state.doc.resolve(targetPos), dir)
    this.view.dispatch(this.view.state.tr.setSelection(selection).scrollIntoView())
    this.view.focus()
  }

当一个代码编辑器中的内容更新的时候,比如做了一个撤销操作,我们在某种程度上需要去做一些与这些 valueChanges(值变化) 相反的操作(译者注:即添加的要被删除掉,删除的要被添加上等), 即检查文本变化,如果发生了变化,将这些变化从外部编辑器传递到内部编辑器(译者注:即当修改了内部代码编辑器的内容后,按撤销的时候,ProseMirror 需要将修改反转的变化同步到 CodeMirror ):

  update(node) {
    if (node.type != this.node.type) return false
    this.node = node
    let change = computeChange(this.cm.getValue(), node.textContent)
    if (change) {
      this.updating = true
      this.cm.replaceRange(change.text, this.cm.posFromIndex(change.from),
                           this.cm.posFromIndex(change.to))
      this.updating = false
    }
    return true
  }

updating 属性用来禁用在代码编辑器上的事件处理函数:


  selectNode() { this.cm.focus() }
  stopEvent() { return true }
}

computeChange 用来比较两个字符串,寻找他们之间的最小差异,就像下面这样:

function computeChange(oldVal, newVal) {
  if (oldVal == newVal) return null
  let start = 0, oldEnd = oldVal.length, newEnd = newVal.length
  while (start < oldEnd && oldVal.charCodeAt(start) == newVal.charCodeAt(start)) ++start
  while (oldEnd > start && newEnd > start &&
         oldVal.charCodeAt(oldEnd - 1) == newVal.charCodeAt(newEnd - 1)) { oldEnd--; newEnd-- }
  return {from: start, to: oldEnd, text: newVal.slice(start, newEnd)}
}

它从字符串的开始迭代寻找,一直到结尾,直到找到一个不同之处,然后返回返回一个对象,含有改变的起始位置,终止位置以及替换的文本,或者 null,表示没有变化。

处理从外部编辑器到内部代码编辑器的光标的移动必须由外部编辑器通过按键映射完成。arrowHandler 函数使用 endOfTextblock 方法 ,以一种受 bidi 文本影响的方式,决定光标是否在给定文本 block 的末尾。如果是的话,并且下一个 block 是代码块,则光标就会被移动到代码块内(译者注:bidi 影响文本的书写方向,因此影响光标是否在文本块的结尾的判断,ProseMirror 处理了这种情况):

import {keymap} from "prosemirror-keymap"

function arrowHandler(dir) {
  return (state, dispatch, view) => {
    if (state.selection.empty && view.endOfTextblock(dir)) {
      let side = dir == "left" || dir == "up" ? -1 : 1, $head = state.selection.$head
      let nextPos = Selection.near(state.doc.resolve(side > 0 ? $head.after() : $head.before()), side)
      if (nextPos.$head && nextPos.$head.parent.type.name == "code_block") {
        dispatch(state.tr.setSelection(nextPos))
        return true
      }
    }
    return false
  }
}

const arrowHandlers = keymap({
  ArrowLeft: arrowHandler("left"),
  ArrowRight: arrowHandler("right"),
  ArrowUp: arrowHandler("up"),
  ArrowDown: arrowHandler("down")
})