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

Comments

PreviousNext

Add comments to text, reply in threads, and keep them attached as your document changes.

DiscussionComment ThreadsComment Toolbar Button
Loading…
CopilotDiscussion

On This Page

FeaturesKit UsageInstallationAdd KitExamplesOverlaps and historySave, reload, and retryRead-only and staticIntegrationApplication dataDiscover conversationsSave and loadAuthorize and commit mutationsDocument history and versionsCollaborationOwnershipManual UsageAPI ReferencePluginsData and rangesComment actionsSubscriptionsCopied UICommentComposer
Build your editor
Production-ready AI template and reusable components.
Get all-access

Click a highlight to open its conversation. Select text and use the Comment button or Mod+Shift+M to start a thread. You are Alice: reply to an existing conversation, or create your own thread to try editing, deleting, and resolving comments. Press Escape to cancel a new comment.

For comments alongside proposed edits, see Discussion.

Features

  • Rich-text comments, replies, and resolved threads
  • Highlights that follow document edits, undo, and redo
  • Session undo and redo for successful local thread creation
  • Overlapping threads and comments spanning text segments
  • Document-level discovery for open, resolved, and unavailable conversations
  • A versioned Comments JSON envelope saved with your document revision
  • Read-only review and static highlights
Report an issue

Kit Usage

Installation

pnpm dlx shadcn@latest add https://platejs.org/r/basic-blocks.json https://platejs.org/r/link.json https://platejs.org/r/discussion.json https://platejs.org/r/comment-toolbar-button.json
pnpm dlx shadcn@latest add https://platejs.org/r/basic-blocks.json https://platejs.org/r/link.json https://platejs.org/r/discussion.json https://platejs.org/r/comment-toolbar-button.json

DiscussionKit supplies comment styling, block buttons, and the floating thread view. Add SuggestionKit only when the editor reviews authored changes. The example uses comments without suggestions.

'use client';
 
import {
  CheckIcon,
  MessageSquareTextIcon,
  MessagesSquareIcon,
  PencilLineIcon,
  XIcon,
} from 'lucide-react';
import {
  createEditorView,
  getEditorRuntimeOwner,
  type NodeKey,
  type Path,
  PathApi,
  PointApi,
  type Range,
  RangeApi,
} from 'platejs';
import type {
  AuthoredChange,
  AuthoredChangeDetails,
  AuthoredChangePart,
  AuthoredResult,
} from 'platejs/authored'





































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































Add Kit

Load CommentsJSON with its matching document revision:

components/editor/comments-editor.tsx
"use client";
 
import type { EditorValueInput, Value } from "platejs";
import type { CommentsJSON, CommentUser } from "platejs/comments";
import { CommentsPlugin } from "platejs/comments/react";
import { EditorRoot, useCreateEditor } from "platejs/react";
 
import { BasicBlocksKit } from "@/components/editor/basic-blocks";
import { AllCommentsButton, CommentToolbarButton } from "@/components/editor/comment-toolbar-button";
import { DiscussionKit } from "@/components/editor/discussion";
import { Editor, EditorContainer } from "@/components/editor/editor";
import { LinkKit } from "@/components/editor/link"

































initialComments initializes each editor once. Verify that it belongs to value before mounting. To load another revision, create a fresh editor with that revision's document and Comments JSON. Updating initial props does not replace the current records.

Examples

Overlaps and history

Click “overlapping” to open both conversations. Edit the highlighted text, then undo and redo. Use All comments to find published conversations after their quoted text disappears.

Loading…

Inserting text inside a range expands it; deleting text contracts it. Text inserted at either boundary stays outside. An intentional atomic replacement carries the target to its replacement text. Deleting the target and typing later is a separate edit, so the later text does not inherit the comment. When no range can be shown, attachment(id) reports unavailable; All comments keeps the thread and its original excerpt reachable without a stale block badge. Undo and redo in the live session restore mapped coverage, including backward direction and overlaps.

Save, reload, and retry

Save a snapshot, change the document or a comment, then reload to restore the saved pair in a fresh editor. The snapshot stays in memory, and reload starts an empty local undo stack. Open All comments to review resolved or unavailable conversations. The reply form simulates one failed send: the draft stays in place, and sending again adds the reply.

Choose Preview saved version with current comments to open the saved document with current conversations and that revision's targets. Threads created after the snapshot remain in the panel with unavailable targets.

Loading…

Await the Comments mutation in CommentComposer's onSubmit and treat only status: 'applied' as success. Preserve the rich-text input on rejection or an error, and block duplicate submissions while waiting. Configure persistence and authorization through initialState.mutate.

Read-only and static

Open All comments in the read-only document to read, navigate, and reply. Document read-only prevents range creation and text edits; currentUserId and mutate still control conversation actions. The static snapshot paints the same initial ranges without thread controls.

Loading…

Set readOnly on both EditorRoot and Editor to prevent document edits. Comment permissions remain an application decision; this example permits replies.

For static rendering, configure BaseCommentsPlugin with commentDecorationAttributes from the copied comment-static item, then pass the editor to EditorStatic. Install those items when adding a static view:

pnpm dlx shadcn@latest add https://platejs.org/r/comment-static.json https://platejs.org/r/editor-static.json
pnpm dlx shadcn@latest add https://platejs.org/r/comment-static.json https://platejs.org/r/editor-static.json

Each independently loaded editor needs CommentsJSON for its document revision. Views of one model share that loaded data. Plain coordinates cannot identify edits that happened in a different document.

Integration

Application data

CommentThread contains conversation data and a semantic target: { type: 'range' } or { type: 'change', id }. It never contains a mapped Range. Message bodies use Plate Value; timestamps use ISO 8601 strings. resolution is null for an open thread, or { resolvedAt: string | null, userId: string | null } for a resolved thread.

Thread records and private anchors belong to the shared editor model. Each view reads its own placement: the same thread can have different ranges, or be unavailable, in different projections without copying its record or anchor.

Read placement through the exact view's plugin API. In this example, editor is the view that will use the returned coordinates; React UI can obtain its mounted editor with useEditor():

import { CommentsPlugin } from "platejs/comments/react";
 
const comments = editor.plugin(CommentsPlugin).api;
const thread = comments.getThread(threadId);
const attachment = comments.attachment(threadId);
 
if (attachment?.type === "range" && attachment.status === "attached") {
  editor.update.selection.set(attachment.range);
}
import { CommentsPlugin } from "platejs/comments/react";
 
const comments = editor.plugin(CommentsPlugin).api;
const thread = comments.getThread(threadId);
const attachment = comments.attachment(threadId);
 
if (attachment?.type === "range" && attachment.status === "attached") {
  editor.update.selection.set(attachment.range);
}

attachment(id) returns one of these values:

ValueMeaning
{ type: 'range', status: 'attached', range }Current mapped coverage is available
{ type: 'range', status: 'unavailable' }No range can be shown in this document projection
{ type: 'change', id }Locate the stable change ID through Authored
nullNo thread exists for that ID

An unavailable attachment does not prove deletion. It can also mean that a projection hides the target or that a historical revision has no target for the thread. Keep conversation data visible independently of inline placement.

Discover conversations

Render AllCommentsButton inside a toolbar whenever the editor installs Comments. It opens a lazy, paginated dialog over published records and works without a document selection, in read-only views, and without Suggestions. The All, Open, and Resolved filters use conversation state; target availability does not remove a record from those filters.

Only the mounted page resolves targets. Show in document reads the attachment again before selecting and scrolling the exact current-view range. Unavailable targets remain readable with the neutral label Target unavailable in this view. Dirty reply or edit input and pending actions keep their page mounted and block dismissal, filtering, paging, navigation, and destructive deletion until the action succeeds or the user cancels.

Save and load

Call toJSON() during an explicit save. It synchronously returns CommentsJSON: { kind: 'plate-comments', version: 1, threads, ranges }. threads contains published semantic records; each ranges entry contains { threadId, range: EditorDocumentRange | null } for a range-targeted thread. Local drafts are excluded. Treat EditorDocumentRange as opaque.

Store the envelope and document atomically. In this example, repository is your application's storage adapter; it checks the expected revision before committing:

import { CommentsPlugin } from "platejs/comments/react";
 
const comments = editor.plugin(CommentsPlugin).api;
const revision = {
  comments: comments.toJSON(),
  conversationGeneration,
  document: editor.read.value(),
  revisionId,
};
 
await repository.put(revision, { ifMatch: previousRevision });
import { CommentsPlugin } from "platejs/comments/react";
 
const comments = editor.plugin(CommentsPlugin).api;
const revision = {
  comments: comments.toJSON(),
  conversationGeneration,
  document: editor.read.value(),
  revisionId,
};
 
await repository.put(revision, { ifMatch: previousRevision });

Before loading, verify the document revision and conversation generation in your storage layer. Construct a fresh editor from revision.document and pass revision.comments as initialComments, using the same content plugins. Comments validates the envelope version, duplicate IDs, timestamps, target agreement, and range restoration before mounting. It cannot identify a foreign document with otherwise valid-looking coordinates.

If saved opaque ranges contain Authored bindings, install the same Authored runtime used for the saved document when loading initialComments. SuggestionKit supplies DefaultAuthoredPlugin; for a headless Comments-only setup, add the matching Authored plugin from platejs/authored. Preserve the full saved document and required authored history. A missing runtime or missing history rejects restoration; saved bindings do not fall back to plain coordinates. Authored remains optional for ordinary range comments.

Ordinary reload starts fresh local undo. getThreads() reads live conversation records; use toJSON() to persist their revision-bound attachments. Do not export the envelope on every keystroke or from attachment subscriptions.

Authorize and commit mutations

Configure your application adapter through CommentsPlugin.configure({ initialState: { mutate } }). The callback receives a structurally valid CommentMutationRequest and returns a CommentMutationDecision, synchronously or through a promise:

import type { EditorDocumentRange } from "platejs";
import type { CommentOperation, CommentThread } from "platejs/comments";
 
export type CommentMutationRequest = Readonly<{
  mutationId: string;
  operation: CommentOperation;
  previous: CommentThread | null;
  proposed: CommentThread | null;
  attachment?: EditorDocumentRange;
}>;
 
export type CommentMutationDecision =
  | Readonly<{ status: 'commit'; thread: CommentThread | null }>
  | Readonly<{ code?: string; status: 'reject' }>;
import type { EditorDocumentRange } from "platejs";
import type { CommentOperation, CommentThread } from "platejs/comments";
 
export type CommentMutationRequest = Readonly<{
  mutationId: string;
  operation: CommentOperation;
  previous: CommentThread | null;
  proposed: CommentThread | null;
  attachment?: EditorDocumentRange;
}>;
 
