├── .npmrc ├── face.png ├── tsconfig.json ├── src ├── pages │ ├── index.less │ ├── index.tsx │ ├── Notification.tsx │ ├── notification.less │ ├── toolbar.less │ ├── documentList.less │ ├── Toolbar.tsx │ ├── components │ │ ├── index.less │ │ └── EditorContainer.tsx │ └── DocumentsList.tsx ├── assets │ └── yay.jpg ├── layouts │ ├── index.less │ └── index.tsx ├── hooks │ └── useLocalStorage.ts └── utils │ └── index.ts ├── .gitignore ├── typings.d.ts ├── .umirc.ts ├── package.json ├── LICENSE └── readme.md /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.com/ 2 | 3 | -------------------------------------------------------------------------------- /face.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrXujiang/md-editor/HEAD/face.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./src/.umi/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /src/pages/index.less: -------------------------------------------------------------------------------- 1 | .app { 2 | height: 100vh; 3 | background-color: #f8fafc; 4 | } -------------------------------------------------------------------------------- /src/assets/yay.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrXujiang/md-editor/HEAD/src/assets/yay.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.env.local 3 | /.umirc.local.ts 4 | /config/config.local.ts 5 | /src/.umi 6 | /src/.umi-production 7 | /src/.umi-test 8 | /dist 9 | .swc 10 | -------------------------------------------------------------------------------- /src/layouts/index.less: -------------------------------------------------------------------------------- 1 | .navs { 2 | ul { 3 | padding: 0; 4 | list-style: none; 5 | display: flex; 6 | } 7 | li { 8 | margin-right: 1em; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | import 'umi/typings'; 2 | 3 | export interface Document { 4 | id: string; 5 | title: string; 6 | content: string; 7 | created: number; 8 | updated: number; 9 | } -------------------------------------------------------------------------------- /src/layouts/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Outlet } from 'umi'; 2 | import styles from './index.less'; 3 | 4 | export default function Layout() { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { EditorContainer } from './components/EditorContainer'; 3 | import styles from './index.less'; 4 | 5 | function App() { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | 13 | export default App; -------------------------------------------------------------------------------- /.umirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "umi"; 2 | 3 | export default defineConfig({ 4 | base: '/edu-editor/', 5 | title: "面向教育的MD智能实时编辑器", 6 | publicPath: '/edu-editor/', 7 | outputPath: 'edu-editor', 8 | exportStatic: {}, 9 | esbuildMinifyIIFE: true, 10 | routes: [ 11 | { path: "/", component: "index" }, 12 | ], 13 | npmClient: 'yarn', 14 | }); 15 | -------------------------------------------------------------------------------- /src/pages/Notification.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import styles from './notification.less'; 4 | 5 | interface NotificationProps { 6 | message: string; 7 | type: 'success' | 'error'; 8 | } 9 | 10 | export const Notification: React.FC = ({ message, type }) => { 11 | return ( 12 |
13 | {message} 14 |
15 | ); 16 | }; -------------------------------------------------------------------------------- /src/pages/notification.less: -------------------------------------------------------------------------------- 1 | .notification { 2 | position: fixed; 3 | top: 1rem; 4 | right: 1rem; 5 | padding: 1rem; 6 | border-radius: 6px; 7 | color: white; 8 | animation: slideIn 0.3s ease-out; 9 | } 10 | 11 | .success { 12 | background: #10b981; 13 | } 14 | 15 | .error { 16 | background: #ef4444; 17 | } 18 | 19 | @keyframes slideIn { 20 | from { 21 | transform: translateX(100%); 22 | opacity: 0; 23 | } 24 | to { 25 | transform: translateX(0); 26 | opacity: 1; 27 | } 28 | } -------------------------------------------------------------------------------- /src/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export function useLocalStorage(key: string, initialValue: T) { 4 | const [storedValue, setStoredValue] = useState(() => { 5 | try { 6 | const item = window.localStorage.getItem(key); 7 | return item ? JSON.parse(item) : initialValue; 8 | } catch (error) { 9 | console.error(error); 10 | return initialValue; 11 | } 12 | }); 13 | 14 | useEffect(() => { 15 | try { 16 | window.localStorage.setItem(key, JSON.stringify(storedValue)); 17 | } catch (error) { 18 | console.error(error); 19 | } 20 | }, [key, storedValue]); 21 | 22 | return [storedValue, setStoredValue] as const; 23 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "author": "", 4 | "scripts": { 5 | "dev": "umi dev", 6 | "build": "umi build", 7 | "postinstall": "umi setup", 8 | "setup": "umi setup", 9 | "start": "npm run dev" 10 | }, 11 | "dependencies": { 12 | "@bytemd/plugin-breaks": "^1.22.0", 13 | "@bytemd/plugin-frontmatter": "^1.22.0", 14 | "@bytemd/plugin-gfm": "^1.22.0", 15 | "@bytemd/plugin-highlight": "^1.22.0", 16 | "@bytemd/plugin-math": "^1.22.0", 17 | "@bytemd/plugin-mermaid": "^1.22.0", 18 | "@bytemd/react": "^1.22.0", 19 | "antd": "^5.24.1", 20 | "bytemd": "^1.22.0", 21 | "file-saver": "^2.0.5", 22 | "github-markdown-css": "^5.8.1", 23 | "nanoid": "^5.1.2", 24 | "umi": "^4.4.5" 25 | }, 26 | "devDependencies": { 27 | "@types/react": "^18.0.33", 28 | "@types/react-dom": "^18.0.11", 29 | "typescript": "^5.0.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { saveAs } from 'file-saver'; 2 | // import html2pdf from 'html2pdf.js'; 3 | 4 | export const exportMarkdown = (content: string, filename: string = 'document') => { 5 | const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }); 6 | saveAs(blob, `${filename}.md`); 7 | }; 8 | 9 | // export const exportPDF = async (content: string, filename: string = 'document') => { 10 | // // 创建临时 DOM 元素来渲染 Markdown 11 | // const element = document.createElement('div'); 12 | // element.innerHTML = content; 13 | // element.style.padding = '20px'; 14 | // document.body.appendChild(element); 15 | // 16 | // const options = { 17 | // margin: 10, 18 | // filename: `${filename}.pdf`, 19 | // image: { type: 'jpeg', quality: 0.98 }, 20 | // html2canvas: { scale: 2 }, 21 | // jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' } 22 | // }; 23 | // 24 | // try { 25 | // await html2pdf().set(options).from(element).save(); 26 | // } finally { 27 | // document.body.removeChild(element); 28 | // } 29 | // }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 徐小夕 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/pages/toolbar.less: -------------------------------------------------------------------------------- 1 | .toolbar { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | padding: 1rem; 6 | background: white; 7 | border-bottom: 1px solid #e2e8f0; 8 | } 9 | 10 | .brand h1 { 11 | margin: 0; 12 | font-size: 1.5rem; 13 | color: #3b82f6; 14 | } 15 | 16 | .actions { 17 | display: flex; 18 | gap: 0.5rem; 19 | } 20 | 21 | .button { 22 | display: flex; 23 | align-items: center; 24 | gap: 0.5rem; 25 | padding: 0.5rem 1rem; 26 | border: 1px solid #e2e8f0; 27 | border-radius: 6px; 28 | background: white; 29 | color: #475569; 30 | cursor: pointer; 31 | transition: all 0.2s; 32 | a { 33 | text-decoration: none; 34 | color: #3b82f6; 35 | display: flex; 36 | align-items: center; 37 | svg { 38 | margin-right: 6px; 39 | } 40 | } 41 | } 42 | 43 | .button:hover { 44 | background: #f8fafc; 45 | } 46 | 47 | .button.primary { 48 | background: #3b82f6; 49 | border-color: #3b82f6; 50 | color: white; 51 | } 52 | 53 | .button.primary:hover { 54 | background: #2563eb; 55 | } 56 | 57 | .button:disabled { 58 | opacity: 0.5; 59 | cursor: not-allowed; 60 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ✨ md-editor - 一款开箱即用的md编辑器 2 | 3 | **让创作回归纯粹,同时拥有专业级力量** 4 | 一款专为开发者和文字工作者设计的开源Markdown编辑器,融合极简界面与强大功能。 5 | 6 | ![](./face.png) 7 | 8 | ### 🌐 跨平台同步 9 | - **云端存档**:自动保存至 GitHub Gist/Dropbox 10 | - **PWA 支持**:无需安装,浏览器即用 11 | 12 | ## 🛠️ 快速开始 13 | 14 | ### 安装方式 15 | ```bash 16 | # npm 17 | npm install 18 | 19 | # yarn 20 | yarn 21 | 22 | # pnpm 23 | pnpm install 24 | ``` 25 | 26 | 或直接下载:[最新发行版](https://github.com/MrXujiang/md-editor/archive/refs/heads/main.zip) 27 | 28 | ### 基础使用 29 | 1. 创建新文档 `Cmd/Ctrl + N` 30 | 2. 输入 `# 标题` 开始创作 31 | 3. 按 `Cmd/Ctrl + P` 切换预览模式 32 | 4. 支持一键导出为Markdown 33 | 34 | ## 🧑💻 开发者指南 35 | 36 | ### 技术栈 37 | - **核心框架**: React + Typescript 38 | - **编辑器引擎**: ByteMd 39 | - **样式系统**: Less + CSS Module 40 | 41 | ### 本地构建 42 | ```bash 43 | git clone git@github.com:MrXujiang/md-editor.git 44 | cd md-editor 45 | npm install 46 | npm run dev 47 | ``` 48 | 49 | ## 🤝 加入创新者行列 50 | 我们欢迎各种形式的贡献: 51 | - 🐛 [报告问题](https://github.com/MrXujiang/md-editor/issues) 52 | - 💡 [提交功能建议](https://github.com/MrXujiang/md-editor/discussions) 53 | - ✨ [发起PR](https://github.com/MrXujiang/md-editor/pulls) 54 | 55 | 56 | ## 📜 开源协议 57 | 本项目采用 [MIT License](LICENSE) 授权 58 | 59 | ### 更多优质项目 60 | 61 | | 项目名称 | 应用场景 | 62 | |-------------------------------------------------------|-------------| 63 | | [H5-Dooring](https://github.com/MrXujiang/h5-Dooring) | 可视化零代码搭建 | 64 | | [flowmix/docx](https://flowmix.turntip.cn) | 下一代多模态文档编辑器 | 65 | | [橙子轻文档](https://orange.turntip.cn/doc) | 文档项目管理平台 | 66 | -------------------------------------------------------------------------------- /src/pages/documentList.less: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 280px; 3 | background: white; 4 | border-right: 1px solid #e2e8f0; 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | 9 | .header { 10 | padding: 1.25rem 1rem; 11 | border-bottom: 1px solid #e2e8f0; 12 | background: #f8fafc; 13 | } 14 | 15 | .title { 16 | margin: 0; 17 | font-size: 1rem; 18 | font-weight: 600; 19 | color: #1e293b; 20 | } 21 | 22 | .count { 23 | font-size: 0.875rem; 24 | color: #64748b; 25 | margin-top: 0.25rem; 26 | display: block; 27 | } 28 | 29 | .list { 30 | flex: 1; 31 | overflow-y: auto; 32 | padding: 1rem; 33 | } 34 | 35 | .empty { 36 | height: 100%; 37 | display: flex; 38 | flex-direction: column; 39 | align-items: center; 40 | justify-content: center; 41 | color: #94a3b8; 42 | padding: 2rem; 43 | text-align: center; 44 | } 45 | 46 | .emptyIcon { 47 | color: #cbd5e1; 48 | margin-bottom: 1rem; 49 | } 50 | 51 | .emptyTip { 52 | font-size: 0.875rem; 53 | margin-top: 0.5rem; 54 | color: #64748b; 55 | } 56 | 57 | .item { 58 | display: flex; 59 | align-items: center; 60 | gap: 0.75rem; 61 | padding: 0.75rem; 62 | border: 1px solid #e2e8f0; 63 | border-radius: 8px; 64 | margin-bottom: 0.75rem; 65 | cursor: pointer; 66 | transition: all 0.2s ease; 67 | position: relative; 68 | } 69 | 70 | .item:hover { 71 | background: #f8fafc; 72 | border-color: #cbd5e1; 73 | transform: translateY(-1px); 74 | box-shadow: 0 2px 4px rgba(148, 163, 184, 0.1); 75 | } 76 | 77 | .item.active { 78 | background: #eff6ff; 79 | border-color: #3b82f6; 80 | } 81 | 82 | .itemIcon { 83 | color: #64748b; 84 | flex-shrink: 0; 85 | } 86 | 87 | .item.active .itemIcon { 88 | color: #3b82f6; 89 | } 90 | 91 | .itemContent { 92 | flex: 1; 93 | min-width: 0; 94 | } 95 | 96 | .itemTitle { 97 | font-weight: 500; 98 | color: #1e293b; 99 | margin-bottom: 0.25rem; 100 | white-space: nowrap; 101 | overflow: hidden; 102 | text-overflow: ellipsis; 103 | } 104 | 105 | .itemMeta { 106 | display: flex; 107 | align-items: center; 108 | gap: 0.75rem; 109 | font-size: 0.75rem; 110 | color: #64748b; 111 | } 112 | 113 | .itemDate { 114 | white-space: nowrap; 115 | } 116 | 117 | .itemWords { 118 | color: #94a3b8; 119 | } 120 | 121 | .deleteButton { 122 | width: 28px; 123 | height: 28px; 124 | padding: 0; 125 | display: flex; 126 | align-items: center; 127 | justify-content: center; 128 | background: #fee2e2; 129 | color: #ef4444; 130 | border: none; 131 | border-radius: 6px; 132 | cursor: pointer; 133 | opacity: 0; 134 | transition: all 0.2s ease; 135 | flex-shrink: 0; 136 | } 137 | 138 | .item:hover .deleteButton { 139 | opacity: 1; 140 | } 141 | 142 | .deleteButton:hover { 143 | background: #fecaca; 144 | transform: scale(1.05); 145 | } 146 | 147 | /* 滚动条样式 */ 148 | .list::-webkit-scrollbar { 149 | width: 6px; 150 | } 151 | 152 | .list::-webkit-scrollbar-track { 153 | background: transparent; 154 | } 155 | 156 | .list::-webkit-scrollbar-thumb { 157 | background: #e2e8f0; 158 | border-radius: 3px; 159 | } 160 | 161 | .list::-webkit-scrollbar-thumb:hover { 162 | background: #cbd5e1; 163 | } -------------------------------------------------------------------------------- /src/pages/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import styles from './toolbar.less'; 4 | interface ToolbarProps { 5 | onNew: () => void; 6 | onSave: () => void; 7 | onExportMd: () => void; 8 | disabled: boolean; 9 | } 10 | 11 | export const Toolbar: React.FC = ({ onNew, onSave, onExportMd, disabled }) => { 12 | return ( 13 |
14 |
15 |

Dooring-EduMD | 教育版

16 |
17 | 18 |
19 | 28 | 29 | 36 | 43 | 58 | 75 |
76 |
77 | ); 78 | }; -------------------------------------------------------------------------------- /src/pages/components/index.less: -------------------------------------------------------------------------------- 1 | .container { 2 | height: 100vh; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .content { 8 | flex: 1; 9 | display: flex; 10 | overflow: hidden; 11 | } 12 | 13 | .editor { 14 | flex: 1; 15 | overflow: hidden; 16 | background: white; 17 | border-radius: 8px; 18 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 19 | margin: 1rem; 20 | } 21 | 22 | .placeholder { 23 | height: 100%; 24 | display: flex; 25 | flex-direction: column; 26 | align-items: center; 27 | justify-content: center; 28 | color: #64748b; 29 | } 30 | 31 | .placeholder button { 32 | margin-top: 1rem; 33 | padding: 0.5rem 1rem; 34 | background: #3b82f6; 35 | color: white; 36 | border: none; 37 | border-radius: 6px; 38 | cursor: pointer; 39 | transition: background-color 0.2s; 40 | } 41 | 42 | .placeholder button:hover { 43 | background: #2563eb; 44 | } 45 | 46 | /* ByteMD 编辑器样式覆盖 */ 47 | :global(.bytemd) { 48 | border: none !important; 49 | } 50 | 51 | :global(.bytemd-toolbar) { 52 | border-bottom: 1px solid #e2e8f0 !important; 53 | background: #f8fafc !important; 54 | } 55 | 56 | :global(.bytemd-status) { 57 | border-top: 1px solid #e2e8f0 !important; 58 | } 59 | 60 | :global(.markdown-body) { 61 | padding: 1rem !important; 62 | } 63 | 64 | /* 无序列表样式 */ 65 | :global(.markdown-body ul) { 66 | padding-left: 1.5em !important; 67 | margin: 0.8em 0 !important; 68 | list-style-type: disc !important; 69 | display: block !important; 70 | } 71 | 72 | :global(.markdown-body ul ul) { 73 | list-style-type: circle !important; 74 | } 75 | 76 | :global(.markdown-body ul ul ul) { 77 | list-style-type: square !important; 78 | } 79 | 80 | :global(.markdown-body li) { 81 | position: relative !important; 82 | padding-left: 0.5em !important; 83 | margin: 0.5em 0 !important; 84 | line-height: 1.8 !important; 85 | width: 100%; 86 | } 87 | 88 | :global(.markdown-body li::marker) { 89 | color: #666 !important; 90 | } 91 | 92 | /* 编辑器中的列表样式 */ 93 | :global(.bytemd-editor .cm-list-1) { 94 | color: #666 !important; 95 | } 96 | 97 | :global(.bytemd-editor .cm-list-2) { 98 | color: #888 !important; 99 | } 100 | 101 | :global(.bytemd-editor .cm-list-3) { 102 | color: #aaa !important; 103 | } 104 | 105 | /* 确保列表项内容正确对齐 */ 106 | :global(.markdown-body li > p) { 107 | margin: 0.3em 0 !important; 108 | display: inline-block !important; 109 | } 110 | 111 | /* 列表项之间的间距 */ 112 | :global(.markdown-body li + li) { 113 | margin-top: 0.3em !important; 114 | } 115 | 116 | /* 嵌套列表的间距 */ 117 | :global(.markdown-body li > ul), 118 | :global(.markdown-body li > ol) { 119 | margin: 0.3em 0 0.3em 1.5em !important; 120 | } 121 | 122 | /* 预览区域中的列表样式 */ 123 | :global(.bytemd-preview ul) { 124 | margin-left: 0 !important; 125 | padding-left: 1.5em !important; 126 | } 127 | 128 | /* 编辑器工具栏按钮样式 */ 129 | :global(.bytemd-toolbar-icon) { 130 | padding: 4px !important; 131 | margin: 0 2px !important; 132 | border-radius: 4px !important; 133 | transition: all 0.2s !important; 134 | } 135 | 136 | :global(.bytemd-toolbar-icon:hover) { 137 | background-color: #f0f0f0 !important; 138 | } 139 | 140 | :global(.bytemd-toolbar-icon svg) { 141 | stroke: currentColor !important; 142 | fill: none !important; 143 | } 144 | 145 | /* 改进任务列表样式 */ 146 | :global(.markdown-body .task-list-item) { 147 | list-style-type: none !important; 148 | padding-left: 0 !important; 149 | margin: 0.5em 0 !important; 150 | display: flex !important; 151 | align-items: flex-start !important; 152 | } 153 | 154 | :global(.markdown-body .task-list-item input[type="checkbox"]) { 155 | margin: 0.25em 0.5em 0 0 !important; 156 | flex-shrink: 0 !important; 157 | } 158 | 159 | /* 预览区域内容样式 */ 160 | :global(.bytemd-preview) { 161 | padding: 1rem !important; 162 | font-size: 16px !important; 163 | line-height: 1.7 !important; 164 | } 165 | 166 | :global(.bytemd-preview .markdown-body) { 167 | max-width: 100% !important; 168 | margin: 0 auto !important; 169 | } 170 | 171 | /* 编辑器内容样式 */ 172 | :global(.bytemd-editor) { 173 | font-family: 'Menlo', 'Monaco', 'Courier New', monospace !important; 174 | line-height: 1.8 !important; 175 | } 176 | 177 | /* 图片样式优化 */ 178 | :global(.markdown-body img) { 179 | max-width: 100% !important; 180 | height: auto !important; 181 | margin: 1em 0 !important; 182 | border-radius: 4px !important; 183 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important; 184 | } 185 | 186 | /* 确保列表项之间有足够的空间 */ 187 | :global(.markdown-body li p) { 188 | margin-bottom: 0.5em !important; 189 | } 190 | 191 | :global(.markdown-body li:last-child p) { 192 | margin-bottom: 0 !important; 193 | } 194 | 195 | /* 嵌套列表的缩进和间距 */ 196 | :global(.markdown-body li > ul), 197 | :global(.markdown-body li > ol) { 198 | margin-top: 0.25em !important; 199 | margin-bottom: 0.25em !important; 200 | } 201 | 202 | :global(.bytemd) { 203 | height: calc(100vh - 120px) !important; 204 | } 205 | 206 | :global(.bytemd-tippy-right:last-child) { 207 | display: none; 208 | } -------------------------------------------------------------------------------- /src/pages/DocumentsList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import styles from './documentList.less'; 4 | 5 | export function formatDate(timestamp: number): string { 6 | const date = new Date(timestamp); 7 | const now = new Date(); 8 | const diff = now.getTime() - date.getTime(); 9 | 10 | // 小于1分钟 11 | if (diff < 60000) { 12 | return '刚刚'; 13 | } 14 | 15 | // 小于1小时 16 | if (diff < 3600000) { 17 | const minutes = Math.floor(diff / 60000); 18 | return `${minutes}分钟前`; 19 | } 20 | 21 | // 小于24小时 22 | if (diff < 86400000) { 23 | const hours = Math.floor(diff / 3600000); 24 | return `${hours}小时前`; 25 | } 26 | 27 | // 小于7天 28 | if (diff < 604800000) { 29 | const days = Math.floor(diff / 86400000); 30 | return `${days}天前`; 31 | } 32 | 33 | // 其他情况显示完整日期 34 | return date.toLocaleDateString('zh-CN', { 35 | year: 'numeric', 36 | month: 'long', 37 | day: 'numeric', 38 | hour: '2-digit', 39 | minute: '2-digit' 40 | }); 41 | } 42 | 43 | interface DocumentsListProps { 44 | documents: Document[]; 45 | currentDoc: Document | null; 46 | onSelect: (doc: Document) => void; 47 | onDelete: (docId: string) => void; 48 | } 49 | 50 | export const DocumentsList: React.FC = ({ 51 | documents, 52 | currentDoc, 53 | onSelect, 54 | onDelete 55 | }) => { 56 | return ( 57 |
58 |
59 |

我的文档

60 | {documents.length} 个文档 61 |
62 | 63 |
64 | {documents.length === 0 ? ( 65 |
66 | 67 | 73 | 74 | 75 |

暂无文档

76 |

点击顶部"新建文档"开始编辑

77 |
78 | ) : ( 79 | documents.map(doc => ( 80 |
onSelect(doc)} 86 | > 87 |
88 | 89 | 95 | 101 | 102 |
103 | 104 |
105 |
{doc.title}
106 |
107 | 108 | {formatDate(doc.updated)} 109 | 110 | 111 | {doc.content.length} 字 112 | 113 |
114 |
115 | 116 | 134 |
135 | )) 136 | )} 137 |
138 |
139 | ); 140 | }; -------------------------------------------------------------------------------- /src/pages/components/EditorContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Editor } from '@bytemd/react'; 3 | import gfm from '@bytemd/plugin-gfm'; 4 | import highlight from '@bytemd/plugin-highlight'; 5 | import math from '@bytemd/plugin-math'; 6 | import breaks from '@bytemd/plugin-breaks'; 7 | import frontmatter from '@bytemd/plugin-frontmatter'; 8 | import mermaid from '@bytemd/plugin-mermaid'; 9 | import { exportMarkdown } from '@/utils'; 10 | import zhHans from 'bytemd/locales/zh_Hans.json'; 11 | import 'github-markdown-css'; 12 | import 'highlight.js/styles/vs.css'; 13 | 14 | // 导入所需的样式 15 | import 'bytemd/dist/index.css'; 16 | 17 | import { Toolbar } from '../Toolbar'; 18 | import { DocumentsList } from '../DocumentsList'; 19 | import { Notification } from '../Notification'; 20 | import { useLocalStorage } from '../../hooks/useLocalStorage'; 21 | import styles from './index.less'; 22 | 23 | 24 | // 配置 ByteMD 插件 25 | const plugins = [ 26 | gfm({ 27 | // 配置 GFM 插件选项 28 | breaks: true, // 支持软换行 29 | bulletListMarker: '-', // 无序列表标记 30 | emoji: true, // 支持 emoji 31 | tasklist: true, // 支持任务列表 32 | strikethrough: true, // 支持删除线 33 | list: true 34 | }), 35 | highlight({ 36 | // 配置代码高亮 37 | theme: 'github' // 或其他主题 38 | }), 39 | math(), 40 | breaks(), 41 | frontmatter(), 42 | mermaid() 43 | ]; 44 | 45 | // 编辑器配置 46 | const editorConfig = { 47 | mode: 'split', // 分屏模式 48 | placeholder: '开始编写你的 Markdown 文档...', 49 | uploadImages: async (files: File[]) => { 50 | // 这里可以实现图片上传功能 51 | return files.map(file => ({ 52 | url: URL.createObjectURL(file), 53 | alt: file.name, 54 | title: file.name 55 | })); 56 | } 57 | }; 58 | 59 | export const EditorContainer: React.FC = () => { 60 | const [documents, setDocuments] = useLocalStorage('edumd-documents', []); 61 | const [currentDoc, setCurrentDoc] = useState(null); 62 | const [notification, setNotification] = useState<{message: string; type: 'success' | 'error'} | null>(null); 63 | 64 | useEffect(() => { 65 | const lastDocId = localStorage.getItem('lastDocId'); 66 | if (lastDocId) { 67 | const doc = documents.find(d => d.id === lastDocId); 68 | if (doc) setCurrentDoc(doc); 69 | } 70 | }, [documents]); 71 | 72 | const createNewDoc = () => { 73 | const newDoc: Document = { 74 | id: `doc_${Date.now()}`, 75 | title: '未命名文档', 76 | content: getDefaultContent(), 77 | created: Date.now(), 78 | updated: Date.now() 79 | }; 80 | 81 | setDocuments([newDoc, ...documents]); 82 | setCurrentDoc(newDoc); 83 | showNotification('新文档已创建', 'success'); 84 | }; 85 | 86 | const getDefaultContent = () => { 87 | return `# 未命名文档 88 | 89 | ## 使用指南 90 | 91 | 这是一个支持完整 Markdown 语法的编辑器,你可以: 92 | 93 | - 创建标题、段落和列表 94 | - 插入代码块和数学公式 95 | - 添加表格和图片 96 | - 使用任务列表 97 | - [x] 已完成的任务 98 | - [ ] 待完成的任务 99 | 100 | ### 示例列表 101 | 102 | 1. 有序列表项 1 103 | 2. 有序列表项 2 104 | - 无序子列表项 105 | - 另一个子列表项 106 | 3. 有序列表项 3 107 | 108 | ### 代码示例 109 | 110 | \`\`\`javascript 111 | function hello() { 112 | console.log('Hello, Markdown!'); 113 | } 114 | \`\`\` 115 | 116 | ### 表格示例 117 | 118 | | 功能 | 支持情况 | 119 | |------|----------| 120 | | 标题 | ✅ | 121 | | 列表 | ✅ | 122 | | 代码块 | ✅ | 123 | | 数学公式 | ✅ | 124 | 125 | `; 126 | }; 127 | 128 | const saveDoc = () => { 129 | if (!currentDoc) return; 130 | 131 | const updatedDoc = { 132 | ...currentDoc, 133 | updated: Date.now() 134 | }; 135 | 136 | setDocuments(documents.map(doc => 137 | doc.id === currentDoc.id ? updatedDoc : doc 138 | )); 139 | setCurrentDoc(updatedDoc); 140 | localStorage.setItem('lastDocId', updatedDoc.id); 141 | showNotification('文档已保存', 'success'); 142 | }; 143 | 144 | const deleteDoc = (docId: string) => { 145 | if (!window.confirm('确定要删除这个文档吗?')) return; 146 | 147 | setDocuments(documents.filter(doc => doc.id !== docId)); 148 | if (currentDoc?.id === docId) { 149 | setCurrentDoc(null); 150 | } 151 | showNotification('文档已删除', 'success'); 152 | }; 153 | 154 | const handleDocChange = (content: string) => { 155 | console.log(content); 156 | if (!currentDoc) return; 157 | 158 | const title = extractTitle(content) || '未命名文档'; 159 | 160 | setCurrentDoc({ 161 | ...currentDoc, 162 | title, 163 | content, 164 | updated: Date.now() 165 | }); 166 | 167 | // 自动保存 168 | const updatedDocs = documents.map(doc => 169 | doc.id === currentDoc.id ? { ...doc, title, content, updated: Date.now() } : doc 170 | ); 171 | localStorage.setItem('lastDocId', currentDoc.id); 172 | setDocuments(updatedDocs); 173 | }; 174 | 175 | const extractTitle = (content: string): string => { 176 | // 从内容中提取第一个标题作为文档标题 177 | const match = content.match(/^#\s+(.+)$/m); 178 | return match ? match[1].trim() : ''; 179 | }; 180 | 181 | const showNotification = (message: string, type: 'success' | 'error') => { 182 | setNotification({ message, type }); 183 | setTimeout(() => setNotification(null), 3000); 184 | }; 185 | 186 | const handleExportMd = () => { 187 | console.log(currentDoc) 188 | exportMarkdown(currentDoc?.content, currentDoc?.title) 189 | } 190 | 191 | return ( 192 |
193 | 199 | 200 |
201 | 207 | 208 |
209 | {currentDoc ? ( 210 | 217 | ) : ( 218 |
219 |

选择一个文档或创建新文档开始编辑

220 | 221 |
222 | )} 223 |
224 |
225 | 226 | {notification && ( 227 | 231 | )} 232 |
233 | ); 234 | }; --------------------------------------------------------------------------------