238 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			238 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
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,
 | 
						|
}, 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='autosuggest-textarea'>
 | 
						|
      <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;
 |