KeyboardEvent.key may be physical key name (Escape, Tab, etc.) even in text composition and it causes hotkeys or suggestion selection. So we need to check e.which or e.isComposing. Checking e.which also allows us to avoid Esc key on compositionend in Safari.
		
			
				
	
	
		
			224 lines
		
	
	
	
		
			6.4 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			224 lines
		
	
	
	
		
			6.4 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| import React from 'react';
 | |
| import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
 | |
| import AutosuggestEmoji from './autosuggest_emoji';
 | |
| import ImmutablePropTypes from 'react-immutable-proptypes';
 | |
| import PropTypes from 'prop-types';
 | |
| import { isRtl } from '../rtl';
 | |
| import ImmutablePureComponent from 'react-immutable-pure-component';
 | |
| import Textarea from 'react-textarea-autosize';
 | |
| import classNames from 'classnames';
 | |
| 
 | |
| 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];
 | |
|   }
 | |
| };
 | |
| 
 | |
| export default class AutosuggestTextarea extends ImmutablePureComponent {
 | |
| 
 | |
|   static 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,
 | |
|     autoFocus: PropTypes.bool,
 | |
|   };
 | |
| 
 | |
|   static defaultProps = {
 | |
|     autoFocus: true,
 | |
|   };
 | |
| 
 | |
|   state = {
 | |
|     suggestionsHidden: false,
 | |
|     selectedSuggestion: 0,
 | |
|     lastToken: null,
 | |
|     tokenStart: 0,
 | |
|   };
 | |
| 
 | |
|   onChange = (e) => {
 | |
|     const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
 | |
| 
 | |
|     if (token !== null && this.state.lastToken !== token) {
 | |
|       this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
 | |
|       this.props.onSuggestionsFetchRequested(token);
 | |
|     } else if (token === null) {
 | |
|       this.setState({ lastToken: null });
 | |
|       this.props.onSuggestionsClearRequested();
 | |
|     }
 | |
| 
 | |
|     this.props.onChange(e);
 | |
|   }
 | |
| 
 | |
|   onKeyDown = (e) => {
 | |
|     const { suggestions, disabled } = this.props;
 | |
|     const { selectedSuggestion, suggestionsHidden } = this.state;
 | |
| 
 | |
|     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();
 | |
|         this.setState({ suggestionsHidden: true });
 | |
|       }
 | |
| 
 | |
|       break;
 | |
|     case 'ArrowDown':
 | |
|       if (suggestions.size > 0 && !suggestionsHidden) {
 | |
|         e.preventDefault();
 | |
|         this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
 | |
|       }
 | |
| 
 | |
|       break;
 | |
|     case 'ArrowUp':
 | |
|       if (suggestions.size > 0 && !suggestionsHidden) {
 | |
|         e.preventDefault();
 | |
|         this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
 | |
|       }
 | |
| 
 | |
|       break;
 | |
|     case 'Enter':
 | |
|     case 'Tab':
 | |
|       // Select suggestion
 | |
|       if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
 | |
|         e.preventDefault();
 | |
|         e.stopPropagation();
 | |
|         this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
 | |
|       }
 | |
| 
 | |
|       break;
 | |
|     }
 | |
| 
 | |
|     if (e.defaultPrevented || !this.props.onKeyDown) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     this.props.onKeyDown(e);
 | |
|   }
 | |
| 
 | |
|   onBlur = () => {
 | |
|     this.setState({ suggestionsHidden: true });
 | |
|   }
 | |
| 
 | |
|   onSuggestionClick = (e) => {
 | |
|     const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
 | |
|     e.preventDefault();
 | |
|     this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
 | |
|     this.textarea.focus();
 | |
|   }
 | |
| 
 | |
|   componentWillReceiveProps (nextProps) {
 | |
|     if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
 | |
|       this.setState({ suggestionsHidden: false });
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   setTextarea = (c) => {
 | |
|     this.textarea = c;
 | |
|   }
 | |
| 
 | |
|   onPaste = (e) => {
 | |
|     if (e.clipboardData && e.clipboardData.files.length === 1) {
 | |
|       this.props.onPaste(e.clipboardData.files);
 | |
|       e.preventDefault();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   renderSuggestion = (suggestion, i) => {
 | |
|     const { selectedSuggestion } = this.state;
 | |
|     let inner, key;
 | |
| 
 | |
|     if (typeof suggestion === 'object') {
 | |
|       inner = <AutosuggestEmoji emoji={suggestion} />;
 | |
|       key   = suggestion.id;
 | |
|     } else if (suggestion[0] === '#') {
 | |
|       inner = suggestion;
 | |
|       key   = suggestion;
 | |
|     } else {
 | |
|       inner = <AutosuggestAccountContainer id={suggestion} />;
 | |
|       key   = suggestion;
 | |
|     }
 | |
| 
 | |
|     return (
 | |
|       <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
 | |
|         {inner}
 | |
|       </div>
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   render () {
 | |
|     const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
 | |
|     const { suggestionsHidden } = this.state;
 | |
|     const style = { direction: 'ltr' };
 | |
| 
 | |
|     if (isRtl(value)) {
 | |
|       style.direction = 'rtl';
 | |
|     }
 | |
| 
 | |
|     return (
 | |
|       <div className='autosuggest-textarea'>
 | |
|         <label>
 | |
|           <span style={{ display: 'none' }}>{placeholder}</span>
 | |
| 
 | |
|           <Textarea
 | |
|             inputRef={this.setTextarea}
 | |
|             className='autosuggest-textarea__textarea'
 | |
|             disabled={disabled}
 | |
|             placeholder={placeholder}
 | |
|             autoFocus={autoFocus}
 | |
|             value={value}
 | |
|             onChange={this.onChange}
 | |
|             onKeyDown={this.onKeyDown}
 | |
|             onKeyUp={onKeyUp}
 | |
|             onBlur={this.onBlur}
 | |
|             onPaste={this.onPaste}
 | |
|             style={style}
 | |
|             aria-autocomplete='list'
 | |
|           />
 | |
|         </label>
 | |
| 
 | |
|         <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
 | |
|           {suggestions.map(this.renderSuggestion)}
 | |
|         </div>
 | |
|       </div>
 | |
|     );
 | |
|   }
 | |
| 
 | |
| }
 |