Example: Undoable Effects
By default, the history extension only tracks
changes to the document and selection, and undoing will only roll back
those, not any other part of the editor state.
Sometimes, you do need other actions on that state to be undoable. If
you model those actions as state effects, it is
possible to wire such functionality into the core history module. The
way you do that is by registering your effect to be
invertable. When the history sees a
transaction with such an effect, it'll store its inverse, and apply
that when the transaction is undone.
Let's go through an example of an extension that allows the user to
highlight parts of the document, and undo that highlighting.
We'll keep the information about highlighted ranges in a state
field, and define effects to add and remove such
ranges.
import {StateEffect, ChangeDesc} from "@codemirror/state"
const addHighlight = StateEffect.define<{from: number, to: number}>({
map: mapRange
})
const removeHighlight = StateEffect.define<{from: number, to: number}>({
map: mapRange
})
function mapRange(range: {from: number, to: number}, change: ChangeDesc) {
let from = change.mapPos(range.from), to = change.mapPos(range.to)
return from < to ? {from, to} : undefined
}
Such effects can be added to a transaction, and inspected up by code
looking at the transaction. Since the effect contain document
positions, they need to define a mapping function in order to be
adjusted properly when, for example, a change is made with the
addToHistory
flag set to false,
causing an undone effect to be applied to a different document than
the one it was originally made for.
We define a state field holding a range set of
decorations representing the highlighted ranges. Whenever a
transaction that with highlighter-related effects comes in, the
field's update
function applies those effects.
import {Decoration, DecorationSet} from "@codemirror/view"
import {StateField} from "@codemirror/state"
const highlight = Decoration.mark({
attributes: {style: `background-color: rgba(255, 50, 0, 0.3)`}
})
const highlightedRanges = StateField.define({
create() {
return Decoration.none
},
update(ranges, tr) {
ranges = ranges.map(tr.changes)
for (let e of tr.effects) {
if (e.is(addHighlight))
ranges = addRange(ranges, e.value)
else if (e.is(removeHighlight))
ranges = cutRange(ranges, e.value)
}
return ranges
},
provide: field => EditorView.decorations.from(field)
})
In order to make sure our range set doesn't contain overlapping or
needlessly fragmented ranges, these helper methods clear or add a
range by replacing all the highlights that touch the given range with
either a single continuous range, when adding, or only the pieces of
the old ranges that stuck out of the cleared region, when removing.
function cutRange(ranges: DecorationSet, r: {from: number, to: number}) {
let leftover = []
ranges.between(r.from, r.to, (from, to, deco) => {
if (from < r.from) leftover.push(deco.range(from, r.from))
if (to > r.to) leftover.push(deco.range(r.to, to))
})
return ranges.update({
filterFrom: r.from,
filterTo: r.to,
filter: () => false,
add: leftover
})
}
function addRange(ranges: DecorationSet, r: {from: number, to: number}) {
ranges.between(r.from, r.to, (from, to) => {
if (from < r.from) r = {from, to: r.to}
if (to > r.to) r = {from: r.from, to}
})
return ranges.update({
filterFrom: r.from,
filterTo: r.to,
filter: () => false,
add: [highlight.range(r.from, r.to)]
})
}
Now we can define our effect-inversion logic. The function we give to
invertedEffects
is called for every
transaction, and returns an array of effects that the history should
store alongside the inverse of that transaction.
So this function turns effects that add highlights into effects that
remove them, and vice versa.
And because deleting a region around a highlight also deletes the
highlight, and we might want to restore them when undoing the
deletion, the function also iterates over all replaced ranges and
creates a highlight effect for any covered highlight in them.
import {invertedEffects} from "@codemirror/commands"
const invertHighlight = invertedEffects.of(tr => {
let found = []
for (let e of tr.effects) {
if (e.is(addHighlight)) found.push(removeHighlight.of(e.value))
else if (e.is(removeHighlight)) found.push(addHighlight.of(e.value))
}
let ranges = tr.startState.field(highlightedRanges)
tr.changes.iterChangedRanges((chFrom, chTo) => {
ranges.between(chFrom, chTo, (rFrom, rTo) => {
let from = Math.max(chFrom, rFrom), to = Math.min(chTo, rTo)
if (from < to) found.push(addHighlight.of({from, to}))
})
})
return found
})
These two commands apply our effects to any selected ranges.
import {EditorView} from "@codemirror/view"
function highlightSelection(view: EditorView) {
view.dispatch({
effects: view.state.selection.ranges.filter(r => !r.empty)
.map(r => addHighlight.of(r))
})
return true
}
function unhighlightSelection(view: EditorView) {
let highlighted = view.state.field(highlightedRanges)
let effects = []
for (let sel of view.state.selection.ranges) {
highlighted.between(sel.from, sel.to, (rFrom, rTo) => {
let from = Math.max(sel.from, rFrom), to = Math.min(sel.to, rTo)
if (from < to) effects.push(removeHighlight.of({from, to}))
})
}
view.dispatch({effects})
return true
}
Note that unhighlightSelection
only creates effects for previously
highlighted ranges that overlap the selection. If we had simply
created them for the entire selected ranges, inverting those effects
could cause things to be highlighted that were not previously
highlighted.
import {keymap} from "@codemirror/view"
const highlightKeymap = keymap.of([
{key: "Mod-h", run: highlightSelection},
{key: "Shift-Mod-h", run: unhighlightSelection}
])
export function rangeHighlighting() {
return [
highlightedRanges,
invertHighlight,
highlightKeymap
]
}
If we tie all that together into an extension, it makes an editor
behave like this: