From b27066e154c8c2da57f23bf659907bacd37ce4da Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 14 Dec 2016 18:21:31 +0100 Subject: [PATCH] Re-implemented autosuggestions component for the compose form Fix #205, fix #156, fix #124 --- .../components/actions/compose.jsx | 3 +- .../components/autosuggest_textarea.jsx | 153 +++++ .../compose/components/compose_form.jsx | 122 +--- .../containers/compose_form_container.jsx | 6 +- .../components/reducers/compose.jsx | 8 +- app/assets/stylesheets/components.scss | 40 ++ package.json | 11 +- storybook/config.js | 12 +- .../stories/autosuggest_textarea.story.jsx | 6 + storybook/stories/button.story.jsx | 1 + storybook/stories/loading_indicator.story.jsx | 6 +- storybook/stories/tabs_bar.story.jsx | 6 - storybook/storybook.css | 3 - storybook/storybook.scss | 15 + storybook/webpack.config.js | 13 + yarn.lock | 524 +++++++++++++++++- 16 files changed, 787 insertions(+), 142 deletions(-) create mode 100644 app/assets/javascripts/components/components/autosuggest_textarea.jsx create mode 100644 storybook/stories/autosuggest_textarea.story.jsx delete mode 100644 storybook/stories/tabs_bar.story.jsx delete mode 100644 storybook/storybook.css create mode 100644 storybook/storybook.scss create mode 100644 storybook/webpack.config.js diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index ec5465381c..a9fbe6b918 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 0000000000..378b0cda4d --- /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 ( +
+