mirror of
https://github.com/mastodon/mastodon.git
synced 2025-11-29 10:53:39 +00:00
Merge df1f2e8706 into 53703202fb
This commit is contained in:
commit
f426c5ee97
74
app/javascript/mastodon/components/emoji/picker/constants.ts
Normal file
74
app/javascript/mastodon/components/emoji/picker/constants.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import type { GroupKey, GroupMessage } from 'emojibase';
|
||||
import groupData from 'emojibase-data/meta/groups.json';
|
||||
|
||||
import type { CustomEmojiData } from '@/mastodon/features/emoji/types';
|
||||
|
||||
type CustomGroupMessage = Omit<GroupMessage, 'key'> & {
|
||||
key: string;
|
||||
};
|
||||
|
||||
export const groupsToHide: readonly GroupKey[] = ['component'];
|
||||
|
||||
export const groupKeysToNumber = Object.fromEntries(
|
||||
Object.entries(groupData.groups).map(([number, key]) => [
|
||||
key,
|
||||
Number(number),
|
||||
]),
|
||||
);
|
||||
|
||||
export const mockCustomGroups = [
|
||||
{ key: 'blobcat', message: 'Blobcat', order: 1 },
|
||||
{ key: 'lgbt', message: 'LGBTQ+', order: 2 },
|
||||
{ key: 'logos', message: 'Logos', order: 3 },
|
||||
] satisfies CustomGroupMessage[];
|
||||
|
||||
export const mockCustomEmojis = [
|
||||
{
|
||||
shortcode: 'blobcat_heart',
|
||||
url: 'https://pics.ishella.gay/custom_emojis/images/000/001/217/original/abede62a1fe634cf.png',
|
||||
static_url:
|
||||
'https://pics.ishella.gay/custom_emojis/images/000/001/217/static/abede62a1fe634cf.png',
|
||||
visible_in_picker: true,
|
||||
category: 'blobcat',
|
||||
},
|
||||
{
|
||||
shortcode: 'blobcat_wave',
|
||||
url: 'https://pics.ishella.gay/custom_emojis/images/000/001/250/original/f924277a36414906.png',
|
||||
static_url:
|
||||
'https://pics.ishella.gay/custom_emojis/images/000/001/250/static/f924277a36414906.png',
|
||||
visible_in_picker: true,
|
||||
category: 'blobcat',
|
||||
},
|
||||
{
|
||||
shortcode: 'mastodon',
|
||||
url: 'https://pics.ishella.gay/custom_emojis/images/000/025/993/original/56c38669cdca5d1c.png',
|
||||
static_url:
|
||||
'https://pics.ishella.gay/custom_emojis/images/000/025/993/static/56c38669cdca5d1c.png',
|
||||
visible_in_picker: true,
|
||||
category: 'mastodon',
|
||||
},
|
||||
{
|
||||
shortcode: 'fediverse',
|
||||
url: 'https://pics.ishella.gay/custom_emojis/images/000/001/198/original/b8041a4f365c4518.png',
|
||||
static_url:
|
||||
'https://pics.ishella.gay/custom_emojis/images/000/001/198/static/b8041a4f365c4518.png',
|
||||
visible_in_picker: true,
|
||||
category: 'logos',
|
||||
},
|
||||
{
|
||||
shortcode: 'ace_heart',
|
||||
url: 'https://pics.ishella.gay/custom_emojis/images/000/001/220/original/8689758e37a1bfbc.png',
|
||||
static_url:
|
||||
'https://pics.ishella.gay/custom_emojis/images/000/001/220/static/8689758e37a1bfbc.png',
|
||||
visible_in_picker: true,
|
||||
category: 'lgbt',
|
||||
},
|
||||
{
|
||||
shortcode: 'nbi_heart',
|
||||
url: 'https://pics.ishella.gay/custom_emojis/images/000/001/199/original/a06d788bce50f260.png',
|
||||
static_url:
|
||||
'https://pics.ishella.gay/custom_emojis/images/000/001/199/static/a06d788bce50f260.png',
|
||||
visible_in_picker: true,
|
||||
category: 'lgbt',
|
||||
},
|
||||
] satisfies (CustomEmojiData & { category: string })[];
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import type { FC } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import type { CompactEmoji } from 'emojibase';
|
||||
import { flattenEmojiData } from 'emojibase';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import { putEmojiData } from '@/mastodon/features/emoji/database';
|
||||
import { toSupportedLocale } from '@/mastodon/features/emoji/locale';
|
||||
|
||||
import { MockEmojiPicker } from './index';
|
||||
|
||||
const onSelect = action('emoji selected');
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Emoji/EmojiPicker',
|
||||
render(_args, { globals }) {
|
||||
const locale = typeof globals.locale === 'string' ? globals.locale : 'en';
|
||||
return <StoryComponent locale={locale} key={locale} />;
|
||||
},
|
||||
} satisfies Meta;
|
||||
|
||||
const StoryComponent: FC<{ locale: string }> = ({ locale }) => {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
if (!loaded) {
|
||||
void loadEmojiData(locale).then(() => {
|
||||
action('emoji data loaded')(locale);
|
||||
setLoaded(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
return <MockEmojiPicker onSelect={onSelect} />;
|
||||
};
|
||||
|
||||
async function loadEmojiData(localeString: string) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
const emojis = (await import(
|
||||
`../../../../../../node_modules/emojibase-data/${locale}/compact.json`
|
||||
)) as { default: CompactEmoji[] };
|
||||
const flattenedEmojis = flattenEmojiData(emojis.default);
|
||||
await putEmojiData(flattenedEmojis, locale);
|
||||
}
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import type { FC, MouseEventHandler } from 'react';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { loadUnicodeEmojiGroupIcon } from '@/mastodon/features/emoji/database';
|
||||
import { useEmojiAppState } from '@/mastodon/features/emoji/mode';
|
||||
import type { AnyEmojiData } from '@/mastodon/features/emoji/types';
|
||||
|
||||
import { Emoji } from '..';
|
||||
|
||||
import { mockCustomEmojis, groupKeysToNumber } from './constants';
|
||||
import classes from './styles.module.css';
|
||||
|
||||
interface PickerGroupButtonProps {
|
||||
onSelect: (key: string) => void;
|
||||
group: string;
|
||||
message: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const PickerGroupButton: FC<PickerGroupButtonProps> = ({
|
||||
onSelect,
|
||||
message,
|
||||
group,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const handleClick: MouseEventHandler = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
onSelect(group);
|
||||
},
|
||||
[onSelect, group],
|
||||
);
|
||||
const { currentLocale } = useEmojiAppState();
|
||||
const [icon, setIcon] = useState<AnyEmojiData | null>(() => {
|
||||
const emoji = mockCustomEmojis.find((emoji) => emoji.category === group);
|
||||
return emoji ?? null;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (icon !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (group in groupKeysToNumber) {
|
||||
const groupNum = groupKeysToNumber[group];
|
||||
if (typeof groupNum !== 'undefined') {
|
||||
void loadUnicodeEmojiGroupIcon(groupNum, currentLocale).then(setIcon);
|
||||
}
|
||||
}
|
||||
}, [currentLocale, group, icon]);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type='button'
|
||||
title={message}
|
||||
onClick={handleClick}
|
||||
className={classNames(classes.groupButton)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon && (
|
||||
<Emoji
|
||||
code={'unicode' in icon ? icon.unicode : `:${icon.shortcode}:`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
40
app/javascript/mastodon/components/emoji/picker/hooks.ts
Normal file
40
app/javascript/mastodon/components/emoji/picker/hooks.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { usePrevious } from '@dnd-kit/utilities';
|
||||
import type { MessagesDataset } from 'emojibase';
|
||||
import enMessages from 'emojibase-data/en/messages.json';
|
||||
|
||||
import { useEmojiAppState } from '@/mastodon/features/emoji/mode';
|
||||
|
||||
import { groupsToHide } from './constants';
|
||||
|
||||
export function useLocaleMessages() {
|
||||
const { currentLocale } = useEmojiAppState();
|
||||
// This isn't needed in real life, as the current locale is only set on page load.
|
||||
// However it Storybook can update the locale without a refresh.
|
||||
const prevLocale = usePrevious(currentLocale);
|
||||
|
||||
const [messages, setMessages] = useState<MessagesDataset>(enMessages);
|
||||
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 }) => {
|
||||
setMessages(module.default);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.warn('fell back to en messages', err);
|
||||
});
|
||||
}
|
||||
|
||||
const groups = useMemo(
|
||||
() => messages.groups.filter((group) => !groupsToHide.includes(group.key)),
|
||||
[messages.groups],
|
||||
);
|
||||
|
||||
return {
|
||||
...messages,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
136
app/javascript/mastodon/components/emoji/picker/index.tsx
Normal file
136
app/javascript/mastodon/components/emoji/picker/index.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { FC, MouseEventHandler } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { AnyEmojiData } from '@/mastodon/features/emoji/types';
|
||||
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
||||
|
||||
import { IconButton } from '../../icon_button';
|
||||
import { CustomEmojiProvider } from '../context';
|
||||
|
||||
import { mockCustomEmojis, mockCustomGroups } from './constants';
|
||||
import { PickerGroupButton } from './group-button';
|
||||
import { useLocaleMessages } from './hooks';
|
||||
import { PickerGroupList } from './list';
|
||||
import { PickerSettings } from './settings';
|
||||
import classes from './styles.module.css';
|
||||
|
||||
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 { groups } = useLocaleMessages();
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const handleGroupSelect = useCallback((key: string) => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
if (mockCustomGroups.at(0)?.key === key) {
|
||||
wrapper.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
const groupHeader = wrapper.querySelector<HTMLHeadingElement>(
|
||||
`[data-group="${key}"]`,
|
||||
);
|
||||
if (groupHeader) {
|
||||
groupHeader.focus({ preventScroll: true });
|
||||
groupHeader.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const searchInput = searchRef.current;
|
||||
if (!searchInput) return;
|
||||
|
||||
searchInput.focus();
|
||||
}, []);
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const handleSettingsClick: MouseEventHandler = useCallback((event) => {
|
||||
event.preventDefault();
|
||||
setShowSettings((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CustomEmojiProvider emojis={mockCustomEmojis}>
|
||||
<div className={classes.wrapper}>
|
||||
<div className={classes.header}>
|
||||
<input
|
||||
type='search'
|
||||
placeholder='Search emojis'
|
||||
className={classes.search}
|
||||
ref={searchRef}
|
||||
/>
|
||||
<IconButton
|
||||
icon='settings'
|
||||
iconComponent={SettingsIcon}
|
||||
title='Picker settings'
|
||||
onClick={handleSettingsClick}
|
||||
/>
|
||||
</div>
|
||||
{showSettings && <PickerSettings />}
|
||||
{!showSettings && (
|
||||
<div className={classes.main} ref={wrapperRef}>
|
||||
{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={classNames(
|
||||
classes.nav,
|
||||
showSettings && classes.settingsNav,
|
||||
)}
|
||||
>
|
||||
{mockCustomGroups.map((group) => (
|
||||
<PickerGroupButton
|
||||
key={group.key}
|
||||
onSelect={handleGroupSelect}
|
||||
message={group.message}
|
||||
group={group.key}
|
||||
disabled={showSettings}
|
||||
/>
|
||||
))}
|
||||
<li key='separator' className={classes.separator} />
|
||||
{groups.map((group) => (
|
||||
<PickerGroupButton
|
||||
key={group.key}
|
||||
onSelect={handleGroupSelect}
|
||||
message={group.message}
|
||||
group={group.key}
|
||||
disabled={showSettings}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CustomEmojiProvider>
|
||||
);
|
||||
};
|
||||
101
app/javascript/mastodon/components/emoji/picker/list.tsx
Normal file
101
app/javascript/mastodon/components/emoji/picker/list.tsx
Normal 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} data-group={group}>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
36
app/javascript/mastodon/components/emoji/picker/settings.tsx
Normal file
36
app/javascript/mastodon/components/emoji/picker/settings.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { FC } from 'react';
|
||||
|
||||
import type { SkinToneKey } from 'emojibase';
|
||||
|
||||
import { useLocaleMessages } from './hooks';
|
||||
import classes from './styles.module.css';
|
||||
|
||||
const toneToEmoji: Record<SkinToneKey, string> = {
|
||||
light: '👋🏻',
|
||||
'medium-light': '👋🏼',
|
||||
medium: '👋🏽',
|
||||
'medium-dark': '👋🏾',
|
||||
dark: '👋🏿',
|
||||
};
|
||||
|
||||
export const PickerSettings: FC = () => {
|
||||
const { skinTones } = useLocaleMessages();
|
||||
return (
|
||||
<div className={classes.main}>
|
||||
<h1>Emoji Settings</h1>
|
||||
<label>
|
||||
<select>
|
||||
<option value='default' title='Default skin tone'>
|
||||
👋
|
||||
</option>
|
||||
{skinTones.map((tone) => (
|
||||
<option key={tone.key} value={tone.key} title={tone.message}>
|
||||
{toneToEmoji[tone.key]}
|
||||
</option>
|
||||
))}
|
||||
</select>{' '}
|
||||
Skin tone
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
.wrapper {
|
||||
--max-width: 20rem;
|
||||
--col-count: 6;
|
||||
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: 2rem minmax(10rem, calc(var(--max-width) - 4rem));
|
||||
background-color: lightgray;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
width: 100%;
|
||||
max-width: var(--max-width);
|
||||
max-height: 50vh;
|
||||
box-sizing: border-box;
|
||||
|
||||
:global(.emojione) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
grid-area: 1 / 1 / 1 / 3;
|
||||
|
||||
svg {
|
||||
color: gray;
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
flex-grow: 1;
|
||||
appearance: none;
|
||||
border: 1px solid gray;
|
||||
border-radius: 0.25rem;
|
||||
height: 100%;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
grid-area: 2 / 2 / 3 / 3;
|
||||
border-radius: 0.25rem;
|
||||
background-color: whitesmoke;
|
||||
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(var(--col-count), 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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
grid-area: 2 / 1 / 3 / 1;
|
||||
overflow: hidden scroll;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.groupButton {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid gray;
|
||||
|
||||
.settingsNav & {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 1px;
|
||||
background-color: gray;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
|
@ -30,8 +30,9 @@ interface LocaleTable {
|
|||
value: UnicodeEmojiData;
|
||||
indexes: {
|
||||
group: number;
|
||||
groupOrder: [number, number];
|
||||
label: string;
|
||||
order: number;
|
||||
order?: number;
|
||||
tags: string[];
|
||||
};
|
||||
}
|
||||
|
|
@ -39,7 +40,7 @@ type LocaleTables = Record<Locale, LocaleTable>;
|
|||
|
||||
type Database = IDBPDatabase<EmojiDB>;
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
const SCHEMA_VERSION = 2;
|
||||
|
||||
const loadedLocales = new Set<Locale>();
|
||||
|
||||
|
|
@ -52,24 +53,42 @@ const loadDB = (() => {
|
|||
// Actually load the DB.
|
||||
async function initDB() {
|
||||
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||
upgrade(database) {
|
||||
const customTable = database.createObjectStore('custom', {
|
||||
keyPath: 'shortcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
customTable.createIndex('category', 'category');
|
||||
|
||||
database.createObjectStore('etags');
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const localeTable = database.createObjectStore(locale, {
|
||||
keyPath: 'hexcode',
|
||||
upgrade(database, oldVersion, _newVersion, transaction) {
|
||||
const storeNames = database.objectStoreNames;
|
||||
if (!storeNames.contains('custom')) {
|
||||
const customTable = database.createObjectStore('custom', {
|
||||
keyPath: 'shortcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
localeTable.createIndex('group', 'group');
|
||||
localeTable.createIndex('label', 'label');
|
||||
localeTable.createIndex('order', 'order');
|
||||
localeTable.createIndex('tags', 'tags', { multiEntry: true });
|
||||
customTable.createIndex('category', 'category');
|
||||
}
|
||||
|
||||
if (!storeNames.contains('etags')) {
|
||||
database.createObjectStore('etags');
|
||||
}
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
if (!storeNames.contains(locale)) {
|
||||
database.createObjectStore(locale, {
|
||||
keyPath: 'hexcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
}
|
||||
const localeTable = transaction.objectStore(locale);
|
||||
|
||||
if (oldVersion < 1) {
|
||||
localeTable.createIndex('group', 'group');
|
||||
localeTable.createIndex('label', 'label');
|
||||
localeTable.createIndex('tags', 'tags', { multiEntry: true });
|
||||
}
|
||||
if (oldVersion < 2) {
|
||||
if (localeTable.indexNames.contains('order')) {
|
||||
localeTable.deleteIndex('order');
|
||||
}
|
||||
localeTable.createIndex('groupOrder', ['group', 'order'], {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -172,6 +191,29 @@ 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,
|
||||
) {
|
||||
const locale = toLoadedLocale(localeString);
|
||||
const db = await loadDB();
|
||||
const trx = db.transaction(locale, 'readonly');
|
||||
const index = trx.store.index('groupOrder');
|
||||
const range = IDBKeyRange.bound([group, 0], [group, Number.MAX_SAFE_INTEGER]);
|
||||
const cursor = await index.openCursor(range);
|
||||
return cursor?.value ?? null;
|
||||
}
|
||||
|
||||
// Private functions
|
||||
|
||||
async function syncLocales(db: Database) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import type { CompactEmoji } from 'emojibase';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import { toSupportedLocale } from '@/mastodon/features/emoji/locale';
|
||||
|
||||
import { customEmojiFactory, relationshipsFactory } from './factories';
|
||||
|
||||
export const mockHandlers = {
|
||||
|
|
@ -47,21 +44,6 @@ export const mockHandlers = {
|
|||
action('fetching custom emoji data')();
|
||||
return HttpResponse.json([customEmojiFactory()]);
|
||||
}),
|
||||
emojiData: http.get<{ locale: string }>(
|
||||
'/packs-dev/emoji/:locale.json',
|
||||
async ({ params }) => {
|
||||
const locale = toSupportedLocale(params.locale);
|
||||
action('fetching emoji data')(locale);
|
||||
const { default: data } = (await import(
|
||||
/* @vite-ignore */
|
||||
`emojibase-data/${locale}/compact.json`
|
||||
)) as {
|
||||
default: CompactEmoji[];
|
||||
};
|
||||
|
||||
return HttpResponse.json([data]);
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
export const unhandledRequestHandler = ({ url }: Request) => {
|
||||
|
|
|
|||
|
|
@ -43,5 +43,14 @@ module.exports = {
|
|||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
'files': ['app/javascript/**/*.module.scss', 'app/javascript/**/*.module.css'],
|
||||
rules: {
|
||||
'selector-pseudo-class-no-unknown': [
|
||||
true,
|
||||
{ ignorePseudoClasses: ['global'] },
|
||||
],
|
||||
}
|
||||
}
|
||||
],
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user