编辑脚注
Editing footnotes
这个示例演示了在 ProseMirror 中实现类似脚注功能的一种方式。
This example demonstrates one way to implement something like footnotes in ProseMirror.
脚注看起来应该是带内容的行内节点——它们出现在其他行内内容之间,但它们的內容并不是周围 textblock 的一部分。让我们像这样定义它们:
Footnotes seem like they should be inline nodes with content—they appear in between other inline content, but their content isn't really part of the textblock around them. Let's define them like this:
import {schema} from "prosemirror-schema-basic"
import {Schema} from "prosemirror-model"
const footnoteSpec = {
group: "inline",
content: "text*",
inline: true,
// This makes the view treat the node as a leaf, even though it
// technically has content
atom: true,
toDOM: () => ["footnote", 0],
parseDOM: [{tag: "footnote"}]
}
const footnoteSchema = new Schema({
nodes: schema.spec.nodes.addBefore("image", "footnote", footnoteSpec),
marks: schema.spec.marks
})
带内容的行内节点没有得到库的良好支持,至少默认情况下没有。你必须为它们编写一个 node view,由它来管理它们在编辑器中的呈现方式。
Inline nodes with content are not handled well by the library, at least not by default. You are required to write a node view for them, which somehow manages the way they appear in the editor.
所以我们就这么做。这个示例中的脚注被绘制为数字。实际上,它们只是 <footnote> 节点,我们依靠 CSS 来添加数字。
So that's what we'll do. Footnotes in this example are drawn as
numbers. In fact, they are just <footnote> nodes, and we'll rely on
CSS to add the numbers.
import {StepMap} from "prosemirror-transform"
import {keymap} from "prosemirror-keymap"
import {undo, redo} from "prosemirror-history"
class FootnoteView {
constructor(node, view, getPos) {
// We'll need these later
this.node = node
this.outerView = view
this.getPos = getPos
// The node's representation in the editor (empty, for now)
this.dom = document.createElement("footnote")
// These are used when the footnote is selected
this.innerView = null
}
只有当节点视图被选中时,用户才能看到并与其内容交互(由于我们在 node spec 上设置了 atom 属性,当用户「按方向键」移到它上面时它会被选中)。这两个方法处理节点视图的选中和取消选中。
Only when the node view is selected does the user get to see and
interact with its content (it'll be selected when the user ‘arrows’
onto it, because we set the atom property
on the node spec). These two methods handle node selection and
deselection the node view.
selectNode() {
this.dom.classList.add("ProseMirror-selectednode")
if (!this.innerView) this.open()
}
deselectNode() {
this.dom.classList.remove("ProseMirror-selectednode")
if (this.innerView) this.close()
}
我们要做的是弹出一个小的子编辑器,它本身就是一个 ProseMirror 视图,内容是节点的内容。这个子编辑器中的事务在 dispatchInner 方法中被特殊处理。
What we'll do is pop up a little sub-editor, which is itself a
ProseMirror view, with the node's content. Transactions in this
sub-editor are handled specially, in the dispatchInner method.
Mod-z 和 y 被绑定为在外部编辑器上运行撤销和重做。我们马上会看到为什么这样可行。
Mod-z and y are bound to run undo and redo on the outer editor. We'll see in a moment why that works.
open() {
// Append a tooltip to the outer node
let tooltip = this.dom.appendChild(document.createElement("div"))
tooltip.className = "footnote-tooltip"
// And put a sub-ProseMirror into that
this.innerView = new EditorView(tooltip, {
// You can use any node as an editor document
state: EditorState.create({
doc: this.node,
plugins: [keymap({
"Mod-z": () => undo(this.outerView.state, this.outerView.dispatch),
"Mod-y": () => redo(this.outerView.state, this.outerView.dispatch)
})]
}),
// This is the magic part
dispatchTransaction: this.dispatchInner.bind(this),
handleDOMEvents: {
mousedown: () => {
// Kludge to prevent issues due to the fact that the whole
// footnote is node-selected (and thus DOM-selected) when
// the parent editor is focused.
if (this.outerView.hasFocus()) this.innerView.focus()
}
}
})
}
close() {
this.innerView.destroy()
this.innerView = null
this.dom.textContent = ""
}
当子编辑器的内容变化时应该发生什么?我们可以直接拿它的内容,把外部文档中脚注的内容重置为它,但这与撤销历史或协同编辑配合得不好。
What should happen when the content of the sub-editor changes? We could just take its content and reset the content of the footnote in the outer document to it, but that wouldn't play well with the undo history or collaborative editing.
更好的做法是简单地把内部编辑器的 step 加上适当的偏移量,应用到外部文档上。
A nicer approach is to simply apply the steps from the inner editor, with an appropriate offset, to the outer document.
我们必须小心处理 appended transactions;并且为了能在不造成无限循环的情况下处理来自外部编辑器的更新,代码还要能理解事务标志 "fromOutside",当它存在时禁用传播。
We have to be careful to handle appended
transactions, and to be able to
handle updates from the outside editor without creating an infinite
loop, the code also understands the transaction flag "fromOutside"
and disables propagation when it's present.
dispatchInner(tr) {
let {state, transactions} = this.innerView.state.applyTransaction(tr)
this.innerView.updateState(state)
if (!tr.getMeta("fromOutside")) {
let outerTr = this.outerView.state.tr, offsetMap = StepMap.offset(this.getPos() + 1)
for (let i = 0; i < transactions.length; i++) {
let steps = transactions[i].steps
for (let j = 0; j < steps.length; j++)
outerTr.step(steps[j].map(offsetMap))
}
if (outerTr.docChanged) this.outerView.dispatch(outerTr)
}
}
为了能干净地处理来自外部的更新(例如通过协同编辑,或当用户撤销某些由外部编辑器处理的操作时),节点视图的 update 方法会仔细找出它当前内容与新节点内容之间的差异。它只替换发生变化的部分,以便尽可能让光标留在原地。
To be able to cleanly handle updates from outside (for example through
collaborative editing, or when the user undoes something, which is
handled by the outer editor), the node view's
update method carefully finds the
difference between its current content and the content of the new
node. It only replaces the changed part, in order to leave the cursor
in place whenever possible.
update(node) {
if (!node.sameMarkup(this.node)) return false
this.node = node
if (this.innerView) {
let state = this.innerView.state
let start = node.content.findDiffStart(state.doc.content)
if (start != null) {
let {a: endA, b: endB} = node.content.findDiffEnd(state.doc.content)
let overlap = start - Math.min(endA, endB)
if (overlap > 0) { endA += overlap; endB += overlap }
this.innerView.dispatch(
state.tr
.replace(start, endB, node.slice(start, endA))
.setMeta("fromOutside", true))
}
}
return true
}
最后,节点视图还要处理销毁,以及回应哪些事件和变更应由外部编辑器处理。
Finally, the nodeview has to handle destruction and queries about which events and mutations should be handled by the outer editor.
destroy() {
if (this.innerView) this.close()
}
stopEvent(event) {
return this.innerView && this.innerView.dom.contains(event.target)
}
ignoreMutation() { return true }
}
我们可以像这样启用我们的 schema 和节点视图,来创建一个真正的编辑器。
We can enable our schema and node view like this, to create an actual editor.
import {EditorState} from "prosemirror-state"
import {DOMParser} from "prosemirror-model"
import {EditorView} from "prosemirror-view"
import {exampleSetup} from "prosemirror-example-setup"
window.view = new EditorView(document.querySelector("#editor"), {
state: EditorState.create({
doc: DOMParser.fromSchema(footnoteSchema).parse(document.querySelector("#content")),
plugins: exampleSetup({schema: footnoteSchema, menuContent: menu.fullMenu})
}),
nodeViews: {
footnote(node, view, getPos) { return new FootnoteView(node, view, getPos) }
}
})