diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 0ee663766ab..94062f2be8a 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -383,7 +383,7 @@ export function readyComposeSuggestionsAccounts(token, accounts) { }; }; -export function selectComposeSuggestion(position, token, suggestion) { +export function selectComposeSuggestion(position, token, suggestion, path) { return (dispatch, getState) => { let completion, startPosition; @@ -405,6 +405,7 @@ export function selectComposeSuggestion(position, token, suggestion) { position: startPosition, token, completion, + path, }); }; }; diff --git a/app/javascript/mastodon/components/autosuggest_input.js b/app/javascript/mastodon/components/autosuggest_input.js new file mode 100644 index 00000000000..bb8ab60db5b --- /dev/null +++ b/app/javascript/mastodon/components/autosuggest_input.js @@ -0,0 +1,229 @@ +import React from 'react'; +import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; +import AutosuggestEmoji from './autosuggest_emoji'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { isRtl } from '../rtl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import classNames from 'classnames'; +import { List as ImmutableList } from 'immutable'; + +const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { + 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 < 3 || searchTokens.indexOf(word[0]) === -1) { + return [null, null]; + } + + word = word.trim().toLowerCase(); + + if (word.length > 0) { + return [left + 1, word]; + } else { + return [null, null]; + } +}; + +export default class AutosuggestInput extends ImmutablePureComponent { + + static propTypes = { + value: PropTypes.string, + suggestions: ImmutablePropTypes.list, + disabled: PropTypes.bool, + placeholder: PropTypes.string, + onSuggestionSelected: PropTypes.func.isRequired, + onSuggestionsClearRequested: PropTypes.func.isRequired, + onSuggestionsFetchRequested: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onKeyUp: PropTypes.func, + onKeyDown: PropTypes.func, + autoFocus: PropTypes.bool, + className: PropTypes.string, + id: PropTypes.string, + searchTokens: PropTypes.list, + maxLength: PropTypes.number, + }; + + static defaultProps = { + autoFocus: true, + searchTokens: ImmutableList(['@', ':', '#']), + }; + + state = { + suggestionsHidden: true, + focused: false, + selectedSuggestion: 0, + lastToken: null, + tokenStart: 0, + }; + + onChange = (e) => { + const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens); + + if (token !== null && this.state.lastToken !== token) { + this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); + this.props.onSuggestionsFetchRequested(token); + } else if (token === 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; + } + + if (e.which === 229 || e.isComposing) { + // Ignore key events during text composition + // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac) + return; + } + + switch(e.key) { + case 'Escape': + if (suggestions.size === 0 || suggestionsHidden) { + document.querySelector('.ui').parentElement.focus(); + } else { + 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; + } + + if (e.defaultPrevented || !this.props.onKeyDown) { + return; + } + + this.props.onKeyDown(e); + } + + onBlur = () => { + this.setState({ suggestionsHidden: true, focused: false }); + } + + onFocus = () => { + this.setState({ focused: true }); + } + + onSuggestionClick = (e) => { + const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); + e.preventDefault(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); + this.input.focus(); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { + this.setState({ suggestionsHidden: false }); + } + } + + setInput = (c) => { + this.input = c; + } + + renderSuggestion = (suggestion, i) => { + const { selectedSuggestion } = this.state; + let inner, key; + + if (typeof suggestion === 'object') { + inner = ; + key = suggestion.id; + } else if (suggestion[0] === '#') { + inner = suggestion; + key = suggestion; + } else { + inner = ; + key = suggestion; + } + + return ( +
+ {inner} +
+ ); + } + + render () { + const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props; + const { suggestionsHidden } = this.state; + const style = { direction: 'ltr' }; + + if (isRtl(value)) { + style.direction = 'rtl'; + } + + return ( +
+ + +
+ {suggestions.map(this.renderSuggestion)} +
+
+ ); + } + +} diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js index a4f5cf50c65..f3fb7fa8bfa 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.js +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -55,7 +55,8 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { }; state = { - suggestionsHidden: false, + suggestionsHidden: true, + focused: false, selectedSuggestion: 0, lastToken: null, tokenStart: 0, @@ -134,7 +135,11 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } onBlur = () => { - this.setState({ suggestionsHidden: true }); + this.setState({ suggestionsHidden: true, focused: false }); + } + + onFocus = () => { + this.setState({ focused: true }); } onSuggestionClick = (e) => { @@ -145,7 +150,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } componentWillReceiveProps (nextProps) { - if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) { + if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { this.setState({ suggestionsHidden: false }); } } @@ -207,6 +212,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { onChange={this.onChange} onKeyDown={this.onKeyDown} onKeyUp={onKeyUp} + onFocus={this.onFocus} onBlur={this.onBlur} onPaste={this.onPaste} style={style} diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 2b9da20d7f3..cf82ef5a87d 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -5,6 +5,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import ReplyIndicatorContainer from '../containers/reply_indicator_container'; import AutosuggestTextarea from '../../../components/autosuggest_textarea'; +import AutosuggestInput from '../../../components/autosuggest_input'; import PollButtonContainer from '../containers/poll_button_container'; import UploadButtonContainer from '../containers/upload_button_container'; import { defineMessages, injectIntl } from 'react-intl'; @@ -102,7 +103,11 @@ class ComposeForm extends ImmutablePureComponent { } onSuggestionSelected = (tokenStart, token, value) => { - this.props.onSuggestionSelected(tokenStart, token, value); + this.props.onSuggestionSelected(tokenStart, token, value, ['text']); + } + + onSpoilerSuggestionSelected = (tokenStart, token, value) => { + this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']); } handleChangeSpoilerText = (e) => { @@ -135,7 +140,7 @@ class ComposeForm extends ImmutablePureComponent { this.autosuggestTextarea.textarea.focus(); } else if (this.props.spoiler !== prevProps.spoiler) { if (this.props.spoiler) { - this.spoilerText.focus(); + this.spoilerText.input.focus(); } else { this.autosuggestTextarea.textarea.focus(); } @@ -178,10 +183,21 @@ class ComposeForm extends ImmutablePureComponent {
- +
diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js index 383e37eb652..211601d5206 100644 --- a/app/javascript/mastodon/features/compose/components/poll_form.js +++ b/app/javascript/mastodon/features/compose/components/poll_form.js @@ -5,6 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import IconButton from 'mastodon/components/icon_button'; import Icon from 'mastodon/components/icon'; +import AutosuggestInput from 'mastodon/components/autosuggest_input'; import classNames from 'classnames'; const messages = defineMessages({ @@ -27,6 +28,10 @@ class Option extends React.PureComponent { onChange: PropTypes.func.isRequired, onRemove: PropTypes.func.isRequired, onToggleMultiple: PropTypes.func.isRequired, + suggestions: ImmutablePropTypes.list, + onClearSuggestions: PropTypes.func.isRequired, + onFetchSuggestions: PropTypes.func.isRequired, + onSuggestionSelected: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; @@ -38,12 +43,25 @@ class Option extends React.PureComponent { this.props.onRemove(this.props.index); }; + handleToggleMultiple = e => { this.props.onToggleMultiple(); e.preventDefault(); e.stopPropagation(); }; + onSuggestionsClearRequested = () => { + this.props.onClearSuggestions(); + } + + onSuggestionsFetchRequested = (token) => { + this.props.onFetchSuggestions(token); + } + + onSuggestionSelected = (tokenStart, token, value) => { + this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]); + } + render () { const { isPollMultiple, title, index, intl } = this.props; @@ -57,12 +75,16 @@ class Option extends React.PureComponent { tabIndex='0' /> - @@ -87,6 +109,10 @@ class PollForm extends ImmutablePureComponent { onAddOption: PropTypes.func.isRequired, onRemoveOption: PropTypes.func.isRequired, onChangeSettings: PropTypes.func.isRequired, + suggestions: ImmutablePropTypes.list, + onClearSuggestions: PropTypes.func.isRequired, + onFetchSuggestions: PropTypes.func.isRequired, + onSuggestionSelected: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; @@ -103,7 +129,7 @@ class PollForm extends ImmutablePureComponent { }; render () { - const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl } = this.props; + const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props; if (!options) { return null; @@ -112,7 +138,7 @@ class PollForm extends ImmutablePureComponent { return (
    - {options.map((title, i) =>
diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index f9f1fba3663..93a46838806 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -45,8 +45,8 @@ const mapDispatchToProps = (dispatch) => ({ dispatch(fetchComposeSuggestions(token)); }, - onSuggestionSelected (position, token, suggestion) { - dispatch(selectComposeSuggestion(position, token, suggestion)); + onSuggestionSelected (position, token, suggestion, path) { + dispatch(selectComposeSuggestion(position, token, suggestion, path)); }, onChangeSpoilerText (checked) { diff --git a/app/javascript/mastodon/features/compose/containers/poll_form_container.js b/app/javascript/mastodon/features/compose/containers/poll_form_container.js index da795a29121..1401371d0f0 100644 --- a/app/javascript/mastodon/features/compose/containers/poll_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/poll_form_container.js @@ -1,8 +1,14 @@ import { connect } from 'react-redux'; import PollForm from '../components/poll_form'; import { addPollOption, removePollOption, changePollOption, changePollSettings } from '../../../actions/compose'; +import { + clearComposeSuggestions, + fetchComposeSuggestions, + selectComposeSuggestion, +} from '../../../actions/compose'; const mapStateToProps = state => ({ + suggestions: state.getIn(['compose', 'suggestions']), options: state.getIn(['compose', 'poll', 'options']), expiresIn: state.getIn(['compose', 'poll', 'expires_in']), isMultiple: state.getIn(['compose', 'poll', 'multiple']), @@ -24,6 +30,19 @@ const mapDispatchToProps = dispatch => ({ onChangeSettings(expiresIn, isMultiple) { dispatch(changePollSettings(expiresIn, isMultiple)); }, + + onClearSuggestions () { + dispatch(clearComposeSuggestions()); + }, + + onFetchSuggestions (token) { + dispatch(fetchComposeSuggestions(token)); + }, + + onSuggestionSelected (position, token, accountId, path) { + dispatch(selectComposeSuggestion(position, token, accountId, path)); + }, + }); export default connect(mapStateToProps, mapDispatchToProps)(PollForm); diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index b45def281b6..39cc5bd8178 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -131,13 +131,15 @@ function removeMedia(state, mediaId) { }); }; -const insertSuggestion = (state, position, token, completion) => { +const insertSuggestion = (state, position, token, completion, path) => { return state.withMutations(map => { - map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`); + map.updateIn(path, oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`); map.set('suggestion_token', null); - map.update('suggestions', ImmutableList(), list => list.clear()); - map.set('focusDate', new Date()); - map.set('caretPosition', position + completion.length + 1); + map.set('suggestions', ImmutableList()); + if (path.length === 1 && path[0] === 'text') { + map.set('focusDate', new Date()); + map.set('caretPosition', position + completion.length + 1); + } map.set('idempotencyKey', uuid()); }); }; @@ -304,7 +306,7 @@ export default function compose(state = initialState, action) { case COMPOSE_SUGGESTIONS_READY: return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token); case COMPOSE_SUGGESTION_SELECT: - return insertSuggestion(state, action.position, action.token, action.completion); + return insertSuggestion(state, action.position, action.token, action.completion, action.path); case COMPOSE_SUGGESTION_TAGS_UPDATE: return updateSuggestionTags(state, action.token); case COMPOSE_TAG_HISTORY_UPDATE: diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 0da3ed9096d..e8c5f70f554 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -319,6 +319,7 @@ } .autosuggest-textarea, + .autosuggest-input, .spoiler-input { position: relative; } diff --git a/app/javascript/styles/mastodon/polls.scss b/app/javascript/styles/mastodon/polls.scss index 37c454a783c..0d55afda409 100644 --- a/app/javascript/styles/mastodon/polls.scss +++ b/app/javascript/styles/mastodon/polls.scss @@ -37,11 +37,14 @@ display: none; } + .autossugest-input { + flex: 1 1 auto; + } + input[type=text] { display: block; box-sizing: border-box; - flex: 1 1 auto; - width: 20px; + width: 100%; font-size: 14px; color: $inverted-text-color; display: block; @@ -64,6 +67,7 @@ &.editable { display: flex; align-items: center; + overflow: visible; } }