├── .editorconfig
├── netlify.toml
├── favicon.svg
├── vercel.json
├── .gitignore
├── package.json
├── .github
└── workflows
│ └── deploy.yml
├── LICENSE
├── start.sh
├── logo.svg
├── logo-zh.svg
├── logo-dark.svg
├── logo-en.svg
├── logo-banner.svg
├── logo-banner-zh.svg
├── logo-banner-en.svg
├── README.zh-CN.md
├── README.md
├── jj
└── index.html
├── index.html
├── styles.css
└── app.js
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig 配置文件
2 | # https://editorconfig.org
3 |
4 | root = true
5 |
6 | [*]
7 | charset = utf-8
8 | end_of_line = lf
9 | insert_final_newline = true
10 | trim_trailing_whitespace = true
11 |
12 | [*.{js,json,css,html}]
13 | indent_style = space
14 | indent_size = 2
15 |
16 | [*.md]
17 | trim_trailing_whitespace = false
18 |
19 | [*.{yml,yaml}]
20 | indent_style = space
21 | indent_size = 2
22 |
23 |
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | # Netlify 配置文件
2 |
3 | [build]
4 | publish = "."
5 | command = "echo 'No build command needed for static site'"
6 |
7 | [[redirects]]
8 | from = "/*"
9 | to = "/index.html"
10 | status = 200
11 |
12 | [[headers]]
13 | for = "/*"
14 | [headers.values]
15 | X-Frame-Options = "DENY"
16 | X-Content-Type-Options = "nosniff"
17 | X-XSS-Protection = "1; mode=block"
18 | Referrer-Policy = "no-referrer-when-downgrade"
19 |
20 |
--------------------------------------------------------------------------------
/favicon.svg:
--------------------------------------------------------------------------------
1 |
12 |
13 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 2,
3 | "name": "markx",
4 | "builds": [
5 | {
6 | "src": "index.html",
7 | "use": "@vercel/static"
8 | }
9 | ],
10 | "routes": [
11 | {
12 | "src": "/(.*)",
13 | "dest": "/$1"
14 | }
15 | ],
16 | "headers": [
17 | {
18 | "source": "/(.*)",
19 | "headers": [
20 | {
21 | "key": "X-Content-Type-Options",
22 | "value": "nosniff"
23 | },
24 | {
25 | "key": "X-Frame-Options",
26 | "value": "DENY"
27 | },
28 | {
29 | "key": "X-XSS-Protection",
30 | "value": "1; mode=block"
31 | }
32 | ]
33 | }
34 | ]
35 | }
36 |
37 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # 操作系统文件
2 | .DS_Store
3 | Thumbs.db
4 | desktop.ini
5 |
6 | # 编辑器和 IDE
7 | .vscode/
8 | .idea/
9 | *.swp
10 | *.swo
11 | *~
12 | .project
13 | .classpath
14 | .settings/
15 |
16 | # 日志文件
17 | *.log
18 | npm-debug.log*
19 | yarn-debug.log*
20 | yarn-error.log*
21 |
22 | # 依赖目录(如果将来使用 npm)
23 | node_modules/
24 | bower_components/
25 |
26 | # 构建输出(如果将来添加构建步骤)
27 | dist/
28 | build/
29 | out/
30 |
31 | # 临时文件
32 | *.tmp
33 | *.temp
34 | .cache/
35 |
36 | # 测试覆盖率
37 | coverage/
38 | .nyc_output/
39 |
40 | # 环境变量
41 | .env
42 | .env.local
43 | .env.*.local
44 |
45 | # 打包文件
46 | *.zip
47 | *.tar.gz
48 | *.rar
49 |
50 | # 备份文件
51 | *.bak
52 | *.backup
53 | *~
54 |
55 | # 草稿和笔记(可选)
56 | drafts/
57 | notes/
58 | TODO.txt
59 |
60 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "markx",
3 | "version": "1.0.0",
4 | "description": "专业的 Markdown + Mermaid 编辑器",
5 | "keywords": [
6 | "markdown",
7 | "mermaid",
8 | "editor",
9 | "wysiwyg",
10 | "gfm"
11 | ],
12 | "author": "MarkX Contributors",
13 | "license": "MIT",
14 | "homepage": "https://github.com/yourusername/markx#readme",
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/yourusername/markx.git"
18 | },
19 | "bugs": {
20 | "url": "https://github.com/yourusername/markx/issues"
21 | },
22 | "scripts": {
23 | "start": "python3 -m http.server 8000 || python -m http.server 8000 || php -S localhost:8000 || npx http-server -p 8000",
24 | "dev": "npm start"
25 | },
26 | "devDependencies": {},
27 | "dependencies": {}
28 | }
29 |
30 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to GitHub Pages
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | workflow_dispatch:
7 |
8 | permissions:
9 | contents: read
10 | pages: write
11 | id-token: write
12 |
13 | concurrency:
14 | group: "pages"
15 | cancel-in-progress: true
16 |
17 | jobs:
18 | deploy:
19 | environment:
20 | name: github-pages
21 | url: ${{ steps.deployment.outputs.page_url }}
22 | runs-on: ubuntu-latest
23 | steps:
24 | - name: Checkout
25 | uses: actions/checkout@v4
26 |
27 | - name: Setup Pages
28 | uses: actions/configure-pages@v4
29 |
30 | - name: Upload artifact
31 | uses: actions/upload-pages-artifact@v3
32 | with:
33 | path: '.'
34 |
35 | - name: Deploy to GitHub Pages
36 | id: deployment
37 | uses: actions/deploy-pages@v4
38 |
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 MarkX Contributors
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 |
23 |
--------------------------------------------------------------------------------
/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # MarkX 启动脚本
4 | # 自动检测并启动本地服务器
5 |
6 | echo "🚀 MarkX 启动脚本"
7 | echo "=================="
8 | echo ""
9 |
10 | # 检测 Python 3
11 | if command -v python3 &> /dev/null; then
12 | echo "✅ 检测到 Python 3"
13 | echo "📡 启动服务器: http://localhost:8000"
14 | echo ""
15 | echo "按 Ctrl+C 停止服务器"
16 | echo ""
17 | python3 -m http.server 8000
18 | exit 0
19 | fi
20 |
21 | # 检测 Python 2
22 | if command -v python &> /dev/null; then
23 | echo "✅ 检测到 Python 2"
24 | echo "📡 启动服务器: http://localhost:8000"
25 | echo ""
26 | echo "按 Ctrl+C 停止服务器"
27 | echo ""
28 | python -m SimpleHTTPServer 8000
29 | exit 0
30 | fi
31 |
32 | # 检测 Node.js
33 | if command -v node &> /dev/null; then
34 | echo "✅ 检测到 Node.js"
35 | echo "📡 启动服务器: http://localhost:8000"
36 | echo ""
37 | echo "按 Ctrl+C 停止服务器"
38 | echo ""
39 | npx http-server -p 8000
40 | exit 0
41 | fi
42 |
43 | # 检测 PHP
44 | if command -v php &> /dev/null; then
45 | echo "✅ 检测到 PHP"
46 | echo "📡 启动服务器: http://localhost:8000"
47 | echo ""
48 | echo "按 Ctrl+C 停止服务器"
49 | echo ""
50 | php -S localhost:8000
51 | exit 0
52 | fi
53 |
54 | # 没有找到任何服务器
55 | echo "❌ 未检测到可用的服务器"
56 | echo ""
57 | echo "请安装以下任一工具:"
58 | echo " - Python 3: https://www.python.org/downloads/"
59 | echo " - Node.js: https://nodejs.org/"
60 | echo " - PHP: https://www.php.net/downloads"
61 | echo ""
62 | exit 1
63 |
64 |
--------------------------------------------------------------------------------
/logo.svg:
--------------------------------------------------------------------------------
1 |
35 |
36 |
--------------------------------------------------------------------------------
/logo-zh.svg:
--------------------------------------------------------------------------------
1 |
35 |
36 |
--------------------------------------------------------------------------------
/logo-dark.svg:
--------------------------------------------------------------------------------
1 |
35 |
36 |
--------------------------------------------------------------------------------
/logo-en.svg:
--------------------------------------------------------------------------------
1 |
35 |
36 |
--------------------------------------------------------------------------------
/logo-banner.svg:
--------------------------------------------------------------------------------
1 |
50 |
51 |
--------------------------------------------------------------------------------
/logo-banner-zh.svg:
--------------------------------------------------------------------------------
1 |
50 |
51 |
--------------------------------------------------------------------------------
/logo-banner-en.svg:
--------------------------------------------------------------------------------
1 |
50 |
51 |
--------------------------------------------------------------------------------
/README.zh-CN.md:
--------------------------------------------------------------------------------
1 |
2 |
专业的 Markdown + Mermaid + KaTeX 编辑器
3 |
现代化 · 开箱即用 · 功能强大
4 |
5 |
6 |

