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
|
||||
);
|
||||
}
|
||||
182
frontend/components/markdown/MdCodeBlock.tsx
Normal file
182
frontend/components/markdown/MdCodeBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
frontend/components/markdown/MdImage.tsx
Normal file
76
frontend/components/markdown/MdImage.tsx
Normal 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))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
frontend/components/markdown/MdLink.tsx
Normal file
38
frontend/components/markdown/MdLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
frontend/components/markdown/mergeComponents.tsx
Normal file
16
frontend/components/markdown/mergeComponents.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
45
frontend/components/markdown/renderMarkdown.tsx
Normal file
45
frontend/components/markdown/renderMarkdown.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user