plate
uiblock-discussionsource: ok · hit
Block Discussion
A popover interface for managing discussions: comments, replies, suggestions.
npx shadcn add "https://platejs.org/r/block-discussion.json"free
Where it goes
comment
Interaction
tooltip
Use cases
community
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@platejs/commentdate-fns@platejs/suggestion
Registry dependencies
buttonpopoveravatardropdown-menuhttps://platejs.org/r/editor.jsonhttps://platejs.org/r/ai-node.jsonhttps://platejs.org/r/date-node.jsonhttps://platejs.org/r/emoji-node.jsonhttps://platejs.org/r/link-node.jsonhttps://platejs.org/r/mention-node.jsonhttps://platejs.org/r/highlight-style.jsonhttps://platejs.org/r/suggestion-kit.jsonhttps://platejs.org/r/discussion-kit.jsonhttps://platejs.org/r/basic-marks-kit.json
Files4 · source cached 10/8/2026, 2:57:46 PM
src/registry/ui/block-discussion.tsx
'use client';
import * as React from 'react';
import type { PlateElementProps, RenderNodeWrapper } from 'platejs/react';
import { getDraftCommentKey } from '@platejs/comment';
import { CommentPlugin } from '@platejs/comment/react';
import { getBoundingClientRect } from '@platejs/floating';
import { getTransientSuggestionKey } from '@platejs/suggestion';
import { SuggestionPlugin } from '@platejs/suggestion/react';
import {
MessageSquareTextIcon,
MessagesSquareIcon,
PencilLineIcon,
} from 'lucide-react';
import { type AnyPluginConfig, type NodeEntry, PathApi } from 'platejs';
import { useEditorRef, usePluginOption } from 'platejs/react';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { commentPlugin } from '@/registry/components/editor/plugins/comment-kit';
import type { TDiscussion } from '@/registry/components/editor/plugins/discussion-kit';
import { useBlockDiscussionItems } from '@/registry/lib/block-discussion-index';
import { suggestionPlugin } from '@/registry/components/editor/plugins/suggestion-kit';
import { BlockSuggestionCard, isResolvedSuggestion } from './block-suggestion';
import { Comment, CommentCreateForm } from './comment';
export const BlockDiscussion: RenderNodeWrapper<AnyPluginConfig> =
(_props) => (props) => <BlockCommentContent {...props} />;
const BlockCommentContent = ({ children, element }: PlateElementProps) => {
const editor = useEditorRef();
const commentsApi = editor.getApi(CommentPlugin).comment;
const blockPath = editor.api.findPath(element) ?? [];
const isTopLevelBlock = blockPath.length === 1;
const draftCommentNode = isTopLevelBlock
? commentsApi.node({ at: blockPath, isDraft: true })
: undefined;
const commentNodes = isTopLevelBlock
? [...commentsApi.nodes({ at: blockPath })]
: [];
const suggestionNodes = isTopLevelBlock
? [
...editor.getApi(SuggestionPlugin).suggestion.nodes({ at: blockPath }),
].filter(([node]) => !node[getTransientSuggestionKey()])
: [];
const { resolvedDiscussions, resolvedSuggestions } =
useBlockDiscussionItems(blockPath);
const suggestionsCount = resolvedSuggestions.length;
const discussionsCount = resolvedDiscussions.length;
const totalCount = suggestionsCount + discussionsCount;
const activeSuggestionId = usePluginOption(suggestionPlugin, 'activeId');
const activeSuggestion =
activeSuggestionId &&
resolvedSuggestions.find((s) => s.suggestionId === activeSuggestionId);
const commentingBlock = usePluginOption(commentPlugin, 'commentingBlock');
const activeCommentId = usePluginOption(commentPlugin, 'activeId');
const isCommenting = activeCommentId === getDraftCommentKey();
const activeDiscussion =
activeCommentId &&
resolvedDiscussions.find((d) => d.id === activeCommentId);
const noneActive = !activeSuggestion && !activeDiscussion;
const sortedMergedData = [
...resolvedDiscussions,
...resolvedSuggestions,
].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
const selected =
resolvedDiscussions.some((d) => d.id === activeCommentId) ||
resolvedSuggestions.some((s) => s.suggestionId === activeSuggestionId);
const [_open, setOpen] = React.useState(selected);
// in some cases, we may comment the multiple blocks
const commentingCurrent =
!!commentingBlock && PathApi.equals(blockPath, commentingBlock);
const open =
_open ||
selected ||
(isCommenting && !!draftCommentNode && commentingCurrent);
const anchorElement = (() => {
let activeNode: NodeEntry | undefined;
if (activeSuggestion) {
activeNode = suggestionNodes.find(
([node]) =>
editor.getApi(SuggestionPlugin).suggestion.nodeId(node) ===
activeSuggestion.suggestionId
);
}
if (activeCommentId) {
if (activeCommentId === getDraftCommentKey()) {
if (!draftCommentNode || !commentingCurrent) return null;
const draftCommentPaths = commentsApi
.nodes({ at: [], isDraft: true })
.map(([, path]) => path);
return {
contextElement:
editor.api.toDOMNode(draftCommentNode[0]) ?? undefined,
getBoundingClientRect: () =>
getBoundingClientRect(editor, draftCommentPaths) ??
editor.api.toDOMNode(element)?.getBoundingClientRect() ??
new DOMRect(),
};
}
activeNode = commentNodes.find(
([node]) =>
editor.getApi(commentPlugin).comment.nodeId(node) === activeCommentId
);
}
if (!activeNode) return null;
return editor.api.toDOMNode(activeNode[0])!;
})();
if (!isTopLevelBlock) return <>{children}</>;
if (suggestionsCount + resolvedDiscussions.length === 0 && !draftCommentNode)
return <div className="w-full">{children}</div>;
return (
<div className="flex w-full justify-between">
<Popover
open={open}
onOpenChange={(_open_) => {
if (!_open_ && isCommenting && draftCommentNode) {
editor.tf.unsetNodes(getDraftCommentKey(), {
at: [],
mode: 'lowest',
match: (n) => n[getDraftCommentKey()],
});
}
setOpen(_open_);
}}
>
<div className="w-full">{children}</div>
{anchorElement && (
<PopoverAnchor
asChild
className="w-full"
virtualRef={{ current: anchorElement }}
/>
)}
<PopoverContent
className="max-h-[min(50dvh,calc(-24px+var(--radix-popper-available-height)))] w-[380px] min-w-[130px] max-w-[calc(100vw-24px)] overflow-y-auto p-0 data-[state=closed]:opacity-0"
collisionPadding={{
bottom: 24,
top: 24,
}}
onCloseAutoFocus={(e) => e.preventDefault()}
onOpenAutoFocus={(e) => e.preventDefault()}
align="center"
side="bottom"
updatePositionStrategy={isCommenting ? 'always' : 'optimized'}
>
{isCommenting ? (
<CommentCreateForm className="p-4" focusOnMount />
) : noneActive ? (
sortedMergedData.map((item, index) =>
isResolvedSuggestion(item) ? (
<BlockSuggestionCard
key={item.suggestionId}
idx={index}
isLast={index === sortedMergedData.length - 1}
suggestion={item}
/>
) : (
<BlockComment
key={item.id}
discussion={item}
isLast={index === sortedMergedData.length - 1}
/>
)
)
) : (
<>
{activeSuggestion && (
<BlockSuggestionCard
key={activeSuggestion.suggestionId}
idx={0}
isLast={true}
suggestion={activeSuggestion}
/>
)}
{activeDiscussion && (
<BlockComment discussion={activeDiscussion} isLast={true} />
)}
</>
)}
</PopoverContent>
{totalCount > 0 && (
<div className="relative left-0 size-0 select-none">
<PopoverTrigger asChild>
<Button
variant="ghost"
className="!px-1.5 mt-1 ml-1 flex h-6 gap-1 py-0 text-muted-foreground/80 hover:text-muted-foreground/80 data-[active=true]:bg-muted"
data-active={open}
contentEditable={false}
>
{suggestionsCount > 0 && discussionsCount === 0 && (
<PencilLineIcon className="size-4 shrink-0" />
)}
{suggestionsCount === 0 && discussionsCount > 0 && (
<MessageSquareTextIcon className="size-4 shrink-0" />
)}
{suggestionsCount > 0 && discussionsCount > 0 && (
<MessagesSquareIcon className="size-4 shrink-0" />
)}
<span className="font-semibold text-xs">{totalCount}</span>
</Button>
</PopoverTrigger>
</div>
)}
</Popover>
</div>
);
};
function BlockComment({
discussion,
isLast,
}: {
discussion: TDiscussion;
isLast: boolean;
}) {
const [editingId, setEditingId] = React.useState<string | null>(null);
return (
<React.Fragment key={discussion.id}>
<div className="p-4">
{discussion.comments.map((comment, index) => (
<Comment
key={comment.id ?? index}
comment={comment}
discussionLength={discussion.comments.length}
documentContent={discussion?.documentContent}
editingId={editingId}
index={index}
setEditingId={setEditingId}
showDocumentContent
/>
))}
<CommentCreateForm discussionId={discussion.id} />
</div>
{!isLast && <div className="h-px w-full bg-muted" />}
</React.Fragment>
);
}
src/registry/lib/block-discussion-index.ts
'use client';
import * as React from 'react';
import type { TResolvedSuggestion } from '@platejs/suggestion';
import type { PlateEditor } from 'platejs/react';
import { CommentPlugin } from '@platejs/comment/react';
import { getSuggestionKey, keyId2SuggestionId } from '@platejs/suggestion';
import { SuggestionPlugin } from '@platejs/suggestion/react';
import {
type NodeEntry,
NodeApi,
type Path,
type TCommentText,
type TElement,
type TSuggestionText,
ElementApi,
KEYS,
PathApi,
TextApi,
} from 'platejs';
import { useEditorRef, useEditorVersion, usePluginOption } from 'platejs/react';
import {
type TDiscussion,
discussionPlugin,
} from '@/registry/components/editor/plugins/discussion-kit';
import type { TComment } from '@/registry/ui/comment';
export interface ResolvedSuggestion extends TResolvedSuggestion {
comments: TComment[];
}
export const BLOCK_SUGGESTION_TOKEN = '__block__';
type BlockDiscussionEntry = NodeEntry<
TCommentText | TElement | TSuggestionText
>;
type SuggestionEntry = NodeEntry<TElement | TSuggestionText>;
type BlockDiscussionIndex = {
discussionsByBlock: Map<string, TDiscussion[]>;
suggestionsByBlock: Map<string, ResolvedSuggestion[]>;
};
type BuildBlockDiscussionIndexOptions = {
entries: BlockDiscussionEntry[];
discussions: TDiscussion[];
getCommentId: (node: TCommentText) => string | undefined;
getSuggestionData: (node: TElement | TSuggestionText) =>
| {
createdAt: Date | number | string;
id: string;
isLineBreak?: boolean;
newProperties?: Record<string, unknown>;
properties?: Record<string, unknown>;
type: 'insert' | 'remove' | 'update';
userId: string;
}
| undefined;
getSuggestionDataList: (node: TSuggestionText) => Array<{
id: string;
newProperties?: Record<string, unknown>;
properties?: Record<string, unknown>;
type: 'insert' | 'remove' | 'update';
}>;
getSuggestionId: (node: TElement | TSuggestionText) => string | undefined;
isBlockSuggestion: (node: TElement | TSuggestionText) => boolean;
};
const discussionIndexCache = new WeakMap<
PlateEditor,
{
discussions: TDiscussion[];
index: BlockDiscussionIndex;
version: number;
}
>();
const TYPE_TEXT_MAP: Record<string, (node?: TElement) => string> = {
[KEYS.audio]: () => 'Audio',
[KEYS.blockquote]: () => 'Blockquote',
[KEYS.callout]: () => 'Callout',
[KEYS.codeBlock]: () => 'Code Block',
[KEYS.column]: () => 'Column',
[KEYS.equation]: () => 'Equation',
[KEYS.file]: () => 'File',
[KEYS.h1]: () => 'Heading 1',
[KEYS.h2]: () => 'Heading 2',
[KEYS.h3]: () => 'Heading 3',
[KEYS.h4]: () => 'Heading 4',
[KEYS.h5]: () => 'Heading 5',
[KEYS.h6]: () => 'Heading 6',
[KEYS.hr]: () => 'Horizontal Rule',
[KEYS.img]: () => 'Image',
[KEYS.mediaEmbed]: () => 'Media',
[KEYS.p]: (node) => {
if (node?.[KEYS.listType] === KEYS.listTodo) return 'Todo List';
if (node?.[KEYS.listType] === KEYS.ol) return 'Ordered List';
if (node?.[KEYS.listType] === KEYS.ul) return 'List';
return 'Paragraph';
},
[KEYS.table]: () => 'Table',
[KEYS.toc]: () => 'Table of Contents',
[KEYS.toggle]: () => 'Toggle',
[KEYS.video]: () => 'Video',
};
const appendByKey = <T>(map: Map<string, T[]>, key: string, value: T) => {
const values = map.get(key);
if (values) {
values.push(value);
return;
}
map.set(key, [value]);
};
const getBlockKey = (path: Path) => path.join(',');
const getTopLevelPath = (path: Path): Path | null =>
path.length > 0 ? path.slice(0, 1) : null;
const getSuggestionIds = (
node: TCommentText | TElement | TSuggestionText,
getSuggestionDataList: BuildBlockDiscussionIndexOptions['getSuggestionDataList'],
getSuggestionId: BuildBlockDiscussionIndexOptions['getSuggestionId']
) => {
if (TextApi.isText(node)) {
const dataList = getSuggestionDataList(node as TSuggestionText);
const updateIds = dataList
.filter((data) => data.type === 'update')
.map((data) => data.id);
if (updateIds.length > 0) return updateIds;
const suggestionId = getSuggestionId(node as TSuggestionText);
return suggestionId ? [suggestionId] : [];
}
if (ElementApi.isElement(node)) {
const suggestionId = getSuggestionId(node);
return suggestionId ? [suggestionId] : [];
}
return [];
};
const suggestionTypeText = (node: TElement) =>
(TYPE_TEXT_MAP[node.type] ?? (() => node.type))(node);
const formatSuggestionDateText = (date: string) => {
const elementDate = new Date(date);
if (Number.isNaN(elementDate.getTime())) return date;
const today = new Date();
const yesterday = new Date(today);
const tomorrow = new Date(today);
yesterday.setDate(today.getDate() - 1);
tomorrow.setDate(today.getDate() + 1);
const sameDay = (left: Date, right: Date) =>
left.getDate() === right.getDate() &&
left.getMonth() === right.getMonth() &&
left.getFullYear() === right.getFullYear();
if (sameDay(elementDate, today)) return 'Today';
if (sameDay(elementDate, yesterday)) return 'Yesterday';
if (sameDay(elementDate, tomorrow)) return 'Tomorrow';
return elementDate.toLocaleDateString(undefined, {
day: 'numeric',
month: 'long',
year: 'numeric',
});
};
const getInlineSuggestionElementText = (node: TElement) => {
if (typeof node.value === 'string' && node.value.length > 0) {
return node.value;
}
if (typeof node.date === 'string' && node.date.length > 0) {
return formatSuggestionDateText(node.date);
}
if (
node.type === KEYS.inlineEquation &&
typeof (node as TElement & { texExpression?: unknown }).texExpression ===
'string' &&
(node as TElement & { texExpression: string }).texExpression.length > 0
) {
return (node as TElement & { texExpression: string }).texExpression;
}
const nodeText = NodeApi.string(node);
if (nodeText.length > 0) {
return nodeText;
}
};
const toResolvedSuggestion = ({
discussionsById,
entries,
getSuggestionData,
getSuggestionDataList,
id,
isBlockSuggestion,
}: {
discussionsById: Map<string, TDiscussion>;
entries: SuggestionEntry[];
getSuggestionData: BuildBlockDiscussionIndexOptions['getSuggestionData'];
getSuggestionDataList: BuildBlockDiscussionIndexOptions['getSuggestionDataList'];
id: string;
isBlockSuggestion: BuildBlockDiscussionIndexOptions['isBlockSuggestion'];
}): ResolvedSuggestion | null => {
const sortedEntries = [...entries].sort(([, path1], [, path2]) =>
PathApi.isChild(path1, path2) ? -1 : 1
);
if (sortedEntries.length === 0) return null;
let newText = '';
let text = '';
let properties: Record<string, unknown> = {};
let newProperties: Record<string, unknown> = {};
sortedEntries.forEach(([node]) => {
if (TextApi.isText(node)) {
getSuggestionDataList(node as TSuggestionText).forEach((data) => {
if (data.id !== id) return;
switch (data.type) {
case 'insert': {
newText += node.text;
break;
}
case 'remove': {
text += node.text;
break;
}
case 'update': {
properties = { ...properties, ...data.properties };
newProperties = { ...newProperties, ...data.newProperties };
newText += node.text;
break;
}
}
});
return;
}
if (!ElementApi.isElement(node)) return;
const suggestionData = getSuggestionData(node);
if (suggestionData?.id !== keyId2SuggestionId(id)) return;
const inlineSuggestionText = getInlineSuggestionElementText(node);
if (inlineSuggestionText) {
if (suggestionData.type === 'insert') {
newText += inlineSuggestionText;
} else if (suggestionData.type === 'remove') {
text += inlineSuggestionText;
} else if (suggestionData.type === 'update') {
properties = { ...properties, ...suggestionData.properties };
newProperties = {
...newProperties,
...suggestionData.newProperties,
};
newText += inlineSuggestionText;
}
return;
}
if (!isBlockSuggestion(node)) return;
const nextText = suggestionData.isLineBreak
? BLOCK_SUGGESTION_TOKEN
: `${BLOCK_SUGGESTION_TOKEN}${suggestionTypeText(node)}`;
if (suggestionData.type === 'insert') {
newText += nextText;
} else if (suggestionData.type === 'remove') {
text += nextText;
}
});
const suggestionData = getSuggestionData(sortedEntries[0][0]);
if (!suggestionData) return null;
const keyId = getSuggestionKey(id);
const comments = discussionsById.get(id)?.comments ?? [];
const createdAt = new Date(suggestionData.createdAt);
const suggestionId = keyId2SuggestionId(id);
if (suggestionData.type === 'update') {
return {
comments,
createdAt,
keyId,
newProperties,
newText,
properties,
suggestionId,
type: 'update',
userId: suggestionData.userId,
};
}
if (newText.length > 0 && text.length > 0) {
return {
comments,
createdAt,
keyId,
newText,
suggestionId,
text,
type: 'replace',
userId: suggestionData.userId,
};
}
if (newText.length > 0) {
return {
comments,
createdAt,
keyId,
newText,
suggestionId,
type: 'insert',
userId: suggestionData.userId,
};
}
if (text.length > 0) {
return {
comments,
createdAt,
keyId,
suggestionId,
text,
type: 'remove',
userId: suggestionData.userId,
};
}
return null;
};
export const buildBlockDiscussionIndex = ({
discussions,
entries,
getCommentId,
getSuggestionData,
getSuggestionDataList,
getSuggestionId,
isBlockSuggestion,
}: BuildBlockDiscussionIndexOptions): BlockDiscussionIndex => {
const commentOwnerById = new Map<string, Path>();
const suggestionOwnerById = new Map<string, Path>();
const commentIds = new Set<string>();
const suggestionEntriesById = new Map<string, SuggestionEntry[]>();
const discussionsById = new Map(
discussions.map((discussion) => [discussion.id, discussion])
);
entries.forEach(([node, path]) => {
const blockPath = getTopLevelPath(path);
if (!blockPath) return;
if (TextApi.isText(node)) {
const commentId = getCommentId(node);
if (commentId) {
commentIds.add(commentId);
if (!commentOwnerById.has(commentId)) {
commentOwnerById.set(commentId, blockPath);
}
}
}
getSuggestionIds(node, getSuggestionDataList, getSuggestionId).forEach(
(suggestionId) => {
if (!suggestionOwnerById.has(suggestionId)) {
suggestionOwnerById.set(suggestionId, blockPath);
}
appendByKey(suggestionEntriesById, suggestionId, [
node as TElement | TSuggestionText,
path,
]);
}
);
});
const discussionsByBlock = new Map<string, TDiscussion[]>();
discussions.forEach((discussion) => {
const ownerPath = commentOwnerById.get(discussion.id);
if (!ownerPath || !commentIds.has(discussion.id) || discussion.isResolved) {
return;
}
appendByKey(discussionsByBlock, getBlockKey(ownerPath), {
...discussion,
createdAt: new Date(discussion.createdAt),
});
});
const suggestionsByBlock = new Map<string, ResolvedSuggestion[]>();
suggestionEntriesById.forEach((suggestionEntries, suggestionId) => {
const ownerPath = suggestionOwnerById.get(suggestionId);
if (!ownerPath) return;
const resolvedSuggestion = toResolvedSuggestion({
discussionsById,
entries: suggestionEntries,
getSuggestionData,
getSuggestionDataList,
id: suggestionId,
isBlockSuggestion,
});
if (!resolvedSuggestion) return;
appendByKey(suggestionsByBlock, getBlockKey(ownerPath), resolvedSuggestion);
});
return {
discussionsByBlock,
suggestionsByBlock,
};
};
const getDiscussionIndex = (
editor: PlateEditor,
discussions: TDiscussion[],
version: number
) => {
const cached = discussionIndexCache.get(editor);
if (
cached &&
cached.version === version &&
cached.discussions === discussions
) {
return cached.index;
}
const commentApi = editor.getApi(CommentPlugin).comment;
const suggestionApi = editor.getApi(SuggestionPlugin).suggestion;
const index = buildBlockDiscussionIndex({
discussions,
entries: [...editor.api.nodes({ at: [], mode: 'all' })],
getCommentId: (node) => commentApi.nodeId(node),
getSuggestionData: (node) => suggestionApi.suggestionData(node),
getSuggestionDataList: (node) => suggestionApi.dataList(node),
getSuggestionId: (node) => suggestionApi.nodeId(node),
isBlockSuggestion: (node) =>
ElementApi.isElement(node) && suggestionApi.isBlockSuggestion(node),
});
discussionIndexCache.set(editor, { discussions, index, version });
return index;
};
export const useBlockDiscussionItems = (blockPath: Path) => {
const editor = useEditorRef();
const discussions = usePluginOption(discussionPlugin, 'discussions');
const version = useEditorVersion() ?? 0;
return React.useMemo(() => {
const index = getDiscussionIndex(editor, discussions, version);
const blockKey = getBlockKey(blockPath);
return {
resolvedDiscussions: index.discussionsByBlock.get(blockKey) ?? [],
resolvedSuggestions: index.suggestionsByBlock.get(blockKey) ?? [],
};
}, [blockPath, discussions, editor, version]);
};
src/registry/ui/block-suggestion.tsx
'use client';
import * as React from 'react';
import { acceptSuggestion, rejectSuggestion } from '@platejs/suggestion';
import { SuggestionPlugin } from '@platejs/suggestion/react';
import { CheckIcon, XIcon } from 'lucide-react';
import { useEditorPlugin, usePluginOption } from 'platejs/react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
type TDiscussion,
discussionPlugin,
} from '@/registry/components/editor/plugins/discussion-kit';
import { Comment, CommentCreateForm, formatCommentDate } from './comment';
import {
BLOCK_SUGGESTION_TOKEN,
type ResolvedSuggestion,
} from '@/registry/lib/block-discussion-index';
export function BlockSuggestionCard({
idx,
isLast,
suggestion,
}: {
idx: number;
isLast: boolean;
suggestion: ResolvedSuggestion;
}) {
const { api, editor } = useEditorPlugin(SuggestionPlugin);
const userInfo = usePluginOption(discussionPlugin, 'user', suggestion.userId);
const accept = (suggestion: ResolvedSuggestion) => {
api.suggestion.withoutSuggestions(() => {
acceptSuggestion(editor, suggestion);
});
};
const reject = (suggestion: ResolvedSuggestion) => {
api.suggestion.withoutSuggestions(() => {
rejectSuggestion(editor, suggestion);
});
};
const [hovering, setHovering] = React.useState(false);
const suggestionText2Array = (text: string) => {
if (text === BLOCK_SUGGESTION_TOKEN) return ['line breaks'];
return text.split(BLOCK_SUGGESTION_TOKEN).filter(Boolean);
};
const getRemoveSummaryItems = (text: string) => {
const items = suggestionText2Array(text).map((item) => {
if (item === 'column_group') return 'Column';
if (item === 'code_block') return 'Code Block';
return item;
});
if (items.includes('Table')) return ['Table'];
if (items.includes('Code Block')) return ['Code Block'];
if (items.includes('Column')) return ['Column'];
return items;
};
const [editingId, setEditingId] = React.useState<string | null>(null);
return (
<div
key={`${suggestion.suggestionId}-${idx}`}
className="relative"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
<div className="flex flex-col p-4">
<div className="relative flex items-center">
{/* Replace to your own backend or refer to potion */}
<Avatar className="size-5">
<AvatarImage alt={userInfo?.name} src={userInfo?.avatarUrl} />
<AvatarFallback>{userInfo?.name?.[0]}</AvatarFallback>
</Avatar>
<h4 className="mx-2 font-semibold text-sm leading-none">
{userInfo?.name}
</h4>
<div className="text-muted-foreground/80 text-xs leading-none">
<span className="mr-1">
{formatCommentDate(new Date(suggestion.createdAt))}
</span>
</div>
</div>
<div className="relative mt-1 mb-4 pl-[32px]">
<div className="flex flex-col gap-2">
{suggestion.type === 'remove' &&
getRemoveSummaryItems(suggestion.text!).map((text, index) => (
<div key={index} className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">Delete:</span>
<span key={index} className="text-sm">
{text}
</span>
</div>
))}
{suggestion.type === 'insert' &&
suggestionText2Array(suggestion.newText!).map((text, index) => (
<div key={index} className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">Add:</span>
<span key={index} className="text-sm">
{text || 'line breaks'}
</span>
</div>
))}
{suggestion.type === 'replace' && (
<div className="flex flex-col gap-2">
{suggestionText2Array(suggestion.newText!).map(
(text, index) => (
<React.Fragment key={index}>
<div
key={index}
className="flex items-start gap-2 text-brand/80"
>
<span className="text-sm">with:</span>
<span className="text-sm">{text || 'line breaks'}</span>
</div>
</React.Fragment>
)
)}
{suggestionText2Array(suggestion.text!).map((text, index) => (
<React.Fragment key={index}>
<div key={index} className="flex items-start gap-2">
<span className="text-muted-foreground text-sm">
{index === 0 ? 'Replace:' : 'Delete:'}
</span>
<span className="text-sm">{text || 'line breaks'}</span>
</div>
</React.Fragment>
))}
</div>
)}
{suggestion.type === 'update' && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">
{Object.keys(suggestion.properties).map((key) => (
<span key={key}>Un{key}</span>
))}
{Object.keys(suggestion.newProperties).map((key) => (
<span key={key}>
{key.charAt(0).toUpperCase() + key.slice(1)}
</span>
))}
</span>
<span className="text-sm">{suggestion.newText}</span>
</div>
)}
</div>
</div>
{suggestion.comments.map((comment, index) => (
<Comment
key={comment.id ?? index}
comment={comment}
discussionLength={suggestion.comments.length}
documentContent="__suggestion__"
editingId={editingId}
index={index}
setEditingId={setEditingId}
/>
))}
{hovering && (
<div className="absolute top-4 right-4 flex gap-2">
<Button
variant="ghost"
className="size-6 p-1 text-muted-foreground"
onClick={() => accept(suggestion)}
>
<CheckIcon className="size-4" />
</Button>
<Button
variant="ghost"
className="size-6 p-1 text-muted-foreground"
onClick={() => reject(suggestion)}
>
<XIcon className="size-4" />
</Button>
</div>
)}
<CommentCreateForm discussionId={suggestion.suggestionId} />
</div>
{!isLast && <div className="h-px w-full bg-muted" />}
</div>
);
}
export const isResolvedSuggestion = (
suggestion: ResolvedSuggestion | TDiscussion
): suggestion is ResolvedSuggestion => 'suggestionId' in suggestion;
src/registry/ui/comment.tsx
'use client';
import * as React from 'react';
import type { CreatePlateEditorOptions } from 'platejs/react';
import { getCommentKey, getDraftCommentKey } from '@platejs/comment';
import { CommentPlugin, useCommentId } from '@platejs/comment/react';
import {
differenceInDays,
differenceInHours,
differenceInMinutes,
format,
} from 'date-fns';
import {
ArrowUpIcon,
CheckIcon,
MoreHorizontalIcon,
PencilIcon,
TrashIcon,
XIcon,
} from 'lucide-react';
import {
type NodeEntry,
type TCommentText,
type Value,
KEYS,
nanoid,
NodeApi,
} from 'platejs';
import {
Plate,
useEditorPlugin,
useEditorRef,
usePlateEditor,
usePluginOption,
} from 'platejs/react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { BasicMarksKit } from '@/registry/components/editor/plugins/basic-marks-kit';
import {
type TDiscussion,
discussionPlugin,
} from '@/registry/components/editor/plugins/discussion-kit';
import { Editor, EditorContainer } from './editor';
export type TComment = {
id: string;
contentRich: Value;
createdAt: Date;
discussionId: string;
isEdited: boolean;
userId: string;
};
export function Comment(props: {
comment: TComment;
discussionLength: number;
editingId: string | null;
index: number;
setEditingId: React.Dispatch<React.SetStateAction<string | null>>;
documentContent?: string;
showDocumentContent?: boolean;
onEditorClick?: () => void;
}) {
const {
comment,
discussionLength,
documentContent,
editingId,
index,
setEditingId,
showDocumentContent = false,
onEditorClick,
} = props;
const editor = useEditorRef();
const userInfo = usePluginOption(discussionPlugin, 'user', comment.userId);
const currentUserId = usePluginOption(discussionPlugin, 'currentUserId');
const resolveDiscussion = async (id: string) => {
const updatedDiscussions = editor
.getOption(discussionPlugin, 'discussions')
.map((discussion) => {
if (discussion.id === id) {
return { ...discussion, isResolved: true };
}
return discussion;
});
editor.setOption(discussionPlugin, 'discussions', updatedDiscussions);
};
const removeDiscussion = async (id: string) => {
const updatedDiscussions = editor
.getOption(discussionPlugin, 'discussions')
.filter((discussion) => discussion.id !== id);
editor.setOption(discussionPlugin, 'discussions', updatedDiscussions);
};
const updateComment = async (input: {
id: string;
contentRich: Value;
discussionId: string;
isEdited: boolean;
}) => {
const updatedDiscussions = editor
.getOption(discussionPlugin, 'discussions')
.map((discussion) => {
if (discussion.id === input.discussionId) {
const updatedComments = discussion.comments.map((comment) => {
if (comment.id === input.id) {
return {
...comment,
contentRich: input.contentRich,
isEdited: true,
updatedAt: new Date(),
};
}
return comment;
});
return { ...discussion, comments: updatedComments };
}
return discussion;
});
editor.setOption(discussionPlugin, 'discussions', updatedDiscussions);
};
const { tf } = useEditorPlugin(CommentPlugin);
// Replace to your own backend or refer to potion
const isMyComment = currentUserId === comment.userId;
const initialValue = comment.contentRich;
const commentEditor = useCommentEditor(
{
id: comment.id,
value: initialValue,
},
[initialValue]
);
const onCancel = () => {
setEditingId(null);
commentEditor.tf.replaceNodes(initialValue, {
at: [],
children: true,
});
};
const onSave = () => {
void updateComment({
id: comment.id,
contentRich: commentEditor.children,
discussionId: comment.discussionId,
isEdited: true,
});
setEditingId(null);
};
const onResolveComment = () => {
void resolveDiscussion(comment.discussionId);
tf.comment.unsetMark({ id: comment.discussionId });
};
const isFirst = index === 0;
const isLast = index === discussionLength - 1;
const isEditing = editingId && editingId === comment.id;
const [hovering, setHovering] = React.useState(false);
const [dropdownOpen, setDropdownOpen] = React.useState(false);
return (
<div
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
<div className="relative flex items-center">
<Avatar className="size-5">
<AvatarImage alt={userInfo?.name} src={userInfo?.avatarUrl} />
<AvatarFallback>{userInfo?.name?.[0]}</AvatarFallback>
</Avatar>
<h4 className="mx-2 font-semibold text-sm leading-none">
{/* Replace to your own backend or refer to potion */}
{userInfo?.name}
</h4>
<div className="text-muted-foreground/80 text-xs leading-none">
<span className="mr-1">
{formatCommentDate(new Date(comment.createdAt))}
</span>
{comment.isEdited && <span>(edited)</span>}
</div>
{isMyComment && (hovering || dropdownOpen) && (
<div className="absolute top-0 right-0 flex space-x-1">
{index === 0 && (
<Button
variant="ghost"
className="h-6 p-1 text-muted-foreground"
onClick={onResolveComment}
type="button"
>
<CheckIcon className="size-4" />
</Button>
)}
<CommentMoreDropdown
onCloseAutoFocus={() => {
setTimeout(() => {
commentEditor.tf.focus({ edge: 'endEditor' });
}, 0);
}}
onRemoveComment={() => {
if (discussionLength === 1) {
tf.comment.unsetMark({ id: comment.discussionId });
void removeDiscussion(comment.discussionId);
}
}}
comment={comment}
dropdownOpen={dropdownOpen}
setDropdownOpen={setDropdownOpen}
setEditingId={setEditingId}
/>
</div>
)}
</div>
{isFirst && showDocumentContent && (
<div className="relative mt-1 flex pl-[32px] text-sm text-subtle-foreground">
{discussionLength > 1 && (
<div className="absolute top-[5px] left-3 h-full w-0.5 shrink-0 bg-muted" />
)}
<div className="my-px w-0.5 shrink-0 bg-highlight" />
{documentContent && <div className="ml-2">{documentContent}</div>}
</div>
)}
<div className="relative my-1 pl-[26px]">
{!isLast && (
<div className="absolute top-0 left-3 h-full w-0.5 shrink-0 bg-muted" />
)}
<Plate readOnly={!isEditing} editor={commentEditor}>
<EditorContainer variant="comment">
<Editor
variant="comment"
className="w-auto grow"
onClick={() => onEditorClick?.()}
/>
{isEditing && (
<div className="ml-auto flex shrink-0 gap-1">
<Button
size="icon"
variant="ghost"
className="size-[28px]"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
void onCancel();
}}
>
<div className="flex size-5 shrink-0 items-center justify-center rounded-[50%] bg-primary/40">
<XIcon className="size-3 stroke-[3px] text-background" />
</div>
</Button>
<Button
size="icon"
variant="ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
void onSave();
}}
>
<div className="flex size-5 shrink-0 items-center justify-center rounded-[50%] bg-brand">
<CheckIcon className="size-3 stroke-[3px] text-background" />
</div>
</Button>
</div>
)}
</EditorContainer>
</Plate>
</div>
</div>
);
}
function CommentMoreDropdown(props: {
comment: TComment;
dropdownOpen: boolean;
setDropdownOpen: React.Dispatch<React.SetStateAction<boolean>>;
setEditingId: React.Dispatch<React.SetStateAction<string | null>>;
onCloseAutoFocus?: () => void;
onRemoveComment?: () => void;
}) {
const {
comment,
dropdownOpen,
setDropdownOpen,
setEditingId,
onCloseAutoFocus,
onRemoveComment,
} = props;
const editor = useEditorRef();
const selectedEditCommentRef = React.useRef<boolean>(false);
const onDeleteComment = React.useCallback(() => {
if (!comment.id)
return alert('You are operating too quickly, please try again later.');
// Find and update the discussion
const updatedDiscussions = editor
.getOption(discussionPlugin, 'discussions')
.map((discussion) => {
if (discussion.id !== comment.discussionId) {
return discussion;
}
const commentIndex = discussion.comments.findIndex(
(c) => c.id === comment.id
);
if (commentIndex === -1) {
return discussion;
}
return {
...discussion,
comments: [
...discussion.comments.slice(0, commentIndex),
...discussion.comments.slice(commentIndex + 1),
],
};
});
// Save back to session storage
editor.setOption(discussionPlugin, 'discussions', updatedDiscussions);
onRemoveComment?.();
}, [comment.discussionId, comment.id, editor, onRemoveComment]);
const onEditComment = React.useCallback(() => {
selectedEditCommentRef.current = true;
if (!comment.id)
return alert('You are operating too quickly, please try again later.');
setEditingId(comment.id);
}, [comment.id, setEditingId]);
return (
<DropdownMenu
open={dropdownOpen}
onOpenChange={setDropdownOpen}
modal={false}
>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" className={cn('h-6 p-1 text-muted-foreground')}>
<MoreHorizontalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48"
onCloseAutoFocus={(e) => {
if (selectedEditCommentRef.current) {
onCloseAutoFocus?.();
selectedEditCommentRef.current = false;
}
return e.preventDefault();
}}
>
<DropdownMenuGroup>
<DropdownMenuItem onClick={onEditComment}>
<PencilIcon className="size-4" />
Edit comment
</DropdownMenuItem>
<DropdownMenuItem onClick={onDeleteComment}>
<TrashIcon className="size-4" />
Delete comment
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
const useCommentEditor = (
options: Omit<CreatePlateEditorOptions, 'plugins'> = {},
deps: any[] = []
) => {
const commentEditor = usePlateEditor(
{
id: 'comment',
plugins: BasicMarksKit,
value: [],
...options,
},
deps
);
return commentEditor;
};
export function CommentCreateForm({
autoFocus = false,
className,
discussionId: discussionIdProp,
focusOnMount = false,
}: {
autoFocus?: boolean;
className?: string;
discussionId?: string;
focusOnMount?: boolean;
}) {
const discussions = usePluginOption(discussionPlugin, 'discussions');
const editor = useEditorRef();
const commentId = useCommentId();
const discussionId = discussionIdProp ?? commentId;
const userInfo = usePluginOption(discussionPlugin, 'currentUser');
const [commentValue, setCommentValue] = React.useState<Value | undefined>();
const commentContent = React.useMemo(
() =>
commentValue
? NodeApi.string({ children: commentValue, type: KEYS.p })
: '',
[commentValue]
);
const commentEditor = useCommentEditor();
React.useEffect(() => {
if (commentEditor && focusOnMount) {
commentEditor.tf.focus();
}
}, [commentEditor, focusOnMount]);
const onAddComment = React.useCallback(async () => {
if (!commentValue) return;
commentEditor.tf.reset();
if (discussionId) {
// Get existing discussion
const discussion = discussions.find((d) => d.id === discussionId);
if (!discussion) {
// Mock creating suggestion
const newDiscussion: TDiscussion = {
id: discussionId,
comments: [
{
id: nanoid(),
contentRich: commentValue,
createdAt: new Date(),
discussionId,
isEdited: false,
userId: editor.getOption(discussionPlugin, 'currentUserId'),
},
],
createdAt: new Date(),
isResolved: false,
userId: editor.getOption(discussionPlugin, 'currentUserId'),
};
editor.setOption(discussionPlugin, 'discussions', [
...discussions,
newDiscussion,
]);
return;
}
// Create reply comment
const comment: TComment = {
id: nanoid(),
contentRich: commentValue,
createdAt: new Date(),
discussionId,
isEdited: false,
userId: editor.getOption(discussionPlugin, 'currentUserId'),
};
// Add reply to discussion comments
const updatedDiscussion = {
...discussion,
comments: [...discussion.comments, comment],
};
// Filter out old discussion and add updated one
const updatedDiscussions = discussions
.filter((d) => d.id !== discussionId)
.concat(updatedDiscussion);
editor.setOption(discussionPlugin, 'discussions', updatedDiscussions);
return;
}
const commentsNodeEntry = editor
.getApi(CommentPlugin)
.comment.nodes({ at: [], isDraft: true });
if (commentsNodeEntry.length === 0) return;
const documentContent = commentsNodeEntry
.map(([node, _path]: NodeEntry<TCommentText>) => node.text)
.join('');
const _discussionId = nanoid();
// Mock creating new discussion
const newDiscussion: TDiscussion = {
id: _discussionId,
comments: [
{
id: nanoid(),
contentRich: commentValue,
createdAt: new Date(),
discussionId: _discussionId,
isEdited: false,
userId: editor.getOption(discussionPlugin, 'currentUserId'),
},
],
createdAt: new Date(),
documentContent,
isResolved: false,
userId: editor.getOption(discussionPlugin, 'currentUserId'),
};
editor.setOption(discussionPlugin, 'discussions', [
...discussions,
newDiscussion,
]);
const id = newDiscussion.id;
commentsNodeEntry.forEach(([, path]: NodeEntry<TCommentText>) => {
editor.tf.setNodes(
{
[getCommentKey(id)]: true,
},
{ at: path, split: true }
);
editor.tf.unsetNodes([getDraftCommentKey()], { at: path });
});
}, [commentValue, commentEditor.tf, discussionId, editor, discussions]);
return (
<div className={cn('flex w-full', className)}>
<div className="mt-2 mr-1 shrink-0">
{/* Replace to your own backend or refer to potion */}
<Avatar className="size-5">
<AvatarImage alt={userInfo?.name} src={userInfo?.avatarUrl} />
<AvatarFallback>{userInfo?.name?.[0]}</AvatarFallback>
</Avatar>
</div>
<div className="relative flex grow gap-2">
<Plate
onChange={({ value }) => {
setCommentValue(value);
}}
editor={commentEditor}
>
<EditorContainer variant="comment">
<Editor
variant="comment"
className="min-h-[25px] grow pt-0.5 pr-8"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onAddComment();
}
}}
placeholder="Reply..."
autoComplete="off"
autoFocus={autoFocus}
/>
<Button
size="icon"
variant="ghost"
className="absolute right-0.5 bottom-0.5 ml-auto size-6 shrink-0"
disabled={commentContent.trim().length === 0}
onClick={(e) => {
e.stopPropagation();
onAddComment();
}}
>
<div className="flex size-6 items-center justify-center rounded-full">
<ArrowUpIcon />
</div>
</Button>
</EditorContainer>
</Plate>
</div>
</div>
);
}
export const formatCommentDate = (date: Date) => {
const now = new Date();
const diffMinutes = differenceInMinutes(now, date);
const diffHours = differenceInHours(now, date);
const diffDays = differenceInDays(now, date);
if (diffMinutes < 60) {
return `${diffMinutes}m`;
}
if (diffHours < 24) {
return `${diffHours}h`;
}
if (diffDays < 2) {
return `${diffDays}d`;
}
return format(date, 'MM/dd/yyyy');
};