返回 presentation-ai
emoji-toolbar-button.tsx
根目录 / src / components / plate / ui / emoji-toolbar-button.tsx
1 /** biome-ignore-all lint/suspicious/noExplicitAny: This use requires any */
2 "use client";
3
4 import { type Emoji } from "@emoji-mart/data";
5 import {
6 EmojiSettings,
7 type EmojiCategoryList,
8 type EmojiIconList,
9 type GridRow,
10 } from "@platejs/emoji";
11 import {
12 useEmojiDropdownMenuState,
13 type EmojiDropdownMenuOptions,
14 type UseEmojiPickerType,
15 } from "@platejs/emoji/react";
16 import * as Popover from "@radix-ui/react-popover";
17 import {
18 AppleIcon,
19 ClockIcon,
20 CompassIcon,
21 FlagIcon,
22 LeafIcon,
23 LightbulbIcon,
24 MusicIcon,
25 SearchIcon,
26 SmileIcon,
27 StarIcon,
28 XIcon,
29 } from "lucide-react";
30 import * as React from "react";
31
32 import { Button } from "@/components/plate/ui/button";
33 import { ToolbarButton } from "@/components/plate/ui/toolbar";
34 import {
35 Tooltip,
36 TooltipContent,
37 TooltipProvider,
38 TooltipTrigger,
39 } from "@/components/plate/ui/tooltip";
40 import { cn } from "@/lib/utils";
41
42 export function EmojiToolbarButton({
43 options,
44 ...props
45 }: {
46 options?: EmojiDropdownMenuOptions;
47 } & React.ComponentPropsWithoutRef<typeof ToolbarButton>) {
48 const { emojiPickerState, isOpen, setIsOpen } =
49 useEmojiDropdownMenuState(options);
50
51 return (
52 <EmojiPopover
53 control={
54 <ToolbarButton pressed={isOpen} tooltip="Emoji" isDropdown {...props}>
55 <SmileIcon />
56 </ToolbarButton>
57 }
58 isOpen={isOpen}
59 setIsOpen={setIsOpen}
60 >
61 <EmojiPicker
62 {...emojiPickerState}
63 isOpen={isOpen}
64 setIsOpen={setIsOpen}
65 settings={options?.settings}
66 />
67 </EmojiPopover>
68 );
69 }
70
71 export function EmojiPopover({
72 children,
73 control,
74 isOpen,
75 setIsOpen,
76 }: {
77 children: React.ReactNode;
78 control: React.ReactNode;
79 isOpen: boolean;
80 setIsOpen: (open: boolean) => void;
81 }) {
82 return (
83 <Popover.Root open={isOpen} onOpenChange={setIsOpen}>
84 <Popover.Trigger asChild>{control}</Popover.Trigger>
85
86 <Popover.Portal>
87 <Popover.Content className="z-100">{children}</Popover.Content>
88 </Popover.Portal>
89 </Popover.Root>
90 );
91 }
92
93 export function EmojiPicker({
94 clearSearch,
95 emoji,
96 emojiLibrary,
97 focusedCategory,
98 hasFound,
99 i18n,
100 icons = {
101 categories: emojiCategoryIcons,
102 search: emojiSearchIcons,
103 },
104 isSearching,
105 refs,
106 searchResult,
107 searchValue,
108 setSearch,
109 settings = EmojiSettings,
110 visibleCategories,
111 handleCategoryClick,
112 onMouseOver,
113 onSelectEmoji,
114 }: Omit<UseEmojiPickerType, "icons"> & {
115 icons?: EmojiIconList<React.ReactElement>;
116 }) {
117 return (
118 <div
119 className={cn(
120 "flex flex-col rounded-xl bg-popover text-popover-foreground",
121 "h-92 w-80 border shadow-md",
122 )}
123 >
124 <EmojiPickerNavigation
125 onClick={handleCategoryClick}
126 emojiLibrary={emojiLibrary}
127 focusedCategory={focusedCategory}
128 i18n={i18n}
129 icons={icons}
130 />
131 <EmojiPickerSearchBar
132 i18n={i18n}
133 searchValue={searchValue}
134 setSearch={setSearch}
135 >
136 <EmojiPickerSearchAndClear
137 clearSearch={clearSearch}
138 i18n={i18n}
139 searchValue={searchValue}
140 />
141 </EmojiPickerSearchBar>
142 <EmojiPickerContent
143 onMouseOver={onMouseOver}
144 onSelectEmoji={onSelectEmoji}
145 emojiLibrary={emojiLibrary}
146 i18n={i18n}
147 isSearching={isSearching}
148 refs={refs}
149 searchResult={searchResult}
150 settings={settings}
151 visibleCategories={visibleCategories}
152 />
153 <EmojiPickerPreview
154 emoji={emoji}
155 hasFound={hasFound}
156 i18n={i18n}
157 isSearching={isSearching}
158 />
159 </div>
160 );
161 }
162
163 const EmojiButton = React.memo(function EmojiButton({
164 emoji,
165 index,
166 onMouseOver,
167 onSelect,
168 }: {
169 emoji: Emoji;
170 index: number;
171 onMouseOver: (emoji?: Emoji) => void;
172 onSelect: (emoji: Emoji) => void;
173 }) {
174 return (
175 <button
176 className="group relative flex size-9 cursor-pointer items-center justify-center border-none bg-transparent text-2xl leading-none"
177 onClick={() => onSelect(emoji)}
178 onMouseEnter={() => onMouseOver(emoji)}
179 onMouseLeave={() => onMouseOver()}
180 aria-label={emoji?.skins[0]?.native}
181 data-index={index}
182 tabIndex={-1}
183 type="button"
184 >
185 <div
186 className="absolute inset-0 rounded-full opacity-0 group-hover:opacity-100"
187 aria-hidden="true"
188 />
189 <span
190 className="relative"
191 style={{
192 fontFamily:
193 '"Apple Color Emoji", "Segoe UI Emoji", NotoColorEmoji, "Noto Color Emoji", "Segoe UI Symbol", "Android Emoji", EmojiSymbols',
194 }}
195 data-emoji-set="native"
196 >
197 {emoji?.skins[0]?.native}
198 </span>
199 </button>
200 );
201 });
202
203 const RowOfButtons = React.memo(function RowOfButtons({
204 emojiLibrary,
205 row,
206 onMouseOver,
207 onSelectEmoji,
208 }: {
209 row: GridRow;
210 } & Pick<
211 UseEmojiPickerType,
212 "emojiLibrary" | "onMouseOver" | "onSelectEmoji"
213 >) {
214 return (
215 <div key={row.id} className="flex" data-index={row.id}>
216 {row.elements.map((emojiId, index) => (
217 <EmojiButton
218 key={emojiId}
219 onMouseOver={onMouseOver}
220 onSelect={onSelectEmoji}
221 emoji={emojiLibrary.getEmoji(emojiId)}
222 index={index}
223 />
224 ))}
225 </div>
226 );
227 });
228
229 function EmojiPickerContent({
230 emojiLibrary,
231 i18n,
232 isSearching = false,
233 refs,
234 searchResult,
235 settings = EmojiSettings,
236 visibleCategories,
237 onMouseOver,
238 onSelectEmoji,
239 }: Pick<
240 UseEmojiPickerType,
241 | "emojiLibrary"
242 | "i18n"
243 | "isSearching"
244 | "onMouseOver"
245 | "onSelectEmoji"
246 | "refs"
247 | "searchResult"
248 | "settings"
249 | "visibleCategories"
250 >) {
251 const getRowWidth = settings.perLine.value * settings.buttonSize.value;
252
253 const isCategoryVisible = React.useCallback(
254 (categoryId: any) => {
255 return visibleCategories.has(categoryId)
256 ? visibleCategories.get(categoryId)
257 : false;
258 },
259 [visibleCategories],
260 );
261
262 const EmojiList = React.useCallback(() => {
263 return emojiLibrary
264 .getGrid()
265 .sections()
266 .map(({ id: categoryId }) => {
267 const section = emojiLibrary.getGrid().section(categoryId);
268 const { buttonSize } = settings;
269
270 return (
271 <div
272 key={categoryId}
273 ref={section.root}
274 style={{ width: getRowWidth }}
275 data-id={categoryId}
276 >
277 <div className="sticky -top-px z-1 bg-popover/90 p-1 py-2 text-sm font-semibold backdrop-blur-xs">
278 {i18n.categories[categoryId]}
279 </div>
280 <div
281 className="relative flex flex-wrap"
282 style={{ height: section.getRows().length * buttonSize.value }}
283 >
284 {isCategoryVisible(categoryId) &&
285 section
286 .getRows()
287 .map((row: GridRow) => (
288 <RowOfButtons
289 key={row.id}
290 onMouseOver={onMouseOver}
291 onSelectEmoji={onSelectEmoji}
292 emojiLibrary={emojiLibrary}
293 row={row}
294 />
295 ))}
296 </div>
297 </div>
298 );
299 });
300 }, [
301 emojiLibrary,
302 getRowWidth,
303 i18n.categories,
304 isCategoryVisible,
305 onSelectEmoji,
306 onMouseOver,
307 settings,
308 ]);
309
310 const SearchList = React.useCallback(() => {
311 return (
312 <div style={{ width: getRowWidth }} data-id="search">
313 <div className="sticky -top-px z-1 bg-popover/90 p-1 py-2 text-sm font-semibold text-card-foreground backdrop-blur-xs">
314 {i18n.searchResult}
315 </div>
316 <div className="relative flex flex-wrap">
317 {searchResult.map((emoji: Emoji, index: number) => (
318 <EmojiButton
319 key={emoji.id}
320 onMouseOver={onMouseOver}
321 onSelect={onSelectEmoji}
322 emoji={emojiLibrary.getEmoji(emoji.id)}
323 index={index}
324 />
325 ))}
326 </div>
327 </div>
328 );
329 }, [
330 emojiLibrary,
331 getRowWidth,
332 i18n.searchResult,
333 searchResult,
334 onSelectEmoji,
335 onMouseOver,
336 ]);
337
338 return (
339 <div
340 ref={refs.current.contentRoot}
341 className={cn(
342 "h-full min-h-[50%] overflow-x-hidden overflow-y-auto px-2",
343 "[&::-webkit-scrollbar]:w-4",
344 "[&::-webkit-scrollbar-button]:hidden [&::-webkit-scrollbar-button]:size-0",
345 "[&::-webkit-scrollbar-thumb]:min-h-11 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted [&::-webkit-scrollbar-thumb]:hover:bg-muted-foreground/25",
346 "[&::-webkit-scrollbar-thumb]:border-4 [&::-webkit-scrollbar-thumb]:border-solid [&::-webkit-scrollbar-thumb]:border-popover [&::-webkit-scrollbar-thumb]:bg-clip-padding",
347 )}
348 data-id="scroll"
349 >
350 <div ref={refs.current.content} className="h-full">
351 {isSearching ? SearchList() : EmojiList()}
352 </div>
353 </div>
354 );
355 }
356
357 function EmojiPickerSearchBar({
358 children,
359 i18n,
360 searchValue,
361 setSearch,
362 }: {
363 children: React.ReactNode;
364 } & Pick<UseEmojiPickerType, "i18n" | "searchValue" | "setSearch">) {
365 return (
366 <div className="flex items-center px-2">
367 <div className="relative flex grow items-center">
368 <input
369 className="block w-full appearance-none rounded-full border-0 bg-muted px-10 py-2 text-sm outline-hidden placeholder:text-muted-foreground focus-visible:outline-hidden"
370 value={searchValue}
371 onChange={(event) => setSearch(event.target.value)}
372 placeholder={i18n.search}
373 aria-label="Search"
374 autoComplete="off"
375 type="text"
376 />
377 {children}
378 </div>
379 </div>
380 );
381 }
382
383 function EmojiPickerSearchAndClear({
384 clearSearch,
385 i18n,
386 searchValue,
387 }: Pick<UseEmojiPickerType, "clearSearch" | "i18n" | "searchValue">) {
388 return (
389 <div className="flex items-center text-foreground">
390 <div
391 className={cn(
392 "absolute top-1/2 left-2.5 z-10 flex size-5 -translate-y-1/2 items-center justify-center text-foreground",
393 )}
394 >
395 {emojiSearchIcons.loupe}
396 </div>
397 {searchValue && (
398 <Button
399 size="icon"
400 variant="ghost"
401 className={cn(
402 "absolute top-1/2 right-0.5 flex size-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-popover-foreground hover:bg-transparent",
403 )}
404 onClick={clearSearch}
405 title={i18n.clear}
406 aria-label="Clear"
407 type="button"
408 >
409 {emojiSearchIcons.delete}
410 </Button>
411 )}
412 </div>
413 );
414 }
415
416 function EmojiPreview({ emoji }: Pick<UseEmojiPickerType, "emoji">) {
417 return (
418 <div className="flex h-14 max-h-14 min-h-14 items-center border-t border-muted p-2">
419 <div className="flex items-center justify-center text-2xl">
420 {emoji?.skins[0]?.native}
421 </div>
422 <div className="overflow-hidden pl-2">
423 <div className="truncate text-sm font-semibold">{emoji?.name}</div>
424 <div className="truncate text-sm">{`:${emoji?.id}:`}</div>
425 </div>
426 </div>
427 );
428 }
429
430 function NoEmoji({ i18n }: Pick<UseEmojiPickerType, "i18n">) {
431 return (
432 <div className="flex h-14 max-h-14 min-h-14 items-center border-t border-muted p-2">
433 <div className="flex items-center justify-center text-2xl">😢</div>
434 <div className="overflow-hidden pl-2">
435 <div className="truncate text-sm font-bold">
436 {i18n.searchNoResultsTitle}
437 </div>
438 <div className="truncate text-sm">{i18n.searchNoResultsSubtitle}</div>
439 </div>
440 </div>
441 );
442 }
443
444 function PickAnEmoji({ i18n }: Pick<UseEmojiPickerType, "i18n">) {
445 return (
446 <div className="flex h-14 max-h-14 min-h-14 items-center border-t border-muted p-2">
447 <div className="flex items-center justify-center text-2xl">☝️</div>
448 <div className="overflow-hidden pl-2">
449 <div className="truncate text-sm font-semibold">{i18n.pick}</div>
450 </div>
451 </div>
452 );
453 }
454
455 function EmojiPickerPreview({
456 emoji,
457 hasFound = true,
458 i18n,
459 isSearching = false,
460 ...props
461 }: Pick<UseEmojiPickerType, "emoji" | "hasFound" | "i18n" | "isSearching">) {
462 const showPickEmoji = !emoji && (!isSearching || hasFound);
463 const showNoEmoji = isSearching && !hasFound;
464 const showPreview = emoji && !showNoEmoji && !showNoEmoji;
465
466 return (
467 <>
468 {showPreview && <EmojiPreview emoji={emoji} {...props} />}
469 {showPickEmoji && <PickAnEmoji i18n={i18n} {...props} />}
470 {showNoEmoji && <NoEmoji i18n={i18n} {...props} />}
471 </>
472 );
473 }
474
475 function EmojiPickerNavigation({
476 emojiLibrary,
477 focusedCategory,
478 i18n,
479 icons,
480 onClick,
481 }: {
482 onClick: (id: EmojiCategoryList) => void;
483 } & Pick<
484 UseEmojiPickerType,
485 "emojiLibrary" | "focusedCategory" | "i18n" | "icons"
486 >) {
487 return (
488 <TooltipProvider delayDuration={500}>
489 <nav
490 id="emoji-nav"
491 className="mb-2.5 border-0 border-b border-solid border-b-border p-1.5"
492 >
493 <div className="relative flex items-center justify-evenly">
494 {emojiLibrary
495 .getGrid()
496 .sections()
497 .map(({ id }) => (
498 <Tooltip key={id}>
499 <TooltipTrigger asChild>
500 <Button
501 size="sm"
502 variant="ghost"
503 className={cn(
504 "h-fit rounded-full fill-current p-1.5 text-muted-foreground hover:bg-muted hover:text-muted-foreground",
505 id === focusedCategory &&
506 "pointer-events-none bg-accent fill-current text-accent-foreground",
507 )}
508 onClick={() => {
509 onClick(id);
510 }}
511 aria-label={i18n.categories[id]}
512 type="button"
513 >
514 <span className="inline-flex size-5 items-center justify-center">
515 {icons.categories[id].outline}
516 </span>
517 </Button>
518 </TooltipTrigger>
519 <TooltipContent side="bottom">
520 {i18n.categories[id]}
521 </TooltipContent>
522 </Tooltip>
523 ))}
524 </div>
525 </nav>
526 </TooltipProvider>
527 );
528 }
529
530 const emojiCategoryIcons: Record<
531 EmojiCategoryList,
532 {
533 outline: React.ReactElement;
534 solid: React.ReactElement; // Needed to add another solid variant - outline will be used for now
535 }
536 > = {
537 activity: {
538 outline: (
539 <svg
540 className="size-full"
541 fill="none"
542 stroke="currentColor"
543 strokeLinecap="round"
544 strokeLinejoin="round"
545 strokeWidth="2"
546 viewBox="0 0 24 24"
547 xmlns="http://www.w3.org/2000/svg"
548 >
549 <circle cx="12" cy="12" r="10" />
550 <path d="M2.1 13.4A10.1 10.1 0 0 0 13.4 2.1" />
551 <path d="m5 4.9 14 14.2" />
552 <path d="M21.9 10.6a10.1 10.1 0 0 0-11.3 11.3" />
553 </svg>
554 ),
555 solid: (
556 <svg
557 className="size-full"
558 fill="none"
559 stroke="currentColor"
560 strokeLinecap="round"
561 strokeLinejoin="round"
562 strokeWidth="2"
563 viewBox="0 0 24 24"
564 xmlns="http://www.w3.org/2000/svg"
565 >
566 <circle cx="12" cy="12" r="10" />
567 <path d="M2.1 13.4A10.1 10.1 0 0 0 13.4 2.1" />
568 <path d="m5 4.9 14 14.2" />
569 <path d="M21.9 10.6a10.1 10.1 0 0 0-11.3 11.3" />
570 </svg>
571 ),
572 },
573
574 custom: {
575 outline: <StarIcon className="size-full" />,
576 solid: <StarIcon className="size-full" />,
577 },
578
579 flags: {
580 outline: <FlagIcon className="size-full" />,
581 solid: <FlagIcon className="size-full" />,
582 },
583
584 foods: {
585 outline: <AppleIcon className="size-full" />,
586 solid: <AppleIcon className="size-full" />,
587 },
588
589 frequent: {
590 outline: <ClockIcon className="size-full" />,
591 solid: <ClockIcon className="size-full" />,
592 },
593
594 nature: {
595 outline: <LeafIcon className="size-full" />,
596 solid: <LeafIcon className="size-full" />,
597 },
598
599 objects: {
600 outline: <LightbulbIcon className="size-full" />,
601 solid: <LightbulbIcon className="size-full" />,
602 },
603
604 people: {
605 outline: <SmileIcon className="size-full" />,
606 solid: <SmileIcon className="size-full" />,
607 },
608
609 places: {
610 outline: <CompassIcon className="size-full" />,
611 solid: <CompassIcon className="size-full" />,
612 },
613
614 symbols: {
615 outline: <MusicIcon className="size-full" />,
616 solid: <MusicIcon className="size-full" />,
617 },
618 };
619
620 const emojiSearchIcons = {
621 delete: <XIcon className="size-4 text-current" />,
622 loupe: <SearchIcon className="size-4 text-current" />,
623 };
624
624 lines Plain Text