add emoji list

This commit is contained in:
ChaosExAnima 2025-11-13 14:04:18 +01:00
parent c719ac23df
commit ae308fd3a9
No known key found for this signature in database
GPG Key ID: 8F2B333100FB6117
5 changed files with 265 additions and 26 deletions

View File

@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { action } from 'storybook/actions';
import {
importCustomEmojiData,
@ -7,14 +8,18 @@ import {
import { MockEmojiPicker } from './index';
const onSelect = action('emoji selected');
const meta = {
title: 'Components/Emoji/EmojiPicker',
render() {
render(_args, { globals }) {
const locale = typeof globals.locale === 'string' ? globals.locale : 'en';
void importCustomEmojiData();
void importEmojiData('en');
return <MockEmojiPicker />;
void importEmojiData(locale);
return <MockEmojiPicker onSelect={onSelect} />;
},
} satisfies Meta<typeof MockEmojiPicker>;
} satisfies Meta;
export default meta;

View File

@ -1,14 +1,13 @@
import { useCallback, useState } from 'react';
import type { FC, MouseEventHandler } from 'react';
import type { GroupMessage, MessagesDataset } from 'emojibase';
import messages from 'emojibase-data/en/messages.json';
import { loadUnicodeEmojiGroupIcon } from '@/mastodon/features/emoji/database';
import { useEmojiAppState } from '@/mastodon/features/emoji/mode';
import type {
CustomEmojiData,
UnicodeEmojiData,
} from '@/mastodon/features/emoji/types';
import type { AnyEmojiData } from '@/mastodon/features/emoji/types';
import { usePrevious } from '@/mastodon/hooks/usePrevious';
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
import { Emoji } from '..';
@ -20,9 +19,24 @@ import {
mockCustomEmojis,
mockCustomGroups,
} from './constants';
import { PickerGroupList } from './list';
import classes from './styles.module.css';
export const MockEmojiPicker: FC = () => {
interface MockEmojiPickerProps {
onSelect?: (emojiCode: string) => void;
}
export const MockEmojiPicker: FC<MockEmojiPickerProps> = ({ onSelect }) => {
const handleEmojiSelect = useCallback(
(emoji: AnyEmojiData) => {
if (onSelect) {
const code =
'unicode' in emoji ? emoji.unicode : `:${emoji.shortcode}:`;
onSelect(code);
}
},
[onSelect],
);
const handleGroupSelect = useCallback((key: string) => {
// eslint-disable-next-line no-console
console.log('Selected group:', key);
@ -34,6 +48,24 @@ export const MockEmojiPicker: FC = () => {
setShowSettings((prev) => !prev);
}, []);
const { currentLocale } = useEmojiAppState();
// This isn't needed in real life, as the current locale is only set on page load.
const prevLocale = usePrevious(currentLocale);
const [groups, setGroups] = useState<GroupMessage[]>([]);
if (prevLocale !== currentLocale) {
// This is messy, but it's just for the mock picker.
import(
`../../../../../../node_modules/emojibase-data/${currentLocale}/messages.json`
)
.then((module: { default: MessagesDataset }) => {
setGroups(module.default.groups);
})
.catch((err: unknown) => {
console.warn('fell back to en messages', err);
setGroups(messages.groups);
});
}
return (
<CustomEmojiProvider emojis={mockCustomEmojis}>
<div className={classes.wrapper}>
@ -51,7 +83,26 @@ export const MockEmojiPicker: FC = () => {
/>
</div>
{showSettings && <div className={classes.main}>Settings here</div>}
{!showSettings && <div className={classes.main} />}
{!showSettings && (
<div className={classes.main}>
{mockCustomGroups.map((group) => (
<PickerGroupList
key={group.key}
group={group.key}
name={group.message}
onSelect={handleEmojiSelect}
/>
))}
{groups.map((group) => (
<PickerGroupList
key={group.key}
group={group.key}
name={group.message}
onSelect={handleEmojiSelect}
/>
))}
</div>
)}
<ul className={classes.nav}>
{mockCustomGroups.map((group) => (
<PickerNavButton
@ -62,7 +113,7 @@ export const MockEmojiPicker: FC = () => {
/>
))}
<li key='separator' className={classes.separator} />
{messages.groups.map((group) => (
{groups.map((group) => (
<PickerNavButton
key={group.key}
onSelect={handleGroupSelect}
@ -91,14 +142,12 @@ const PickerNavButton: FC<PickerNavProps> = ({ onSelect, message, group }) => {
[onSelect, group],
);
const { currentLocale } = useEmojiAppState();
const [icon, setIcon] = useState<UnicodeEmojiData | CustomEmojiData | null>(
() => {
const emoji = mockCustomEmojis.find((emoji) => emoji.category === group);
return emoji ?? null;
},
);
const [icon, setIcon] = useState<AnyEmojiData | null>(() => {
const emoji = mockCustomEmojis.find((emoji) => emoji.category === group);
return emoji ?? null;
});
if (group in groupKeysToNumber) {
if (group in groupKeysToNumber && icon === null) {
const groupNum = groupKeysToNumber[group];
if (typeof groupNum !== 'undefined') {
void loadUnicodeEmojiGroupIcon(groupNum, currentLocale).then(setIcon);

View File

@ -0,0 +1,101 @@
import { useCallback, useState } from 'react';
import type { FC, MouseEventHandler } from 'react';
import classNames from 'classnames';
import { loadUnicodeEmojiGroup } from '@/mastodon/features/emoji/database';
import { useEmojiAppState } from '@/mastodon/features/emoji/mode';
import type { AnyEmojiData } from '@/mastodon/features/emoji/types';
import ArrowIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react';
import { Emoji } from '..';
import { groupKeysToNumber, mockCustomEmojis } from './constants';
import classes from './styles.module.css';
interface PickerGroupListProps {
group: string;
name: string;
onSelect: (emoji: AnyEmojiData) => void;
}
export const PickerGroupList: FC<PickerGroupListProps> = ({
group,
name,
onSelect,
}) => {
const [emojis, setEmojis] = useState<AnyEmojiData[] | null>(() => {
const emojis = mockCustomEmojis.filter((emoji) => emoji.category === group);
return emojis.length > 0 ? emojis : null;
});
const { currentLocale } = useEmojiAppState();
if (group in groupKeysToNumber && emojis === null) {
const groupNum = groupKeysToNumber[group];
if (typeof groupNum !== 'undefined') {
void loadUnicodeEmojiGroup(groupNum, currentLocale).then(setEmojis);
}
}
const [isMinimized, setMinimized] = useState(false);
const handleToggleMinimize = useCallback(() => {
setMinimized((prev) => !prev);
}, []);
// Still loading emojis.
if (emojis === null) {
return null;
}
return (
<div tabIndex={-1}>
<h2
className={classNames(
classes.groupHeader,
isMinimized && classes.isMinimized,
)}
>
<button type='button' onClick={handleToggleMinimize}>
{name}
<ArrowIcon className={classes.groupHeaderArrow} />
</button>
</h2>
{!isMinimized && (
<ul className={classes.emojiGrid}>
{emojis.map((emoji) => (
<PickerListEmoji
key={'unicode' in emoji ? emoji.hexcode : emoji.shortcode}
emoji={emoji}
onClick={onSelect}
/>
))}
</ul>
)}
</div>
);
};
interface PickerListEmojiProps {
emoji: AnyEmojiData;
onClick: (emoji: AnyEmojiData) => void;
}
const PickerListEmoji: FC<PickerListEmojiProps> = ({ emoji, onClick }) => {
const handleClick: MouseEventHandler = useCallback(() => {
onClick(emoji);
}, [emoji, onClick]);
return (
<li>
<button
type='button'
title={'unicode' in emoji ? emoji.label : `:${emoji.shortcode}:`}
onClick={handleClick}
className={classes.listButton}
>
<Emoji
code={'unicode' in emoji ? emoji.unicode : `:${emoji.shortcode}:`}
/>
</button>
</li>
);
};

View File

@ -49,6 +49,86 @@
overflow: hidden auto;
scrollbar-width: thin;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.groupHeader {
font-size: 1rem;
font-weight: bold;
margin-bottom: 0.5rem;
transition: opacity 0.1s ease;
&:not(.isMinimized):hover {
opacity: 0.7;
}
button {
appearance: none;
border: none;
background-color: transparent;
padding: 0;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
}
}
.groupHeader.isMinimized {
opacity: 0.5;
}
.groupHeaderArrow {
transition: transform 0.1s ease;
width: 1rem;
height: 1rem;
}
.isMinimized .groupHeaderArrow {
transform: rotate(-90deg);
}
.emojiGrid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.5rem;
}
.listButton,
.groupButton {
aspect-ratio: 1;
padding: 0;
appearance: none;
display: block;
> * {
pointer-events: none;
}
}
.listButton {
border: none;
background-color: transparent;
font-size: 1.5rem;
text-align: center;
width: 100%;
border-radius: 5px;
transition: background-color 0.1s ease;
&:hover {
background-color: gray;
}
> img {
padding: 5px;
}
> * {
width: 100%;
height: 100%;
}
}
.nav {
@ -61,16 +141,10 @@
}
.groupButton {
border-radius: 50%;
aspect-ratio: 1;
border: 1px solid gray;
width: 2rem;
height: 2rem;
padding: 0;
> * {
pointer-events: none;
}
border-radius: 50%;
border: 1px solid gray;
}
.separator {

View File

@ -172,6 +172,16 @@ export async function loadLatestEtag(localeString: string) {
return etag ?? null;
}
export async function loadUnicodeEmojiGroup(
group: number,
localeString: string,
) {
const locale = toLoadedLocale(localeString);
const db = await loadDB();
const emojis = await db.getAllFromIndex(locale, 'group', group);
return emojis.toSorted(({ order: a = 0 }, { order: b = 0 }) => a - b);
}
export async function loadUnicodeEmojiGroupIcon(
group: number,
localeString: string,