export type CommentMutationDecision =
  | Readonly<{ status: 'commit'; thread:

Authenticate the actor, authorize the operation, and persist it in your service before returning { status: 'commit', thread }. Return the canonical approved record, or thread: null for deletion. The committed record preserves the proposed thread ID and target identity. Return { status: 'reject' } with an optional code to deny a write. Use mutationId for idempotency and enforce cross-client compare-and-swap in your storage layer.

Comments publishes only after commit. Rejection or a thrown error preserves the previous record and composer. Actions are serialized per thread; an editor or thread retired while work is pending can return stale. The default adapter commits locally. Hiding controls or setting document readOnly does not authorize comment writes.

Document history and versions

Successful local thread creation joins the same session undo order as document edits. History waits for the configured mutation adapter before moving its branch. Undo removes the unchanged one-message thread; redo restores its saved target and canonical thread. A reply, edit, resolution, canonical replacement, rejected mutation, or storage error blocks that replay and keeps it at the history head, so the next document batch is never skipped.

Resolve and reopen update conversation metadata outside document undo. Replies, message edits, and explicit message or thread deletion also stay outside document history. Undoing a text deletion can restore its highlight, but never recreates an explicitly deleted conversation.

Accepting or rejecting a suggestion is a document action that can be undone. These decisions preserve its discussion, including messages and resolution. A change-targeted thread follows the same Authored change ID when the pending change becomes visible again.

For a read-only version preview, combine current retained conversations with range targets saved for that exact document revision. Do not load old conversation records over current ones. This example assumes your storage layer has verified revision and currentComments:

import type { CommentsJSON } from "platejs/comments";
 
const savedRanges = new Map(
  revision.comments.ranges.map(({ threadId, range }) => [threadId, range])
);
const initialComments: CommentsJSON = {
  kind: "plate-comments",
  version: 1,
  threads: currentComments.threads,
  ranges: currentComments.threads.flatMap((thread) =>
    thread.target.type === "range"
      ? [{ threadId: thread.id, range: savedRanges.get(thread.id) ?? null }]
      : []
  ),
};
import type { CommentsJSON } from "platejs/comments";
 
const savedRanges = new Map(
  revision.comments.ranges.map(({ threadId, range }) => [threadId, range])
);
const initialComments: CommentsJSON = {
  kind: "plate-comments",
  version: 1,
  threads: currentComments.threads,
  ranges: currentComments.threads.flatMap((thread) =>
    thread.target.type === "range"
      ? [{ threadId: thread.id, range: savedRanges.get(thread.id) ?? null }]
      : []
  ),

Open a fresh read-only editor with revision.document and this initialComments. Newer threads without a saved target are unavailable; currently deleted threads stay absent. Change targets can be located only when their IDs exist in that revision's Authored document.

Restoring a version creates a new application revision under both document-head and conversation-generation checks. Retain current conversations, install only the selected revision's proven targets, and open a fresh editor with empty local history. Local thread-creation undo is session-only and is not restored from CommentsJSON or History.toJSON(). See History for local undo and retained author history.

Collaboration

Independent editors can load the same saved revision and bind their own attachments. Real-time remote comment synchronization is not supported by this persistence API. Mutation results provide canonical records to the initiating editor.

Saved range targets cannot be restored against a changed Yjs room baseline. A state vector alone does not map missed edits, and persisted local undo is not supported on Yjs admission. Your application must establish the exact saved baseline before admitting its targets.

Ownership

OwnerResponsibility
platejs/commentsOwn records, actions, subscriptions, and private native range handles
platejs/comments/reactAdd selection commands, shortcuts, and highlight clicks
Copied comment, comment-toolbar-button, and discussion itemsStyle highlights; render forms, cards, live block triggers, Floating Discussion, and document-level discovery
Your applicationPair documents and Comments JSON; supply users, authorize mutations, and persist canonical records

Comment messages remain separate from document nodes and marks. A message edit notifies that thread's subscribers without resolving anchors or refreshing editor decorations. users and currentUserId use the plugin store; subscribe with usePluginStore and update them through store.set.

Manual Usage

The headless plugin provides the same data and attachment API without React. This example loads a verified paragraph-only revision; install the matching content plugins for richer documents:

pnpm add platejs
pnpm add platejs
import { BaseParagraphPlugin, createEditor } from "platejs";
import { BaseCommentsPlugin } from "platejs/comments";
 
const editor = createEditor({
  plugins: [BaseParagraphPlugin, BaseCommentsPlugin.configure({
    initialState: { initialComments: revision.comments, currentUserId: "alice" },
  })],
  initialValue: revision.document,
});
import { BaseParagraphPlugin, createEditor } from "platejs";
import { BaseCommentsPlugin } from "platejs/comments";
 
const editor = createEditor({
  plugins: [BaseParagraphPlugin, BaseCommentsPlugin.configure({
    initialState: { initialComments: revision.comments, currentUserId: "alice" },
  })],
  initialValue: revision.document,
});

For stored documents with inline comment properties, see Import comment annotations.

API Reference

Plugins

  • BaseCommentsPlugin from platejs/comments owns headless data and range behavior.
  • CommentsPlugin from platejs/comments/react adds React view interactions.

Data and ranges

MethodPurpose
api.getThreads()Read immutable semantic thread records
api.getThread(id)Read one stable record, or undefined
api.toJSON()Synchronously export CommentsJSON for this document revision
api.getSnapshot()Read stable thread IDs, visible IDs, draft IDs, and pending input
api.attachment(id)Read CommentAttachment in the calling view, or null when the thread is absent
api.idsAt(location)Read unresolved IDs overlapping this view's location, in record order
api.setActive(ids)Select an ordered group; use [] to clear it
api.subscribeAttachments(listener)Observe attachment mapping and projection changes in the calling view

Comment actions

MethodPurpose
api.begin(range?)Capture input in the calling view; a caret expands to its text block
api.pendingRange()Read the pending input's mapped range in the calling view
api.cancel()Cancel pending input and release its range
api.create(body)Publish the pending thread; await CommentMutationResult<string>
api.createThread(input)Publish a range or authored-change thread; await CommentMutationResult<string>
api.reply(id, body)Append a message to an unresolved thread
api.edit(id, messageId, body)Edit a message under adapter authorization
api.removeMessage(id, messageId)Remove a message; remove its thread when no messages remain
api.removeThread(id)Delete a thread under adapter authorization
api.resolve(id)Record canonical resolution actor and time
api.reopen(id)Clear resolution metadata
api.publishDraft(id)Commit a local draft through the mutation adapter
api.createDraft(input)Create a local unpublished draft; synchronously return string or null
api.discardDraft(id)Remove a local unpublished draft; synchronously return boolean

createThread and createDraft accept CreateCommentThreadInput:

import type { Range, Value } from "platejs";
 
export type CreateCommentThreadInput = Readonly<{
  body: Value;
  excerpt?: string;
  id?: string;
  target:
    | Readonly<{ range: Range; type: 'range' }>
    | Readonly<{ id: string; type: 'change' }>;
}>;
import type { Range, Value } from "platejs";
 
export type CreateCommentThreadInput = Readonly<{
  body: Value;
  excerpt?: string;
  id?: string;
  target:
    | Readonly<{ range: Range; type: 'range' }>
    | Readonly<{ id: string; type: 'change' }>;
}>;

Creation requires a current user and a nonempty body. Range creation requires a noncollapsed range in the primary document. Comments supplies actors and timestamps, or accepts canonical values from the adapter.

Pass ranges from the calling view to begin, createThread, and createDraft. Their anchors capture that view's coordinates. Read pendingRange() and attachment(id) through the view that will consume them rather than reusing coordinates from another projection.

create and createThread return Promise<CommentMutationResult<string>>. All other durable methods return Promise<CommentMutationResult<undefined>>. begin, cancel, createDraft, and discardDraft stay local.

export type CommentMutationResult<T = undefined> =
  | Readonly<{ status: 'applied'; value: T }>
  | Readonly<{ status: 'invalid' }>
  | Readonly<{ code?: string; status: 'rejected' }>
  | Readonly<{ status: 'stale' }>;
export type CommentMutationResult<T = undefined> =
  | Readonly<{ status: 'applied'; value: T }>
  | Readonly<{ status: 'invalid' }>
  | Readonly<{ code?: string; status: 'rejected' }>
  | Readonly<{ status: 'stale' }>;

Await the result before clearing input or updating selection:

const result = await comments.create(body);
 
if (result.status === "applied") {
  comments.setActive([result.value]);
}
const result = await comments.create(body);
 
if (result.status === "applied") {
  comments.setActive([result.value]);
}

Handle invalid, rejected, stale, and thrown errors without discarding input. The AI integration publishes completed generated comments as ordinary threads without an approval prompt; local draft operations do not require a separate approval step.

Subscriptions

subscribeThread(id, listener) observes one shared semantic record. subscribeThreads(listener) reports { ids } for semantic record changes only. Document edits that map attachments and view projection changes do not rewrite threads or wake semantic subscribers.

Use subscribeAttachments(listener) through the exact view's API for its placement changes. Decorations read and observe that same view. Switching another view's projection does not switch this subscription's view. Unsubscribing releases the view observation without releasing shared conversation targets.

subscribeVisibleThreadIds, subscribeDraftThreadIds, and subscribePending observe their respective snapshots. Each method returns an unsubscribe function. Keep explicit toJSON() export outside these listeners.

Copied UI

CommentKit from the copied comment item configures highlight styling. DiscussionKit adds block buttons and the floating thread view. Use the exported CommentThreadCard and CommentComposer to build a custom thread layout.

CommentComposer

onSubmit(body) returns Promise<CommentMutationResult<string | undefined>>. Return the awaited durable action's result; the composer clears input only for status: 'applied'. It preserves rich input while saving and blocks duplicate submissions. Failed actions retain the draft and display a retry message.

;
import { DefaultAuthoredPlugin } from 'platejs/authored';
import { CommentsPlugin } from 'platejs/comments/react';
import {
type EditableSiblingProps,
type Editor,
type RenderNodeWrapperDescriptor,
type RenderNodeWrapperProps,
useEditor,
useEditorRootElement,
useEditorSelector,
useEditorViewState,
usePath,
usePluginStore,
} from 'platejs/react';
import {
SuggestionPlugin,
useActiveSuggestion,
useSuggestionChanges,
} from 'platejs/suggestion/react';
import * as React from 'react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import {
CommentComposer,
CommentThreadCard,
formatCommentDate,
useCommentUser,
CommentKit,
usePendingComment,
useVisibleCommentThreadIds,
} from '@/components/editor/comment';
import {
FloatingPopover,
FloatingPopoverAnchor,
FloatingPopoverContent,
} from '@/components/editor/floating-popover';
type DiscussionItem =
| Readonly<{
blockIndices: readonly number[];
changeId?: string;
createdAt: Date;
id: string;
kind: 'comment';
}>
| Readonly<{
change: AuthoredChange;
createdAt: Date;
id: string;
kind: 'suggestion';
range: Range | null;
threadIds: readonly string[];
}>;
type DiscussionGroup = Readonly<{
blockIndex: number;
blockKey: NodeKey;
items: readonly DiscussionItem[];
}>;
type DiscussionBlockSnapshot = Readonly<{
active: boolean;
hasComments: boolean;
hasSuggestions: boolean;
totalCount: number;
}>;
type DiscussionTarget = Readonly<{
anchor: HTMLElement;
blockKey: NodeKey;
}>;
type DiscussionSnapshot = Readonly<{
revision: number;
target: DiscussionTarget | null;
}>;
type AuthoredViewSource = Editor & {
read: Editor['read'] & { authored: unknown };
};
const EMPTY_BLOCK_SNAPSHOT: DiscussionBlockSnapshot = Object.freeze({
active: false,
hasComments: false,
hasSuggestions: false,
totalCount: 0,
});
const EMPTY_DISCUSSION_SNAPSHOT: DiscussionSnapshot = Object.freeze({
revision: 0,
target: null,
});
const DISCUSSION_PAGE_SIZE = 20;
const sortDiscussionItems = (items: readonly DiscussionItem[]) =>
items.toSorted(
(left, right) => left.createdAt.getTime() - right.createdAt.getTime()
);
const sameSuggestionChanges = (
left: readonly AuthoredChange[] | undefined,
right: readonly AuthoredChange[]
) =>
left?.length === right.length &&
left.every((change, index) => {
const current = right[index];
return (
current?.id === change.id &&
current.revision === change.revision &&
current.status === change.status
);
});
const sameBlockSnapshot = (
left: DiscussionBlockSnapshot | undefined,
right: DiscussionBlockSnapshot
) =>
left?.active === right.active &&
left.hasComments === right.hasComments &&
left.hasSuggestions === right.hasSuggestions &&
left.totalCount === right.totalCount;
const createDiscussionStore = () => {
const listeners = new Set<() => void>();
const blockListeners = new Map<NodeKey, Set<() => void>>();
const blockTriggers = new Map<NodeKey, HTMLElement>();
const blockSnapshots = new Map<NodeKey, DiscussionBlockSnapshot>();
let commentBlocks = new Map<
NodeKey,
Readonly<{
blockIndex: number;
items: ReadonlyArray<Extract<DiscussionItem, { kind: 'comment' }>>;
}>
>();
let commentsById = new Map<
string,
Extract<DiscussionItem, { kind: 'comment' }>
>();
let notifyQueued = false;
let prepareSelection: (() => void) | null = null;
const suggestionBlocks = new Map<
NodeKey,
Readonly<{
blockIndex: number;
changes: readonly AuthoredChange[];
}>
>();
const suggestionLocations = new Map<string, Map<NodeKey, AuthoredChange>>();
let suggestionThreads = new Map<string, readonly string[]>();
let snapshot = EMPTY_DISCUSSION_SNAPSHOT;
const notifyBlocks = (keys: ReadonlySet<NodeKey>) => {
keys.forEach((key) => {
blockListeners.get(key)?.forEach((listener) => listener());
});
};
const notify = () => {
if (notifyQueued) return;
notifyQueued = true;
queueMicrotask(() => {
notifyQueued = false;
listeners.forEach((listener) => listener());
});
};
const getGroup = (blockKey: NodeKey): DiscussionGroup | undefined => {
const comments = commentBlocks.get(blockKey);
const suggestions = suggestionBlocks.get(blockKey);
const items: DiscussionItem[] = [
...(comments?.items ?? []),
...(suggestions?.changes.map((change): DiscussionItem => ({
change,
createdAt: new Date(change.createdAt),
id: change.id,
kind: 'suggestion',
range: change.ranges[0] ?? null,
threadIds: suggestionThreads.get(change.id) ?? [],
})) ?? []),
];
if (items.length === 0) return undefined;
return {
blockIndex: suggestions?.blockIndex ?? comments?.blockIndex ?? 0,
blockKey,
items: sortDiscussionItems(items),
};
};
const readBlockSnapshot = (blockKey: NodeKey): DiscussionBlockSnapshot => {
const group = getGroup(blockKey);
if (!group) return EMPTY_BLOCK_SNAPSHOT;
return {
active: snapshot.target?.blockKey === blockKey,
hasComments: group.items.some(
(item) =>
item.kind === 'comment' ||
(item.kind === 'suggestion' && item.threadIds.length > 0)
),
hasSuggestions: group.items.some(({ kind }) => kind === 'suggestion'),
totalCount: group.items.reduce(
(count, item) =>
count + 1 + (item.kind === 'suggestion' ? item.threadIds.length : 0),
0
),
};
};
const publishBlocks = (keys: ReadonlySet<NodeKey>) => {
const changedKeys = new Set<NodeKey>();
keys.forEach((key) => {
const previous = blockSnapshots.get(key);
const next = readBlockSnapshot(key);
if (next === EMPTY_BLOCK_SNAPSHOT) blockSnapshots.delete(key);
else if (previous && sameBlockSnapshot(previous, next)) return;
else blockSnapshots.set(key, next);
if (previous !== next) changedKeys.add(key);
});
notifyBlocks(changedKeys);
snapshot = {
revision: snapshot.revision + 1,
target:
snapshot.target && !getGroup(snapshot.target.blockKey)
? null
: snapshot.target,
};
notify();
};
const publishTarget = (target: DiscussionTarget | null) => {
if (snapshot.target === target) return;
const changedKeys = new Set<NodeKey>();
const previousKey = snapshot.target?.blockKey;
const nextKey = target?.blockKey;
if (previousKey) changedKeys.add(previousKey);
if (nextKey) changedKeys.add(nextKey);
changedKeys.forEach((key) => {
const current = blockSnapshots.get(key);
if (!current) return;
blockSnapshots.set(key, {
...current,
active: key === nextKey,
});
});
snapshot = { ...snapshot, target };
listeners.forEach((listener) => listener());
notifyBlocks(changedKeys);
};
return {
clearTarget() {
publishTarget(null);
},
getBlockSnapshot(key: NodeKey) {
return blockSnapshots.get(key) ?? EMPTY_BLOCK_SNAPSHOT;
},
getBlockTrigger(key: NodeKey) {
return blockTriggers.get(key) ?? null;
},
getComment(id: string) {
return commentsById.get(id) ?? null;
},
hasChangeCommentAt(nodeKeys: readonly NodeKey[]) {
return nodeKeys.some((key) =>
commentBlocks.get(key)?.items.some((item) => item.changeId)
);
},
getGroup,
getSnapshot: () => snapshot,
getSuggestion(id: string) {
const location = suggestionLocations.get(id)?.values().next().value;
return location
? ({
change: location,
createdAt: new Date(location.createdAt),
id: location.id,
kind: 'suggestion',
range: location.ranges[0] ?? null,
threadIds: suggestionThreads.get(location.id) ?? [],
} satisfies Extract<DiscussionItem, { kind: 'suggestion' }>)
: null;
},
selectBlock(blockKey: NodeKey, anchor: HTMLElement) {
if (!getGroup(blockKey) || !prepareSelection) return;
prepareSelection();
publishTarget(
snapshot.target?.blockKey === blockKey ? null : { anchor, blockKey }
);
},
removeBlock(blockKey: NodeKey) {
const previous = suggestionBlocks.get(blockKey);
if (!previous) return;
suggestionBlocks.delete(blockKey);
previous.changes.forEach((change) => {
const locations = suggestionLocations.get(change.id);
locations?.delete(blockKey);
if (locations?.size === 0) suggestionLocations.delete(change.id);
});
publishBlocks(new Set([blockKey]));
},
setBlockSuggestions(
blockKey: NodeKey,
blockIndex: number,
changes: readonly AuthoredChange[]
) {
const previous = suggestionBlocks.get(blockKey);
if (
previous?.blockIndex === blockIndex &&
sameSuggestionChanges(previous.changes, changes)
) {
return;
}
previous?.changes.forEach((change) => {
const locations = suggestionLocations.get(change.id);
locations?.delete(blockKey);
if (locations?.size === 0) suggestionLocations.delete(change.id);
});
suggestionBlocks.set(blockKey, { blockIndex, changes });
changes.forEach((change) => {
const locations =
suggestionLocations.get(change.id) ??
new Map<NodeKey, AuthoredChange>();
locations.set(blockKey, change);
suggestionLocations.set(change.id, locations);
});
publishBlocks(new Set([blockKey]));
},
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
subscribeBlock(key: NodeKey, listener: () => void) {
const keyListeners = blockListeners.get(key) ?? new Set<() => void>();
keyListeners.add(listener);
blockListeners.set(key, keyListeners);
return () => {
keyListeners.delete(listener);
if (keyListeners.size === 0) blockListeners.delete(key);
};
},
setPrepareSelection(next: typeof prepareSelection) {
prepareSelection = next;
},
setBlockTrigger(key: NodeKey, element: HTMLElement | null) {
if (element) {
blockTriggers.set(key, element);
} else {
blockTriggers.delete(key);
}
},
update(
currentEditor: Editor,
items: ReadonlyArray<Extract<DiscussionItem, { kind: 'comment' }>>,
threads: ReadonlyMap<string, readonly string[]>
) {
const previousKeys = new Set(commentBlocks.keys());
const nextBlocks = new Map<
NodeKey,
{
blockIndex: number;
items: Array<Extract<DiscussionItem, { kind: 'comment' }>>;
}
>();
commentsById = new Map(items.map((item) => [item.id, item]));
items.forEach((item) => {
item.blockIndices.forEach((blockIndex) => {
const blockKey = currentEditor.key([blockIndex]);
if (!blockKey) return;
const block = nextBlocks.get(blockKey);
if (block) block.items.push(item);
else nextBlocks.set(blockKey, { blockIndex, items: [item] });
});
});
commentBlocks = nextBlocks;
suggestionThreads = new Map(threads);
publishBlocks(
new Set([
...previousKeys,
...commentBlocks.keys(),
...suggestionBlocks.keys(),
])
);
},
};
};
type DiscussionStore = ReturnType<typeof createDiscussionStore>;
const DiscussionContext = React.createContext<DiscussionStore | null>(null);
const useDiscussionStore = () => {
const store = React.useContext(DiscussionContext);
if (!store) throw new Error('Discussion requires DiscussionSlots.');
return store;
};
const useDiscussionController = () => {
const editor = useEditor();
const { api: comments } = useEditor().plugin(CommentsPlugin);
const visibleThreadIds = useVisibleCommentThreadIds();
const authoredView = useEditorViewState(editor, () => {
const authored = editor.plugin(DefaultAuthoredPlugin);
return authored.installed ? authored.read.view().projection : null;
});
const [store] = React.useState(createDiscussionStore);
React.useEffect(() => {
const { api } = editor.plugin(CommentsPlugin);
let suggestionThreads = new Map<string, string[]>();
const locate = (id: string, thread = comments.getThread(id)) => {
if (!thread || thread.resolution) return null;
const attachment = api.attachment(id);
const authored = editor.plugin(DefaultAuthoredPlugin);
const change =
thread.target.type === 'change' && authored.installed
? authored.read.change(thread.target.id)
: null;
if (
thread.target.type === 'change' &&
(!change ||
change.status === 'pending' ||
change.status === 'conflicted')
) {
return null;
}
const ranges =
attachment?.type === 'range' && attachment.status === 'attached'
? [attachment.range]
: (change?.ranges ?? []);
if (ranges.length === 0) return null;
return {
...(thread.target.type === 'change'
? { changeId: thread.target.id }
: {}),
blockIndices: [
...new Set(
ranges.flatMap((range) => {
const [start, end] = RangeApi.edges(range);
const firstBlock = start.path[0] ?? 0;
return Array.from(
{ length: (end.path[0] ?? firstBlock) - firstBlock + 1 },
(_, offset) => firstBlock + offset
);
})
),
],
createdAt: new Date(thread.createdAt),
id,
kind: 'comment' as const,
};
};
const publish = () => {
suggestionThreads = new Map<string, string[]>();
const commentItems: Array<Extract<DiscussionItem, { kind: 'comment' }>> =
[];
const visible = new Set(visibleThreadIds);
comments.getThreads().forEach((thread) => {
if (thread.resolution) return;
if (thread.target.type === 'change') {
const ids = suggestionThreads.get(thread.target.id) ?? [];
ids.push(thread.id);
suggestionThreads.set(thread.target.id, ids);
const item = locate(thread.id, thread);
if (item) commentItems.push(item);
return;
}
if (!visible.has(thread.id)) return;
const item = locate(thread.id, thread);
if (item) commentItems.push(item);
});
store.update(editor, commentItems, suggestionThreads);
};
const unsubscribeAttachments = api.subscribeAttachments(publish);
const unsubscribeThreads = api.subscribeThreads(publish);
const authored = editor.plugin(DefaultAuthoredPlugin);
const unsubscribeAuthored = authored.installed
? authored.api.subscribeChanges(({ changeIds }) => {
if (changeIds.some((id) => suggestionThreads.has(id))) {
publish();
}
})
: undefined;
const unsubscribeCommit = editor.subscribeCommit((commit) => {
if (
!authored.installed ||
!suggestionThreads.size ||
!(commit.changed.hasAny('document') || commit.changed.hasAny('replace'))
) {
return;
}
if (
commit.changed.hasAny('structure') ||
commit.changed.hasAny('replace') ||
commit.changed.hasAny('root-order') ||
store.hasChangeCommentAt(commit.changed.nodeKeysAll('node')) ||
[...suggestionThreads].some(([changeId, ids]) => {
if (ids.some((id) => store.getComment(id))) return false;
const change = authored.read.change(changeId);
return (
!change ||
change.status === 'accepted' ||
change.status === 'rejected'
);
})
) {
publish();
}
});
publish();
return () => {
unsubscribeAttachments();
unsubscribeThreads();
unsubscribeAuthored?.();
unsubscribeCommit();
};
}, [authoredView, comments, editor, store, visibleThreadIds]);
return store;
};
function DiscussionRoot({ children }: { children: React.ReactNode }) {
const store = useDiscussionController();
return <DiscussionContext value={store}>{children}</DiscussionContext>;
}
function DiscussionCard({
item,
onDecisionApplied,
}: {
item: DiscussionItem;
onDecisionApplied: () => void;
}) {
return item.kind === 'comment' ? (
<CommentThreadCard id={item.id} />
) : (
<SuggestionDiscussionCard
change={item.change}
createdAt={item.createdAt}
onDecisionApplied={onDecisionApplied}
threadIds={item.threadIds}
/>
);
}
const readContentText = (value: unknown): string => {
if (!value || typeof value !== 'object') return '';
const node = value as { children?: unknown; text?: unknown };
if (typeof node.text === 'string') return node.text;
if (!Array.isArray(node.children)) return '';
return node.children.map(readContentText).join('');
};
const contentText = (
content: Extract<AuthoredChangePart, { kind: 'content' }>['after']
) => {
if (!content) return '';
return content.content.content.map(readContentText).join('\n');
};
const textPreview = (text: string) => {
const preview = text.trim();
return preview.length > 80 ? `${preview.slice(0, 77)}…` : preview;
};
const contentPreview = (
content: Extract<AuthoredChangePart, { kind: 'content' }>['after']
) => textPreview(contentText(content));
const insertionPartsAreAdjacent = (
editor: Editor,
left: Extract<AuthoredChangePart, { kind: 'content' }>,
right: Extract<AuthoredChangePart, { kind: 'content' }>
) => {
if (left.action !== 'insert' || right.action !== 'insert') return false;
const leftLocation = left.after?.location;
const rightLocation = right.after?.location;
if (
left.after?.root !== right.after?.root ||
leftLocation?.kind !== 'range' ||
rightLocation?.kind !== 'range'
) {
return false;
}
const [, leftEnd] = RangeApi.edges(leftLocation.range);
const [rightStart] = RangeApi.edges(rightLocation.range);
const afterLeft = editor.read.points.after(leftEnd);
return (
PointApi.equals(leftEnd, rightStart) ||
(!PathApi.equals(leftEnd.path, rightStart.path) &&
!!afterLeft &&
PointApi.equals(afterLeft, rightStart))
);
};
const proposedEditors = new WeakMap<Editor, Editor>();
const getProposedEditor = (editor: Editor) => {
const documentEditor = getEditorRuntimeOwner(editor) as AuthoredViewSource;
const existing = proposedEditors.get(documentEditor);
if (existing) return existing;
const proposedEditor = createEditorView(documentEditor, {
authored: { intent: 'propose', projection: 'proposed' },
});
proposedEditors.set(documentEditor, proposedEditor);
return proposedEditor;
};
const formatPropertyName = (key: string) =>
`${key[0]?.toUpperCase() ?? ''}${key.slice(1)}`
.replaceAll(/[_-]+/g, ' ')
.replaceAll(/([a-z0-9])([A-Z])/g, '$1 $2');
const formatPropertyValue = (value: unknown) => {
if (value === undefined) return 'none';
if (typeof value === 'boolean') return value ? 'on' : 'off';
if (typeof value === 'string' || typeof value === 'number') {
return String(value);
}
return JSON.stringify(value);
};
const describeSuggestionPart = (
part: AuthoredChangePart
): readonly string[] => {
switch (part.kind) {
case 'boundary': {
return [
part.action === 'split'
? 'Add paragraph break'
: 'Delete paragraph break',
];
}
case 'content': {
const before = contentPreview(part.before);
const after = contentPreview(part.after);
switch (part.action) {
case 'delete': {
return [before ? `Delete “${before}”` : 'Delete content'];
}
case 'insert': {
return [after ? `Add “${after}”` : 'Add content'];
}
case 'move': {
return [before ? `Move “${before}”` : 'Move content'];
}
case 'replace': {
return [
before && after
? `Replace “${before}” with “${after}”`
: 'Replace content',
];
}
}
return [];
}
case 'properties': {
const keys = [
...new Set([...Object.keys(part.before), ...Object.keys(part.after)]),
].sort();
return keys.map(
(key) =>
`${formatPropertyName(key)}: ${formatPropertyValue(
part.before[key]
)} → ${formatPropertyValue(part.after[key])}`
);
}
case 'root': {
return [
`${part.after ? 'Add' : 'Delete'} ${
part.root === 'main' ? 'document content' : part.root
}`,
];
}
}
return [];
};
const describeSuggestion = (
editor: Editor,
details: AuthoredChangeDetails | null,
fallback: AuthoredChange['kind']
) => {
if (details?.parts.status === 'available') {
const descriptions: string[] = [];
for (let index = 0; index < details.parts.items.length; index++) {
const part = details.parts.items[index];
if (part.kind !== 'content' || part.action !== 'insert') {
descriptions.push(...describeSuggestionPart(part));
continue;
}
let text = contentText(part.after);
let current = part;
while (index + 1 < details.parts.items.length) {
const next = details.parts.items[index + 1];
if (
next.kind !== 'content' ||
!insertionPartsAreAdjacent(editor, current, next)
) {
break;
}
text += contentText(next.after);
current = next;
index += 1;
}
const preview = textPreview(text);
descriptions.push(preview ? `Add “${preview}”` : 'Add content');
}
if (descriptions.length > 0) return descriptions;
}
return [
{
delete: 'Delete content',
format: 'Change formatting',
insert: 'Add content',
mixed: 'Edit content',
structure: 'Change structure',
}[fallback],
];
};
function SuggestionDiscussionCard({
change,
createdAt,
onDecisionApplied,
threadIds,
}: {
change: AuthoredChange;
createdAt: Date;
onDecisionApplied: () => void;
threadIds: readonly string[];
}) {
const editor = useEditor();
const proposedEditor = getProposedEditor(editor);
const { api: comments } = useEditor().plugin(CommentsPlugin);
const user = useCommentUser(change.authorId);
const changeKey = `${change.id}:${change.revision}`;
const [outcomeState, setOutcomeState] = React.useState<{
changeKey: string;
result: AuthoredResult;
} | null>(null);
const outcome =
outcomeState?.changeKey === changeKey ? outcomeState.result : null;
const details = useEditorSelector(
(current) =>
current.plugin(DefaultAuthoredPlugin).read.details(change.id) ?? null,
{
shouldUpdate: (commit) =>
!commit ||
commit.changed.hasAny('document') ||
commit.changed.hasAny('state'),
}
);
const descriptions = describeSuggestion(
proposedEditor,
details ?? null,
change.kind
);
const setOutcome = (result: AuthoredResult) =>
setOutcomeState({ changeKey, result });
const decide = (
action: 'accept' | 'reject',
related: readonly string[] = []
) => {
const authored = editor.plugin(DefaultAuthoredPlugin);
const latest = authored.read.change(change.id);
if (!latest) {
setOutcome({ status: 'stale', ids: [change.id] });
return;
}
const ids = [...new Set([latest.id, ...related])];
const input = {
action,
selection: authored.read.select({ ids }),
};
const result =
latest.status === 'conflicted'
? authored.update.resolve(input)
: authored.update.decide(input);
if (result.status === 'applied' || result.status === 'unchanged') {
onDecisionApplied();
return;
}
setOutcome(result);
};
const relatedIds =
outcome?.status === 'blocked'
? [...outcome.dependencies, ...outcome.dependants, ...outcome.conflicts]
: [];
const outcomeMessage = (() => {
if (!outcome) return null;
switch (outcome.status) {
case 'applied': {
return null;
}
case 'unchanged': {
return null;
}
case 'blocked': {
return `This decision also affects ${
relatedIds.length
} related suggestion${relatedIds.length === 1 ? '' : 's'}.`;
}
case 'invalid': {
return 'This suggestion no longer belongs to the current document.';
}
case 'stale': {
return 'This suggestion changed. Review it again before deciding.';
}
case 'unavailable': {
return 'The retained content needed for this action is unavailable.';
}
}
return null;
})();
return (
<article
className="relative flex flex-col focus-within:[&>header>.editor-suggestion-actions]:pointer-events-auto focus-within:[&>header>.editor-suggestion-actions]:opacity-100 hover:[&>header>.editor-suggestion-actions]:pointer-events-auto hover:[&>header>.editor-suggestion-actions]:opacity-100"
data-suggestion-review={change.id}
>
<header className="relative flex items-center">
<Avatar className="size-5">
<AvatarImage alt={user?.name} src={user?.avatarUrl} />
<AvatarFallback>{user?.name?.[0] ?? '?'}</AvatarFallback>
</Avatar>
<span className="mx-2 text-sm leading-none font-semibold">
{user?.name ?? change.authorId}
</span>
<span className="text-xs leading-none text-muted-foreground/80">
{formatCommentDate(createdAt)}
</span>
<span className="editor-suggestion-actions pointer-events-none absolute top-0 right-0 flex gap-2 opacity-0 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">
<Button
aria-label="Accept suggestion"
className="size-6 p-1 text-muted-foreground"
onClick={() => decide('accept')}
variant="ghost"
>
<CheckIcon className="size-4" />
</Button>
<Button
aria-label="Reject suggestion"
className="size-6 p-1 text-muted-foreground"
onClick={() => decide('reject')}
variant="ghost"
>
<XIcon className="size-4" />
</Button>
</span>
</header>
<div className="relative mt-1 mb-4 flex flex-col gap-2 pl-[32px] text-sm">
{descriptions.map((description, index) => (
<p className="text-muted-foreground" key={`${index}:${description}`}>
{description}
</p>
))}
{outcomeMessage && (
<div className="flex flex-col items-start gap-2" role="alert">
<p>{outcomeMessage}</p>
{outcome?.status === 'blocked' && relatedIds.length > 0 && (
<div className="flex gap-2">
<Button
onClick={() => decide('accept', relatedIds)}
size="sm"
variant="outline"
>
Accept related
</Button>
<Button
onClick={() => decide('reject', relatedIds)}
size="sm"
variant="outline"
>
Reject related
</Button>
</div>
)}
</div>
)}
</div>
{threadIds.map((id) => (
<CommentThreadCard id={id} key={id} showReply={false} />
))}
<CommentComposer
ariaLabel="Comment on suggestion"
onSubmit={(body) =>
comments.createThread({
body,
excerpt: `${change.kind} suggestion`,
target: { id: change.id, type: 'change' },
})
}
placeholder="Reply..."
/>
</article>
);
}
function NewComment({
autoFocus,
editableRef,
}: EditableSiblingProps & { autoFocus: boolean }) {
const { api: comments } = useEditor().plugin(CommentsPlugin);
return (
<CommentComposer
ariaLabel="New comment"
autoFocus={autoFocus}
onCancel={() => {
comments.cancel();
editableRef.current?.focus();
}}
onSubmit={async (body) => {
const result = await comments.create(body);
if (result.status === 'applied') comments.setActive([result.value]);
return result;
}}
placeholder="Add a comment"
/>
);
}
function DiscussionBlock({
children,
editor,
}: RenderNodeWrapperProps<typeof CommentsPlugin>) {
const path = usePath();
const blockKey = editor.key(path);
if (!blockKey) return children;
const props = {
blockIndex: path[0] ?? 0,
blockKey,
children,
};
if (editor.plugin(SuggestionPlugin).installed) {
return <SuggestionDiscussionBlockContent {...props} path={path} />;
}
return <DiscussionBlockContent {...props} changes={[]} />;
}
function SuggestionDiscussionBlockContent({
path,
...props
}: React.PropsWithChildren<{
blockIndex: number;
blockKey: NodeKey;
path: Path;
}>) {
const changes = useSuggestionChanges(path);
return <DiscussionBlockContent {...props} changes={changes} />;
}
function DiscussionBlockContent({
blockIndex,
blockKey,
changes,
children,
}: React.PropsWithChildren<{
blockIndex: number;
blockKey: NodeKey;
changes: readonly AuthoredChange[];
}>) {
const store = useDiscussionStore();
React.useEffect(() => {
store.setBlockSuggestions(blockKey, blockIndex, changes);
return () => store.removeBlock(blockKey);
}, [blockIndex, blockKey, changes, store]);
const subscribe = React.useCallback(
(listener: () => void) => store.subscribeBlock(blockKey, listener),
[blockKey, store]
);
const block = React.useSyncExternalStore(
subscribe,
() => store.getBlockSnapshot(blockKey),
() => store.getBlockSnapshot(blockKey)
);
const setTriggerRef = React.useCallback(
(trigger: HTMLButtonElement | null) => {
store.setBlockTrigger(blockKey, trigger);
},
[blockKey, store]
);
const isActive = block.active;
const Icon =
block.hasComments && block.hasSuggestions
? MessagesSquareIcon
: block.hasSuggestions
? PencilLineIcon
: MessageSquareTextIcon;
const itemLabel = block.totalCount === 1 ? 'item' : 'items';
return (
<div className="flex w-full justify-between">
<div className="w-full min-w-0">{children}</div>
<div
className="relative left-0 size-0 select-none"
contentEditable={false}
>
{block.totalCount > 0 && (
<Button
aria-expanded={isActive}
aria-label={`${isActive ? 'Close' : 'Open'} ${
block.totalCount
} discussion ${itemLabel} for this block`}
className="mt-1 ml-1 flex h-6 gap-1 !px-1.5 py-0 text-muted-foreground/80 hover:text-muted-foreground/80 data-[active=true]:bg-muted"
contentEditable={false}
data-active={isActive}
data-discussion-block-trigger
data-editor-keep-selection-visible
onClick={(event) => {
event.stopPropagation();
store.selectBlock(blockKey, event.currentTarget);
}}
onMouseDown={(event) => event.preventDefault()}
ref={setTriggerRef}
type="button"
variant="ghost"
>
<Icon aria-hidden="true" className="size-4 shrink-0" />
<span
aria-hidden="true"
className="text-xs font-semibold after:content-[attr(data-count)]"
data-count={block.totalCount}
/>
</Button>
)}
</div>
</div>
);
}
const DiscussionBlockSlot: RenderNodeWrapperDescriptor<typeof CommentsPlugin> =
{
component: DiscussionBlock,
match: ({ renderPath }) => renderPath.length === 1,
};
function DiscussionPopover({
activeSuggestionId,
editableRef,
setActiveSuggestionId,
snapshot,
}: EditableSiblingProps & {
activeSuggestionId: string | null;
setActiveSuggestionId?: (id: string | null) => void;
snapshot: DiscussionSnapshot;
}) {
const editor = useEditor();
const rootElement = useEditorRootElement(editor);
const store = useDiscussionStore();
const popoverRef = React.useRef<HTMLDivElement>(null);
const { api: comments } = useEditor().plugin(CommentsPlugin);
const pending = usePendingComment();
const activeCommentIds = usePluginStore(CommentsPlugin, 'activeIds');
const activeOrder = new Map(activeCommentIds.map((id, index) => [id, index]));
const activeItems: DiscussionItem[] = [
...activeCommentIds.flatMap((id) => {
const item = store.getComment(id);
return item ? [item] : [];
}),
...(activeSuggestionId
? (() => {
const item = store.getSuggestion(activeSuggestionId);
return item ? [item] : [];
})()
: []),
].toSorted(
(left, right) =>
(activeOrder.get(left.id) ?? activeCommentIds.length) -
(activeOrder.get(right.id) ?? activeCommentIds.length)
);
const targetGroup = snapshot.target
? store.getGroup(snapshot.target.blockKey)
: undefined;
const target = !pending && activeItems.length === 0 ? snapshot.target : null;
const targetKey = snapshot.target?.blockKey ?? null;
const [pagination, setPagination] = React.useState<{
count: number;
targetKey: NodeKey | null;
}>({ count: DISCUSSION_PAGE_SIZE, targetKey });
const visibleCount =
pagination.targetKey === targetKey
? pagination.count
: DISCUSSION_PAGE_SIZE;
const targetItems = targetGroup?.items ?? [];
const shownItems =
activeItems.length > 0 ? activeItems : targetItems.slice(0, visibleCount);
const activeSuggestionRange = activeItems.find(
(item): item is Extract<DiscussionItem, { kind: 'suggestion' }> =>
item.kind === 'suggestion'
)?.range;
const { api } = editor.plugin(CommentsPlugin);
const commentRange = (() => {
const authored = editor.plugin(DefaultAuthoredPlugin);
for (const id of activeCommentIds) {
const attachment = api.attachment(id);
if (attachment?.type === 'range' && attachment.status === 'attached') {
return attachment.range;
}
if (attachment?.type === 'change' && authored.installed) {
const range = authored.read.change(attachment.id)?.ranges[0];
if (range) return range;
}
}
return null;
})();
const pendingRange = useEditorSelector(
() => (pending ? comments.pendingRange() : null),
{
equalityFn: RangeApi.equals,
shouldUpdate: (change) => !change || change.changed.hasAny('document'),
}
);
const anchorRange =
pendingRange ?? commentRange ?? activeSuggestionRange ?? null;
const anchorRangeJson = anchorRange ? JSON.stringify(anchorRange) : null;
const anchorBlockKey = anchorRange
? editor.key([RangeApi.start(anchorRange).path[0] ?? 0])
: null;
const virtualAnchor = React.useMemo(() => {
const currentAnchorRange = anchorRangeJson
? (JSON.parse(anchorRangeJson) as Range)
: null;
return {
contextElement: rootElement ?? undefined,
getBoundingClientRect: () => {
const blockRect = anchorBlockKey
? store.getBlockTrigger(anchorBlockKey)?.getBoundingClientRect()
: null;
const domRange = currentAnchorRange
? editor.api.dom.resolveDOMRange(currentAnchorRange)
: null;
if (!domRange) return blockRect ?? new DOMRect();
const clientRect = Array.from(domRange.getClientRects()).findLast(
({ height, width }) => height > 0 || width > 0
);
if (clientRect) return clientRect;
const rangeRect = domRange.getBoundingClientRect();
return rangeRect.height > 0 || rangeRect.width > 0
? rangeRect
: (blockRect ?? rangeRect);
},
};
}, [anchorBlockKey, anchorRangeJson, editor, rootElement, store]);
const anchorElement = target?.anchor ?? (anchorRange ? virtualAnchor : null);
const open = Boolean(
anchorElement && (pending || activeItems.length > 0 || targetGroup)
);
const openRef = React.useRef(open);
const [placedPending, setPlacedPending] =
React.useState<typeof pending>(null);
React.useLayoutEffect(() => {
openRef.current = open;
}, [open]);
React.useEffect(() => {
if (snapshot.target && (pending || activeItems.length > 0)) {
store.clearTarget();
}
}, [activeItems.length, pending, snapshot.target, store]);
return (
<div data-discussion-root="">
<FloatingPopover
modal={false}
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) return;
if (
activeCommentIds.some((id) =>
comments.getSnapshot().draftThreadIds.includes(id)
)
) {
return;
}
store.clearTarget();
comments.cancel();
editor.plugin(CommentsPlugin).api.setActive([]);
setActiveSuggestionId?.(null);
}}
>
{anchorElement && <FloatingPopoverAnchor element={anchorElement} />}
<FloatingPopoverContent
align="center"
aria-label={pending ? 'New comment' : 'Discussion items'}
className="max-h-[min(50dvh,calc(-24px+var(--floating-popover-available-height)))] w-[380px] max-w-[calc(100vw-24px)] min-w-[130px] gap-0 overflow-y-auto p-0 data-[state=closed]:opacity-0"
data-discussion-popover=""
data-editor-keep-selection-visible
ref={popoverRef}
tabIndex={-1}
onFinalFocus={(event) => {
event.preventDefault();
const document = editableRef.current?.ownerDocument;
const active = document?.activeElement;
if (
!openRef.current &&
(!active ||
active === document?.body ||
popoverRef.current?.contains(active))
) {
editableRef.current?.focus();
}
}}
onInitialFocus={(event) => event.preventDefault()}
onPlaced={() => {
if (pending) setPlacedPending(pending);
}}
side="bottom"
>
{pending ? (
<div className="p-4">
<NewComment
autoFocus={placedPending === pending}
editableRef={editableRef}
/>
</div>
) : (
<>
{shownItems.map((item, index) => (
<React.Fragment key={`${item.kind}-${item.id}`}>
<div className="p-4">
<DiscussionCard
item={item}
onDecisionApplied={() => {
if (!snapshot.target && item.kind === 'suggestion') {
requestAnimationFrame(() => {
comments.setActive(item.threadIds);
popoverRef.current?.focus();
});
return;
}
popoverRef.current?.focus();
}}
/>
</div>
{(index < shownItems.length - 1 ||
shownItems.length < targetItems.length) && (
<Separator data-discussion-separator="" />
)}
</React.Fragment>
))}
{activeItems.length === 0 &&
shownItems.length < targetItems.length && (
<div className="p-3">
<Button
className="w-full"
onClick={() =>
setPagination({
count: Math.min(
visibleCount + DISCUSSION_PAGE_SIZE,
targetItems.length
),
targetKey,
})
}
size="sm"
variant="ghost"
>
Show more discussion items
</Button>
</div>
)}
</>
)}
</FloatingPopoverContent>
</FloatingPopover>
</div>
);
}
const usePrepareDiscussionSelection = (
setActiveSuggestionId?: (id: string | null) => void
) => {
const editor = useEditor();
const store = useDiscussionStore();
const { api: comments } = editor.plugin(CommentsPlugin);
React.useEffect(() => {
store.setPrepareSelection(() => {
comments.cancel();
editor.plugin(CommentsPlugin).api.setActive([]);
setActiveSuggestionId?.(null);
});
return () => store.setPrepareSelection(null);
}, [comments, editor, setActiveSuggestionId, store]);
};
function SuggestionDiscussion({
editableRef,
snapshot,
}: EditableSiblingProps & { snapshot: DiscussionSnapshot }) {
const { activeId, setActiveId } = useActiveSuggestion();
usePrepareDiscussionSelection(setActiveId);
return (
<DiscussionPopover
activeSuggestionId={activeId}
editableRef={editableRef}
setActiveSuggestionId={setActiveId}
snapshot={snapshot}
/>
);
}
function CommentDiscussion({
editableRef,
snapshot,
}: EditableSiblingProps & { snapshot: DiscussionSnapshot }) {
usePrepareDiscussionSelection();
return (
<DiscussionPopover
activeSuggestionId={null}
editableRef={editableRef}
snapshot={snapshot}
/>
);
}
function Discussion({ editableRef }: EditableSiblingProps) {
const store = useDiscussionStore();
const snapshot = React.useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot
);
const suggestionsInstalled = useEditorSelector(
(current) => current.plugin(SuggestionPlugin).installed
);
return suggestionsInstalled ? (
<SuggestionDiscussion editableRef={editableRef} snapshot={snapshot} />
) : (
<CommentDiscussion editableRef={editableRef} snapshot={snapshot} />
);
}
export const DiscussionSlots = {
afterEditable: Discussion,
wrapNode: DiscussionBlockSlot,
wrapRoot: DiscussionRoot,
} satisfies typeof CommentsPlugin.slots;
export const DiscussionKit = [
...CommentKit,
CommentsPlugin.configure({ slots: DiscussionSlots }),
];
'use client';
 
