diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2016-12-14 18:21:31 +0100 |
---|---|---|
committer | Eugen Rochko <eugen@zeonfederated.com> | 2016-12-14 18:21:31 +0100 |
commit | b27066e154c8c2da57f23bf659907bacd37ce4da (patch) | |
tree | 25fe95ddb85b78978d529d88c57d619682c98bb7 /app/assets/javascripts/components | |
parent | 4284093aa3c33ee7d163d6d4343e60eb4df561c6 (diff) |
Re-implemented autosuggestions component for the compose form
Fix #205, fix #156, fix #124
Diffstat (limited to 'app/assets/javascripts/components')
5 files changed, 183 insertions, 109 deletions
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index ec5465381..a9fbe6b91 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -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 }); }; diff --git a/app/assets/javascripts/components/components/autosuggest_textarea.jsx b/app/assets/javascripts/components/components/autosuggest_textarea.jsx new file mode 100644 index 000000000..378b0cda4 --- /dev/null +++ b/app/assets/javascripts/components/components/autosuggest_textarea.jsx @@ -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; diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx index 00589b3c8..02f394993 100644 --- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx @@ -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(); - } - } - }, - - onSuggestionSelected (e, { suggestionValue }) { - const textarea = this.autosuggest.input; - - if (textarea) { - this.props.onSuggestionSelected(textarea.selectionStart, suggestionValue); - } + onSuggestionsFetchRequested (token) { + this.props.onFetchSuggestions(token); }, - 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' }}> diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx index 8aa719476..c774b2687 100644 --- a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx @@ -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) { diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx index 9d1d53083..4bb76dff0 100644 --- a/app/assets/javascripts/components/reducers/compose.jsx +++ b/app/assets/javascripts/components/reducers/compose.jsx @@ -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); |