初始化提交

This commit is contained in:
2025-09-29 11:56:03 +08:00
parent 7e292ef96f
commit df2f1bf3dd
173 changed files with 31142 additions and 129 deletions

View File

@@ -0,0 +1,340 @@
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import remarkBreaks from 'remark-breaks';
import rehypeRaw from 'rehype-raw';
import rehypeKatex from 'rehype-katex';
import rehypeHighlight from 'rehype-highlight';
import { useApp } from '../context/AppContext';
import { generateBreadcrumbs, getFileTitle } from '../utils/fileUtils';
import './MarkdownRenderer.css';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/github.css';
function Breadcrumbs({ filePath }) {
const breadcrumbs = generateBreadcrumbs(filePath);
if (breadcrumbs.length === 0) return null;
return (
<nav className="breadcrumbs" aria-label="当前位置">
{breadcrumbs.map((crumb, index) => (
<span key={crumb.path} className="breadcrumb-item">
{index > 0 && <span className="breadcrumb-separator">/</span>}
<span className="breadcrumb-text">{crumb.name}</span>
</span>
))}
</nav>
);
}
function CodeBlock({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
if (inline) {
return (
<code className="inline-code" {...props}>
{children}
</code>
);
}
return (
<div className="code-block-wrapper">
{language && (
<div className="code-block-header">
<span className="code-language">{language}</span>
</div>
)}
<pre className={className} {...props}>
<code>{children}</code>
</pre>
</div>
);
}
function CustomLink({ href, children, ...props }) {
if (href && href.startsWith('[[') && href.endsWith(']]')) {
const linkText = href.slice(2, -2);
return (
<span className="internal-link" title={内部链接: }>
{children || linkText}
</span>
);
}
const isExternal = href && /^(https?:)?\/\//.test(href);
return (
<a
href={href}
target={isExternal ? '_blank' : '_self'}
rel={isExternal ? 'noopener noreferrer' : undefined}
className={isExternal ? 'external-link' : 'internal-link'}
{...props}
>
{children}
{isExternal && <span className="external-link-icon" aria-hidden>🔗</span>}
</a>
);
}
function CustomTable({ children, ...props }) {
return (
<div className="table-wrapper">
<table {...props}>{children}</table>
</div>
);
}
function useHeadings(content) {
return useMemo(() => {
const headingRegex = /^(#{1,4})\s+(.+)$/gm;
const headings = [];
let match;
while ((match = headingRegex.exec(content)) !== null) {
const [, hashes, text] = match;
const level = hashes.length;
if (level > 4) continue;
const plainText = text.replace(/[*_~]/g, '').trim();
const id = plainText
.toLowerCase()
.replace(/[^a-z0-9\u00C0-\u024f\u4e00-\u9fa5\s-]/g, '')
.replace(/\s+/g, '-');
headings.push({ id, text: plainText, level });
}
return headings;
}, [content]);
}
function TableOfContents({ headings }) {
if (headings.length === 0) return null;
return (
<aside className="content-toc" aria-label="目录">
<h3>目录</h3>
<ul className="toc-list">
{headings.map((heading) => (
<li key={heading.id} className="toc-item">
<a
className={ oc-link level-}
href={#}
>
{heading.text}
</a>
</li>
))}
</ul>
</aside>
);
}
export default function MarkdownRenderer() {
const { currentFile, currentContent, isLoading, sidebarOpen } = useApp();
const [activeHeading, setActiveHeading] = useState(null);
const headings = useHeadings(currentContent);
useEffect(() => {
if (typeof window === 'undefined' || headings.length === 0) {
return undefined;
}
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible[0]) {
setActiveHeading(visible[0].target.id);
}
},
{
rootMargin: '-40% 0px -50% 0px',
threshold: [0, 1],
}
);
headings.forEach((heading) => {
const element = document.getElementById(heading.id);
if (element) {
observer.observe(element);
}
});
return () => {
headings.forEach((heading) => {
const element = document.getElementById(heading.id);
if (element) {
observer.unobserve(element);
}
});
observer.disconnect();
};
}, [headings]);
const renderHeading = useCallback((Tag) => ({ children, ...props }) => {
const text = React.Children.toArray(children)
.map((child) => {
if (typeof child === 'string') return child;
if (React.isValidElement(child) && typeof child.props.children === 'string') {
return child.props.children;
}
return '';
})
.join(' ')
.trim();
const id = text
.toLowerCase()
.replace(/[^a-z0-9\u00C0-\u024f\u4e00-\u9fa5\s-]/g, '')
.replace(/\s+/g, '-');
return (
<Tag id={id} {...props}>
<a href={#} aria-hidden className="heading-anchor">
#
</a>
{children}
</Tag>
);
}, []);
const components = useMemo(() => ({
code: CodeBlock,
a: CustomLink,
table: CustomTable,
h1: renderHeading('h1'),
h2: renderHeading('h2'),
h3: renderHeading('h3'),
h4: renderHeading('h4'),
blockquote: ({ children, ...props }) => {
const childText = React.Children.toArray(children)
.map((child) => {
if (typeof child === 'string') return child.trim();
if (
React.isValidElement(child) &&
typeof child.props.children === 'string'
) {
return child.props.children.trim();
}
return '';
})
.join(' ');
const calloutMatch = childText.match(/^\s*\[(info|warning|danger|success)\]\s*/i);
const calloutType = calloutMatch ? calloutMatch[1].toLowerCase() : null;
return (
<blockquote
className={custom-blockquote}
{...props}
>
{children}
</blockquote>
);
},
img: ({ alt, ...props }) => (
<figure className="markdown-image">
<img alt={alt} {...props} />
{alt && <figcaption>{alt}</figcaption>}
</figure>
),
ol: ({ ordered, ...props }) => <ol className="ordered-list" {...props} />,
ul: ({ ordered, ...props }) => <ul className="unordered-list" {...props} />,
li: ({ checked, children, ...props }) => (
<li
className={list-item}
{...props}
>
{children}
</li>
),
}), [renderHeading]);
useEffect(() => {
if (!activeHeading) return undefined;
const tocLinks = document.querySelectorAll('.toc-link');
tocLinks.forEach((link) => {
if (link.getAttribute('href') === #) {
link.classList.add('active');
} else {
link.classList.remove('active');
}
});
return () => {
tocLinks.forEach((link) => link.classList.remove('active'));
};
}, [activeHeading, headings]);
if (!currentFile) {
return (
<div className={content-area }>
<div className="welcome-message">
<div className="welcome-content">
<h1>🌱 欢迎来到萌芽笔记</h1>
<p>请从左侧导航栏选择一个笔记文件开始阅读</p>
<div className="welcome-features">
<div className="feature-item">
<span className="feature-icon">📝</span>
<span>支持完整的Markdown语法</span>
</div>
<div className="feature-item">
<span className="feature-icon">🎨</span>
<span>代码语法高亮</span>
</div>
<div className="feature-item">
<span className="feature-icon">📊</span>
<span>数学公式与图表</span>
</div>
<div className="feature-item">
<span className="feature-icon">📱</span>
<span>移动端友好布局</span>
</div>
</div>
</div>
</div>
</div>
);
}
const fileTitle = getFileTitle(currentFile.split('/').pop(), currentContent);
const hasHeadings = headings.length > 0;
return (
<div className={content-area }>
<div className="content-header">
<Breadcrumbs filePath={currentFile} />
<h1 className="content-title">{fileTitle}</h1>
</div>
<div className={content-body }>
<div className="markdown-pane">
{isLoading ? (
<div className="loading-content">
<div className="loading-spinner" aria-hidden />
<span>加载中...</span>
</div>
) : (
<div className="markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath, remarkBreaks]}
rehypePlugins={[rehypeRaw, rehypeKatex, rehypeHighlight]}
components={components}
>
{currentContent}
</ReactMarkdown>
</div>
)}
</div>
{hasHeadings && <TableOfContents headings={headings} />}
</div>
</div>
);
}