151 lines
5.1 KiB
TypeScript
151 lines
5.1 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback, useRef } from "react";
|
|
import { marked } from "marked";
|
|
import styles from "./lesson-markdown.module.css";
|
|
|
|
type TocItem = { level: number; text: string; id: string };
|
|
|
|
function slugify(text: string): string {
|
|
return text
|
|
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
|
.replace(/\s+/g, "-")
|
|
.toLowerCase();
|
|
}
|
|
|
|
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 markdownToHtml(content: string): string {
|
|
const raw = marked.parse(content, { breaks: true, gfm: true }) as string;
|
|
let html = raw
|
|
.replace(/<img /g, '<img loading="lazy" decoding="async" ')
|
|
.replace(/<a href=/g, '<a href=');
|
|
// 给 h1/h2 添加 id
|
|
html = html.replace(/<h1>([^<]*)<\/h1>/gi, (_, text) => {
|
|
const t = text.replace(/<[^>]+>/g, "").trim();
|
|
return `<h1 id="${slugify(t)}" class="scroll-mt-24">${text}</h1>`;
|
|
});
|
|
html = html.replace(/<h2>([^<]*)<\/h2>/gi, (_, text) => {
|
|
const t = text.replace(/<[^>]+>/g, "").trim();
|
|
return `<h2 id="${slugify(t)}" class="scroll-mt-24">${text}</h2>`;
|
|
});
|
|
html = html.replace(/<a href="([^"]+)"([^>]*)>/g, '<a href="$1" target="_blank" rel="noopener noreferrer"$2>');
|
|
html = html.replace(/<pre>\s*<code([^>]*)>/g, `<pre class="${styles.codeBlock}"><code$1>`);
|
|
return html;
|
|
}
|
|
|
|
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);
|
|
const html = markdownToHtml(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`}
|
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
/>
|
|
|
|
<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>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|