Allow HTML, add language picker

Utilizes code from Mastodon LanguageDropdown: https://github.com/mastodon/mastodon/blob/main/app/javascript/mastodon/features/compose/components/language_dropdown.jsx

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2024-05-15 23:47:11 +02:00
parent 4ceef0fe81
commit 2e43f8967f
11 changed files with 444 additions and 15 deletions

View File

@ -27,6 +27,7 @@ import { $createEmojiNode } from '../editor/nodes/emoji-node';
import { countableText } from '../util/counter';
import ContentTypeButton from './content-type-button';
import LanguageDropdown from './language-dropdown';
import PollButton from './poll-button';
import PollForm from './polls/poll-form';
import PrivacyDropdown from './privacy-dropdown';
@ -242,6 +243,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
if (features.privacyScopes && !group && !groupId) selectButtons.push(<PrivacyDropdown composeId={id} />);
if (features.richText) selectButtons.push(<ContentTypeButton composeId={id} />);
selectButtons.push(<LanguageDropdown composeId={id} />);
return (
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>

View File

@ -0,0 +1,69 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changeComposeContentType } from 'soapbox/actions/compose';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import { Button } from 'soapbox/components/ui';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
const messages = defineMessages({
content_type_plaintext: { id: 'preferences.options.content_type_plaintext', defaultMessage: 'Plain text' },
content_type_markdown: { id: 'preferences.options.content_type_markdown', defaultMessage: 'Markdown' },
content_type_html: { id: 'preferences.options.content_type_html', defaultMessage: 'HTML' },
change_content_type: { id: 'compose_form.content_type.change', defaultMessage: 'Change content type' },
});
interface IContentTypeButton {
composeId: string;
}
const ContentTypeButton: React.FC<IContentTypeButton> = ({ composeId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const contentType = useCompose(composeId).content_type;
const handleChange = (contentType: string) => () => dispatch(changeComposeContentType(composeId, contentType));
const options = [
{
icon: require('@tabler/icons/outline/pilcrow.svg'),
text: intl.formatMessage(messages.content_type_plaintext),
value: 'text/plain',
},
{ icon: require('@tabler/icons/outline/markdown.svg'),
text: intl.formatMessage(messages.content_type_markdown),
value: 'text/markdown',
},
{
icon: require('@tabler/icons/outline/html.svg'),
text: intl.formatMessage(messages.content_type_html),
value: 'text/html',
},
];
const option = options.find(({ value }) => value === contentType);
return (
<DropdownMenu
items={options.map(({ icon, text, value }) => ({
icon,
text,
action: handleChange(value),
active: contentType === value,
}))}
>
<Button
theme='muted'
size='xs'
text={option?.text}
icon={option?.icon}
secondaryIcon={require('@tabler/icons/outline/chevron-down.svg')}
title={intl.formatMessage(messages.change_content_type)}
/>
</DropdownMenu>
);
};
export { ContentTypeButton as default };

View File

@ -0,0 +1,329 @@
import { offset, useFloating, flip, arrow, shift } from '@floating-ui/react';
import clsx from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import fuzzysort from 'fuzzysort';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changeComposeLanguage } from 'soapbox/actions/compose';
import { Button, Icon, Input, Portal } from 'soapbox/components/ui';
import { type Language, languages as languagesObject } from 'soapbox/features/preferences';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const languages = Object.entries(languagesObject) as Array<[Language, string]>;
const messages = defineMessages({
languagePrompt: { id: 'compose.language_dropdown.prompt', defaultMessage: 'Select language' },
search: { id: 'compose.language_dropdown.search', defaultMessage: 'Search language…' },
});
interface ILanguageDropdown {
composeId: string;
}
const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const node = useRef<HTMLDivElement>(null);
const focusedItem = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [searchValue, setSearchValue] = useState('');
const arrowRef = useRef<HTMLDivElement>(null);
const { x, y, strategy, refs, middlewareData, placement } = useFloating<HTMLButtonElement>({
placement: 'top',
middleware: [
offset(12),
flip(),
shift({
padding: 8,
}),
arrow({
element: arrowRef,
}),
],
});
const language = useCompose(composeId).language;
const handleClick: React.EventHandler<
React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>
> = (event) => {
event.stopPropagation();
setIsOpen(!isOpen);
};
const handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (event) => {
switch (event.key) {
case ' ':
case 'Enter':
event.stopPropagation();
event.preventDefault();
handleClick(event);
break;
}
};
const handleChange = (language: Language | null) => dispatch(changeComposeLanguage(composeId, language));
const handleOptionKeyDown: React.KeyboardEventHandler = e => {
const value = e.currentTarget.getAttribute('data-index');
const index = results.findIndex(([key]) => key === value);
let element: ChildNode | null | undefined = null;
switch (e.key) {
case 'Escape':
setIsOpen(false);
break;
case 'Enter':
handleOptionClick(e);
break;
case 'ArrowDown':
element = node.current?.childNodes[index + 1] || node.current?.firstChild;
break;
case 'ArrowUp':
element = node.current?.childNodes[index - 1] || node.current?.lastChild;
break;
case 'Home':
element = node.current?.firstChild;
break;
case 'End':
element = node.current?.lastChild;
break;
}
if (element) {
(element as HTMLElement).focus();
e.preventDefault();
e.stopPropagation();
}
};
const handleOptionClick: React.EventHandler<any> = (e: MouseEvent | KeyboardEvent) => {
const value = (e.currentTarget as HTMLElement)?.getAttribute('data-index') as Language;
e.preventDefault();
setIsOpen(false);
handleChange(value);
};
const handleClear: React.MouseEventHandler = (e) => {
e.preventDefault();
e.stopPropagation();
setSearchValue('');
};
const search = () => {
if (searchValue === '') {
return [...languages].sort((a, b) => {
// Push current selection to the top of the list
if (a[0] === language) {
return -1;
} else if (b[0] === language) {
return 1;
} else {
return 0;
}
});
}
return fuzzysort.go(searchValue, languages, {
keys: ['0', '1'],
limit: 5,
threshold: -10000,
}).map(result => result.obj);
};
const handleDocumentClick = (event: Event) => {
if (refs.floating.current && !refs.floating.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!refs.floating.current) return;
const items = Array.from(refs.floating.current.getElementsByTagName('a'));
const index = items.indexOf(document.activeElement as any);
let element = null;
switch (e.key) {
case 'ArrowDown':
element = items[index + 1] || items[0];
break;
case 'ArrowUp':
element = items[index - 1] || items[items.length - 1];
break;
case 'Tab':
if (e.shiftKey) {
element = items[index - 1] || items[items.length - 1];
} else {
element = items[index + 1] || items[0];
}
break;
case 'Home':
element = items[0];
break;
case 'End':
element = items[items.length - 1];
break;
case 'Escape':
setIsOpen(false);
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
};
const arrowProps: React.CSSProperties = useMemo(() => {
if (middlewareData.arrow) {
const { x, y } = middlewareData.arrow;
const staticPlacement = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[placement.split('-')[0]];
return {
left: x !== null ? `${x}px` : '',
top: y !== null ? `${y}px` : '',
// Ensure the static side gets unset when
// flipping to other placements' axes.
right: '',
bottom: '',
[staticPlacement as string]: `${(-(arrowRef.current?.offsetWidth || 0)) / 2}px`,
transform: 'rotate(45deg)',
};
}
return {};
}, [middlewareData.arrow, placement]);
useEffect(() => {
if (isOpen) {
if (refs.floating.current) {
(refs.floating.current?.querySelector('li a[role=\'button\']') as HTMLAnchorElement)?.focus();
}
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('keydown', handleKeyDown, false);
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
return () => {
document.removeEventListener('click', handleDocumentClick);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('touchend', handleDocumentClick);
};
}
}, [isOpen, refs.floating.current]);
const isSearching = searchValue !== '';
const results = search();
return (
<>
<Button
theme='muted'
size='xs'
text={language ? languagesObject[language] : intl.formatMessage(messages.languagePrompt)}
icon={require('@tabler/icons/outline/language.svg')}
secondaryIcon={require('@tabler/icons/outline/chevron-down.svg')}
title={intl.formatMessage(messages.languagePrompt)}
onClick={handleClick}
onKeyPress={handleKeyPress}
ref={refs.setReference}
/>
{isOpen ? (
<Portal>
<div
id='language-dropdown'
ref={refs.setFloating}
className={clsx('z-[1001] flex flex-col rounded-md bg-white text-sm shadow-lg transition-opacity duration-100 focus:outline-none black:border black:border-gray-800 black:bg-black dark:bg-gray-900 dark:ring-2 dark:ring-primary-700', {
'opacity-0 pointer-events-none': !isOpen,
})}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
}}
role='listbox'
>
<label className='relative grow p-2'>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
<Input
className='w-64'
type='text'
value={searchValue}
onChange={({ target }) => setSearchValue(target.value)}
outerClassName='mt-0'
placeholder={intl.formatMessage(messages.search)}
/>
<div role='button' tabIndex={0} className='absolute inset-y-0 right-0 flex cursor-pointer items-center px-5 rtl:left-0 rtl:right-auto' onClick={handleClear}>
<Icon
className='h-5 w-5 text-gray-600'
src={isSearching ? require('@tabler/icons/outline/backspace.svg') : require('@tabler/icons/outline/search.svg')}
aria-label={intl.formatMessage(messages.search)}
/>
</div>
</label>
<div className='h-96 w-full overflow-scroll' ref={node} tabIndex={-1}>
{results.map(([code, name]) => {
const active = code === language;
return (
<div
role='option'
tabIndex={0}
key={code}
data-index={code}
onKeyDown={handleOptionKeyDown}
onClick={handleOptionClick}
className={clsx(
'flex cursor-pointer p-2.5 text-sm text-gray-700 hover:bg-gray-100 black:hover:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800',
{ 'bg-gray-100 dark:bg-gray-800 black:bg-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700': active },
)}
aria-selected={active}
ref={active ? focusedItem : null}
>
<div
className={clsx('flex-auto text-primary-600 dark:text-primary-400', {
'text-black dark:text-white': active,
})}
>
{name}
</div>
</div>
);
})}
</div>
<div
ref={arrowRef}
style={arrowProps}
className='pointer-events-none absolute z-[-1] h-3 w-3 bg-white black:bg-black dark:bg-gray-900'
/>
</div>
</Portal>
) : null}
</>
);
};
export { LanguageDropdown as default };