返回 presentation-ai
block-discussion.tsx
根目录 / src / components / plate / ui / block-discussion.tsx
1 "use client";
2
3 import { getDraftCommentKey } from "@platejs/comment";
4 import { CommentPlugin } from "@platejs/comment/react";
5 import { getTransientSuggestionKey } from "@platejs/suggestion";
6 import { SuggestionPlugin } from "@platejs/suggestion/react";
7 import {
8 MessageSquareTextIcon,
9 MessagesSquareIcon,
10 PencilLineIcon,
11 } from "lucide-react";
12 import {
13 PathApi,
14 TextApi,
15 type AnyPluginConfig,
16 type NodeEntry,
17 type Path,
18 type TCommentText,
19 type TElement,
20 type TSuggestionText,
21 } from "platejs";
22 import {
23 useEditorPlugin,
24 useEditorRef,
25 usePluginOption,
26 type PlateElementProps,
27 type RenderNodeWrapper,
28 } from "platejs/react";
29 import * as React from "react";
30
31 import { commentPlugin } from "@/components/plate/plugins/comment-kit";
32 import {
33 discussionPlugin,
34 type TDiscussion,
35 } from "@/components/plate/plugins/discussion-kit";
36 import { suggestionPlugin } from "@/components/plate/plugins/suggestion-kit";
37 import {
38 Popover,
39 PopoverAnchor,
40 PopoverContent,
41 PopoverTrigger,
42 } from "@/components/plate/ui/popover";
43 import { Button } from "@/components/ui/button";
44 import { BlockSuggestionCard } from "./block-suggestion";
45 import {
46 isResolvedSuggestion,
47 useResolveSuggestion,
48 } from "./block-suggestion";
49 import { Comment, CommentCreateForm } from "./comment";
50
51 export const BlockDiscussion: RenderNodeWrapper<AnyPluginConfig> = (props) => {
52 const { editor, element } = props;
53
54 const commentsApi = editor.getApi(CommentPlugin).comment;
55 const blockPath = editor.api.findPath(element);
56
57 // avoid duplicate in table or column
58 if (!blockPath || blockPath.length > 1) return;
59
60 const draftCommentNode = commentsApi.node({ at: blockPath, isDraft: true });
61 const commentNodes = [...commentsApi.nodes({ at: blockPath })];
62
63 const suggestionNodes = [
64 ...editor.getApi(SuggestionPlugin).suggestion.nodes({ at: blockPath }),
65 ].filter(([node]) => !node[getTransientSuggestionKey()]);
66
67 if (
68 commentNodes.length === 0 &&
69 suggestionNodes.length === 0 &&
70 !draftCommentNode
71 ) {
72 return;
73 }
74
75 return (props) => (
76 <BlockCommentContent
77 blockPath={blockPath}
78 commentNodes={commentNodes}
79 draftCommentNode={draftCommentNode}
80 suggestionNodes={suggestionNodes}
81 {...props}
82 />
83 );
84 };
85
86 const BlockCommentContent = ({
87 blockPath,
88 children,
89 commentNodes,
90 draftCommentNode,
91 suggestionNodes,
92 }: PlateElementProps & {
93 blockPath: Path;
94 commentNodes: NodeEntry<TCommentText>[];
95 draftCommentNode: NodeEntry<TCommentText> | undefined;
96 suggestionNodes: NodeEntry<TElement | TSuggestionText>[];
97 }) => {
98 const editor = useEditorRef();
99 const resolvedSuggestions = useResolveSuggestion(suggestionNodes, blockPath);
100 const resolvedDiscussions = useResolvedDiscussion(commentNodes, blockPath);
101
102 const suggestionsCount = resolvedSuggestions.length;
103 const discussionsCount = resolvedDiscussions.length;
104 const totalCount = suggestionsCount + discussionsCount;
105
106 const activeSuggestionId = usePluginOption(suggestionPlugin, "activeId");
107 const activeSuggestion =
108 activeSuggestionId &&
109 resolvedSuggestions.find((s) => s.suggestionId === activeSuggestionId);
110
111 const commentingBlock = usePluginOption(commentPlugin, "commentingBlock");
112 const activeCommentId = usePluginOption(commentPlugin, "activeId");
113 const isCommenting = activeCommentId === getDraftCommentKey();
114 const activeDiscussion =
115 activeCommentId &&
116 resolvedDiscussions.find((d) => d.id === activeCommentId);
117
118 const noneActive = !activeSuggestion && !activeDiscussion;
119
120 const sortedMergedData = [
121 ...resolvedDiscussions,
122 ...resolvedSuggestions,
123 ].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
124
125 const selected =
126 resolvedDiscussions.some((d) => d.id === activeCommentId) ||
127 resolvedSuggestions.some((s) => s.suggestionId === activeSuggestionId);
128
129 const [_open, setOpen] = React.useState(selected);
130
131 // in some cases, we may comment the multiple blocks
132 const commentingCurrent =
133 !!commentingBlock && PathApi.equals(blockPath, commentingBlock);
134
135 const open =
136 _open ||
137 selected ||
138 (isCommenting && !!draftCommentNode && commentingCurrent);
139
140 const anchorElement = React.useMemo(() => {
141 let activeNode: NodeEntry | undefined;
142
143 if (activeSuggestion) {
144 activeNode = suggestionNodes.find(
145 ([node]) =>
146 TextApi.isText(node) &&
147 editor.getApi(SuggestionPlugin).suggestion.nodeId(node) ===
148 activeSuggestion.suggestionId,
149 );
150 }
151
152 if (activeCommentId) {
153 if (activeCommentId === getDraftCommentKey()) {
154 activeNode = draftCommentNode;
155 } else {
156 activeNode = commentNodes.find(
157 ([node]) =>
158 editor.getApi(commentPlugin).comment.nodeId(node) ===
159 activeCommentId,
160 );
161 }
162 }
163
164 if (!activeNode) return null;
165
166 return editor.api.toDOMNode(activeNode[0])!;
167 }, [
168 open,
169 activeSuggestion,
170 activeCommentId,
171 editor.api,
172 suggestionNodes,
173 draftCommentNode,
174 commentNodes,
175 ]);
176
177 if (suggestionsCount + resolvedDiscussions.length === 0 && !draftCommentNode)
178 return <div className="w-full">{children}</div>;
179
180 return (
181 <div className="flex w-full justify-between">
182 <Popover
183 open={open}
184 onOpenChange={(_open_) => {
185 if (!_open_ && isCommenting && draftCommentNode) {
186 editor.tf.unsetNodes(getDraftCommentKey(), {
187 at: [],
188 mode: "lowest",
189 match: (n) => n[getDraftCommentKey()],
190 });
191 }
192 setOpen(_open_);
193 }}
194 >
195 <div className="w-full">{children}</div>
196 {anchorElement && (
197 <PopoverAnchor
198 asChild
199 className="w-full"
200 virtualRef={{ current: anchorElement }}
201 />
202 )}
203
204 <PopoverContent
205 className="max-h-[min(50dvh,calc(-24px+var(--radix-popper-available-height)))] w-95 max-w-[calc(100vw-24px)] min-w-32.5 overflow-y-auto rounded-xl border-border/50 bg-popover/95 p-0 shadow-xl backdrop-blur-md data-[state=closed]:opacity-0"
206 onCloseAutoFocus={(e) => e.preventDefault()}
207 onOpenAutoFocus={(e) => e.preventDefault()}
208 align="center"
209 side="bottom"
210 sideOffset={10}
211 >
212 {isCommenting ? (
213 <CommentCreateForm className="p-4" focusOnMount variant="popover" />
214 ) : noneActive ? (
215 sortedMergedData.map((item, index) =>
216 isResolvedSuggestion(item) ? (
217 <BlockSuggestionCard
218 key={item.suggestionId}
219 idx={index}
220 isLast={index === sortedMergedData.length - 1}
221 suggestion={item}
222 />
223 ) : (
224 <BlockComment
225 key={item.id}
226 discussion={item}
227 isLast={index === sortedMergedData.length - 1}
228 />
229 ),
230 )
231 ) : (
232 <>
233 {activeSuggestion && (
234 <BlockSuggestionCard
235 key={activeSuggestion.suggestionId}
236 idx={0}
237 isLast={true}
238 suggestion={activeSuggestion}
239 />
240 )}
241
242 {activeDiscussion && (
243 <BlockComment discussion={activeDiscussion} isLast={true} />
244 )}
245 </>
246 )}
247 </PopoverContent>
248
249 {totalCount > 0 && (
250 <div className="relative left-0 size-0 select-none">
251 <PopoverTrigger asChild>
252 <Button
253 variant="ghost"
254 className="mt-1 ml-1 flex h-6 gap-1 rounded-md px-1.5! py-0 text-muted-foreground/70 transition-colors hover:text-muted-foreground data-[active=true]:bg-accent data-[active=true]:text-foreground"
255 data-active={open}
256 contentEditable={false}
257 >
258 {suggestionsCount > 0 && discussionsCount === 0 && (
259 <PencilLineIcon className="size-3.5 shrink-0" />
260 )}
261
262 {suggestionsCount === 0 && discussionsCount > 0 && (
263 <MessageSquareTextIcon className="size-3.5 shrink-0" />
264 )}
265
266 {suggestionsCount > 0 && discussionsCount > 0 && (
267 <MessagesSquareIcon className="size-3.5 shrink-0" />
268 )}
269
270 <span className="text-xs font-medium">{totalCount}</span>
271 </Button>
272 </PopoverTrigger>
273 </div>
274 )}
275 </Popover>
276 </div>
277 );
278 };
279
280 function BlockComment({
281 discussion,
282 isLast,
283 }: {
284 discussion: TDiscussion;
285 isLast: boolean;
286 }) {
287 const [editingId, setEditingId] = React.useState<string | null>(null);
288
289 return (
290 <React.Fragment key={discussion.id}>
291 <div className="px-4 pt-3 pb-2">
292 {discussion.comments.map((comment, index) => (
293 <Comment
294 key={comment.id ?? index}
295 comment={comment}
296 discussionLength={discussion.comments.length}
297 documentContent={discussion?.documentContent}
298 editingId={editingId}
299 index={index}
300 setEditingId={setEditingId}
301 showDocumentContent
302 />
303 ))}
304 <div className="mt-1 ml-7.5">
305 <CommentCreateForm discussionId={discussion.id} />
306 </div>
307 </div>
308
309 {!isLast && <div className="mx-4 h-px bg-border/40" />}
310 </React.Fragment>
311 );
312 }
313
314 const useResolvedDiscussion = (
315 commentNodes: NodeEntry<TCommentText>[],
316 blockPath: Path,
317 ) => {
318 const { api, getOption, setOption } = useEditorPlugin(commentPlugin);
319 const discussions = usePluginOption(discussionPlugin, "discussions");
320
321 commentNodes.forEach(([node]) => {
322 const id = api.comment.nodeId(node);
323 const map = getOption("uniquePathMap");
324
325 if (!id) return;
326
327 const previousPath = map.get(id);
328
329 // If there are no comment nodes in the corresponding path in the map, then update it.
330 if (PathApi.isPath(previousPath)) {
331 const nodes = api.comment.node({ id, at: previousPath });
332
333 if (!nodes) {
334 setOption("uniquePathMap", new Map(map).set(id, blockPath));
335 return;
336 }
337
338 return;
339 }
340 // TODO: fix throw error
341 setOption("uniquePathMap", new Map(map).set(id, blockPath));
342 });
343
344 const commentsIds = new Set(
345 commentNodes.flatMap(([node]) => {
346 const nodeId = api.comment.nodeId(node);
347 return nodeId ? [nodeId] : [];
348 }),
349 );
350
351 const resolvedDiscussions = discussions
352 .map((d: TDiscussion) => ({
353 ...d,
354 createdAt: new Date(d.createdAt),
355 }))
356 .filter((item: TDiscussion) => {
357 /** If comment cross blocks just show it in the first block */
358 const commentsPathMap = getOption("uniquePathMap");
359 const firstBlockPath = commentsPathMap.get(item.id);
360
361 if (!firstBlockPath) return false;
362 if (!PathApi.equals(firstBlockPath, blockPath)) return false;
363
364 return (
365 api.comment.has({ id: item.id }) &&
366 commentsIds.has(item.id) &&
367 !item.isResolved
368 );
369 });
370
371 return resolvedDiscussions;
372 };
373
373 lines Plain Text