Browse Source

Re-implemented autosuggestions component for the compose form

Fix #205, fix #156, fix #124
pull/4/head
Eugen Rochko 5 years ago
parent
commit
b27066e154
16 changed files with 787 additions and 142 deletions
  1. +2
    -1
      app/assets/javascripts/components/actions/compose.jsx
  2. +153
    -0
      app/assets/javascripts/components/components/autosuggest_textarea.jsx
  3. +22
    -100
      app/assets/javascripts/components/features/compose/components/compose_form.jsx
  4. +3
    -3
      app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
  5. +3
    -5
      app/assets/javascripts/components/reducers/compose.jsx
  6. +40
    -0
      app/assets/stylesheets/components.scss
  7. +7
    -4
      package.json
  8. +9
    -3
      storybook/config.js
  9. +6
    -0
      storybook/stories/autosuggest_textarea.story.jsx
  10. +1
    -0
      storybook/stories/button.story.jsx
  11. +3
    -3
      storybook/stories/loading_indicator.story.jsx
  12. +0
    -6
      storybook/stories/tabs_bar.story.jsx
  13. +0
    -3
      storybook/storybook.css
  14. +15
    -0
      storybook/storybook.scss
  15. +13
    -0
      storybook/webpack.config.js
  16. +510
    -14
      yarn.lock

+ 2
- 1
app/assets/javascripts/components/actions/compose.jsx View File

@ -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
});
};

+ 153
- 0
app/assets/javascripts/components/components/autosuggest_textarea.jsx View File

@ -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;

+ 22
- 100
app/assets/javascripts/components/features/compose/components/compose_form.jsx View File

@ -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' }}>

+ 3
- 3
app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx View File

@ -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) {

+ 3
- 5
app/assets/javascripts/components/reducers/compose.jsx View File

@ -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);

+ 40
- 0
app/assets/stylesheets/components.scss View File

@ -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;
}
}

+ 7
- 4
package.json View File

@ -17,6 +17,7 @@
"browserify-incremental": "^3.1.1",
"chai": "^3.5.0",
"chai-enzyme": "^0.5.2",
"css-loader": "^0.26.1",
"emojione": "^2.2.6",
"enzyme": "^2.4.1",
"es6-promise": "^3.2.1",
@ -25,6 +26,7 @@
"intl": "^1.2.5",
"jsdom": "^9.6.0",
"mocha": "^3.1.1",
"node-sass": "^4.0.0",
"react": "^15.3.2",
"react-addons-perf": "^15.3.2",
"react-addons-pure-render-mixin": "^15.3.1",
@ -42,13 +44,14 @@
"react-router": "^2.8.0",
"react-router-scroll": "^0.3.2",
"react-simple-dropdown": "^1.1.4",
"react-storybook-addon-intl": "^0.1.0",
"react-toggle": "^2.1.1",
"redux": "^3.5.2",
"redux-immutable": "^3.0.8",
"redux-thunk": "^2.1.0",
"reselect": "^2.5.4",
"sinon": "^1.17.6"
},
"dependencies": {
"react-toggle": "^2.1.1"
"sass-loader": "^4.0.2",
"sinon": "^1.17.6",
"style-loader": "^0.13.1"
}
}

+ 9
- 3
storybook/config.js View File

@ -1,8 +1,14 @@
import { configure } from '@kadira/storybook';
import { configure, setAddon } from '@kadira/storybook';
import IntlAddon from 'react-storybook-addon-intl';
import React from 'react';
import { storiesOf, action } from '@kadira/storybook';
import { addLocaleData } from 'react-intl';
import en from 'react-intl/locale-data/en';
import '../app/assets/stylesheets/components.scss'
import './storybook.scss'
import './storybook.css'
setAddon(IntlAddon);
addLocaleData(en);
window.storiesOf = storiesOf;
window.action = action;
@ -11,7 +17,7 @@ window.React = React;
function loadStories () {
require('./stories/loading_indicator.story.jsx');
require('./stories/button.story.jsx');
require('./stories/tabs_bar.story.jsx');
require('./stories/autosuggest_textarea.story.jsx');
}
configure(loadStories, module);

+ 6
- 0
storybook/stories/autosuggest_textarea.story.jsx View File

@ -0,0 +1,6 @@
import { storiesOf } from '@kadira/storybook';
import AutosuggestTextarea from '../../app/assets/javascripts/components/components/autosuggest_textarea.jsx'
storiesOf('AutosuggestTextarea', module)
.add('default state', () => <AutosuggestTextarea />)
.add('with text', () => <AutosuggestTextarea value='Hello' />)

+ 1
- 0
storybook/stories/button.story.jsx View File

@ -1,3 +1,4 @@
import { storiesOf } from '@kadira/storybook';
import Button from '../../app/assets/javascripts/components/components/button.jsx'
storiesOf('Button', module)

+ 3
- 3
storybook/stories/loading_indicator.story.jsx View File

@ -1,6 +1,6 @@
import { storiesOf } from '@kadira/storybook';
import LoadingIndicator from '../../app/assets/javascripts/components/components/loading_indicator.jsx'
import { IntlProvider } from 'react-intl';
storiesOf('LoadingIndicator', module)
.add('default state', () => (
<LoadingIndicator />
));
.add('default state', () => <IntlProvider><LoadingIndicator /></IntlProvider>);

+ 0
- 6
storybook/stories/tabs_bar.story.jsx View File

@ -1,6 +0,0 @@
import TabsBar from '../../app/assets/javascripts/components/features/ui/components/tabs_bar.jsx'
storiesOf('TabsBar', module)
.add('default state', () => (
<TabsBar />
));

+ 0
- 3
storybook/storybook.css View File

@ -1,3 +0,0 @@
#root {
padding: 4rem;
}

+ 15
- 0
storybook/storybook.scss View File

@ -0,0 +1,15 @@
@import url(https://fonts.googleapis.com/css?family=Roboto:400,500,400italic);
@import url(https://fonts.googleapis.com/css?family=Roboto+Mono:400,500);
#root {
font-family: 'Roboto', sans-serif;
background: #282c37;
font-size: 13px;
line-height: 18px;
font-weight: 400;
color: #fff;
padding-bottom: 140px;
text-rendering: optimizelegibility;
font-feature-settings: "kern";
padding: 4rem;
}

+ 13
- 0
storybook/webpack.config.js View File

@ -0,0 +1,13 @@
const path = require('path');
module.exports = {
module: {
loaders: [
{
test: /.scss$/,
loaders: ["style", "css", "sass"],
include: path.resolve(__dirname, '../')
}
]
}
}

+ 510
- 14
yarn.lock
File diff suppressed because it is too large
View File


Loading…
Cancel
Save