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,30 @@
"use client";
import { useState } from "react";
import { dyEmojiUrl } from "@/lib/dyEmoji";
type EmojiImgProps = {
/** 表情 key(短代码 [:key:] 的 key) */
k: string;
className?: string;
};
/** 抖音表情小图:加载失败回退为 [表情] 文本,避免破图 */
export default function EmojiImg({ k, className }: EmojiImgProps) {
const [broken, setBroken] = useState(false);
if (broken) {
return <span className={className}>[表情]</span>;
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={dyEmojiUrl(k)}
alt={`[:${k}:]`}
title={`[:${k}:]`}
className={className}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
onError={() => setBroken(true)}
/>
);
}

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

View File

@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import type { Components } from "react-markdown";
import { renderMarkdownToReact } from "@/components/markdown/renderMarkdown";
import { splitContentSegments } from "@/lib/directiveBlocks";
import { expandDyEmojiShortcodes } from "@/lib/dyEmoji";
import type { HideKind } from "@/lib/hideBlocks";
import MdHideBlock from "@/components/markdown/MdHideBlock";
import MdTimeline from "@/components/markdown/MdTimeline";
@@ -28,7 +29,7 @@ export default async function MarkdownBody({
loginHref,
loggedIn = false,
}: MarkdownBodyProps) {
const segs = splitContentSegments(content || "");
const segs = splitContentSegments(expandDyEmojiShortcodes(content || ""));
const nodes = [];
for (let i = 0; i < segs.length; i++) {
const seg = segs[i];
@@ -48,7 +49,7 @@ export default async function MarkdownBody({
continue;
}
// 条目正文不挂目录 id,避免与块外 headingRenderer 错位
bodies.push(await renderMarkdownToReact(it.body));
bodies.push(await renderMarkdownToReact(expandDyEmojiShortcodes(it.body)));
}
nodes.push(
<MdTimeline
@@ -75,7 +76,7 @@ export default async function MarkdownBody({
continue;
}
const inner = seg.body.trim()
? await renderMarkdownToReact(seg.body)
? await renderMarkdownToReact(expandDyEmojiShortcodes(seg.body))
: null;
nodes.push(
<MdHideBlock key={`hide-${i}`} kind={kind} points={seg.points}>

View File

@@ -4,6 +4,7 @@ import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { mergeMarkdownComponents } from "@/components/markdown/mergeComponents";
import { splitContentSegments } from "@/lib/directiveBlocks";
import { expandDyEmojiShortcodes } from "@/lib/dyEmoji";
import type { HideKind } from "@/lib/hideBlocks";
import MdHideBlock from "@/components/markdown/MdHideBlock";
import MdTimeline from "@/components/markdown/MdTimeline";
@@ -28,7 +29,7 @@ export default function MarkdownBodyClient({
loginHref,
loggedIn = false,
}: MarkdownBodyClientProps) {
const segs = splitContentSegments(content || "");
const segs = splitContentSegments(expandDyEmojiShortcodes(content || ""));
const mdComponents = mergeMarkdownComponents(components);
// 时间线/隐藏块正文不套外层 heading 覆盖,避免预览与正文目录 id 错位
const nestedMd = mergeMarkdownComponents();
@@ -52,7 +53,7 @@ export default function MarkdownBodyClient({
renderBody={(it) =>
it.body.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
{it.body}
{expandDyEmojiShortcodes(it.body)}
</ReactMarkdown>
) : null
}
@@ -77,7 +78,7 @@ export default function MarkdownBodyClient({
<MdHideBlock key={`hide-${i}`} kind={kind} points={seg.points}>
{seg.body.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
{seg.body}
{expandDyEmojiShortcodes(seg.body)}
</ReactMarkdown>
) : null}
</MdHideBlock>

View File

@@ -32,12 +32,14 @@ import {
ChevronDown,
History,
Ellipsis,
Smile,
} from "lucide-react";
import { apiSearchPosts, apiUploadPostImage, type PostListItem } from "@/lib/api";
import { toast } from "@/lib/toast";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
import ImageInsertModal from "@/components/ImageInsertModal";
import TimelineEditor from "@/components/TimelineEditor";
import EmojiPicker from "@/components/EmojiPicker";
import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks";
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
import { postPath } from "@/lib/urlStyle";
@@ -88,7 +90,7 @@ type WrapOpts = {
linePrefix?: string;
};
type PopoverKind = "link" | "code" | "hide" | "heading" | "more" | null;
type PopoverKind = "link" | "code" | "hide" | "heading" | "more" | "emoji" | null;
const CODE_LANGS = ["plaintext", "aardio", ...SHIKI_LANG_IDS.filter((l) => l !== "plaintext")] as const;
@@ -412,6 +414,36 @@ export default function MarkdownEditor({
[value, onChange]
);
/** 行内插入(无换行 padding):表情等短代码用 */
const insertInline = useCallback(
(text: string) => {
const el = taRef.current;
if (!el) {
onChange(value + text);
return;
}
const start = savedSel.current?.start ?? el.selectionStart;
const end = savedSel.current?.end ?? el.selectionEnd;
const next = value.slice(0, start) + text + value.slice(end);
onChange(next);
requestAnimationFrame(() => {
el.focus();
const caret = start + text.length;
el.setSelectionRange(caret, caret);
});
savedSel.current = null;
},
[value, onChange]
);
const pickEmoji = useCallback(
(key: string) => {
insertInline(`[:${key}:]`);
setPopover(null);
},
[insertInline]
);
const uploadImageFile = async (file: File | null) => {
if (!file || disabled) return;
retryFile.current = file;
@@ -976,6 +1008,19 @@ export default function MarkdownEditor({
</div>
)}
</div>
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="表情"
icon={<Smile size={15} />}
active={popover === "emoji"}
onClick={() => openPopover("emoji")}
/>
{popover === "emoji" && (
<div className="j13-md-popover j13-md-emoji-popover" role="dialog" aria-label="插入表情">
<EmojiPicker onPick={pickEmoji} />
</div>
)}
</div>
<div className="j13-md-popover-anchor relative">
<ToolBtn
label={uploading ? "上传中…" : "图片"}

View File

@@ -42,6 +42,29 @@ export default function MdImage({ src, alt, title, className, ...rest }: MdImage
const provided = (typeof alt === "string" ? alt.trim() : "") || (typeof title === "string" ? title.trim() : "");
const imgAlt = provided || "图片";
// 抖音表情(/emoji/ 路径):行内小图,不进图箱、不带 caption;
// alt 由 expandDyEmojiShortcodes 产出("dy:<key>"),悬停 title 显示其短代码
const isEmoji = typeof src === "string" && src.includes("/emoji/");
const emojiSc =
typeof alt === "string" && alt.startsWith("dy:") ? `[:${alt.slice(3)}:]` : undefined;
if (isEmoji && !broken) {
return (
/* eslint-disable-next-line @next/next/no-img-element */
<img
{...rest}
src={src}
alt={imgAlt}
title={emojiSc}
className={`j13-md-emoji${className ? ` ${className}` : ""}`}
loading="lazy"
decoding="async"
draggable={false}
onContextMenu={(e) => e.preventDefault()}
onError={() => setBroken(true)}
/>
);
}
if (!src || broken) {
return (
<span className="md-img-fallback" role="img" aria-label={provided || "图片加载失败"}>