不知名提交
This commit is contained in:
1102
mengyanote-frontend/src/components/MarkdownRenderer.css
Normal file
1102
mengyanote-frontend/src/components/MarkdownRenderer.css
Normal file
File diff suppressed because it is too large
Load Diff
434
mengyanote-frontend/src/components/MarkdownRenderer.jsx
Normal file
434
mengyanote-frontend/src/components/MarkdownRenderer.jsx
Normal file
@@ -0,0 +1,434 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
371
mengyanote-frontend/src/components/Sidebar.css
Normal file
371
mengyanote-frontend/src/components/Sidebar.css
Normal file
@@ -0,0 +1,371 @@
|
||||
/* ========================================
|
||||
侧边栏组件样式文件 (Sidebar.css)
|
||||
用途:定义侧边栏布局、交互效果和响应式行为
|
||||
======================================== */
|
||||
|
||||
/* ========================================
|
||||
侧边栏主容器
|
||||
======================================== */
|
||||
|
||||
/* 侧边栏基础样式 - 使用毛玻璃效果 */
|
||||
.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);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem 1.25rem 1.75rem; /* 上 左右 下 内边距 */
|
||||
color: var(--color-text);
|
||||
box-shadow: 0 6px 24px rgba(45, 60, 115, 0.18);
|
||||
overflow: hidden; /* 防止内容溢出 */
|
||||
}
|
||||
|
||||
/* 侧边栏关闭状态 - 完全隐藏 */
|
||||
.sidebar.closed {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none; /* 禁用鼠标事件 */
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
侧边栏头部区域
|
||||
======================================== */
|
||||
|
||||
/* 侧边栏头部布局 */
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem; /* 元素间距 */
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* 侧边栏标题样式 */
|
||||
.sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0.02em; /* 字母间距 */
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
切换按钮样式
|
||||
======================================== */
|
||||
|
||||
/* 侧边栏切换按钮 - 毛玻璃风格 */
|
||||
.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);
|
||||
color: var(--color-muted);
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
border-radius: 12px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease; /* 平滑过渡动画 */
|
||||
box-shadow: 0 6px 14px rgba(45, 60, 115, 0.18);
|
||||
}
|
||||
|
||||
/* 切换按钮悬停状态 */
|
||||
.toggle-button:hover {
|
||||
color: var(--color-accent);
|
||||
border-color: rgba(125, 167, 242, 0.55);
|
||||
}
|
||||
|
||||
/* 切换按钮激活状态 */
|
||||
.toggle-button.open {
|
||||
color: var(--color-accent);
|
||||
border-color: rgba(125, 167, 242, 0.65);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
侧边栏内容区域
|
||||
======================================== */
|
||||
|
||||
/* 侧边栏滚动内容区 */
|
||||
.sidebar-content {
|
||||
flex: 1; /* 占据剩余空间 */
|
||||
overflow-y: auto; /* 垂直滚动 */
|
||||
overflow-x: hidden; /* 隐藏水平滚动 */
|
||||
padding-right: 0.5rem; /* 右侧内边距 */
|
||||
height: 0; /* 配合 flex: 1 实现正确的滚动 */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
目录树结构
|
||||
======================================== */
|
||||
|
||||
/* 目录树容器 */
|
||||
.directory-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0rem; /* 节点间无间距 */
|
||||
}
|
||||
|
||||
/* 树节点基础样式 */
|
||||
.tree-node {
|
||||
user-select: none; /* 禁用文本选择 */
|
||||
}
|
||||
|
||||
/* 树节点内容区 - 交互式设计 */
|
||||
.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); /* 默认文本颜色 */
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease; /* 平滑过渡效果 */
|
||||
}
|
||||
|
||||
/* 树节点悬停效果 */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 树节点选中状态 */
|
||||
.tree-node-content.selected {
|
||||
background: rgba(255, 255, 255, 0.26);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(255, 255, 255, 0.34);
|
||||
box-shadow: 0 4px 14px rgba(125, 167, 242, 0.25);
|
||||
}
|
||||
|
||||
/* 树节点图标样式 */
|
||||
.tree-node-icon {
|
||||
font-size: 1rem;
|
||||
opacity: 0.9;
|
||||
transition: transform 0.2s ease; /* 图标变换动画 */
|
||||
}
|
||||
|
||||
/* 选中状态下的图标放大效果 */
|
||||
.tree-node-content.selected .tree-node-icon {
|
||||
transform: scale(1.06); /* 轻微放大 */
|
||||
}
|
||||
|
||||
/* 树节点子级容器 */
|
||||
.tree-node-children {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* 树节点名称样式 */
|
||||
.tree-node-name {
|
||||
font-weight: 600;
|
||||
flex: 1; /* 占据剩余空间 */
|
||||
}
|
||||
|
||||
/* 文章数量标识样式 */
|
||||
.article-count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0.1rem 0.35rem;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 文章数量标识悬停效果 */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 选中状态下的文章数量标识 */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
加载和错误状态
|
||||
======================================== */
|
||||
|
||||
/* 加载状态和错误状态的通用样式 */
|
||||
.loading,
|
||||
.error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.7rem;
|
||||
padding: 2.5rem 1rem;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 加载动画 - 旋转圆圈 */
|
||||
.loading-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(111, 123, 146, 0.25);
|
||||
border-top: 2px solid var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite; /* 无限旋转动画 */
|
||||
}
|
||||
|
||||
/* 旋转动画关键帧 */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
侧边栏背景遮罩
|
||||
======================================== */
|
||||
|
||||
/* 侧边栏背景遮罩 - 默认隐藏 */
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 侧边栏切换按钮 - 固定定位 */
|
||||
.sidebar-toggle {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 130; /* 最高层级 */
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
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;
|
||||
touch-action: none; /* 禁用默认触摸行为 */
|
||||
user-select: none; /* 禁用文本选择 */
|
||||
}
|
||||
|
||||
/* 拖动状态样式 */
|
||||
.sidebar-toggle.dragging {
|
||||
box-shadow: 0 20px 40px rgba(86, 105, 141, 0.3);
|
||||
opacity: 0.9;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
响应式设计 - 移动端适配
|
||||
======================================== */
|
||||
|
||||
/* 手机屏幕适配 (768px 以下) */
|
||||
@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);
|
||||
}
|
||||
|
||||
/* 移动端关闭状态 - 使用平移隐藏 */
|
||||
.sidebar.closed {
|
||||
transform: translateX(-100%); /* 向左平移隐藏 */
|
||||
}
|
||||
|
||||
/* 移动端切换按钮调整 */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 移动端拖动状态增强 */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 移动端悬停效果 */
|
||||
.sidebar-toggle:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 移动端内容区域优化 */
|
||||
.sidebar-content {
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 移动端目录树优化 */
|
||||
.directory-tree {
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 移动端标题调整 */
|
||||
.sidebar-header h2 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* 移动端文章数量标识调整 */
|
||||
.article-count-badge {
|
||||
min-width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
padding: 0.05rem 0.25rem;
|
||||
font-size: 0.65rem;
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 桌面端适配 (769px 以上) */
|
||||
@media (min-width: 769px) {
|
||||
/* 桌面端侧边栏始终可见 */
|
||||
.sidebar {
|
||||
position: static; /* 静态定位 */
|
||||
transform: none; /* 无变换 */
|
||||
opacity: 1; /* 完全不透明 */
|
||||
pointer-events: auto; /* 启用鼠标事件 */
|
||||
}
|
||||
|
||||
/* 桌面端隐藏切换按钮 */
|
||||
.sidebar-toggle {
|
||||
display: none !important; /* 完全隐藏切换按钮 */
|
||||
}
|
||||
}
|
||||
237
mengyanote-frontend/src/components/Sidebar.jsx
Normal file
237
mengyanote-frontend/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { NODE_TYPES } from '../utils/fileUtils';
|
||||
import './Sidebar.css';
|
||||
|
||||
function TreeNode({ node, level = 0 }) {
|
||||
const { selectFile, toggleNodeExpansion, currentFile } = useApp();
|
||||
|
||||
// 计算当前目录下的直接文章数量
|
||||
const articleCount = useMemo(() => {
|
||||
if (node.type !== NODE_TYPES.FOLDER || !node.children) {
|
||||
return 0;
|
||||
}
|
||||
return node.children.filter(child =>
|
||||
child.type === NODE_TYPES.FILE && child.name.endsWith('.md')
|
||||
).length;
|
||||
}, [node]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (node.type === NODE_TYPES.FOLDER) {
|
||||
toggleNodeExpansion(node.path);
|
||||
} else {
|
||||
selectFile(node.path);
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = currentFile === node.path;
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const paddingLeft = `${level * 18 + 14}px`;
|
||||
const contentClass = isSelected ? 'tree-node-content selected' : 'tree-node-content';
|
||||
const folderIconClass = `tree-node-icon${node.isExpanded ? ' expanded' : ''}`;
|
||||
|
||||
return (
|
||||
<div className="tree-node">
|
||||
<div
|
||||
className={contentClass}
|
||||
style={{ paddingLeft }}
|
||||
onClick={handleClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{node.type === NODE_TYPES.FOLDER && (
|
||||
<span className={folderIconClass} aria-hidden>
|
||||
{hasChildren ? (node.isExpanded ? '📂' : '📁') : '📁'}
|
||||
</span>
|
||||
)}
|
||||
{node.type === NODE_TYPES.FILE && (
|
||||
<span className="tree-node-icon" aria-hidden>📄</span>
|
||||
)}
|
||||
<span className="tree-node-name">
|
||||
{node.type === NODE_TYPES.FILE && node.name.endsWith('.md')
|
||||
? node.name.slice(0, -3)
|
||||
: node.name}
|
||||
</span>
|
||||
{node.type === NODE_TYPES.FOLDER && articleCount > 0 && (
|
||||
<span className="article-count-badge">{articleCount}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{node.type === NODE_TYPES.FOLDER && node.isExpanded && hasChildren && (
|
||||
<div className="tree-node-children">
|
||||
{node.children.map((child) => (
|
||||
<TreeNode key={child.path} node={child} level={level + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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}>
|
||||
<div className="sidebar-header">
|
||||
<h2>📚 文章目录</h2>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-content">
|
||||
{isLoading && (
|
||||
<div className="loading">
|
||||
<div className="loading-spinner" aria-hidden />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error">
|
||||
<span>目录加载失败: {error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<div className="directory-tree">
|
||||
{directoryTree.map((node) => (
|
||||
<TreeNode key={node.path} node={node} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user