"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { DY_EMOJI_BASE, DY_EMOJI_GROUPS, dyEmojiUrl, type DyEmojiManifest } from "@/lib/dyEmoji"; type EmojiPickerProps = { onPick: (key: string) => void; }; type Tab = "tos" | "fus"; const CHUNK = 120; /** 最近使用一排的容量(与面板 8 列对齐,多了挤掉最旧的) */ const RECENT_MAX = 8; const RECENT_STORAGE_KEY = "j13_dy_emoji_recent"; let manifestPromise: Promise | null = null; async function fetchManifest(): Promise { try { const res = await fetch(`${DY_EMOJI_BASE}/manifest.json`); if (!res.ok) return null; const data = (await res.json()) as DyEmojiManifest; if (!Array.isArray(data.tos) || !Array.isArray(data.fus)) return null; return data; } catch { return null; } } /** 拉取表情清单(模块级缓存,全站只请求一次;仅「合成」tab 使用) */ function useDyEmojiManifest(): { manifest: DyEmojiManifest | null; failed: boolean } { const [state, setState] = useState<{ manifest: DyEmojiManifest | null; failed: boolean }>({ manifest: null, failed: false, }); useEffect(() => { if (!manifestPromise) manifestPromise = fetchManifest(); let cancelled = false; manifestPromise.then((m) => { if (!cancelled) setState({ manifest: m, failed: m === null }); }); return () => { cancelled = true; }; }, []); return state; } function loadRecentKeys(): string[] { try { const raw = localStorage.getItem(RECENT_STORAGE_KEY); if (!raw) return []; const arr = JSON.parse(raw); return Array.isArray(arr) ? arr.filter((k) => typeof k === "string").slice(0, RECENT_MAX) : []; } catch { return []; } } function saveRecentKeys(keys: string[]) { try { localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(keys)); } catch { // 隐私模式等存储不可用时静默降级为会话内记忆 } } /** 记录最近使用:最新插前、去重、超出一排丢弃最旧(LRU),返回新列表 */ function pushRecentKey(k: string): string[] { const next = [k, ...loadRecentKeys().filter((x) => x !== k)].slice(0, RECENT_MAX); saveRecentKeys(next); return next; } function EmojiCell({ k, onPick }: { k: string; onPick: (k: string) => void }) { return ( ); } /** * 抖音表情面板:默认 tab 按分组排列(同类型相邻)+ 顶部最近使用一排; * 合成 tab 分块渲染避免一次挂载数千图片。 */ export default function EmojiPicker({ onPick }: EmojiPickerProps) { const { manifest, failed } = useDyEmojiManifest(); const [tab, setTab] = useState("tos"); const [visible, setVisible] = useState(CHUNK); const [recent, setRecent] = useState(() => loadRecentKeys()); const scrollRef = useRef(null); const sentinelRef = useRef(null); const fusKeys = useMemo(() => (manifest ? manifest.fus : []), [manifest]); // 切 tab / 清单变化时重置分块 useEffect(() => { setVisible(CHUNK); }, [tab, manifest]); // 合成 tab 滚动到底部分块加载(哨兵离开/进入视口反复触发) useEffect(() => { const el = sentinelRef.current; if (tab !== "fus" || !el || visible >= fusKeys.length) return; const io = new IntersectionObserver( (entries) => { if (entries.some((e) => e.isIntersecting)) { setVisible((v) => Math.min(v + CHUNK, fusKeys.length)); } }, { root: scrollRef.current, rootMargin: "240px" } ); io.observe(el); return () => io.disconnect(); }, [tab, visible, fusKeys.length]); const pick = (k: string) => { setRecent(pushRecentKey(k)); onPick(k); }; return (
{recent.length > 0 && (
最近
{recent.map((k) => ( ))}
)} {tab === "tos" ? DY_EMOJI_GROUPS.map((g) => (
{g.label}
{g.keys.map((k) => ( ))}
)) : (
合成
{fusKeys.slice(0, visible).map((k) => ( ))} {fusKeys.length > visible &&
}
)} {tab === "fus" && !manifest && !failed &&

表情加载中…

} {tab === "fus" && !manifest && failed &&

表情加载失败

} {manifest && tab === "fus" && fusKeys.length === 0 && (

暂无表情

)}
); }