diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2016-10-30 18:13:05 +0100 |
---|---|---|
committer | Eugen Rochko <eugen@zeonfederated.com> | 2016-10-30 18:13:05 +0100 |
commit | c49f6290eb9c93720bd5407f4320bb0fd6c96ed9 (patch) | |
tree | 6b39ea083c41313b1443d71a6b1adaaf1d31f431 | |
parent | fa1cc2d05a12783e166f30bf7a0b3239ebccf732 (diff) |
Basic username autocomplete for text area
-rw-r--r-- | app/assets/javascripts/components/actions/compose.jsx | 27 | ||||
-rw-r--r-- | app/assets/javascripts/components/features/ui/components/compose_form.jsx | 105 | ||||
-rw-r--r-- | app/assets/javascripts/components/features/ui/components/upload_button.jsx | 2 | ||||
-rw-r--r-- | app/assets/javascripts/components/features/ui/containers/compose_form_container.jsx | 29 | ||||
-rw-r--r-- | app/assets/javascripts/components/reducers/compose.jsx | 11 | ||||
-rw-r--r-- | app/assets/stylesheets/components.scss | 28 | ||||
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | yarn.lock | 45 |
8 files changed, 235 insertions, 13 deletions
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index 1bf95eec0..e27b606ee 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -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 + }; +}; diff --git a/app/assets/javascripts/components/features/ui/components/compose_form.jsx b/app/assets/javascripts/components/features/ui/components/compose_form.jsx index 5b00fc1b9..464423cf8 100644 --- a/app/assets/javascripts/components/features/ui/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/ui/components/compose_form.jsx @@ -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> diff --git a/app/assets/javascripts/components/features/ui/components/upload_button.jsx b/app/assets/javascripts/components/features/ui/components/upload_button.jsx index d1b093242..9e9fc7298 100644 --- a/app/assets/javascripts/components/features/ui/components/upload_button.jsx +++ b/app/assets/javascripts/components/features/ui/components/upload_button.jsx @@ -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' }} /> diff --git a/app/assets/javascripts/components/features/ui/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/ui/containers/compose_form_container.jsx index 163d6fa20..dcfeef752 100644 --- a/app/assets/javascripts/components/features/ui/containers/compose_form_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/compose_form_container.jsx @@ -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)); } } }; diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx index 1c676326f..85799bf01 100644 --- a/app/assets/javascripts/components/reducers/compose.jsx +++ b/app/assets/javascripts/components/reducers/compose.jsx @@ -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); diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index 2b1c1194d..b720848c5 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -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; +} diff --git a/package.json b/package.json index 558e56541..e17fbef52 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/yarn.lock b/yarn.lock index 766ca3114..75baca887 100644 --- a/yarn.lock +++ b/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" |