From 9c8891b39a2d04898880f4bd0754a52739ada30a Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 31 Oct 2023 11:17:37 +0100 Subject: [PATCH 01/19] Rewrite `AutosuggestTextarea` as Functional Component (#27618) --- .../components/autosuggest_textarea.jsx | 231 +++++++++--------- .../compose/components/compose_form.jsx | 26 +- 2 files changed, 130 insertions(+), 127 deletions(-) diff --git a/app/javascript/mastodon/components/autosuggest_textarea.jsx b/app/javascript/mastodon/components/autosuggest_textarea.jsx index 230e4f6572..4d173af59d 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.jsx +++ b/app/javascript/mastodon/components/autosuggest_textarea.jsx @@ -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 ( -
+
{inner}
); }; - render () { - const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, lang, children } = this.props; - const { suggestionsHidden } = this.state; + return [ +
+
+