返回 presentation-ai
comment.tsx
根目录 / src / components / plate / ui / comment.tsx
1 "use client";
2
3 import { getCommentKey, getDraftCommentKey } from "@platejs/comment";
4 import { CommentPlugin, useCommentId } from "@platejs/comment/react";
5 import {
6 differenceInDays,
7 differenceInHours,
8 differenceInMinutes,
9 format,
10 } from "date-fns";
11 import {
12 ArrowUpIcon,
13 CheckIcon,
14 MoreHorizontalIcon,
15 PencilIcon,
16 TrashIcon,
17 XIcon,
18 } from "lucide-react";
19 import {
20 KEYS,
21 nanoid,
22 NodeApi,
23 type NodeEntry,
24 type TCommentText,
25 type Value,
26 } from "platejs";
27 import {
28 Plate,
29 useEditorPlugin,
30 useEditorRef,
31 usePlateEditor,
32 usePluginOption,
33 type CreatePlateEditorOptions,
34 } from "platejs/react";
35 import * as React from "react";
36
37 import { BasicMarksKit } from "@/components/plate/plugins/basic-marks-kit";
38 import {
39 discussionPlugin,
40 updateDiscussionState,
41 type TDiscussion,
42 } from "@/components/plate/plugins/discussion-kit";
43 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
44 import {
45 DropdownMenu,
46 DropdownMenuContent,
47 DropdownMenuGroup,
48 DropdownMenuItem,
49 DropdownMenuTrigger,
50 } from "@/components/ui/dropdown-menu";
51 import { type DiscussionUser } from "@/lib/notes/discussions";
52 import { cn } from "@/lib/utils";
53 import { Editor, EditorContainer } from "./editor";
54
55 export type TComment = {
56 id: string;
57 contentRich: Value;
58 createdAt: Date | string;
59 discussionId: string;
60 isEdited: boolean;
61 updatedAt?: Date | string;
62 user?: DiscussionUser;
63 userId: string;
64 };
65
66 export function Comment(props: {
67 comment: TComment;
68 discussionLength: number;
69 editingId: string | null;
70 index: number;
71 setEditingId: React.Dispatch<React.SetStateAction<string | null>>;
72 documentContent?: string;
73 showDocumentContent?: boolean;
74 onEditorClick?: () => void;
75 }) {
76 const {
77 comment,
78 discussionLength,
79 documentContent,
80 editingId,
81 index,
82 setEditingId,
83 showDocumentContent = false,
84 onEditorClick,
85 } = props;
86
87 const editor = useEditorRef();
88 const userInfo = usePluginOption(discussionPlugin, "user", comment.userId);
89 const currentUserId = usePluginOption(discussionPlugin, "currentUserId");
90 const resolveDiscussion = async (id: string) => {
91 const updatedDiscussions = editor
92 .getOption(discussionPlugin, "discussions")
93 .map((discussion) => {
94 if (discussion.id === id) {
95 return { ...discussion, isResolved: true };
96 }
97 return discussion;
98 });
99 updateDiscussionState(editor, updatedDiscussions);
100 };
101
102 const removeDiscussion = async (id: string) => {
103 const updatedDiscussions = editor
104 .getOption(discussionPlugin, "discussions")
105 .filter((discussion) => discussion.id !== id);
106 updateDiscussionState(editor, updatedDiscussions);
107 };
108
109 const updateComment = async (input: {
110 id: string;
111 contentRich: Value;
112 discussionId: string;
113 }) => {
114 const updatedDiscussions = editor
115 .getOption(discussionPlugin, "discussions")
116 .map((discussion) => {
117 if (discussion.id === input.discussionId) {
118 const updatedComments = discussion.comments.map((comment) => {
119 if (comment.id === input.id) {
120 return {
121 ...comment,
122 contentRich: input.contentRich,
123 isEdited: true,
124 updatedAt: new Date(),
125 };
126 }
127 return comment;
128 });
129 return { ...discussion, comments: updatedComments };
130 }
131 return discussion;
132 });
133 updateDiscussionState(editor, updatedDiscussions);
134 };
135
136 const { tf } = useEditorPlugin(CommentPlugin);
137
138 const isMyComment = currentUserId === comment.userId;
139
140 const initialValue = comment.contentRich;
141
142 const commentEditor = useCommentEditor(
143 {
144 id: comment.id,
145 value: initialValue,
146 },
147 [initialValue],
148 );
149
150 const onCancel = () => {
151 setEditingId(null);
152 commentEditor.tf.replaceNodes(initialValue, {
153 at: [],
154 children: true,
155 });
156 };
157
158 const onSave = () => {
159 void updateComment({
160 id: comment.id,
161 contentRich: commentEditor.children,
162 discussionId: comment.discussionId,
163 });
164 setEditingId(null);
165 };
166
167 const onResolveComment = () => {
168 void resolveDiscussion(comment.discussionId);
169 tf.comment.unsetMark({ id: comment.discussionId });
170 };
171
172 const isFirst = index === 0;
173 const isLast = index === discussionLength - 1;
174 const isEditing = editingId && editingId === comment.id;
175
176 const [hovering, setHovering] = React.useState(false);
177 const [dropdownOpen, setDropdownOpen] = React.useState(false);
178
179 return (
180 <div
181 className={cn(
182 "group/comment relative rounded-lg px-1 py-1.5 transition-colors",
183 isFirst && "pt-0",
184 )}
185 onMouseEnter={() => setHovering(true)}
186 onMouseLeave={() => setHovering(false)}
187 >
188 {/* Thread connector */}
189 {!isFirst && !isLast && (
190 <div className="absolute top-0 left-4.25 h-full w-px bg-border/60" />
191 )}
192 {!isFirst && isLast && (
193 <div className="absolute top-0 left-4.25 h-4 w-px bg-border/60" />
194 )}
195 {isFirst && !isLast && (
196 <div className="absolute top-8 bottom-0 left-4.25 w-px bg-border/60" />
197 )}
198
199 {/* Header row */}
200 <div className="relative flex items-center gap-2">
201 <Avatar className="size-5.5 shrink-0 ring ring-border/50">
202 <AvatarImage
203 alt={userInfo?.name ?? undefined}
204 src={userInfo?.avatarUrl ?? undefined}
205 />
206 <AvatarFallback className="text-[10px] font-medium">
207 {userInfo?.name?.[0]}
208 </AvatarFallback>
209 </Avatar>
210
211 <span className="text-[13px] leading-none font-semibold text-foreground/90">
212 {userInfo?.name}
213 </span>
214
215 <span className="text-[11px] leading-none text-muted-foreground/60">
216 {formatCommentDate(new Date(comment.createdAt))}
217 {comment.isEdited && <span className="ml-1 italic">(edited)</span>}
218 </span>
219
220 {/* Hover actions */}
221 {isMyComment && (hovering || dropdownOpen) && (
222 <div className="absolute -top-0.5 right-0 flex items-center gap-0.5 rounded-md border border-border/50 bg-popover/95 p-0.5 shadow backdrop-blur-sm">
223 {index === 0 && (
224 <button
225 className="flex size-6 items-center justify-center rounded-[5px] text-muted-foreground transition-colors hover:bg-accent hover:text-emerald-500"
226 onClick={onResolveComment}
227 title="Resolve"
228 type="button"
229 >
230 <CheckIcon className="size-3.5" />
231 </button>
232 )}
233
234 <CommentMoreDropdown
235 onCloseAutoFocus={() => {
236 setTimeout(() => {
237 commentEditor.tf.focus({ edge: "endEditor" });
238 }, 0);
239 }}
240 onRemoveComment={() => {
241 if (discussionLength === 1) {
242 tf.comment.unsetMark({ id: comment.discussionId });
243 void removeDiscussion(comment.discussionId);
244 }
245 }}
246 comment={comment}
247 dropdownOpen={dropdownOpen}
248 setDropdownOpen={setDropdownOpen}
249 setEditingId={setEditingId}
250 />
251 </div>
252 )}
253 </div>
254
255 {/* Quoted document content */}
256 {isFirst && showDocumentContent && documentContent && (
257 <div className="mt-1.5 ml-7.5 flex items-center">
258 <div className="w-0.5 shrink-0 self-stretch rounded-full bg-highlight/60" />
259 <div
260 className="min-w-0 truncate pl-2 text-[12px] leading-none text-muted-foreground/70"
261 title={documentContent}
262 >
263 {documentContent}
264 </div>
265 </div>
266 )}
267
268 {/* Comment body */}
269 <div className="mt-0.5 ml-7.5">
270 <Plate readOnly={!isEditing} editor={commentEditor}>
271 <EditorContainer
272 variant="comment"
273 className={cn(
274 isEditing && "rounded-lg border-border/60 bg-muted/30",
275 )}
276 >
277 <Editor
278 variant="comment"
279 className="w-auto grow text-foreground/85"
280 onClick={() => onEditorClick?.()}
281 />
282
283 {isEditing && (
284 <div className="ml-auto flex shrink-0 items-end gap-1 pb-0.5">
285 <button
286 type="button"
287 className="flex size-6 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
288 onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
289 e.stopPropagation();
290 void onCancel();
291 }}
292 >
293 <XIcon className="size-3.5" />
294 </button>
295
296 <button
297 type="button"
298 className="flex size-6 items-center justify-center rounded-full bg-brand text-background transition-opacity hover:opacity-90"
299 onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
300 e.stopPropagation();
301 void onSave();
302 }}
303 >
304 <CheckIcon className="size-3 stroke-[2.5px]" />
305 </button>
306 </div>
307 )}
308 </EditorContainer>
309 </Plate>
310 </div>
311 </div>
312 );
313 }
314
315 function CommentMoreDropdown(props: {
316 comment: TComment;
317 dropdownOpen: boolean;
318 setDropdownOpen: React.Dispatch<React.SetStateAction<boolean>>;
319 setEditingId: React.Dispatch<React.SetStateAction<string | null>>;
320 onCloseAutoFocus?: () => void;
321 onRemoveComment?: () => void;
322 }) {
323 const {
324 comment,
325 dropdownOpen,
326 setDropdownOpen,
327 setEditingId,
328 onCloseAutoFocus,
329 onRemoveComment,
330 } = props;
331
332 const editor = useEditorRef();
333
334 const selectedEditCommentRef = React.useRef<boolean>(false);
335
336 const onDeleteComment = React.useCallback(() => {
337 if (!comment.id)
338 return alert("You are operating too quickly, please try again later.");
339
340 const updatedDiscussions = editor
341 .getOption(discussionPlugin, "discussions")
342 .map((discussion) => {
343 if (discussion.id !== comment.discussionId) {
344 return discussion;
345 }
346
347 const commentIndex = discussion.comments.findIndex(
348 (c) => c.id === comment.id,
349 );
350 if (commentIndex === -1) {
351 return discussion;
352 }
353
354 return {
355 ...discussion,
356 comments: [
357 ...discussion.comments.slice(0, commentIndex),
358 ...discussion.comments.slice(commentIndex + 1),
359 ],
360 };
361 });
362
363 updateDiscussionState(editor, updatedDiscussions);
364 onRemoveComment?.();
365 }, [comment.discussionId, comment.id, editor, onRemoveComment]);
366
367 const onEditComment = React.useCallback(() => {
368 selectedEditCommentRef.current = true;
369
370 if (!comment.id)
371 return alert("You are operating too quickly, please try again later.");
372
373 setEditingId(comment.id);
374 }, [comment.id, setEditingId]);
375
376 return (
377 <DropdownMenu
378 open={dropdownOpen}
379 onOpenChange={setDropdownOpen}
380 modal={false}
381 >
382 <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
383 <button className="flex size-6 items-center justify-center rounded-[5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">
384 <MoreHorizontalIcon className="size-3.5" />
385 </button>
386 </DropdownMenuTrigger>
387 <DropdownMenuContent
388 className="w-40 rounded-lg"
389 onCloseAutoFocus={(e) => {
390 if (selectedEditCommentRef.current) {
391 onCloseAutoFocus?.();
392 selectedEditCommentRef.current = false;
393 }
394
395 return e.preventDefault();
396 }}
397 >
398 <DropdownMenuGroup>
399 <DropdownMenuItem onClick={onEditComment}>
400 <PencilIcon className="size-3.5" />
401 Edit
402 </DropdownMenuItem>
403 <DropdownMenuItem
404 className="text-destructive focus:text-destructive"
405 onClick={onDeleteComment}
406 >
407 <TrashIcon className="size-3.5" />
408 Delete
409 </DropdownMenuItem>
410 </DropdownMenuGroup>
411 </DropdownMenuContent>
412 </DropdownMenu>
413 );
414 }
415
416 const useCommentEditor = (
417 options: Omit<CreatePlateEditorOptions, "plugins"> = {},
418 deps: React.DependencyList = [],
419 ) => {
420 const commentEditor = usePlateEditor(
421 {
422 id: "comment",
423 plugins: BasicMarksKit,
424 value: [],
425 ...options,
426 },
427 deps,
428 );
429
430 return commentEditor;
431 };
432
433 export function CommentCreateForm({
434 autoFocus = false,
435 className,
436 discussionId: discussionIdProp,
437 focusOnMount = false,
438 variant = "inline",
439 }: {
440 autoFocus?: boolean;
441 className?: string;
442 discussionId?: string;
443 focusOnMount?: boolean;
444 variant?: "inline" | "popover";
445 }) {
446 const discussions = usePluginOption(discussionPlugin, "discussions");
447
448 const editor = useEditorRef();
449 const commentId = useCommentId();
450 const discussionId = discussionIdProp ?? commentId;
451
452 const currentUser = usePluginOption(discussionPlugin, "currentUser");
453 const currentUserId = usePluginOption(discussionPlugin, "currentUserId");
454 const [commentValue, setCommentValue] = React.useState<Value | undefined>();
455 const commentContent = React.useMemo(
456 () =>
457 commentValue
458 ? NodeApi.string({ children: commentValue, type: KEYS.p })
459 : "",
460 [commentValue],
461 );
462 const commentEditor = useCommentEditor();
463
464 React.useEffect(() => {
465 if (commentEditor && focusOnMount) {
466 commentEditor.tf.focus();
467 }
468 }, [commentEditor, focusOnMount]);
469
470 const onAddComment = React.useCallback(async () => {
471 if (!commentValue || !currentUserId) return;
472
473 commentEditor.tf.reset();
474
475 if (discussionId) {
476 const discussion = discussions.find((d) => d.id === discussionId);
477 if (!discussion) {
478 const newDiscussion: TDiscussion = {
479 id: discussionId,
480 comments: [
481 {
482 id: nanoid(),
483 contentRich: commentValue,
484 createdAt: new Date(),
485 discussionId,
486 isEdited: false,
487 user: currentUser,
488 userId: currentUserId,
489 },
490 ],
491 createdAt: new Date(),
492 isResolved: false,
493 user: currentUser,
494 userId: currentUserId,
495 };
496
497 updateDiscussionState(editor, [...discussions, newDiscussion]);
498 return;
499 }
500
501 const comment: TComment = {
502 id: nanoid(),
503 contentRich: commentValue,
504 createdAt: new Date(),
505 discussionId,
506 isEdited: false,
507 user: currentUser,
508 userId: currentUserId,
509 };
510
511 const updatedDiscussion = {
512 ...discussion,
513 comments: [...discussion.comments, comment],
514 };
515
516 const updatedDiscussions = discussions
517 .filter((d) => d.id !== discussionId)
518 .concat(updatedDiscussion);
519
520 updateDiscussionState(editor, updatedDiscussions);
521
522 return;
523 }
524
525 const commentsNodeEntry = editor
526 .getApi(CommentPlugin)
527 .comment.nodes({ at: [], isDraft: true });
528
529 if (commentsNodeEntry.length === 0) return;
530
531 const documentContent = commentsNodeEntry
532 .map(([node, _path]: NodeEntry<TCommentText>) => node.text)
533 .join("");
534
535 const _discussionId = nanoid();
536 const newDiscussion: TDiscussion = {
537 id: _discussionId,
538 comments: [
539 {
540 id: nanoid(),
541 contentRich: commentValue,
542 createdAt: new Date(),
543 discussionId: _discussionId,
544 isEdited: false,
545 user: currentUser,
546 userId: currentUserId,
547 },
548 ],
549 createdAt: new Date(),
550 documentContent,
551 isResolved: false,
552 user: currentUser,
553 userId: currentUserId,
554 };
555
556 updateDiscussionState(editor, [...discussions, newDiscussion]);
557
558 const id = newDiscussion.id;
559
560 commentsNodeEntry.forEach(([, path]: NodeEntry<TCommentText>) => {
561 editor.tf.setNodes(
562 {
563 [getCommentKey(id)]: true,
564 },
565 { at: path, split: true },
566 );
567 editor.tf.unsetNodes([getDraftCommentKey()], { at: path });
568 });
569 }, [
570 commentValue,
571 commentEditor.tf,
572 currentUser,
573 currentUserId,
574 discussionId,
575 editor,
576 discussions,
577 ]);
578
579 const hasContent = commentContent.trim().length > 0;
580
581 return (
582 <div
583 className={cn("w-full", variant === "popover" && "space-y-3", className)}
584 >
585 {variant === "popover" && (
586 <div className="space-y-2.5">
587 <div className="text-[11px] font-semibold tracking-widest text-muted-foreground/70 uppercase">
588 Add comment
589 </div>
590 </div>
591 )}
592
593 <div className="flex w-full items-start gap-2">
594 <div
595 className={cn("shrink-0", variant === "popover" ? "mt-1.5" : "mt-1")}
596 >
597 <Avatar className="size-5.5 ring ring-border/50">
598 <AvatarImage
599 alt={currentUser?.name ?? undefined}
600 src={currentUser?.avatarUrl ?? undefined}
601 />
602 <AvatarFallback className="text-[10px] font-medium">
603 {currentUser?.name?.[0]}
604 </AvatarFallback>
605 </Avatar>
606 </div>
607
608 <div className="relative flex min-w-0 grow">
609 <Plate
610 onChange={({ value }) => {
611 setCommentValue(value);
612 }}
613 editor={commentEditor}
614 >
615 <EditorContainer
616 variant="comment"
617 className={cn(
618 variant === "popover" &&
619 "rounded-lg border-border/60 bg-muted/20 shadow transition-colors focus-within:bg-muted/30",
620 )}
621 >
622 <Editor
623 variant="comment"
624 className={cn(
625 "grow",
626 variant === "popover"
627 ? "min-h-9 pt-1.5 pr-9"
628 : "min-h-6 pt-0.5 pr-7",
629 )}
630 onKeyDown={(e) => {
631 if (e.key === "Enter" && !e.shiftKey) {
632 e.preventDefault();
633 onAddComment();
634 }
635 }}
636 placeholder={
637 variant === "popover" ? "Write a comment..." : "Reply..."
638 }
639 autoComplete="off"
640 autoFocus={autoFocus}
641 />
642
643 <button
644 className={cn(
645 "absolute top-1/2 right-1 flex size-6 -translate-y-1/2 items-center justify-center rounded-full transition-all",
646 hasContent && currentUserId
647 ? "bg-brand text-background shadow hover:opacity-90"
648 : "text-muted-foreground/40",
649 )}
650 disabled={!currentUserId || !hasContent}
651 onClick={(e) => {
652 e.stopPropagation();
653 onAddComment();
654 }}
655 >
656 <ArrowUpIcon className="size-3.5 stroke-[2.5px]" />
657 </button>
658 </EditorContainer>
659 </Plate>
660 </div>
661 </div>
662 </div>
663 );
664 }
665
666 export const formatCommentDate = (date: Date) => {
667 const now = new Date();
668 const diffMinutes = differenceInMinutes(now, date);
669 const diffHours = differenceInHours(now, date);
670 const diffDays = differenceInDays(now, date);
671
672 if (diffMinutes < 60) {
673 return `${diffMinutes}m`;
674 }
675 if (diffHours < 24) {
676 return `${diffHours}h`;
677 }
678 if (diffDays < 2) {
679 return `${diffDays}d`;
680 }
681
682 return format(date, "MM/dd/yyyy");
683 };
684
684 lines Plain Text