emoji-suggestion.tsx10.7 KBView on GitHub import {
computePosition,
flip,
shift,
offset,
autoUpdate,
type VirtualElement,
type Placement,
} from '@floating-ui/dom';
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { ReactRenderer } from '@tiptap/react';
import type { Editor, Range } from '@tiptap/core';
import { cn } from '@/lib/utils';
// ============================================
// Types
// ============================================
interface EmojiItem {
name: string;
emoji?: string;
shortcodes: string[];
tags: string[];
group: string;
fallbackImage?: string;
}
interface EmojiListProps {
items: EmojiItem[];
command: (item: EmojiItem) => void;
}
interface EmojiListRef {
onKeyDown: (data: { event: KeyboardEvent }) => boolean;
}
interface SuggestionProps {
editor: Editor;
range: Range;
query: string;
text: string;
items: EmojiItem[];
command: (item: EmojiItem) => void;
decorationNode: Element | null;
clientRect?: (() => DOMRect | null) | null;
}
interface SuggestionKeyDownProps {
event: KeyboardEvent;
}
// ============================================
// EmojiList Component
// ============================================
const EmojiList = forwardRef<EmojiListRef, EmojiListProps>(({ items, command }, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
// Scroll selected item into view
useEffect(() => {
const selectedItem = itemRefs.current[selectedIndex];
if (selectedItem && containerRef.current) {
const container = containerRef.current;
const itemTop = selectedItem.offsetTop;
const itemBottom = itemTop + selectedItem.offsetHeight;
const containerTop = container.scrollTop;
const containerBottom = containerTop + container.clientHeight;
if (itemBottom > containerBottom) {
container.scrollTop = itemBottom - container.clientHeight;
} else if (itemTop < containerTop) {
container.scrollTop = itemTop;
}
}
}, [selectedIndex]);
const selectItem = (index: number) => {
const item = items[index];
if (item) {
command(item);
}
};
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === 'ArrowUp') {
setSelectedIndex((prev) => (prev + items.length - 1) % items.length);
return true;
}
if (event.key === 'ArrowDown') {
setSelectedIndex((prev) => (prev + 1) % items.length);
return true;
}
if (event.key === 'Enter') {
selectItem(selectedIndex);
return true;
}
return false;
},
}));
if (!items.length) {
return (
<div className="rounded-lg border border-[#E7E7E7] bg-white px-3 py-2 text-sm text-gray-500 shadow-lg dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-gray-400">
No emojis found
</div>
);
}
return (
<div
ref={containerRef}
className="max-h-60 overflow-y-auto rounded-lg border border-[#E7E7E7] bg-white py-1 shadow-lg dark:border-[#3A3A3A] dark:bg-[#2C2C2C]"
>
{items.map((item, index) => (
<button
key=[redacted] + '-' + index}
ref={(el) => {
itemRefs.current[index] = el;
}}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-[#3A3A3A]',
index === selectedIndex && 'bg-gray-100 dark:bg-[#3A3A3A]',
)}
onClick={() => selectItem(index)}
>
{item.fallbackImage ? (
<img src={item.fallbackImage} alt={item.name} className="h-5 w-5" />
) : (
<span className="flex h-5 w-5 items-center justify-center text-base leading-none">
{item.emoji}
</span>
)}
<span className="text-gray-700 dark:text-gray-300">:{item.name}:</span>
</button>
))}
</div>
);
});
EmojiList.displayName = 'EmojiList';
// ============================================
// Popular emoji ordering (most commonly used first)
// ============================================
const POPULAR_EMOJIS: string[] = [
// Smileys & expressions
'smile',
'laughing',
'grinning',
'joy',
'rofl',
'smiley',
'wink',
'blush',
'heart_eyes',
'kissing_heart',
'smiling_face_with_three_hearts',
'stuck_out_tongue_winking_eye',
'stuck_out_tongue',
'sunglasses',
'thinking',
'raised_eyebrow',
'neutral_face',
'expressionless',
'rolling_eyes',
'smirk',
'persevere',
'disappointed',
'sweat',
'sob',
'cry',
'angry',
'rage',
'scream',
'sleepy',
'sleeping',
'relieved',
'nerd_face',
'clown_face',
'skull',
'poop',
'ghost',
'fire',
'sparkles',
'star',
'star2',
'zap',
'wave',
'clap',
'raised_hands',
'pray',
'muscle',
'ok_hand',
'v',
'+1',
'-1',
'point_up',
'point_down',
'point_left',
'point_right',
'middle_finger',
'hand',
'fist',
'handshake',
'writing_hand',
// Hearts & love
'heart',
'orange_heart',
'yellow_heart',
'green_heart',
'blue_heart',
'purple_heart',
'black_heart',
'broken_heart',
'sparkling_heart',
'heartpulse',
// Objects & symbols
'tada',
'confetti_ball',
'balloon',
'gift',
'trophy',
'medal',
'crown',
'gem',
'rocket',
'airplane',
'bulb',
'money_with_wings',
'moneybag',
'chart_with_upwards_trend',
'check',
'white_check_mark',
'heavy_check_mark',
'x',
'warning',
'exclamation',
'question',
'100',
'eyes',
'brain',
'speech_balloon',
'thought_balloon',
'email',
'envelope',
'calendar',
'clock',
'hourglass',
'mag',
'lock',
'key',
'hammer',
'wrench',
'gear',
'link',
'paperclip',
'scissors',
'memo',
'pencil',
'book',
'computer',
'phone',
'bell',
// Food & drink
'coffee',
'beer',
'wine_glass',
'pizza',
'hamburger',
'cookie',
'cake',
// Nature & weather
'sun_with_face',
'rainbow',
'cloud',
'snowflake',
'cherry_blossom',
'rose',
'four_leaf_clover',
// Animals
'dog',
'cat',
'unicorn',
'butterfly',
'bee',
];
const popularityMap = new Map(POPULAR_EMOJIS.map((name, i) => [name, i]));
function getPopularityScore(item: EmojiItem): number {
// Check the item name first
const nameScore = popularityMap.get(item.name);
if (nameScore !== undefined) return nameScore;
// Check shortcodes
for (const sc of item.shortcodes) {
const scScore = popularityMap.get(sc);
if (scScore !== undefined) return scScore;
}
// Not in popular list — push to the end
return POPULAR_EMOJIS.length;
}
// ============================================
// Emoji Suggestion Config
// ============================================
export const emojiSuggestion = {
items: ({ editor, query }: { editor: Editor; query: string }): EmojiItem[] => {
const emojis = (editor.storage.emoji?.emojis || []) as EmojiItem[];
if (!query) {
return [...emojis].sort((a, b) => getPopularityScore(a) - getPopularityScore(b)).slice(0, 10);
}
const lowerQuery = query.toLowerCase();
return emojis
.filter(
({ shortcodes, tags }) =>
shortcodes.some((shortcode) => shortcode.startsWith(lowerQuery)) ||
tags.some((tag) => tag.startsWith(lowerQuery)),
)
.sort((a, b) => getPopularityScore(a) - getPopularityScore(b))
.slice(0, 10);
},
render: () => {
let component: ReactRenderer;
let popupElement: HTMLDivElement | null = null;
let cleanup: (() => void) | null = null;
return {
onStart: (props: SuggestionProps) => {
component = new ReactRenderer(EmojiList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
// Create popup element
popupElement = document.createElement('div');
popupElement.style.position = 'absolute';
popupElement.style.zIndex = '99999';
popupElement.style.width = 'fit-content';
popupElement.appendChild(component.element);
document.body.appendChild(popupElement);
// Create virtual element for positioning
const virtualElement: VirtualElement = {
getBoundingClientRect: () => {
const rect = props.clientRect?.();
return rect || new DOMRect();
},
};
// Update position function
const updatePosition = async () => {
if (!popupElement) return;
const { x, y } = await computePosition(virtualElement, popupElement, {
placement: 'bottom-start' as Placement,
middleware: [offset(6), flip(), shift({ padding: 5 })],
});
Object.assign(popupElement.style, {
left: `${x}px`,
top: `${y}px`,
});
};
// Set up auto update
cleanup = autoUpdate(virtualElement, popupElement, updatePosition);
},
onUpdate(props: SuggestionProps) {
component.updateProps(props);
if (!props.clientRect || !popupElement) {
return;
}
// Create updated virtual element
const virtualElement: VirtualElement = {
getBoundingClientRect: () => {
const rect = props.clientRect?.();
return rect || new DOMRect();
},
};
// Update position
computePosition(virtualElement, popupElement, {
placement: 'bottom-start' as Placement,
middleware: [offset(6), flip(), shift({ padding: 5 })],
}).then(({ x, y }) => {
if (popupElement) {
Object.assign(popupElement.style, {
left: `${x}px`,
top: `${y}px`,
});
}
});
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === 'Escape') {
if (popupElement) {
popupElement.style.display = 'none';
}
return true;
}
// Type assertion for EmojiList ref
const emojiListRef = component.ref as {
onKeyDown?: (data: { event: KeyboardEvent }) => boolean;
} | null;
if (emojiListRef?.onKeyDown) {
const handled = emojiListRef.onKeyDown({ event: props.event });
if (handled) {
props.event.preventDefault();
props.event.stopPropagation();
}
return handled;
}
return false;
},
onExit() {
if (cleanup) {
cleanup();
}
if (popupElement && popupElement.parentNode) {
popupElement.parentNode.removeChild(popupElement);
popupElement = null;
}
component.destroy();
},
};
},
};