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

579 lines
16 KiB
TypeScript
Raw Normal View History

2025-07-29 02:54:53 +08:00
'use client';
import * as React from 'react';
import type { Emoji } from '@emoji-mart/data';
import {
type EmojiCategoryList,
type EmojiIconList,
type GridRow,
EmojiSettings
} from '@platejs/emoji';
import {
type EmojiDropdownMenuOptions,
type UseEmojiPickerType,
useEmojiDropdownMenuState
} from '@platejs/emoji/react';
import * as Popover from '@radix-ui/react-popover';
import {
Apple,
Flag,
Magnifier,
Leaf,
Lightbulb,
Music,
Star,
Xmark,
FaceGrin,
Clock,
Compass
} from '../../icons';
import { Button } from '@/components/ui/buttons';
import {
TooltipBase,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { ToolbarButton } from '@/components/ui/toolbar';
export function EmojiToolbarButton({
options,
...props
}: {
options?: EmojiDropdownMenuOptions;
} & React.ComponentPropsWithoutRef<typeof ToolbarButton>) {
const { emojiPickerState, isOpen, setIsOpen } = useEmojiDropdownMenuState(options);
return (
<EmojiPopover
control={
<ToolbarButton pressed={isOpen} tooltip="Emoji" isDropdown {...props}>
<FaceGrin />
</ToolbarButton>
}
isOpen={isOpen}
setIsOpen={setIsOpen}>
<EmojiPicker
{...emojiPickerState}
isOpen={isOpen}
setIsOpen={setIsOpen}
settings={options?.settings}
/>
</EmojiPopover>
);
}
export function EmojiPopover({
children,
control,
isOpen,
setIsOpen
}: {
children: React.ReactNode;
control: React.ReactNode;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}) {
return (
<Popover.Root open={isOpen} onOpenChange={setIsOpen}>
<Popover.Trigger asChild>{control}</Popover.Trigger>
<Popover.Portal>
<Popover.Content className="z-100">{children}</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
export function EmojiPicker({
clearSearch,
emoji,
emojiLibrary,
focusedCategory,
hasFound,
i18n,
icons = {
categories: emojiCategoryIcons,
search: emojiSearchIcons
},
isSearching,
refs,
searchResult,
searchValue,
setSearch,
settings = EmojiSettings,
visibleCategories,
handleCategoryClick,
onMouseOver,
onSelectEmoji
}: Omit<UseEmojiPickerType, 'icons'> & {
icons?: EmojiIconList<React.ReactElement>;
}) {
return (
<div
className={cn(
'bg-popover text-popover-foreground flex flex-col rounded-xl',
'h-[23rem] w-80 border shadow-md'
)}>
<EmojiPickerNavigation
onClick={handleCategoryClick}
emojiLibrary={emojiLibrary}
focusedCategory={focusedCategory}
i18n={i18n}
icons={icons}
/>
<EmojiPickerSearchBar i18n={i18n} searchValue={searchValue} setSearch={setSearch}>
<EmojiPickerSearchAndClear
clearSearch={clearSearch}
i18n={i18n}
searchValue={searchValue}
/>
</EmojiPickerSearchBar>
<EmojiPickerContent
onMouseOver={onMouseOver}
onSelectEmoji={onSelectEmoji}
emojiLibrary={emojiLibrary}
i18n={i18n}
isSearching={isSearching}
refs={refs}
searchResult={searchResult}
settings={settings}
visibleCategories={visibleCategories}
/>
<EmojiPickerPreview emoji={emoji} hasFound={hasFound} i18n={i18n} isSearching={isSearching} />
</div>
);
}
const EmojiButton = React.memo(function EmojiButton({
emoji,
index,
onMouseOver,
onSelect
}: {
emoji: Emoji;
index: number;
onMouseOver: (emoji?: Emoji) => void;
onSelect: (emoji: Emoji) => void;
}) {
return (
<button
className="group relative flex size-9 cursor-pointer items-center justify-center border-none bg-transparent text-2xl leading-none"
onClick={() => onSelect(emoji)}
onMouseEnter={() => onMouseOver(emoji)}
onMouseLeave={() => onMouseOver()}
aria-label={emoji.skins[0].native}
data-index={index}
tabIndex={-1}
type="button">
<div
className="absolute inset-0 rounded-full opacity-0 group-hover:opacity-100"
aria-hidden="true"
/>
<span
className="relative"
style={{
fontFamily:
'"Apple Color Emoji", "Segoe UI Emoji", NotoColorEmoji, "Noto Color Emoji", "Segoe UI Symbol", "Android Emoji", EmojiSymbols'
}}
data-emoji-set="native">
{emoji.skins[0].native}
</span>
</button>
);
});
const RowOfButtons = React.memo(function RowOfButtons({
emojiLibrary,
row,
onMouseOver,
onSelectEmoji
}: {
row: GridRow;
} & Pick<UseEmojiPickerType, 'emojiLibrary' | 'onMouseOver' | 'onSelectEmoji'>) {
return (
<div key={row.id} className="flex" data-index={row.id}>
{row.elements.map((emojiId, index) => (
<EmojiButton
key={emojiId}
onMouseOver={onMouseOver}
onSelect={onSelectEmoji}
emoji={emojiLibrary.getEmoji(emojiId)}
index={index}
/>
))}
</div>
);
});
function EmojiPickerContent({
emojiLibrary,
i18n,
isSearching = false,
refs,
searchResult,
settings = EmojiSettings,
visibleCategories,
onMouseOver,
onSelectEmoji
}: Pick<
UseEmojiPickerType,
| 'emojiLibrary'
| 'i18n'
| 'isSearching'
| 'onMouseOver'
| 'onSelectEmoji'
| 'refs'
| 'searchResult'
| 'settings'
| 'visibleCategories'
>) {
const getRowWidth = settings.perLine.value * settings.buttonSize.value;
const isCategoryVisible = React.useCallback(
(categoryId: any) => {
return visibleCategories.has(categoryId) ? visibleCategories.get(categoryId) : false;
},
[visibleCategories]
);
const EmojiList = React.useCallback(() => {
return emojiLibrary
.getGrid()
.sections()
.map(({ id: categoryId }) => {
const section = emojiLibrary.getGrid().section(categoryId);
const { buttonSize } = settings;
return (
<div
key={categoryId}
2025-07-29 03:15:11 +08:00
ref={section.root as React.RefObject<HTMLDivElement>}
2025-07-29 02:54:53 +08:00
style={{ width: getRowWidth }}
data-id={categoryId}>
<div className="bg-popover/90 sticky -top-px z-1 p-1 py-2 text-sm font-semibold backdrop-blur-xs">
{i18n.categories[categoryId]}
</div>
<div
className="relative flex flex-wrap"
style={{ height: section.getRows().length * buttonSize.value }}>
{isCategoryVisible(categoryId) &&
section
.getRows()
.map((row: GridRow) => (
<RowOfButtons
key={row.id}
onMouseOver={onMouseOver}
onSelectEmoji={onSelectEmoji}
emojiLibrary={emojiLibrary}
row={row}
/>
))}
</div>
</div>
);
});
}, [
emojiLibrary,
getRowWidth,
i18n.categories,
isCategoryVisible,
onSelectEmoji,
onMouseOver,
settings
]);
const SearchList = React.useCallback(() => {
return (
<div style={{ width: getRowWidth }} data-id="search">
<div className="bg-popover/90 text-card-foreground sticky -top-px z-1 p-1 py-2 text-sm font-semibold backdrop-blur-xs">
{i18n.searchResult}
</div>
<div className="relative flex flex-wrap">
{searchResult.map((emoji: Emoji, index: number) => (
<EmojiButton
key={emoji.id}
onMouseOver={onMouseOver}
onSelect={onSelectEmoji}
emoji={emojiLibrary.getEmoji(emoji.id)}
index={index}
/>
))}
</div>
</div>
);
}, [emojiLibrary, getRowWidth, i18n.searchResult, searchResult, onSelectEmoji, onMouseOver]);
return (
<div
2025-07-29 03:15:11 +08:00
ref={refs.current.contentRoot as React.RefObject<HTMLDivElement>}
2025-07-29 02:54:53 +08:00
className={cn(
'h-full min-h-[50%] overflow-x-hidden overflow-y-auto px-2',
'[&::-webkit-scrollbar]:w-4',
'[&::-webkit-scrollbar-button]:hidden [&::-webkit-scrollbar-button]:size-0',
'[&::-webkit-scrollbar-thumb]:bg-muted [&::-webkit-scrollbar-thumb]:hover:bg-muted-foreground/25 [&::-webkit-scrollbar-thumb]:min-h-11 [&::-webkit-scrollbar-thumb]:rounded-full',
'[&::-webkit-scrollbar-thumb]:border-popover [&::-webkit-scrollbar-thumb]:border-4 [&::-webkit-scrollbar-thumb]:border-solid [&::-webkit-scrollbar-thumb]:bg-clip-padding'
)}
data-id="scroll">
2025-07-29 03:15:11 +08:00
<div ref={refs.current.content as React.RefObject<HTMLDivElement>} className="h-full">
2025-07-29 02:54:53 +08:00
{isSearching ? SearchList() : EmojiList()}
</div>
</div>
);
}
function EmojiPickerSearchBar({
children,
i18n,
searchValue,
setSearch
}: {
children: React.ReactNode;
} & Pick<UseEmojiPickerType, 'i18n' | 'searchValue' | 'setSearch'>) {
return (
<div className="flex items-center px-2">
<div className="relative flex grow items-center">
<input
className="bg-muted placeholder:text-muted-foreground block w-full appearance-none rounded-full border-0 px-10 py-2 text-sm outline-none focus-visible:outline-none"
value={searchValue}
onChange={(event) => setSearch(event.target.value)}
placeholder={i18n.search}
aria-label="Search"
autoComplete="off"
type="text"
autoFocus
/>
{children}
</div>
</div>
);
}
function EmojiPickerSearchAndClear({
clearSearch,
i18n,
searchValue
}: Pick<UseEmojiPickerType, 'clearSearch' | 'i18n' | 'searchValue'>) {
return (
<div className="text-foreground flex items-center">
<div
className={cn(
'text-foreground absolute top-1/2 left-2.5 z-10 flex size-5 -translate-y-1/2 items-center justify-center'
)}>
{emojiSearchIcons.loupe}
</div>
{searchValue && (
<Button
variant="ghost"
className={cn(
'text-popover-foreground 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 hover:bg-transparent'
)}
onClick={clearSearch}
title={i18n.clear}
aria-label="Clear"
type="button"
prefix={emojiSearchIcons.delete}
/>
)}
</div>
);
}
function EmojiPreview({ emoji }: Pick<UseEmojiPickerType, 'emoji'>) {
return (
<div className="border-muted flex h-14 max-h-14 min-h-14 items-center border-t p-2">
<div className="flex items-center justify-center text-2xl">{emoji?.skins[0].native}</div>
<div className="overflow-hidden pl-2">
<div className="truncate text-sm font-semibold">{emoji?.name}</div>
<div className="truncate text-sm">{`:${emoji?.id}:`}</div>
</div>
</div>
);
}
function NoEmoji({ i18n }: Pick<UseEmojiPickerType, 'i18n'>) {
return (
<div className="border-muted flex h-14 max-h-14 min-h-14 items-center border-t p-2">
<div className="flex items-center justify-center text-2xl">😢</div>
<div className="overflow-hidden pl-2">
<div className="truncate text-sm font-bold">{i18n.searchNoResultsTitle}</div>
<div className="truncate text-sm">{i18n.searchNoResultsSubtitle}</div>
</div>
</div>
);
}
function PickAnEmoji({ i18n }: Pick<UseEmojiPickerType, 'i18n'>) {
return (
<div className="border-muted flex h-14 max-h-14 min-h-14 items-center border-t p-2">
<div className="flex items-center justify-center text-2xl"></div>
<div className="overflow-hidden pl-2">
<div className="truncate text-sm font-semibold">{i18n.pick}</div>
</div>
</div>
);
}
function EmojiPickerPreview({
emoji,
hasFound = true,
i18n,
isSearching = false,
...props
}: Pick<UseEmojiPickerType, 'emoji' | 'hasFound' | 'i18n' | 'isSearching'>) {
const showPickEmoji = !emoji && (!isSearching || hasFound);
const showNoEmoji = isSearching && !hasFound;
const showPreview = emoji && !showNoEmoji && !showNoEmoji;
return (
<>
{showPreview && <EmojiPreview emoji={emoji} {...props} />}
{showPickEmoji && <PickAnEmoji i18n={i18n} {...props} />}
{showNoEmoji && <NoEmoji i18n={i18n} {...props} />}
</>
);
}
function EmojiPickerNavigation({
emojiLibrary,
focusedCategory,
i18n,
icons,
onClick
}: {
onClick: (id: EmojiCategoryList) => void;
} & Pick<UseEmojiPickerType, 'emojiLibrary' | 'focusedCategory' | 'i18n' | 'icons'>) {
return (
<TooltipProvider delayDuration={500}>
<nav id="emoji-nav" className="border-b-border mb-2.5 border-0 border-b border-solid p-1.5">
<div className="relative flex items-center justify-evenly">
{emojiLibrary
.getGrid()
.sections()
.map(({ id }) => (
<TooltipBase key={id}>
<TooltipTrigger asChild>
<Button
size="small"
variant="ghost"
className={cn(
'text-muted-foreground hover:bg-muted hover:text-muted-foreground h-fit rounded-full fill-current p-1.5',
id === focusedCategory &&
'bg-accent text-accent-foreground pointer-events-none fill-current'
)}
onClick={() => {
onClick(id);
}}
aria-label={i18n.categories[id]}
type="button">
<span className="inline-flex size-5 items-center justify-center">
{icons.categories[id].outline}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{i18n.categories[id]}</TooltipContent>
</TooltipBase>
))}
</div>
</nav>
</TooltipProvider>
);
}
const emojiCategoryIcons: Record<
EmojiCategoryList,
{
outline: React.ReactElement;
solid: React.ReactElement; // Needed to add another solid variant - outline will be used for now
}
> = {
activity: {
outline: (
<svg
className="size-full"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" />
<path d="M2.1 13.4A10.1 10.1 0 0 0 13.4 2.1" />
<path d="m5 4.9 14 14.2" />
<path d="M21.9 10.6a10.1 10.1 0 0 0-11.3 11.3" />
</svg>
),
solid: (
<svg
className="size-full"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" />
<path d="M2.1 13.4A10.1 10.1 0 0 0 13.4 2.1" />
<path d="m5 4.9 14 14.2" />
<path d="M21.9 10.6a10.1 10.1 0 0 0-11.3 11.3" />
</svg>
)
},
custom: {
outline: <Star />,
solid: <Star />
},
flags: {
outline: <Flag />,
solid: <Flag />
},
foods: {
outline: <Apple />,
solid: <Apple />
},
frequent: {
outline: <Clock />,
solid: <Clock />
},
nature: {
outline: <Leaf />,
solid: <Leaf />
},
objects: {
outline: <Lightbulb />,
solid: <Lightbulb />
},
people: {
outline: <FaceGrin />,
solid: <FaceGrin />
},
places: {
outline: <Compass />,
solid: <Compass />
},
symbols: {
outline: <Music />,
solid: <Music />
}
};
const emojiSearchIcons = {
delete: <Xmark />,
loupe: <Magnifier />
};