├── .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 | 
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 |
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 |
75 |
暂无文档
76 |
点击顶部"新建文档"开始编辑
77 |
78 | ) : (
79 | documents.map(doc => (
80 |
onSelect(doc)}
86 | >
87 |
88 |
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 | };
--------------------------------------------------------------------------------