Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Comments
  • Discussion
  • Suggestions
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Details
  • Marks
    • Bold
    • Italic
    • Underline
    • Code
    • Highlight
    • Keyboard Input
    • Strikethrough
    • Subscript
    • Superscript
      • Font
      • Line Height
      • Text Align
    • Indent
    • List
      • Exit Break
      • Single Block
      • Trailing Block
    • Autoformat
    • Block Menu
    • Block Placeholder
    • Combobox
      • Emoji
      • MentionElement
      • Slash Command
    • Drag & Drop
    • Navigation Feedback
    • Linting
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • DOCX
    • HTML
    • Markdown

Suggestions

PreviousNext

Propose text, formatting, and block edits, then review them with copied Plate UI.

Authored ChangesDiscussionCommentsSuggestion Toolbar Button
Loading…
DiscussionBasic Blocks

On This Page

FeaturesKit UsageInstallationAdd the kitsSet the modeRead view stateExamplesFormatting and blocksAccepted, proposed, and markupSave proposals and repliesCreate and decide changesDecide every suggestionSave and reloadHeadless usageDiscussion reviewAPI ReferenceBaseSuggestionPluginSuggestionPluginsuggestion.api.setMode(mode)suggestion.read.mode()React hooksSuggestionKit
Build your editor
Production-ready AI template and reusable components.
Get all-access

The demo starts in Suggestion mode with a proposed insertion and deletion. Open a paragraph's discussion button to accept or reject its card, or choose Accept all suggestions or Reject all suggestions in the mode menu, then try undo and redo. Switch to Editing to keep the pending changes visible while ordinary edits apply directly, or type in Suggestion mode to propose an edit.

Typing inside struck text splits the pending deletion around the new text. In Suggestion mode the new text is a proposed insertion of its own; in Editing mode it applies directly. Deleting struck text in Editing mode leaves it to its pending deletion.

Features

  • Switch a mounted editor view between editing and suggesting
  • Decorate proposed text, formatting, and structural edits
  • Keep the active card local to each mounted view
  • Read native authored changes and review cards for custom review UI
  • Save pending changes with the complete document
  • Add review cards and comment replies through copied registry UI
Report an issue

Kit Usage

Installation

Start with an Editor, then install the suggestion and discussion UI:

pnpm dlx shadcn@latest add https://platejs.org/r/basic-blocks.json https://platejs.org/r/suggestion.json https://platejs.org/r/discussion.json https://platejs.org/r/suggestion-toolbar-button.json https://platejs.org/r/history-toolbar-button.json
pnpm dlx shadcn@latest add https://platejs.org/r/basic-blocks.json https://platejs.org/r/suggestion.json https://platejs.org/r/discussion.json https://platejs.org/r/suggestion-toolbar-button.json https://platejs.org/r/history-toolbar-button.json
'use client';
 
import { AuthoredPlugin } from 'platejs/authored';
import {
  type EditableSiblingProps,
  useEditor,
  useEditorSelector,
} from 'platejs/react';
import { suggestionColorOf } from 'platejs/suggestion';
import {
  SuggestionPlugin,
  useActiveSuggestion,
} from 'platejs/suggestion/react';
import * as React from 'react';
import { toast } from 'sonner';
 
