初版测试

This commit is contained in:
张梦南 2026-08-12 16:54:48 +08:00
parent 3afda95906
commit ee2202a0b5
43 changed files with 6277 additions and 2 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
# 系统文件
.DS_Store
Thumbs.db
desktop.ini
# 编辑器
.vscode/
.idea/
*.swp
*.swo
*~
# 临时文件
*.log
*.tmp

154
README.md
View File

@ -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
View 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
View 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
View 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...&#10;&#10;# 标题&#10;- 列表项 1&#10; - 子项&#10;- 列表项 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
View 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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

86
libs/highlightjs/styles/default.min.css vendored Normal file
View 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

File diff suppressed because one or more lines are too long

398
libs/jspdf.js Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
libs/pako.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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;
}

View 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
View 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
View 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