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.

305 lines
8.1 KiB

6 years ago
6 years ago
6 years ago
6 years ago
  1. // Package imports.
  2. import PropTypes from 'prop-types';
  3. import React from 'react';
  4. import ImmutablePropTypes from 'react-immutable-proptypes';
  5. import {
  6. defineMessages,
  7. FormattedMessage,
  8. } from 'react-intl';
  9. import Textarea from 'react-textarea-autosize';
  10. // Components.
  11. import EmojiPicker from 'flavours/glitch/features/emoji_picker';
  12. import ComposerTextareaIcons from './icons';
  13. import ComposerTextareaSuggestions from './suggestions';
  14. // Utils.
  15. import { isRtl } from 'flavours/glitch/util/rtl';
  16. import {
  17. assignHandlers,
  18. hiddenComponent,
  19. } from 'flavours/glitch/util/react_helpers';
  20. // Messages.
  21. const messages = defineMessages({
  22. placeholder: {
  23. defaultMessage: 'What is on your mind?',
  24. id: 'compose_form.placeholder',
  25. },
  26. });
  27. // Handlers.
  28. const handlers = {
  29. // When blurring the textarea, suggestions are hidden.
  30. handleBlur () {
  31. this.setState({ suggestionsHidden: true });
  32. },
  33. // When the contents of the textarea change, we have to pull up new
  34. // autosuggest suggestions if applicable, and also change the value
  35. // of the textarea in our store.
  36. handleChange ({
  37. target: {
  38. selectionStart,
  39. value,
  40. },
  41. }) {
  42. const {
  43. onChange,
  44. onSuggestionsFetchRequested,
  45. onSuggestionsClearRequested,
  46. } = this.props;
  47. const { lastToken } = this.state;
  48. // This gets the token at the caret location, if it begins with an
  49. // `@` (mentions) or `:` (shortcodes).
  50. const left = value.slice(0, selectionStart).search(/[^\s\u200B]+$/);
  51. const right = value.slice(selectionStart).search(/[\s\u200B]/);
  52. const token = function () {
  53. switch (true) {
  54. case left < 0 || !/[@:]/.test(value[left]):
  55. return null;
  56. case right < 0:
  57. return value.slice(left);
  58. default:
  59. return value.slice(left, right + selectionStart).trim().toLowerCase();
  60. }
  61. }();
  62. // We only request suggestions for tokens which are at least 3
  63. // characters long.
  64. if (onSuggestionsFetchRequested && token && token.length >= 3) {
  65. if (lastToken !== token) {
  66. this.setState({
  67. lastToken: token,
  68. selectedSuggestion: 0,
  69. tokenStart: left,
  70. });
  71. onSuggestionsFetchRequested(token);
  72. }
  73. } else {
  74. this.setState({ lastToken: null });
  75. if (onSuggestionsClearRequested) {
  76. onSuggestionsClearRequested();
  77. }
  78. }
  79. // Updates the value of the textarea.
  80. if (onChange) {
  81. onChange(value);
  82. }
  83. },
  84. // Handles a click on an autosuggestion.
  85. handleClickSuggestion (index) {
  86. const { textarea } = this;
  87. const {
  88. onSuggestionSelected,
  89. suggestions,
  90. } = this.props;
  91. const {
  92. lastToken,
  93. tokenStart,
  94. } = this.state;
  95. onSuggestionSelected(tokenStart, lastToken, suggestions.get(index));
  96. textarea.focus();
  97. },
  98. // Handles a keypress. If the autosuggestions are visible, we need
  99. // to allow keypresses to navigate and sleect them.
  100. handleKeyDown (e) {
  101. const {
  102. disabled,
  103. onSubmit,
  104. onSuggestionSelected,
  105. suggestions,
  106. } = this.props;
  107. const {
  108. lastToken,
  109. suggestionsHidden,
  110. selectedSuggestion,
  111. tokenStart,
  112. } = this.state;
  113. // Keypresses do nothing if the composer is disabled.
  114. if (disabled) {
  115. e.preventDefault();
  116. return;
  117. }
  118. // We submit the status on control/meta + enter.
  119. if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
  120. onSubmit();
  121. }
  122. // Switches over the pressed key.
  123. switch(e.key) {
  124. // On arrow down, we pick the next suggestion.
  125. case 'ArrowDown':
  126. if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
  127. e.preventDefault();
  128. this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
  129. }
  130. return;
  131. // On arrow up, we pick the previous suggestion.
  132. case 'ArrowUp':
  133. if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
  134. e.preventDefault();
  135. this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
  136. }
  137. return;
  138. // On enter or tab, we select the suggestion.
  139. case 'Enter':
  140. case 'Tab':
  141. if (onSuggestionSelected && lastToken !== null && suggestions && suggestions.size > 0 && !suggestionsHidden) {
  142. e.preventDefault();
  143. e.stopPropagation();
  144. onSuggestionSelected(tokenStart, lastToken, suggestions.get(selectedSuggestion));
  145. }
  146. return;
  147. }
  148. },
  149. // When the escape key is released, we either close the suggestions
  150. // window or focus the UI.
  151. handleKeyUp ({ key }) {
  152. const { suggestionsHidden } = this.state;
  153. if (key === 'Escape') {
  154. if (!suggestionsHidden) {
  155. this.setState({ suggestionsHidden: true });
  156. } else {
  157. document.querySelector('.ui').parentElement.focus();
  158. }
  159. }
  160. },
  161. // Handles the pasting of images into the composer.
  162. handlePaste (e) {
  163. const { onPaste } = this.props;
  164. let d;
  165. if (onPaste && (d = e.clipboardData) && (d = d.files).length === 1) {
  166. onPaste(d);
  167. e.preventDefault();
  168. }
  169. },
  170. // Saves a reference to the textarea.
  171. handleRefTextarea (textarea) {
  172. this.textarea = textarea;
  173. },
  174. };
  175. // The component.
  176. export default class ComposerTextarea extends React.Component {
  177. // Constructor.
  178. constructor (props) {
  179. super(props);
  180. assignHandlers(this, handlers);
  181. this.state = {
  182. suggestionsHidden: false,
  183. selectedSuggestion: 0,
  184. lastToken: null,
  185. tokenStart: 0,
  186. };
  187. // Instance variables.
  188. this.textarea = null;
  189. }
  190. // When we receive new suggestions, we unhide the suggestions window
  191. // if we didn't have any suggestions before.
  192. componentWillReceiveProps (nextProps) {
  193. const { suggestions } = this.props;
  194. const { suggestionsHidden } = this.state;
  195. if (nextProps.suggestions && nextProps.suggestions !== suggestions && nextProps.suggestions.size > 0 && suggestionsHidden) {
  196. this.setState({ suggestionsHidden: false });
  197. }
  198. }
  199. // Rendering.
  200. render () {
  201. const {
  202. handleBlur,
  203. handleChange,
  204. handleClickSuggestion,
  205. handleKeyDown,
  206. handleKeyUp,
  207. handlePaste,
  208. handleRefTextarea,
  209. } = this.handlers;
  210. const {
  211. advancedOptions,
  212. autoFocus,
  213. disabled,
  214. intl,
  215. onPickEmoji,
  216. suggestions,
  217. value,
  218. } = this.props;
  219. const {
  220. selectedSuggestion,
  221. suggestionsHidden,
  222. } = this.state;
  223. // The result.
  224. return (
  225. <div className='composer--textarea'>
  226. <label>
  227. <span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span>
  228. <ComposerTextareaIcons
  229. advancedOptions={advancedOptions}
  230. intl={intl}
  231. />
  232. <Textarea
  233. aria-autocomplete='list'
  234. autoFocus={autoFocus}
  235. className='textarea'
  236. disabled={disabled}
  237. inputRef={handleRefTextarea}
  238. onBlur={handleBlur}
  239. onChange={handleChange}
  240. onKeyDown={handleKeyDown}
  241. onKeyUp={handleKeyUp}
  242. onPaste={handlePaste}
  243. placeholder={intl.formatMessage(messages.placeholder)}
  244. value={value}
  245. style={{ direction: isRtl(value) ? 'rtl' : 'ltr' }}
  246. />
  247. </label>
  248. <EmojiPicker onPickEmoji={onPickEmoji} />
  249. <ComposerTextareaSuggestions
  250. hidden={suggestionsHidden}
  251. onSuggestionClick={handleClickSuggestion}
  252. suggestions={suggestions}
  253. value={selectedSuggestion}
  254. />
  255. </div>
  256. );
  257. }
  258. }
  259. // Props.
  260. ComposerTextarea.propTypes = {
  261. advancedOptions: ImmutablePropTypes.map,
  262. autoFocus: PropTypes.bool,
  263. disabled: PropTypes.bool,
  264. intl: PropTypes.object.isRequired,
  265. onChange: PropTypes.func,
  266. onPaste: PropTypes.func,
  267. onPickEmoji: PropTypes.func,
  268. onSubmit: PropTypes.func,
  269. onSuggestionsClearRequested: PropTypes.func,
  270. onSuggestionsFetchRequested: PropTypes.func,
  271. onSuggestionSelected: PropTypes.func,
  272. suggestions: ImmutablePropTypes.list,
  273. value: PropTypes.string,
  274. };
  275. // Default props.
  276. ComposerTextarea.defaultProps = { autoFocus: true };