Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
  • Feature Kits
  • Upload Files
  • Plugin
    • Plugin Methods
    • Plugin Shortcuts
    • Plugin Context
    • Plugin Components
    • Plugin Rules
    • Editing Behavior
    • Plugin Input Rules
  • Editor
    • Editor Methods
    • Controlled Value
  • Authored Changes
  • Performance
  • Static Rendering
  • HTML
  • Markdown
  • Form
  • TypeScript
  • Debugging
  • Unit Testing
  • Browser
  • Troubleshooting
  • Locations
  • Transactions
  • Serializing
  • Roots
  • Document Meta
  • Clipboard and Paste
  • Decorations and annotations
  • Schema
  • History
  • PaginationExperimental
  • Annotations
  • DOM Coverage
  • External Text Views
  • Virtualized Rendering

Authored Changes

PreviousNext

Record attributed edits, review proposals by change or card, and export explicit projections.

SuggestionsCommentsCollaboration

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.

Choose the right surface

NeedSurface
Switch between editing and suggestingSuggestionPlugin from platejs/suggestion/react
Add copied highlighting and review cardsSuggestionKit and DiscussionKit
Editing BehaviorPlugin Input Rules

On This Page

Choose the right surfaceInstall authored changesCapture changesImport a compared revisionConfigure a viewReview changesReview cardsDecision resultsSave the documentExport explicit projectionsJSONPlain text and MarkdownHTML and DOCXCollaborationAPI ReferenceAuthoredPluginTransaction, reads, and updatesTypes and format helpers
Build your editor
Production-ready AI template and reusable components.
Get all-access
Record changes as the editor's userAuthoredPlugin from platejs/authored
Read and decide changes or cardsThe installed authored plugin portal
Attach a conversationCommentsPlugin 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.

Install authored changes

AuthoredPlugin resolves the author from the Plate editor's userId once per transaction:

editor.ts
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);
editor.ts
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.

Capture changes

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.

Import a compared revision

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.

Configure a view

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" });
ViewVisible contentWrites
{ intent: 'edit' }Stored content with pending insertions and deletionsApplied as written
{ intent: 'propose' }Stored content with pending insertions and deletionsRecorded as proposals by the current author
{ preview: 'proposed' }Every pending change acceptedPlain edits throw; see below
{ preview: 'accepted' }Every pending change rejectedPlain 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.

Review changes

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.

Review cards

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.

Decision results

Always inspect the result of decide:

StatusMeaning
appliedThe decision applied to the returned IDs.
unchangedNo change was needed.
staleA change or card moved since it was read. Read it again.
blockedDecide the changes with names with it. Nothing applied.
invalidThe decisions contradict, or the schema refuses the result.

Save the document

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.

Export explicit projections

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.

JSON

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.

Plain text and Markdown

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.

HTML and DOCX

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.

FormatAccepted or proposedReview
JSONPlain projected documentThe saved editor value
Plain textStructural textNot supported
MarkdownPlain MarkdownNot supported; save authored JSON
Semantic HTMLFeature-mapped HTMLNot supported; save authored JSON
Static HTMLComponent-rendered HTMLNot supported; save authored JSON
DOCXProjected Word documentWord 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.

Collaboration

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.

API Reference

AuthoredPlugin

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.

Transaction, reads, and updates

After const authored = editor.plugin(AuthoredPlugin), use these methods:

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

Types and format helpers

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.