Rewrite `AutosuggestTextarea` as Functional Component (#27618)

shrike
Claire 2023-10-31 11:17:37 +01:00 committed by GitHub
parent a916251d8a
commit 9c8891b39a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 130 additions and 127 deletions

View File

@ -1,9 +1,9 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useCallback, useRef, useState, useEffect, forwardRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize'; 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 = { const [suggestionsHidden, setSuggestionsHidden] = useState(true);
value: PropTypes.string, const [selectedSuggestion, setSelectedSuggestion] = useState(0);
suggestions: ImmutablePropTypes.list, const lastTokenRef = useRef(null);
disabled: PropTypes.bool, const tokenStartRef = useRef(0);
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,
};
static defaultProps = { const handleChange = useCallback((e) => {
autoFocus: true,
};
state = {
suggestionsHidden: true,
focused: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0,
};
onChange = (e) => {
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
if (token !== null && this.state.lastToken !== token) { if (token !== null && lastTokenRef.current !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); tokenStartRef.current = tokenStart;
this.props.onSuggestionsFetchRequested(token); lastTokenRef.current = token;
setSelectedSuggestion(0);
onSuggestionsFetchRequested(token);
} else if (token === null) { } else if (token === null) {
this.setState({ lastToken: null }); lastTokenRef.current = null;
this.props.onSuggestionsClearRequested(); onSuggestionsClearRequested();
} }
this.props.onChange(e); onChange(e);
}; }, [onSuggestionsFetchRequested, onSuggestionsClearRequested, onChange, setSelectedSuggestion]);
onKeyDown = (e) => {
const { suggestions, disabled } = this.props;
const { selectedSuggestion, suggestionsHidden } = this.state;
const handleKeyDown = useCallback((e) => {
if (disabled) { if (disabled) {
e.preventDefault(); e.preventDefault();
return; return;
@ -102,80 +94,75 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
document.querySelector('.ui').parentElement.focus(); document.querySelector('.ui').parentElement.focus();
} else { } else {
e.preventDefault(); e.preventDefault();
this.setState({ suggestionsHidden: true }); setSuggestionsHidden(true);
} }
break; break;
case 'ArrowDown': case 'ArrowDown':
if (suggestions.size > 0 && !suggestionsHidden) { if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault(); e.preventDefault();
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); setSelectedSuggestion(Math.min(selectedSuggestion + 1, suggestions.size - 1));
} }
break; break;
case 'ArrowUp': case 'ArrowUp':
if (suggestions.size > 0 && !suggestionsHidden) { if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault(); e.preventDefault();
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); setSelectedSuggestion(Math.max(selectedSuggestion - 1, 0));
} }
break; break;
case 'Enter': case 'Enter':
case 'Tab': case 'Tab':
// Select suggestion // Select suggestion
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { if (lastTokenRef.current !== null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestions.get(selectedSuggestion));
} }
break; break;
} }
if (e.defaultPrevented || !this.props.onKeyDown) { if (e.defaultPrevented || !onKeyDown) {
return; return;
} }
this.props.onKeyDown(e); onKeyDown(e);
}; }, [disabled, suggestions, suggestionsHidden, selectedSuggestion, setSelectedSuggestion, setSuggestionsHidden, onSuggestionSelected, onKeyDown]);
onBlur = () => { const handleBlur = useCallback(() => {
this.setState({ suggestionsHidden: true, focused: false }); setSuggestionsHidden(true);
}; }, [setSuggestionsHidden]);
onFocus = (e) => { const handleFocus = useCallback((e) => {
this.setState({ focused: true }); if (onFocus) {
if (this.props.onFocus) { onFocus(e);
this.props.onFocus(e);
} }
}; }, [onFocus]);
onSuggestionClick = (e) => { const handleSuggestionClick = useCallback((e) => {
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index'));
e.preventDefault(); e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestion);
this.textarea.focus(); textareaRef.current?.focus();
}; }, [suggestions, onSuggestionSelected, textareaRef]);
UNSAFE_componentWillReceiveProps (nextProps) { const handlePaste = useCallback((e) => {
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) => {
if (e.clipboardData && e.clipboardData.files.length === 1) { if (e.clipboardData && e.clipboardData.files.length === 1) {
this.props.onPaste(e.clipboardData.files); onPaste(e.clipboardData.files);
e.preventDefault(); e.preventDefault();
} }
}; }, [onPaste]);
renderSuggestion = (suggestion, i) => { // Show the suggestions again whenever they change and the textarea is focused
const { selectedSuggestion } = this.state; useEffect(() => {
if (suggestions.size > 0 && textareaRef.current === document.activeElement) {
setSuggestionsHidden(false);
}
}, [suggestions, textareaRef, setSuggestionsHidden]);
const renderSuggestion = (suggestion, i) => {
let inner, key; let inner, key;
if (suggestion.type === 'emoji') { if (suggestion.type === 'emoji') {
@ -190,50 +177,64 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
} }
return ( 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} {inner}
</div> </div>
); );
}; };
render () { return [
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, lang, children } = this.props; <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
const { suggestionsHidden } = this.state; <div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
return [ <Textarea
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'> ref={textareaRef}
<div className='autosuggest-textarea'> className='autosuggest-textarea__textarea'
<label> disabled={disabled}
<span style={{ display: 'none' }}>{placeholder}</span> 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 <div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
ref={this.setTextarea} <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
className='autosuggest-textarea__textarea' {suggestions.map(renderSuggestion)}
disabled={disabled} </div>
placeholder={placeholder} </div>,
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'> AutosuggestTextarea.propTypes = {
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> value: PropTypes.string,
{suggestions.map(this.renderSuggestion)} suggestions: ImmutablePropTypes.list,
</div> disabled: PropTypes.bool,
</div>, 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;

View File

@ -1,4 +1,5 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { createRef } from 'react';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
@ -79,6 +80,11 @@ class ComposeForm extends ImmutablePureComponent {
highlighted: false, highlighted: false,
}; };
constructor(props) {
super(props);
this.textareaRef = createRef(null);
}
handleChange = (e) => { handleChange = (e) => {
this.props.onChange(e.target.value); this.props.onChange(e.target.value);
}; };
@ -102,10 +108,10 @@ class ComposeForm extends ImmutablePureComponent {
}; };
handleSubmit = (e) => { 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) // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text // 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()) { if (!this.canSubmit()) {
@ -184,26 +190,22 @@ class ComposeForm extends ImmutablePureComponent {
// immediately selectable, we have to wait for observers to run, as // immediately selectable, we have to wait for observers to run, as
// described in https://github.com/WICG/inert#performance-and-gotchas // described in https://github.com/WICG/inert#performance-and-gotchas
Promise.resolve().then(() => { Promise.resolve().then(() => {
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); this.textareaRef.current.setSelectionRange(selectionStart, selectionEnd);
this.autosuggestTextarea.textarea.focus(); this.textareaRef.current.focus();
this.setState({ highlighted: true }); this.setState({ highlighted: true });
this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700); this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700);
}).catch(console.error); }).catch(console.error);
} else if(prevProps.isSubmitting && !this.props.isSubmitting) { } else if(prevProps.isSubmitting && !this.props.isSubmitting) {
this.autosuggestTextarea.textarea.focus(); this.textareaRef.current.focus();
} else if (this.props.spoiler !== prevProps.spoiler) { } else if (this.props.spoiler !== prevProps.spoiler) {
if (this.props.spoiler) { if (this.props.spoiler) {
this.spoilerText.input.focus(); this.spoilerText.input.focus();
} else if (prevProps.spoiler) { } else if (prevProps.spoiler) {
this.autosuggestTextarea.textarea.focus(); this.textareaRef.current.focus();
} }
} }
}; };
setAutosuggestTextarea = (c) => {
this.autosuggestTextarea = c;
};
setSpoilerText = (c) => { setSpoilerText = (c) => {
this.spoilerText = c; this.spoilerText = c;
}; };
@ -214,7 +216,7 @@ class ComposeForm extends ImmutablePureComponent {
handleEmojiPick = (data) => { handleEmojiPick = (data) => {
const { text } = this.props; 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]); const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
this.props.onPickEmoji(position, data, needsSpace); this.props.onPickEmoji(position, data, needsSpace);
@ -263,7 +265,7 @@ class ComposeForm extends ImmutablePureComponent {
<div className={classNames('compose-form__highlightable', { active: highlighted })}> <div className={classNames('compose-form__highlightable', { active: highlighted })}>
<AutosuggestTextarea <AutosuggestTextarea
ref={this.setAutosuggestTextarea} ref={this.textareaRef}
placeholder={intl.formatMessage(messages.placeholder)} placeholder={intl.formatMessage(messages.placeholder)}
disabled={disabled} disabled={disabled}
value={this.props.text} value={this.props.text}