Composer redesign: Text editing improvements (#40150)

This commit is contained in:
Echo
2026-08-14 09:15:25 +00:00
committed by GitHub
parent 91630a87c7
commit cebb47f7f7
11 changed files with 175 additions and 61 deletions

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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'

View File

@@ -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);
}
}

View File

@@ -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}
/>
);
};

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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>

View File

@@ -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:",

View File

@@ -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());