markmap/javascript/markmap_editor.js
2026-08-13 15:56:33 +08:00

1442 lines
48 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
// ── 移动端菜单 ──
// 点击 ⋮ 按钮展开/收起工具栏下拉浮层
// 选择操作后自动收起(主题按钮除外,它有自己的子菜单)
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();
}
});
}
// ── 可拖动分割线 ──
// 桌面端:左右拖动调整编辑区宽度
// 移动端:上下拖动调整编辑区高度,支持触屏
function isMobileLayout() {
return window.matchMedia('(max-width: 768px)').matches;
}
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;
function onStart(e) {
isDragging = true;
splitter.classList.add('dragging');
document.body.style.cursor = isMobileLayout() ? 'row-resize' : 'col-resize';
document.body.style.userSelect = 'none';
e.preventDefault();
}
function onMove(e) {
if (!isDragging) return;
const rect = main.getBoundingClientRect();
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 = '';
}
}
function onEnd() {
if (isDragging) {
isDragging = false;
splitter.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
if (mm) mm.fit();
}
}
// 鼠标事件
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();
});
}
// ── 插入图片(本地文件转 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();
initMobileMenu();
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);