继续提交

This commit is contained in:
2025-12-19 15:51:26 +08:00
parent 0ce60d78df
commit cf2203e3eb
500 changed files with 3259 additions and 7739 deletions

View File

@@ -0,0 +1,96 @@
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error('[MengyaNote] Error Boundary 捕获错误:', error, errorInfo);
this.setState({
error,
errorInfo
});
}
render() {
if (this.state.hasError) {
return (
<div style={{
padding: '40px',
textAlign: 'center',
maxWidth: '600px',
margin: '100px auto',
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '8px'
}}>
<h1 style={{ color: '#856404', marginBottom: '20px' }}> 应用出现错误</h1>
<p style={{ color: '#856404', marginBottom: '15px' }}>
抱歉应用遇到了一个问题这可能是由于浏览器权限设置导致的
</p>
<div style={{
backgroundColor: '#f8f9fa',
padding: '15px',
borderRadius: '4px',
marginBottom: '20px',
textAlign: 'left',
fontFamily: 'monospace',
fontSize: '12px',
overflow: 'auto'
}}>
<strong>错误信息:</strong>
<pre style={{ margin: '10px 0', whiteSpace: 'pre-wrap' }}>
{this.state.error && this.state.error.toString()}
</pre>
</div>
<div style={{ marginTop: '20px' }}>
<button
onClick={() => window.location.reload()}
style={{
padding: '10px 20px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
刷新页面重试
</button>
</div>
<div style={{
marginTop: '30px',
padding: '15px',
backgroundColor: '#e7f3ff',
border: '1px solid #b3d9ff',
borderRadius: '4px',
textAlign: 'left',
fontSize: '13px'
}}>
<strong>💡 可能的解决方案:</strong>
<ul style={{ marginTop: '10px', paddingLeft: '20px' }}>
<li>尝试在浏览器设置中允许此网站访问存储</li>
<li>清除浏览器缓存和Cookie后重试</li>
<li>尝试使用无痕/隐私模式访问</li>
<li>更换其他浏览器推荐 ChromeEdgeFirefox</li>
</ul>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

File diff suppressed because it is too large Load Diff

View File

@@ -1,434 +1,157 @@
import React, { useMemo, useState } from 'react';
import React, { useEffect, useRef } 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 rehypeRaw from 'rehype-raw';
import { useApp } from '../context/AppContext';
import { generateBreadcrumbs, getFileTitle } from '../utils/fileUtils';
import './MarkdownRenderer.css';
import 'github-markdown-css/github-markdown-light.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);
}
function MarkdownRenderer() {
const { currentContent, currentFile, currentMetadata, isLoading, error } = useApp();
const contentRef = useRef(null);
// 复制到剪贴板功能
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 formatFileSize = (bytes) => {
if (!bytes) return '0 B';
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(2)} KB`;
const mb = kb / 1024;
if (mb < 1024) return `${mb.toFixed(2)} MB`;
const gb = mb / 1024;
return `${gb.toFixed(2)} GB`;
};
const handleCopy = async () => {
const success = await copyToClipboard(content);
setCopyStatus(success ? 'success' : 'error');
setTimeout(() => setCopyStatus(''), 2000);
};
// 当内容变化时,滚动到顶部
useEffect(() => {
if (contentRef.current) {
contentRef.current.scrollTop = 0;
}
}, [currentFile]);
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 className="markdown-renderer" ref={contentRef}>
<div className="markdown-container">
{isLoading && (
<div className="loading-state">
<div className="loading-spinner"></div>
<p>加载中...</p>
</div>
</div>
</div>
);
}
)}
{error && !isLoading && (
<div className="error-state">
<div className="error-icon"></div>
<h2>加载失败</h2>
<p>{error}</p>
</div>
)}
{!isLoading && !error && !currentContent && (
<div className="empty-state" style={{ padding: '50px', textAlign: 'center', color: '#888' }}>
<p>请选择左侧文件查看内容</p>
<p style={{ fontSize: '0.8em', marginTop: '10px' }}>Select a file to view content</p>
</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>
{!isLoading && !error && currentContent && (
<article className="markdown-body">
<ReactMarkdown
remarkPlugins={[
remarkGfm, // GitHub Flavored Markdown
remarkMath, // 数学公式支持
remarkBreaks, // 自动换行
]}
rehypePlugins={[
rehypeRaw, // 支持HTML标签
rehypeKatex, // 数学公式渲染
rehypeHighlight, // 代码高亮
]}
components={{
// 自定义图片渲染,支持点击放大
img: ({ node, ...props }) => (
<img
{...props}
loading="lazy"
onClick={(e) => {
// 可以添加图片预览功能
e.target.classList.toggle('zoomed');
}}
style={{ cursor: 'pointer' }}
/>
),
// 自定义链接渲染,外部链接在新标签页打开
a: ({ node, href, children, ...props }) => {
const isExternal = href?.startsWith('http');
return (
<a
href={href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
{...props}
>
{children}
</a>
);
},
// 自定义代码块渲染
code: ({ node, inline, className, children, ...props }) => {
if (inline) {
return <code className={className} {...props}>{children}</code>;
}
return (
<code className={className} {...props}>
{children}
</code>
);
}
}}
>
{currentContent}
</ReactMarkdown>
{/* 显示文件元数据 */}
{currentMetadata && (
<div className="file-metadata">
<div className="metadata-divider"></div>
<div className="metadata-content">
<div className="metadata-item" title="字数统计">
<span className="metadata-icon">📝</span>
<span className="metadata-label">字数:</span>
<span className="metadata-value">{currentMetadata.wordCount}</span>
</div>
<div className="metadata-item" title="文件大小">
<span className="metadata-icon">💾</span>
<span className="metadata-label">大小:</span>
<span className="metadata-value">{formatFileSize(currentMetadata.fileSize)}</span>
</div>
{currentMetadata.createdTime && (
<div className="metadata-item" title="创建时间">
<span className="metadata-icon">📅</span>
<span className="metadata-label">创建于:</span>
<span className="metadata-value">{currentMetadata.createdTime}</span>
</div>
)}
{currentMetadata.modifiedTime && (
<div className="metadata-item" title="修改时间">
<span className="metadata-icon">🕒</span>
<span className="metadata-label">最后修改于:</span>
<span className="metadata-value">{currentMetadata.modifiedTime}</span>
</div>
)}
</div>
</div>
)}
</article>
)}
</div>
</div>
);
}
export default MarkdownRenderer;

View File

@@ -7,23 +7,22 @@
侧边栏主容器
======================================== */
/* 侧边栏基础样式 - 使用毛玻璃效果 */
/* 侧边栏基础样式 - GitHub 风格 */
.sidebar {
position: relative;
z-index: 110; /* 高层级确保在其他元素之上 */
width: var(--sidebar-width); /* 使用 CSS 变量控制宽度 */
height: 100vh; /* 全屏高度 */
/* 毛玻璃背景效果 */
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border-right: 1px solid rgba(255, 255, 255, 0.18);
/* GitHub 风格背景 */
background: #f6f8fa;
border-right: 1px solid #d0d7de;
display: flex;
flex-direction: column;
padding: 1.5rem 1.25rem 1.75rem; /* 上 左右 下 内边距 */
padding: 1.5rem 1rem 1.75rem; /* 上 左右 下 内边距 */
color: var(--color-text);
box-shadow: 0 6px 24px rgba(45, 60, 115, 0.18);
box-shadow: none;
overflow: hidden; /* 防止内容溢出 */
font-family: 'Maple Mono CN', ui-monospace, var(--font-family-base);
}
/* 侧边栏关闭状态 - 完全隐藏 */
@@ -43,6 +42,7 @@
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between; /* 左右分布:标题在左,按钮在右 */
gap: 0.75rem; /* 元素间距 */
margin-bottom: 1.5rem;
}
@@ -54,44 +54,52 @@
letter-spacing: 0.02em; /* 字母间距 */
font-weight: 600;
color: var(--color-text);
font-family: 'Maple Mono CN', ui-monospace, var(--font-family-base);
flex-shrink: 0; /* 标题不收缩 */
}
/* ========================================
切换按钮样式
======================================== */
/* 侧边栏切换按钮 - 毛玻璃风格 */
/* 侧边栏切换按钮 - 柔和的 GitHub 风格 */
.toggle-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 42px;
height: 42px;
padding: 0 0.7rem;
/* 毛玻璃按钮效果 */
background: rgba(255, 255, 255, 0.16);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
min-width: 36px;
height: 36px;
padding: 0 0.6rem;
/* 柔和的 GitHub 风格按钮 */
background: rgba(255, 255, 255, 0.7);
color: var(--color-muted);
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 12px;
font-size: 0.95rem;
border: 1px solid rgba(208, 215, 222, 0.5);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease; /* 平滑过渡动画 */
box-shadow: 0 6px 14px rgba(45, 60, 115, 0.18);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(31, 35, 40, 0.06);
font-family: 'Maple Mono CN', ui-monospace, var(--font-family-base);
flex-shrink: 0; /* 按钮不收缩 */
margin-left: auto; /* 按钮靠右 */
opacity: 0.8;
}
/* 切换按钮悬停状态 */
/* 切换按钮悬停状态 - 柔和的 GitHub 风格 */
.toggle-button:hover {
background: rgba(255, 255, 255, 1);
border-color: var(--color-accent);
color: var(--color-accent);
border-color: rgba(125, 167, 242, 0.55);
opacity: 1;
transform: translateX(2px);
box-shadow: 0 2px 6px rgba(9, 105, 218, 0.12);
}
/* 切换按钮激活状态 */
.toggle-button.open {
color: var(--color-accent);
border-color: rgba(125, 167, 242, 0.65);
.toggle-button:active {
transform: translateX(0);
box-shadow: 0 1px 3px rgba(9, 105, 218, 0.1);
}
/* ========================================
@@ -124,36 +132,31 @@
user-select: none; /* 禁用文本选择 */
}
/* 树节点内容 - 交互式设计 */
/* 树节点内容 - GitHub 风格 */
.tree-node-content {
display: flex;
align-items: center;
gap: 0.55rem; /* 图标与文本间距 */
padding: 0.3rem 0.65rem;
border-radius: 12px;
color: rgba(31, 42, 68, 0.7); /* 默认文本颜色 */
gap: 0.5rem; /* 图标与文本间距 */
padding: 0.4rem 0.6rem;
border-radius: 6px;
color: #24292f; /* GitHub 文本颜色 */
cursor: pointer;
transition: all 0.2s ease; /* 平滑过渡效果 */
min-width: 0; /* 允许flex子项缩小 */
}
/* 树节点悬停效果 */
.tree-node-content:hover {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: var(--color-accent);
box-shadow: 0 3px 10px rgba(45, 60, 115, 0.16);
background: #eef2f6;
color: #0969da;
box-shadow: none;
}
/* 树节点选中状态 */
/* 树节点选中状态 - GitHub 风格 */
.tree-node-content.selected {
background: rgba(255, 255, 255, 0.26);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: var(--color-accent);
background: #ddf4ff;
color: #0969da;
font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.34);
box-shadow: 0 4px 14px rgba(125, 167, 242, 0.25);
border: none;
box-shadow: none;
}
/* 树节点图标样式 */
@@ -177,6 +180,9 @@
.tree-node-name {
font-weight: 600;
flex: 1; /* 占据剩余空间 */
overflow: hidden; /* 隐藏溢出内容 */
text-overflow: ellipsis; /* 显示省略号 */
white-space: nowrap; /* 不换行 */
}
/* 文章数量标识样式 */
@@ -184,41 +190,27 @@
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0.1rem 0.35rem;
padding: 0.1rem 0.25rem;
font-size: 0.7rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
background: linear-gradient(135deg, rgba(125, 167, 242, 0.85), rgba(88, 134, 230, 0.9));
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.25);
box-shadow: 0 2px 6px rgba(125, 167, 242, 0.3);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: all 0.2s ease;
margin-left: 0.4rem;
font-weight: 600;
color: #57606a;
background: transparent;
border-radius: 4px;
transition: all 0.15s ease;
flex-shrink: 0; /* 防止被压缩 */
margin-left: 0.25rem; /* 与文件名保持间距 */
}
/* 文章数量标识悬停效果 */
/* 悬停状态下的文章数量标识 - GitHub 风格 */
.tree-node-content:hover .article-count-badge {
background: linear-gradient(135deg, rgba(125, 167, 242, 0.95), rgba(88, 134, 230, 1));
box-shadow: 0 3px 8px rgba(125, 167, 242, 0.4);
transform: scale(1.05);
color: #0969da;
background: transparent;
}
/* 选中状态下的文章数量标识 */
/* 选中状态下的文章数量标识 - GitHub 风格 */
.tree-node-content.selected .article-count-badge {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.8));
color: var(--color-accent);
border: 1px solid rgba(125, 167, 242, 0.4);
box-shadow: 0 3px 10px rgba(125, 167, 242, 0.35);
color: #0969da;
background: transparent;
}
/* ========================================
加载和错误状态
======================================== */
/* 加载状态和错误状态的通用样式 */
.loading,
.error {
@@ -230,19 +222,19 @@
padding: 2.5rem 1rem;
color: var(--color-muted);
font-size: 0.9rem;
font-family: 'Maple Mono CN', ui-monospace, var(--font-family-base);
}
/* 加载动画 - 旋转圆圈 */
/* 加载动画 - 旋转圆圈 - GitHub 风格 */
.loading-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(111, 123, 146, 0.25);
border-top: 2px solid var(--color-accent);
border: 2px solid #d0d7de;
border-top-color: #0969da;
border-radius: 50%;
animation: spin 0.9s linear infinite; /* 无限旋转动画 */
animation: spin 0.8s linear infinite;
}
/* 旋转动画关键帧 */
@keyframes spin {
to {
transform: rotate(360deg);
@@ -258,67 +250,71 @@
display: none;
}
/* 侧边栏切换按钮 - 固定定位 */
/* 侧边栏切换按钮 - 固定定位 - GitHub 风格 */
.sidebar-toggle {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 130; /* 最高层级 */
width: 44px;
height: 44px;
width: 40px;
height: 40px;
padding: 0;
border-radius: 14px;
box-shadow: 0 14px 30px rgba(86, 105, 141, 0.18);
transition: transform 0.2s ease, box-shadow 0.2s ease;
background: #ffffff;
border: 1px solid #d0d7de;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(31, 35, 40, 0.12), 0 8px 24px rgba(66, 74, 83, 0.12);
transition: all 0.2s ease;
touch-action: none; /* 禁用默认触摸行为 */
user-select: none; /* 禁用文本选择 */
color: #24292f;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
/* 拖动状态样式 */
/* 拖动状态 - GitHub 风格 */
.sidebar-toggle.dragging {
box-shadow: 0 20px 40px rgba(86, 105, 141, 0.3);
opacity: 0.9;
cursor: grabbing;
box-shadow: 0 3px 12px rgba(31, 35, 40, 0.15), 0 12px 40px rgba(66, 74, 83, 0.15);
opacity: 0.95;
}
/* ========================================
响应式设计 - 移动端适配
======================================== */
/* 手机屏幕适配 (768px 以下) */
/* 手机屏幕适配 (768px 以下) - GitHub 风格 */
@media (max-width: 768px) {
/* 移动端侧边栏样式调整 */
.sidebar {
width: var(--sidebar-width-mobile, 280px);
padding: 0.75rem;
max-width: calc(100vw - 1rem); /* 防止超出屏幕 */
overflow-x: hidden;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
background: #f6f8fa;
border-right: 1px solid #d0d7de;
box-shadow: 2px 0 8px rgba(31, 35, 40, 0.12);
}
/* 移动端关闭状态 - 使用平移隐藏 */
/* 移动端关闭状态 */
.sidebar.closed {
transform: translateX(-100%); /* 向左平移隐藏 */
transform: translateX(-100%);
}
/* 移动端切换按钮调整 */
/* 移动端切换按钮 - GitHub 风格 */
.sidebar-toggle {
width: 44px;
height: 44px;
font-size: 18px;
/* 移动端特殊样式 */
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 20px rgba(86, 105, 141, 0.25);
width: 40px;
height: 40px;
font-size: 16px;
background: #ffffff;
border: 1px solid #d0d7de;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(31, 35, 40, 0.12), 0 8px 24px rgba(66, 74, 83, 0.12);
}
/* 移动端拖动状态增强 */
/* 移动端拖动状态 - GitHub 风格 */
.sidebar-toggle.dragging {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(125, 167, 242, 0.5);
box-shadow: 0 12px 30px rgba(125, 167, 242, 0.4);
background: #f6f8fa;
border-color: #0969da;
box-shadow: 0 3px 12px rgba(31, 35, 40, 0.15), 0 12px 40px rgba(9, 105, 218, 0.15);
}
/* 移动端悬停效果 */

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState, useRef } from 'react';
import React, { useMemo } from 'react';
import { useApp } from '../context/AppContext';
import { NODE_TYPES } from '../utils/fileUtils';
import './Sidebar.css';
@@ -59,7 +59,7 @@ function TreeNode({ node, level = 0 }) {
: node.name}
</span>
{node.type === NODE_TYPES.FOLDER && articleCount > 0 && (
<span className="article-count-badge">{articleCount}</span>
<span className="article-count-badge">[{articleCount}]</span>
)}
</div>
@@ -76,117 +76,21 @@ function TreeNode({ node, level = 0 }) {
export default function Sidebar() {
const { directoryTree, isLoading, error, sidebarOpen, toggleSidebar } = useApp();
// 拖动相关状态
const [isDragging, setIsDragging] = useState(false);
const [dragPosition, setDragPosition] = useState(() => {
// 从localStorage读取保存的位置默认右上角
const saved = localStorage.getItem('sidebar-toggle-position');
return saved ? JSON.parse(saved) : { x: window.innerWidth - 60, y: 20 };
});
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [touchStartPos, setTouchStartPos] = useState({ x: 0, y: 0 });
const [hasMoved, setHasMoved] = useState(false);
const buttonRef = useRef(null);
// 拖动事件处理函数
const handleTouchStart = (e) => {
if (window.innerWidth > 768) return; // 只在移动端启用拖动
const touch = e.touches[0];
const rect = buttonRef.current.getBoundingClientRect();
setTouchStartPos({ x: touch.clientX, y: touch.clientY });
setDragOffset({
x: touch.clientX - rect.left,
y: touch.clientY - rect.top
});
setHasMoved(false);
// 不要在这里preventDefault让点击事件能正常触发
};
const handleTouchMove = (e) => {
if (window.innerWidth > 768) return;
const touch = e.touches[0];
const deltaX = Math.abs(touch.clientX - touchStartPos.x);
const deltaY = Math.abs(touch.clientY - touchStartPos.y);
// 如果移动距离超过5px认为是拖动
if (deltaX > 5 || deltaY > 5) {
if (!hasMoved) {
setHasMoved(true);
setIsDragging(true);
}
const newX = touch.clientX - dragOffset.x;
const newY = touch.clientY - dragOffset.y;
// 限制在屏幕范围内
const maxX = window.innerWidth - 44;
const maxY = window.innerHeight - 44;
setDragPosition({
x: Math.max(0, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY))
});
e.preventDefault(); // 只在确认拖动时阻止默认行为
}
};
const handleTouchEnd = (e) => {
if (hasMoved && isDragging) {
// 保存位置到localStorage
localStorage.setItem('sidebar-toggle-position', JSON.stringify(dragPosition));
e.preventDefault(); // 阻止点击事件
}
setIsDragging(false);
setHasMoved(false);
};
// 处理点击事件
const handleButtonClick = (e) => {
// 如果刚刚完成拖动,不触发点击
if (hasMoved) {
return;
}
toggleSidebar();
};
// 处理窗口大小变化
useEffect(() => {
const handleResize = () => {
if (window.innerWidth > 768) {
// 桌面端重置位置
return;
}
// 移动端调整位置确保按钮在屏幕范围内
const maxX = window.innerWidth - 44;
const maxY = window.innerHeight - 44;
setDragPosition(prev => ({
x: Math.max(0, Math.min(prev.x, maxX)),
y: Math.max(0, Math.min(prev.y, maxY))
}));
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const toggleLabel = sidebarOpen ? '隐藏目录' : '展开目录';
const toggleSymbol = sidebarOpen ? '◀' : '☰';
const sidebarClass = sidebarOpen ? 'sidebar open' : 'sidebar closed';
const toggleButtonClass = sidebarOpen ? 'toggle-button open' : 'toggle-button';
return (
<>
<aside className={sidebarClass} aria-hidden={!sidebarOpen}>
<aside className={sidebarOpen ? 'sidebar open' : 'sidebar closed'} aria-hidden={!sidebarOpen}>
<div className="sidebar-header">
<h2>📚 文章目录</h2>
<button
type="button"
onClick={toggleSidebar}
className="toggle-button"
aria-label={sidebarOpen ? '隐藏目录' : '展开目录'}
title={sidebarOpen ? '隐藏目录' : '展开目录'}
>
{sidebarOpen ? '◀' : '▶'}
</button>
</div>
<div className="sidebar-content">
@@ -212,26 +116,6 @@ export default function Sidebar() {
)}
</div>
</aside>
<button
ref={buttonRef}
type="button"
onClick={handleButtonClick}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
className={`sidebar-toggle ${toggleButtonClass} ${isDragging ? 'dragging' : ''}`.trim()}
style={{
left: `${dragPosition.x}px`,
top: `${dragPosition.y}px`,
right: 'auto',
transform: isDragging ? 'scale(1.1)' : 'scale(1)',
zIndex: isDragging ? 1000 : 130
}}
aria-label={toggleLabel}
>
{toggleSymbol}
</button>
</>
);
}