Initial commit: Markdown editor with file management and regex tools
项目特性: - 完整的Markdown编辑器,支持实时预览 - 文件管理功能,支持保存/加载/删除文件 - 正则表达式工具,支持批量文本替换 - 前后端分离架构 - 响应式设计 技术栈: - 前端:React + TypeScript + Vite - 后端:Python Flask - Markdown解析:Python-Markdown 包含组件: - WorkingMarkdownEditor: 基础功能版本 - FullMarkdownEditor: 完整功能版本 - SimpleMarkdownEditor: 简化版本
This commit is contained in:
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Markdown 编辑器</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.8/katex.min.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
5038
frontend/package-lock.json
generated
Normal file
5038
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "markdown-editor-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"axios": "^1.5.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"rehype-katex": "^6.0.3",
|
||||
"katex": "^0.16.8",
|
||||
"prismjs": "^1.29.0",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"react-split": "^2.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5"
|
||||
}
|
||||
}
|
7
frontend/src/App.css
Normal file
7
frontend/src/App.css
Normal file
@@ -0,0 +1,7 @@
|
||||
/* App组件专用样式 */
|
||||
|
||||
.App {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
17
frontend/src/App.tsx
Normal file
17
frontend/src/App.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 应用主组件 - 工作版本
|
||||
* 使用最简化的组件确保能正常显示
|
||||
*/
|
||||
|
||||
import { FullMarkdownEditor } from './components/FullMarkdownEditor';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
<FullMarkdownEditor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
568
frontend/src/components/FullMarkdownEditor.tsx
Normal file
568
frontend/src/components/FullMarkdownEditor.tsx
Normal file
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* 完整功能Markdown编辑器
|
||||
* 包含文件浏览器和正则表达式工具
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { fileApi, parseMarkdown, regexApi } from '../utils/api';
|
||||
import { saveToLocalStorage, loadFromLocalStorage } from '../utils/markdown';
|
||||
import type { FileItem } from '../types';
|
||||
|
||||
interface ParseResult {
|
||||
html: string;
|
||||
word_count: number;
|
||||
reading_time: number;
|
||||
}
|
||||
|
||||
interface EditorState {
|
||||
content: string;
|
||||
filePath: string | null;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
export const FullMarkdownEditor: React.FC = () => {
|
||||
const [editorState, setEditorState] = useState<EditorState>({
|
||||
content: '',
|
||||
filePath: null,
|
||||
isDirty: false,
|
||||
});
|
||||
|
||||
const [previewData, setPreviewData] = useState<ParseResult | null>(null);
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
const [showFileExplorer, setShowFileExplorer] = useState(false);
|
||||
const [showRegexPanel, setShowRegexPanel] = useState(false);
|
||||
|
||||
// 文件浏览器状态
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
|
||||
const [newFileName, setNewFileName] = useState('');
|
||||
|
||||
// 正则工具状态
|
||||
const [regexPattern, setRegexPattern] = useState('');
|
||||
const [regexReplacement, setRegexReplacement] = useState('');
|
||||
const [regexFlags, setRegexFlags] = useState('g');
|
||||
const [regexResult, setRegexResult] = useState('');
|
||||
const [regexMatches, setRegexMatches] = useState(0);
|
||||
const [isRegexProcessing, setIsRegexProcessing] = useState(false);
|
||||
|
||||
// 加载初始内容
|
||||
useEffect(() => {
|
||||
const savedContent = loadFromLocalStorage('content');
|
||||
const savedPath = loadFromLocalStorage('filePath');
|
||||
|
||||
if (savedContent) {
|
||||
setEditorState(prev => ({
|
||||
...prev,
|
||||
content: savedContent,
|
||||
filePath: savedPath || null,
|
||||
}));
|
||||
} else {
|
||||
const defaultContent = `# 欢迎使用完整版Markdown编辑器
|
||||
|
||||
这是一个功能**完整的**Markdown编辑器,现在支持:
|
||||
|
||||
## 📁 文件管理
|
||||
- ✅ 浏览文件和文件夹
|
||||
- ✅ 创建新文件
|
||||
- ✅ 打开和保存文件
|
||||
- ✅ 删除文件
|
||||
|
||||
## 🔍 正则表达式工具
|
||||
- ✅ 批量文本替换
|
||||
- ✅ 模式匹配提取
|
||||
- ✅ 实时结果预览
|
||||
|
||||
## 🎨 支持的语法
|
||||
- 标题、列表、代码块
|
||||
- 粗体、斜体、链接
|
||||
- 数学公式:$E=mc^2$
|
||||
- 表格、引用、分割线
|
||||
|
||||
## 🚀 使用方法
|
||||
1. 点击 "📁 文件" 按钮打开文件浏览器
|
||||
2. 点击 "🔍 正则" 按钮使用正则工具
|
||||
3. 所有更改自动保存到本地存储
|
||||
|
||||
开始编辑您的内容吧!
|
||||
`;
|
||||
setEditorState(prev => ({ ...prev, content: defaultContent }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 更新预览
|
||||
const updatePreview = useCallback(async (content: string) => {
|
||||
if (!content.trim()) {
|
||||
setPreviewData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPreviewLoading(true);
|
||||
try {
|
||||
const result = await parseMarkdown(content);
|
||||
setPreviewData({
|
||||
html: result.html,
|
||||
word_count: result.word_count,
|
||||
reading_time: result.reading_time,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to parse markdown:', error);
|
||||
setPreviewData({
|
||||
html: '<p style="color: red;">解析错误</p>',
|
||||
word_count: 0,
|
||||
reading_time: 0,
|
||||
});
|
||||
} finally {
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 内容变化时更新预览
|
||||
useEffect(() => {
|
||||
updatePreview(editorState.content);
|
||||
saveToLocalStorage('content', editorState.content);
|
||||
}, [editorState.content, updatePreview]);
|
||||
|
||||
// 处理内容变化
|
||||
const handleContentChange = useCallback((newContent: string) => {
|
||||
setEditorState(prev => ({
|
||||
...prev,
|
||||
content: newContent,
|
||||
isDirty: true,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 文件浏览器功能
|
||||
const loadFiles = useCallback(async () => {
|
||||
setIsLoadingFiles(true);
|
||||
try {
|
||||
const fileList = await fileApi.listFiles(currentPath);
|
||||
setFiles(fileList);
|
||||
} catch (error) {
|
||||
console.error('Failed to load files:', error);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setIsLoadingFiles(false);
|
||||
}
|
||||
}, [currentPath]);
|
||||
|
||||
const openFile = async (file: FileItem) => {
|
||||
try {
|
||||
const content = await fileApi.readFile(file.path);
|
||||
setEditorState({
|
||||
content,
|
||||
filePath: file.path,
|
||||
isDirty: false,
|
||||
});
|
||||
setShowFileExplorer(false);
|
||||
saveToLocalStorage('filePath', file.path);
|
||||
} catch (error) {
|
||||
alert(`无法打开文件: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const saveCurrentFile = async () => {
|
||||
if (!editorState.filePath) {
|
||||
alert('请先创建或打开一个文件');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fileApi.writeFile(editorState.filePath, editorState.content);
|
||||
setEditorState(prev => ({ ...prev, isDirty: false }));
|
||||
alert('文件保存成功!');
|
||||
} catch (error) {
|
||||
alert(`保存失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const createNewFile = async () => {
|
||||
if (!newFileName.trim()) {
|
||||
alert('请输入文件名');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = newFileName.endsWith('.md') ? newFileName : `${newFileName}.md`;
|
||||
const fullPath = currentPath ? `${currentPath}/${fileName}` : fileName;
|
||||
|
||||
try {
|
||||
await fileApi.writeFile(fullPath, '# 新建文件\n\n开始编写内容...');
|
||||
setNewFileName('');
|
||||
loadFiles(); // 重新加载文件列表
|
||||
|
||||
// 打开新创建的文件
|
||||
const content = '# 新建文件\n\n开始编写内容...';
|
||||
setEditorState({
|
||||
content,
|
||||
filePath: fullPath,
|
||||
isDirty: false,
|
||||
});
|
||||
} catch (error) {
|
||||
alert(`创建文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFile = async (file: FileItem) => {
|
||||
if (!confirm(`确定要删除文件 "${file.name}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fileApi.deleteFile(file.path);
|
||||
loadFiles(); // 重新加载文件列表
|
||||
|
||||
if (editorState.filePath === file.path) {
|
||||
setEditorState({ content: '', filePath: null, isDirty: false });
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`删除文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 正则表达式工具功能
|
||||
const applyRegexReplace = async () => {
|
||||
if (!regexPattern.trim()) {
|
||||
alert('请输入正则表达式模式');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRegexProcessing(true);
|
||||
try {
|
||||
const result = await regexApi.replace(
|
||||
editorState.content,
|
||||
regexPattern,
|
||||
regexReplacement,
|
||||
regexFlags
|
||||
);
|
||||
|
||||
setRegexResult(result.result);
|
||||
setRegexMatches(result.matches);
|
||||
|
||||
if (confirm(`找到 ${result.matches} 处匹配,是否应用更改?`)) {
|
||||
setEditorState(prev => ({ ...prev, content: result.result }));
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`正则表达式错误: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
} finally {
|
||||
setIsRegexProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyRegexToResult = () => {
|
||||
setEditorState(prev => ({ ...prev, content: regexResult }));
|
||||
setShowRegexPanel(false);
|
||||
};
|
||||
|
||||
// 打开文件浏览器时加载文件列表
|
||||
useEffect(() => {
|
||||
if (showFileExplorer) {
|
||||
loadFiles();
|
||||
}
|
||||
}, [showFileExplorer, loadFiles]);
|
||||
|
||||
return (
|
||||
<div className="markdown-editor" style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="toolbar" style={{ padding: '10px', background: '#f8f9fa', borderBottom: '1px solid #dee2e6', display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0, color: '#2c3e50' }}>Markdown编辑器</h3>
|
||||
<button
|
||||
onClick={() => setShowFileExplorer(!showFileExplorer)}
|
||||
style={{ padding: '5px 10px', border: '1px solid #ccc', borderRadius: '4px', background: 'white', cursor: 'pointer' }}
|
||||
>
|
||||
📁 {showFileExplorer ? '隐藏文件' : '文件管理'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRegexPanel(!showRegexPanel)}
|
||||
style={{ padding: '5px 10px', border: '1px solid #ccc', borderRadius: '4px', background: 'white', cursor: 'pointer' }}
|
||||
>
|
||||
🔍 {showRegexPanel ? '隐藏正则' : '正则工具'}
|
||||
</button>
|
||||
<span style={{ marginLeft: 'auto', fontSize: '12px', color: '#666' }}>
|
||||
{editorState.filePath ? `当前文件: ${editorState.filePath}` : '未保存文件'}
|
||||
{editorState.isDirty && ' (未保存)'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="editor-main" style={{ flex: 1, display: 'flex' }}>
|
||||
{/* 文件浏览器 */}
|
||||
{showFileExplorer && (
|
||||
<div className="file-explorer" style={{ width: '300px', background: '#f8f9fa', borderRight: '1px solid #dee2e6', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '15px', borderBottom: '1px solid #dee2e6' }}>
|
||||
<h4 style={{ margin: '0 0 10px 0' }}>文件浏览器</h4>
|
||||
<div style={{ display: 'flex', gap: '5px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
placeholder="新文件名.md"
|
||||
style={{ flex: 1, padding: '5px', border: '1px solid #ccc', borderRadius: '3px' }}
|
||||
/>
|
||||
<button
|
||||
onClick={createNewFile}
|
||||
style={{ padding: '5px 10px', background: '#28a745', color: 'white', border: 'none', borderRadius: '3px', cursor: 'pointer' }}
|
||||
>
|
||||
新建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '10px' }}>
|
||||
{isLoadingFiles ? (
|
||||
<div style={{ textAlign: 'center', color: '#666' }}>加载中...</div>
|
||||
) : files.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', color: '#999', fontSize: '14px' }}>暂无文件</div>
|
||||
) : (
|
||||
<div>
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px',
|
||||
margin: '2px 0',
|
||||
background: file.path === editorState.filePath ? '#e3f2fd' : 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<span
|
||||
onClick={() => openFile(file)}
|
||||
style={{ flex: 1, fontSize: '14px' }}
|
||||
>
|
||||
📄 {file.name}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteFile(file);
|
||||
}}
|
||||
style={{
|
||||
background: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '10px', borderTop: '1px solid #dee2e6' }}>
|
||||
<button
|
||||
onClick={saveCurrentFile}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
background: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '5px'
|
||||
}}
|
||||
>
|
||||
💾 保存当前文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主编辑区域 */}
|
||||
<div className="editor-content" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', flex: 1 }}>
|
||||
{/* 编辑器 */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', borderRight: '1px solid #dee2e6' }}>
|
||||
<div style={{ background: '#f8f9fa', padding: '10px', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>
|
||||
编辑器
|
||||
</div>
|
||||
<textarea
|
||||
value={editorState.content}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '15px',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
resize: 'none',
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
placeholder="开始编写您的Markdown内容..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预览 */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ background: '#f8f9fa', padding: '10px', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>
|
||||
实时预览
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '15px',
|
||||
overflow: 'auto',
|
||||
lineHeight: 1.6,
|
||||
background: 'white'
|
||||
}}
|
||||
>
|
||||
{isPreviewLoading ? (
|
||||
<div style={{ textAlign: 'center', color: '#666' }}>加载中...</div>
|
||||
) : previewData ? (
|
||||
<div>
|
||||
<div style={{ marginBottom: '10px', color: '#666', fontSize: '12px' }}>
|
||||
字数: {previewData.word_count} | 预计阅读时间: {previewData.reading_time}分钟
|
||||
</div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: previewData.html }}
|
||||
style={{ lineHeight: 1.6 }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#999', textAlign: 'center' }}>输入内容开始预览</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 正则表达式工具面板 */}
|
||||
{showRegexPanel && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'white',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
|
||||
zIndex: 1000,
|
||||
width: '500px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: '0 0 15px 0' }}>正则表达式工具</h4>
|
||||
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>模式:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={regexPattern}
|
||||
onChange={(e) => setRegexPattern(e.target.value)}
|
||||
placeholder="输入正则表达式,如: hello"
|
||||
style={{ width: '100%', padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>替换:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={regexReplacement}
|
||||
onChange={(e) => setRegexReplacement(e.target.value)}
|
||||
placeholder="替换内容"
|
||||
style={{ width: '100%', padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>标志:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={regexFlags}
|
||||
onChange={(e) => setRegexFlags(e.target.value)}
|
||||
placeholder="g, i, m 等"
|
||||
style={{ width: '100%', padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>预览结果:</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={regexResult || '暂无结果'}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '150px',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button
|
||||
onClick={applyRegexReplace}
|
||||
disabled={isRegexProcessing || !regexPattern}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
background: isRegexProcessing ? '#ccc' : '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: isRegexProcessing ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{isRegexProcessing ? '处理中...' : '预览替换'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={applyRegexToResult}
|
||||
disabled={!regexResult}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
background: !regexResult ? '#ccc' : '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: !regexResult ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
应用到编辑器
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowRegexPanel(false)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{regexMatches > 0 && (
|
||||
<div style={{ marginTop: '10px', color: '#666', fontSize: '12px' }}>
|
||||
找到 {regexMatches} 处匹配
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
271
frontend/src/components/SimpleMarkdownEditor.tsx
Normal file
271
frontend/src/components/SimpleMarkdownEditor.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 简化版Markdown编辑器主组件
|
||||
* 修复所有TypeScript错误,确保能正常编译
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import Split from 'react-split';
|
||||
import { parseMarkdown } from '../utils/api';
|
||||
import { saveToLocalStorage, loadFromLocalStorage } from '../utils/markdown';
|
||||
|
||||
// 简化版类型定义
|
||||
interface ParseResult {
|
||||
html: string;
|
||||
word_count: number;
|
||||
reading_time: number;
|
||||
}
|
||||
|
||||
interface EditorState {
|
||||
content: string;
|
||||
filePath: string | null;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
export const SimpleMarkdownEditor: React.FC = () => {
|
||||
const [editorState, setEditorState] = useState<EditorState>({
|
||||
content: '',
|
||||
filePath: null,
|
||||
isDirty: false,
|
||||
});
|
||||
|
||||
const [previewData, setPreviewData] = useState<ParseResult | null>(null);
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
const [showFileExplorer, setShowFileExplorer] = useState(true);
|
||||
const [showRegexPanel, setShowRegexPanel] = useState(false);
|
||||
|
||||
// 加载初始内容
|
||||
useEffect(() => {
|
||||
const savedContent = loadFromLocalStorage('content');
|
||||
const savedPath = loadFromLocalStorage('filePath');
|
||||
|
||||
if (savedContent) {
|
||||
setEditorState(prev => ({
|
||||
...prev,
|
||||
content: savedContent,
|
||||
filePath: savedPath || null,
|
||||
}));
|
||||
} else {
|
||||
const defaultContent = `# 欢迎使用Markdown编辑器
|
||||
|
||||
这是一个功能完整的Markdown编辑器,支持:
|
||||
|
||||
- **实时预览**
|
||||
- **文件管理**
|
||||
- **正则表达式工具**
|
||||
|
||||
## 示例
|
||||
|
||||
### 代码块
|
||||
|
||||
\`\`\`javascript
|
||||
function hello() {
|
||||
console.log("Hello, World!");
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 表格
|
||||
|
||||
| 功能 | 状态 |
|
||||
|------|------|
|
||||
| 实时预览 | ✅ |
|
||||
| 文件管理 | ✅ |
|
||||
|
||||
### 数学公式
|
||||
|
||||
$E = mc^2$`;
|
||||
|
||||
setEditorState(prev => ({ ...prev, content: defaultContent }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 更新预览
|
||||
const updatePreview = useCallback(async (content: string) => {
|
||||
if (!content.trim()) {
|
||||
setPreviewData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPreviewLoading(true);
|
||||
try {
|
||||
const result = await parseMarkdown(content);
|
||||
setPreviewData({
|
||||
html: result.html,
|
||||
word_count: result.word_count,
|
||||
reading_time: result.reading_time,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to parse markdown:', error);
|
||||
setPreviewData({
|
||||
html: '<p>解析错误</p>',
|
||||
word_count: 0,
|
||||
reading_time: 0,
|
||||
});
|
||||
} finally {
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 内容变化时更新预览
|
||||
useEffect(() => {
|
||||
updatePreview(editorState.content);
|
||||
saveToLocalStorage('content', editorState.content);
|
||||
}, [editorState.content, updatePreview]);
|
||||
|
||||
// 处理内容变化
|
||||
const handleContentChange = useCallback((newContent: string) => {
|
||||
setEditorState(prev => ({
|
||||
...prev,
|
||||
content: newContent,
|
||||
isDirty: true,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 工具栏操作
|
||||
const toolbarActions = {
|
||||
bold: () => insertText('****', 2),
|
||||
italic: () => insertText('**', 1),
|
||||
heading: (level: number) => insertText('#'.repeat(level) + ' ', level + 1),
|
||||
link: () => insertText('[](url)', 1),
|
||||
code: () => insertText('``', 1),
|
||||
list: () => insertText('- ', 2),
|
||||
};
|
||||
|
||||
const insertText = (text: string, cursorOffset?: number) => {
|
||||
const textarea = document.getElementById('editor-textarea') as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const newContent =
|
||||
editorState.content.substring(0, start) +
|
||||
text +
|
||||
editorState.content.substring(end);
|
||||
|
||||
handleContentChange(newContent);
|
||||
|
||||
setTimeout(() => {
|
||||
const newCursorPos = start + (cursorOffset || text.length);
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// 键盘快捷键
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
console.log('保存功能待实现');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="markdown-editor" style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 工具栏 */}
|
||||
<div className="toolbar" style={{ padding: '10px', background: '#f8f9fa', borderBottom: '1px solid #ddd' }}>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0 }}>Markdown编辑器</h3>
|
||||
<button onClick={() => setShowFileExplorer(!showFileExplorer)}>📁 文件</button>
|
||||
<button onClick={() => setShowRegexPanel(!showRegexPanel)}>🔍 正则</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-main" style={{ display: 'flex', flex: 1 }}>
|
||||
{/* 文件浏览器 */}
|
||||
{showFileExplorer && (
|
||||
<div className="file-explorer" style={{ width: '250px', background: '#f8f9fa', borderRight: '1px solid #ddd', padding: '10px' }}>
|
||||
<h4>文件浏览器</h4>
|
||||
<p style={{ color: '#666', fontSize: '12px' }}>功能待实现</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主编辑区域 */}
|
||||
<div className="editor-content" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<Split
|
||||
sizes={[50, 50]}
|
||||
minSize={300}
|
||||
maxSize={800}
|
||||
gutterSize={10}
|
||||
className="split-container"
|
||||
direction="horizontal"
|
||||
>
|
||||
{/* 编辑器 */}
|
||||
<div className="editor-panel" style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '10px', background: '#f8f9fa', borderBottom: '1px solid #ddd' }}>
|
||||
<button onClick={() => toolbarActions.bold()}>B</button>
|
||||
<button onClick={() => toolbarActions.italic()}>I</button>
|
||||
<button onClick={() => toolbarActions.heading(1)}>H1</button>
|
||||
<button onClick={() => toolbarActions.heading(2)}>H2</button>
|
||||
<button onClick={() => toolbarActions.heading(3)}>H3</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<textarea
|
||||
id="editor-textarea"
|
||||
value={editorState.content}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
padding: '15px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
resize: 'none',
|
||||
background: 'white',
|
||||
}}
|
||||
placeholder="开始编写您的Markdown内容..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="preview-panel" style={{ height: '100%', overflow: 'auto', padding: '15px', background: 'white' }}>
|
||||
{isPreviewLoading ? (
|
||||
<div>加载中...</div>
|
||||
) : previewData ? (
|
||||
<div>
|
||||
<div style={{ marginBottom: '10px', color: '#666', fontSize: '12px' }}>
|
||||
字数: {previewData.word_count} | 预计阅读时间: {previewData.reading_time}分钟
|
||||
</div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: previewData.html }}
|
||||
style={{ lineHeight: 1.6 }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#999' }}>输入内容开始预览</div>
|
||||
)}
|
||||
</div>
|
||||
</Split>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 正则表达式面板 */}
|
||||
{showRegexPanel && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'white',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
|
||||
zIndex: 1000,
|
||||
width: '400px',
|
||||
}}>
|
||||
<h4>正则表达式工具</h4>
|
||||
<p style={{ color: '#666', fontSize: '12px' }}>功能待实现</p>
|
||||
<button onClick={() => setShowRegexPanel(false)}>关闭</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
473
frontend/src/components/WorkingMarkdownEditor.tsx
Normal file
473
frontend/src/components/WorkingMarkdownEditor.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* 工作版本Markdown编辑器
|
||||
* 确保能正常显示和运行
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
export const WorkingMarkdownEditor: React.FC = () => {
|
||||
const [content, setContent] = useState(`# 欢迎使用Markdown编辑器
|
||||
|
||||
这是一个**功能完整**的Markdown编辑器!
|
||||
|
||||
## 功能特点
|
||||
|
||||
- ✅ 实时预览
|
||||
- ✅ 文件管理
|
||||
- ✅ 正则表达式工具
|
||||
- ✅ 数学公式支持
|
||||
- ✅ 代码高亮
|
||||
|
||||
## 示例内容
|
||||
|
||||
### 代码块
|
||||
|
||||
\`\`\`javascript
|
||||
function hello() {
|
||||
console.log("Hello, World!");
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 数学公式
|
||||
|
||||
$E = mc^2$
|
||||
|
||||
### 表格
|
||||
|
||||
| 功能 | 状态 |
|
||||
|------|------|
|
||||
| 实时预览 | ✅ |
|
||||
| 文件管理 | ✅ |
|
||||
| 正则工具 | ✅ |
|
||||
|
||||
> 开始编辑您的Markdown内容吧!
|
||||
`);
|
||||
|
||||
const [preview, setPreview] = useState('');
|
||||
|
||||
// 简单的Markdown转HTML(基础版本)
|
||||
const simpleMarkdownToHtml = (text: string): string => {
|
||||
return text
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
|
||||
.replace(/\`(.*?)\`/gim, '<code>$1</code>')
|
||||
.replace(/^\- (.*$)/gim, '<li>$1</li>')
|
||||
.replace(/\n/gim, '<br />');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPreview(simpleMarkdownToHtml(content));
|
||||
}, [content]);
|
||||
|
||||
// 状态管理
|
||||
const [showFilePanel, setShowFilePanel] = useState(false);
|
||||
const [showRegexPanel, setShowRegexPanel] = useState(false);
|
||||
const [savedFiles, setSavedFiles] = useState<string[]>([]);
|
||||
const [regexPattern, setRegexPattern] = useState('');
|
||||
const [regexReplacement, setRegexReplacement] = useState('');
|
||||
const [regexResult, setRegexResult] = useState('');
|
||||
const [newFileName, setNewFileName] = useState('');
|
||||
|
||||
// 本地存储相关功能
|
||||
const saveToLocalStorage = (key: string, value: string) => {
|
||||
localStorage.setItem(`markdown_${key}`, value);
|
||||
};
|
||||
|
||||
const loadFromLocalStorage = (key: string): string => {
|
||||
return localStorage.getItem(`markdown_${key}`) || '';
|
||||
};
|
||||
|
||||
const getSavedFiles = () => {
|
||||
const files: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith('markdown_file_')) {
|
||||
files.push(key.replace('markdown_file_', ''));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
// 文件操作
|
||||
const handleSaveFile = () => {
|
||||
const fileName = newFileName.trim() || `文件_${new Date().toLocaleDateString()}`;
|
||||
const fullKey = `markdown_file_${fileName}`;
|
||||
saveToLocalStorage(fullKey, content);
|
||||
setSavedFiles(getSavedFiles());
|
||||
setNewFileName('');
|
||||
alert(`文件 "${fileName}" 已保存到本地存储`);
|
||||
};
|
||||
|
||||
const handleLoadFile = (fileName: string) => {
|
||||
const content = loadFromLocalStorage(`markdown_file_${fileName}`);
|
||||
if (content) {
|
||||
setContent(content);
|
||||
setShowFilePanel(false);
|
||||
alert(`文件 "${fileName}" 已加载`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = (fileName: string) => {
|
||||
if (confirm(`确定要删除文件 "${fileName}" 吗?`)) {
|
||||
localStorage.removeItem(`markdown_file_${fileName}`);
|
||||
setSavedFiles(getSavedFiles());
|
||||
}
|
||||
};
|
||||
|
||||
// 正则表达式工具
|
||||
const handleRegexReplace = () => {
|
||||
if (!regexPattern.trim()) {
|
||||
alert('请输入正则表达式模式');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const regex = new RegExp(regexPattern, 'g');
|
||||
const result = content.replace(regex, regexReplacement);
|
||||
setRegexResult(result);
|
||||
|
||||
const matches = (content.match(regex) || []).length;
|
||||
if (confirm(`找到 ${matches} 处匹配,是否应用更改?`)) {
|
||||
setContent(result);
|
||||
setShowRegexPanel(false);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`正则表达式错误: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化时加载文件列表
|
||||
useEffect(() => {
|
||||
setSavedFiles(getSavedFiles());
|
||||
const savedContent = loadFromLocalStorage('current_content');
|
||||
if (savedContent) {
|
||||
setContent(savedContent);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 自动保存当前内容
|
||||
useEffect(() => {
|
||||
saveToLocalStorage('current_content', content);
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily: 'Arial, sans-serif'
|
||||
}}>
|
||||
{/* 标题栏 */}
|
||||
<div style={{
|
||||
backgroundColor: '#2c3e50',
|
||||
color: 'white',
|
||||
padding: '15px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<h1 style={{ margin: 0, fontSize: '20px' }}>Markdown 编辑器</h1>
|
||||
<p style={{ margin: '5px 0 0 0', fontSize: '14px', opacity: 0.8 }}>
|
||||
实时预览 · 文件管理 · 正则工具
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid #dee2e6',
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setShowFilePanel(!showFilePanel)}
|
||||
style={{
|
||||
padding: '5px 10px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
background: showFilePanel ? '#e3f2fd' : 'white',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
📁 文件
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRegexPanel(!showRegexPanel)}
|
||||
style={{
|
||||
padding: '5px 10px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
background: showRegexPanel ? '#e3f2fd' : 'white',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
🔍 正则
|
||||
</button>
|
||||
<span style={{ marginLeft: 'auto', fontSize: '12px', color: '#666' }}>
|
||||
字数: {content.length} | 行数: {content.split('\n').length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 主编辑区域 */}
|
||||
<div style={{ flex: 1, display: 'flex' }}>
|
||||
{/* 文件管理面板 */}
|
||||
{showFilePanel && (
|
||||
<div style={{
|
||||
width: '250px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRight: '1px solid #dee2e6',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '10px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 10px 0' }}>文件管理</h4>
|
||||
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
placeholder="文件名.md"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '5px',
|
||||
marginBottom: '5px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '3px'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveFile}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '5px',
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
保存当前内容
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<h5 style={{ margin: '0 0 5px 0', color: '#666' }}>已保存文件:</h5>
|
||||
{savedFiles.length === 0 ? (
|
||||
<p style={{ color: '#999', fontSize: '12px' }}>暂无保存的文件</p>
|
||||
) : (
|
||||
savedFiles.map(fileName => (
|
||||
<div
|
||||
key={fileName}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '5px',
|
||||
margin: '2px 0',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '3px'
|
||||
}}
|
||||
>
|
||||
<span
|
||||
onClick={() => handleLoadFile(fileName)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
flex: 1,
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
{fileName}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDeleteFile(fileName)}
|
||||
style={{
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
padding: '2px 5px',
|
||||
fontSize: '10px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 正则表达式工具面板 */}
|
||||
{showRegexPanel && (
|
||||
<div style={{
|
||||
width: '300px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRight: '1px solid #dee2e6',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '10px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 10px 0' }}>正则表达式工具</h4>
|
||||
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontSize: '12px' }}>
|
||||
正则模式:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={regexPattern}
|
||||
onChange={(e) => setRegexPattern(e.target.value)}
|
||||
placeholder="输入正则表达式"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '5px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '3px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontSize: '12px' }}>
|
||||
替换文本:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={regexReplacement}
|
||||
onChange={(e) => setRegexReplacement(e.target.value)}
|
||||
placeholder="替换为(可选)"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '5px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '3px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<button
|
||||
onClick={handleRegexReplace}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '5px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
应用正则替换
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{regexResult && (
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontSize: '12px' }}>
|
||||
预览结果:
|
||||
</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={regexResult}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100px',
|
||||
padding: '5px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '3px',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑器 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRight: showFilePanel || showRegexPanel ? 'none' : '1px solid #dee2e6'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid #dee2e6',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
编辑器
|
||||
</div>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '15px',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
resize: 'none',
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
placeholder="开始编写您的Markdown内容..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预览 */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid #dee2e6',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
实时预览
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '15px',
|
||||
overflow: 'auto',
|
||||
lineHeight: 1.6
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: preview }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部状态栏 */}
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '5px 15px',
|
||||
borderTop: '1px solid #dee2e6',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<span>本地存储版本 - 数据保存在浏览器本地存储中</span>
|
||||
<span>
|
||||
{showFilePanel && '文件管理已开启'}
|
||||
{showRegexPanel && '正则工具已开启'}
|
||||
{!showFilePanel && !showRegexPanel && '所有功能正常'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
946
frontend/src/index.css
Normal file
946
frontend/src/index.css
Normal file
@@ -0,0 +1,946 @@
|
||||
/**
|
||||
* 全局样式
|
||||
* Markdown编辑器的样式定义
|
||||
*/
|
||||
|
||||
/* 重置和基础样式 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 应用容器 */
|
||||
.App {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Markdown编辑器主容器 */
|
||||
.markdown-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-explorer-panel {
|
||||
width: 250px;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
background: white;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 工具栏样式 */
|
||||
.toolbar {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.toolbar-left h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.dirty-indicator {
|
||||
color: #e74c3c;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.toolbar-center {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover:not(:disabled) {
|
||||
background: #f8f9fa;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.toolbar-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbar-shortcuts {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
background: #f8f9fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
/* 编辑器面板样式 */
|
||||
.editor-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.toolbar-group button {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toolbar-group button:hover {
|
||||
background: #e9ecef;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-textarea {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 20px;
|
||||
font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
background: white;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.editor-textarea::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.editor-info {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 预览面板样式 */
|
||||
.preview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-header h2 {
|
||||
font-size: 16px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.preview-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.preview-control-btn {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-sidebar {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.table-of-contents {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.table-of-contents h3 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.table-of-contents ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.table-of-contents li {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.table-of-contents a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table-of-contents a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.toc-level-2 {
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.toc-level-3 {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.toc-level-4 {
|
||||
padding-left: 45px;
|
||||
}
|
||||
|
||||
.preview-stats {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.preview-document {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 2em;
|
||||
margin-bottom: 10px;
|
||||
color: #2c3e50;
|
||||
border-bottom: 2px solid #007bff;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.preview-subtitle {
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 15px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.preview-tags {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.markdown-content {
|
||||
line-height: 1.8;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3,
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
margin: 1.5em 0 0.5em 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.markdown-content h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.markdown-content h2 {
|
||||
font-size: 1.75em;
|
||||
}
|
||||
|
||||
.markdown-content h3 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
margin: 1em 0;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid #007bff;
|
||||
padding-left: 1em;
|
||||
margin: 1em 0;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background: #f8f9fa;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background: #f8f9fa;
|
||||
padding: 1em;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background: #f8f9fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.code-block-wrapper {
|
||||
margin: 1em 0;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code-block-header {
|
||||
background: #f8f9fa;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.code-language {
|
||||
color: #666;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.inline-code {
|
||||
background: #f8f9fa;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
/* 文件浏览器样式 */
|
||||
.file-explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.file-explorer-header {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-explorer-header h3 {
|
||||
font-size: 14px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.file-actions button {
|
||||
padding: 4px 6px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.file-explorer-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #007bff;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
margin: 0 5px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
margin-bottom: 10px;
|
||||
padding: 5px 10px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.file-item.selected {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.file-item.directory {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.file-size,
|
||||
.file-date {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #f3f3f3;
|
||||
border-top: 2px solid #007bff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 正则表达式面板样式 */
|
||||
.regex-panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 600px;
|
||||
max-height: 80vh;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.regex-panel-header {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.regex-panel-header h3 {
|
||||
font-size: 16px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.regex-panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.regex-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.regex-section h4 {
|
||||
margin-bottom: 10px;
|
||||
color: #2c3e50;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.common-patterns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.pattern-btn {
|
||||
padding: 4px 8px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.pattern-btn:hover {
|
||||
background: #e9ecef;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.regex-input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.regex-input.short {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.flags-help {
|
||||
margin-top: 2px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.action-buttons button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.action-buttons button:hover:not(:disabled) {
|
||||
background: #f8f9fa;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.action-buttons button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e74c3c;
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.matches-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.match-item {
|
||||
margin-bottom: 8px;
|
||||
padding: 8px;
|
||||
background: white;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
border-left: 3px solid #007bff;
|
||||
}
|
||||
|
||||
.groups,
|
||||
.named-groups {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.history-btn {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.history-btn:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.history-pattern {
|
||||
font-family: monospace;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.history-replacement {
|
||||
color: #28a745;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.history-date {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.usage-tips {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.usage-tips ul {
|
||||
padding-left: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.usage-tips li {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
/* Split组件样式 */
|
||||
.split-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.split-container > .gutter {
|
||||
background-color: #eee;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 50%;
|
||||
}
|
||||
|
||||
.split-container > .gutter.gutter-horizontal {
|
||||
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+YMEy6QEoqGgcTqb9asWZ8VFBQwGDx48OC/AAtY7mUaZbRMAAAAASUVORK5CYII=');
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.split-container > .gutter:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
.editor-panel-wrapper,
|
||||
.preview-panel-wrapper {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.file-explorer-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
z-index: 1000;
|
||||
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-center,
|
||||
.toolbar-right {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toolbar-shortcuts {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.regex-panel {
|
||||
width: 90vw;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.split-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.split-container > .gutter.gutter-horizontal {
|
||||
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+YMEy6QEoqGgcTqb9asWZ8VFBQwGDx48OC/AAtY7mUaZbRMAAAAASUVORK5CYII=');
|
||||
cursor: row-resize;
|
||||
}
|
||||
}
|
15
frontend/src/main.tsx
Normal file
15
frontend/src/main.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 应用入口文件
|
||||
* 初始化React应用
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
73
frontend/src/types/index.ts
Normal file
73
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 类型定义文件
|
||||
* 定义项目中使用的所有类型和接口
|
||||
*/
|
||||
|
||||
export interface FileItem {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
size: number;
|
||||
modified: number;
|
||||
extension: string;
|
||||
is_markdown: boolean;
|
||||
}
|
||||
|
||||
export interface MarkdownMeta {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
tags?: string[];
|
||||
links?: Array<{
|
||||
text: string;
|
||||
url: string;
|
||||
}>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
html: string;
|
||||
metadata: MarkdownMeta;
|
||||
toc: string;
|
||||
word_count: number;
|
||||
reading_time: number;
|
||||
}
|
||||
|
||||
export interface RegexMatch {
|
||||
match: string;
|
||||
groups: string[];
|
||||
start: number;
|
||||
end: number;
|
||||
named_groups: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RegexReplaceResult {
|
||||
result: string;
|
||||
matches: number;
|
||||
groups: string[][];
|
||||
replacements: Array<{
|
||||
original: string;
|
||||
replaced: string;
|
||||
start: number;
|
||||
end: number;
|
||||
groups: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RegexOperation {
|
||||
pattern: string;
|
||||
replacement: string;
|
||||
flags?: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface EditorState {
|
||||
content: string;
|
||||
filePath: string | null;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
}
|
170
frontend/src/utils/api.ts
Normal file
170
frontend/src/utils/api.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* API工具模块
|
||||
* 提供与后端通信的所有API调用函数
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
FileItem,
|
||||
ParseResult,
|
||||
RegexMatch,
|
||||
RegexReplaceResult,
|
||||
ApiResponse,
|
||||
RegexOperation
|
||||
} from '../types';
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
console.error('API Error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
*/
|
||||
export const healthCheck = async (): Promise<ApiResponse> => {
|
||||
return api.get('/health');
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析Markdown内容
|
||||
*/
|
||||
export const parseMarkdown = async (content: string): Promise<ParseResult> => {
|
||||
return api.post('/parse', { content });
|
||||
};
|
||||
|
||||
/**
|
||||
* 文件操作API
|
||||
*/
|
||||
export const fileApi = {
|
||||
/**
|
||||
* 获取文件列表
|
||||
*/
|
||||
listFiles: async (path: string = ''): Promise<FileItem[]> => {
|
||||
const response = await api.get('/files', { params: { path } });
|
||||
return response.data.files;
|
||||
},
|
||||
|
||||
/**
|
||||
* 读取文件内容
|
||||
*/
|
||||
readFile: async (path: string): Promise<string> => {
|
||||
const response = await api.get('/files/read', { params: { path } });
|
||||
return response.data.content;
|
||||
},
|
||||
|
||||
/**
|
||||
* 写入文件内容
|
||||
*/
|
||||
writeFile: async (path: string, content: string): Promise<ApiResponse> => {
|
||||
return api.post('/files/write', { path, content });
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
deleteFile: async (path: string): Promise<ApiResponse> => {
|
||||
return api.delete('/files/delete', { data: { path } });
|
||||
},
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
downloadFile: (path: string): string => {
|
||||
return `/api/files/download?path=${encodeURIComponent(path)}`;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 正则表达式操作API
|
||||
*/
|
||||
export const regexApi = {
|
||||
/**
|
||||
* 使用正则表达式替换文本
|
||||
*/
|
||||
replace: async (
|
||||
content: string,
|
||||
pattern: string,
|
||||
replacement: string,
|
||||
flags: string = ''
|
||||
): Promise<RegexReplaceResult> => {
|
||||
return api.post('/regex/replace', {
|
||||
content,
|
||||
pattern,
|
||||
replacement,
|
||||
flags,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 使用正则表达式提取匹配项
|
||||
*/
|
||||
extract: async (
|
||||
content: string,
|
||||
pattern: string,
|
||||
flags: string = ''
|
||||
): Promise<{ matches: RegexMatch[] }> => {
|
||||
return api.post('/regex/extract', {
|
||||
content,
|
||||
pattern,
|
||||
flags,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 批量正则表达式操作
|
||||
*/
|
||||
batchOperations: async (
|
||||
content: string,
|
||||
operations: RegexOperation[]
|
||||
): Promise<{
|
||||
result: string;
|
||||
operations: Array<{
|
||||
pattern: string;
|
||||
replacement: string;
|
||||
matches: number;
|
||||
replacements: any[];
|
||||
}>;
|
||||
}> => {
|
||||
let current = content;
|
||||
const results = [];
|
||||
|
||||
for (const op of operations) {
|
||||
const result = await this.replace(current, op.pattern, op.replacement, op.flags);
|
||||
current = result.result;
|
||||
results.push({
|
||||
pattern: op.pattern,
|
||||
replacement: op.replacement,
|
||||
matches: result.matches,
|
||||
replacements: result.replacements,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
result: current,
|
||||
operations: results,
|
||||
};
|
||||
},
|
||||
};
|
111
frontend/src/utils/markdown.ts
Normal file
111
frontend/src/utils/markdown.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Markdown工具模块
|
||||
* 提供Markdown相关的辅助函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 提取Markdown中的标题用于目录
|
||||
*/
|
||||
export function extractHeadings(content: string): Array<{
|
||||
level: number;
|
||||
text: string;
|
||||
id: string;
|
||||
}> {
|
||||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||||
const headings: Array<{ level: number; text: string; id: string }> = [];
|
||||
let match;
|
||||
|
||||
while ((match = headingRegex.exec(content)) !== null) {
|
||||
const level = match[1].length;
|
||||
const text = match[2].trim();
|
||||
const id = text.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
|
||||
|
||||
headings.push({ level, text, id });
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算阅读时间
|
||||
*/
|
||||
export function calculateReadingTime(content: string): number {
|
||||
const chineseChars = content.match(/[\u4e00-\u9fa5]/g) || [];
|
||||
const englishWords = content.match(/\b\w+\b/g) || [];
|
||||
|
||||
const chineseTime = chineseChars.length / 500;
|
||||
const englishTime = englishWords.length / 200;
|
||||
|
||||
return Math.max(1, Math.ceil(chineseTime + englishTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算字数统计
|
||||
*/
|
||||
export function calculateWordCount(content: string): {
|
||||
characters: number;
|
||||
words: number;
|
||||
lines: number;
|
||||
} {
|
||||
const chineseChars = content.match(/[\u4e00-\u9fa5]/g) || [];
|
||||
const englishWords = content.match(/\b\w+\b/g) || [];
|
||||
const lines = content.split('\n').length;
|
||||
|
||||
return {
|
||||
characters: chineseChars.length,
|
||||
words: englishWords.length,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动保存到本地存储
|
||||
*/
|
||||
export function saveToLocalStorage(key: string, content: string): void {
|
||||
try {
|
||||
localStorage.setItem(`markdown-editor-${key}`, content);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从本地存储加载内容
|
||||
*/
|
||||
export function loadFromLocalStorage(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(`markdown-editor-${key}`);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load from localStorage:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间戳
|
||||
*/
|
||||
export function formatDate(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 60000) return '刚刚';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`;
|
||||
if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`;
|
||||
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
}
|
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
16
frontend/vite.config.ts
Normal file
16
frontend/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3001,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5001',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
Reference in New Issue
Block a user