7 |
8 |
9 | [English](README.md) | **中文**
10 |
11 | [在线演示](https://steamedbread2333.github.io/MarkX)
12 |
13 | ---
14 |
15 | ## ✨ 核心特性
16 |
17 |
18 |
19 | |
20 |
21 | ### 📊 Mermaid 图表支持
22 |
23 | 完美集成 Mermaid.js,支持多种专业图表:
24 |
25 | - ✅ **流程图** - 可视化业务流程
26 | - ✅ **时序图** - 展示交互时序
27 | - ✅ **甘特图** - 项目进度管理
28 | - ✅ **类图** - UML 类关系图
29 | - ✅ **状态图** - 状态机可视化
30 | - ✅ **一键导出** - 支持 SVG/PNG 格式
31 |
32 | |
33 |
34 |
35 | ### 🧮 KaTeX 数学公式
36 |
37 | 强大的数学公式渲染引擎:
38 |
39 | - ✅ **行内公式** - `$E=mc^2$`
40 | - ✅ **块级公式** - `$$\int_0^\infty$$`
41 | - ✅ **丰富符号** - 积分、求和、矩阵等
42 | - ✅ **实时渲染** - 输入即显示
43 | - ✅ **LaTeX 语法** - 标准数学排版
44 | - ✅ **快速插入** - 内置常用模板
45 |
46 | |
47 |
48 |
49 |
50 | ### 🎯 更多功能
51 |
52 | - 📝 **GFM 支持** - 完整的 GitHub Flavored Markdown
53 | - 🎨 **主题切换** - 亮色/暗色护眼模式
54 | - 💾 **自动保存** - 每 30 秒自动保存草稿
55 | - 📊 **实时统计** - 字数、行数、阅读时间
56 | - 🔒 **安全防护** - DOMPurify XSS 防护
57 | - 📱 **响应式** - 完美适配桌面和移动端
58 |
59 | ## 🚀 快速开始
60 |
61 | ### 方法一:直接使用(推荐)
62 |
63 | 1. **克隆仓库**
64 | ```bash
65 | git clone https://github.com/yourusername/markx.git
66 | cd markx
67 | ```
68 |
69 | 2. **启动本地服务器**
70 |
71 | 由于使用了 ES 模块和 Import Maps,需要通过 HTTP 服务器访问:
72 |
73 | ```bash
74 | # 使用 Python (推荐)
75 | python3 -m http.server 8000
76 |
77 | # 或使用 Node.js http-server
78 | npx http-server -p 8000
79 |
80 | # 或使用 PHP
81 | php -S localhost:8000
82 | ```
83 |
84 | 3. **打开浏览器**
85 |
86 | 访问 `http://localhost:8000` 即可使用!
87 |
88 | ### 方法二:在线部署
89 |
90 | #### 部署到 GitHub Pages
91 |
92 | 1. Fork 本仓库
93 | 2. 进入仓库设置 → Pages
94 | 3. Source 选择 `main` 分支
95 | 4. 保存后等待几分钟即可访问
96 |
97 | #### 部署到 Vercel
98 |
99 | [](https://vercel.com/new/clone?repository-url=https://github.com/yourusername/markx)
100 |
101 | 1. 点击上方按钮
102 | 2. 登录 Vercel 账号
103 | 3. 一键部署完成
104 |
105 | #### 部署到 Netlify
106 |
107 | [](https://app.netlify.com/start/deploy?repository=https://github.com/yourusername/markx)
108 |
109 | 1. 点击上方按钮
110 | 2. 登录 Netlify 账号
111 | 3. 自动部署完成
112 |
113 | ---
114 |
115 | ## 📖 使用指南
116 |
117 | ### 基础操作
118 |
119 | #### 编辑 Markdown
120 | 在左侧编辑器输入 Markdown 内容,右侧实时预览:
121 |
122 | ```markdown
123 | # 一级标题
124 | ## 二级标题
125 |
126 | **加粗文本** *斜体文本* ~~删除线~~
127 |
128 | - 无序列表项 1
129 | - 无序列表项 2
130 |
131 | 1. 有序列表项 1
132 | 2. 有序列表项 2
133 |
134 | [链接文本](https://example.com)
135 | 
136 | ```
137 |
138 | #### 📊 插入 Mermaid 图表
139 |
140 | **方法一:使用工具栏**
141 | 1. 点击工具栏的「图表」按钮
142 | 2. 选择需要的图表类型(流程图/时序图/甘特图/类图/状态图)
143 | 3. 自动插入模板,修改内容即可
144 |
145 | **方法二:手动输入**
146 |
147 | ````markdown
148 | ```mermaid
149 | graph TD
150 | A[开始] --> B{判断}
151 | B -->|是| C[结果1]
152 | B -->|否| D[结果2]
153 | ```
154 | ````
155 |
156 | **💡 提示:** 每个 Mermaid 图表都支持导出为 SVG 或 PNG 格式!
157 |
158 | ---
159 |
160 | #### 🧮 插入数学公式
161 |
162 | **方法一:使用工具栏**
163 | 1. 点击工具栏的「公式」按钮
164 | 2. 选择公式类型(行内/块级/分数/根号/求和/积分/极限/矩阵)
165 | 3. 自动插入模板,修改内容即可
166 |
167 | **方法二:手动输入**
168 |
169 | **行内公式**(使用单个 `$` 包裹):
170 | ```markdown
171 | 质能方程:$E = mc^2$,勾股定理:$a^2 + b^2 = c^2$
172 | ```
173 |
174 | **块级公式**(使用双 `$$` 包裹,独立成行):
175 | ```markdown
176 | 二次方程求根公式:
177 |
178 | $$
179 | x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}
180 | $$
181 |
182 | 矩阵示例:
183 |
184 | $$
185 | \begin{bmatrix}
186 | a & b \\
187 | c & d
188 | \end{bmatrix}
189 | $$
190 | ```
191 |
192 | **💡 重要提示**:
193 | - 块级公式的 `$$` 符号必须单独占一行
194 | - 公式内容可以跨越多行
195 | - 前后要有空行以确保正确渲染
196 |
197 | **常用示例**:
198 | ```markdown
199 | - 分数:$\frac{a}{b}$
200 | - 根号:$\sqrt{x}$ 或 $\sqrt[3]{x}$
201 | - 求和:$\sum_{i=1}^{n} i$
202 | - 积分:$\int_{0}^{\infty} e^{-x}dx$
203 | - 极限:$\lim_{x \to \infty} \frac{1}{x} = 0$
204 | ```
205 | ---
206 |
207 | #### 快捷键
208 |
209 | | 快捷键 | 功能 |
210 | |--------|------|
211 | | `Ctrl + S` | 保存文件 |
212 | | `Ctrl + O` | 打开文件 |
213 | | `Ctrl + N` | 新建文档 |
214 | | `Ctrl + B` | 加粗 |
215 | | `Ctrl + I` | 斜体 |
216 | | `Ctrl + K` | 插入链接 |
217 |
218 | ### 高级功能
219 |
220 | #### 表格
221 | ```markdown
222 | | 列1 | 列2 | 列3 |
223 | | --- | --- | --- |
224 | | 单元格1 | 单元格2 | 单元格3 |
225 | | 内容A | 内容B | 内容C |
226 | ```
227 |
228 | #### 任务列表
229 | ```markdown
230 | - [x] 已完成任务
231 | - [ ] 待完成任务
232 | - [ ] 另一个任务
233 | ```
234 |
235 | #### 代码块
236 | ````markdown
237 | ```javascript
238 | function hello() {
239 | console.log('Hello, MarkX!');
240 | }
241 | ```
242 | ````
243 |
244 | ---
245 |
246 | ## 🎨 Mermaid 图表示例
247 |
248 | ### 流程图
249 | ````markdown
250 | ```mermaid
251 | graph LR
252 | A[方形] --> B(圆角)
253 | B --> C{菱形}
254 | C -->|选项1| D[结果1]
255 | C -->|选项2| E[结果2]
256 | ```
257 | ````
258 |
259 | ### 时序图
260 | ````markdown
261 | ```mermaid
262 | sequenceDiagram
263 | Alice->>John: 你好,John!
264 | John-->>Alice: 你好,Alice!
265 | Alice-)John: 再见!
266 | ```
267 | ````
268 |
269 | ### 甘特图
270 | ````markdown
271 | ```mermaid
272 | gantt
273 | title 项目进度
274 | dateFormat YYYY-MM-DD
275 | section 设计
276 | 需求分析 :a1, 2024-01-01, 7d
277 | 原型设计 :after a1, 5d
278 | section 开发
279 | 前端开发 :2024-01-15, 10d
280 | 后端开发 :2024-01-15, 12d
281 | ```
282 | ````
283 |
284 | ### 类图
285 | ````markdown
286 | ```mermaid
287 | classDiagram
288 | Animal <|-- Duck
289 | Animal <|-- Fish
290 | Animal : +int age
291 | Animal : +String gender
292 | Animal: +isMammal()
293 | class Duck{
294 | +String beakColor
295 | +swim()
296 | +quack()
297 | }
298 | class Fish{
299 | -int sizeInFeet
300 | -canEat()
301 | }
302 | ```
303 | ````
304 |
305 | ---
306 |
307 | ## 🛠️ 技术栈
308 |
309 | ### 核心库
310 | - **[Marked.js](https://marked.js.org/)** `v11.1.1` - Markdown 解析
311 | - **[Mermaid.js](https://mermaid.js.org/)** `v10.6.1` - 图表渲染
312 | - **[DOMPurify](https://github.com/cure53/DOMPurify)** `v3.0.8` - XSS 防护
313 | - **[Highlight.js](https://highlightjs.org/)** `v11.9.0` - 代码高亮
314 |
315 | ### 架构特点
316 | - ✅ **零构建** - 无需 Webpack/Vite,直接运行
317 | - ✅ **ES Modules** - 原生 JavaScript 模块
318 | - ✅ **Import Maps** - CDN 依赖管理
319 | - ✅ **纯静态** - 可部署到任何静态托管平台
320 |
321 | ### 浏览器兼容性
322 | - ✅ Chrome 90+
323 | - ✅ Firefox 88+
324 | - ✅ Safari 14+
325 | - ✅ Edge 90+
326 | - ✅ 移动端浏览器(iOS Safari 14+, Chrome Mobile)
327 |
328 | ---
329 |
330 | ## 📂 项目结构
331 |
332 | ```
333 | markx/
334 | ├── index.html # 主页面(HTML 结构)
335 | ├── styles.css # 样式文件(CSS + 主题)
336 | ├── app.js # 应用逻辑(JavaScript)
337 | ├── README.md # 项目文档(英文版)
338 | ├── README.zh-CN.md # 项目文档(中文版,本文件)
339 | ├── LICENSE # MIT 许可证
340 | ├── .gitignore # Git 忽略文件
341 | └── screenshots/ # 截图目录
342 | ├── light-mode.png
343 | ├── dark-mode.png
344 | └── mobile.png
345 | ```
346 |
347 | ---
348 |
349 |
350 |
351 |

352 |
353 |
354 |
355 | **如果觉得 MarkX 有帮助,请给个 ⭐️ Star 支持一下!**
356 |
357 |
358 |
359 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
Professional Markdown + Mermaid + KaTeX Editor
3 |
Modern · Ready-to-use · Powerful
4 |
5 |
6 |

7 |
8 |
9 | **English** | [中文](README.zh-CN.md)
10 |
11 | [Live Demo](https://steamedbread2333.github.io/MarkX)
12 |
13 | ---
14 |
15 | ## ✨ Core Features
16 |
17 |
18 |
19 | |
20 |
21 | ### 📊 Mermaid Diagram Support
22 |
23 | Perfect integration with Mermaid.js, supporting various professional diagrams:
24 |
25 | - ✅ **Flowcharts** - Visualize business processes
26 | - ✅ **Sequence Diagrams** - Show interaction sequences
27 | - ✅ **Gantt Charts** - Project progress management
28 | - ✅ **Class Diagrams** - UML class relationship diagrams
29 | - ✅ **State Diagrams** - State machine visualization
30 | - ✅ **One-click Export** - Support SVG/PNG formats
31 |
32 | |
33 |
34 |
35 | ### 🧮 KaTeX Math Formulas
36 |
37 | Powerful math formula rendering engine:
38 |
39 | - ✅ **Inline Formulas** - `$E=mc^2$`
40 | - ✅ **Block Formulas** - `$$\int_0^\infty$$`
41 | - ✅ **Rich Symbols** - Integrals, summations, matrices, etc.
42 | - ✅ **Real-time Rendering** - Display as you type
43 | - ✅ **LaTeX Syntax** - Standard mathematical typesetting
44 | - ✅ **Quick Insert** - Built-in common templates
45 |
46 | |
47 |
48 |
49 |
50 | ### 🎯 More Features
51 |
52 | - 📝 **GFM Support** - Complete GitHub Flavored Markdown
53 | - 🎨 **Theme Toggle** - Light/Dark eye-friendly modes
54 | - 💾 **Auto Save** - Auto-save drafts every 30 seconds
55 | - 📊 **Real-time Stats** - Word count, line count, reading time
56 | - 🔒 **Security** - DOMPurify XSS protection
57 | - 📱 **Responsive** - Perfect adaptation for desktop and mobile
58 |
59 | ## 🚀 Quick Start
60 |
61 | ### Method 1: Direct Use (Recommended)
62 |
63 | 1. **Clone the repository**
64 | ```bash
65 | git clone https://github.com/yourusername/markx.git
66 | cd markx
67 | ```
68 |
69 | 2. **Start a local server**
70 |
71 | Since ES modules and Import Maps are used, access via HTTP server is required:
72 |
73 | ```bash
74 | # Using Python (Recommended)
75 | python3 -m http.server 8000
76 |
77 | # Or using Node.js http-server
78 | npx http-server -p 8000
79 |
80 | # Or using PHP
81 | php -S localhost:8000
82 | ```
83 |
84 | 3. **Open your browser**
85 |
86 | Visit `http://localhost:8000` to start using!
87 |
88 | ### Method 2: Online Deployment
89 |
90 | #### Deploy to GitHub Pages
91 |
92 | 1. Fork this repository
93 | 2. Go to repository settings → Pages
94 | 3. Select `main` branch as source
95 | 4. Wait a few minutes and access
96 |
97 | #### Deploy to Vercel
98 |
99 | [](https://vercel.com/new/clone?repository-url=https://github.com/yourusername/markx)
100 |
101 | 1. Click the button above
102 | 2. Log in to your Vercel account
103 | 3. One-click deployment complete
104 |
105 | #### Deploy to Netlify
106 |
107 | [](https://app.netlify.com/start/deploy?repository=https://github.com/yourusername/markx)
108 |
109 | 1. Click the button above
110 | 2. Log in to your Netlify account
111 | 3. Automatic deployment complete
112 |
113 | ---
114 |
115 | ## 📖 User Guide
116 |
117 | ### Basic Operations
118 |
119 | #### Edit Markdown
120 | Type Markdown content in the left editor, real-time preview on the right:
121 |
122 | ```markdown
123 | # Heading 1
124 | ## Heading 2
125 |
126 | **Bold text** *Italic text* ~~Strikethrough~~
127 |
128 | - Unordered list item 1
129 | - Unordered list item 2
130 |
131 | 1. Ordered list item 1
132 | 2. Ordered list item 2
133 |
134 | [Link text](https://example.com)
135 | 
136 | ```
137 |
138 | #### 📊 Insert Mermaid Diagrams
139 |
140 | **Method 1: Using Toolbar**
141 | 1. Click the "Diagram" button in the toolbar
142 | 2. Select the diagram type needed (Flowchart/Sequence/Gantt/Class/State)
143 | 3. Template is automatically inserted, modify content as needed
144 |
145 | **Method 2: Manual Input**
146 |
147 | ````markdown
148 | ```mermaid
149 | graph TD
150 | A[Start] --> B{Decision}
151 | B -->|Yes| C[Result 1]
152 | B -->|No| D[Result 2]
153 | ```
154 | ````
155 |
156 | **💡 Tip:** Each Mermaid diagram supports export as SVG or PNG format!
157 |
158 | ---
159 |
160 | #### 🧮 Insert Math Formulas
161 |
162 | **Method 1: Using Toolbar**
163 | 1. Click the "Formula" button in the toolbar
164 | 2. Select formula type (Inline/Block/Fraction/Root/Sum/Integral/Limit/Matrix)
165 | 3. Template is automatically inserted, modify content as needed
166 |
167 | **Method 2: Manual Input**
168 |
169 | **Inline Formula** (wrap with single `$`):
170 | ```markdown
171 | Mass-energy equation: $E = mc^2$, Pythagorean theorem: $a^2 + b^2 = c^2$
172 | ```
173 |
174 | **Block Formula** (wrap with double `$$`, standalone line):
175 | ```markdown
176 | Quadratic formula:
177 |
178 | $$
179 | x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}
180 | $$
181 |
182 | Matrix example:
183 |
184 | $$
185 | \begin{bmatrix}
186 | a & b \\
187 | c & d
188 | \end{bmatrix}
189 | $$
190 | ```
191 |
192 | **💡 Important Notes**:
193 | - Block formula `$$` symbols must be on separate lines
194 | - Formula content can span multiple lines
195 | - Ensure blank lines before and after for correct rendering
196 |
197 | **Common Examples**:
198 | ```markdown
199 | - Fraction: $\frac{a}{b}$
200 | - Root: $\sqrt{x}$ or $\sqrt[3]{x}$
201 | - Sum: $\sum_{i=1}^{n} i$
202 | - Integral: $\int_{0}^{\infty} e^{-x}dx$
203 | - Limit: $\lim_{x \to \infty} \frac{1}{x} = 0$
204 | ```
205 |
206 | ---
207 |
208 | #### Keyboard Shortcuts
209 |
210 | | Shortcut | Function |
211 | |----------|----------|
212 | | `Ctrl + S` | Save file |
213 | | `Ctrl + O` | Open file |
214 | | `Ctrl + N` | New document |
215 | | `Ctrl + B` | Bold |
216 | | `Ctrl + I` | Italic |
217 | | `Ctrl + K` | Insert link |
218 |
219 | ### Advanced Features
220 |
221 | #### Tables
222 | ```markdown
223 | | Column 1 | Column 2 | Column 3 |
224 | | --- | --- | --- |
225 | | Cell 1 | Cell 2 | Cell 3 |
226 | | Content A | Content B | Content C |
227 | ```
228 |
229 | #### Task Lists
230 | ```markdown
231 | - [x] Completed task
232 | - [ ] Pending task
233 | - [ ] Another task
234 | ```
235 |
236 | #### Code Blocks
237 | ````markdown
238 | ```javascript
239 | function hello() {
240 | console.log('Hello, MarkX!');
241 | }
242 | ```
243 | ````
244 |
245 | ---
246 |
247 | ## 🎨 Mermaid Diagram Examples
248 |
249 | ### Flowchart
250 | ````markdown
251 | ```mermaid
252 | graph LR
253 | A[Square] --> B(Rounded)
254 | B --> C{Diamond}
255 | C -->|Option 1| D[Result 1]
256 | C -->|Option 2| E[Result 2]
257 | ```
258 | ````
259 |
260 | ### Sequence Diagram
261 | ````markdown
262 | ```mermaid
263 | sequenceDiagram
264 | Alice->>John: Hello John!
265 | John-->>Alice: Hello Alice!
266 | Alice-)John: Goodbye!
267 | ```
268 | ````
269 |
270 | ### Gantt Chart
271 | ````markdown
272 | ```mermaid
273 | gantt
274 | title Project Progress
275 | dateFormat YYYY-MM-DD
276 | section Design
277 | Requirements Analysis :a1, 2024-01-01, 7d
278 | Prototype Design :after a1, 5d
279 | section Development
280 | Frontend Development :2024-01-15, 10d
281 | Backend Development :2024-01-15, 12d
282 | ```
283 | ````
284 |
285 | ### Class Diagram
286 | ````markdown
287 | ```mermaid
288 | classDiagram
289 | Animal <|-- Duck
290 | Animal <|-- Fish
291 | Animal : +int age
292 | Animal : +String gender
293 | Animal: +isMammal()
294 | class Duck{
295 | +String beakColor
296 | +swim()
297 | +quack()
298 | }
299 | class Fish{
300 | -int sizeInFeet
301 | -canEat()
302 | }
303 | ```
304 | ````
305 |
306 | ---
307 |
308 | ## 🛠️ Tech Stack
309 |
310 | ### Core Libraries
311 | - **[Marked.js](https://marked.js.org/)** `v11.1.1` - Markdown parsing
312 | - **[Mermaid.js](https://mermaid.js.org/)** `v10.6.1` - Diagram rendering
313 | - **[DOMPurify](https://github.com/cure53/DOMPurify)** `v3.0.8` - XSS protection
314 | - **[Highlight.js](https://highlightjs.org/)** `v11.9.0` - Code highlighting
315 |
316 | ### Architecture Features
317 | - ✅ **Zero Build** - No Webpack/Vite needed, runs directly
318 | - ✅ **ES Modules** - Native JavaScript modules
319 | - ✅ **Import Maps** - CDN dependency management
320 | - ✅ **Pure Static** - Deployable to any static hosting platform
321 |
322 | ### Browser Compatibility
323 | - ✅ Chrome 90+
324 | - ✅ Firefox 88+
325 | - ✅ Safari 14+
326 | - ✅ Edge 90+
327 | - ✅ Mobile browsers (iOS Safari 14+, Chrome Mobile)
328 |
329 | ---
330 |
331 | ## 📂 Project Structure
332 |
333 | ```
334 | markx/
335 | ├── index.html # Main page (HTML structure)
336 | ├── styles.css # Stylesheet (CSS + themes)
337 | ├── app.js # Application logic (JavaScript)
338 | ├── README.md # Project documentation (this file)
339 | ├── README.zh-CN.md # Chinese documentation
340 | ├── LICENSE # MIT License
341 | ├── .gitignore # Git ignore file
342 | └── screenshots/ # Screenshots directory
343 | ├── light-mode.png
344 | ├── dark-mode.png
345 | └── mobile.png
346 | ```
347 |
348 | ---
349 |
350 |
351 |
352 |

353 |
354 |
355 |
356 | **If MarkX is helpful, please give it a ⭐️ Star!**
357 |
358 |
359 |
--------------------------------------------------------------------------------
/jj/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | MarkX - 专业 Markdown 编辑器
7 |
368 |
369 |
370 |
371 |
372 |
375 |
376 |
377 |
378 |
Welcome to MarkX
379 |
380 | 专业的 Markdown 编辑器,支持 Mermaid 图表和 KaTeX 数学公式
381 |
382 | https://steamedbread2333.github.io/MarkX/
383 |
384 |
388 |
389 | 链接已复制成功!
390 |
391 |
433 |
434 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | MarkX - 专业 Markdown + Mermaid + KaTeX 编辑器
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
60 |
61 |
62 |
63 |
200 |
201 |
202 |
203 |
206 |
207 |
208 |
209 |
317 |
318 |
319 |
320 |
321 |
382 |
383 |
384 |
387 |
388 |
389 |
390 |
404 |
405 |
406 |
407 |
408 |
409 |
410 |
411 |
412 |
413 |
--------------------------------------------------------------------------------
/styles.css:
--------------------------------------------------------------------------------
1 | /* ==================== 全局变量与重置 ==================== */
2 |
3 | :root {
4 | /* 亮色主题颜色 */
5 | --bg-primary: #ffffff;
6 | --bg-secondary: #f6f8fa;
7 | --bg-tertiary: #f0f2f5;
8 | --bg-hover: #e8eaed;
9 | --text-primary: #24292f;
10 | --text-secondary: #57606a;
11 | --text-tertiary: #8b949e;
12 | --border-color: #d0d7de;
13 | --border-hover: #a8b1ba;
14 | --accent-color: #0969da;
15 | --accent-hover: #0550ae;
16 | --accent-color-alpha: rgba(9, 105, 218, 0.2);
17 | --selection-bg: rgba(9, 105, 218, 0.25);
18 | --success-color: #1a7f37;
19 | --danger-color: #cf222e;
20 | --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
21 | --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.15);
22 | --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.2);
23 |
24 | /* 编辑器颜色 */
25 | --editor-bg: #ffffff;
26 | --editor-text: #24292f;
27 | --editor-line-number: #8b949e;
28 |
29 | /* 滚动条 */
30 | --scrollbar-track: #f6f8fa;
31 | --scrollbar-thumb: #d0d7de;
32 | --scrollbar-thumb-hover: #a8b1ba;
33 |
34 | /* 间距 */
35 | --toolbar-height: 56px;
36 | --statusbar-height: 32px;
37 | --spacing-xs: 4px;
38 | --spacing-sm: 8px;
39 | --spacing-md: 16px;
40 | --spacing-lg: 24px;
41 |
42 | /* 字体 */
43 | --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
44 | --font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
45 |
46 | /* 动画 */
47 | --transition-fast: 0.15s ease;
48 | --transition-normal: 0.25s ease;
49 | }
50 |
51 | /* 暗色主题 */
52 | [data-theme="dark"] {
53 | --bg-primary: #0d1117;
54 | --bg-secondary: #161b22;
55 | --bg-tertiary: #1c2128;
56 | --bg-hover: #262c36;
57 | --text-primary: #e6edf3;
58 | --text-secondary: #8b949e;
59 | --text-tertiary: #6e7681;
60 | --border-color: #30363d;
61 | --border-hover: #484f58;
62 | --accent-color: #2f81f7;
63 | --accent-hover: #539bf5;
64 | --accent-color-alpha: rgba(47, 129, 247, 0.3);
65 | --selection-bg: rgba(47, 129, 247, 0.35);
66 | --success-color: #3fb950;
67 | --danger-color: #f85149;
68 | --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
69 | --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
70 | --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);
71 |
72 | --editor-bg: #0d1117;
73 | --editor-text: #e6edf3;
74 | --editor-line-number: #6e7681;
75 |
76 | --scrollbar-track: #161b22;
77 | --scrollbar-thumb: #30363d;
78 | --scrollbar-thumb-hover: #484f58;
79 | }
80 |
81 | /* 基础重置 */
82 | * {
83 | margin: 0;
84 | padding: 0;
85 | box-sizing: border-box;
86 | }
87 |
88 | /* SVG 图标全局样式 */
89 | svg {
90 | display: inline-block;
91 | vertical-align: middle;
92 | }
93 |
94 | svg.icon {
95 | color: currentColor;
96 | fill: currentColor;
97 | transition: all var(--transition-fast);
98 | }
99 |
100 | /* Logo 动画 - 酷炫悬浮 + 光效 */
101 | .logo-icon {
102 | animation: logoFloat 4s ease-in-out infinite;
103 | }
104 |
105 | @keyframes logoFloat {
106 | 0%, 100% {
107 | transform: translateY(0) rotate(0deg);
108 | filter: drop-shadow(0 2px 8px rgba(91, 134, 229, 0.3));
109 | }
110 | 50% {
111 | transform: translateY(-3px) rotate(1deg);
112 | filter: drop-shadow(0 4px 16px rgba(91, 134, 229, 0.5));
113 | }
114 | }
115 |
116 | [data-theme="dark"] .logo-icon {
117 | animation: logoFloatDark 4s ease-in-out infinite;
118 | }
119 |
120 | @keyframes logoFloatDark {
121 | 0%, 100% {
122 | transform: translateY(0) rotate(0deg);
123 | filter: drop-shadow(0 2px 12px rgba(102, 126, 234, 0.5));
124 | }
125 | 50% {
126 | transform: translateY(-3px) rotate(1deg);
127 | filter: drop-shadow(0 4px 20px rgba(102, 126, 234, 0.8));
128 | }
129 | }
130 |
131 | /* Logo 悬停效果 */
132 | .logo:hover .logo-icon {
133 | animation: logoSpin 0.6s ease;
134 | }
135 |
136 | @keyframes logoSpin {
137 | 0% {
138 | transform: rotate(0deg) scale(1);
139 | }
140 | 50% {
141 | transform: rotate(180deg) scale(1.1);
142 | }
143 | 100% {
144 | transform: rotate(360deg) scale(1);
145 | }
146 | }
147 |
148 | /* 工具栏按钮图标悬停效果 */
149 | .toolbar-btn:hover svg.icon {
150 | transform: scale(1.1);
151 | }
152 |
153 | .toolbar-btn:active svg.icon {
154 | transform: scale(0.95);
155 | }
156 |
157 | /* 主题切换按钮 - 超酷动画 */
158 | #themeBtn {
159 | position: relative;
160 | overflow: visible;
161 | }
162 |
163 | #themeBtn svg.icon {
164 | transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
165 | }
166 |
167 | #themeBtn:hover svg.icon {
168 | transform: rotate(180deg) scale(1.2);
169 | }
170 |
171 | #themeBtn:active svg.icon {
172 | transform: rotate(360deg) scale(1);
173 | }
174 |
175 | [data-theme="dark"] #themeBtn svg.icon {
176 | filter: drop-shadow(0 0 10px rgba(255, 193, 7, 0.6));
177 | color: #FDD835;
178 | }
179 |
180 | [data-theme="light"] #themeBtn svg.icon {
181 | filter: drop-shadow(0 0 8px rgba(63, 81, 181, 0.4));
182 | color: #5C6BC0;
183 | }
184 |
185 | /* 主题切换按钮光环效果 */
186 | #themeBtn::after {
187 | content: '';
188 | position: absolute;
189 | top: 50%;
190 | left: 50%;
191 | width: 100%;
192 | height: 100%;
193 | border-radius: 50%;
194 | background: var(--accent-color);
195 | opacity: 0;
196 | transform: translate(-50%, -50%) scale(0);
197 | transition: all 0.5s ease;
198 | }
199 |
200 | #themeBtn:hover::after {
201 | opacity: 0.1;
202 | transform: translate(-50%, -50%) scale(1.5);
203 | }
204 |
205 | /* 布局切换按钮 - 3D 翻转效果 */
206 | #layoutBtn svg.icon {
207 | transition: all 0.4s ease;
208 | }
209 |
210 | #layoutBtn:hover svg.icon {
211 | transform: rotateY(180deg) scale(1.15);
212 | }
213 |
214 | /* 图表按钮 - 脉冲效果 */
215 | #mermaidBtn {
216 | position: relative;
217 | }
218 |
219 | #mermaidBtn svg.icon {
220 | transition: all 0.3s ease;
221 | }
222 |
223 | #mermaidBtn:hover svg.icon {
224 | animation: chartPulse 1s ease infinite;
225 | color: #36D1DC;
226 | }
227 |
228 | @keyframes chartPulse {
229 | 0%, 100% {
230 | transform: scale(1);
231 | opacity: 1;
232 | }
233 | 50% {
234 | transform: scale(1.15);
235 | opacity: 0.8;
236 | }
237 | }
238 |
239 | /* Markdown 格式化按钮 - 统一色彩提示 */
240 | #boldBtn:hover svg.icon { color: #E91E63; }
241 | #italicBtn:hover svg.icon { color: #9C27B0; }
242 | #headingBtn:hover svg.icon { color: #3F51B5; }
243 | #linkBtn:hover svg.icon { color: #2196F3; }
244 | #imageBtn:hover svg.icon { color: #00BCD4; }
245 | #codeBtn:hover svg.icon { color: #009688; }
246 | #tableBtn:hover svg.icon { color: #4CAF50; }
247 |
248 | /* Mermaid 导出按钮图标 */
249 | .mermaid-export-btn:hover svg.icon {
250 | transform: translateY(-2px);
251 | }
252 |
253 | /* 图标渐变效果(高级设计)*/
254 | .toolbar-btn:hover svg.icon use {
255 | animation: iconPulse 0.6s ease;
256 | }
257 |
258 | @keyframes iconPulse {
259 | 0% {
260 | opacity: 1;
261 | }
262 | 50% {
263 | opacity: 0.7;
264 | }
265 | 100% {
266 | opacity: 1;
267 | }
268 | }
269 |
270 | /* 保存按钮特殊效果 - 更酷炫 */
271 | #saveBtn {
272 | position: relative;
273 | overflow: hidden;
274 | }
275 |
276 | #saveBtn::before {
277 | content: '';
278 | position: absolute;
279 | top: 50%;
280 | left: 50%;
281 | width: 0;
282 | height: 0;
283 | border-radius: 50%;
284 | background: var(--accent-color);
285 | opacity: 0.3;
286 | transform: translate(-50%, -50%);
287 | transition: width 0.6s, height 0.6s;
288 | }
289 |
290 | #saveBtn:hover::before {
291 | width: 200%;
292 | height: 200%;
293 | }
294 |
295 | #saveBtn:hover svg.icon {
296 | animation: saveBounce 0.5s ease;
297 | }
298 |
299 | @keyframes saveBounce {
300 | 0%, 100% {
301 | transform: scale(1);
302 | }
303 | 25% {
304 | transform: scale(1.15) rotate(-5deg);
305 | }
306 | 75% {
307 | transform: scale(1.15) rotate(5deg);
308 | }
309 | }
310 |
311 | /* 文件操作按钮组 - 高级渐变效果 */
312 | #newBtn,
313 | #openBtn,
314 | #saveBtn {
315 | position: relative;
316 | overflow: hidden;
317 | }
318 |
319 | #newBtn:hover,
320 | #openBtn:hover,
321 | #saveBtn:hover {
322 | background: linear-gradient(135deg, var(--bg-hover) 0%, var(--bg-tertiary) 100%);
323 | box-shadow: 0 2px 8px rgba(91, 134, 229, 0.15);
324 | }
325 |
326 | #newBtn:hover svg.icon {
327 | color: #36D1DC;
328 | }
329 |
330 | #openBtn:hover svg.icon {
331 | color: #5B86E5;
332 | }
333 |
334 | #saveBtn:hover svg.icon {
335 | color: #4CAF50;
336 | }
337 |
338 | /* Mermaid 导出按钮渐变效果 */
339 | .mermaid-export-btn:hover {
340 | background: linear-gradient(135deg, var(--bg-hover) 0%, var(--bg-tertiary) 100%);
341 | border-color: var(--accent-color);
342 | }
343 |
344 | .mermaid-export-btn[data-format="svg"]:hover {
345 | box-shadow: 0 4px 12px rgba(76, 175, 80, 0.2);
346 | }
347 |
348 | .mermaid-export-btn[data-format="png"]:hover {
349 | box-shadow: 0 4px 12px rgba(33, 150, 243, 0.2);
350 | }
351 |
352 | /* ==================== KaTeX 数学公式样式 ==================== */
353 |
354 | /* 行内公式 */
355 | .katex {
356 | font-size: 1.05em;
357 | color: var(--text-primary);
358 | }
359 |
360 | /* 块级公式容器 */
361 | .katex-block {
362 | margin: 1.5em 0;
363 | padding: 0;
364 | text-align: center;
365 | }
366 |
367 | /* 块级公式 */
368 | .katex-display {
369 | margin: 0 !important;
370 | padding: 1.2em;
371 | background: var(--bg-secondary);
372 | border-radius: 8px;
373 | border-left: 4px solid var(--accent-color);
374 | overflow-x: auto;
375 | text-align: center;
376 | }
377 |
378 | /* 暗色主题优化 */
379 | [data-theme="dark"] .katex {
380 | color: var(--text-primary);
381 | }
382 |
383 | [data-theme="dark"] .katex-display {
384 | background: var(--bg-tertiary);
385 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
386 | }
387 |
388 | /* 错误显示 */
389 | .katex-error {
390 | color: #ef5350 !important;
391 | background: rgba(239, 83, 80, 0.1);
392 | padding: 2px 6px;
393 | border-radius: 4px;
394 | font-family: var(--font-mono);
395 | }
396 |
397 | /* 公式滚动条美化 */
398 | .katex-display::-webkit-scrollbar {
399 | height: 8px;
400 | }
401 |
402 | .katex-display::-webkit-scrollbar-track {
403 | background: var(--bg-tertiary);
404 | border-radius: 4px;
405 | }
406 |
407 | .katex-display::-webkit-scrollbar-thumb {
408 | background: var(--text-tertiary);
409 | border-radius: 4px;
410 | }
411 |
412 | .katex-display::-webkit-scrollbar-thumb:hover {
413 | background: var(--text-secondary);
414 | }
415 |
416 | /* 数学公式按钮特殊效果 */
417 | #mathBtn:hover svg.icon {
418 | color: #FF6B6B;
419 | animation: formulaShake 0.5s ease;
420 | }
421 |
422 | @keyframes formulaShake {
423 | 0%, 100% { transform: rotate(0deg); }
424 | 25% { transform: rotate(-5deg); }
425 | 75% { transform: rotate(5deg); }
426 | }
427 |
428 | html, body {
429 | height: 100%;
430 | overflow: hidden;
431 | }
432 |
433 | body {
434 | font-family: var(--font-family);
435 | background: var(--bg-primary);
436 | color: var(--text-primary);
437 | transition: background-color var(--transition-normal), color var(--transition-normal);
438 | }
439 |
440 | /* ==================== 工具栏样式 ==================== */
441 |
442 | .toolbar {
443 | height: var(--toolbar-height);
444 | background: var(--bg-secondary);
445 | border-bottom: 1px solid var(--border-color);
446 | display: flex;
447 | align-items: center;
448 | justify-content: space-between;
449 | padding: 0 var(--spacing-md);
450 | gap: var(--spacing-sm);
451 | flex-shrink: 0;
452 | overflow: visible;
453 | position: relative;
454 | z-index: 200;
455 | backdrop-filter: blur(10px);
456 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
457 | }
458 |
459 | [data-theme="dark"] .toolbar {
460 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
461 | }
462 |
463 | .toolbar-left,
464 | .toolbar-right {
465 | display: flex;
466 | align-items: center;
467 | gap: var(--spacing-sm);
468 | flex-wrap: nowrap;
469 | min-width: 0;
470 | }
471 |
472 | /* 确保工具栏右侧有足够空间,不被GitHub角标遮挡 */
473 | .toolbar-right {
474 | padding-right: 50px; /* 为GitHub角标留出空间 */
475 | }
476 |
477 | .logo {
478 | display: flex;
479 | align-items: center;
480 | gap: var(--spacing-xs);
481 | font-size: 18px;
482 | font-weight: 600;
483 | white-space: nowrap;
484 | margin-right: var(--spacing-sm);
485 | cursor: pointer;
486 | padding: 4px 8px;
487 | border-radius: 8px;
488 | transition: all var(--transition-normal);
489 | }
490 |
491 | .logo:hover {
492 | background: var(--bg-hover);
493 | }
494 |
495 | .logo span {
496 | background: linear-gradient(135deg, #5B86E5 0%, #36D1DC 100%);
497 | -webkit-background-clip: text;
498 | -webkit-text-fill-color: transparent;
499 | background-clip: text;
500 | font-weight: 700;
501 | letter-spacing: -0.5px;
502 | }
503 |
504 | [data-theme="dark"] .logo span {
505 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
506 | -webkit-background-clip: text;
507 | -webkit-text-fill-color: transparent;
508 | background-clip: text;
509 | }
510 |
511 | .logo-icon {
512 | width: 28px;
513 | height: 28px;
514 | flex-shrink: 0;
515 | --logo-from: #5B86E5;
516 | --logo-to: #36D1DC;
517 | filter: drop-shadow(0 2px 8px rgba(91, 134, 229, 0.3));
518 | }
519 |
520 | [data-theme="dark"] .logo-icon {
521 | --logo-from: #667eea;
522 | --logo-to: #764ba2;
523 | filter: drop-shadow(0 2px 12px rgba(102, 126, 234, 0.5));
524 | }
525 |
526 | .toolbar-btn {
527 | display: flex;
528 | align-items: center;
529 | gap: var(--spacing-xs);
530 | padding: var(--spacing-xs) var(--spacing-sm);
531 | background: transparent;
532 | border: 1px solid transparent;
533 | border-radius: 6px;
534 | color: var(--text-primary);
535 | cursor: pointer;
536 | font-size: 14px;
537 | font-family: var(--font-family);
538 | transition: all var(--transition-fast);
539 | white-space: nowrap;
540 | }
541 |
542 | .toolbar-btn:hover {
543 | background: var(--bg-hover);
544 | border-color: var(--border-color);
545 | }
546 |
547 | .toolbar-btn:active {
548 | transform: scale(0.98);
549 | }
550 |
551 | .toolbar-btn .icon,
552 | .toolbar-btn svg.icon {
553 | width: 18px;
554 | height: 18px;
555 | flex-shrink: 0;
556 | }
557 |
558 | .toolbar-btn svg.icon {
559 | --icon-bg: var(--bg-primary);
560 | }
561 |
562 | .toolbar-btn .text {
563 | font-size: 13px;
564 | }
565 |
566 | .toolbar-divider {
567 | width: 1px;
568 | height: 24px;
569 | background: var(--border-color);
570 | margin: 0 var(--spacing-xs);
571 | }
572 |
573 | /* 下拉菜单 */
574 | .dropdown {
575 | position: relative;
576 | }
577 |
578 | .dropdown-content {
579 | position: absolute;
580 | top: calc(100% + 4px);
581 | left: 0;
582 | min-width: 160px;
583 | background: var(--bg-primary);
584 | border: 1px solid var(--border-color);
585 | border-radius: 8px;
586 | box-shadow: var(--shadow-md);
587 | padding: var(--spacing-xs);
588 | display: none;
589 | z-index: 9999;
590 | animation: dropdownFadeIn var(--transition-fast);
591 | }
592 |
593 | /* 增加下拉菜单的悬停区域,防止鼠标移动时意外关闭 */
594 | .dropdown-content::before {
595 | content: '';
596 | position: absolute;
597 | top: -8px;
598 | left: 0;
599 | right: 0;
600 | height: 8px;
601 | }
602 |
603 | .dropdown-right {
604 | left: auto;
605 | right: 0;
606 | }
607 |
608 | .dropdown:hover .dropdown-content,
609 | .dropdown:focus-within .dropdown-content {
610 | display: block;
611 | }
612 |
613 | .dropdown-item {
614 | width: 100%;
615 | padding: var(--spacing-sm) var(--spacing-md);
616 | background: transparent;
617 | border: none;
618 | border-radius: 6px;
619 | color: var(--text-primary);
620 | text-align: left;
621 | cursor: pointer;
622 | font-size: 14px;
623 | font-family: var(--font-family);
624 | transition: background var(--transition-fast);
625 | user-select: none;
626 | -webkit-user-select: none;
627 | }
628 |
629 | .dropdown-item:hover {
630 | background: var(--bg-hover);
631 | }
632 |
633 | @keyframes dropdownFadeIn {
634 | from {
635 | opacity: 0;
636 | transform: translateY(-4px);
637 | }
638 | to {
639 | opacity: 1;
640 | transform: translateY(0);
641 | }
642 | }
643 |
644 | /* ==================== 主内容区 ==================== */
645 |
646 | .main-content {
647 | height: calc(100vh - var(--toolbar-height) - var(--statusbar-height));
648 | display: flex;
649 | position: relative;
650 | overflow: hidden;
651 | z-index: 1;
652 | }
653 |
654 | /* 编辑器容器 */
655 | .editor-container {
656 | flex: 1;
657 | min-width: 0;
658 | background: var(--editor-bg);
659 | border-right: 1px solid var(--border-color);
660 | display: flex;
661 | flex-direction: column;
662 | transition: flex var(--transition-normal);
663 | position: relative;
664 | }
665 |
666 | /* 编辑器容器右侧拖拽区域 */
667 | .editor-container::after {
668 | content: '';
669 | position: absolute;
670 | right: -4px;
671 | top: 0;
672 | bottom: 0;
673 | width: 8px;
674 | cursor: col-resize;
675 | z-index: 10;
676 | background: transparent;
677 | transition: background var(--transition-fast);
678 | }
679 |
680 | .editor-container:hover::after {
681 | background: rgba(9, 105, 218, 0.1);
682 | }
683 |
684 | [data-theme="dark"] .editor-container:hover::after {
685 | background: rgba(47, 129, 247, 0.2);
686 | }
687 |
688 | .editor-container.dragging::after {
689 | background: var(--accent-color);
690 | cursor: col-resize;
691 | }
692 |
693 | .editor {
694 | flex: 1;
695 | width: 100%;
696 | padding: var(--spacing-lg);
697 | background: var(--editor-bg);
698 | color: var(--editor-text);
699 | border: none;
700 | outline: none;
701 | font-family: var(--font-mono);
702 | font-size: 14px;
703 | line-height: 1.6;
704 | resize: none;
705 | overflow-y: auto;
706 | tab-size: 2;
707 | }
708 |
709 | .editor::placeholder {
710 | color: var(--text-tertiary);
711 | }
712 |
713 | /* 预览容器 */
714 | .preview-container {
715 | flex: 1;
716 | min-width: 0;
717 | background: var(--bg-primary);
718 | overflow-y: auto;
719 | transition: flex var(--transition-normal);
720 | }
721 |
722 | .preview {
723 | max-width: 900px;
724 | margin: 0 auto;
725 | padding: var(--spacing-lg);
726 | min-height: 100%;
727 | }
728 |
729 | /* 布局模式 */
730 | body.layout-editor-only .preview-container {
731 | display: none;
732 | }
733 |
734 | body.layout-editor-only .editor-container {
735 | border-right: none;
736 | }
737 |
738 | body.layout-preview-only .editor-container {
739 | display: none;
740 | }
741 |
742 | body.layout-vertical .main-content {
743 | flex-direction: column;
744 | }
745 |
746 | body.layout-vertical .editor-container {
747 | border-right: none;
748 | border-bottom: 1px solid var(--border-color);
749 | }
750 |
751 | /* ==================== Markdown 预览样式 ==================== */
752 |
753 | .markdown-body {
754 | color: var(--text-primary);
755 | line-height: 1.6;
756 | }
757 |
758 | .markdown-body h1,
759 | .markdown-body h2,
760 | .markdown-body h3,
761 | .markdown-body h4,
762 | .markdown-body h5,
763 | .markdown-body h6 {
764 | margin-top: 24px;
765 | margin-bottom: 16px;
766 | font-weight: 600;
767 | line-height: 1.25;
768 | scroll-margin-top: var(--spacing-lg);
769 | }
770 |
771 | .markdown-body h1 {
772 | font-size: 2em;
773 | border-bottom: 1px solid var(--border-color);
774 | padding-bottom: 0.3em;
775 | }
776 |
777 | .markdown-body h2 {
778 | font-size: 1.5em;
779 | border-bottom: 1px solid var(--border-color);
780 | padding-bottom: 0.3em;
781 | }
782 |
783 | .markdown-body h3 { font-size: 1.25em; }
784 | .markdown-body h4 { font-size: 1em; }
785 | .markdown-body h5 { font-size: 0.875em; }
786 | .markdown-body h6 { font-size: 0.85em; color: var(--text-secondary); }
787 |
788 | .markdown-body p {
789 | margin-top: 0;
790 | margin-bottom: 16px;
791 | }
792 |
793 | .markdown-body a {
794 | color: var(--accent-color);
795 | text-decoration: none;
796 | }
797 |
798 | .markdown-body a:hover {
799 | text-decoration: underline;
800 | }
801 |
802 | .markdown-body strong {
803 | font-weight: 600;
804 | }
805 |
806 | .markdown-body em {
807 | font-style: italic;
808 | }
809 |
810 | .markdown-body code {
811 | padding: 0.2em 0.4em;
812 | margin: 0;
813 | font-size: 85%;
814 | background: var(--bg-tertiary);
815 | border-radius: 6px;
816 | font-family: var(--font-mono);
817 | }
818 |
819 | .markdown-body pre {
820 | padding: 16px;
821 | overflow: auto;
822 | font-size: 85%;
823 | line-height: 1.45;
824 | background: var(--bg-secondary);
825 | border-radius: 6px;
826 | margin-bottom: 16px;
827 | }
828 |
829 | .markdown-body pre code {
830 | background: transparent;
831 | padding: 0;
832 | margin: 0;
833 | font-size: 100%;
834 | border-radius: 0;
835 | }
836 |
837 | .markdown-body blockquote {
838 | margin: 0 0 16px 0;
839 | padding: 0 1em;
840 | color: var(--text-secondary);
841 | border-left: 4px solid var(--border-color);
842 | }
843 |
844 | .markdown-body ul,
845 | .markdown-body ol {
846 | padding-left: 2em;
847 | margin-bottom: 16px;
848 | }
849 |
850 | .markdown-body li {
851 | margin-bottom: 0.25em;
852 | }
853 |
854 | .markdown-body li > p {
855 | margin-bottom: 0.5em;
856 | }
857 |
858 | .markdown-body table {
859 | border-spacing: 0;
860 | border-collapse: collapse;
861 | margin-bottom: 16px;
862 | width: 100%;
863 | overflow: auto;
864 | }
865 |
866 | .markdown-body table th,
867 | .markdown-body table td {
868 | padding: 6px 13px;
869 | border: 1px solid var(--border-color);
870 | }
871 |
872 | .markdown-body table th {
873 | font-weight: 600;
874 | background: var(--bg-secondary);
875 | }
876 |
877 | .markdown-body table tr {
878 | background: var(--bg-primary);
879 | }
880 |
881 | .markdown-body table tr:nth-child(2n) {
882 | background: var(--bg-secondary);
883 | }
884 |
885 | .markdown-body img {
886 | max-width: 100%;
887 | height: auto;
888 | border-radius: 6px;
889 | }
890 |
891 | .markdown-body hr {
892 | height: 0.25em;
893 | padding: 0;
894 | margin: 24px 0;
895 | background-color: var(--border-color);
896 | border: 0;
897 | }
898 |
899 | /* 任务列表 */
900 | .markdown-body input[type="checkbox"] {
901 | margin-right: 0.5em;
902 | }
903 |
904 | /* Mermaid 图表容器 */
905 | .mermaid {
906 | text-align: center;
907 | margin: 24px 0;
908 | padding: 0;
909 | background: var(--bg-secondary);
910 | border-radius: 8px;
911 | position: relative;
912 | }
913 |
914 | /* Mermaid 包装器 */
915 | .mermaid-wrapper {
916 | position: relative;
917 | }
918 |
919 | /* Mermaid 内容区 */
920 | .mermaid-content {
921 | padding: 16px;
922 | }
923 |
924 | /* Mermaid 导出工具栏 */
925 | .mermaid-export-toolbar {
926 | display: flex;
927 | gap: var(--spacing-xs);
928 | justify-content: center;
929 | padding: var(--spacing-sm);
930 | border-top: 1px solid var(--border-color);
931 | background: var(--bg-primary);
932 | border-radius: 0 0 8px 8px;
933 | opacity: 0;
934 | transition: opacity var(--transition-fast);
935 | }
936 |
937 | .mermaid:hover .mermaid-export-toolbar,
938 | .mermaid-export-toolbar:hover {
939 | opacity: 1;
940 | }
941 |
942 | /* Mermaid 导出按钮 */
943 | .mermaid-export-btn {
944 | display: flex;
945 | align-items: center;
946 | gap: var(--spacing-xs);
947 | padding: 6px 12px;
948 | background: var(--bg-secondary);
949 | border: 1px solid var(--border-color);
950 | border-radius: 6px;
951 | color: var(--text-primary);
952 | font-size: 12px;
953 | font-family: var(--font-family);
954 | cursor: pointer;
955 | transition: all var(--transition-fast);
956 | }
957 |
958 | .mermaid-export-btn:hover {
959 | background: var(--bg-hover);
960 | border-color: var(--accent-color);
961 | color: var(--accent-color);
962 | transform: translateY(-1px);
963 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
964 | }
965 |
966 | .mermaid-export-btn:active {
967 | transform: translateY(0);
968 | }
969 |
970 | .mermaid-export-btn .icon,
971 | .mermaid-export-btn svg.icon {
972 | width: 16px;
973 | height: 16px;
974 | flex-shrink: 0;
975 | }
976 |
977 | .mermaid-export-btn .text {
978 | font-weight: 500;
979 | }
980 |
981 | /* Mermaid 错误提示 */
982 | .mermaid-error {
983 | padding: 16px;
984 | background: rgba(207, 34, 46, 0.1);
985 | border: 1px solid var(--danger-color);
986 | border-radius: 6px;
987 | color: var(--danger-color);
988 | margin: 16px 0;
989 | }
990 |
991 | .mermaid-error-title {
992 | font-weight: 600;
993 | margin-bottom: 8px;
994 | }
995 |
996 | /* ==================== 状态栏 ==================== */
997 |
998 | .statusbar {
999 | height: var(--statusbar-height);
1000 | background: var(--bg-secondary);
1001 | border-top: 1px solid var(--border-color);
1002 | display: flex;
1003 | align-items: center;
1004 | justify-content: space-between;
1005 | padding: 0 var(--spacing-md);
1006 | font-size: 12px;
1007 | color: var(--text-secondary);
1008 | flex-shrink: 0;
1009 | position: relative;
1010 | z-index: 1;
1011 | }
1012 |
1013 | .statusbar-left,
1014 | .statusbar-right {
1015 | display: flex;
1016 | align-items: center;
1017 | gap: var(--spacing-sm);
1018 | }
1019 |
1020 | .statusbar-divider {
1021 | color: var(--border-color);
1022 | }
1023 |
1024 | /* ==================== 滚动条美化 ==================== */
1025 |
1026 | ::-webkit-scrollbar {
1027 | width: 12px;
1028 | height: 12px;
1029 | }
1030 |
1031 | ::-webkit-scrollbar-track {
1032 | background: var(--scrollbar-track);
1033 | }
1034 |
1035 | ::-webkit-scrollbar-thumb {
1036 | background: var(--scrollbar-thumb);
1037 | border-radius: 6px;
1038 | border: 2px solid var(--scrollbar-track);
1039 | }
1040 |
1041 | ::-webkit-scrollbar-thumb:hover {
1042 | background: var(--scrollbar-thumb-hover);
1043 | }
1044 |
1045 | /* Firefox 滚动条 */
1046 | * {
1047 | scrollbar-width: thin;
1048 | scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
1049 | }
1050 |
1051 | /* ==================== 响应式设计 ==================== */
1052 |
1053 | @media (max-width: 768px) {
1054 | .toolbar {
1055 | padding: 0 var(--spacing-sm);
1056 | }
1057 |
1058 | .toolbar-btn .text {
1059 | display: none;
1060 | }
1061 |
1062 | .toolbar-divider {
1063 | display: none;
1064 | }
1065 |
1066 | .logo {
1067 | font-size: 16px;
1068 | }
1069 |
1070 | /* 移动端默认单栏布局 */
1071 | .main-content {
1072 | flex-direction: column;
1073 | }
1074 |
1075 | .editor-container,
1076 | .preview-container {
1077 | height: 50vh;
1078 | border-right: none;
1079 | }
1080 |
1081 | .editor-container {
1082 | border-bottom: 1px solid var(--border-color);
1083 | }
1084 |
1085 | body.layout-editor-only .editor-container,
1086 | body.layout-preview-only .preview-container {
1087 | height: 100%;
1088 | }
1089 |
1090 | .editor,
1091 | .preview {
1092 | padding: var(--spacing-md);
1093 | }
1094 |
1095 | /* 移动端 Mermaid 导出工具栏始终显示 */
1096 | .mermaid-export-toolbar {
1097 | opacity: 1;
1098 | }
1099 | }
1100 |
1101 | @media (max-width: 480px) {
1102 | .statusbar-right span:not(#charCount):not(#readTime) {
1103 | display: none;
1104 | }
1105 |
1106 | .statusbar-divider {
1107 | display: none;
1108 | }
1109 | }
1110 |
1111 | /* ==================== 打印样式 ==================== */
1112 |
1113 | @media print {
1114 | .toolbar,
1115 | .editor-container,
1116 | .statusbar {
1117 | display: none !important;
1118 | }
1119 |
1120 | .main-content {
1121 | display: block;
1122 | height: auto;
1123 | }
1124 |
1125 | .preview-container {
1126 | border: none;
1127 | overflow: visible;
1128 | }
1129 |
1130 | .preview {
1131 | max-width: 100%;
1132 | padding: 0;
1133 | }
1134 | }
1135 |
1136 | /* ==================== 加载动画 ==================== */
1137 |
1138 | @keyframes pulse {
1139 | 0%, 100% {
1140 | opacity: 1;
1141 | }
1142 | 50% {
1143 | opacity: 0.5;
1144 | }
1145 | }
1146 |
1147 | .loading {
1148 | animation: pulse 1.5s ease-in-out infinite;
1149 | }
1150 |
1151 | /* ==================== 辅助类 ==================== */
1152 |
1153 | .hidden {
1154 | display: none !important;
1155 | }
1156 |
1157 | .sr-only {
1158 | position: absolute;
1159 | width: 1px;
1160 | height: 1px;
1161 | padding: 0;
1162 | margin: -1px;
1163 | overflow: hidden;
1164 | clip: rect(0, 0, 0, 0);
1165 | white-space: nowrap;
1166 | border-width: 0;
1167 | }
1168 |
1169 | /* ==================== 草稿恢复对话框 ==================== */
1170 |
1171 | .draft-dialog {
1172 | position: fixed;
1173 | top: 0;
1174 | left: 0;
1175 | right: 0;
1176 | bottom: 0;
1177 | z-index: 10000;
1178 | display: flex;
1179 | align-items: center;
1180 | justify-content: center;
1181 | }
1182 |
1183 | .draft-dialog-overlay {
1184 | position: absolute;
1185 | top: 0;
1186 | left: 0;
1187 | right: 0;
1188 | bottom: 0;
1189 | background: rgba(0, 0, 0, 0.5);
1190 | backdrop-filter: blur(4px);
1191 | animation: fadeIn 0.2s ease;
1192 | }
1193 |
1194 | .draft-dialog-content {
1195 | position: relative;
1196 | background: var(--bg-primary);
1197 | border: 1px solid var(--border-color);
1198 | border-radius: 12px;
1199 | box-shadow: var(--shadow-lg);
1200 | padding: var(--spacing-lg);
1201 | max-width: 400px;
1202 | width: 90%;
1203 | animation: slideIn 0.3s ease;
1204 | }
1205 |
1206 | .draft-dialog-content h3 {
1207 | margin: 0 0 var(--spacing-md) 0;
1208 | font-size: 18px;
1209 | font-weight: 600;
1210 | color: var(--text-primary);
1211 | }
1212 |
1213 | .draft-dialog-content p {
1214 | margin: 0 0 var(--spacing-lg) 0;
1215 | color: var(--text-secondary);
1216 | font-size: 14px;
1217 | }
1218 |
1219 | .draft-dialog-actions {
1220 | display: flex;
1221 | flex-direction: column;
1222 | gap: var(--spacing-md);
1223 | }
1224 |
1225 | .draft-dialog-checkbox {
1226 | display: flex;
1227 | align-items: center;
1228 | gap: var(--spacing-sm);
1229 | cursor: pointer;
1230 | font-size: 14px;
1231 | color: var(--text-secondary);
1232 | }
1233 |
1234 | .draft-dialog-checkbox input[type="checkbox"] {
1235 | cursor: pointer;
1236 | }
1237 |
1238 | .draft-dialog-buttons {
1239 | display: flex;
1240 | gap: var(--spacing-sm);
1241 | justify-content: flex-end;
1242 | }
1243 |
1244 | .draft-btn {
1245 | padding: var(--spacing-sm) var(--spacing-lg);
1246 | border: none;
1247 | border-radius: 6px;
1248 | font-size: 14px;
1249 | font-family: var(--font-family);
1250 | font-weight: 500;
1251 | cursor: pointer;
1252 | transition: all var(--transition-fast);
1253 | }
1254 |
1255 | .draft-btn-primary {
1256 | background: var(--accent-color);
1257 | color: white;
1258 | }
1259 |
1260 | .draft-btn-primary:hover {
1261 | background: var(--accent-hover);
1262 | transform: translateY(-1px);
1263 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
1264 | }
1265 |
1266 | .draft-btn-secondary {
1267 | background: var(--bg-secondary);
1268 | color: var(--text-primary);
1269 | border: 1px solid var(--border-color);
1270 | }
1271 |
1272 | .draft-btn-secondary:hover {
1273 | background: var(--bg-hover);
1274 | }
1275 |
1276 | @keyframes fadeIn {
1277 | from { opacity: 0; }
1278 | to { opacity: 1; }
1279 | }
1280 |
1281 | @keyframes slideIn {
1282 | from {
1283 | opacity: 0;
1284 | transform: translateY(-20px);
1285 | }
1286 | to {
1287 | opacity: 1;
1288 | transform: translateY(0);
1289 | }
1290 | }
1291 |
1292 | /* ==================== 选择文本样式 ==================== */
1293 |
1294 | ::selection {
1295 | background: var(--accent-color);
1296 | color: white;
1297 | }
1298 |
1299 | ::-moz-selection {
1300 | background: var(--accent-color);
1301 | color: white;
1302 | }
1303 |
1304 | /* ==================== Ace Editor 编辑器样式 ==================== */
1305 |
1306 | /* 编辑器容器 */
1307 | .editor {
1308 | width: 100%;
1309 | height: 100%;
1310 | overflow: hidden;
1311 | font-size: 15px;
1312 | }
1313 |
1314 | /* Ace Editor 自定义样式 */
1315 | .ace_editor {
1316 | font-family: var(--font-mono) !important;
1317 | line-height: 1.8 !important;
1318 | }
1319 |
1320 | .ace_gutter {
1321 | background: var(--bg-secondary) !important;
1322 | color: var(--text-tertiary) !important;
1323 | border-right: 1px solid var(--border-color) !important;
1324 | }
1325 |
1326 | .ace_gutter-active-line {
1327 | background: var(--bg-hover) !important;
1328 | }
1329 |
1330 | .ace_cursor {
1331 | color: var(--accent-color) !important;
1332 | }
1333 |
1334 | /* 选中文本高亮 - 确保明显可见 */
1335 | .ace_marker-layer .ace_selection {
1336 | background: var(--selection-bg) !important;
1337 | border-radius: 2px;
1338 | }
1339 |
1340 | /* 聚焦时的选中文本 */
1341 | .ace_editor.ace_focus .ace_marker-layer .ace_selection {
1342 | background: var(--selection-bg) !important;
1343 | }
1344 |
1345 | .ace_marker-layer .ace_active-line {
1346 | background: var(--bg-hover) !important;
1347 | }
1348 |
1349 | /* 暗色主题覆盖 */
1350 | [data-theme="dark"] .ace_gutter {
1351 | background: rgba(0, 0, 0, 0.3) !important;
1352 | }
1353 |
1354 | [data-theme="dark"] .ace_gutter-active-line {
1355 | background: rgba(255, 255, 255, 0.05) !important;
1356 | }
1357 |
1358 | /* ==================== Mermaid 全屏查看器 ==================== */
1359 |
1360 | .mermaid-fullscreen-viewer {
1361 | position: fixed;
1362 | top: 0;
1363 | left: 0;
1364 | width: 100vw;
1365 | height: 100vh;
1366 | z-index: 10000;
1367 | background: var(--bg-primary);
1368 | display: flex;
1369 | flex-direction: column;
1370 | overflow: hidden;
1371 | }
1372 |
1373 | .mermaid-viewer-header {
1374 | display: flex;
1375 | align-items: center;
1376 | justify-content: space-between;
1377 | padding: var(--spacing-md);
1378 | background: var(--bg-secondary);
1379 | border-bottom: 1px solid var(--border-color);
1380 | flex-shrink: 0;
1381 | }
1382 |
1383 | .mermaid-viewer-title {
1384 | font-size: 16px;
1385 | font-weight: 600;
1386 | color: var(--text-primary);
1387 | }
1388 |
1389 | .mermaid-viewer-controls {
1390 | display: flex;
1391 | gap: var(--spacing-xs);
1392 | }
1393 |
1394 | .mermaid-viewer-btn {
1395 | display: flex;
1396 | align-items: center;
1397 | justify-content: center;
1398 | width: 36px;
1399 | height: 36px;
1400 | padding: 0;
1401 | background: var(--bg-primary);
1402 | border: 1px solid var(--border-color);
1403 | border-radius: 6px;
1404 | color: var(--text-primary);
1405 | cursor: pointer;
1406 | transition: all var(--transition-fast);
1407 | }
1408 |
1409 | .mermaid-viewer-btn:hover {
1410 | background: var(--bg-hover);
1411 | border-color: var(--accent-color);
1412 | color: var(--accent-color);
1413 | transform: translateY(-1px);
1414 | }
1415 |
1416 | .mermaid-viewer-btn:active {
1417 | transform: translateY(0);
1418 | }
1419 |
1420 | .mermaid-viewer-btn .icon {
1421 | width: 18px;
1422 | height: 18px;
1423 | }
1424 |
1425 | .mermaid-viewer-content {
1426 | flex: 1;
1427 | display: flex;
1428 | align-items: center;
1429 | justify-content: center;
1430 | overflow: hidden;
1431 | position: relative;
1432 | }
1433 |
1434 | .mermaid-viewer-svg-container {
1435 | width: 100%;
1436 | height: 100%;
1437 | overflow: hidden;
1438 | position: relative;
1439 | cursor: grab;
1440 | }
1441 |
1442 | .mermaid-viewer-svg-container:active {
1443 | cursor: grabbing;
1444 | }
1445 |
1446 | .mermaid-viewer-svg-wrapper {
1447 | display: inline-block;
1448 | position: absolute;
1449 | will-change: transform;
1450 | transition: transform 0.1s ease-out;
1451 | }
1452 |
1453 | .mermaid-viewer-svg-wrapper svg {
1454 | display: block;
1455 | width: auto;
1456 | height: auto;
1457 | max-width: none;
1458 | max-height: none;
1459 | }
1460 |
1461 | .mermaid-viewer-footer {
1462 | padding: var(--spacing-sm) var(--spacing-md);
1463 | background: var(--bg-secondary);
1464 | border-top: 1px solid var(--border-color);
1465 | text-align: center;
1466 | flex-shrink: 0;
1467 | }
1468 |
1469 | .mermaid-viewer-hint {
1470 | font-size: 12px;
1471 | color: var(--text-secondary);
1472 | }
1473 |
1474 | /* 当查看器激活时,隐藏其他内容 */
1475 | body.mermaid-viewer-active > *:not(.mermaid-fullscreen-viewer) {
1476 | visibility: hidden;
1477 | }
1478 |
1479 | body.mermaid-viewer-active .mermaid-fullscreen-viewer {
1480 | visibility: visible;
1481 | }
1482 |
1483 | /* ==================== GitHub 角标 ==================== */
1484 |
1485 | .github-corner {
1486 | position: fixed;
1487 | top: 0;
1488 | right: 0;
1489 | z-index: 201;
1490 | width: 80px;
1491 | height: 80px;
1492 | overflow: hidden;
1493 | pointer-events: auto;
1494 | transition: all var(--transition-normal);
1495 | text-decoration: none;
1496 | }
1497 |
1498 | .github-corner:hover {
1499 | transform: scale(1.05);
1500 | }
1501 |
1502 | .github-corner svg {
1503 | display: block;
1504 | pointer-events: none;
1505 | position: absolute;
1506 | top: 10px;
1507 | right: 10px;
1508 | border: 0;
1509 | filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.2));
1510 | }
1511 |
1512 | /* 亮色主题:使用渐变蓝色 */
1513 | .github-corner-bg {
1514 | fill: url(#githubGradient);
1515 | }
1516 |
1517 | /* 暗色主题:使用更亮的颜色 */
1518 | [data-theme="dark"] .github-corner-bg {
1519 | fill: url(#githubGradient);
1520 | opacity: 0.95;
1521 | }
1522 |
1523 | [data-theme="dark"] .github-corner svg {
1524 | filter: drop-shadow(0 2px 12px rgba(91, 134, 229, 0.4));
1525 | }
1526 |
1527 | .github-corner:hover svg {
1528 | filter: drop-shadow(0 4px 16px rgba(91, 134, 229, 0.5));
1529 | }
1530 |
1531 | [data-theme="dark"] .github-corner:hover svg {
1532 | filter: drop-shadow(0 4px 20px rgba(91, 134, 229, 0.6));
1533 | }
1534 |
1535 | .github-corner .octo-arm {
1536 | transform-origin: 130px 106px;
1537 | }
1538 |
1539 | .github-corner:hover .octo-arm {
1540 | animation: octocat-wave 560ms ease-in-out;
1541 | }
1542 |
1543 | @keyframes octocat-wave {
1544 | 0%, 100% {
1545 | transform: rotate(0);
1546 | }
1547 | 20%, 60% {
1548 | transform: rotate(-25deg);
1549 | }
1550 | 40%, 80% {
1551 | transform: rotate(10deg);
1552 | }
1553 | }
1554 |
1555 | /* 响应式:移动端缩小角标 */
1556 | @media (max-width: 768px) {
1557 | .github-corner {
1558 | width: 60px;
1559 | height: 60px;
1560 | }
1561 |
1562 | .github-corner svg {
1563 | width: 60px;
1564 | height: 60px;
1565 | }
1566 |
1567 | /* 移动端工具栏右侧padding调整 */
1568 | .toolbar-right {
1569 | padding-right: 30px;
1570 | }
1571 | }
1572 |
1573 | /* 打印时隐藏 */
1574 | @media print {
1575 | .github-corner {
1576 | display: none !important;
1577 | }
1578 | }
1579 |
1580 |
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | /**
2 | * MarkX - 专业 Markdown + Mermaid 编辑器
3 | * 完整的前端应用逻辑
4 | */
5 |
6 | // ==================== 导入依赖库 ====================
7 | import { marked } from 'marked';
8 | import DOMPurify from 'dompurify';
9 | import mermaid from 'mermaid';
10 | import hljs from 'highlight.js';
11 |
12 | // Ace Editor 通过全局变量 window.ace 加载,无需 import
13 |
14 | // ==================== 应用状态管理 ====================
15 | const AppState = {
16 | currentTheme: 'light',
17 | currentLayout: 'split', // split, editor-only, preview-only, vertical
18 | autoSaveTimer: null,
19 | currentFileName: 'untitled.md',
20 | isDirty: false,
21 | };
22 |
23 | // ==================== 配置 Marked.js ====================
24 | const renderer = new marked.Renderer();
25 |
26 | // 自定义代码块渲染 - 处理 Mermaid
27 | renderer.code = function(code, language) {
28 | const lang = language || '';
29 |
30 | // 检测 Mermaid 代码块
31 | if (lang === 'mermaid' || lang === 'mmd') {
32 | return `${code}
`;
33 | }
34 |
35 | // 其他代码使用 highlight.js 高亮
36 | if (lang && hljs.getLanguage(lang)) {
37 | try {
38 | const highlighted = hljs.highlight(code, { language: lang }).value;
39 | return `${highlighted}
`;
40 | } catch (err) {
41 | console.error('代码高亮失败:', err);
42 | }
43 | }
44 |
45 | // 默认代码块
46 | return `${escapeHtml(code)}
`;
47 | };
48 |
49 | // 自定义标题渲染 - 添加 ID 用于目录跳转
50 | renderer.heading = function(text, level) {
51 | const id = generateHeadingId(text);
52 | return `${text}`;
53 | };
54 |
55 | // 配置 Marked 选项
56 | marked.setOptions({
57 | renderer: renderer,
58 | gfm: true, // GitHub Flavored Markdown
59 | breaks: true, // 支持换行
60 | pedantic: false,
61 | smartLists: true,
62 | smartypants: true,
63 | });
64 |
65 | // ==================== 配置 Mermaid ====================
66 | function initMermaid() {
67 | const theme = AppState.currentTheme === 'dark' ? 'dark' : 'default';
68 |
69 | mermaid.initialize({
70 | startOnLoad: false,
71 | theme: theme,
72 | securityLevel: 'loose',
73 | fontFamily: 'var(--font-family)',
74 | themeVariables: {
75 | primaryColor: AppState.currentTheme === 'dark' ? '#2f81f7' : '#0969da',
76 | primaryTextColor: AppState.currentTheme === 'dark' ? '#e6edf3' : '#24292f',
77 | primaryBorderColor: AppState.currentTheme === 'dark' ? '#30363d' : '#d0d7de',
78 | lineColor: AppState.currentTheme === 'dark' ? '#484f58' : '#d0d7de',
79 | secondaryColor: AppState.currentTheme === 'dark' ? '#161b22' : '#f6f8fa',
80 | tertiaryColor: AppState.currentTheme === 'dark' ? '#0d1117' : '#ffffff',
81 | },
82 | });
83 | }
84 |
85 | // ==================== DOM 元素引用 ====================
86 | const elements = {
87 | editor: document.getElementById('editor'),
88 | editorTextarea: document.getElementById('editorTextarea'),
89 | preview: document.getElementById('preview'),
90 | editorContainer: document.getElementById('editorContainer'),
91 | previewContainer: document.getElementById('previewContainer'),
92 | themeBtn: document.getElementById('themeBtn'),
93 | layoutBtn: document.getElementById('layoutBtn'),
94 | newBtn: document.getElementById('newBtn'),
95 | openBtn: document.getElementById('openBtn'),
96 | saveBtn: document.getElementById('saveBtn'),
97 | fileInput: document.getElementById('fileInput'),
98 | statusMessage: document.getElementById('statusMessage'),
99 | charCount: document.getElementById('charCount'),
100 | wordCount: document.getElementById('wordCount'),
101 | lineCount: document.getElementById('lineCount'),
102 | readTime: document.getElementById('readTime'),
103 | };
104 |
105 | // ==================== Ace Editor 编辑器 ====================
106 |
107 | // 全局编辑器实例
108 | let aceEditor = null;
109 |
110 | // 默认文档内容
111 | const defaultContent = `# 欢迎使用 MarkX!
112 |
113 | 现代化的 Markdown 编辑器,支持 **Mermaid 图表** 和 **KaTeX 数学公式**!
114 |
115 | ## ✨ 特色功能
116 |
117 | - ✅ 实时预览
118 | - ✅ Mermaid 图表支持
119 | - ✅ KaTeX 数学公式
120 | - ✅ 代码高亮
121 | - ✅ 暗色/亮色主题
122 | - ✅ 文件导入导出
123 | - ✅ 自动保存草稿
124 |
125 | ---
126 |
127 | ## 📊 Mermaid 图表示例
128 |
129 | 点击工具栏的「图表」按钮快速插入模板!
130 |
131 | \`\`\`mermaid
132 | graph TD
133 | A[开始] --> B{是否喜欢?}
134 | B -->|是| C[太棒了!]
135 | B -->|否| D[试试其他功能]
136 | C --> E[分享给朋友]
137 | D --> E
138 | \`\`\`
139 |
140 | ---
141 |
142 | ## 🧮 数学公式示例
143 |
144 | 点击工具栏的「公式」按钮快速插入模板!
145 |
146 | **行内公式**:质能方程 $E = mc^2$,勾股定理 $a^2 + b^2 = c^2$
147 |
148 | **块级公式**:
149 |
150 | $$
151 | x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}
152 | $$
153 |
154 | $$
155 | \\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}
156 | $$
157 |
158 | ---
159 |
160 | 试试编辑内容,右侧会实时更新!🚀`;
161 |
162 | /**
163 | * 初始化 Ace Editor
164 | */
165 | function initEditor() {
166 | try {
167 | // 确保 Ace 已加载
168 | if (typeof window.ace === 'undefined') {
169 | console.error('❌ Ace Editor 未加载');
170 | return;
171 | }
172 |
173 | // 创建编辑器实例
174 | aceEditor = window.ace.edit('editor', {
175 | mode: 'ace/mode/markdown',
176 | theme: 'ace/theme/github',
177 | value: defaultContent,
178 | fontSize: '15px',
179 | showPrintMargin: false,
180 | highlightActiveLine: true,
181 | highlightGutterLine: true,
182 | enableBasicAutocompletion: true,
183 | enableLiveAutocompletion: false,
184 | enableSnippets: true,
185 | wrap: true,
186 | wrapBehavioursEnabled: true,
187 | tabSize: 4,
188 | useSoftTabs: true,
189 | showFoldWidgets: true,
190 | showLineNumbers: true,
191 | showGutter: true,
192 | displayIndentGuides: true,
193 | animatedScroll: true,
194 | vScrollBarAlwaysVisible: false,
195 | hScrollBarAlwaysVisible: false,
196 | scrollPastEnd: 0.5,
197 | behavioursEnabled: true,
198 | wrapBehavioursEnabled: true
199 | });
200 |
201 | // 获取 session
202 | const session = aceEditor.getSession();
203 |
204 | // 设置编辑器选项
205 | session.setUseWrapMode(true);
206 |
207 | // 监听内容变化
208 | aceEditor.session.on('change', () => {
209 | AppState.isDirty = true;
210 | debouncedRender();
211 | });
212 |
213 | // 自定义快捷键
214 | aceEditor.commands.addCommand({
215 | name: 'save',
216 | bindKey: { win: 'Ctrl-S', mac: 'Cmd-S' },
217 | exec: () => {
218 | saveFile();
219 | }
220 | });
221 |
222 | console.log('✅ Ace Editor 初始化成功');
223 |
224 | } catch (error) {
225 | console.error('❌ Ace Editor 初始化失败:', error);
226 | throw error;
227 | }
228 | }
229 |
230 | /**
231 | * 更新编辑器主题
232 | */
233 | function updateEditorTheme(isDark) {
234 | if (!aceEditor) return;
235 |
236 | try {
237 | aceEditor.setTheme(isDark ? 'ace/theme/one_dark' : 'ace/theme/github');
238 | } catch (error) {
239 | console.error('更新主题失败:', error);
240 | }
241 | }
242 |
243 | /**
244 | * 获取编辑器内容
245 | */
246 | function getEditorContent() {
247 | return aceEditor ? aceEditor.getValue() : '';
248 | }
249 |
250 | /**
251 | * 设置编辑器内容
252 | */
253 | function setEditorContent(content) {
254 | if (!aceEditor) return;
255 |
256 | const cursorPosition = aceEditor.getCursorPosition();
257 | aceEditor.setValue(content, -1); // -1 移动光标到开始
258 |
259 | // 尝试恢复光标位置
260 | try {
261 | aceEditor.moveCursorToPosition(cursorPosition);
262 | } catch (e) {
263 | // 如果恢复失败,移动到文档开始
264 | aceEditor.moveCursorTo(0, 0);
265 | }
266 | }
267 |
268 | // ==================== 工具函数 ====================
269 |
270 | /**
271 | * 防抖函数
272 | */
273 | function debounce(func, wait) {
274 | let timeout;
275 | return function executedFunction(...args) {
276 | const later = () => {
277 | clearTimeout(timeout);
278 | func(...args);
279 | };
280 | clearTimeout(timeout);
281 | timeout = setTimeout(later, wait);
282 | };
283 | }
284 |
285 | /**
286 | * 转义 HTML 特殊字符
287 | */
288 | function escapeHtml(text) {
289 | const map = {
290 | '&': '&',
291 | '<': '<',
292 | '>': '>',
293 | '"': '"',
294 | "'": '''
295 | };
296 | return text.replace(/[&<>"']/g, m => map[m]);
297 | }
298 |
299 | /**
300 | * 从标题文本生成 ID
301 | */
302 | function generateHeadingId(text) {
303 | return text
304 | .toLowerCase()
305 | .replace(/[^\w\u4e00-\u9fa5]+/g, '-')
306 | .replace(/^-+|-+$/g, '');
307 | }
308 |
309 | /**
310 | * 设置状态消息
311 | */
312 | function setStatus(message, duration = 3000) {
313 | elements.statusMessage.textContent = message;
314 | if (duration > 0) {
315 | setTimeout(() => {
316 | elements.statusMessage.textContent = '就绪';
317 | }, duration);
318 | }
319 | }
320 |
321 | /**
322 | * 更新统计信息
323 | */
324 | function updateStats(text) {
325 | // 字符数
326 | const charCount = text.length;
327 | elements.charCount.textContent = `${charCount.toLocaleString()} 字符`;
328 |
329 | // 词数(中英文混合)
330 | const chineseWords = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
331 | const englishWords = (text.match(/[a-zA-Z]+/g) || []).length;
332 | const totalWords = chineseWords + englishWords;
333 | elements.wordCount.textContent = `${totalWords.toLocaleString()} 词`;
334 |
335 | // 行数
336 | const lineCount = text.split('\n').length;
337 | elements.lineCount.textContent = `${lineCount.toLocaleString()} 行`;
338 |
339 | // 预计阅读时间(假设每分钟 200 中文字或 300 英文词)
340 | const readMinutes = Math.max(1, Math.ceil((chineseWords / 200) + (englishWords / 300)));
341 | elements.readTime.textContent = `预计阅读 ${readMinutes} 分钟`;
342 | }
343 |
344 | // ==================== Markdown 渲染 ====================
345 |
346 | /**
347 | * 渲染 Markdown 为 HTML
348 | */
349 | async function renderMarkdown() {
350 | let markdown = getEditorContent();
351 |
352 | try {
353 | // 预处理:保护数学公式不被 Markdown 解析器破坏
354 | const mathBlocks = [];
355 | let processedMarkdown = markdown;
356 |
357 | // 1. 先提取并保护块级公式 $$...$$(包括多行)
358 | processedMarkdown = processedMarkdown.replace(/\$\$([\s\S]+?)\$\$/g, (match, formula) => {
359 | const index = mathBlocks.length;
360 | mathBlocks.push({ type: 'display', formula: formula.trim() });
361 | return `MATH_BLOCK_PLACEHOLDER_${index}`;
362 | });
363 |
364 | // 2. 提取并保护行内公式 $...$(单行,不包含换行)
365 | processedMarkdown = processedMarkdown.replace(/\$([^\$\n]+?)\$/g, (match, formula) => {
366 | const index = mathBlocks.length;
367 | mathBlocks.push({ type: 'inline', formula: formula.trim() });
368 | return `MATH_INLINE_PLACEHOLDER_${index}`;
369 | });
370 |
371 | // 使用 Marked 解析 Markdown
372 | let html = marked.parse(processedMarkdown);
373 |
374 | // 还原数学公式占位符(在 DOMPurify 之前)
375 | mathBlocks.forEach((mathBlock, index) => {
376 | const placeholder = mathBlock.type === 'display'
377 | ? `MATH_BLOCK_PLACEHOLDER_${index}`
378 | : `MATH_INLINE_PLACEHOLDER_${index}`;
379 |
380 | if (mathBlock.type === 'display') {
381 | // 块级公式用 div 包裹,确保独立成行
382 | html = html.replace(placeholder, `$$${mathBlock.formula}$$
`);
383 | } else {
384 | // 行内公式直接替换
385 | html = html.replace(placeholder, `$${mathBlock.formula}$`);
386 | }
387 | });
388 |
389 | // 使用 DOMPurify 清理 HTML(防止 XSS)
390 | html = DOMPurify.sanitize(html, {
391 | ADD_TAGS: ['iframe', 'div'],
392 | ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling', 'class'],
393 | });
394 |
395 | // 更新预览区
396 | elements.preview.innerHTML = html;
397 |
398 | // 渲染数学公式 (KaTeX)
399 | if (window.renderMathInElement) {
400 | try {
401 | renderMathInElement(elements.preview, {
402 | delimiters: [
403 | {left: '$$', right: '$$', display: true}, // 块级公式
404 | {left: '$', right: '$', display: false}, // 行内公式
405 | {left: '\\[', right: '\\]', display: true}, // 备用块级
406 | {left: '\\(', right: '\\)', display: false} // 备用行内
407 | ],
408 | throwOnError: false,
409 | errorColor: '#cc0000'
410 | });
411 | } catch (error) {
412 | console.warn('KaTeX 渲染失败:', error);
413 | }
414 | }
415 |
416 | // 渲染 Mermaid 图表
417 | await renderMermaidCharts();
418 |
419 | // 更新统计信息
420 | updateStats(markdown);
421 |
422 | setStatus('预览已更新');
423 |
424 | } catch (error) {
425 | console.error('渲染错误:', error);
426 | elements.preview.innerHTML = `
427 |
428 |
渲染失败
429 |
${escapeHtml(error.message)}
430 |
431 | `;
432 | setStatus('渲染失败', 5000);
433 | }
434 | }
435 |
436 | /**
437 | * 渲染所有 Mermaid 图表
438 | */
439 | async function renderMermaidCharts() {
440 | const mermaidElements = elements.preview.querySelectorAll('.mermaid');
441 |
442 | if (mermaidElements.length === 0) return;
443 |
444 | // 重新初始化 Mermaid(以应用主题)
445 | initMermaid();
446 |
447 | // 渲染每个图表
448 | for (let i = 0; i < mermaidElements.length; i++) {
449 | const element = mermaidElements[i];
450 | const code = element.textContent;
451 |
452 | try {
453 | // 生成唯一 ID
454 | const id = `mermaid-${Date.now()}-${i}`;
455 |
456 | // 渲染图表
457 | const { svg } = await mermaid.render(id, code);
458 |
459 | // 创建容器包装 SVG 和导出按钮
460 | const wrapper = document.createElement('div');
461 | wrapper.className = 'mermaid-wrapper';
462 | wrapper.innerHTML = `
463 | ${svg}
464 |
465 |
469 |
473 |
477 |
478 | `;
479 |
480 | // 替换元素内容
481 | element.innerHTML = '';
482 | element.appendChild(wrapper);
483 |
484 | // 绑定导出事件
485 | bindMermaidExportEvents(wrapper, id);
486 |
487 | } catch (error) {
488 | console.error('Mermaid 渲染错误:', error);
489 | element.innerHTML = `
490 |
491 |
Mermaid 图表渲染失败
492 |
${escapeHtml(error.message)}
493 |
${escapeHtml(code)}
494 |
495 | `;
496 | }
497 | }
498 | }
499 |
500 | /**
501 | * 绑定 Mermaid 图表导出事件
502 | */
503 | function bindMermaidExportEvents(wrapper, diagramId) {
504 | const exportButtons = wrapper.querySelectorAll('.mermaid-export-btn');
505 |
506 | exportButtons.forEach(btn => {
507 | btn.addEventListener('click', (e) => {
508 | // 防止重复点击
509 | if (btn.disabled) {
510 | console.log('按钮已禁用,忽略点击');
511 | return;
512 | }
513 |
514 | const action = btn.getAttribute('data-action');
515 | const format = btn.getAttribute('data-format');
516 | const svgElement = wrapper.querySelector('svg');
517 |
518 | if (!svgElement) {
519 | console.error('找不到 SVG 元素');
520 | setStatus('操作失败:找不到图表 ❌', 3000);
521 | return;
522 | }
523 |
524 | // 处理全屏按钮
525 | if (action === 'fullscreen') {
526 | openMermaidFullscreenViewer(svgElement, diagramId, wrapper);
527 | return;
528 | }
529 |
530 | // 禁用按钮,防止重复点击
531 | btn.disabled = true;
532 | btn.style.opacity = '0.5';
533 | btn.style.cursor = 'not-allowed';
534 |
535 | // 延迟后恢复按钮状态
536 | const enableButton = () => {
537 | setTimeout(() => {
538 | btn.disabled = false;
539 | btn.style.opacity = '';
540 | btn.style.cursor = '';
541 | }, 1000);
542 | };
543 |
544 | if (format === 'svg') {
545 | exportMermaidAsSVG(svgElement, diagramId);
546 | enableButton();
547 | } else if (format === 'png') {
548 | exportMermaidAsPNG(svgElement, diagramId);
549 | enableButton();
550 | }
551 | });
552 | });
553 | }
554 |
555 | /**
556 | * 导出 Mermaid 图表为 SVG
557 | */
558 | function exportMermaidAsSVG(svgElement, diagramId) {
559 | try {
560 | setStatus('正在导出 SVG...');
561 |
562 | // 克隆 SVG 元素
563 | const svgClone = svgElement.cloneNode(true);
564 |
565 | // 获取 SVG 字符串
566 | const serializer = new XMLSerializer();
567 | const svgString = serializer.serializeToString(svgClone);
568 |
569 | // 添加 XML 声明和样式
570 | const fullSvg = `
571 |
572 | ${svgString}`;
573 |
574 | // 创建 Blob 并下载
575 | const blob = new Blob([fullSvg], { type: 'image/svg+xml' });
576 | const url = URL.createObjectURL(blob);
577 | const a = document.createElement('a');
578 | a.href = url;
579 | a.download = `${diagramId}.svg`;
580 | a.click();
581 | URL.revokeObjectURL(url);
582 |
583 | setStatus('SVG 导出成功 ✅');
584 | } catch (error) {
585 | console.error('SVG 导出失败:', error);
586 | setStatus('SVG 导出失败 ❌', 3000);
587 | }
588 | }
589 |
590 | /**
591 | * 导出 Mermaid 图表为 PNG
592 | */
593 | function exportMermaidAsPNG(svgElement, diagramId) {
594 | try {
595 | setStatus('正在导出 PNG...');
596 | console.log('开始导出 PNG:', diagramId);
597 |
598 | // 获取 SVG 尺寸
599 | const bbox = svgElement.getBoundingClientRect();
600 | const width = Math.floor(bbox.width);
601 | const height = Math.floor(bbox.height);
602 |
603 | console.log('SVG 尺寸:', width, 'x', height);
604 |
605 | // 检查尺寸是否有效
606 | if (width <= 0 || height <= 0) {
607 | throw new Error('SVG 尺寸无效');
608 | }
609 |
610 | // 创建 canvas
611 | const canvas = document.createElement('canvas');
612 | const scale = 2; // 提高清晰度
613 | canvas.width = width * scale;
614 | canvas.height = height * scale;
615 | const ctx = canvas.getContext('2d');
616 | ctx.scale(scale, scale);
617 |
618 | // 根据当前主题设置背景色
619 | const bgColor = AppState.currentTheme === 'dark' ? '#0d1117' : '#ffffff';
620 | ctx.fillStyle = bgColor;
621 | ctx.fillRect(0, 0, width, height);
622 |
623 | // 将 SVG 转换为图片
624 | const svgClone = svgElement.cloneNode(true);
625 |
626 | // 确保 SVG 有正确的命名空间
627 | if (!svgClone.getAttribute('xmlns')) {
628 | svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
629 | }
630 |
631 | const serializer = new XMLSerializer();
632 | let svgString = serializer.serializeToString(svgClone);
633 |
634 | // 编码 SVG 为 data URL(更可靠)
635 | const svgBase64 = btoa(unescape(encodeURIComponent(svgString)));
636 | const dataUrl = `data:image/svg+xml;base64,${svgBase64}`;
637 |
638 | const img = new Image();
639 |
640 | // 设置超时(10秒)
641 | const timeout = setTimeout(() => {
642 | console.error('PNG 导出超时');
643 | setStatus('PNG 导出超时 ⏱️ 请重试或使用 SVG 格式', 5000);
644 | alert('PNG 导出超时\n\n可能原因:\n1. 图表太大或太复杂\n2. 浏览器性能限制\n\n建议:\n• 再次点击重试\n• 或使用 SVG 格式导出');
645 | }, 10000);
646 |
647 | img.onload = () => {
648 | clearTimeout(timeout);
649 | console.log('图片加载成功');
650 |
651 | try {
652 | ctx.drawImage(img, 0, 0, width, height);
653 |
654 | // 导出为 PNG
655 | canvas.toBlob((blob) => {
656 | if (!blob) {
657 | console.error('Canvas toBlob 失败');
658 | setStatus('PNG 转换失败 ❌', 3000);
659 | return;
660 | }
661 |
662 | console.log('PNG Blob 创建成功,大小:', blob.size);
663 |
664 | const pngUrl = URL.createObjectURL(blob);
665 | const a = document.createElement('a');
666 | a.href = pngUrl;
667 | a.download = `${diagramId}.png`;
668 | document.body.appendChild(a);
669 | a.click();
670 | document.body.removeChild(a);
671 |
672 | // 延迟释放 URL
673 | setTimeout(() => {
674 | URL.revokeObjectURL(pngUrl);
675 | }, 100);
676 |
677 | setStatus('PNG 导出成功 ✅');
678 | console.log('PNG 导出完成');
679 | }, 'image/png');
680 | } catch (err) {
681 | clearTimeout(timeout);
682 | console.error('绘制或导出失败:', err);
683 | setStatus('PNG 导出失败 ❌', 3000);
684 | }
685 | };
686 |
687 | img.onerror = (err) => {
688 | clearTimeout(timeout);
689 | console.error('图片加载失败:', err);
690 | setStatus('PNG 导出失败 ❌ 建议使用 SVG 格式', 5000);
691 |
692 | // 提示用户
693 | if (confirm('PNG 导出失败\n\n建议改用 SVG 格式导出(矢量图,质量更好)\n\n是否立即导出为 SVG?')) {
694 | exportMermaidAsSVG(svgElement, diagramId);
695 | }
696 | };
697 |
698 | // 设置图片源
699 | img.src = dataUrl;
700 |
701 | } catch (error) {
702 | console.error('PNG 导出异常:', error);
703 | setStatus(`PNG 导出失败 ❌`, 5000);
704 |
705 | // 显示详细错误信息
706 | alert(`PNG 导出失败\n\n错误信息:${error.message}\n\n可能的解决方案:\n1. 刷新页面后重试\n2. 使用 SVG 格式导出\n3. 尝试缩小图表大小\n4. 使用其他浏览器\n\n如果问题持续,请打开浏览器控制台(F12)查看详细日志。`);
707 | }
708 | }
709 |
710 | /**
711 | * 打开 Mermaid 全屏查看器(支持缩放和拖拽)
712 | * 采用新方法:直接移动 SVG 元素,避免克隆导致的尺寸和样式问题
713 | */
714 | function openMermaidFullscreenViewer(svgElement, diagramId, originalWrapper) {
715 | // 获取当前主题
716 | const currentTheme = document.body.getAttribute('data-theme') || 'light';
717 |
718 | // 获取 SVG 的实际尺寸(使用多种方法确保准确性)
719 | let svgWidth, svgHeight;
720 |
721 | // 方法1: 从 getBoundingClientRect 获取(最准确,反映实际渲染尺寸)
722 | const rect = svgElement.getBoundingClientRect();
723 | if (rect.width > 0 && rect.height > 0) {
724 | svgWidth = rect.width;
725 | svgHeight = rect.height;
726 | }
727 |
728 | // 方法2: 从 getBBox 获取(SVG 内部尺寸)
729 | if ((!svgWidth || svgWidth === 0) && svgElement.getBBox) {
730 | try {
731 | const bbox = svgElement.getBBox();
732 | if (bbox.width > 0 && bbox.height > 0) {
733 | svgWidth = bbox.width;
734 | svgHeight = bbox.height;
735 | }
736 | } catch (e) {
737 | // getBBox 可能失败,继续尝试其他方法
738 | }
739 | }
740 |
741 | // 方法3: 从 viewBox 获取
742 | if (!svgWidth || svgWidth === 0) {
743 | const viewBox = svgElement.getAttribute('viewBox');
744 | if (viewBox) {
745 | const vb = viewBox.split(/\s+|,/).filter(v => v);
746 | if (vb.length >= 4) {
747 | svgWidth = parseFloat(vb[2]);
748 | svgHeight = parseFloat(vb[3]);
749 | }
750 | }
751 | }
752 |
753 | // 方法4: 从 width/height 属性获取
754 | if (!svgWidth || svgWidth === 0) {
755 | const widthAttr = svgElement.getAttribute('width');
756 | const heightAttr = svgElement.getAttribute('height');
757 | if (widthAttr && heightAttr) {
758 | svgWidth = parseFloat(widthAttr);
759 | svgHeight = parseFloat(heightAttr);
760 | }
761 | }
762 |
763 | // 如果还是没有尺寸,使用默认值
764 | if (!svgWidth || svgWidth === 0) {
765 | svgWidth = 800;
766 | svgHeight = 600;
767 | console.warn('无法获取 SVG 尺寸,使用默认值', {
768 | rect: rect,
769 | viewBox: svgElement.getAttribute('viewBox'),
770 | width: svgElement.getAttribute('width'),
771 | height: svgElement.getAttribute('height')
772 | });
773 | }
774 |
775 | console.log('获取到的 SVG 尺寸:', { svgWidth, svgHeight });
776 |
777 | // 创建全屏容器
778 | const viewer = document.createElement('div');
779 | viewer.className = 'mermaid-fullscreen-viewer';
780 | viewer.setAttribute('data-theme', currentTheme);
781 | viewer.id = `mermaid-viewer-${diagramId}`;
782 |
783 | // 创建查看器内容
784 | viewer.innerHTML = `
785 |
802 |
807 |
810 | `;
811 |
812 | // 添加到 body
813 | document.body.appendChild(viewer);
814 | document.body.classList.add('mermaid-viewer-active');
815 |
816 | // 克隆 SVG 而不是移动它(避免恢复问题)
817 | // 使用 outerHTML 克隆可以保留所有样式和属性
818 | const svgClone = svgElement.cloneNode(true);
819 |
820 | // 确保克隆的 SVG 有正确的尺寸属性
821 | if (!svgClone.getAttribute('width') || svgClone.getAttribute('width') === '0') {
822 | svgClone.setAttribute('width', svgWidth);
823 | }
824 | if (!svgClone.getAttribute('height') || svgClone.getAttribute('height') === '0') {
825 | svgClone.setAttribute('height', svgHeight);
826 | }
827 |
828 | // 获取包装器并添加克隆的 SVG
829 | const wrapper = viewer.querySelector('.mermaid-viewer-svg-wrapper');
830 | wrapper.appendChild(svgClone);
831 |
832 | // 保存信息到 viewer(不需要恢复,因为原始 SVG 没有被移动)
833 | viewer._svgWidth = svgWidth;
834 | viewer._svgHeight = svgHeight;
835 |
836 | // 确保 SVG 立即可见,避免尺寸为 0
837 | svgElement.style.display = 'block';
838 | svgElement.style.visibility = 'visible';
839 | svgElement.style.maxWidth = 'none';
840 | svgElement.style.maxHeight = 'none';
841 |
842 | // 如果 SVG 没有明确的尺寸属性,立即设置
843 | if (!svgElement.getAttribute('width') || svgElement.getAttribute('width') === '0') {
844 | svgElement.setAttribute('width', svgWidth);
845 | }
846 | if (!svgElement.getAttribute('height') || svgElement.getAttribute('height') === '0') {
847 | svgElement.setAttribute('height', svgHeight);
848 | }
849 |
850 | // 等待 DOM 渲染完成后再初始化(使用多个 requestAnimationFrame 确保完全渲染)
851 | requestAnimationFrame(() => {
852 | requestAnimationFrame(() => {
853 | setTimeout(() => {
854 | // 再次验证尺寸,如果保存的尺寸无效,尝试重新获取
855 | if (!viewer._svgWidth || viewer._svgWidth === 0) {
856 | const newRect = svgElement.getBoundingClientRect();
857 | if (newRect.width > 0 && newRect.height > 0) {
858 | viewer._svgWidth = newRect.width;
859 | viewer._svgHeight = newRect.height;
860 | console.log('重新获取 SVG 尺寸:', { width: viewer._svgWidth, height: viewer._svgHeight });
861 | }
862 | }
863 | initMermaidViewer(viewer, svgClone);
864 | }, 100);
865 | });
866 | });
867 |
868 | setStatus('已打开全屏查看器');
869 | }
870 |
871 | /**
872 | * 初始化 Mermaid 查看器(缩放和拖拽功能)
873 | */
874 | function initMermaidViewer(viewer, svgElement) {
875 | const container = viewer.querySelector('.mermaid-viewer-svg-container');
876 | const wrapper = viewer.querySelector('.mermaid-viewer-svg-wrapper');
877 | const svg = svgElement;
878 |
879 | if (!container || !wrapper || !svg) {
880 | console.error('查看器初始化失败:找不到容器或 SVG', { container, wrapper, svg });
881 | return;
882 | }
883 |
884 | // 获取 SVG 尺寸(优先使用保存的尺寸)
885 | let svgWidth = viewer._svgWidth;
886 | let svgHeight = viewer._svgHeight;
887 |
888 | // 如果保存的尺寸无效,尝试从当前 SVG 获取
889 | if (!svgWidth || svgWidth === 0) {
890 | // 方法1: 从 getBBox 获取(SVG 内部尺寸,最可靠)
891 | if (svg.getBBox) {
892 | try {
893 | const bbox = svg.getBBox();
894 | if (bbox.width > 0 && bbox.height > 0) {
895 | svgWidth = bbox.width;
896 | svgHeight = bbox.height;
897 | }
898 | } catch (e) {
899 | console.warn('无法从 getBBox 获取尺寸', e);
900 | }
901 | }
902 |
903 | // 方法2: 从 viewBox 获取
904 | if (!svgWidth || svgWidth === 0) {
905 | const viewBox = svg.getAttribute('viewBox');
906 | if (viewBox) {
907 | const vb = viewBox.split(/\s+|,/).filter(v => v);
908 | if (vb.length >= 4) {
909 | svgWidth = parseFloat(vb[2]);
910 | svgHeight = parseFloat(vb[3]);
911 | }
912 | }
913 | }
914 |
915 | // 方法3: 从属性获取
916 | if (!svgWidth || svgWidth === 0) {
917 | const widthAttr = svg.getAttribute('width');
918 | const heightAttr = svg.getAttribute('height');
919 | if (widthAttr && heightAttr) {
920 | svgWidth = parseFloat(widthAttr);
921 | svgHeight = parseFloat(heightAttr);
922 | }
923 | }
924 |
925 | // 方法4: 从 getBoundingClientRect 获取(可能不准确,因为 SVG 刚移动)
926 | if (!svgWidth || svgWidth === 0) {
927 | const rect = svg.getBoundingClientRect();
928 | if (rect.width > 0 && rect.height > 0) {
929 | svgWidth = rect.width;
930 | svgHeight = rect.height;
931 | }
932 | }
933 |
934 | // 如果还是没有尺寸,使用默认值
935 | if (!svgWidth || svgWidth === 0) {
936 | svgWidth = 800;
937 | svgHeight = 600;
938 | console.warn('无法获取 SVG 尺寸,使用默认值');
939 | }
940 | }
941 |
942 | // 确保 SVG 有明确的尺寸属性(如果缺失则添加)
943 | if (!svg.getAttribute('width') || svg.getAttribute('width') === '0') {
944 | svg.setAttribute('width', svgWidth);
945 | }
946 | if (!svg.getAttribute('height') || svg.getAttribute('height') === '0') {
947 | svg.setAttribute('height', svgHeight);
948 | }
949 |
950 | // 确保 SVG 可见且有尺寸
951 | svg.style.display = 'block';
952 | svg.style.visibility = 'visible';
953 | svg.style.maxWidth = 'none';
954 | svg.style.maxHeight = 'none';
955 |
956 | console.log('初始化时的 SVG 尺寸:', {
957 | svgWidth,
958 | svgHeight,
959 | savedSize: { width: viewer._svgWidth, height: viewer._svgHeight },
960 | attrSize: { width: svg.getAttribute('width'), height: svg.getAttribute('height') },
961 | viewBox: svg.getAttribute('viewBox'),
962 | bbox: svg.getBBox ? (() => {
963 | try {
964 | const b = svg.getBBox();
965 | return { width: b.width, height: b.height };
966 | } catch (e) {
967 | return null;
968 | }
969 | })() : null
970 | });
971 |
972 | // 查看器状态
973 | const state = {
974 | scale: 1,
975 | translateX: 0,
976 | translateY: 0,
977 | isDragging: false,
978 | startX: 0,
979 | startY: 0,
980 | startTranslateX: 0,
981 | startTranslateY: 0,
982 | minScale: 0.1,
983 | maxScale: 5,
984 | svgWidth: svgWidth,
985 | svgHeight: svgHeight
986 | };
987 |
988 | // 更新 SVG 包装器的变换
989 | function updateTransform() {
990 | wrapper.style.transform = `translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale})`;
991 | wrapper.style.transformOrigin = '0 0';
992 | }
993 |
994 | // 居中显示函数
995 | function centerView() {
996 | const containerRect = container.getBoundingClientRect();
997 | const actualWidth = state.svgWidth * state.scale;
998 | const actualHeight = state.svgHeight * state.scale;
999 |
1000 | state.translateX = (containerRect.width - actualWidth) / 2;
1001 | state.translateY = (containerRect.height - actualHeight) / 2;
1002 | updateTransform();
1003 | }
1004 |
1005 | // 重置视图并居中
1006 | function resetView() {
1007 | state.scale = 1;
1008 | centerView();
1009 |
1010 | // 延迟再次居中,确保 SVG 完全渲染
1011 | setTimeout(() => {
1012 | centerView();
1013 | }, 50);
1014 | }
1015 |
1016 | // 缩放
1017 | function zoom(delta, clientX, clientY) {
1018 | const rect = container.getBoundingClientRect();
1019 |
1020 | // 计算鼠标相对于容器的位置
1021 | const mouseX = clientX - rect.left;
1022 | const mouseY = clientY - rect.top;
1023 |
1024 | // 计算鼠标相对于当前 SVG 的位置(考虑当前的变换)
1025 | const svgX = (mouseX - state.translateX) / state.scale;
1026 | const svgY = (mouseY - state.translateY) / state.scale;
1027 |
1028 | const oldScale = state.scale;
1029 | const newScale = Math.max(state.minScale, Math.min(state.maxScale, state.scale + delta));
1030 |
1031 | if (newScale === oldScale) return;
1032 |
1033 | // 以鼠标位置为中心缩放
1034 | state.translateX = mouseX - svgX * newScale;
1035 | state.translateY = mouseY - svgY * newScale;
1036 | state.scale = newScale;
1037 |
1038 | updateTransform();
1039 | }
1040 |
1041 | // 鼠标滚轮缩放
1042 | container.addEventListener('wheel', (e) => {
1043 | e.preventDefault();
1044 | e.stopPropagation();
1045 | const delta = -e.deltaY * 0.01;
1046 | zoom(delta, e.clientX, e.clientY);
1047 | }, { passive: false });
1048 |
1049 | // 也监听 viewer-content 的滚轮事件
1050 | const content = viewer.querySelector('.mermaid-viewer-content');
1051 | if (content) {
1052 | content.addEventListener('wheel', (e) => {
1053 | e.preventDefault();
1054 | e.stopPropagation();
1055 | const delta = -e.deltaY * 0.01;
1056 | zoom(delta, e.clientX, e.clientY);
1057 | }, { passive: false });
1058 | }
1059 |
1060 | // 鼠标拖拽
1061 | const handleMouseDown = (e) => {
1062 | if (e.button !== 0) return; // 只处理左键
1063 | // 如果点击的是按钮,不触发拖拽
1064 | if (e.target.closest('.mermaid-viewer-btn')) return;
1065 |
1066 | state.isDragging = true;
1067 | state.startX = e.clientX;
1068 | state.startY = e.clientY;
1069 | state.startTranslateX = state.translateX;
1070 | state.startTranslateY = state.translateY;
1071 | container.style.cursor = 'grabbing';
1072 | wrapper.style.cursor = 'grabbing';
1073 | e.preventDefault();
1074 | };
1075 |
1076 | const handleMouseMove = (e) => {
1077 | if (!state.isDragging) return;
1078 | state.translateX = state.startTranslateX + (e.clientX - state.startX);
1079 | state.translateY = state.startTranslateY + (e.clientY - state.startY);
1080 | updateTransform();
1081 | };
1082 |
1083 | const handleMouseUp = () => {
1084 | if (state.isDragging) {
1085 | state.isDragging = false;
1086 | container.style.cursor = 'grab';
1087 | wrapper.style.cursor = 'grab';
1088 | }
1089 | };
1090 |
1091 | container.addEventListener('mousedown', handleMouseDown);
1092 | wrapper.addEventListener('mousedown', handleMouseDown);
1093 | document.addEventListener('mousemove', handleMouseMove);
1094 | document.addEventListener('mouseup', handleMouseUp);
1095 |
1096 | // 按钮事件
1097 | const zoomInBtn = viewer.querySelector('[data-action="zoom-in"]');
1098 | const zoomOutBtn = viewer.querySelector('[data-action="zoom-out"]');
1099 | const resetBtn = viewer.querySelector('[data-action="reset"]');
1100 | const closeBtn = viewer.querySelector('[data-action="close"]');
1101 |
1102 | if (zoomInBtn) {
1103 | zoomInBtn.addEventListener('click', () => {
1104 | const rect = container.getBoundingClientRect();
1105 | zoom(0.1, rect.left + rect.width / 2, rect.top + rect.height / 2);
1106 | });
1107 | }
1108 |
1109 | if (zoomOutBtn) {
1110 | zoomOutBtn.addEventListener('click', () => {
1111 | const rect = container.getBoundingClientRect();
1112 | zoom(-0.1, rect.left + rect.width / 2, rect.top + rect.height / 2);
1113 | });
1114 | }
1115 |
1116 | if (resetBtn) {
1117 | resetBtn.addEventListener('click', resetView);
1118 | }
1119 |
1120 | if (closeBtn) {
1121 | closeBtn.addEventListener('click', () => {
1122 | closeMermaidViewer(viewer);
1123 | });
1124 | }
1125 |
1126 | // ESC 键关闭
1127 | const handleEsc = (e) => {
1128 | if (e.key === 'Escape' && document.body.contains(viewer)) {
1129 | closeMermaidViewer(viewer);
1130 | document.removeEventListener('keydown', handleEsc);
1131 | }
1132 | };
1133 | document.addEventListener('keydown', handleEsc);
1134 |
1135 | // 保存状态和事件处理器
1136 | viewer._viewerState = state;
1137 | viewer._viewerEscHandler = handleEsc;
1138 |
1139 | // 初始化样式
1140 | container.style.cursor = 'grab';
1141 | wrapper.style.cursor = 'grab';
1142 | wrapper.style.display = 'inline-block';
1143 | wrapper.style.willChange = 'transform';
1144 |
1145 | // 初始居中显示
1146 | centerView();
1147 |
1148 | // 延迟再次居中,确保 SVG 完全渲染
1149 | setTimeout(centerView, 100);
1150 | setTimeout(centerView, 300);
1151 |
1152 | updateTransform();
1153 | }
1154 |
1155 | /**
1156 | * 关闭 Mermaid 查看器(不需要恢复,因为使用的是克隆的 SVG)
1157 | */
1158 | function closeMermaidViewer(viewer) {
1159 | // 清理事件监听器
1160 | if (viewer._viewerEscHandler) {
1161 | document.removeEventListener('keydown', viewer._viewerEscHandler);
1162 | }
1163 |
1164 | // 移除查看器(克隆的 SVG 会随着查看器一起被移除)
1165 | if (document.body.contains(viewer)) {
1166 | document.body.removeChild(viewer);
1167 | }
1168 | document.body.classList.remove('mermaid-viewer-active');
1169 |
1170 | setStatus('已关闭全屏查看器');
1171 | }
1172 |
1173 | /**
1174 | * 防抖渲染(避免输入时频繁渲染)
1175 | */
1176 | const debouncedRender = debounce(renderMarkdown, 300);
1177 |
1178 | // ==================== 主题切换 ====================
1179 |
1180 | /**
1181 | * 切换主题
1182 | */
1183 | function toggleTheme() {
1184 | AppState.currentTheme = AppState.currentTheme === 'light' ? 'dark' : 'light';
1185 | document.body.setAttribute('data-theme', AppState.currentTheme);
1186 |
1187 | // 更新主题图标
1188 | const themeIcon = elements.themeBtn.querySelector('use');
1189 | themeIcon.setAttribute('href',
1190 | AppState.currentTheme === 'dark' ? '#icon-theme-light' : '#icon-theme-dark');
1191 |
1192 | // 更新 CodeMirror 主题
1193 | updateEditorTheme(AppState.currentTheme === 'dark');
1194 |
1195 | // 切换代码高亮主题
1196 | const lightTheme = document.getElementById('highlight-light');
1197 | const darkTheme = document.getElementById('highlight-dark');
1198 | if (AppState.currentTheme === 'dark') {
1199 | lightTheme.disabled = true;
1200 | darkTheme.disabled = false;
1201 | } else {
1202 | lightTheme.disabled = false;
1203 | darkTheme.disabled = true;
1204 | }
1205 |
1206 | // 保存到 localStorage
1207 | localStorage.setItem('markx-theme', AppState.currentTheme);
1208 |
1209 | // 重新渲染 Mermaid 图表(应用新主题)
1210 | renderMarkdown();
1211 |
1212 | setStatus(`已切换到${AppState.currentTheme === 'dark' ? '暗色' : '亮色'}模式`);
1213 | }
1214 |
1215 | /**
1216 | * 初始化主题
1217 | */
1218 | function initTheme() {
1219 | // 从 localStorage 读取主题设置
1220 | const savedTheme = localStorage.getItem('markx-theme');
1221 | if (savedTheme) {
1222 | AppState.currentTheme = savedTheme;
1223 | } else {
1224 | // 检测系统主题偏好
1225 | if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
1226 | AppState.currentTheme = 'dark';
1227 | }
1228 | }
1229 |
1230 | document.body.setAttribute('data-theme', AppState.currentTheme);
1231 | const themeIcon = elements.themeBtn.querySelector('use');
1232 | themeIcon.setAttribute('href',
1233 | AppState.currentTheme === 'dark' ? '#icon-theme-light' : '#icon-theme-dark');
1234 |
1235 | // 更新 CodeMirror 主题
1236 | updateEditorTheme(AppState.currentTheme === 'dark');
1237 |
1238 | // 设置代码高亮主题
1239 | const lightTheme = document.getElementById('highlight-light');
1240 | const darkTheme = document.getElementById('highlight-dark');
1241 | if (AppState.currentTheme === 'dark') {
1242 | lightTheme.disabled = true;
1243 | darkTheme.disabled = false;
1244 | }
1245 | }
1246 |
1247 | // ==================== 布局切换 ====================
1248 |
1249 | /**
1250 | * 切换布局模式
1251 | */
1252 | function toggleLayout() {
1253 | const layouts = ['split', 'editor-only', 'preview-only', 'vertical'];
1254 | const currentIndex = layouts.indexOf(AppState.currentLayout);
1255 | const nextIndex = (currentIndex + 1) % layouts.length;
1256 | AppState.currentLayout = layouts[nextIndex];
1257 |
1258 | // 移除所有布局类
1259 | document.body.classList.remove(
1260 | 'layout-editor-only',
1261 | 'layout-preview-only',
1262 | 'layout-vertical'
1263 | );
1264 |
1265 | // 添加新布局类
1266 | if (AppState.currentLayout !== 'split') {
1267 | document.body.classList.add(`layout-${AppState.currentLayout}`);
1268 | }
1269 |
1270 | const layoutNames = {
1271 | 'split': '分屏',
1272 | 'editor-only': '仅编辑器',
1273 | 'preview-only': '仅预览',
1274 | 'vertical': '上下分屏'
1275 | };
1276 |
1277 | setStatus(`布局: ${layoutNames[AppState.currentLayout]}`);
1278 | }
1279 |
1280 | // ==================== 编辑器右侧拖拽调整大小 ====================
1281 |
1282 | let resizeState = {
1283 | isDragging: false,
1284 | startX: 0,
1285 | startEditorWidth: 0,
1286 | startPreviewWidth: 0
1287 | };
1288 |
1289 | /**
1290 | * 初始化编辑器右侧拖拽功能
1291 | */
1292 | function initEditorResize() {
1293 | if (!elements.editorContainer || !elements.previewContainer) {
1294 | console.warn('编辑器容器未找到,无法初始化拖拽功能');
1295 | return;
1296 | }
1297 |
1298 | // 鼠标移动时检查是否在拖拽区域
1299 | const handleMouseMove = (e) => {
1300 | if (AppState.currentLayout !== 'split') return;
1301 |
1302 | const rect = elements.editorContainer.getBoundingClientRect();
1303 | const clickX = e.clientX;
1304 | const rightEdge = rect.right;
1305 |
1306 | // 如果鼠标在右边框附近,改变光标
1307 | if (clickX >= rightEdge - 4 && clickX <= rightEdge + 4 && !resizeState.isDragging) {
1308 | document.body.style.cursor = 'col-resize';
1309 | } else if (!resizeState.isDragging) {
1310 | document.body.style.cursor = '';
1311 | }
1312 |
1313 | // 如果正在拖拽,调整大小
1314 | if (resizeState.isDragging) {
1315 | const deltaX = e.clientX - resizeState.startX;
1316 | const totalWidth = resizeState.startEditorWidth + resizeState.startPreviewWidth;
1317 |
1318 | // 计算新宽度(限制最小宽度)
1319 | const minWidth = 200;
1320 | const newEditorWidth = Math.max(minWidth, Math.min(totalWidth - minWidth, resizeState.startEditorWidth + deltaX));
1321 | const newPreviewWidth = totalWidth - newEditorWidth;
1322 |
1323 | // 使用 flex-basis 设置宽度
1324 | elements.editorContainer.style.flex = `0 0 ${newEditorWidth}px`;
1325 | elements.previewContainer.style.flex = `0 0 ${newPreviewWidth}px`;
1326 |
1327 | // 禁用过渡动画(拖拽时)
1328 | elements.editorContainer.style.transition = 'none';
1329 | elements.previewContainer.style.transition = 'none';
1330 |
1331 | e.preventDefault();
1332 | }
1333 | };
1334 |
1335 | // 鼠标按下事件
1336 | const handleMouseDown = (e) => {
1337 | if (AppState.currentLayout !== 'split') return;
1338 |
1339 | const rect = elements.editorContainer.getBoundingClientRect();
1340 | const clickX = e.clientX;
1341 | const rightEdge = rect.right;
1342 |
1343 | // 如果点击在右边框附近 8px 范围内,开始拖拽
1344 | if (clickX >= rightEdge - 4 && clickX <= rightEdge + 4) {
1345 | resizeState.isDragging = true;
1346 | resizeState.startX = e.clientX;
1347 |
1348 | // 获取当前容器宽度
1349 | const editorRect = elements.editorContainer.getBoundingClientRect();
1350 | const previewRect = elements.previewContainer.getBoundingClientRect();
1351 | resizeState.startEditorWidth = editorRect.width;
1352 | resizeState.startPreviewWidth = previewRect.width;
1353 |
1354 | // 添加拖拽样式
1355 | elements.editorContainer.classList.add('dragging');
1356 | document.body.style.cursor = 'col-resize';
1357 | document.body.style.userSelect = 'none';
1358 |
1359 | e.preventDefault();
1360 | e.stopPropagation();
1361 | }
1362 | };
1363 |
1364 | // 鼠标释放事件
1365 | const handleMouseUp = () => {
1366 | if (resizeState.isDragging) {
1367 | resizeState.isDragging = false;
1368 | elements.editorContainer.classList.remove('dragging');
1369 | document.body.style.cursor = '';
1370 | document.body.style.userSelect = '';
1371 |
1372 | // 恢复过渡动画
1373 | elements.editorContainer.style.transition = '';
1374 | elements.previewContainer.style.transition = '';
1375 | }
1376 | };
1377 |
1378 | // 绑定事件到文档级别,确保能捕获到事件
1379 | document.addEventListener('mousemove', handleMouseMove);
1380 | document.addEventListener('mousedown', handleMouseDown);
1381 | document.addEventListener('mouseup', handleMouseUp);
1382 |
1383 | console.log('✅ 编辑器拖拽功能已初始化', {
1384 | editorContainer: !!elements.editorContainer,
1385 | previewContainer: !!elements.previewContainer
1386 | });
1387 | }
1388 |
1389 | // ==================== 滚动同步 ====================
1390 |
1391 | let scrollSyncState = {
1392 | isSyncing: false,
1393 | editorScrollHandler: null,
1394 | previewScrollHandler: null,
1395 | lastEditorScrollTop: 0,
1396 | pollingInterval: null
1397 | };
1398 |
1399 | /**
1400 | * 初始化滚动同步功能
1401 | */
1402 | function initScrollSync() {
1403 | // 如果编辑器还没初始化,延迟重试
1404 | if (!aceEditor) {
1405 | setTimeout(() => {
1406 | if (aceEditor) {
1407 | initScrollSync();
1408 | }
1409 | }, 1000);
1410 | return;
1411 | }
1412 |
1413 | if (!elements.previewContainer) {
1414 | console.warn('预览容器未找到,无法初始化滚动同步');
1415 | return;
1416 | }
1417 |
1418 | // 移除旧的事件监听器(如果存在)
1419 | if (scrollSyncState.editorScrollHandler) {
1420 | // 尝试多种方式移除事件监听器
1421 | try {
1422 | aceEditor.off('changeScrollTop', scrollSyncState.editorScrollHandler);
1423 | } catch (e) {}
1424 | try {
1425 | aceEditor.renderer.off('scroll', scrollSyncState.editorScrollHandler);
1426 | } catch (e) {}
1427 | try {
1428 | aceEditor.renderer.container.removeEventListener('scroll', scrollSyncState.editorScrollHandler);
1429 | } catch (e) {}
1430 | }
1431 | if (scrollSyncState.previewScrollHandler) {
1432 | elements.previewContainer.removeEventListener('scroll', scrollSyncState.previewScrollHandler);
1433 | }
1434 |
1435 | // 获取编辑器的滚动信息
1436 | const getEditorScrollInfo = () => {
1437 | try {
1438 | const renderer = aceEditor.renderer;
1439 | const session = aceEditor.getSession();
1440 | const scrollTop = renderer.getScrollTop();
1441 | const lineHeight = renderer.lineHeight;
1442 | const screenLines = session.getScreenLength();
1443 | const container = renderer.container;
1444 | const clientHeight = container.clientHeight;
1445 | const maxScroll = Math.max(0, (screenLines * lineHeight) - clientHeight);
1446 | return { scrollTop, maxScroll };
1447 | } catch (error) {
1448 | // 备用方法:使用容器的 scrollHeight
1449 | const container = aceEditor.renderer.container;
1450 | const scrollTop = container.scrollTop;
1451 | const maxScroll = Math.max(0, container.scrollHeight - container.clientHeight);
1452 | return { scrollTop, maxScroll };
1453 | }
1454 | };
1455 |
1456 | // 设置编辑器的滚动位置
1457 | const setEditorScrollTop = (scrollTop) => {
1458 | try {
1459 | // 尝试使用 renderer.setScrollTop
1460 | if (typeof aceEditor.renderer.setScrollTop === 'function') {
1461 | aceEditor.renderer.setScrollTop(scrollTop);
1462 | } else {
1463 | // 使用 session.setScrollTop
1464 | aceEditor.getSession().setScrollTop(scrollTop);
1465 | }
1466 | } catch (error) {
1467 | // 最后尝试直接设置容器的 scrollTop
1468 | aceEditor.renderer.container.scrollTop = scrollTop;
1469 | }
1470 | };
1471 |
1472 | // 编辑器滚动同步到预览区
1473 | scrollSyncState.editorScrollHandler = () => {
1474 | if (scrollSyncState.isSyncing || AppState.currentLayout !== 'split') return;
1475 | scrollSyncState.isSyncing = true;
1476 |
1477 | try {
1478 | const { scrollTop, maxScroll } = getEditorScrollInfo();
1479 |
1480 | // 更新最后滚动位置
1481 | scrollSyncState.lastEditorScrollTop = scrollTop;
1482 |
1483 | // 计算滚动百分比(避免除以零)
1484 | if (maxScroll <= 0) {
1485 | scrollSyncState.isSyncing = false;
1486 | return;
1487 | }
1488 | const scrollPercent = Math.max(0, Math.min(1, scrollTop / maxScroll));
1489 |
1490 | // 同步到预览区
1491 | const previewScrollHeight = elements.previewContainer.scrollHeight;
1492 | const previewClientHeight = elements.previewContainer.clientHeight;
1493 | const previewMaxScroll = previewScrollHeight - previewClientHeight;
1494 | if (previewMaxScroll > 0) {
1495 | const targetScrollTop = scrollPercent * previewMaxScroll;
1496 | // 只有在值真正改变时才设置,避免循环触发
1497 | if (Math.abs(elements.previewContainer.scrollTop - targetScrollTop) > 1) {
1498 | elements.previewContainer.scrollTop = targetScrollTop;
1499 | }
1500 | }
1501 | } catch (error) {
1502 | console.warn('编辑器滚动同步失败:', error);
1503 | }
1504 |
1505 | requestAnimationFrame(() => {
1506 | scrollSyncState.isSyncing = false;
1507 | });
1508 | };
1509 |
1510 | // 预览区滚动同步到编辑器
1511 | scrollSyncState.previewScrollHandler = () => {
1512 | if (scrollSyncState.isSyncing || AppState.currentLayout !== 'split') return;
1513 | scrollSyncState.isSyncing = true;
1514 |
1515 | try {
1516 | const scrollTop = elements.previewContainer.scrollTop;
1517 | const scrollHeight = elements.previewContainer.scrollHeight;
1518 | const clientHeight = elements.previewContainer.clientHeight;
1519 |
1520 | // 计算滚动百分比(避免除以零)
1521 | const maxScroll = scrollHeight - clientHeight;
1522 | if (maxScroll <= 0) {
1523 | scrollSyncState.isSyncing = false;
1524 | return;
1525 | }
1526 | const scrollPercent = Math.max(0, Math.min(1, scrollTop / maxScroll));
1527 |
1528 | // 同步到编辑器
1529 | const { maxScroll: editorMaxScroll } = getEditorScrollInfo();
1530 | if (editorMaxScroll > 0) {
1531 | const targetScrollTop = scrollPercent * editorMaxScroll;
1532 | setEditorScrollTop(targetScrollTop);
1533 | }
1534 | } catch (error) {
1535 | console.warn('预览区滚动同步失败:', error);
1536 | }
1537 |
1538 | requestAnimationFrame(() => {
1539 | scrollSyncState.isSyncing = false;
1540 | });
1541 | };
1542 |
1543 | // 初始化编辑器滚动位置
1544 | scrollSyncState.lastEditorScrollTop = aceEditor.renderer.getScrollTop();
1545 |
1546 | // 监听编辑器滚动 - 使用多种方式确保能捕获到滚动事件
1547 | // 方式1: Ace Editor 的 changeScrollTop 事件
1548 | try {
1549 | aceEditor.on('changeScrollTop', scrollSyncState.editorScrollHandler);
1550 | } catch (e) {
1551 | console.warn('无法绑定 changeScrollTop 事件:', e);
1552 | }
1553 |
1554 | // 方式2: 监听 renderer 的 scroll 事件
1555 | try {
1556 | aceEditor.renderer.on('scroll', scrollSyncState.editorScrollHandler);
1557 | } catch (e) {
1558 | console.warn('无法绑定 renderer scroll 事件:', e);
1559 | }
1560 |
1561 | // 方式3: 监听容器的 scroll 事件
1562 | try {
1563 | aceEditor.renderer.container.addEventListener('scroll', scrollSyncState.editorScrollHandler, { passive: true });
1564 | } catch (e) {
1565 | console.warn('无法绑定容器 scroll 事件:', e);
1566 | }
1567 |
1568 | // 方式4: 使用轮询检测编辑器滚动(最可靠的方法)
1569 | if (scrollSyncState.pollingInterval) {
1570 | clearInterval(scrollSyncState.pollingInterval);
1571 | }
1572 | scrollSyncState.pollingInterval = setInterval(() => {
1573 | if (AppState.currentLayout !== 'split' || scrollSyncState.isSyncing) return;
1574 |
1575 | try {
1576 | const currentScrollTop = aceEditor.renderer.getScrollTop();
1577 | if (Math.abs(currentScrollTop - scrollSyncState.lastEditorScrollTop) > 1) {
1578 | scrollSyncState.lastEditorScrollTop = currentScrollTop;
1579 | scrollSyncState.editorScrollHandler();
1580 | }
1581 | } catch (e) {
1582 | // 忽略错误
1583 | }
1584 | }, 50); // 每 50ms 检查一次
1585 |
1586 | // 监听预览区滚动
1587 | elements.previewContainer.addEventListener('scroll', scrollSyncState.previewScrollHandler, { passive: true });
1588 |
1589 | console.log('✅ 滚动同步已初始化', {
1590 | aceEditor: !!aceEditor,
1591 | previewContainer: !!elements.previewContainer,
1592 | renderer: !!aceEditor.renderer,
1593 | container: !!aceEditor.renderer.container,
1594 | pollingInterval: !!scrollSyncState.pollingInterval
1595 | });
1596 | }
1597 |
1598 | // ==================== 文件操作 ====================
1599 |
1600 | /**
1601 | * 新建文档
1602 | */
1603 | function newDocument() {
1604 | if (AppState.isDirty) {
1605 | if (!confirm('当前文档未保存,确定要新建吗?')) {
1606 | return;
1607 | }
1608 | }
1609 |
1610 | setEditorContent('');
1611 | AppState.currentFileName = 'untitled.md';
1612 | AppState.isDirty = false;
1613 | renderMarkdown();
1614 | setStatus('已新建文档');
1615 | }
1616 |
1617 | /**
1618 | * 打开文件
1619 | */
1620 | function openFile() {
1621 | elements.fileInput.click();
1622 | }
1623 |
1624 | /**
1625 | * 处理文件选择
1626 | */
1627 | function handleFileSelect(event) {
1628 | const file = event.target.files[0];
1629 | if (!file) return;
1630 |
1631 | const reader = new FileReader();
1632 | reader.onload = (e) => {
1633 | setEditorContent(e.target.result);
1634 | AppState.currentFileName = file.name;
1635 | AppState.isDirty = false;
1636 | renderMarkdown();
1637 | setStatus(`已打开 ${file.name}`);
1638 | };
1639 | reader.onerror = () => {
1640 | setStatus('文件读取失败', 5000);
1641 | };
1642 | reader.readAsText(file);
1643 |
1644 | // 重置 input 以允许重复选择同一文件
1645 | event.target.value = '';
1646 | }
1647 |
1648 | /**
1649 | * 保存文件
1650 | */
1651 | function saveFile() {
1652 | // 提取当前文件名(不含扩展名)
1653 | const currentName = AppState.currentFileName.replace('.md', '');
1654 |
1655 | // 弹出对话框让用户输入文件名
1656 | const fileName = prompt('请输入文件名(无需输入 .md 扩展名):', currentName);
1657 |
1658 | // 如果用户取消或输入为空,则不保存
1659 | if (!fileName || fileName.trim() === '') {
1660 | return;
1661 | }
1662 |
1663 | // 清理文件名,添加 .md 扩展名
1664 | const cleanFileName = fileName.trim();
1665 | const fullFileName = cleanFileName.endsWith('.md') ? cleanFileName : `${cleanFileName}.md`;
1666 |
1667 | // 更新当前文件名
1668 | AppState.currentFileName = fullFileName;
1669 |
1670 | // 保存文件
1671 | const content = getEditorContent();
1672 | const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1673 | const url = URL.createObjectURL(blob);
1674 | const a = document.createElement('a');
1675 | a.href = url;
1676 | a.download = fullFileName;
1677 | a.click();
1678 | URL.revokeObjectURL(url);
1679 |
1680 | AppState.isDirty = false;
1681 | setStatus(`已保存 ${fullFileName}`);
1682 | }
1683 |
1684 | /**
1685 | * 导出 HTML
1686 | */
1687 | function exportHTML() {
1688 | // 克隆预览区内容,避免修改原始 DOM
1689 | const previewClone = elements.preview.cloneNode(true);
1690 |
1691 | // 移除所有 .mermaid 元素的 mermaid 类,避免 Mermaid 脚本再次渲染
1692 | const mermaidElements = previewClone.querySelectorAll('.mermaid');
1693 | mermaidElements.forEach(el => {
1694 | el.classList.remove('mermaid');
1695 | });
1696 |
1697 | // 移除导出工具栏(导出的 HTML 不需要这些按钮)
1698 | const toolbars = previewClone.querySelectorAll('.mermaid-export-toolbar');
1699 | toolbars.forEach(toolbar => toolbar.remove());
1700 |
1701 | const html = `
1702 |
1703 |
1704 |
1705 |
1706 | ${AppState.currentFileName.replace('.md', '')}
1707 |
1708 |
1727 |
1728 |
1729 |
1730 | ${previewClone.innerHTML}
1731 |
1732 |
1733 |
1734 | `;
1735 |
1736 | const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
1737 | const url = URL.createObjectURL(blob);
1738 | const a = document.createElement('a');
1739 | a.href = url;
1740 | a.download = AppState.currentFileName.replace('.md', '.html');
1741 | a.click();
1742 | URL.revokeObjectURL(url);
1743 |
1744 | setStatus('已导出 HTML');
1745 | }
1746 |
1747 | /**
1748 | * 复制 Markdown
1749 | */
1750 | async function copyMarkdown() {
1751 | try {
1752 | await navigator.clipboard.writeText(getEditorContent());
1753 | setStatus('Markdown 已复制到剪贴板');
1754 | } catch (err) {
1755 | console.error('复制失败:', err);
1756 | setStatus('复制失败', 3000);
1757 | }
1758 | }
1759 |
1760 | /**
1761 | * 复制 HTML
1762 | */
1763 | async function copyHTML() {
1764 | try {
1765 | await navigator.clipboard.writeText(elements.preview.innerHTML);
1766 | setStatus('HTML 已复制到剪贴板');
1767 | } catch (err) {
1768 | console.error('复制失败:', err);
1769 | setStatus('复制失败', 3000);
1770 | }
1771 | }
1772 |
1773 | /**
1774 | * 清空内容
1775 | */
1776 | function clearContent() {
1777 | if (!confirm('确定要清空所有内容吗?此操作不可恢复。')) {
1778 | return;
1779 | }
1780 | setEditorContent('');
1781 | AppState.isDirty = false;
1782 | renderMarkdown();
1783 | setStatus('已清空内容');
1784 | }
1785 |
1786 | // ==================== Markdown 编辑工具 ====================
1787 |
1788 | /**
1789 | * 在编辑器中插入文本
1790 | */
1791 | function insertText(before, after = '', placeholder = '') {
1792 | if (!aceEditor) return;
1793 |
1794 | const selectedText = aceEditor.getSelectedText();
1795 | const textToInsert = before + (selectedText || placeholder) + after;
1796 |
1797 | // 插入文本
1798 | aceEditor.insert(textToInsert);
1799 |
1800 | // 如果没有选中文本且有占位符,选中占位符
1801 | if (!selectedText && placeholder) {
1802 | const cursor = aceEditor.getCursorPosition();
1803 | const Range = window.ace.require('ace/range').Range;
1804 | const startCol = cursor.column - after.length - placeholder.length;
1805 | const endCol = cursor.column - after.length;
1806 | aceEditor.selection.setRange(new Range(cursor.row, startCol, cursor.row, endCol));
1807 | }
1808 |
1809 | aceEditor.focus();
1810 | AppState.isDirty = true;
1811 | debouncedRender();
1812 | }
1813 |
1814 | /**
1815 | * Mermaid 模板
1816 | */
1817 | const mermaidTemplates = {
1818 | flowchart: `\`\`\`mermaid
1819 | graph TD
1820 | A[开始] --> B{判断条件}
1821 | B -->|是| C[执行操作1]
1822 | B -->|否| D[执行操作2]
1823 | C --> E[结束]
1824 | D --> E
1825 | \`\`\`\n\n`,
1826 |
1827 | sequence: `\`\`\`mermaid
1828 | sequenceDiagram
1829 | participant A as 用户
1830 | participant B as 系统
1831 | participant C as 数据库
1832 |
1833 | A->>B: 发送请求
1834 | B->>C: 查询数据
1835 | C-->>B: 返回结果
1836 | B-->>A: 响应数据
1837 | \`\`\`\n\n`,
1838 |
1839 | gantt: `\`\`\`mermaid
1840 | gantt
1841 | title 项目时间线
1842 | dateFormat YYYY-MM-DD
1843 | section 阶段一
1844 | 需求分析 :a1, 2024-01-01, 7d
1845 | 设计方案 :after a1, 5d
1846 | section 阶段二
1847 | 开发实现 :2024-01-15, 14d
1848 | 测试优化 :7d
1849 | \`\`\`\n\n`,
1850 |
1851 | class: `\`\`\`mermaid
1852 | classDiagram
1853 | class Animal {
1854 | +String name
1855 | +int age
1856 | +eat()
1857 | +sleep()
1858 | }
1859 | class Dog {
1860 | +bark()
1861 | }
1862 | class Cat {
1863 | +meow()
1864 | }
1865 | Animal <|-- Dog
1866 | Animal <|-- Cat
1867 | \`\`\`\n\n`,
1868 |
1869 | state: `\`\`\`mermaid
1870 | stateDiagram-v2
1871 | [*] --> 待处理
1872 | 待处理 --> 处理中: 开始处理
1873 | 处理中 --> 已完成: 处理成功
1874 | 处理中 --> 失败: 处理失败
1875 | 失败 --> 待处理: 重试
1876 | 已完成 --> [*]
1877 | \`\`\`\n\n`,
1878 | };
1879 |
1880 | /**
1881 | * 数学公式模板
1882 | */
1883 | const mathTemplates = {
1884 | inline: ' $x$ ',
1885 | block: '\n$$\nx\n$$\n\n',
1886 | fraction: '$\\frac{a}{b}$ ',
1887 | sqrt: '$\\sqrt{x}$ ',
1888 | sum: '$\\sum_{i=1}^{n} a_i$ ',
1889 | integral: '$\\int_{a}^{b} f(x)dx$ ',
1890 | limit: '$\\lim_{x \\to \\infty} f(x)$ ',
1891 | matrix: '\n$$\n\\begin{bmatrix}\na & b \\\\\nc & d\n\\end{bmatrix}\n$$\n\n'
1892 | };
1893 |
1894 | // ==================== 本地存储(自动保存) ====================
1895 |
1896 | /**
1897 | * 保存草稿到 localStorage
1898 | */
1899 | function saveDraft() {
1900 | try {
1901 | localStorage.setItem('markx-draft', getEditorContent());
1902 | localStorage.setItem('markx-draft-time', new Date().toISOString());
1903 | } catch (err) {
1904 | console.error('保存草稿失败:', err);
1905 | }
1906 | }
1907 |
1908 | /**
1909 | * 加载草稿
1910 | */
1911 | function loadDraft() {
1912 | try {
1913 | const draft = localStorage.getItem('markx-draft');
1914 | const draftTime = localStorage.getItem('markx-draft-time');
1915 | const autoRestore = localStorage.getItem('markx-auto-restore');
1916 |
1917 | if (draft && draftTime) {
1918 | const time = new Date(draftTime);
1919 | const now = new Date();
1920 | const diffMinutes = (now - time) / 1000 / 60;
1921 |
1922 | // 如果草稿是最近 7 天内的
1923 | if (diffMinutes < 7 * 24 * 60) {
1924 | // 检查是否设置了自动恢复
1925 | if (autoRestore === 'always') {
1926 | // 自动恢复,不提示
1927 | setEditorContent(draft);
1928 | renderMarkdown();
1929 | setStatus('已自动恢复草稿');
1930 | } else if (autoRestore === 'never') {
1931 | // 永不恢复,不提示
1932 | return;
1933 | } else {
1934 | // 首次或每次询问
1935 | const timeStr = time.toLocaleString('zh-CN');
1936 | const message = `发现 ${timeStr} 的草稿,是否恢复?\n\n提示:可以在下方选择记住此操作`;
1937 |
1938 | // 创建自定义对话框
1939 | showDraftRestoreDialog(draft, timeStr);
1940 | }
1941 | }
1942 | }
1943 | } catch (err) {
1944 | console.error('加载草稿失败:', err);
1945 | }
1946 | }
1947 |
1948 | /**
1949 | * 显示草稿恢复对话框
1950 | */
1951 | function showDraftRestoreDialog(draft, timeStr) {
1952 | // 创建对话框容器
1953 | const dialog = document.createElement('div');
1954 | dialog.className = 'draft-dialog';
1955 | dialog.innerHTML = `
1956 |
1957 |
1958 |
1959 |
1960 | 发现未保存的草稿
1961 |
1962 |
上次编辑时间:${timeStr}
1963 |
1964 |
1968 |
1969 |
1970 |
1971 |
1972 |
1973 |
1974 | `;
1975 |
1976 | document.body.appendChild(dialog);
1977 |
1978 | // 绑定事件
1979 | const remember = dialog.querySelector('#draftRemember');
1980 | const ignoreBtn = dialog.querySelector('#draftIgnore');
1981 | const restoreBtn = dialog.querySelector('#draftRestore');
1982 |
1983 | ignoreBtn.addEventListener('click', () => {
1984 | if (remember.checked) {
1985 | localStorage.setItem('markx-auto-restore', 'never');
1986 | }
1987 | document.body.removeChild(dialog);
1988 | });
1989 |
1990 | restoreBtn.addEventListener('click', () => {
1991 | if (remember.checked) {
1992 | localStorage.setItem('markx-auto-restore', 'always');
1993 | }
1994 | setEditorContent(draft);
1995 | renderMarkdown();
1996 | setStatus('已恢复草稿');
1997 | document.body.removeChild(dialog);
1998 | });
1999 |
2000 | // ESC 键关闭
2001 | const handleEsc = (e) => {
2002 | if (e.key === 'Escape' && document.body.contains(dialog)) {
2003 | document.body.removeChild(dialog);
2004 | document.removeEventListener('keydown', handleEsc);
2005 | }
2006 | };
2007 | document.addEventListener('keydown', handleEsc);
2008 | }
2009 |
2010 | /**
2011 | * 定期自动保存
2012 | */
2013 | function startAutoSave() {
2014 | AppState.autoSaveTimer = setInterval(() => {
2015 | if (AppState.isDirty || elements.editor.value) {
2016 | saveDraft();
2017 | }
2018 | }, 30000); // 每 30 秒保存一次
2019 | }
2020 |
2021 | // ==================== 键盘快捷键 ====================
2022 |
2023 | /**
2024 | * 处理键盘快捷键
2025 | */
2026 | function handleKeyboard(event) {
2027 | const ctrl = event.ctrlKey || event.metaKey;
2028 |
2029 | if (ctrl && event.key === 's') {
2030 | event.preventDefault();
2031 | saveFile();
2032 | } else if (ctrl && event.key === 'o') {
2033 | event.preventDefault();
2034 | openFile();
2035 | } else if (ctrl && event.key === 'n') {
2036 | event.preventDefault();
2037 | newDocument();
2038 | } else if (ctrl && event.key === 'b') {
2039 | event.preventDefault();
2040 | insertText('**', '**', '加粗文本');
2041 | } else if (ctrl && event.key === 'i') {
2042 | event.preventDefault();
2043 | insertText('*', '*', '斜体文本');
2044 | } else if (ctrl && event.key === 'k') {
2045 | event.preventDefault();
2046 | insertText('[', '](https://example.com)', '链接文本');
2047 | }
2048 | }
2049 |
2050 | // ==================== 事件绑定 ====================
2051 |
2052 | /**
2053 | * 初始化所有事件监听器
2054 | */
2055 | function initEventListeners() {
2056 | // Ace Editor 的输入和键盘事件已在编辑器初始化时设置
2057 |
2058 | // 工具栏按钮
2059 | elements.newBtn.addEventListener('click', newDocument);
2060 | elements.openBtn.addEventListener('click', openFile);
2061 | elements.saveBtn.addEventListener('click', saveFile);
2062 | elements.themeBtn.addEventListener('click', toggleTheme);
2063 | elements.layoutBtn.addEventListener('click', toggleLayout);
2064 |
2065 | // Markdown 格式化按钮
2066 | document.getElementById('boldBtn').addEventListener('click', () => {
2067 | insertText('**', '**', '加粗文本');
2068 | });
2069 |
2070 | document.getElementById('italicBtn').addEventListener('click', () => {
2071 | insertText('*', '*', '斜体文本');
2072 | });
2073 |
2074 | document.getElementById('headingBtn').addEventListener('click', () => {
2075 | insertText('## ', '', '标题');
2076 | });
2077 |
2078 | document.getElementById('linkBtn').addEventListener('click', () => {
2079 | insertText('[', '](https://example.com)', '链接文本');
2080 | });
2081 |
2082 | document.getElementById('imageBtn').addEventListener('click', () => {
2083 | insertText('', '图片描述');
2084 | });
2085 |
2086 | document.getElementById('codeBtn').addEventListener('click', () => {
2087 | insertText('```javascript\n', '\n```\n', '代码');
2088 | });
2089 |
2090 | document.getElementById('tableBtn').addEventListener('click', () => {
2091 | const table = '| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| 单元格1 | 单元格2 | 单元格3 |\n\n';
2092 | insertText(table);
2093 | });
2094 |
2095 | // Mermaid 模板按钮(使用 mousedown 事件以避免菜单过早关闭)
2096 | document.querySelectorAll('[data-mermaid]').forEach(btn => {
2097 | btn.addEventListener('mousedown', (e) => {
2098 | e.preventDefault();
2099 | e.stopPropagation();
2100 | const type = btn.getAttribute('data-mermaid');
2101 | insertText(mermaidTemplates[type]);
2102 | setStatus(`已插入${btn.textContent}模板`);
2103 | });
2104 | });
2105 |
2106 | // 数学公式按钮(使用 mousedown 事件以避免菜单过早关闭)
2107 | document.querySelectorAll('[data-math]').forEach(btn => {
2108 | btn.addEventListener('mousedown', (e) => {
2109 | e.preventDefault();
2110 | e.stopPropagation();
2111 | const type = btn.getAttribute('data-math');
2112 | insertText(mathTemplates[type]);
2113 | setStatus(`已插入${btn.textContent.split(' ')[0]}`);
2114 | });
2115 | });
2116 |
2117 | // 更多选项按钮
2118 | document.getElementById('exportHtmlBtn').addEventListener('click', exportHTML);
2119 | document.getElementById('copyMdBtn').addEventListener('click', copyMarkdown);
2120 | document.getElementById('copyHtmlBtn').addEventListener('click', copyHTML);
2121 | document.getElementById('clearBtn').addEventListener('click', clearContent);
2122 |
2123 | // 文件输入
2124 | elements.fileInput.addEventListener('change', handleFileSelect);
2125 |
2126 | // 键盘快捷键
2127 | document.addEventListener('keydown', handleKeyboard);
2128 |
2129 | // 页面离开警告(有未保存内容时)
2130 | window.addEventListener('beforeunload', (e) => {
2131 | if (AppState.isDirty) {
2132 | e.preventDefault();
2133 | e.returnValue = '';
2134 | }
2135 | });
2136 |
2137 | // 系统主题变化监听
2138 | if (window.matchMedia) {
2139 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
2140 | if (!localStorage.getItem('markx-theme')) {
2141 | AppState.currentTheme = e.matches ? 'dark' : 'light';
2142 | document.body.setAttribute('data-theme', AppState.currentTheme);
2143 | renderMarkdown();
2144 | }
2145 | });
2146 | }
2147 | }
2148 |
2149 | // ==================== 应用初始化 ====================
2150 |
2151 | /**
2152 | * 初始化应用
2153 | */
2154 | async function initApp() {
2155 | console.log('🚀 MarkX 正在启动...');
2156 |
2157 | try {
2158 | // 初始化编辑器
2159 | initEditor();
2160 |
2161 | // 初始化主题
2162 | initTheme();
2163 |
2164 | // 初始化 Mermaid
2165 | initMermaid();
2166 |
2167 | // 加载草稿
2168 | loadDraft();
2169 |
2170 | // 初始渲染
2171 | await renderMarkdown();
2172 |
2173 | // 绑定事件
2174 | initEventListeners();
2175 |
2176 | // 初始化编辑器右侧拖拽调整大小
2177 | initEditorResize();
2178 |
2179 | // 延迟初始化滚动同步,确保编辑器完全加载
2180 | setTimeout(() => {
2181 | initScrollSync();
2182 | }, 1000);
2183 |
2184 | // 启动自动保存
2185 | startAutoSave();
2186 |
2187 | console.log('✅ MarkX 启动成功!');
2188 | setStatus('就绪');
2189 |
2190 | } catch (error) {
2191 | console.error('❌ 启动失败:', error);
2192 | setStatus('启动失败', 0);
2193 | }
2194 | }
2195 |
2196 | // ==================== 启动应用 ====================
2197 |
2198 | // 等待 DOM 完全加载后启动
2199 | if (document.readyState === 'loading') {
2200 | document.addEventListener('DOMContentLoaded', initApp);
2201 | } else {
2202 | initApp();
2203 | }
2204 |
2205 | // 导出供调试使用
2206 | window.MarkX = {
2207 | state: AppState,
2208 | render: renderMarkdown,
2209 | version: '1.0.0',
2210 | };
2211 |
2212 |
--------------------------------------------------------------------------------