1442 lines
48 KiB
JavaScript
1442 lines
48 KiB
JavaScript
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 ``;
|
||
});
|
||
}
|
||
|
||
// 图片压缩:缩放大图并转为 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 = `<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 = ``;
|
||
|
||
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);
|