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