Better Rich Text Buy licence

Revision history

On this page
  1. Quick start
  2. Options
  3. Revisions
  4. Commands
  5. Restore
  6. Hooks
  7. Stores
  8. REST contract
  9. Collaboration later
  10. Semantic diff
  11. Budgets
  12. HTML output
  13. History panel
  14. Limitations

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/revisions has no DOM dependency. It holds the revision model, the RevisionStore contract 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-revisions holds the editor plugin (commands, hooks, automatic snapshots) and the browser UI (the history toolbar item and panel).

Quick start

TypeScript
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

OptionDefaultMeaning
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

TypeScript
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().

CommandInputResult
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:

TypeScript
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 settled

Restore

revisions.restore does the following:

  1. If the document differs from the newest revision, it records an auto revision with reason before-restore, so unsaved work stays in history.

  2. It replaces the document root and metadata with the revision's content in one transaction. One undo step reverses it.

  3. After commit, it records a restore revision with restoredFrom set to the source, then fires revisions.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

HookKindSignature
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

TypeScript
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.

  • list returns summaries without documents, newest first (createdAt descending, then id descending). 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.

  • get resolves null for an unknown id.

  • put is idempotent: storing an identical record again returns its summary. A different record under an existing id rejects with RevisionStoreError code conflict.

  • rename trims the label. null or an empty string removes it. An unknown id rejects with not-found.

  • delete resolves 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 a byDocument index on [documentId, createdAt, id].

  • httpRevisionStore({ baseUrl, headers?, fetch? }): headers may 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.

RequestBodySuccessErrors
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:

  • POST sends Idempotency-Key: <revision id>. Accept a client-generated id. A repeated identical POST must succeed, and different content under an existing id must return 409.

  • Never change document, kind, createdAt or contentRevision after storage. Revisions are immutable apart from label.

  • Authorise every route per document. Revisions contain full document content.

  • Validate the document envelope against the document's schema before storing it.

  • Return summaries without document from 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.

  • contentRevision records the envelope revision a snapshot was taken at. A server can map it to its accepted command log (document_revisions in 14.3).

  • A server-side store can take revisions from the service and ignore client puts. The plugin only needs list and get to 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

TypeScript
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:

  1. 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, with fromPath and toPath). Moves are not counted as added or removed text.

  2. 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.

  3. 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 ("colour").

  4. Formatting. Over unchanged text, marks are compared character by character, producing format-changed with added and removed marks.

  5. Atoms. Inline atoms such as images match by id (or by type and attributes when ids differ) and report atom-inserted, atom-deleted or atom-changed with attribute differences. Block atoms and containers report type-changed and attrs-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

OptionDefaultEffect
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 in data-brt-diff-label ("Added", "Removed", "Moved here", "Moved from here", "Changed") and data-brt-move on moves. Type and attribute changes are described in title ("Paragraph → Heading; align: none → center").

  • Inline atoms are wrapped in ins, del or span with 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/html and text/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.