You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

233 lines
6.8 KiB

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