Basic username autocomplete for text area
This commit is contained in:
		
							parent
							
								
									fa1cc2d05a
								
							
						
					
					
						commit
						c49f6290eb
					
				
					 8 changed files with 235 additions and 13 deletions
				
			
		|  | @ -13,6 +13,9 @@ export const COMPOSE_UPLOAD_FAIL     = 'COMPOSE_UPLOAD_FAIL'; | |||
| export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; | ||||
| export const COMPOSE_UPLOAD_UNDO     = 'COMPOSE_UPLOAD_UNDO'; | ||||
| 
 | ||||
| export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; | ||||
| export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; | ||||
| 
 | ||||
| export function changeCompose(text) { | ||||
|   return { | ||||
|     type: COMPOSE_CHANGE, | ||||
|  | @ -129,3 +132,27 @@ export function undoUploadCompose(media_id) { | |||
|     media_id: media_id | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function clearComposeSuggestions() { | ||||
|   return { | ||||
|     type: COMPOSE_SUGGESTIONS_CLEAR | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function fetchComposeSuggestions(token) { | ||||
|   return (dispatch, getState) => { | ||||
|     const loadedCandidates = getState().get('accounts').filter(item => item.get('acct').toLowerCase().slice(0, token.length) === token).map(item => ({ | ||||
|       label: item.get('acct'), | ||||
|       completion: item.get('acct').slice(0, token.length) | ||||
|     })).toList().toJS(); | ||||
| 
 | ||||
|     dispatch(readyComposeSuggestions(loadedCandidates)); | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function readyComposeSuggestions(accounts) { | ||||
|   return { | ||||
|     type: COMPOSE_SUGGESTIONS_READY, | ||||
|     accounts | ||||
|   }; | ||||
| }; | ||||
|  |  | |||
|  | @ -4,11 +4,62 @@ 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'; | ||||
| 
 | ||||
| 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 = suggestion => suggestion; | ||||
| 
 | ||||
| const renderSuggestion = suggestion => ( | ||||
|   <span>{suggestion}</span> | ||||
| ); | ||||
| 
 | ||||
| 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' | ||||
| }; | ||||
| 
 | ||||
| const renderInputComponent = inputProps => ( | ||||
|   <textarea {...inputProps} placeholder='What is on your mind?'  className='compose-form__textarea' style={textareaStyle} /> | ||||
| ); | ||||
| 
 | ||||
| const ComposeForm = React.createClass({ | ||||
| 
 | ||||
|   propTypes: { | ||||
|     text: React.PropTypes.string.isRequired, | ||||
|     suggestions: React.PropTypes.array, | ||||
|     is_submitting: React.PropTypes.bool, | ||||
|     is_uploading: React.PropTypes.bool, | ||||
|     in_reply_to: ImmutablePropTypes.map, | ||||
|  | @ -35,7 +86,39 @@ const ComposeForm = React.createClass({ | |||
| 
 | ||||
|   componentDidUpdate (prevProps) { | ||||
|     if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to) { | ||||
|       this.refs.textarea.focus(); | ||||
|       const node     = ReactDOM.findDOMNode(this.refs.autosuggest); | ||||
|       const textarea = node.querySelector('textarea'); | ||||
| 
 | ||||
|       if (textarea) { | ||||
|         textarea.focus(); | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
| 
 | ||||
|   onSuggestionsClearRequested () { | ||||
|     this.props.onClearSuggestions(); | ||||
|   }, | ||||
| 
 | ||||
|   onSuggestionsFetchRequested ({ value }) { | ||||
|     const node     = ReactDOM.findDOMNode(this.refs.autosuggest); | ||||
|     const textarea = node.querySelector('textarea'); | ||||
| 
 | ||||
|     if (textarea) { | ||||
|       const token = getTokenForSuggestions(value, textarea.selectionStart); | ||||
| 
 | ||||
|       if (token !== null) { | ||||
|         this.props.onFetchSuggestions(token); | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
| 
 | ||||
|   onSuggestionSelected (e, { suggestionValue, method }) { | ||||
|     const node     = ReactDOM.findDOMNode(this.refs.autosuggest); | ||||
|     const textarea = node.querySelector('textarea'); | ||||
| 
 | ||||
|     if (textarea) { | ||||
|       const str = this.props.text; | ||||
|       this.props.onChange([str.slice(0, textarea.selectionStart), suggestionValue, str.slice(textarea.selectionStart)].join('')); | ||||
|     } | ||||
|   }, | ||||
| 
 | ||||
|  | @ -47,11 +130,29 @@ const ComposeForm = React.createClass({ | |||
|       replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />; | ||||
|     } | ||||
| 
 | ||||
|     const inputProps = { | ||||
|       placeholder: 'What is on your mind?', | ||||
|       value: this.props.text, | ||||
|       onKeyUp: this.handleKeyUp, | ||||
|       onChange: this.handleChange, | ||||
|       disabled: disabled | ||||
|     }; | ||||
| 
 | ||||
|     return ( | ||||
|       <div style={{ padding: '10px' }}> | ||||
|         {replyArea} | ||||
| 
 | ||||
|         <textarea ref='textarea' disabled={disabled} placeholder='What is on your mind?' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='compose-form__textarea' style={{ display: 'block', boxSizing: 'border-box', width: '100%', height: '100px', resize: 'none', border: 'none', color: '#282c37', padding: '10px', fontFamily: 'Roboto', fontSize: '14px', margin: '0' }} /> | ||||
|         <Autosuggest | ||||
|           ref='autosuggest' | ||||
|           suggestions={this.props.suggestions} | ||||
|           onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} | ||||
|           onSuggestionsClearRequested={this.onSuggestionsClearRequested} | ||||
|           onSuggestionSelected={this.onSuggestionSelected} | ||||
|           getSuggestionValue={getSuggestionValue} | ||||
|           renderSuggestion={renderSuggestion} | ||||
|           renderInputComponent={renderInputComponent} | ||||
|           inputProps={inputProps} | ||||
|         /> | ||||
| 
 | ||||
|         <div style={{ marginTop: '10px', overflow: 'hidden' }}> | ||||
|           <div style={{ float: 'right' }}><Button text='Publish' onClick={this.handleSubmit} disabled={disabled} /></div> | ||||
|  |  | |||
|  | @ -24,7 +24,7 @@ const UploadButton = React.createClass({ | |||
|     return ( | ||||
|       <div> | ||||
|         <Button disabled={this.props.disabled} onClick={this.handleClick} block={true}> | ||||
|           <i className='fa fa-fw fa-photo' /> Add images | ||||
|           <i className='fa fa-fw fa-photo' /> Add media | ||||
|         </Button> | ||||
| 
 | ||||
|         <input ref='fileElement' type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} /> | ||||
|  |  | |||
|  | @ -1,7 +1,13 @@ | |||
| import { connect }                                          from 'react-redux'; | ||||
| import ComposeForm                                          from '../components/compose_form'; | ||||
| import { changeCompose, submitCompose, cancelReplyCompose } from '../../../actions/compose'; | ||||
| import { makeGetStatus }                                    from '../../../selectors'; | ||||
| import { connect } from 'react-redux'; | ||||
| import ComposeForm from '../components/compose_form'; | ||||
| import { | ||||
|   changeCompose, | ||||
|   submitCompose, | ||||
|   cancelReplyCompose, | ||||
|   clearComposeSuggestions, | ||||
|   fetchComposeSuggestions | ||||
| } from '../../../actions/compose'; | ||||
| import { makeGetStatus } from '../../../selectors'; | ||||
| 
 | ||||
| const makeMapStateToProps = () => { | ||||
|   const getStatus = makeGetStatus(); | ||||
|  | @ -9,6 +15,7 @@ const makeMapStateToProps = () => { | |||
|   const mapStateToProps = function (state, props) { | ||||
|     return { | ||||
|       text: state.getIn(['compose', 'text']), | ||||
|       suggestions: state.getIn(['compose', 'suggestions']), | ||||
|       is_submitting: state.getIn(['compose', 'is_submitting']), | ||||
|       is_uploading: state.getIn(['compose', 'is_uploading']), | ||||
|       in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])) | ||||
|  | @ -20,16 +27,24 @@ const makeMapStateToProps = () => { | |||
| 
 | ||||
| const mapDispatchToProps = function (dispatch) { | ||||
|   return { | ||||
|     onChange: function (text) { | ||||
|     onChange (text) { | ||||
|       dispatch(changeCompose(text)); | ||||
|     }, | ||||
| 
 | ||||
|     onSubmit: function () { | ||||
|     onSubmit () { | ||||
|       dispatch(submitCompose()); | ||||
|     }, | ||||
| 
 | ||||
|     onCancelReply: function () { | ||||
|     onCancelReply () { | ||||
|       dispatch(cancelReplyCompose()); | ||||
|     }, | ||||
| 
 | ||||
|     onClearSuggestions () { | ||||
|       dispatch(clearComposeSuggestions()); | ||||
|     }, | ||||
| 
 | ||||
|     onFetchSuggestions (token) { | ||||
|       dispatch(fetchComposeSuggestions(token)); | ||||
|     } | ||||
|   } | ||||
| }; | ||||
|  |  | |||
|  | @ -10,7 +10,9 @@ import { | |||
|   COMPOSE_UPLOAD_SUCCESS, | ||||
|   COMPOSE_UPLOAD_FAIL, | ||||
|   COMPOSE_UPLOAD_UNDO, | ||||
|   COMPOSE_UPLOAD_PROGRESS | ||||
|   COMPOSE_UPLOAD_PROGRESS, | ||||
|   COMPOSE_SUGGESTIONS_CLEAR, | ||||
|   COMPOSE_SUGGESTIONS_READY | ||||
| } from '../actions/compose'; | ||||
| import { TIMELINE_DELETE } from '../actions/timelines'; | ||||
| import { ACCOUNT_SET_SELF } from '../actions/accounts'; | ||||
|  | @ -22,7 +24,8 @@ const initialState = Immutable.Map({ | |||
|   is_submitting: false, | ||||
|   is_uploading: false, | ||||
|   progress: 0, | ||||
|   media_attachments: Immutable.List([]), | ||||
|   media_attachments: Immutable.List(), | ||||
|   suggestions: [], | ||||
|   me: null | ||||
| }); | ||||
| 
 | ||||
|  | @ -95,6 +98,10 @@ export default function compose(state = initialState, action) { | |||
|       return state.set('progress', Math.round((action.loaded / action.total) * 100)); | ||||
|     case COMPOSE_MENTION: | ||||
|       return state.update('text', text => `${text}@${action.account.get('acct')} `); | ||||
|     case COMPOSE_SUGGESTIONS_CLEAR: | ||||
|       return state.set('suggestions', []); | ||||
|     case COMPOSE_SUGGESTIONS_READY: | ||||
|       return state.set('suggestions', action.accounts); | ||||
|     case TIMELINE_DELETE: | ||||
|       if (action.id === state.get('in_reply_to')) { | ||||
|         return state.set('in_reply_to', null); | ||||
|  |  | |||
|  | @ -266,3 +266,31 @@ | |||
|     flex-direction: column; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .react-autosuggest__container { | ||||
|   position: relative; | ||||
| } | ||||
| 
 | ||||
| .react-autosuggest__suggestions-container { | ||||
|   position: absolute; | ||||
|   top: 100%; | ||||
|   width: 100%; | ||||
|   z-index: 99; | ||||
| } | ||||
| 
 | ||||
| .react-autosuggest__suggestions-list { | ||||
|   background: #9baec8; | ||||
|   color: #282c37; | ||||
|   box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); | ||||
|   font-size: 14px; | ||||
| } | ||||
| 
 | ||||
| .react-autosuggest__suggestion { | ||||
|   padding: 10px; | ||||
|   cursor: pointer; | ||||
| } | ||||
| 
 | ||||
| .react-autosuggest__suggestion--focused { | ||||
|   background: #2b90d9; | ||||
|   color: #fff; | ||||
| } | ||||
|  |  | |||
|  | @ -41,6 +41,7 @@ | |||
|     "sinon": "^1.17.6" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "react-autosuggest": "^7.0.1", | ||||
|     "react-responsive": "^1.1.5", | ||||
|     "react-router-scroll": "^0.3.2", | ||||
|     "react-skylight": "^0.4.1" | ||||
|  |  | |||
							
								
								
									
										45
									
								
								yarn.lock
									
										
									
									
									
								
							
							
						
						
									
										45
									
								
								yarn.lock
									
										
									
									
									
								
							|  | @ -3243,6 +3243,10 @@ oauth-sign@~0.8.1: | |||
|   version "0.8.2" | ||||
|   resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" | ||||
| 
 | ||||
| object-assign@^3.0.0: | ||||
|   version "3.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2" | ||||
| 
 | ||||
| object-assign@^4.0.0, object-assign@^4.0.1, object-assign@^4.1.0: | ||||
|   version "4.1.0" | ||||
|   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.0.tgz#7a3b3d0e98063d43f4c03f2e8ae6cd51a86883a0" | ||||
|  | @ -3807,6 +3811,22 @@ react-addons-pure-render-mixin@^15.3.1: | |||
|   version "15.3.2" | ||||
|   resolved "https://registry.yarnpkg.com/react-addons-test-utils/-/react-addons-test-utils-15.3.2.tgz#c09a44f583425a4a9c1b38444d7a6c3e6f0f41f6" | ||||
| 
 | ||||
| react-autosuggest: | ||||
|   version "7.0.1" | ||||
|   resolved "https://registry.yarnpkg.com/react-autosuggest/-/react-autosuggest-7.0.1.tgz#e751d2c2e516a344f6cdc150672e85f134f5f2f1" | ||||
|   dependencies: | ||||
|     react-autowhatever "^7.0.0" | ||||
|     react-redux "^4.4.5" | ||||
|     redux "^3.6.0" | ||||
|     shallow-equal "^1.0.0" | ||||
| 
 | ||||
| react-autowhatever@^7.0.0: | ||||
|   version "7.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/react-autowhatever/-/react-autowhatever-7.0.0.tgz#7ea19f8024183acf1568fc8e4b76c0d0cc250d00" | ||||
|   dependencies: | ||||
|     react-themeable "^1.1.0" | ||||
|     section-iterator "^2.0.0" | ||||
| 
 | ||||
| react-deep-force-update@^1.0.0: | ||||
|   version "1.0.1" | ||||
|   resolved "https://registry.yarnpkg.com/react-deep-force-update/-/react-deep-force-update-1.0.1.tgz#f911b5be1d2a6fe387507dd6e9a767aa2924b4c7" | ||||
|  | @ -3878,6 +3898,15 @@ react-redux-loading-bar@^2.3.3: | |||
|   version "2.4.0" | ||||
|   resolved "https://registry.yarnpkg.com/react-redux-loading-bar/-/react-redux-loading-bar-2.4.0.tgz#00cd884c7ea8e0146fb94aeb1435b1a0caffd888" | ||||
| 
 | ||||
| react-redux@^4.4.5: | ||||
|   version "4.4.5" | ||||
|   resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-4.4.5.tgz#f509a2981be2252d10c629ef7c559347a4aec457" | ||||
|   dependencies: | ||||
|     hoist-non-react-statics "^1.0.3" | ||||
|     invariant "^2.0.0" | ||||
|     lodash "^4.2.0" | ||||
|     loose-envify "^1.1.0" | ||||
| 
 | ||||
| react-redux@^5.0.0-beta.3: | ||||
|   version "5.0.0-beta.3" | ||||
|   resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.0.0-beta.3.tgz#d50bfb00799cf7d2a9fd55fe34d6b3ecc24d3072" | ||||
|  | @ -3931,6 +3960,12 @@ react-skylight: | |||
|   version "0.4.1" | ||||
|   resolved "https://registry.yarnpkg.com/react-skylight/-/react-skylight-0.4.1.tgz#07d1af6dea0a50a5d8122a786a8ce8bc6bdf2241" | ||||
| 
 | ||||
| react-themeable@^1.1.0: | ||||
|   version "1.1.0" | ||||
|   resolved "https://registry.yarnpkg.com/react-themeable/-/react-themeable-1.1.0.tgz#7d4466dd9b2b5fa75058727825e9f152ba379a0e" | ||||
|   dependencies: | ||||
|     object-assign "^3.0.0" | ||||
| 
 | ||||
| react@^15.3.2: | ||||
|   version "15.3.2" | ||||
|   resolved "https://registry.yarnpkg.com/react/-/react-15.3.2.tgz#a7bccd2fee8af126b0317e222c28d1d54528d09e" | ||||
|  | @ -4033,7 +4068,7 @@ redux-thunk@^2.1.0: | |||
|   version "2.1.0" | ||||
|   resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.1.0.tgz#c724bfee75dbe352da2e3ba9bc14302badd89a98" | ||||
| 
 | ||||
| redux@^3.5.2: | ||||
| redux@^3.5.2, redux@^3.6.0: | ||||
|   version "3.6.0" | ||||
|   resolved "https://registry.yarnpkg.com/redux/-/redux-3.6.0.tgz#887c2b3d0b9bd86eca2be70571c27654c19e188d" | ||||
|   dependencies: | ||||
|  | @ -4170,6 +4205,10 @@ scroll-behavior@^0.8.0: | |||
|     dom-helpers "^2.4.0" | ||||
|     invariant "^2.2.1" | ||||
| 
 | ||||
| section-iterator@^2.0.0: | ||||
|   version "2.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/section-iterator/-/section-iterator-2.0.0.tgz#bf444d7afeeb94ad43c39ad2fb26151627ccba2a" | ||||
| 
 | ||||
| semver@~5.3.0: | ||||
|   version "5.3.0" | ||||
|   resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" | ||||
|  | @ -4232,6 +4271,10 @@ sha.js@2.2.6: | |||
|   version "2.2.6" | ||||
|   resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.2.6.tgz#17ddeddc5f722fb66501658895461977867315ba" | ||||
| 
 | ||||
| shallow-equal@^1.0.0: | ||||
|   version "1.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.0.0.tgz#508d1838b3de590ab8757b011b25e430900945f7" | ||||
| 
 | ||||
| shallowequal@0.2.x: | ||||
|   version "0.2.2" | ||||
|   resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-0.2.2.tgz#1e32fd5bcab6ad688a4812cb0cc04efc75c7014e" | ||||
|  |  | |||
		Reference in a new issue