feat: 论坛 emoji 表情支持
- 新增 dyEmoji 表情库与解析测试、EmojiPicker 选择器与 EmojiImg 组件 - Markdown 编辑器/渲染支持表情图片,新增 public/emoji 资源与生成脚本 - 聊天室与首页适配
This commit is contained in:
195
frontend/components/EmojiPicker.tsx
Normal file
195
frontend/components/EmojiPicker.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user