mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-12 23:35:59 -05:00
Composer redesign: useScrollSensor (#40287)
This commit is contained in:
@@ -46,7 +46,7 @@ interface MenuTriggerContextProps {
|
||||
}
|
||||
|
||||
interface MenuListContextProps {
|
||||
ref: (button: HTMLDivElement | null) => void;
|
||||
ref: (list: HTMLDivElement | null) => void;
|
||||
role?: 'menu'; // only for menus of type === 'actions'
|
||||
tabIndex: -1;
|
||||
id: string;
|
||||
@@ -75,7 +75,7 @@ export function useMenuContext(): MenuState {
|
||||
return context;
|
||||
}
|
||||
|
||||
function getAllMenuItems(menuListElement: HTMLDivElement) {
|
||||
export function getAllMenuItems(menuListElement: HTMLDivElement) {
|
||||
return Array.from(
|
||||
menuListElement.querySelectorAll<HTMLElement>(
|
||||
':scope [data-menu-item]:not([disabled])',
|
||||
@@ -91,9 +91,14 @@ interface MenuProps {
|
||||
*/
|
||||
type?: MenuType;
|
||||
children: React.ReactNode;
|
||||
noFocus?: boolean;
|
||||
}
|
||||
|
||||
export const Menu: React.FC<MenuProps> = ({ type = 'actions', children }) => {
|
||||
export const Menu: React.FC<MenuProps> = ({
|
||||
type = 'actions',
|
||||
children,
|
||||
noFocus,
|
||||
}) => {
|
||||
const id = useId();
|
||||
const triggerId = `${id}-trigger`;
|
||||
const listId = `${id}-list`;
|
||||
@@ -105,13 +110,13 @@ export const Menu: React.FC<MenuProps> = ({ type = 'actions', children }) => {
|
||||
(element: HTMLDivElement | null) => {
|
||||
setListElement(element);
|
||||
|
||||
if (element && type === 'actions') {
|
||||
if (element && type === 'actions' && !noFocus) {
|
||||
const menuItems = getAllMenuItems(element);
|
||||
const elementToFocus = menuItems[0] ?? element;
|
||||
elementToFocus.focus();
|
||||
}
|
||||
},
|
||||
[type],
|
||||
[noFocus, type],
|
||||
);
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
} from './selectors';
|
||||
import { ComposeUpload } from './upload';
|
||||
|
||||
export const ComposeAttachments: React.FC = () => {
|
||||
export const ComposeAttachments: React.FC<{ className?: string }> = ({
|
||||
className,
|
||||
}) => {
|
||||
const { hasPoll, hasAttachments, quotedStatusId } = useAppSelector(
|
||||
selectComposeHasAttachments,
|
||||
);
|
||||
@@ -20,11 +22,11 @@ export const ComposeAttachments: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={className}>
|
||||
{hasPoll && <ComposePoll />}
|
||||
{hasAttachments && <ComposeMediaAttachments />}
|
||||
{quotedStatusId && <ComposeQuotedStatus id={quotedStatusId} />}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -64,7 +64,8 @@ export const ComposeHints = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (isDifferentLanguage) {
|
||||
const { wasDismissed } = useDismissible('compose_language_hint');
|
||||
if (isDifferentLanguage && !wasDismissed) {
|
||||
messages.push(<LanguageHint guess={guess} key='different-language' />);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ export function useLanguages() {
|
||||
}
|
||||
|
||||
export function useLanguageGuess() {
|
||||
const text = useAppSelector((state) => state.compose.get('text') as string);
|
||||
const text = useAppSelector((state) =>
|
||||
(state.compose.get('text') as string).trim(),
|
||||
);
|
||||
const [guess, setGuess] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { LockSimpleOpenIcon } from '@phosphor-icons/react';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import {
|
||||
changeComposeSpoilerness,
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
TextInputField,
|
||||
} from '@/mastodon/components/form_fields/redesign';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { useResizeObserver } from '@/mastodon/hooks/useObserver';
|
||||
import { useScrollSensor } from '@/mastodon/hooks/useScrollSensor';
|
||||
import {
|
||||
focusComposerTextarea,
|
||||
getComposerTextarea,
|
||||
@@ -69,18 +68,17 @@ export const RedesignComposeForm: React.FC<RedesignComposeFormProps> = ({
|
||||
const type = useAppSelector(selectComposeType);
|
||||
const { sensitive, sensitiveText } = useAppSelector(selectComposeSensitive);
|
||||
|
||||
const {
|
||||
onSensitiveChange,
|
||||
onSensitiveTextChange,
|
||||
onEmojiPick,
|
||||
onSubmit,
|
||||
onWrapperMount,
|
||||
onWrapperScroll,
|
||||
} = useComposeHandlers(redirectOnSuccess);
|
||||
const { onSensitiveChange, onSensitiveTextChange, onEmojiPick, onSubmit } =
|
||||
useComposeHandlers(redirectOnSuccess);
|
||||
|
||||
const intl = useIntl();
|
||||
const titleId = useId();
|
||||
|
||||
const { sensor, isInViewport } = useScrollSensor({
|
||||
placement: 'bottom',
|
||||
tolerance: 10,
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
role='dialog'
|
||||
@@ -127,18 +125,16 @@ export const RedesignComposeForm: React.FC<RedesignComposeFormProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={onWrapperMount}
|
||||
onScroll={onWrapperScroll}
|
||||
className={classes.editorWrapper}
|
||||
>
|
||||
<div data-scroll-down={!isInViewport} className={classes.editorWrapper}>
|
||||
<ComposeTextarea
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
|
||||
<ComposeAttachments />
|
||||
<ComposeAttachments className={classes.attachments} />
|
||||
|
||||
{sensor}
|
||||
</div>
|
||||
|
||||
<ComposeHints />
|
||||
@@ -209,49 +205,10 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,6 @@
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
scrollbar-width: thin;
|
||||
overscroll-behavior-y: contain;
|
||||
position: relative;
|
||||
@@ -102,13 +101,13 @@
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
flex-shrink: 0;
|
||||
margin-top: calc((-1 * var(--fade-size)) - var(--space-md));
|
||||
margin-top: calc(-1 * var(--fade-size));
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
|
||||
&[data-scroll-down]::after {
|
||||
&[data-scroll-down='true']::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -138,6 +137,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.attachments {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -119,21 +119,15 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
|
||||
},
|
||||
[onSubmit],
|
||||
);
|
||||
const onPaste: React.ClipboardEventHandler = useCallback(
|
||||
(event) => {
|
||||
if (event.clipboardData.files.length === 1) {
|
||||
|
||||
const onPasteOrDrop = useCallback(
|
||||
(event: React.ClipboardEvent | React.DragEvent) => {
|
||||
const data =
|
||||
'clipboardData' in event ? event.clipboardData : event.dataTransfer;
|
||||
if (data.files.length === 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
dispatch(processPasteOrDrop(event.clipboardData));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const onDrop: React.DragEventHandler = useCallback(
|
||||
(event) => {
|
||||
if (event.dataTransfer.files.length === 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
dispatch(processPasteOrDrop(event.dataTransfer));
|
||||
dispatch(processPasteOrDrop(data));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
@@ -178,8 +172,8 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
|
||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||
onSuggestionSelected={onSuggestionSelected}
|
||||
onKeyDown={onKeyDown}
|
||||
onDrop={onDrop}
|
||||
onPaste={onPaste}
|
||||
onDrop={onPasteOrDrop}
|
||||
onPaste={onPasteOrDrop}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export function useMergedRefs<T>(...refs: React.Ref<T>[]) {
|
||||
export function useMergedRefs<T>(...refs: (React.Ref<T> | undefined)[]) {
|
||||
const setRef: React.RefCallback<T> = useCallback(
|
||||
(node: T | null) => {
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
8
app/javascript/mastodon/utils/strings.ts
Normal file
8
app/javascript/mastodon/utils/strings.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Returns the input if it is a string, otherwise returns undefined.
|
||||
* @param input Any input.
|
||||
* @returns The string, or undefined.
|
||||
*/
|
||||
export function stringOrUndefined(input: unknown) {
|
||||
return typeof input === 'string' ? input : undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user