mirror of
https://github.com/mastodon/mastodon.git
synced 2026-08-23 07:14:45 -05:00
Composer redesign: Text editing improvements (#40150)
This commit is contained in:
@@ -20,7 +20,12 @@
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover:not(:active, :disabled) {
|
||||
&:hover:not(
|
||||
:active,
|
||||
:disabled,
|
||||
[aria-pressed='true'],
|
||||
[aria-expanded='true']
|
||||
) {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
@@ -87,7 +92,7 @@ a.base {
|
||||
--fg: var(--color-text-inverted);
|
||||
}
|
||||
|
||||
&.tonal:not(:active) {
|
||||
&.tonal:not(:active, [aria-pressed='true'], [aria-expanded='true']) {
|
||||
--bg: var(--color-bg-highlight);
|
||||
--fg: var(--color-text-primary);
|
||||
}
|
||||
@@ -101,7 +106,9 @@ a.base {
|
||||
// Ghost
|
||||
|
||||
.ghost {
|
||||
&:active {
|
||||
&:active,
|
||||
&[aria-pressed='true'],
|
||||
&[aria-expanded='true'] {
|
||||
--bg: var(--color-bg-inverted);
|
||||
--fg: var(--color-text-inverted);
|
||||
}
|
||||
@@ -109,7 +116,9 @@ a.base {
|
||||
&.accent {
|
||||
--fg: var(--color-text-brand);
|
||||
|
||||
&:active {
|
||||
&:active,
|
||||
&[aria-pressed='true'],
|
||||
&[aria-expanded='true'] {
|
||||
--bg: var(--color-bg-brand-base);
|
||||
--fg: var(--color-text-on-brand-base);
|
||||
}
|
||||
@@ -118,7 +127,9 @@ a.base {
|
||||
&.destructive {
|
||||
--fg: var(--color-text-error);
|
||||
|
||||
&:active {
|
||||
&:active,
|
||||
&[aria-pressed='true'],
|
||||
&[aria-expanded='true'] {
|
||||
--fg: var(--color-text-on-error-base);
|
||||
--bg: var(--color-bg-error-base);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { UploadForm } from './upload_form';
|
||||
import { Warning } from './warning';
|
||||
import { ComposeQuotedStatus } from './quoted_post';
|
||||
import { VisibilityButton } from './visibility_button';
|
||||
import { isRedesignEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
|
||||
|
||||
@@ -187,6 +188,9 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (isRedesignEnabled()) {
|
||||
return;
|
||||
}
|
||||
this._updateFocusAndSelection(prevProps);
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ export const ComposeEmojiButton: React.FC<{ onPick: OnEmojiPick }> = ({
|
||||
icon={SmileyIcon}
|
||||
ref={setTarget}
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='emoji_button.label'
|
||||
|
||||
@@ -6,6 +6,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { LockSimpleOpenIcon } from '@phosphor-icons/react';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import {
|
||||
changeComposeSpoilerness,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
TextInputField,
|
||||
} from '@/mastodon/components/form_fields/redesign';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { useResizeObserver } from '@/mastodon/hooks/useObserver';
|
||||
import {
|
||||
focusComposerTextarea,
|
||||
getComposerTextarea,
|
||||
@@ -65,8 +67,14 @@ export const RedesignComposeForm: React.FC<RedesignComposeFormProps> = ({
|
||||
const type = useAppSelector(selectComposeType);
|
||||
const { sensitive, sensitiveText } = useAppSelector(selectComposeSensitive);
|
||||
|
||||
const { onSensitiveChange, onSensitiveTextChange, onEmojiPick, onSubmit } =
|
||||
useComposeHandlers(redirectOnSuccess);
|
||||
const {
|
||||
onSensitiveChange,
|
||||
onSensitiveTextChange,
|
||||
onEmojiPick,
|
||||
onSubmit,
|
||||
onWrapperMount,
|
||||
onWrapperScroll,
|
||||
} = useComposeHandlers(redirectOnSuccess);
|
||||
|
||||
const intl = useIntl();
|
||||
const titleId = useId();
|
||||
@@ -114,7 +122,11 @@ export const RedesignComposeForm: React.FC<RedesignComposeFormProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={classes.editorWrapper}>
|
||||
<div
|
||||
ref={onWrapperMount}
|
||||
onScroll={onWrapperScroll}
|
||||
className={classes.editorWrapper}
|
||||
>
|
||||
<ComposeTextarea
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
@@ -190,10 +202,49 @@ function useComposeHandlers(redirectOnSuccess?: boolean) {
|
||||
[canSubmit, dispatch, redirectOnSuccess],
|
||||
);
|
||||
|
||||
// Handle wrapper fade to indicate scroll.
|
||||
const onWrapperScroll = useDebouncedCallback(wrapperScroll, 20, {
|
||||
leading: true,
|
||||
});
|
||||
const observer = useResizeObserver(wrapperResize);
|
||||
const onWrapperMount: React.RefCallback<HTMLElement> = useCallback(
|
||||
(ele) => {
|
||||
if (ele) {
|
||||
observer.observe(ele);
|
||||
}
|
||||
},
|
||||
[observer],
|
||||
);
|
||||
|
||||
return {
|
||||
onSubmit,
|
||||
onEmojiPick,
|
||||
onSensitiveChange,
|
||||
onSensitiveTextChange,
|
||||
onWrapperScroll,
|
||||
onWrapperMount,
|
||||
};
|
||||
}
|
||||
|
||||
function wrapperUpdate(ele: HTMLElement) {
|
||||
const scrollMax = ele.scrollHeight - ele.offsetHeight - 5; // 5px padding to account for sub-pixel issues
|
||||
if (scrollMax > 0 && ele.scrollTop < scrollMax) {
|
||||
ele.dataset.scrollDown = 'true';
|
||||
} else {
|
||||
delete ele.dataset.scrollDown;
|
||||
}
|
||||
}
|
||||
|
||||
function wrapperResize(entries: ResizeObserverEntry[]) {
|
||||
for (const entry of entries) {
|
||||
if (entry.target instanceof HTMLElement) {
|
||||
wrapperUpdate(entry.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wrapperScroll(event: React.UIEvent<HTMLElement>) {
|
||||
if (event.target instanceof HTMLElement) {
|
||||
wrapperUpdate(event.target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ import { TranslateIcon } from '@phosphor-icons/react';
|
||||
|
||||
import { changeComposeLanguage } from '@/mastodon/actions/compose';
|
||||
import { IconButton } from '@/mastodon/components/button/redesign';
|
||||
import { Dropdown } from '@/mastodon/components/dropdown/redesign';
|
||||
import type { PopoverChildProps } from '@/mastodon/components/popover';
|
||||
import { Popover } from '@/mastodon/components/popover';
|
||||
import { DropdownPopover } from '@/mastodon/components/dropdown/redesign';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { LanguageDropdownMenu } from '../components/language_dropdown';
|
||||
@@ -57,22 +55,24 @@ export const LanguageButton: React.FC = () => {
|
||||
/>
|
||||
</IconButton>
|
||||
|
||||
<Popover
|
||||
<DropdownPopover
|
||||
isOpen={open}
|
||||
onClose={handleClose}
|
||||
offset={4}
|
||||
placement='bottom-end'
|
||||
reference={trigger}
|
||||
className={classes.languageMenu}
|
||||
maxWidth={280}
|
||||
>
|
||||
{({ props }) => <LanguageDropdown {...props} onClose={handleClose} />}
|
||||
</Popover>
|
||||
<LanguageDropdown onClose={handleClose} />
|
||||
</DropdownPopover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const LanguageDropdown: React.FC<
|
||||
PopoverChildProps & { onClose: () => void }
|
||||
> = ({ onClose, ...props }) => {
|
||||
export const LanguageDropdown: React.FC<{ onClose: () => void }> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const language = useAppSelector(
|
||||
(state) => state.compose.get('language') as string,
|
||||
);
|
||||
@@ -88,14 +88,12 @@ export const LanguageDropdown: React.FC<
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown {...props} className={classes.languageMenu} maxWidth={280}>
|
||||
<LanguageDropdownMenu
|
||||
value={language}
|
||||
guess={guess}
|
||||
onChange={handleChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Dropdown>
|
||||
<LanguageDropdownMenu
|
||||
value={language}
|
||||
guess={guess}
|
||||
onChange={handleChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -45,9 +45,10 @@ export const selectComposeCharsCount = createAppSelector(
|
||||
(maxChars, text, spoilerText) => {
|
||||
const allText = (countableText(text) as string) + spoilerText;
|
||||
return {
|
||||
text: allText,
|
||||
current: length(allText),
|
||||
text,
|
||||
allText,
|
||||
max: maxChars ?? 500,
|
||||
current: length(allText),
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -59,12 +60,38 @@ export const selectComposeCanSubmit = createAppSelector(
|
||||
(state) => !!state.compose.get('is_changing_upload'),
|
||||
selectComposeCharsCount,
|
||||
],
|
||||
(isSubmitting, isUploading, isChangingUpload, { current, max }) =>
|
||||
(isSubmitting, isUploading, isChangingUpload, { text, max }) =>
|
||||
!isSubmitting &&
|
||||
!isUploading &&
|
||||
!isChangingUpload &&
|
||||
current <= max &&
|
||||
current > 0,
|
||||
text.trim().length <= max &&
|
||||
text.trim().length > 0,
|
||||
);
|
||||
|
||||
export const selectComposeMentions = createAppSelector(
|
||||
[
|
||||
(state) => state.accounts_map,
|
||||
(state) => state.compose.get('text') as string,
|
||||
(state) => state.server.server.item?.domain,
|
||||
],
|
||||
(accountsMap, text, localDomain) => {
|
||||
const accounts = new Set<string>();
|
||||
const potentialAccounts = text.matchAll(
|
||||
/@(?<username>[a-zA-Z0-9_.-]+)(?<domain>@[a-zA-Z0-9_.-]+)?/g,
|
||||
);
|
||||
for (const match of potentialAccounts) {
|
||||
const { username, domain } = match.groups ?? {};
|
||||
if (!username) {
|
||||
continue;
|
||||
}
|
||||
const account =
|
||||
domain && domain !== localDomain ? `${username}@${domain}` : username;
|
||||
if (accountsMap[account]) {
|
||||
accounts.add(accountsMap[account]);
|
||||
}
|
||||
}
|
||||
return accounts;
|
||||
},
|
||||
);
|
||||
|
||||
export const selectComposeSensitive = createAppSelector(
|
||||
|
||||
@@ -71,20 +71,42 @@
|
||||
}
|
||||
|
||||
.editorWrapper {
|
||||
--fade-size: var(--space-xl);
|
||||
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-gutter: stable;
|
||||
overscroll-behavior-y: contain;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
height: var(--fade-size);
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
var(--color-bg-primary),
|
||||
transparent var(--fade-size)
|
||||
);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
flex-shrink: 0;
|
||||
margin-top: calc((-1 * var(--fade-size)) - var(--space-md));
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
|
||||
&[data-scroll-down]::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.textareaWrapper {
|
||||
flex-grow: 1;
|
||||
padding: var(--space-xs);
|
||||
margin: 2px;
|
||||
border-radius: var(--radius-xs);
|
||||
transition: border 200ms;
|
||||
cursor: text;
|
||||
@@ -92,6 +114,10 @@
|
||||
&:focus-within {
|
||||
outline: 2px solid var(--color-border-brand);
|
||||
outline-offset: -2px;
|
||||
|
||||
textarea::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from '@/mastodon/actions/compose';
|
||||
import { processPasteOrDrop } from '@/mastodon/actions/compose_typed';
|
||||
import AutosuggestTextareaOriginal from '@/mastodon/components/autosuggest_textarea';
|
||||
import { useToggle } from '@/mastodon/hooks/useToggle';
|
||||
import { COMPOSER_TEXTAREA_ID } from '@/mastodon/reducers/slices/composer';
|
||||
import {
|
||||
createAppSelector,
|
||||
@@ -108,18 +107,12 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
|
||||
const onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> =
|
||||
useCallback(
|
||||
(event) => {
|
||||
if (
|
||||
event.key.toLowerCase() === 'enter' &&
|
||||
(event.ctrlKey || event.metaKey)
|
||||
) {
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'enter' && (event.ctrlKey || event.metaKey)) {
|
||||
onSubmit();
|
||||
event.preventDefault();
|
||||
}
|
||||
if (
|
||||
['esc', 'escape'].includes(event.key.toLowerCase()) &&
|
||||
event.target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
event.target.blur();
|
||||
} else if (['esc', 'escape'].includes(key)) {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
},
|
||||
[onSubmit],
|
||||
@@ -153,20 +146,11 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
|
||||
}, [dispatch]);
|
||||
const onSuggestionSelected: SuggestSelectedHandler = useCallback(
|
||||
(position, token, suggestion) => {
|
||||
dispatch(selectComposeSuggestion(position, token, suggestion));
|
||||
dispatch(selectComposeSuggestion(position, token, suggestion, ['text']));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
// Only show placeholder if we don't have focus
|
||||
let placeholder = intl.formatMessage(
|
||||
type === 'message' ? messages.messagePlaceholder : messages.placeholder,
|
||||
);
|
||||
const [focused, { onTrue: onFocus, onFalse: onBlur }] = useToggle();
|
||||
if (focused) {
|
||||
placeholder = '';
|
||||
}
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
return (
|
||||
@@ -181,14 +165,16 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
lang={lang}
|
||||
placeholder={placeholder}
|
||||
placeholder={intl.formatMessage(
|
||||
type === 'message'
|
||||
? messages.messagePlaceholder
|
||||
: messages.placeholder,
|
||||
)}
|
||||
disabled={disabled || isSubmitting}
|
||||
suggestions={suggestions}
|
||||
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||
onSuggestionSelected={onSuggestionSelected}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
onDrop={onDrop}
|
||||
onPaste={onPaste}
|
||||
|
||||
@@ -32,11 +32,12 @@ import { Popover } from '@/mastodon/components/popover';
|
||||
import { useToggle } from '@/mastodon/hooks/useToggle';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { selectComposePrivacy } from './selectors';
|
||||
import { selectComposeMentions, selectComposePrivacy } from './selectors';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const ComposeVisibility: React.FC = () => {
|
||||
const privacy = useAppSelector(selectComposePrivacy);
|
||||
const mentions = useAppSelector(selectComposeMentions);
|
||||
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
|
||||
const [showMenu, { onToggle, onFalse }] = useToggle();
|
||||
|
||||
@@ -48,14 +49,21 @@ export const ComposeVisibility: React.FC = () => {
|
||||
description='Before button that indicates who a post is for (Public, Followers, mentioned people)'
|
||||
/>
|
||||
|
||||
<Button size='sm' onClick={onToggle} ref={setTrigger}>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={onToggle}
|
||||
ref={setTrigger}
|
||||
aria-expanded={showMenu}
|
||||
>
|
||||
{privacy !== 'private' && (
|
||||
<FormattedMessage id='privacy.public.short' defaultMessage='Public' />
|
||||
)}
|
||||
{privacy === 'private' && (
|
||||
<FormattedMessage
|
||||
id='privacy.private.short'
|
||||
defaultMessage='Followers'
|
||||
id='compose.post.privacy.followers'
|
||||
defaultMessage='Followers {count, plural, =0 {} one {+ # other} other {+ # others}}'
|
||||
description='Count is # of other people mentioned in the post. If zero, just output "Followers".'
|
||||
values={{ count: mentions.size }}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -513,6 +513,7 @@
|
||||
"compose.poll.duration": "Duration: {button}",
|
||||
"compose.poll.multiple": "Allow multiple selections",
|
||||
"compose.post.placeholder": "What would you like to say?",
|
||||
"compose.post.privacy.followers": "Followers {count, plural, =0 {} one {+ # other} other {+ # others}}",
|
||||
"compose.post.title.edit": "Edit post",
|
||||
"compose.post.title.new": "New post",
|
||||
"compose.post.to": "To:",
|
||||
|
||||
@@ -108,7 +108,8 @@ export const hideComposer = createAppThunk((_arg, { getState, dispatch }) => {
|
||||
!!compose.get('spoiler_text') ||
|
||||
!!compose.get('poll') ||
|
||||
(compose.get('media_attachments') as unknown as Immutable.List<unknown>)
|
||||
.size > 0;
|
||||
.size > 0 ||
|
||||
Number(compose.get('pending_media_attachments')) > 0;
|
||||
|
||||
if (!isChanged) {
|
||||
dispatch(resetComposer());
|
||||
|
||||
Reference in New Issue
Block a user