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

104 lines
3.3 KiB
TypeScript
Raw 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 { useState, useCallback, type ImgHTMLAttributes } from "react";
import ImageLightbox, { type LightboxItem } from "./ImageLightbox";
type MdImageProps = ImgHTMLAttributes<HTMLImageElement>;
function collectGallery(from: HTMLImageElement): { items: LightboxItem[]; index: number } {
const root = from.closest(".prose-content");
const nodes = root
? Array.from(root.querySelectorAll<HTMLImageElement>("img.md-content-img[src]"))
: [from];
const items: LightboxItem[] = nodes
.map((el) => ({
src: el.currentSrc || el.getAttribute("src") || "",
alt: el.dataset.caption || undefined,
}))
.filter((it) => !!it.src);
const selfSrc = from.currentSrc || from.getAttribute("src") || "";
let index = items.findIndex((it) => it.src === selfSrc);
if (index < 0) index = 0;
if (items.length === 0 && selfSrc) {
return { items: [{ src: selfSrc, alt: from.dataset.caption || undefined }], index: 0 };
}
return { items, index };
}
/** Markdown 正文图片:点击进入图箱,同文多图可切换 */
export default function MdImage({ src, alt, title, className, ...rest }: MdImageProps) {
const [broken, setBroken] = useState(false);
const [lightbox, setLightbox] = useState<{
items: LightboxItem[];
index: number;
} | null>(null);
const open = useCallback((el: HTMLImageElement) => {
const gallery = collectGallery(el);
if (gallery.items.length === 0) return;
setLightbox(gallery);
}, []);
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 || "图片加载失败"}>
{provided || "图片加载失败"}
</span>
);
}
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
{...rest}
src={src}
alt={imgAlt}
data-caption={provided || undefined}
title={title}
className={`md-content-img${className ? ` ${className}` : ""}`}
loading="lazy"
decoding="async"
onClick={(e) => open(e.currentTarget)}
onError={() => setBroken(true)}
/>
{lightbox && (
<ImageLightbox
items={lightbox.items}
index={lightbox.index}
onClose={() => setLightbox(null)}
onIndexChange={(i) =>
setLightbox((prev) => (prev ? { ...prev, index: i } : prev))
}
/>
)}
</>
);
}