Fuzzy-Searchable Emoji Picker

This commit is contained in:
Ken Powers 2019-05-24 16:58:27 -07:00 committed by Scott Nonnenberg
parent 2f47a3570b
commit 0e9d549cf3
48 changed files with 1697 additions and 280 deletions

View 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>
```

View 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`,
}}
/>
);
}
)
);

View 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>
```

View 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>
);
}
);

View 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>
```

View 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
View 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;
});
}