You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

306 lines
8.1 KiB

// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import {
defineMessages,
FormattedMessage,
} from 'react-intl';
import Textarea from 'react-textarea-autosize';
// Components.
import EmojiPicker from 'flavours/glitch/features/emoji_picker';
import ComposerTextareaIcons from './icons';
import ComposerTextareaSuggestions from './suggestions';
// Utils.
import { isRtl } from 'flavours/glitch/util/rtl';
import {
assignHandlers,
hiddenComponent,
} from 'flavours/glitch/util/react_helpers';
// Messages.
const messages = defineMessages({
placeholder: {
defaultMessage: 'What is on your mind?',
id: 'compose_form.placeholder',
},
});
// Handlers.
const handlers = {
// When blurring the textarea, suggestions are hidden.
handleBlur () {
this.setState({ suggestionsHidden: true });
},
// When the contents of the textarea change, we have to pull up new
// autosuggest suggestions if applicable, and also change the value
// of the textarea in our store.
handleChange ({
target: {
selectionStart,
value,
},
}) {
const {
onChange,
onSuggestionsFetchRequested,
onSuggestionsClearRequested,
} = this.props;
const { lastToken } = this.state;
// This gets the token at the caret location, if it begins with an
// `@` (mentions) or `:` (shortcodes).
const left = value.slice(0, selectionStart).search(/[^\s\u200B]+$/);
const right = value.slice(selectionStart).search(/[\s\u200B]/);
const token = function () {
switch (true) {
case left < 0 || !/[@:]/.test(value[left]):
return null;
case right < 0:
return value.slice(left);
default:
return value.slice(left, right + selectionStart).trim().toLowerCase();
}
}();
// We only request suggestions for tokens which are at least 3
// characters long.
if (onSuggestionsFetchRequested && token && token.length >= 3) {
if (lastToken !== token) {
this.setState({
lastToken: token,
selectedSuggestion: 0,
tokenStart: left,
});
onSuggestionsFetchRequested(token);
}
} else {
this.setState({ lastToken: null });
if (onSuggestionsClearRequested) {
onSuggestionsClearRequested();
}
}
// Updates the value of the textarea.
if (onChange) {
onChange(value);
}
},
// Handles a click on an autosuggestion.
handleClickSuggestion (index) {
const { textarea } = this;
const {
onSuggestionSelected,
suggestions,
} = this.props;
const {
lastToken,
tokenStart,
} = this.state;
onSuggestionSelected(tokenStart, lastToken, suggestions.get(index));
textarea.focus();
},
// Handles a keypress. If the autosuggestions are visible, we need
// to allow keypresses to navigate and sleect them.
handleKeyDown (e) {
const {
disabled,
onSubmit,
onSuggestionSelected,
suggestions,
} = this.props;
const {
lastToken,
suggestionsHidden,
selectedSuggestion,
tokenStart,
} = this.state;
// Keypresses do nothing if the composer is disabled.
if (disabled) {
e.preventDefault();
return;
}
// Switches over the pressed key.
switch(e.key) {
// On arrow down, we pick the next suggestion.
case 'ArrowDown':
if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
}
return;
// On arrow up, we pick the previous suggestion.
case 'ArrowUp':
if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
}
return;
// On enter or tab, we select the suggestion.
case 'Enter':
case 'Tab':
if (onSuggestionSelected && lastToken !== null && suggestions && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
e.stopPropagation();
onSuggestionSelected(tokenStart, lastToken, suggestions.get(selectedSuggestion));
}
return;
}
// We submit the status on control/meta + enter.
if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
onSubmit();
}
},
// When the escape key is released, we either close the suggestions
// window or focus the UI.
handleKeyUp ({ key }) {
const { suggestionsHidden } = this.state;
if (key === 'Escape') {
if (!suggestionsHidden) {
this.setState({ suggestionsHidden: true });
} else {
document.querySelector('.ui').parentElement.focus();
}
}
},
// Handles the pasting of images into the composer.
handlePaste (e) {
const { onPaste } = this.props;
let d;
if (onPaste && (d = e.clipboardData) && (d = d.files).length === 1) {
onPaste(d);
e.preventDefault();
}
},
// Saves a reference to the textarea.
handleRefTextarea (textarea) {
this.textarea = textarea;
},
};
// The component.
export default class ComposerTextarea extends React.Component {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
this.state = {
suggestionsHidden: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0,
};
// Instance variables.
this.textarea = null;
}
// When we receive new suggestions, we unhide the suggestions window
// if we didn't have any suggestions before.
componentWillReceiveProps (nextProps) {
const { suggestions } = this.props;
const { suggestionsHidden } = this.state;
if (nextProps.suggestions && nextProps.suggestions !== suggestions && nextProps.suggestions.size > 0 && suggestionsHidden) {
this.setState({ suggestionsHidden: false });
}
}
// Rendering.
render () {
const {
handleBlur,
handleChange,
handleClickSuggestion,
handleKeyDown,
handleKeyUp,
handlePaste,
handleRefTextarea,
} = this.handlers;
const {
advancedOptions,
autoFocus,
disabled,
intl,
onPickEmoji,
suggestions,
value,
} = this.props;
const {
selectedSuggestion,
suggestionsHidden,
} = this.state;
// The result.
return (
<div className='composer--textarea'>
<label>
<span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span>
<ComposerTextareaIcons
advancedOptions={advancedOptions}
intl={intl}
/>
<Textarea
aria-autocomplete='list'
autoFocus={autoFocus}
className='textarea'
disabled={disabled}
inputRef={handleRefTextarea}
onBlur={handleBlur}
onChange={handleChange}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPaste={handlePaste}
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
style={{ direction: isRtl(value) ? 'rtl' : 'ltr' }}
/>
</label>
<EmojiPicker onPickEmoji={onPickEmoji} />
<ComposerTextareaSuggestions
hidden={suggestionsHidden}
onSuggestionClick={handleClickSuggestion}
suggestions={suggestions}
value={selectedSuggestion}
/>
</div>
);
}
}
// Props.
ComposerTextarea.propTypes = {
advancedOptions: ImmutablePropTypes.map,
autoFocus: PropTypes.bool,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func,
onPaste: PropTypes.func,
onPickEmoji: PropTypes.func,
onSubmit: PropTypes.func,
onSuggestionsClearRequested: PropTypes.func,
onSuggestionsFetchRequested: PropTypes.func,
onSuggestionSelected: PropTypes.func,
suggestions: ImmutablePropTypes.list,
value: PropTypes.string,
};
// Default props.
ComposerTextarea.defaultProps = { autoFocus: true };