初版测试
This commit is contained in:
parent
3afda95906
commit
ee2202a0b5
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# 系统文件
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# 编辑器
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
154
README.md
154
README.md
@ -1,3 +1,153 @@
|
|||||||
# markmap
|
# Markmap - Markdown 思维导图编辑器
|
||||||
|
|
||||||
markdown转思维导图
|
纯 HTML + CSS + JavaScript 实现的 Markdown 思维导图工具,无需 Node.js、无需编译,开箱即用。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 方式一:本地服务器(推荐)
|
||||||
|
|
||||||
|
双击 `start.bat` 即可启动(需要 Python 3):
|
||||||
|
|
||||||
|
```
|
||||||
|
start.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
或手动运行:
|
||||||
|
|
||||||
|
```
|
||||||
|
python serve.py # 默认端口 8080
|
||||||
|
python serve.py 3000 # 指定端口
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后浏览器会自动打开 `http://127.0.0.1:8080`。
|
||||||
|
|
||||||
|
### 方式二:直接打开
|
||||||
|
|
||||||
|
直接双击 `index.html` 用浏览器打开。
|
||||||
|
|
||||||
|
> 注意:直接打开时 `content.md` 加载会失败,编辑器会自动使用内置示例内容。分享链接功能在 `file://` 协议下不可用。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 实时预览 | 左侧输入 Markdown,右侧实时生成思维导图 |
|
||||||
|
| 文件操作 | 新建、加载 `.md` 文件、保存为 `.md` 文件 |
|
||||||
|
| 插入图片 | 点击"图片"按钮选择本地图片,自动转 Base64 插入到光标位置 |
|
||||||
|
| 历史文档 | 左侧侧边栏管理历史文档,自动缓存到浏览器,刷新不丢失 |
|
||||||
|
| 主题切换 | 7 种节点配色主题(默认、柔和、深色、森林、单色、极简、彩色) |
|
||||||
|
| 日间/夜间模式 | 一键切换浅色/深色背景,夜间模式适合暗光环境 |
|
||||||
|
| 可拖动分割线 | 拖动中间分割线调整编辑区与预览区的占比 |
|
||||||
|
| 导出 | 支持导出 SVG / PNG / PDF |
|
||||||
|
| 分享链接 | 将内容压缩编码到 URL 中,一键分享 |
|
||||||
|
| 缩放控制 | 放大、缩小、适应窗口 |
|
||||||
|
| 全屏模式 | 隐藏编辑器,全屏展示思维导图 |
|
||||||
|
| 节点折叠 | 点击节点圆点折叠/展开子树 |
|
||||||
|
| 代码高亮 | 支持 JavaScript、Python 等语法高亮 |
|
||||||
|
| 数学公式 | 支持 KaTeX 行内和块级公式 |
|
||||||
|
| 快捷键 | Ctrl+S 保存文件 |
|
||||||
|
|
||||||
|
## Markdown 语法支持
|
||||||
|
|
||||||
|
使用 `#` 标题和 `-` 列表来构建思维导图的层级结构:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 根节点
|
||||||
|
|
||||||
|
## 一级分支
|
||||||
|
|
||||||
|
- 二级项目
|
||||||
|
- 三级项目
|
||||||
|
- 另一个三级项目
|
||||||
|
- 另一个二级项目
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontmatter 配置
|
||||||
|
|
||||||
|
在 Markdown 开头使用 YAML frontmatter 配置思维导图选项:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: 我的思维导图
|
||||||
|
markmap:
|
||||||
|
colorFreezeLevel: 2
|
||||||
|
theme: soft
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 配色主题
|
||||||
|
|
||||||
|
控制思维导图节点和连线的颜色、节点背景、连线弧度、线宽及悬停效果,不影响页面背景(除 `dark` 主题自带画布底色,已自动移除以保持与日/夜间模式独立):
|
||||||
|
|
||||||
|
| 主题值 | 名称 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| (留空) | 默认 | markmap 原始配色(schemeCategory10) |
|
||||||
|
| `soft` | 柔和 | 柔和暖色调,带节点背景和悬停变暗 |
|
||||||
|
| `dark` | 深色 | 深色系配色,适合夜间模式 |
|
||||||
|
| `forest` | 森林 | 绿色系自然色调,带节点背景 |
|
||||||
|
| `monochrome` | 单色 | 灰度配色,简约连线 |
|
||||||
|
| `minimal` | 极简 | 透明背景,按层级不同字号 |
|
||||||
|
| `colorful` | 彩色 | 鲜艳多彩,每条分支不同颜色 |
|
||||||
|
|
||||||
|
通过工具栏"主题"按钮可快速切换,切换会自动更新 frontmatter 中的 `theme` 字段。
|
||||||
|
|
||||||
|
#### 日间/夜间模式
|
||||||
|
|
||||||
|
通过工具栏"日间/夜间"按钮切换整个页面的浅色/深色背景。夜间模式会将背景、编辑器、预览区全部切换为深色配色,模式选择会保存在浏览器本地,下次打开自动恢复。
|
||||||
|
|
||||||
|
#### 历史文档
|
||||||
|
|
||||||
|
左侧侧边栏自动记录所有编辑过的文档,缓存在浏览器 `localStorage` 中,刷新页面不会丢失。每次输入内容后自动保存(防抖 1 秒),最多保留 50 条记录。
|
||||||
|
|
||||||
|
- 点击侧边栏文档可切换查看
|
||||||
|
- 鼠标悬停显示删除按钮
|
||||||
|
- 点击侧边栏顶部 `+` 或工具栏"新建"创建新文档
|
||||||
|
- 侧边栏可通过左上角菜单按钮收起/展开,状态自动记忆
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
markmap/
|
||||||
|
├── index.html # 主页面
|
||||||
|
├── content.md # 默认加载的示例内容
|
||||||
|
├── serve.py # Python 本地服务器
|
||||||
|
├── start.bat # Windows 启动脚本
|
||||||
|
├── README.md # 本文档
|
||||||
|
├── LICENSE # MIT 许可证
|
||||||
|
├── css/
|
||||||
|
│ └── styles.css # 样式表
|
||||||
|
├── javascript/
|
||||||
|
│ ├── markmap_editor.js # 编辑器核心逻辑
|
||||||
|
│ └── export_utils.js # 导出功能(SVG/PNG/PDF)
|
||||||
|
└── libs/ # 预构建依赖库(无需安装)
|
||||||
|
├── d3/ # D3 可视化库
|
||||||
|
├── markmap_view/ # 思维导图渲染
|
||||||
|
├── markmap_lib/ # Markdown 转换
|
||||||
|
├── katex/ # 数学公式渲染
|
||||||
|
├── prismjs/ # 代码语法高亮
|
||||||
|
├── highlightjs/ # 代码高亮备选
|
||||||
|
├── webfontloader/ # 字体加载
|
||||||
|
├── jspdf.js # PDF 生成
|
||||||
|
├── html2canvas.js # HTML 转图片
|
||||||
|
└── pako.js # 压缩/解压
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **HTML5** - 页面结构
|
||||||
|
- **CSS3** - 样式(使用 CSS 变量,支持主题定制)
|
||||||
|
- **JavaScript (ES6+)** - 所有应用逻辑,无框架依赖
|
||||||
|
- **D3.js** - 数据可视化
|
||||||
|
- **markmap-lib / markmap-view** - Markdown 解析与思维导图渲染
|
||||||
|
- **KaTeX** - 数学公式
|
||||||
|
- **Prism.js / highlight.js** - 代码高亮
|
||||||
|
- **jsPDF + html2canvas** - 导出功能
|
||||||
|
- **pako** - URL 分享压缩
|
||||||
|
|
||||||
|
## 浏览器兼容性
|
||||||
|
|
||||||
|
支持所有现代浏览器:Chrome、Firefox、Edge、Safari。
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
84
content.md
Normal file
84
content.md
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
title: Markmap Features
|
||||||
|
markmap:
|
||||||
|
colorFreezeLevel: 2
|
||||||
|
direction: balanced
|
||||||
|
theme: soft
|
||||||
|
---
|
||||||
|
|
||||||
|
## Themes <!-- markmap: icon: star -->
|
||||||
|
|
||||||
|
- classic <!-- markmap: icon: done -->
|
||||||
|
- soft <!-- markmap: icon: done -->
|
||||||
|
- dark <!-- markmap: icon: done -->
|
||||||
|
- forest <!-- markmap: icon: done -->
|
||||||
|
- monochrome <!-- markmap: icon: done -->
|
||||||
|
- minimal <!-- markmap: icon: done -->
|
||||||
|
|
||||||
|
## Directions <!-- markmap: icon: info -->
|
||||||
|
|
||||||
|
- right
|
||||||
|
- left
|
||||||
|
- down
|
||||||
|
- balanced
|
||||||
|
|
||||||
|
## Link Styles <!-- markmap: icon: info -->
|
||||||
|
|
||||||
|
- `classic` - stroke lines
|
||||||
|
- `0.3` - slight taper
|
||||||
|
- `0.5` - moderate taper
|
||||||
|
- `0.85` - brush style <!-- markmap: icon: star -->
|
||||||
|
- `1` - full taper
|
||||||
|
|
||||||
|
## Node Styling <!-- markmap: boundary: Visual System | #e8f5e9 -->
|
||||||
|
|
||||||
|
### Backgrounds <!-- markmap: icon: done -->
|
||||||
|
|
||||||
|
- Capsule root node
|
||||||
|
- Rounded rect L1/L2
|
||||||
|
- Underline L3+
|
||||||
|
- Auto border colors
|
||||||
|
|
||||||
|
### Typography <!-- markmap: icon: done -->
|
||||||
|
|
||||||
|
- Font size by depth
|
||||||
|
- Font weight by depth
|
||||||
|
- Auto text color (dark/light)
|
||||||
|
|
||||||
|
### Effects <!-- markmap: icon: wip -->
|
||||||
|
|
||||||
|
- Drop shadow (L1)
|
||||||
|
- Hover dimming
|
||||||
|
- Canvas background color
|
||||||
|
|
||||||
|
## Directives <!-- markmap: boundary: | #fff3e0 -->
|
||||||
|
|
||||||
|
### Icons <!-- markmap: icon: star -->
|
||||||
|
|
||||||
|
- `done` <!-- markmap: icon: done -->
|
||||||
|
- `wip` <!-- markmap: icon: wip -->
|
||||||
|
- `blocked` <!-- markmap: icon: blocked -->
|
||||||
|
- `priority-high` <!-- markmap: icon: priority-high -->
|
||||||
|
- `priority-mid` <!-- markmap: icon: priority-mid -->
|
||||||
|
- `priority-low` <!-- markmap: icon: priority-low -->
|
||||||
|
- `flag` <!-- markmap: icon: flag -->
|
||||||
|
- `question` <!-- markmap: icon: question -->
|
||||||
|
|
||||||
|
### Relations <!-- markmap: icon: info, rel: Boundaries | related -->
|
||||||
|
|
||||||
|
- `rel: target | label`
|
||||||
|
- Auto arc direction
|
||||||
|
- Collision avoidance
|
||||||
|
|
||||||
|
### Boundaries <!-- markmap: icon: info -->
|
||||||
|
|
||||||
|
- `boundary: name` - with title
|
||||||
|
- `boundary: name | #color` - custom color
|
||||||
|
- `boundary: | #color` - color only, no title
|
||||||
|
- `boundary` - default style
|
||||||
|
|
||||||
|
### Folding <!-- markmap: icon: done -->
|
||||||
|
|
||||||
|
- `fold` - fold node <!-- markmap: fold -->
|
||||||
|
- `foldAll` - fold recursively
|
||||||
|
- Balanced: left/right independent
|
||||||
716
css/styles.css
Normal file
716
css/styles.css
Normal file
@ -0,0 +1,716 @@
|
|||||||
|
:root {
|
||||||
|
--accent-color: #4a9eff;
|
||||||
|
--accent-soft: rgba(74, 158, 255, 0.1);
|
||||||
|
--accent-medium: rgba(74, 158, 255, 0.18);
|
||||||
|
--text-primary: #1a1a2e;
|
||||||
|
--text-secondary: #555;
|
||||||
|
--text-muted: #999;
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.55);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.6);
|
||||||
|
--glass-shadow: 0 2px 16px rgba(0, 0, 0, 0.04);
|
||||||
|
--header-bg: rgba(245, 247, 252, 0.88);
|
||||||
|
--header-border: rgba(180, 195, 220, 0.35);
|
||||||
|
--footer-bg: rgba(242, 244, 248, 0.88);
|
||||||
|
--footer-border: rgba(180, 195, 220, 0.3);
|
||||||
|
--divider-color: rgba(0, 0, 0, 0.06);
|
||||||
|
--editor-bg: rgba(255, 255, 255, 0.72);
|
||||||
|
--editor-font: 'Cascadia Code', 'Fira Code', 'Consolas', 'Monaco', monospace;
|
||||||
|
--editor-text: #2c2c2c;
|
||||||
|
--preview-bg: rgba(248, 250, 252, 0.6);
|
||||||
|
--toolbar-bg: rgba(255, 255, 255, 0.65);
|
||||||
|
--toolbar-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||||
|
--btn-bg: rgba(255, 255, 255, 0.7);
|
||||||
|
--scrollbar-thumb: rgba(0, 0, 0, 0.1);
|
||||||
|
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.2);
|
||||||
|
--splitter-handle: rgba(0, 0, 0, 0.12);
|
||||||
|
--sidebar-bg: rgba(250, 250, 253, 0.85);
|
||||||
|
--transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: linear-gradient(135deg, #e8f0fe 0%, #f5f7fa 30%, #fef6f0 70%, #f0f4ff 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: var(--header-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 52px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
z-index: 100;
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
border-bottom: 1px solid var(--header-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--accent-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M2 4h6v2H4v10h4v2H2V4zm10 0h6v14h-6v-2h4V6h-4V4z' fill='black'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||||
|
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M2 4h6v2H4v10h4v2H2V4zm10 0h6v14h-6v-2h4V6h-4V4z' fill='black'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background-color: var(--btn-bg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all var(--transition);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background-color: var(--accent-soft);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
color: var(--accent-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn svg {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .toolbar button {
|
||||||
|
background-color: var(--btn-bg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all var(--transition);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
header .toolbar button:hover {
|
||||||
|
background-color: var(--accent-soft);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
color: var(--accent-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(74, 158, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
header .toolbar button:active {
|
||||||
|
background-color: var(--accent-medium);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout ── */
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Editor ── */
|
||||||
|
|
||||||
|
#editor-container {
|
||||||
|
width: 40%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--editor-bg);
|
||||||
|
position: relative;
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
resize: none;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-family: var(--editor-font);
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--editor-text);
|
||||||
|
outline: none;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--scrollbar-thumb-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar ── */
|
||||||
|
|
||||||
|
#sidebar {
|
||||||
|
width: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
border-right: 1px solid var(--divider-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar.collapsed {
|
||||||
|
width: 0;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar.no-anim {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header button {
|
||||||
|
background: var(--btn-bg);
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header button:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-color);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-docs {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-docs::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-docs::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 20px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item.active {
|
||||||
|
background: var(--accent-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item-delete {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 6px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item:hover .doc-item-delete {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item-delete:hover {
|
||||||
|
background: rgba(255, 99, 71, 0.15);
|
||||||
|
color: #ff6347;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Splitter ── */
|
||||||
|
|
||||||
|
#splitter {
|
||||||
|
width: 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: col-resize;
|
||||||
|
background: var(--divider-color);
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitter::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--splitter-handle);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitter:hover::after,
|
||||||
|
#splitter.dragging::after {
|
||||||
|
background: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitter:hover,
|
||||||
|
#splitter.dragging {
|
||||||
|
background: rgba(74, 158, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Theme Selector ── */
|
||||||
|
|
||||||
|
.theme-selector {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dropdown {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
background: var(--header-bg);
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
padding: 4px;
|
||||||
|
min-width: 100px;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dropdown.show {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dropdown button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dropdown button:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dropdown button.active {
|
||||||
|
background: var(--accent-medium);
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 夜间模式 ── */
|
||||||
|
|
||||||
|
body.night-mode {
|
||||||
|
--text-primary: #e0e0e0;
|
||||||
|
--text-secondary: #aaa;
|
||||||
|
--text-muted: #777;
|
||||||
|
--glass-bg: rgba(30, 30, 45, 0.55);
|
||||||
|
--glass-border: rgba(60, 60, 80, 0.4);
|
||||||
|
--header-bg: rgba(20, 20, 35, 0.88);
|
||||||
|
--header-border: rgba(60, 60, 80, 0.35);
|
||||||
|
--footer-bg: rgba(20, 20, 35, 0.88);
|
||||||
|
--footer-border: rgba(60, 60, 80, 0.3);
|
||||||
|
--divider-color: rgba(255, 255, 255, 0.06);
|
||||||
|
--editor-bg: rgba(30, 30, 45, 0.72);
|
||||||
|
--editor-text: #e0e0e0;
|
||||||
|
--preview-bg: rgba(25, 25, 40, 0.6);
|
||||||
|
--toolbar-bg: rgba(30, 30, 45, 0.65);
|
||||||
|
--btn-bg: rgba(255, 255, 255, 0.08);
|
||||||
|
--scrollbar-thumb: rgba(255, 255, 255, 0.15);
|
||||||
|
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.25);
|
||||||
|
--splitter-handle: rgba(255, 255, 255, 0.15);
|
||||||
|
--sidebar-bg: rgba(20, 20, 35, 0.85);
|
||||||
|
background: linear-gradient(135deg, #0d1117 0%, #161b22 30%, #1a1a2e 70%, #0d1117 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.night-mode .markmap-node text {
|
||||||
|
fill: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.night-mode .markmap-link {
|
||||||
|
stroke: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 思维导图节点内图片尺寸 ── */
|
||||||
|
|
||||||
|
.markmap-node img {
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 150px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Preview ── */
|
||||||
|
|
||||||
|
#markmap-container {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
background: var(--preview-bg);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#markmap {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
|
||||||
|
footer {
|
||||||
|
background: var(--footer-bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 11.5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
border-top: 1px solid var(--footer-border);
|
||||||
|
box-shadow: 0 -1px 6px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Floating Toolbar ── */
|
||||||
|
|
||||||
|
.markmap-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
z-index: 10000;
|
||||||
|
background: var(--toolbar-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 6px;
|
||||||
|
box-shadow: var(--toolbar-shadow);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markmap-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 5px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all var(--transition);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markmap-btn::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(4px);
|
||||||
|
background: rgba(40, 42, 60, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
z-index: 10001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markmap-btn::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 3px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 5px solid transparent;
|
||||||
|
border-top-color: rgba(40, 42, 60, 0.92);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markmap-btn:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markmap-btn:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markmap-btn:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markmap-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
background: var(--accent-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markmap-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自动适应窗口激活状态 */
|
||||||
|
.markmap-btn.autofit-active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markmap-btn.autofit-active::after {
|
||||||
|
background: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markmap-btn.autofit-active::before {
|
||||||
|
border-top-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 激活状态脉冲指示点 */
|
||||||
|
.markmap-btn.autofit-active svg {
|
||||||
|
animation: autofit-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes autofit-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PDF 方向选择弹出菜单 ── */
|
||||||
|
|
||||||
|
.pdf-orientation-menu {
|
||||||
|
background: var(--toolbar-bg);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--toolbar-shadow);
|
||||||
|
padding: 4px;
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 140px;
|
||||||
|
animation: pdfMenuFadeIn 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pdfMenuFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-orientation-item {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-orientation-item:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-orientation-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-orientation-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Fullscreen ── */
|
||||||
|
|
||||||
|
body.fullscreen header,
|
||||||
|
body.fullscreen footer,
|
||||||
|
body.fullscreen #editor-container,
|
||||||
|
body.fullscreen #splitter,
|
||||||
|
body.fullscreen #sidebar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.fullscreen #markmap-container {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
z-index: 9999 !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.fullscreen .markmap-toolbar {
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
}
|
||||||
75
index.html
Normal file
75
index.html
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Markmap - Markdown 思维导图编辑器</title>
|
||||||
|
<meta name="description" content="将 Markdown 文本实时转换为思维导图的在线工具">
|
||||||
|
<link rel="stylesheet" href="css/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<header>
|
||||||
|
<div class="header-left">
|
||||||
|
<button id="sidebar-toggle" class="icon-btn" title="展开/收起文档列表">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
<h1>Markmap 编辑器</h1>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="new-map" title="新建思维导图 (清空当前内容)">新建</button>
|
||||||
|
<button id="load-map" title="从文件加载 Markdown">加载</button>
|
||||||
|
<button id="save-map" title="保存为 Markdown 文件 (Ctrl+S)">保存</button>
|
||||||
|
<button id="insert-image" title="选择本地图片插入到光标位置">图片</button>
|
||||||
|
<button id="share-url" title="生成可分享的链接">分享</button>
|
||||||
|
<div class="theme-selector">
|
||||||
|
<button id="theme-btn" title="选择思维导图配色">主题</button>
|
||||||
|
<div class="theme-dropdown" id="theme-dropdown">
|
||||||
|
<button data-theme="">默认</button>
|
||||||
|
<button data-theme="soft">柔和</button>
|
||||||
|
<button data-theme="dark">深色</button>
|
||||||
|
<button data-theme="forest">森林</button>
|
||||||
|
<button data-theme="monochrome">单色</button>
|
||||||
|
<button data-theme="minimal">极简</button>
|
||||||
|
<button data-theme="colorful">彩色</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="mode-toggle" title="切换日间/夜间模式">日间</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<aside id="sidebar" class="collapsed">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<span>历史文档</span>
|
||||||
|
<button id="sidebar-new" title="新建文档">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-docs" id="sidebar-docs"></div>
|
||||||
|
</aside>
|
||||||
|
<div id="editor-container">
|
||||||
|
<textarea id="editor" placeholder="在这里输入 Markdown... # 标题 - 列表项 1 - 子项 - 列表项 2" spellcheck="false"></textarea>
|
||||||
|
</div>
|
||||||
|
<div id="splitter"></div>
|
||||||
|
<div id="markmap-container">
|
||||||
|
<svg id="markmap"></svg>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<span>Markmap - Markdown 转思维导图 | 按 Ctrl+S 保存 | 双击节点折叠/展开</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 核心依赖库 (预构建,无需编译) -->
|
||||||
|
<script src="libs/d3/dist/d3.min.js"></script>
|
||||||
|
<script src="libs/markmap_view/dist/browser/index.js"></script>
|
||||||
|
<script src="libs/markmap_lib/dist/browser/index.iife.js"></script>
|
||||||
|
|
||||||
|
<!-- 导出工具库 -->
|
||||||
|
<script src="libs/jspdf.js"></script>
|
||||||
|
<script src="libs/html2canvas.js"></script>
|
||||||
|
<script src="libs/pako.js"></script>
|
||||||
|
|
||||||
|
<!-- 应用脚本 -->
|
||||||
|
<script src="javascript/export_utils.js"></script>
|
||||||
|
<script src="javascript/markmap_editor.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
655
javascript/export_utils.js
Normal file
655
javascript/export_utils.js
Normal file
@ -0,0 +1,655 @@
|
|||||||
|
// ── 另存为辅助函数 ──
|
||||||
|
// 使用浏览器原生 showSaveFilePicker 弹出保存对话框
|
||||||
|
// 让用户选择保存位置和修改文件名
|
||||||
|
|
||||||
|
async function saveFileAs(blob, suggestedName, fileTypes) {
|
||||||
|
if (window.showSaveFilePicker) {
|
||||||
|
try {
|
||||||
|
const handle = await window.showSaveFilePicker({
|
||||||
|
suggestedName: suggestedName,
|
||||||
|
types: fileTypes || []
|
||||||
|
});
|
||||||
|
const writable = await handle.createWritable();
|
||||||
|
await writable.write(blob);
|
||||||
|
await writable.close();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') return false; // 用户取消保存
|
||||||
|
console.warn('showSaveFilePicker 不可用,回退到传统下载:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:传统下载方式
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = suggestedName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从编辑器内容提取文档标题作为默认文件名
|
||||||
|
function getSuggestedFileName(ext) {
|
||||||
|
try {
|
||||||
|
if (typeof extractDocTitle === 'function' && typeof editor !== 'undefined') {
|
||||||
|
const title = extractDocTitle(editor.value);
|
||||||
|
// 清理文件名中的非法字符
|
||||||
|
const safeName = (title || 'markmap').replace(/[<>:"/\\|?*]/g, '_');
|
||||||
|
return safeName + '.' + ext;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return 'markmap.' + ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 等待 SVG 中所有图片加载完成 ──
|
||||||
|
|
||||||
|
async function waitForImages() {
|
||||||
|
const svg = document.getElementById('markmap');
|
||||||
|
if (!svg) return;
|
||||||
|
const imgs = svg.querySelectorAll('img');
|
||||||
|
const promises = [];
|
||||||
|
imgs.forEach(img => {
|
||||||
|
if (!img.complete && img.src) {
|
||||||
|
promises.push(new Promise(resolve => {
|
||||||
|
img.onload = resolve;
|
||||||
|
img.onerror = resolve;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (promises.length > 0) {
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SVG 内容获取 ──
|
||||||
|
// forCanvas: 为 Canvas 渲染准备时,将 foreignObject 中的 <img> 转为 SVG <image> 元素
|
||||||
|
// 原因:SVG 作为 data URL 加载时,浏览器安全限制不渲染 foreignObject 中的 <img>,
|
||||||
|
// 但原生 SVG <image> 元素可以正常渲染。
|
||||||
|
|
||||||
|
function getSVGContent(forCanvas = false) {
|
||||||
|
const svg = document.getElementById('markmap');
|
||||||
|
if (!svg) {
|
||||||
|
console.error('未找到 SVG 元素');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clonedSvg = svg.cloneNode(true);
|
||||||
|
|
||||||
|
// 移除 id 属性,防止 CSS 中的 #markmap { width:100%; height:100% } 覆盖显式宽高
|
||||||
|
// (SVG 作为 data URL 加载时,100% 无父元素参照会导致尺寸为 0)
|
||||||
|
clonedSvg.removeAttribute('id');
|
||||||
|
|
||||||
|
// 设置命名空间
|
||||||
|
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||||
|
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
|
||||||
|
|
||||||
|
// ── 获取完整内容尺寸(不限于当前视口,支持长图高清导出)──
|
||||||
|
// markmap 的 SVG: <svg><g transform="zoom"> 所有节点和连线 </g></svg>
|
||||||
|
// 第一个 <g> 是缩放容器,getBBox() 返回全部内容在本地坐标系中的包围盒
|
||||||
|
const originalMainG = svg.querySelector('g');
|
||||||
|
let contentX = 0, contentY = 0, contentWidth = 0, contentHeight = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (originalMainG) {
|
||||||
|
const contentBBox = originalMainG.getBBox();
|
||||||
|
contentX = contentBBox.x;
|
||||||
|
contentY = contentBBox.y;
|
||||||
|
contentWidth = contentBBox.width;
|
||||||
|
contentHeight = contentBBox.height;
|
||||||
|
|
||||||
|
// 克隆中移除缩放 transform,让内容以原始坐标显示(1:1 无缩放)
|
||||||
|
// 这样导出的 SVG/PNG/PDF 包含全部内容,不受当前缩放/平移影响
|
||||||
|
const clonedMainG = clonedSvg.querySelector('g');
|
||||||
|
if (clonedMainG) {
|
||||||
|
clonedMainG.removeAttribute('transform');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('获取内容包围盒失败,回退到视口尺寸:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentWidth === 0 || contentHeight === 0) {
|
||||||
|
// 回退:使用视口尺寸
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
contentX = 0;
|
||||||
|
contentY = 0;
|
||||||
|
contentWidth = Math.ceil(rect.width);
|
||||||
|
contentHeight = Math.ceil(rect.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加边距,确保内容不被裁切
|
||||||
|
const padding = 40;
|
||||||
|
contentX -= padding;
|
||||||
|
contentY -= padding;
|
||||||
|
contentWidth = Math.max(Math.ceil(contentWidth + padding * 2), 100);
|
||||||
|
contentHeight = Math.max(Math.ceil(contentHeight + padding * 2), 100);
|
||||||
|
|
||||||
|
// 设置 viewBox 覆盖完整内容(长图效果:内容多时图片自动变长)
|
||||||
|
clonedSvg.setAttribute('viewBox', `${contentX} ${contentY} ${contentWidth} ${contentHeight}`);
|
||||||
|
clonedSvg.setAttribute('width', contentWidth);
|
||||||
|
clonedSvg.setAttribute('height', contentHeight);
|
||||||
|
|
||||||
|
// 确保图片 src 属性正确序列化
|
||||||
|
// cloneNode(true) 可能不会正确复制 img.src(浏览器内部属性 vs HTML 属性)
|
||||||
|
const originalImgs = svg.querySelectorAll('img');
|
||||||
|
const clonedImgs = clonedSvg.querySelectorAll('img');
|
||||||
|
for (let i = 0; i < originalImgs.length && i < clonedImgs.length; i++) {
|
||||||
|
const src = originalImgs[i].src;
|
||||||
|
if (src) {
|
||||||
|
clonedImgs[i].setAttribute('src', src);
|
||||||
|
}
|
||||||
|
const style = originalImgs[i].getAttribute('style');
|
||||||
|
if (style) {
|
||||||
|
clonedImgs[i].setAttribute('style', style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为 Canvas 渲染准备:将 foreignObject 中的 <img> 转为 SVG <image> 元素
|
||||||
|
if (forCanvas) {
|
||||||
|
convertImagesToSvgImages(clonedSvg, svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集页面中与 markmap 相关的样式表
|
||||||
|
const styleElement = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
||||||
|
let cssText = '';
|
||||||
|
|
||||||
|
for (const sheet of document.styleSheets) {
|
||||||
|
try {
|
||||||
|
const rules = sheet.cssRules || [];
|
||||||
|
for (const rule of rules) {
|
||||||
|
const selector = rule.selectorText || '';
|
||||||
|
// 跳过会影响 SVG 根元素尺寸的 CSS 规则(100% 在独立 SVG 中无意义)
|
||||||
|
if ((selector === 'svg' || selector === '#markmap') &&
|
||||||
|
(rule.cssText.includes('width') || rule.cssText.includes('height'))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (selector.includes('markmap') ||
|
||||||
|
selector.includes('svg') ||
|
||||||
|
selector.includes('foreignObject') ||
|
||||||
|
selector.includes('img') ||
|
||||||
|
selector.includes(':root') ||
|
||||||
|
rule.cssText.includes('--markmap')) {
|
||||||
|
cssText += rule.cssText + '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 跨域样式表无法访问 cssRules,跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加内联样式中的 CSS 变量
|
||||||
|
const rootStyle = getComputedStyle(document.documentElement);
|
||||||
|
const markmapVars = [
|
||||||
|
'--markmap-circle-open-bg',
|
||||||
|
'--markmap-max-width',
|
||||||
|
];
|
||||||
|
let rootCss = ':root {';
|
||||||
|
for (const varName of markmapVars) {
|
||||||
|
const val = rootStyle.getPropertyValue(varName).trim();
|
||||||
|
if (val) rootCss += `${varName}: ${val};`;
|
||||||
|
}
|
||||||
|
rootCss += '}';
|
||||||
|
if (rootCss !== ':root {}') cssText = rootCss + '\n' + cssText;
|
||||||
|
|
||||||
|
styleElement.textContent = cssText;
|
||||||
|
clonedSvg.insertBefore(styleElement, clonedSvg.firstChild);
|
||||||
|
|
||||||
|
return new XMLSerializer().serializeToString(clonedSvg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 将 foreignObject 中的 <img> 转为 SVG <image> 元素 ──
|
||||||
|
// SVG 作为 data URL 图片加载时,浏览器不渲染 foreignObject 中的 HTML <img>,
|
||||||
|
// 但原生 SVG <image> 元素可以正常渲染,因此需要转换。
|
||||||
|
// 通过 getBoundingClientRect 计算图片在屏幕上的位置,再换算为 SVG 坐标。
|
||||||
|
|
||||||
|
function convertImagesToSvgImages(clonedSvg, originalSvg) {
|
||||||
|
const originalFos = originalSvg.querySelectorAll('foreignObject');
|
||||||
|
const clonedFos = clonedSvg.querySelectorAll('foreignObject');
|
||||||
|
|
||||||
|
for (let i = 0; i < originalFos.length && i < clonedFos.length; i++) {
|
||||||
|
const originalFo = originalFos[i];
|
||||||
|
const clonedFo = clonedFos[i];
|
||||||
|
|
||||||
|
const originalImgs = originalFo.querySelectorAll('img');
|
||||||
|
const clonedImgs = clonedFo.querySelectorAll('img');
|
||||||
|
|
||||||
|
for (let j = 0; j < originalImgs.length && j < clonedImgs.length; j++) {
|
||||||
|
const originalImg = originalImgs[j];
|
||||||
|
const clonedImg = clonedImgs[j];
|
||||||
|
|
||||||
|
const src = originalImg.src;
|
||||||
|
if (!src) continue;
|
||||||
|
|
||||||
|
// 获取图片在屏幕上的渲染尺寸和位置
|
||||||
|
const imgRect = originalImg.getBoundingClientRect();
|
||||||
|
const foRect = originalFo.getBoundingClientRect();
|
||||||
|
|
||||||
|
// 跳过未渲染或尺寸为 0 的图片
|
||||||
|
if (imgRect.width === 0 || imgRect.height === 0) continue;
|
||||||
|
|
||||||
|
// 图片相对于 foreignObject 的偏移(屏幕像素)
|
||||||
|
const offsetX = imgRect.left - foRect.left;
|
||||||
|
const offsetY = imgRect.top - foRect.top;
|
||||||
|
|
||||||
|
// 计算屏幕像素到 SVG 坐标单位的缩放比
|
||||||
|
// foreignObject 的 width 属性是 SVG 坐标单位,foRect.width 是屏幕像素
|
||||||
|
const foWidthAttr = parseFloat(clonedFo.getAttribute('width')) || 0;
|
||||||
|
const scaleFactor = foWidthAttr > 0 && foRect.width > 0
|
||||||
|
? foWidthAttr / foRect.width
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
// foreignObject 在父级 SVG 坐标中的位置
|
||||||
|
const foX = parseFloat(clonedFo.getAttribute('x')) || 0;
|
||||||
|
const foY = parseFloat(clonedFo.getAttribute('y')) || 0;
|
||||||
|
|
||||||
|
// 图片在 SVG 坐标中的位置和尺寸
|
||||||
|
const imgX = foX + offsetX * scaleFactor;
|
||||||
|
const imgY = foY + offsetY * scaleFactor;
|
||||||
|
const imgW = imgRect.width * scaleFactor;
|
||||||
|
const imgH = imgRect.height * scaleFactor;
|
||||||
|
|
||||||
|
// 创建 SVG <image> 元素
|
||||||
|
const svgImage = document.createElementNS('http://www.w3.org/2000/svg', 'image');
|
||||||
|
svgImage.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', src);
|
||||||
|
svgImage.setAttribute('href', src);
|
||||||
|
svgImage.setAttribute('x', imgX);
|
||||||
|
svgImage.setAttribute('y', imgY);
|
||||||
|
svgImage.setAttribute('width', imgW);
|
||||||
|
svgImage.setAttribute('height', imgH);
|
||||||
|
svgImage.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||||
|
|
||||||
|
// 在 foreignObject 之前插入 SVG <image>
|
||||||
|
clonedFo.parentNode.insertBefore(svgImage, clonedFo);
|
||||||
|
|
||||||
|
// 从克隆的 foreignObject 中移除 <img>(避免重复显示)
|
||||||
|
clonedImg.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 导出 SVG ──
|
||||||
|
|
||||||
|
async function exportSVG() {
|
||||||
|
// 等待图片加载完成(getSVGContent(true) 需要用 getBoundingClientRect 计算坐标)
|
||||||
|
await waitForImages();
|
||||||
|
|
||||||
|
// 使用 forCanvas=true 将 foreignObject 中的 <img> 转为原生 SVG <image> 元素
|
||||||
|
// 原因:许多 SVG 查看器不渲染 foreignObject 中的 HTML 内容,
|
||||||
|
// 原生 SVG <image> 元素在所有 SVG 查看器中都能正常显示
|
||||||
|
const svgContent = getSVGContent(true);
|
||||||
|
if (!svgContent) {
|
||||||
|
alert('无法生成 SVG,请确保思维导图已渲染。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([svgContent], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
await saveFileAs(blob, getSuggestedFileName('svg'), [{
|
||||||
|
description: 'SVG 矢量图',
|
||||||
|
accept: { 'image/svg+xml': ['.svg'] }
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SVG 转 Canvas ──
|
||||||
|
// SVG 中的图片已转换为原生 SVG <image> 元素,data URL 方式可正常渲染。
|
||||||
|
// 失败时回退到 html2canvas。
|
||||||
|
|
||||||
|
function svgToCanvasViaDataUrl(svgContent, scale = 2) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 使用 encodeURIComponent 编码,兼容 Unicode 字符
|
||||||
|
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgContent);
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
const w = img.naturalWidth || img.width || 1;
|
||||||
|
const h = img.naturalHeight || img.height || 1;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.ceil(w * scale);
|
||||||
|
canvas.height = Math.ceil(h * scale);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
resolve(canvas);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
reject(new Error('data URL 方式失败'));
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function svgToCanvasViaHtml2canvas(scale = 4) {
|
||||||
|
if (typeof html2canvas === 'undefined') {
|
||||||
|
throw new Error('html2canvas 未加载');
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById('markmap-container');
|
||||||
|
if (!container) throw new Error('未找到容器');
|
||||||
|
|
||||||
|
// 隐藏工具栏
|
||||||
|
const toolbars = container.querySelectorAll('.markmap-toolbar, .mm-toolbar');
|
||||||
|
const hidden = [];
|
||||||
|
toolbars.forEach(tb => {
|
||||||
|
hidden.push({ el: tb, vis: tb.style.visibility, disp: tb.style.display });
|
||||||
|
tb.style.visibility = 'hidden';
|
||||||
|
tb.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
const origBg = container.style.background;
|
||||||
|
container.style.background = 'white';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const canvas = await html2canvas(container, {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
scale: scale,
|
||||||
|
logging: false,
|
||||||
|
useCORS: true,
|
||||||
|
allowTaint: true
|
||||||
|
});
|
||||||
|
return canvas;
|
||||||
|
} finally {
|
||||||
|
container.style.background = origBg;
|
||||||
|
hidden.forEach(({ el, vis, disp }) => {
|
||||||
|
el.style.visibility = vis || '';
|
||||||
|
el.style.display = disp || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderToCanvas(svgContent, scale = 2) {
|
||||||
|
// 优先尝试 SVG data URL 方式(图片已转换为 SVG <image>,可正常渲染)
|
||||||
|
try {
|
||||||
|
return await svgToCanvasViaDataUrl(svgContent, scale);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('SVG data URL 渲染失败,回退到 html2canvas:', e.message);
|
||||||
|
return await svgToCanvasViaHtml2canvas(scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 高清导出辅助函数 ──
|
||||||
|
|
||||||
|
// 浏览器 Canvas 最大尺寸限制(大多数浏览器为 16384px)
|
||||||
|
const MAX_CANVAS_DIM = 16384;
|
||||||
|
|
||||||
|
// 根据内容尺寸计算最优缩放倍数,防止 Canvas 超出浏览器限制
|
||||||
|
function getOptimalScale(width, height, desiredScale = 3) {
|
||||||
|
const scaledW = width * desiredScale;
|
||||||
|
const scaledH = height * desiredScale;
|
||||||
|
if (scaledW <= MAX_CANVAS_DIM && scaledH <= MAX_CANVAS_DIM) {
|
||||||
|
return desiredScale;
|
||||||
|
}
|
||||||
|
// 降级:确保不超过浏览器限制,但至少保持 1 倍
|
||||||
|
return Math.max(1, Math.min(
|
||||||
|
MAX_CANVAS_DIM / width,
|
||||||
|
MAX_CANVAS_DIM / height,
|
||||||
|
desiredScale
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取思维导图完整内容尺寸(与 getSVGContent 中的计算一致)
|
||||||
|
function getContentDimensions() {
|
||||||
|
const svg = document.getElementById('markmap');
|
||||||
|
if (!svg) return { width: 1000, height: 800 };
|
||||||
|
const mainG = svg.querySelector('g');
|
||||||
|
try {
|
||||||
|
if (mainG) {
|
||||||
|
const bbox = mainG.getBBox();
|
||||||
|
const padding = 40;
|
||||||
|
return {
|
||||||
|
width: Math.max(Math.ceil(bbox.width + padding * 2), 100),
|
||||||
|
height: Math.max(Math.ceil(bbox.height + padding * 2), 100)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) { /* 回退 */ }
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
return { width: Math.ceil(rect.width), height: Math.ceil(rect.height) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在 Canvas 中查找最佳分页切割点(在内容间隙处切割,避免切穿节点文字)
|
||||||
|
// 在 [searchStart, searchEnd] 范围内寻找全白行,返回最接近 idealY 的白行位置
|
||||||
|
function findBestSplitPoint(canvas, idealY, searchStart, searchEnd) {
|
||||||
|
try {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const regionH = searchEnd - searchStart;
|
||||||
|
if (regionH <= 0) return idealY;
|
||||||
|
|
||||||
|
// 获取搜索区域的像素数据
|
||||||
|
const imageData = ctx.getImageData(0, searchStart, canvas.width, regionH);
|
||||||
|
const data = imageData.data;
|
||||||
|
// 采样列以提高性能(约每 100 列采样一次)
|
||||||
|
const sampleStep = Math.max(1, Math.floor(canvas.width / 100));
|
||||||
|
|
||||||
|
// 查找范围内的全白行(像素值 ≥ 240 视为白色背景)
|
||||||
|
const whiteRows = [];
|
||||||
|
for (let y = 0; y < regionH; y++) {
|
||||||
|
let isWhite = true;
|
||||||
|
for (let x = 0; x < canvas.width; x += sampleStep) {
|
||||||
|
const idx = (y * canvas.width + x) * 4;
|
||||||
|
if (data[idx] < 240 || data[idx + 1] < 240 || data[idx + 2] < 240) {
|
||||||
|
isWhite = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isWhite) whiteRows.push(searchStart + y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (whiteRows.length === 0) return idealY;
|
||||||
|
|
||||||
|
// 返回最接近 idealY 的白行位置
|
||||||
|
let bestRow = whiteRows[0];
|
||||||
|
let bestDist = Math.abs(bestRow - idealY);
|
||||||
|
for (const row of whiteRows) {
|
||||||
|
const dist = Math.abs(row - idealY);
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
bestRow = row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestRow;
|
||||||
|
} catch (e) {
|
||||||
|
// canvas 可能被污染(tainted),回退到理想位置
|
||||||
|
return idealY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 导出 PNG ──
|
||||||
|
|
||||||
|
async function exportPNG() {
|
||||||
|
// 等待所有图片加载完成(确保 getBoundingClientRect 返回正确尺寸)
|
||||||
|
await waitForImages();
|
||||||
|
|
||||||
|
const svgContent = getSVGContent(true);
|
||||||
|
if (!svgContent) {
|
||||||
|
alert('无法生成 PNG,请确保思维导图已渲染。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 高清导出:3 倍缩放,内容过大时自动降级防止 Canvas 超限
|
||||||
|
const { width, height } = getContentDimensions();
|
||||||
|
const scale = getOptimalScale(width, height, 3);
|
||||||
|
const canvas = await renderToCanvas(svgContent, scale);
|
||||||
|
const blob = await new Promise(resolve => canvas.toBlob(resolve));
|
||||||
|
if (!blob) {
|
||||||
|
console.error('生成 PNG Blob 失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await saveFileAs(blob, getSuggestedFileName('png'), [{
|
||||||
|
description: 'PNG 图片',
|
||||||
|
accept: { 'image/png': ['.png'] }
|
||||||
|
}]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出 PNG 失败:', error);
|
||||||
|
alert('导出 PNG 失败,请重试。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 导出 PDF ──
|
||||||
|
// PDF 生成耗时较长,需在用户手势上下文中先弹出保存对话框,再生成内容
|
||||||
|
|
||||||
|
async function exportPDF(orientation = 'auto') {
|
||||||
|
if (!window.jspdf) {
|
||||||
|
alert('导出库未加载,无法导出 PDF。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先弹出原生保存对话框(必须在用户手势上下文中,耗时操作之前)
|
||||||
|
const suggestedName = getSuggestedFileName('pdf');
|
||||||
|
let fileHandle = null;
|
||||||
|
|
||||||
|
if (window.showSaveFilePicker) {
|
||||||
|
try {
|
||||||
|
fileHandle = await window.showSaveFilePicker({
|
||||||
|
suggestedName: suggestedName,
|
||||||
|
types: [{ description: 'PDF 文档', accept: { 'application/pdf': ['.pdf'] } }]
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') return; // 用户取消
|
||||||
|
console.warn('showSaveFilePicker 不可用,回退到传统下载:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待所有图片加载完成
|
||||||
|
await waitForImages();
|
||||||
|
|
||||||
|
const svgContent = getSVGContent(true);
|
||||||
|
if (!svgContent) {
|
||||||
|
alert('无法生成 PDF,请确保思维导图已渲染。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 高清渲染完整内容
|
||||||
|
const { width, height } = getContentDimensions();
|
||||||
|
const scale = getOptimalScale(width, height, 3);
|
||||||
|
const canvas = await renderToCanvas(svgContent, scale);
|
||||||
|
|
||||||
|
// A4 尺寸(mm)
|
||||||
|
const A4_SHORT = 210;
|
||||||
|
const A4_LONG = 297;
|
||||||
|
|
||||||
|
// 根据用户选择或内容宽高比确定方向
|
||||||
|
// orientation: 'auto'(自动)| 'landscape'(横向)| 'portrait'(纵向)
|
||||||
|
const contentRatio = canvas.width / canvas.height;
|
||||||
|
let useLandscape;
|
||||||
|
if (orientation === 'landscape') {
|
||||||
|
useLandscape = true;
|
||||||
|
} else if (orientation === 'portrait') {
|
||||||
|
useLandscape = false;
|
||||||
|
} else {
|
||||||
|
// 自动:宽 ≥ 高 → 横向,否则 → 纵向
|
||||||
|
useLandscape = contentRatio >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A4 纸张摆放宽度:竖向A4用短边(210mm),横向A4用长边(297mm)
|
||||||
|
// 内容按此宽度缩放,决定每像素对应的毫米数
|
||||||
|
const pageW = useLandscape ? A4_LONG : A4_SHORT; // 摆放宽度:竖向=短边,横向=长边
|
||||||
|
const pageH = useLandscape ? A4_SHORT : A4_LONG; // 页面高度:竖向=长边,横向=短边
|
||||||
|
|
||||||
|
// 将内容缩放至 A4 摆放宽度
|
||||||
|
const imgW = pageW;
|
||||||
|
const imgH = (canvas.height / canvas.width) * imgW;
|
||||||
|
|
||||||
|
// 计算总页数:内容高度超过一页时自动分页
|
||||||
|
const pagesNeeded = Math.max(1, Math.ceil(imgH / pageH));
|
||||||
|
|
||||||
|
const pdf = new window.jspdf.jsPDF({
|
||||||
|
orientation: useLandscape ? 'landscape' : 'portrait',
|
||||||
|
unit: 'mm',
|
||||||
|
format: 'a4'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pagesNeeded === 1) {
|
||||||
|
// 单页:垂直居中显示
|
||||||
|
const imgData = canvas.toDataURL('image/png');
|
||||||
|
const yOffset = (pageH - imgH) / 2;
|
||||||
|
pdf.addImage(imgData, 'PNG', 0, yOffset, imgW, imgH);
|
||||||
|
} else {
|
||||||
|
// 多页:智能分页,在内容间隙(白行)处切割,避免切穿节点文字
|
||||||
|
// 理想切割点:按照A4摆放宽度(竖向短边210mm / 横向长边297mm)缩放后,
|
||||||
|
// 一页A4高度对应的Canvas像素数 = (pageH / pageW) * canvas.width
|
||||||
|
// 竖向:(297 / 210) * canvas.width ≈ 1.414 × canvas.width
|
||||||
|
// 横向:(210 / 297) * canvas.width ≈ 0.707 × canvas.width
|
||||||
|
const srcSliceHeight = Math.round((pageH / pageW) * canvas.width);
|
||||||
|
// 搜索范围:在理想切割点向前 15% 页高内寻找白行
|
||||||
|
// 只向前搜索,确保每页切片不超过一页高度
|
||||||
|
const searchRange = Math.round(srcSliceHeight * 0.15);
|
||||||
|
|
||||||
|
// 逐页计算智能切割点
|
||||||
|
const splitPoints = [0];
|
||||||
|
let currentY = 0;
|
||||||
|
|
||||||
|
while (currentY < canvas.height - 1) {
|
||||||
|
const idealEnd = currentY + srcSliceHeight;
|
||||||
|
|
||||||
|
if (idealEnd >= canvas.height) {
|
||||||
|
// 剩余内容不足一页,作为最后一页
|
||||||
|
splitPoints.push(canvas.height);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在 [idealEnd - searchRange, idealEnd] 范围搜索白行
|
||||||
|
const searchStart = Math.max(currentY + 1, idealEnd - searchRange);
|
||||||
|
const bestSplit = findBestSplitPoint(canvas, idealEnd, searchStart, idealEnd);
|
||||||
|
|
||||||
|
if (bestSplit <= currentY) {
|
||||||
|
// 未找到合适白行,在理想位置切割
|
||||||
|
splitPoints.push(idealEnd);
|
||||||
|
currentY = idealEnd;
|
||||||
|
} else {
|
||||||
|
splitPoints.push(bestSplit);
|
||||||
|
currentY = bestSplit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按切割点逐页生成 PDF
|
||||||
|
for (let i = 0; i < splitPoints.length - 1; i++) {
|
||||||
|
if (i > 0) pdf.addPage();
|
||||||
|
|
||||||
|
const srcY = splitPoints[i];
|
||||||
|
const srcH = splitPoints[i + 1] - srcY;
|
||||||
|
|
||||||
|
const pageCanvas = document.createElement('canvas');
|
||||||
|
pageCanvas.width = canvas.width;
|
||||||
|
pageCanvas.height = srcH;
|
||||||
|
const ctx = pageCanvas.getContext('2d');
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, pageCanvas.width, pageCanvas.height);
|
||||||
|
ctx.drawImage(canvas, 0, srcY, canvas.width, srcH,
|
||||||
|
0, 0, canvas.width, srcH);
|
||||||
|
|
||||||
|
const pageImgData = pageCanvas.toDataURL('image/png');
|
||||||
|
// 当前页显示高度(mm),不超过页面高度
|
||||||
|
const sliceDisplayH = Math.min((srcH / canvas.width) * pageW, pageH);
|
||||||
|
// 内容置于顶部,便于多页拼接连续观看
|
||||||
|
pdf.addImage(pageImgData, 'PNG', 0, 0, pageW, sliceDisplayH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfBlob = pdf.output('blob');
|
||||||
|
|
||||||
|
// 写入文件:优先使用原生句柄,回退到传统下载
|
||||||
|
if (fileHandle) {
|
||||||
|
const writable = await fileHandle.createWritable();
|
||||||
|
await writable.write(pdfBlob);
|
||||||
|
await writable.close();
|
||||||
|
} else {
|
||||||
|
const url = URL.createObjectURL(pdfBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = suggestedName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出 PDF 失败:', error);
|
||||||
|
alert('导出 PDF 失败,请重试。');
|
||||||
|
}
|
||||||
|
}
|
||||||
1366
javascript/markmap_editor.js
Normal file
1366
javascript/markmap_editor.js
Normal file
File diff suppressed because it is too large
Load Diff
2
libs/d3/dist/d3.min.js
vendored
Normal file
2
libs/d3/dist/d3.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1244
libs/highlightjs/highlight.min.js
vendored
Normal file
1244
libs/highlightjs/highlight.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
86
libs/highlightjs/styles/default.min.css
vendored
Normal file
86
libs/highlightjs/styles/default.min.css
vendored
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
/*!
|
||||||
|
Theme: Default
|
||||||
|
Description: Original highlight.js style
|
||||||
|
Author: (c) Ivan Sagalaev <maniac@softwaremaniacs.org>
|
||||||
|
Maintainer: @highlightjs/core-team
|
||||||
|
Website: https://highlightjs.org/
|
||||||
|
License: see project LICENSE
|
||||||
|
Touched: 2021
|
||||||
|
*/
|
||||||
|
pre code.hljs {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
code.hljs {
|
||||||
|
padding: 3px 5px;
|
||||||
|
}
|
||||||
|
.hljs {
|
||||||
|
background: #f3f3f3;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
.hljs-comment {
|
||||||
|
color: #697070;
|
||||||
|
}
|
||||||
|
.hljs-punctuation,
|
||||||
|
.hljs-tag {
|
||||||
|
color: #444a;
|
||||||
|
}
|
||||||
|
.hljs-tag .hljs-attr,
|
||||||
|
.hljs-tag .hljs-name {
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
.hljs-attribute,
|
||||||
|
.hljs-doctag,
|
||||||
|
.hljs-keyword,
|
||||||
|
.hljs-meta .hljs-keyword,
|
||||||
|
.hljs-name,
|
||||||
|
.hljs-selector-tag {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.hljs-deletion,
|
||||||
|
.hljs-number,
|
||||||
|
.hljs-quote,
|
||||||
|
.hljs-selector-class,
|
||||||
|
.hljs-selector-id,
|
||||||
|
.hljs-string,
|
||||||
|
.hljs-template-tag,
|
||||||
|
.hljs-type {
|
||||||
|
color: #800;
|
||||||
|
}
|
||||||
|
.hljs-section,
|
||||||
|
.hljs-title {
|
||||||
|
color: #800;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.hljs-link,
|
||||||
|
.hljs-operator,
|
||||||
|
.hljs-regexp,
|
||||||
|
.hljs-selector-attr,
|
||||||
|
.hljs-selector-pseudo,
|
||||||
|
.hljs-symbol,
|
||||||
|
.hljs-template-variable,
|
||||||
|
.hljs-variable {
|
||||||
|
color: #ab5656;
|
||||||
|
}
|
||||||
|
.hljs-literal {
|
||||||
|
color: #695;
|
||||||
|
}
|
||||||
|
.hljs-addition,
|
||||||
|
.hljs-built_in,
|
||||||
|
.hljs-bullet,
|
||||||
|
.hljs-code {
|
||||||
|
color: #397300;
|
||||||
|
}
|
||||||
|
.hljs-meta {
|
||||||
|
color: #1f7199;
|
||||||
|
}
|
||||||
|
.hljs-meta .hljs-string {
|
||||||
|
color: #38a;
|
||||||
|
}
|
||||||
|
.hljs-emphasis {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.hljs-strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
20
libs/html2canvas.js
Normal file
20
libs/html2canvas.js
Normal file
File diff suppressed because one or more lines are too long
398
libs/jspdf.js
Normal file
398
libs/jspdf.js
Normal file
File diff suppressed because one or more lines are too long
BIN
libs/katex/dist/fonts/KaTeX_AMS-Regular.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_AMS-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_Caligraphic-Bold.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_Caligraphic-Bold.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_Caligraphic-Regular.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_Caligraphic-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_Fraktur-Bold.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_Fraktur-Bold.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_Fraktur-Regular.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_Fraktur-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_Main-Bold.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_Main-Bold.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_Main-BoldItalic.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_Main-BoldItalic.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_Main-Italic.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_Main-Italic.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_Main-Regular.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_Main-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_Math-BoldItalic.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_Math-BoldItalic.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_Math-Italic.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_Math-Italic.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_SansSerif-Bold.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_SansSerif-Bold.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_SansSerif-Italic.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_SansSerif-Italic.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_SansSerif-Regular.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_SansSerif-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_Script-Regular.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_Script-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_Size1-Regular.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_Size1-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_Size2-Regular.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_Size2-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_Size3-Regular.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_Size3-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_Size4-Regular.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_Size4-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
libs/katex/dist/fonts/KaTeX_Typewriter-Regular.woff2
vendored
Normal file
BIN
libs/katex/dist/fonts/KaTeX_Typewriter-Regular.woff2
vendored
Normal file
Binary file not shown.
1162
libs/katex/dist/katex.min.css
vendored
Normal file
1162
libs/katex/dist/katex.min.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
libs/katex/dist/katex.min.js
vendored
Normal file
1
libs/katex/dist/katex.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
libs/markmap_lib/dist/browser/index.iife.js
vendored
Normal file
2
libs/markmap_lib/dist/browser/index.iife.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
libs/markmap_view/dist/browser/index.js
vendored
Normal file
2
libs/markmap_view/dist/browser/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
libs/pako.js
Normal file
2
libs/pako.js
Normal file
File diff suppressed because one or more lines are too long
1
libs/prismjs/components/prism-core.min.js
vendored
Normal file
1
libs/prismjs/components/prism-core.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
libs/prismjs/plugins/autoloader/prism-autoloader.min.js
vendored
Normal file
1
libs/prismjs/plugins/autoloader/prism-autoloader.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
144
libs/prismjs/themes/prism.css
Normal file
144
libs/prismjs/themes/prism.css
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* prism.js default theme for JavaScript, CSS and HTML
|
||||||
|
* Based on dabblet (http://dabblet.com)
|
||||||
|
* @author Lea Verou
|
||||||
|
*/
|
||||||
|
|
||||||
|
code[class*='language-'],
|
||||||
|
pre[class*='language-'] {
|
||||||
|
color: black;
|
||||||
|
background: none;
|
||||||
|
text-shadow: 0 1px white;
|
||||||
|
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 1em;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre;
|
||||||
|
word-spacing: normal;
|
||||||
|
word-break: normal;
|
||||||
|
word-wrap: normal;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
-moz-tab-size: 4;
|
||||||
|
-o-tab-size: 4;
|
||||||
|
tab-size: 4;
|
||||||
|
|
||||||
|
-webkit-hyphens: none;
|
||||||
|
-moz-hyphens: none;
|
||||||
|
-ms-hyphens: none;
|
||||||
|
hyphens: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre[class*='language-']::-moz-selection,
|
||||||
|
pre[class*='language-'] ::-moz-selection,
|
||||||
|
code[class*='language-']::-moz-selection,
|
||||||
|
code[class*='language-'] ::-moz-selection {
|
||||||
|
text-shadow: none;
|
||||||
|
background: #b3d4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre[class*='language-']::selection,
|
||||||
|
pre[class*='language-'] ::selection,
|
||||||
|
code[class*='language-']::selection,
|
||||||
|
code[class*='language-'] ::selection {
|
||||||
|
text-shadow: none;
|
||||||
|
background: #b3d4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
code[class*='language-'],
|
||||||
|
pre[class*='language-'] {
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
pre[class*='language-'] {
|
||||||
|
padding: 1em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:not(pre) > code[class*='language-'],
|
||||||
|
pre[class*='language-'] {
|
||||||
|
background: #f5f2f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline code */
|
||||||
|
:not(pre) > code[class*='language-'] {
|
||||||
|
padding: 0.1em;
|
||||||
|
border-radius: 0.3em;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.comment,
|
||||||
|
.token.prolog,
|
||||||
|
.token.doctype,
|
||||||
|
.token.cdata {
|
||||||
|
color: slategray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.punctuation {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.namespace {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.property,
|
||||||
|
.token.tag,
|
||||||
|
.token.boolean,
|
||||||
|
.token.number,
|
||||||
|
.token.constant,
|
||||||
|
.token.symbol,
|
||||||
|
.token.deleted {
|
||||||
|
color: #905;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.selector,
|
||||||
|
.token.attr-name,
|
||||||
|
.token.string,
|
||||||
|
.token.char,
|
||||||
|
.token.builtin,
|
||||||
|
.token.inserted {
|
||||||
|
color: #690;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.operator,
|
||||||
|
.token.entity,
|
||||||
|
.token.url,
|
||||||
|
.language-css .token.string,
|
||||||
|
.style .token.string {
|
||||||
|
color: #9a6e3a;
|
||||||
|
/* This background color was intended by the author of this theme. */
|
||||||
|
background: hsla(0, 0%, 100%, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.atrule,
|
||||||
|
.token.attr-value,
|
||||||
|
.token.keyword {
|
||||||
|
color: #07a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.function,
|
||||||
|
.token.class-name {
|
||||||
|
color: #dd4a68;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.regex,
|
||||||
|
.token.important,
|
||||||
|
.token.variable {
|
||||||
|
color: #e90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.important,
|
||||||
|
.token.bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.token.italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.entity {
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
17
libs/webfontloader/webfontloader.js
Normal file
17
libs/webfontloader/webfontloader.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/* Web Font Loader v1.6.28 - (c) Adobe Systems, Google. License: Apache 2.0 */(function(){function aa(a,b,c){return a.call.apply(a.bind,arguments)}function ba(a,b,c){if(!a)throw Error();if(2<arguments.length){var d=Array.prototype.slice.call(arguments,2);return function(){var c=Array.prototype.slice.call(arguments);Array.prototype.unshift.apply(c,d);return a.apply(b,c)}}return function(){return a.apply(b,arguments)}}function p(a,b,c){p=Function.prototype.bind&&-1!=Function.prototype.bind.toString().indexOf("native code")?aa:ba;return p.apply(null,arguments)}var q=Date.now||function(){return+new Date};function ca(a,b){this.a=a;this.o=b||a;this.c=this.o.document}var da=!!window.FontFace;function t(a,b,c,d){b=a.c.createElement(b);if(c)for(var e in c)c.hasOwnProperty(e)&&("style"==e?b.style.cssText=c[e]:b.setAttribute(e,c[e]));d&&b.appendChild(a.c.createTextNode(d));return b}function u(a,b,c){a=a.c.getElementsByTagName(b)[0];a||(a=document.documentElement);a.insertBefore(c,a.lastChild)}function v(a){a.parentNode&&a.parentNode.removeChild(a)}
|
||||||
|
function w(a,b,c){b=b||[];c=c||[];for(var d=a.className.split(/\s+/),e=0;e<b.length;e+=1){for(var f=!1,g=0;g<d.length;g+=1)if(b[e]===d[g]){f=!0;break}f||d.push(b[e])}b=[];for(e=0;e<d.length;e+=1){f=!1;for(g=0;g<c.length;g+=1)if(d[e]===c[g]){f=!0;break}f||b.push(d[e])}a.className=b.join(" ").replace(/\s+/g," ").replace(/^\s+|\s+$/,"")}function y(a,b){for(var c=a.className.split(/\s+/),d=0,e=c.length;d<e;d++)if(c[d]==b)return!0;return!1}
|
||||||
|
function ea(a){return a.o.location.hostname||a.a.location.hostname}function z(a,b,c){function d(){m&&e&&f&&(m(g),m=null)}b=t(a,"link",{rel:"stylesheet",href:b,media:"all"});var e=!1,f=!0,g=null,m=c||null;da?(b.onload=function(){e=!0;d()},b.onerror=function(){e=!0;g=Error("Stylesheet failed to load");d()}):setTimeout(function(){e=!0;d()},0);u(a,"head",b)}
|
||||||
|
function A(a,b,c,d){var e=a.c.getElementsByTagName("head")[0];if(e){var f=t(a,"script",{src:b}),g=!1;f.onload=f.onreadystatechange=function(){g||this.readyState&&"loaded"!=this.readyState&&"complete"!=this.readyState||(g=!0,c&&c(null),f.onload=f.onreadystatechange=null,"HEAD"==f.parentNode.tagName&&e.removeChild(f))};e.appendChild(f);setTimeout(function(){g||(g=!0,c&&c(Error("Script load timeout")))},d||5E3);return f}return null};function B(){this.a=0;this.c=null}function C(a){a.a++;return function(){a.a--;D(a)}}function E(a,b){a.c=b;D(a)}function D(a){0==a.a&&a.c&&(a.c(),a.c=null)};function F(a){this.a=a||"-"}F.prototype.c=function(a){for(var b=[],c=0;c<arguments.length;c++)b.push(arguments[c].replace(/[\W_]+/g,"").toLowerCase());return b.join(this.a)};function G(a,b){this.c=a;this.f=4;this.a="n";var c=(b||"n4").match(/^([nio])([1-9])$/i);c&&(this.a=c[1],this.f=parseInt(c[2],10))}function fa(a){return H(a)+" "+(a.f+"00")+" 300px "+I(a.c)}function I(a){var b=[];a=a.split(/,\s*/);for(var c=0;c<a.length;c++){var d=a[c].replace(/['"]/g,"");-1!=d.indexOf(" ")||/^\d/.test(d)?b.push("'"+d+"'"):b.push(d)}return b.join(",")}function J(a){return a.a+a.f}function H(a){var b="normal";"o"===a.a?b="oblique":"i"===a.a&&(b="italic");return b}
|
||||||
|
function ga(a){var b=4,c="n",d=null;a&&((d=a.match(/(normal|oblique|italic)/i))&&d[1]&&(c=d[1].substr(0,1).toLowerCase()),(d=a.match(/([1-9]00|normal|bold)/i))&&d[1]&&(/bold/i.test(d[1])?b=7:/[1-9]00/.test(d[1])&&(b=parseInt(d[1].substr(0,1),10))));return c+b};function ha(a,b){this.c=a;this.f=a.o.document.documentElement;this.h=b;this.a=new F("-");this.j=!1!==b.events;this.g=!1!==b.classes}function ia(a){a.g&&w(a.f,[a.a.c("wf","loading")]);K(a,"loading")}function L(a){if(a.g){var b=y(a.f,a.a.c("wf","active")),c=[],d=[a.a.c("wf","loading")];b||c.push(a.a.c("wf","inactive"));w(a.f,c,d)}K(a,"inactive")}function K(a,b,c){if(a.j&&a.h[b])if(c)a.h[b](c.c,J(c));else a.h[b]()};function ja(){this.c={}}function ka(a,b,c){var d=[],e;for(e in b)if(b.hasOwnProperty(e)){var f=a.c[e];f&&d.push(f(b[e],c))}return d};function M(a,b){this.c=a;this.f=b;this.a=t(this.c,"span",{"aria-hidden":"true"},this.f)}function N(a){u(a.c,"body",a.a)}function O(a){return"display:block;position:absolute;top:-9999px;left:-9999px;font-size:300px;width:auto;height:auto;line-height:normal;margin:0;padding:0;font-variant:normal;white-space:nowrap;font-family:"+I(a.c)+";"+("font-style:"+H(a)+";font-weight:"+(a.f+"00")+";")};function P(a,b,c,d,e,f){this.g=a;this.j=b;this.a=d;this.c=c;this.f=e||3E3;this.h=f||void 0}P.prototype.start=function(){var a=this.c.o.document,b=this,c=q(),d=new Promise(function(d,e){function f(){q()-c>=b.f?e():a.fonts.load(fa(b.a),b.h).then(function(a){1<=a.length?d():setTimeout(f,25)},function(){e()})}f()}),e=null,f=new Promise(function(a,d){e=setTimeout(d,b.f)});Promise.race([f,d]).then(function(){e&&(clearTimeout(e),e=null);b.g(b.a)},function(){b.j(b.a)})};function Q(a,b,c,d,e,f,g){this.v=a;this.B=b;this.c=c;this.a=d;this.s=g||"BESbswy";this.f={};this.w=e||3E3;this.u=f||null;this.m=this.j=this.h=this.g=null;this.g=new M(this.c,this.s);this.h=new M(this.c,this.s);this.j=new M(this.c,this.s);this.m=new M(this.c,this.s);a=new G(this.a.c+",serif",J(this.a));a=O(a);this.g.a.style.cssText=a;a=new G(this.a.c+",sans-serif",J(this.a));a=O(a);this.h.a.style.cssText=a;a=new G("serif",J(this.a));a=O(a);this.j.a.style.cssText=a;a=new G("sans-serif",J(this.a));a=
|
||||||
|
O(a);this.m.a.style.cssText=a;N(this.g);N(this.h);N(this.j);N(this.m)}var R={D:"serif",C:"sans-serif"},S=null;function T(){if(null===S){var a=/AppleWebKit\/([0-9]+)(?:\.([0-9]+))/.exec(window.navigator.userAgent);S=!!a&&(536>parseInt(a[1],10)||536===parseInt(a[1],10)&&11>=parseInt(a[2],10))}return S}Q.prototype.start=function(){this.f.serif=this.j.a.offsetWidth;this.f["sans-serif"]=this.m.a.offsetWidth;this.A=q();U(this)};
|
||||||
|
function la(a,b,c){for(var d in R)if(R.hasOwnProperty(d)&&b===a.f[R[d]]&&c===a.f[R[d]])return!0;return!1}function U(a){var b=a.g.a.offsetWidth,c=a.h.a.offsetWidth,d;(d=b===a.f.serif&&c===a.f["sans-serif"])||(d=T()&&la(a,b,c));d?q()-a.A>=a.w?T()&&la(a,b,c)&&(null===a.u||a.u.hasOwnProperty(a.a.c))?V(a,a.v):V(a,a.B):ma(a):V(a,a.v)}function ma(a){setTimeout(p(function(){U(this)},a),50)}function V(a,b){setTimeout(p(function(){v(this.g.a);v(this.h.a);v(this.j.a);v(this.m.a);b(this.a)},a),0)};function W(a,b,c){this.c=a;this.a=b;this.f=0;this.m=this.j=!1;this.s=c}var X=null;W.prototype.g=function(a){var b=this.a;b.g&&w(b.f,[b.a.c("wf",a.c,J(a).toString(),"active")],[b.a.c("wf",a.c,J(a).toString(),"loading"),b.a.c("wf",a.c,J(a).toString(),"inactive")]);K(b,"fontactive",a);this.m=!0;na(this)};
|
||||||
|
W.prototype.h=function(a){var b=this.a;if(b.g){var c=y(b.f,b.a.c("wf",a.c,J(a).toString(),"active")),d=[],e=[b.a.c("wf",a.c,J(a).toString(),"loading")];c||d.push(b.a.c("wf",a.c,J(a).toString(),"inactive"));w(b.f,d,e)}K(b,"fontinactive",a);na(this)};function na(a){0==--a.f&&a.j&&(a.m?(a=a.a,a.g&&w(a.f,[a.a.c("wf","active")],[a.a.c("wf","loading"),a.a.c("wf","inactive")]),K(a,"active")):L(a.a))};function oa(a){this.j=a;this.a=new ja;this.h=0;this.f=this.g=!0}oa.prototype.load=function(a){this.c=new ca(this.j,a.context||this.j);this.g=!1!==a.events;this.f=!1!==a.classes;pa(this,new ha(this.c,a),a)};
|
||||||
|
function qa(a,b,c,d,e){var f=0==--a.h;(a.f||a.g)&&setTimeout(function(){var a=e||null,m=d||null||{};if(0===c.length&&f)L(b.a);else{b.f+=c.length;f&&(b.j=f);var h,l=[];for(h=0;h<c.length;h++){var k=c[h],n=m[k.c],r=b.a,x=k;r.g&&w(r.f,[r.a.c("wf",x.c,J(x).toString(),"loading")]);K(r,"fontloading",x);r=null;if(null===X)if(window.FontFace){var x=/Gecko.*Firefox\/(\d+)/.exec(window.navigator.userAgent),xa=/OS X.*Version\/10\..*Safari/.exec(window.navigator.userAgent)&&/Apple/.exec(window.navigator.vendor);
|
||||||
|
X=x?42<parseInt(x[1],10):xa?!1:!0}else X=!1;X?r=new P(p(b.g,b),p(b.h,b),b.c,k,b.s,n):r=new Q(p(b.g,b),p(b.h,b),b.c,k,b.s,a,n);l.push(r)}for(h=0;h<l.length;h++)l[h].start()}},0)}function pa(a,b,c){var d=[],e=c.timeout;ia(b);var d=ka(a.a,c,a.c),f=new W(a.c,b,e);a.h=d.length;b=0;for(c=d.length;b<c;b++)d[b].load(function(b,d,c){qa(a,f,b,d,c)})};function ra(a,b){this.c=a;this.a=b}
|
||||||
|
ra.prototype.load=function(a){function b(){if(f["__mti_fntLst"+d]){var c=f["__mti_fntLst"+d](),e=[],h;if(c)for(var l=0;l<c.length;l++){var k=c[l].fontfamily;void 0!=c[l].fontStyle&&void 0!=c[l].fontWeight?(h=c[l].fontStyle+c[l].fontWeight,e.push(new G(k,h))):e.push(new G(k))}a(e)}else setTimeout(function(){b()},50)}var c=this,d=c.a.projectId,e=c.a.version;if(d){var f=c.c.o;A(this.c,(c.a.api||"https://fast.fonts.net/jsapi")+"/"+d+".js"+(e?"?v="+e:""),function(e){e?a([]):(f["__MonotypeConfiguration__"+
|
||||||
|
d]=function(){return c.a},b())}).id="__MonotypeAPIScript__"+d}else a([])};function sa(a,b){this.c=a;this.a=b}sa.prototype.load=function(a){var b,c,d=this.a.urls||[],e=this.a.families||[],f=this.a.testStrings||{},g=new B;b=0;for(c=d.length;b<c;b++)z(this.c,d[b],C(g));var m=[];b=0;for(c=e.length;b<c;b++)if(d=e[b].split(":"),d[1])for(var h=d[1].split(","),l=0;l<h.length;l+=1)m.push(new G(d[0],h[l]));else m.push(new G(d[0]));E(g,function(){a(m,f)})};function ta(a,b){a?this.c=a:this.c=ua;this.a=[];this.f=[];this.g=b||""}var ua="https://fonts.googleapis.com/css";function va(a,b){for(var c=b.length,d=0;d<c;d++){var e=b[d].split(":");3==e.length&&a.f.push(e.pop());var f="";2==e.length&&""!=e[1]&&(f=":");a.a.push(e.join(f))}}
|
||||||
|
function wa(a){if(0==a.a.length)throw Error("No fonts to load!");if(-1!=a.c.indexOf("kit="))return a.c;for(var b=a.a.length,c=[],d=0;d<b;d++)c.push(a.a[d].replace(/ /g,"+"));b=a.c+"?family="+c.join("%7C");0<a.f.length&&(b+="&subset="+a.f.join(","));0<a.g.length&&(b+="&text="+encodeURIComponent(a.g));return b};function ya(a){this.f=a;this.a=[];this.c={}}
|
||||||
|
var za={latin:"BESbswy","latin-ext":"\u00e7\u00f6\u00fc\u011f\u015f",cyrillic:"\u0439\u044f\u0416",greek:"\u03b1\u03b2\u03a3",khmer:"\u1780\u1781\u1782",Hanuman:"\u1780\u1781\u1782"},Aa={thin:"1",extralight:"2","extra-light":"2",ultralight:"2","ultra-light":"2",light:"3",regular:"4",book:"4",medium:"5","semi-bold":"6",semibold:"6","demi-bold":"6",demibold:"6",bold:"7","extra-bold":"8",extrabold:"8","ultra-bold":"8",ultrabold:"8",black:"9",heavy:"9",l:"3",r:"4",b:"7"},Ba={i:"i",italic:"i",n:"n",normal:"n"},
|
||||||
|
Ca=/^(thin|(?:(?:extra|ultra)-?)?light|regular|book|medium|(?:(?:semi|demi|extra|ultra)-?)?bold|black|heavy|l|r|b|[1-9]00)?(n|i|normal|italic)?$/;
|
||||||
|
function Da(a){for(var b=a.f.length,c=0;c<b;c++){var d=a.f[c].split(":"),e=d[0].replace(/\+/g," "),f=["n4"];if(2<=d.length){var g;var m=d[1];g=[];if(m)for(var m=m.split(","),h=m.length,l=0;l<h;l++){var k;k=m[l];if(k.match(/^[\w-]+$/)){var n=Ca.exec(k.toLowerCase());if(null==n)k="";else{k=n[2];k=null==k||""==k?"n":Ba[k];n=n[1];if(null==n||""==n)n="4";else var r=Aa[n],n=r?r:isNaN(n)?"4":n.substr(0,1);k=[k,n].join("")}}else k="";k&&g.push(k)}0<g.length&&(f=g);3==d.length&&(d=d[2],g=[],d=d?d.split(","):
|
||||||
|
g,0<d.length&&(d=za[d[0]])&&(a.c[e]=d))}a.c[e]||(d=za[e])&&(a.c[e]=d);for(d=0;d<f.length;d+=1)a.a.push(new G(e,f[d]))}};function Ea(a,b){this.c=a;this.a=b}var Fa={Arimo:!0,Cousine:!0,Tinos:!0};Ea.prototype.load=function(a){var b=new B,c=this.c,d=new ta(this.a.api,this.a.text),e=this.a.families;va(d,e);var f=new ya(e);Da(f);z(c,wa(d),C(b));E(b,function(){a(f.a,f.c,Fa)})};function Ga(a,b){this.c=a;this.a=b}Ga.prototype.load=function(a){var b=this.a.id,c=this.c.o;b?A(this.c,(this.a.api||"https://use.typekit.net")+"/"+b+".js",function(b){if(b)a([]);else if(c.Typekit&&c.Typekit.config&&c.Typekit.config.fn){b=c.Typekit.config.fn;for(var e=[],f=0;f<b.length;f+=2)for(var g=b[f],m=b[f+1],h=0;h<m.length;h++)e.push(new G(g,m[h]));try{c.Typekit.load({events:!1,classes:!1,async:!0})}catch(l){}a(e)}},2E3):a([])};function Ha(a,b){this.c=a;this.f=b;this.a=[]}Ha.prototype.load=function(a){var b=this.f.id,c=this.c.o,d=this;b?(c.__webfontfontdeckmodule__||(c.__webfontfontdeckmodule__={}),c.__webfontfontdeckmodule__[b]=function(b,c){for(var g=0,m=c.fonts.length;g<m;++g){var h=c.fonts[g];d.a.push(new G(h.name,ga("font-weight:"+h.weight+";font-style:"+h.style)))}a(d.a)},A(this.c,(this.f.api||"https://f.fontdeck.com/s/css/js/")+ea(this.c)+"/"+b+".js",function(b){b&&a([])})):a([])};var Y=new oa(window);Y.a.c.custom=function(a,b){return new sa(b,a)};Y.a.c.fontdeck=function(a,b){return new Ha(b,a)};Y.a.c.monotype=function(a,b){return new ra(b,a)};Y.a.c.typekit=function(a,b){return new Ga(b,a)};Y.a.c.google=function(a,b){return new Ea(b,a)};var Z={load:p(Y.load,Y)};"function"===typeof define&&define.amd?define(function(){return Z}):"undefined"!==typeof module&&module.exports?module.exports=Z:(window.WebFont=Z,window.WebFontConfig&&Y.load(window.WebFontConfig));}());
|
||||||
89
serve.py
Normal file
89
serve.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Markmap 本地服务器
|
||||||
|
|
||||||
|
启动一个简单的 HTTP 服务器,用于本地预览 Markmap 编辑器。
|
||||||
|
支持 Python 3.x,无需安装任何额外依赖。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python serve.py # 默认端口 8080
|
||||||
|
python serve.py 3000 # 指定端口 3000
|
||||||
|
python serve.py 3000 0.0.0.0 # 绑定到所有网络接口
|
||||||
|
"""
|
||||||
|
|
||||||
|
import http.server
|
||||||
|
import socketserver
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
# 默认配置
|
||||||
|
DEFAULT_PORT = 8080
|
||||||
|
DEFAULT_HOST = '127.0.0.1'
|
||||||
|
|
||||||
|
def main():
|
||||||
|
port = DEFAULT_PORT
|
||||||
|
host = DEFAULT_HOST
|
||||||
|
|
||||||
|
# 解析命令行参数
|
||||||
|
if len(sys.argv) >= 2:
|
||||||
|
try:
|
||||||
|
port = int(sys.argv[1])
|
||||||
|
except ValueError:
|
||||||
|
print(f'无效的端口号: {sys.argv[1]}')
|
||||||
|
sys.exit(1)
|
||||||
|
if len(sys.argv) >= 3:
|
||||||
|
host = sys.argv[2]
|
||||||
|
|
||||||
|
# 切换到脚本所在目录
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
os.chdir(script_dir)
|
||||||
|
|
||||||
|
handler = http.server.SimpleHTTPRequestHandler
|
||||||
|
|
||||||
|
# 添加 CORS 头和正确的 MIME 类型
|
||||||
|
class MarkmapHandler(handler):
|
||||||
|
def end_headers(self):
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
self.send_header('Cache-Control', 'no-cache')
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
def guess_type(self, path):
|
||||||
|
mimetype = super().guess_type(path)
|
||||||
|
# 确保正确的 MIME 类型
|
||||||
|
if path.endswith('.mjs') or path.endswith('.js'):
|
||||||
|
return 'application/javascript'
|
||||||
|
if path.endswith('.css'):
|
||||||
|
return 'text/css'
|
||||||
|
if path.endswith('.svg'):
|
||||||
|
return 'image/svg+xml'
|
||||||
|
if path.endswith('.md'):
|
||||||
|
return 'text/markdown'
|
||||||
|
if path.endswith('.woff2'):
|
||||||
|
return 'font/woff2'
|
||||||
|
return mimetype
|
||||||
|
|
||||||
|
try:
|
||||||
|
with socketserver.TCPServer((host, port), MarkmapHandler) as httpd:
|
||||||
|
url = f'http://{host}:{port}'
|
||||||
|
print(f'')
|
||||||
|
print(f' Markmap 编辑器已启动')
|
||||||
|
print(f' 访问地址: {url}')
|
||||||
|
print(f' 按 Ctrl+C 停止服务器')
|
||||||
|
print(f'')
|
||||||
|
|
||||||
|
# 自动打开浏览器
|
||||||
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
httpd.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print(f'\n服务器已停止')
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == 48 or 'Address already in use' in str(e):
|
||||||
|
print(f'端口 {port} 已被占用,请尝试其他端口')
|
||||||
|
else:
|
||||||
|
print(f'启动失败: {e}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
43
start.bat
Normal file
43
start.bat
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul 2>&1
|
||||||
|
title Markmap 编辑器
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Markmap 编辑器 - 本地服务器
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM 尝试用 Python 启动服务器
|
||||||
|
python --version >nul 2>&1
|
||||||
|
if %errorlevel% == 0 (
|
||||||
|
echo 正在使用 Python 启动服务器...
|
||||||
|
echo.
|
||||||
|
python serve.py 8080
|
||||||
|
goto :end
|
||||||
|
)
|
||||||
|
|
||||||
|
REM 尝试用 Python3 启动服务器
|
||||||
|
python3 --version >nul 2>&1
|
||||||
|
if %errorlevel% == 0 (
|
||||||
|
echo 正在使用 Python3 启动服务器...
|
||||||
|
echo.
|
||||||
|
python3 serve.py 8080
|
||||||
|
goto :end
|
||||||
|
)
|
||||||
|
|
||||||
|
REM 尝试用 py 启动服务器
|
||||||
|
py --version >nul 2>&1
|
||||||
|
if %errorlevel% == 0 (
|
||||||
|
echo 正在使用 py 启动服务器...
|
||||||
|
echo.
|
||||||
|
py serve.py 8080
|
||||||
|
goto :end
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [错误] 未找到 Python,请先安装 Python 3.x
|
||||||
|
echo 下载地址: https://www.python.org/downloads/
|
||||||
|
echo.
|
||||||
|
echo 或者直接用浏览器打开 index.html(部分功能可能受限)
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
|
||||||
|
:end
|
||||||
Loading…
x
Reference in New Issue
Block a user