import {
  CheckIcon,
  MessageSquareTextIcon,
  MessagesSquareIcon,
  PencilLineIcon,
  XIcon,
} from 'lucide-react';
import {
  createEditorView,
  getEditorRuntimeOwner,
  type NodeKey,
  type Path,
  PathApi,
  PointApi,
  type Range,
  RangeApi,
} from 'platejs';
import type {
  AuthoredChange,
  AuthoredChangeDetails,
  AuthoredChangePart,
  AuthoredResult,
} from 'platejs/authored';
import { DefaultAuthoredPlugin } from 'platejs/authored';
import { CommentsPlugin } from 'platejs/comments/react';
import {
  type EditableSiblingProps,
  type Editor,
  type RenderNodeWrapperDescriptor,
  type RenderNodeWrapperProps,
  useEditor,
  useEditorRootElement,
  useEditorSelector,
  useEditorViewState,
  usePath,
  usePluginStore,
} from 'platejs/react';
import {
  SuggestionPlugin,
  useActiveSuggestion,
  useSuggestionChanges,
} from 'platejs/suggestion/react';
import * as React from 'react';
 
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import {
  CommentComposer,
  CommentThreadCard,
  formatCommentDate,
  useCommentUser,
  CommentKit,
  usePendingComment,
  useVisibleCommentThreadIds,
} from '@/components/editor/comment';
import {
  FloatingPopover,
  FloatingPopoverAnchor,
  FloatingPopoverContent,
} from '@/components/editor/floating-popover';
 
