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
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • CSV
    • 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 type { EditableSiblingProps } from 'platejs/react';
import { useEditor } from 'platejs/react';
import { SuggestionPlugin } from 'platejs/suggestion/react';
import * as React from 'react';
 
type SuggestionColor = Readonly<{
  active: string;
  background: string;
  foreground: string;
  hover: string;
}>;
 
const OWN_COLOR: SuggestionColor = {






































































































































































































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 in both userId and Comments' currentUserId. userId attributes document changes. currentUserId identifies the person writing replies, while users supplies names and optional avatars.

SuggestionPlugin depends on DefaultAuthoredPlugin, which reads the editor's userId once per transaction. A missing identity rejects tracked document writes. Do not install another 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. An edit that depends on pending content remains 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, input that maps exactly to accepted content edits that content directly. Pending insertions, retained deletions, and selections that cross accepted and pending content stay review-only; switch to Suggestion mode to amend them.

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 { DefaultAuthoredPlugin } from 'platejs/authored';
 
const authored = editor.plugin(DefaultAuthoredPlugin);
 
authored.api.setView({
  intent: 'propose',
  projection: 'proposed',
});
import { DefaultAuthoredPlugin } from 'platejs/authored';
 
const authored = editor.plugin(DefaultAuthoredPlugin);
 
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 { DefaultAuthoredPlugin } from 'platejs/authored';
 
const authored = editor.plugin(DefaultAuthoredPlugin);
const selection = authored.read.select({ ids: [changeId] });
const result = authored.update.decide({
  action: 'accept',
  selection,
});
import { DefaultAuthoredPlugin } from 'platejs/authored';
 
const authored = editor.plugin(DefaultAuthoredPlugin);
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.

For JSON, Markdown, HTML, or DOCX, choose an explicit export projection. Accepted and proposed exports omit review records; use review when the saved format must preserve them.

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

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)',
};
const COLLABORATOR_COLORS: readonly SuggestionColor[] = [
{
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 hashAuthor = (authorId: string) => {
let hash = 2_166_136_261;
for (const character of authorId) {
hash ^= character.codePointAt(0) ?? 0;
hash = Math.imul(hash, 16_777_619);
}
return hash >>> 0;
};
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 editor = useEditor();
const currentUserId = editor.runtime.userId ?? '';
const scopeId = React.useId();
const colors = React.useRef(new Map<string, SuggestionColor>());
const collaboratorSlots = React.useRef(new Map<string, number>());
const styleRef = React.useRef<HTMLStyleElement>(null);
const registerAuthors = React.useCallback(
(nextAuthorIds: Iterable<string>) => {
const rules: string[] = [];
const scope = `[data-editor-suggestion-scope="${escapeCssString(
scopeId
)}"]`;
const usedSlots = new Set(collaboratorSlots.current.values());
for (const authorId of nextAuthorIds) {
if (!authorId || colors.current.has(authorId)) continue;
let color = OWN_COLOR;
if (authorId !== currentUserId) {
let slot = hashAuthor(authorId) % COLLABORATOR_COLORS.length;
for (let offset = 0; offset < COLLABORATOR_COLORS.length; offset++) {
const candidate = (slot + offset) % COLLABORATOR_COLORS.length;
if (!usedSlots.has(candidate)) {
slot = candidate;
break;
}
}
usedSlots.add(slot);
collaboratorSlots.current.set(authorId, slot);
color = COLLABORATOR_COLORS[slot];
}
colors.current.set(authorId, color);
const author = `[data-editor-authored-author="${escapeCssString(
authorId
)}"]`;
rules.push(
`${scope} ${author},${scope} .editor-inline-suggestion:has(${author}){${colorDeclarations(color)}}`
);
}
if (rules.length > 0 && styleRef.current) {
styleRef.current.textContent += rules.join('');
}
},
[currentUserId, scopeId]
);
React.useLayoutEffect(() => {
const root = editableRef.current;
if (!root) return undefined;
colors.current.clear();
collaboratorSlots.current.clear();
if (styleRef.current) styleRef.current.textContent = '';
root.setAttribute('data-editor-suggestion-scope', scopeId);
const collect = (target: ParentNode, authors: Set<string>) => {
if (
target instanceof HTMLElement &&
target.hasAttribute('data-editor-authored-author')
) {
authors.add(target.getAttribute('data-editor-authored-author') ?? '');
}
target
.querySelectorAll<HTMLElement>('[data-editor-authored-author]')
.forEach((element) =>
authors.add(element.getAttribute('data-editor-authored-author') ?? '')
);
};
const initialAuthors = new Set<string>();
collect(root, initialAuthors);
registerAuthors(initialAuthors);
const observer = new MutationObserver((records) => {
const authors = new Set<string>();
for (const record of records) {
if (record.type === 'attributes') {
collect(record.target as HTMLElement, authors);
continue;
}
record.addedNodes.forEach((node) => {
if (node instanceof HTMLElement) collect(node, authors);
});
}
registerAuthors(authors);
});
observer.observe(root, {
attributeFilter: ['data-editor-authored-author'],
attributes: true,
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
if (root.getAttribute('data-editor-suggestion-scope') === scopeId) {
root.removeAttribute('data-editor-suggestion-scope');
}
};
}, [editableRef, registerAuthors, scopeId]);
return <style ref={styleRef} data-editor-suggestion-colors="" />;
}
export const SuggestionKit = [
SuggestionPlugin.configure(() => ({
render: {
contentAttributes: {
className:
'[&_[data-editor-authored-change]]:bg-[var(--suggestion-bg,var(--color-emerald-100))] [&_[data-editor-authored-change]]:text-[var(--suggestion-fg,var(--color-emerald-700))] [&_:is([data-editor-authored-kind=insert],[data-editor-authored-kind=mixed]):not([data-editor-retained=delete])]:underline [&_:is([data-editor-authored-kind=insert],[data-editor-authored-kind=mixed]):not([data-editor-retained=delete])]:decoration-2 [&_:is([data-editor-authored-kind=insert],[data-editor-authored-kind=mixed]):not([data-editor-retained=delete])]:underline-offset-2 [&_[data-editor-authored-change]:hover]:bg-[var(--suggestion-hover,var(--color-emerald-200))] [&_[data-editor-authored-change][data-editor-authored-status=conflicted]]:bg-amber-100 [&_[data-editor-authored-change][data-editor-authored-status=conflicted]]:text-amber-800 [&_[data-editor-authored-change][data-editor-authored-status=conflicted]:hover]:bg-amber-200/80 [&_[data-editor-authored-status=conflicted][data-editor-suggestion-active]]:bg-amber-200/80 [&_[data-editor-authored-status=conflicted][data-editor-suggestion-active]]:text-amber-900 [&_[data-editor-retained=delete]]:line-through [&_[data-editor-suggestion-active]]:bg-[var(--suggestion-hover,var(--color-emerald-200))] [&_[data-editor-suggestion-active]]:text-[var(--suggestion-active,var(--color-emerald-800))]',
},
},
slots: { afterEditable: SuggestionColorStyles },
})),
];
'use client';
 
import type { EditableSiblingProps } from 'platejs/react';
import { useEditor } from 'platejs/react';
import { SuggestionPlugin } from 'platejs/suggestion/react';
import * as React from 'react';
 
type SuggestionColor = Readonly<{
  active: string;
  background: string;
  foreground: string;
  hover: string;
}>;
 
const OWN_COLOR: 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)',
};
 
