- 新增 dyEmoji 表情库与解析测试、EmojiPicker 选择器与 EmojiImg 组件 - Markdown 编辑器/渲染支持表情图片,新增 public/emoji 资源与生成脚本 - 聊天室与首页适配
104 lines
3.3 KiB
TypeScript
104 lines
3.3 KiB
TypeScript
"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))
|
||
}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|