694 lines
19 KiB
TypeScript
694 lines
19 KiB
TypeScript
|
import * as React from 'react';
|
||
|
import { createPortal } from 'react-dom';
|
||
|
import { createSelector } from 'reselect';
|
||
|
import {
|
||
|
CompositeDecorator,
|
||
|
ContentBlock,
|
||
|
ContentState,
|
||
|
DraftEditorCommand,
|
||
|
DraftHandleValue,
|
||
|
Editor,
|
||
|
EditorChangeType,
|
||
|
EditorState,
|
||
|
getDefaultKeyBinding,
|
||
|
Modifier,
|
||
|
SelectionState,
|
||
|
} from 'draft-js';
|
||
|
import Measure, { ContentRect } from 'react-measure';
|
||
|
import { Manager, Popper, Reference } from 'react-popper';
|
||
|
import { clamp, noop } from 'lodash';
|
||
|
import classNames from 'classnames';
|
||
|
import emojiRegex from 'emoji-regex';
|
||
|
import { Emoji } from './emoji/Emoji';
|
||
|
import { EmojiPickDataType } from './emoji/EmojiPicker';
|
||
|
import {
|
||
|
convertShortName,
|
||
|
EmojiData,
|
||
|
replaceColons,
|
||
|
search,
|
||
|
} from './emoji/lib';
|
||
|
import { LocalizerType } from '../types/Util';
|
||
|
|
||
|
const colonsRegex = /(?:^|\s):[a-z0-9-_+]+:?/gi;
|
||
|
|
||
|
export type Props = {
|
||
|
readonly i18n: LocalizerType;
|
||
|
readonly disabled?: boolean;
|
||
|
readonly editorRef?: React.RefObject<Editor>;
|
||
|
readonly inputApi?: React.MutableRefObject<InputApi | undefined>;
|
||
|
readonly skinTone?: EmojiPickDataType['skinTone'];
|
||
|
onDirtyChange?(dirty: boolean): unknown;
|
||
|
onEditorStateChange?(messageText: string, caretLocation: number): unknown;
|
||
|
onEditorSizeChange?(rect: ContentRect): unknown;
|
||
|
onPickEmoji(o: EmojiPickDataType): unknown;
|
||
|
onSubmit(message: string): unknown;
|
||
|
};
|
||
|
|
||
|
export type InputApi = {
|
||
|
insertEmoji: (e: EmojiPickDataType) => void;
|
||
|
reset: () => void;
|
||
|
resetEmojiResults: () => void;
|
||
|
submit: () => void;
|
||
|
};
|
||
|
|
||
|
export type CompositionInputEditorCommand =
|
||
|
| DraftEditorCommand
|
||
|
| ('enter-emoji' | 'next-emoji' | 'prev-emoji' | 'submit');
|
||
|
|
||
|
function getTrimmedMatchAtIndex(str: string, index: number, pattern: RegExp) {
|
||
|
let match;
|
||
|
|
||
|
// Reset regex state
|
||
|
pattern.exec('');
|
||
|
|
||
|
// tslint:disable-next-line no-conditional-assignment
|
||
|
while ((match = pattern.exec(str))) {
|
||
|
const matchStr = match.toString();
|
||
|
const start = match.index + (matchStr.length - matchStr.trimLeft().length);
|
||
|
const end = match.index + matchStr.trimRight().length;
|
||
|
|
||
|
if (index >= start && index <= end) {
|
||
|
return match.toString();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
function getWordAtIndex(str: string, index: number) {
|
||
|
const start = str
|
||
|
.slice(0, index + 1)
|
||
|
.replace(/\s+$/, '')
|
||
|
.search(/\S+$/);
|
||
|
const end = str.slice(index).search(/(?:\s|$)/) + index;
|
||
|
|
||
|
return {
|
||
|
start,
|
||
|
end,
|
||
|
word: str.slice(start, end),
|
||
|
};
|
||
|
}
|
||
|
|
||
|
const compositeDecorator = new CompositeDecorator([
|
||
|
{
|
||
|
strategy: (block, cb) => {
|
||
|
const pat = emojiRegex();
|
||
|
const text = block.getText();
|
||
|
let match;
|
||
|
let index;
|
||
|
// tslint:disable-next-line no-conditional-assignment
|
||
|
while ((match = pat.exec(text))) {
|
||
|
index = match.index;
|
||
|
cb(index, index + match[0].length);
|
||
|
}
|
||
|
},
|
||
|
component: ({
|
||
|
children,
|
||
|
contentState,
|
||
|
entityKey,
|
||
|
}: {
|
||
|
children: React.ReactNode;
|
||
|
contentState: ContentState;
|
||
|
entityKey: string;
|
||
|
}) =>
|
||
|
entityKey ? (
|
||
|
<Emoji
|
||
|
shortName={contentState.getEntity(entityKey).getData().shortName}
|
||
|
skinTone={contentState.getEntity(entityKey).getData().skinTone}
|
||
|
inline={true}
|
||
|
size={20}
|
||
|
>
|
||
|
{children}
|
||
|
</Emoji>
|
||
|
) : (
|
||
|
children
|
||
|
),
|
||
|
},
|
||
|
]);
|
||
|
|
||
|
type FunctionRef = (el: HTMLElement | null) => unknown;
|
||
|
|
||
|
// A selector which combines multiple react refs into a single, referentially-equal functional ref.
|
||
|
const combineRefs = createSelector(
|
||
|
(r1: FunctionRef) => r1,
|
||
|
(_r1: any, r2: FunctionRef) => r2,
|
||
|
(_r1: any, _r2: any, r3: React.MutableRefObject<HTMLDivElement>) => r3,
|
||
|
(r1, r2, r3) => (el: HTMLDivElement) => {
|
||
|
r1(el);
|
||
|
r2(el);
|
||
|
r3.current = el;
|
||
|
}
|
||
|
);
|
||
|
|
||
|
// tslint:disable-next-line max-func-body-length
|
||
|
export const CompositionInput = ({
|
||
|
i18n,
|
||
|
disabled,
|
||
|
editorRef,
|
||
|
inputApi,
|
||
|
onDirtyChange,
|
||
|
onEditorStateChange,
|
||
|
onEditorSizeChange,
|
||
|
onPickEmoji,
|
||
|
onSubmit,
|
||
|
skinTone,
|
||
|
}: Props) => {
|
||
|
const [editorState, setEditorState] = React.useState(
|
||
|
EditorState.createEmpty(compositeDecorator)
|
||
|
);
|
||
|
const [searchText, setSearchText] = React.useState<string>('');
|
||
|
const [emojiResults, setEmojiResults] = React.useState<Array<EmojiData>>([]);
|
||
|
const [emojiResultsIndex, setEmojiResultsIndex] = React.useState<number>(0);
|
||
|
const [editorWidth, setEditorWidth] = React.useState<number>(0);
|
||
|
const [popperRoot, setPopperRoot] = React.useState<HTMLDivElement | null>(
|
||
|
null
|
||
|
);
|
||
|
const dirtyRef = React.useRef(false);
|
||
|
const focusRef = React.useRef(false);
|
||
|
const editorStateRef = React.useRef<EditorState>(editorState);
|
||
|
const rootElRef = React.useRef<HTMLDivElement>();
|
||
|
|
||
|
// This function sets editorState and also keeps a reference to the newly set
|
||
|
// state so we can reference the state in effects and callbacks without
|
||
|
// excessive cleanup
|
||
|
const setAndTrackEditorState = React.useCallback(
|
||
|
(newState: EditorState) => {
|
||
|
setEditorState(newState);
|
||
|
editorStateRef.current = newState;
|
||
|
},
|
||
|
[setEditorState, editorStateRef]
|
||
|
);
|
||
|
|
||
|
const updateExternalStateListeners = React.useCallback(
|
||
|
(newState: EditorState) => {
|
||
|
const plainText = newState.getCurrentContent().getPlainText();
|
||
|
const currentBlockKey = newState.getSelection().getStartKey();
|
||
|
const currentBlockIndex = editorState
|
||
|
.getCurrentContent()
|
||
|
.getBlockMap()
|
||
|
.keySeq()
|
||
|
.findIndex(key => key === currentBlockKey);
|
||
|
const caretLocation = newState
|
||
|
.getCurrentContent()
|
||
|
.getBlockMap()
|
||
|
.valueSeq()
|
||
|
.toArray()
|
||
|
.reduce((sum: number, block: ContentBlock, index: number) => {
|
||
|
if (currentBlockIndex < index) {
|
||
|
return sum + block.getText().length + 1; // +1 for newline
|
||
|
}
|
||
|
|
||
|
if (currentBlockIndex === index) {
|
||
|
return sum + newState.getSelection().getStartOffset();
|
||
|
}
|
||
|
|
||
|
return sum;
|
||
|
}, 0);
|
||
|
|
||
|
if (onDirtyChange) {
|
||
|
const isDirty = !!plainText;
|
||
|
if (dirtyRef.current !== isDirty) {
|
||
|
dirtyRef.current = isDirty;
|
||
|
onDirtyChange(isDirty);
|
||
|
}
|
||
|
}
|
||
|
if (onEditorStateChange) {
|
||
|
onEditorStateChange(plainText, caretLocation);
|
||
|
}
|
||
|
},
|
||
|
[onDirtyChange, onEditorStateChange]
|
||
|
);
|
||
|
|
||
|
const resetEmojiResults = React.useCallback(
|
||
|
() => {
|
||
|
setEmojiResults([]);
|
||
|
setEmojiResultsIndex(0);
|
||
|
setSearchText('');
|
||
|
},
|
||
|
[setEmojiResults, setEmojiResultsIndex, setSearchText]
|
||
|
);
|
||
|
|
||
|
const handleEditorStateChange = React.useCallback(
|
||
|
(newState: EditorState) => {
|
||
|
// Does the current position have any emojiable text?
|
||
|
const selection = newState.getSelection();
|
||
|
const caretLocation = selection.getStartOffset();
|
||
|
const content = newState
|
||
|
.getCurrentContent()
|
||
|
.getBlockForKey(selection.getAnchorKey())
|
||
|
.getText();
|
||
|
const match = getTrimmedMatchAtIndex(content, caretLocation, colonsRegex);
|
||
|
|
||
|
// Update the state to indicate emojiable text at the current position.
|
||
|
const newSearchText = match ? match.trim().substr(1) : '';
|
||
|
if (newSearchText.length >= 2 && focusRef.current) {
|
||
|
setEmojiResults(search(newSearchText, 10));
|
||
|
setSearchText(newSearchText);
|
||
|
setEmojiResultsIndex(0);
|
||
|
} else {
|
||
|
resetEmojiResults();
|
||
|
}
|
||
|
|
||
|
// Finally, update the editor state
|
||
|
setAndTrackEditorState(newState);
|
||
|
updateExternalStateListeners(newState);
|
||
|
},
|
||
|
[
|
||
|
focusRef,
|
||
|
resetEmojiResults,
|
||
|
setAndTrackEditorState,
|
||
|
setSearchText,
|
||
|
setEmojiResults,
|
||
|
]
|
||
|
);
|
||
|
|
||
|
const resetEditorState = React.useCallback(
|
||
|
() => {
|
||
|
const newEmptyState = EditorState.createEmpty(compositeDecorator);
|
||
|
setAndTrackEditorState(newEmptyState);
|
||
|
resetEmojiResults();
|
||
|
},
|
||
|
[editorStateRef, resetEmojiResults, setAndTrackEditorState]
|
||
|
);
|
||
|
|
||
|
const submit = React.useCallback(
|
||
|
() => {
|
||
|
const text = editorState.getCurrentContent().getPlainText();
|
||
|
const emojidText = replaceColons(text);
|
||
|
onSubmit(emojidText);
|
||
|
},
|
||
|
[editorState, onSubmit]
|
||
|
);
|
||
|
|
||
|
const handleEditorSizeChange = React.useCallback(
|
||
|
(rect: ContentRect) => {
|
||
|
if (rect.bounds) {
|
||
|
setEditorWidth(rect.bounds.width);
|
||
|
if (onEditorSizeChange) {
|
||
|
onEditorSizeChange(rect);
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
[onEditorSizeChange, setEditorWidth]
|
||
|
);
|
||
|
|
||
|
const selectEmojiResult = React.useCallback(
|
||
|
(dir: 'next' | 'prev', e?: React.KeyboardEvent) => {
|
||
|
if (emojiResults.length > 0) {
|
||
|
if (e) {
|
||
|
e.preventDefault();
|
||
|
}
|
||
|
|
||
|
if (dir === 'next') {
|
||
|
setEmojiResultsIndex(
|
||
|
clamp(emojiResultsIndex + 1, 0, emojiResults.length - 1)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
if (dir === 'prev') {
|
||
|
setEmojiResultsIndex(
|
||
|
clamp(emojiResultsIndex - 1, 0, emojiResults.length - 1)
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
[setEmojiResultsIndex, emojiResultsIndex, emojiResults]
|
||
|
);
|
||
|
|
||
|
const handleEditorArrowKey = React.useCallback(
|
||
|
(e: React.KeyboardEvent) => {
|
||
|
if (e.key === 'ArrowUp') {
|
||
|
selectEmojiResult('prev', e);
|
||
|
}
|
||
|
|
||
|
if (e.key === 'ArrowDown') {
|
||
|
selectEmojiResult('next', e);
|
||
|
}
|
||
|
},
|
||
|
[selectEmojiResult]
|
||
|
);
|
||
|
|
||
|
const handleEscapeKey = React.useCallback(
|
||
|
(e: React.KeyboardEvent) => {
|
||
|
if (emojiResults.length > 0) {
|
||
|
e.preventDefault();
|
||
|
resetEmojiResults();
|
||
|
}
|
||
|
},
|
||
|
[resetEmojiResults, emojiResults]
|
||
|
);
|
||
|
|
||
|
const getWordAtCaret = React.useCallback(
|
||
|
() => {
|
||
|
const selection = editorState.getSelection();
|
||
|
const index = selection.getAnchorOffset();
|
||
|
|
||
|
return getWordAtIndex(
|
||
|
editorState
|
||
|
.getCurrentContent()
|
||
|
.getBlockForKey(selection.getAnchorKey())
|
||
|
.getText(),
|
||
|
index
|
||
|
);
|
||
|
},
|
||
|
[editorState]
|
||
|
);
|
||
|
|
||
|
const insertEmoji = React.useCallback(
|
||
|
(e: EmojiPickDataType, replaceWord: boolean = false) => {
|
||
|
const selection = editorState.getSelection();
|
||
|
const oldContent = editorState.getCurrentContent();
|
||
|
const emojiContent = convertShortName(e.shortName, e.skinTone);
|
||
|
const emojiEntityKey = oldContent
|
||
|
.createEntity('emoji', 'IMMUTABLE', {
|
||
|
shortName: e.shortName,
|
||
|
skinTone: e.skinTone,
|
||
|
})
|
||
|
.getLastCreatedEntityKey();
|
||
|
const word = getWordAtCaret();
|
||
|
|
||
|
let newContent = replaceWord
|
||
|
? Modifier.replaceText(
|
||
|
oldContent,
|
||
|
selection.merge({
|
||
|
anchorOffset: word.start,
|
||
|
focusOffset: word.end,
|
||
|
}) as SelectionState,
|
||
|
emojiContent,
|
||
|
undefined,
|
||
|
emojiEntityKey
|
||
|
)
|
||
|
: Modifier.insertText(
|
||
|
oldContent,
|
||
|
selection,
|
||
|
emojiContent,
|
||
|
undefined,
|
||
|
emojiEntityKey
|
||
|
);
|
||
|
|
||
|
const afterSelection = newContent.getSelectionAfter();
|
||
|
|
||
|
if (
|
||
|
afterSelection.getAnchorOffset() ===
|
||
|
newContent.getBlockForKey(afterSelection.getAnchorKey()).getLength()
|
||
|
) {
|
||
|
newContent = Modifier.insertText(newContent, afterSelection, ' ');
|
||
|
}
|
||
|
|
||
|
const newState = EditorState.push(
|
||
|
editorState,
|
||
|
newContent,
|
||
|
'insert-emoji' as EditorChangeType
|
||
|
);
|
||
|
setAndTrackEditorState(newState);
|
||
|
resetEmojiResults();
|
||
|
},
|
||
|
[editorState, setAndTrackEditorState, resetEmojiResults]
|
||
|
);
|
||
|
|
||
|
const handleEditorCommand = React.useCallback(
|
||
|
(
|
||
|
command: CompositionInputEditorCommand,
|
||
|
state: EditorState
|
||
|
): DraftHandleValue => {
|
||
|
if (command === 'enter-emoji') {
|
||
|
const shortName = emojiResults[emojiResultsIndex].short_name;
|
||
|
|
||
|
const content = state.getCurrentContent();
|
||
|
const selection = state.getSelection();
|
||
|
const word = getWordAtCaret();
|
||
|
const emojiContent = convertShortName(shortName, skinTone);
|
||
|
const emojiEntityKey = content
|
||
|
.createEntity('emoji', 'IMMUTABLE', {
|
||
|
shortName,
|
||
|
skinTone,
|
||
|
})
|
||
|
.getLastCreatedEntityKey();
|
||
|
|
||
|
const replaceSelection = selection.merge({
|
||
|
anchorOffset: word.start,
|
||
|
focusOffset: word.end,
|
||
|
});
|
||
|
|
||
|
let newContent = Modifier.replaceText(
|
||
|
content,
|
||
|
replaceSelection as SelectionState,
|
||
|
emojiContent,
|
||
|
undefined,
|
||
|
emojiEntityKey
|
||
|
);
|
||
|
|
||
|
const afterSelection = newContent.getSelectionAfter();
|
||
|
|
||
|
if (
|
||
|
afterSelection.getAnchorOffset() ===
|
||
|
newContent.getBlockForKey(afterSelection.getAnchorKey()).getLength()
|
||
|
) {
|
||
|
newContent = Modifier.insertText(newContent, afterSelection, ' ');
|
||
|
}
|
||
|
|
||
|
const newState = EditorState.push(
|
||
|
state,
|
||
|
newContent,
|
||
|
'insert-emoji' as EditorChangeType
|
||
|
);
|
||
|
setAndTrackEditorState(newState);
|
||
|
resetEmojiResults();
|
||
|
onPickEmoji({ shortName });
|
||
|
|
||
|
return 'handled';
|
||
|
}
|
||
|
|
||
|
if (command === 'submit') {
|
||
|
submit();
|
||
|
|
||
|
return 'handled';
|
||
|
}
|
||
|
|
||
|
if (command === 'next-emoji') {
|
||
|
selectEmojiResult('next');
|
||
|
}
|
||
|
|
||
|
if (command === 'prev-emoji') {
|
||
|
selectEmojiResult('prev');
|
||
|
}
|
||
|
|
||
|
return 'not-handled';
|
||
|
},
|
||
|
[
|
||
|
emojiResults,
|
||
|
emojiResultsIndex,
|
||
|
resetEmojiResults,
|
||
|
selectEmojiResult,
|
||
|
setAndTrackEditorState,
|
||
|
skinTone,
|
||
|
submit,
|
||
|
]
|
||
|
);
|
||
|
|
||
|
const onTab = React.useCallback(
|
||
|
(e: React.KeyboardEvent) => {
|
||
|
if (e.shiftKey || emojiResults.length === 0) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
e.preventDefault();
|
||
|
handleEditorCommand('enter-emoji', editorState);
|
||
|
},
|
||
|
[emojiResults, editorState, handleEditorCommand, resetEmojiResults]
|
||
|
);
|
||
|
|
||
|
const editorKeybindingFn = React.useCallback(
|
||
|
(e: React.KeyboardEvent): CompositionInputEditorCommand | null => {
|
||
|
if (e.key === 'Enter' && emojiResults.length > 0) {
|
||
|
e.preventDefault();
|
||
|
|
||
|
return 'enter-emoji';
|
||
|
}
|
||
|
|
||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||
|
e.preventDefault();
|
||
|
|
||
|
return 'submit';
|
||
|
}
|
||
|
|
||
|
if (e.key === 'n' && e.ctrlKey) {
|
||
|
e.preventDefault();
|
||
|
|
||
|
return 'next-emoji';
|
||
|
}
|
||
|
|
||
|
if (e.key === 'p' && e.ctrlKey) {
|
||
|
e.preventDefault();
|
||
|
|
||
|
return 'prev-emoji';
|
||
|
}
|
||
|
|
||
|
return getDefaultKeyBinding(e);
|
||
|
},
|
||
|
[emojiResults]
|
||
|
);
|
||
|
|
||
|
// Create popper root
|
||
|
React.useEffect(
|
||
|
() => {
|
||
|
if (emojiResults.length > 0) {
|
||
|
const root = document.createElement('div');
|
||
|
setPopperRoot(root);
|
||
|
document.body.appendChild(root);
|
||
|
|
||
|
return () => {
|
||
|
document.body.removeChild(root);
|
||
|
setPopperRoot(null);
|
||
|
};
|
||
|
}
|
||
|
|
||
|
return noop;
|
||
|
},
|
||
|
[setPopperRoot, emojiResults]
|
||
|
);
|
||
|
|
||
|
const onFocus = React.useCallback(
|
||
|
() => {
|
||
|
focusRef.current = true;
|
||
|
},
|
||
|
[focusRef]
|
||
|
);
|
||
|
|
||
|
const onBlur = React.useCallback(
|
||
|
() => {
|
||
|
focusRef.current = false;
|
||
|
},
|
||
|
[focusRef]
|
||
|
);
|
||
|
|
||
|
// Manage focus
|
||
|
// Chromium places the editor caret at the beginning of contenteditable divs on focus
|
||
|
// Here, we force the last known selection on focusin (doing this with onFocus wasn't behaving properly)
|
||
|
// This needs to be done in an effect because React doesn't support focus{In,Out}
|
||
|
// https://github.com/facebook/react/issues/6410
|
||
|
React.useLayoutEffect(
|
||
|
() => {
|
||
|
const { current: rootEl } = rootElRef;
|
||
|
|
||
|
if (rootEl) {
|
||
|
const onFocusIn = () => {
|
||
|
const { current: oldState } = editorStateRef;
|
||
|
// Force selection to be old selection
|
||
|
setAndTrackEditorState(
|
||
|
EditorState.forceSelection(oldState, oldState.getSelection())
|
||
|
);
|
||
|
};
|
||
|
|
||
|
rootEl.addEventListener('focusin', onFocusIn);
|
||
|
|
||
|
return () => {
|
||
|
rootEl.removeEventListener('focusin', onFocusIn);
|
||
|
};
|
||
|
}
|
||
|
|
||
|
return noop;
|
||
|
},
|
||
|
[editorStateRef, rootElRef, setAndTrackEditorState]
|
||
|
);
|
||
|
|
||
|
if (inputApi) {
|
||
|
inputApi.current = {
|
||
|
reset: resetEditorState,
|
||
|
submit,
|
||
|
insertEmoji,
|
||
|
resetEmojiResults,
|
||
|
};
|
||
|
}
|
||
|
|
||
|
return (
|
||
|
<Manager>
|
||
|
<Reference>
|
||
|
{({ ref: popperRef }) => (
|
||
|
<Measure bounds={true} onResize={handleEditorSizeChange}>
|
||
|
{({ measureRef }) => (
|
||
|
<div
|
||
|
className="module-composition-input__input"
|
||
|
ref={combineRefs(popperRef, measureRef, rootElRef)}
|
||
|
>
|
||
|
<div className="module-composition-input__input__scroller">
|
||
|
<Editor
|
||
|
ref={editorRef}
|
||
|
editorState={editorState}
|
||
|
onChange={handleEditorStateChange}
|
||
|
placeholder={i18n('sendMessage')}
|
||
|
onUpArrow={handleEditorArrowKey}
|
||
|
onDownArrow={handleEditorArrowKey}
|
||
|
onEscape={handleEscapeKey}
|
||
|
onTab={onTab}
|
||
|
handleKeyCommand={handleEditorCommand}
|
||
|
keyBindingFn={editorKeybindingFn}
|
||
|
spellCheck={true}
|
||
|
stripPastedStyles={true}
|
||
|
readOnly={disabled}
|
||
|
onFocus={onFocus}
|
||
|
onBlur={onBlur}
|
||
|
/>
|
||
|
</div>
|
||
|
</div>
|
||
|
)}
|
||
|
</Measure>
|
||
|
)}
|
||
|
</Reference>
|
||
|
{emojiResults.length > 0 && popperRoot
|
||
|
? createPortal(
|
||
|
<Popper placement="top" key={searchText}>
|
||
|
{({ ref, style }) => (
|
||
|
<div
|
||
|
ref={ref}
|
||
|
className="module-composition-input__emoji-suggestions"
|
||
|
style={{
|
||
|
...style,
|
||
|
width: editorWidth,
|
||
|
}}
|
||
|
role="listbox"
|
||
|
aria-expanded={true}
|
||
|
aria-activedescendant={`emoji-result--${
|
||
|
emojiResults[emojiResultsIndex].short_name
|
||
|
}`}
|
||
|
>
|
||
|
{emojiResults.map((emoji, index) => (
|
||
|
<button
|
||
|
key={emoji.short_name}
|
||
|
id={`emoji-result--${emoji.short_name}`}
|
||
|
role="option button"
|
||
|
aria-selected={emojiResultsIndex === index}
|
||
|
onMouseDown={() => {
|
||
|
insertEmoji(
|
||
|
{ shortName: emoji.short_name, skinTone },
|
||
|
true
|
||
|
);
|
||
|
onPickEmoji({ shortName: emoji.short_name });
|
||
|
}}
|
||
|
className={classNames(
|
||
|
'module-composition-input__emoji-suggestions__row',
|
||
|
emojiResultsIndex === index
|
||
|
? 'module-composition-input__emoji-suggestions__row--selected'
|
||
|
: null
|
||
|
)}
|
||
|
>
|
||
|
<Emoji
|
||
|
shortName={emoji.short_name}
|
||
|
size={16}
|
||
|
skinTone={skinTone}
|
||
|
/>
|
||
|
<div className="module-composition-input__emoji-suggestions__row__short-name">
|
||
|
:{emoji.short_name}:
|
||
|
</div>
|
||
|
</button>
|
||
|
))}
|
||
|
</div>
|
||
|
)}
|
||
|
</Popper>,
|
||
|
popperRoot
|
||
|
)
|
||
|
: null}
|
||
|
</Manager>
|
||
|
);
|
||
|
};
|