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.

312 lines
8.3 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. onSecondarySubmit,
  105. onSuggestionSelected,
  106. suggestions,
  107. } = this.props;
  108. const {
  109. lastToken,
  110. suggestionsHidden,
  111. selectedSuggestion,
  112. tokenStart,
  113. } = this.state;
  114. // Keypresses do nothing if the composer is disabled.
  115. if (disabled) {
  116. e.preventDefault();
  117. return;
  118. }
  119. // We submit the status on control/meta + enter.
  120. if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
  121. onSubmit();
  122. }
  123. // Submit the status with secondary visibility on alt + enter.
  124. if (onSecondarySubmit && e.keyCode === 13 && e.altKey) {
  125. onSecondarySubmit();
  126. }
  127. // Switches over the pressed key.
  128. switch(e.key) {
  129. // On arrow down, we pick the next suggestion.
  130. case 'ArrowDown':
  131. if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
  132. e.preventDefault();
  133. this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
  134. }
  135. return;
  136. // On arrow up, we pick the previous suggestion.
  137. case 'ArrowUp':
  138. if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
  139. e.preventDefault();
  140. this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
  141. }
  142. return;
  143. // On enter or tab, we select the suggestion.
  144. case 'Enter':
  145. case 'Tab':
  146. if (onSuggestionSelected && lastToken !== null && suggestions && suggestions.size > 0 && !suggestionsHidden) {
  147. e.preventDefault();
  148. e.stopPropagation();
  149. onSuggestionSelected(tokenStart, lastToken, suggestions.get(selectedSuggestion));
  150. }
  151. return;
  152. }
  153. },
  154. // When the escape key is released, we either close the suggestions
  155. // window or focus the UI.
  156. handleKeyUp ({ key }) {
  157. const { suggestionsHidden } = this.state;
  158. if (key === 'Escape') {
  159. if (!suggestionsHidden) {
  160. this.setState({ suggestionsHidden: true });
  161. } else {
  162. document.querySelector('.ui').parentElement.focus();
  163. }
  164. }
  165. },
  166. // Handles the pasting of images into the composer.
  167. handlePaste (e) {
  168. const { onPaste } = this.props;
  169. let d;
  170. if (onPaste && (d = e.clipboardData) && (d = d.files).length === 1) {
  171. onPaste(d);
  172. e.preventDefault();
  173. }
  174. },
  175. // Saves a reference to the textarea.
  176. handleRefTextarea (textarea) {
  177. this.textarea = textarea;
  178. },
  179. };
  180. // The component.
  181. export default class ComposerTextarea extends React.Component {
  182. // Constructor.
  183. constructor (props) {
  184. super(props);
  185. assignHandlers(this, handlers);
  186. this.state = {
  187. suggestionsHidden: false,
  188. selectedSuggestion: 0,
  189. lastToken: null,
  190. tokenStart: 0,
  191. };
  192. // Instance variables.
  193. this.textarea = null;
  194. }
  195. // When we receive new suggestions, we unhide the suggestions window
  196. // if we didn't have any suggestions before.
  197. componentWillReceiveProps (nextProps) {
  198. const { suggestions } = this.props;
  199. const { suggestionsHidden } = this.state;
  200. if (nextProps.suggestions && nextProps.suggestions !== suggestions && nextProps.suggestions.size > 0 && suggestionsHidden) {
  201. this.setState({ suggestionsHidden: false });
  202. }
  203. }
  204. // Rendering.
  205. render () {
  206. const {
  207. handleBlur,
  208. handleChange,
  209. handleClickSuggestion,
  210. handleKeyDown,
  211. handleKeyUp,
  212. handlePaste,
  213. handleRefTextarea,
  214. } = this.handlers;
  215. const {
  216. advancedOptions,
  217. autoFocus,
  218. disabled,
  219. intl,
  220. onPickEmoji,
  221. suggestions,
  222. value,
  223. } = this.props;
  224. const {
  225. selectedSuggestion,
  226. suggestionsHidden,
  227. } = this.state;
  228. // The result.
  229. return (
  230. <div className='composer--textarea'>
  231. <label>
  232. <span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span>
  233. <ComposerTextareaIcons
  234. advancedOptions={advancedOptions}
  235. intl={intl}
  236. />
  237. <Textarea
  238. aria-autocomplete='list'
  239. autoFocus={autoFocus}
  240. className='textarea'
  241. disabled={disabled}
  242. inputRef={handleRefTextarea}
  243. onBlur={handleBlur}
  244. onChange={handleChange}
  245. onKeyDown={handleKeyDown}
  246. onKeyUp={handleKeyUp}
  247. onPaste={handlePaste}
  248. placeholder={intl.formatMessage(messages.placeholder)}
  249. value={value}
  250. style={{ direction: isRtl(value) ? 'rtl' : 'ltr' }}
  251. />
  252. </label>
  253. <EmojiPicker onPickEmoji={onPickEmoji} />
  254. <ComposerTextareaSuggestions
  255. hidden={suggestionsHidden}
  256. onSuggestionClick={handleClickSuggestion}
  257. suggestions={suggestions}
  258. value={selectedSuggestion}
  259. />
  260. </div>
  261. );
  262. }
  263. }
  264. // Props.
  265. ComposerTextarea.propTypes = {
  266. advancedOptions: ImmutablePropTypes.map,
  267. autoFocus: PropTypes.bool,
  268. disabled: PropTypes.bool,
  269. intl: PropTypes.object.isRequired,
  270. onChange: PropTypes.func,
  271. onPaste: PropTypes.func,
  272. onPickEmoji: PropTypes.func,
  273. onSubmit: PropTypes.func,
  274. onSecondarySubmit: PropTypes.func,
  275. onSuggestionsClearRequested: PropTypes.func,
  276. onSuggestionsFetchRequested: PropTypes.func,
  277. onSuggestionSelected: PropTypes.func,
  278. suggestions: ImmutablePropTypes.list,
  279. value: PropTypes.string,
  280. };
  281. // Default props.
  282. ComposerTextarea.defaultProps = { autoFocus: true };