History panel
The version history of a room for your app, as in Notion. VersionHistory lists the room's versions with their editors, previews one read-only with the blocks added, removed and changed since highlighted, and restores it or undoes the restore; the panel's markup is yours.
VersionHistory is the page history of Notion for a document kept by the room’s version history: the versions, newest first, the selected one previewed read-only, and Restore version. The preview highlights what changed since that version: the blocks added since in green, the blocks removed since in red, the blocks changed since in yellow. The package ships the controller; the panel’s markup is yours (the demo’s HistoryPanel.svelte, in the repository’s src/routes/chrome, is a complete one: the list on the right, the preview on the left).
<script lang="ts">
import { untrack } from 'svelte';
import {
Edytor,
VersionHistory,
type EdytorDocument,
type HistoryClient,
type VersionHistoryStatus
} from 'edytor';
let { client, document }: { client: HistoryClient; document: EdytorDocument } = $props();
// Made in the component's script: its effects list the versions and follow the document.
const history = untrack(() => new VersionHistory({ client, document }));
/** Each outcome in your words. */
const said: Record<VersionHistoryStatus, string> = {
'': '',
restoring: 'Restoring…',
restored: 'Version restored.',
unchanged: 'The page already matches this version.',
unavailable: 'This version is no longer available.',
undone: 'Restore undone.',
nothingToUndo: 'There is no restore to undo.',
denied: 'You do not have access to this history.',
failed: 'The history could not be reached.'
};
</script>
<section aria-label="Version history">
<ul role="listbox" aria-label="Versions" {@attach history.keys}>
{#each history.versions ?? [] as version, index (version.key)}
<li role="presentation">
<button type="button" {@attach history.option(index)} onclick={() => void history.select(version.key)}>
{history.dateOf(version)} · {version.slot === 'am' ? 'Morning' : 'Evening'} ·
{version.editors.join(', ')}
</button>
</li>
{/each}
</ul>
{#if history.preview}
{#key history.previewKey}
<Edytor readonly value={history.preview} plugins={history.previewPlugins} />
{/key}
{/if}
<button disabled={history.busy || !history.shown} onclick={() => void history.restore()}>
Restore version
</button>
{#if history.undoable}
<button onclick={() => void history.undo()}>Undo restore</button>
{/if}
<p role="status">{history.status && said[history.status]}</p>
</section>
<script lang="ts">
import { Edytor, createDocument, createHistoryClient } from 'edytor';
import Versions from './Versions.svelte';
let { id, token }: { id: string; token: string } = $props();
const document = createDocument();
const client = createHistoryClient({
server: 'https://example.com/history',
room: id,
params: { token }
});
let open = $state(false);
</script>
<Edytor {document} server="wss://example.com/rooms" room={id} params={{ token }} />
<button onclick={() => (open = true)}>Version history</button>
{#if open}
<dialog open>
<Versions {client} {document} />
<button onclick={() => (open = false)}>Close</button>
</dialog>
{/if}
The controller needs two things: a client, which reads and restores the versions, and the document the page edits, which it compares each version with. Pass the same plugins the page’s editor uses (new VersionHistory({ client, document, plugins })) so every kind renders in the preview as it does on the page. Its options are read once: a new client is a new VersionHistory (a {#key client} around your panel).
The client
The controller asks a HistoryClient for everything it shows and does:
| Method | Answer |
|---|---|
list() |
The versions, newest first: HistoryVersion[], the shape the room’s listHistory() returns ({ key, date, slot, bytes, blocks, editors, more, at, expiresAt }). |
read(key) |
That version as a JSONDoc, or null when it is gone. |
restore(key) |
Restore it: { status: 'applied' | 'noop' | 'refused', key, … }. |
undo() |
Undo the last restore: { status: 'applied' | 'noop' }. |
createHistoryClient({ server, room, params, fetch }) is the client for the HTTP door the room ships, routeDocumentHistory. Its requests go to <server>/<room>, the room percent-encoded as one path segment, as the provider sends it (a ws: or wss: server reads as http: or https:):
| Method | Request | Reads |
|---|---|---|
list() |
GET <url> |
the JSON list |
read(key) |
GET <url>?key=<key> |
the JSON version; 404 is null |
restore(key) |
POST <url>?restore=<key> |
the JSON result; 404 is { status: 'refused', key } |
undo() |
POST <url>?undo |
the JSON result |
params are added to every request (a token): an object, or a function read again at each request, so a refreshed token is used at once. Any other refusal throws a HistoryRequestError whose status is the HTTP status (401 expired, 403 denied or a read-only identity’s restore, 404 no history, 503 the room cannot serve it). A room id no request can carry (empty, . or .., over 256 characters, or with a lone surrogate) throws a TypeError at once.
Any object with the four methods is a client: your own API routes, a SvelteKit remote function, Durable Object RPC behind your server, or a stub in tests.
import type { HistoryClient } from 'edytor';
/** The versions through the app's own API, which calls the room over RPC. */
export const historyOf = (documentId: string): HistoryClient => {
const base = `/api/documents/${encodeURIComponent(documentId)}/versions`;
const json = async (response: Response) => {
if (!response.ok) throw new Error(`versions: ${response.status}`);
return response.json();
};
return {
list: () => fetch(base).then(json),
read: async (key) => {
const response = await fetch(`${base}/${encodeURIComponent(key)}`);
return response.status === 404 ? null : json(response);
},
restore: (key) =>
fetch(`${base}/${encodeURIComponent(key)}/restore`, { method: 'POST' }).then(json),
undo: () => fetch(`${base}/undo`, { method: 'POST' }).then(json)
};
};
The list
versions holds the versions, newest first (null while the first list loads). The controller lists them when it is made and selects the newest; refresh() lists them again, keeping the selection while it is listed. Write a row with titleOf(version), its date and half of the day (Oct 6, 2026 · Morning), in the room’s calendar (the history’s timeZone); editorsOf(version), its editors (ada, bob +2, or No editors for a slot only the server edited); and timeOf(version), when it was saved, in the reader’s time zone. Attach option(index) to each row: it is an option of your listbox, its one tab stop when selected (its highlight is yours, from version.key === history.selected); give the list {@attach history.keys}: ↑ and ↓ move the selection, Home and End go to the newest and the oldest. A click on a row calls select(key).
The preview
The selected version (selected) is read (client.read(key), reading meanwhile) and is preview: render it in a read-only <Edytor> of its own, value={history.preview} and plugins={history.previewPlugins} (the change highlights, then your plugins), keyed by previewKey (an editor reads its value once). It is a separate document: nothing done in the preview reaches the page. A version that is gone by the time it is read sets status to labels.unavailable.
Changes since the version
With document, the controller compares the version with the page, block by block, matching blocks by id (a restore keeps ids, so a block keeps its id across versions):
| Highlight | The block | A restore |
|---|---|---|
| Added since (green) | is on the page, not in the version | deletes it |
| Removed since (red) | is in the version, not on the page | brings it back |
| Changed since (yellow) | is in both, with another type, data or text (marks included) | rewrites it |
While highlight is on (the default; it is writable, bind:checked={history.highlight} drives it), the preview shows the version with the blocks added since inserted where the page has them, so all three show, and diff counts them (added, removed, changed; the demo’s legend says Same as the current page when there are none). Off, it shows the version alone. Each highlight is a tint over the block’s own row (not its children), with a bar on its left, drawn in the preview’s overlay, never inside its blocks.
The comparison follows the page: an edit on the page, yours or a collaborator’s, updates the highlights at once. A move is not a change, and neither is a change of a block’s children alone (the child is highlighted, not its parent); the document’s own data is not compared. A block inside a closed toggle shows no highlight. Past 2,000 changed blocks, only the first 2,000 are highlighted; the legend counts them all.
versionDiff(version, current) is the comparison alone: { preview, changes, added, removed, changed }, changes a Map from block id to 'added' | 'removed' | 'changed' and preview the version with the added blocks inserted. Use it to show changes in a UI of your own.
Restore and undo
restore() calls client.restore(key) for the selected version (busy meanwhile). The room writes the version as a forward edit that every client receives (what a restore does), so the page updates through its sync and the highlights clear. status is then restored (noop: unchanged; refused: unavailable, the version is gone, the list is read again). After a restore applied from this controller, undoable is true: undo() calls client.undo(), which undoes the room’s last restore (one level, whoever made it).
A 401 or 403 (a reader’s restore, an expired session) sets status to denied, any other failure to failed, and failed to true; onError receives the error. For readers, render no Restore. status is a code (VersionHistoryStatus: '', restoring, restored, unchanged, unavailable, undone, nothingToUndo, denied, failed): say it in your words in a polite live region, so screen readers announce each outcome.
Options
| Option | Type | |
|---|---|---|
client |
HistoryClient |
Where the versions come from. |
document |
EdytorDocument |
The page’s document, compared with each version. Without it, no change is highlighted. |
plugins |
Plugin[] |
The preview’s plugins: the page editor’s. |
locale |
string | string[] |
The locale of dates and times (default the browser’s). |
id |
string |
The rows’ id prefix (page-unique: $props.id()). |
onRestore |
(result) => void |
After each restore the room answered. |
onUndo |
(result) => void |
After each undo the room answered. |
onError |
(error) => void |
A request failed. |
Words
The controller writes no word: your panel writes them, and formats a version’s day and time with dateOf(version) and timeOf(version), in the controller’s locale (version.slot is its half of the day, am or pm). The demo’s panel keeps its words with the rest of its chrome’s (src/routes/chrome/words.svelte.ts in the repository).
Styling
The panel’s look is yours (the demo’s is two columns, the list --edytor-history-side-width wide, stacked below 640px of its own width). The change highlights are the controller’s, drawn in the preview’s overlay, never inside its blocks: a tint over each block’s own row (not its children), with a bar on its left. These variables set their colors:
| Variable | |
|---|---|
--edytor-version-added, --edytor-version-added-bar |
The tint and bar of a block added since. |
--edytor-version-removed, --edytor-version-removed-bar |
Of a block removed since. |
--edytor-version-changed, --edytor-version-changed-bar |
Of a block changed since. |
The defaults are a light theme (Notion’s colors, at AA contrast). For a dark theme, set the variables; the tints blend with multiply, so give them values that read on a dark page.