type DiscussionItem =
  | Readonly<{
      blockIndices: readonly number[];
      changeId?: string;
      createdAt: Date;
      id: string;
      kind: 'comment';
    }>
  | Readonly<{
      change: AuthoredChange;
      createdAt: Date;
      id: string;
      kind: 'suggestion';
      range: Range | null;
      threadIds: readonly string[];
    }>;
 
type DiscussionGroup = Readonly<{
  blockIndex: number;
  blockKey: NodeKey;
  items: readonly DiscussionItem[];
}>;
 
type DiscussionBlockSnapshot = Readonly<{
  active: boolean;
  hasComments: boolean;
  hasSuggestions: boolean;
  totalCount: number;
}>;
 
type DiscussionTarget = Readonly<{
  anchor: HTMLElement;
  blockKey: NodeKey;
}>;
 
type DiscussionSnapshot = Readonly<{
  revision: number;
  target: DiscussionTarget | null;
}>;
 
type AuthoredViewSource = Editor & {
  read: Editor['read'] & { authored: unknown };
};
 
const EMPTY_BLOCK_SNAPSHOT: DiscussionBlockSnapshot = Object.freeze({
  active: false,
  hasComments: false,
  hasSuggestions: false,
  totalCount: 0,
});
const EMPTY_DISCUSSION_SNAPSHOT: DiscussionSnapshot = Object.freeze({
  revision: 0,
  target: null,
});
const DISCUSSION_PAGE_SIZE = 20;
 
