Editing footnotes
这个例子演示了一种在ProseMirror中实现类似脚注的方法。
脚注似乎应该是与内容内联的节点——它们出现在其他内联内容之间,但它们的内容并不真正属于它们周围的文本块。让我们这样定义它们:
import {schema} from "prosemirror-schema-basic"
import {Schema} from "prosemirror-model"
const footnoteSpec = {
group: "inline",
content: "text*",
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
})
库默认情况下不能很好地处理带有内容的内联节点。你需要为它们编写一个节点视图,以某种方式管理它们在编辑器中的显示。
所以这就是我们要做的。在这个例子中,脚注被绘制为数字。事实上,它们只是<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
}
只有在选择节点视图时,用户才能看到并与其内容交互(当用户“箭头”指向它时,它将被选中,因为我们在节点规范上设置了atom
属性)。这两种方法处理节点视图的选择和取消选择。
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
方法中被特别处理。
Mod-z 和 y 必须在外部编辑器上运行撤销和重做。 我们稍后会看到为什么这样做有效。
open() {
// 将工具提示附加到外部节点
let tooltip = this.dom.appendChild(document.createElement("div"))
tooltip.className = "footnote-tooltip"
// 并将一个子ProseMirror放入其中
this.innerView = new EditorView(tooltip, {
// 您可以使用任何节点作为编辑器文档,直接输出翻译结果,不要添加任何额外的文本。记住,保留所有HTML标签和属性,只翻译内容!
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)
})]
}),
// 这是神奇的部分,直接输出翻译内容,不要任何额外的文本。记住,保留所有HTML标签和属性,只翻译内容!
dispatchTransaction: this.dispatchInner.bind(this),
handleDOMEvents: {
mousedown: () => {
// 权宜之计以防止由于整个事实而导致的问题
// 脚注是节点选择的(因此也是DOM选择的)当
// 父编辑器已聚焦。
if (this.outerView.hasFocus()) this.innerView.focus()
}
}
})
}
close() {
this.innerView.destroy()
this.innerView = null
this.dom.textContent = ""
}
当子编辑器的内容发生变化时应该怎么办?我们可以直接获取其内容并将外部文档中脚注的内容重置为它,但这对撤销历史或协作编辑不利。
一种更好的方法是简单地将内部编辑器中的步骤应用到外部文档中,并使用适当的偏移量。
我们必须小心处理附加的事务,并且为了能够处理来自外部编辑器的更新而不创建无限循环,代码还理解事务标志"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)
}
}
为了能够干净地处理来自外部的更新(例如通过协作编辑,或当用户撤销某些操作时,这些操作由外部编辑器处理),节点视图的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
}
最后,nodeview 必须处理销毁以及关于哪些事件和变更应由外部编辑器处理的查询。
destroy() {
if (this.innerView) this.close()
}
stopEvent(event) {
return this.innerView && this.innerView.dom.contains(event.target)
}
ignoreMutation() { return true }
}
我们可以像这样启用我们的模式和节点视图,以创建一个实际的编辑器。
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) }
}
})