Files
mengyanote/src/components/MarkdownRenderer.jsx

435 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')
// 移除图片 ![alt](url)
.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>
);
}