about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2016-12-14 18:21:31 +0100
committerEugen Rochko <eugen@zeonfederated.com>2016-12-14 18:21:31 +0100
commitb27066e154c8c2da57f23bf659907bacd37ce4da (patch)
tree25fe95ddb85b78978d529d88c57d619682c98bb7 /app
parent4284093aa3c33ee7d163d6d4343e60eb4df561c6 (diff)
Re-implemented autosuggestions component for the compose form
Fix #205, fix #156, fix #124
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/components/actions/compose.jsx3
-rw-r--r--app/assets/javascripts/components/components/autosuggest_textarea.jsx153
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx122
-rw-r--r--app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx6
-rw-r--r--app/assets/javascripts/components/reducers/compose.jsx8
-rw-r--r--app/assets/stylesheets/components.scss40
6 files changed, 223 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);
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index 517fcd3f1..210e722cc 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -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;
+  }
+}