435 lines
12 KiB
JavaScript
435 lines
12 KiB
JavaScript
import React, { useMemo, useState } 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';
|
||
|
||
// 下载Markdown文件功能
|
||
function downloadMarkdown(content, filename) {
|
||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = filename || 'document.md';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
// 复制到剪贴板功能
|
||
async function copyToClipboard(content) {
|
||
try {
|
||
await navigator.clipboard.writeText(content);
|
||
return true;
|
||
} catch (err) {
|
||
// 降级方案:使用传统的复制方法
|
||
const textArea = document.createElement('textarea');
|
||
textArea.value = content;
|
||
textArea.style.position = 'fixed';
|
||
textArea.style.left = '-999999px';
|
||
textArea.style.top = '-999999px';
|
||
document.body.appendChild(textArea);
|
||
textArea.focus();
|
||
textArea.select();
|
||
try {
|
||
document.execCommand('copy');
|
||
document.body.removeChild(textArea);
|
||
return true;
|
||
} catch (err) {
|
||
document.body.removeChild(textArea);
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 功能按钮组件
|
||
function ActionButtons({ content, filename }) {
|
||
const [copyStatus, setCopyStatus] = useState('');
|
||
const [downloadStatus, setDownloadStatus] = useState('');
|
||
|
||
const handleDownload = () => {
|
||
try {
|
||
downloadMarkdown(content, filename);
|
||
setDownloadStatus('success');
|
||
setTimeout(() => setDownloadStatus(''), 2000);
|
||
} catch (error) {
|
||
setDownloadStatus('error');
|
||
setTimeout(() => setDownloadStatus(''), 2000);
|
||
}
|
||
};
|
||
|
||
const handleCopy = async () => {
|
||
const success = await copyToClipboard(content);
|
||
setCopyStatus(success ? 'success' : 'error');
|
||
setTimeout(() => setCopyStatus(''), 2000);
|
||
};
|
||
|
||
return (
|
||
<div className="action-buttons">
|
||
<button
|
||
className={`action-button download-button ${downloadStatus}`}
|
||
onClick={handleDownload}
|
||
title="下载Markdown文件"
|
||
aria-label="下载Markdown文件"
|
||
>
|
||
📥
|
||
</button>
|
||
<button
|
||
className={`action-button copy-button ${copyStatus}`}
|
||
onClick={handleCopy}
|
||
title="复制Markdown内容"
|
||
aria-label="复制Markdown内容"
|
||
>
|
||
📋
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 字数统计工具函数
|
||
function countWords(markdownText) {
|
||
if (!markdownText || typeof markdownText !== 'string') {
|
||
return 0;
|
||
}
|
||
|
||
// 移除Markdown格式符号的正则表达式
|
||
let plainText = markdownText
|
||
// 移除代码块
|
||
.replace(/```[\s\S]*?```/g, '')
|
||
// 移除内联代码
|
||
.replace(/`[^`]*`/g, '')
|
||
// 移除链接 [text](url)
|
||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||
// 移除图片 
|
||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||
// 移除标题标记
|
||
.replace(/^#{1,6}\s+/gm, '')
|
||
// 移除粗体和斜体标记
|
||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||
.replace(/\*([^*]+)\*/g, '$1')
|
||
.replace(/__([^_]+)__/g, '$1')
|
||
.replace(/_([^_]+)_/g, '$1')
|
||
// 移除删除线
|
||
.replace(/~~([^~]+)~~/g, '$1')
|
||
// 移除引用标记
|
||
.replace(/^>\s*/gm, '')
|
||
// 移除列表标记
|
||
.replace(/^[\s]*[-*+]\s+/gm, '')
|
||
.replace(/^[\s]*\d+\.\s+/gm, '')
|
||
// 移除水平分割线
|
||
.replace(/^[-*_]{3,}$/gm, '')
|
||
// 移除HTML标签
|
||
.replace(/<[^>]*>/g, '')
|
||
// 移除多余的空白字符
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
|
||
// 统计中文字符和英文单词
|
||
const chineseChars = (plainText.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||
const englishWords = plainText
|
||
.replace(/[\u4e00-\u9fa5]/g, ' ')
|
||
.split(/\s+/)
|
||
.filter(word => word.length > 0 && /[a-zA-Z]/.test(word)).length;
|
||
|
||
return chineseChars + englishWords;
|
||
}
|
||
|
||
// 字数统计显示组件
|
||
function WordCount({ content }) {
|
||
const wordCount = useMemo(() => countWords(content), [content]);
|
||
|
||
if (wordCount === 0) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div className="word-count-container">
|
||
<div className="word-count-info">
|
||
<span className="word-count-icon">📊</span>
|
||
<span className="word-count-text">全文共 {wordCount.toLocaleString()} 字</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 自定义插件:禁用内联代码解析
|
||
function remarkDisableInlineCode() {
|
||
return (tree) => {
|
||
// 移除所有内联代码节点,将其转换为普通文本
|
||
function visit(node, parent, index) {
|
||
if (node.type === 'inlineCode') {
|
||
// 将内联代码节点替换为文本节点
|
||
const textNode = {
|
||
type: 'text',
|
||
value: node.value
|
||
};
|
||
if (parent && typeof index === 'number') {
|
||
parent.children[index] = textNode;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (node.children) {
|
||
for (let i = 0; i < node.children.length; i++) {
|
||
visit(node.children[i], node, i);
|
||
}
|
||
}
|
||
}
|
||
|
||
visit(tree);
|
||
};
|
||
}
|
||
|
||
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 [copied, setCopied] = useState(false);
|
||
|
||
if (inline) {
|
||
// 不渲染为代码,直接返回普通文本
|
||
return <span>{children}</span>;
|
||
}
|
||
|
||
// 改进的文本提取逻辑,处理React元素和纯文本
|
||
const extractText = (node) => {
|
||
if (typeof node === 'string') {
|
||
return node;
|
||
}
|
||
if (typeof node === 'number') {
|
||
return String(node);
|
||
}
|
||
if (React.isValidElement(node)) {
|
||
return extractText(node.props.children);
|
||
}
|
||
if (Array.isArray(node)) {
|
||
return node.map(extractText).join('');
|
||
}
|
||
return '';
|
||
};
|
||
|
||
const codeText = extractText(children).replace(/\n$/, '');
|
||
const match = /language-(\w+)/.exec(className || '');
|
||
const language = match ? match[1] : 'text';
|
||
const buttonClass = 'code-copy-button' + (copied ? ' copied' : '');
|
||
const buttonLabel = copied ? '已复制' : '复制代码';
|
||
|
||
const handleCopy = async () => {
|
||
if (typeof navigator === 'undefined' || !navigator.clipboard) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(codeText);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2200);
|
||
} catch (error) {
|
||
console.error('Failed to copy code block', error);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="code-block-wrapper">
|
||
<div className="code-block-header">
|
||
<span className="code-language">{language}</span>
|
||
<button type="button" className={buttonClass} onClick={handleCopy} aria-live="polite">
|
||
{buttonLabel}
|
||
</button>
|
||
</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={`内部链接: ${linkText}`}>
|
||
{children || linkText}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
const isExternal = href && /^(https?:)?\/\//.test(href);
|
||
const linkClass = isExternal ? 'external-link' : 'internal-link';
|
||
|
||
return (
|
||
<a
|
||
href={href}
|
||
target={isExternal ? '_blank' : '_self'}
|
||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||
className={linkClass}
|
||
{...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 createHeadingRenderer(tag) {
|
||
return function HeadingRenderer({ 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, '-');
|
||
|
||
const HeadingTag = tag;
|
||
|
||
return (
|
||
<HeadingTag id={id} {...props}>
|
||
<a href={`#${id}`} aria-hidden className="heading-anchor">
|
||
#
|
||
</a>
|
||
{children}
|
||
</HeadingTag>
|
||
);
|
||
};
|
||
}
|
||
|
||
const headingComponents = {
|
||
h1: createHeadingRenderer('h1'),
|
||
h2: createHeadingRenderer('h2'),
|
||
h3: createHeadingRenderer('h3'),
|
||
h4: createHeadingRenderer('h4'),
|
||
};
|
||
|
||
export default function MarkdownRenderer() {
|
||
const { currentFile, currentContent, isLoading, sidebarOpen } = useApp();
|
||
|
||
const components = useMemo(
|
||
() => ({
|
||
code: ({ inline, className, children, ...props }) => {
|
||
if (inline) {
|
||
// 内联代码直接返回普通文本,不做任何特殊处理
|
||
return <span>{children}</span>;
|
||
}
|
||
// 代码块使用原来的CodeBlock组件
|
||
return <CodeBlock inline={inline} className={className} {...props}>{children}</CodeBlock>;
|
||
},
|
||
a: CustomLink,
|
||
table: CustomTable,
|
||
...headingComponents,
|
||
blockquote: ({ children, ...props }) => (
|
||
<blockquote className="custom-blockquote" {...props}>
|
||
{children}
|
||
</blockquote>
|
||
),
|
||
img: ({ alt, ...props }) => (
|
||
<figure className="markdown-image">
|
||
<img alt={alt} {...props} />
|
||
{alt && <figcaption>{alt}</figcaption>}
|
||
</figure>
|
||
),
|
||
}),
|
||
[],
|
||
);
|
||
|
||
const contentAreaClass = 'content-area' + (sidebarOpen ? ' with-sidebar' : '');
|
||
|
||
if (!currentFile) {
|
||
return (
|
||
<div className={contentAreaClass}>
|
||
<div className="welcome-message">
|
||
<div className="welcome-content">
|
||
<h1>🌙 欢迎回来</h1>
|
||
<p>从左侧目录选择任意 Markdown 笔记即可开始阅读。</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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const fileTitle = getFileTitle(currentFile.split('/').pop(), currentContent);
|
||
const filename = currentFile.split('/').pop();
|
||
|
||
return (
|
||
<div className={contentAreaClass}>
|
||
<div className="content-header">
|
||
<h1 className="content-title">{fileTitle}</h1>
|
||
<ActionButtons content={currentContent} filename={filename} />
|
||
</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={[remarkDisableInlineCode, remarkGfm, remarkMath, remarkBreaks]}
|
||
rehypePlugins={[rehypeRaw, rehypeKatex, rehypeHighlight]}
|
||
components={components}
|
||
>
|
||
{currentContent}
|
||
</ReactMarkdown>
|
||
<WordCount content={currentContent} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|