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 changesSave 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 change, then try undo and redo. Switch to Editing to keep the pending changes visible while ordinary edits publish directly, or type in Suggestion mode to propose an edit.

You can type, paste, or press Enter inside your own pending deletion. These edits remain part of that deletion. Backspace preserves the original deleted characters and removes text you added inside it. Reject restores the original content; Accept removes the entire deletion, including your additions.

Features

  • Switch a mounted editor view between editing and suggesting
  • Decorate proposed text, formatting, structural edits, and retained content
  • Keep active suggestion state local to each mounted view
  • Read stable native authored changes for custom review UI
  • Save pending changes and decisions 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 } from 'platejs/react';
import {
  SuggestionPlugin,
  useActiveSuggestion,
  useSuggestionGroups,
} from 'platejs/suggestion/react';
import * as React from 'react';
import { toast } from 'sonner';
 
type SuggestionColor = Readonly<{
  active: string;
  background: string;
  foreground: string










































































































































































SuggestionKit configures the package SuggestionPlugin with render.contentAttributes for its default styles. The package supplies suggestion modes, semantic attributes, active-change 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', projection: 'markup' }} as in the example above. Changing either value updates that view. Rerendering with the same values 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'
        )
      }
    >



Changing the mode updates the input intent and preserves the current projection:

ModeIntentProjection
'suggesting''propose'Keeps markup or proposed. An accepted view changes to markup so new proposals remain visible.
'editing''edit'Keeps accepted, proposed, or markup.

An editing markup view shows pending changes while ordinary edits to accepted content publish directly. A replacement that fully contains pending changes resolves them and publishes the replacement directly in the same undoable transaction. Other dependent edits remain reviewable with the current author. Use an accepted projection only when the view should hide pending content.

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. In Editing mode, exact accepted targets edit directly. A replacement spanning pending content applies directly only when the selection fully contains every involved pending change and dependency. Partial retained or dependency-spanning selections stay protected. Switch to Suggestion mode to intentionally propose or amend reviewable work.

To show accepted content without pending proposals, set { intent: 'edit', projection: 'accepted' } directly on the authored view.

For proposed content without retained review fragments, set an advanced authored view directly:

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

Changing the projection does not accept or reject changes. Coordinates can differ between projections, so read selections and apply edits through the view that produced them.

Read view state

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

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

useSuggestionChanges(path) returns native AuthoredChange records touching that block. It updates for document, authored-state, and projection changes while preserving stable results when the visible records are unchanged.

Clicking an element with data-editor-authored-change activates its change in the current view. Clicking unmarked editor content clears it. Unmounting the view, changing projection, or deciding the change also clears stale active state. Use setActiveId when a custom card or control needs to activate a change 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. Normal editor commands record text, mark, and node operations as proposals while the view is in suggesting mode.

Accepted, proposed, and markup

Switch projections to compare accepted content, the proposed result, and retained review fragments. The proposals stay pending in all three views.

Loading…

This example sets readOnly on both EditorRoot and Editor. Projection controls choose visible content; read-only prevents 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 and decisions 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 amend a pending contribution owned by the current author. Use separate proposing transactions when reviewers need to decide the edits independently.

Authored owns review decisions and their structured results:

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

Use action: 'reject' to reject the change. Inspect result.status: another edit can make the selection stale, while dependencies or conflicts can make it blocked. Authored Changes covers queries, previews, bulk decisions, and result handling.

Save and reload

Save the complete value returned by editor.read.value(). Its meta.authored field belongs to the document codec; do not edit it or copy proposal metadata into nodes.

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 authored 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 exact review records, 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');

Ordinary suggestion ranges expose data-editor-authored-change, data-editor-authored-kind, and data-editor-authored-status. Retained fragments expose data-editor-authored-change and data-editor-retained. 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). Both modes retain the markup projection and its pending review UI.

suggestion.read.mode()

Read 'suggesting' or 'editing' for that editor view.

React hooks

HookResult
useSuggestionMode()The exact mounted view's 'suggesting' | 'editing' mode.
useSuggestionChanges(path)Native AuthoredChange records touching the block at path.
useActiveSuggestion()The view-local activeId and 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.

;
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 groups = useSuggestionGroups();
const { activeId } = useActiveSuggestion();
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)}"]`;
const selector = (changeId: string) =>
`${scope} [data-editor-authored-change~="${escapeCssString(changeId)}"]`;
const rules = groups.map((group) => {
const selectors = group.changes.map((change) => selector(change.id));
const color = SUGGESTION_COLORS[group.rank % SUGGESTION_COLORS.length];
const active = group.changes.some((change) => change.id === activeId)
? `${selectors.join(',')}{--suggestion-bg:${
color.hover
};--suggestion-fg:${color.active};}`
: '';
return `${selectors.join(',')}{${colorDeclarations(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',
list: 'lists',
table: 'tables',
};
/** Says why Suggesting refused a change, which then applied nothing. */
function SuggestionRefusals() {
const editor = useEditor();
React.useEffect(
() =>
editor.plugin(AuthoredPlugin).api.subscribeRefusals((refusal) => {
const places = refusal.reasons.map(
(reason) => REFUSED[reason] ?? reason
);
toast.error(
refusal.code === 'veto'
? `Suggesting is off in ${new Intl.ListFormat('en').format(
places
)} for now. Switch to Editing to change them.`
: refusal.code === 'generated-values'
? 'Suggesting is off in this editor.'
: '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 } from 'platejs/react';
import {
  SuggestionPlugin,
  useActiveSuggestion,
  useSuggestionGroups,
} 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 groups = useSuggestionGroups();
  const { activeId } = useActiveSuggestion();
 
  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)}"]`;
  const selector = (changeId: string) =>
    `${scope} [data-editor-authored-change~="${escapeCssString(changeId)}"]`;
  const rules = groups.map((group) => {
    const selectors = group.changes.map((change) => selector(change.id));
    const color = SUGGESTION_COLORS[group.rank % SUGGESTION_COLORS.length];
    const active = group.changes.some((change) => change.id === activeId)
      ? `${selectors.join(',')}{--suggestion-bg:${
          color.hover
        };--suggestion-fg:${color.active};}`
      : '';
 
    return `${selectors.join(',')}{${colorDeclarations(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',
  list: 'lists',
  table: 'tables',
};
 
/** Says why Suggesting refused a change, which then applied nothing. */
function SuggestionRefusals() {
  const editor = useEditor();
 
  React.useEffect(
    () =>
      editor.plugin(AuthoredPlugin).api.subscribeRefusals((refusal) => {
        const places = refusal.reasons.map(
          (reason) => REFUSED[reason] ?? reason
        );
 
        toast.error(
          refusal.code === 'veto'
            ? `Suggesting is off in ${new Intl.ListFormat('en').format(
                places
              )} for now. Switch to Editing to change them.`
            : refusal.code === 'generated-values'
              ? 'Suggesting is off in this editor.'
              : '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', projection: 'markup' }}
>
<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', projection: 'markup' }}
    >
      <EditorContainer>
        <Toolbar>
          <UndoToolbarButton />
          <RedoToolbarButton />
          <SuggestionToolbarButton />
        </Toolbar>
        <Editor />
      </EditorContainer>
    </EditorRoot>
  );
}
{mode
===
'suggesting'
?
'Stop suggesting'
:
'Suggest edits'
}
</button>
);
}