不知名提交

This commit is contained in:
2025-12-13 20:48:21 +08:00
parent f8bd6388d7
commit 0ce60d78df
483 changed files with 16100 additions and 3332 deletions

File diff suppressed because it is too large Load Diff

View 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')
// 移除图片 ![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>
);
}

View 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; /* 完全隐藏切换按钮 */
}
}

View 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>
</>
);
}