Tracking changes

Changes are first-class values in ProseMirror. You can hold on to them, and do things with them. Such as rebasing them across other changes, inverting them, or inspecting them to see what they did.

This example uses those properties to allow you to “commit” your changes, to revert individual commits, and to find out which commit a piece of text originates from.

Commit message:

Remix on Glitch

Hover over commits to highlight the text they introduced.

This page won't list the whole source code for the example, only the most interesting parts.

The first thing we need is a way to track the commit history. An editor plugin works well for this, since it can observe changes as they come in. This is what the plugin's state value looks like:

class TrackState {
  constructor(blameMap, commits, uncommittedSteps, uncommittedMaps) {
    // The blame map is a data structure that lists a sequence of
    // document ranges, along with the commit that inserted them. This
    // can be used to, for example, highlight the part of the document
    // that was inserted by a commit.
    this.blameMap = blameMap
    // The commit history, as an array of objects.
    this.commits = commits
    // Inverted steps and their maps corresponding to the changes that
    // have been made since the last commit.
    this.uncommittedSteps = uncommittedSteps
    this.uncommittedMaps = uncommittedMaps
  }

  // Apply a transform to this state
  applyTransform(transform) {
    // Invert the steps in the transaction, to be able to save them in
    // the next commit
    let inverted =
      transform.steps.map((step, i) => step.invert(transform.docs[i]))
    let newBlame = updateBlameMap(this.blameMap, transform, this.commits.length)
    // Create a new state—since these are part of the editor state, a
    // persistent data structure, they must not be mutated.
    return new TrackState(newBlame, this.commits,
                          this.uncommittedSteps.concat(inverted),
                          this.uncommittedMaps.concat(transform.mapping.maps))
  }

  // When a transaction is marked as a commit, this is used to put any
  // uncommitted steps into a new commit.
  applyCommit(message, time) {
    if (this.uncommittedSteps.length == 0) return this
    let commit = new Commit(message, time, this.uncommittedSteps,
                            this.uncommittedMaps)
    return new TrackState(this.blameMap, this.commits.concat(commit), [], [])
  }
}

The plugin itself does little more than watch transactions and update its state. When a meta property tagged by the plugin is present on the transaction, it is a commit transaction, and the property's value is the commit message.

import {Plugin} from "prosemirror-state"

const trackPlugin = new Plugin({
  state: {
    init(_, instance) {
      return new TrackState([new Span(0, instance.doc.content.size, null)], [], [], [])
    },
    apply(tr, tracked) {
      if (tr.docChanged) tracked = tracked.applyTransform(tr)
      let commitMessage = tr.getMeta(this)
      if (commitMessage) tracked = tracked.applyCommit(commitMessage, new Date(tr.time))
      return tracked
    }
  }
})

Tracking history like this allows for all kinds of useful things, such as figuring out who added a given piece of code, and when. Or reverting individual commits.

Reverting an old steps requires rebasing the inverted form of those steps over all intermediate steps. That is what this function does.

import {Mapping} from "prosemirror-transform"

function revertCommit(commit) {
  let trackState = trackPlugin.getState(state)
  let index = trackState.commits.indexOf(commit)
  // If this commit is not in the history, we can't revert it
  if (index == -1) return

  // Reverting is only possible if there are no uncommitted changes
  if (trackState.uncommittedSteps.length)
    return alert("Commit your changes first!")

  // This is the mapping from the document as it was at the start of
  // the commit to the current document.
  let remap = new Mapping(trackState.commits.slice(index)
                          .reduce((maps, c) => maps.concat(c.maps), []))
  let tr = state.tr
  // Build up a transaction that includes all (inverted) steps in this
  // commit, rebased to the current document. They have to be applied
  // in reverse order.
  for (let i = commit.steps.length - 1; i >= 0; i--) {
    // The mapping is sliced to not include maps for this step and the
    // ones before it.
    let remapped = commit.steps[i].map(remap.slice(i + 1))
    if (!remapped) continue
    let result = tr.maybeStep(remapped)
    // If the step can be applied, add its map to our mapping
    // pipeline, so that subsequent steps are mapped over it.
    if (result.doc) remap.appendMap(remapped.getMap(), i)
  }
  // Add a commit message and dispatch.
  if (tr.docChanged)
    dispatch(tr.setMeta(trackPlugin, `Revert '${commit.message}'`))
}

Due to the implicit conflict resolution when moving changes across each other, outcomes of complicated reverts, where later changes touch the same content, can sometimes be unintuitive. In a production application, it may be desirable to detect such conflicts and provide the user with an interface to resolve them.