Rewrite AutosuggestTextarea as Functional Component (#27618)
				
					
				
			This commit is contained in:
		@@ -1,9 +1,9 @@
 | 
			
		||||
import PropTypes from 'prop-types';
 | 
			
		||||
import { useCallback, useRef, useState, useEffect, forwardRef } from 'react';
 | 
			
		||||
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
 | 
			
		||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
			
		||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
			
		||||
 | 
			
		||||
import Textarea from 'react-textarea-autosize';
 | 
			
		||||
 | 
			
		||||
@@ -37,54 +37,46 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default class AutosuggestTextarea extends ImmutablePureComponent {
 | 
			
		||||
const AutosuggestTextarea = forwardRef(({
 | 
			
		||||
  value,
 | 
			
		||||
  suggestions,
 | 
			
		||||
  disabled,
 | 
			
		||||
  placeholder,
 | 
			
		||||
  onSuggestionSelected,
 | 
			
		||||
  onSuggestionsClearRequested,
 | 
			
		||||
  onSuggestionsFetchRequested,
 | 
			
		||||
  onChange,
 | 
			
		||||
  onKeyUp,
 | 
			
		||||
  onKeyDown,
 | 
			
		||||
  onPaste,
 | 
			
		||||
  onFocus,
 | 
			
		||||
  autoFocus = true,
 | 
			
		||||
  lang,
 | 
			
		||||
  children,
 | 
			
		||||
}, textareaRef) => {
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
    value: PropTypes.string,
 | 
			
		||||
    suggestions: ImmutablePropTypes.list,
 | 
			
		||||
    disabled: PropTypes.bool,
 | 
			
		||||
    placeholder: PropTypes.string,
 | 
			
		||||
    onSuggestionSelected: PropTypes.func.isRequired,
 | 
			
		||||
    onSuggestionsClearRequested: PropTypes.func.isRequired,
 | 
			
		||||
    onSuggestionsFetchRequested: PropTypes.func.isRequired,
 | 
			
		||||
    onChange: PropTypes.func.isRequired,
 | 
			
		||||
    onKeyUp: PropTypes.func,
 | 
			
		||||
    onKeyDown: PropTypes.func,
 | 
			
		||||
    onPaste: PropTypes.func.isRequired,
 | 
			
		||||
    autoFocus: PropTypes.bool,
 | 
			
		||||
    lang: PropTypes.string,
 | 
			
		||||
  };
 | 
			
		||||
  const [suggestionsHidden, setSuggestionsHidden] = useState(true);
 | 
			
		||||
  const [selectedSuggestion, setSelectedSuggestion] = useState(0);
 | 
			
		||||
  const lastTokenRef = useRef(null);
 | 
			
		||||
  const tokenStartRef = useRef(0);
 | 
			
		||||
 | 
			
		||||
  static defaultProps = {
 | 
			
		||||
    autoFocus: true,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  state = {
 | 
			
		||||
    suggestionsHidden: true,
 | 
			
		||||
    focused: false,
 | 
			
		||||
    selectedSuggestion: 0,
 | 
			
		||||
    lastToken: null,
 | 
			
		||||
    tokenStart: 0,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  onChange = (e) => {
 | 
			
		||||
  const handleChange = useCallback((e) => {
 | 
			
		||||
    const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
 | 
			
		||||
 | 
			
		||||
    if (token !== null && this.state.lastToken !== token) {
 | 
			
		||||
      this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
 | 
			
		||||
      this.props.onSuggestionsFetchRequested(token);
 | 
			
		||||
    if (token !== null && lastTokenRef.current !== token) {
 | 
			
		||||
      tokenStartRef.current = tokenStart;
 | 
			
		||||
      lastTokenRef.current = token;
 | 
			
		||||
      setSelectedSuggestion(0);
 | 
			
		||||
      onSuggestionsFetchRequested(token);
 | 
			
		||||
    } else if (token === null) {
 | 
			
		||||
      this.setState({ lastToken: null });
 | 
			
		||||
      this.props.onSuggestionsClearRequested();
 | 
			
		||||
      lastTokenRef.current = null;
 | 
			
		||||
      onSuggestionsClearRequested();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.props.onChange(e);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  onKeyDown = (e) => {
 | 
			
		||||
    const { suggestions, disabled } = this.props;
 | 
			
		||||
    const { selectedSuggestion, suggestionsHidden } = this.state;
 | 
			
		||||
    onChange(e);
 | 
			
		||||
  }, [onSuggestionsFetchRequested, onSuggestionsClearRequested, onChange, setSelectedSuggestion]);
 | 
			
		||||
 | 
			
		||||
  const handleKeyDown = useCallback((e) => {
 | 
			
		||||
    if (disabled) {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      return;
 | 
			
		||||
@@ -102,80 +94,75 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
 | 
			
		||||
        document.querySelector('.ui').parentElement.focus();
 | 
			
		||||
      } else {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        this.setState({ suggestionsHidden: true });
 | 
			
		||||
        setSuggestionsHidden(true);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      break;
 | 
			
		||||
    case 'ArrowDown':
 | 
			
		||||
      if (suggestions.size > 0 && !suggestionsHidden) {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
 | 
			
		||||
        setSelectedSuggestion(Math.min(selectedSuggestion + 1, suggestions.size - 1));
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      break;
 | 
			
		||||
    case 'ArrowUp':
 | 
			
		||||
      if (suggestions.size > 0 && !suggestionsHidden) {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
 | 
			
		||||
        setSelectedSuggestion(Math.max(selectedSuggestion - 1, 0));
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      break;
 | 
			
		||||
    case 'Enter':
 | 
			
		||||
    case 'Tab':
 | 
			
		||||
      // Select suggestion
 | 
			
		||||
      if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
 | 
			
		||||
      if (lastTokenRef.current !== null && suggestions.size > 0 && !suggestionsHidden) {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        e.stopPropagation();
 | 
			
		||||
        this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
 | 
			
		||||
        onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestions.get(selectedSuggestion));
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (e.defaultPrevented || !this.props.onKeyDown) {
 | 
			
		||||
    if (e.defaultPrevented || !onKeyDown) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.props.onKeyDown(e);
 | 
			
		||||
  };
 | 
			
		||||
    onKeyDown(e);
 | 
			
		||||
  }, [disabled, suggestions, suggestionsHidden, selectedSuggestion, setSelectedSuggestion, setSuggestionsHidden, onSuggestionSelected, onKeyDown]);
 | 
			
		||||
 | 
			
		||||
  onBlur = () => {
 | 
			
		||||
    this.setState({ suggestionsHidden: true, focused: false });
 | 
			
		||||
  };
 | 
			
		||||
  const handleBlur = useCallback(() => {
 | 
			
		||||
    setSuggestionsHidden(true);
 | 
			
		||||
  }, [setSuggestionsHidden]);
 | 
			
		||||
 | 
			
		||||
  onFocus = (e) => {
 | 
			
		||||
    this.setState({ focused: true });
 | 
			
		||||
    if (this.props.onFocus) {
 | 
			
		||||
      this.props.onFocus(e);
 | 
			
		||||
  const handleFocus = useCallback((e) => {
 | 
			
		||||
    if (onFocus) {
 | 
			
		||||
      onFocus(e);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  }, [onFocus]);
 | 
			
		||||
 | 
			
		||||
  onSuggestionClick = (e) => {
 | 
			
		||||
    const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
 | 
			
		||||
  const handleSuggestionClick = useCallback((e) => {
 | 
			
		||||
    const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index'));
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
 | 
			
		||||
    this.textarea.focus();
 | 
			
		||||
  };
 | 
			
		||||
    onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestion);
 | 
			
		||||
    textareaRef.current?.focus();
 | 
			
		||||
  }, [suggestions, onSuggestionSelected, textareaRef]);
 | 
			
		||||
 | 
			
		||||
  UNSAFE_componentWillReceiveProps (nextProps) {
 | 
			
		||||
    if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
 | 
			
		||||
      this.setState({ suggestionsHidden: false });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setTextarea = (c) => {
 | 
			
		||||
    this.textarea = c;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  onPaste = (e) => {
 | 
			
		||||
  const handlePaste = useCallback((e) => {
 | 
			
		||||
    if (e.clipboardData && e.clipboardData.files.length === 1) {
 | 
			
		||||
      this.props.onPaste(e.clipboardData.files);
 | 
			
		||||
      onPaste(e.clipboardData.files);
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  }, [onPaste]);
 | 
			
		||||
 | 
			
		||||
  renderSuggestion = (suggestion, i) => {
 | 
			
		||||
    const { selectedSuggestion } = this.state;
 | 
			
		||||
  // Show the suggestions again whenever they change and the textarea is focused
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (suggestions.size > 0 && textareaRef.current === document.activeElement) {
 | 
			
		||||
      setSuggestionsHidden(false);
 | 
			
		||||
    }
 | 
			
		||||
  }, [suggestions, textareaRef, setSuggestionsHidden]);
 | 
			
		||||
 | 
			
		||||
  const renderSuggestion = (suggestion, i) => {
 | 
			
		||||
    let inner, key;
 | 
			
		||||
 | 
			
		||||
    if (suggestion.type === 'emoji') {
 | 
			
		||||
@@ -190,50 +177,64 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
 | 
			
		||||
      <div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={handleSuggestionClick}>
 | 
			
		||||
        {inner}
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, lang, children } = this.props;
 | 
			
		||||
    const { suggestionsHidden } = this.state;
 | 
			
		||||
  return [
 | 
			
		||||
    <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
 | 
			
		||||
      <div className='autosuggest-textarea'>
 | 
			
		||||
        <label>
 | 
			
		||||
          <span style={{ display: 'none' }}>{placeholder}</span>
 | 
			
		||||
 | 
			
		||||
    return [
 | 
			
		||||
      <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
 | 
			
		||||
        <div className='autosuggest-textarea'>
 | 
			
		||||
          <label>
 | 
			
		||||
            <span style={{ display: 'none' }}>{placeholder}</span>
 | 
			
		||||
          <Textarea
 | 
			
		||||
            ref={textareaRef}
 | 
			
		||||
            className='autosuggest-textarea__textarea'
 | 
			
		||||
            disabled={disabled}
 | 
			
		||||
            placeholder={placeholder}
 | 
			
		||||
            autoFocus={autoFocus}
 | 
			
		||||
            value={value}
 | 
			
		||||
            onChange={handleChange}
 | 
			
		||||
            onKeyDown={handleKeyDown}
 | 
			
		||||
            onKeyUp={onKeyUp}
 | 
			
		||||
            onFocus={handleFocus}
 | 
			
		||||
            onBlur={handleBlur}
 | 
			
		||||
            onPaste={handlePaste}
 | 
			
		||||
            dir='auto'
 | 
			
		||||
            aria-autocomplete='list'
 | 
			
		||||
            lang={lang}
 | 
			
		||||
          />
 | 
			
		||||
        </label>
 | 
			
		||||
      </div>
 | 
			
		||||
      {children}
 | 
			
		||||
    </div>,
 | 
			
		||||
 | 
			
		||||
            <Textarea
 | 
			
		||||
              ref={this.setTextarea}
 | 
			
		||||
              className='autosuggest-textarea__textarea'
 | 
			
		||||
              disabled={disabled}
 | 
			
		||||
              placeholder={placeholder}
 | 
			
		||||
              autoFocus={autoFocus}
 | 
			
		||||
              value={value}
 | 
			
		||||
              onChange={this.onChange}
 | 
			
		||||
              onKeyDown={this.onKeyDown}
 | 
			
		||||
              onKeyUp={onKeyUp}
 | 
			
		||||
              onFocus={this.onFocus}
 | 
			
		||||
              onBlur={this.onBlur}
 | 
			
		||||
              onPaste={this.onPaste}
 | 
			
		||||
              dir='auto'
 | 
			
		||||
              aria-autocomplete='list'
 | 
			
		||||
              lang={lang}
 | 
			
		||||
            />
 | 
			
		||||
          </label>
 | 
			
		||||
        </div>
 | 
			
		||||
        {children}
 | 
			
		||||
      </div>,
 | 
			
		||||
    <div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
 | 
			
		||||
      <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
 | 
			
		||||
        {suggestions.map(renderSuggestion)}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>,
 | 
			
		||||
  ];
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
      <div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
 | 
			
		||||
        <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
 | 
			
		||||
          {suggestions.map(this.renderSuggestion)}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>,
 | 
			
		||||
    ];
 | 
			
		||||
  }
 | 
			
		||||
AutosuggestTextarea.propTypes = {
 | 
			
		||||
  value: PropTypes.string,
 | 
			
		||||
  suggestions: ImmutablePropTypes.list,
 | 
			
		||||
  disabled: PropTypes.bool,
 | 
			
		||||
  placeholder: PropTypes.string,
 | 
			
		||||
  onSuggestionSelected: PropTypes.func.isRequired,
 | 
			
		||||
  onSuggestionsClearRequested: PropTypes.func.isRequired,
 | 
			
		||||
  onSuggestionsFetchRequested: PropTypes.func.isRequired,
 | 
			
		||||
  onChange: PropTypes.func.isRequired,
 | 
			
		||||
  onKeyUp: PropTypes.func,
 | 
			
		||||
  onKeyDown: PropTypes.func,
 | 
			
		||||
  onPaste: PropTypes.func.isRequired,
 | 
			
		||||
  onFocus:PropTypes.func,
 | 
			
		||||
  children: PropTypes.node,
 | 
			
		||||
  autoFocus: PropTypes.bool,
 | 
			
		||||
  lang: PropTypes.string,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
export default AutosuggestTextarea;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import PropTypes from 'prop-types';
 | 
			
		||||
import { createRef } from 'react';
 | 
			
		||||
 | 
			
		||||
import { defineMessages, injectIntl } from 'react-intl';
 | 
			
		||||
 | 
			
		||||
@@ -79,6 +80,11 @@ class ComposeForm extends ImmutablePureComponent {
 | 
			
		||||
    highlighted: false,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  constructor(props) {
 | 
			
		||||
    super(props);
 | 
			
		||||
    this.textareaRef = createRef(null);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleChange = (e) => {
 | 
			
		||||
    this.props.onChange(e.target.value);
 | 
			
		||||
  };
 | 
			
		||||
@@ -102,10 +108,10 @@ class ComposeForm extends ImmutablePureComponent {
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  handleSubmit = (e) => {
 | 
			
		||||
    if (this.props.text !== this.autosuggestTextarea.textarea.value) {
 | 
			
		||||
    if (this.props.text !== this.textareaRef.current.value) {
 | 
			
		||||
      // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
 | 
			
		||||
      // Update the state to match the current text
 | 
			
		||||
      this.props.onChange(this.autosuggestTextarea.textarea.value);
 | 
			
		||||
      this.props.onChange(this.textareaRef.current.value);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!this.canSubmit()) {
 | 
			
		||||
@@ -184,26 +190,22 @@ class ComposeForm extends ImmutablePureComponent {
 | 
			
		||||
      // immediately selectable, we have to wait for observers to run, as
 | 
			
		||||
      // described in https://github.com/WICG/inert#performance-and-gotchas
 | 
			
		||||
      Promise.resolve().then(() => {
 | 
			
		||||
        this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
 | 
			
		||||
        this.autosuggestTextarea.textarea.focus();
 | 
			
		||||
        this.textareaRef.current.setSelectionRange(selectionStart, selectionEnd);
 | 
			
		||||
        this.textareaRef.current.focus();
 | 
			
		||||
        this.setState({ highlighted: true });
 | 
			
		||||
        this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700);
 | 
			
		||||
      }).catch(console.error);
 | 
			
		||||
    } else if(prevProps.isSubmitting && !this.props.isSubmitting) {
 | 
			
		||||
      this.autosuggestTextarea.textarea.focus();
 | 
			
		||||
      this.textareaRef.current.focus();
 | 
			
		||||
    } else if (this.props.spoiler !== prevProps.spoiler) {
 | 
			
		||||
      if (this.props.spoiler) {
 | 
			
		||||
        this.spoilerText.input.focus();
 | 
			
		||||
      } else if (prevProps.spoiler) {
 | 
			
		||||
        this.autosuggestTextarea.textarea.focus();
 | 
			
		||||
        this.textareaRef.current.focus();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  setAutosuggestTextarea = (c) => {
 | 
			
		||||
    this.autosuggestTextarea = c;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  setSpoilerText = (c) => {
 | 
			
		||||
    this.spoilerText = c;
 | 
			
		||||
  };
 | 
			
		||||
@@ -214,7 +216,7 @@ class ComposeForm extends ImmutablePureComponent {
 | 
			
		||||
 | 
			
		||||
  handleEmojiPick = (data) => {
 | 
			
		||||
    const { text }     = this.props;
 | 
			
		||||
    const position     = this.autosuggestTextarea.textarea.selectionStart;
 | 
			
		||||
    const position     = this.textarea.selectionStart;
 | 
			
		||||
    const needsSpace   = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
 | 
			
		||||
 | 
			
		||||
    this.props.onPickEmoji(position, data, needsSpace);
 | 
			
		||||
@@ -263,7 +265,7 @@ class ComposeForm extends ImmutablePureComponent {
 | 
			
		||||
 | 
			
		||||
        <div className={classNames('compose-form__highlightable', { active: highlighted })}>
 | 
			
		||||
          <AutosuggestTextarea
 | 
			
		||||
            ref={this.setAutosuggestTextarea}
 | 
			
		||||
            ref={this.textareaRef}
 | 
			
		||||
            placeholder={intl.formatMessage(messages.placeholder)}
 | 
			
		||||
            disabled={disabled}
 | 
			
		||||
            value={this.props.text}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user