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