diff options
Diffstat (limited to 'app/javascript/flavours/glitch/features/composer/textarea')
3 files changed, 439 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/index.js b/app/javascript/flavours/glitch/features/composer/textarea/index.js new file mode 100644 index 000000000..ad0a35d7f --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/textarea/index.js @@ -0,0 +1,297 @@ +// 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 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. + blur () { + 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. + change ({ + 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. + clickSuggestion (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. + keyDown (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. + keyUp ({ 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. + paste (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. + refTextarea (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 { + blur, + change, + clickSuggestion, + keyDown, + keyUp, + paste, + refTextarea, + } = this.handlers; + const { + autoFocus, + disabled, + intl, + onPickEmoji, + suggestions, + value, + } = this.props; + const { + selectedSuggestion, + suggestionsHidden, + } = this.state; + + // The result. + return ( + <div className='autosuggest-textarea'> + <label> + <span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span> + <Textarea + aria-autocomplete='list' + autoFocus={autoFocus} + disabled={disabled} + inputRef={refTextarea} + onBlur={blur} + onChange={change} + onKeyDown={keyDown} + onKeyUp={keyUp} + onPaste={paste} + placeholder={intl.formatMessage(messages.placeholder)} + value={value} + style={{ direction: isRtl(value) ? 'rtl' : 'ltr' }} + /> + </label> + <EmojiPicker onPickEmoji={onPickEmoji} /> + <ComposerTextareaSuggestions + hidden={suggestionsHidden} + onSuggestionClick={clickSuggestion} + suggestions={suggestions} + value={selectedSuggestion} + /> + </div> + ); + } + +} + +// Props. +ComposerTextarea.propTypes = { + 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 }; diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js new file mode 100644 index 000000000..b90696910 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js @@ -0,0 +1,41 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// Components. +import ComposerTextareaSuggestionsItem from './item'; + +// The component. +export default function ComposerTextareaSuggestions ({ + hidden, + onSuggestionClick, + suggestions, + value, +}) { + const computedClass = classNames('comoser--textarea--suggestions', { hidden: hidden || suggestions.isEmpty() }); + + return ( + <div className={computedClass}> + {!hidden ? suggestions.map( + (suggestion, index) => ( + <ComposerTextareaSuggestionsItem + index={index} + key={typeof suggestion === 'object' ? suggestion.id : suggestion} + onClick={onSuggestionClick} + selected={index === value} + suggestion={suggestion} + /> + ) + ) : null} + </div> + ); +} + +ComposerTextareaSuggestions.propTypes = { + hidden: PropTypes.bool, + onSuggestionClick: PropTypes.func, + suggestions: ImmutablePropTypes.list, + value: PropTypes.string, +}; diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js new file mode 100644 index 000000000..08c99ed0e --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js @@ -0,0 +1,101 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +// Components. +import AccountContainer from 'flavours/glitch/containers/account_container'; + +// Utils. +import { unicodeMapping } from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light'; +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +// Gets our asset host from the environment, if available. +const assetHost = ((process || {}).env || {}).CDN_HOST || ''; + +// Handlers. +const handlers = { + + // Handles a click on a suggestion. + click (e) { + const { + index, + onClick, + } = this.props; + if (onClick) { + e.preventDefault(); + onClick(index); + } + }, +}; + +// The component. +export default class ComposerTextareaSuggestionsItem extends React.Component { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + } + + // Rendering. + render () { + const { click } = this.handlers; + const { + selected, + suggestion, + } = this.props; + const computedClass = classNames('composer--textarea--suggestions--item', { selected }); + + // The result. + return ( + <div + role='button' + tabIndex='0' + className={computedClass} + onMouseDown={click} + > + { // If the suggestion is an object, then we render an emoji. + // Otherwise, we render an account. + typeof suggestion === 'object' ? function () { + const url = function () { + if (suggestion.custom) { + return suggestion.imageUrl; + } else { + const mapping = unicodeMapping[suggestion.native] || unicodeMapping[suggestion.native.replace(/\uFE0F$/, '')]; + if (!mapping) { + return null; + } + return `${assetHost}/emoji/${mapping.filename}.svg`; + } + }(); + return url ? ( + <div className='emoji'> + <img + alt={suggestion.native || suggestion.colons} + className='emojione' + src={url} + /> + {suggestion.colons} + </div> + ) : null; + }() : ( + <AccountContainer + id={suggestion} + small + /> + ) + } + </div> + ); + } + +} + +// Props. +ComposerTextareaSuggestionsItem.propTypes = { + index: PropTypes.number, + onClick: PropTypes.func, + selected: PropTypes.bool, + suggestion: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), +}; |