const sortDiscussionItems = (items: readonly DiscussionItem[]) =>
  items.toSorted(
    (left, right) => left.createdAt.getTime() - right.createdAt.getTime()
  );
 
const sameSuggestionChanges = (
  left: readonly AuthoredChange[] | undefined,
  right: readonly AuthoredChange[]
) =>
  left?.length === right.length &&
  left.every((change, index) => {
    const current = right[index];
 
    return (
      current?.id === change.id &&
      current.revision === change.revision &&
      current.status === change.status
    );
  });
 
const sameBlockSnapshot = (
  left: DiscussionBlockSnapshot | undefined,
  right: DiscussionBlockSnapshot
) =>
  left?.active === right.active &&
  left.hasComments === right.hasComments &&
  left.hasSuggestions === right.hasSuggestions &&
  left.totalCount === right.totalCount;
 
const createDiscussionStore = () => {
  const listeners = new Set<() => void>();
  const blockListeners = new Map<NodeKey, Set<() => void>>();
  const blockTriggers = new Map<NodeKey, HTMLElement>();
  const blockSnapshots = new Map<NodeKey, DiscussionBlockSnapshot>();
  let commentBlocks = new Map<
    NodeKey,
    Readonly<{
      blockIndex: number;
      items: ReadonlyArray<Extract<DiscussionItem, { kind: 'comment' }>>;
    }>
  >();
  let commentsById = new Map<
    string,
    Extract<DiscussionItem, { kind: 'comment' }>
  >();
  let notifyQueued = false;
  let prepareSelection: (() => void) | null = null;
  const suggestionBlocks = new Map<
    NodeKey,
    Readonly<{
      blockIndex: number;
      changes: readonly AuthoredChange[];
    }>
  >();
  const suggestionLocations = new Map<string, Map<NodeKey, AuthoredChange>>();
  let suggestionThreads = new Map<string, readonly string[]>();
  let snapshot = EMPTY_DISCUSSION_SNAPSHOT;
 
  const notifyBlocks = (keys: ReadonlySet<NodeKey>) => {
    keys.forEach((key) => {
      blockListeners.get(key)?.forEach((listener) => listener());
    });
  };
  const notify = () => {
    if (notifyQueued) return;
    notifyQueued = true;
    queueMicrotask(() => {
      notifyQueued = false;
      listeners.forEach((listener) => listener());
    });
  };
  const getGroup = (blockKey: NodeKey): DiscussionGroup | undefined => {
    const comments = commentBlocks.get(blockKey);
    const suggestions = suggestionBlocks.get(blockKey);
    const items: DiscussionItem[] = [
      ...(comments?.items ?? []),
      ...(suggestions?.changes.map((change): DiscussionItem => ({
        change,
        createdAt: new Date(change.createdAt),
        id: change.id,
        kind: 'suggestion',
        range: change.ranges[0] ?? null,
        threadIds: suggestionThreads.get(change.id) ?? [],
      })) ?? []),
    ];
 
    if (items.length === 0) return undefined;
 
    return {
      blockIndex: suggestions?.blockIndex ?? comments?.blockIndex ?? 0,
      blockKey,
      items: sortDiscussionItems(items),
    };
  };
  const readBlockSnapshot = (blockKey: NodeKey): DiscussionBlockSnapshot => {
    const group = getGroup(blockKey);
    if (!group) return EMPTY_BLOCK_SNAPSHOT;
 
    return {
      active: snapshot.target?.blockKey === blockKey,
      hasComments: group.items.some(
        (item) =>
          item.kind === 'comment' ||
          (item.kind === 'suggestion' && item.threadIds.length > 0)
      ),
      hasSuggestions: group.items.some(({ kind }) => kind === 'suggestion'),
      totalCount: group.items.reduce(
        (count, item) =>
          count + 1 + (item.kind === 'suggestion' ? item.threadIds.length : 0),
        0
      ),
    };
  };
  const publishBlocks = (keys: ReadonlySet<NodeKey>) => {
    const changedKeys = new Set<NodeKey>();
 
    keys.forEach((key) => {
      const previous = blockSnapshots.get(key);
      const next = readBlockSnapshot(key);
 
      if (next === EMPTY_BLOCK_SNAPSHOT) blockSnapshots.delete(key);
      else if (previous && sameBlockSnapshot(previous, next)) return;
      else blockSnapshots.set(key, next);
      if (previous !== next) changedKeys.add(key);
    });
    notifyBlocks(changedKeys);
    snapshot = {
      revision: snapshot.revision + 1,
      target:
        snapshot.target && !getGroup(snapshot.target.blockKey)
          ? null
          : snapshot.target,
    };
    notify();
  };
  const publishTarget = (target: DiscussionTarget | null) => {
    if (snapshot.target === target) return;
 
    const changedKeys = new Set<NodeKey>();
    const previousKey = snapshot.target?.blockKey;
    const nextKey = target?.blockKey;
 
    if (previousKey) changedKeys.add(previousKey);
    if (nextKey) changedKeys.add(nextKey);
 
    changedKeys.forEach((key) => {
      const current = blockSnapshots.get(key);
 
      if (!current) return;
 
      blockSnapshots.set(key, {
        ...current,
        active: key === nextKey,
      });
    });
    snapshot = { ...snapshot, target };
    listeners.forEach((listener) => listener());
    notifyBlocks(changedKeys);
  };
 
  return {
    clearTarget() {
      publishTarget(null);
    },
    getBlockSnapshot(key: NodeKey) {
      return blockSnapshots.get(key) ?? EMPTY_BLOCK_SNAPSHOT;
    },
    getBlockTrigger(key: NodeKey) {
      return blockTriggers.get(key) ?? null;
    },
    getComment(id: string) {
      return commentsById.get(id) ?? null;
    },
    hasChangeCommentAt(nodeKeys: readonly NodeKey[]) {
      return nodeKeys.some((key) =>
        commentBlocks.get(key)?.items.some((item) => item.changeId)
      );
    },
    getGroup,
    getSnapshot: () => snapshot,
    getSuggestion(id: string) {
      const location = suggestionLocations.get(id)?.values().next().value;
 
      return location
        ? ({
            change: location,
            createdAt: new Date(location.createdAt),
            id: location.id,
            kind: 'suggestion',
            range: location.ranges[0] ?? null,
            threadIds: suggestionThreads.get(location.id) ?? [],
          } satisfies Extract<DiscussionItem, { kind: 'suggestion' }>)
        : null;
    },
    selectBlock(blockKey: NodeKey, anchor: HTMLElement) {
      if (!getGroup(blockKey) || !prepareSelection) return;
 
      prepareSelection();
 
      publishTarget(
        snapshot.target?.blockKey === blockKey ? null : { anchor, blockKey }
      );
    },
    removeBlock(blockKey: NodeKey) {
      const previous = suggestionBlocks.get(blockKey);
      if (!previous) return;
      suggestionBlocks.delete(blockKey);
      previous.changes.forEach((change) => {
        const locations = suggestionLocations.get(change.id);
        locations?.delete(blockKey);
        if (locations?.size === 0) suggestionLocations.delete(change.id);
      });
      publishBlocks(new Set([blockKey]));
    },
    setBlockSuggestions(
      blockKey: NodeKey,
      blockIndex: number,
      changes: readonly AuthoredChange[]
    ) {
      const previous = suggestionBlocks.get(blockKey);
      if (
        previous?.blockIndex === blockIndex &&
        sameSuggestionChanges(previous.changes, changes)
      ) {
        return;
      }
      previous?.changes.forEach((change) => {
        const locations = suggestionLocations.get(change.id);
        locations?.delete(blockKey);
        if (locations?.size === 0) suggestionLocations.delete(change.id);
      });
      suggestionBlocks.set(blockKey, { blockIndex, changes });
      changes.forEach((change) => {
        const locations =
          suggestionLocations.get(change.id) ??
          new Map<NodeKey, AuthoredChange>();
 
        locations.set(blockKey, change);
        suggestionLocations.set(change.id, locations);
      });
      publishBlocks(new Set([blockKey]));
    },
    subscribe: (listener: () => void) => {
      listeners.add(listener);
 
      return () => listeners.delete(listener);
    },
    subscribeBlock(key: NodeKey, listener: () => void) {
      const keyListeners = blockListeners.get(key) ?? new Set<() => void>();
 
      keyListeners.add(listener);
      blockListeners.set(key, keyListeners);
 
      return () => {
        keyListeners.delete(listener);
        if (keyListeners.size === 0) blockListeners.delete(key);
      };
    },
    setPrepareSelection(next: typeof prepareSelection) {
      prepareSelection = next;
    },
    setBlockTrigger(key: NodeKey, element: HTMLElement | null) {
      if (element) {
        blockTriggers.set(key, element);
      } else {
        blockTriggers.delete(key);
      }
    },
    update(
      currentEditor: Editor,
      items: ReadonlyArray<Extract<DiscussionItem, { kind: 'comment' }>>,
      threads: ReadonlyMap<string, readonly string[]>
    ) {
      const previousKeys = new Set(commentBlocks.keys());
      const nextBlocks = new Map<
        NodeKey,
        {
          blockIndex: number;
          items: Array<Extract<DiscussionItem, { kind: 'comment' }>>;
        }
      >();
      commentsById = new Map(items.map((item) => [item.id, item]));
      items.forEach((item) => {
        item.blockIndices.forEach((blockIndex) => {
          const blockKey = currentEditor.key([blockIndex]);
          if (!blockKey) return;
          const block = nextBlocks.get(blockKey);
 
          if (block) block.items.push(item);
          else nextBlocks.set(blockKey, { blockIndex, items: [item] });
        });
      });
      commentBlocks = nextBlocks;
      suggestionThreads = new Map(threads);
      publishBlocks(
        new Set([
          ...previousKeys,
          ...commentBlocks.keys(),
          ...suggestionBlocks.keys(),
        ])
      );
    },
  };
};
 
