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();
      },
    };
  },
};