初始化提交
This commit is contained in:
340
src/components/MarkdownRenderer.jsx
Normal file
340
src/components/MarkdownRenderer.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user