type DiscussionStore = ReturnType<typeof createDiscussionStore>;
 
const DiscussionContext = React.createContext<DiscussionStore | null>(null);
 
const useDiscussionStore = () => {
  const store = React.useContext(DiscussionContext);
 
  if (!store) throw new Error('Discussion requires DiscussionSlots.');
 
  return store;
};
 
const useDiscussionController = () => {
  const editor = useEditor();
  const { api: comments } = useEditor().plugin(CommentsPlugin);
  const visibleThreadIds = useVisibleCommentThreadIds();
  const authoredView = useEditorViewState(editor, () => {
    const authored = editor.plugin(DefaultAuthoredPlugin);
    return authored.installed ? authored.read.view().projection : null;
  });
  const [store] = React.useState(createDiscussionStore);
  React.useEffect(() => {
    const { api } = editor.plugin(CommentsPlugin);
    let suggestionThreads = new Map<string, string[]>();
    const locate = (id: string, thread = comments.getThread(id)) => {
      if (!thread || thread.resolution) return null;
      const attachment = api.attachment(id);
      const authored = editor.plugin(DefaultAuthoredPlugin);
      const change =
        thread.target.type === 'change' && authored.installed
          ? authored.read.change(thread.target.id)
          : null;
      if (
        thread.target.type === 'change' &&
        (!change ||
          change.status === 'pending' ||
          change.status === 'conflicted')
      ) {
        return null;
      }
      const ranges =
        attachment?.type === 'range' && attachment.status === 'attached'
          ? [attachment.range]
          : (change?.ranges ?? []);
      if (ranges.length === 0) return null;
      return {
        ...(thread.target.type === 'change'
          ? { changeId: thread.target.id }
          : {}),
        blockIndices: [
          ...new Set(
            ranges.flatMap((range) => {
              const [start, end] = RangeApi.edges(range);
              const firstBlock = start.path[0] ?? 0;
              return Array.from(
                { length: (end.path[0] ?? firstBlock) - firstBlock + 1 },
                (_, offset) => firstBlock + offset
              );
            })
          ),
        ],
        createdAt: new Date(thread.createdAt),
        id,
        kind: 'comment' as const,
      };
    };
    const publish = () => {
      suggestionThreads = new Map<string, string[]>();
      const commentItems: Array<Extract<DiscussionItem, { kind: 'comment' }>> =
        [];
      const visible = new Set(visibleThreadIds);
 
      comments.getThreads().forEach((thread) => {
        if (thread.resolution) return;
        if (thread.target.type === 'change') {
          const ids = suggestionThreads.get(thread.target.id) ?? [];
 
          ids.push(thread.id);
          suggestionThreads.set(thread.target.id, ids);
          const item = locate(thread.id, thread);
          if (item) commentItems.push(item);
          return;
        }
        if (!visible.has(thread.id)) return;
        const item = locate(thread.id, thread);
        if (item) commentItems.push(item);
      });
      store.update(editor, commentItems, suggestionThreads);
    };
    const unsubscribeAttachments = api.subscribeAttachments(publish);
    const unsubscribeThreads = api.subscribeThreads(publish);
    const authored = editor.plugin(DefaultAuthoredPlugin);
    const unsubscribeAuthored = authored.installed
      ? authored.api.subscribeChanges(({ changeIds }) => {
          if (changeIds.some((id) => suggestionThreads.has(id))) {
            publish();
          }
        })
      : undefined;
    const unsubscribeCommit = editor.subscribeCommit((commit) => {
      if (
        !authored.installed ||
        !suggestionThreads.size ||
        !(commit.changed.hasAny('document') || commit.changed.hasAny('replace'))
      ) {
        return;
      }
      if (
        commit.changed.hasAny('structure') ||
        commit.changed.hasAny('replace') ||
        commit.changed.hasAny('root-order') ||
        store.hasChangeCommentAt(commit.changed.nodeKeysAll('node')) ||
        [...suggestionThreads].some(([changeId, ids]) => {
          if (ids.some((id) => store.getComment(id))) return false;
          const change = authored.read.change(changeId);
          return (
            !change ||
            change.status === 'accepted' ||
            change.status === 'rejected'
          );
        })
      ) {
        publish();
      }
    });
 
    publish();
    return () => {
      unsubscribeAttachments();
      unsubscribeThreads();
      unsubscribeAuthored?.();
      unsubscribeCommit();
    };
  }, [authoredView, comments, editor, store, visibleThreadIds]);
 
  return store;
};
 
function DiscussionRoot({ children }: { children: React.ReactNode }) {
  const store = useDiscussionController();
 
  return <DiscussionContext value={store}>{children}</DiscussionContext>;
}
 
function DiscussionCard({
  item,
  onDecisionApplied,
}: {
  item: DiscussionItem;
  onDecisionApplied: () => void;
}) {
  return item.kind === 'comment' ? (
    <CommentThreadCard id={item.id} />
  ) : (
    <SuggestionDiscussionCard
      change={item.change}
      createdAt={item.createdAt}
      onDecisionApplied={onDecisionApplied}
      threadIds={item.threadIds}
    />
  );
}
 
const readContentText = (value: unknown): string => {
  if (!value || typeof value !== 'object') return '';
  const node = value as { children?: unknown; text?: unknown };
 
  if (typeof node.text === 'string') return node.text;
  if (!Array.isArray(node.children)) return '';
 
  return node.children.map(readContentText).join('');
};
 
const contentText = (
  content: Extract<AuthoredChangePart, { kind: 'content' }>['after']
) => {
  if (!content) return '';
 
  return content.content.content.map(readContentText).join('\n');
};
 
const textPreview = (text: string) => {
  const preview = text.trim();
 
  return preview.length > 80 ? `${preview.slice(0, 77)}…` : preview;
};
 
const contentPreview = (
  content: Extract<AuthoredChangePart, { kind: 'content' }>['after']
) => textPreview(contentText(content));
 
const insertionPartsAreAdjacent = (
  editor: Editor,
  left: Extract<AuthoredChangePart, { kind: 'content' }>,
  right: Extract<AuthoredChangePart, { kind: 'content' }>
) => {
  if (left.action !== 'insert' || right.action !== 'insert') return false;
  const leftLocation = left.after?.location;
  const rightLocation = right.after?.location;
 
  if (
    left.after?.root !== right.after?.root ||
    leftLocation?.kind !== 'range' ||
    rightLocation?.kind !== 'range'
  ) {
    return false;
  }
 
  const [, leftEnd] = RangeApi.edges(leftLocation.range);
  const [rightStart] = RangeApi.edges(rightLocation.range);
  const afterLeft = editor.read.points.after(leftEnd);
 
  return (
    PointApi.equals(leftEnd, rightStart) ||
    (!PathApi.equals(leftEnd.path, rightStart.path) &&
      !!afterLeft &&
      PointApi.equals(afterLeft, rightStart))
  );
};
 
