195 lines
6.7 KiB
TypeScript
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>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|