soapbox/app/soapbox/components/autosuggest-textarea.tsx

289 wiersze
8.7 KiB
TypeScript
Czysty Zwykły widok Historia

import { Portal } from '@reach/portal';
import classNames from 'clsx';
2020-03-27 20:59:38 +00:00
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
2020-03-27 20:59:38 +00:00
import Textarea from 'react-textarea-autosize';
2022-11-22 14:55:31 +00:00
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
2022-11-15 17:23:36 +00:00
import AutosuggestAccount from '../features/compose/components/autosuggest-account';
import { isRtl } from '../rtl';
2022-11-15 16:11:42 +00:00
import AutosuggestEmoji, { Emoji } from './autosuggest-emoji';
2020-03-27 20:59:38 +00:00
import type { List as ImmutableList } from 'immutable';
interface IAutosuggesteTextarea {
id?: string,
value: string,
suggestions: ImmutableList<string>,
disabled: boolean,
placeholder: string,
onSuggestionSelected: (tokenStart: number, token: string | null, value: string | undefined) => void,
onSuggestionsClearRequested: () => void,
onSuggestionsFetchRequested: (token: string | number) => void,
onChange: React.ChangeEventHandler<HTMLTextAreaElement>,
onKeyUp?: React.KeyboardEventHandler<HTMLTextAreaElement>,
onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>,
onPaste: (files: FileList) => void,
autoFocus: boolean,
onFocus: () => void,
onBlur?: () => void,
condensed?: boolean,
2023-01-10 23:03:15 +00:00
children: React.ReactNode,
}
class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea> {
textarea: HTMLTextAreaElement | null = null;
2020-03-27 20:59:38 +00:00
static defaultProps = {
autoFocus: true,
};
state = {
suggestionsHidden: true,
focused: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0,
};
onChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
2022-11-22 14:55:31 +00:00
const [tokenStart, token] = textAtCursorMatchesToken(
e.target.value,
e.target.selectionStart,
['@', ':', '#'],
);
2020-03-27 20:59:38 +00:00
if (token !== null && this.state.lastToken !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
this.props.onSuggestionsFetchRequested(token);
} else if (token === null) {
this.setState({ lastToken: null });
this.props.onSuggestionsClearRequested();
}
this.props.onChange(e);
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
2020-03-27 20:59:38 +00:00
const { suggestions, disabled } = this.props;
const { selectedSuggestion, suggestionsHidden } = this.state;
if (disabled) {
e.preventDefault();
return;
}
if (e.which === 229 || (e as any).isComposing) {
2020-03-27 20:59:38 +00:00
// Ignore key events during text composition
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
return;
}
switch (e.key) {
2022-05-11 21:06:35 +00:00
case 'Escape':
if (suggestions.size === 0 || suggestionsHidden) {
document.querySelector('.ui')?.parentElement?.focus();
2022-05-11 21:06:35 +00:00
} else {
e.preventDefault();
this.setState({ suggestionsHidden: true });
}
break;
case 'ArrowDown':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: 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) });
}
break;
case 'Enter':
case 'Tab':
2022-05-16 15:21:17 +00:00
// Select suggestion
2022-05-11 21:06:35 +00:00
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
e.stopPropagation();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
}
2020-03-27 20:59:38 +00:00
2022-05-11 21:06:35 +00:00
break;
2020-03-27 20:59:38 +00:00
}
if (e.defaultPrevented || !this.props.onKeyDown) {
return;
}
this.props.onKeyDown(e);
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
onBlur = () => {
this.setState({ suggestionsHidden: true, focused: false });
if (this.props.onBlur) {
this.props.onBlur();
}
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
onFocus = () => {
this.setState({ focused: true });
if (this.props.onFocus) {
this.props.onFocus();
}
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
onSuggestionClick: React.MouseEventHandler<HTMLDivElement> = (e) => {
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index') as any);
2020-03-27 20:59:38 +00:00
e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
this.textarea?.focus();
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
shouldComponentUpdate(nextProps: IAutosuggesteTextarea, nextState: any) {
// Skip updating when only the lastToken changes so the
// cursor doesn't jump around due to re-rendering unnecessarily
const lastTokenUpdated = this.state.lastToken !== nextState.lastToken;
const valueUpdated = this.props.value !== nextProps.value;
if (lastTokenUpdated && !valueUpdated) {
2020-09-29 02:58:56 +00:00
return false;
} else {
// https://stackoverflow.com/a/35962835
return super.shouldComponentUpdate!.bind(this)(nextProps, nextState, undefined);
2020-09-29 02:58:56 +00:00
}
}
componentDidUpdate(prevProps: IAutosuggesteTextarea, prevState: any) {
const { suggestions } = this.props;
if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) {
this.setState({ suggestionsHidden: false });
2020-03-27 20:59:38 +00:00
}
}
setTextarea: React.Ref<HTMLTextAreaElement> = (c) => {
2020-03-27 20:59:38 +00:00
this.textarea = c;
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
onPaste: React.ClipboardEventHandler<HTMLTextAreaElement> = (e) => {
2020-03-27 20:59:38 +00:00
if (e.clipboardData && e.clipboardData.files.length === 1) {
this.props.onPaste(e.clipboardData.files);
e.preventDefault();
}
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
renderSuggestion = (suggestion: string | Emoji, i: number) => {
2020-03-27 20:59:38 +00:00
const { selectedSuggestion } = this.state;
let inner, key;
if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
2022-05-16 15:21:17 +00:00
key = suggestion.id;
2020-03-27 20:59:38 +00:00
} else if (suggestion[0] === '#') {
inner = suggestion;
2022-05-16 15:21:17 +00:00
key = suggestion;
2020-03-27 20:59:38 +00:00
} else {
inner = <AutosuggestAccount id={suggestion} />;
2022-05-16 15:21:17 +00:00
key = suggestion;
2020-03-27 20:59:38 +00:00
}
return (
2022-03-21 18:09:01 +00:00
<div
role='button'
tabIndex={0}
2022-03-21 18:09:01 +00:00
key={key}
data-index={i}
className={classNames({
'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 group': true,
'bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800': i === selectedSuggestion,
2022-03-21 18:09:01 +00:00
})}
onMouseDown={this.onSuggestionClick}
>
2020-03-27 20:59:38 +00:00
{inner}
</div>
);
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-03-21 18:09:01 +00:00
setPortalPosition() {
if (!this.textarea) {
return {};
}
const { top, height, left, width } = this.textarea.getBoundingClientRect();
return {
top: top + height,
left,
width,
};
}
render() {
2022-03-21 18:09:01 +00:00
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children, condensed, id } = this.props;
2020-03-27 20:59:38 +00:00
const { suggestionsHidden } = this.state;
const style = { direction: 'ltr', minRows: 10 };
// TODO: convert to functional component and use `useLocale()` hook instead of checking placeholder text.
if (isRtl(value) || (!value && placeholder && isRtl(placeholder))) {
style.direction = 'rtl';
}
2020-03-27 20:59:38 +00:00
return [
2022-03-21 18:09:01 +00:00
<div key='textarea'>
<div className='relative'>
2020-03-27 20:59:38 +00:00
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<Textarea
ref={this.setTextarea}
className={classNames('transition-[min-height] motion-reduce:transition-none dark:bg-transparent px-0 border-0 text-gray-800 dark:text-white placeholder:text-gray-600 dark:placeholder:text-gray-600 resize-none w-full focus:shadow-none focus:border-0 focus:ring-0', {
'min-h-[40px]': condensed,
2022-03-21 18:09:01 +00:00
'min-h-[100px]': !condensed,
})}
id={id}
2020-03-27 20:59:38 +00:00
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}
style={style as any}
2020-03-27 20:59:38 +00:00
aria-autocomplete='list'
/>
</label>
</div>
2022-03-21 18:09:01 +00:00
2020-03-27 20:59:38 +00:00
{children}
</div>,
2022-03-21 18:09:01 +00:00
<Portal key='portal'>
<div
style={this.setPortalPosition()}
className={classNames({
'fixed z-1000 shadow bg-white dark:bg-gray-900 rounded-lg py-1 space-y-0 dark:ring-2 dark:ring-primary-700 focus:outline-none': true,
2022-03-21 18:09:01 +00:00
hidden: suggestionsHidden || suggestions.isEmpty(),
block: !suggestionsHidden && !suggestions.isEmpty(),
})}
>
2020-03-27 20:59:38 +00:00
{suggestions.map(this.renderSuggestion)}
</div>
2022-03-21 18:09:01 +00:00
</Portal>,
2020-03-27 20:59:38 +00:00
];
}
}
export default AutosuggestTextarea;