闭社主体 forked from https://github.com/tootsuite/mastodon
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.

223 lines
6.5 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 classNames from 'classnames';
  9. const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
  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 || searchTokens.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. export default class AutosuggestInput extends ImmutablePureComponent {
  29. static propTypes = {
  30. value: PropTypes.string,
  31. suggestions: ImmutablePropTypes.list,
  32. disabled: PropTypes.bool,
  33. placeholder: PropTypes.string,
  34. onSuggestionSelected: PropTypes.func.isRequired,
  35. onSuggestionsClearRequested: PropTypes.func.isRequired,
  36. onSuggestionsFetchRequested: PropTypes.func.isRequired,
  37. onChange: PropTypes.func.isRequired,
  38. onKeyUp: PropTypes.func,
  39. onKeyDown: PropTypes.func,
  40. autoFocus: PropTypes.bool,
  41. className: PropTypes.string,
  42. id: PropTypes.string,
  43. searchTokens: PropTypes.arrayOf(PropTypes.string),
  44. maxLength: PropTypes.number,
  45. };
  46. static defaultProps = {
  47. autoFocus: true,
  48. searchTokens: ['@', ':', '#'],
  49. };
  50. state = {
  51. suggestionsHidden: true,
  52. focused: false,
  53. selectedSuggestion: 0,
  54. lastToken: null,
  55. tokenStart: 0,
  56. };
  57. onChange = (e) => {
  58. const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens);
  59. if (token !== null && this.state.lastToken !== token) {
  60. this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
  61. this.props.onSuggestionsFetchRequested(token);
  62. } else if (token === null) {
  63. this.setState({ lastToken: null });
  64. this.props.onSuggestionsClearRequested();
  65. }
  66. this.props.onChange(e);
  67. }
  68. onKeyDown = (e) => {
  69. const { suggestions, disabled } = this.props;
  70. const { selectedSuggestion, suggestionsHidden } = this.state;
  71. if (disabled) {
  72. e.preventDefault();
  73. return;
  74. }
  75. if (e.which === 229 || e.isComposing) {
  76. // Ignore key events during text composition
  77. // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
  78. return;
  79. }
  80. switch(e.key) {
  81. case 'Escape':
  82. if (suggestions.size === 0 || suggestionsHidden) {
  83. document.querySelector('.ui').parentElement.focus();
  84. } else {
  85. e.preventDefault();
  86. this.setState({ suggestionsHidden: true });
  87. }
  88. break;
  89. case 'ArrowDown':
  90. if (suggestions.size > 0 && !suggestionsHidden) {
  91. e.preventDefault();
  92. this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
  93. }
  94. break;
  95. case 'ArrowUp':
  96. if (suggestions.size > 0 && !suggestionsHidden) {
  97. e.preventDefault();
  98. this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
  99. }
  100. break;
  101. case 'Enter':
  102. case 'Tab':
  103. // Select suggestion
  104. if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
  105. e.preventDefault();
  106. e.stopPropagation();
  107. this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
  108. }
  109. break;
  110. }
  111. if (e.defaultPrevented || !this.props.onKeyDown) {
  112. return;
  113. }
  114. this.props.onKeyDown(e);
  115. }
  116. onBlur = () => {
  117. this.setState({ suggestionsHidden: true, focused: false });
  118. }
  119. onFocus = () => {
  120. this.setState({ focused: true });
  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.input.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. setInput = (c) => {
  134. this.input = c;
  135. }
  136. renderSuggestion = (suggestion, i) => {
  137. const { selectedSuggestion } = this.state;
  138. let inner, key;
  139. if (suggestion.type === 'emoji') {
  140. inner = <AutosuggestEmoji emoji={suggestion} />;
  141. key = suggestion.id;
  142. } else if (suggestion.type ==='hashtag') {
  143. inner = <AutosuggestHashtag tag={suggestion} />;
  144. key = suggestion.name;
  145. } else if (suggestion.type === 'account') {
  146. inner = <AutosuggestAccountContainer id={suggestion.id} />;
  147. key = suggestion.id;
  148. }
  149. return (
  150. <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
  151. {inner}
  152. </div>
  153. );
  154. }
  155. render () {
  156. const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props;
  157. const { suggestionsHidden } = this.state;
  158. return (
  159. <div className='autosuggest-input'>
  160. <label>
  161. <span style={{ display: 'none' }}>{placeholder}</span>
  162. <input
  163. type='text'
  164. ref={this.setInput}
  165. disabled={disabled}
  166. placeholder={placeholder}
  167. autoFocus={autoFocus}
  168. value={value}
  169. onChange={this.onChange}
  170. onKeyDown={this.onKeyDown}
  171. onKeyUp={onKeyUp}
  172. onFocus={this.onFocus}
  173. onBlur={this.onBlur}
  174. dir='auto'
  175. aria-autocomplete='list'
  176. id={id}
  177. className={className}
  178. maxLength={maxLength}
  179. />
  180. </label>
  181. <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
  182. {suggestions.map(this.renderSuggestion)}
  183. </div>
  184. </div>
  185. );
  186. }
  187. }