Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
  • HTML Export
  • Export
  • Server-Side
  • Version History
  • Editable Voids
  • Huge Document
  • Hundreds Editors
  • Markdown Streaming
  • Preview Markdown
  • Collaboration Demo
  • Table Nomerge Demo
  • Excalidraw Demo
  • Code Drawing Demo
  • Single Block Demo
  • Find Demo
  • AI Demo
  • Align Demo
  • Autoformat Demo
  • Basic Nodes Demo
  • Block Menu Demo
  • Node Selection Demo
  • Column Demo
  • Code Block Demo
  • Callout Demo
  • Discussion Demo
  • Date Demo
  • Footnote Demo
  • Drag & Drop Demo
  • Emoji Demo
  • Equation Demo
  • Exit Break Demo
  • Floating Toolbar Demo
  • Font Demo
  • Indent Demo
  • List Demo
  • Line Height Demo
  • Link Demo
  • Media Demo
  • Mention Demo
  • Block Placeholder Demo
  • Serializing CSV Demo
  • Serializing Docx Demo
  • Serializing HTML Demo
  • Serializing Markdown Demo
  • Slash Command Demo
  • Plugin Rules Demo
  • Table Demo
  • Table of Contents Demo
  • Details Demo

Version History

PreviousNext

Retain author history and compare saved document revisions.

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.

Demo

Loading…
Server-SideEditable Voids Demo

On This Page

DemoSourceUsageRetained history flowCompare saved revisionsPersist the history
Build your editor
Production-ready AI template and reusable components.
Get all-access

Source

'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 } 













































































































































































































































Usage

PieceOwnerRole
authored({ retainHistory: true })platejs/authoredCaptures accepted edits with stable author and change identities and retains their edit bodies.
authorId callbackapplicationResolves the selected author ID once per transaction.
read.authored.changes(...)native authored readsReturns accepted history as cursor-paged AuthoredChange records.
update.authored.revert(...)native authored updatesCreates a compensating contribution for the selected retained change.
compare(...)platejs/diffCompares two immutable saved revisions without changing the editor.
Diffcopied Plate UIShows comparison groups, effects, filters, and navigation.
BasicMarksKitcopied Plate UIProvides normal rich-text marks for the editable document.

Retained history flow

  1. The editor starts with authored and retainHistory: true.
  2. The author selector changes the identity returned for later transactions.
  3. Ordinary accepted edits create attributed records.
  4. The history list reads accepted changes by stable ID and revision.
  5. Revert captures a snapshot-bound selection and calls update.authored.revert(...).
  6. The new compensating change belongs to the current author; the original record remains intact.

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.

Compare saved revisions

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

Persist the history

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.

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>
);
}