4e35ce8269
This fixes following cases which causes hotkey action accidentally: * hitting Esc key to cancel text composition (mostly in CJK) Although events on cancelling composition are still heavily browser / input method dependent, but this implementation would covers current UI Events spec and some exceptions. * hitting Esc key to close autocomplete suggestions This PR changes to use keydown event instead of keyup event as well as other hotkeys.
218 lines
6.1 KiB
JavaScript
218 lines
6.1 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;
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
}
|