import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import data from '@emoji-mart/data'; import { CustomEmojiImg } from '@/components/CustomEmoji'; import { cn } from '@/lib/utils'; import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis'; import { usePortalDropdown } from '@/hooks/usePortalDropdown'; interface EmojiData { id: string; name: string; keywords?: string[]; skins: Array<{ native: string }>; } interface EmojiShortcodeAutocompleteProps { textareaRef: React.RefObject; content: string; onInsertEmoji: (params: { start: number; end: number; replacement: string }) => void; /** Called when a custom NIP-30 emoji is selected so the caller can track the emoji tag. */ onCustomEmojiInsert?: (emoji: CustomEmoji) => void; } /** CSS properties that affect text layout and must be copied to the mirror element. */ const MIRROR_PROPS = [ 'direction', 'boxSizing', 'width', 'height', 'overflowX', 'overflowY', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderStyle', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize', 'fontSizeAdjust', 'lineHeight', 'fontFamily', 'textAlign', 'textTransform', 'textIndent', 'textDecoration', 'letterSpacing', 'wordSpacing', 'tabSize', 'MozTabSize', 'whiteSpace', 'wordWrap', 'wordBreak', ] as const; /** * Returns the pixel {top, left} of a character position within a textarea, * relative to the textarea element's top-left corner. */ function getCaretCoordinates(textarea: HTMLTextAreaElement, position: number): { top: number; left: number } { const mirror = document.createElement('div'); mirror.id = 'emoji-mirror'; const style = window.getComputedStyle(textarea); for (const prop of MIRROR_PROPS) { mirror.style[prop as string] = style.getPropertyValue( prop.replace(/([A-Z])/g, '-$1').toLowerCase(), ); } mirror.style.position = 'absolute'; mirror.style.visibility = 'hidden'; mirror.style.whiteSpace = 'pre-wrap'; mirror.style.wordWrap = 'break-word'; mirror.style.overflow = 'hidden'; document.body.appendChild(mirror); mirror.textContent = textarea.value.substring(0, position); const marker = document.createElement('span'); marker.textContent = '\u200b'; mirror.appendChild(marker); const mirrorRect = mirror.getBoundingClientRect(); const markerRect = marker.getBoundingClientRect(); const coords = { top: markerRect.top - mirrorRect.top - textarea.scrollTop, left: markerRect.left - mirrorRect.left - textarea.scrollLeft, }; document.body.removeChild(mirror); return coords; } const MAX_RESULTS = 8; /** A result entry that can be either a native emoji or a custom NIP-30 emoji. */ interface EmojiResult { id: string; name: string; /** For native emojis */ native?: string; /** For custom emojis */ customUrl?: string; /** Search score (lower = better match) */ score: number; } /** Build a flat searchable list of emojis from emoji-mart data. */ function buildEmojiIndex(): Array<{ id: string; name: string; native: string; keywords: string[] }> { const emojis = (data as { emojis: Record; aliases: Record }).emojis; const aliases = (data as { aliases: Record }).aliases; const results: Array<{ id: string; name: string; native: string; keywords: string[] }> = []; for (const [id, emoji] of Object.entries(emojis)) { const native = emoji.skins?.[0]?.native; if (!native) continue; // Collect all alias names that point to this emoji const aliasNames: string[] = []; for (const [alias, target] of Object.entries(aliases)) { if (target === id) { aliasNames.push(alias); } } results.push({ id, name: emoji.name, native, keywords: [...(emoji.keywords || []), ...aliasNames], }); } return results; } /** Lazily initialized emoji index. */ let emojiIndex: ReturnType | null = null; function getEmojiIndex() { if (!emojiIndex) { emojiIndex = buildEmojiIndex(); } return emojiIndex; } /** Search emojis by shortcode query (includes both native and custom emojis). */ function searchEmojis(query: string, customEmojis: CustomEmoji[]): EmojiResult[] { if (!query) return []; const q = query.toLowerCase(); const results: EmojiResult[] = []; // Search custom emojis first (they get priority) for (const emoji of customEmojis) { const sc = emoji.shortcode.toLowerCase(); if (sc === q) { results.push({ id: `custom:${emoji.shortcode}`, name: emoji.shortcode, customUrl: emoji.url, score: -1 }); } else if (sc.startsWith(q)) { results.push({ id: `custom:${emoji.shortcode}`, name: emoji.shortcode, customUrl: emoji.url, score: 0 }); } else if (sc.includes(q)) { results.push({ id: `custom:${emoji.shortcode}`, name: emoji.shortcode, customUrl: emoji.url, score: 1 }); } } // Search native emojis const index = getEmojiIndex(); for (const emoji of index) { if (emoji.id === q) { results.push({ id: emoji.id, name: emoji.name, native: emoji.native, score: 2 }); } else if (emoji.id.startsWith(q)) { results.push({ id: emoji.id, name: emoji.name, native: emoji.native, score: 3 }); } else if (emoji.id.includes(q)) { results.push({ id: emoji.id, name: emoji.name, native: emoji.native, score: 4 }); } else if (emoji.name.toLowerCase().startsWith(q)) { results.push({ id: emoji.id, name: emoji.name, native: emoji.native, score: 5 }); } else if (emoji.name.toLowerCase().includes(q)) { results.push({ id: emoji.id, name: emoji.name, native: emoji.native, score: 6 }); } else if (emoji.keywords?.some((kw: string) => kw.startsWith(q) || kw.includes(q))) { results.push({ id: emoji.id, name: emoji.name, native: emoji.native, score: 7 }); } } results.sort((a, b) => a.score - b.score); return results.slice(0, MAX_RESULTS); } /** * Detects `:shortcode` at the cursor position in a textarea and shows * an emoji autocomplete dropdown. On selection, replaces `:shortcode` * with the native emoji character or `:shortcode:` for custom emojis. */ export function EmojiShortcodeAutocomplete({ textareaRef, content, onInsertEmoji, onCustomEmojiInsert, }: EmojiShortcodeAutocompleteProps) { const { emojis: customEmojis } = useCustomEmojis(); const [query, setQuery] = useState(''); const [colonStart, setColonStart] = useState(-1); const [selectedIndex, setSelectedIndex] = useState(0); const [isOpen, setIsOpen] = useState(false); const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number } | null>(null); const dropdownRef = useRef(null); const listRef = useRef(null); const handleClose = useCallback(() => setIsOpen(false), []); const { computePosition, renderPortal } = usePortalDropdown({ textareaRef, isOpen, onClose: handleClose, dropdownHeight: 280, // must match max-h-[280px] below }); const results = useMemo(() => searchEmojis(query, customEmojis), [query, customEmojis]); // Detect :shortcode query at cursor const detectShortcode = useCallback((text?: string, cursorPos?: number) => { const textarea = textareaRef.current; if (!textarea) return; const cursor = cursorPos ?? textarea.selectionStart; const value = text ?? textarea.value; // Walk back from cursor to find a colon that starts a shortcode let colonPos = -1; for (let i = cursor - 1; i >= 0; i--) { const ch = value[i]; if (ch === ' ' || ch === '\n' || ch === '\t') break; if (ch === ':' && i < cursor - 1) { if (i === 0 || /[\s]/.test(value[i - 1])) { colonPos = i; } break; } } if (colonPos === -1) { setIsOpen(false); setQuery(''); setColonStart(-1); return; } const q = value.slice(colonPos + 1, cursor); if (q.length < 2 || q.length > 32) { setIsOpen(false); setQuery(''); setColonStart(-1); return; } if (q.includes(':')) { setIsOpen(false); setQuery(''); setColonStart(-1); return; } setQuery(q); setColonStart(colonPos); setIsOpen(true); setSelectedIndex(0); // Position the dropdown using fixed viewport coordinates so it isn't // clipped by ancestor overflow containers (e.g. the compose modal). const coords = getCaretCoordinates(textarea, colonPos); setDropdownPos(computePosition(coords)); }, [textareaRef, computePosition]); // Listen for input/cursor changes on the textarea element useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; const handleInput = () => { detectShortcode(textarea.value, textarea.selectionStart); }; const handleClick = () => detectShortcode(); const handleKeyUp = (e: KeyboardEvent) => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) { detectShortcode(); } }; textarea.addEventListener('input', handleInput); textarea.addEventListener('click', handleClick); textarea.addEventListener('keyup', handleKeyUp); return () => { textarea.removeEventListener('input', handleInput); textarea.removeEventListener('click', handleClick); textarea.removeEventListener('keyup', handleKeyUp); }; }, [textareaRef, detectShortcode, content]); // Re-detect when content changes externally useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; detectShortcode(content, textarea.selectionStart); }, [content, detectShortcode, textareaRef]); // Handle keyboard navigation within the dropdown useEffect(() => { if (!isOpen || results.length === 0) return; const textarea = textareaRef.current; if (!textarea) return; const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { case 'ArrowDown': e.preventDefault(); setSelectedIndex((prev) => (prev < results.length - 1 ? prev + 1 : 0)); break; case 'ArrowUp': e.preventDefault(); setSelectedIndex((prev) => (prev > 0 ? prev - 1 : results.length - 1)); break; case 'Enter': case 'Tab': if (results.length > 0) { e.preventDefault(); selectEmoji(results[selectedIndex]); } break; case 'Escape': e.preventDefault(); setIsOpen(false); break; } }; textarea.addEventListener('keydown', handleKeyDown); return () => textarea.removeEventListener('keydown', handleKeyDown); }, [isOpen, results, selectedIndex, textareaRef]); // eslint-disable-line react-hooks/exhaustive-deps // Scroll selected item into view useEffect(() => { if (selectedIndex >= 0 && listRef.current) { const items = listRef.current.querySelectorAll('[data-emoji-item]'); items[selectedIndex]?.scrollIntoView({ block: 'nearest' }); } }, [selectedIndex]); const selectEmoji = useCallback((emoji: EmojiResult) => { const textarea = textareaRef.current; const cursor = textarea?.selectionStart ?? colonStart + query.length + 1; if (emoji.customUrl) { // Custom emoji: replace with `:shortcode: ` and track the emoji tag const shortcode = emoji.name; onInsertEmoji({ start: colonStart, end: cursor, replacement: `:${shortcode}: `, }); // Notify parent to track this custom emoji for the emoji tag const entry = customEmojis.find((e) => e.shortcode === shortcode); if (entry && onCustomEmojiInsert) { onCustomEmojiInsert(entry); } } else if (emoji.native) { // Native emoji: replace with the unicode character onInsertEmoji({ start: colonStart, end: cursor, replacement: emoji.native, }); } setIsOpen(false); setQuery(''); setColonStart(-1); }, [colonStart, query, textareaRef, onInsertEmoji, onCustomEmojiInsert, customEmojis]); if (!isOpen || !dropdownPos || results.length === 0) { return null; } const dropdown = (
{results.map((emoji, index) => ( ))}
); // Portal to document.body so the dropdown escapes any ancestor overflow // clipping and CSS transform containing blocks (e.g. Radix Dialog). return renderPortal(dropdown, document.body); }