#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
+1
View File
@@ -0,0 +1 @@
.env
+7
View File
@@ -0,0 +1,7 @@
declare module '*.module.pcss' {
interface ClassNames {
[className: string]: string;
}
const classNames: ClassNames;
export = classNames;
}
+4
View File
@@ -19,6 +19,10 @@ declare module 'preact/src/jsx' {
'md-link': any;
'md-unordered-list': any;
'md-ordered-list': any;
'text-expander': {
ref: any;
children: any;
};
}
}
}
+1
View File
@@ -29,6 +29,7 @@ export const StaticStore: StaticStoreType = {
simple_view: false,
anon_vote: false,
email_notifications: false,
emoji_enabled: false,
},
query: querySettings as QuerySettingsType,
};
+1
View File
@@ -115,6 +115,7 @@ export interface Config {
simple_view: boolean;
anon_vote: boolean;
email_notifications: boolean;
emoji_enabled: boolean;
}
export interface RemarkConfig {
@@ -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 = '';
+1
View File
@@ -24,5 +24,6 @@ beforeEach(() => {
simple_view: false,
anon_vote: false,
email_notifications: false,
emoji_enabled: true,
};
});
+1
View File
@@ -8,6 +8,7 @@ module.exports = {
testRegex: '(\\.|/)(test|spec)\\.(j|t)s(x?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
moduleNameMapper: {
'\\.module.pcss': `identity-obj-proxy`,
'\\.scss$': '<rootDir>/app/testUtils/mockStyles.js',
'@app/(.*)': '<rootDir>/app/$1',
'^react$': 'preact/compat',
+62
View File
@@ -1137,11 +1137,24 @@
"minimist": "^1.2.0"
}
},
"@github/combobox-nav": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-1.0.1.tgz",
"integrity": "sha512-5I/jWX/ymlZ0zIbBLZVkZXE80cYqwnNRkFOw3DrwklEqHouTyNWrXUn9z2kAQL4aSKEYqLbyZbTyCB59TYcJjg=="
},
"@github/markdown-toolbar-element": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@github/markdown-toolbar-element/-/markdown-toolbar-element-1.1.0.tgz",
"integrity": "sha512-QBTFBSsfreBiS0h//3NL0D7qC5rsU2M9kwahKQWna01cbVMmgismW/PZlaizub+0Q3Yw2VBYAr08xJp/v0BiQQ=="
},
"@github/text-expander-element": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@github/text-expander-element/-/text-expander-element-1.0.1.tgz",
"integrity": "sha512-QAOvzcQ6mA/k9P2NSk+1pjLeL5XD86qZlQY4U13uVe4Pitb6xh08hFgUgZpDUeUkm0z3bphMFN/czuP/58/6zg==",
"requires": {
"@github/combobox-nav": "^1.0.0"
}
},
"@jest/console": {
"version": "24.7.1",
"resolved": "https://registry.npmjs.org/@jest/console/-/console-24.7.1.tgz",
@@ -1758,6 +1771,11 @@
"@types/node": "*"
}
},
"@types/classnames": {
"version": "2.2.9",
"resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.9.tgz",
"integrity": "sha512-MNl+rT5UmZeilaPxAVs6YaPC2m6aA8rofviZbhbxpPpl61uKodfdQVsBtgJGTqGizEf02oW3tsVe7FYB8kK14A=="
},
"@types/core-js": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/@types/core-js/-/core-js-2.5.2.tgz",
@@ -1867,6 +1885,11 @@
"integrity": "sha512-JNbGaHFCLwgHn/iCckiGSOZ1XYHsKFwREtzPwSGCVld1SGhOlmZw2D4ZI94HQCrBHbADzW9m4LER/8olJTRGHA==",
"dev": true
},
"@types/node-emoji": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@types/node-emoji/-/node-emoji-1.8.1.tgz",
"integrity": "sha512-0fRfA90FWm6KJfw6P9QGyo0HDTCmthZ7cWaBQndITlaWLTZ6njRyKwrwpzpg+n6kBXBIGKeUHEQuBx7bphGJkA=="
},
"@types/normalize-package-data": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz",
@@ -3579,6 +3602,11 @@
}
}
},
"classnames": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
},
"clean-css": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.1.tgz",
@@ -4698,6 +4726,12 @@
"domelementtype": "1"
}
},
"dotenv": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz",
"integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==",
"dev": true
},
"duplexer": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz",
@@ -6771,6 +6805,12 @@
"har-schema": "^2.0.0"
}
},
"harmony-reflect": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.1.tgz",
"integrity": "sha512-WJTeyp0JzGtHcuMsi7rw2VwtkvLa+JyfEKJCFyfcS0+CDkjQ5lHPu7zEhFZP+PDSRrEgXa5Ah0l1MbgbE41XjA==",
"dev": true
},
"has": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
@@ -7249,6 +7289,15 @@
"postcss": "^7.0.14"
}
},
"identity-obj-proxy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
"integrity": "sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ=",
"dev": true,
"requires": {
"harmony-reflect": "^1.4.6"
}
},
"ieee754": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
@@ -10182,6 +10231,11 @@
"integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=",
"dev": true
},
"lodash.toarray": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
"integrity": "sha1-JMS/zWsvuji/0FlNsRedjptlZWE="
},
"lodash.unescape": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.unescape/-/lodash.unescape-4.0.1.tgz",
@@ -10731,6 +10785,14 @@
"lower-case": "^1.1.1"
}
},
"node-emoji": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.10.0.tgz",
"integrity": "sha512-Yt3384If5H6BYGVHiHwTL+99OzJKHhgp82S8/dktEK73T26BazdgZ4JZh92xSVtGNJvz9UbXdNAc5hcrXV42vw==",
"requires": {
"lodash.toarray": "^4.4.0"
}
},
"node-fetch": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
+7
View File
@@ -51,6 +51,7 @@
"copy-webpack-plugin": "^5.1.1",
"css-loader": "^3.2.0",
"document-register-element": "^1.14.3",
"dotenv": "^8.2.0",
"enzyme": "^3.10.0",
"enzyme-adapter-preact-pure": "^2.1.0",
"es-check": "^5.1.0",
@@ -63,6 +64,7 @@
"file-loader": "^4.2.0",
"html-webpack-plugin": "^3.2.0",
"husky": "^3.0.9",
"identity-obj-proxy": "^3.0.0",
"jest": "^24.9.0",
"jest-enzyme": "^7.1.1",
"jest-extended": "^0.11.2",
@@ -94,11 +96,16 @@
},
"dependencies": {
"@github/markdown-toolbar-element": "^1.1.0",
"@github/text-expander-element": "^1.0.1",
"@types/classnames": "^2.2.9",
"@types/node-emoji": "^1.8.1",
"@webcomponents/custom-elements": "^1.3.0",
"bem-react-helper": "^1.1.2",
"classnames": "^2.2.6",
"core-js": "^3.2.1",
"focus-visible": "^5.0.2",
"intersection-observer": "^0.7.0",
"node-emoji": "^1.10.0",
"preact": "^10.0.1",
"react-redux": "^7.1.1",
"redux": "^4.0.4",
+36 -19
View File
@@ -1,5 +1,5 @@
/* eslint-disable no-console */
require('dotenv').config();
const path = require('path');
const webpack = require('webpack');
@@ -27,7 +27,7 @@ console.log(`REMARK_ENV = ${remarkUrl}`);
* so we have to exclude from ignore these modules
*/
function getExcluded() {
const modules = ['@github/markdown-toolbar-element'];
const modules = ['@github/markdown-toolbar-element', '@github/text-expander-element', '@github/combobox-nav'];
const exclude = new RegExp(`node_modules\\/(?!(${modules.map(m => m.replace(/\//g, '\\/')).join('|')})\\/).*`);
return {
@@ -38,24 +38,23 @@ function getExcluded() {
// console.log(getExcluded())
// process.exit(1)
const commonStyleLoaders = [
'css-loader',
{
loader: 'postcss-loader',
options: {
plugins: [
require('postcss-for'),
require('postcss-simple-vars'),
require('postcss-nested'),
require('postcss-calc'),
require('autoprefixer')({ overrideBrowserslist: ['> 1%'] }),
require('postcss-url')({ url: 'inline', maxSize: 5 }),
require('postcss-wrap')({ selector: `#${NODE_ID}` }),
require('postcss-csso'),
],
},
const postCssLoader = wrap => ({
loader: 'postcss-loader',
options: {
plugins: [
require('postcss-for'),
require('postcss-simple-vars'),
require('postcss-nested'),
require('postcss-calc'),
require('autoprefixer')({ overrideBrowserslist: ['> 1%'] }),
require('postcss-url')({ url: 'inline', maxSize: 5 }),
wrap ? require('postcss-wrap')({ selector: `#${NODE_ID}` }) : false,
require('postcss-csso'),
].filter(plugin => plugin),
},
];
});
const commonStyleLoaders = ['css-loader', postCssLoader(true)];
const babelConfigPath = path.resolve(__dirname, './.babelrc.js');
@@ -104,6 +103,24 @@ module.exports = () => ({
...commonStyleLoaders,
],
},
{
test: /\.module\.pcss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
{
loader: 'css-loader',
options: {
modules: {
mode: `local`,
localIdentName: `${NODE_ID}__[name]__[local]`,
},
},
},
postCssLoader(false),
],
},
{
test: /\.(png|jpg|jpeg|gif|svg)$/,
use: {