autosuggest_textarea.jsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import PropTypes from 'prop-types';
  2. import { useCallback, useRef, useState, useEffect, forwardRef } from 'react';
  3. import classNames from 'classnames';
  4. import ImmutablePropTypes from 'react-immutable-proptypes';
  5. import Textarea from 'react-textarea-autosize';
  6. import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
  7. import AutosuggestEmoji from './autosuggest_emoji';
  8. import { AutosuggestHashtag } from './autosuggest_hashtag';
  9. const textAtCursorMatchesToken = (str, caretPosition) => {
  10. let word;
  11. let left = str.slice(0, caretPosition).search(/\S+$/);
  12. let right = str.slice(caretPosition).search(/\s/);
  13. if (right < 0) {
  14. word = str.slice(left);
  15. } else {
  16. word = str.slice(left, right + caretPosition);
  17. }
  18. if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) {
  19. return [null, null];
  20. }
  21. word = word.trim().toLowerCase();
  22. if (word.length > 0) {
  23. return [left + 1, word];
  24. } else {
  25. return [null, null];
  26. }
  27. };
  28. const AutosuggestTextarea = forwardRef(({
  29. value,
  30. suggestions,
  31. disabled,
  32. placeholder,
  33. onSuggestionSelected,
  34. onSuggestionsClearRequested,
  35. onSuggestionsFetchRequested,
  36. onChange,
  37. onKeyUp,
  38. onKeyDown,
  39. onPaste,
  40. onFocus,
  41. autoFocus = true,
  42. lang,
  43. children,
  44. }, textareaRef) => {
  45. const [suggestionsHidden, setSuggestionsHidden] = useState(true);
  46. const [selectedSuggestion, setSelectedSuggestion] = useState(0);
  47. const lastTokenRef = useRef(null);
  48. const tokenStartRef = useRef(0);
  49. const handleChange = useCallback((e) => {
  50. const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
  51. if (token !== null && lastTokenRef.current !== token) {
  52. tokenStartRef.current = tokenStart;
  53. lastTokenRef.current = token;
  54. setSelectedSuggestion(0);
  55. onSuggestionsFetchRequested(token);
  56. } else if (token === null) {
  57. lastTokenRef.current = null;
  58. onSuggestionsClearRequested();
  59. }
  60. onChange(e);
  61. }, [onSuggestionsFetchRequested, onSuggestionsClearRequested, onChange, setSelectedSuggestion]);
  62. const handleKeyDown = useCallback((e) => {
  63. if (disabled) {
  64. e.preventDefault();
  65. return;
  66. }
  67. if (e.which === 229 || e.isComposing) {
  68. // Ignore key events during text composition
  69. // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
  70. return;
  71. }
  72. switch(e.key) {
  73. case 'Escape':
  74. if (suggestions.size === 0 || suggestionsHidden) {
  75. document.querySelector('.ui').parentElement.focus();
  76. } else {
  77. e.preventDefault();
  78. setSuggestionsHidden(true);
  79. }
  80. break;
  81. case 'ArrowDown':
  82. if (suggestions.size > 0 && !suggestionsHidden) {
  83. e.preventDefault();
  84. setSelectedSuggestion(Math.min(selectedSuggestion + 1, suggestions.size - 1));
  85. }
  86. break;
  87. case 'ArrowUp':
  88. if (suggestions.size > 0 && !suggestionsHidden) {
  89. e.preventDefault();
  90. setSelectedSuggestion(Math.max(selectedSuggestion - 1, 0));
  91. }
  92. break;
  93. case 'Enter':
  94. case 'Tab':
  95. // Select suggestion
  96. if (lastTokenRef.current !== null && suggestions.size > 0 && !suggestionsHidden) {
  97. e.preventDefault();
  98. e.stopPropagation();
  99. onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestions.get(selectedSuggestion));
  100. }
  101. break;
  102. }
  103. if (e.defaultPrevented || !onKeyDown) {
  104. return;
  105. }
  106. onKeyDown(e);
  107. }, [disabled, suggestions, suggestionsHidden, selectedSuggestion, setSelectedSuggestion, setSuggestionsHidden, onSuggestionSelected, onKeyDown]);
  108. const handleBlur = useCallback(() => {
  109. setSuggestionsHidden(true);
  110. }, [setSuggestionsHidden]);
  111. const handleFocus = useCallback((e) => {
  112. if (onFocus) {
  113. onFocus(e);
  114. }
  115. }, [onFocus]);
  116. const handleSuggestionClick = useCallback((e) => {
  117. const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index'));
  118. e.preventDefault();
  119. onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestion);
  120. textareaRef.current?.focus();
  121. }, [suggestions, onSuggestionSelected, textareaRef]);
  122. const handlePaste = useCallback((e) => {
  123. if (e.clipboardData && e.clipboardData.files.length === 1) {
  124. onPaste(e.clipboardData.files);
  125. e.preventDefault();
  126. }
  127. }, [onPaste]);
  128. // Show the suggestions again whenever they change and the textarea is focused
  129. useEffect(() => {
  130. if (suggestions.size > 0 && textareaRef.current === document.activeElement) {
  131. setSuggestionsHidden(false);
  132. }
  133. }, [suggestions, textareaRef, setSuggestionsHidden]);
  134. const renderSuggestion = (suggestion, i) => {
  135. let inner, key;
  136. if (suggestion.type === 'emoji') {
  137. inner = <AutosuggestEmoji emoji={suggestion} />;
  138. key = suggestion.id;
  139. } else if (suggestion.type === 'hashtag') {
  140. inner = <AutosuggestHashtag tag={suggestion} />;
  141. key = suggestion.name;
  142. } else if (suggestion.type === 'account') {
  143. inner = <AutosuggestAccountContainer id={suggestion.id} />;
  144. key = suggestion.id;
  145. }
  146. return (
  147. <div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={handleSuggestionClick}>
  148. {inner}
  149. </div>
  150. );
  151. };
  152. return [
  153. <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
  154. <div className='autosuggest-textarea'>
  155. <label>
  156. <span style={{ display: 'none' }}>{placeholder}</span>
  157. <Textarea
  158. ref={textareaRef}
  159. className='autosuggest-textarea__textarea'
  160. disabled={disabled}
  161. placeholder={placeholder}
  162. autoFocus={autoFocus}
  163. value={value}
  164. onChange={handleChange}
  165. onKeyDown={handleKeyDown}
  166. onKeyUp={onKeyUp}
  167. onFocus={handleFocus}
  168. onBlur={handleBlur}
  169. onPaste={handlePaste}
  170. dir='auto'
  171. aria-autocomplete='list'
  172. lang={lang}
  173. />
  174. </label>
  175. </div>
  176. {children}
  177. </div>,
  178. <div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
  179. <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
  180. {suggestions.map(renderSuggestion)}
  181. </div>
  182. </div>,
  183. ];
  184. });
  185. AutosuggestTextarea.propTypes = {
  186. value: PropTypes.string,
  187. suggestions: ImmutablePropTypes.list,
  188. disabled: PropTypes.bool,
  189. placeholder: PropTypes.string,
  190. onSuggestionSelected: PropTypes.func.isRequired,
  191. onSuggestionsClearRequested: PropTypes.func.isRequired,
  192. onSuggestionsFetchRequested: PropTypes.func.isRequired,
  193. onChange: PropTypes.func.isRequired,
  194. onKeyUp: PropTypes.func,
  195. onKeyDown: PropTypes.func,
  196. onPaste: PropTypes.func.isRequired,
  197. onFocus:PropTypes.func,
  198. children: PropTypes.node,
  199. autoFocus: PropTypes.bool,
  200. lang: PropTypes.string,
  201. };
  202. export default AutosuggestTextarea;