Merge branch 'input-rtl' into 'main'
Add RTL support to Input and Textarea, delete AutosuggestTextarea Closes #1558 See merge request soapbox-pub/soapbox!2798
This commit is contained in:
commit
04214c5075
11 changed files with 30 additions and 368 deletions
|
@ -152,7 +152,6 @@
|
||||||
"react-sparklines": "^1.7.0",
|
"react-sparklines": "^1.7.0",
|
||||||
"react-sticky-box": "^2.0.0",
|
"react-sticky-box": "^2.0.0",
|
||||||
"react-swipeable-views": "^0.14.0",
|
"react-swipeable-views": "^0.14.0",
|
||||||
"react-textarea-autosize": "^8.3.4",
|
|
||||||
"react-virtuoso": "^4.3.11",
|
"react-virtuoso": "^4.3.11",
|
||||||
"redux": "^4.1.1",
|
"redux": "^4.1.1",
|
||||||
"redux-immutable": "^4.0.0",
|
"redux-immutable": "^4.0.0",
|
||||||
|
|
|
@ -7,7 +7,6 @@ import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji';
|
||||||
import Icon from 'soapbox/components/icon';
|
import Icon from 'soapbox/components/icon';
|
||||||
import { Input, Portal } from 'soapbox/components/ui';
|
import { Input, Portal } from 'soapbox/components/ui';
|
||||||
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
|
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
|
||||||
import { isRtl } from 'soapbox/utils/rtl';
|
|
||||||
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
||||||
|
|
||||||
import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu';
|
import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu';
|
||||||
|
@ -264,15 +263,9 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
||||||
render() {
|
render() {
|
||||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu, theme } = this.props;
|
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu, theme } = this.props;
|
||||||
const { suggestionsHidden } = this.state;
|
const { suggestionsHidden } = this.state;
|
||||||
const style: React.CSSProperties = { direction: 'ltr' };
|
|
||||||
|
|
||||||
const visible = !suggestionsHidden && (!suggestions.isEmpty() || (menu && value));
|
const visible = !suggestionsHidden && (!suggestions.isEmpty() || (menu && value));
|
||||||
|
|
||||||
// TODO: convert to functional component and use `useLocale()` hook instead of checking placeholder text.
|
|
||||||
if (isRtl(value) || (!value && placeholder && isRtl(placeholder))) {
|
|
||||||
style.direction = 'rtl';
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
<div key='input' className='relative w-full'>
|
<div key='input' className='relative w-full'>
|
||||||
<label className='sr-only'>{placeholder}</label>
|
<label className='sr-only'>{placeholder}</label>
|
||||||
|
@ -291,7 +284,6 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
||||||
onKeyUp={onKeyUp}
|
onKeyUp={onKeyUp}
|
||||||
onFocus={this.onFocus}
|
onFocus={this.onFocus}
|
||||||
onBlur={this.onBlur}
|
onBlur={this.onBlur}
|
||||||
style={style}
|
|
||||||
aria-autocomplete='list'
|
aria-autocomplete='list'
|
||||||
id={id}
|
id={id}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
|
|
|
@ -1,288 +0,0 @@
|
||||||
import clsx from 'clsx';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import Textarea from 'react-textarea-autosize';
|
|
||||||
|
|
||||||
import { Portal } from 'soapbox/components/ui';
|
|
||||||
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
|
|
||||||
import { isRtl } from 'soapbox/utils/rtl';
|
|
||||||
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
|
||||||
|
|
||||||
import AutosuggestEmoji from './autosuggest-emoji';
|
|
||||||
|
|
||||||
import type { List as ImmutableList } from 'immutable';
|
|
||||||
import type { Emoji } from 'soapbox/features/emoji';
|
|
||||||
|
|
||||||
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;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea> {
|
|
||||||
|
|
||||||
textarea: HTMLTextAreaElement | null = null;
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
autoFocus: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
suggestionsHidden: true,
|
|
||||||
focused: false,
|
|
||||||
selectedSuggestion: 0,
|
|
||||||
lastToken: null,
|
|
||||||
tokenStart: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
onChange: React.ChangeEventHandler<HTMLTextAreaElement> = (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);
|
|
||||||
} else if (token === null) {
|
|
||||||
this.setState({ lastToken: null });
|
|
||||||
this.props.onSuggestionsClearRequested();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.onChange(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
|
||||||
const { suggestions, disabled } = this.props;
|
|
||||||
const { selectedSuggestion, suggestionsHidden } = this.state;
|
|
||||||
|
|
||||||
if (disabled) {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.which === 229 || (e as any).isComposing) {
|
|
||||||
// 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) {
|
|
||||||
case 'Escape':
|
|
||||||
if (suggestions.size === 0 || suggestionsHidden) {
|
|
||||||
document.querySelector('.ui')?.parentElement?.focus();
|
|
||||||
} 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':
|
|
||||||
// Select suggestion
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.defaultPrevented || !this.props.onKeyDown) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.onKeyDown(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
onBlur = () => {
|
|
||||||
this.setState({ suggestionsHidden: true, focused: false });
|
|
||||||
|
|
||||||
if (this.props.onBlur) {
|
|
||||||
this.props.onBlur();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onFocus = () => {
|
|
||||||
this.setState({ focused: true });
|
|
||||||
|
|
||||||
if (this.props.onFocus) {
|
|
||||||
this.props.onFocus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onSuggestionClick: React.MouseEventHandler<HTMLDivElement> = (e) => {
|
|
||||||
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index') as any);
|
|
||||||
e.preventDefault();
|
|
||||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
|
||||||
this.textarea?.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
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) {
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
// https://stackoverflow.com/a/35962835
|
|
||||||
return super.shouldComponentUpdate!.bind(this)(nextProps, nextState, undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps: IAutosuggesteTextarea, prevState: any) {
|
|
||||||
const { suggestions } = this.props;
|
|
||||||
if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) {
|
|
||||||
this.setState({ suggestionsHidden: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTextarea: React.Ref<HTMLTextAreaElement> = (c) => {
|
|
||||||
this.textarea = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
onPaste: React.ClipboardEventHandler<HTMLTextAreaElement> = (e) => {
|
|
||||||
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
|
||||||
this.props.onPaste(e.clipboardData.files);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
renderSuggestion = (suggestion: string | Emoji, i: number) => {
|
|
||||||
const { selectedSuggestion } = this.state;
|
|
||||||
let inner, key;
|
|
||||||
|
|
||||||
if (typeof suggestion === 'object') {
|
|
||||||
inner = <AutosuggestEmoji emoji={suggestion} />;
|
|
||||||
key = suggestion.id;
|
|
||||||
} else if (suggestion[0] === '#') {
|
|
||||||
inner = suggestion;
|
|
||||||
key = suggestion;
|
|
||||||
} else {
|
|
||||||
inner = <AutosuggestAccount id={suggestion} />;
|
|
||||||
key = suggestion;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role='button'
|
|
||||||
tabIndex={0}
|
|
||||||
key={key}
|
|
||||||
data-index={i}
|
|
||||||
className={clsx({
|
|
||||||
'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,
|
|
||||||
})}
|
|
||||||
onMouseDown={this.onSuggestionClick}
|
|
||||||
>
|
|
||||||
{inner}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
setPortalPosition() {
|
|
||||||
if (!this.textarea) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { top, height, left, width } = this.textarea.getBoundingClientRect();
|
|
||||||
|
|
||||||
return {
|
|
||||||
top: top + height,
|
|
||||||
left,
|
|
||||||
width,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children, condensed, id } = this.props;
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
<div key='textarea'>
|
|
||||||
<div className='relative'>
|
|
||||||
<label>
|
|
||||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
|
||||||
|
|
||||||
<Textarea
|
|
||||||
ref={this.setTextarea}
|
|
||||||
className={clsx('w-full resize-none border-0 px-0 text-gray-800 transition-[min-height] placeholder:text-gray-600 focus:border-0 focus:shadow-none focus:ring-0 motion-reduce:transition-none dark:bg-transparent dark:text-white dark:placeholder:text-gray-600', {
|
|
||||||
'min-h-[40px]': condensed,
|
|
||||||
'min-h-[100px]': !condensed,
|
|
||||||
})}
|
|
||||||
id={id}
|
|
||||||
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}
|
|
||||||
aria-autocomplete='list'
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</div>,
|
|
||||||
|
|
||||||
<Portal key='portal'>
|
|
||||||
<div
|
|
||||||
style={this.setPortalPosition()}
|
|
||||||
className={clsx({
|
|
||||||
'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,
|
|
||||||
hidden: suggestionsHidden || suggestions.isEmpty(),
|
|
||||||
block: !suggestionsHidden && !suggestions.isEmpty(),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{suggestions.map(this.renderSuggestion)}
|
|
||||||
</div>
|
|
||||||
</Portal>,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AutosuggestTextarea;
|
|
|
@ -2,6 +2,9 @@ import clsx from 'clsx';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { useLocale } from 'soapbox/hooks';
|
||||||
|
import { getTextDirection } from 'soapbox/utils/rtl';
|
||||||
|
|
||||||
import Icon from '../icon/icon';
|
import Icon from '../icon/icon';
|
||||||
import SvgIcon from '../icon/svg-icon';
|
import SvgIcon from '../icon/svg-icon';
|
||||||
import Tooltip from '../tooltip/tooltip';
|
import Tooltip from '../tooltip/tooltip';
|
||||||
|
@ -45,6 +48,7 @@ interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxL
|
||||||
const Input = React.forwardRef<HTMLInputElement, IInput>(
|
const Input = React.forwardRef<HTMLInputElement, IInput>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
const { type = 'text', icon, className, outerClassName, append, prepend, theme = 'normal', ...filteredProps } = props;
|
const { type = 'text', icon, className, outerClassName, append, prepend, theme = 'normal', ...filteredProps } = props;
|
||||||
|
|
||||||
|
@ -90,10 +94,11 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
|
||||||
'text-gray-600': props.disabled,
|
'text-gray-600': props.disabled,
|
||||||
'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme === 'normal',
|
'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme === 'normal',
|
||||||
'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search',
|
'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search',
|
||||||
'pr-7 rtl:pl-7 rtl:pr-3': isPassword || append,
|
'pr-10 rtl:pl-10 rtl:pr-3': isPassword || append,
|
||||||
'pl-8': typeof icon !== 'undefined',
|
'pl-8': typeof icon !== 'undefined',
|
||||||
'pl-16': typeof prepend !== 'undefined',
|
'pl-16': typeof prepend !== 'undefined',
|
||||||
}, className)}
|
}, className)}
|
||||||
|
dir={typeof props.value === 'string' ? getTextDirection(props.value, { fallback: locale.direction }) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{append ? (
|
{append ? (
|
||||||
|
|
|
@ -2,10 +2,13 @@ import clsx from 'clsx';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { useLocale } from 'soapbox/hooks';
|
||||||
|
import { getTextDirection } from 'soapbox/utils/rtl';
|
||||||
|
|
||||||
import Stack from '../stack/stack';
|
import Stack from '../stack/stack';
|
||||||
import Text from '../text/text';
|
import Text from '../text/text';
|
||||||
|
|
||||||
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'onKeyDown' | 'onPaste' | 'required' | 'disabled' | 'rows' | 'readOnly'> {
|
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'id' | 'maxLength' | 'onChange' | 'onKeyDown' | 'onPaste' | 'required' | 'disabled' | 'rows' | 'readOnly'> {
|
||||||
/** Put the cursor into the input on mount. */
|
/** Put the cursor into the input on mount. */
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
/** Allows the textarea height to grow while typing */
|
/** Allows the textarea height to grow while typing */
|
||||||
|
@ -52,6 +55,7 @@ const Textarea = React.forwardRef(({
|
||||||
}: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
|
}: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
|
||||||
const length = value?.length || 0;
|
const length = value?.length || 0;
|
||||||
const [rows, setRows] = useState<number>(autoGrow ? 1 : 4);
|
const [rows, setRows] = useState<number>(autoGrow ? 1 : 4);
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
if (autoGrow) {
|
if (autoGrow) {
|
||||||
|
@ -94,6 +98,7 @@ const Textarea = React.forwardRef(({
|
||||||
'text-red-600 border-red-600': hasError,
|
'text-red-600 border-red-600': hasError,
|
||||||
'resize-none': !isResizeable,
|
'resize-none': !isResizeable,
|
||||||
})}
|
})}
|
||||||
|
dir={value?.length ? getTextDirection(value, { fallback: locale.direction }) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{maxLength && (
|
{maxLength && (
|
||||||
|
|
|
@ -41,7 +41,7 @@ const Results = ({ accountSearchResult, onSelect }: IResults) => {
|
||||||
<Text weight='bold' size='sm' truncate>{account.display_name}</Text>
|
<Text weight='bold' size='sm' truncate>{account.display_name}</Text>
|
||||||
{account.verified && <VerificationBadge />}
|
{account.verified && <VerificationBadge />}
|
||||||
</div>
|
</div>
|
||||||
<Text size='sm' weight='medium' theme='muted' truncate>@{account.acct}</Text>
|
<Text size='sm' weight='medium' theme='muted' direction='ltr' truncate>@{account.acct}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</HStack>
|
</HStack>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -14,7 +14,6 @@ import {
|
||||||
uploadCompose,
|
uploadCompose,
|
||||||
} from 'soapbox/actions/compose';
|
} from 'soapbox/actions/compose';
|
||||||
import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
||||||
import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea';
|
|
||||||
import { Button, HStack, Stack } from 'soapbox/components/ui';
|
import { Button, HStack, Stack } from 'soapbox/components/ui';
|
||||||
import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
|
import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
|
||||||
import { ComposeEditor } from 'soapbox/features/ui/util/async-components';
|
import { ComposeEditor } from 'soapbox/features/ui/util/async-components';
|
||||||
|
@ -82,8 +81,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
spoiler,
|
spoiler,
|
||||||
spoiler_text: spoilerText,
|
spoiler_text: spoilerText,
|
||||||
privacy,
|
privacy,
|
||||||
focusDate,
|
|
||||||
caretPosition,
|
|
||||||
is_submitting: isSubmitting,
|
is_submitting: isSubmitting,
|
||||||
is_changing_upload:
|
is_changing_upload:
|
||||||
isChangingUpload,
|
isChangingUpload,
|
||||||
|
@ -104,7 +101,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
const firstRender = useRef(true);
|
const firstRender = useRef(true);
|
||||||
const formRef = useRef<HTMLDivElement>(null);
|
const formRef = useRef<HTMLDivElement>(null);
|
||||||
const spoilerTextRef = useRef<AutosuggestInput>(null);
|
const spoilerTextRef = useRef<AutosuggestInput>(null);
|
||||||
const autosuggestTextareaRef = useRef<AutosuggestTextarea>(null);
|
|
||||||
const editorRef = useRef<LexicalEditor>(null);
|
const editorRef = useRef<LexicalEditor>(null);
|
||||||
|
|
||||||
const { isDraggedOver } = useDraggedFiles(formRef);
|
const { isDraggedOver } = useDraggedFiles(formRef);
|
||||||
|
@ -172,11 +168,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['spoiler_text']));
|
dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['spoiler_text']));
|
||||||
};
|
};
|
||||||
|
|
||||||
const setCursor = (start: number, end: number = start) => {
|
|
||||||
if (!autosuggestTextareaRef.current?.textarea) return;
|
|
||||||
autosuggestTextareaRef.current.textarea.setSelectionRange(start, end);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEmojiPick = (data: Emoji) => {
|
const handleEmojiPick = (data: Emoji) => {
|
||||||
const editor = editorRef.current;
|
const editor = editorRef.current;
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
|
@ -194,18 +185,9 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
spoilerTextRef.current?.input?.focus();
|
spoilerTextRef.current?.input?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const focusTextarea = () => {
|
|
||||||
autosuggestTextareaRef.current?.textarea?.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const length = text.length;
|
|
||||||
document.addEventListener('click', handleClick, true);
|
document.addEventListener('click', handleClick, true);
|
||||||
|
|
||||||
if (length > 0) {
|
|
||||||
setCursor(length); // Set cursor at end
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('click', handleClick, true);
|
document.removeEventListener('click', handleClick, true);
|
||||||
};
|
};
|
||||||
|
@ -213,21 +195,14 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (spoiler && firstRender.current) {
|
if (spoiler && firstRender.current) {
|
||||||
focusTextarea();
|
|
||||||
firstRender.current = false;
|
firstRender.current = false;
|
||||||
} else if (!spoiler && prevSpoiler) {
|
} else if (!spoiler && prevSpoiler) {
|
||||||
focusTextarea();
|
//
|
||||||
} else if (spoiler && !prevSpoiler) {
|
} else if (spoiler && !prevSpoiler) {
|
||||||
focusSpoilerInput();
|
focusSpoilerInput();
|
||||||
}
|
}
|
||||||
}, [spoiler]);
|
}, [spoiler]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof caretPosition === 'number') {
|
|
||||||
setCursor(caretPosition);
|
|
||||||
}
|
|
||||||
}, [focusDate]);
|
|
||||||
|
|
||||||
const renderButtons = useCallback(() => (
|
const renderButtons = useCallback(() => (
|
||||||
<HStack alignItems='center' space={2}>
|
<HStack alignItems='center' space={2}>
|
||||||
{features.media && <UploadButtonContainer composeId={id} />}
|
{features.media && <UploadButtonContainer composeId={id} />}
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
import Textarea from 'react-textarea-autosize';
|
|
||||||
|
|
||||||
import { submitAccountNote } from 'soapbox/actions/account-notes';
|
import { submitAccountNote } from 'soapbox/actions/account-notes';
|
||||||
import { HStack, Text, Widget } from 'soapbox/components/ui';
|
import { HStack, Text, Textarea, Widget } from 'soapbox/components/ui';
|
||||||
import { useAppDispatch } from 'soapbox/hooks';
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
import type { Account as AccountEntity } from 'soapbox/schemas';
|
import type { Account as AccountEntity } from 'soapbox/schemas';
|
||||||
|
@ -64,14 +63,17 @@ const AccountNotePanel: React.FC<IAccountNotePanel> = ({ account }) => {
|
||||||
)}
|
)}
|
||||||
</HStack>}
|
</HStack>}
|
||||||
>
|
>
|
||||||
|
<div className='-mx-2'>
|
||||||
<Textarea
|
<Textarea
|
||||||
id={`account-note-${account.id}`}
|
id={`account-note-${account.id}`}
|
||||||
className='mx-[-8px] w-full resize-none rounded-md border-0 bg-transparent p-2 text-sm text-gray-800 transition-colors placeholder:text-gray-600 focus:border-0 focus:bg-white focus:shadow-none focus:ring-0 motion-reduce:transition-none dark:text-white dark:placeholder:text-gray-600 focus:dark:bg-primary-900'
|
theme='transparent'
|
||||||
placeholder={intl.formatMessage(messages.placeholder)}
|
placeholder={intl.formatMessage(messages.placeholder)}
|
||||||
value={value || ''}
|
value={value || ''}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
ref={textarea}
|
ref={textarea}
|
||||||
|
autoGrow
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</Widget>
|
</Widget>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,21 +2,19 @@ import { getLocale } from 'soapbox/actions/settings';
|
||||||
|
|
||||||
import { useAppSelector } from './useAppSelector';
|
import { useAppSelector } from './useAppSelector';
|
||||||
|
|
||||||
import type { CSSProperties } from 'react';
|
|
||||||
|
|
||||||
/** Locales which should be presented in right-to-left. */
|
/** Locales which should be presented in right-to-left. */
|
||||||
const RTL_LOCALES = ['ar', 'ckb', 'fa', 'he'];
|
const RTL_LOCALES = ['ar', 'ckb', 'fa', 'he'];
|
||||||
|
|
||||||
interface UseLocaleResult {
|
interface UseLocaleResult {
|
||||||
locale: string;
|
locale: string;
|
||||||
direction: CSSProperties['direction'];
|
direction: 'ltr' | 'rtl';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get valid locale from settings. */
|
/** Get valid locale from settings. */
|
||||||
const useLocale = (fallback = 'en'): UseLocaleResult => {
|
const useLocale = (fallback = 'en'): UseLocaleResult => {
|
||||||
const locale = useAppSelector((state) => getLocale(state, fallback));
|
const locale = useAppSelector((state) => getLocale(state, fallback));
|
||||||
|
|
||||||
const direction: CSSProperties['direction'] =
|
const direction: 'ltr' | 'rtl' =
|
||||||
RTL_LOCALES.includes(locale)
|
RTL_LOCALES.includes(locale)
|
||||||
? 'rtl'
|
? 'rtl'
|
||||||
: 'ltr';
|
: 'ltr';
|
||||||
|
|
|
@ -45,7 +45,7 @@ function isRtl(text: string, confidence = 0.3): boolean {
|
||||||
|
|
||||||
interface GetTextDirectionOpts {
|
interface GetTextDirectionOpts {
|
||||||
/** The default direction to return if the text is empty. */
|
/** The default direction to return if the text is empty. */
|
||||||
fallback?: 'ltr' | 'rtl';
|
fallback?: 'ltr' | 'rtl' | undefined;
|
||||||
/** The confidence threshold (0-1) to use when determining the direction. */
|
/** The confidence threshold (0-1) to use when determining the direction. */
|
||||||
confidence?: number;
|
confidence?: number;
|
||||||
}
|
}
|
||||||
|
|
28
yarn.lock
28
yarn.lock
|
@ -1051,7 +1051,7 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
regenerator-runtime "^0.12.0"
|
regenerator-runtime "^0.12.0"
|
||||||
|
|
||||||
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
|
"@babel/runtime@^7.1.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
|
||||||
version "7.22.15"
|
version "7.22.15"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8"
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8"
|
||||||
integrity sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==
|
integrity sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==
|
||||||
|
@ -7546,15 +7546,6 @@ react-swipeable-views@^0.14.0:
|
||||||
react-swipeable-views-utils "^0.14.0"
|
react-swipeable-views-utils "^0.14.0"
|
||||||
warning "^4.0.1"
|
warning "^4.0.1"
|
||||||
|
|
||||||
react-textarea-autosize@^8.3.4:
|
|
||||||
version "8.3.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz#270a343de7ad350534141b02c9cb78903e553524"
|
|
||||||
integrity sha512-CdtmP8Dc19xL8/R6sWvtknD/eCXkQr30dtvC4VmGInhRsfF8X/ihXCq6+9l9qbxmKRiq407/7z5fxE7cVWQNgQ==
|
|
||||||
dependencies:
|
|
||||||
"@babel/runtime" "^7.10.2"
|
|
||||||
use-composed-ref "^1.3.0"
|
|
||||||
use-latest "^1.2.1"
|
|
||||||
|
|
||||||
react-transition-group@^2.2.1:
|
react-transition-group@^2.2.1:
|
||||||
version "2.9.0"
|
version "2.9.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d"
|
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d"
|
||||||
|
@ -8913,23 +8904,6 @@ url-parse@^1.5.3:
|
||||||
querystringify "^2.1.1"
|
querystringify "^2.1.1"
|
||||||
requires-port "^1.0.0"
|
requires-port "^1.0.0"
|
||||||
|
|
||||||
use-composed-ref@^1.3.0:
|
|
||||||
version "1.3.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda"
|
|
||||||
integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==
|
|
||||||
|
|
||||||
use-isomorphic-layout-effect@^1.1.1:
|
|
||||||
version "1.1.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
|
|
||||||
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
|
|
||||||
|
|
||||||
use-latest@^1.2.1:
|
|
||||||
version "1.2.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.1.tgz#d13dfb4b08c28e3e33991546a2cee53e14038cf2"
|
|
||||||
integrity sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==
|
|
||||||
dependencies:
|
|
||||||
use-isomorphic-layout-effect "^1.1.1"
|
|
||||||
|
|
||||||
use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0:
|
use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
|
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
|
||||||
|
|
Loading…
Reference in a new issue