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 index 0525a911..c50cc9bf 100644 --- a/apps/client/src/features/editor/components/emoji-menu/emoji-items.ts +++ b/apps/client/src/features/editor/components/emoji-menu/emoji-items.ts @@ -1,30 +1,26 @@ import { CommandProps, EmojiMenuItemType } from "./types"; -import { SearchIndex } from "emoji-mart"; -import { getFrequentlyUsedEmoji, sortFrequentlyUsedEmoji } from "./utils"; +import { buildEmojiIndex, getFrequentlyUsedEmoji, sortFrequentlyUsedEmoji } from "./utils"; -const searchEmoji = async (value: string): Promise => { - if (value === "") { - const frequentlyUsedEmoji = getFrequentlyUsedEmoji(); - return sortFrequentlyUsedEmoji(frequentlyUsedEmoji); +const MAX_RESULTS = 5; + +const searchEmoji = async (query: string): Promise => { + if (query === "") { + return sortFrequentlyUsedEmoji(getFrequentlyUsedEmoji()); } - 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(); - }, - }; - }); + const q = query.toLowerCase(); + const index = await buildEmojiIndex(); - return results; + return index + .filter((e) => e.name.includes(q) || e.id.includes(q)) + .slice(0, MAX_RESULTS) + .map((entry) => ({ + id: entry.id, + emoji: entry.native, + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).insertContent(entry.native + " ").run(); + }, + })); }; export const getEmojiItems = async ({ 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 index cbe63479..e08d320a 100644 --- a/apps/client/src/features/editor/components/emoji-menu/emoji-list.tsx +++ b/apps/client/src/features/editor/components/emoji-menu/emoji-list.tsx @@ -1,154 +1,208 @@ -import { - ActionIcon, - Loader, - Paper, - ScrollArea, - SimpleGrid, - Text, -} from "@mantine/core"; -import { EmojiMenuItemType } from "./types"; +import { Loader, Paper, ScrollArea, Text, UnstyledButton } from "@mantine/core"; import clsx from "clsx"; -import classes from "./emoji-menu.module.css"; import { useCallback, useEffect, useRef, useState } from "react"; -import { GRID_COLUMNS, incrementEmojiUsage } from "./utils"; +import { useTranslation } from "react-i18next"; +import { EmojiMenuItemType } from "./types"; +import { + EmojiCategory, + EmojiIndexEntry, + getEmojiCategories, + incrementEmojiUsage, +} from "./utils"; +import classes from "./emoji-menu.module.css"; -const EmojiList = ({ +const COLS = 8; + +const CAT_ICONS: Record = { + people: "😀", + nature: "🌿", + foods: "🍕", + activity: "🎮", + places: "🗺️", + objects: "🔧", + symbols: "💯", + flags: "🚩", +}; + +function EmojiList({ items, isLoading, command, editor, range, + query = "", }: { items: EmojiMenuItemType[]; isLoading: boolean; - command: any; + command: (item: EmojiMenuItemType) => void; editor: any; range: any; -}) => { - const [selectedIndex, setSelectedIndex] = useState(0); - const viewportRef = useRef(null); + query?: string; +}) { + const { t } = useTranslation(); + const [idx, setIdx] = useState(0); + const [cats, setCats] = useState([]); + const [activeCat, setActiveCat] = useState(""); + const [focusZone, setFocusZone] = useState<"grid" | "tabs">("grid"); + const listViewport = useRef(null); + const gridViewport = useRef(null); + const catBar = useRef(null); - const selectItem = useCallback( - (index: number) => { - const item = items[index]; - if (item) { - command(item); - incrementEmojiUsage(item.id); - } + const searching = query.length > 0; + const browseLoading = !searching && cats.length === 0; + const gridItems = cats.find((c) => c.id === activeCat)?.emojis ?? []; + + useEffect(() => { + getEmojiCategories().then((data) => { + setCats(data); + setActiveCat((prev) => prev || data[0]?.id || ""); + }); + }, []); + + useEffect(() => { setIdx(0); }, [query, activeCat]); + + useEffect(() => { if (searching) setFocusZone("grid"); }, [searching]); + + useEffect(() => { + if (focusZone !== "tabs") return; + catBar.current?.querySelector(`[data-cat="${activeCat}"]`)?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [activeCat, focusZone]); + + useEffect(() => { + if (focusZone === "tabs") return; + const vp = searching ? listViewport.current : gridViewport.current; + vp?.querySelector(`[data-i="${idx}"]`)?.scrollIntoView({ block: "nearest" }); + }, [idx, searching, focusZone]); + + const pickSearchItem = useCallback( + (i: number) => { + const item = items[i]; + if (!item) return; + command(item); + incrementEmojiUsage(item.id); }, - [command, items] + [command, items], + ); + + const pickGridItem = useCallback( + (entry: EmojiIndexEntry) => { + editor.chain().focus().deleteRange(range).insertContent(entry.native + " ").run(); + incrementEmojiUsage(entry.id); + }, + [editor, range], ); 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; + function onKey(e: KeyboardEvent) { + if (searching) { + if (e.key === "ArrowDown") { e.preventDefault(); setIdx((i) => Math.min(i + 1, items.length - 1)); } + else if (e.key === "ArrowUp") { e.preventDefault(); setIdx((i) => Math.max(i - 1, 0)); } + else if (e.key === "Enter") { e.preventDefault(); pickSearchItem(idx); } + } else if (focusZone === "tabs") { + const catIdx = cats.findIndex((c) => c.id === activeCat); + if (e.key === "ArrowRight") { e.preventDefault(); const next = cats[Math.min(catIdx + 1, cats.length - 1)]; if (next) setActiveCat(next.id); } + else if (e.key === "ArrowLeft") { e.preventDefault(); const prev = cats[Math.max(catIdx - 1, 0)]; if (prev) setActiveCat(prev.id); } + else if (e.key === "ArrowDown" || e.key === "Enter") { e.preventDefault(); setFocusZone("grid"); } + else if (e.key === "ArrowUp") { e.preventDefault(); } + } else { + const total = gridItems.length; + if (e.key === "ArrowRight") { e.preventDefault(); setIdx((i) => Math.min(i + 1, total - 1)); } + else if (e.key === "ArrowLeft") { e.preventDefault(); setIdx((i) => Math.max(i - 1, 0)); } + else if (e.key === "ArrowDown") { e.preventDefault(); setIdx((i) => Math.min(i + COLS, total - 1)); } + else if (e.key === "ArrowUp") { + e.preventDefault(); + if (idx < COLS) setFocusZone("tabs"); + else setIdx((i) => Math.max(i - COLS, 0)); } - - 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; + else if (e.key === "Enter") { e.preventDefault(); if (gridItems[idx]) pickGridItem(gridItems[idx]); } } - }; - document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("keydown", onKeyDown); - }; - }, [items, selectedIndex, setSelectedIndex]); + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [searching, items, idx, gridItems, pickSearchItem, pickGridItem, focusZone, cats, activeCat]); - useEffect(() => { - setSelectedIndex(0); - }, [items]); - - useEffect(() => { - viewportRef.current - ?.querySelector(`[data-item-index="${selectedIndex}"]`) - ?.scrollIntoView({ block: "nearest" }); - }, [selectedIndex]); - - return items.length > 0 || isLoading ? ( + return ( 0 ? `emoji-command-option-${selectedIndex}` : undefined - } + aria-label={t("Emoji picker")} > - {isLoading && } - {items.length > 0 && ( - - - {items.map((item, index: number) => ( - selectItem(index)} - > - {item.emoji} - - ))} - - + {searching ? ( + <> + {isLoading && } + +
+ {items.length === 0 && !isLoading ? ( + {t("No results")} + ) : items.map((item, i) => ( + pickSearchItem(i)} + onMouseEnter={() => setIdx(i)} + role="option" + aria-selected={i === idx} + > + {item.emoji} + :{item.id}: + + ))} +
+
+ + ) : browseLoading ? ( + + ) : ( + <> +
+ {cats.map((c) => { + const isActive = c.id === activeCat; + const isFocused = isActive && focusZone === "tabs"; + return ( + + ); + })} +
+ +
+ {gridItems.map((entry, i) => ( + + ))} +
+
+ )}
- ) : 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 index 40617c2d..bc83729e 100644 --- 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 @@ -1,9 +1,13 @@ -.menuBtn { +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; border-radius: var(--mantine-radius-sm); &:hover { @mixin light { - background: var(--mantine-color-gray-2); + background: var(--mantine-color-gray-1); } @mixin dark { @@ -12,7 +16,7 @@ } } -.selectedItem { +.active { @mixin light { background: var(--mantine-color-gray-2); } @@ -21,3 +25,83 @@ background: var(--mantine-color-gray-light); } } + +.catBar { + display: flex; + gap: 2px; + padding: 4px 6px; + overflow-x: auto; + scrollbar-width: none; + + @mixin light { + border-bottom: 1px solid var(--mantine-color-gray-2); + } + + @mixin dark { + border-bottom: 1px solid var(--mantine-color-dark-4); + } +} + +.catTab { + background: transparent; + border: none; + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 4px 5px; + border-radius: var(--mantine-radius-sm); + flex-shrink: 0; + + &:hover { + @mixin light { + background: var(--mantine-color-gray-1); + } + + @mixin dark { + background: var(--mantine-color-gray-light); + } + } +} + +.catTabActive { + @mixin light { + background: var(--mantine-color-gray-2); + } + + @mixin dark { + background: var(--mantine-color-gray-light); + } +} + +.catTabFocused { + outline: 1px solid var(--mantine-color-blue-filled); + outline-offset: -1px; +} + +.grid { + display: grid; + gap: 1px; + padding: 6px; +} + +.emojiBtn { + background: transparent; + border: none; + cursor: pointer; + font-size: 20px; + line-height: 1; + padding: 3px; + border-radius: var(--mantine-radius-sm); + aspect-ratio: 1 / 1; + + &:hover { + @mixin light { + background: var(--mantine-color-gray-1); + } + + @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 index 0ae5e24a..946cc1fa 100644 --- 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 @@ -1,6 +1,5 @@ import { ReactRenderer, useEditor } from "@tiptap/react"; import EmojiList from "./emoji-list"; -import { init } from "emoji-mart"; import { autoUpdate, computePosition, @@ -37,10 +36,6 @@ const renderEmojiItems = () => { editor: ReturnType; clientRect: () => DOMRect; }) => { - init({ - data: async () => (await import("@emoji-mart/data")).default, - }); - component = new ReactRenderer(EmojiList, { props: { isLoading: true, items: [] }, editor: props.editor, diff --git a/apps/client/src/features/editor/components/emoji-menu/utils.ts b/apps/client/src/features/editor/components/emoji-menu/utils.ts index 7bac301a..8a86ee50 100644 --- a/apps/client/src/features/editor/components/emoji-menu/utils.ts +++ b/apps/client/src/features/editor/components/emoji-menu/utils.ts @@ -1,8 +1,4 @@ -import { CommandProps } from "./types"; -import { getEmojiDataFromNative } from "emoji-mart"; -import { EmojiMartFrequentlyType, EmojiMenuItemType } from "./types"; - -export const GRID_COLUMNS = 10; +import { CommandProps, EmojiMartFrequentlyType, EmojiMenuItemType } from "./types"; export const LOCAL_STORAGE_FREQUENT_KEY = "emoji-mart.frequently"; @@ -19,41 +15,76 @@ export const DEFAULT_FREQUENTLY_USED_EMOJI_MART = `{ "rocket": 1 }`; +export type EmojiIndexEntry = { id: string; name: string; native: string }; + +let _emojiIndex: EmojiIndexEntry[] | null = null; + +export const buildEmojiIndex = async (): Promise => { + if (_emojiIndex) return _emojiIndex; + const { default: data } = await import("@emoji-mart/data"); + _emojiIndex = (Object.values((data as any).emojis) as any[]) + .filter((e) => e.id && e.name && e.skins?.[0]?.native) + .map((e) => ({ + id: e.id as string, + name: (e.name as string).toLowerCase(), + native: e.skins[0].native as string, + })); + return _emojiIndex; +}; + 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) + const stored = JSON.parse( + localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART, ); + stored[emojiId] = (stored[emojiId] ?? 0) + 1; + localStorage.setItem(LOCAL_STORAGE_FREQUENT_KEY, JSON.stringify(stored)); }; export const sortFrequentlyUsedEmoji = async ( - frequentlyUsedEmoji: EmojiMartFrequentlyType + frequentlyUsedEmoji: EmojiMartFrequentlyType, ): Promise => { - const data = await Promise.all( - Object.entries(frequentlyUsedEmoji).map( - async ([id, count]): Promise => ({ + const index = await buildEmojiIndex(); + const results: EmojiMenuItemType[] = Object.entries(frequentlyUsedEmoji) + .map(([id, count]): EmojiMenuItemType | null => { + const entry = index.find((e) => e.id === id); + if (!entry) return null; + return { id, count, - emoji: (await getEmojiDataFromNative(id))?.native, - command: async ({ editor, range }: CommandProps) => { - editor - .chain() - .focus() - .deleteRange(range) - .insertContent((await getEmojiDataFromNative(id))?.native + " ") - .run(); + emoji: entry.native, + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).insertContent(entry.native + " ").run(); }, - }) - ) - ); - return data.sort((a, b) => b.count - a.count); + }; + }) + .filter((e): e is EmojiMenuItemType => e !== null); + return results.sort((a, b) => (b.count ?? 0) - (a.count ?? 0)).slice(0, 5); }; -export const getFrequentlyUsedEmoji = () => { - return JSON.parse(localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART); -} +export const getFrequentlyUsedEmoji = (): EmojiMartFrequentlyType => { + return JSON.parse( + localStorage.getItem(LOCAL_STORAGE_FREQUENT_KEY) || DEFAULT_FREQUENTLY_USED_EMOJI_MART, + ); +}; + +export type EmojiCategory = { id: string; emojis: EmojiIndexEntry[] }; + +let _cats: EmojiCategory[] | null = null; + +export const getEmojiCategories = async (): Promise => { + if (_cats) return _cats; + const [{ default: data }, index] = await Promise.all([ + import("@emoji-mart/data"), + buildEmojiIndex(), + ]); + const byId = new Map(index.map((e) => [e.id, e])); + _cats = ((data as any).categories as { id: string; emojis: string[] }[]) + .map((cat) => ({ + id: cat.id, + emojis: cat.emojis + .map((id) => byId.get(id)) + .filter((e): e is EmojiIndexEntry => !!e), + })) + .filter((c) => c.emojis.length > 0); + return _cats; +}; diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 23100bd0..4a0532fe 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -25,6 +25,7 @@ import { IconColumns3, IconColumns2, IconTag, + IconMoodSmile, IconRotate2, } from "@tabler/icons-react"; import { @@ -477,6 +478,15 @@ const CommandGroups: SlashMenuGroupedItemsType = { .run(); }, }, + { + title: "Emoji", + description: "Insert emoji.", + searchTerms: ["emoji", "icon", "smiley", "emoticon", "symbol", "reaction"], + icon: IconMoodSmile, + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).insertContent(":").run(); + }, + }, { title: "Subpages (Child pages)", description: "List all subpages of the current page",