Editing footnotes
This example demonstrates one way to implement something like
footnotes in ProseMirror.
This paragraph has a footnoteWhich is a piece of text placed at the bottom of a page or chapter, providing additional comments or citations. in it. And anotherSome more footnote text. one.
Move onto or click on a footnote number to edit it.
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,
atom: true,
toDOM: () => ["footnote", 0],
parseDOM: [{tag: "footnote"}]
}
const footnoteSchema = new Schema({
nodes: schema.spec.nodes.addBefore("image", "footnote", footnoteSpec),
marks: schema.spec.marks
})
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.
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) {
this.node = node
this.outerView = view
this.getPos = getPos
this.dom = document.createElement("footnote")
this.innerView = null
}
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()
}
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 and y are bound to run undo and redo on the outer editor.
We'll see in a moment why that works.
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 = ""
}
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.
A nicer approach is to simply apply the steps from the inner editor,
with an appropriate offset, to the outer document.
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)
}
}
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 }
}
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) }
}
})