Paid features

Version history

npm i @matrajs/versions

Snapshots of the document, a real diff between any two of them — block pairing and then word runs inside a changed block, not a line-by-line text compare — and a restore that lands as a single undo step.

This package is commercial. Free to evaluate, develop against, test with, teach with and use in personal projects · a subscription buys running it in production. Nothing phones home and there is no runtime licence check.

Setting it up

import { createEditor, starterKit } from '@matrajs/core'
import { localVersionStore, versions } from '@matrajs/versions'

const editor = createEditor({
  extensions: [
    ...starterKit,
    versions({
      idleMs: 30_000,
      keep: 50,
      store: localVersionStore('doc-42'),
      onChange: (state) => render(state.versions, state.diff),
    }),
  ] as const,
})
OptionType
idleMsnumber | null Snapshot once the document has been still this long. null turns automatic snapshots off and leaves them to snapshotVersion. A version per keystroke is not history, it is a keylogger with a nicer name.
keepnumber How many to hold. The oldest go first · the very first one never does.
storeVersionStore Where the list lives between visits. Without one it lives until reload.
onChange(state) => void Called whenever the list or the preview changes.
now() => number Where "now" comes from. Injected rather than reached for, because a test that has to sleep to make two versions differ is a test that fails on a slow machine.

A first version — Opened — exists from the moment the editor does, mounted or not, so a headless editor in a test, a server render or an import job always has a "before" to compare against.

Commands

Command
snapshotVersion(label?) Takes one now. Returns false when nothing has moved since the last version — a list of identical entries is a list nobody will scroll.
restoreVersion(id) Puts that document back. Takes a Before restore snapshot first, so the way back is the version list rather than undo · and undo is not where anybody thinks to look for their work.
previewVersion(id | null) Decorate the document with the diff against that version. null clears it.
forgetVersion(id)Drop one from the list.
import { versionList } from '@matrajs/versions'

// Take it before the rewrite, not after. A snapshot of a document that has
// not changed since the last one is refused — there is already a version
// holding exactly that content, and it returns false.
editor.commands.snapshotVersion('before the rewrite')

// … the rewrite happens …

// Newest last, so the version you just took is at the end.
const list = versionList(editor)
const before = list.at(-1)
if (before) {
  editor.commands.previewVersion(before.id)   // show what changed
  editor.commands.restoreVersion(before.id)   // go back · one undo step
}
A restore is isolated as its own undo step, whatever was typed a second before it. Undo grouping is a kindness to typing and a hazard to anything deliberate.

A version

FieldType
idnumberContinues from what the store loaded, rather than restarting at one and colliding.
labelstringDefaults to Snapshot.
atnumberEpoch milliseconds, from the clock you supplied.
docDocNodeThe document as it was.
sizenumberCharacters at the time.

Persisting

localVersionStore(key) is a localStorage implementation, and the interface is two methods if you would rather keep them on a server.

interface VersionStore {
  /** Sync, so the first render already has them. */
  load(): Version[] | null
  /** Called whenever the list changes · debouncing it is yours to decide. */
  save(versions: Version[]): void
}

The diff on its own

The diff is exported separately, so a review screen can compare two documents without an editor anywhere near it — on a server, in a worker, in a test.

import { diffDocs } from '@matrajs/versions'

const diff = diffDocs(before, after)
// { blocks, added, removed, changed, same }
Export
diffDocs(a, b)DocDiff — blocks paired, with word runs inside each changed one.
diffWords(a, b)WordRun[] · { kind, text } where kind is added, removed or same.
textOf(node)The text of a node, the way a reader sees it.
sizeOf(node)Its size in document coordinates.
blockStarts(doc)Where each top-level block begins.
versionClasses{ added, changed, removed } — the class names the preview decorations use.
versionDiffCSSThe stylesheet for them. See Styling.

A BlockChange carries before and after indices — one of them -1 when the block is new or gone — so a side-by-side view can line the two documents up without matching text a second time.

Edit this page on GitHub