Embedded code editor

It can be useful to have the in-document representation of some node, such as a code block, math formula, or image, show up as a custom editor control specifically for such content. Node views are a ProseMirror feature that make this possible.

Wiring this node view and keymap into an editor gives us something like this:

In this example, we set up code blocks, as they exist in the basic schema, to be rendered as instances of CodeMirror, a code editor component. The general idea is quite similar to the footnote example, but instead of popping up the node-specific editor when the user selects the node, it is always visible.

The adaptor code in the node view gets a bit more involved, because we are translating between two diffent document concepts—ProseMirror's tree versus CodeMirror's plain text.

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

    // Create a CodeMirror instance
    this.cm = new CodeMirror(null, {
      value: this.node.textContent,
      lineNumbers: true,
      extraKeys: this.codeMirrorKeymap()
    })

    // The editor's outer node is our DOM representation
    this.dom = this.cm.getWrapperElement()
    // CodeMirror needs to be in the DOM to properly initialize, so
    // schedule it to update itself
    setTimeout(() => this.cm.refresh(), 20)

    // This flag is used to avoid an update loop between the outer and
    // inner editor
    this.updating = false
    // Propagate updates from the code editor to ProseMirror
    this.cm.on("cursorActivity",
               () => {if (!this.updating) this.forwardSelection()})
    this.cm.on("changes", () => {if (!this.updating) this.valueChanged()})
    this.cm.on("focus", () => this.forwardSelection())
  }

When the code editor is focused, we can keep the selection of the outer editor synchronized with the inner one, so that any commands executed on the outer editor see an accurate selection.

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

This helper function translates from a CodeMirror selection to a ProseMirror selection. Because CodeMirror uses a line/column based indexing system, indexFromPos is used to convert to an actual character index.

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

Selections are also synchronized the other way, from ProseMirror to CodeMirror, using the view's setSelection method.

  setSelection(anchor, head) {
    this.cm.focus()
    this.updating = true
    this.cm.setSelection(this.cm.posFromIndex(anchor),
                         this.cm.posFromIndex(head))
    this.updating = false
    console.log("set sel", anchor, head, this.cm.getCursor())
  }

When the actual content of the code editor is changed, the event handler registered in the node view's constructor calls this method. It'll compare the code block node's current value to the value in the editor, and dispatch a transaction if there is a difference.

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

A somewhat tricky aspect of nesting editor like this is handling cursor motion across the edges of the inner editor. This node view will have to take care of allowing the user to move the selection out of the code editor. For that purpose, it binds the arrow keys to handlers that check if further motion would ‘escape’ the editor, and if so, return the selection and focus to the outer editor.

The keymap also binds keys for undo and redo, which the outer editor will handle, and for ctrl-enter, which, in ProseMirror's base keymap, creates a new paragraph after a code block.

  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),
      [`${mod}-Z`]: () => undo(view.state, view.dispatch),
      [`Shift-${mod}-Z`]: () => redo(view.state, view.dispatch),
      [`${mod}-Y`]: () => redo(view.state, view.dispatch),
      "Ctrl-Enter": () => {
        if (exitCode(view.state, view.dispatch)) view.focus()
      }
    })
  }

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

When an update comes in from the editor, for example because of an undo action, we kind of have to do the inverse of what valueChanged did—check for text changes, and if present, propagate them from the outer to the inner editor.

  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
  }

The updating property is used to disable the event handlers on the code editor.


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

computeChange which was used to compare two strings and find the minimal change between them, looks like this:

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

It iterates from the start and end of the strings, until it hits a difference, and returns an object giving the change's start, end, and replacement text, or null if there was no change.

Handling cursor motion from the outer to the inner editor must be done with a keymap on the outer editor. The arrowHandler function uses the endOfTextblock method to determine, in a bidi-text-aware way, whether the cursor is at the end of a given textblock. If it is, and the next block is a code block, the selection is moved into it.

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