]> cat aescling's git repositories - mastodon.git/blob - app/javascript/mastodon/components/autosuggest_textarea.js
TURBO shortcode search
[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 AutosuggestShortcode from '../features/compose/components/autosuggest_shortcode';
4 import ImmutablePropTypes from 'react-immutable-proptypes';
5 import PropTypes from 'prop-types';
6 import { isRtl } from '../rtl';
7 import ImmutablePureComponent from 'react-immutable-pure-component';
8 import Textarea from 'react-textarea-autosize';
9
10 const textAtCursorMatchesToken = (str, caretPosition) => {
11 let word;
12
13 let left = str.slice(0, caretPosition).search(/\S+$/);
14 let right = str.slice(caretPosition).search(/\s/);
15
16 if (right < 0) {
17 word = str.slice(left);
18 } else {
19 word = str.slice(left, right + caretPosition);
20 }
21
22 if (!word || word.trim().length < 2 || ['@', ':', '#'].indexOf(word[0]) === -1) {
23 return [null, null];
24 }
25
26 word = word.trim().toLowerCase();
27 // was: .slice(1); - we leave the leading char there, handler can decide what to do based on it
28
29 if (word.length > 0) {
30 return [left + 1, word];
31 } else {
32 return [null, null];
33 }
34 };
35
36 export default class AutosuggestTextarea extends ImmutablePureComponent {
37
38 static propTypes = {
39 value: PropTypes.string,
40 suggestions: ImmutablePropTypes.list,
41 disabled: PropTypes.bool,
42 placeholder: PropTypes.string,
43 onSuggestionSelected: PropTypes.func.isRequired,
44 onSuggestionsClearRequested: PropTypes.func.isRequired,
45 onSuggestionsFetchRequested: PropTypes.func.isRequired,
46 onLocalSuggestionsFetchRequested: 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: false,
60 selectedSuggestion: 0,
61 lastToken: null,
62 tokenStart: 0,
63 };
64
65 onChange = (e) => {
66 const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
67
68 if (token !== null && this.state.lastToken !== token) {
69 this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
70 if (token[0] === ':') {
71 // faster debounce for shortcodes.
72 // hashtags have long debounce because they're fetched from server.
73 this.props.onLocalSuggestionsFetchRequested(token);
74 } else {
75 this.props.onSuggestionsFetchRequested(token);
76 }
77 } else if (token === null) {
78 this.setState({ lastToken: null });
79 this.props.onSuggestionsClearRequested();
80 }
81
82 this.props.onChange(e);
83 }
84
85 onKeyDown = (e) => {
86 const { suggestions, disabled } = this.props;
87 const { selectedSuggestion, suggestionsHidden } = this.state;
88
89 if (disabled) {
90 e.preventDefault();
91 return;
92 }
93
94 switch(e.key) {
95 case 'Escape':
96 if (!suggestionsHidden) {
97 e.preventDefault();
98 this.setState({ suggestionsHidden: true });
99 }
100
101 break;
102 case 'ArrowDown':
103 if (suggestions.size > 0 && !suggestionsHidden) {
104 e.preventDefault();
105 this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
106 }
107
108 break;
109 case 'ArrowUp':
110 if (suggestions.size > 0 && !suggestionsHidden) {
111 e.preventDefault();
112 this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
113 }
114
115 break;
116 case 'Enter':
117 case 'Tab':
118 // Select suggestion
119 if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
120 e.preventDefault();
121 e.stopPropagation();
122 this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
123 }
124
125 break;
126 }
127
128 if (e.defaultPrevented || !this.props.onKeyDown) {
129 return;
130 }
131
132 this.props.onKeyDown(e);
133 }
134
135 onBlur = () => {
136 this.setState({ suggestionsHidden: true });
137 }
138
139 onSuggestionClick = (e) => {
140 // leave suggestion string unchanged if it's a hash / shortcode suggestion. convert account number to int.
141 const suggestionStr = e.currentTarget.getAttribute('data-index');
142 const suggestion = [':', '#'].indexOf(suggestionStr[0]) !== -1 ? suggestionStr : Number(suggestionStr);
143 e.preventDefault();
144 this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
145 this.textarea.focus();
146 }
147
148 componentWillReceiveProps (nextProps) {
149 if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
150 this.setState({ suggestionsHidden: false });
151 }
152 }
153
154 setTextarea = (c) => {
155 this.textarea = c;
156 }
157
158 onPaste = (e) => {
159 if (e.clipboardData && e.clipboardData.files.length === 1) {
160 this.props.onPaste(e.clipboardData.files);
161 e.preventDefault();
162 }
163 }
164
165 render () {
166 const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
167 const { suggestionsHidden, selectedSuggestion } = this.state;
168 const style = { direction: 'ltr' };
169
170 if (isRtl(value)) {
171 style.direction = 'rtl';
172 }
173
174 let makeItem = (suggestion) => {
175 if (suggestion[0] === ':') return <AutosuggestShortcode shortcode={suggestion} />;
176 if (suggestion[0] === '#') return suggestion; // hashtag
177
178 // else - accounts are always returned as IDs with no prefix
179 return <AutosuggestAccountContainer id={suggestion} />;
180 };
181
182 return (
183 <div className='autosuggest-textarea'>
184 <label>
185 <span style={{ display: 'none' }}>{placeholder}</span>
186 <Textarea
187 inputRef={this.setTextarea}
188 className='autosuggest-textarea__textarea'
189 disabled={disabled}
190 placeholder={placeholder}
191 autoFocus={autoFocus}
192 value={value}
193 onChange={this.onChange}
194 onKeyDown={this.onKeyDown}
195 onKeyUp={onKeyUp}
196 onBlur={this.onBlur}
197 onPaste={this.onPaste}
198 style={style}
199 />
200 </label>
201
202 <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
203 {suggestions.map((suggestion, i) => (
204 <div
205 role='button'
206 tabIndex='0'
207 key={suggestion}
208 data-index={suggestion}
209 className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`}
210 onMouseDown={this.onSuggestionClick}
211 >
212 {makeItem(suggestion)}
213 </div>
214 ))}
215 </div>
216 </div>
217 );
218 }
219
220 }
This page took 0.117518 seconds and 4 git commands to generate.