'use client'; import React, { useMemo, useState } from 'react'; import type { TResolvedSuggestion, TSuggestionElement, TSuggestionText, } from '@udecode/plate-suggestion'; import { cn } from '@udecode/cn'; import { type NodeEntry, type Path, type TElement, ElementApi, PathApi, TextApi, } from '@udecode/plate'; import { BlockquotePlugin } from '@udecode/plate-block-quote/react'; import { CalloutPlugin } from '@udecode/plate-callout/react'; import { CodeBlockPlugin } from '@udecode/plate-code-block/react'; import { HEADING_KEYS } from '@udecode/plate-heading'; import { TocPlugin } from '@udecode/plate-heading/react'; import { HorizontalRulePlugin } from '@udecode/plate-horizontal-rule/react'; import { INDENT_LIST_KEYS, ListStyleType } from '@udecode/plate-indent-list'; import { IndentListPlugin } from '@udecode/plate-indent-list/react'; import { ColumnPlugin } from '@udecode/plate-layout/react'; import { AudioPlugin, FilePlugin, ImagePlugin, MediaEmbedPlugin, VideoPlugin, } from '@udecode/plate-media/react'; import { acceptSuggestion, getSuggestionKey, keyId2SuggestionId, rejectSuggestion, } from '@udecode/plate-suggestion'; import { SuggestionPlugin } from '@udecode/plate-suggestion/react'; import { TablePlugin } from '@udecode/plate-table/react'; import { TogglePlugin } from '@udecode/plate-toggle/react'; import { ParagraphPlugin, useEditorPlugin, useStoreSelect, } from '@udecode/plate/react'; import { CheckIcon, XIcon } from 'lucide-react'; import { suggestionPlugin } from '../editor/plugins/suggestion-plugin'; import { Avatar, AvatarFallback, AvatarImage } from './avatar'; import { type TDiscussion, discussionStore, useFakeUserInfo, } from './block-discussion'; import { Button } from './button'; import { type TComment, Comment, formatCommentDate } from './comment'; import { CommentCreateForm } from './comment-create-form'; export interface ResolvedSuggestion extends TResolvedSuggestion { comments: TComment[]; } export const BLOCK_SUGGESTION = '__block__'; export const TYPE_TEXT_MAP: Record string> = { [AudioPlugin.key]: () => 'Audio', [BlockquotePlugin.key]: () => 'Blockquote', [CalloutPlugin.key]: () => 'Callout', [CodeBlockPlugin.key]: () => 'Code Block', [ColumnPlugin.key]: () => 'Column', [FilePlugin.key]: () => 'File', [HEADING_KEYS.h1]: () => `Heading 1`, [HEADING_KEYS.h2]: () => `Heading 2`, [HEADING_KEYS.h3]: () => `Heading 3`, [HEADING_KEYS.h4]: () => `Heading 4`, [HEADING_KEYS.h5]: () => `Heading 5`, [HEADING_KEYS.h6]: () => `Heading 6`, [HorizontalRulePlugin.key]: () => 'Horizontal Rule', [ImagePlugin.key]: () => 'Image', [MediaEmbedPlugin.key]: () => 'Media', [ParagraphPlugin.key]: (node) => { if (node?.[IndentListPlugin.key] === INDENT_LIST_KEYS.todo) return 'Todo List'; if (node?.[IndentListPlugin.key] === ListStyleType.Decimal) return 'Ordered List'; if (node?.[IndentListPlugin.key] === ListStyleType.Disc) return 'List'; return 'Paragraph'; }, [TablePlugin.key]: () => 'Table', [TocPlugin.key]: () => 'Table of Contents', [TogglePlugin.key]: () => 'Toggle', [VideoPlugin.key]: () => 'Video', }; export const BlockSuggestionCard = ({ idx, isLast, suggestion, }: { idx: number; isLast: boolean; suggestion: ResolvedSuggestion; }) => { const { api, editor } = useEditorPlugin(SuggestionPlugin); const userInfo = useFakeUserInfo(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] = useState(false); const suggestionText2Array = (text: string) => { if (text === BLOCK_SUGGESTION) return ['line breaks']; return text.split(BLOCK_SUGGESTION).filter(Boolean); }; const [editingId, setEditingId] = useState(null); return (
setHovering(true)} onMouseLeave={() => setHovering(false)} >
{/* Replace to your own backend or refer to potion */} {userInfo?.name?.[0]}

{userInfo?.name}

{formatCommentDate(new Date(suggestion.createdAt))}
{suggestion.type === 'remove' && ( {suggestionText2Array(suggestion.text!).map((text, index) => (
Delete: {text}
))}
)} {suggestion.type === 'insert' && ( {suggestionText2Array(suggestion.newText!).map( (text, index) => (
Add: {text || 'line breaks'}
), )}
)} {suggestion.type === 'replace' && (
{suggestionText2Array(suggestion.newText!).map( (text, index) => (
with: {text || 'line breaks'}
), )} {suggestionText2Array(suggestion.text!).map((text, index) => (
{index === 0 ? 'Replace:' : 'Delete:'} {text || 'line breaks'}
))}
)} {suggestion.type === 'update' && (
{Object.keys(suggestion.properties).map((key) => ( Un{key} ))} {Object.keys(suggestion.newProperties).map((key) => ( {key.charAt(0).toUpperCase() + key.slice(1)} ))} {suggestion.newText}
)}
{suggestion.comments.map((comment, index) => ( ))} {hovering && (
)}
{!isLast &&
}
); }; export const useResolveSuggestion = ( suggestionNodes: NodeEntry[], blockPath: Path, ) => { const discussions = useStoreSelect( discussionStore, (state) => state.discussions, ); const { api, editor, getOption, setOption } = useEditorPlugin(suggestionPlugin); suggestionNodes.forEach(([node]) => { const id = api.suggestion.nodeId(node); const map = getOption('uniquePathMap'); if (!id) return; const previousPath = map.get(id); // If there are no suggestion nodes in the corresponding path in the map, then update it. if (PathApi.isPath(previousPath)) { const nodes = api.suggestion.node({ id, at: previousPath, isText: true }); const parentNode = api.node(previousPath); let lineBreakId: string | null = null; if (parentNode && ElementApi.isElement(parentNode[0])) { lineBreakId = api.suggestion.nodeId(parentNode[0]) ?? null; } if (!nodes && lineBreakId !== id) { return setOption('uniquePathMap', new Map(map).set(id, blockPath)); } return; } setOption('uniquePathMap', new Map(map).set(id, blockPath)); }); const resolvedSuggestion: ResolvedSuggestion[] = useMemo(() => { const map = getOption('uniquePathMap'); if (suggestionNodes.length === 0) return []; const suggestionIds = new Set( suggestionNodes .flatMap(([node]) => { if (TextApi.isText(node)) { const dataList = api.suggestion.dataList(node); const includeUpdate = dataList.some( (data) => data.type === 'update', ); if (!includeUpdate) return api.suggestion.nodeId(node); return dataList .filter((data) => data.type === 'update') .map((d) => d.id); } if (ElementApi.isElement(node)) { return api.suggestion.nodeId(node); } }) .filter(Boolean), ); const res: ResolvedSuggestion[] = []; suggestionIds.forEach((id) => { if (!id) return; const path = map.get(id); if (!path || !PathApi.isPath(path)) return; if (!PathApi.equals(path, blockPath)) return; const entries = [ ...editor.api.nodes({ at: [], mode: 'all', match: (n) => (n[SuggestionPlugin.key] && n[getSuggestionKey(id)]) || api.suggestion.nodeId(n as TElement) === id, }), ]; // move line break to the end entries.sort(([, path1], [, path2]) => { return PathApi.isChild(path1, path2) ? -1 : 1; }); let newText = ''; let text = ''; let properties: any = {}; let newProperties: any = {}; // overlapping suggestion entries.forEach(([node]) => { if (TextApi.isText(node)) { const dataList = api.suggestion.dataList(node); dataList.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; } // No default } }); } else { const lineBreakData = api.suggestion.isBlockSuggestion(node) ? node.suggestion : undefined; if (lineBreakData?.id !== keyId2SuggestionId(id)) return; if (lineBreakData.type === 'insert') { newText += lineBreakData.isLineBreak ? BLOCK_SUGGESTION : BLOCK_SUGGESTION + TYPE_TEXT_MAP[node.type](node); } else if (lineBreakData.type === 'remove') { text += lineBreakData.isLineBreak ? BLOCK_SUGGESTION : BLOCK_SUGGESTION + TYPE_TEXT_MAP[node.type](node); } } }); if (entries.length === 0) return; const nodeData = api.suggestion.suggestionData(entries[0][0]); if (!nodeData) return; // const comments = data?.discussions.find((d) => d.id === id)?.comments; const comments = discussions.find((s: TDiscussion) => s.id === id)?.comments || []; const createdAt = new Date(nodeData.createdAt); const keyId = getSuggestionKey(id); if (nodeData.type === 'update') { return res.push({ comments, createdAt, keyId, newProperties, newText, properties, suggestionId: keyId2SuggestionId(id), type: 'update', userId: nodeData.userId, }); } if (newText.length > 0 && text.length > 0) { return res.push({ comments, createdAt, keyId, newText, suggestionId: keyId2SuggestionId(id), text, type: 'replace', userId: nodeData.userId, }); } if (newText.length > 0) { return res.push({ comments, createdAt, keyId, newText, suggestionId: keyId2SuggestionId(id), type: 'insert', userId: nodeData.userId, }); } if (text.length > 0) { return res.push({ comments, createdAt, keyId, suggestionId: keyId2SuggestionId(id), text, type: 'remove', userId: nodeData.userId, }); } }); return res; }, [ api.suggestion, blockPath, discussions, editor.api, getOption, suggestionNodes, ]); return resolvedSuggestion; }; export const isResolvedSuggestion = ( suggestion: ResolvedSuggestion | TDiscussion, ): suggestion is ResolvedSuggestion => { return 'suggestionId' in suggestion; }; export function BlockSuggestion({ element }: { element: TSuggestionElement }) { const suggestionData = element.suggestion; if (suggestionData?.isLineBreak) return null; const isRemove = suggestionData?.type === 'remove'; return (
); }