Fuzzy-Searchable Emoji Picker
This commit is contained in:
parent
2f47a3570b
commit
0e9d549cf3
48 changed files with 1697 additions and 280 deletions
21
ts/components/emoji/Emoji.md
Normal file
21
ts/components/emoji/Emoji.md
Normal file
|
@ -0,0 +1,21 @@
|
|||
#### Simple Emoji
|
||||
|
||||
```jsx
|
||||
<div>
|
||||
<Emoji shortName="grinning_face_with_star_eyes" />
|
||||
<Emoji shortName="grinning_face_with_star_eyes" size={64} />
|
||||
</div>
|
||||
```
|
||||
|
||||
#### More Options
|
||||
|
||||
```jsx
|
||||
<div>
|
||||
<Emoji inline shortName="raised_back_of_hand" />
|
||||
<Emoji inline shortName="raised_back_of_hand" skinTone={1} />
|
||||
<Emoji inline shortName="raised_back_of_hand" skinTone={2} />
|
||||
<Emoji inline shortName="raised_back_of_hand" skinTone={3} />
|
||||
<Emoji inline shortName="raised_back_of_hand" skinTone={4} />
|
||||
<Emoji inline shortName="raised_back_of_hand" skinTone={5} />
|
||||
</div>
|
||||
```
|
43
ts/components/emoji/Emoji.tsx
Normal file
43
ts/components/emoji/Emoji.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import * as React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { getSheetCoordinates, SkinToneKey } from './lib';
|
||||
|
||||
export type OwnProps = {
|
||||
inline?: boolean;
|
||||
shortName: string;
|
||||
skinTone?: SkinToneKey | number;
|
||||
size?: 16 | 20 | 28 | 32 | 64 | 66;
|
||||
};
|
||||
|
||||
export type Props = OwnProps &
|
||||
Pick<React.HTMLProps<HTMLDivElement>, 'style' | 'className'>;
|
||||
|
||||
export const Emoji = React.memo(
|
||||
React.forwardRef<HTMLDivElement, Props>(
|
||||
(
|
||||
{ style = {}, size = 28, shortName, skinTone, inline, className }: Props,
|
||||
ref
|
||||
) => {
|
||||
const [sheetX, sheetY] = getSheetCoordinates(shortName, skinTone);
|
||||
const x = -(size * sheetX);
|
||||
const y = -(size * sheetY);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
'module-emoji',
|
||||
`module-emoji--${size}px`,
|
||||
inline ? 'module-emoji--inline' : null,
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
...style,
|
||||
backgroundPositionX: `${x}px`,
|
||||
backgroundPositionY: `${y}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
56
ts/components/emoji/EmojiButton.md
Normal file
56
ts/components/emoji/EmojiButton.md
Normal file
|
@ -0,0 +1,56 @@
|
|||
#### Default
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<div
|
||||
style={{
|
||||
height: '500px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<EmojiButton
|
||||
i18n={util.i18n}
|
||||
onPickEmoji={e => console.log('onPickEmoji', e)}
|
||||
skinTone={0}
|
||||
onSetSkinTone={t => console.log('onSetSkinTone', t)}
|
||||
onClose={() => console.log('onClose')}
|
||||
recentEmojis={[
|
||||
'grinning',
|
||||
'grin',
|
||||
'joy',
|
||||
'rolling_on_the_floor_laughing',
|
||||
'smiley',
|
||||
'smile',
|
||||
'sweat_smile',
|
||||
'laughing',
|
||||
'wink',
|
||||
'blush',
|
||||
'yum',
|
||||
'sunglasses',
|
||||
'heart_eyes',
|
||||
'kissing_heart',
|
||||
'kissing',
|
||||
'kissing_smiling_eyes',
|
||||
'kissing_closed_eyes',
|
||||
'relaxed',
|
||||
'slightly_smiling_face',
|
||||
'hugging_face',
|
||||
'grinning_face_with_star_eyes',
|
||||
'thinking_face',
|
||||
'face_with_one_eyebrow_raised',
|
||||
'neutral_face',
|
||||
'expressionless',
|
||||
'no_mouth',
|
||||
'face_with_rolling_eyes',
|
||||
'smirk',
|
||||
'persevere',
|
||||
'disappointed_relieved',
|
||||
'open_mouth',
|
||||
'zipper_mouth_face',
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</util.ConversationContext>
|
||||
```
|
106
ts/components/emoji/EmojiButton.tsx
Normal file
106
ts/components/emoji/EmojiButton.tsx
Normal file
|
@ -0,0 +1,106 @@
|
|||
import * as React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { noop } from 'lodash';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { EmojiPicker, Props as EmojiPickerProps } from './EmojiPicker';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
export type OwnProps = {
|
||||
readonly i18n: LocalizerType;
|
||||
};
|
||||
|
||||
export type Props = OwnProps &
|
||||
Pick<
|
||||
EmojiPickerProps,
|
||||
'onPickEmoji' | 'skinTone' | 'onSetSkinTone' | 'recentEmojis'
|
||||
>;
|
||||
|
||||
export const EmojiButton = React.memo(
|
||||
({ i18n, onPickEmoji, skinTone, onSetSkinTone, recentEmojis }: Props) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [popperRoot, setPopperRoot] = React.useState<HTMLElement | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const handleClickButton = React.useCallback(
|
||||
() => {
|
||||
if (popperRoot) {
|
||||
setOpen(false);
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
},
|
||||
[popperRoot, setOpen]
|
||||
);
|
||||
|
||||
const handleClose = React.useCallback(
|
||||
() => {
|
||||
setOpen(false);
|
||||
},
|
||||
[setOpen]
|
||||
);
|
||||
|
||||
// Create popper root and handle outside clicks
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (open) {
|
||||
const root = document.createElement('div');
|
||||
setPopperRoot(root);
|
||||
document.body.appendChild(root);
|
||||
const handleOutsideClick = ({ target }: MouseEvent) => {
|
||||
if (!root.contains(target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleOutsideClick);
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(root);
|
||||
document.removeEventListener('click', handleOutsideClick);
|
||||
setPopperRoot(null);
|
||||
};
|
||||
}
|
||||
|
||||
return noop;
|
||||
},
|
||||
[open, setOpen, setPopperRoot]
|
||||
);
|
||||
|
||||
return (
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<button
|
||||
ref={ref}
|
||||
onClick={handleClickButton}
|
||||
className={classNames({
|
||||
'module-emoji-button__button': true,
|
||||
'module-emoji-button__button--active': open,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Reference>
|
||||
{open && popperRoot
|
||||
? createPortal(
|
||||
<Popper placement="top-start">
|
||||
{({ ref, style }) => (
|
||||
<EmojiPicker
|
||||
ref={ref}
|
||||
i18n={i18n}
|
||||
style={style}
|
||||
onPickEmoji={onPickEmoji}
|
||||
onClose={handleClose}
|
||||
skinTone={skinTone}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
recentEmojis={recentEmojis}
|
||||
/>
|
||||
)}
|
||||
</Popper>,
|
||||
popperRoot
|
||||
)
|
||||
: null}
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
);
|
60
ts/components/emoji/EmojiPicker.md
Normal file
60
ts/components/emoji/EmojiPicker.md
Normal file
|
@ -0,0 +1,60 @@
|
|||
#### Default
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<EmojiPicker
|
||||
i18n={util.i18n}
|
||||
onPickEmoji={e => console.log('onPickEmoji', e)}
|
||||
onSetSkinTone={t => console.log('onSetSkinTone', t)}
|
||||
onClose={() => console.log('onClose')}
|
||||
recentEmojis={[
|
||||
'grinning',
|
||||
'grin',
|
||||
'joy',
|
||||
'rolling_on_the_floor_laughing',
|
||||
'smiley',
|
||||
'smile',
|
||||
'sweat_smile',
|
||||
'laughing',
|
||||
'wink',
|
||||
'blush',
|
||||
'yum',
|
||||
'sunglasses',
|
||||
'heart_eyes',
|
||||
'kissing_heart',
|
||||
'kissing',
|
||||
'kissing_smiling_eyes',
|
||||
'kissing_closed_eyes',
|
||||
'relaxed',
|
||||
'slightly_smiling_face',
|
||||
'hugging_face',
|
||||
'grinning_face_with_star_eyes',
|
||||
'thinking_face',
|
||||
'face_with_one_eyebrow_raised',
|
||||
'neutral_face',
|
||||
'expressionless',
|
||||
'no_mouth',
|
||||
'face_with_rolling_eyes',
|
||||
'smirk',
|
||||
'persevere',
|
||||
'disappointed_relieved',
|
||||
'open_mouth',
|
||||
'zipper_mouth_face',
|
||||
]}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### No Recents
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<EmojiPicker
|
||||
i18n={util.i18n}
|
||||
onPickEmoji={e => console.log('onPickEmoji', e)}
|
||||
onSetSkinTone={t => console.log('onSetSkinTone', t)}
|
||||
onClose={() => console.log('onClose')}
|
||||
recentEmojis={[]}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
364
ts/components/emoji/EmojiPicker.tsx
Normal file
364
ts/components/emoji/EmojiPicker.tsx
Normal file
|
@ -0,0 +1,364 @@
|
|||
import * as React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
AutoSizer,
|
||||
Grid,
|
||||
GridCellRenderer,
|
||||
SectionRenderedParams,
|
||||
} from 'react-virtualized';
|
||||
import {
|
||||
chunk,
|
||||
debounce,
|
||||
findLast,
|
||||
flatMap,
|
||||
initial,
|
||||
last,
|
||||
zipObject,
|
||||
} from 'lodash';
|
||||
import { Emoji } from './Emoji';
|
||||
import { dataByCategory, search } from './lib';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
export type OwnProps = {
|
||||
readonly i18n: LocalizerType;
|
||||
readonly onPickEmoji: (o: { skinTone: number; shortName: string }) => unknown;
|
||||
readonly skinTone: number;
|
||||
readonly onSetSkinTone: (tone: number) => unknown;
|
||||
readonly recentEmojis: Array<string>;
|
||||
readonly onClose: () => unknown;
|
||||
};
|
||||
|
||||
export type Props = OwnProps & Pick<React.HTMLProps<HTMLDivElement>, 'style'>;
|
||||
|
||||
function focusRef(el: HTMLElement | null) {
|
||||
if (el) {
|
||||
el.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const COL_COUNT = 8;
|
||||
|
||||
const categories = [
|
||||
'recents',
|
||||
'emoji',
|
||||
'animal',
|
||||
'food',
|
||||
'activity',
|
||||
'travel',
|
||||
'object',
|
||||
'symbol',
|
||||
'flag',
|
||||
];
|
||||
|
||||
export const EmojiPicker = React.memo(
|
||||
React.forwardRef<HTMLDivElement, Props>(
|
||||
// tslint:disable-next-line max-func-body-length
|
||||
(
|
||||
{
|
||||
i18n,
|
||||
onPickEmoji,
|
||||
skinTone = 0,
|
||||
onSetSkinTone,
|
||||
recentEmojis,
|
||||
style,
|
||||
onClose,
|
||||
}: Props,
|
||||
ref
|
||||
) => {
|
||||
// Per design: memoize the initial recent emojis so the grid only updates after re-opening the picker.
|
||||
const firstRecent = React.useMemo(() => {
|
||||
return recentEmojis;
|
||||
}, []);
|
||||
const [selectedCategory, setSelectedCategory] = React.useState(
|
||||
categories[0]
|
||||
);
|
||||
const [searchMode, setSearchMode] = React.useState(false);
|
||||
const [searchText, setSearchText] = React.useState('');
|
||||
const [scrollToRow, setScrollToRow] = React.useState(0);
|
||||
const [selectedTone, setSelectedTone] = React.useState(skinTone);
|
||||
|
||||
const handleToggleSearch = React.useCallback(
|
||||
() => {
|
||||
setSearchText('');
|
||||
setSelectedCategory(categories[0]);
|
||||
setSearchMode(m => !m);
|
||||
},
|
||||
[setSearchText, setSearchMode]
|
||||
);
|
||||
|
||||
const debounceSearchChange = React.useMemo(
|
||||
() =>
|
||||
debounce(query => {
|
||||
setSearchText(query);
|
||||
setScrollToRow(0);
|
||||
}, 200),
|
||||
[setSearchText, setScrollToRow]
|
||||
);
|
||||
|
||||
const handleSearchChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
debounceSearchChange(e.currentTarget.value);
|
||||
},
|
||||
[debounceSearchChange]
|
||||
);
|
||||
|
||||
const handlePickTone = React.useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const { tone = '0' } = e.currentTarget.dataset;
|
||||
const parsedTone = parseInt(tone, 10);
|
||||
setSelectedTone(parsedTone);
|
||||
onSetSkinTone(parsedTone);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePickEmoji = React.useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const { shortName } = e.currentTarget.dataset;
|
||||
if (shortName) {
|
||||
onPickEmoji({ skinTone: selectedTone, shortName });
|
||||
}
|
||||
},
|
||||
[onClose, onPickEmoji, selectedTone]
|
||||
);
|
||||
|
||||
// Handle escape key
|
||||
React.useEffect(
|
||||
() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (searchMode && e.key === 'Escape') {
|
||||
setSearchText('');
|
||||
setSearchMode(false);
|
||||
setScrollToRow(0);
|
||||
} else if (!searchMode) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keyup', handler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keyup', handler);
|
||||
};
|
||||
},
|
||||
[onClose, searchMode]
|
||||
);
|
||||
|
||||
const emojiGrid = React.useMemo(
|
||||
() => {
|
||||
if (searchText) {
|
||||
return chunk(search(searchText).map(e => e.short_name), COL_COUNT);
|
||||
}
|
||||
|
||||
const [, ...cats] = categories;
|
||||
|
||||
const chunks = flatMap(cats, cat =>
|
||||
chunk(dataByCategory[cat].map(e => e.short_name), COL_COUNT)
|
||||
);
|
||||
|
||||
return [...chunk(firstRecent, COL_COUNT), ...chunks];
|
||||
},
|
||||
[dataByCategory, categories, firstRecent, searchText]
|
||||
);
|
||||
|
||||
const catRowEnds = React.useMemo(
|
||||
() => {
|
||||
const rowEnds: Array<number> = [
|
||||
Math.ceil(firstRecent.length / COL_COUNT) - 1,
|
||||
];
|
||||
const [, ...cats] = categories;
|
||||
|
||||
cats.forEach(cat => {
|
||||
rowEnds.push(
|
||||
Math.ceil(dataByCategory[cat].length / COL_COUNT) +
|
||||
(last(rowEnds) as number)
|
||||
);
|
||||
});
|
||||
|
||||
return rowEnds;
|
||||
},
|
||||
[categories, dataByCategory]
|
||||
);
|
||||
|
||||
const catToRowOffsets = React.useMemo(
|
||||
() => {
|
||||
const offsets = initial(catRowEnds).map(i => i + 1);
|
||||
|
||||
return zipObject(categories, [0, ...offsets]);
|
||||
},
|
||||
[categories, catRowEnds]
|
||||
);
|
||||
|
||||
const catOffsetEntries = React.useMemo(
|
||||
() => Object.entries(catToRowOffsets),
|
||||
[catToRowOffsets]
|
||||
);
|
||||
|
||||
const handleSelectCategory = React.useCallback(
|
||||
({ currentTarget }: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const { category } = currentTarget.dataset;
|
||||
if (category) {
|
||||
setSelectedCategory(category);
|
||||
setScrollToRow(catToRowOffsets[category]);
|
||||
}
|
||||
},
|
||||
[catToRowOffsets, setSelectedCategory, setScrollToRow]
|
||||
);
|
||||
|
||||
const cellRenderer = React.useCallback<GridCellRenderer>(
|
||||
({ key, style: cellStyle, rowIndex, columnIndex }) => {
|
||||
const shortName = emojiGrid[rowIndex][columnIndex];
|
||||
|
||||
return shortName ? (
|
||||
<div
|
||||
key={key}
|
||||
className="module-emoji-picker__body__emoji-cell"
|
||||
style={cellStyle}
|
||||
>
|
||||
<button
|
||||
className="module-emoji-picker__button"
|
||||
onClick={handlePickEmoji}
|
||||
data-short-name={shortName}
|
||||
title={shortName}
|
||||
>
|
||||
<Emoji shortName={shortName} skinTone={selectedTone} />
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
},
|
||||
[emojiGrid, selectedTone]
|
||||
);
|
||||
|
||||
const getRowHeight = React.useCallback(
|
||||
({ index }: { index: number }) => {
|
||||
if (searchText) {
|
||||
return 34;
|
||||
}
|
||||
|
||||
if (catRowEnds.includes(index) && index !== last(catRowEnds)) {
|
||||
return 44;
|
||||
}
|
||||
|
||||
return 34;
|
||||
},
|
||||
[catRowEnds, searchText]
|
||||
);
|
||||
|
||||
const onSectionRendered = React.useMemo(
|
||||
() =>
|
||||
debounce(({ rowStartIndex }: SectionRenderedParams) => {
|
||||
const [cat] =
|
||||
findLast(catOffsetEntries, ([, row]) => rowStartIndex >= row) ||
|
||||
categories;
|
||||
|
||||
setSelectedCategory(cat);
|
||||
}, 10),
|
||||
[catOffsetEntries, categories]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="module-emoji-picker" ref={ref} style={style}>
|
||||
<header className="module-emoji-picker__header">
|
||||
<button
|
||||
onClick={handleToggleSearch}
|
||||
title={i18n('EmojiPicker--search-placeholder')}
|
||||
className={classNames(
|
||||
'module-emoji-picker__button',
|
||||
'module-emoji-picker__button--icon',
|
||||
searchMode
|
||||
? 'module-emoji-picker__button--icon--close'
|
||||
: 'module-emoji-picker__button--icon--search'
|
||||
)}
|
||||
/>
|
||||
{searchMode ? (
|
||||
<div className="module-emoji-picker__header__search-field">
|
||||
<input
|
||||
ref={focusRef}
|
||||
className="module-emoji-picker__header__search-field__input"
|
||||
placeholder={i18n('EmojiPicker--search-placeholder')}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
categories.map(
|
||||
cat =>
|
||||
cat === 'recents' && firstRecent.length === 0 ? null : (
|
||||
<button
|
||||
key={cat}
|
||||
data-category={cat}
|
||||
title={cat}
|
||||
onClick={handleSelectCategory}
|
||||
className={classNames(
|
||||
'module-emoji-picker__button',
|
||||
'module-emoji-picker__button--icon',
|
||||
`module-emoji-picker__button--icon--${cat}`,
|
||||
selectedCategory === cat
|
||||
? 'module-emoji-picker__button--selected'
|
||||
: null
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</header>
|
||||
{emojiGrid.length > 0 ? (
|
||||
<div>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<Grid
|
||||
key={searchText}
|
||||
className="module-emoji-picker__body"
|
||||
width={width}
|
||||
height={height}
|
||||
columnCount={COL_COUNT}
|
||||
columnWidth={38}
|
||||
rowHeight={getRowHeight}
|
||||
rowCount={emojiGrid.length}
|
||||
cellRenderer={cellRenderer}
|
||||
scrollToRow={scrollToRow}
|
||||
scrollToAlignment="start"
|
||||
onSectionRendered={onSectionRendered}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-emoji-picker__body',
|
||||
'module-emoji-picker__body--empty'
|
||||
)}
|
||||
>
|
||||
{i18n('EmojiPicker--empty')}
|
||||
<Emoji
|
||||
shortName="slightly_frowning_face"
|
||||
size={16}
|
||||
inline={true}
|
||||
style={{ marginLeft: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<footer className="module-emoji-picker__footer">
|
||||
{[0, 1, 2, 3, 4, 5].map(tone => (
|
||||
<button
|
||||
key={tone}
|
||||
data-tone={tone}
|
||||
onClick={handlePickTone}
|
||||
title={i18n('EmojiPicker--skin-tone', [`${tone}`])}
|
||||
className={classNames(
|
||||
'module-emoji-picker__button',
|
||||
'module-emoji-picker__button--footer',
|
||||
selectedTone === tone
|
||||
? 'module-emoji-picker__button--selected'
|
||||
: null
|
||||
)}
|
||||
>
|
||||
<Emoji shortName="hand" skinTone={tone} size={20} />
|
||||
</button>
|
||||
))}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
192
ts/components/emoji/lib.ts
Normal file
192
ts/components/emoji/lib.ts
Normal file
|
@ -0,0 +1,192 @@
|
|||
// @ts-ignore: untyped json
|
||||
import untypedData from 'emoji-datasource';
|
||||
import {
|
||||
compact,
|
||||
flatMap,
|
||||
groupBy,
|
||||
isNumber,
|
||||
keyBy,
|
||||
map,
|
||||
mapValues,
|
||||
sortBy,
|
||||
} from 'lodash';
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
export type ValuesOf<T extends Array<any>> = T[number];
|
||||
|
||||
export const skinTones = ['1F3FB', '1F3FC', '1F3FD', '1F3FE', '1F3FF'];
|
||||
|
||||
export type SkinToneKey = '1F3FB' | '1F3FC' | '1F3FD' | '1F3FE' | '1F3FF';
|
||||
|
||||
export type EmojiData = {
|
||||
name: string;
|
||||
unified: string;
|
||||
non_qualified: string | null;
|
||||
docomo: string | null;
|
||||
au: string | null;
|
||||
softbank: string | null;
|
||||
google: string | null;
|
||||
image: string;
|
||||
sheet_x: number;
|
||||
sheet_y: number;
|
||||
short_name: string;
|
||||
short_names: Array<string>;
|
||||
text: string | null;
|
||||
texts: Array<string> | null;
|
||||
category: string;
|
||||
sort_order: number;
|
||||
added_in: string;
|
||||
has_img_apple: boolean;
|
||||
has_img_google: boolean;
|
||||
has_img_twitter: boolean;
|
||||
has_img_emojione: boolean;
|
||||
has_img_facebook: boolean;
|
||||
has_img_messenger: boolean;
|
||||
skin_variations?: {
|
||||
[key: string]: {
|
||||
unified: string;
|
||||
non_qualified: null;
|
||||
image: string;
|
||||
sheet_x: number;
|
||||
sheet_y: number;
|
||||
added_in: string;
|
||||
has_img_apple: boolean;
|
||||
has_img_google: boolean;
|
||||
has_img_twitter: boolean;
|
||||
has_img_emojione: boolean;
|
||||
has_img_facebook: boolean;
|
||||
has_img_messenger: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const data: Array<EmojiData> = untypedData;
|
||||
|
||||
export const dataByShortName = keyBy(data, 'short_name');
|
||||
|
||||
data.forEach(emoji => {
|
||||
const { short_names } = emoji;
|
||||
if (short_names) {
|
||||
short_names.forEach(name => {
|
||||
dataByShortName[name] = emoji;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const dataByCategory = mapValues(
|
||||
groupBy(data, ({ category }) => {
|
||||
if (category === 'Activities') {
|
||||
return 'activity';
|
||||
}
|
||||
|
||||
if (category === 'Animals & Nature') {
|
||||
return 'animal';
|
||||
}
|
||||
|
||||
if (category === 'Flags') {
|
||||
return 'flag';
|
||||
}
|
||||
|
||||
if (category === 'Food & Drink') {
|
||||
return 'food';
|
||||
}
|
||||
|
||||
if (category === 'Objects') {
|
||||
return 'object';
|
||||
}
|
||||
|
||||
if (category === 'Travel & Places') {
|
||||
return 'travel';
|
||||
}
|
||||
|
||||
if (category === 'Smileys & People') {
|
||||
return 'emoji';
|
||||
}
|
||||
|
||||
if (category === 'Symbols') {
|
||||
return 'symbol';
|
||||
}
|
||||
|
||||
return 'misc';
|
||||
}),
|
||||
arr => sortBy(arr, 'sort_order')
|
||||
);
|
||||
|
||||
export function getSheetCoordinates(
|
||||
shortName: keyof typeof dataByShortName,
|
||||
skinTone?: SkinToneKey | number
|
||||
): [number, number] {
|
||||
const base = dataByShortName[shortName];
|
||||
|
||||
if (skinTone && base.skin_variations) {
|
||||
const variation = isNumber(skinTone) ? skinTones[skinTone - 1] : skinTone;
|
||||
const { sheet_x, sheet_y } = base.skin_variations[variation];
|
||||
|
||||
return [sheet_x, sheet_y];
|
||||
}
|
||||
|
||||
return [base.sheet_x, base.sheet_y];
|
||||
}
|
||||
|
||||
const fuse = new Fuse(data, {
|
||||
shouldSort: true,
|
||||
threshold: 0.3,
|
||||
location: 0,
|
||||
distance: 5,
|
||||
maxPatternLength: 20,
|
||||
minMatchCharLength: 1,
|
||||
keys: ['name', 'short_name', 'short_names'],
|
||||
});
|
||||
|
||||
export function search(query: string) {
|
||||
return fuse.search(query);
|
||||
}
|
||||
|
||||
const shortNames = new Set([
|
||||
...map(data, 'short_name'),
|
||||
...compact<string>(flatMap(data, 'short_names')),
|
||||
]);
|
||||
|
||||
export function isShortName(name: string) {
|
||||
return shortNames.has(name);
|
||||
}
|
||||
|
||||
export function unifiedToEmoji(unified: string) {
|
||||
return unified
|
||||
.split('-')
|
||||
.map(c => String.fromCodePoint(parseInt(c, 16)))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function convertShortName(shortName: string, skinTone: number = 0) {
|
||||
const base = dataByShortName[shortName];
|
||||
|
||||
if (!base) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (skinTone && base.skin_variations) {
|
||||
const toneKey = skinTones[0];
|
||||
const variation = base.skin_variations[toneKey];
|
||||
if (variation) {
|
||||
return unifiedToEmoji(variation.unified);
|
||||
}
|
||||
}
|
||||
|
||||
return unifiedToEmoji(base.unified);
|
||||
}
|
||||
|
||||
export function replaceColons(str: string) {
|
||||
return str.replace(/:[a-z0-9-_+]+:(?::skin-tone-[1-4]:)?/gi, m => {
|
||||
const [shortName = '', skinTone = '0'] = m
|
||||
.replace('skin-tone-', '')
|
||||
.split(':')
|
||||
.filter(Boolean);
|
||||
|
||||
if (shortName) {
|
||||
return convertShortName(shortName, parseInt(skinTone, 10));
|
||||
}
|
||||
|
||||
return m;
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue