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.
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.
Wiring such a node view and keymap into an editor gives us something
like this:
The code block is a code editor
This editor has been wired up to render code blocks as instances of
the CodeMirror code editor, which
provides syntax highlighting, auto-indentation, and similar.
function max(a, b) {
return a > b ? a : b
}
The content of the code editor is kept in sync with the content of
the code block in the rich text editor, so that it is as if you're
directly editing the outer document, using a more convenient
interface.
Because we want changes in the code editor to be reflected in the
ProseMirror document, our node view must flush changes to its content
to ProseMirror as soon as they happen. To allow ProseMirror commands
to act on the right selection, the code editor will also sync its
current selection to ProseMirror.
The first thing we do in our code block node view is create an editor
with some basic extensions, a few extra key bindings, and an update
listener that will do the synchronization.
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
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))
]
})
this.dom = this.cm.dom
this.updating = false
}
When the code editor is focused, translate any update that changes the
document or selection to a ProseMirror transaction. The getPos
that
was passed to the node view can be used to find out where our code
content starts, relative to the outer document (the + 1
skips the
code block opening token).
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)
}
}
When adding steps to a transaction for content changes, the offset is
adjusted for the changes in length caused by the change, so that
further steps are created in the correct position.
The setSelection
method on a node view will be called when
ProseMirror tries to put the selection inside the node. Our
implementation makes sure the CodeMirror selection is set to match the
position that is passed in.
setSelection(anchor, head) {
this.cm.focus()
this.updating = true
this.cm.dispatch({selection: {anchor, head}})
this.updating = false
}
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
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()
}
When a node update comes in from ProseMirror, for example because of
an undo action, we sort of have to do the inverse of what
forwardUpdate
did—check for text changes, and if present, propagate
them from the outer to the inner editor.
To avoid needlessly clobbering the state of the inner editor, this
method only generates a replacement for the range of the content that
was changed, by comparing the start and end of the old and new
content.
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
}
The updating
property is used to disable the event listener on the
code editor, so that it doesn't try to forward the change (which just
came from ProseMirror) back to ProseMirror.
selectNode() { this.cm.focus() }
stopEvent() { return true }
}
Handling cursor motion from the outer to the inner editor must be done
with a keymap on the outer editor, because the browser's native
behavior won't handle this. 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
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")
})