Embedded code editor

在文档中显示某些节点(例如代码块、数学公式或图像)的自定义编辑器控件可能会很有用。节点视图是一个使这成为可能的ProseMirror功能。

在这个例子中,我们设置了代码块,正如它们在基本模式中存在的那样,以CodeMirror(一个代码编辑器组件)的实例形式呈现。总体思路与脚注示例非常相似,但不是在用户选择节点时弹出节点特定的编辑器,而是始终可见。

将这样的节点视图和键映射连接到编辑器中,会得到如下结果:

因为我们希望代码编辑器中的更改能够反映在ProseMirror文档中,我们的节点视图必须在内容发生更改时立即将其刷新到ProseMirror。为了让ProseMirror命令作用于正确的选择,代码编辑器还会将其当前选择同步到ProseMirror。

在我们的代码块节点视图中,我们首先创建一个带有一些基本扩展、一些额外键绑定和一个将进行同步的更新监听器的编辑器。

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) {
    // 稍后存储
    this.node = node
    this.view = view
    this.getPos = getPos

    // 创建一个CodeMirror实例
    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))
      ]
    })

    // 编辑器的外部节点是我们的DOM表示
    this.dom = this.cm.dom

    // 此标志用于避免外部和之间的更新循环
    // 内部编辑器
    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)
    }
  }

当为内容更改添加步骤到事务时,偏移量会根据更改引起的长度变化进行调整,以便在正确的位置创建进一步的步骤。

当 ProseMirror 尝试将选区放入节点时,将调用节点视图上的 setSelection 方法。我们的实现确保 CodeMirror 选区设置为匹配传入的位置。

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

嵌套编辑器的一个有点棘手的方面是处理光标在内部编辑器边缘的移动。这个节点视图必须负责允许用户将选择移出代码编辑器。为此,它将箭头键绑定到处理程序,这些处理程序会检查进一步的移动是否会“逃离”编辑器,如果是,则将选择和焦点返回到外部编辑器。

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

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

处理从外部编辑器到内部编辑器的光标移动必须在外部编辑器上使用键映射,因为浏览器的原生行为无法处理这一点。arrowHandler函数使用endOfTextblock方法以双向文本感知的方式确定光标是否位于给定文本块的末尾。如果是,并且下一个块是代码块,则将选择移动到其中。

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