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 回退)── // 保存:同步接口,内部异步写 IndexedDB(fire-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 = `${opt.label}${opt.desc}`; 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 = ``; 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 = ''; 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 = `
${timeStr}
`; 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);