Files
jiang13-bbs/frontend/components/EmojiPicker.tsx
freefire 34aee91a13 feat: 论坛 emoji 表情支持
- 新增 dyEmoji 表情库与解析测试、EmojiPicker 选择器与 EmojiImg 组件
- Markdown 编辑器/渲染支持表情图片,新增 public/emoji 资源与生成脚本
- 聊天室与首页适配
2026-09-28 01:28:41 +08:00

196 lines
6.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<DyEmojiManifest | null> | null = null;
async function fetchManifest(): Promise<DyEmojiManifest | null> {
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 (
<button
type="button"
className="j13-emoji-cell"
aria-label={`表情 ${k}`}
title={`[:${k}:]`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPick(k)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={dyEmojiUrl(k)} alt="" loading="lazy" draggable={false} />
</button>
);
}
/**
* 抖音表情面板:默认 tab 按分组排列(同类型相邻)+ 顶部最近使用一排;
* 合成 tab 分块渲染避免一次挂载数千图片。
*/
export default function EmojiPicker({ onPick }: EmojiPickerProps) {
const { manifest, failed } = useDyEmojiManifest();
const [tab, setTab] = useState<Tab>("tos");
const [visible, setVisible] = useState(CHUNK);
const [recent, setRecent] = useState<string[]>(() => loadRecentKeys());
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement | null>(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 (
<div className="j13-emoji-picker">
<div className="j13-emoji-tabs" role="tablist" aria-label="表情类型">
<button
type="button"
role="tab"
aria-selected={tab === "tos"}
className={tab === "tos" ? "is-on" : ""}
onClick={() => setTab("tos")}
>
默认
</button>
<button
type="button"
role="tab"
aria-selected={tab === "fus"}
className={tab === "fus" ? "is-on" : ""}
onClick={() => setTab("fus")}
>
合成
</button>
</div>
<div className="j13-emoji-scroll" ref={scrollRef}>
{recent.length > 0 && (
<div className="j13-emoji-group" aria-label="最近使用">
<div className="j13-emoji-group-label">最近</div>
<div className="j13-emoji-grid">
{recent.map((k) => (
<EmojiCell key={k} k={k} onPick={pick} />
))}
</div>
</div>
)}
{tab === "tos"
? DY_EMOJI_GROUPS.map((g) => (
<div className="j13-emoji-group" key={g.label}>
<div className="j13-emoji-group-label">{g.label}</div>
<div className="j13-emoji-grid">
{g.keys.map((k) => (
<EmojiCell key={k} k={k} onPick={pick} />
))}
</div>
</div>
))
: (
<div className="j13-emoji-group">
<div className="j13-emoji-group-label">合成</div>
<div className="j13-emoji-grid">
{fusKeys.slice(0, visible).map((k) => (
<EmojiCell key={k} k={k} onPick={pick} />
))}
{fusKeys.length > visible && <div ref={sentinelRef} aria-hidden />}
</div>
</div>
)}
{tab === "fus" && !manifest && !failed && <p className="j13-emoji-empty meta">表情加载中…</p>}
{tab === "fus" && !manifest && failed && <p className="j13-emoji-empty meta">表情加载失败</p>}
{manifest && tab === "fus" && fusKeys.length === 0 && (
<p className="j13-emoji-empty meta">暂无表情</p>
)}
</div>
</div>
);
}