#378 Emoji suggestions (#546)

* #378 add emoji suggestion

* #378 use lazy load for nodeEmoji

* #378 apply remark styles only for light theme

* #378 add dark theme

* #378 fix test

* #378 use permanent class names

*  #378 flip if else for readability
This commit is contained in:
Konstantin Krivlenia
2020-01-13 18:26:32 -06:00
committed by Umputun
parent 3c12fde0f8
commit 17be003652
14 changed files with 304 additions and 39 deletions
@@ -15,6 +15,7 @@ import { SubscribeByRSS } from './__subscribe-by-rss';
import MarkdownToolbar from './markdown-toolbar';
import TextareaAutosize from './textarea-autosize';
import { TextExpander } from './text-expander';
let textareaId = 0;
@@ -144,7 +145,7 @@ export class CommentForm extends Component<Props, State> {
}
send(e: Event) {
const text = this.state.text;
const text = this.textAreaRef.current ? this.textAreaRef.current.getValue() : this.state.text;
const props = this.props;
if (e) e.preventDefault();
@@ -156,7 +157,7 @@ export class CommentForm extends Component<Props, State> {
this.setState({ preview: null, text: '' });
}
this.setState({ isDisabled: true, isErrorShown: false });
this.setState({ isDisabled: true, isErrorShown: false, text });
props
.onSubmit(text, pageTitle || document.title)
@@ -172,11 +173,11 @@ export class CommentForm extends Component<Props, State> {
}
getPreview() {
const text = this.state.text;
const text = this.textAreaRef.current ? this.textAreaRef.current.getValue() : this.state.text;
if (!text || !text.trim()) return;
this.setState({ isErrorShown: false, errorMessage: null });
this.setState({ isErrorShown: false, errorMessage: null, text });
this.props
.getPreview(text)
@@ -369,21 +370,22 @@ export class CommentForm extends Component<Props, State> {
</div>
)}
<div className="comment-form__field-wrapper">
<TextareaAutosize
id={this.textareaId}
onPaste={this.onPaste}
ref={this.textAreaRef}
className="comment-form__field"
placeholder="Your comment here"
value={text}
maxLength={maxLength}
onInput={this.onInput}
onKeyDown={this.onKeyDown}
disabled={isDisabled}
autofocus={!!props.autofocus}
spellcheck={true}
/>
<TextExpander>
<TextareaAutosize
id={this.textareaId}
onPaste={this.onPaste}
ref={this.textAreaRef}
className="comment-form__field"
placeholder="Your comment here"
value={text}
maxLength={maxLength}
onInput={this.onInput}
onKeyDown={this.onKeyDown}
disabled={isDisabled}
autofocus={!!props.autofocus}
spellcheck={true}
/>
</TextExpander>
{charactersLeft < 100 && <span className="comment-form__counter">{charactersLeft}</span>}
</div>
@@ -0,0 +1,61 @@
.suggester {
position: absolute;
top: 0;
left: 0;
z-index: 30;
min-width: 180px;
padding: 0;
margin: 24px 0 0;
list-style: none;
cursor: pointer;
background: #fff;
border: 1px solid #dfe2e5;
border-radius: 3px;
box-shadow: 0 1px 5px rgba(27, 31, 35, 0.15);
}
.suggesterDark {
background: #22201c;
color: #ddd;
border: 1px solid #333;
}
.suggesterItem {
display: block;
padding: 4px 8px;
font-size: 14px;
font-weight: 600;
border-bottom: 1px solid #dfe2e5;
}
.suggesterItemDark {
border-bottom: 1px solid #333;
}
.suggesterItem:hover,
.suggesterItem[aria-selected='true'] {
color: #fff;
text-decoration: none;
background-color: rgba(37, 156, 154, 0.6);
}
.suggesterItem:first-child {
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}
.suggesterItem:last-child {
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom: none;
}
.emojiResult {
margin-right: 0;
display: inline-block;
line-height: 20px;
font-size: 20px;
height: 20px;
vertical-align: middle;
}
@@ -0,0 +1,98 @@
/** @jsx createElement */
import { createElement, Fragment, render, FunctionalComponent } from 'preact';
import { StaticStore } from '@app/common/static_store';
import { useEffect, useRef } from 'preact/hooks';
import '@github/text-expander-element';
import styles from './text-expander.module.pcss';
import cx from 'classnames';
import { Theme } from '@app/common/types';
import useTheme from '@app/hooks/useTheme';
type Emoji = {
key: string;
emoji: string;
};
function SuggestionList({ items, theme }: { items: Array<Emoji>; theme: Theme }) {
const isDarkTheme = theme === `dark`;
const suggesterClass = cx(styles.suggester, { [styles.suggesterDark]: isDarkTheme });
const suggesterItemClass = cx(styles.suggesterItem, { [styles.suggesterItemDark]: isDarkTheme });
return (
<ul className={suggesterClass}>
{items.map(({ key, emoji }: Emoji) => {
return (
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props
<li role="option" className={suggesterItemClass} data-value={key}>
<span className={styles.emojiResult}>{emoji}</span> {key}
</li>
);
})}
</ul>
);
}
function searchEmoji(key: string, text: string, theme: Theme) {
return import(/* webpackChunkName: "node-emoji" */ `node-emoji`)
.then(nodeEmoji => {
if (key === ':') {
const emojiList = nodeEmoji.search(text);
if (emojiList.length === 0) {
return Promise.resolve({ matched: false });
}
const fragment = document.createDocumentFragment();
render(<SuggestionList theme={theme} items={emojiList.slice(0, 5)} />, fragment);
return Promise.resolve({ matched: true, fragment: fragment.firstChild });
}
return Promise.resolve({ matched: false });
})
.catch(() => Promise.resolve({ matched: false }));
}
type ChangeListerEvent = Event & {
detail: {
key: string;
text: string;
provide(value: Promise<{ matched: boolean }>): void;
};
};
type ValueListerEvent = Event & {
detail: {
key: string;
value: string;
item: HTMLLIElement;
};
};
export const TextExpander: FunctionalComponent = ({ children }) => {
const expanderRef = useRef<HTMLElement>();
const theme = useTheme();
useEffect(() => {
if (expanderRef.current) {
const expander = expanderRef.current;
expander.setAttribute(`keys`, ':');
const textExpanderChangeLister: EventListener = (event: Event) => {
const { provide, key, text } = (event as ChangeListerEvent).detail;
provide(searchEmoji(key, text, theme));
};
const textExpanderValueListener = (event: Event) => {
const { key, item } = (event as ValueListerEvent).detail;
if (key === ':') {
(event as ValueListerEvent).detail.value = `:${item.dataset.value}:`;
}
};
expander.addEventListener('text-expander-change', textExpanderChangeLister);
expander.addEventListener('text-expander-value', textExpanderValueListener);
return () => {
expander.removeEventListener('text-expander-change', textExpanderChangeLister);
expander.removeEventListener('text-expander-value', textExpanderValueListener);
};
}
return () => {};
}, [theme]);
if (StaticStore.config.emoji_enabled) {
return <text-expander ref={expanderRef}>{children}</text-expander>;
}
return <Fragment>{children}</Fragment>;
};
@@ -58,7 +58,9 @@ export default class TextareaAutosize extends Component<Props> {
onRef(node: HTMLTextAreaElement) {
this.textareaRef = node;
}
getValue() {
return this.textareaRef ? this.textareaRef.value : '';
}
autoResize() {
if (this.textareaRef) {
this.textareaRef.style.height = '';