feat(frontend): Markdown 支持 GFM、代码高亮与图片灯箱

统一 SSR/客户端渲染路径,评论与预览共用增强组件。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 05:41:25 +08:00
parent f3145b24ff
commit 2056d6a3f9
14 changed files with 1458 additions and 31 deletions

View File

@@ -4,7 +4,6 @@ import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Trash2, MessagesSquare, ChevronRight, ChevronDown, BadgeCheck } from "lucide-react";
import ReactMarkdown from "react-markdown";
import {
apiCreateComment,
apiDeleteComment,
@@ -18,6 +17,7 @@ import { canModerateBoard } from "@/lib/roles";
import { toast } from "@/lib/toast";
import Avatar from "./Avatar";
import Pagination from "./Pagination";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
interface CommentSectionProps {
postId: string;
@@ -294,8 +294,11 @@ export default function CommentSection({
</>
)}
</div>
<div className="prose-content prose-comment mt-0.5">
<ReactMarkdown>{c.content}</ReactMarkdown>
<div className="mt-0.5">
<MarkdownBodyClient
content={c.content}
className="prose-content prose-comment"
/>
</div>
{/* 底部行:时间 · 回复(左),删除(右) */}
<div className="flex items-center justify-between mt-1.5">
@@ -398,9 +401,10 @@ export default function CommentSection({
</a>
)}
</div>
<div className="prose-content prose-comment">
<ReactMarkdown>{c.content}</ReactMarkdown>
</div>
<MarkdownBodyClient
content={c.content}
className="prose-content prose-comment"
/>
{/* 底部行:时间 · 回复(左),删除(右) */}
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-3">

View File

@@ -1,4 +1,5 @@
import ReactMarkdown, { type Components } from "react-markdown";
import type { Components } from "react-markdown";
import { renderMarkdownToReact } from "@/components/markdown/renderMarkdown";
type MarkdownBodyProps = {
content: string;
@@ -6,15 +7,15 @@ type MarkdownBodyProps = {
components?: Components;
};
/** 站点统一 Markdown 渲染:详情页 / 发帖预览共用 prose-content */
export default function MarkdownBody({
/**
* 站点统一 Markdown 渲染(服务端):GFM + Shiki 双主题、图箱、代码块复制/折叠。
* 客户端预览请用 MarkdownBodyClient。
*/
export default async function MarkdownBody({
content,
className = "prose-content",
components,
}: MarkdownBodyProps) {
return (
<div className={className}>
<ReactMarkdown components={components}>{content || ""}</ReactMarkdown>
</div>
);
const tree = await renderMarkdownToReact(content, components);
return <div className={className}>{tree}</div>;
}

View File

@@ -0,0 +1,32 @@
"use client";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { mergeMarkdownComponents } from "@/components/markdown/mergeComponents";
type MarkdownBodyClientProps = {
content: string;
className?: string;
components?: Components;
};
/**
* 客户端 Markdown(发帖预览 / 评论):GFM + 图箱 / 代码折叠复制。
* 语法高亮在 MdCodeBlock 内按需用 Shiki 补齐。
*/
export default function MarkdownBodyClient({
content,
className = "prose-content",
components,
}: MarkdownBodyClientProps) {
return (
<div className={className}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={mergeMarkdownComponents(components)}
>
{content || ""}
</ReactMarkdown>
</div>
);
}

View File

@@ -17,7 +17,7 @@ import {
} from "lucide-react";
import { apiUploadPostImage } from "@/lib/api";
import { toast } from "@/lib/toast";
import MarkdownBody from "@/components/MarkdownBody";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
type MarkdownEditorProps = {
value: string;
@@ -304,7 +304,7 @@ export default function MarkdownEditor({
) : (
<div className="j13-md-preview">
{value.trim() ? (
<MarkdownBody content={value} />
<MarkdownBodyClient content={value} />
) : (
<p className="meta text-center py-16">暂无内容可预览</p>
)}

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

View File

@@ -0,0 +1,182 @@
"use client";
import {
useMemo,
useRef,
useState,
useEffect,
type HTMLAttributes,
type ReactNode,
} from "react";
import { Check, Copy, ChevronDown, ChevronUp } from "lucide-react";
import { toast } from "@/lib/toast";
const COLLAPSE_LINE_THRESHOLD = 5;
/** 约 5 行代码高度(含行高) */
const COLLAPSED_MAX_HEIGHT = "7.75rem";
type MdCodeBlockProps = HTMLAttributes<HTMLPreElement> & {
children?: ReactNode;
};
function extractText(node: ReactNode): string {
if (node == null || typeof node === "boolean") return "";
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(extractText).join("");
if (typeof node === "object" && "props" in node) {
const el = node as { props?: { children?: ReactNode } };
return extractText(el.props?.children);
}
return "";
}
function countLines(pre: HTMLElement | null, fallbackText: string): number {
if (pre) {
const lineEls = pre.querySelectorAll(".line, [data-line]");
if (lineEls.length > 0) return lineEls.length;
}
const text = fallbackText.replace(/\n$/, "");
if (!text) return 0;
return text.split("\n").length;
}
function readLanguage(
el: HTMLElement | null,
className?: string,
dataLanguage?: string
): string {
const fromData = dataLanguage || el?.getAttribute("data-language") || "";
if (fromData) return fromData;
const cls = [className, el?.className].filter(Boolean).join(" ");
const m = cls.match(/language-([\w+-]+)/);
return m?.[1] || "";
}
/** Markdown 代码块:语言标签、复制、>5 行默认折叠;客户端可补 Shiki 双主题 */
export default function MdCodeBlock({ children, className, ...rest }: MdCodeBlockProps) {
const hostRef = useRef<HTMLDivElement>(null);
const plainText = useMemo(() => extractText(children), [children]);
const dataLanguage =
typeof (rest as Record<string, unknown>)["data-language"] === "string"
? String((rest as Record<string, unknown>)["data-language"])
: undefined;
const approxLines = useMemo(() => countLines(null, plainText), [plainText]);
const alreadyShiki =
typeof className === "string" && className.includes("shiki");
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
const [lineCount, setLineCount] = useState(approxLines);
const [language, setLanguage] = useState(() =>
readLanguage(null, className, dataLanguage)
);
const [clientHtml, setClientHtml] = useState<string | null>(null);
useEffect(() => {
const host = hostRef.current;
const pre = host?.querySelector("pre") ?? null;
setLineCount(countLines(pre, plainText));
setLanguage(readLanguage(pre, className, dataLanguage));
}, [plainText, className, dataLanguage, clientHtml]);
// 服务端已着色时跳过;客户端预览动态补双主题
useEffect(() => {
if (alreadyShiki || !plainText.trim()) return;
const lang = readLanguage(null, className, dataLanguage) || "plaintext";
let cancelled = false;
(async () => {
try {
const { codeToHtml } = await import("shiki");
const html = await codeToHtml(plainText.replace(/\n$/, ""), {
lang: lang === "text" ? "plaintext" : lang,
themes: {
light: "github-light",
dark: "github-dark-dimmed",
},
defaultColor: false,
});
if (!cancelled) setClientHtml(html);
} catch {
// 未知语言:保持纯文本
}
})();
return () => {
cancelled = true;
};
}, [plainText, className, dataLanguage, alreadyShiki]);
const effectiveLines = lineCount > 0 ? lineCount : approxLines;
const canCollapse = effectiveLines > COLLAPSE_LINE_THRESHOLD;
const collapsed = canCollapse && !expanded;
const onCopy = async () => {
const pre = hostRef.current?.querySelector("pre");
const text =
pre?.innerText?.replace(/\n$/, "") || plainText.replace(/\n$/, "");
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
} catch {
toast("复制失败,请手动选择文本");
}
};
const preClass = ["md-code-pre", className].filter(Boolean).join(" ");
return (
<div className="md-code-block">
<div className="md-code-toolbar">
<span className="md-code-lang">{language || "text"}</span>
<button
type="button"
className="md-code-copy"
onClick={onCopy}
aria-label={copied ? "已复制" : "复制代码"}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
<span>{copied ? "已复制" : "复制"}</span>
</button>
</div>
<div
ref={hostRef}
className={`md-code-body${collapsed ? " md-code-collapsed" : ""}`}
style={collapsed ? { maxHeight: COLLAPSED_MAX_HEIGHT } : undefined}
>
{clientHtml ? (
<div
className="md-code-shiki-host"
dangerouslySetInnerHTML={{ __html: clientHtml }}
/>
) : (
<pre className={preClass} {...rest}>
{children}
</pre>
)}
{collapsed && <div className="md-code-fade" aria-hidden="true" />}
</div>
{canCollapse && (
<button
type="button"
className="md-code-fold"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? (
<>
<ChevronUp size={14} />
收起
</>
) : (
<>
<ChevronDown size={14} />
展开全部({effectiveLines} 行)
</>
)}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,76 @@
"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.getAttribute("alt") || 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.alt || 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);
}, []);
if (!src || broken) {
return (
<span className="md-img-fallback" role="img" aria-label={alt || "图片加载失败"}>
{alt || "图片加载失败"}
</span>
);
}
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
{...rest}
src={src}
alt={alt || ""}
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))
}
/>
)}
</>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import type { AnchorHTMLAttributes } from "react";
type MdLinkProps = AnchorHTMLAttributes<HTMLAnchorElement>;
function isExternalHref(href?: string): boolean {
if (!href) return false;
if (href.startsWith("/") || href.startsWith("#") || href.startsWith("?")) {
return false;
}
if (href.startsWith("mailto:") || href.startsWith("tel:")) return false;
try {
if (typeof window !== "undefined") {
const url = new URL(href, window.location.origin);
return url.origin !== window.location.origin;
}
} catch {
/* 相对或非法 URL 当站内 */
}
return /^https?:\/\//i.test(href);
}
/** Markdown 链接:外链新开页 */
export default function MdLink({ href, children, ...rest }: MdLinkProps) {
const external = isExternalHref(href);
return (
<a
{...rest}
href={href}
{...(external
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
>
{children}
</a>
);
}

View File

@@ -0,0 +1,16 @@
import type { Components } from "react-markdown";
import MdImage from "./MdImage";
import MdCodeBlock from "./MdCodeBlock";
import MdLink from "./MdLink";
/** 默认自定义标签:图箱 / 代码块 / 外链;可被调用方浅合并覆盖 */
export function mergeMarkdownComponents(
overrides?: Components
): Components {
return {
img: MdImage,
pre: MdCodeBlock,
a: MdLink,
...overrides,
};
}

View File

@@ -0,0 +1,45 @@
import type { Components } from "react-markdown";
import type { ReactElement } from "react";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypePrettyCode from "rehype-pretty-code";
import type { Root } from "hast";
import { mergeMarkdownComponents } from "./mergeComponents";
export const prettyCodeOptions = {
theme: {
light: "github-light",
dark: "github-dark-dimmed",
},
keepBackground: false,
defaultLang: "plaintext",
} as const;
/** 服务端异步管线:GFM + Shiki 双主题高亮 → React 树 */
export async function renderMarkdownToReact(
content: string,
overrides?: Components
): Promise<ReactElement> {
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypePrettyCode, { ...prettyCodeOptions });
const mdast = processor.parse(content || "");
const hast = (await processor.run(mdast)) as Root;
return toJsxRuntime(hast, {
Fragment,
jsx,
jsxs,
components: mergeMarkdownComponents(overrides) as Record<
string,
React.ElementType
>,
}) as ReactElement;
}