嵌入代码编辑器
Embedded code editor
让某些节点(例如代码块、数学公式或图片)在文档中的表示呈现为一个专门针对此类内容的自定义编辑器控件,有时会很有用。Node views(节点视图)正是让这成为可能的 ProseMirror 特性。
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.
在这个示例中,我们把基础 schema中已有的代码块设置为渲染成 CodeMirror(一个代码编辑器组件)的实例。总体思路与 footnote 示例非常相似,只不过不是在用户选中节点时弹出节点专属的编辑器,而是始终可见。
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.
把这样一个节点视图和 keymap 接入编辑器后,我们会得到类似这样的东西:
Wiring such a node view and keymap into an editor gives us something like this:
因为我们希望代码编辑器中的变化能反映到 ProseMirror 文档中,所以我们的节点视图必须在变化一发生时就把它内容的更改冲刷到 ProseMirror。为了让 ProseMirror 命令能作用于正确的选区,代码编辑器也会把它当前的选区同步到 ProseMirror。
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.
我们在代码块节点视图中做的第一件事,是创建一个带有一些基础扩展、几个额外按键绑定和一个负责同步的 update 监听器的编辑器。
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) {
// 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 是跳过代码块的开始标记)。
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)
}
}
在为内容变化向事务添加 step 时,偏移量会根据变化带来的长度差异进行调整,这样后续的 step 就能在正确的位置创建。
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.
节点视图上的 setSelection 方法会在 ProseMirror 试图把选区放到节点内部时被调用。我们的实现确保 CodeMirror 的选区被设置为与传入的位置相匹配。
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.
这个 keymap 还绑定了撤销和重做键(由外部编辑器处理),以及 ctrl-enter 键(在 ProseMirror 的基础 keymap 中,它会在代码块之后创建一个新段落)。
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()
}
当来自 ProseMirror 的节点更新到来时(例如由于撤销操作),我们差不多要做与 forwardUpdate 相反的事情——检查文本变化,如果存在,就把它们从外部传播到内部编辑器。
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
}
updating 属性用于禁用代码编辑器上的事件监听器,这样它就不会试图把(刚刚来自 ProseMirror 的)变化再转发回 ProseMirror。
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 }
}
处理从外部到内部编辑器的光标移动,必须用外部编辑器上的 keymap 来完成,因为浏览器的原生行为不会处理它。arrowHandler 函数使用 endOfTextblock 方法,以感知双向文本的方式判断光标是否在某个 textblock 的末尾。如果是,并且下一个块是代码块,就把选区移入其中。
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")
})