编辑脚注
这个示例演示了如何在 ProseMirror 中实现类似脚注一样的东西:
这个段落有一个脚注它是一个个文本碎片被放在一个页面或者章节的底部,提供额外的 评论 或者 引用 信息。 。和其他脚注 更多脚注信息 。
移动光标到脚注,或者点击以编辑它。
脚注看起来应该被实现为一种带有内容的内联节点--他们出现在其他内联内容之间,但是它的内容并不是它外层文本 block 的内容。
因此让我们先像下面一样定义它们:
import {schema} from "prosemirror-schema-basic"
import {Schema} from "prosemirror-model"
const footnoteSpec = {
group: "inline",
content: "inline*",
inline: true,
atom: true,
toDOM: () => ["footnote", 0],
parseDOM: [{tag: "footnote"}]
}
const footnoteSchema = new Schema({
nodes: schema.spec.nodes.addBefore("image", "footnote", footnoteSpec),
marks: schema.spec.marks
})
对于有内容的内联节点,ProseMirror 处理的并不太好,至少默认并不支持这种类型。
所以你需要为这种类型的节点写一个 node view,
它可以以某种方式管理这种带内容的内联节点出现在编辑器中的方式。
因此这就是我们将要做的事情。本示例中的脚注以一个数字的形式显示在文档中。而事实上,
他们仅仅是 <footnote>
节点,我们需要依赖 CSS 来将数字添加上去:
import {StepMap} from "prosemirror-transform"
import {keymap} from "prosemirror-keymap"
import {undo, redo} from "prosemirror-history"
class FootnoteView {
constructor(node, view, getPos) {
this.node = node
this.outerView = view
this.getPos = getPos
this.dom = document.createElement("footnote")
this.innerView = null
}
只有当 node view 被选中的时候,用户才可以与它的内容做交互(它将在用户的光标放到上去的时候或者鼠标点击的时候才会被选中,
因为我们对这种类型的节点设置了 atom
属性)。下面这两种方法处理 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 view,内容是节点的内容。在子编辑器中的 Transaction 被特殊的由父编辑器的 dispatchInner
方法处理。
Mod-z 和 y 按键被绑定到 父编辑器 的 undo 和 redo 功能上。我们一会儿再来看它是如何做到的:
open() {
let tooltip = this.dom.appendChild(document.createElement("div"))
tooltip.className = "footnote-tooltip"
this.innerView = new EditorView(tooltip, {
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)
})]
}),
dispatchTransaction: this.dispatchInner.bind(this),
handleDOMEvents: {
mousedown: () => {
if (this.outerView.hasFocus()) this.innerView.focus()
}
}
})
}
close() {
this.innerView.destroy()
this.innerView = null
this.dom.textContent = ""
}
当子编辑器的内容改变的时候应该如何处理?我们可以仅仅是拿到内容,然后将在外部编辑器的脚注的内容给重置为该内容,但是这对于 undo 历史和协同编辑来说并不可行。
一个更好的实现是简单的将自于子编辑器的 setps,加上合适的偏移位置,应用到外部文档中去。
我们需要小心的处理 appended transactions,同时需要能够处理来自外部编辑器的更新而不造成一个无限循环,
下面代码也同样理解 transaction 的 「fromOutside」
的含义,会在它出现的时候不让其向外传播(冒泡):
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)
}
}
为了能够干净的处理来自外部编辑器的更新(比如协同编辑或者由外部编辑器处理的用户 undo 的一些操作的时候),node view 的 update
方法将会仔细的查看当前内容和节点内容的不同。它只替换掉发生变化的部分,尽可能的保证光标在原地不动:
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
}
最后,nodevidw 需要处理销毁事件,以及告诉外部编辑器应该处理哪些来自于 node view 的事件和变化:
destroy() {
if (this.innerView) this.close()
}
stopEvent(event) {
return this.innerView && this.innerView.dom.contains(event.target)
}
ignoreMutation() { return true }
}
我们可以像下面这样启用 schema 和 node view,以创建一个真实编辑器:
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) }
}
})