markmap/javascript/markmap_editor.js

1442 lines
48 KiB
JavaScript
Raw Permalink Normal View History

2026-08-12 16:54:48 +08:00
const editor = document.getElementById('editor');
const { Markmap, loadCSS, loadJS, deriveOptions, Transformer } = window.markmap;
let mm;
const transformer = new Transformer();
// ── 思维导图主题(复用 markmap 库内置主题 + 自定义彩色主题)──
// native: true 表示使用库内置主题(含节点样式、连线弧度、线宽、悬停效果等)
// native: false 表示自定义主题,仅设置颜色
const THEMES = {
'': {
name: '默认',
native: false,
colors: null
},
'soft': {
name: '柔和',
native: true
},
'dark': {
name: '深色',
native: true,
stripCanvas: true
},
'forest': {
name: '森林',
native: true
},
'monochrome': {
name: '单色',
native: true
},
'minimal': {
name: '极简',
native: true
},
'colorful': {
name: '彩色',
native: false,
colors: ['#e6194b', '#3cb44b', '#ffe119', '#4363d8', '#f58231', '#911eb4', '#42d4f4', '#f032e6', '#bfef45', '#fabed4']
}
};
let currentTheme = '';
let currentMode = localStorage.getItem('markmap-mode') || 'day';
// 自动适应窗口:双击"适应窗口"按钮开启,内容变化时自动 fit再次双击关闭
let autoFitEnabled = false;
// ── 文档历史localStorage 缓存)──
const DOC_HISTORY_KEY = 'markmap-doc-history';
const MAX_HISTORY = 50;
let currentDocId = null;
// ── 图片数据存储(编辑器中用短占位符,渲染/保存时展开为 Base64──
const imageDataMap = new Map();
let imageCounter = 0;
function expandImages(text) {
return text.replace(/local:(img_\d+)/g, (match, id) => {
const data = imageDataMap.get(id);
return data || match;
});
}
function restoreImages(images) {
imageDataMap.clear();
imageCounter = 0;
if (images) {
Object.entries(images).forEach(([k, v]) => {
// 跳过空值,避免无效数据进入 imageMap 导致占位符无法展开
if (v) {
imageDataMap.set(k, v);
const num = parseInt(k.replace('img_', ''), 10);
if (num > imageCounter) imageCounter = num;
}
});
}
}
// 同步 imageMap 与编辑器内容:清除文档中已不引用的孤儿图片数据
// 避免已删除的图片继续占用 imageDataMap 内存和 localStorage 空间
function syncImageMapWithContent(text) {
// 扫描文本中所有被引用的图片 ID
const referencedIds = new Set();
const regex = /local:(img_\d+)/g;
let m;
while ((m = regex.exec(text)) !== null) {
referencedIds.add(m[1]);
}
// 移除 imageMap 中未被引用的孤儿图片
const orphanedIds = [];
for (const id of imageDataMap.keys()) {
if (!referencedIds.has(id)) {
orphanedIds.push(id);
imageDataMap.delete(id);
}
}
return orphanedIds;
}
// 扫描文本中的 Base64 图片,转为占位符并存入 imageMap
function extractImagesFromContent(text) {
const regex = /!\[([^\]]*)\]\((data:image\/[^)]+)\)/g;
return text.replace(regex, (match, alt, dataUrl) => {
const imgId = `img_${++imageCounter}`;
imageDataMap.set(imgId, dataUrl);
return `![${alt}](local:${imgId})`;
});
}
// 图片压缩:缩放大图并转为 JPEG 以减小 localStorage 占用
async function compressImage(base64, maxWidth = 1200, quality = 0.75) {
// SVG 是矢量图canvas 无法正确渲染,不压缩
if (base64.startsWith('data:image/svg')) return base64;
// 小图(< 100KB无需压缩
if (base64.length < 100000) return base64;
return new Promise(resolve => {
const img = new Image();
img.onload = () => {
let w = img.width;
let h = img.height;
if (w > maxWidth) {
h = Math.round(h * maxWidth / w);
w = maxWidth;
}
// 限制 canvas 最大高度,防止超高图片超出浏览器 canvas 限制16384px
// 导致 toDataURL 返回空数据或失败
const MAX_CANVAS_H = 16000;
if (h > MAX_CANVAS_H) {
w = Math.round(w * MAX_CANVAS_H / h);
h = MAX_CANVAS_H;
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
try {
const result = canvas.toDataURL('image/jpeg', quality);
// 验证压缩结果有效(非空且非空白 data URL
if (result && result.length > 100) {
resolve(result);
} else {
// 压缩失败,回退到原始数据
console.warn('图片压缩结果无效,使用原始数据');
resolve(base64);
}
} catch (e) {
console.warn('canvas.toDataURL 失败,使用原始数据:', e);
resolve(base64);
}
};
img.onerror = () => resolve(base64);
img.src = base64;
});
}
// ── IndexedDB 图片存储(大容量,替代 localStorage──
// localStorage 限制 5-10MB多张图片容易超出配额导致保存失败
// IndexedDB 容量通常为 50MB-无限制,适合存储 Base64 图片
const IMG_DB_NAME = 'markmap-images';
const IMG_DB_VERSION = 1;
const IMG_STORE = 'images';
let _imgDB = null;
function getImgDB() {
if (!window.indexedDB) return Promise.resolve(null);
if (_imgDB) return Promise.resolve(_imgDB);
return new Promise((resolve) => {
const req = indexedDB.open(IMG_DB_NAME, IMG_DB_VERSION);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(IMG_STORE)) {
db.createObjectStore(IMG_STORE);
}
};
req.onsuccess = () => { _imgDB = req.result; resolve(_imgDB); };
req.onerror = () => resolve(null);
});
}
// IDB: 保存文档所有图片key=docId, value={imgId: base64, ...}
async function saveDocImagesToIDB(docId, images) {
const db = await getImgDB();
if (!db) return false;
return new Promise((resolve) => {
const tx = db.transaction(IMG_STORE, 'readwrite');
tx.objectStore(IMG_STORE).put(images || {}, docId);
tx.oncomplete = () => resolve(true);
tx.onerror = () => resolve(false);
});
}
// IDB: 加载文档所有图片
async function loadDocImagesFromIDB(docId) {
const db = await getImgDB();
if (!db) return undefined;
return new Promise((resolve) => {
const tx = db.transaction(IMG_STORE, 'readonly');
const req = tx.objectStore(IMG_STORE).get(docId);
req.onsuccess = () => {
const images = req.result;
if (images && typeof images === 'object' && Object.keys(images).length > 0) {
resolve(images);
} else {
resolve(undefined);
}
};
req.onerror = () => resolve(undefined);
});
}
// IDB: 删除文档图片
async function deleteDocImagesFromIDB(docId) {
const db = await getImgDB();
if (!db) return;
return new Promise((resolve) => {
const tx = db.transaction(IMG_STORE, 'readwrite');
tx.objectStore(IMG_STORE).delete(docId);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
}
// ── localStorage 图片存储(仅作为 IndexedDB 不可用时的回退)──
const IMG_KEY_PREFIX = 'markmap-img-';
function saveDocImagesToLS(docId, images) {
if (!images) images = {};
const prefix = `${IMG_KEY_PREFIX}${docId}-`;
const currentIds = new Set(Object.keys(images));
const keysToDelete = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) {
const oldImgId = key.slice(prefix.length);
if (!currentIds.has(oldImgId)) {
keysToDelete.push(key);
}
}
}
keysToDelete.forEach(key => localStorage.removeItem(key));
let allSaved = true;
for (const [imgId, base64] of Object.entries(images)) {
if (!base64) continue;
try {
localStorage.setItem(`${prefix}${imgId}`, base64);
} catch (e) {
console.error(`LS 保存图片 ${imgId} 失败:`, e);
allSaved = false;
}
}
return allSaved;
}
function loadDocImagesFromLS(docId) {
const prefix = `${IMG_KEY_PREFIX}${docId}-`;
const images = {};
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) {
const imgId = key.slice(prefix.length);
const data = localStorage.getItem(key);
if (data) {
images[imgId] = data;
}
}
}
return Object.keys(images).length > 0 ? images : undefined;
}
function deleteDocImagesFromLS(docId) {
const prefix = `${IMG_KEY_PREFIX}${docId}-`;
const keysToDelete = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) {
keysToDelete.push(key);
}
}
keysToDelete.forEach(key => localStorage.removeItem(key));
}
// ── 统一图片存储接口IndexedDB 优先localStorage 回退)──
// 保存:同步接口,内部异步写 IndexedDBfire-and-forget
function saveDocImages(docId, images) {
if (!images) images = {};
if (window.indexedDB) {
// 异步保存到 IndexedDB不阻塞fire-and-forget
saveDocImagesToIDB(docId, images).catch(e => {
console.error('IndexedDB 保存图片失败,回退到 localStorage:', e);
saveDocImagesToLS(docId, images);
});
return true;
} else {
// 无 IndexedDB 时直接用 localStorage
return saveDocImagesToLS(docId, images);
}
}
// 加载:异步接口,先查 IndexedDB再回退 localStorage
async function loadDocImagesAsync(docId) {
if (window.indexedDB) {
const idbImages = await loadDocImagesFromIDB(docId);
if (idbImages) {
// 迁移:如果 localStorage 中也有该文档的旧图片,清理掉释放空间
const lsImages = loadDocImagesFromLS(docId);
if (lsImages) {
deleteDocImagesFromLS(docId);
}
return idbImages;
}
}
// 回退到 localStorage
return loadDocImagesFromLS(docId);
}
// 删除:同时清理 IndexedDB 和 localStorage
function deleteDocImages(docId) {
deleteDocImagesFromIDB(docId).catch(() => {});
deleteDocImagesFromLS(docId);
}
// 一次性迁移:将 localStorage 中的旧图片数据迁移到 IndexedDB
async function migrateImagesToIDB() {
if (!window.indexedDB) return;
const docs = getDocHistory();
for (const doc of docs) {
const lsImages = loadDocImagesFromLS(doc.id);
if (lsImages) {
await saveDocImagesToIDB(doc.id, lsImages);
deleteDocImagesFromLS(doc.id);
}
}
}
// 配置本地资源加载(使用 libs/ 目录中的预构建文件)
// 将带版本号的包名映射为本地清洁目录名
transformer.urlBuilder.setProvider('local', (path) => {
let p = path;
// @highlightjs/cdn-assets@11.11.1/... → highlightjs/...
p = p.replace(/^@highlightjs\/cdn-assets@[\d.]+\//, 'highlightjs/');
// package@version/... → package/...
p = p.replace(/^([^@\/]+)@[\d.]+\//, '$1/');
return `./libs/${p}`;
});
transformer.urlBuilder.provider = 'local';
// ── 内置默认内容(无需服务器也能显示)──
const DEFAULT_CONTENT = `---
title: Markmap
markmap:
colorFreezeLevel: 2
---
# Markmap 编辑器
## 功能特性
- 实时预览
- 左侧输入 Markdown
- 右侧自动生成思维导图
- 配色主题
- 默认
- 柔和
- 深色
- 森林
- 单色
- 极简
- 彩色
- 日间/夜间模式
- 一键切换背景配色
- 导出功能
- SVG 矢量图
- PNG 图片
- PDF 文档
- 其他
- 分享链接
- 全屏模式
- 加载/保存文件
- 历史文档侧边栏
- 插入本地图片
## 快速开始
- 在左侧文本框中输入 Markdown
- 使用 \`#\` 标题或 \`-\` 列表来组织层级
- 点击右下角工具栏进行缩放和导出
## Markdown 语法示例
### 文本样式
- **粗体文本**
- *斜体文本*
- ~~删除线~~
- \`行内代码\`
### 列表
- 一级项目
- 二级项目
- 三级项目
- 另一个二级项目
- 另一个一级项目
### 代码块
\`\`\`javascript
function hello() {
console.log('Hello, Markmap!');
}
\`\`\`
### 数学公式
- 行内公式: $E = mc^2$
- 块级公式: $\\int_0^1 x^2 dx = \\frac{1}{3}$
### 链接
- [Markmap 官网](https://markmap.js.org/)
- [GitHub 仓库](https://github.com/gera2ld/markmap)
`;
// ── 防抖工具 ──
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// ── 事件监听器 ──
document.getElementById('new-map')?.addEventListener('click', newMap);
document.getElementById('load-map')?.addEventListener('click', loadMap);
document.getElementById('save-map')?.addEventListener('click', saveMap);
document.getElementById('share-url')?.addEventListener('click', () => {
shareURL().catch(error => console.error('分享链接生成失败:', error));
});
function newMap() {
if (confirm('确定要创建新的思维导图吗?当前内容将被保存到历史记录。')) {
autoSaveToHistory();
currentDocId = 'doc_' + Date.now();
restoreImages(null);
editor.value = '# 新的思维导图\n\n- 主题 1\n - 子主题 1\n - 子主题 2\n- 主题 2\n - 子主题 1\n - 子主题 2';
updateMarkmap();
autoSaveToHistory();
}
}
function loadMap() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.md,.markdown,.txt';
input.onchange = e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
autoSaveToHistory();
currentDocId = 'doc_' + Date.now();
restoreImages(null);
editor.value = extractImagesFromContent(e.target.result);
updateMarkmap();
autoSaveToHistory();
};
reader.onerror = function() {
console.error('读取文件失败');
alert('文件读取失败,请重试。');
};
reader.readAsText(file);
};
input.click();
}
function saveMap() {
const content = expandImages(editor.value);
const blob = new Blob([content], {type: 'text/markdown;charset=utf-8'});
saveFileAs(blob, getSuggestedFileName('md'), [{
description: 'Markdown 文件',
accept: { 'text/markdown': ['.md'] }
}]);
}
function toggleFullscreen() {
document.body.classList.toggle('fullscreen');
// 等待 CSS 过渡完成后重新渲染
setTimeout(() => {
updateMarkmap();
if (mm) mm.fit();
}, 100);
}
// 展开所有节点:从根节点开始逐层展开,移除所有 fold 标记
function expandAllNodes() {
if (!mm || !mm.state || !mm.state.data) return;
function walkTree(node) {
if (node.payload && node.payload.fold !== undefined) {
delete node.payload.fold;
}
if (node.children) {
node.children.forEach(walkTree);
}
}
walkTree(mm.state.data);
mm.renderData(mm.state.data).then(() => {
if (autoFitEnabled && mm) mm.fit();
});
}
// 收起所有节点:保留第一层子节点可见,折叠其下所有层级
function collapseAllNodes() {
if (!mm || !mm.state || !mm.state.data) return;
function walkTree(node, depth) {
// depth=0 是根节点depth=1 是第一层子节点(保留可见)
// depth≥2 的有子节点的节点全部折叠
if (depth >= 1 && node.children && node.children.length > 0) {
if (!node.payload) node.payload = {};
node.payload.fold = 1;
}
if (node.children) {
node.children.forEach(child => walkTree(child, depth + 1));
}
}
walkTree(mm.state.data, 0);
mm.renderData(mm.state.data).then(() => {
if (autoFitEnabled && mm) mm.fit();
});
}
// PDF 导出方向选择弹出菜单
function showPdfOrientationMenu(btnElement) {
// 移除已存在的菜单(再次点击同一按钮时关闭)
const existing = document.querySelector('.pdf-orientation-menu');
if (existing) {
existing.remove();
return;
}
const menu = document.createElement('div');
menu.className = 'pdf-orientation-menu';
const options = [
{ label: '自动选择', desc: '根据内容比例', value: 'auto' },
{ label: '横向导出', desc: 'Landscape', value: 'landscape' },
{ label: '纵向导出', desc: 'Portrait', value: 'portrait' }
];
options.forEach(opt => {
const item = document.createElement('button');
item.className = 'pdf-orientation-item';
item.innerHTML = `<span class="pdf-orientation-label">${opt.label}</span><span class="pdf-orientation-desc">${opt.desc}</span>`;
item.addEventListener('click', () => {
menu.remove();
exportPDF(opt.value);
});
menu.appendChild(item);
});
// 定位到按钮上方
const rect = btnElement.getBoundingClientRect();
menu.style.position = 'fixed';
menu.style.bottom = (window.innerHeight - rect.top + 6) + 'px';
menu.style.right = (window.innerWidth - rect.right) + 'px';
document.body.appendChild(menu);
// 点击外部关闭菜单
setTimeout(() => {
const closeHandler = (e) => {
if (!menu.contains(e.target) && e.target !== btnElement && !btnElement.contains(e.target)) {
menu.remove();
document.removeEventListener('click', closeHandler);
}
};
document.addEventListener('click', closeHandler);
}, 0);
}
function createToolbar(mm) {
const toolbar = document.createElement('div');
toolbar.className = 'markmap-toolbar';
const buttons = [
{title: '放大', path: 'M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14zm-2.5-4h2v2h1v-2h2V9h-2V7h-1v2H7v1z', action: () => mm.rescale(1.25)},
{title: '缩小', path: 'M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14zM7 9h5v1H7z', action: () => mm.rescale(0.8)},
{title: '适应窗口', path: 'M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z', action: () => mm.fit()},
{title: '展开全部', path: 'M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z', action: expandAllNodes},
{title: '收起全部', path: 'M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z', action: collapseAllNodes},
{title: '全屏', path: 'M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z', action: toggleFullscreen},
{title: '导出 SVG', path: 'M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z', action: exportSVG},
{title: '导出 PNG', path: 'M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z', action: exportPNG},
{title: '导出 PDF', path: 'M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8.5 7.5c0 .83-.67 1.5-1.5 1.5H9v2H7.5V7H10c.83 0 1.5.67 1.5 1.5v1zm5 2c0 .83-.67 1.5-1.5 1.5h-2.5V7H15c.83 0 1.5.67 1.5 1.5v3zm4-3H19v1h1.5V11H19v2h-1.5V7h3v1.5zM9 9.5h1v-1H9v1zM4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm10 5.5h1v-3h-1v3z', action: exportPDF}
];
buttons.forEach(btn => {
const button = document.createElement('button');
button.className = 'markmap-btn';
button.dataset.tooltip = btn.title;
button.innerHTML = `<svg viewBox="0 0 24 24" width="20" height="20"><path d="${btn.path}"></path></svg>`;
if (btn.title === '适应窗口') {
// 单击:适应窗口;双击:切换自动适应模式
button.addEventListener('click', () => mm.fit());
button.addEventListener('dblclick', () => {
autoFitEnabled = !autoFitEnabled;
button.classList.toggle('autofit-active', autoFitEnabled);
button.dataset.tooltip = autoFitEnabled ? '自动适应(已开启,双击关闭)' : '适应窗口';
if (autoFitEnabled && mm) mm.fit();
});
} else if (btn.title === '导出 PDF') {
// PDF 按钮弹出方向选择菜单
button.addEventListener('click', () => showPdfOrientationMenu(button));
} else {
button.addEventListener('click', btn.action);
}
toolbar.appendChild(button);
});
return toolbar;
}
// 防抖更新,避免输入时频繁渲染
const debouncedUpdate = debounce(updateMarkmap, 300);
// 共享防抖自动保存500ms 延迟,避免每次按键都写 localStorage
const debouncedAutoSave = debounce(autoSaveToHistory, 500);
function updateMarkmap() {
try {
const fullContent = expandImages(editor.value);
const { root, features, frontmatter, relations } = transformer.transform(fullContent);
const { styles, scripts } = transformer.getUsedAssets(features);
const opts = deriveOptions(frontmatter?.markmap);
// 从 frontmatter 提取并应用主题
currentTheme = frontmatter?.markmap?.theme || '';
applyTheme(currentTheme, opts);
if (styles) loadCSS(styles);
if (scripts) loadJS(scripts, { getMarkmap: () => window.markmap });
if (mm) {
// 自动适应窗口模式:内容变化后自动 fit
if (autoFitEnabled) {
mm.setData(root, opts, relations).then(() => mm.fit());
} else {
mm.setData(root, opts, relations);
}
} else {
mm = new Markmap('#markmap', opts);
mm.setData(root, opts, relations).then(() => mm.fit());
// 添加浮动工具栏(缩放/全屏/导出)
const toolbar = createToolbar(mm);
document.getElementById('markmap-container').appendChild(toolbar);
// 监听思维导图节点点击:区分单击(切换展开/折叠)和双击(展开该节点下所有内容)
const svgEl = document.getElementById('markmap');
if (svgEl) {
let pendingClickTimer = null;
const DBLCLICK_DELAY = 250;
// 捕获阶段拦截节点点击,阻止 markmap 默认 toggle自行区分单击/双击
svgEl.addEventListener('click', (e) => {
const nodeEl = e.target.closest('.markmap-node');
if (!nodeEl) return; // 非节点点击,不拦截
// 阻止 markmap 内部的 handleClick避免双击时 toggle 两次造成闪烁)
e.stopPropagation();
const nodeData = nodeEl.__data__;
if (pendingClickTimer) {
// 第二次点击 → 双击:切换该节点直接子节点(一层)的展开/收起
clearTimeout(pendingClickTimer);
pendingClickTimer = null;
if (nodeData && nodeData.children && mm) {
// 判断子节点是否全部展开:有任一子节点 fold=1 则视为未全展开
const allExpanded = nodeData.children.every(child =>
!child.payload || child.payload.fold === undefined
);
nodeData.children.forEach(child => {
if (!child.payload) child.payload = {};
if (allExpanded) {
// 全展开 → 收起
child.payload.fold = 1;
} else {
// 未全展开 → 展开
delete child.payload.fold;
}
});
mm.renderData(mm.state.data).then(() => {
if (autoFitEnabled && mm) mm.fit();
});
}
} else {
// 第一次点击 → 等待判断是否为双击
pendingClickTimer = setTimeout(() => {
pendingClickTimer = null;
// 超时无第二次点击 → 单击:执行 markmap 默认 toggle
if (nodeData && mm) {
mm.toggleNode(nodeData).then(() => {
if (autoFitEnabled) mm.fit();
});
}
}, DBLCLICK_DELAY);
}
}, true); // 捕获阶段
// 非节点区域点击(背景),自动适应窗口
svgEl.addEventListener('click', (e) => {
if (autoFitEnabled && !e.target.closest('.markmap-node')) {
setTimeout(() => { if (mm) mm.fit(); }, 400);
}
});
}
}
} catch (error) {
console.error('渲染思维导图失败:', error);
}
}
function loadFromHash() {
if (window.location.hash) {
const compressedContent = window.location.hash.slice(1);
const content = decompressContent(compressedContent);
if (content) {
restoreImages(null);
editor.value = extractImagesFromContent(content);
updateMarkmap();
return true;
}
}
return false;
}
// ── 文档历史功能 ──
function getDocHistory() {
try {
return JSON.parse(localStorage.getItem(DOC_HISTORY_KEY) || '[]');
} catch {
return [];
}
}
function saveDocHistory(docs) {
try {
localStorage.setItem(DOC_HISTORY_KEY, JSON.stringify(docs));
} catch (e) {
console.error('保存文档历史失败:', e);
}
}
function extractDocTitle(content) {
// 尝试从 frontmatter 提取 title
const fmMatch = content.match(/^---\r?\n[\s\S]*?title:\s*(.+?)\r?\n/);
if (fmMatch) return fmMatch[1].trim().replace(/^["']|["']$/g, '');
// 尝试从第一个 # 标题提取
const headingMatch = content.match(/^#\s+(.+)$/m);
if (headingMatch) return headingMatch[1].trim();
return '未命名文档';
}
function autoSaveToHistory() {
if (!editor.value.trim()) return;
if (!currentDocId) {
currentDocId = 'doc_' + Date.now();
}
const docs = getDocHistory();
const existingIdx = docs.findIndex(d => d.id === currentDocId);
// 文档元数据(不含图片数据,避免大图导致整体保存失败)
const doc = {
id: currentDocId,
title: extractDocTitle(editor.value),
content: editor.value,
updatedAt: new Date().toISOString()
};
if (existingIdx >= 0) {
docs[existingIdx] = doc;
} else {
docs.unshift(doc);
if (docs.length > MAX_HISTORY) {
docs.length = MAX_HISTORY;
}
}
saveDocHistory(docs);
// 保存前同步清理:移除编辑器中已删除的孤儿图片,释放内存和存储空间
syncImageMapWithContent(editor.value);
// 图片存储到 IndexedDB大容量不再受 localStorage 5-10MB 限制
if (imageDataMap.size > 0) {
const images = Object.fromEntries(imageDataMap);
saveDocImages(currentDocId, images);
} else {
// 没有图片时也清理该文档的旧图片存储
saveDocImages(currentDocId, {});
}
renderSidebarDocs();
}
function renderSidebarDocs() {
const container = document.getElementById('sidebar-docs');
if (!container) return;
const docs = getDocHistory();
container.innerHTML = '';
if (docs.length === 0) {
container.innerHTML = '<div class="sidebar-empty">暂无历史文档</div>';
return;
}
docs.forEach(doc => {
const item = document.createElement('div');
item.className = 'doc-item' + (doc.id === currentDocId ? ' active' : '');
const time = new Date(doc.updatedAt);
const timeStr = formatTime(time);
item.innerHTML = `
<div class="doc-item-title"></div>
<div class="doc-item-time">${timeStr}</div>
<button class="doc-item-delete" title="删除">×</button>
`;
item.querySelector('.doc-item-title').textContent = doc.title;
item.addEventListener('click', (e) => {
if (e.target.classList.contains('doc-item-delete')) return;
loadDocFromHistory(doc.id);
});
item.querySelector('.doc-item-delete').addEventListener('click', (e) => {
e.stopPropagation();
deleteDocFromHistory(doc.id);
});
container.appendChild(item);
});
}
function formatTime(date) {
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes} 分钟前`;
if (hours < 24) return `${hours} 小时前`;
if (days < 7) return `${days} 天前`;
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
async function loadDocFromHistory(docId) {
autoSaveToHistory();
const docs = getDocHistory();
const doc = docs.find(d => d.id === docId);
if (!doc) return;
currentDocId = doc.id;
// 异步从 IndexedDB 加载图片,回退到 localStorage
let images = await loadDocImagesAsync(doc.id);
if (!images && doc.images) {
// 旧版数据迁移:将内联图片转存到独立存储
images = doc.images;
saveDocImages(doc.id, images);
delete doc.images;
saveDocHistory(docs);
}
restoreImages(images);
editor.value = doc.content;
updateMarkmap();
renderSidebarDocs();
}
async function deleteDocFromHistory(docId) {
const docs = getDocHistory();
const doc = docs.find(d => d.id === docId);
const title = doc ? doc.title : '该文档';
if (!confirm(`确定要删除「${title}」吗?`)) return;
let updated = docs.filter(d => d.id !== docId);
saveDocHistory(updated);
// 删除该文档关联的图片IndexedDB + localStorage
deleteDocImages(docId);
if (docId === currentDocId) {
if (updated.length > 0) {
// 直接加载下一篇,不调用 loadDocFromHistory其内部的 autoSaveToHistory 会重新保存已删除文档)
const nextDoc = updated[0];
currentDocId = nextDoc.id;
// 异步加载下一篇图片
let nextImages = await loadDocImagesAsync(nextDoc.id);
if (!nextImages && nextDoc.images) {
nextImages = nextDoc.images;
}
restoreImages(nextImages);
editor.value = nextDoc.content;
updateMarkmap();
} else {
currentDocId = null;
restoreImages(null);
editor.value = '';
updateMarkmap();
}
}
renderSidebarDocs();
}
function initSidebar() {
const sidebarToggle = document.getElementById('sidebar-toggle');
const sidebar = document.getElementById('sidebar');
const sidebarNew = document.getElementById('sidebar-new');
// 禁用过渡动画,避免刷新时出现收缩动效
sidebar.classList.add('no-anim');
// 恢复侧边栏状态(默认收缩,仅当用户之前手动展开时才展开)
if (localStorage.getItem('markmap-sidebar-collapsed') === 'false') {
sidebar.classList.remove('collapsed');
}
// 下一帧恢复过渡动画,确保后续交互有动效
requestAnimationFrame(() => {
requestAnimationFrame(() => {
sidebar.classList.remove('no-anim');
});
});
if (sidebarToggle && sidebar) {
sidebarToggle.addEventListener('click', () => {
sidebar.classList.toggle('collapsed');
localStorage.setItem('markmap-sidebar-collapsed', sidebar.classList.contains('collapsed'));
setTimeout(() => { if (mm) mm.fit(); }, 300);
});
}
if (sidebarNew) {
sidebarNew.addEventListener('click', () => newMap());
}
renderSidebarDocs();
}
// ── 主题功能 ──
function applyTheme(theme, opts) {
const themeConfig = THEMES[theme];
// 库原生主题deriveOptions 已处理颜色/节点样式/线宽等,无需覆盖
// 仅处理需要修正的项
if (themeConfig?.native) {
// dark 主题自带 canvasColor移除以保持背景由日/夜间模式控制
if (themeConfig.stripCanvas && opts.theme) {
opts.theme = { ...opts.theme, canvasColor: undefined };
}
} else if (themeConfig?.colors) {
// 自定义主题(如彩色):使用 scaleOrdinal 按分支路径分配颜色
const scale = d3.scaleOrdinal(themeConfig.colors);
opts.color = (node) => scale(`${node.state.path}`);
}
updateThemeUI();
}
function setTheme(theme) {
const content = editor.value;
const themeValue = theme || '';
// 匹配 frontmatter 块
const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---/;
const fmMatch = content.match(fmRegex);
if (fmMatch) {
let fm = fmMatch[1];
// 检查是否已存在 theme 行
const themeLineRegex = /^(\s+)theme:\s*.+$/m;
const themeLineMatch = fm.match(themeLineRegex);
if (themeLineMatch) {
if (themeValue) {
// 替换已有 theme 值
fm = fm.replace(themeLineRegex, `$1theme: ${themeValue}`);
} else {
// 移除 theme 行
fm = fm.replace(/^\s+theme:.*\r?\n?/m, '');
}
} else if (themeValue) {
// 尚无 theme 行,需添加
const markmapRegex = /^markmap:\s*\r?\n/m;
if (markmapRegex.test(fm)) {
// 在 markmap 段落首行添加 theme
fm = fm.replace(markmapRegex, `markmap:\n theme: ${themeValue}\n`);
} else {
// 添加 markmap 段落
fm = fm + `\nmarkmap:\n theme: ${themeValue}`;
}
}
editor.value = content.replace(fmRegex, `---\n${fm}\n---`);
} else if (themeValue) {
// 无 frontmatter创建一个
editor.value = `---\nmarkmap:\n theme: ${themeValue}\n---\n\n${content}`;
}
updateMarkmap();
}
function updateThemeUI() {
const themeDropdown = document.getElementById('theme-dropdown');
if (!themeDropdown) return;
themeDropdown.querySelectorAll('button').forEach(btn => {
const btnTheme = btn.dataset.theme || '';
btn.classList.toggle('active', btnTheme === currentTheme);
});
}
function initThemeSelector() {
const themeBtn = document.getElementById('theme-btn');
const themeDropdown = document.getElementById('theme-dropdown');
if (!themeBtn || !themeDropdown) return;
// 切换下拉菜单
themeBtn.addEventListener('click', (e) => {
e.stopPropagation();
themeDropdown.classList.toggle('show');
});
// 点击外部关闭下拉菜单
document.addEventListener('click', (e) => {
if (!e.target.closest('.theme-selector')) {
themeDropdown.classList.remove('show');
}
});
// 主题选择
themeDropdown.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
setTheme(btn.dataset.theme);
themeDropdown.classList.remove('show');
});
});
}
// ── 日间/夜间模式 ──
function toggleMode() {
currentMode = currentMode === 'day' ? 'night' : 'day';
localStorage.setItem('markmap-mode', currentMode);
applyMode();
if (mm) mm.fit();
}
function applyMode() {
document.body.classList.toggle('night-mode', currentMode === 'night');
const modeBtn = document.getElementById('mode-toggle');
if (modeBtn) {
modeBtn.textContent = currentMode === 'day' ? '日间' : '夜间';
}
}
function initModeToggle() {
const modeBtn = document.getElementById('mode-toggle');
if (!modeBtn) return;
modeBtn.addEventListener('click', toggleMode);
applyMode();
}
2026-08-13 15:56:33 +08:00
// ── 移动端菜单 ──
// 点击 ⋮ 按钮展开/收起工具栏下拉浮层
// 选择操作后自动收起(主题按钮除外,它有自己的子菜单)
function initMobileMenu() {
const menuBtn = document.getElementById('mobile-menu-btn');
const toolbar = document.querySelector('.toolbar');
if (!menuBtn || !toolbar) return;
function closeMenu() {
toolbar.classList.remove('show');
menuBtn.classList.remove('menu-active');
}
// 点击 ⋮ 按钮切换工具栏显隐
menuBtn.addEventListener('click', () => {
const willShow = !toolbar.classList.contains('show');
toolbar.classList.toggle('show', willShow);
menuBtn.classList.toggle('menu-active', willShow);
});
// 点击工具栏内按钮后自动收起(主题按钮除外,它有自己的子菜单)
toolbar.querySelectorAll('button').forEach(btn => {
if (btn.id === 'theme-btn') return;
btn.addEventListener('click', closeMenu);
});
// 点击外部关闭菜单
document.addEventListener('click', (e) => {
if (!e.target.closest('#mobile-menu-btn') && !e.target.closest('.toolbar')) {
closeMenu();
}
});
}
2026-08-12 16:54:48 +08:00
// ── 可拖动分割线 ──
2026-08-13 15:56:33 +08:00
// 桌面端:左右拖动调整编辑区宽度
// 移动端:上下拖动调整编辑区高度,支持触屏
function isMobileLayout() {
return window.matchMedia('(max-width: 768px)').matches;
}
2026-08-12 16:54:48 +08:00
function initSplitter() {
const splitter = document.getElementById('splitter');
const editorContainer = document.getElementById('editor-container');
const main = document.querySelector('main');
if (!splitter || !editorContainer || !main) return;
let isDragging = false;
2026-08-13 15:56:33 +08:00
function onStart(e) {
2026-08-12 16:54:48 +08:00
isDragging = true;
splitter.classList.add('dragging');
2026-08-13 15:56:33 +08:00
document.body.style.cursor = isMobileLayout() ? 'row-resize' : 'col-resize';
2026-08-12 16:54:48 +08:00
document.body.style.userSelect = 'none';
e.preventDefault();
2026-08-13 15:56:33 +08:00
}
2026-08-12 16:54:48 +08:00
2026-08-13 15:56:33 +08:00
function onMove(e) {
2026-08-12 16:54:48 +08:00
if (!isDragging) return;
const rect = main.getBoundingClientRect();
2026-08-13 15:56:33 +08:00
if (isMobileLayout()) {
// 移动端:纵向拖动,调整编辑区高度
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const y = clientY - rect.top;
const percentage = (y / rect.height) * 100;
const clamped = Math.max(15, Math.min(85, percentage));
editorContainer.style.height = `${clamped}%`;
editorContainer.style.width = '100%';
} else {
// 桌面端:横向拖动,调整编辑区宽度
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const x = clientX - rect.left;
const percentage = (x / rect.width) * 100;
const clamped = Math.max(10, Math.min(90, percentage));
editorContainer.style.width = `${clamped}%`;
editorContainer.style.height = '';
}
}
2026-08-12 16:54:48 +08:00
2026-08-13 15:56:33 +08:00
function onEnd() {
2026-08-12 16:54:48 +08:00
if (isDragging) {
isDragging = false;
splitter.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
if (mm) mm.fit();
}
2026-08-13 15:56:33 +08:00
}
// 鼠标事件
splitter.addEventListener('mousedown', onStart);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onEnd);
// 触屏事件
splitter.addEventListener('touchstart', onStart, { passive: false });
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('touchend', onEnd);
// 窗口尺寸变化时重置布局模式
window.addEventListener('resize', () => {
if (!isMobileLayout()) {
// 切回桌面端时清除移动端设置的 height
editorContainer.style.height = '';
} else {
// 切到移动端时清除桌面端设置的 width
editorContainer.style.width = '100%';
}
if (mm) mm.fit();
2026-08-12 16:54:48 +08:00
});
}
// ── 插入图片(本地文件转 Base64──
function insertImage() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/png,image/jpeg,image/gif,image/svg+xml,image/webp';
input.onchange = e => {
const file = e.target.files[0];
if (!file) return;
// 限制图片大小5MB避免 Base64 过长撑爆 localStorage
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
alert('图片过大(超过 5MB请选择较小的图片或压缩后再插入。');
return;
}
const reader = new FileReader();
reader.onload = async function(ev) {
const rawBase64 = ev.target.result;
// 压缩图片以减小 localStorage 占用(大图缩放至 1200px 宽,转 JPEG 75% 质量)
const base64 = await compressImage(rawBase64);
const fileName = file.name.replace(/\.[^.]+$/, ''); // 去扩展名作为描述
// 将 Base64 存入 imageMap编辑器中只插入短占位符
const imgId = `img_${++imageCounter}`;
imageDataMap.set(imgId, base64);
// 在光标位置插入 Markdown 图片语法(使用占位符)
const start = editor.selectionStart;
const end = editor.selectionEnd;
const text = editor.value;
const insertText = `![${fileName}](local:${imgId})`;
editor.value = text.slice(0, start) + insertText + text.slice(end);
editor.selectionStart = editor.selectionEnd = start + insertText.length;
editor.focus();
updateMarkmap();
// 立即保存图片到 IndexedDB不等防抖防止刷新丢失
autoSaveToHistory();
};
reader.onerror = function() {
alert('图片读取失败,请重试。');
};
reader.readAsDataURL(file);
};
input.click();
}
function loadDefaultContent() {
fetch('content.md')
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
})
.then(content => {
editor.value = content;
updateMarkmap();
autoSaveToHistory();
})
.catch(() => {
// 直接打开 file:// 时 fetch 会失败,使用内置默认内容
editor.value = DEFAULT_CONTENT;
updateMarkmap();
autoSaveToHistory();
});
}
async function init() {
// 初始化侧边栏
initSidebar();
// 先执行一次性迁移:将 localStorage 中的旧图片数据迁移到 IndexedDB
await migrateImagesToIDB();
if (!loadFromHash()) {
// 尝试从历史记录加载最近的文档
const docs = getDocHistory();
if (docs.length > 0) {
currentDocId = docs[0].id;
// 异步从 IndexedDB 加载图片,回退到 localStorage
let images = await loadDocImagesAsync(docs[0].id);
if (!images && docs[0].images) {
// 旧版数据迁移:将内联图片转存到独立存储
images = docs[0].images;
saveDocImages(docs[0].id, images);
delete docs[0].images;
saveDocHistory(docs);
}
restoreImages(images);
editor.value = docs[0].content;
updateMarkmap();
renderSidebarDocs();
} else {
loadDefaultContent();
}
} else {
// 从分享链接加载loadFromHash 已提取图片到 imageMap
currentDocId = null;
autoSaveToHistory();
}
// 防抖更新与自动保存(使用共享的 debouncedAutoSave
editor.addEventListener('input', () => {
debouncedUpdate();
debouncedAutoSave();
});
// 编辑器失焦时立即保存
editor.addEventListener('blur', () => autoSaveToHistory());
// 页面关闭前立即保存
window.addEventListener('beforeunload', () => autoSaveToHistory());
// 页面切换到后台时立即保存(移动端切换 App、PC 切换标签页)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') autoSaveToHistory();
});
// 初始化主题选择器、模式切换和分割线
initThemeSelector();
initModeToggle();
2026-08-13 15:56:33 +08:00
initMobileMenu();
2026-08-12 16:54:48 +08:00
initSplitter();
// 插入图片按钮
document.getElementById('insert-image')?.addEventListener('click', insertImage);
// 窗口大小变化时重新适配
window.addEventListener('resize', debounce(() => {
if (mm) mm.fit();
}, 200));
// 键盘快捷键
document.addEventListener('keydown', (e) => {
// Ctrl/Cmd + S: 保存
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveMap();
}
});
}
// ── 压缩/解压(用于分享链接)──
function compressContent(content) {
const uint8Array = new TextEncoder().encode(content);
const compressed = pako.gzip(uint8Array);
// 分块转换,避免 fromCharCode.apply 栈溢出
let binaryString = '';
const chunkSize = 8192;
for (let i = 0; i < compressed.length; i += chunkSize) {
binaryString += String.fromCharCode.apply(null, compressed.subarray(i, i + chunkSize));
}
return btoa(binaryString)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
function decompressContent(compressed) {
try {
const binaryString = atob(compressed.replace(/-/g, '+').replace(/_/g, '/'));
const uint8Array = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
uint8Array[i] = binaryString.charCodeAt(i);
}
const decompressed = pako.ungzip(uint8Array);
return new TextDecoder().decode(decompressed);
} catch (error) {
console.error('解压内容失败:', error);
return null;
}
}
function generateShareableURL() {
const content = expandImages(editor.value);
const compressedContent = compressContent(content);
return `${window.location.origin}${window.location.pathname}#${compressedContent}`;
}
async function shareURL() {
const url = generateShareableURL();
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(url);
alert('分享链接已复制到剪贴板!');
} catch (err) {
console.error('复制链接失败:', err);
fallbackCopyTextToClipboard(url);
}
} else {
fallbackCopyTextToClipboard(url);
}
}
function fallbackCopyTextToClipboard(text) {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.position = 'fixed';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
alert(successful ? '分享链接已复制到剪贴板!' : '无法复制链接,请手动复制');
} catch (err) {
console.error('回退复制失败:', err);
prompt('请手动复制此链接进行分享:', text);
}
document.body.removeChild(textArea);
}
// 确保 DOM 加载完成后执行
document.addEventListener('DOMContentLoaded', init);