@chayns-components/emoji-input
Version:
Input field that supports HTML elements and emojis
647 lines (625 loc) • 25.4 kB
JavaScript
import { AreaContext, useIsTouch } from '@chayns-components/core';
import { AnimatePresence } from 'motion/react';
import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { convertEmojisToUnicode, escapeHTML } from '../../utils/emoji';
import { insertTextAtCursorPosition, replaceText, revertAsciiSmileyConversion } from '../../utils/insert';
import { getCharCodeThatWillBeDeleted, insertInvisibleCursorMarker, restoreSelection, saveSelection, insertPseudoMarker, insertCursorAtMarker, getCurrentCursorPosition, setCursorPositionByAbsIndex, moveCursorOutOfIgnoreEmojiSpan, unwrapIgnoreEmojiSpanAtCursor } from '../../utils/selection';
import { convertHTMLToText, convertTextToHTML, cleanupEmptyIgnoreEmojiSpans } from '../../utils/text';
import EmojiPickerPopup from '../emoji-picker-popup/EmojiPickerPopup';
import { StyledEmojiInput, StyledEmojiInputContent, StyledEmojiInputLabel, StyledEmojiInputRightWrapper, StyledMotionEmojiInputEditor, StyledMotionEmojiInputProgress } from './EmojiInput.styles';
import PrefixElement from './prefix-element/PrefixElement';
import { loadEmojiShortList } from '../../utils/asyncEmojiData';
import { scrollCursorIntoView } from '../../utils/scroll';
import { useCursorPosition } from '../../hooks/cursor';
const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'Meta', 'CapsLock']);
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
const EmojiInput = /*#__PURE__*/forwardRef(({
accessToken,
container,
height,
inputId,
isDisabled,
maxHeight = '190px',
onBlur,
onFocus,
onInput,
onKeyDown,
onPrefixElementRemove,
onPopupVisibilityChange,
personId,
placeholder,
popupAlignment,
prefixElement,
rightElement,
shouldHidePlaceholderOnFocus = false,
shouldRevertAsciiSmileyConversionOnBackspace = false,
shouldPreventEmojiPicker,
value,
onCursorPositionChange
}, ref) => {
const isTouch = useIsTouch();
const [plainTextValue, setPlainTextValue] = useState(value);
const [hasFocus, setHasFocus] = useState(false);
const [progressDuration, setProgressDuration] = useState(0);
const [labelWidth, setLabelWidth] = useState(0);
const [isPopupVisible, setIsPopupVisible] = useState(false);
const [isPrefixAnimationFinished, setIsPrefixAnimationFinished] = useState(!prefixElement);
const [prefixElementWidth, setPrefixElementWidth] = useState();
const [emojiShortNames, setEmojiShortNames] = useState({});
const [emojiRegShortNames, setEmojiRegShortNames] = useState(/./);
const areaProvider = useContext(AreaContext);
const editorRef = useRef(null);
const prefixElementRef = useRef(null);
const hasPrefixRendered = useRef(false);
const hasPrefixChanged = useRef(false);
const shouldDeleteOneMoreBackwards = useRef(false);
const shouldDeleteOneMoreForwards = useRef(false);
/** * Tracks the most recently auto-converted ASCII smiley so the next * Backspace keystroke can revert it (Word/IntelliJ-style autocorrect undo). * The ref is invalidated as soon as the user presses any non-modifier key * other than Backspace (see `handleKeyDown`). */
const lastAsciiConversionRef = useRef(null);
const savedCursorPositionRef = useRef(0);
const valueRef = useRef(value);
useCursorPosition(editorRef, useCallback(position => {
savedCursorPositionRef.current = position;
if (typeof onCursorPositionChange === 'function') {
onCursorPositionChange(position);
}
}, [onCursorPositionChange]), {
isDisabled
});
const shouldChangeColor = useMemo(() => areaProvider.shouldChangeColor ?? false, [areaProvider.shouldChangeColor]);
useEffect(() => {
void loadEmojiShortList().then(({
shortNameList,
regShortnames
}) => {
setEmojiShortNames(shortNameList);
setEmojiRegShortNames(regShortnames);
});
}, []);
/**
* This function updates the content of the 'contentEditable' element if the new text is
* different from the previous content. So this is only true if, for example, a text like ":-)"
* has been replaced to the corresponding emoji.
*
* When updating the HTML, the current cursor position is saved before replacing the content, so
* that it can be set again afterward.
*/
const handleUpdateHTML = useCallback(html => {
if (!editorRef.current) {
return;
}
const conversions = [];
let newInnerHTML = convertEmojisToUnicode(html, emojiRegShortNames, emojiShortNames, {
onAsciiConversion: conv => conversions.push(conv)
});
newInnerHTML = convertTextToHTML(newInnerHTML);
newInnerHTML = cleanupEmptyIgnoreEmojiSpans(newInnerHTML);
if (newInnerHTML !== editorRef.current.innerHTML) {
saveSelection(editorRef.current, {
shouldIgnoreEmptyTextNodes: true
});
editorRef.current.innerHTML = newInnerHTML;
restoreSelection(editorRef.current);
// Remember the LAST ASCII conversion so Backspace can revert it.
// Older conversions are dropped on purpose: only the smiley the
// user just produced should be undoable.
if (shouldRevertAsciiSmileyConversionOnBackspace && conversions.length > 0) {
const lastConv = conversions[conversions.length - 1];
const pos = getCurrentCursorPosition(editorRef.current);
if (pos !== null) {
lastAsciiConversionRef.current = {
original: lastConv.original,
emoji: lastConv.emoji,
plainTextCursorPos: pos
};
}
} else {
lastAsciiConversionRef.current = null;
}
}
}, [emojiRegShortNames, emojiShortNames, shouldRevertAsciiSmileyConversionOnBackspace]);
const handleBeforeInput = useCallback(event => {
if (!editorRef.current) {
return;
}
if (isDisabled) {
event.preventDefault();
event.stopPropagation();
return;
}
const {
data,
type
} = event.nativeEvent;
if (type === 'textInput' && data && data.includes('\n')) {
event.preventDefault();
event.stopPropagation();
const text = convertEmojisToUnicode(data, emojiRegShortNames, emojiShortNames);
insertTextAtCursorPosition({
editorElement: editorRef.current,
text
});
const newEvent = new Event('input', {
bubbles: true
});
editorRef.current.dispatchEvent(newEvent);
}
}, [emojiRegShortNames, emojiShortNames, isDisabled]);
/**
* This function handles the 'input' events of the 'contentEditable' element and also passes the
* respective event up accordingly if the 'onInput' property is a function.
*/
const handleInput = useCallback(event => {
if (!editorRef.current) {
return;
}
// If the user is typing inside a no-emoji-convert span, unwrap it first
// This prevents text from getting stuck inside the protection span
unwrapIgnoreEmojiSpanAtCursor(editorRef.current);
if (isDisabled) {
event.stopPropagation();
event.preventDefault();
}
if (shouldDeleteOneMoreBackwards.current) {
shouldDeleteOneMoreBackwards.current = false;
shouldDeleteOneMoreForwards.current = false;
event.preventDefault();
event.stopPropagation();
// Remove content and set cursor to the right position
insertInvisibleCursorMarker();
return;
}
if (shouldDeleteOneMoreForwards.current) {
shouldDeleteOneMoreBackwards.current = false;
shouldDeleteOneMoreForwards.current = false;
event.preventDefault();
event.stopPropagation();
// noinspection JSDeprecatedSymbols
document.execCommand('forwardDelete', false);
return;
}
let cleanedHTML = cleanupEmptyIgnoreEmojiSpans(editorRef.current.innerHTML);
handleUpdateHTML(cleanedHTML);
// After handleUpdateHTML, the DOM has been updated with emojis
// We need to read the text from the updated DOM, not from pre-update HTML
// convertHTMLToText will convert no-emoji-convert spans to [ignoreEmoji] BBCode
const text = convertHTMLToText(editorRef.current.innerHTML);
setPlainTextValue(text);
if (typeof onInput === 'function') {
onInput(event, text);
}
insertCursorAtMarker(editorRef);
moveCursorOutOfIgnoreEmojiSpan(editorRef.current);
}, [handleUpdateHTML, isDisabled, onInput]);
const handleKeyDown = useCallback(event => {
if (isDisabled) {
event.preventDefault();
event.stopPropagation();
return;
}
// --- Backspace-revert for the most recent auto-conversion ---
// If the user presses Backspace immediately after an ASCII smiley
// was auto-converted (and the cursor is still at the post-conversion
// position), we revert the emoji back to the original text and wrap
// it in a protection span so it does not get re-converted.
if (shouldRevertAsciiSmileyConversionOnBackspace && event.key === 'Backspace' && !event.ctrlKey && !event.metaKey && lastAsciiConversionRef.current && editorRef.current) {
const {
original,
emoji,
plainTextCursorPos
} = lastAsciiConversionRef.current;
const currentPos = getCurrentCursorPosition(editorRef.current);
if (currentPos === plainTextCursorPos) {
event.preventDefault();
event.stopPropagation();
const didRevert = revertAsciiSmileyConversion({
editorElement: editorRef.current,
original,
emoji
});
lastAsciiConversionRef.current = null;
if (didRevert) {
// Notify React + downstream consumers via a synthetic input event
const newEvent = new Event('input', {
bubbles: true
});
editorRef.current.dispatchEvent(newEvent);
return;
}
} else {
// Cursor moved away from the just-converted emoji -> drop tracker
lastAsciiConversionRef.current = null;
}
} else if (shouldRevertAsciiSmileyConversionOnBackspace && !MODIFIER_KEYS.has(event.key)) {
// Any other actual keystroke invalidates the revert window
lastAsciiConversionRef.current = null;
}
if (event.key === 'Enter' && isPopupVisible) {
event.preventDefault();
return;
}
if (typeof onKeyDown === 'function') {
onKeyDown(event);
}
if (event.key === 'Enter' && !event.isPropagationStopped() && editorRef.current) {
event.preventDefault();
// noinspection JSDeprecatedSymbols
document.execCommand('insertLineBreak', false);
}
if (event.key === 'Enter') {
requestAnimationFrame(() => {
if (editorRef.current) scrollCursorIntoView(editorRef.current);
});
}
if (event.key === 'Backspace' || event.key === 'Delete' || event.key === 'Unidentified') {
const charCodeThatWillBeDeleted = getCharCodeThatWillBeDeleted(event);
if (charCodeThatWillBeDeleted === 8203) {
if (event.key === 'Backspace' || event.key === 'Unidentified') {
shouldDeleteOneMoreBackwards.current = true;
} else {
shouldDeleteOneMoreForwards.current = true;
}
}
}
}, [isDisabled, isPopupVisible, onKeyDown, shouldRevertAsciiSmileyConversionOnBackspace]);
const handlePopupVisibility = useCallback(isVisible => {
setIsPopupVisible(isVisible);
if (editorRef.current && isVisible) {
saveSelection(editorRef.current);
}
if (typeof onPopupVisibilityChange === 'function') {
onPopupVisibilityChange(isVisible);
}
}, [onPopupVisibilityChange]);
/**
* This function prevents formatting from being adopted when texts are inserted. To do this, the
* plain text is read from the event after the default behavior has been prevented. The plain
* text is then inserted at the correct position in the input field using document.execCommand('insertHTML')
*/
const handlePaste = useCallback(event => {
if (editorRef.current) {
event.preventDefault();
if (isDisabled) {
event.stopPropagation();
return;
}
// This ensures, that only the copied text is inserted and not its HTML formatting.
let text = event.clipboardData.getData('text/plain');
text = convertEmojisToUnicode(text, emojiRegShortNames, emojiShortNames);
/* This ensures, that valid HTML in the inserted text is not interpreted as such. e.g. if the user
pasted the text '<b>test</b>' (not as formatted html), the <b> tags need to be escaped, to
prevent it from being interpreted as html. */
text = escapeHTML(text);
// Insert an invisible control character at the end of the text to place the cursor in the correct position after insertion.
if (text.includes('\n')) {
text += '\u200C';
}
// This deprecated function is used, because it causes the inserted content to be added to the undo stack.
// If the text were to be inserted directly into the 'innerHTML' of the editor element, the undo stack would not be updated.
// In that case on CTRL+Z the inserted text would not be removed.
document.execCommand('insertHTML', false, text);
const newEvent = new Event('input', {
bubbles: true
});
editorRef.current.dispatchEvent(newEvent);
}
}, [emojiRegShortNames, emojiShortNames, isDisabled]);
/**
* This function prevents formatting from being adopted when texts are dropped. To do this, the
* plain text is read from the event after the default behavior has been prevented. The plain
* text is then inserted at the correct position in the input field using document.execCommand('insertHTML')
*/
const handleDrop = useCallback(event => {
if (editorRef.current) {
event.preventDefault();
if (isDisabled) {
event.stopPropagation();
return;
}
// This ensures, that only the dropped text is inserted and not its HTML formatting.
let text = event.dataTransfer?.getData('text');
if (!text) {
return;
}
text = convertEmojisToUnicode(text, emojiRegShortNames, emojiShortNames);
/* This ensures, that valid HTML in the inserted text is not interpreted as such. e.g. if the user
drops the text '<b>test</b>' (not as formatted html), the <b> tags need to be escaped, to
prevent it from being interpreted as html. */
text = escapeHTML(text);
// This deprecated function is used, because it causes the inserted content to be added to the undo stack.
// If the text were to be inserted directly into the 'innerHTML' of the editor element, the undo stack would not be updated.
// In that case on CTRL+Z the inserted text would not be removed.
document.execCommand('insertHTML', false, text);
const newEvent = new Event('input', {
bubbles: true
});
editorRef.current.dispatchEvent(newEvent);
}
}, [emojiRegShortNames, emojiShortNames, isDisabled]);
/**
* This function uses the 'insertTextAtCursorPosition' function to insert the emoji at the
* correct position in the editor element.
*
* At the end an 'input' event is dispatched, so that the function 'handleInput' is triggered,
* which in turn executes the 'onInput' function from the props. So this serves to ensure that
* the event is also passed through to the top when inserting via the popup.
*/
const handlePopupSelect = useCallback(emoji => {
if (editorRef.current) {
insertTextAtCursorPosition({
editorElement: editorRef.current,
text: emoji,
shouldUseSavedSelection: true
});
const event = new Event('input', {
bubbles: true
});
editorRef.current.dispatchEvent(event);
}
}, []);
useEffect(() => {
if (!shouldRevertAsciiSmileyConversionOnBackspace) {
lastAsciiConversionRef.current = null;
}
}, [shouldRevertAsciiSmileyConversionOnBackspace]);
useEffect(() => {
if (typeof onPrefixElementRemove !== 'function') {
return;
}
if (!hasPrefixRendered.current) {
return;
}
const convertedText = convertHTMLToText(editorRef.current?.innerHTML ?? '').replace(' ', ' ');
const convertedPrefix = prefixElement && prefixElement.replace(' ', ' ');
if (convertedPrefix && convertedText.includes(convertedPrefix) && convertedText.length > convertedPrefix.length || convertedPrefix === convertedText) {
return;
}
if (hasPrefixChanged.current) {
hasPrefixChanged.current = false;
return;
}
onPrefixElementRemove();
hasPrefixRendered.current = false;
}, [onPrefixElementRemove, plainTextValue.length, prefixElement]);
useEffect(() => {
if (typeof prefixElement === 'string') {
hasPrefixChanged.current = true;
}
}, [prefixElement]);
useEffect(() => {
if (value !== plainTextValue) {
setPlainTextValue(value);
handleUpdateHTML(value);
}
}, [handleUpdateHTML, plainTextValue, value]);
// After every input, ensure the cursor is not stuck inside a no-emoji-convert span
useEffect(() => {
moveCursorOutOfIgnoreEmojiSpan(editorRef.current);
}, [plainTextValue]);
// This effect is used to call the 'handleUpdateHTML' function once after the component has been
// rendered. This is necessary because the 'contentEditable' element otherwise does not display
// the HTML content correctly when the component is rendered for the first time.
useIsomorphicLayoutEffect(() => {
handleUpdateHTML(valueRef.current);
}, [handleUpdateHTML]);
const handleInsertTextAtCursorPosition = useCallback(text => {
if (editorRef.current) {
insertTextAtCursorPosition({
editorElement: editorRef.current,
text
});
const newEvent = new Event('input', {
bubbles: true
});
editorRef.current.dispatchEvent(newEvent);
}
}, []);
const handleReplaceText = useCallback((searchText, pasteText, options) => {
if (editorRef.current) {
replaceText({
editorElement: editorRef.current,
searchText,
pasteText,
options
});
insertPseudoMarker();
const newEvent = new Event('input', {
bubbles: true
});
editorRef.current.dispatchEvent(newEvent);
}
}, []);
const handleStartProgress = useCallback(duration => {
setProgressDuration(duration);
}, []);
const handleStopProgress = useCallback(() => {
setProgressDuration(0);
}, []);
const handleSetCursorPosition = useCallback(position => {
if (!editorRef.current) {
return;
}
const resolvedPosition = position ?? savedCursorPositionRef.current;
savedCursorPositionRef.current = resolvedPosition;
editorRef.current.focus();
setCursorPositionByAbsIndex({
editorElement: editorRef.current,
position: resolvedPosition
});
const updatedPosition = getCurrentCursorPosition(editorRef.current);
if (typeof updatedPosition === 'number') {
savedCursorPositionRef.current = updatedPosition;
}
}, []);
useImperativeHandle(ref, () => ({
insertTextAtCursorPosition: handleInsertTextAtCursorPosition,
replaceText: handleReplaceText,
startProgress: handleStartProgress,
stopProgress: handleStopProgress,
focus: () => editorRef.current?.focus(),
blur: () => editorRef.current?.blur(),
setCursorPosition: handleSetCursorPosition
}), [handleInsertTextAtCursorPosition, handleReplaceText, handleStartProgress, handleStopProgress, handleSetCursorPosition]);
useEffect(() => {
/**
* This function ensures that the input field does not lose focus when the popup is opened
* or an emoji is selected in it. For this purpose the corresponding elements get the class
* 'prevent-lose-focus'.
*
* The class can also be set to any other elements that should also not cause the input
* field to lose focus.
*/
const handlePreventLoseFocus = event => {
const element = event.target;
if (element.classList.contains('prevent-lose-focus') || element.parentElement?.classList.contains('prevent-lose-focus') || element.parentElement?.parentElement?.classList.contains('prevent-lose-focus')) {
event.preventDefault();
event.stopPropagation();
}
};
document.body.addEventListener('mousedown', handlePreventLoseFocus);
return () => {
document.body.removeEventListener('mousedown', handlePreventLoseFocus);
};
}, []);
const shouldShowPlaceholder = useMemo(() => {
if (!isPrefixAnimationFinished) {
return false;
}
const isJustPrefixElement = prefixElement && convertTextToHTML(prefixElement) === editorRef.current?.innerHTML;
const shouldRenderPlaceholder = prefixElement && !plainTextValue || (prefixElement ? prefixElementWidth && prefixElementWidth > 0 : true);
switch (true) {
case (!plainTextValue || isJustPrefixElement) && shouldHidePlaceholderOnFocus && !hasFocus:
case (!plainTextValue || isJustPrefixElement) && !shouldHidePlaceholderOnFocus:
return shouldRenderPlaceholder;
case (!plainTextValue || isJustPrefixElement) && shouldHidePlaceholderOnFocus && hasFocus:
return false;
default:
return false;
}
}, [isPrefixAnimationFinished, hasFocus, plainTextValue, prefixElement, shouldHidePlaceholderOnFocus, prefixElementWidth]);
useEffect(() => {
if (prefixElement) {
setIsPrefixAnimationFinished(false);
}
}, [prefixElement]);
const handleFocus = event => {
if (typeof onFocus === 'function' && !isDisabled) {
onFocus(event);
}
setHasFocus(true);
};
const handleBlur = event => {
if (typeof onBlur === 'function' && !isDisabled) {
onBlur(event);
}
setHasFocus(false);
};
useEffect(() => {
if (editorRef.current && prefixElement) {
const text = convertEmojisToUnicode(prefixElement, emojiRegShortNames, emojiShortNames);
insertTextAtCursorPosition({
editorElement: editorRef.current,
text
});
handleUpdateHTML(prefixElement);
hasPrefixRendered.current = true;
}
}, [emojiRegShortNames, emojiShortNames, handleUpdateHTML, prefixElement]);
useEffect(() => {
if (prefixElementRef.current && prefixElement && convertTextToHTML(prefixElement) === editorRef.current?.innerHTML) {
setPrefixElementWidth(prefixElementRef.current.offsetWidth + 2);
} else {
setPrefixElementWidth(undefined);
}
}, [plainTextValue, prefixElement]);
useEffect(() => {
const handleResize = () => {
if (editorRef.current) {
setLabelWidth(editorRef.current.offsetWidth);
}
};
const resizeObserver = new ResizeObserver(handleResize);
if (editorRef.current) {
resizeObserver.observe(editorRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, []);
useEffect(() => {
const blurElement = () => {
if (editorRef.current && document.activeElement === editorRef.current && isDisabled) {
editorRef.current.blur();
}
};
document.addEventListener('focus', blurElement, true);
return () => {
document.removeEventListener('focus', blurElement, true);
};
}, [isDisabled]);
return /*#__PURE__*/React.createElement(StyledEmojiInput, {
$isDisabled: isDisabled,
$shouldChangeColor: shouldChangeColor
}, /*#__PURE__*/React.createElement(AnimatePresence, {
initial: true
}, progressDuration > 0 && /*#__PURE__*/React.createElement(StyledMotionEmojiInputProgress, {
animate: {
width: '100%'
},
exit: {
opacity: 0
},
initial: {
opacity: 1,
width: '0%'
},
transition: {
width: {
ease: 'linear',
duration: progressDuration
},
opacity: {
type: 'tween',
duration: 0.3
}
}
})), /*#__PURE__*/React.createElement(StyledEmojiInputContent, null, prefixElement && /*#__PURE__*/React.createElement(PrefixElement, {
key: prefixElement,
element: prefixElement,
prefixElementRef: prefixElementRef,
setIsPrefixAnimationFinished: setIsPrefixAnimationFinished
}), /*#__PURE__*/React.createElement(StyledMotionEmojiInputEditor, {
className: "chayns-scrollbar",
animate: {
maxHeight: height ?? maxHeight,
minHeight: height ?? '26px'
},
contentEditable: true,
id: inputId,
onBeforeInput: handleBeforeInput,
onBlur: handleBlur,
onFocus: handleFocus,
onInput: handleInput,
onKeyDown: handleKeyDown,
onPaste: handlePaste,
onDrop: handleDrop,
ref: editorRef,
$shouldShowContent: isPrefixAnimationFinished,
transition: {
type: 'tween',
duration: 0.2
}
}), shouldShowPlaceholder && /*#__PURE__*/React.createElement(StyledEmojiInputLabel, {
$maxWidth: labelWidth,
$offsetWidth: prefixElementWidth
}, placeholder), !isTouch && !shouldPreventEmojiPicker && /*#__PURE__*/React.createElement(EmojiPickerPopup, {
accessToken: accessToken,
container: container,
onSelect: handlePopupSelect,
onPopupVisibilityChange: handlePopupVisibility,
personId: personId
})), rightElement && /*#__PURE__*/React.createElement(StyledEmojiInputRightWrapper, null, rightElement));
});
EmojiInput.displayName = 'EmojiInput';
export default EmojiInput;
//# sourceMappingURL=EmojiInput.js.map