Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
5.9 kB · 197 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198// Emoji data + search.//// The data file is generated by `scripts/gen-emoji-data.mjs` from emojibase-data// and ships as a static asset at /emoji-data.json.
import { useEffect, useState } from "react";
export interface Skin { n: string;}
export interface Emoji { /** Canonical shortcode, e.g. "grinning-face". */ id: string; /** Human-readable name, e.g. "Grinning Face". */ m: string; /** Tags for keyword search. */ k: string[]; /** Skin variants. s[0] is always the default (no tone modifier); * s[1..5] correspond to Fitzpatrick tones 1-5 when the emoji supports them. */ s: Skin[];}
export interface EmojiData { /** Map of canonical id → emoji. */ emojis: { [id: string]: Emoji }; /** Alternate shortcodes (e.g. "shrug" → "person-shrugging") pointing to the canonical id. */ aliases: { [alias: string]: string };}
/** Returns the native character for a given Fitzpatrick tone (0 = default). */export function getSkinNative(emoji: Emoji, skinTone: number = 0): string { return (emoji.s[skinTone] ?? emoji.s[0]).n;}
const DATA_URL = "/emoji-data.json";
// Module-level promise cache so concurrent callers (e.g. two chat inputs// mounting at the same time) share a single fetch. The HTTP layer also caches,// but we don't want to fire two parses for the same data.let dataPromise: Promise<EmojiData> | null = null;
export function getEmojiData(): Promise<EmojiData> { if (dataPromise) return dataPromise; dataPromise = fetch(DATA_URL) .then((r) => { if (!r.ok) throw new Error(`emoji-data fetch failed: ${r.status}`); return r.json() as Promise<EmojiData>; }) .catch((err) => { // If the fetch fails, drop the promise so a future call can retry. dataPromise = null; throw err; }); return dataPromise;}
/** React hook: returns the loaded emoji data, or null until it's ready. */export function useEmojiData(): EmojiData | null { const [data, setData] = useState<EmojiData | null>(null); useEffect(() => { let cancelled = false; getEmojiData() .then((d) => { if (!cancelled) setData(d); }) .catch((err) => { console.error("Failed to load emoji data", err); }); return () => { cancelled = true; }; }, []); return data;}
const MIN_QUERY_LENGTH = 3;const MAX_RESULTS = 10;
/** * Rank-matching emoji for a `:`-prefixed query. * * Ported from the React Native reference implementation in * `js/components/src/components/chat/chat-box.tsx`. Match order, all else equal: * 0. exact alias → 1. alias startsWith → 2. alias includes * 3. exact id → 4. id startsWith → 5. id includes * 6. name includes → 7. keyword includes * * Ties on matchType are broken by position of the substring within the * matched field. * * @param data the loaded emoji data, or null when not yet available * @param query the text after the last `:` (no colon), already lowercased * @param limit cap on the result list */export function searchEmojis( data: EmojiData | null, query: string, limit: number = MAX_RESULTS,): Emoji[] { if (!data) return []; if (query.length < MIN_QUERY_LENGTH) return []; if (query.includes(" ")) return [];
// For each alias, compute a match type (0=exact, 1=prefix, 2=contains) and // the substring position. We then collapse aliases to "the best one per // emoji" so each emoji is only represented once. const aliasMatches = Object.entries(data.aliases) .map(([alias, emojiId]) => { const aliasLower = alias.toLowerCase(); if (aliasLower === query) { return { emojiId, alias, matchType: 0, index: 0 }; } if (aliasLower.startsWith(query)) { return { emojiId, alias, matchType: 1, index: 0 }; } if (aliasLower.includes(query)) { return { emojiId, alias, matchType: 2, index: aliasLower.indexOf(query), }; } return null; }) .filter((m): m is NonNullable<typeof m> => m !== null);
// Best alias match per emoji id. const bestAliasMatch: Record< string, { matchType: number; index: number; alias: string } > = {}; for (const match of aliasMatches) { const prev = bestAliasMatch[match.emojiId]; if ( !prev || match.matchType < prev.matchType || (match.matchType === prev.matchType && match.index < prev.index) ) { bestAliasMatch[match.emojiId] = match; } }
const allEmojis = Object.values(data.emojis); const seen = new Set<string>(); const out: { emoji: Emoji; sort: number[] }[] = [];
for (const emoji of allEmojis) { if (seen.has(emoji.id)) continue; const aliasMatch = bestAliasMatch[emoji.id]; if (aliasMatch) { seen.add(emoji.id); out.push({ emoji, sort: [aliasMatch.matchType, aliasMatch.index, 0], }); continue; } const idLower = emoji.id.toLowerCase(); if (idLower === query) { seen.add(emoji.id); out.push({ emoji, sort: [3, 0, 0] }); continue; } if (idLower.startsWith(query)) { seen.add(emoji.id); out.push({ emoji, sort: [4, 0, 0] }); continue; } if (idLower.includes(query)) { seen.add(emoji.id); out.push({ emoji, sort: [5, idLower.indexOf(query), 0] }); continue; } const nameLower = emoji.m.toLowerCase(); if (nameLower.includes(query)) { seen.add(emoji.id); out.push({ emoji, sort: [6, nameLower.indexOf(query), 0] }); continue; } if (emoji.k.some((kw) => kw.toLowerCase().includes(query))) { seen.add(emoji.id); out.push({ emoji, sort: [7, 0, 0] }); continue; } }
out.sort((a, b) => { for (let i = 0; i < a.sort.length; i++) { if (a.sort[i] !== b.sort[i]) return a.sort[i] - b.sort[i]; } return 0; });
return out.slice(0, limit).map((e) => e.emoji);}