mirror of https://github.com/buster-so/buster.git
579 lines
16 KiB
TypeScript
579 lines
16 KiB
TypeScript
'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}
|
||
ref={section.root as React.RefObject<HTMLDivElement>}
|
||
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
|
||
ref={refs.current.contentRoot as React.RefObject<HTMLDivElement>}
|
||
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">
|
||
<div ref={refs.current.content as React.RefObject<HTMLDivElement>} className="h-full">
|
||
{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 />
|
||
};
|