const proposedEditors = new WeakMap<Editor, Editor>();
 
const getProposedEditor = (editor: Editor) => {
  const documentEditor = getEditorRuntimeOwner(editor) as AuthoredViewSource;
  const existing = proposedEditors.get(documentEditor);
 
  if (existing) return existing;
 
  const proposedEditor = createEditorView(documentEditor, {
    authored: { intent: 'propose', projection: 'proposed' },
  });
 
  proposedEditors.set(documentEditor, proposedEditor);
  return proposedEditor;
};
 
const formatPropertyName = (key: string) =>
  `${key[0]?.toUpperCase() ?? ''}${key.slice(1)}`
    .replaceAll(/[_-]+/g, ' ')
    .replaceAll(/([a-z0-9])([A-Z])/g, '$1 $2');
 
const formatPropertyValue = (value: unknown) => {
  if (value === undefined) return 'none';
  if (typeof value === 'boolean') return value ? 'on' : 'off';
  if (typeof value === 'string' || typeof value === 'number') {
    return String(value);
  }
 
  return JSON.stringify(value);
};
 
const describeSuggestionPart = (
  part: AuthoredChangePart
): readonly string[] => {
  switch (part.kind) {
    case 'boundary': {
      return [
        part.action === 'split'
          ? 'Add paragraph break'
          : 'Delete paragraph break',
      ];
    }
    case 'content': {
      const before = contentPreview(part.before);
      const after = contentPreview(part.after);
 
      switch (part.action) {
        case 'delete': {
          return [before ? `Delete “${before}”` : 'Delete content'];
        }
        case 'insert': {
          return [after ? `Add “${after}”` : 'Add content'];
        }
        case 'move': {
          return [before ? `Move “${before}”` : 'Move content'];
        }
        case 'replace': {
          return [
            before && after
              ? `Replace “${before}” with “${after}”`
              : 'Replace content',
          ];
        }
      }
 
      return [];
    }
    case 'properties': {
      const keys = [
        ...new Set([...Object.keys(part.before), ...Object.keys(part.after)]),
      ].sort();
 
      return keys.map(
        (key) =>
          `${formatPropertyName(key)}: ${formatPropertyValue(
            part.before[key]
          )} → ${formatPropertyValue(part.after[key])}`
      );
    }
    case 'root': {
      return [
        `${part.after ? 'Add' : 'Delete'} ${
          part.root === 'main' ? 'document content' : part.root
        }`,
      ];
    }
  }
 
  return [];
};
 
const describeSuggestion = (
  editor: Editor,
  details: AuthoredChangeDetails | null,
  fallback: AuthoredChange['kind']
) => {
  if (details?.parts.status === 'available') {
    const descriptions: string[] = [];
 
    for (let index = 0; index < details.parts.items.length; index++) {
      const part = details.parts.items[index];
 
      if (part.kind !== 'content' || part.action !== 'insert') {
        descriptions.push(...describeSuggestionPart(part));
        continue;
      }
 
      let text = contentText(part.after);
      let current = part;
      while (index + 1 < details.parts.items.length) {
        const next = details.parts.items[index + 1];
        if (
          next.kind !== 'content' ||
          !insertionPartsAreAdjacent(editor, current, next)
        ) {
          break;
        }
        text += contentText(next.after);
        current = next;
        index += 1;
      }
      const preview = textPreview(text);
      descriptions.push(preview ? `Add “${preview}”` : 'Add content');
    }
 
    if (descriptions.length > 0) return descriptions;
  }
 
  return [
    {
      delete: 'Delete content',
      format: 'Change formatting',
      insert: 'Add content',
      mixed: 'Edit content',
      structure: 'Change structure',
    }[fallback],
  ];
};
 
function SuggestionDiscussionCard({
  change,
  createdAt,
  onDecisionApplied,
  threadIds,
}: {
  change: AuthoredChange;
  createdAt: Date;
  onDecisionApplied: () => void;
  threadIds: readonly string[];
}) {
  const editor = useEditor();
  const proposedEditor = getProposedEditor(editor);
  const { api: comments } = useEditor().plugin(CommentsPlugin);
  const user = useCommentUser(change.authorId);
  const changeKey = `${change.id}:${change.revision}`;
  const [outcomeState, setOutcomeState] = React.useState<{
    changeKey: string;
    result: AuthoredResult;
  } | null>(null);
  const outcome =
    outcomeState?.changeKey === changeKey ? outcomeState.result : null;
  const details = useEditorSelector(
    (current) =>
      current.plugin(DefaultAuthoredPlugin).read.details(change.id) ?? null,
    {
      shouldUpdate: (commit) =>
        !commit ||
        commit.changed.hasAny('document') ||
        commit.changed.hasAny('state'),
    }
  );
  const descriptions = describeSuggestion(
    proposedEditor,
    details ?? null,
    change.kind
  );
  const setOutcome = (result: AuthoredResult) =>
    setOutcomeState({ changeKey, result });
 
  const decide = (
    action: 'accept' | 'reject',
    related: readonly string[] = []
  ) => {
    const authored = editor.plugin(DefaultAuthoredPlugin);
    const latest = authored.read.change(change.id);
    if (!latest) {
      setOutcome({ status: 'stale', ids: [change.id] });
      return;
    }
    const ids = [...new Set([latest.id, ...related])];
    const input = {
      action,
      selection: authored.read.select({ ids }),
    };
    const result =
      latest.status === 'conflicted'
        ? authored.update.resolve(input)
        : authored.update.decide(input);
 
    if (result.status === 'applied' || result.status === 'unchanged') {
      onDecisionApplied();
      return;
    }
    setOutcome(result);
  };
  const relatedIds =
    outcome?.status === 'blocked'
      ? [...outcome.dependencies, ...outcome.dependants, ...outcome.conflicts]
      : [];
  const outcomeMessage = (() => {
    if (!outcome) return null;
    switch (outcome.status) {
      case 'applied': {
        return null;
      }
      case 'unchanged': {
        return null;
      }
      case 'blocked': {
        return `This decision also affects ${
          relatedIds.length
        } related suggestion${relatedIds.length === 1 ? '' : 's'}.`;
      }
      case 'invalid': {
        return 'This suggestion no longer belongs to the current document.';
      }
      case 'stale': {
        return 'This suggestion changed. Review it again before deciding.';
      }
      case 'unavailable': {
        return 'The retained content needed for this action is unavailable.';
      }
    }
 
    return null;
  })();
 
  return (
    <article
      className="relative flex flex-col focus-within:[&>header>.editor-suggestion-actions]:pointer-events-auto focus-within:[&>header>.editor-suggestion-actions]:opacity-100 hover:[&>header>.editor-suggestion-actions]:pointer-events-auto hover:[&>header>.editor-suggestion-actions]:opacity-100"
      data-suggestion-review={change.id}
    >
      <header className="relative flex items-center">
        <Avatar className="size-5">
          <AvatarImage alt={user?.name} src={user?.avatarUrl} />
          <AvatarFallback>{user?.name?.[0] ?? '?'}</AvatarFallback>
        </Avatar>
        <span className="mx-2 text-sm leading-none font-semibold">
          {user?.name ?? change.authorId}
        </span>
        <span className="text-xs leading-none text-muted-foreground/80">
          {formatCommentDate(createdAt)}
        </span>
        <span className="editor-suggestion-actions pointer-events-none absolute top-0 right-0 flex gap-2 opacity-0 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">
          <Button
            aria-label="Accept suggestion"
            className="size-6 p-1 text-muted-foreground"
            onClick={() => decide('accept')}
            variant="ghost"
          >
            <CheckIcon className="size-4" />
          </Button>
          <Button
            aria-label="Reject suggestion"
            className="size-6 p-1 text-muted-foreground"
            onClick={() => decide('reject')}
            variant="ghost"
          >
            <XIcon className="size-4" />
          </Button>
        </span>
      </header>
 
      <div className="relative mt-1 mb-4 flex flex-col gap-2 pl-[32px] text-sm">
        {descriptions.map((description, index) => (
          <p className="text-muted-foreground" key={`${index}:${description}`}>
            {description}
          </p>
        ))}
        {outcomeMessage && (
          <div className="flex flex-col items-start gap-2" role="alert">
            <p>{outcomeMessage}</p>
            {outcome?.status === 'blocked' && relatedIds.length > 0 && (
              <div className="flex gap-2">
                <Button
                  onClick={() => decide('accept', relatedIds)}
                  size="sm"
                  variant="outline"
                >
                  Accept related
                </Button>
                <Button
                  onClick={() => decide('reject', relatedIds)}
                  size="sm"
                  variant="outline"
                >
                  Reject related
                </Button>
              </div>
            )}
          </div>
        )}
      </div>
 
      {threadIds.map((id) => (
        <CommentThreadCard id={id} key={id} showReply={false} />
      ))}
 
      <CommentComposer
        ariaLabel="Comment on suggestion"
        onSubmit={(body) =>
          comments.createThread({
            body,
            excerpt: `${change.kind} suggestion`,
            target: { id: change.id, type: 'change' },
          })
        }
        placeholder="Reply..."
      />
    </article>
  );
}
 
function NewComment({
  autoFocus,
  editableRef,
}: EditableSiblingProps & { autoFocus: boolean }) {
  const { api: comments } = useEditor().plugin(CommentsPlugin);
 
  return (
    <CommentComposer
      ariaLabel="New comment"
      autoFocus={autoFocus}
      onCancel={() => {
        comments.cancel();
        editableRef.current?.focus();
      }}
      onSubmit={async (body) => {
        const result = await comments.create(body);
 
        if (result.status === 'applied') comments.setActive([result.value]);
        return result;
      }}
      placeholder="Add a comment"
    />
  );
}
 
function DiscussionBlock({
  children,
  editor,
}: RenderNodeWrapperProps<typeof CommentsPlugin>) {
  const path = usePath();
  const blockKey = editor.key(path);
 
  if (!blockKey) return children;
 
  const props = {
    blockIndex: path[0] ?? 0,
    blockKey,
    children,
  };
 
  if (editor.plugin(SuggestionPlugin).installed) {
    return <SuggestionDiscussionBlockContent {...props} path={path} />;
  }
 
  return <DiscussionBlockContent {...props} changes={[]} />;
}
 
function SuggestionDiscussionBlockContent({
  path,
  ...props
}: React.PropsWithChildren<{
  blockIndex: number;
  blockKey: NodeKey;
  path: Path;
}>) {
  const changes = useSuggestionChanges(path);
 
  return <DiscussionBlockContent {...props} changes={changes} />;
}
 
function DiscussionBlockContent({
  blockIndex,
  blockKey,
  changes,
  children,
}: React.PropsWithChildren<{
  blockIndex: number;
  blockKey: NodeKey;
  changes: readonly AuthoredChange[];
}>) {
  const store = useDiscussionStore();
 
  React.useEffect(() => {
    store.setBlockSuggestions(blockKey, blockIndex, changes);
 
    return () => store.removeBlock(blockKey);
  }, [blockIndex, blockKey, changes, store]);
  const subscribe = React.useCallback(
    (listener: () => void) => store.subscribeBlock(blockKey, listener),
    [blockKey, store]
  );
  const block = React.useSyncExternalStore(
    subscribe,
    () => store.getBlockSnapshot(blockKey),
    () => store.getBlockSnapshot(blockKey)
  );
  const setTriggerRef = React.useCallback(
    (trigger: HTMLButtonElement | null) => {
      store.setBlockTrigger(blockKey, trigger);
    },
    [blockKey, store]
  );
 
  const isActive = block.active;
  const Icon =
    block.hasComments && block.hasSuggestions
      ? MessagesSquareIcon
      : block.hasSuggestions
        ? PencilLineIcon
        : MessageSquareTextIcon;
  const itemLabel = block.totalCount === 1 ? 'item' : 'items';
 
  return (
    <div className="flex w-full justify-between">
      <div className="w-full min-w-0">{children}</div>
      <div
        className="relative left-0 size-0 select-none"
        contentEditable={false}
      >
        {block.totalCount > 0 && (
          <Button
            aria-expanded={isActive}
            aria-label={`${isActive ? 'Close' : 'Open'} ${
              block.totalCount
            } discussion ${itemLabel} for this block`}
            className="mt-1 ml-1 flex h-6 gap-1 !px-1.5 py-0 text-muted-foreground/80 hover:text-muted-foreground/80 data-[active=true]:bg-muted"
            contentEditable={false}
            data-active={isActive}
            data-discussion-block-trigger
            data-editor-keep-selection-visible
            onClick={(event) => {
              event.stopPropagation();
              store.selectBlock(blockKey, event.currentTarget);
            }}
            onMouseDown={(event) => event.preventDefault()}
            ref={setTriggerRef}
            type="button"
            variant="ghost"
          >
            <Icon aria-hidden="true" className="size-4 shrink-0" />
            <span
              aria-hidden="true"
              className="text-xs font-semibold after:content-[attr(data-count)]"
              data-count={block.totalCount}
            />
          </Button>
        )}
      </div>
    </div>
  );
}
 