const COLLABORATOR_COLORS: readonly SuggestionColor[] = [
  {
    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 hashAuthor = (authorId: string) => {
  let hash = 2_166_136_261;
 
  for (const character of authorId) {
    hash ^= character.codePointAt(0) ?? 0;
    hash = Math.imul(hash, 16_777_619);
  }
 
  return hash >>> 0;
};
 
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 editor = useEditor();
  const currentUserId = editor.runtime.userId ?? '';
  const scopeId = React.useId();
  const colors = React.useRef(new Map<string, SuggestionColor>());
  const collaboratorSlots = React.useRef(new Map<string, number>());
  const styleRef = React.useRef<HTMLStyleElement>(null);
  const registerAuthors = React.useCallback(
    (nextAuthorIds: Iterable<string>) => {
      const rules: string[] = [];
      const scope = `[data-editor-suggestion-scope="${escapeCssString(
        scopeId
      )}"]`;
      const usedSlots = new Set(collaboratorSlots.current.values());
 
      for (const authorId of nextAuthorIds) {
        if (!authorId || colors.current.has(authorId)) continue;
 
        let color = OWN_COLOR;
 
        if (authorId !== currentUserId) {
          let slot = hashAuthor(authorId) % COLLABORATOR_COLORS.length;
 
          for (let offset = 0; offset < COLLABORATOR_COLORS.length; offset++) {
            const candidate = (slot + offset) % COLLABORATOR_COLORS.length;
 
            if (!usedSlots.has(candidate)) {
              slot = candidate;
              break;
            }
          }
          usedSlots.add(slot);
          collaboratorSlots.current.set(authorId, slot);
          color = COLLABORATOR_COLORS[slot];
        }
 
        colors.current.set(authorId, color);
        const author = `[data-editor-authored-author="${escapeCssString(
          authorId
        )}"]`;
 
        rules.push(
          `${scope} ${author},${scope} .editor-inline-suggestion:has(${author}){${colorDeclarations(color)}}`
        );
      }
 
      if (rules.length > 0 && styleRef.current) {
        styleRef.current.textContent += rules.join('');
      }
    },
    [currentUserId, scopeId]
  );
 
  React.useLayoutEffect(() => {
    const root = editableRef.current;
    if (!root) return undefined;
 
    colors.current.clear();
    collaboratorSlots.current.clear();
    if (styleRef.current) styleRef.current.textContent = '';
    root.setAttribute('data-editor-suggestion-scope', scopeId);
 
    const collect = (target: ParentNode, authors: Set<string>) => {
      if (
        target instanceof HTMLElement &&
        target.hasAttribute('data-editor-authored-author')
      ) {
        authors.add(target.getAttribute('data-editor-authored-author') ?? '');
      }
      target
        .querySelectorAll<HTMLElement>('[data-editor-authored-author]')
        .forEach((element) =>
          authors.add(element.getAttribute('data-editor-authored-author') ?? '')
        );
    };
 
    const initialAuthors = new Set<string>();
    collect(root, initialAuthors);
    registerAuthors(initialAuthors);
    const observer = new MutationObserver((records) => {
      const authors = new Set<string>();
 
      for (const record of records) {
        if (record.type === 'attributes') {
          collect(record.target as HTMLElement, authors);
          continue;
        }
        record.addedNodes.forEach((node) => {
          if (node instanceof HTMLElement) collect(node, authors);
        });
      }
      registerAuthors(authors);
    });
 
    observer.observe(root, {
      attributeFilter: ['data-editor-authored-author'],
      attributes: true,
      childList: true,
      subtree: true,
    });
 
    return () => {
      observer.disconnect();
      if (root.getAttribute('data-editor-suggestion-scope') === scopeId) {
        root.removeAttribute('data-editor-suggestion-scope');
      }
    };
  }, [editableRef, registerAuthors, scopeId]);
 
  return <style ref={styleRef} data-editor-suggestion-colors="" />;
}
 
export const SuggestionKit = [
  SuggestionPlugin.configure(() => ({
    render: {
      contentAttributes: {
        className:
          '[&_[data-editor-authored-change]]:bg-[var(--suggestion-bg,var(--color-emerald-100))] [&_[data-editor-authored-change]]:text-[var(--suggestion-fg,var(--color-emerald-700))] [&_:is([data-editor-authored-kind=insert],[data-editor-authored-kind=mixed]):not([data-editor-retained=delete])]:underline [&_:is([data-editor-authored-kind=insert],[data-editor-authored-kind=mixed]):not([data-editor-retained=delete])]:decoration-2 [&_:is([data-editor-authored-kind=insert],[data-editor-authored-kind=mixed]):not([data-editor-retained=delete])]:underline-offset-2 [&_[data-editor-authored-change]:hover]:bg-[var(--suggestion-hover,var(--color-emerald-200))] [&_[data-editor-authored-change][data-editor-authored-status=conflicted]]:bg-amber-100 [&_[data-editor-authored-change][data-editor-authored-status=conflicted]]:text-amber-800 [&_[data-editor-authored-change][data-editor-authored-status=conflicted]:hover]:bg-amber-200/80 [&_[data-editor-authored-status=conflicted][data-editor-suggestion-active]]:bg-amber-200/80 [&_[data-editor-authored-status=conflicted][data-editor-suggestion-active]]:text-amber-900 [&_[data-editor-retained=delete]]:line-through [&_[data-editor-suggestion-active]]:bg-[var(--suggestion-hover,var(--color-emerald-200))] [&_[data-editor-suggestion-active]]:text-[var(--suggestion-active,var(--color-emerald-800))]',
      },
    },
    slots: { afterEditable: SuggestionColorStyles },
  })),
];
import { Toolbar } from '@/components/editor/toolbar';
export function SuggestionEditor() {
const editor = useCreateEditor({
plugins: [
...BasicBlocksKit,
...SuggestionKit,
...DiscussionKit,
CommentsPlugin.configure({
initialState: {
currentUserId: 'alice',
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: {
          currentUserId: 'alice',
          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>
);
}