Revision history
On this page
Revision history is opt-in. It adds snapshots (manual, automatic and after saves), a history panel that compares any version with the current document or with another version, and restore as a new, undoable revision. Restoring never rewinds or deletes history.
Two packages:
@better-rich-text/revisionshas no DOM dependency. It holds the revision model, theRevisionStorecontract with memory, IndexedDB and HTTP stores, the semantic diff and HTML rendering of a diff. It runs in browsers, workers and servers.@better-rich-text/plugin-revisionsholds the editor plugin (commands, hooks, automatic snapshots) and the browser UI (thehistorytoolbar item and panel).
Quick start
import { createRichTextEditor, standardPreset } from '@better-rich-text/core';
import { createRevisionsPlugin } from '@better-rich-text/plugin-revisions';
import { indexedDbRevisionStore } from '@better-rich-text/revisions';
const editor = createRichTextEditor('#editor', {
content: savedHtml,
plugins: [
createRevisionsPlugin({
store: indexedDbRevisionStore(),
documentId: 'report-42',
author: { id: user.id, name: user.name },
autoSnapshot: { everyMs: 5 * 60_000, onSave: true },
}),
],
toolbar: `${standardPreset.toolbar} | history`,
});Pass a stable documentId with any persistent store. Without one, the plugin uses the envelope's documentId, which a fresh editor generates at random.
Options
| Option | Default | Meaning |
|---|---|---|
store |
memoryRevisionStore() |
Where revisions live. |
documentId |
envelope documentId |
Key in the store. |
author |
none | { id, name }, or a function read at snapshot time. |
autoSnapshot |
{ everyMs: 300000, onSave: true } |
false turns automatic snapshots off. |
initialSnapshot |
true |
Records the opened document as an import revision when the store has none. |
pageSize |
50 |
Revisions fetched per page. |
diff |
see Budgets | Comparison budgets. |
labels |
English | Overrides for every string in the history panel. |
Automatic snapshots happen at most once every everyMs while the document is changing. The timer starts at the first change after a snapshot, so an idle document gets no snapshots. A snapshot is skipped when the content matches the newest revision. With onSave, a snapshot follows every save.status action whose state is saved (from @better-rich-text/plugin-save or any host that emits it).
Revisions
interface Revision {
id: string; // stable, created where the snapshot is taken: r<base36 ms>-<random>
documentId: string;
createdAt: string; // ISO 8601
author?: { id: string; name: string };
label?: string; // a named version
kind: 'auto' | 'manual' | 'restore' | 'import';
parentId?: string; // newest revision when this one was taken
restoredFrom?: string; // for kind 'restore': the source revision
reason?: string; // 'interval', 'save', 'before-restore', 'restore', 'initial'
contentRevision: number; // envelope contentRevision at snapshot time
document: DocumentEnvelope;
summary: { added: number; removed: number; changedBlocks: number }; // against the parent
}Revisions are immutable. Only label can change. summary counts Unicode code points added and removed (an inline atom counts as one) and the leaf blocks touched. Use createRevision() to build one, validateRevision() to check untrusted input, and toSummary() to drop the document.
Revisions loaded from a store are validated, then decoded with the editor's schema, before they are compared or restored.
Commands
All commands have runtime input schemas, so they also appear in editor.toolDefinitions().
| Command | Input | Result |
|---|---|---|
revisions.snapshot |
{ label? } |
{ id } (null if the beforeSnapshot filter vetoed it). Saved in the background. |
revisions.list |
none | { revisions, hasMore }: loaded summaries, newest first. |
revisions.restore |
{ id } |
{ revisionId, restoredFrom, changed } |
revisions.rename |
{ id, label: string | null } |
{ id } |
revisions.delete |
{ id } |
{ id } |
revisions.compare |
{ fromId, toId } (toId may be "current") |
A DocumentDiff |
Commands run synchronously, so restore and compare need the revisions to be loaded already. The panel loads them for you. In code, use the service:
const revisions = editor.service<RevisionsController>('revisions')!;
await revisions.restore(id); // loads, then runs revisions.restore
const diff = await revisions.compare(id, 'current');
await revisions.loadMore();
await revisions.whenIdle(); // every store request settledRestore
revisions.restore does the following:
If the document differs from the newest revision, it records an
autorevision with reasonbefore-restore, so unsaved work stays in history.It replaces the document root and metadata with the revision's content in one transaction. One undo step reverses it.
After commit, it records a
restorerevision withrestoredFromset to the source, then firesrevisions.restored.
Undo after a restore brings the previous content back. It does not remove the restore revision. Restoring content that already matches the document changes nothing and returns changed: false.
Hooks
| Hook | Kind | Signature |
|---|---|---|
revisions.beforeSnapshot |
filter | (revision: Revision | false, context: { kind, reason }) => Revision | false. Return a modified revision (add a label, strip metadata) or false to skip it. |
revisions.created |
action | (revision) after the store accepts a revision. |
revisions.restored |
action | ({ revision, source }) after the restore revision is stored. |
Store failures do not throw into the editor. The revision stays in the list marked "Not saved", controller.status becomes { state: 'error', message }, and controller.retryFailed() tries again.
Stores
interface RevisionStore {
list(documentId: string, options?: { cursor?: string | null; limit?: number }): Promise<{ items: RevisionSummary[]; nextCursor: string | null }>;
get(documentId: string, id: string): Promise<Revision | null>;
put(revision: Revision): Promise<RevisionSummary>;
rename(documentId: string, id: string, label: string | null): Promise<RevisionSummary>;
delete(documentId: string, id: string): Promise<void>;
}Every store follows this contract. The shared test suite runs it against the memory and HTTP stores.
listreturns summaries without documents, newest first (createdAtdescending, theniddescending). The limit is clamped to 1–200 (default 50). Cursors are opaque and key-based, so a revision added while paging does not shift older pages.getresolvesnullfor an unknown id.putis idempotent: storing an identical record again returns its summary. A different record under an existing id rejects withRevisionStoreErrorcodeconflict.renametrims the label.nullor an empty string removes it. An unknown id rejects withnot-found.deleteresolves even when the revision is already gone.
Errors are RevisionStoreError with a code of not-found, conflict, invalid, unauthorized or unavailable, plus an HTTP status when one exists.
Available stores:
memoryRevisionStore(initial?): copies records on the way in and out.indexedDbRevisionStore({ dbName?, storeName? }): keyed by[documentId, id], with abyDocumentindex on[documentId, createdAt, id].httpRevisionStore({ baseUrl, headers?, fetch? }):headersmay be a function, called per request so tokens can refresh.
REST contract
httpRevisionStore calls the routes below under baseUrl, which is for example https://api.example.com/v1. They match the /v1/documents/:id/revisions routes in the specification (14.4). Bodies are JSON. The store validates every response before use.
| Request | Body | Success | Errors |
|---|---|---|---|
GET /documents/:documentId/revisions?limit=50&cursor=… |
none | 200 { "items": RevisionSummary[], "nextCursor": string | null } |
401/403 unauthorized |
GET /documents/:documentId/revisions/:id |
none | 200 Revision (with document) |
404 → get resolves null |
POST /documents/:documentId/revisions |
Revision |
200/201 RevisionSummary |
409 different content under that id; 400/422 invalid |
PATCH /documents/:documentId/revisions/:id |
{ "label": string | null } |
200 RevisionSummary |
404 |
DELETE /documents/:documentId/revisions/:id |
none | 204 |
404 is treated as success |
Server requirements:
POSTsendsIdempotency-Key: <revision id>. Accept a client-generatedid. A repeated identicalPOSTmust succeed, and different content under an existing id must return409.Never change
document,kind,createdAtorcontentRevisionafter storage. Revisions are immutable apart fromlabel.Authorise every route per document. Revisions contain full document content.
Validate the
documentenvelope against the document's schema before storing it.Return summaries without
documentfrom the list route.
Collaboration later
The model is built to be fed by a collaboration service:
Ids are stable and created where the snapshot is taken, so a server can accept client snapshots idempotently or create its own revisions for accepted server revisions.
contentRevisionrecords the envelope revision a snapshot was taken at. A server can map it to its accepted command log (document_revisionsin 14.3).A server-side store can take revisions from the service and ignore client
puts. The plugin only needslistandgetto browse, compare and restore. A restore is an ordinary document edit, so it goes through the same permissions and validation as any other command.
Semantic diff
import { diffDocuments, diffToHtml } from '@better-rich-text/revisions';
const diff = diffDocuments(before.root, after.root);
diff.changes; // typed change list
diff.root; // merged diff tree for rendering
diff.summary; // { added, removed, changedBlocks, moved, formatted }
diff.limited; // null, or why the result was coarsened
const html = diffToHtml(diff, editor.schema, { view: 'inline' });The diff is deterministic: the same inputs give the same output, which can be serialised as JSON. It compares document trees, not HTML:
Blocks by id. Within a parent, children matched by id that kept their order (a longest increasing subsequence) anchor the alignment. The others are moves. A block whose id now sits under another parent is also a move (
block-moved, withfromPathandtoPath). Moves are not counted as added or removed text.Blocks by content. Unmatched blocks between anchors are paired by identical content (Myers alignment over content hashes), then by similar text of the same type (word Dice coefficient of at least 0.5). Documents whose ids were regenerated, for example by an HTML import, still get word-level diffs. Turn this off with
matchByContent: false.Text. Matched textblocks are aligned with Myers' algorithm over word tokens. Tokens never split a grapheme cluster, so emoji sequences, combining marks and flags stay whole. Scripts written without spaces are tokenised per character. Changes separated only by whitespace are merged into one. A short replacement inside a single word is refined to characters ("colo
ur").Formatting. Over unchanged text, marks are compared character by character, producing
format-changedwithaddedandremovedmarks.Atoms. Inline atoms such as images match by id (or by type and attributes when ids differ) and report
atom-inserted,atom-deletedoratom-changedwith attribute differences. Block atoms and containers reporttype-changedandattrs-changed. Tables diff row by row and cell by cell, by id.
Change kinds: block-inserted, block-deleted, block-moved, type-changed, attrs-changed, text-inserted, text-deleted, format-changed, atom-inserted, atom-deleted, atom-changed, content-changed (the block-level fallback). Paths are child-index paths: from the old document for deletions, from the new one otherwise. Text offsets are UTF-16 offsets into a textblock's inline text, where each inline atom counts as one U+FFFC. Deletions use the old block's offsets.
The merged tree (diff.root) holds DiffElement nodes (state: equal, inserted, deleted or modified; optional move: 'source' | 'target', previousType and previousAttrs) and DiffText runs (state: equal, inserted, deleted or formatted, with previousMarks). A moved block appears twice: a source ghost at its old position and the target at its new position.
Budgets
| Option | Default | Effect |
|---|---|---|
maxNodes |
60 000 | Above this many nodes (both documents together), textblocks are compared whole. |
maxTextLength |
2 000 000 | Same, for total text length. |
maxWork |
8 000 000 | Alignment steps for the whole diff. When they run out, the remaining changed blocks are compared whole. |
maxEditsPerBlock |
1500 | Edit-distance cap for one textblock. |
characterRefinement |
true |
Character-level refinement of short in-word edits. |
When a budget is hit, diff.limited is { reason: 'node-budget' | 'text-budget' | 'work-budget', message, degradedBlocks }. The affected blocks report content-changed and render as whole-block replacements. The panel shows the message above the comparison. Revision summaries use a smaller maxWork (2 000 000) so snapshots stay cheap.
HTML output
diffToHtml(diff, schema, { view, pretty, labels }) serialises through the schema's own HTML specs, so the output is escaped and URL-filtered like any other export.
view: 'inline'shows both sides merged.'before'shows the older version with deletions marked.'after'shows the newer version with insertions marked. The last two make up the side-by-side layout.Text:
<ins class="brt-diff-ins" data-brt-diff="inserted">,<del class="brt-diff-del" data-brt-diff="deleted">, and<span class="brt-diff-format" data-brt-diff="formatted" title="Formatting changed: Bold added">.Blocks keep their own tag and gain
class="brt-diff brt-diff-{inserted|deleted|moved|changed}",data-brt-diff, a text label indata-brt-diff-label("Added", "Removed", "Moved here", "Moved from here", "Changed") anddata-brt-moveon moves. Type and attribute changes are described intitle("Paragraph → Heading; align: none → center").Inline atoms are wrapped in
ins,delorspanwith the same classes.
DIFF_EXPORT_CSS is a small standalone stylesheet for exported diffs outside the editor.
History panel
Add history to the toolbar. The item appears only when the plugin is installed. The panel replaces the editing surface while it is open, so nothing can be typed into a document under comparison. Closing it returns focus to the editor.
The version list is grouped by day (Today, Yesterday, then dates). Each entry shows the time, the label or kind ("Original", "Autosaved", "Saved version", "Restored (10:42)", "Before restore"), the author,
+added −removed, and "Saving…" or "Not saved" when relevant.The list is a listbox: arrow keys, Home/End and Page Up/Down move the selection, Enter moves focus to the comparison, and Escape closes the panel. "Load older versions" fetches the next page.
The comparison shows the selected version against the current document, or another version picked in "Compare with". Changes always read forward in time. Choose Inline or Side by side.
Actions: Restore this version, Name this version (or Rename), Copy as HTML (copies the inline comparison as
text/htmlandtext/plain), and Save version in the list header.
Changes never rely on colour alone. Insertions are underlined, deletions are struck through, formatting changes have a dotted underline, and added, removed, moved or changed blocks get a text badge plus a bar pattern (deleted blocks also get hatching). ins and del include visually hidden "added" and "removed" markers for screen readers. Styles are injected once into @layer brt and use only --brt-* tokens (--brt-insertion, --brt-deletion, --brt-accent, surfaces and borders), so every theme and skin applies.
Limitations
Automatic snapshots run in the browser session that makes the edits. A collaboration server should create revisions itself.
Comments and suggestions are not part of the diff. Revisions store the envelope (annotations included), but only content is compared.
The IndexedDB store has no automated tests in this repository (there is no IndexedDB in the Node test environment). It shares the paging and conflict rules covered by the contract tests.
Retention (pruning old automatic revisions) is left to the store or server.