From e62bc6c2501e511d7c7b735f1d88e179edfb034b Mon Sep 17 00:00:00 2001 From: fuscodev Date: Fri, 7 Mar 2025 12:53:06 +0100 Subject: [PATCH] feat: editor emoji picker (#775) * feat: emoji picker * fix: lazy load emoji data * loading animation (for slow connection) * parsing :shortcode: and replace with emoji + add extension to title-editor * fix * Remove title editor support * Remove shortcuts support * Cleanup --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com> --- .../components/emoji-menu/emoji-items.ts | 38 +++++ .../components/emoji-menu/emoji-list.tsx | 140 ++++++++++++++++++ .../emoji-menu/emoji-menu.module.css | 23 +++ .../emoji-menu/render-emoji-items.ts | 92 ++++++++++++ .../editor/components/emoji-menu/types.ts | 16 ++ .../editor/components/emoji-menu/utils.ts | 59 ++++++++ .../editor/extensions/emoji-command.ts | 40 +++++ .../features/editor/extensions/extensions.ts | 2 + .../src/features/editor/page-editor.tsx | 6 + 9 files changed, 416 insertions(+) create mode 100644 apps/client/src/features/editor/components/emoji-menu/emoji-items.ts create mode 100644 apps/client/src/features/editor/components/emoji-menu/emoji-list.tsx create mode 100644 apps/client/src/features/editor/components/emoji-menu/emoji-menu.module.css create mode 100644 apps/client/src/features/editor/components/emoji-menu/render-emoji-items.ts create mode 100644 apps/client/src/features/editor/components/emoji-menu/types.ts create mode 100644 apps/client/src/features/editor/components/emoji-menu/utils.ts create mode 100644 apps/client/src/features/editor/extensions/emoji-command.ts diff --git a/apps/client/src/features/editor/components/emoji-menu/emoji-items.ts b/apps/client/src/features/editor/components/emoji-menu/emoji-items.ts new file mode 100644 index 00000000..0525a911 --- /dev/null +++ b/apps/client/src/features/editor/components/emoji-menu/emoji-items.ts @@ -0,0 +1,38 @@ +import { CommandProps, EmojiMenuItemType } from "./types"; +import { SearchIndex } from "emoji-mart"; +import { getFrequentlyUsedEmoji, sortFrequentlyUsedEmoji } from "./utils"; + +const searchEmoji = async (value: string): Promise => { + if (value === "") { + const frequentlyUsedEmoji = getFrequentlyUsedEmoji(); + return sortFrequentlyUsedEmoji(frequentlyUsedEmoji); + } + + const emojis = await SearchIndex.search(value); + const results = emojis.map((emoji: any) => { + return { + id: emoji.id, + emoji: emoji.skins[0].native, + command: ({ editor, range }: CommandProps) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertContent(emoji.skins[0].native + " ") + .run(); + }, + }; + }); + + return results; +}; + +export const getEmojiItems = async ({ + query, +}: { + query: string; +}): Promise => { + return searchEmoji(query); +}; + +export default getEmojiItems; diff --git a/apps/client/src/features/editor/components/emoji-menu/emoji-list.tsx b/apps/client/src/features/editor/components/emoji-menu/emoji-list.tsx new file mode 100644 index 00000000..15428bbe --- /dev/null +++ b/apps/client/src/features/editor/components/emoji-menu/emoji-list.tsx @@ -0,0 +1,140 @@ +import { + ActionIcon, + Loader, + Paper, + ScrollArea, + SimpleGrid, + Text, +} from "@mantine/core"; +import { EmojiMenuItemType } from "./types"; +import clsx from "clsx"; +import classes from "./emoji-menu.module.css"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { GRID_COLUMNS, incrementEmojiUsage } from "./utils"; + +const EmojiList = ({ + items, + isLoading, + command, + editor, + range, +}: { + items: EmojiMenuItemType[]; + isLoading: boolean; + command: any; + editor: any; + range: any; +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const viewportRef = useRef(null); + + const selectItem = useCallback( + (index: number) => { + const item = items[index]; + if (item) { + command(item); + incrementEmojiUsage(item.id); + } + }, + [command, items] + ); + + useEffect(() => { + const navigationKeys = [ + "ArrowRight", + "ArrowLeft", + "ArrowUp", + "ArrowDown", + "Enter", + ]; + const onKeyDown = (e: KeyboardEvent) => { + if (navigationKeys.includes(e.key)) { + e.preventDefault(); + + if (e.key === "ArrowRight") { + setSelectedIndex( + selectedIndex + 1 < items.length ? selectedIndex + 1 : selectedIndex + ); + return true; + } + + if (e.key === "ArrowLeft") { + setSelectedIndex( + selectedIndex - 1 >= 0 ? selectedIndex - 1 : selectedIndex + ); + return true; + } + + if (e.key === "ArrowUp") { + setSelectedIndex( + selectedIndex - GRID_COLUMNS >= 0 + ? selectedIndex - GRID_COLUMNS + : selectedIndex + ); + return true; + } + + if (e.key === "ArrowDown") { + setSelectedIndex( + selectedIndex + GRID_COLUMNS < items.length + ? selectedIndex + GRID_COLUMNS + : selectedIndex + ); + return true; + } + + if (e.key === "Enter") { + selectItem(selectedIndex); + return true; + } + return false; + } + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [items, selectedIndex, setSelectedIndex]); + + useEffect(() => { + setSelectedIndex(0); + }, [items]); + + useEffect(() => { + viewportRef.current + ?.querySelector(`[data-item-index="${selectedIndex}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + return items.length > 0 || isLoading ? ( + + {isLoading && } + {items.length > 0 && ( + + + {items.map((item, index: number) => ( + selectItem(index)} + > + {item.emoji} + + ))} + + + )} + + ) : null; +}; + +export default EmojiList; diff --git a/apps/client/src/features/editor/components/emoji-menu/emoji-menu.module.css b/apps/client/src/features/editor/components/emoji-menu/emoji-menu.module.css new file mode 100644 index 00000000..40617c2d --- /dev/null +++ b/apps/client/src/features/editor/components/emoji-menu/emoji-menu.module.css @@ -0,0 +1,23 @@ +.menuBtn { + border-radius: var(--mantine-radius-sm); + + &:hover { + @mixin light { + background: var(--mantine-color-gray-2); + } + + @mixin dark { + background: var(--mantine-color-gray-light); + } + } +} + +.selectedItem { + @mixin light { + background: var(--mantine-color-gray-2); + } + + @mixin dark { + background: var(--mantine-color-gray-light); + } +} diff --git a/apps/client/src/features/editor/components/emoji-menu/render-emoji-items.ts b/apps/client/src/features/editor/components/emoji-menu/render-emoji-items.ts new file mode 100644 index 00000000..ba035a8b --- /dev/null +++ b/apps/client/src/features/editor/components/emoji-menu/render-emoji-items.ts @@ -0,0 +1,92 @@ +import { ReactRenderer, useEditor } from "@tiptap/react"; +import EmojiList from "./emoji-list"; +import tippy from "tippy.js"; +import { init } from "emoji-mart"; + +const renderEmojiItems = () => { + let component: ReactRenderer | null = null; + let popup: any | null = null; + + return { + onBeforeStart: (props: { + editor: ReturnType; + clientRect: DOMRect; + }) => { + init({ + data: async () => (await import("@emoji-mart/data")).default, + }); + + component = new ReactRenderer(EmojiList, { + props: { isLoading: true, items: [] }, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + // @ts-ignore + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + onStart: (props: { + editor: ReturnType; + clientRect: DOMRect; + }) => { + component?.updateProps({...props, isLoading: false}); + + if (!props.clientRect) { + return; + } + + popup && + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + onUpdate: (props: { + editor: ReturnType; + clientRect: DOMRect; + }) => { + component?.updateProps(props); + + if (!props.clientRect) { + return; + } + + popup && + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + onKeyDown: (props: { event: KeyboardEvent }) => { + if (props.event.key === "Escape") { + popup?.[0].hide(); + component?.destroy() + + return true; + } + + // @ts-ignore + return component?.ref?.onKeyDown(props); + }, + onExit: () => { + if (popup && !popup[0]?.state.isDestroyed) { + popup[0]?.destroy(); + } + + if (component) { + component?.destroy(); + } + }, + }; +}; + +export default renderEmojiItems; diff --git a/apps/client/src/features/editor/components/emoji-menu/types.ts b/apps/client/src/features/editor/components/emoji-menu/types.ts new file mode 100644 index 00000000..2d3f3ee9 --- /dev/null +++ b/apps/client/src/features/editor/components/emoji-menu/types.ts @@ -0,0 +1,16 @@ +import { Range } from "@tiptap/core"; +import { useEditor } from "@tiptap/react"; + +export type EmojiMartFrequentlyType = Record; + +export type CommandProps = { + editor: ReturnType; + range: Range; +}; + +export type EmojiMenuItemType = { + id: string; + emoji: string; + count?: number; + command: (props: CommandProps) => void; +}; diff --git a/apps/client/src/features/editor/components/emoji-menu/utils.ts b/apps/client/src/features/editor/components/emoji-menu/utils.ts new file mode 100644 index 00000000..7bac301a --- /dev/null +++ b/apps/client/src/features/editor/components/emoji-menu/utils.ts @@ -0,0 +1,59 @@ +import { CommandProps } from "./types"; +import { getEmojiDataFromNative } from "emoji-mart"; +import { EmojiMartFrequentlyType, EmojiMenuItemType } from "./types"; + +export const GRID_COLUMNS = 10; + +export const LOCAL_STORAGE_FREQUENT_KEY = "emoji-mart.frequently"; + +export const DEFAULT_FREQUENTLY_USED_EMOJI_MART = `{ + "+1": 10, + "grinning": 9, + "kissing_heart": 8, + "heart_eyes": 7, + "laughing": 6, + "stuck_out_tongue_winking_eye": 5, + "sweat_smile": 4, + "joy": 3, + "scream": 2, + "rocket": 1 +}`; + +export const incrementEmojiUsage = (emojiId: string) => { + const frequentlyUsedEmoji = + JSON.parse(localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART); + frequentlyUsedEmoji[emojiId] + ? (frequentlyUsedEmoji[emojiId] += 1) + : (frequentlyUsedEmoji[emojiId] = 1); + localStorage.setItem( + LOCAL_STORAGE_FREQUENT_KEY, + JSON.stringify(frequentlyUsedEmoji) + ); +}; + +export const sortFrequentlyUsedEmoji = async ( + frequentlyUsedEmoji: EmojiMartFrequentlyType +): Promise => { + const data = await Promise.all( + Object.entries(frequentlyUsedEmoji).map( + async ([id, count]): Promise => ({ + id, + count, + emoji: (await getEmojiDataFromNative(id))?.native, + command: async ({ editor, range }: CommandProps) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertContent((await getEmojiDataFromNative(id))?.native + " ") + .run(); + }, + }) + ) + ); + return data.sort((a, b) => b.count - a.count); +}; + +export const getFrequentlyUsedEmoji = () => { + return JSON.parse(localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART); +} diff --git a/apps/client/src/features/editor/extensions/emoji-command.ts b/apps/client/src/features/editor/extensions/emoji-command.ts new file mode 100644 index 00000000..85a50f18 --- /dev/null +++ b/apps/client/src/features/editor/extensions/emoji-command.ts @@ -0,0 +1,40 @@ +import { Extension } from "@tiptap/core"; +import { PluginKey } from "@tiptap/pm/state"; +import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; +import getEmojiItems from "../components/emoji-menu/emoji-items"; +import renderEmojiItems from "../components/emoji-menu/render-emoji-items"; +export const emojiMenuPluginKey = new PluginKey("emoji-command"); + +const Command = Extension.create({ + name: "emoji-command", + + addOptions() { + return { + suggestion: { + char: ":", + command: ({ editor, range, props }) => { + props.command({ editor, range, props }); + }, + } as Partial, + }; + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + pluginKey: emojiMenuPluginKey, + ...this.options.suggestion, + editor: this.editor, + }), + ]; + }, +}); + +const EmojiCommand = Command.configure({ + suggestion: { + items: getEmojiItems, + render: renderEmojiItems, + }, +}); + +export default EmojiCommand; diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 9dcd288b..7a5d2363 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -70,6 +70,7 @@ import { ReactNodeViewRenderer } from "@tiptap/react"; import MentionView from "@/features/editor/components/mention/mention-view.tsx"; import i18n from "@/i18n.ts"; import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts"; +import EmojiCommand from "./emoji-command"; const lowlight = createLowlight(common); lowlight.register("mermaid", plaintext); @@ -132,6 +133,7 @@ export const mainExtensions = [ TextStyle, Color, SlashCommand, + EmojiCommand, Comment.configure({ HTMLAttributes: { class: "comment-mark", diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index bcad7f1f..feb9fd6f 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -137,6 +137,12 @@ export default function PageEditor({ return true; } } + if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"].includes(event.key)) { + const emojiCommand = document.querySelector("#emoji-command"); + if (emojiCommand) { + return true; + } + } }, }, handlePaste: (view, event, slice) =>