mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-12 20:56:56 -05:00
Composer redesign: Autocomplete (#40276)
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
import { accountFactoryImmutable } from '@/testing/factories';
|
||||
|
||||
import { TextArea } from '../form_fields';
|
||||
import menuClasses from '../menu/styles.module.scss';
|
||||
|
||||
import { useAutosuggestMenu } from './hooks';
|
||||
import { AutosuggestItem } from './items';
|
||||
import { AutosuggestMenu } from './list';
|
||||
import type {
|
||||
AccountSuggestion,
|
||||
EmojiSuggestion,
|
||||
HashtagSuggestion,
|
||||
Suggestion,
|
||||
} from './types';
|
||||
|
||||
type SuggestTypes = Suggestion['type'];
|
||||
|
||||
const suggestionsMap = {
|
||||
account: [{ type: 'account', id: '1' }] satisfies AccountSuggestion[],
|
||||
emoji: [
|
||||
{ type: 'emoji', id: '+1', native: '👍' },
|
||||
{ type: 'emoji', id: '-1', native: '👎' },
|
||||
{ type: 'emoji', id: 'smile', native: '🙂' },
|
||||
] satisfies EmojiSuggestion[],
|
||||
hashtag: [
|
||||
{ type: 'hashtag', name: 'Testing', id: '1', totalUses: 0 },
|
||||
{ type: 'hashtag', name: 'Test', id: '2', totalUses: 10 },
|
||||
] satisfies HashtagSuggestion[],
|
||||
} satisfies Record<SuggestTypes, Suggestion[]>;
|
||||
|
||||
const fetchCb = fn().mockName('fetching token');
|
||||
const selectCb = fn().mockName('selected item');
|
||||
const clearCb = fn().mockName('cleared suggestions');
|
||||
|
||||
const meta = {
|
||||
title: 'Redesign/Autosuggest',
|
||||
render() {
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const { onTextChange, suggestProps, sourceProps } = useAutosuggestMenu({
|
||||
suggestions,
|
||||
onSelect: selectCb,
|
||||
onFetch(token) {
|
||||
const newSuggestions = tokenToSuggestions(token);
|
||||
|
||||
fetchCb(token, newSuggestions);
|
||||
setSuggestions(newSuggestions);
|
||||
},
|
||||
onClear() {
|
||||
clearCb();
|
||||
setSuggestions([]);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TextArea {...sourceProps} onChange={onTextChange} ref={textareaRef} />
|
||||
<AutosuggestMenu
|
||||
{...suggestProps}
|
||||
reference={textareaRef.current}
|
||||
maxWidth={200}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
state: {
|
||||
accounts: {
|
||||
'1': accountFactoryImmutable(),
|
||||
},
|
||||
},
|
||||
redesign: true,
|
||||
},
|
||||
} satisfies Meta;
|
||||
|
||||
function tokenToSuggestions(token: string) {
|
||||
let suggestions: Suggestion[] = [];
|
||||
switch (token.charAt(0)) {
|
||||
case '@':
|
||||
suggestions = suggestionsMap.account;
|
||||
break;
|
||||
case ':':
|
||||
suggestions = suggestionsMap.emoji;
|
||||
break;
|
||||
case '#':
|
||||
suggestions = suggestionsMap.hashtag;
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Textarea: Story = {};
|
||||
|
||||
export const Static: Story = {
|
||||
render() {
|
||||
return (
|
||||
<div className={menuClasses.card} style={{ width: '300px' }}>
|
||||
{Object.values(suggestionsMap)
|
||||
.flat()
|
||||
.map((suggestion) => (
|
||||
<AutosuggestItem
|
||||
suggestion={suggestion}
|
||||
key={suggestion.id}
|
||||
className={menuClasses.item}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
233
app/javascript/mastodon/components/autosuggest/hooks.tsx
Normal file
233
app/javascript/mastodon/components/autosuggest/hooks.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { Simplify } from 'type-fest';
|
||||
import { useThrottledCallback } from 'use-debounce';
|
||||
|
||||
import { getAllMenuItems } from '../menu';
|
||||
|
||||
import type { AutosuggestMenuProps } from './list';
|
||||
import classes from './styles.module.scss';
|
||||
import type { AutosuggestSourceElements, Source, Suggestion } from './types';
|
||||
import { sourceToElement, textAtCursorMatchesToken } from './utils';
|
||||
|
||||
export type OnSuggestionSelect = (
|
||||
start: number,
|
||||
token: string,
|
||||
suggestion: Suggestion,
|
||||
) => void;
|
||||
|
||||
interface UseAutosuggestMenuOptions {
|
||||
suggestions: Suggestion[];
|
||||
onSelect: OnSuggestionSelect;
|
||||
onFetch?: (token: string) => void;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
type SourceProps = React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<AutosuggestSourceElements>,
|
||||
AutosuggestSourceElements
|
||||
>;
|
||||
|
||||
interface UseAutosuggestReturn {
|
||||
onTextChange: React.ChangeEventHandler<AutosuggestSourceElements>;
|
||||
focus: (event?: React.SyntheticEvent) => void;
|
||||
getToken: () => { token: string | null; startPosition: number };
|
||||
|
||||
mirror?: React.JSX.Element;
|
||||
|
||||
suggestProps: Simplify<Omit<AutosuggestMenuProps, 'children'>>;
|
||||
|
||||
sourceProps: Simplify<
|
||||
Pick<SourceProps, 'onSelect' | 'onScroll' | 'aria-autocomplete'>
|
||||
>;
|
||||
}
|
||||
|
||||
export function useAutosuggestMenu({
|
||||
suggestions,
|
||||
onSelect,
|
||||
onFetch,
|
||||
onClear,
|
||||
}: UseAutosuggestMenuOptions): UseAutosuggestReturn {
|
||||
const lastTokenRef = useRef<string | null>(null); // The last suggestion token encountered.
|
||||
const tokenStartRef = useRef(0); // Character location of the token start.
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onTextChange: React.ChangeEventHandler<AutosuggestSourceElements> =
|
||||
useCallback(
|
||||
(event) => {
|
||||
// Detect a token, and if so fetch suggestions, or dismiss them if not.
|
||||
const [tokenStart, token] = textAtCursorMatchesToken(
|
||||
event.target.value,
|
||||
event.target.selectionStart ?? 0,
|
||||
['@', '@', ':', '#', '#'],
|
||||
);
|
||||
|
||||
if (token !== null && lastTokenRef.current !== token) {
|
||||
tokenStartRef.current = tokenStart;
|
||||
lastTokenRef.current = token;
|
||||
onFetch?.(token);
|
||||
} else if (token === null) {
|
||||
lastTokenRef.current = null;
|
||||
onClear?.();
|
||||
}
|
||||
},
|
||||
[onClear, onFetch],
|
||||
);
|
||||
|
||||
const focus = useCallback(
|
||||
(event?: React.SyntheticEvent) => {
|
||||
if (suggestions.length > 0 && listRef.current) {
|
||||
event?.preventDefault();
|
||||
(getAllMenuItems(listRef.current).at(0) ?? listRef.current).focus();
|
||||
}
|
||||
},
|
||||
[suggestions.length],
|
||||
);
|
||||
|
||||
const getToken = useCallback(
|
||||
() => ({
|
||||
token: lastTokenRef.current,
|
||||
startPosition: tokenStartRef.current,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const tokenCb = useCallback(() => lastTokenRef.current, []);
|
||||
|
||||
const onSuggestionClick: React.MouseEventHandler<HTMLButtonElement> =
|
||||
useCallback(
|
||||
(event) => {
|
||||
const { id, index, type } = event.currentTarget.dataset;
|
||||
const suggestion = suggestions.find((suggestion, i) =>
|
||||
suggestion.type === type && suggestion.id
|
||||
? suggestion.id === id
|
||||
: index && Number.parseInt(index) === i,
|
||||
);
|
||||
if (!suggestion || !lastTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(tokenStartRef.current, lastTokenRef.current, suggestion);
|
||||
},
|
||||
[onSelect, suggestions],
|
||||
);
|
||||
|
||||
return {
|
||||
// Used by the parent.
|
||||
onTextChange,
|
||||
focus,
|
||||
getToken,
|
||||
|
||||
// For the component below.
|
||||
suggestProps: {
|
||||
suggestions,
|
||||
onSuggestionClick,
|
||||
listRef,
|
||||
tokenCb,
|
||||
} satisfies AutosuggestMenuProps,
|
||||
|
||||
sourceProps: {
|
||||
'aria-autocomplete': 'list',
|
||||
} satisfies SourceProps,
|
||||
};
|
||||
}
|
||||
|
||||
interface UseAutosuggestFloatingMenuOptions extends UseAutosuggestMenuOptions {
|
||||
text?: string;
|
||||
sourceRef: Source;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function useAutosuggestFloatingMenu({
|
||||
text,
|
||||
sourceRef,
|
||||
className,
|
||||
...suggestOptions
|
||||
}: UseAutosuggestFloatingMenuOptions): UseAutosuggestReturn {
|
||||
const [mirrorElement, setMirrorElement] = useState<HTMLElement | null>(null); // Reference to the mirror element.
|
||||
const [selectedText, setSelectedText] = useState(text ?? ''); // The actual selected text inside the mirror.
|
||||
const updatePopover = useRef<() => void>(null); // Reference to the popover update callback.
|
||||
|
||||
const { getToken, ...autosuggestProps } = useAutosuggestMenu(suggestOptions);
|
||||
|
||||
const { onClear } = suggestOptions;
|
||||
|
||||
const source = sourceToElement(sourceRef);
|
||||
|
||||
// Update the popover on scroll or select.
|
||||
const onUpdate = useCallback(() => {
|
||||
// Call the popover update callback. This enables the popover to adjust position with scroll.
|
||||
updatePopover.current?.();
|
||||
|
||||
if (source && mirrorElement) {
|
||||
// Set top to scroll offset so bottom edge looks right.
|
||||
mirrorElement.style.setProperty('top', `${-1 * source.scrollTop}px`);
|
||||
|
||||
const { height } = source.getBoundingClientRect();
|
||||
const offset = mirrorElement.offsetHeight - source.scrollTop;
|
||||
if (offset < 0 || offset > height) {
|
||||
onClear?.();
|
||||
}
|
||||
}
|
||||
}, [mirrorElement, onClear, source, updatePopover]);
|
||||
|
||||
// When the caret or selection changes, update the selected text and clear the composer if it doesn't include a token.
|
||||
const onSelect: React.ReactEventHandler<AutosuggestSourceElements> =
|
||||
useCallback(
|
||||
(event) => {
|
||||
const { token, startPosition } = getToken();
|
||||
if (token === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate();
|
||||
|
||||
const { selectionStart: rawStart, value } = event.currentTarget;
|
||||
const selectionStart = rawStart ?? 0;
|
||||
const tokenEnd = startPosition + token.length;
|
||||
setSelectedText(value.slice(0, tokenEnd)); // Only set the text up to the selected end point.
|
||||
|
||||
if (selectionStart < startPosition || selectionStart > tokenEnd) {
|
||||
onClear?.();
|
||||
}
|
||||
},
|
||||
[getToken, onClear, onUpdate],
|
||||
);
|
||||
|
||||
const onScroll = useThrottledCallback(onUpdate, 0);
|
||||
const updatePopoverCb = useCallback((update: () => void) => {
|
||||
updatePopover.current = () => update;
|
||||
}, []);
|
||||
|
||||
const mirror = (
|
||||
<div
|
||||
className={classNames(
|
||||
classes.mirror,
|
||||
source instanceof HTMLTextAreaElement && classes.textAreaMirror,
|
||||
className,
|
||||
)}
|
||||
ref={setMirrorElement}
|
||||
>
|
||||
{selectedText}
|
||||
</div>
|
||||
);
|
||||
|
||||
return {
|
||||
mirror,
|
||||
getToken,
|
||||
...autosuggestProps,
|
||||
|
||||
sourceProps: {
|
||||
...autosuggestProps.sourceProps,
|
||||
onScroll,
|
||||
onSelect,
|
||||
} satisfies SourceProps,
|
||||
|
||||
suggestProps: {
|
||||
...autosuggestProps.suggestProps,
|
||||
reference: mirrorElement,
|
||||
updatePopoverCb,
|
||||
} satisfies AutosuggestMenuProps,
|
||||
};
|
||||
}
|
||||
97
app/javascript/mastodon/components/autosuggest/items.tsx
Normal file
97
app/javascript/mastodon/components/autosuggest/items.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import type React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { selectPlainAccount } from '@/mastodon/selectors/accounts';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { Avatar } from '../avatar';
|
||||
import { DisplayName } from '../display_name';
|
||||
import { Emoji } from '../emoji';
|
||||
import { MenuItem } from '../menu';
|
||||
import { ShortNumber } from '../short_number';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
import type {
|
||||
EmojiSuggestion,
|
||||
HashtagSuggestion,
|
||||
LocalHashtagSuggestion,
|
||||
Suggestion,
|
||||
} from './types';
|
||||
|
||||
export const AutosuggestItem: React.FC<
|
||||
{
|
||||
suggestion: Suggestion;
|
||||
className?: string;
|
||||
} & React.ComponentPropsWithoutRef<'button'>
|
||||
> = ({ suggestion, className, ...props }) => {
|
||||
let suggestComp: React.ReactNode = null;
|
||||
if (suggestion.type === 'account') {
|
||||
suggestComp = <AutosuggestAccount {...suggestion} />;
|
||||
} else if (suggestion.type === 'hashtag') {
|
||||
suggestComp = <AutosuggestHashtag {...suggestion} />;
|
||||
} else {
|
||||
suggestComp = <AutosuggestEmoji {...suggestion} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
{...props}
|
||||
className={classNames(classes.item, className)}
|
||||
data-id={suggestion.id}
|
||||
data-type={suggestion.type}
|
||||
>
|
||||
{suggestComp}
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const AutosuggestAccount: React.FC<{ id: string }> = ({ id }) => {
|
||||
const account = useAppSelector((state) => selectPlainAccount(state, id));
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Avatar account={account} className={classes.itemIcon} size={32} />
|
||||
<div>
|
||||
<DisplayName
|
||||
account={account}
|
||||
variant='noDomain'
|
||||
className={classes.itemAccountName}
|
||||
/>
|
||||
<span className={classes.itemAccountHandle}>{account.acct}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AutosuggestEmoji: React.FC<EmojiSuggestion> = ({ id, native }) => {
|
||||
const colons = `:${id}:`;
|
||||
return (
|
||||
<>
|
||||
<span className={classes.itemIcon}>
|
||||
<Emoji code={native ?? colons} />
|
||||
</span>
|
||||
|
||||
<span>{colons}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AutosuggestHashtag: React.FC<
|
||||
HashtagSuggestion | LocalHashtagSuggestion
|
||||
> = ({ name, ...props }) => {
|
||||
return (
|
||||
<>
|
||||
#{name}
|
||||
{'totalUses' in props ? (
|
||||
<span className={classes.itemHashUses}>
|
||||
<ShortNumber value={props.totalUses} />
|
||||
</span>
|
||||
) : null}{' '}
|
||||
</>
|
||||
);
|
||||
};
|
||||
114
app/javascript/mastodon/components/autosuggest/list.tsx
Normal file
114
app/javascript/mastodon/components/autosuggest/list.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useMergedRefs } from '@/mastodon/hooks/useMergedRefs';
|
||||
import { usePrevious } from '@/mastodon/hooks/usePrevious';
|
||||
|
||||
import { LocalCustomEmojiProvider } from '../emoji/context';
|
||||
import { Menu, useMenuContext } from '../menu';
|
||||
import { MenuCard } from '../menu/card';
|
||||
import { Popover } from '../popover';
|
||||
|
||||
import { AutosuggestItem } from './items';
|
||||
import classes from './styles.module.scss';
|
||||
import type { Suggestion } from './types';
|
||||
|
||||
export interface AutosuggestMenuProps {
|
||||
suggestions: Suggestion[];
|
||||
tokenCb: () => string | null;
|
||||
onSuggestionClick: React.MouseEventHandler;
|
||||
children?: React.ReactNode;
|
||||
listRef?: React.Ref<HTMLDivElement>;
|
||||
reference?: HTMLElement | null;
|
||||
updatePopoverCb?: (update: () => void) => void;
|
||||
maxWidth?: number | string;
|
||||
}
|
||||
|
||||
export const AutosuggestMenu: React.FC<AutosuggestMenuProps> = ({
|
||||
suggestions,
|
||||
onSuggestionClick,
|
||||
children,
|
||||
...menuProps
|
||||
}) => {
|
||||
return (
|
||||
<Menu noFocus>
|
||||
{suggestions.length > 0 && (
|
||||
<AutosuggestMenuList {...menuProps} suggestions={suggestions}>
|
||||
{children ??
|
||||
suggestions.map((suggestion, index) => (
|
||||
<AutosuggestItem
|
||||
key={`${suggestion.type}:${suggestion.id}`}
|
||||
onClick={onSuggestionClick}
|
||||
suggestion={suggestion}
|
||||
data-index={index}
|
||||
/>
|
||||
))}
|
||||
</AutosuggestMenuList>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
type AutosuggestMenuListProps = Omit<
|
||||
AutosuggestMenuProps,
|
||||
'onSuggestionClick'
|
||||
> & {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const AutosuggestMenuList: React.FC<AutosuggestMenuListProps> = ({
|
||||
children,
|
||||
listRef,
|
||||
tokenCb,
|
||||
suggestions,
|
||||
reference,
|
||||
updatePopoverCb,
|
||||
maxWidth,
|
||||
}) => {
|
||||
const token = tokenCb();
|
||||
const lastToken = usePrevious(token);
|
||||
|
||||
const { popover, menuListProps } = useMenuContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!popover.isMenuOpen && token !== lastToken && suggestions.length > 0) {
|
||||
popover.openMenu();
|
||||
}
|
||||
}, [lastToken, popover, suggestions.length, token]);
|
||||
|
||||
const mergedRef = useMergedRefs(menuListProps.ref, listRef);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
isOpen={popover.isMenuOpen}
|
||||
onClose={popover.closeMenu}
|
||||
reference={reference ?? null}
|
||||
popoverElement={popover.popover}
|
||||
container={null}
|
||||
placement='bottom-start'
|
||||
>
|
||||
{({ props: popoverChildProps, update }) => {
|
||||
updatePopoverCb?.(update);
|
||||
|
||||
return (
|
||||
<MenuCard
|
||||
{...popoverChildProps}
|
||||
{...menuListProps}
|
||||
style={
|
||||
{
|
||||
'--_max-card-width':
|
||||
typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth,
|
||||
...popoverChildProps.style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
ref={mergedRef}
|
||||
className={classNames(maxWidth && classes.menuWidth)}
|
||||
>
|
||||
<LocalCustomEmojiProvider>{children}</LocalCustomEmojiProvider>
|
||||
</MenuCard>
|
||||
);
|
||||
}}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
@use '@/styles/mastodon/mixins';
|
||||
|
||||
.mirror {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.textAreaMirror {
|
||||
white-space: pre-wrap; // This makes the formatting match the text area.
|
||||
}
|
||||
|
||||
.debug.mirror {
|
||||
visibility: visible;
|
||||
opacity: 0.2;
|
||||
background-color: red;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.menuWidth {
|
||||
width: 100%;
|
||||
max-width: var(--_max-card-width);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
min-height: var(--space-3xl);
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
width: var(--space-3xl);
|
||||
height: var(--space-3xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-round);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-highlight);
|
||||
}
|
||||
|
||||
.itemHashUses {
|
||||
color: var(--color-text-tertiary);
|
||||
margin-inline-start: auto;
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.itemAccountName {
|
||||
@include mixins.type-label-lg;
|
||||
}
|
||||
|
||||
.itemAccountHandle {
|
||||
@include mixins.type-label-sm;
|
||||
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
35
app/javascript/mastodon/components/autosuggest/types.ts
Normal file
35
app/javascript/mastodon/components/autosuggest/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export interface AccountSuggestion {
|
||||
type: 'account';
|
||||
id: string;
|
||||
}
|
||||
export interface EmojiSuggestion {
|
||||
type: 'emoji';
|
||||
id: string;
|
||||
custom?: boolean;
|
||||
native?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
export interface LocalHashtagSuggestion {
|
||||
type: 'hashtag';
|
||||
id?: string;
|
||||
name: string;
|
||||
}
|
||||
export interface HashtagSuggestion {
|
||||
type: 'hashtag';
|
||||
id: string;
|
||||
name: string;
|
||||
totalUses: number;
|
||||
}
|
||||
|
||||
export type Suggestion =
|
||||
| AccountSuggestion
|
||||
| EmojiSuggestion
|
||||
| LocalHashtagSuggestion
|
||||
| HashtagSuggestion;
|
||||
|
||||
export type AutosuggestSourceElements = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
export type Source =
|
||||
| React.RefObject<AutosuggestSourceElements | null>
|
||||
| AutosuggestSourceElements
|
||||
| null;
|
||||
@@ -1,10 +1,15 @@
|
||||
import { isRecordObject } from '@/mastodon/utils/objects';
|
||||
import { stringOrUndefined } from '@/mastodon/utils/strings';
|
||||
|
||||
import { WORD } from '../../utils/hashtags';
|
||||
|
||||
export const textAtCursorMatchesToken = (
|
||||
import type { Source, Suggestion } from './types';
|
||||
|
||||
export function textAtCursorMatchesToken(
|
||||
str: string,
|
||||
caretPosition: number,
|
||||
searchTokens: string[],
|
||||
) => {
|
||||
) {
|
||||
let word: string;
|
||||
|
||||
const regex = new RegExp(
|
||||
@@ -31,4 +36,72 @@ export const textAtCursorMatchesToken = (
|
||||
} else {
|
||||
return [null, null] as const;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function immutableListToSuggestions(list: Immutable.List<unknown>) {
|
||||
const suggestions: Suggestion[] = [];
|
||||
const hashtagSet = new Set<string>();
|
||||
|
||||
let fakeId = 0;
|
||||
|
||||
for (const suggestion of list.toArray()) {
|
||||
if (!isRecordObject(suggestion)) {
|
||||
continue;
|
||||
}
|
||||
const type = suggestion.type;
|
||||
const id = stringOrUndefined(suggestion.id) ?? `fake-${fakeId++}`; // Fake ID so we don't have React key issues.
|
||||
|
||||
switch (type) {
|
||||
case 'account':
|
||||
suggestions.push({
|
||||
type,
|
||||
id,
|
||||
});
|
||||
break;
|
||||
case 'emoji':
|
||||
suggestions.push({
|
||||
type,
|
||||
id,
|
||||
custom: !!suggestion.custom,
|
||||
native: stringOrUndefined(suggestion.native),
|
||||
imageUrl: stringOrUndefined(suggestion.imageUrl),
|
||||
});
|
||||
break;
|
||||
case 'hashtag': {
|
||||
const name = stringOrUndefined(suggestion.name);
|
||||
if (!name || hashtagSet.has(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
hashtagSet.add(name);
|
||||
|
||||
suggestions.push({
|
||||
type,
|
||||
name,
|
||||
id,
|
||||
totalUses: tagHistoryToUses(suggestion.history),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
export function tagHistoryToUses(history: unknown) {
|
||||
if (!Array.isArray(history)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return history.reduce<number>(
|
||||
(total, current) =>
|
||||
isRecordObject(current) && typeof current.uses === 'number'
|
||||
? total + current.uses
|
||||
: total,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export function sourceToElement(source: Source) {
|
||||
return !source || source instanceof HTMLElement ? source : source.current;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { length } from 'stringz';
|
||||
|
||||
import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments';
|
||||
import { immutableListToSuggestions } from '@/mastodon/components/autosuggest/utils';
|
||||
import type { StatusVisibility } from '@/mastodon/models/status';
|
||||
import type { ComposeType } from '@/mastodon/reducers/slices/composer';
|
||||
import { createAppSelector } from '@/mastodon/store';
|
||||
@@ -239,3 +240,11 @@ export const selectComposePoll = createAppSelector(
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const selectSuggestions = createAppSelector(
|
||||
[
|
||||
(state) =>
|
||||
state.compose.get('suggestions') as unknown as Immutable.List<unknown>,
|
||||
],
|
||||
(list) => immutableListToSuggestions(list),
|
||||
);
|
||||
|
||||
@@ -114,27 +114,50 @@
|
||||
|
||||
.textareaWrapper {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.textarea,
|
||||
.textareaMirror {
|
||||
@include mixins.type-body-lg;
|
||||
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
flex-grow: 1;
|
||||
border-radius: var(--radius-xs);
|
||||
transition: border 200ms;
|
||||
cursor: text;
|
||||
border: none;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
|
||||
&:focus-within {
|
||||
&:focus {
|
||||
outline: 2px solid var(--color-border-brand);
|
||||
outline-offset: -2px;
|
||||
|
||||
textarea::placeholder {
|
||||
&::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
@include mixins.type-body-lg;
|
||||
textarea.textarea {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
border: none;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
.textareaMirror {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
white-space: pre-wrap; // This makes the formatting match the text area.
|
||||
}
|
||||
|
||||
.attachments {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import type React from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
|
||||
|
||||
import {
|
||||
changeCompose,
|
||||
fetchComposeSuggestions,
|
||||
@@ -14,15 +11,22 @@ import {
|
||||
selectComposeSuggestion,
|
||||
} from '@/mastodon/actions/compose';
|
||||
import { processPasteOrDrop } from '@/mastodon/actions/compose_typed';
|
||||
import AutosuggestTextareaOriginal from '@/mastodon/components/autosuggest_textarea';
|
||||
import { COMPOSER_TEXTAREA_ID } from '@/mastodon/reducers/slices/composer';
|
||||
import type { OnSuggestionSelect } from '@/mastodon/components/autosuggest/hooks';
|
||||
import { useAutosuggestFloatingMenu } from '@/mastodon/components/autosuggest/hooks';
|
||||
import { AutosuggestMenu } from '@/mastodon/components/autosuggest/list';
|
||||
import { TextArea } from '@/mastodon/components/form_fields';
|
||||
import { normalizeKey } from '@/mastodon/components/hotkeys/utils';
|
||||
import {
|
||||
COMPOSER_TEXTAREA_ID,
|
||||
focusComposerTextarea,
|
||||
} from '@/mastodon/reducers/slices/composer';
|
||||
import {
|
||||
createAppSelector,
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
} from '@/mastodon/store';
|
||||
|
||||
import { selectComposeType } from './selectors';
|
||||
import { selectComposeType, selectSuggestions } from './selectors';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -38,25 +42,8 @@ const messages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
type SuggestSelectedHandler = (
|
||||
position: number,
|
||||
token: string,
|
||||
suggestion: unknown,
|
||||
) => void;
|
||||
|
||||
const AutosuggestTextarea =
|
||||
AutosuggestTextareaOriginal as React.ForwardRefExoticComponent<
|
||||
{
|
||||
suggestions: Immutable.List<unknown>;
|
||||
onSuggestionSelected: SuggestSelectedHandler;
|
||||
onSuggestionsClearRequested: () => void;
|
||||
onSuggestionsFetchRequested: (token: string) => void;
|
||||
} & TextareaAutosizeProps &
|
||||
React.RefAttributes<HTMLTextAreaElement>
|
||||
>;
|
||||
|
||||
type ComposeTextareaProps = Omit<
|
||||
TextareaAutosizeProps,
|
||||
React.ComponentPropsWithoutRef<'textarea'>,
|
||||
| 'placeholder'
|
||||
| 'onFocus'
|
||||
| 'onBlur'
|
||||
@@ -71,9 +58,6 @@ const selectComposeTextState = createAppSelector(
|
||||
(compose) => ({
|
||||
text: compose.get('text') as string,
|
||||
lang: compose.get('language') as string,
|
||||
suggestions: compose.get(
|
||||
'suggestions',
|
||||
) as unknown as Immutable.List<unknown>,
|
||||
isSubmitting: !!compose.get('is_submitting'),
|
||||
}),
|
||||
);
|
||||
@@ -86,38 +70,78 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
// Selectors
|
||||
const type = useAppSelector(selectComposeType);
|
||||
const { suggestions, text, lang, isSubmitting } = useAppSelector(
|
||||
selectComposeTextState,
|
||||
);
|
||||
|
||||
const { text, lang, isSubmitting } = useAppSelector(selectComposeTextState);
|
||||
const dispatch = useAppDispatch();
|
||||
const onClickWrapper: React.MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
(event) => {
|
||||
if (event.target instanceof HTMLDivElement) {
|
||||
event.target.querySelector('textarea')?.focus();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const onChange: React.ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
||||
(event) => {
|
||||
dispatch(changeCompose(event.target.value));
|
||||
|
||||
// Suggestion logic
|
||||
const onSuggestionFetch = useCallback(
|
||||
(token: string) => {
|
||||
dispatch(fetchComposeSuggestions(token));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const onSuggestion: OnSuggestionSelect = useCallback(
|
||||
(tokenStart, token, suggestion) => {
|
||||
dispatch(
|
||||
selectComposeSuggestion(tokenStart, token, suggestion, ['text']),
|
||||
);
|
||||
focusComposerTextarea(true);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const onSuggestionClear = useCallback(() => {
|
||||
dispatch(clearComposeSuggestions());
|
||||
}, [dispatch]);
|
||||
|
||||
const suggestions = useAppSelector(selectSuggestions);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const { onTextChange, focus, mirror, sourceProps, suggestProps } =
|
||||
useAutosuggestFloatingMenu({
|
||||
suggestions,
|
||||
text,
|
||||
className: classes.textareaMirror,
|
||||
sourceRef: textAreaRef,
|
||||
onSelect: onSuggestion,
|
||||
onFetch: onSuggestionFetch,
|
||||
onClear: onSuggestionClear,
|
||||
});
|
||||
|
||||
// Update the composer text and trigger suggestions.
|
||||
const onChange: React.ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
||||
(event) => {
|
||||
dispatch(changeCompose(event.target.value));
|
||||
onTextChange(event);
|
||||
},
|
||||
[dispatch, onTextChange],
|
||||
);
|
||||
|
||||
const onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> =
|
||||
useCallback(
|
||||
(event) => {
|
||||
const key = event.key.toLowerCase();
|
||||
const key = normalizeKey(event.key);
|
||||
|
||||
if (key === 'enter' && (event.ctrlKey || event.metaKey)) {
|
||||
onSubmit();
|
||||
event.preventDefault();
|
||||
} else if (['esc', 'escape'].includes(key)) {
|
||||
event.currentTarget.blur();
|
||||
onSuggestionClear();
|
||||
} else if (key === 'escape') {
|
||||
// Dismiss the suggestions if we're displaying any.
|
||||
if (suggestions.length > 0) {
|
||||
onSuggestionClear();
|
||||
} else {
|
||||
// Otherwise lose focus on the textarea.
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
} else if (key === 'down') {
|
||||
focus(event);
|
||||
}
|
||||
},
|
||||
[onSubmit],
|
||||
[onSubmit, onSuggestionClear, suggestions.length, focus],
|
||||
);
|
||||
|
||||
const onPasteOrDrop = useCallback(
|
||||
@@ -131,34 +155,15 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const onSuggestionsFetchRequested = useCallback(
|
||||
(token: string) => {
|
||||
dispatch(fetchComposeSuggestions(token));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const onSuggestionsClearRequested = useCallback(() => {
|
||||
dispatch(clearComposeSuggestions());
|
||||
}, [dispatch]);
|
||||
const onSuggestionSelected: SuggestSelectedHandler = useCallback(
|
||||
(position, token, suggestion) => {
|
||||
dispatch(selectComposeSuggestion(position, token, suggestion, ['text']));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- This just moves focus to the textarea.
|
||||
<div
|
||||
onClick={onClickWrapper}
|
||||
className={classNames(className, classes.textareaWrapper)}
|
||||
>
|
||||
<AutosuggestTextarea
|
||||
<div className={classes.textareaWrapper}>
|
||||
<TextArea
|
||||
{...props}
|
||||
dir='auto'
|
||||
id={COMPOSER_TEXTAREA_ID}
|
||||
ref={textareaRef}
|
||||
className={classNames(className, classes.textarea)}
|
||||
ref={textAreaRef}
|
||||
value={text}
|
||||
lang={lang}
|
||||
placeholder={intl.formatMessage(
|
||||
@@ -167,15 +172,16 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
|
||||
: messages.placeholder,
|
||||
)}
|
||||
disabled={disabled || isSubmitting}
|
||||
suggestions={suggestions}
|
||||
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||
onSuggestionSelected={onSuggestionSelected}
|
||||
onKeyDown={onKeyDown}
|
||||
onDrop={onPasteOrDrop}
|
||||
onPaste={onPasteOrDrop}
|
||||
onChange={onChange}
|
||||
{...sourceProps}
|
||||
/>
|
||||
|
||||
{mirror}
|
||||
|
||||
<AutosuggestMenu {...suggestProps} maxWidth={280} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user