Linting example
The browser DOM serves its purpose—representing complex webpages—very
well. But its huge scope and loose structure makes it difficult to
make assumptions about. A document model that represents a smaller set
of documents can be easier to reason about.
This example implements a simple document
linter that finds
problems in your document, and makes it easy to fix them.
Lint example
This is a sentence ,but the comma isn't in the right place.
Too-minor header
This is an image without alt text.
You can hover over the icons on the right to see what the
problem is, click them to select the relevant text, and, obviously,
double-click them to automatically fix it (if supported).
The first part of this example is a function that, given a document,
produces an array of problems found in that document. We'll use the
descendants
method to easily iterate
over all nodes in a document. Depending on the type of node, different
types of problems are checked for.
Each problem is represented as an object with a message, a start, and
an end, so that they can be displayed and highlighted. The objects may
also optionally have a fix
method, which can be called (passing the
view) to fix the problem.
const badWords = /\b(obviously|clearly|evidently|simply)\b/ig
const badPunc = / ([,\.!?:]) ?/g
function lint(doc) {
let result = [], lastHeadLevel = null
function record(msg, from, to, fix) {
result.push({msg, from, to, fix})
}
doc.descendants((node, pos) => {
if (node.isText) {
let m
while (m = badWords.exec(node.text))
record(`Try not to say '${m[0]}'`,
pos + m.index, pos + m.index + m[0].length)
while (m = badPunc.exec(node.text))
record("Suspicious spacing around punctuation",
pos + m.index, pos + m.index + m[0].length,
fixPunc(m[1] + " "))
} else if (node.type.name == "heading") {
let level = node.attrs.level
if (lastHeadLevel != null && level > lastHeadLevel + 1)
record(`Heading too small (${level} under ${lastHeadLevel})`,
pos + 1, pos + 1 + node.content.size,
fixHeader(lastHeadLevel + 1))
lastHeadLevel = level
} else if (node.type.name == "image" && !node.attrs.alt) {
record("Image without alt text", pos, pos + 1, addAlt)
}
})
return result
}
The helper utilities that are used to provide fix commands look like
this.
function fixPunc(replacement) {
return function({state, dispatch}) {
dispatch(state.tr.replaceWith(this.from, this.to,
state.schema.text(replacement)))
}
}
function fixHeader(level) {
return function({state, dispatch}) {
dispatch(state.tr.setNodeMarkup(this.from - 1, null, {level}))
}
}
function addAlt({state, dispatch}) {
let alt = prompt("Alt text", "")
if (alt) {
let attrs = Object.assign({}, state.doc.nodeAt(this.from).attrs, {alt})
dispatch(state.tr.setNodeMarkup(this.from, null, attrs))
}
}
The way the plugin will work is that it'll keep a set of decorations
that highlight problems and inserts an icon next to them. CSS is used
to position the icon on the right side of the editor, so that it
doesn't interfere with the document flow.
import {Decoration, DecorationSet} from "prosemirror-view"
function lintDeco(doc) {
let decos = []
lint(doc).forEach(prob => {
decos.push(Decoration.inline(prob.from, prob.to, {class: "problem"}),
Decoration.widget(prob.from, lintIcon(prob), {key: prob.msg}))
})
return DecorationSet.create(doc, decos)
}
function lintIcon(prob) {
return () => {
let icon = document.createElement("div")
icon.className = "lint-icon"
icon.title = prob.msg
icon.problem = prob
return icon
}
}
The problem object is stored in the icon DOM nodes, so that event
handlers can access them when handling clicks on the node. We'll make
a single click on an icon select the annotated region, and a double
click run the fix
method.
Recomputing the whole set of problems, and recreating the set of
decorations, on every change isn't very efficient, so for production
code you might want to consider an approach that can incrementally
update these. That'd be quite a bit more complex, but definitely
doable—the transaction can give you the information you need to figure
out what part of the document changed.
import {Plugin, TextSelection} from "prosemirror-state"
let lintPlugin = new Plugin({
state: {
init(_, {doc}) { return lintDeco(doc) },
apply(tr, old) { return tr.docChanged ? lintDeco(tr.doc) : old }
},
props: {
decorations(state) { return this.getState(state) },
handleClick(view, _, event) {
if (/lint-icon/.test(event.target.className)) {
let {from, to} = event.target.problem
view.dispatch(
view.state.tr
.setSelection(TextSelection.create(view.state.doc, from, to))
.scrollIntoView())
return true
}
},
handleDoubleClick(view, _, event) {
if (/lint-icon/.test(event.target.className)) {
let prob = event.target.problem
if (prob.fix) {
prob.fix(view)
view.focus()
return true
}
}
}
}
})