Mastodon/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx
Eugen Rochko 0565eb62d6 [Glitch] Fix hashtags not being picked up when full-width hash sign is used
Port 779a1f84481ae9b73247083aa1573cc4e9e04cca to glitch-soc

Co-authored-by: Claire <claire.github-309c@sitedethib.com>
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-28 22:19:01 +01:00

244 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import PropTypes from 'prop-types';
import { useCallback, useRef, useState, useEffect, forwardRef } from 'react';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Overlay from 'react-overlays/Overlay';
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,
className,
}, 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) => {
onPaste(e);
}, [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]);
// Hack to force Firefox to change language in autocorrect
useEffect(() => {
if (lang && textareaRef.current && textareaRef.current === document.activeElement) {
textareaRef.current.blur();
textareaRef.current.focus();
}
}, [lang]);
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={classNames('autosuggest-textarea', className)}>
<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'
aria-label={placeholder}
lang={lang}
/>
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
{({ props }) => (
<div {...props}>
<div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
{suggestions.map(renderSuggestion)}
</div>
</div>
)}
</Overlay>
</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,
autoFocus: PropTypes.bool,
lang: PropTypes.string,
};
export default AutosuggestTextarea;