UNPKG

@chayns-components/emoji-input

Version:
536 lines (519 loc) • 21.9 kB
"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('&nbsp;', ' '); const convertedPrefix = prefixElement && prefixElement.replace('&nbsp;', ' '); 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