嵌入代码编辑器

Embedded code editor

让某些节点(例如代码块、数学公式或图片)在文档中的表示呈现为一个专门针对此类内容的自定义编辑器控件,有时会很有用。Node views(节点视图)正是让这成为可能的 ProseMirror 特性。

在这个示例中,我们把基础 schema中已有的代码块设置为渲染成 CodeMirror(一个代码编辑器组件)的实例。总体思路与 footnote 示例非常相似,只不过不是在用户选中节点时弹出节点专属的编辑器,而是始终可见。

把这样一个节点视图和 keymap 接入编辑器后,我们会得到类似这样的东西:

因为我们希望代码编辑器中的变化能反映到 ProseMirror 文档中,所以我们的节点视图必须在变化一发生时就把它内容的更改冲刷到 ProseMirror。为了让 ProseMirror 命令能作用于正确的选区,代码编辑器也会把它当前的选区同步到 ProseMirror。

我们在代码块节点视图中做的第一件事,是创建一个带有一些基础扩展、几个额外按键绑定和一个负责同步的 update 监听器的编辑器。

import {
  EditorView as CodeMirror, keymap as cmKeymap, drawSelection
} from "@codemirror/view"
import {javascript} from "@codemirror/lang-javascript"
import {defaultKeymap} from "@codemirror/commands"
import {syntaxHighlighting, defaultHighlightStyle} from "@codemirror/language"

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

    // Create a CodeMirror instance
    this.cm = new CodeMirror({
      doc: this.node.textContent,
      extensions: [
        cmKeymap.of([
          ...this.codeMirrorKeymap(),
          ...defaultKeymap
        ]),
        drawSelection(),
        syntaxHighlighting(defaultHighlightStyle),
        javascript(),
        CodeMirror.updateListener.of(update => this.forwardUpdate(update))
      ]
    })

    // The editor's outer node is our DOM representation
    this.dom = this.cm.dom

    // This flag is used to avoid an update loop between the outer and
    // inner editor
    this.updating = false
  }

当代码编辑器获得焦点时,把任何改变文档或选区的更新都转换成一个 ProseMirror 事务。传给节点视图的 getPos 可以用来找出我们的代码内容相对于外部文档从哪里开始(+ 1 是跳过代码块的开始标记)。

  forwardUpdate(update) {
    if (this.updating || !this.cm.hasFocus) return
    let offset = this.getPos() + 1, {main} = update.state.selection
    let selFrom = offset + main.from, selTo = offset + main.to
    let pmSel = this.view.state.selection
    if (update.docChanged || pmSel.from != selFrom || pmSel.to != selTo) {
      let tr = this.view.state.tr
      update.changes.iterChanges((fromA, toA, fromB, toB, text) => {
        if (text.length)
          tr.replaceWith(offset + fromA, offset + toA,
                         schema.text(text.toString()))
        else
          tr.delete(offset + fromA, offset + toA)
        offset += (toB - fromB) - (toA - fromA)
      })
      tr.setSelection(TextSelection.create(tr.doc, selFrom, selTo))
      this.view.dispatch(tr)
    }
  }

在为内容变化向事务添加 step 时,偏移量会根据变化带来的长度差异进行调整,这样后续的 step 就能在正确的位置创建。

节点视图上的 setSelection 方法会在 ProseMirror 试图把选区放到节点内部时被调用。我们的实现确保 CodeMirror 的选区被设置为与传入的位置相匹配。

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

嵌套这种编辑器的一个有点棘手的方面,是处理跨过内部编辑器边缘的光标移动。这个节点视图必须负责允许用户把选区移出代码编辑器。为此,它把方向键绑定到一些处理器上,这些处理器检查继续移动是否会「逃出」编辑器,如果是,就把选区和焦点交还给外部编辑器。

这个 keymap 还绑定了撤销和重做键(由外部编辑器处理),以及 ctrl-enter 键(在 ProseMirror 的基础 keymap 中,它会在代码块之后创建一个新段落)。

  codeMirrorKeymap() {
    let view = this.view
    return [
      {key: "ArrowUp", run: () => this.maybeEscape("line", -1)},
      {key: "ArrowLeft", run: () => this.maybeEscape("char", -1)},
      {key: "ArrowDown", run: () => this.maybeEscape("line", 1)},
      {key: "ArrowRight", run: () => this.maybeEscape("char", 1)},
      {key: "Ctrl-Enter", run: () => {
        if (!exitCode(view.state, view.dispatch)) return false
        view.focus()
        return true
      }},
      {key: "Ctrl-z", mac: "Cmd-z",
       run: () => undo(view.state, view.dispatch)},
      {key: "Shift-Ctrl-z", mac: "Shift-Cmd-z",
       run: () => redo(view.state, view.dispatch)},
      {key: "Ctrl-y", mac: "Cmd-y",
       run: () => redo(view.state, view.dispatch)}
    ]
  }

  maybeEscape(unit, dir) {
    let {state} = this.cm, {main} = state.selection
    if (!main.empty) return false
    if (unit == "line") main = state.doc.lineAt(main.head)
    if (dir < 0 ? main.from > 0 : main.to < state.doc.length) return false
    let targetPos = this.getPos() + (dir < 0 ? 0 : this.node.nodeSize)
    let selection = Selection.near(this.view.state.doc.resolve(targetPos), dir)
    let tr = this.view.state.tr.setSelection(selection).scrollIntoView()
    this.view.dispatch(tr)
    this.view.focus()
  }

当来自 ProseMirror 的节点更新到来时(例如由于撤销操作),我们差不多要做与 forwardUpdate 相反的事情——检查文本变化,如果存在,就把它们从外部传播到内部编辑器。

为了避免不必要地破坏内部编辑器的状态,这个方法只对被改变的那段内容生成一个替换,通过比较旧内容和新内容的起止位置来确定。

  update(node) {
    if (node.type != this.node.type) return false
    this.node = node
    if (this.updating) return true
    let newText = node.textContent, curText = this.cm.state.doc.toString()
    if (newText != curText) {
      let start = 0, curEnd = curText.length, newEnd = newText.length
      while (start < curEnd &&
             curText.charCodeAt(start) == newText.charCodeAt(start)) {
        ++start
      }
      while (curEnd > start && newEnd > start &&
             curText.charCodeAt(curEnd - 1) == newText.charCodeAt(newEnd - 1)) {
        curEnd--
        newEnd--
      }
      this.updating = true
      this.cm.dispatch({
        changes: {
          from: start, to: curEnd,
          insert: newText.slice(start, newEnd)
        }
      })
      this.updating = false
    }
    return true
  }

updating 属性用于禁用代码编辑器上的事件监听器,这样它就不会试图把(刚刚来自 ProseMirror 的)变化再转发回 ProseMirror。


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

处理从外部到内部编辑器的光标移动,必须用外部编辑器上的 keymap 来完成,因为浏览器的原生行为不会处理它。arrowHandler 函数使用 endOfTextblock 方法,以感知双向文本的方式判断光标是否在某个 textblock 的末尾。如果是,并且下一个块是代码块,就把选区移入其中。

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
      let $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")
})