2026-08-12 16:54:48 +08:00
|
|
|
|
const editor = document.getElementById('editor');
|
|
|
|
|
|
const { Markmap, loadCSS, loadJS, deriveOptions, Transformer } = window.markmap;
|
|
|
|
|
|
|
|
|
|
|
|
let mm;
|
|
|
|
|
|
const transformer = new Transformer();
|
|
|
|
|
|
|
|
|
|
|
|
// ── 思维导图主题(复用 markmap 库内置主题 + 自定义彩色主题)──
|
|
|
|
|
|
// native: true 表示使用库内置主题(含节点样式、连线弧度、线宽、悬停效果等)
|
|
|
|
|
|
// native: false 表示自定义主题,仅设置颜色
|
|
|
|
|
|
const THEMES = {
|
|
|
|
|
|
'': {
|
|
|
|
|
|
name: '默认',
|
|
|
|
|
|
native: false,
|
|
|
|
|
|
colors: null
|
|
|
|
|
|
},
|
|
|
|
|
|
'soft': {
|
|
|
|
|
|
name: '柔和',
|
|
|
|
|
|
native: true
|
|
|
|
|
|
},
|
|
|
|
|
|
'dark': {
|
|
|
|
|
|
name: '深色',
|
|
|
|
|
|
native: true,
|
|
|
|
|
|
stripCanvas: true
|
|
|
|
|
|
},
|
|
|
|
|
|
'forest': {
|
|
|
|
|
|
name: '森林',
|
|
|
|
|
|
native: true
|
|
|
|
|
|
},
|
|
|
|
|
|
'monochrome': {
|
|
|
|
|
|
name: '单色',
|
|
|
|
|
|
native: true
|
|
|
|
|
|
},
|
|
|
|
|
|
'minimal': {
|
|
|
|
|
|
name: '极简',
|
|
|
|
|
|
native: true
|
|
|
|
|
|
},
|
|
|
|
|
|
'colorful': {
|
|
|
|
|
|
name: '彩色',
|
|
|
|
|
|
native: false,
|
|
|
|
|
|
colors: ['#e6194b', '#3cb44b', '#ffe119', '#4363d8', '#f58231', '#911eb4', '#42d4f4', '#f032e6', '#bfef45', '#fabed4']
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let currentTheme = '';
|
|
|
|
|
|
let currentMode = localStorage.getItem('markmap-mode') || 'day';
|
|
|
|
|
|
|
|
|
|
|
|
// 自动适应窗口:双击"适应窗口"按钮开启,内容变化时自动 fit;再次双击关闭
|
|
|
|
|
|
let autoFitEnabled = false;
|
|
|
|
|
|
|
|
|
|
|
|
// ── 文档历史(localStorage 缓存)──
|
|
|
|
|
|
const DOC_HISTORY_KEY = 'markmap-doc-history';
|
|
|
|
|
|
const MAX_HISTORY = 50;
|
|
|
|
|
|
let currentDocId = null;
|
|
|
|
|
|
|
|
|
|
|
|
// ── 图片数据存储(编辑器中用短占位符,渲染/保存时展开为 Base64)──
|
|
|
|
|
|
const imageDataMap = new Map();
|
|
|
|
|
|
let imageCounter = 0;
|
|
|
|
|
|
|
|
|
|
|
|
function expandImages(text) {
|
|
|
|
|
|
return text.replace(/local:(img_\d+)/g, (match, id) => {
|
|
|
|
|
|
const data = imageDataMap.get(id);
|
|
|
|
|
|
return data || match;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function restoreImages(images) {
|
|
|
|
|
|
imageDataMap.clear();
|
|
|
|
|
|
imageCounter = 0;
|
|
|
|
|
|
if (images) {
|
|
|
|
|
|
Object.entries(images).forEach(([k, v]) => {
|
|
|
|
|
|
// 跳过空值,避免无效数据进入 imageMap 导致占位符无法展开
|
|
|
|
|
|
if (v) {
|
|
|
|
|
|
imageDataMap.set(k, v);
|
|
|
|
|
|
const num = parseInt(k.replace('img_', ''), 10);
|
|
|
|
|
|
if (num > imageCounter) imageCounter = num;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 同步 imageMap 与编辑器内容:清除文档中已不引用的孤儿图片数据
|
|
|
|
|
|
// 避免已删除的图片继续占用 imageDataMap 内存和 localStorage 空间
|
|
|
|
|
|
function syncImageMapWithContent(text) {
|
|
|
|
|
|
// 扫描文本中所有被引用的图片 ID
|
|
|
|
|
|
const referencedIds = new Set();
|
|
|
|
|
|
const regex = /local:(img_\d+)/g;
|
|
|
|
|
|
let m;
|
|
|
|
|
|
while ((m = regex.exec(text)) !== null) {
|
|
|
|
|
|
referencedIds.add(m[1]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 移除 imageMap 中未被引用的孤儿图片
|
|
|
|
|
|
const orphanedIds = [];
|
|
|
|
|
|
for (const id of imageDataMap.keys()) {
|
|
|
|
|
|
if (!referencedIds.has(id)) {
|
|
|
|
|
|
orphanedIds.push(id);
|
|
|
|
|
|
imageDataMap.delete(id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return orphanedIds;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 扫描文本中的 Base64 图片,转为占位符并存入 imageMap
|
|
|
|
|
|
function extractImagesFromContent(text) {
|
|
|
|
|
|
const regex = /!\[([^\]]*)\]\((data:image\/[^)]+)\)/g;
|
|
|
|
|
|
return text.replace(regex, (match, alt, dataUrl) => {
|
|
|
|
|
|
const imgId = `img_${++imageCounter}`;
|
|
|
|
|
|
imageDataMap.set(imgId, dataUrl);
|
|
|
|
|
|
return ``;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 图片压缩:缩放大图并转为 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 15:56:33 +08:00
|
|
|
|
// ── 移动端菜单 ──
|
|
|
|
|
|
// 点击 ⋮ 按钮展开/收起工具栏下拉浮层
|
|
|
|
|
|
// 选择操作后自动收起(主题按钮除外,它有自己的子菜单)
|
|
|
|
|
|
|
|
|
|
|
|
function initMobileMenu() {
|
|
|
|
|
|
const menuBtn = document.getElementById('mobile-menu-btn');
|
|
|
|
|
|
const toolbar = document.querySelector('.toolbar');
|
|
|
|
|
|
if (!menuBtn || !toolbar) return;
|
|
|
|
|
|
|
|
|
|
|
|
function closeMenu() {
|
|
|
|
|
|
toolbar.classList.remove('show');
|
|
|
|
|
|
menuBtn.classList.remove('menu-active');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 点击 ⋮ 按钮切换工具栏显隐
|
|
|
|
|
|
menuBtn.addEventListener('click', () => {
|
|
|
|
|
|
const willShow = !toolbar.classList.contains('show');
|
|
|
|
|
|
toolbar.classList.toggle('show', willShow);
|
|
|
|
|
|
menuBtn.classList.toggle('menu-active', willShow);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 点击工具栏内按钮后自动收起(主题按钮除外,它有自己的子菜单)
|
|
|
|
|
|
toolbar.querySelectorAll('button').forEach(btn => {
|
|
|
|
|
|
if (btn.id === 'theme-btn') return;
|
|
|
|
|
|
btn.addEventListener('click', closeMenu);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 点击外部关闭菜单
|
|
|
|
|
|
document.addEventListener('click', (e) => {
|
|
|
|
|
|
if (!e.target.closest('#mobile-menu-btn') && !e.target.closest('.toolbar')) {
|
|
|
|
|
|
closeMenu();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 16:54:48 +08:00
|
|
|
|
// ── 可拖动分割线 ──
|
2026-08-13 15:56:33 +08:00
|
|
|
|
// 桌面端:左右拖动调整编辑区宽度
|
|
|
|
|
|
// 移动端:上下拖动调整编辑区高度,支持触屏
|
|
|
|
|
|
|
|
|
|
|
|
function isMobileLayout() {
|
|
|
|
|
|
return window.matchMedia('(max-width: 768px)').matches;
|
|
|
|
|
|
}
|
2026-08-12 16:54:48 +08:00
|
|
|
|
|
|
|
|
|
|
function initSplitter() {
|
|
|
|
|
|
const splitter = document.getElementById('splitter');
|
|
|
|
|
|
const editorContainer = document.getElementById('editor-container');
|
|
|
|
|
|
const main = document.querySelector('main');
|
|
|
|
|
|
if (!splitter || !editorContainer || !main) return;
|
|
|
|
|
|
|
|
|
|
|
|
let isDragging = false;
|
|
|
|
|
|
|
2026-08-13 15:56:33 +08:00
|
|
|
|
function onStart(e) {
|
2026-08-12 16:54:48 +08:00
|
|
|
|
isDragging = true;
|
|
|
|
|
|
splitter.classList.add('dragging');
|
2026-08-13 15:56:33 +08:00
|
|
|
|
document.body.style.cursor = isMobileLayout() ? 'row-resize' : 'col-resize';
|
2026-08-12 16:54:48 +08:00
|
|
|
|
document.body.style.userSelect = 'none';
|
|
|
|
|
|
e.preventDefault();
|
2026-08-13 15:56:33 +08:00
|
|
|
|
}
|
2026-08-12 16:54:48 +08:00
|
|
|
|
|
2026-08-13 15:56:33 +08:00
|
|
|
|
function onMove(e) {
|
2026-08-12 16:54:48 +08:00
|
|
|
|
if (!isDragging) return;
|
|
|
|
|
|
|
|
|
|
|
|
const rect = main.getBoundingClientRect();
|
|
|
|
|
|
|
2026-08-13 15:56:33 +08:00
|
|
|
|
if (isMobileLayout()) {
|
|
|
|
|
|
// 移动端:纵向拖动,调整编辑区高度
|
|
|
|
|
|
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
|
|
|
|
|
const y = clientY - rect.top;
|
|
|
|
|
|
const percentage = (y / rect.height) * 100;
|
|
|
|
|
|
const clamped = Math.max(15, Math.min(85, percentage));
|
|
|
|
|
|
editorContainer.style.height = `${clamped}%`;
|
|
|
|
|
|
editorContainer.style.width = '100%';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 桌面端:横向拖动,调整编辑区宽度
|
|
|
|
|
|
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
|
|
|
|
|
const x = clientX - rect.left;
|
|
|
|
|
|
const percentage = (x / rect.width) * 100;
|
|
|
|
|
|
const clamped = Math.max(10, Math.min(90, percentage));
|
|
|
|
|
|
editorContainer.style.width = `${clamped}%`;
|
|
|
|
|
|
editorContainer.style.height = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-12 16:54:48 +08:00
|
|
|
|
|
2026-08-13 15:56:33 +08:00
|
|
|
|
function onEnd() {
|
2026-08-12 16:54:48 +08:00
|
|
|
|
if (isDragging) {
|
|
|
|
|
|
isDragging = false;
|
|
|
|
|
|
splitter.classList.remove('dragging');
|
|
|
|
|
|
document.body.style.cursor = '';
|
|
|
|
|
|
document.body.style.userSelect = '';
|
|
|
|
|
|
if (mm) mm.fit();
|
|
|
|
|
|
}
|
2026-08-13 15:56:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 鼠标事件
|
|
|
|
|
|
splitter.addEventListener('mousedown', onStart);
|
|
|
|
|
|
document.addEventListener('mousemove', onMove);
|
|
|
|
|
|
document.addEventListener('mouseup', onEnd);
|
|
|
|
|
|
|
|
|
|
|
|
// 触屏事件
|
|
|
|
|
|
splitter.addEventListener('touchstart', onStart, { passive: false });
|
|
|
|
|
|
document.addEventListener('touchmove', onMove, { passive: false });
|
|
|
|
|
|
document.addEventListener('touchend', onEnd);
|
|
|
|
|
|
|
|
|
|
|
|
// 窗口尺寸变化时重置布局模式
|
|
|
|
|
|
window.addEventListener('resize', () => {
|
|
|
|
|
|
if (!isMobileLayout()) {
|
|
|
|
|
|
// 切回桌面端时清除移动端设置的 height
|
|
|
|
|
|
editorContainer.style.height = '';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 切到移动端时清除桌面端设置的 width
|
|
|
|
|
|
editorContainer.style.width = '100%';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mm) mm.fit();
|
2026-08-12 16:54:48 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 插入图片(本地文件转 Base64)──
|
|
|
|
|
|
|
|
|
|
|
|
function insertImage() {
|
|
|
|
|
|
const input = document.createElement('input');
|
|
|
|
|
|
input.type = 'file';
|
|
|
|
|
|
input.accept = 'image/png,image/jpeg,image/gif,image/svg+xml,image/webp';
|
|
|
|
|
|
|
|
|
|
|
|
input.onchange = e => {
|
|
|
|
|
|
const file = e.target.files[0];
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 限制图片大小(5MB),避免 Base64 过长撑爆 localStorage
|
|
|
|
|
|
const maxSize = 5 * 1024 * 1024;
|
|
|
|
|
|
if (file.size > maxSize) {
|
|
|
|
|
|
alert('图片过大(超过 5MB),请选择较小的图片或压缩后再插入。');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
|
reader.onload = async function(ev) {
|
|
|
|
|
|
const rawBase64 = ev.target.result;
|
|
|
|
|
|
// 压缩图片以减小 localStorage 占用(大图缩放至 1200px 宽,转 JPEG 75% 质量)
|
|
|
|
|
|
const base64 = await compressImage(rawBase64);
|
|
|
|
|
|
const fileName = file.name.replace(/\.[^.]+$/, ''); // 去扩展名作为描述
|
|
|
|
|
|
|
|
|
|
|
|
// 将 Base64 存入 imageMap,编辑器中只插入短占位符
|
|
|
|
|
|
const imgId = `img_${++imageCounter}`;
|
|
|
|
|
|
imageDataMap.set(imgId, base64);
|
|
|
|
|
|
|
|
|
|
|
|
// 在光标位置插入 Markdown 图片语法(使用占位符)
|
|
|
|
|
|
const start = editor.selectionStart;
|
|
|
|
|
|
const end = editor.selectionEnd;
|
|
|
|
|
|
const text = editor.value;
|
|
|
|
|
|
const insertText = ``;
|
|
|
|
|
|
|
|
|
|
|
|
editor.value = text.slice(0, start) + insertText + text.slice(end);
|
|
|
|
|
|
editor.selectionStart = editor.selectionEnd = start + insertText.length;
|
|
|
|
|
|
editor.focus();
|
|
|
|
|
|
updateMarkmap();
|
|
|
|
|
|
|
|
|
|
|
|
// 立即保存图片到 IndexedDB(不等防抖,防止刷新丢失)
|
|
|
|
|
|
autoSaveToHistory();
|
|
|
|
|
|
};
|
|
|
|
|
|
reader.onerror = function() {
|
|
|
|
|
|
alert('图片读取失败,请重试。');
|
|
|
|
|
|
};
|
|
|
|
|
|
reader.readAsDataURL(file);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
input.click();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function loadDefaultContent() {
|
|
|
|
|
|
fetch('content.md')
|
|
|
|
|
|
.then(response => {
|
|
|
|
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
|
|
|
|
return response.text();
|
|
|
|
|
|
})
|
|
|
|
|
|
.then(content => {
|
|
|
|
|
|
editor.value = content;
|
|
|
|
|
|
updateMarkmap();
|
|
|
|
|
|
autoSaveToHistory();
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
// 直接打开 file:// 时 fetch 会失败,使用内置默认内容
|
|
|
|
|
|
editor.value = DEFAULT_CONTENT;
|
|
|
|
|
|
updateMarkmap();
|
|
|
|
|
|
autoSaveToHistory();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function init() {
|
|
|
|
|
|
// 初始化侧边栏
|
|
|
|
|
|
initSidebar();
|
|
|
|
|
|
|
|
|
|
|
|
// 先执行一次性迁移:将 localStorage 中的旧图片数据迁移到 IndexedDB
|
|
|
|
|
|
await migrateImagesToIDB();
|
|
|
|
|
|
|
|
|
|
|
|
if (!loadFromHash()) {
|
|
|
|
|
|
// 尝试从历史记录加载最近的文档
|
|
|
|
|
|
const docs = getDocHistory();
|
|
|
|
|
|
if (docs.length > 0) {
|
|
|
|
|
|
currentDocId = docs[0].id;
|
|
|
|
|
|
// 异步从 IndexedDB 加载图片,回退到 localStorage
|
|
|
|
|
|
let images = await loadDocImagesAsync(docs[0].id);
|
|
|
|
|
|
if (!images && docs[0].images) {
|
|
|
|
|
|
// 旧版数据迁移:将内联图片转存到独立存储
|
|
|
|
|
|
images = docs[0].images;
|
|
|
|
|
|
saveDocImages(docs[0].id, images);
|
|
|
|
|
|
delete docs[0].images;
|
|
|
|
|
|
saveDocHistory(docs);
|
|
|
|
|
|
}
|
|
|
|
|
|
restoreImages(images);
|
|
|
|
|
|
editor.value = docs[0].content;
|
|
|
|
|
|
updateMarkmap();
|
|
|
|
|
|
renderSidebarDocs();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
loadDefaultContent();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 从分享链接加载(loadFromHash 已提取图片到 imageMap)
|
|
|
|
|
|
currentDocId = null;
|
|
|
|
|
|
autoSaveToHistory();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 防抖更新与自动保存(使用共享的 debouncedAutoSave)
|
|
|
|
|
|
editor.addEventListener('input', () => {
|
|
|
|
|
|
debouncedUpdate();
|
|
|
|
|
|
debouncedAutoSave();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 编辑器失焦时立即保存
|
|
|
|
|
|
editor.addEventListener('blur', () => autoSaveToHistory());
|
|
|
|
|
|
|
|
|
|
|
|
// 页面关闭前立即保存
|
|
|
|
|
|
window.addEventListener('beforeunload', () => autoSaveToHistory());
|
|
|
|
|
|
|
|
|
|
|
|
// 页面切换到后台时立即保存(移动端切换 App、PC 切换标签页)
|
|
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
|
|
|
|
if (document.visibilityState === 'hidden') autoSaveToHistory();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化主题选择器、模式切换和分割线
|
|
|
|
|
|
initThemeSelector();
|
|
|
|
|
|
initModeToggle();
|
2026-08-13 15:56:33 +08:00
|
|
|
|
initMobileMenu();
|
2026-08-12 16:54:48 +08:00
|
|
|
|
initSplitter();
|
|
|
|
|
|
|
|
|
|
|
|
// 插入图片按钮
|
|
|
|
|
|
document.getElementById('insert-image')?.addEventListener('click', insertImage);
|
|
|
|
|
|
|
|
|
|
|
|
// 窗口大小变化时重新适配
|
|
|
|
|
|
window.addEventListener('resize', debounce(() => {
|
|
|
|
|
|
if (mm) mm.fit();
|
|
|
|
|
|
}, 200));
|
|
|
|
|
|
|
|
|
|
|
|
// 键盘快捷键
|
|
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
|
|
|
|
// Ctrl/Cmd + S: 保存
|
|
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
saveMap();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 压缩/解压(用于分享链接)──
|
|
|
|
|
|
|
|
|
|
|
|
function compressContent(content) {
|
|
|
|
|
|
const uint8Array = new TextEncoder().encode(content);
|
|
|
|
|
|
const compressed = pako.gzip(uint8Array);
|
|
|
|
|
|
// 分块转换,避免 fromCharCode.apply 栈溢出
|
|
|
|
|
|
let binaryString = '';
|
|
|
|
|
|
const chunkSize = 8192;
|
|
|
|
|
|
for (let i = 0; i < compressed.length; i += chunkSize) {
|
|
|
|
|
|
binaryString += String.fromCharCode.apply(null, compressed.subarray(i, i + chunkSize));
|
|
|
|
|
|
}
|
|
|
|
|
|
return btoa(binaryString)
|
|
|
|
|
|
.replace(/\+/g, '-')
|
|
|
|
|
|
.replace(/\//g, '_')
|
|
|
|
|
|
.replace(/=+$/, '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function decompressContent(compressed) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const binaryString = atob(compressed.replace(/-/g, '+').replace(/_/g, '/'));
|
|
|
|
|
|
const uint8Array = new Uint8Array(binaryString.length);
|
|
|
|
|
|
for (let i = 0; i < binaryString.length; i++) {
|
|
|
|
|
|
uint8Array[i] = binaryString.charCodeAt(i);
|
|
|
|
|
|
}
|
|
|
|
|
|
const decompressed = pako.ungzip(uint8Array);
|
|
|
|
|
|
return new TextDecoder().decode(decompressed);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('解压内容失败:', error);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function generateShareableURL() {
|
|
|
|
|
|
const content = expandImages(editor.value);
|
|
|
|
|
|
const compressedContent = compressContent(content);
|
|
|
|
|
|
return `${window.location.origin}${window.location.pathname}#${compressedContent}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function shareURL() {
|
|
|
|
|
|
const url = generateShareableURL();
|
|
|
|
|
|
|
|
|
|
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await navigator.clipboard.writeText(url);
|
|
|
|
|
|
alert('分享链接已复制到剪贴板!');
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('复制链接失败:', err);
|
|
|
|
|
|
fallbackCopyTextToClipboard(url);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
fallbackCopyTextToClipboard(url);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fallbackCopyTextToClipboard(text) {
|
|
|
|
|
|
const textArea = document.createElement("textarea");
|
|
|
|
|
|
textArea.value = text;
|
|
|
|
|
|
textArea.style.top = '0';
|
|
|
|
|
|
textArea.style.left = '0';
|
|
|
|
|
|
textArea.style.position = 'fixed';
|
|
|
|
|
|
|
|
|
|
|
|
document.body.appendChild(textArea);
|
|
|
|
|
|
textArea.focus();
|
|
|
|
|
|
textArea.select();
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const successful = document.execCommand('copy');
|
|
|
|
|
|
alert(successful ? '分享链接已复制到剪贴板!' : '无法复制链接,请手动复制');
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('回退复制失败:', err);
|
|
|
|
|
|
prompt('请手动复制此链接进行分享:', text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.body.removeChild(textArea);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 确保 DOM 加载完成后执行
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|