@chayns-components/emoji-input
Version:
Input field that supports HTML elements and emojis
536 lines (519 loc) • 21.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
var _core = require("@chayns-components/core");
var _chaynsApi = require("chayns-api");
var _react = require("motion/react");
var _react2 = _interopRequireWildcard(require("react"));
var _emoji = require("../../utils/emoji");
var _insert = require("../../utils/insert");
var _selection = require("../../utils/selection");
var _text = require("../../utils/text");
var _EmojiPickerPopup = _interopRequireDefault(require("../emoji-picker-popup/EmojiPickerPopup"));
var _EmojiInput = require("./EmojiInput.styles");
var _PrefixElement = _interopRequireDefault(require("./prefix-element/PrefixElement"));
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? _react2.useLayoutEffect : _react2.useEffect;
const EmojiInput = /*#__PURE__*/(0, _react2.forwardRef)(({
accessToken,
height,
inputId,
isDisabled,
maxHeight = '190px',
onBlur,
onFocus,
onInput,
onKeyDown,
onPrefixElementRemove,
onPopupVisibilityChange,
personId,
placeholder,
popupAlignment,
prefixElement,
rightElement,
shouldHidePlaceholderOnFocus = false,
shouldPreventEmojiPicker,
value
}, ref) => {
const [isTouch] = (0, _react2.useState)((0, _core.getIsTouch)());
const [plainTextValue, setPlainTextValue] = (0, _react2.useState)(value);
const [hasFocus, setHasFocus] = (0, _react2.useState)(false);
const [progressDuration, setProgressDuration] = (0, _react2.useState)(0);
const [labelWidth, setLabelWidth] = (0, _react2.useState)(0);
const [isPopupVisible, setIsPopupVisible] = (0, _react2.useState)(false);
const [isPrefixAnimationFinished, setIsPrefixAnimationFinished] = (0, _react2.useState)(!prefixElement);
const [prefixElementWidth, setPrefixElementWidth] = (0, _react2.useState)();
const [textLength, setTextLength] = (0, _react2.useState)(0);
const areaProvider = (0, _react2.useContext)(_core.AreaContext);
const editorRef = (0, _react2.useRef)(null);
const prefixElementRef = (0, _react2.useRef)(null);
const hasPrefixRendered = (0, _react2.useRef)(false);
const hasPrefixChanged = (0, _react2.useRef)(false);
const shouldDeleteOneMoreBackwards = (0, _react2.useRef)(false);
const shouldDeleteOneMoreForwards = (0, _react2.useRef)(false);
const valueRef = (0, _react2.useRef)(value);
const {
browser
} = (0, _chaynsApi.getDevice)();
const shouldChangeColor = (0, _react2.useMemo)(() => areaProvider.shouldChangeColor ?? false, [areaProvider.shouldChangeColor]);
/**
* 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 = (0, _react2.useCallback)(html => {
if (!editorRef.current) {
return;
}
let newInnerHTML = (0, _emoji.convertEmojisToUnicode)(html);
newInnerHTML = (0, _text.convertTextToHTML)(newInnerHTML);
if (newInnerHTML !== editorRef.current.innerHTML) {
(0, _selection.saveSelection)(editorRef.current, {
shouldIgnoreEmptyTextNodes: true
});
editorRef.current.innerHTML = newInnerHTML;
(0, _selection.restoreSelection)(editorRef.current);
}
}, []);
const handleBeforeInput = (0, _react2.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 = (0, _emoji.convertEmojisToUnicode)(data);
(0, _insert.insertTextAtCursorPosition)({
editorElement: editorRef.current,
text
});
const newEvent = new Event('input', {
bubbles: true
});
editorRef.current.dispatchEvent(newEvent);
}
}, [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 = (0, _react2.useCallback)(event => {
if (!editorRef.current) {
return;
}
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
(0, _selection.insertInvisibleCursorMarker)();
return;
}
if (shouldDeleteOneMoreForwards.current) {
shouldDeleteOneMoreBackwards.current = false;
shouldDeleteOneMoreForwards.current = false;
event.preventDefault();
event.stopPropagation();
// noinspection JSDeprecatedSymbols
document.execCommand('forwardDelete', false);
return;
}
handleUpdateHTML(editorRef.current.innerHTML);
const text = (0, _text.convertHTMLToText)(editorRef.current.innerHTML);
setPlainTextValue(text);
if (typeof onInput === 'function') {
onInput(event, text);
}
}, [handleUpdateHTML, isDisabled, onInput]);
const handleKeyDown = (0, _react2.useCallback)(event => {
if (isDisabled) {
event.preventDefault();
event.stopPropagation();
return;
}
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 === 'Backspace' || event.key === 'Delete' || event.key === 'Unidentified') {
const charCodeThatWillBeDeleted = (0, _selection.getCharCodeThatWillBeDeleted)(event);
if (charCodeThatWillBeDeleted === 8203) {
if (event.key === 'Backspace' || event.key === 'Unidentified') {
shouldDeleteOneMoreBackwards.current = true;
} else {
shouldDeleteOneMoreForwards.current = true;
}
}
}
}, [isDisabled, isPopupVisible, onKeyDown]);
const handlePopupVisibility = (0, _react2.useCallback)(isVisible => {
setIsPopupVisible(isVisible);
if (editorRef.current && isVisible) {
(0, _selection.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 = (0, _react2.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 = (0, _emoji.convertEmojisToUnicode)(text);
/* 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 = (0, _emoji.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);
}
}, [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 = (0, _react2.useCallback)(event => {
if (editorRef.current) {
var _event$dataTransfer;
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 = event.dataTransfer) === null || _event$dataTransfer === void 0 ? void 0 : _event$dataTransfer.getData('text');
if (!text) {
return;
}
text = (0, _emoji.convertEmojisToUnicode)(text);
/* 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 = (0, _emoji.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);
}
}, [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 = (0, _react2.useCallback)(emoji => {
if (editorRef.current) {
(0, _insert.insertTextAtCursorPosition)({
editorElement: editorRef.current,
text: emoji,
shouldUseSavedSelection: true
});
const event = new Event('input', {
bubbles: true
});
editorRef.current.dispatchEvent(event);
}
}, []);
(0, _react2.useEffect)(() => {
var _editorRef$current;
if (typeof onPrefixElementRemove !== 'function') {
return;
}
if (!hasPrefixRendered.current) {
return;
}
const convertedText = (0, _text.convertHTMLToText)(((_editorRef$current = editorRef.current) === null || _editorRef$current === void 0 ? void 0 : _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]);
(0, _react2.useEffect)(() => {
if (typeof prefixElement === 'string') {
hasPrefixChanged.current = true;
}
}, [prefixElement]);
(0, _react2.useEffect)(() => {
if (value !== plainTextValue) {
setPlainTextValue(value);
handleUpdateHTML(value);
}
}, [handleUpdateHTML, plainTextValue, value]);
// 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 = (0, _react2.useCallback)(text => {
if (editorRef.current) {
(0, _insert.insertTextAtCursorPosition)({
editorElement: editorRef.current,
text
});
const newEvent = new Event('input', {
bubbles: true
});
editorRef.current.dispatchEvent(newEvent);
}
}, []);
const handleReplaceText = (0, _react2.useCallback)((searchText, pasteText) => {
if (editorRef.current) {
(0, _insert.replaceText)({
editorElement: editorRef.current,
searchText,
pasteText
});
const newEvent = new Event('input', {
bubbles: true
});
editorRef.current.dispatchEvent(newEvent);
}
}, []);
const handleStartProgress = (0, _react2.useCallback)(duration => {
setProgressDuration(duration);
}, []);
const handleStopProgress = (0, _react2.useCallback)(() => {
setProgressDuration(0);
}, []);
(0, _react2.useImperativeHandle)(ref, () => ({
insertTextAtCursorPosition: handleInsertTextAtCursorPosition,
replaceText: handleReplaceText,
startProgress: handleStartProgress,
stopProgress: handleStopProgress,
focus: () => {
var _editorRef$current2;
return (_editorRef$current2 = editorRef.current) === null || _editorRef$current2 === void 0 ? void 0 : _editorRef$current2.focus();
},
blur: () => {
var _editorRef$current3;
return (_editorRef$current3 = editorRef.current) === null || _editorRef$current3 === void 0 ? void 0 : _editorRef$current3.blur();
}
}), [handleInsertTextAtCursorPosition, handleReplaceText, handleStartProgress, handleStopProgress]);
(0, _react2.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 => {
var _element$parentElemen, _element$parentElemen2;
const element = event.target;
if (element.classList.contains('prevent-lose-focus') || (_element$parentElemen = element.parentElement) !== null && _element$parentElemen !== void 0 && _element$parentElemen.classList.contains('prevent-lose-focus') || (_element$parentElemen2 = element.parentElement) !== null && _element$parentElemen2 !== void 0 && (_element$parentElemen2 = _element$parentElemen2.parentElement) !== null && _element$parentElemen2 !== void 0 && _element$parentElemen2.classList.contains('prevent-lose-focus')) {
event.preventDefault();
event.stopPropagation();
}
};
document.body.addEventListener('mousedown', handlePreventLoseFocus);
return () => {
document.body.removeEventListener('mousedown', handlePreventLoseFocus);
};
}, []);
const shouldShowPlaceholder = (0, _react2.useMemo)(() => {
var _editorRef$current4;
if (!isPrefixAnimationFinished) {
return false;
}
const isJustPrefixElement = prefixElement && (0, _text.convertTextToHTML)(prefixElement) === ((_editorRef$current4 = editorRef.current) === null || _editorRef$current4 === void 0 ? void 0 : _editorRef$current4.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]);
(0, _react2.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);
};
(0, _react2.useEffect)(() => {
if (editorRef.current && prefixElement) {
const text = (0, _emoji.convertEmojisToUnicode)(prefixElement);
(0, _insert.insertTextAtCursorPosition)({
editorElement: editorRef.current,
text
});
handleUpdateHTML(prefixElement);
hasPrefixRendered.current = true;
}
}, [handleUpdateHTML, prefixElement]);
(0, _react2.useEffect)(() => {
var _editorRef$current5;
if (prefixElementRef.current && prefixElement && (0, _text.convertTextToHTML)(prefixElement) === ((_editorRef$current5 = editorRef.current) === null || _editorRef$current5 === void 0 ? void 0 : _editorRef$current5.innerHTML)) {
setPrefixElementWidth(prefixElementRef.current.offsetWidth + 2);
} else {
setPrefixElementWidth(undefined);
}
}, [plainTextValue, prefixElement]);
(0, _react2.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();
};
}, []);
(0, _react2.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__*/_react2.default.createElement(_EmojiInput.StyledEmojiInput, {
$isDisabled: isDisabled,
$shouldChangeColor: shouldChangeColor
}, /*#__PURE__*/_react2.default.createElement(_react.AnimatePresence, {
initial: true
}, progressDuration > 0 && /*#__PURE__*/_react2.default.createElement(_EmojiInput.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__*/_react2.default.createElement(_EmojiInput.StyledEmojiInputContent, null, prefixElement && /*#__PURE__*/_react2.default.createElement(_PrefixElement.default, {
key: prefixElement,
element: prefixElement,
prefixElementRef: prefixElementRef,
setIsPrefixAnimationFinished: setIsPrefixAnimationFinished
}), /*#__PURE__*/_react2.default.createElement(_EmojiInput.StyledMotionEmojiInputEditor, {
$browser: browser === null || browser === void 0 ? void 0 : browser.name,
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__*/_react2.default.createElement(_EmojiInput.StyledEmojiInputLabel, {
$maxWidth: labelWidth,
$offsetWidth: prefixElementWidth
}, placeholder), !isTouch && !shouldPreventEmojiPicker && /*#__PURE__*/_react2.default.createElement(_EmojiPickerPopup.default, {
accessToken: accessToken,
onSelect: handlePopupSelect,
onPopupVisibilityChange: handlePopupVisibility,
personId: personId
})), rightElement && /*#__PURE__*/_react2.default.createElement(_EmojiInput.StyledEmojiInputRightWrapper, null, rightElement));
});
EmojiInput.displayName = 'EmojiInput';
var _default = exports.default = EmojiInput;
//# sourceMappingURL=EmojiInput.js.map