WIPgit status <Compose> Refactor; <Composer> ed.

This commit is contained in:
kibigo!
2017-12-23 22:16:45 -08:00
parent fc884d015a
commit 924ffe81d4
64 changed files with 2588 additions and 2002 deletions

View File

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

View File

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

View File

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