返回 presentation-ai
focused-paragraph-placeholder-plugin.tsx
根目录 / src / components / notebook / presentation / editor / plugins / focused-paragraph-placeholder-plugin.tsx
1 "use client";
2
3 import {
4 CheckSquareIcon,
5 Code2Icon,
6 Heading1Icon,
7 Heading2Icon,
8 Heading3Icon,
9 Heading4Icon,
10 Heading5Icon,
11 Heading6Icon,
12 ImageIcon,
13 LayoutGridIcon,
14 ListIcon,
15 ListOrderedIcon,
16 MessageSquareQuoteIcon,
17 PilcrowIcon,
18 TableIcon,
19 TextQuoteIcon,
20 ToggleRightIcon,
21 X,
22 } from "lucide-react";
23 import { KEYS, nanoid, NodeApi, PathApi, type TElement } from "platejs";
24 import {
25 useEditorSelector,
26 type PlateEditor,
27 type PlateElementProps,
28 } from "platejs/react";
29 import * as React from "react";
30 import { type ReactNode } from "react";
31
32 import { insertBlock } from "@/components/plate/utils/transforms";
33 import {
34 Command,
35 CommandEmpty,
36 CommandGroup,
37 CommandInput,
38 CommandItem,
39 CommandList,
40 } from "@/components/ui/command";
41 import {
42 Popover,
43 PopoverContent,
44 PopoverTrigger,
45 } from "@/components/ui/popover";
46 import { cn } from "@/lib/utils";
47 import {
48 CATEGORY_ICONS,
49 COLUMN_GROUP,
50 getDefaultChartDataForType,
51 GROUPED_BLOCKS,
52 isChartType,
53 PARENT_CHILD_RELATIONSHIP,
54 QUOTE_ELEMENT,
55 } from "../lib";
56
57 type BlockOption = {
58 icon: ReactNode;
59 key?: string;
60 name: string;
61 supportsOrientation?: boolean;
62 type: string;
63 variant?: string;
64 };
65
66 type InsertOption = {
67 id: string;
68 icon: ReactNode;
69 label: string;
70 type: string;
71 keywords?: readonly string[];
72 props?: Partial<TElement>;
73 };
74
75 type InsertOptionGroup = {
76 label: string;
77 options: readonly InsertOption[];
78 };
79
80 const basicOptionGroups = [
81 {
82 label: "Basic blocks",
83 options: [
84 {
85 id: "paragraph",
86 icon: <PilcrowIcon className="size-4" />,
87 label: "Text",
88 type: KEYS.p,
89 keywords: ["paragraph", "body"],
90 },
91 {
92 id: "heading-1",
93 icon: <Heading1Icon className="size-4" />,
94 label: "Heading 1",
95 type: KEYS.h1,
96 keywords: ["title"],
97 },
98 {
99 id: "heading-2",
100 icon: <Heading2Icon className="size-4" />,
101 label: "Heading 2",
102 type: KEYS.h2,
103 keywords: ["subtitle"],
104 },
105 {
106 id: "heading-3",
107 icon: <Heading3Icon className="size-4" />,
108 label: "Heading 3",
109 type: KEYS.h3,
110 },
111 {
112 id: "heading-4",
113 icon: <Heading4Icon className="size-4" />,
114 label: "Heading 4",
115 type: KEYS.h4,
116 },
117 {
118 id: "heading-5",
119 icon: <Heading5Icon className="size-4" />,
120 label: "Heading 5",
121 type: KEYS.h5,
122 },
123 {
124 id: "heading-6",
125 icon: <Heading6Icon className="size-4" />,
126 label: "Heading 6",
127 type: KEYS.h6,
128 },
129 {
130 id: "blockquote",
131 icon: <TextQuoteIcon className="size-4" />,
132 label: "Blockquote",
133 type: KEYS.blockquote,
134 keywords: ["quote", "citation"],
135 },
136 {
137 id: "callout",
138 icon: <MessageSquareQuoteIcon className="size-4" />,
139 label: "Callout",
140 type: KEYS.callout,
141 keywords: ["note", "info", "warning"],
142 },
143 {
144 id: "toggle",
145 icon: <ToggleRightIcon className="size-4" />,
146 label: "Toggle",
147 type: KEYS.toggle,
148 keywords: ["collapsible", "expand"],
149 },
150 {
151 id: "code-block",
152 icon: <Code2Icon className="size-4" />,
153 label: "Code block",
154 type: KEYS.codeBlock,
155 keywords: ["code"],
156 },
157 ],
158 },
159 {
160 label: "Lists",
161 options: [
162 {
163 id: "bulleted-list",
164 icon: <ListIcon className="size-4" />,
165 label: "Bulleted list",
166 type: KEYS.ul,
167 keywords: ["unordered", "bullet"],
168 },
169 {
170 id: "numbered-list",
171 icon: <ListOrderedIcon className="size-4" />,
172 label: "Numbered list",
173 type: KEYS.ol,
174 keywords: ["ordered"],
175 },
176 {
177 id: "todo-list",
178 icon: <CheckSquareIcon className="size-4" />,
179 label: "To-do list",
180 type: KEYS.listTodo,
181 keywords: ["task", "checklist"],
182 },
183 ],
184 },
185 {
186 label: "Media",
187 options: [
188 {
189 id: "image",
190 icon: <ImageIcon className="size-4" />,
191 label: "Image",
192 type: KEYS.img,
193 keywords: ["photo", "picture"],
194 },
195 {
196 id: "table",
197 icon: <TableIcon className="size-4" />,
198 label: "Table",
199 type: KEYS.table,
200 keywords: ["grid", "rows", "columns"],
201 },
202 ],
203 },
204 ] as const satisfies readonly InsertOptionGroup[];
205
206 const layoutOptionGroups = Object.entries(GROUPED_BLOCKS).map(
207 ([category, items]) =>
208 ({
209 label: category,
210 options: items.map((option) => ({
211 id: `${option.type}-${option.key ?? "type"}-${
212 option.variant ?? "default"
213 }`,
214 icon: option.icon,
215 label: option.name,
216 type: option.type,
217 props: getBlockProps(option),
218 })),
219 }) satisfies InsertOptionGroup,
220 );
221
222 const insertOptionGroups = [...basicOptionGroups, ...layoutOptionGroups];
223
224 function getCategoryIcon(label: string) {
225 if (label in CATEGORY_ICONS) {
226 return CATEGORY_ICONS[label as keyof typeof CATEGORY_ICONS];
227 }
228
229 return null;
230 }
231
232 function isSelectionInsidePath(
233 selectionPath: readonly number[] | undefined,
234 elementPath: readonly number[],
235 ) {
236 if (!selectionPath || selectionPath.length < elementPath.length) {
237 return false;
238 }
239
240 for (let index = 0; index < elementPath.length; index += 1) {
241 if (elementPath[index] !== selectionPath[index]) {
242 return false;
243 }
244 }
245
246 return true;
247 }
248
249 function isElementNode(node: unknown): node is TElement {
250 return (
251 typeof node === "object" &&
252 node !== null &&
253 "type" in node &&
254 "children" in node
255 );
256 }
257
258 function isTableCellType(type: unknown) {
259 return type === KEYS.td || type === KEYS.th;
260 }
261
262 function getBlockProps(option: BlockOption): Partial<TElement> | undefined {
263 const props: Partial<TElement> = {};
264
265 if (option.key && option.variant) {
266 props[option.key] =
267 option.key === "isFunnel" ? option.variant === "funnel" : option.variant;
268 }
269
270 if (option.supportsOrientation) {
271 props.orientation = "vertical";
272 }
273
274 return Object.keys(props).length > 0 ? props : undefined;
275 }
276
277 function insertPlaceholderBlock(editor: PlateEditor, option: InsertOption) {
278 const customBlock = createPresentationBlock(option);
279
280 if (!customBlock) {
281 insertBlock(editor, option.type, { props: option.props });
282 return;
283 }
284
285 const activeBlock = editor.api.block();
286 if (!activeBlock) return;
287
288 editor.tf.withoutNormalizing(() => {
289 const [block, blockPath] = activeBlock;
290
291 editor.tf.insertNodes(customBlock, {
292 at: PathApi.next(blockPath),
293 select: true,
294 });
295
296 if (NodeApi.string(block).trim().length === 0) {
297 editor.tf.removeNodes({ at: blockPath });
298 }
299 });
300 }
301
302 function createTextBlock(type: string, text: string): TElement {
303 return {
304 id: nanoid(),
305 type,
306 children: [{ text }],
307 };
308 }
309
310 function createNestedTextChildren(title: string, description: string) {
311 return [
312 createTextBlock(KEYS.h3, title),
313 createTextBlock(KEYS.p, description),
314 ];
315 }
316
317 function createRelationshipChild(childType: string, index: number): TElement {
318 if (childType === "bullet") {
319 return {
320 id: nanoid(),
321 type: childType,
322 children: [{ text: `Point ${index + 1}` }],
323 };
324 }
325
326 return {
327 id: nanoid(),
328 type: childType,
329 children: createNestedTextChildren(
330 `Item ${index + 1}`,
331 "Add supporting detail.",
332 ),
333 };
334 }
335
336 function createPresentationBlock(option: InsertOption): TElement | null {
337 if (isChartType(option.type)) {
338 return {
339 id: nanoid(),
340 type: option.type,
341 data: getDefaultChartDataForType(option.type),
342 ...option.props,
343 children: [{ text: "" }],
344 };
345 }
346
347 if (option.type === QUOTE_ELEMENT) {
348 return {
349 id: nanoid(),
350 type: option.type,
351 ...option.props,
352 children: [{ text: "Add a quote." }],
353 };
354 }
355
356 const relationship =
357 PARENT_CHILD_RELATIONSHIP[
358 option.type as keyof typeof PARENT_CHILD_RELATIONSHIP
359 ];
360
361 if (!relationship) return null;
362
363 const childTypes = Array.isArray(relationship.child)
364 ? relationship.child
365 : [relationship.child];
366
367 return {
368 id: nanoid(),
369 type: option.type,
370 ...(option.type === COLUMN_GROUP ? { layout: [1, 1] } : {}),
371 ...option.props,
372 children: Array.from({ length: Math.max(2, childTypes.length) }).map(
373 (_, index) =>
374 createRelationshipChild(childTypes[index % childTypes.length]!, index),
375 ),
376 };
377 }
378
379 const PlaceholderActionButton = React.forwardRef<
380 HTMLButtonElement,
381 React.ButtonHTMLAttributes<HTMLButtonElement>
382 >(({ className, onMouseDown, type = "button", ...props }, ref) => {
383 return (
384 <button
385 ref={ref}
386 type={type}
387 contentEditable={false}
388 className={cn(
389 "inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full border border-(--presentation-muted)/30 bg-(--presentation-background)/85 px-1.5 text-[13px] leading-none font-medium text-(--presentation-muted-foreground)/70 shadow-xs transition-[background-color,border-color,color,box-shadow]",
390 "hover:border-(--presentation-text)/45 hover:bg-(--presentation-card-background) hover:text-(--presentation-text) hover:shadow-sm",
391 "focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:outline-hidden",
392 className,
393 )}
394 onMouseDown={(event) => {
395 event.preventDefault();
396 event.stopPropagation();
397 onMouseDown?.(event);
398 }}
399 {...props}
400 />
401 );
402 });
403 PlaceholderActionButton.displayName = "PlaceholderActionButton";
404
405 function MoreBlocksPopover({
406 editor,
407 hideTable,
408 open,
409 onOpenChange,
410 showLabels,
411 }: {
412 editor: PlateEditor;
413 hideTable: boolean;
414 open: boolean;
415 onOpenChange: (open: boolean) => void;
416 showLabels: boolean;
417 }) {
418 const visibleOptionGroups = React.useMemo(
419 () =>
420 hideTable
421 ? insertOptionGroups
422 .map(({ label, options }) => ({
423 label,
424 options: options.filter((option) => option.type !== KEYS.table),
425 }))
426 .filter(({ options }) => options.length > 0)
427 : insertOptionGroups,
428 [hideTable],
429 );
430
431 const handleSelect = React.useCallback(
432 (option: InsertOption) => {
433 insertPlaceholderBlock(editor, option);
434 onOpenChange(false);
435 },
436 [editor, onOpenChange],
437 );
438
439 return (
440 <Popover open={open} onOpenChange={onOpenChange}>
441 <PopoverTrigger asChild>
442 <PlaceholderActionButton>
443 <LayoutGridIcon className="size-4" />
444 {showLabels ? <span>Add more blocks</span> : null}
445 </PlaceholderActionButton>
446 </PopoverTrigger>
447 <PopoverContent
448 align="start"
449 className="w-84 overflow-hidden border-border/70 p-0"
450 contentEditable={false}
451 onMouseDown={(event) => event.stopPropagation()}
452 onPointerDown={(event) => event.stopPropagation()}
453 >
454 <Command>
455 <CommandInput placeholder="Search blocks..." />
456 <CommandList className="max-h-80">
457 <CommandEmpty>No blocks found</CommandEmpty>
458 {visibleOptionGroups.map(({ label, options }) => {
459 const categoryIcon = getCategoryIcon(label);
460
461 return (
462 <CommandGroup key={label} heading={label}>
463 {options.map((option) => (
464 <CommandItem
465 key={option.id}
466 value={`${label} ${option.label} ${option.type} ${(
467 option.keywords ?? []
468 ).join(" ")}`}
469 onSelect={() => handleSelect(option)}
470 >
471 <span className="text-muted-foreground">
472 {option.icon}
473 </span>
474 <span className="flex-1">{option.label}</span>
475 {categoryIcon ? (
476 <span className="text-muted-foreground">
477 {categoryIcon}
478 </span>
479 ) : null}
480 </CommandItem>
481 ))}
482 </CommandGroup>
483 );
484 })}
485 </CommandList>
486 </Command>
487 </PopoverContent>
488 </Popover>
489 );
490 }
491
492 export function FocusedParagraphPlaceholder(props: PlateElementProps) {
493 const { editor, element, path } = props;
494 const [moreBlocksOpen, setMoreBlocksOpen] = React.useState(false);
495 const isRootLevel = path.length === 1;
496 const isCurrentElementEmpty = NodeApi.string(element).trim().length === 0;
497 const isFocusedInside = useEditorSelector(
498 (editor) =>
499 isSelectionInsidePath(editor.selection?.focus.path, path) &&
500 !editor.api.isReadOnly(),
501 [path],
502 );
503 const isInsideTableCell = useEditorSelector(
504 (editor) =>
505 Boolean(
506 editor.api.above({
507 at: path,
508 match: (node) => isElementNode(node) && isTableCellType(node.type),
509 }),
510 ),
511 [path],
512 );
513 const showPlaceholder =
514 isCurrentElementEmpty && (isFocusedInside || moreBlocksOpen);
515
516 const handleInsertImage = React.useCallback(() => {
517 insertBlock(editor, KEYS.img);
518 }, [editor]);
519
520 const handleInsertTable = React.useCallback(() => {
521 insertBlock(editor, KEYS.table);
522 }, [editor]);
523
524 return (
525 <div className="relative">
526 {showPlaceholder ? (
527 <div
528 contentEditable={false}
529 className={cn(
530 "absolute top-1/2 z-10 flex -translate-y-1/2 flex-nowrap items-center gap-2 overflow-hidden whitespace-nowrap",
531 isRootLevel
532 ? "right-4.5 left-4.5 md:right-8.5 md:left-8.5"
533 : "right-0 left-0",
534 "text-(--presentation-muted-foreground)",
535 )}
536 >
537 <span className="pointer-events-none inline-flex min-h-7 shrink-0 items-center text-[13px] leading-none font-normal opacity-80">
538 Type / to add blocks or...
539 </span>
540 <PlaceholderActionButton
541 onClick={(event) => {
542 event.preventDefault();
543 event.stopPropagation();
544 handleInsertImage();
545 }}
546 >
547 <ImageIcon className="size-4" />
548 {isRootLevel ? <span>Add image</span> : null}
549 </PlaceholderActionButton>
550 {!isInsideTableCell ? (
551 <PlaceholderActionButton
552 onClick={(event) => {
553 event.preventDefault();
554 event.stopPropagation();
555 handleInsertTable();
556 }}
557 >
558 <TableIcon className="size-4" />
559 {isRootLevel ? <span>Add table</span> : null}
560 </PlaceholderActionButton>
561 ) : null}
562 <MoreBlocksPopover
563 editor={editor}
564 hideTable={isInsideTableCell}
565 open={moreBlocksOpen}
566 onOpenChange={setMoreBlocksOpen}
567 showLabels={isRootLevel}
568 />
569 {moreBlocksOpen ? (
570 <button
571 type="button"
572 aria-label="Close block search"
573 contentEditable={false}
574 className="inline-flex size-8 items-center justify-center rounded-full text-(--presentation-muted-foreground) transition-colors hover:bg-(--presentation-muted-foreground)/15 hover:text-(--presentation-text)"
575 onClick={(event) => {
576 event.preventDefault();
577 event.stopPropagation();
578 setMoreBlocksOpen(false);
579 }}
580 onMouseDown={(event) => {
581 event.preventDefault();
582 event.stopPropagation();
583 }}
584 >
585 <X className="size-4" />
586 </button>
587 ) : null}
588 </div>
589 ) : null}
590 {props.children}
591 </div>
592 );
593 }
594
594 lines Plain Text