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: