返回 presentation-ai
block-suggestion.tsx
根目录 / src / components / plate / ui / block-suggestion.tsx
1 "use client";
2
3 import {
4 acceptSuggestion,
5 rejectSuggestion,
6 getSuggestionKey,
7 keyId2SuggestionId,
8 type TResolvedSuggestion,
9 } from "@platejs/suggestion";
10 import { SuggestionPlugin } from "@platejs/suggestion/react";
11 import { CheckIcon, XIcon } from "lucide-react";
12 import {
13 ElementApi,
14 KEYS,
15 PathApi,
16 TextApi,
17 type NodeEntry,
18 type Path,
19 type TElement,
20 type TSuggestionText,
21 } from "platejs";
22 import { useEditorPlugin, usePluginOption } from "platejs/react";
23 import * as React from "react";
24
25 import {
26 discussionPlugin,
27 type TDiscussion,
28 } from "@/components/plate/plugins/discussion-kit";
29 import { suggestionPlugin } from "@/components/plate/plugins/suggestion-kit";
30 import {
31 Avatar,
32 AvatarFallback,
33 AvatarImage,
34 } from "@/components/plate/ui/avatar";
35 import { Button } from "@/components/plate/ui/button";
36 import {
37 Comment,
38 CommentCreateForm,
39 formatCommentDate,
40 type TComment,
41 } from "./comment";
42
43 export interface ResolvedSuggestion extends TResolvedSuggestion {
44 comments: TComment[];
45 }
46
47 const BLOCK_SUGGESTION = "__block__";
48
49 export function BlockSuggestionCard({
50 idx,
51 isLast,
52 suggestion,
53 }: {
54 idx: number;
55 isLast: boolean;
56 suggestion: ResolvedSuggestion;
57 }) {
58 const { api, editor } = useEditorPlugin(SuggestionPlugin);
59
60 const userInfo = usePluginOption(discussionPlugin, "user", suggestion.userId);
61
62 const accept = (suggestion: ResolvedSuggestion) => {
63 api.suggestion.withoutSuggestions(() => {
64 acceptSuggestion(editor, suggestion);
65 });
66 };
67
68 const reject = (suggestion: ResolvedSuggestion) => {
69 api.suggestion.withoutSuggestions(() => {
70 rejectSuggestion(editor, suggestion);
71 });
72 };
73
74 const [hovering, setHovering] = React.useState(false);
75
76 const suggestionText2Array = (text: string) => {
77 if (text === BLOCK_SUGGESTION) return ["line breaks"];
78
79 return text.split(BLOCK_SUGGESTION).filter(Boolean);
80 };
81
82 const [editingId, setEditingId] = React.useState<string | null>(null);
83
84 return (
85 <div
86 key={`${suggestion.suggestionId}-${idx}`}
87 className="relative"
88 onMouseEnter={() => setHovering(true)}
89 onMouseLeave={() => setHovering(false)}
90 >
91 <div className="flex flex-col p-4">
92 <div className="relative flex items-center">
93 {/* Replace to your own backend or refer to potion */}
94 <Avatar className="size-5">
95 <AvatarImage
96 alt={userInfo?.name ?? undefined}
97 src={userInfo?.avatarUrl ?? undefined}
98 />
99 <AvatarFallback>{userInfo?.name?.[0]}</AvatarFallback>
100 </Avatar>
101 <h4 className="mx-2 text-sm leading-none font-semibold">
102 {userInfo?.name}
103 </h4>
104 <div className="text-xs leading-none text-muted-foreground/80">
105 <span className="mr-1">
106 {formatCommentDate(new Date(suggestion.createdAt))}
107 </span>
108 </div>
109 </div>
110
111 <div className="relative mt-1 mb-4 pl-8">
112 <div className="flex flex-col gap-2">
113 {suggestion.type === "remove" &&
114 suggestionText2Array(suggestion.text!).map((text, index) => (
115 <div key={index} className="flex items-center gap-2">
116 <span className="text-sm text-muted-foreground">Delete:</span>
117
118 <span key={index} className="text-sm">
119 {text}
120 </span>
121 </div>
122 ))}
123
124 {suggestion.type === "insert" &&
125 suggestionText2Array(suggestion.newText!).map((text, index) => (
126 <div key={index} className="flex items-center gap-2">
127 <span className="text-sm text-muted-foreground">Add:</span>
128
129 <span key={index} className="text-sm">
130 {text || "line breaks"}
131 </span>
132 </div>
133 ))}
134
135 {suggestion.type === "replace" && (
136 <div className="flex flex-col gap-2">
137 {suggestionText2Array(suggestion.newText!).map(
138 (text, index) => (
139 <React.Fragment key={index}>
140 <div
141 key={index}
142 className="flex items-start gap-2 text-brand/80"
143 >
144 <span className="text-sm">with:</span>
145 <span className="text-sm">{text || "line breaks"}</span>
146 </div>
147 </React.Fragment>
148 ),
149 )}
150
151 {suggestionText2Array(suggestion.text!).map((text, index) => (
152 <React.Fragment key={index}>
153 <div key={index} className="flex items-start gap-2">
154 <span className="text-sm text-muted-foreground">
155 {index === 0 ? "Replace:" : "Delete:"}
156 </span>
157 <span className="text-sm">{text || "line breaks"}</span>
158 </div>
159 </React.Fragment>
160 ))}
161 </div>
162 )}
163
164 {suggestion.type === "update" && (
165 <div className="flex items-center gap-2">
166 <span className="text-sm text-muted-foreground">
167 {Object.keys(suggestion.properties).map((key) => (
168 <span key={key}>Un{key}</span>
169 ))}
170
171 {Object.keys(suggestion.newProperties).map((key) => (
172 <span key={key}>
173 {key.charAt(0).toUpperCase() + key.slice(1)}
174 </span>
175 ))}
176 </span>
177 <span className="text-sm">{suggestion.newText}</span>
178 </div>
179 )}
180 </div>
181 </div>
182
183 {suggestion.comments.map((comment, index) => (
184 <Comment
185 key={comment.id ?? index}
186 comment={comment}
187 discussionLength={suggestion.comments.length}
188 documentContent="__suggestion__"
189 editingId={editingId}
190 index={index}
191 setEditingId={setEditingId}
192 />
193 ))}
194
195 {hovering && (
196 <div className="absolute top-4 right-4 flex gap-2">
197 <Button
198 variant="ghost"
199 className="size-6 p-1 text-muted-foreground"
200 onClick={() => accept(suggestion)}
201 >
202 <CheckIcon className="size-4" />
203 </Button>
204
205 <Button
206 variant="ghost"
207 className="size-6 p-1 text-muted-foreground"
208 onClick={() => reject(suggestion)}
209 >
210 <XIcon className="size-4" />
211 </Button>
212 </div>
213 )}
214
215 <CommentCreateForm discussionId={suggestion.suggestionId} />
216 </div>
217
218 {!isLast && <div className="h-px w-full bg-muted" />}
219 </div>
220 );
221 }
222
223 const TYPE_TEXT_MAP: Record<string, (node?: TElement) => string> = {
224 [KEYS.audio]: () => "Audio",
225 [KEYS.blockquote]: () => "Blockquote",
226 [KEYS.callout]: () => "Callout",
227 [KEYS.codeBlock]: () => "Code Block",
228 [KEYS.column]: () => "Column",
229 [KEYS.equation]: () => "Equation",
230 [KEYS.file]: () => "File",
231 [KEYS.h1]: () => "Heading 1",
232 [KEYS.h2]: () => "Heading 2",
233 [KEYS.h3]: () => "Heading 3",
234 [KEYS.h4]: () => "Heading 4",
235 [KEYS.h5]: () => "Heading 5",
236 [KEYS.h6]: () => "Heading 6",
237 [KEYS.hr]: () => "Horizontal Rule",
238 [KEYS.img]: () => "Image",
239 [KEYS.mediaEmbed]: () => "Media",
240 [KEYS.p]: (node) => {
241 if (node?.[KEYS.listType] === KEYS.listTodo) return "Todo List";
242 if (node?.[KEYS.listType] === KEYS.ol) return "Ordered List";
243 if (node?.[KEYS.listType] === KEYS.ul) return "List";
244
245 return "Paragraph";
246 },
247 [KEYS.table]: () => "Table",
248 [KEYS.toc]: () => "Table of Contents",
249 [KEYS.toggle]: () => "Toggle",
250 [KEYS.video]: () => "Video",
251 };
252
253 export const useResolveSuggestion = (
254 suggestionNodes: NodeEntry<TElement | TSuggestionText>[],
255 blockPath: Path,
256 ) => {
257 const discussions = usePluginOption(discussionPlugin, "discussions");
258
259 const { api, editor, getOption, setOption } =
260 useEditorPlugin(suggestionPlugin);
261
262 for (const [node] of suggestionNodes) {
263 const id = api.suggestion.nodeId(node);
264 const map = getOption("uniquePathMap");
265
266 if (!id) continue;
267
268 const previousPath = map.get(id);
269
270 // If there are no suggestion nodes in the corresponding path in the map, then update it.
271 if (PathApi.isPath(previousPath)) {
272 const nodes = api.suggestion.node({ id, at: previousPath, isText: true });
273 const parentNode = api.node(previousPath);
274 let lineBreakId: string | null = null;
275
276 if (parentNode && ElementApi.isElement(parentNode[0])) {
277 lineBreakId = api.suggestion.nodeId(parentNode[0]) ?? null;
278 }
279
280 if (!nodes && lineBreakId !== id) {
281 setOption("uniquePathMap", new Map(map).set(id, blockPath));
282 continue;
283 }
284
285 continue;
286 }
287 setOption("uniquePathMap", new Map(map).set(id, blockPath));
288 }
289
290 const resolvedSuggestion: ResolvedSuggestion[] = (() => {
291 const map = getOption("uniquePathMap");
292
293 if (suggestionNodes.length === 0) return [];
294
295 const suggestionIds = new Set(
296 suggestionNodes
297 .flatMap(([node]) => {
298 if (TextApi.isText(node)) {
299 const dataList = api.suggestion.dataList(node);
300 const includeUpdate = dataList.some(
301 (data) => data.type === "update",
302 );
303
304 if (!includeUpdate) return api.suggestion.nodeId(node);
305
306 return dataList
307 .filter((data) => data.type === "update")
308 .map((d) => d.id);
309 }
310 if (ElementApi.isElement(node)) {
311 return api.suggestion.nodeId(node);
312 }
313 })
314 .filter(Boolean),
315 );
316
317 const res: ResolvedSuggestion[] = [];
318
319 suggestionIds.forEach((id) => {
320 if (!id) return;
321
322 const path = map.get(id);
323
324 if (!path || !PathApi.isPath(path)) return;
325 if (!PathApi.equals(path, blockPath)) return;
326
327 const entries = [
328 ...editor.api.nodes({
329 at: [],
330 mode: "all",
331 match: (n) =>
332 Boolean(n[KEYS.suggestion] && (n as Record<string, unknown>)[getSuggestionKey(id)]) ||
333 api.suggestion.nodeId(n as TElement) === id,
334 }),
335 ] as NodeEntry<TElement | TSuggestionText>[];
336
337 // move line break to the end
338 entries.sort(([, path1], [, path2]) => {
339 return PathApi.isChild(path1, path2) ? -1 : 1;
340 });
341
342 let newText = "";
343 let text = "";
344 let properties: Record<string, unknown> = {};
345 let newProperties: Record<string, unknown> = {};
346
347 // overlapping suggestion
348 entries.forEach(([node]) => {
349 if (TextApi.isText(node)) {
350 const suggestionTextNode = node as TSuggestionText;
351 const dataList = api.suggestion.dataList(suggestionTextNode);
352
353 dataList.forEach((data) => {
354 if (data.id !== id) return;
355
356 switch (data.type) {
357 case "insert": {
358 newText += suggestionTextNode.text;
359
360 break;
361 }
362 case "remove": {
363 text += suggestionTextNode.text;
364
365 break;
366 }
367 case "update": {
368 properties = {
369 ...properties,
370 ...data.properties,
371 };
372
373 newProperties = {
374 ...newProperties,
375 ...data.newProperties,
376 };
377
378 newText += suggestionTextNode.text;
379
380 break;
381 }
382 // No default
383 }
384 });
385 } else {
386 const lineBreakData = api.suggestion.isBlockSuggestion(node)
387 ? (node as unknown as { suggestion?: { id?: string; type?: string; isLineBreak?: boolean } }).suggestion
388 : undefined;
389
390 if (lineBreakData?.id !== keyId2SuggestionId(id)) return;
391 if (lineBreakData.type === "insert") {
392 newText += lineBreakData.isLineBreak
393 ? BLOCK_SUGGESTION
394 : BLOCK_SUGGESTION + TYPE_TEXT_MAP[node.type]!(node);
395 } else if (lineBreakData.type === "remove") {
396 text += lineBreakData.isLineBreak
397 ? BLOCK_SUGGESTION
398 : BLOCK_SUGGESTION + TYPE_TEXT_MAP[node.type]!(node);
399 }
400 }
401 });
402
403 if (entries.length === 0) return;
404
405 const nodeData = api.suggestion.suggestionData(entries![0]![0]);
406
407 if (!nodeData) return;
408
409 // const comments = data?.discussions.find((d) => d.id === id)?.comments;
410 const comments =
411 discussions.find((s: TDiscussion) => s.id === id)?.comments || [];
412 const createdAt = new Date(nodeData.createdAt);
413
414 const keyId = getSuggestionKey(id);
415
416 if (nodeData.type === "update") {
417 return res.push({
418 comments,
419 createdAt,
420 keyId,
421 newProperties,
422 newText,
423 properties,
424 suggestionId: keyId2SuggestionId(id),
425 type: "update",
426 userId: nodeData.userId,
427 });
428 }
429 if (newText.length > 0 && text.length > 0) {
430 return res.push({
431 comments,
432 createdAt,
433 keyId,
434 newText,
435 suggestionId: keyId2SuggestionId(id),
436 text,
437 type: "replace",
438 userId: nodeData.userId,
439 });
440 }
441 if (newText.length > 0) {
442 return res.push({
443 comments,
444 createdAt,
445 keyId,
446 newText,
447 suggestionId: keyId2SuggestionId(id),
448 type: "insert",
449 userId: nodeData.userId,
450 });
451 }
452 if (text.length > 0) {
453 return res.push({
454 comments,
455 createdAt,
456 keyId,
457 suggestionId: keyId2SuggestionId(id),
458 text,
459 type: "remove",
460 userId: nodeData.userId,
461 });
462 }
463 });
464
465 return res;
466 })();
467
468 return resolvedSuggestion;
469 };
470
471 export const isResolvedSuggestion = (
472 suggestion: ResolvedSuggestion | TDiscussion,
473 ): suggestion is ResolvedSuggestion => {
474 return "suggestionId" in suggestion;
475 };
476
477
477 lines Plain Text