const DiscussionBlockSlot: RenderNodeWrapperDescriptor<typeof CommentsPlugin> =
  {
    component: DiscussionBlock,
    match: ({ renderPath }) => renderPath.length === 1,
  };
 
function DiscussionPopover({
  activeSuggestionId,
  editableRef,
  setActiveSuggestionId,
  snapshot,
}: EditableSiblingProps & {
  activeSuggestionId: string | null;
  setActiveSuggestionId?: (id: string | null) => void;
  snapshot: DiscussionSnapshot;
}) {
  const editor = useEditor();
  const rootElement = useEditorRootElement(editor);
  const store = useDiscussionStore();
  const popoverRef = React.useRef<HTMLDivElement>(null);
  const { api: comments } = useEditor().plugin(CommentsPlugin);
  const pending = usePendingComment();
  const activeCommentIds = usePluginStore(CommentsPlugin, 'activeIds');
  const activeOrder = new Map(activeCommentIds.map((id, index) => [id, index]));
  const activeItems: DiscussionItem[] = [
    ...activeCommentIds.flatMap((id) => {
      const item = store.getComment(id);
 
      return item ? [item] : [];
    }),
    ...(activeSuggestionId
      ? (() => {
          const item = store.getSuggestion(activeSuggestionId);
 
          return item ? [item] : [];
        })()
      : []),
  ].toSorted(
    (left, right) =>
      (activeOrder.get(left.id) ?? activeCommentIds.length) -
      (activeOrder.get(right.id) ?? activeCommentIds.length)
  );
  const targetGroup = snapshot.target
    ? store.getGroup(snapshot.target.blockKey)
    : undefined;
  const target = !pending && activeItems.length === 0 ? snapshot.target : null;
  const targetKey = snapshot.target?.blockKey ?? null;
  const [pagination, setPagination] = React.useState<{
    count: number;
    targetKey: NodeKey | null;
  }>({ count: DISCUSSION_PAGE_SIZE, targetKey });
  const visibleCount =
    pagination.targetKey === targetKey
      ? pagination.count
      : DISCUSSION_PAGE_SIZE;
  const targetItems = targetGroup?.items ?? [];
  const shownItems =
    activeItems.length > 0 ? activeItems : targetItems.slice(0, visibleCount);
  const activeSuggestionRange = activeItems.find(
    (item): item is Extract<DiscussionItem, { kind: 'suggestion' }> =>
      item.kind === 'suggestion'
  )?.range;
  const { api } = editor.plugin(CommentsPlugin);
  const commentRange = (() => {
    const authored = editor.plugin(DefaultAuthoredPlugin);
 
    for (const id of activeCommentIds) {
      const attachment = api.attachment(id);
      if (attachment?.type === 'range' && attachment.status === 'attached') {
        return attachment.range;
      }
      if (attachment?.type === 'change' && authored.installed) {
        const range = authored.read.change(attachment.id)?.ranges[0];
 
        if (range) return range;
      }
    }
    return null;
  })();
  const pendingRange = useEditorSelector(
    () => (pending ? comments.pendingRange() : null),
    {
      equalityFn: RangeApi.equals,
      shouldUpdate: (change) => !change || change.changed.hasAny('document'),
    }
  );
  const anchorRange =
    pendingRange ?? commentRange ?? activeSuggestionRange ?? null;
  const anchorRangeJson = anchorRange ? JSON.stringify(anchorRange) : null;
  const anchorBlockKey = anchorRange
    ? editor.key([RangeApi.start(anchorRange).path[0] ?? 0])
    : null;
  const virtualAnchor = React.useMemo(() => {
    const currentAnchorRange = anchorRangeJson
      ? (JSON.parse(anchorRangeJson) as Range)
      : null;
 
    return {
      contextElement: rootElement ?? undefined,
      getBoundingClientRect: () => {
        const blockRect = anchorBlockKey
          ? store.getBlockTrigger(anchorBlockKey)?.getBoundingClientRect()
          : null;
        const domRange = currentAnchorRange
          ? editor.api.dom.resolveDOMRange(currentAnchorRange)
          : null;
 
        if (!domRange) return blockRect ?? new DOMRect();
 
        const clientRect = Array.from(domRange.getClientRects()).findLast(
          ({ height, width }) => height > 0 || width > 0
        );
 
        if (clientRect) return clientRect;
 
        const rangeRect = domRange.getBoundingClientRect();
 
        return rangeRect.height > 0 || rangeRect.width > 0
          ? rangeRect
          : (blockRect ?? rangeRect);
      },
    };
  }, [anchorBlockKey, anchorRangeJson, editor, rootElement, store]);
  const anchorElement = target?.anchor ?? (anchorRange ? virtualAnchor : null);
  const open = Boolean(
    anchorElement && (pending || activeItems.length > 0 || targetGroup)
  );
  const openRef = React.useRef(open);
  const [placedPending, setPlacedPending] =
    React.useState<typeof pending>(null);
 
  React.useLayoutEffect(() => {
    openRef.current = open;
  }, [open]);
 
  React.useEffect(() => {
    if (snapshot.target && (pending || activeItems.length > 0)) {
      store.clearTarget();
    }
  }, [activeItems.length, pending, snapshot.target, store]);
 
  return (
    <div data-discussion-root="">
      <FloatingPopover
        modal={false}
        open={open}
        onOpenChange={(nextOpen) => {
          if (nextOpen) return;
          if (
            activeCommentIds.some((id) =>
              comments.getSnapshot().draftThreadIds.includes(id)
            )
          ) {
            return;
          }
 
          store.clearTarget();
          comments.cancel();
          editor.plugin(CommentsPlugin).api.setActive([]);
          setActiveSuggestionId?.(null);
        }}
      >
        {anchorElement && <FloatingPopoverAnchor element={anchorElement} />}
        <FloatingPopoverContent
          align="center"
          aria-label={pending ? 'New comment' : 'Discussion items'}
          className="max-h-[min(50dvh,calc(-24px+var(--floating-popover-available-height)))] w-[380px] max-w-[calc(100vw-24px)] min-w-[130px] gap-0 overflow-y-auto p-0 data-[state=closed]:opacity-0"
          data-discussion-popover=""
          data-editor-keep-selection-visible
          ref={popoverRef}
          tabIndex={-1}
          onFinalFocus={(event) => {
            event.preventDefault();
            const document = editableRef.current?.ownerDocument;
            const active = document?.activeElement;
            if (
              !openRef.current &&
              (!active ||
                active === document?.body ||
                popoverRef.current?.contains(active))
            ) {
              editableRef.current?.focus();
            }
          }}
          onInitialFocus={(event) => event.preventDefault()}
          onPlaced={() => {
            if (pending) setPlacedPending(pending);
          }}
          side="bottom"
        >
          {pending ? (
            <div className="p-4">
              <NewComment
                autoFocus={placedPending === pending}
                editableRef={editableRef}
              />
            </div>
          ) : (
            <>
              {shownItems.map((item, index) => (
                <React.Fragment key={`${item.kind}-${item.id}`}>
                  <div className="p-4">
                    <DiscussionCard
                      item={item}
                      onDecisionApplied={() => {
                        if (!snapshot.target && item.kind === 'suggestion') {
                          requestAnimationFrame(() => {
                            comments.setActive(item.threadIds);
                            popoverRef.current?.focus();
                          });
                          return;
                        }
                        popoverRef.current?.focus();
                      }}
                    />
                  </div>
                  {(index < shownItems.length - 1 ||
                    shownItems.length < targetItems.length) && (
                    <Separator data-discussion-separator="" />
                  )}
                </React.Fragment>
              ))}
              {activeItems.length === 0 &&
                shownItems.length < targetItems.length && (
                  <div className="p-3">
                    <Button
                      className="w-full"
                      onClick={() =>
                        setPagination({
                          count: Math.min(
                            visibleCount + DISCUSSION_PAGE_SIZE,
                            targetItems.length
                          ),
                          targetKey,
                        })
                      }
                      size="sm"
                      variant="ghost"
                    >
                      Show more discussion items
                    </Button>
                  </div>
                )}
            </>
          )}
        </FloatingPopoverContent>
      </FloatingPopover>
    </div>
  );
}
 
const usePrepareDiscussionSelection = (
  setActiveSuggestionId?: (id: string | null) => void
) => {
  const editor = useEditor();
  const store = useDiscussionStore();
  const { api: comments } = editor.plugin(CommentsPlugin);
 
  React.useEffect(() => {
    store.setPrepareSelection(() => {
      comments.cancel();
      editor.plugin(CommentsPlugin).api.setActive([]);
      setActiveSuggestionId?.(null);
    });
 
    return () => store.setPrepareSelection(null);
  }, [comments, editor, setActiveSuggestionId, store]);
};
 
function SuggestionDiscussion({
  editableRef,
  snapshot,
}: EditableSiblingProps & { snapshot: DiscussionSnapshot }) {
  const { activeId, setActiveId } = useActiveSuggestion();
 
  usePrepareDiscussionSelection(setActiveId);
 
  return (
    <DiscussionPopover
      activeSuggestionId={activeId}
      editableRef={editableRef}
      setActiveSuggestionId={setActiveId}
      snapshot={snapshot}
    />
  );
}
 
function CommentDiscussion({
  editableRef,
  snapshot,
}: EditableSiblingProps & { snapshot: DiscussionSnapshot }) {
  usePrepareDiscussionSelection();
 
  return (
    <DiscussionPopover
      activeSuggestionId={null}
      editableRef={editableRef}
      snapshot={snapshot}
    />
  );
}
 
function Discussion({ editableRef }: EditableSiblingProps) {
  const store = useDiscussionStore();
  const snapshot = React.useSyncExternalStore(
    store.subscribe,
    store.getSnapshot,
    store.getSnapshot
  );
  const suggestionsInstalled = useEditorSelector(
    (current) => current.plugin(SuggestionPlugin).installed
  );
  return suggestionsInstalled ? (
    <SuggestionDiscussion editableRef={editableRef} snapshot={snapshot} />
  ) : (
    <CommentDiscussion editableRef={editableRef} snapshot={snapshot} />
  );
}
 
export const DiscussionSlots = {
  afterEditable: Discussion,
  wrapNode: DiscussionBlockSlot,
  wrapRoot: DiscussionRoot,
} satisfies typeof CommentsPlugin.slots;
 
export const DiscussionKit = [
  ...CommentKit,
  CommentsPlugin.configure({ slots: DiscussionSlots }),
];
;
import { SuggestionKit } from "@/components/editor/suggestion";
import { Toolbar } from "@/components/editor/toolbar";
export function CommentsEditor({ value, initialComments, users, currentUserId }: {
value: EditorValueInput<Value>;
initialComments: CommentsJSON;
users: Record<string, CommentUser>;
currentUserId: string;
}) {
const editor = useCreateEditor({
plugins: [
...BasicBlocksKit,
...LinkKit,
...SuggestionKit,
...DiscussionKit,
CommentsPlugin.configure({
initialState: { initialComments, users, currentUserId },
}),
],
initialValue: value,
});
return (
<EditorRoot editor={editor}>
<EditorContainer>
<Toolbar>
<CommentToolbarButton />
<AllCommentsButton />
</Toolbar>
<Editor />
</EditorContainer>
</EditorRoot>
);
}
components/editor/comments-editor.tsx
"use client";
 
import type { EditorValueInput, Value } from "platejs";
import type { CommentsJSON, CommentUser } from "platejs/comments";
import { CommentsPlugin } from "platejs/comments/react";
import { EditorRoot, useCreateEditor } from "platejs/react";
 
import { BasicBlocksKit } from "@/components/editor/basic-blocks";
import { AllCommentsButton, CommentToolbarButton } from "@/components/editor/comment-toolbar-button";
import { DiscussionKit } from "@/components/editor/discussion";
import { Editor, EditorContainer } from "@/components/editor/editor";
import { LinkKit } from "@/components/editor/link";
import { SuggestionKit } from "@/components/editor/suggestion";
import { Toolbar } from "@/components/editor/toolbar";
 
export function CommentsEditor({ value, initialComments, users, currentUserId }: {
  value: EditorValueInput<Value>;
  initialComments: CommentsJSON;
  users: Record<string, CommentUser>;
  currentUserId: string;
}) {
  const editor = useCreateEditor({
    plugins: [
      ...BasicBlocksKit,
      ...LinkKit,
      ...SuggestionKit,
      ...DiscussionKit,
      CommentsPlugin.configure({
        initialState: { initialComments, users, currentUserId },
      }),
    ],
    initialValue: value,
  });
 
  return (
    <EditorRoot editor={editor}>
      <EditorContainer>
        <Toolbar>
          <CommentToolbarButton />
          <AllCommentsButton />
        </Toolbar>
        <Editor />
      </EditorContainer>
    </EditorRoot>
  );
}
CommentThread
|
null
}>
| Readonly<{ code?: string; status: 'reject' }>;
};