You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
240 lines
7.0 KiB
240 lines
7.0 KiB
import PropTypes from 'prop-types'; |
|
import { useCallback, useRef, useState, useEffect, forwardRef } from 'react'; |
|
|
|
import classNames from 'classnames'; |
|
|
|
import ImmutablePropTypes from 'react-immutable-proptypes'; |
|
|
|
import Textarea from 'react-textarea-autosize'; |
|
|
|
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; |
|
|
|
import AutosuggestEmoji from './autosuggest_emoji'; |
|
import { AutosuggestHashtag } from './autosuggest_hashtag'; |
|
|
|
const textAtCursorMatchesToken = (str, caretPosition) => { |
|
let word; |
|
|
|
let left = str.slice(0, caretPosition).search(/\S+$/); |
|
let right = str.slice(caretPosition).search(/\s/); |
|
|
|
if (right < 0) { |
|
word = str.slice(left); |
|
} else { |
|
word = str.slice(left, right + caretPosition); |
|
} |
|
|
|
if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) { |
|
return [null, null]; |
|
} |
|
|
|
word = word.trim().toLowerCase(); |
|
|
|
if (word.length > 0) { |
|
return [left + 1, word]; |
|
} else { |
|
return [null, null]; |
|
} |
|
}; |
|
|
|
const AutosuggestTextarea = forwardRef(({ |
|
value, |
|
suggestions, |
|
disabled, |
|
placeholder, |
|
onSuggestionSelected, |
|
onSuggestionsClearRequested, |
|
onSuggestionsFetchRequested, |
|
onChange, |
|
onKeyUp, |
|
onKeyDown, |
|
onPaste, |
|
onFocus, |
|
autoFocus = true, |
|
lang, |
|
children, |
|
}, textareaRef) => { |
|
|
|
const [suggestionsHidden, setSuggestionsHidden] = useState(true); |
|
const [selectedSuggestion, setSelectedSuggestion] = useState(0); |
|
const lastTokenRef = useRef(null); |
|
const tokenStartRef = useRef(0); |
|
|
|
const handleChange = useCallback((e) => { |
|
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); |
|
|
|
if (token !== null && lastTokenRef.current !== token) { |
|
tokenStartRef.current = tokenStart; |
|
lastTokenRef.current = token; |
|
setSelectedSuggestion(0); |
|
onSuggestionsFetchRequested(token); |
|
} else if (token === null) { |
|
lastTokenRef.current = null; |
|
onSuggestionsClearRequested(); |
|
} |
|
|
|
onChange(e); |
|
}, [onSuggestionsFetchRequested, onSuggestionsClearRequested, onChange, setSelectedSuggestion]); |
|
|
|
const handleKeyDown = useCallback((e) => { |
|
if (disabled) { |
|
e.preventDefault(); |
|
return; |
|
} |
|
|
|
if (e.which === 229 || e.isComposing) { |
|
// Ignore key events during text composition |
|
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac) |
|
return; |
|
} |
|
|
|
switch(e.key) { |
|
case 'Escape': |
|
if (suggestions.size === 0 || suggestionsHidden) { |
|
document.querySelector('.ui').parentElement.focus(); |
|
} else { |
|
e.preventDefault(); |
|
setSuggestionsHidden(true); |
|
} |
|
|
|
break; |
|
case 'ArrowDown': |
|
if (suggestions.size > 0 && !suggestionsHidden) { |
|
e.preventDefault(); |
|
setSelectedSuggestion(Math.min(selectedSuggestion + 1, suggestions.size - 1)); |
|
} |
|
|
|
break; |
|
case 'ArrowUp': |
|
if (suggestions.size > 0 && !suggestionsHidden) { |
|
e.preventDefault(); |
|
setSelectedSuggestion(Math.max(selectedSuggestion - 1, 0)); |
|
} |
|
|
|
break; |
|
case 'Enter': |
|
case 'Tab': |
|
// Select suggestion |
|
if (lastTokenRef.current !== null && suggestions.size > 0 && !suggestionsHidden) { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestions.get(selectedSuggestion)); |
|
} |
|
|
|
break; |
|
} |
|
|
|
if (e.defaultPrevented || !onKeyDown) { |
|
return; |
|
} |
|
|
|
onKeyDown(e); |
|
}, [disabled, suggestions, suggestionsHidden, selectedSuggestion, setSelectedSuggestion, setSuggestionsHidden, onSuggestionSelected, onKeyDown]); |
|
|
|
const handleBlur = useCallback(() => { |
|
setSuggestionsHidden(true); |
|
}, [setSuggestionsHidden]); |
|
|
|
const handleFocus = useCallback((e) => { |
|
if (onFocus) { |
|
onFocus(e); |
|
} |
|
}, [onFocus]); |
|
|
|
const handleSuggestionClick = useCallback((e) => { |
|
const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index')); |
|
e.preventDefault(); |
|
onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestion); |
|
textareaRef.current?.focus(); |
|
}, [suggestions, onSuggestionSelected, textareaRef]); |
|
|
|
const handlePaste = useCallback((e) => { |
|
if (e.clipboardData && e.clipboardData.files.length === 1) { |
|
onPaste(e.clipboardData.files); |
|
e.preventDefault(); |
|
} |
|
}, [onPaste]); |
|
|
|
// Show the suggestions again whenever they change and the textarea is focused |
|
useEffect(() => { |
|
if (suggestions.size > 0 && textareaRef.current === document.activeElement) { |
|
setSuggestionsHidden(false); |
|
} |
|
}, [suggestions, textareaRef, setSuggestionsHidden]); |
|
|
|
const renderSuggestion = (suggestion, i) => { |
|
let inner, key; |
|
|
|
if (suggestion.type === 'emoji') { |
|
inner = <AutosuggestEmoji emoji={suggestion} />; |
|
key = suggestion.id; |
|
} else if (suggestion.type === 'hashtag') { |
|
inner = <AutosuggestHashtag tag={suggestion} />; |
|
key = suggestion.name; |
|
} else if (suggestion.type === 'account') { |
|
inner = <AutosuggestAccountContainer id={suggestion.id} />; |
|
key = suggestion.id; |
|
} |
|
|
|
return ( |
|
<div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={handleSuggestionClick}> |
|
{inner} |
|
</div> |
|
); |
|
}; |
|
|
|
return [ |
|
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'> |
|
<div className='autosuggest-textarea'> |
|
<label> |
|
<span style={{ display: 'none' }}>{placeholder}</span> |
|
|
|
<Textarea |
|
ref={textareaRef} |
|
className='autosuggest-textarea__textarea' |
|
disabled={disabled} |
|
placeholder={placeholder} |
|
autoFocus={autoFocus} |
|
value={value} |
|
onChange={handleChange} |
|
onKeyDown={handleKeyDown} |
|
onKeyUp={onKeyUp} |
|
onFocus={handleFocus} |
|
onBlur={handleBlur} |
|
onPaste={handlePaste} |
|
dir='auto' |
|
aria-autocomplete='list' |
|
lang={lang} |
|
/> |
|
</label> |
|
</div> |
|
{children} |
|
</div>, |
|
|
|
<div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'> |
|
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> |
|
{suggestions.map(renderSuggestion)} |
|
</div> |
|
</div>, |
|
]; |
|
}); |
|
|
|
AutosuggestTextarea.propTypes = { |
|
value: PropTypes.string, |
|
suggestions: ImmutablePropTypes.list, |
|
disabled: PropTypes.bool, |
|
placeholder: PropTypes.string, |
|
onSuggestionSelected: PropTypes.func.isRequired, |
|
onSuggestionsClearRequested: PropTypes.func.isRequired, |
|
onSuggestionsFetchRequested: PropTypes.func.isRequired, |
|
onChange: PropTypes.func.isRequired, |
|
onKeyUp: PropTypes.func, |
|
onKeyDown: PropTypes.func, |
|
onPaste: PropTypes.func.isRequired, |
|
onFocus:PropTypes.func, |
|
children: PropTypes.node, |
|
autoFocus: PropTypes.bool, |
|
lang: PropTypes.string, |
|
}; |
|
|
|
export default AutosuggestTextarea;
|
|
|