]> cat aescling's git repositories - mastodon.git/blob - app/javascript/mastodon/components/autosuggest_textarea.js
Merge branch 'master' into glitch-soc/merge-upstream
[mastodon.git] / app / javascript / mastodon / components / autosuggest_textarea.js
1 import React from 'react';
2 import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
3 import AutosuggestEmoji from './autosuggest_emoji';
4 import AutosuggestHashtag from './autosuggest_hashtag';
5 import ImmutablePropTypes from 'react-immutable-proptypes';
6 import PropTypes from 'prop-types';
7 import { isRtl } from '../rtl';
8 import ImmutablePureComponent from 'react-immutable-pure-component';
9 import Textarea from 'react-textarea-autosize';
10 import classNames from 'classnames';
11
12 const textAtCursorMatchesToken = (str, caretPosition) => {
13 let word;
14
15 let left = str.slice(0, caretPosition).search(/\S+$/);
16 let right = str.slice(caretPosition).search(/\s/);
17
18 if (right < 0) {
19 word = str.slice(left);
20 } else {
21 word = str.slice(left, right + caretPosition);
22 }
23
24 if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) {
25 return [null, null];
26 }
27
28 word = word.trim().toLowerCase();
29
30 if (word.length > 0) {
31 return [left + 1, word];
32 } else {
33 return [null, null];
34 }
35 };
36
37 export default class AutosuggestTextarea extends ImmutablePureComponent {
38
39 static propTypes = {
40 value: PropTypes.string,
41 suggestions: ImmutablePropTypes.list,
42 disabled: PropTypes.bool,
43 placeholder: PropTypes.string,
44 onSuggestionSelected: PropTypes.func.isRequired,
45 onSuggestionsClearRequested: PropTypes.func.isRequired,
46 onSuggestionsFetchRequested: PropTypes.func.isRequired,
47 onChange: PropTypes.func.isRequired,
48 onKeyUp: PropTypes.func,
49 onKeyDown: PropTypes.func,
50 onPaste: PropTypes.func.isRequired,
51 autoFocus: PropTypes.bool,
52 };
53
54 static defaultProps = {
55 autoFocus: true,
56 };
57
58 state = {
59 suggestionsHidden: true,
60 focused: false,
61 selectedSuggestion: 0,
62 lastToken: null,
63 tokenStart: 0,
64 };
65
66 onChange = (e) => {
67 const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
68
69 if (token !== null && this.state.lastToken !== token) {
70 this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
71 this.props.onSuggestionsFetchRequested(token);
72 } else if (token === null) {
73 this.setState({ lastToken: null });
74 this.props.onSuggestionsClearRequested();
75 }
76
77 this.props.onChange(e);
78 }
79
80 onKeyDown = (e) => {
81 const { suggestions, disabled } = this.props;
82 const { selectedSuggestion, suggestionsHidden } = this.state;
83
84 if (disabled) {
85 e.preventDefault();
86 return;
87 }
88
89 if (e.which === 229 || e.isComposing) {
90 // Ignore key events during text composition
91 // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
92 return;
93 }
94
95 switch(e.key) {
96 case 'Escape':
97 if (suggestions.size === 0 || suggestionsHidden) {
98 document.querySelector('.ui').parentElement.focus();
99 } else {
100 e.preventDefault();
101 this.setState({ suggestionsHidden: true });
102 }
103
104 break;
105 case 'ArrowDown':
106 if (suggestions.size > 0 && !suggestionsHidden) {
107 e.preventDefault();
108 this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
109 }
110
111 break;
112 case 'ArrowUp':
113 if (suggestions.size > 0 && !suggestionsHidden) {
114 e.preventDefault();
115 this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
116 }
117
118 break;
119 case 'Enter':
120 case 'Tab':
121 // Select suggestion
122 if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
123 e.preventDefault();
124 e.stopPropagation();
125 this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
126 }
127
128 break;
129 }
130
131 if (e.defaultPrevented || !this.props.onKeyDown) {
132 return;
133 }
134
135 this.props.onKeyDown(e);
136 }
137
138 onBlur = () => {
139 this.setState({ suggestionsHidden: true, focused: false });
140 }
141
142 onFocus = (e) => {
143 this.setState({ focused: true });
144 if (this.props.onFocus) {
145 this.props.onFocus(e);
146 }
147 }
148
149 onSuggestionClick = (e) => {
150 const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
151 e.preventDefault();
152 this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
153 this.textarea.focus();
154 }
155
156 componentWillReceiveProps (nextProps) {
157 if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
158 this.setState({ suggestionsHidden: false });
159 }
160 }
161
162 setTextarea = (c) => {
163 this.textarea = c;
164 }
165
166 onPaste = (e) => {
167 if (e.clipboardData && e.clipboardData.files.length === 1) {
168 this.props.onPaste(e.clipboardData.files);
169 e.preventDefault();
170 }
171 }
172
173 renderSuggestion = (suggestion, i) => {
174 const { selectedSuggestion } = this.state;
175 let inner, key;
176
177 if (typeof suggestion === 'object' && suggestion.shortcode) {
178 inner = <AutosuggestEmoji emoji={suggestion} />;
179 key = suggestion.id;
180 } else if (typeof suggestion === 'object' && suggestion.name) {
181 inner = <AutosuggestHashtag tag={suggestion} />;
182 key = suggestion.name;
183 } else {
184 inner = <AutosuggestAccountContainer id={suggestion} />;
185 key = suggestion;
186 }
187
188 return (
189 <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
190 {inner}
191 </div>
192 );
193 }
194
195 render () {
196 const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props;
197 const { suggestionsHidden } = this.state;
198 const style = { direction: 'ltr' };
199
200 if (isRtl(value)) {
201 style.direction = 'rtl';
202 }
203
204 return [
205 <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
206 <div className='autosuggest-textarea'>
207 <label>
208 <span style={{ display: 'none' }}>{placeholder}</span>
209
210 <Textarea
211 inputRef={this.setTextarea}
212 className='autosuggest-textarea__textarea'
213 disabled={disabled}
214 placeholder={placeholder}
215 autoFocus={autoFocus}
216 value={value}
217 onChange={this.onChange}
218 onKeyDown={this.onKeyDown}
219 onKeyUp={onKeyUp}
220 onFocus={this.onFocus}
221 onBlur={this.onBlur}
222 onPaste={this.onPaste}
223 style={style}
224 aria-autocomplete='list'
225 />
226 </label>
227 </div>
228 {children}
229 </div>,
230
231 <div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
232 <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
233 {suggestions.map(this.renderSuggestion)}
234 </div>
235 </div>,
236 ];
237 }
238
239 }
This page took 0.141553 seconds and 5 git commands to generate.