This example installs native authored changes with retainHistory: true. Edit as Alice or Bob, revert an accepted contribution, or save two revisions to inspect their structural comparison.
For application revision previews and restores with comments, see Comments and versions.
'use client';
import type { EditorStateSchemaApi, Value } from 'platejs';
import {
type AuthoredChange,
type AuthoredPlugin,
authored,
} from 'platejs/authored';
import { compare, type TwoWayComparison } from 'platejs/diff';
import {
type Editor,
EditorRoot,
EditorContent,
useCreateEditor,
useEditor,
useEditorSelector,
} from 'platejs/react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { BasicMarksKit } from '@/components/editor/basic-marks';
import { Diff } from '@/components/editor/diff';
type VersionHistoryEditor = Editor<Value, readonly [AuthoredPlugin]>;
const readPlateUserId = (editor: object) => {
const runtime = Reflect.get(editor, 'runtime');
if (!runtime || typeof runtime !== 'object') return null;
const userId = Reflect.get(runtime, 'userId');
return typeof userId === 'string' && userId.length > 0 ? userId : null;
};
const initialValue: Value = [
{
children: [{ text: 'This document keeps each accepted edit by author.' }],
type: 'paragraph',
},
{
children: [
{ text: 'Edit this text as Alice or Bob, then revert one contribution.' },
],
type: 'paragraph',
},
];
const sameChanges = (
left: readonly AuthoredChange[] | null,
right: readonly AuthoredChange[]
) =>
left !== null &&
left.length === right.length &&
left.every(
(change, index) =>
change.id === right[index]?.id &&
change.revision === right[index]?.revision &&
change.status === right[index]?.status
);
function AuthorHistory({ onResult }: { onResult: (value: string) => void }) {
const editor = useEditor() as VersionHistoryEditor;
const changes = useEditorSelector(
(current) =>
(current as VersionHistoryEditor).read.authored.changes({
limit: 50,
status: 'accepted',
}).items,
{ equalityFn: sameChanges }
);
if (changes.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Accepted edits appear here after you change the document.
</p>
);
}
return (
<ol className="space-y-2">
{changes.toReversed().map((change) => (
<li
className="flex items-center justify-between gap-3 rounded-md border p-2"
key={`${change.id}:${change.revision}`}
>
<span className="text-sm">
<strong>{change.authorId}</strong> · {change.kind} · revision{' '}
{change.revision}
</span>
<Button
onClick={() => {
const result = editor.update.authored.revert({
selection: editor.read.authored.select({ ids: [change.id] }),
});
onResult(
result.status === 'applied'
? `Reverted ${change.authorId}'s ${change.kind} change.`
: `Revert ${result.status}.`
);
}}
size="sm"
variant="outline"
>
Revert
</Button>
</li>
))}
</ol>
);
}
export function VersionDiff({
after,
before,
schema,
}: {
after: unknown;
before: unknown;
schema: EditorStateSchemaApi;
}) {
const [result, setResult] = React.useState<{
after: unknown;
comparison: TwoWayComparison | null;
error: string | null;
} | null>(null);
React.useEffect(() => {
const controller = new AbortController();
void compare({ after, before, schema, signal: controller.signal }).then(
(comparison) => {
if (!controller.signal.aborted) {
setResult({ after, comparison, error: null });
}
},
(error: unknown) => {
if (
!controller.signal.aborted &&
!(error instanceof Error && error.name === 'AbortError')
) {
setResult({
after,
comparison: null,
error:
error instanceof Error ? error.message : 'Comparison failed.',
});
}
}
);
return () => controller.abort();
}, [after, before, schema]);
if (result && result.after === after && result.error) {
return (
<p className="rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-950">
{result.error}
</p>
);
}
if (!result || result.after !== after || !result.comparison) {
return (
<p aria-live="polite" className="rounded-md border p-3 text-sm">
Comparing revisions…
</p>
);
}
return <Diff comparison={result.comparison} />;
}
function RevisionHistory() {
const editor = useEditor() as VersionHistoryEditor;
const [revisions, setRevisions] = React.useState<readonly unknown[]>(() => [
structuredClone(editor.read.value()),
]);
return (
<section className="space-y-2">
<div className="flex items-center justify-between gap-3">
<h2 className="font-medium">Saved revisions</h2>
<Button
onClick={() =>
setRevisions((current) => [
...current,
structuredClone(editor.read.value()),
])
}
size="sm"
variant="outline"
>
Save revision
</Button>
</div>
<p className="text-sm text-muted-foreground">
Edit the document and save a revision to compare it with the previous
snapshot.
</p>
{revisions.length > 1 && (
<VersionDiff
after={revisions.at(-1)}
before={revisions.at(-2)}
schema={editor.read.schema}
/>
)}
</section>
);
}
export default function VersionHistoryDemo() {
const [authorId, setAuthorId] = React.useState('alice');
const [result, setResult] = React.useState('');
const editor = useCreateEditor({
plugins: [
...BasicMarksKit,
authored({
authorId: readPlateUserId,
retainHistory: true,
}),
],
initialValue,
userId: 'alice',
});
return (
<div className="flex flex-col gap-4 p-3">
<label className="flex items-center gap-2 text-sm font-medium">
Edit as
<select
className="rounded-md border bg-background px-2 py-1"
onChange={(event) => {
Reflect.set(editor.runtime, 'userId', event.target.value);
setAuthorId(event.target.value);
}}
value={authorId}
>
<option value="alice">Alice</option>
<option value="bob">Bob</option>
</select>
</label>
<EditorRoot editor={editor}>
<EditorContent className="rounded-md border p-3" />
<section className="space-y-2">
<h2 className="font-medium">Retained author history</h2>
<AuthorHistory onResult={setResult} />
{result && (
<p aria-live="polite" className="text-sm text-muted-foreground">
{result}
</p>
)}
</section>
<RevisionHistory />
</EditorRoot>
</div>
);
}'use client';
import type { EditorStateSchemaApi, Value } from 'platejs';
import {
type AuthoredChange,
type AuthoredPlugin,
authored,
} from 'platejs/authored';
import { compare, type TwoWayComparison } from 'platejs/diff';
import {
type Editor,
EditorRoot,
EditorContent,
useCreateEditor,
useEditor,
useEditorSelector,
} from 'platejs/react';
import * as React from 'react';
import { Button }
| Piece | Owner | Role |
|---|---|---|
authored({ retainHistory: true }) | platejs/authored | Captures accepted edits with stable author and change identities and retains their edit bodies. |
authorId callback | application | Resolves the selected author ID once per transaction. |
read.authored.changes(...) | native authored reads | Returns accepted history as cursor-paged AuthoredChange records. |
update.authored.revert(...) | native authored updates | Creates a compensating contribution for the selected retained change. |
compare(...) | platejs/diff | Compares two immutable saved revisions without changing the editor. |
Diff | copied Plate UI | Shows comparison groups, effects, filters, and navigation. |
BasicMarksKit | copied Plate UI | Provides normal rich-text marks for the editable document. |
authored and retainHistory: true.update.authored.revert(...).A revert can be blocked by dependent work or become unavailable when the required retained content has expired. Local undo and redo remain separate interaction history. Use native decide(...) to accept or reject pending review proposals.
The demo captures an initial snapshot. Edit the document and select Save revision to compare the two latest snapshots. Saving another revision starts a new comparison; an obsolete comparison is cancelled. The comparison is read-only. To turn a validated comparison into a pending proposal, use proposeAuthoredComparison(...).
Save editor.read.value() as one document. The authored envelope includes accepted content, causal records, decisions, and the retained bodies selected by policy.
const saved = editor.read.value();
const reloaded = createEditor({
plugins: [
authored({
authorId: () => session.user.id,
retainHistory: true,
}),
],
initialValue: saved,
});const saved = editor.read.value();
const reloaded = createEditor({
plugins: [
authored({
authorId: () => session.user.id,
retainHistory: true,
}),
],
initialValue: saved,
});The fresh editor starts with empty local undo and redo stacks. Retained authored contributions remain in the saved document. If the document has comments, save CommentsJSON through comments.toJSON() with the same revision and load it through initialComments; document undo does not rewind the discussion.
See Authored Changes for review views, decision results, collaboration, and format projections.