Files
gitlab-instance-0a899031_di…/app/[locale]/course/[moduleIndex]/[lessonIndex]/LessonMarkdown.tsx
2026-03-11 22:26:12 -05:00

195 lines
6.7 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/atom-one-dark.min.css";
import styles from "./lesson-markdown.module.css";
type TocItem = { level: number; text: string; id: string };
function extractToc(content: string): TocItem[] {
const items: TocItem[] = [];
const lines = content.split("\n");
let idCounter = 0;
for (const line of lines) {
const h1 = line.match(/^# (.+)$/);
const h2 = line.match(/^## (.+)$/);
if (h1) {
items.push({ level: 1, text: h1[1].replace(/#{1,6}\s/g, "").trim(), id: `h-${idCounter++}` });
} else if (h2) {
items.push({ level: 2, text: h2[1].replace(/#{1,6}\s/g, "").trim(), id: `h-${idCounter++}` });
}
}
return items;
}
function slugify(text: string): string {
return text
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.replace(/\s+/g, "-")
.toLowerCase();
}
export function LessonMarkdown({ content }: { content: string }) {
const [readProgress, setReadProgress] = useState(0);
const [showBackTop, setShowBackTop] = useState(false);
const [showToc, setShowToc] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const toc = extractToc(content);
useEffect(() => {
let ticking = false;
const onScroll = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
setReadProgress(docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0);
setShowBackTop(scrollTop > 400);
ticking = false;
});
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
const handleTocJump = useCallback((index: number) => {
setShowToc(false);
const root = contentRef.current;
if (!root) return;
const headings = root.querySelectorAll("h1, h2");
const el = headings[index] as HTMLElement;
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, []);
return (
<>
<div className={styles.readingProgress} style={{ width: `${readProgress}%` }} />
<div ref={contentRef} className={`${styles.markdownBody} markdown-body`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeHighlight, { detect: true }]]}
components={{
h1: ({ children }) => {
const text = String(children);
const id = slugify(text);
return (
<h1 id={id} className="scroll-mt-24">
{children}
</h1>
);
},
h2: ({ children }) => {
const text = String(children);
const id = slugify(text);
return (
<h2 id={id} className="scroll-mt-24">
{children}
</h2>
);
},
h3: ({ children }) => <h3 className="scroll-mt-20">{children}</h3>,
h4: ({ children }) => <h4>{children}</h4>,
h5: ({ children }) => <h5>{children}</h5>,
h6: ({ children }) => <h6>{children}</h6>,
p: ({ children }) => <p>{children}</p>,
ul: ({ children }) => <ul>{children}</ul>,
ol: ({ children }) => <ol>{children}</ol>,
li: ({ children, className }) => (
<li className={className?.includes("task-list-item") ? "flex items-start gap-2" : ""}>
{children}
</li>
),
pre: ({ children }) => (
<pre className={styles.codeBlock}>{children}</pre>
),
code: ({ className, children }) => (
<code className={className}>{children}</code>
),
blockquote: ({ children }) => <blockquote>{children}</blockquote>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
img: ({ src, alt }) => (
// eslint-disable-next-line @next/next/no-img-element
<img src={src || ""} alt={alt || ""} loading="lazy" decoding="async" />
),
table: ({ children }) => (
<div className="overflow-x-auto">
<table>{children}</table>
</div>
),
th: ({ children }) => <th>{children}</th>,
td: ({ children }) => <td>{children}</td>,
hr: () => <hr />,
strong: ({ children }) => <strong>{children}</strong>,
em: ({ children }) => <em>{children}</em>,
}}
>
{content}
</ReactMarkdown>
</div>
<div
className={`${styles.drawerMask} ${showToc ? styles.drawerMaskOpen : ""}`}
onClick={() => setShowToc(false)}
aria-hidden="true"
/>
<aside className={`${styles.drawer} ${showToc ? styles.drawerOpen : ""}`}>
<div className={styles.drawerTitle}></div>
{toc.map((item, i) => (
<div
key={item.id}
role="button"
tabIndex={0}
className={item.level === 2 ? `${styles.drawerItem} ${styles.drawerItemH2}` : styles.drawerItem}
onClick={() => handleTocJump(i)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleTocJump(i);
}}
>
{item.text}
</div>
))}
</aside>
{showBackTop && (
<>
{toc.length > 0 && (
<button
type="button"
className={styles.tocButton}
onClick={() => setShowToc(true)}
aria-label="打开目录"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
)}
<button
type="button"
className={styles.backToTop}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
aria-label="回到顶部"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 15l-6-6-6 6" />
</svg>
</button>
</>
)}
</>
);
}