feat: 论坛 emoji 表情支持

- 新增 dyEmoji 表情库与解析测试、EmojiPicker 选择器与 EmojiImg 组件
- Markdown 编辑器/渲染支持表情图片,新增 public/emoji 资源与生成脚本
- 聊天室与首页适配
This commit is contained in:
2026-09-28 01:28:41 +08:00
parent 2f96a36275
commit 34aee91a13
3522 changed files with 1560 additions and 251 deletions

View 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>
);
}