[Glitch] Rewrite `AutosuggestTextarea` as Functional Component

Port 9c8891b39a to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
pull/2517/head
Claire 2023-10-31 11:17:37 +01:00
parent e22c3cd768
commit bb4fa0c374
2 changed files with 133 additions and 134 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';
@ -90,6 +91,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);
}; };
@ -118,10 +124,10 @@ class ComposeForm extends ImmutablePureComponent {
onChangeVisibility, onChangeVisibility,
} = this.props; } = this.props;
if (this.props.text !== this.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.textarea.value); this.props.onChange(this.textareaRef.current.value);
} }
if (!this.canSubmit()) { if (!this.canSubmit()) {
@ -188,13 +194,6 @@ class ComposeForm extends ImmutablePureComponent {
} }
}; };
// Sets a reference to the textarea.
setAutosuggestTextarea = (textareaComponent) => {
if (textareaComponent) {
this.textarea = textareaComponent.textarea;
}
};
// Sets a reference to the CW field. // Sets a reference to the CW field.
handleRefSpoilerText = (spoilerComponent) => { handleRefSpoilerText = (spoilerComponent) => {
if (spoilerComponent) { if (spoilerComponent) {
@ -232,7 +231,6 @@ class ComposeForm extends ImmutablePureComponent {
// everyone else from the conversation. // everyone else from the conversation.
_updateFocusAndSelection = (prevProps) => { _updateFocusAndSelection = (prevProps) => {
const { const {
textarea,
spoilerText, spoilerText,
} = this; } = this;
const { const {
@ -259,30 +257,30 @@ class ComposeForm extends ImmutablePureComponent {
default: default:
selectionStart = selectionEnd = text.length; selectionStart = selectionEnd = text.length;
} }
if (textarea) { if (this.textareaRef.current) {
// Because of the wicg-inert polyfill, the activeElement may not be // Because of the wicg-inert polyfill, the activeElement may not be
// 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(() => {
textarea.setSelectionRange(selectionStart, selectionEnd); this.textareaRef.current.setSelectionRange(selectionStart, selectionEnd);
textarea.focus(); this.textareaRef.current.focus();
if (!singleColumn) textarea.scrollIntoView(); if (!singleColumn) this.textareaRef.current.scrollIntoView();
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);
} }
// Refocuses the textarea after submitting. // Refocuses the textarea after submitting.
} else if (textarea && prevProps.isSubmitting && !isSubmitting) { } else if (this.textareaRef.current && prevProps.isSubmitting && !isSubmitting) {
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) {
if (spoilerText) { if (spoilerText) {
spoilerText.focus(); spoilerText.focus();
} }
} else { } else {
if (textarea) { if (this.textareaRef.current) {
textarea.focus(); this.textareaRef.current.focus();
} }
} }
} }
@ -347,7 +345,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={isSubmitting} disabled={isSubmitting}
value={this.props.text} value={this.props.text}