Fix #205, fix #156, fix #124master
@@ -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 | |||
}); | |||
}; | |||
@@ -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; |
@@ -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' }}> | |||
@@ -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) { | |||
@@ -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); | |||
@@ -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; | |||
} | |||
} |
@@ -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" | |||
} | |||
} |
@@ -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); |
@@ -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,3 +1,4 @@ | |||
import { storiesOf } from '@kadira/storybook'; | |||
import Button from '../../app/assets/javascripts/components/components/button.jsx' | |||
storiesOf('Button', module) | |||
@@ -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>); |
@@ -1,6 +0,0 @@ | |||
import TabsBar from '../../app/assets/javascripts/components/features/ui/components/tabs_bar.jsx' | |||
storiesOf('TabsBar', module) | |||
.add('default state', () => ( | |||
<TabsBar /> | |||
)); |
@@ -1,3 +0,0 @@ | |||
#root { | |||
padding: 4rem; | |||
} |
@@ -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; | |||
} |
@@ -0,0 +1,13 @@ | |||
const path = require('path'); | |||
module.exports = { | |||
module: { | |||
loaders: [ | |||
{ | |||
test: /.scss$/, | |||
loaders: ["style", "css", "sass"], | |||
include: path.resolve(__dirname, '../') | |||
} | |||
] | |||
} | |||
} |