diff --git a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx deleted file mode 100644 index e1fc87da77..0000000000 --- a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx +++ /dev/null @@ -1,299 +0,0 @@ -import PropTypes from 'prop-types'; -import { useCallback, useRef, useState, useEffect, useMemo } from 'react'; - -import { useIntl, defineMessages } from 'react-intl'; - -import classNames from 'classnames'; - -import { createSelector } from '@reduxjs/toolkit'; -import { Map as ImmutableMap } from 'immutable'; - -import { supportsPassiveEvents } from 'detect-passive-events'; -import fuzzysort from 'fuzzysort'; -import Overlay from 'react-overlays/Overlay'; - -import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react'; -import SearchIcon from '@/material-icons/400-24px/search.svg?react'; -import TranslateIcon from '@/material-icons/400-24px/translate.svg?react'; -import { changeComposeLanguage } from 'flavours/glitch/actions/compose'; -import { Icon } from 'flavours/glitch/components/icon'; -import { languages as preloadedLanguages } from 'flavours/glitch/initial_state'; -import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; - -import { debouncedGuess } from '../util/language_detection'; - -const messages = defineMessages({ - changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' }, - search: { id: 'compose.language.search', defaultMessage: 'Search languages...' }, - clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' }, -}); - -const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; - -const getFrequentlyUsedLanguages = createSelector([ - state => state.getIn(['settings', 'frequentlyUsedLanguages'], ImmutableMap()), -], languageCounters => ( - languageCounters.keySeq() - .sort((a, b) => languageCounters.get(a) - languageCounters.get(b)) - .reverse() - .toArray() -)); - -const LanguageDropdownMenu = ({ value, guess, onClose, onChange, languages = preloadedLanguages, intl }) => { - const [searchValue, setSearchValue] = useState(''); - const nodeRef = useRef(null); - const listNodeRef = useRef(null); - - const frequentlyUsedLanguages = useAppSelector(getFrequentlyUsedLanguages); - - const handleSearchChange = useCallback(({ target }) => { - setSearchValue(target.value); - }, [setSearchValue]); - - const handleClick = useCallback((e) => { - const value = e.currentTarget.getAttribute('data-index'); - - e.preventDefault(); - - onClose(); - onChange(value); - }, [onClose, onChange]); - - const handleKeyDown = useCallback(e => { - const index = Array.from(listNodeRef.current.childNodes).findIndex(node => node === e.currentTarget); - - let element = null; - - switch(e.key) { - case 'Escape': - onClose(); - break; - case ' ': - case 'Enter': - handleClick(e); - break; - case 'ArrowDown': - element = listNodeRef.current.childNodes[index + 1] || listNodeRef.current.firstChild; - break; - case 'ArrowUp': - element = listNodeRef.current.childNodes[index - 1] || listNodeRef.current.lastChild; - break; - case 'Tab': - if (e.shiftKey) { - element = listNodeRef.current.childNodes[index - 1] || listNodeRef.current.lastChild; - } else { - element = listNodeRef.current.childNodes[index + 1] || listNodeRef.current.firstChild; - } - break; - case 'Home': - element = listNodeRef.current.firstChild; - break; - case 'End': - element = listNodeRef.current.lastChild; - break; - } - - if (element) { - element.focus(); - e.preventDefault(); - e.stopPropagation(); - } - }, [onClose, handleClick]); - - const handleSearchKeyDown = useCallback(e => { - let element = null; - - switch(e.key) { - case 'Tab': - case 'ArrowDown': - element = listNodeRef.current.firstChild; - - if (element) { - element.focus(); - e.preventDefault(); - e.stopPropagation(); - } - - break; - case 'Enter': - element = listNodeRef.current.firstChild; - - if (element) { - onChange(element.getAttribute('data-index')); - onClose(); - } - break; - case 'Escape': - if (searchValue !== '') { - e.preventDefault(); - this.handleClear(); - } - - break; - } - }, [onChange, onClose, searchValue]); - - const handleClear = useCallback(() => { - setSearchValue(''); - }, [setSearchValue]); - - const isSearching = searchValue !== ''; - - useEffect(() => { - const handleDocumentClick = (e) => { - if (nodeRef.current && !nodeRef.current.contains(e.target)) { - onClose(); - e.stopPropagation(); - } - }; - - document.addEventListener('click', handleDocumentClick, { capture: true }); - document.addEventListener('touchend', handleDocumentClick, listenerOptions); - - // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need - // to wait for a frame before focusing - requestAnimationFrame(() => { - if (nodeRef.current) { - const element = nodeRef.current.querySelector('input[type="search"]'); - if (element) element.focus(); - } - }); - - return () => { - document.removeEventListener('click', handleDocumentClick, { capture: true }); - document.removeEventListener('touchend', handleDocumentClick, listenerOptions); - }; - }, [onClose]); - - const results = useMemo(() => { - if (searchValue === '') { - return [...languages].sort((a, b) => { - - if (guess && a[0] === guess) { // Push guessed language higher than current selection - return -1; - } else if (guess && b[0] === guess) { - return 1; - } else if (a[0] === value) { // Push current selection to the top of the list - return -1; - } else if (b[0] === value) { - return 1; - } else { - // Sort according to frequently used languages - - const indexOfA = frequentlyUsedLanguages.indexOf(a[0]); - const indexOfB = frequentlyUsedLanguages.indexOf(b[0]); - - return ((indexOfA > -1 ? indexOfA : Infinity) - (indexOfB > -1 ? indexOfB : Infinity)); - } - }); - } - - return fuzzysort.go(searchValue, languages, { - keys: ['0', '1', '2'], - limit: 5, - threshold: -10000, - }).map(result => result.obj); - }, [searchValue, languages, guess, frequentlyUsedLanguages, value]); - - return ( -
-
- - -
- -
- {results.map((lang) => ( -
- {lang[2]} ({lang[1]}) -
- ))} -
-
- ); -}; - -LanguageDropdownMenu.propTypes = { - value: PropTypes.string.isRequired, - guess: PropTypes.string, - onClose: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), - intl: PropTypes.object, -}; - -export const LanguageDropdown = () => { - const [open, setOpen] = useState(false); - const [placement, setPlacement] = useState('bottom'); - const [guess, setGuess] = useState(''); - const activeElementRef = useRef(null); - const targetRef = useRef(null); - - const intl = useIntl(); - - const dispatch = useAppDispatch(); - const value = useAppSelector((state) => state.compose.get('language')); - const text = useAppSelector((state) => state.compose.get('text')); - - const current = preloadedLanguages.find(lang => lang[0] === value) ?? []; - - const handleToggle = useCallback(() => { - if (open && activeElementRef.current) - activeElementRef.current.focus({ preventScroll: true }); - - setOpen(!open); - }, [open, setOpen]); - - const handleClose = useCallback(() => { - if (open && activeElementRef.current) - activeElementRef.current.focus({ preventScroll: true }); - - setOpen(false); - }, [open, setOpen]); - - const handleChange = useCallback((value) => { - dispatch(changeComposeLanguage(value)); - }, [dispatch]); - - const handleOverlayEnter = useCallback(({ placement }) => { - setPlacement(placement); - }, [setPlacement]); - - useEffect(() => { - if (text.length > 20) { - debouncedGuess(text, setGuess); - } else { - setGuess(''); - } - }, [text, setGuess]); - - return ( -
- - - - {({ props, placement }) => ( -
-
- -
-
- )} -
-
- ); -}; diff --git a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.tsx b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.tsx new file mode 100644 index 0000000000..e8ead66fae --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.tsx @@ -0,0 +1,427 @@ +import { useCallback, useRef, useState, useEffect, useMemo } from 'react'; + +import { useIntl, defineMessages } from 'react-intl'; + +import classNames from 'classnames'; + +import { createSelector } from '@reduxjs/toolkit'; +import { Map as ImmutableMap } from 'immutable'; + +import fuzzysort from 'fuzzysort'; +import Overlay from 'react-overlays/Overlay'; +import type { State, Placement } from 'react-overlays/usePopper'; + +import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react'; +import SearchIcon from '@/material-icons/400-24px/search.svg?react'; +import TranslateIcon from '@/material-icons/400-24px/translate.svg?react'; +import { changeComposeLanguage } from 'flavours/glitch/actions/compose'; +import { Icon } from 'flavours/glitch/components/icon'; +import { languages as preloadedLanguages } from 'flavours/glitch/initial_state'; +import type { RootState } from 'flavours/glitch/store'; +import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; + +import { debouncedGuess } from '../util/language_detection'; + +const messages = defineMessages({ + changeLanguage: { + id: 'compose.language.change', + defaultMessage: 'Change language', + }, + search: { + id: 'compose.language.search', + defaultMessage: 'Search languages...', + }, + clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' }, +}); + +type Language = [string, string, string]; + +const getFrequentlyUsedLanguages = createSelector( + [ + (state: RootState) => + (state.settings as ImmutableMap).get( + 'frequentlyUsedLanguages', + ImmutableMap(), + ) as ImmutableMap, + ], + (languageCounters) => + languageCounters + .keySeq() + .sort( + (a, b) => + (languageCounters.get(a) ?? 0) - (languageCounters.get(b) ?? 0), + ) + .reverse() + .toArray(), +); + +const LanguageDropdownMenu: React.FC<{ + value: string; + guess?: string; + onClose: () => void; + onChange: (arg0: string) => void; +}> = ({ value, guess, onClose, onChange }) => { + const languages = preloadedLanguages as Language[]; + const intl = useIntl(); + const [searchValue, setSearchValue] = useState(''); + const nodeRef = useRef(null); + const listNodeRef = useRef(null); + + const frequentlyUsedLanguages = useAppSelector(getFrequentlyUsedLanguages); + + const handleSearchChange = useCallback( + ({ target }: React.ChangeEvent) => { + setSearchValue(target.value); + }, + [setSearchValue], + ); + + const handleClick = useCallback( + (e: React.MouseEvent | React.KeyboardEvent) => { + const value = e.currentTarget.getAttribute('data-index'); + + if (!value) { + return; + } + + e.preventDefault(); + + onClose(); + onChange(value); + }, + [onClose, onChange], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!listNodeRef.current) { + return; + } + + const index = Array.from(listNodeRef.current.childNodes).findIndex( + (node) => node === e.currentTarget, + ); + + let element = null; + + switch (e.key) { + case 'Escape': + onClose(); + break; + case ' ': + case 'Enter': + handleClick(e); + break; + case 'ArrowDown': + element = + listNodeRef.current.childNodes[index + 1] ?? + listNodeRef.current.firstChild; + break; + case 'ArrowUp': + element = + listNodeRef.current.childNodes[index - 1] ?? + listNodeRef.current.lastChild; + break; + case 'Tab': + if (e.shiftKey) { + element = + listNodeRef.current.childNodes[index - 1] ?? + listNodeRef.current.lastChild; + } else { + element = + listNodeRef.current.childNodes[index + 1] ?? + listNodeRef.current.firstChild; + } + break; + case 'Home': + element = listNodeRef.current.firstChild; + break; + case 'End': + element = listNodeRef.current.lastChild; + break; + } + + if (element && element instanceof HTMLElement) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + }, + [onClose, handleClick], + ); + + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + let element = null; + + if (!listNodeRef.current) { + return; + } + + switch (e.key) { + case 'Tab': + case 'ArrowDown': + element = listNodeRef.current.firstChild; + + if (element && element instanceof HTMLElement) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + + break; + case 'Enter': + element = listNodeRef.current.firstChild; + + if (element && element instanceof HTMLElement) { + const value = element.getAttribute('data-index'); + + if (value) { + onChange(value); + onClose(); + } + } + break; + case 'Escape': + if (searchValue !== '') { + e.preventDefault(); + setSearchValue(''); + } + + break; + } + }, + [setSearchValue, onChange, onClose, searchValue], + ); + + const handleClear = useCallback(() => { + setSearchValue(''); + }, [setSearchValue]); + + const isSearching = searchValue !== ''; + + useEffect(() => { + const handleDocumentClick = (e: MouseEvent) => { + if ( + nodeRef.current && + e.target instanceof HTMLElement && + !nodeRef.current.contains(e.target) + ) { + onClose(); + e.stopPropagation(); + } + }; + + document.addEventListener('click', handleDocumentClick, { capture: true }); + + // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need + // to wait for a frame before focusing + requestAnimationFrame(() => { + if (nodeRef.current) { + const element = nodeRef.current.querySelector( + 'input[type="search"]', + ); + if (element) element.focus(); + } + }); + + return () => { + document.removeEventListener('click', handleDocumentClick); + }; + }, [onClose]); + + const results = useMemo(() => { + if (searchValue === '') { + return [...languages].sort((a, b) => { + if (guess && a[0] === guess) { + // Push guessed language higher than current selection + return -1; + } else if (guess && b[0] === guess) { + return 1; + } else if (a[0] === value) { + // Push current selection to the top of the list + return -1; + } else if (b[0] === value) { + return 1; + } else { + // Sort according to frequently used languages + + const indexOfA = frequentlyUsedLanguages.indexOf(a[0]); + const indexOfB = frequentlyUsedLanguages.indexOf(b[0]); + + return ( + (indexOfA > -1 ? indexOfA : Infinity) - + (indexOfB > -1 ? indexOfB : Infinity) + ); + } + }); + } + + return fuzzysort + .go(searchValue, languages, { + keys: ['0', '1', '2'], + limit: 5, + threshold: -10000, + }) + .map((result) => result.obj); + }, [searchValue, languages, guess, frequentlyUsedLanguages, value]); + + return ( +
+
+ + +
+ +
+ {results.map((lang) => ( +
+ + {lang[2]} + {' '} + + ({lang[1]}) + +
+ ))} +
+
+ ); +}; + +export const LanguageDropdown: React.FC = () => { + const [open, setOpen] = useState(false); + const [placement, setPlacement] = useState('bottom'); + const [guess, setGuess] = useState(''); + const activeElementRef = useRef(null); + const targetRef = useRef(null); + + const intl = useIntl(); + + const dispatch = useAppDispatch(); + const value = useAppSelector( + (state) => state.compose.get('language') as string, + ); + const text = useAppSelector((state) => state.compose.get('text') as string); + + const current = + (preloadedLanguages as Language[]).find((lang) => lang[0] === value) ?? []; + + const handleMouseDown = useCallback(() => { + if (!open && document.activeElement instanceof HTMLElement) { + activeElementRef.current = document.activeElement; + } + }, [open]); + + const handleToggle = useCallback(() => { + if (open && activeElementRef.current) + activeElementRef.current.focus({ preventScroll: true }); + + setOpen(!open); + }, [open, setOpen]); + + const handleClose = useCallback(() => { + if (open && activeElementRef.current) + activeElementRef.current.focus({ preventScroll: true }); + + setOpen(false); + }, [open, setOpen]); + + const handleChange = useCallback( + (value: string) => { + dispatch(changeComposeLanguage(value)); + }, + [dispatch], + ); + + const handleOverlayEnter = useCallback( + (state: Partial) => { + setPlacement(state.placement); + }, + [setPlacement], + ); + + useEffect(() => { + if (text.length > 20) { + debouncedGuess(text, setGuess); + } else { + setGuess(''); + } + }, [text, setGuess]); + + return ( +
+ + + + {({ props, placement }) => ( +
+
+ +
+
+ )} +
+
+ ); +};