feat(frontend): Markdown 支持 GFM、代码高亮与图片灯箱
统一 SSR/客户端渲染路径,评论与预览共用增强组件。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
122
frontend/components/markdown/ImageLightbox.tsx
Normal file
122
frontend/components/markdown/ImageLightbox.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
export type LightboxItem = {
|
||||
src: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
type ImageLightboxProps = {
|
||||
items: LightboxItem[];
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
onIndexChange: (index: number) => void;
|
||||
};
|
||||
|
||||
/** 全屏图箱:Esc 关闭,多图时左右切换 */
|
||||
export default function ImageLightbox({
|
||||
items,
|
||||
index,
|
||||
onClose,
|
||||
onIndexChange,
|
||||
}: ImageLightboxProps) {
|
||||
const total = items.length;
|
||||
const current = items[index];
|
||||
const hasNav = total > 1;
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (!hasNav) return;
|
||||
onIndexChange((index - 1 + total) % total);
|
||||
}, [hasNav, index, total, onIndexChange]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (!hasNav) return;
|
||||
onIndexChange((index + 1) % total);
|
||||
}, [hasNav, index, total, onIndexChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
else if (e.key === "ArrowLeft") goPrev();
|
||||
else if (e.key === "ArrowRight") goNext();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [onClose, goPrev, goNext]);
|
||||
|
||||
if (!current || typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="md-lightbox"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="图片预览"
|
||||
onClick={onClose}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="md-lightbox-close"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={22} />
|
||||
</button>
|
||||
|
||||
{hasNav && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="md-lightbox-nav md-lightbox-prev"
|
||||
aria-label="上一张"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goPrev();
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={28} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="md-lightbox-nav md-lightbox-next"
|
||||
aria-label="下一张"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goNext();
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={28} />
|
||||
</button>
|
||||
<div className="md-lightbox-counter" onClick={(e) => e.stopPropagation()}>
|
||||
{index + 1} / {total}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<figure
|
||||
className="md-lightbox-figure"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={current.src}
|
||||
alt={current.alt || ""}
|
||||
className="md-lightbox-img"
|
||||
draggable={false}
|
||||
/>
|
||||
{current.alt ? (
|
||||
<figcaption className="md-lightbox-caption">{current.alt}</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user