type SuggestionColor = Readonly<{
  active









































































































































































































SuggestionKit configures the package SuggestionPlugin with render.contentAttributes for its default styles. The package supplies suggestion modes, semantic attributes, active-card behavior, and authored-change hooks. Core applies the kit's classes to the existing content root, including when you render EditorContent directly. DiscussionKit adds block buttons, Accept and Reject cards, and comment replies.

Add the kits

components/editor/suggestion-editor.tsx
'use client';
 
import { CommentsPlugin } from 'platejs/comments/react';
import { EditorRoot, useCreateEditor } from 'platejs/react';
 
import { BasicBlocksKit } from '@/components/editor/basic-blocks';
import { DiscussionKit } from '@/components/editor/discussion';
import { Editor, EditorContainer } from '@/components/editor/editor';
import {
  RedoToolbarButton,
  UndoToolbarButton,
} from '@/components/editor/history-toolbar-button';
import { SuggestionKit } from '@/components/editor/suggestion';
import { SuggestionToolbarButton } from '@/components/editor/suggestion-toolbar-button';
































Replace 'alice' with the authenticated user's ID. The editor's userId attributes document changes and comments, and users supplies names and optional avatars. A solo editor can omit userId; it then suggests and comments as the local user, 'local'.

SuggestionPlugin depends on AuthoredPlugin, which reads the editor's userId once per write transaction. Do not install a second authored descriptor alongside the kit.

Set the mode

Set the starting view with EditorRoot authored={{ intent: 'propose' }} as in the example above. Changing the value updates that view. Rerendering with an equivalent value preserves mode changes made by its controls.

For an interactive mode control, call the SuggestionPlugin portal from the mounted editor view:

import { SuggestionPlugin, useSuggestionMode } from 'platejs/suggestion/react';
import { useEditor } from 'platejs/react';
 
export function SuggestionModeButton() {
  const editor = useEditor();
  const mode = useSuggestionMode();
  const suggestion = editor.plugin(SuggestionPlugin);
 
  return (
    <button
      onClick={() =>
        suggestion.api.setMode(
          mode === 'suggesting' ? 'editing' : 'suggesting'
        )
      }
    >
      {mode === 'suggesting' ? 'Stop suggesting' : 'Suggest edits'}
    </button>
  );
}
import { SuggestionPlugin, useSuggestionMode } from 'platejs/suggestion/react';
import { useEditor } from 'platejs/react';
 
export function SuggestionModeButton() {
  const editor = useEditor();
  const mode = useSuggestionMode();
  const suggestion = editor.plugin(SuggestionPlugin);
 
  return (
    <button
      onClick={() =>
        suggestion.api.setMode(
          mode === 'suggesting' ? 'editing' : 'suggesting'
        )
      }
    >



setMode sets the view's input intent: 'suggesting' sets { intent: 'propose' } and 'editing' sets { intent: 'edit' }. Both show the pending insertions and deletions. In Editing mode, ordinary edits apply directly; in Suggestion mode, they become proposals by the current author.

Mode belongs to the exact mounted view. Two views over one document can use different modes without changing each other.

Changing the mode affects only subsequent input. It does not accept, reject, delete, or hide pending changes, and it does not add an undo step.

To read the document with every pending change accepted or rejected, switch the view to a preview:

import { AuthoredPlugin } from 'platejs/authored';
 
const authored = editor.plugin(AuthoredPlugin);
 
authored.api.setView({ preview: 'proposed' });
import { AuthoredPlugin } from 'platejs/authored';
 
const authored = editor.plugin(AuthoredPlugin);
 
authored.api.setView({ preview: 'proposed' });

A preview throws on a plain edit, but some other writes still change the document, such as an update that makes a decision, undo and redo, and remote collaboration changes. read.mode() reports it as 'editing'. Switching to a preview does not accept or reject changes. Coordinates can differ between views, so read selections and apply edits through the view that produced them. Call setMode to leave the preview.

Read view state

The React entrypoint exposes focused hooks for custom controls and review UI:

import {
  useActiveSuggestion,
  useSuggestionChanges,
  useSuggestionGroups,
  useSuggestionMode,
} from 'platejs/suggestion/react';
 
const mode = useSuggestionMode();
const changes = useSuggestionChanges(path);
const cards = useSuggestionGroups(blockKey);
const { activeId, setActiveId } = useActiveSuggestion();
import {
  useActiveSuggestion,
  useSuggestionChanges,
  useSuggestionGroups,
  useSuggestionMode,
} from 'platejs/suggestion/react';
 
const mode = useSuggestionMode();
const changes = useSuggestionChanges(path);
const cards = useSuggestionGroups(blockKey);
const { activeId, setActiveId } = useActiveSuggestion();

useSuggestionChanges(path) returns the native AuthoredChange records touching that block. It keeps its result stable while those records are unchanged.

useSuggestionGroups(blockKey) returns the review cards touching the top-level block with that key, as read.groups summaries. It rereads when the block redraws and when a change in the block moves to another card. An edit to the same card in another block does neither, so a summary's changeCount or revision can trail; read editor.plugin(AuthoredPlugin).read.groupOf(card.id) when a control needs the current one. Read a card's changes a page at a time with read.changes({ group: card.id }).

In the current view, clicking a marked element activates the first card its data-editor-authored-groups attribute names. Clicking unmarked editor content clears it. Unmounting the view, switching it to another intent or preview, or deciding the card also clears stale active state. Use setActiveId when a custom card or control needs to activate a card directly.

Examples

Formatting and blocks

The first proposal makes a sentence bold; the second inserts a paragraph. Open each block's discussion button to accept or reject it. Select text and use Bold, or press Enter to propose another paragraph.

Loading…

Add Basic Marks for supported text formatting. Editor commands record text, mark, and node operations as proposals while the view is in suggesting mode. A propose view refuses a write it cannot record as a proposal and rolls it back; see Authored Changes. Formatting in Editing mode over a pending format suggestion applies directly and leaves the suggestion pending, so rejecting it later keeps the direct edit. If a collaborator decides or withdraws that suggestion while the edit syncs, the direct edit can be lost.

Accepted, proposed, and markup

Switch views to compare accepted content, the proposed result, and the markup view that shows pending insertions and deletions. The proposals stay pending in all three views.

Loading…

The Accepted and Proposed controls set a preview view; Markup sets { intent: 'edit' }. This example sets readOnly on both EditorRoot and Editor, so no view takes document input. The application still owns authorization for review actions and remote writes.

Save proposals and replies

Open a change's discussion button and add a reply, then save a snapshot. Accept or reject that change, or type another proposal, and reload the snapshot.

Loading…

The snapshot stays in memory and disappears when the page reloads. Reload snapshot creates a fresh editor from the saved document and CommentsJSON, discarding unsaved work and starting an empty local undo stack. Pending changes belong to the document; conversations persist separately from document undo.

Create and decide changes

Suggesting mode turns ordinary input and editor commands into native proposals. A headless or explicit transaction can begin a proposal with tx.authored.propose():

let changeId = '';
 
editor.update((tx) => {
  changeId = tx.authored.propose();
  tx.text.insert(' proposed', {
    at: { offset: 4, path: [0, 0] },
  });
});
let changeId = '';
 
editor.update((tx) => {
  changeId = tx.authored.propose();
  tx.text.insert(' proposed', {
    at: { offset: 4, path: [0, 0] },
  });
});

Pass { changeId } to add the transaction's writes to the pending change with that ID. Use separate proposing transactions when reviewers need to decide the edits independently.

Authored owns review decisions and their structured results. Read the change, then decide the record you read:

import { AuthoredPlugin } from 'platejs/authored';
 
const authored = editor.plugin(AuthoredPlugin);
const change = authored.read.change(changeId);
 
if (change) {
  const result = authored.update.decide({
    action: 'accept',
    changes: [change],
  });
}
import { AuthoredPlugin } from 'platejs/authored';
 
const authored = editor.plugin(AuthoredPlugin);
const change = authored.read.change(changeId);
 
if (change) {
  const result = authored.update.decide({
    action: 'accept',
    changes: [change],
  });
}

Use action: 'reject' to reject the change. Inspect result.status: another edit can make the change stale, and a decision that needs other changes decided with it returns blocked. To decide a whole card, pass { action, groups: [card] }. Authored Changes covers paging, cards, previews, and result handling.

Decide every suggestion

To accept or reject every pending suggestion, read every page of changes and decide them in one call:

import { type AuthoredChange, AuthoredPlugin } from 'platejs/authored';
 
const authored = editor.plugin(AuthoredPlugin);
const changes: AuthoredChange[] = [];
 
for (let cursor: string | undefined; ; ) {
  const page = authored.read.changes({ cursor, limit: 200 });
 
  changes.push(...page.items);
  if (!page.cursor) break;
  ({ cursor } = page);
}
 
const result = authored.update.decide({ action: 'accept', changes });
import { type AuthoredChange, AuthoredPlugin } from 'platejs/authored';
 
const authored = editor.plugin(AuthoredPlugin);
const changes: AuthoredChange[] = [];
 
for (let cursor: string | undefined; ; ) {
  const page = authored.read.changes({ cursor, limit: 200 });
 
  changes.push(...page.items);
  if (!page.cursor) break;
  ({ cursor } = page);
}
 
const result = authored.update.

One call decides every change together, so it never returns blocked. When it applies, it leaves the same document as deciding the changes one at a time in any order, where each blocked step adds the changes its with names. One undo restores every change it decided. Read and decide in the same event handler, because an edit between them can make a change stale. When the schema refuses the decided document, the result is invalid and nothing changes. The copied ModeToolbarButton offers this as Accept all suggestions and Reject all suggestions in its menu.

Save and reload

Save the complete value returned by editor.read.value(). Pending review marks live in each node's authored property and review records in the document's records; leave both to the editor.

const savedDocument = editor.read.value();
const savedComments = editor.plugin(CommentsPlugin).api.toJSON();
const savedDocument = editor.read.value();
const savedComments = editor.plugin(CommentsPlugin).api.toJSON();

Persist the document and CommentsJSON atomically for the same application revision and conversation generation. Verify that association before loading, and use the same content plugins. Saving only children discards the review records.

Create a fresh editor with the saved document as initialValue and CommentsPlugin.configure({ initialState: { initialComments: savedComments } }). Local undo starts empty. The document preserves each change's author and ID, so replies can locate the same suggestions. Set userId to the current author; loading another author's suggestions does not require changing it or replaying their edits. For historical previews and restores, join current conversations with the selected revision's targets.

Choose an explicit export projection. HTML and Markdown carry visible accepted or proposed content only. Save authored JSON when Plate must recover the review, or use DOCX review export for Word revisions.

Headless usage

Use BaseSuggestionPlugin for suggestion modes and semantic decorations without React, Comments, or copied UI:

suggestion-editor.ts
import { BaseParagraphPlugin, createEditor } from 'platejs';
import { BaseSuggestionPlugin } from 'platejs/suggestion';
 
const editor = createEditor({
  plugins: [BaseParagraphPlugin, BaseSuggestionPlugin],
  initialValue: [
    { type: 'paragraph', children: [{ text: 'Edit this sentence.' }] },
  ],
  userId: 'alice',
});
 
editor.plugin(BaseSuggestionPlugin).api.setMode('suggesting');
suggestion-editor.ts
import { BaseParagraphPlugin, createEditor } from 'platejs';
import { BaseSuggestionPlugin } from 'platejs/suggestion';
 
const editor = createEditor({
  plugins: [BaseParagraphPlugin, BaseSuggestionPlugin],
  initialValue: [
    { type: 'paragraph', children: [{ text: 'Edit this sentence.' }] },
  ],
  userId: 'alice',
});
 
editor.plugin(BaseSuggestionPlugin).api.setMode('suggesting');

Marked text and elements expose data-editor-authored-change, data-editor-authored-kind, data-editor-authored-author, data-editor-authored-groups, and data-editor-authored-color. Each holds a space-separated list, except color, which holds the palette slot, 0 to 6, of the card the element paints. A pending deletion has delete in its kind. Add application styles against these attributes through the plugin's render.contentAttributes. Static presets can configure BaseSuggestionPlugin with a server-safe attribute object; SuggestionKit supplies the live default styles.

Discussion review

Add DiscussionKit for Accept and Reject cards and replies attached to a change. It composes optional Comments data with native authored changes; review presentation remains copied application code.

To attach a conversation programmatically, target the authored change ID:

import { CommentsPlugin } from 'platejs/comments/react';
 
const comments = editor.plugin(CommentsPlugin).api;
const result = await comments.createThread({
  body: [
    { type: 'paragraph', children: [{ text: 'Can we explain this edit?' }] },
  ],
  target: { type: 'change', id: changeId },
});
 
if (result.status === 'applied') {
  comments.setActive([result.value]);
}
import { CommentsPlugin } from 'platejs/comments/react';
 
const comments = editor.plugin(CommentsPlugin).api;
const result = await comments.createThread({
  body: [
    { type: 'paragraph', children: [{ text: 'Can we explain this edit?' }] },
  ],
  target: { type: 'change', id: changeId },
});
 
if (result.status === 'applied') {
  comments.setActive([result.value]);
}

Preserve input for non-applied results and thrown errors. Comments authorizes durable writes through initialState.mutate and publishes only the canonical committed record. Accepting or rejecting a change is a document action that can be undone; it hides the review card without deleting or resolving its discussion. Undo restores the pending change with its messages and resolution intact. Resolving or reopening the thread stays outside document undo.

API Reference

BaseSuggestionPlugin

Import from platejs/suggestion. It supplies the two suggestion modes and CSS-free semantic decorations, and depends on AuthoredPlugin for change records and identity.

SuggestionPlugin

Import from platejs/suggestion/react. It extends BaseSuggestionPlugin with exact-view active state and click activation.

suggestion.api.setMode(mode)

Set whether subsequent input proposes or directly edits through editor.plugin(SuggestionPlugin) or editor.plugin(BaseSuggestionPlugin). It sets the view's intent, 'propose' or 'edit'; both show pending insertions and deletions.

suggestion.read.mode()

Read 'suggesting' for a propose view, and 'editing' for an edit view or a preview.

React hooks

HookResult
useSuggestionMode()The exact mounted view's 'suggesting' | 'editing' mode.
useSuggestionChanges(path)Native AuthoredChange records touching the block at path.
useSuggestionGroups(blockKey)Summaries of the review cards touching the top-level block with that key.
useActiveSuggestion()The view-local active card's activeId and the setActiveId function.

SuggestionKit

The copied registry kit configures SuggestionPlugin with content-root styles. Generic editor components remain independent of suggestions. Review cards, Comments integration, toolbars, and popovers remain in registry code.

:
string
;
background: string;
foreground: string;
hover: string;
}>;
const SUGGESTION_COLORS: readonly SuggestionColor[] = [
{
active: 'var(--color-emerald-800)',
background: 'var(--color-emerald-100)',
foreground: 'var(--color-emerald-700)',
hover: 'color-mix(in oklab, var(--color-emerald-200) 80%, transparent)',
},
{
active: 'var(--color-violet-800)',
background: 'var(--color-violet-100)',
foreground: 'var(--color-violet-700)',
hover: 'color-mix(in oklab, var(--color-violet-200) 80%, transparent)',
},
{
active: 'var(--color-sky-800)',
background: 'var(--color-sky-100)',
foreground: 'var(--color-sky-700)',
hover: 'color-mix(in oklab, var(--color-sky-200) 80%, transparent)',
},
{
active: 'var(--color-fuchsia-800)',
background: 'var(--color-fuchsia-100)',
foreground: 'var(--color-fuchsia-700)',
hover: 'color-mix(in oklab, var(--color-fuchsia-200) 80%, transparent)',
},
{
active: 'var(--color-orange-800)',
background: 'var(--color-orange-100)',
foreground: 'var(--color-orange-700)',
hover: 'color-mix(in oklab, var(--color-orange-200) 80%, transparent)',
},
{
active: 'var(--color-cyan-800)',
background: 'var(--color-cyan-100)',
foreground: 'var(--color-cyan-700)',
hover: 'color-mix(in oklab, var(--color-cyan-200) 80%, transparent)',
},
{
active: 'var(--color-rose-800)',
background: 'var(--color-rose-100)',
foreground: 'var(--color-rose-700)',
hover: 'color-mix(in oklab, var(--color-rose-200) 80%, transparent)',
},
] as const;
const escapeCssString = (value: string) =>
Array.from(value)
.map((character) => {
const code = character.codePointAt(0) ?? 0;
if (character === '"' || character === '\\') return `\\${character}`;
if (code < 0x20 || code === 0x7f) return `\\${code.toString(16)} `;
return character;
})
.join('');
const colorDeclarations = ({
active,
background,
foreground,
hover,
}: SuggestionColor) =>
`--suggestion-active:${active};--suggestion-bg:${background};--suggestion-fg:${foreground};--suggestion-hover:${hover};`;
function SuggestionColorStyles({ editableRef }: EditableSiblingProps) {
const scopeId = React.useId();
const { activeId } = useActiveSuggestion();
// The active change's card, read again after each review commit, so the
// active rule follows the card through a merge or a new oldest change.
const activeCard = useEditorSelector((editor) =>
activeId
? (editor.plugin(AuthoredPlugin).read.groupOf(activeId)?.id ?? null)
: null
);
React.useLayoutEffect(() => {
const root = editableRef.current;
if (!root) return undefined;
root.setAttribute('data-editor-suggestion-scope', scopeId);
return () => {
if (root.getAttribute('data-editor-suggestion-scope') === scopeId) {
root.removeAttribute('data-editor-suggestion-scope');
}
};
}, [editableRef, scopeId]);
const scope = `[data-editor-suggestion-scope="${escapeCssString(scopeId)}"]`;
// The active rule follows the color rules, so it wins on a highlight that
// also paints another card, and it shows the active card's own colors.
const rules = SUGGESTION_COLORS.map(
(color, slot) =>
`${scope} [data-editor-authored-color="${slot}"]{${colorDeclarations(color)}}`
);
if (activeCard) {
const color =
SUGGESTION_COLORS[
suggestionColorOf(activeCard, SUGGESTION_COLORS.length)
];
rules.push(
`${scope} [data-editor-authored-groups~="${escapeCssString(
activeCard
)}"]{${colorDeclarations(color)}--suggestion-bg:${color.hover};--suggestion-fg:${color.active};}`
);
}
return (
<style
data-editor-suggestion-active={activeId ?? undefined}
data-editor-suggestion-colors=""
>
{rules.join('')}
</style>
);
}
const REFUSED: Readonly<Record<string, string>> = {
'code-block': 'code blocks',
table: 'tables',
};
const refusedPlaces = new Intl.ListFormat('en');
function vetoMessage(reasons: readonly string[]) {
const places = reasons
.filter((reason) => reason !== 'list')
.map((reason) => REFUSED[reason] ?? reason);
const sentences =
places.length > 0
? [
`Suggesting is off in ${refusedPlaces.format(places)} for now. Switch to Editing to change them.`,
]
: [];
if (reasons.includes('list')) {
sentences.push(
"Suggesting can't change list items yet, only the text in them."
);
}
return sentences.join(' ');
}
function SuggestionRefusals() {
const editor = useEditor();
React.useEffect(() => {
const authored = editor.plugin(AuthoredPlugin);
return authored.api.subscribeRefusals((refusal) => {
const view = authored.read.view();
toast.error(
refusal.code === 'veto'
? vetoMessage(refusal.reasons)
: refusal.code === 'generated-values'
? 'Suggesting is off in this editor.'
: 'intent' in view && view.intent === 'edit'
? 'Editing cannot record this change next to a pending suggestion yet. Accept or reject that suggestion first.'
: 'Suggesting cannot record this change yet. Switch to Editing to make it.',
{ id: 'suggestion-refusal' }
);
});
}, [editor]);
return null;
}
function SuggestionSiblings(props: EditableSiblingProps) {
return (
<>
<SuggestionColorStyles {...props} />
<SuggestionRefusals />
</>
);
}
export const SuggestionKit = [
SuggestionPlugin.configure(() => ({
render: {
contentAttributes: {
// A block's own mark, such as a split, a join or a format change,
// may leave its text unmarked, so the block gets a gutter marker:
// a pilcrow at a suggested line break, a bar otherwise.
className: [
'[&_[data-editor-node=text][data-editor-authored-change]]:bg-[var(--suggestion-bg,var(--color-emerald-100))] [&_[data-editor-node=text][data-editor-authored-change]]:text-[var(--suggestion-fg,var(--color-emerald-700))] [&_[data-editor-node=text][data-editor-authored-change]:hover]:bg-[var(--suggestion-hover,var(--color-emerald-200))] [&_[data-editor-node=text][data-editor-authored-kind~=insert]]:underline [&_[data-editor-node=text][data-editor-authored-kind~=insert]]:decoration-2 [&_[data-editor-node=text][data-editor-authored-kind~=insert]]:underline-offset-2 [&_[data-editor-node=text][data-editor-authored-kind~=delete]]:line-through',
'[&_[data-editor-node=element][data-editor-authored-change]:not([data-editor-inline])]:after:pointer-events-none [&_[data-editor-node=element][data-editor-authored-change]:not([data-editor-inline])]:after:absolute [&_[data-editor-node=element][data-editor-authored-change]:not([data-editor-inline])]:after:inset-y-1 [&_[data-editor-node=element][data-editor-authored-change]:not([data-editor-inline])]:after:-left-7 [&_[data-editor-node=element][data-editor-authored-change]:not([data-editor-inline])]:after:w-0.5 [&_[data-editor-node=element][data-editor-authored-change]:not([data-editor-inline])]:after:rounded-full [&_[data-editor-node=element][data-editor-authored-change]:not([data-editor-inline])]:after:bg-[var(--suggestion-fg,var(--color-emerald-700))] [&_[data-editor-node=element][data-editor-authored-change]:not([data-editor-inline])]:after:content-[""]',
'[&_[data-editor-node=element][data-editor-authored-change]:is([data-editor-authored-kind~=split],[data-editor-authored-kind~=join]):not([data-editor-inline])]:after:-left-8 [&_[data-editor-node=element][data-editor-authored-change]:is([data-editor-authored-kind~=split],[data-editor-authored-kind~=join]):not([data-editor-inline])]:after:w-auto [&_[data-editor-node=element][data-editor-authored-change]:is([data-editor-authored-kind~=split],[data-editor-authored-kind~=join]):not([data-editor-inline])]:after:bg-transparent [&_[data-editor-node=element][data-editor-authored-change]:is([data-editor-authored-kind~=split],[data-editor-authored-kind~=join]):not([data-editor-inline])]:after:font-semibold [&_[data-editor-node=element][data-editor-authored-change]:is([data-editor-authored-kind~=split],[data-editor-authored-kind~=join]):not([data-editor-inline])]:after:text-[var(--suggestion-fg,var(--color-emerald-700))] [&_[data-editor-node=element][data-editor-authored-change]:is([data-editor-authored-kind~=split],[data-editor-authored-kind~=join]):not([data-editor-inline])]:after:content-["¶"] [&_[data-editor-node=element][data-editor-authored-change][data-editor-authored-kind~=join]:not([data-editor-inline])]:after:line-through',
].join(' '),
},
},
slots: { afterEditable: SuggestionSiblings },
})),
];
'use client';
 
import { AuthoredPlugin } from 'platejs/authored';
import {
  type EditableSiblingProps,
  useEditor,
  useEditorSelector,
} from 'platejs/react';
import { suggestionColorOf } from 'platejs/suggestion';
import {
  SuggestionPlugin,
  useActiveSuggestion,
} from 'platejs/suggestion/react';
import * as React from 'react';
import { toast } from 'sonner';
 
type SuggestionColor = Readonly<{
  active: string;
  background: string;
  foreground: string;
  hover: string;
}>;
 
const SUGGESTION_COLORS: readonly SuggestionColor[] = [
  {
    active: 'var(--color-emerald-800)',
    background: 'var(--color-emerald-100)',
    foreground: 'var(--color-emerald-700)',
    hover: 'color-mix(in oklab, var(--color-emerald-200) 80%, transparent)',
  },
  {
    active: 'var(--color-violet-800)',
    background: 'var(--color-violet-100)',
    foreground: 'var(--color-violet-700)',
    hover: 'color-mix(in oklab, var(--color-violet-200) 80%, transparent)',
  },
  {
    active: 'var(--color-sky-800)',
    background: 'var(--color-sky-100)',
    foreground: 'var(--color-sky-700)',
    hover: 'color-mix(in oklab, var(--color-sky-200) 80%, transparent)',
  },
  {
    active: 'var(--color-fuchsia-800)',
    background: 'var(--color-fuchsia-100)',
    foreground: 'var(--color-fuchsia-700)',
    hover: 'color-mix(in oklab, var(--color-fuchsia-200) 80%, transparent)',
  },
  {
    active: 'var(--color-orange-800)',
    background: 'var(--color-orange-100)',
    foreground: 'var(--color-orange-700)',
    hover: 'color-mix(in oklab, var(--color-orange-200) 80%, transparent)',
  },
  {
    active: 'var(--color-cyan-800)',
    background: 'var(--color-cyan-100)',
    foreground: 'var(--color-cyan-700)',
    hover: 'color-mix(in oklab, var(--color-cyan-200) 80%, transparent)',
  },
  {
    active: 'var(--color-rose-800)',
    background: 'var(--color-rose-100)',
    foreground: 'var(--color-rose-700)',
    hover: 'color-mix(in oklab, var(--color-rose-200) 80%, transparent)',
  },
] as const;
 
const escapeCssString = (value: string) =>
  Array.from(value)
    .map((character) => {
      const code = character.codePointAt(0) ?? 0;
 
      if (character === '"' || character === '\\') return `\\${character}`;
      if (code < 0x20 || code === 0x7f) return `\\${code.toString(16)} `;
 
      return character;
    })
    .join('');
 
const colorDeclarations = ({
  active,
  background,
  foreground,
  hover,
}: SuggestionColor) =>
  `--suggestion-active:${active};--suggestion-bg:${background};--suggestion-fg:${foreground};--suggestion-hover:${hover};`;
 
function SuggestionColorStyles({ editableRef }: EditableSiblingProps) {
  const scopeId = React.useId();
  const { activeId } = useActiveSuggestion();
  // The active change's card, read again after each review commit, so the
  // active rule follows the card through a merge or a new oldest change.
  const activeCard = useEditorSelector((editor) =>
    activeId
      ? (editor.plugin(AuthoredPlugin).read.groupOf(activeId)?.id ?? null)
      : null
  );
 
  React.useLayoutEffect(() => {
    const root = editableRef.current;
    if (!root) return undefined;
 
    root.setAttribute('data-editor-suggestion-scope', scopeId);
 
    return () => {
      if (root.getAttribute('data-editor-suggestion-scope') === scopeId) {
        root.removeAttribute('data-editor-suggestion-scope');
      }
    };
  }, [editableRef, scopeId]);
 
  const scope = `[data-editor-suggestion-scope="${escapeCssString(scopeId)}"]`;
  // The active rule follows the color rules, so it wins on a highlight that
  // also paints another card, and it shows the active card's own colors.
  const rules = SUGGESTION_COLORS.map(
    (color, slot) =>
      `${scope} [data-editor-authored-color="${slot}"]{${colorDeclarations(color)}}`
  );
 
  if (activeCard) {
    const color =
      SUGGESTION_COLORS[
        suggestionColorOf(activeCard, SUGGESTION_COLORS.length)
      ];
 
    rules.push(
      `${scope} [data-editor-authored-groups~="${escapeCssString(
        activeCard
      )}"]{${colorDeclarations(color)}--suggestion-bg:${color.hover};--suggestion-fg:${color.active};}`
    );
  }
 
  return (
    <style
      data-editor-suggestion-active={activeId ?? undefined}
      data-editor-suggestion-colors=""
    >
      {rules.join('')}
    </style>
  );
}
 
const REFUSED: Readonly<Record<string, string>> = {
  'code-block': 'code blocks',
  table: 'tables',
};
const refusedPlaces = new Intl.ListFormat('en');
 
function vetoMessage(reasons: readonly string[]) {
  const places = reasons
    .filter((reason) => reason !== 'list')
    .map((reason) => REFUSED[reason] ?? reason);
  const sentences =
    places.length > 0
      ? [
          `Suggesting is off in ${refusedPlaces.format(places)} for now. Switch to Editing to change them.`,
        ]
      : [];
 
  if (reasons.includes('list')) {
    sentences.push(
      "Suggesting can't change list items yet, only the text in them."
    );
  }
 
  return sentences.join(' ');
}
 
function SuggestionRefusals() {
  const editor = useEditor();
 
  React.useEffect(() => {
    const authored = editor.plugin(AuthoredPlugin);
 
    return authored.api.subscribeRefusals((refusal) => {
      const view = authored.read.view();
 
      toast.error(
        refusal.code === 'veto'
          ? vetoMessage(refusal.reasons)
          : refusal.code === 'generated-values'
            ? 'Suggesting is off in this editor.'
            : 'intent' in view && view.intent === 'edit'
              ? 'Editing cannot record this change next to a pending suggestion yet. Accept or reject that suggestion first.'
              : 'Suggesting cannot record this change yet. Switch to Editing to make it.',
        { id: 'suggestion-refusal' }
      );
    });
  }, [editor]);
 
  return null;
}
 
function SuggestionSiblings(props: EditableSiblingProps) {
  return (
    <>
      <SuggestionColorStyles {...props} />
      <SuggestionRefusals />
    </>
  );
}
 
export const SuggestionKit = [
  SuggestionPlugin.configure(() => ({
    render: {
      contentAttributes: {
        // A block's own mark, such as a split, a join or a format change,
        // may leave its text unmarked, so the block gets a gutter marker:
        // a pilcrow at a suggested line break, a bar otherwise.
        className: [
          '[&_[data-editor-node=text][data-editor-authored-change]]:bg-[var(--suggestion-bg,var(--color-emerald-100))] [&_[data-editor-node=text][data-editor-authored-change]]:text-[var(--suggestion-fg,var(--color-emerald-700))] [&_[data-editor-node=text][data-editor-authored-change]:hover]:bg-[var(--suggestion-hover,var(--color-emerald-200))] [&_[data-editor-node=text][data-editor-authored-kind~=insert]]:underline [&_[data-editor-node=text][data-editor-authored-kind~=insert]]:decoration-2 [&_[data-editor-node=text][data-editor-authored-kind~=insert]]:underline-offset-2 [&_[data-editor-node=text][data-editor-authored-kind~=delete]]:line-through',
          '[&_[data-editor-node=element][data-editor-authored-change]:not([data-editor-inline])]:after:pointer-events-none [&_[data-editor-node=element][data-editor-authored-change]:not([data-editor-inline])]:after:absolute [&_[data-editor-node=element][data-editor-authored-change]:not([data-editor-inline])]:after:inset-y-1 [&_[data-editor-node=element][data-editor-authored-change]:not([data-editor-inline])]:after:-left-7 [&_[data-editor-node=element][data-editor-authored-change]:not([data-editor-inline])]:after:w-0.5 [&_[data-editor-node=element][data-editor-authored-change]:not([data-editor-inline])]:after:rounded-full [&_[data-editor-node=element][data-editor-authored-change]:not([data-editor-inline])]:after:bg-[var(--suggestion-fg,var(--color-emerald-700))] [&_[data-editor-node=element][data-editor-authored-change]:not([data-editor-inline])]:after:content-[""]',
          '[&_[data-editor-node=element][data-editor-authored-change]:is([data-editor-authored-kind~=split],[data-editor-authored-kind~=join]):not([data-editor-inline])]:after:-left-8 [&_[data-editor-node=element][data-editor-authored-change]:is([data-editor-authored-kind~=split],[data-editor-authored-kind~=join]):not([data-editor-inline])]:after:w-auto [&_[data-editor-node=element][data-editor-authored-change]:is([data-editor-authored-kind~=split],[data-editor-authored-kind~=join]):not([data-editor-inline])]:after:bg-transparent [&_[data-editor-node=element][data-editor-authored-change]:is([data-editor-authored-kind~=split],[data-editor-authored-kind~=join]):not([data-editor-inline])]:after:font-semibold [&_[data-editor-node=element][data-editor-authored-change]:is([data-editor-authored-kind~=split],[data-editor-authored-kind~=join]):not([data-editor-inline])]:after:text-[var(--suggestion-fg,var(--color-emerald-700))] [&_[data-editor-node=element][data-editor-authored-change]:is([data-editor-authored-kind~=split],[data-editor-authored-kind~=join]):not([data-editor-inline])]:after:content-["¶"] [&_[data-editor-node=element][data-editor-authored-change][data-editor-authored-kind~=join]:not([data-editor-inline])]:after:line-through',
        ].join(' '),
      },
    },
    slots: { afterEditable: SuggestionSiblings },
  })),
];
import { Toolbar } from '@/components/editor/toolbar';
export function SuggestionEditor() {
const editor = useCreateEditor({
plugins: [
...BasicBlocksKit,
...SuggestionKit,
...DiscussionKit,
CommentsPlugin.configure({
initialState: {
users: { alice: { id: 'alice', name: 'Alice' } },
},
}),
],
initialValue: [
{ type: 'paragraph', children: [{ text: 'Edit this sentence.' }] },
],
userId: 'alice',
});
return (
<EditorRoot editor={editor} authored={{ intent: 'propose' }}>
<EditorContainer>
<Toolbar>
<UndoToolbarButton />
<RedoToolbarButton />
<SuggestionToolbarButton />
</Toolbar>
<Editor />
</EditorContainer>
</EditorRoot>
);
}
components/editor/suggestion-editor.tsx
'use client';
 
import { CommentsPlugin } from 'platejs/comments/react';
import { EditorRoot, useCreateEditor } from 'platejs/react';
 
import { BasicBlocksKit } from '@/components/editor/basic-blocks';
import { DiscussionKit } from '@/components/editor/discussion';
import { Editor, EditorContainer } from '@/components/editor/editor';
import {
  RedoToolbarButton,
  UndoToolbarButton,
} from '@/components/editor/history-toolbar-button';
import { SuggestionKit } from '@/components/editor/suggestion';
import { SuggestionToolbarButton } from '@/components/editor/suggestion-toolbar-button';
import { Toolbar } from '@/components/editor/toolbar';
 
export function SuggestionEditor() {
  const editor = useCreateEditor({
    plugins: [
      ...BasicBlocksKit,
      ...SuggestionKit,
      ...DiscussionKit,
      CommentsPlugin.configure({
        initialState: {
          users: { alice: { id: 'alice', name: 'Alice' } },
        },
      }),
    ],
    initialValue: [
      { type: 'paragraph', children: [{ text: 'Edit this sentence.' }] },
    ],
    userId: 'alice',
  });
 
  return (
    <EditorRoot editor={editor} authored={{ intent: 'propose' }}>
      <EditorContainer>
        <Toolbar>
          <UndoToolbarButton />
          <RedoToolbarButton />
          <SuggestionToolbarButton />
        </Toolbar>
        <Editor />
      </EditorContainer>
    </EditorRoot>
  );
}
{mode
===
'suggesting'
?
'Stop suggesting'
:
'Suggest edits'
}
</button>
);
}
decide
({ action:
'accept'
, changes });