buster/apps/web/src/components/ui/report/elements/BlockDiscussion.tsx

329 lines
11 KiB
TypeScript

'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 { SuggestionPlugin } from '@platejs/suggestion/react';
import { TextArea, Message, Pencil } from '@/components/ui/icons';
import {
type AnyPluginConfig,
type NodeEntry,
type Path,
type TCommentText,
type TElement,
type TSuggestionText,
PathApi,
TextApi
} from 'platejs';
import { useEditorPlugin, useEditorRef, usePluginOption } from 'platejs/react';
import { Button } from '@/components/ui/buttons';
import {
PopoverBase,
PopoverAnchor,
PopoverContent,
PopoverTrigger
} from '@/components/ui/popover';
import { commentPlugin } from '../plugins/comment-kit';
import { type TDiscussion, discussionPlugin } from '../plugins/discussion-kit';
import { suggestionPlugin } from '../plugins/suggestion-kit';
import { BlockSuggestionCard, isResolvedSuggestion, useResolveSuggestion } from './BlockSuggestion';
import { Comment, CommentCreateForm } from './Comment';
export const BlockDiscussion: RenderNodeWrapper<AnyPluginConfig> = (props) => {
const { editor, element } = props;
const commentsApi = editor.getApi(CommentPlugin).comment;
const blockPath = editor.api.findPath(element);
// avoid duplicate in table or column
if (!blockPath || blockPath.length > 1) return;
const draftCommentNode = commentsApi.node({ at: blockPath, isDraft: true });
const commentNodes = [...commentsApi.nodes({ at: blockPath })];
const suggestionNodes = [...editor.getApi(SuggestionPlugin).suggestion.nodes({ at: blockPath })];
if (commentNodes.length === 0 && suggestionNodes.length === 0 && !draftCommentNode) {
return;
}
return (props) => (
<BlockCommentContent
blockPath={blockPath}
commentNodes={commentNodes}
draftCommentNode={draftCommentNode}
suggestionNodes={suggestionNodes}
{...props}
/>
);
};
const BlockCommentContent = ({
blockPath,
children,
commentNodes,
draftCommentNode,
suggestionNodes
}: PlateElementProps & {
blockPath: Path;
commentNodes: NodeEntry<TCommentText>[];
draftCommentNode: NodeEntry<TCommentText> | undefined;
suggestionNodes: NodeEntry<TElement | TSuggestionText>[];
}) => {
const editor = useEditorRef();
const resolvedSuggestions = useResolveSuggestion(suggestionNodes, blockPath);
const resolvedDiscussions = useResolvedDiscussion(commentNodes, 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 = React.useMemo(() => {
let activeNode: NodeEntry | undefined;
if (activeSuggestion) {
activeNode = suggestionNodes.find(
([node]) =>
TextApi.isText(node) &&
editor.getApi(SuggestionPlugin).suggestion.nodeId(node) === activeSuggestion.suggestionId
);
}
if (activeCommentId) {
if (activeCommentId === getDraftCommentKey()) {
activeNode = draftCommentNode;
} else {
activeNode = commentNodes.find(
([node]) => editor.getApi(commentPlugin).comment.nodeId(node) === activeCommentId
);
}
}
if (!activeNode) return null;
return editor.api.toDOMNode(activeNode[0])!;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
open,
activeSuggestion,
activeCommentId,
editor.api,
suggestionNodes,
draftCommentNode,
commentNodes
]);
if (suggestionsCount + resolvedDiscussions.length === 0 && !draftCommentNode)
return <div className="w-full">{children}</div>;
return (
<div className="flex w-full justify-between">
<PopoverBase
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] max-w-[calc(100vw-24px)] min-w-[130px] overflow-y-auto p-0 data-[state=closed]:opacity-0"
onCloseAutoFocus={(e) => e.preventDefault()}
onOpenAutoFocus={(e) => e.preventDefault()}
align="center"
side="bottom">
{isCommenting ? (
<CommentCreateForm className="p-4" focusOnMount />
) : (
<React.Fragment>
{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}
/>
)
)
) : (
<React.Fragment>
{activeSuggestion && (
<BlockSuggestionCard
key={activeSuggestion.suggestionId}
idx={0}
isLast={true}
suggestion={activeSuggestion}
/>
)}
{activeDiscussion && <BlockComment discussion={activeDiscussion} isLast={true} />}
</React.Fragment>
)}
</React.Fragment>
)}
</PopoverContent>
{totalCount > 0 && (
<div className="relative left-0 size-0 select-none">
<PopoverTrigger asChild>
<Button
variant="ghost"
className="text-muted-foreground/80 hover:text-muted-foreground/80 data-[active=true]:bg-muted mt-1 ml-1 flex h-6 gap-1 !px-1.5 py-0"
data-active={open}
contentEditable={false}>
{suggestionsCount > 0 && discussionsCount === 0 && (
<div className="size-4 shrink-0">
<Pencil />
</div>
)}
{suggestionsCount === 0 && discussionsCount > 0 && (
<div className="size-4 shrink-0">
<Message />
</div>
)}
{suggestionsCount > 0 && discussionsCount > 0 && (
<div className="size-4 shrink-0">
<Message />
</div>
)}
<span className="text-xs font-semibold">{totalCount}</span>
</Button>
</PopoverTrigger>
</div>
)}
</PopoverBase>
</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="bg-muted h-px w-full" />}
</React.Fragment>
);
}
const useResolvedDiscussion = (commentNodes: NodeEntry<TCommentText>[], blockPath: Path) => {
const { api, getOption, setOption } = useEditorPlugin(commentPlugin);
const discussions = usePluginOption(discussionPlugin, 'discussions');
commentNodes.forEach(([node]) => {
const id = api.comment.nodeId(node);
const map = getOption('uniquePathMap');
if (!id) return;
const previousPath = map.get(id);
// If there are no comment nodes in the corresponding path in the map, then update it.
if (PathApi.isPath(previousPath)) {
const nodes = api.comment.node({ id, at: previousPath });
if (!nodes) {
setOption('uniquePathMap', new Map(map).set(id, blockPath));
return;
}
return;
}
// TODO: fix throw error
setOption('uniquePathMap', new Map(map).set(id, blockPath));
});
const commentsIds = new Set(
commentNodes.map(([node]) => api.comment.nodeId(node)).filter(Boolean)
);
const resolvedDiscussions = discussions
.map((d: TDiscussion) => ({
...d,
createdAt: new Date(d.createdAt)
}))
.filter((item: TDiscussion) => {
/** If comment cross blocks just show it in the first block */
const commentsPathMap = getOption('uniquePathMap');
const firstBlockPath = commentsPathMap.get(item.id);
if (!firstBlockPath) return false;
if (!PathApi.equals(firstBlockPath, blockPath)) return false;
return api.comment.has({ id: item.id }) && commentsIds.has(item.id) && !item.isResolved;
});
return resolvedDiscussions;
};