Re-implemented autosuggestions component for the compose form
Fix #205, fix #156, fix #124
This commit is contained in:
@ -185,13 +185,14 @@ export function readyComposeSuggestions(token, accounts) {
|
||||
};
|
||||
};
|
||||
|
||||
export function selectComposeSuggestion(position, accountId) {
|
||||
export function selectComposeSuggestion(position, token, accountId) {
|
||||
return (dispatch, getState) => {
|
||||
const completion = getState().getIn(['accounts', accountId, 'acct']);
|
||||
|
||||
dispatch({
|
||||
type: COMPOSE_SUGGESTION_SELECT,
|
||||
position,
|
||||
token,
|
||||
completion
|
||||
});
|
||||
};
|
||||
|
@ -0,0 +1,153 @@
|
||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
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 < 2 || word[0] !== '@') {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
word = word.trim().toLowerCase().slice(1);
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left + 1, word];
|
||||
} else {
|
||||
return [null, null];
|
||||
}
|
||||
};
|
||||
|
||||
const AutosuggestTextarea = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
value: React.PropTypes.string,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
disabled: React.PropTypes.bool,
|
||||
placeholder: React.PropTypes.string,
|
||||
onSuggestionSelected: React.PropTypes.func.isRequired,
|
||||
onSuggestionsClearRequested: React.PropTypes.func.isRequired,
|
||||
onSuggestionsFetchRequested: React.PropTypes.func.isRequired,
|
||||
onChange: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
getInitialState () {
|
||||
return {
|
||||
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.state.lastToken != 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 (!suggestionsHidden) {
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
onSuggestionClick (suggestion, e) {
|
||||
e.preventDefault();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
||||
},
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
|
||||
this.setState({ suggestionsHidden: false });
|
||||
}
|
||||
},
|
||||
|
||||
setTextarea (c) {
|
||||
this.textarea = c;
|
||||
},
|
||||
|
||||
render () {
|
||||
const { value, suggestions, disabled, placeholder } = this.props;
|
||||
const { suggestionsHidden, selectedSuggestion } = this.state;
|
||||
|
||||
return (
|
||||
<div className='autosuggest-textarea'>
|
||||
<textarea
|
||||
ref={this.setTextarea}
|
||||
className='autosuggest-textarea__textarea'
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
/>
|
||||
|
||||
<div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'>
|
||||
{suggestions.map((suggestion, i) => (
|
||||
<div key={suggestion} className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`} onClick={this.onSuggestionClick.bind(this, suggestion)}>
|
||||
<AutosuggestAccountContainer id={suggestion} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default AutosuggestTextarea;
|
@ -4,7 +4,7 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ReplyIndicator from './reply_indicator';
|
||||
import UploadButton from './upload_button';
|
||||
import Autosuggest from 'react-autosuggest';
|
||||
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
|
||||
import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container';
|
||||
import { debounce } from 'react-decoration';
|
||||
import UploadButtonContainer from '../containers/upload_button_container';
|
||||
@ -16,59 +16,12 @@ const messages = defineMessages({
|
||||
publish: { id: 'compose_form.publish', defaultMessage: 'Publish' }
|
||||
});
|
||||
|
||||
const getTokenForSuggestions = (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 < 2 || word[0] !== '@') {
|
||||
return null;
|
||||
}
|
||||
|
||||
word = word.trim().toLowerCase().slice(1);
|
||||
|
||||
if (word.length > 0) {
|
||||
return word;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getSuggestionValue = suggestionId => suggestionId;
|
||||
const renderSuggestion = suggestionId => <AutosuggestAccountContainer id={suggestionId} />;
|
||||
|
||||
const textareaStyle = {
|
||||
display: 'block',
|
||||
boxSizing: 'border-box',
|
||||
width: '100%',
|
||||
height: '100px',
|
||||
resize: 'none',
|
||||
border: 'none',
|
||||
color: '#282c37',
|
||||
padding: '10px',
|
||||
fontFamily: 'Roboto',
|
||||
fontSize: '14px',
|
||||
margin: '0',
|
||||
resize: 'vertical'
|
||||
};
|
||||
|
||||
const renderInputComponent = inputProps => (
|
||||
<textarea {...inputProps} className='compose-form__textarea' style={textareaStyle} />
|
||||
);
|
||||
|
||||
const ComposeForm = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
text: React.PropTypes.string.isRequired,
|
||||
suggestion_token: React.PropTypes.string,
|
||||
suggestions: React.PropTypes.array,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
sensitive: React.PropTypes.bool,
|
||||
unlisted: React.PropTypes.bool,
|
||||
is_submitting: React.PropTypes.bool,
|
||||
@ -87,10 +40,6 @@ const ComposeForm = React.createClass({
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
handleChange (e) {
|
||||
if (typeof e.target.value === 'undefined' || typeof e.target.value === 'number') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onChange(e.target.value);
|
||||
},
|
||||
|
||||
@ -104,45 +53,17 @@ const ComposeForm = React.createClass({
|
||||
this.props.onSubmit();
|
||||
},
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to) {
|
||||
const textarea = this.autosuggest.input;
|
||||
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onSuggestionsClearRequested () {
|
||||
this.props.onClearSuggestions();
|
||||
},
|
||||
|
||||
@debounce(500)
|
||||
onSuggestionsFetchRequested ({ value }) {
|
||||
const textarea = this.autosuggest.input;
|
||||
|
||||
if (textarea) {
|
||||
const token = getTokenForSuggestions(value, textarea.selectionStart);
|
||||
|
||||
if (token !== null) {
|
||||
this.props.onFetchSuggestions(token);
|
||||
} else {
|
||||
this.props.onClearSuggestions();
|
||||
}
|
||||
}
|
||||
onSuggestionsFetchRequested (token) {
|
||||
this.props.onFetchSuggestions(token);
|
||||
},
|
||||
|
||||
onSuggestionSelected (e, { suggestionValue }) {
|
||||
const textarea = this.autosuggest.input;
|
||||
|
||||
if (textarea) {
|
||||
this.props.onSuggestionSelected(textarea.selectionStart, suggestionValue);
|
||||
}
|
||||
},
|
||||
|
||||
setRef (c) {
|
||||
this.autosuggest = c;
|
||||
onSuggestionSelected (tokenStart, token, value) {
|
||||
this.props.onSuggestionSelected(tokenStart, token, value);
|
||||
},
|
||||
|
||||
handleChangeSensitivity (e) {
|
||||
@ -153,6 +74,16 @@ const ComposeForm = React.createClass({
|
||||
this.props.onChangeVisibility(e.target.checked);
|
||||
},
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.in_reply_to !== this.props.in_reply_to) {
|
||||
this.autosuggestTextarea.textarea.focus();
|
||||
}
|
||||
},
|
||||
|
||||
setAutosuggestTextarea (c) {
|
||||
this.autosuggestTextarea = c;
|
||||
},
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
let replyArea = '';
|
||||
@ -162,29 +93,20 @@ const ComposeForm = React.createClass({
|
||||
replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
|
||||
}
|
||||
|
||||
const inputProps = {
|
||||
placeholder: intl.formatMessage(messages.placeholder),
|
||||
value: this.props.text,
|
||||
onKeyUp: this.handleKeyUp,
|
||||
onChange: this.handleChange,
|
||||
disabled: disabled
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '10px' }}>
|
||||
{replyArea}
|
||||
|
||||
<Autosuggest
|
||||
ref={this.setRef}
|
||||
<AutosuggestTextarea
|
||||
ref={this.setAutosuggestTextarea}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
disabled={disabled}
|
||||
value={this.props.text}
|
||||
onChange={this.handleChange}
|
||||
suggestions={this.props.suggestions}
|
||||
focusFirstSuggestion={true}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
onSuggestionSelected={this.onSuggestionSelected}
|
||||
getSuggestionValue={getSuggestionValue}
|
||||
renderSuggestion={renderSuggestion}
|
||||
renderInputComponent={renderInputComponent}
|
||||
inputProps={inputProps}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
|
||||
|
@ -19,7 +19,7 @@ const makeMapStateToProps = () => {
|
||||
return {
|
||||
text: state.getIn(['compose', 'text']),
|
||||
suggestion_token: state.getIn(['compose', 'suggestion_token']),
|
||||
suggestions: state.getIn(['compose', 'suggestions']).toJS(),
|
||||
suggestions: state.getIn(['compose', 'suggestions']),
|
||||
sensitive: state.getIn(['compose', 'sensitive']),
|
||||
unlisted: state.getIn(['compose', 'unlisted']),
|
||||
is_submitting: state.getIn(['compose', 'is_submitting']),
|
||||
@ -53,8 +53,8 @@ const mapDispatchToProps = function (dispatch) {
|
||||
dispatch(fetchComposeSuggestions(token));
|
||||
},
|
||||
|
||||
onSuggestionSelected (position, accountId) {
|
||||
dispatch(selectComposeSuggestion(position, accountId));
|
||||
onSuggestionSelected (position, token, accountId) {
|
||||
dispatch(selectComposeSuggestion(position, token, accountId));
|
||||
},
|
||||
|
||||
onChangeSensitivity (checked) {
|
||||
|
@ -75,11 +75,9 @@ function removeMedia(state, mediaId) {
|
||||
});
|
||||
};
|
||||
|
||||
const insertSuggestion = (state, position, completion) => {
|
||||
const token = state.get('suggestion_token');
|
||||
|
||||
const insertSuggestion = (state, position, token, completion) => {
|
||||
return state.withMutations(map => {
|
||||
map.update('text', oldText => `${oldText.slice(0, position - token.length)}${completion}${oldText.slice(position + token.length)}`);
|
||||
map.update('text', oldText => `${oldText.slice(0, position)}${completion}${oldText.slice(position + token.length)}`);
|
||||
map.set('suggestion_token', null);
|
||||
map.update('suggestions', Immutable.List(), list => list.clear());
|
||||
});
|
||||
@ -130,7 +128,7 @@ export default function compose(state = initialState, action) {
|
||||
case COMPOSE_SUGGESTIONS_READY:
|
||||
return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token);
|
||||
case COMPOSE_SUGGESTION_SELECT:
|
||||
return insertSuggestion(state, action.position, action.completion);
|
||||
return insertSuggestion(state, action.position, action.token, action.completion);
|
||||
case TIMELINE_DELETE:
|
||||
if (action.id === state.get('in_reply_to')) {
|
||||
return state.set('in_reply_to', null);
|
||||
|
@ -530,3 +530,43 @@
|
||||
background: lighten(#373b4a, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
.autosuggest-textarea {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.autosuggest-textarea__textarea {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
resize: none;
|
||||
border: none;
|
||||
color: #282c37;
|
||||
padding: 10px;
|
||||
font-family: 'Roboto';
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.autosuggest-textarea__suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
z-index: 99;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
|
||||
background: #d9e1e8;
|
||||
color: #282c37;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.autosuggest-textarea__suggestions__item {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
&.selected {
|
||||
background: #2b90d9;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user