diff --git a/package.json b/package.json index 6080302bdbb..e381f68b5ee 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "@ecency/render-helper": "^2.2.26", "@ecency/render-helper-amp": "^1.1.0", + "@emoji-mart/data": "^1.1.2", "@firebase/analytics": "^0.8.0", "@firebase/app": "^0.7.28", "@firebase/messaging": "^0.9.16", @@ -34,6 +35,7 @@ "currency-symbol-map": "^4.0.4", "debounce": "^1.2.1", "diff-match-patch": "^1.0.5", + "emoji-mart": "^5.5.2", "express": "^4.17.3", "history": "^4.7.2", "hive-uri": "^0.2.3", diff --git a/src/common/components/decks/deck-settings/decks-settings.tsx b/src/common/components/decks/deck-settings/decks-settings.tsx index 407ea36d42c..9b9b8058f08 100644 --- a/src/common/components/decks/deck-settings/decks-settings.tsx +++ b/src/common/components/decks/deck-settings/decks-settings.tsx @@ -2,7 +2,7 @@ import { Alert, Button, Form, InputGroup, Modal } from "react-bootstrap"; import React, { useContext, useEffect, useState } from "react"; import "./_decks-settings.scss"; import { DeckGrid } from "../types"; -import EmojiPicker from "../../emoji-picker"; +import EmojiPicker from "../../emoji-picker/index-old"; import { deleteForeverSvg, emoticonHappyOutlineSvg } from "../../../img/svg"; import ClickAwayListener from "../../clickaway-listener"; import * as uuid from "uuid"; diff --git a/src/common/components/decks/deck-threads-form/deck-threads-form-emoji-picker.tsx b/src/common/components/decks/deck-threads-form/deck-threads-form-emoji-picker.tsx index e3711ead1c3..72587416795 100644 --- a/src/common/components/decks/deck-threads-form/deck-threads-form-emoji-picker.tsx +++ b/src/common/components/decks/deck-threads-form/deck-threads-form-emoji-picker.tsx @@ -1,28 +1,23 @@ -import React from "react"; +import React, { useRef } from "react"; import { emojiIconSvg } from "../icons"; import { _t } from "../../../i18n"; -import { PopperDropdown } from "../../popper-dropdown"; import Tooltip from "../../tooltip"; -import EmojiPicker from "../../emoji-picker"; +import { EmojiPicker } from "../../emoji-picker"; interface Props { onPick: (v: string) => void; } export const DeckThreadsFormEmojiPicker = ({ onPick }: Props) => { + const anchorRef = useRef(null); + return ( -
- - -
- { - onPick(value); - }} - /> -
-
-
+
+ {emojiIconSvg} + onPick(value)} />
); }; diff --git a/src/common/components/editor-toolbar/index.tsx b/src/common/components/editor-toolbar/index.tsx index c144bbf0b64..26a7e83099b 100644 --- a/src/common/components/editor-toolbar/index.tsx +++ b/src/common/components/editor-toolbar/index.tsx @@ -7,7 +7,7 @@ import { User } from "../../store/users/types"; import { Global } from "../../store/global/types"; import Tooltip from "../tooltip"; -import EmojiPicker from "../emoji-picker"; +import { EmojiPicker } from "../emoji-picker"; import GifPicker from "../gif-picker"; import Gallery from "../gallery"; import Fragments from "../fragments"; @@ -65,6 +65,7 @@ interface State { shGif: boolean; showVideoUpload: boolean; showVideoGallery: boolean; + isMounted: boolean; } export const detectEvent = (eventType: string) => { @@ -86,7 +87,8 @@ export class EditorToolbar extends Component { mobileImage: false, shGif: false, showVideoUpload: false, - showVideoGallery: false + showVideoGallery: false, + isMounted: false }; holder = React.createRef(); @@ -155,6 +157,9 @@ export class EditorToolbar extends Component { window.addEventListener("blockquote", this.quote); window.addEventListener("image", this.toggleImage); window.addEventListener("customToolbarEvent", this.handleCustomToolbarEvent); + this.setState({ + isMounted: true + }); } componentWillUnmount() { @@ -576,13 +581,12 @@ export class EditorToolbar extends Component { )} -
+
{emoticonHappyOutlineSvg} - {showEmoji && ( + {showEmoji && this.state.isMounted && ( { - this.insertText(e, ""); - }} + anchor={document.querySelector("#editor-tool-emoji-picker")!!} + onSelect={(e) => this.insertText(e, "")} /> )}
diff --git a/src/common/components/emoji-picker/_index-old.scss b/src/common/components/emoji-picker/_index-old.scss new file mode 100644 index 00000000000..c478b0df909 --- /dev/null +++ b/src/common/components/emoji-picker/_index-old.scss @@ -0,0 +1,74 @@ +@import "src/style/colors"; +@import "src/style/variables"; +@import "src/style/bootstrap_vars"; +@import "src/style/mixins"; + +.emoji-picker { + width: 280px; + position: absolute; + right: 0; + z-index: 100; + border-bottom-right-radius: 8px; + border-bottom-left-radius: 8px; + padding: 14px 6px; + + @media (max-width: $sm-break) { + width: 160px !important; + } + + @include themify(day) { + background: darken($white-three, 5); + } + + @include themify(night) { + background: $dark-two; + } + + .emoji-cat-list { + height: 160px; + overflow: auto; + + .emoji-cat { + .cat-title { + font-weight: 500; + font-size: 14px; + margin: 4px 0 6px 0; + + @include themify(day) { + color: $charcoal-grey; + } + + @include themify(night) { + color: $pinkish-grey; + } + } + + .emoji-list { + @include clearfix(); + display: flex; + flex-wrap: wrap; + + .emoji { + cursor: pointer; + font-size: 18px; + margin: 3px 3px 1px 3px; + align-items: center; + justify-content: center; + display: flex; + font-family: "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji"; + border-bottom: 2px solid transparent; + + &:hover { + @include themify(day) { + border-bottom: 2px solid $white-three; + } + + @include themify(night) { + border-bottom: 2px solid lighten($dark-six, 14); + } + } + } + } + } + } +} diff --git a/src/common/components/emoji-picker/_index.scss b/src/common/components/emoji-picker/_index.scss index c478b0df909..583adb88c9c 100644 --- a/src/common/components/emoji-picker/_index.scss +++ b/src/common/components/emoji-picker/_index.scss @@ -1,74 +1,30 @@ -@import "src/style/colors"; -@import "src/style/variables"; -@import "src/style/bootstrap_vars"; -@import "src/style/mixins"; +@import "src/style/vars_mixins"; -.emoji-picker { - width: 280px; +.emoji-picker-dialog { position: absolute; - right: 0; - z-index: 100; - border-bottom-right-radius: 8px; - border-bottom-left-radius: 8px; - padding: 14px 6px; + z-index: 201; - @media (max-width: $sm-break) { - width: 160px !important; + em-emoji-picker { + width: 300px; } - @include themify(day) { - background: darken($white-three, 5); - } - - @include themify(night) { - background: $dark-two; - } - - .emoji-cat-list { - height: 160px; - overflow: auto; - - .emoji-cat { - .cat-title { - font-weight: 500; - font-size: 14px; - margin: 4px 0 6px 0; - - @include themify(day) { - color: $charcoal-grey; - } - - @include themify(night) { - color: $pinkish-grey; - } - } - - .emoji-list { - @include clearfix(); - display: flex; - flex-wrap: wrap; - - .emoji { - cursor: pointer; - font-size: 18px; - margin: 3px 3px 1px 3px; - align-items: center; - justify-content: center; - display: flex; - font-family: "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji"; - border-bottom: 2px solid transparent; - - &:hover { - @include themify(day) { - border-bottom: 2px solid $white-three; - } + @include media-breakpoint-down(sm) { + bottom: 0; + top: unset !important; + left: 0 !important; + width: 100%; + background: #fff; + align-items: center; + justify-content: center; + border-top: 1px solid var(--border-color); + position: fixed; + + @include themify(night) { + background-color: rgba(21, 22, 23); + } - @include themify(night) { - border-bottom: 2px solid lighten($dark-six, 14); - } - } - } - } + em-emoji-picker { + width: 100%; } } -} +} \ No newline at end of file diff --git a/src/common/components/emoji-picker/index-old.tsx b/src/common/components/emoji-picker/index-old.tsx new file mode 100644 index 00000000000..57420a94fa8 --- /dev/null +++ b/src/common/components/emoji-picker/index-old.tsx @@ -0,0 +1,201 @@ +import React from "react"; +import { FormControl } from "react-bootstrap"; +import BaseComponent from "../base"; +import SearchBox from "../search-box"; +import { _t } from "../../i18n"; +import { getEmojiData } from "../../api/misc"; +import * as ls from "../../util/local-storage"; +import { insertOrReplace } from "../../util/input-util"; +import "./_index-old.scss"; + +interface Emoji { + a: string; + b: string; + j: string[]; +} + +interface EmojiCategory { + id: string; + name: string; + emojis: string[]; +} + +interface EmojiData { + categories: EmojiCategory[]; + emojis: Record; +} + +interface EmojiCacheItem { + id: string; + name: string; + keywords: string[]; +} + +interface Props { + fallback?: (e: string) => void; +} + +interface State { + data: EmojiData | null; + cache: EmojiCacheItem[] | null; + filter: string; +} + +export default class EmojiPicker extends BaseComponent { + state: State = { + data: null, + cache: null, + filter: "" + }; + + _target: HTMLInputElement | null = null; + + componentDidMount() { + getEmojiData().then((data) => this.setData(data)); + + this.watchTarget(); // initial + + if (typeof window !== "undefined") { + window.addEventListener("focus", this.watchTarget, true); + } + } + + componentWillUnmount() { + super.componentWillUnmount(); + if (typeof window !== "undefined") { + window.removeEventListener("focus", this.watchTarget, true); + } + } + + watchTarget = () => { + if (document.activeElement?.classList.contains("accepts-emoji")) { + this._target = document.activeElement as HTMLInputElement; + } + }; + + setData = (data: EmojiData) => { + const cache: EmojiCacheItem[] = Object.keys(data.emojis).map((e) => { + const em = data.emojis[e]; + return { + id: e, + name: em.a.toLowerCase(), + keywords: em.j ? em.j : [] + }; + }); + + this.stateSet({ data, cache }); + }; + + filterChanged = (e: React.ChangeEvent) => { + this.setState({ filter: e.target.value }); + }; + + clicked = (id: string, native: string) => { + const recent = ls.get("recent-emoji", []); + if (!recent.includes(id)) { + const newRecent = [...new Set([id, ...recent])].slice(0, 18); + ls.set("recent-emoji", newRecent); + this.forceUpdate(); // Re-render recent list + } + + if (this._target) { + insertOrReplace(this._target, native); + } else { + const { fallback } = this.props; + if (fallback) fallback(native); + } + }; + + renderEmoji = (emoji: string) => { + const { data } = this.state; + const em = data!.emojis[emoji]; + if (!em) { + return null; + } + const unicodes = em.b.split("-"); + const codePoints = unicodes.map((u) => Number(`0x${u}`)); + const native = String.fromCodePoint(...codePoints); + + return ( +
{ + this.clicked(emoji, native); + }} + key={emoji} + className="emoji" + title={em.a} + > + {native} +
+ ); + }; + + render() { + const { data, cache, filter } = this.state; + if (!data || !cache) { + return null; + } + + const recent: string[] = ls.get("recent-emoji", []); + + return ( +
+ + + {(() => { + if (filter) { + const results = cache + .filter( + (i) => + i.id.indexOf(filter) !== -1 || + i.name.indexOf(filter) !== -1 || + i.keywords.includes(filter) + ) + .map((i) => i.id); + + return ( +
+
+
+ {results.length === 0 && _t("emoji-picker.filter-no-match")} + {results.length > 0 && results.map((emoji) => this.renderEmoji(emoji))} +
+
+
+ ); + } else { + return ( +
+ {recent.length > 0 && ( +
+
{_t("emoji-picker.recently-used")}
+
+ {recent.map((emoji) => this.renderEmoji(emoji))} +
+
+ )} + + {data.categories.map((cat) => ( +
+
{cat.name}
+
+ {cat.emojis.map((emoji) => this.renderEmoji(emoji))} +
+
+ ))} +
+ ); + } + })()} +
+ ); + } +} diff --git a/src/common/components/emoji-picker/index.spec.tsx b/src/common/components/emoji-picker/index.spec.tsx index 3d5ade5679b..09ccc449af5 100644 --- a/src/common/components/emoji-picker/index.spec.tsx +++ b/src/common/components/emoji-picker/index.spec.tsx @@ -2,7 +2,7 @@ import React from "react"; import renderer from "react-test-renderer"; -import EmojiPicker from "./index"; +import EmojiPicker from "./index-old"; import emojiData from "../../../../public/emoji.json"; diff --git a/src/common/components/emoji-picker/index.tsx b/src/common/components/emoji-picker/index.tsx index 581795c50d8..11f6f21f787 100644 --- a/src/common/components/emoji-picker/index.tsx +++ b/src/common/components/emoji-picker/index.tsx @@ -1,207 +1,102 @@ -import React from "react"; - -import { FormControl } from "react-bootstrap"; - -import BaseComponent from "../base"; -import SearchBox from "../search-box"; - -import { _t } from "../../i18n"; - -import { getEmojiData } from "../../api/misc"; - -import * as ls from "../../util/local-storage"; - -import { insertOrReplace } from "../../util/input-util"; +import React, { useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { QueryIdentifiers } from "../../core"; +import { Picker } from "emoji-mart"; +import { createPortal } from "react-dom"; +import useClickAway from "react-use/lib/useClickAway"; +import useMountedState from "react-use/lib/useMountedState"; import "./_index.scss"; - -interface Emoji { - a: string; - b: string; - j: string[]; -} - -interface EmojiCategory { - id: string; - name: string; - emojis: string[]; -} - -interface EmojiData { - categories: EmojiCategory[]; - emojis: Record; -} - -interface EmojiCacheItem { - id: string; - name: string; - keywords: string[]; -} +import { useMappedStore } from "../../store/use-mapped-store"; + +export const DEFAULT_EMOJI_DATA = { + categories: [], + emojis: {}, + aliases: {}, + sheet: { + cols: 0, + rows: 0 + } +}; interface Props { - fallback?: (e: string) => void; -} - -interface State { - data: EmojiData | null; - cache: EmojiCacheItem[] | null; - filter: string; + anchor: Element | null; + onSelect: (e: string) => void; } -export default class EmojiPicker extends BaseComponent { - state: State = { - data: null, - cache: null, - filter: "" - }; - - _target: HTMLInputElement | null = null; - - componentDidMount() { - getEmojiData().then((data) => this.setData(data)); - - this.watchTarget(); // initial - - if (typeof window !== "undefined") { - window.addEventListener("focus", this.watchTarget, true); +/** + * Renders an emoji picker dialog. + * + * @param {Props} anchor - The anchor element to position the picker relative to. + * @param {function} onSelect - The callback function to be called when an emoji is selected. + * @return The rendered emoji picker dialog. + */ +export function EmojiPicker({ anchor, onSelect }: Props) { + const ref = useRef(null); + + const { global } = useMappedStore(); + + const [show, setShow] = useState(false); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [pickerInstance, setPickerInstance] = useState(); + + useClickAway(ref, () => { + setShow(false); + }); + + const { data } = useQuery( + [QueryIdentifiers.EMOJI_PICKER], + async () => { + try { + const data = await import(/* webpackChunkName: "emojis" */ "@emoji-mart/data"); + return data.default as typeof DEFAULT_EMOJI_DATA; + } catch (e) { + console.error("Failed to load emoji data"); + } + + return DEFAULT_EMOJI_DATA; + }, + { + initialData: DEFAULT_EMOJI_DATA } - } - - componentWillUnmount() { - super.componentWillUnmount(); - if (typeof window !== "undefined") { - window.removeEventListener("focus", this.watchTarget, true); + ); + + const isMounted = useMountedState(); + + useEffect(() => { + if (data.categories.length > 0) { + setPickerInstance( + new Picker({ + dynamicWidth: true, + onEmojiSelect: (e: { native: string }) => onSelect(e.native), + previewPosition: "none", + ref, + set: "apple", + theme: global.theme === "day" ? "light" : "dark" + }) + ); } - } - - watchTarget = () => { - if (document.activeElement?.classList.contains("accepts-emoji")) { - this._target = document.activeElement as HTMLInputElement; + }, [data, global.theme]); + + useEffect(() => { + if (anchor) { + anchor.addEventListener("click", () => { + const { x, y } = anchor.getBoundingClientRect(); + setPosition({ x: x + window.scrollX, y: y + window.scrollY }); + setShow(true); + }); } - }; - - setData = (data: EmojiData) => { - const cache: EmojiCacheItem[] = Object.keys(data.emojis).map((e) => { - const em = data.emojis[e]; - return { - id: e, - name: em.a.toLowerCase(), - keywords: em.j ? em.j : [] - }; - }); - - this.stateSet({ data, cache }); - }; - - filterChanged = (e: React.ChangeEvent) => { - this.setState({ filter: e.target.value }); - }; - - clicked = (id: string, native: string) => { - const recent = ls.get("recent-emoji", []); - if (!recent.includes(id)) { - const newRecent = [...new Set([id, ...recent])].slice(0, 18); - ls.set("recent-emoji", newRecent); - this.forceUpdate(); // Re-render recent list - } - - if (this._target) { - insertOrReplace(this._target, native); - } else { - const { fallback } = this.props; - if (fallback) fallback(native); - } - }; - - renderEmoji = (emoji: string) => { - const { data } = this.state; - const em = data!.emojis[emoji]; - if (!em) { - return null; - } - const unicodes = em.b.split("-"); - const codePoints = unicodes.map((u) => Number(`0x${u}`)); - const native = String.fromCodePoint(...codePoints); - - return ( -
{ - this.clicked(emoji, native); - }} - key={emoji} - className="emoji" - title={em.a} - > - {native} -
- ); - }; - - render() { - const { data, cache, filter } = this.state; - if (!data || !cache) { - return null; - } - - const recent: string[] = ls.get("recent-emoji", []); - - return ( -
- - - {(() => { - if (filter) { - const results = cache - .filter( - (i) => - i.id.indexOf(filter) !== -1 || - i.name.indexOf(filter) !== -1 || - i.keywords.includes(filter) - ) - .map((i) => i.id); - - return ( -
-
-
- {results.length === 0 && _t("emoji-picker.filter-no-match")} - {results.length > 0 && results.map((emoji) => this.renderEmoji(emoji))} -
-
-
- ); - } else { - return ( -
- {recent.length > 0 && ( -
-
{_t("emoji-picker.recently-used")}
-
- {recent.map((emoji) => this.renderEmoji(emoji))} -
-
- )} - - {data.categories.map((cat) => ( -
-
{cat.name}
-
- {cat.emojis.map((emoji) => this.renderEmoji(emoji))} -
-
- ))} -
- ); - } - })()} -
- ); - } + }, [anchor]); + + return createPortal( +
, + document.querySelector("#root")!! + ); } diff --git a/src/common/core/react-query.ts b/src/common/core/react-query.ts index f3db0fc44c1..de5ac6e4422 100644 --- a/src/common/core/react-query.ts +++ b/src/common/core/react-query.ts @@ -25,5 +25,7 @@ export enum QueryIdentifiers { THREE_SPEAK_VIDEO_LIST = "three-speak-video-list", THREE_SPEAK_VIDEO_LIST_FILTERED = "three-speak-video-list-filtered", DRAFTS = "drafts", - BY_DRAFT_ID = "by-draft-id" + BY_DRAFT_ID = "by-draft-id", + + EMOJI_PICKER = "emoji-picker" } diff --git a/yarn.lock b/yarn.lock index 7c9ac68760d..c93f6b75baf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1344,6 +1344,11 @@ xmldom "^0.5.0" xss "^1.0.9" +"@emoji-mart/data@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.1.2.tgz#777c976f8f143df47cbb23a7077c9ca9fe5fc513" + integrity sha512-1HP8BxD2azjqWJvxIaWAMyTySeZY0Osr83ukYjltPVkNXeJvTz7yDrPLBtnrD5uqJ3tg4CcLuuBW09wahqL/fg== + "@firebase/analytics@^0.8.0": version "0.8.0" resolved "https://registry.yarnpkg.com/@firebase/analytics/-/analytics-0.8.0.tgz#b5d595082f57d33842b1fd9025d88f83065e87fe" @@ -5077,6 +5082,11 @@ emittery@^0.7.1: resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.2.tgz#25595908e13af0f5674ab419396e2fb394cdfa82" integrity sha512-A8OG5SR/ij3SsJdWDJdkkSYUjQdCUx6APQXem0SaEePBSRg4eymGYwBkKo1Y6DU+af/Jn2dBQqDBvjnr9Vi8nQ== +emoji-mart@^5.5.2: + version "5.5.2" + resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.5.2.tgz#3ddbaf053139cf4aa217650078bc1c50ca8381af" + integrity sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A== + emoji-regex@^7.0.1: version "7.0.3" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"