From 17be0036520db8cbbd39fe320fef6ea752b29aa2 Mon Sep 17 00:00:00 2001 From: Konstantin Krivlenia Date: Tue, 14 Jan 2020 03:26:32 +0300 Subject: [PATCH] #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 --- frontend/.gitignore | 1 + frontend/app/@types/module.css.d.ts | 7 ++ frontend/app/@types/preact/index.d.ts | 4 + frontend/app/common/static_store.ts | 1 + frontend/app/common/types.ts | 1 + .../components/comment-form/comment-form.tsx | 40 ++++---- .../comment-form/text-expander.module.pcss | 61 ++++++++++++ .../components/comment-form/text-expander.tsx | 98 +++++++++++++++++++ .../comment-form/textarea-autosize.tsx | 4 +- frontend/app/testUtils/index.ts | 1 + frontend/jest.config.js | 1 + frontend/package-lock.json | 62 ++++++++++++ frontend/package.json | 7 ++ frontend/webpack.config.js | 55 +++++++---- 14 files changed, 304 insertions(+), 39 deletions(-) create mode 100644 frontend/.gitignore create mode 100644 frontend/app/@types/module.css.d.ts create mode 100644 frontend/app/components/comment-form/text-expander.module.pcss create mode 100644 frontend/app/components/comment-form/text-expander.tsx diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..4c49bd78 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1 @@ +.env diff --git a/frontend/app/@types/module.css.d.ts b/frontend/app/@types/module.css.d.ts new file mode 100644 index 00000000..237c2152 --- /dev/null +++ b/frontend/app/@types/module.css.d.ts @@ -0,0 +1,7 @@ +declare module '*.module.pcss' { + interface ClassNames { + [className: string]: string; + } + const classNames: ClassNames; + export = classNames; +} diff --git a/frontend/app/@types/preact/index.d.ts b/frontend/app/@types/preact/index.d.ts index 442ccaa8..4eb7357e 100644 --- a/frontend/app/@types/preact/index.d.ts +++ b/frontend/app/@types/preact/index.d.ts @@ -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; + }; } } } diff --git a/frontend/app/common/static_store.ts b/frontend/app/common/static_store.ts index 2d2e8f7d..76f1bc79 100644 --- a/frontend/app/common/static_store.ts +++ b/frontend/app/common/static_store.ts @@ -29,6 +29,7 @@ export const StaticStore: StaticStoreType = { simple_view: false, anon_vote: false, email_notifications: false, + emoji_enabled: false, }, query: querySettings as QuerySettingsType, }; diff --git a/frontend/app/common/types.ts b/frontend/app/common/types.ts index a7b70bfe..a159e7de 100644 --- a/frontend/app/common/types.ts +++ b/frontend/app/common/types.ts @@ -115,6 +115,7 @@ export interface Config { simple_view: boolean; anon_vote: boolean; email_notifications: boolean; + emoji_enabled: boolean; } export interface RemarkConfig { diff --git a/frontend/app/components/comment-form/comment-form.tsx b/frontend/app/components/comment-form/comment-form.tsx index 5d1defaf..0fb4a736 100644 --- a/frontend/app/components/comment-form/comment-form.tsx +++ b/frontend/app/components/comment-form/comment-form.tsx @@ -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 { } 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 { 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 { } 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 { )}
- - + + + {charactersLeft < 100 && {charactersLeft}}
diff --git a/frontend/app/components/comment-form/text-expander.module.pcss b/frontend/app/components/comment-form/text-expander.module.pcss new file mode 100644 index 00000000..9f0d5d3b --- /dev/null +++ b/frontend/app/components/comment-form/text-expander.module.pcss @@ -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; +} diff --git a/frontend/app/components/comment-form/text-expander.tsx b/frontend/app/components/comment-form/text-expander.tsx new file mode 100644 index 00000000..0b4fe089 --- /dev/null +++ b/frontend/app/components/comment-form/text-expander.tsx @@ -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; theme: Theme }) { + const isDarkTheme = theme === `dark`; + const suggesterClass = cx(styles.suggester, { [styles.suggesterDark]: isDarkTheme }); + const suggesterItemClass = cx(styles.suggesterItem, { [styles.suggesterItemDark]: isDarkTheme }); + return ( +
    + {items.map(({ key, emoji }: Emoji) => { + return ( + // eslint-disable-next-line jsx-a11y/role-has-required-aria-props +
  • + {emoji} {key} +
  • + ); + })} +
+ ); +} + +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(, 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(); + 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 {children}; + } + + return {children}; +}; diff --git a/frontend/app/components/comment-form/textarea-autosize.tsx b/frontend/app/components/comment-form/textarea-autosize.tsx index 15c6c6e4..ff1a06ec 100644 --- a/frontend/app/components/comment-form/textarea-autosize.tsx +++ b/frontend/app/components/comment-form/textarea-autosize.tsx @@ -58,7 +58,9 @@ export default class TextareaAutosize extends Component { onRef(node: HTMLTextAreaElement) { this.textareaRef = node; } - + getValue() { + return this.textareaRef ? this.textareaRef.value : ''; + } autoResize() { if (this.textareaRef) { this.textareaRef.style.height = ''; diff --git a/frontend/app/testUtils/index.ts b/frontend/app/testUtils/index.ts index fa789b93..22e7baa4 100644 --- a/frontend/app/testUtils/index.ts +++ b/frontend/app/testUtils/index.ts @@ -24,5 +24,6 @@ beforeEach(() => { simple_view: false, anon_vote: false, email_notifications: false, + emoji_enabled: true, }; }); diff --git a/frontend/jest.config.js b/frontend/jest.config.js index b27f6565..e28caa1b 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -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$': '/app/testUtils/mockStyles.js', '@app/(.*)': '/app/$1', '^react$': 'preact/compat', diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f8f65834..7534f3a9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 23239dba..0d97884f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index cfabe192..bf592d4c 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -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: {