Record attributed edits, review proposals by change or card, and export explicit projections.
Authored changes keep accepted content, pending proposals, and stable change identities in one saved document. Use Suggestions for the common editing and suggesting modes. This guide covers the underlying views, reads, decisions, and formats.
| Need | Surface |
|---|---|
| Switch between editing and suggesting | SuggestionPlugin from platejs/suggestion/react |
| Add copied highlighting and review cards | SuggestionKit and DiscussionKit |
| Record changes as the editor's user | AuthoredPlugin from platejs/authored |
| Read and decide changes or cards | The installed authored plugin portal |
| Attach a conversation | CommentsPlugin with a { type: 'change', id } target |
Suggestion is a behavior and presentation layer over authored changes. It does not define another change record, decision result, or persistence format.
AuthoredPlugin resolves the author from the Plate editor's userId once per transaction:
import { BaseParagraphPlugin, createEditor } from "platejs";
import { AuthoredPlugin } from "platejs/authored";
const editor = createEditor({
plugins: [BaseParagraphPlugin, AuthoredPlugin],
initialValue: [
{ type: "paragraph", children: [{ text: "Review this sentence." }] },
],
userId: "alice",
});
const authored = editor.plugin(AuthoredPlugin);import { BaseParagraphPlugin, createEditor } from "platejs";
import { AuthoredPlugin } from "platejs/authored";
const editor = createEditor({
plugins: [BaseParagraphPlugin, AuthoredPlugin],
initialValue: [
{ type: "paragraph", children: [{ text: "Review this sentence." }] },
],
userId: "alice",
});
const authored = editor.plugin(AuthoredPlugin);An editor without a userId writes as the local user, 'local', so a solo
editor needs no setup, AI suggestions included. Pass an authenticated, stable
user ID whenever more than one person writes the document.
An editing view applies ordinary updates as written. Begin a proposal inside the transaction before applying its document mutation:
let changeId = "";
editor.update((tx) => {
changeId = tx.authored.propose();
tx.text.insert(" reviewed", {
at: { offset: 8, path: [0, 0] },
});
});let changeId = "";
editor.update((tx) => {
changeId = tx.authored.propose();
tx.text.insert(" reviewed", {
at: { offset: 8, path: [0, 0] },
});
});Pass { changeId } to name the proposal, or to add the transaction's writes to the pending change with that ID. An ID whose change was already decided starts a new change. IDs starting with ~edit: belong to Editing and are refused.
Compare a fixed draft with the accepted document, then publish its canonical change as one native proposal:
import { proposeAuthoredComparison } from "platejs/authored";
import { compare } from "platejs/diff";
const incomingDraft = [
{ type: "paragraph", children: [{ text: "Revised sentence." }] },
];
const comparison = await compare({
before: editor.read.value(),
after: incomingDraft,
schema: editor.read.schema,
});
const imported = proposeAuthoredComparison(editor, { comparison });import { proposeAuthoredComparison } from "platejs/authored";
import { compare } from "platejs/diff";
const incomingDraft = [
{ type: "paragraph", children: [{ text: "Revised sentence." }] },
];
const comparison = await compare({
before: editor.read.value(),
after: incomingDraft,
schema: editor.read.schema,
});
const imported = proposeAuthoredComparison(editor, { comparison });groupIds selects comparison groups and includes their required dependencies. The result maps selected group IDs to the native change ID. Import checks the schema, accepted content, and authored frontier before writing; handle stale, blocked, invalid, and unavailable without treating them as applied proposals. The comparison must be a two-way comparison against the live accepted document.
Set the starting intent on EditorRoot:
import { AuthoredPlugin } from "platejs/authored";
import { EditorContent, EditorRoot, useCreateEditor } from "platejs/react";
export function SuggestionEditor() {
const editor = useCreateEditor({
plugins: [AuthoredPlugin],
userId: "alice",
});
return (
<EditorRoot editor={editor} authored={{ intent: "propose" }}>
<EditorContent />
</EditorRoot>
);
}import { AuthoredPlugin } from "platejs/authored";
import { EditorContent, EditorRoot, useCreateEditor } from "platejs/react";
export function SuggestionEditor() {
const editor = useCreateEditor({
plugins: [AuthoredPlugin],
userId: "alice",
});
return (
<EditorRoot editor={editor} authored={{ intent: "propose" }}>
<EditorContent />
</EditorRoot>
);
}The editor must install an authored plugin, either directly or through SuggestionPlugin. Changing the authored prop updates this view. Rerendering with an equivalent value preserves later mode changes made through the mounted view's portal.
For an interactive mode control, let SuggestionPlugin change the intent:
import { SuggestionPlugin } from "platejs/suggestion/react";
editor.plugin(SuggestionPlugin).api.setMode("suggesting");import { SuggestionPlugin } from "platejs/suggestion/react";
editor.plugin(SuggestionPlugin).api.setMode("suggesting");To read the document with every pending change decided, switch the view to a preview:
const authored = editor.plugin(AuthoredPlugin);
authored.api.setView({ preview: "proposed" });const authored = editor.plugin(AuthoredPlugin);
authored.api.setView({ preview: "proposed" });| View | Visible content | Writes |
|---|---|---|
{ intent: 'edit' } | Stored content with pending insertions and deletions | Applied as written |
{ intent: 'propose' } | Stored content with pending insertions and deletions | Recorded as proposals by the current author |
{ preview: 'proposed' } | Every pending change accepted | Plain edits throw; see below |
{ preview: 'accepted' } | Every pending change rejected | Plain edits throw; see below |
A preview throws on a plain edit, but some other writes still change the stored document, such as an update that makes a decision, undo and redo, and remote collaboration changes.
Typing inside struck text splits the pending deletion around the new text. An edit view applies that text as written, and a propose view records it as an insertion. A propose view refuses a write it cannot record as a proposal, such as a change inside a table, and rolls it back; api.subscribeRefusals(listener) reports each refusal.
Deleting a block that owns a content root in a propose view strikes the block and leaves the root's content unchanged and unmarked, so style the root as deleted in the block's component. Typing or pasting inside that root writes nothing until someone decides the deletion. Accepting it removes the block and its root, and returns blocked while the root holds text another pending change inserted. Rejecting it keeps both. Rejecting a proposed block that owns a content root removes the root too. A propose view still refuses deleting a named root that no element owns.
A view's policy belongs to that exact view. Selection, clipboard input, commands, and anchors use that view's coordinates. Two mounted views over one document can use different policies.
Read a page of pending changes, then decide the records you read:
const authored = editor.plugin(AuthoredPlugin);
const page = authored.read.changes({ authorId: "alice", limit: 50 });
const result = authored.update.decide({
action: "accept",
changes: page.items,
});const authored = editor.plugin(AuthoredPlugin);
const page = authored.read.changes({ authorId: "alice", limit: 50 });
const result = authored.update.decide({
action: "accept",
changes: page.items,
});Use action: 'reject' to reject changes. changes(query) reads pending changes in document order, 50 at a time by default and at most 200. Pass a non-null page cursor to read the next page; a cursor whose changes have all left throws as stale. Narrow a page to a place of the stored document with at, to one author with authorId, or to one card with group.
Each AuthoredChange has its id, authorId, createdAt, kind, current ranges, and a revision that differs whenever its marks or the content they mark differ. Use change(id) to read one change.
A decision names the changes as they were read, and a change whose revision moved since returns stale. Accepting a change can need other changes accepted with it, and rejecting one can need its dependants rejected. The result is then blocked, with names the changes it needs, and nothing applies. The editor never decides a change you did not name. Several decisions in one call decide together:
if (result.status === "blocked") {
const related = result.with.flatMap((id) => authored.read.change(id) ?? []);
authored.update.decide([
{ action: "accept", changes: page.items },
{ action: "accept", changes: related },
]);
}if (result.status === "blocked") {
const related = result.with.flatMap((id) => authored.read.change(id) ?? []);
authored.update.decide([
{ action: "accept", changes: page.items },
{ action: "accept", changes: related },
]);
}Read semantic detail when the reviewer opens one change:
const details = authored.read.details(changeId);
for (const part of details?.parts ?? []) {
// Render content, boundary, enclosure, or property parts in review UI.
}const details = authored.read.details(changeId);
for (const part of details?.parts ?? []) {
// Render content, boundary, enclosure, or property parts in review UI.
}details(id) returns the change and its parts. Content parts include before and after slices. Boundary parts distinguish paragraph splits from joins. Enclosure parts name wraps and unwraps. Property parts preserve exact before and after values.
A review card groups one author's adjacent pending changes, such as a deletion and the insertion that replaces it:
const [card] = authored.read.groups({ at: [0] });
if (card) {
const firstChanges = authored.read.changes({ group: card.id, limit: 20 });
const decided = authored.update.decide({ action: "accept", groups: [card] });
}const [card] = authored.read.groups({ at: [0] });
if (card) {
const firstChanges = authored.read.changes({ group: card.id, limit: 20 });
const decided = authored.update.decide({ action: "accept", groups: [card] });
}groups({ at }) returns summaries of the cards touching a place of the stored document, in document order: id, authorId, createdAt, changeCount, and revision. A card's ID is its oldest change, so every peer and reload names it alike. The ID moves when the card splits or merges, or when that change leaves it. groupOf(changeId) returns the card that holds a change.
Deciding a card decides every change it holds at that moment. The decision reads as stale once the card's ID or revision moved, so read the card again with groupOf before retrying.
Always inspect the result of decide:
| Status | Meaning |
|---|---|
applied | The decision applied to the returned IDs. |
unchanged | No change was needed. |
stale | A change or card moved since it was read. Read it again. |
blocked | Decide the changes with names with it. Nothing applied. |
invalid | The decisions contradict, or the schema refuses the result. |
Save the complete result of editor.read.value(). It holds the content with each node's pending review marks, and the review records in the document's records. Saving only the node array loses those records.
const savedDocument = editor.read.value();
const restored = createEditor({
plugins: [BaseParagraphPlugin, AuthoredPlugin],
initialValue: savedDocument,
userId: "alice",
});const savedDocument = editor.read.value();
const restored = createEditor({
plugins: [BaseParagraphPlugin, AuthoredPlugin],
initialValue: savedDocument,
userId: "alice",
});Use the same content plugins and schema when loading a revision into a fresh editor. Loading validates the review marks, review records, and document schema before publishing the value. Leave the authored node property and the review records to the editor. Local undo starts empty.
If you install Comments, save editor.plugin(CommentsPlugin).api.toJSON() atomically with the document and its application revision and conversation generation. Verify that association, then pass the saved CommentsJSON to CommentsPlugin.configure({ initialState: { initialComments } }) in the fresh editor. Live thread targets contain semantic identities; attachment(id) reads their placement. Accepting, rejecting, and undoing a suggestion decision preserve its discussion. Resolving or reopening a thread stays outside document undo.
Historical previews and restores retain current conversations and use only the selected revision's proven targets. See Comments and versions.
Choose accepted or proposed for a clean document, or review when the
format can preserve review records. Clean projections report diagnostics when
they omit pending work. Every helper accepts or captures a complete document,
including named roots and metadata. A clean projection leaves out a content root
once it leaves out the root's owner, by the
content root ownership rule.
HTML, Markdown, plain-text and static HTML exports of a document with pending
changes throw a TypeError unless projection is accepted or proposed. A
document without pending changes needs no projection. DOCX export always takes
one.
Save editor.read.value() as JSON to keep the review. For a clean document,
project it first:
import { projectAuthoredDocument } from "platejs/authored";
const accepted = projectAuthoredDocument(editor.read.value(), {
projection: "accepted",
});import { projectAuthoredDocument } from "platejs/authored";
const accepted = projectAuthoredDocument(editor.read.value(), {
projection: "accepted",
});projectAuthoredDocument returns the projected document, its diagnostics,
and the number of pending changes it decided. Loading saved JSON into an
editor applies the installed application schema and the review checks.
import { serializePlainText } from "platejs";
const text = serializePlainText(editor, { projection: "accepted" }).data;
const markdown = editor.api.markdown.serialize({ projection: "proposed" });
if (markdown.ok) downloadMarkdown(markdown.data);import { serializePlainText } from "platejs";
const text = serializePlainText(editor, { projection: "accepted" }).data;
const markdown = editor.api.markdown.serialize({ projection: "proposed" });
if (markdown.ok) downloadMarkdown(markdown.data);Plain text and Markdown carry visible content only. Feature-owned format mappings preserve the structures they can represent and report omitted roots, metadata, pending work, and unsupported nodes. Markdown does not contain a hidden Plate document and cannot restore exact review state.
import { exportDocx } from "platejs/docx/export";
import { renderStaticHtml } from "platejs/static";
import { BaseEditorKit } from "@/components/editor/plugins-static";
const semanticHtml = editor.api.html.serialize({ projection: "proposed" });
const styledHtml = await renderStaticHtml(editor, {
presentation: BaseEditorKit,
projection: "proposed",
});
const docx = await exportDocx(editor, {
presentation: BaseEditorKit,
projection: "review",
});import { exportDocx } from "platejs/docx/export";
import { renderStaticHtml } from "platejs/static";
import { BaseEditorKit } from "@/components/editor/plugins-static";
const semanticHtml = editor.api.html.serialize({ projection: "proposed" });
const styledHtml = await renderStaticHtml(editor, {
presentation: BaseEditorKit,
projection: "proposed",
});
const docx = await exportDocx(editor, {
presentation: BaseEditorKit,
projection: "review",
});renderStaticHtml and exportDocx draw with the static kit passed as
presentation, because a live editor's components are editing UI. Semantic HTML
uses feature mappings and no application styling. Both HTML forms carry visible
accepted or proposed content only. A successful DOCX result returns a Blob in
docx.blob. Every serializer returns structured diagnostics.
| Format | Accepted or proposed | Review |
|---|---|---|
| JSON | Plain projected document | The saved editor value |
| Plain text | Structural text | Not supported |
| Markdown | Plain Markdown | Not supported; save authored JSON |
| Semantic HTML | Feature-mapped HTML | Not supported; save authored JSON |
| Static HTML | Component-rendered HTML | Not supported; save authored JSON |
| DOCX | Projected Word document | Word revisions, with Plate's review blocks and records in a custom XML part |
A DOCX review file shows Word revisions and also stores each reviewed block and
its review records in a custom XML part, bound to the block's Word paragraphs.
Importing the file restores each block whose paragraphs Word left unchanged; an
edited paragraph imports from its Word revisions. Named roots and document
metadata stay out of the file, so save authored JSON beside it to keep them.
DOCX review export reports lossy-content or unsupported-content when Word
cannot express a native construct.
Install the authored plugin and Yjs on every peer, with matching document and schema identities. Plate supplies local History by default.
The Yjs binding applies content, review marks, and review records in one remote transaction.
See Collaboration for setup.
Import the standard descriptor from platejs/authored. It records each change as the Plate editor's userId, or as the local user, 'local', when the editor has none. It takes no options. SuggestionPlugin and AIChatPlugin depend on this descriptor.
After const authored = editor.plugin(AuthoredPlugin), use these methods:
| Surface | Purpose |
|---|---|
tx.authored.propose(options?) | Record the transaction's writes as a proposal; { changeId } names it or extends one. |
authored.read.view() | Read this view's { intent } or { preview }. |
authored.read.change(id) | Read one pending change, or null. |
authored.read.details(id) | Read one change with its semantic parts, or null. |
authored.read.changes(query?) | Read a page of pending changes in document order. |
authored.read.groups({ at }) | Read summaries of the cards touching a place of the stored document. |
authored.read.groupOf(changeId) | Read the card that holds a change, or null. |
authored.read.reviewOf(node) | Read a stored node's pending marks and the accepted values of the properties they format. |
authored.update.decide(decision) | Accept or reject changes or cards; an array of decisions decides together. |
authored.api.setView(view) | Change the policy on this exact view. |
authored.api.subscribeChanges(listener) | Receive one shared delta for each commit that changes the review. |
authored.api.subscribeRefusals(listener) | Receive each proposal write or view switch the editor refused. |
subscribeChanges passes { blocks, changed, groups, removed }: the blocks holding a change whose card ID changed, the changes that began or whose revision changed, the cards whose summary changed, began, or ended, and the changes no longer pending. It is a low-level integration hook; suggestion UI uses useSuggestionChanges(path) and useSuggestionGroups(blockKey) instead.
platejs/authored exports the authored plugin descriptor, the change, card,
query, page, delta, decision, and result types, proposeAuthoredComparison,
and the format helpers below.
projectAuthoredDocument(document, { projection }) returns
{ ok, document, diagnostics, pending } for an accepted or proposed
projection. It is a pure document operation: it does not construct, activate, or
subscribe an editor runtime. Pass editor as well to complete the result with
that editor's schema; under a schema that generates property values, a document
with pending changes returns an AuthoredProjectionRefusal instead.
projectAuthoredRange(document, projected, range) maps a range of the stored
document onto one of its projections, or returns null when the projection
keeps none of its content.