diff --git a/main.js b/main.js index f203d93..32215d8 100644 --- a/main.js +++ b/main.js @@ -141,8 +141,13 @@ class LightMindMapPlugin extends obsidian.Plugin { } onunload() { - document.querySelectorAll('.lmm-overlay').forEach((el) => el.remove()); + document.querySelectorAll('.lmm-overlay').forEach((el) => { + if (el._lmmCleanup) el._lmmCleanup(); + el.remove(); + }); document.querySelectorAll('.lmm-fab').forEach((el) => el.remove()); + const doodleSvg = document.getElementById('lmm-doodle-filter'); + if (doodleSvg) doodleSvg.closest('svg').remove(); } async _doScan() { @@ -176,7 +181,7 @@ class LightMindMapPlugin extends obsidian.Plugin { overlay._lmmLastContent = null; } if (overlay._lmmLastContent === content) continue; - if (this._lmmWriting && existing) { + if (overlay._lmmWriting && existing) { overlay._lmmLastContent = content; continue; } @@ -468,6 +473,19 @@ class LightMindMapPlugin extends obsidian.Plugin { } } + async _batchPersistFrontmatter(file, updates) { + if (!file || !updates || Object.keys(updates).length === 0) return; + try { + await this.app.fileManager.processFrontMatter(file, (fm) => { + for (const [key, value] of Object.entries(updates)) { + fm[key] = value; + } + }); + } catch (e) { + console.error('[LightMindMap] frontmatter batch persist error', e); + } + } + _render(overlay, content, frontmatter, fileBasename, view, file) { overlay._lmmLastContent = content; overlay._lmmView = view; @@ -478,32 +496,30 @@ class LightMindMapPlugin extends obsidian.Plugin { const fmLayoutValid = fmLayout && ['balanced', 'right', 'left'].includes(String(fmLayout)); const layout = overlay._lmmLayout || (fmLayoutValid ? String(fmLayout) : 'balanced'); overlay._lmmLayout = layout; - if (!fmLayoutValid) { - this._persistFrontmatterValue(file, 'mindmap-layout', layout); - } const fmTheme = frontmatter && (frontmatter['mindmap-theme'] || frontmatter['mindmap_theme']); const fmThemeValid = fmTheme && THEMES[String(fmTheme).toLowerCase()]; const themeId = overlay._lmmTheme && THEMES[overlay._lmmTheme] ? overlay._lmmTheme : this._resolveTheme(frontmatter); this._applyThemeToOverlay(overlay, themeId); - if (!fmThemeValid) { - this._persistFrontmatterValue(file, 'mindmap-theme', overlay._lmmTheme); - } const fmLine = frontmatter && (frontmatter['mindmap-line'] || frontmatter['mindmap_line']); const fmLineValid = fmLine && LINE_STYLES[String(fmLine).toLowerCase()]; const lineId = overlay._lmmLine && LINE_STYLES[overlay._lmmLine] ? overlay._lmmLine : this._resolveLine(frontmatter); overlay._lmmLine = lineId; - if (!fmLineValid) { - this._persistFrontmatterValue(file, 'mindmap-line', lineId); - } const fmNodeStyle = frontmatter && (frontmatter['mindmap-node'] || frontmatter['mindmap_node']); const fmNodeStyleValid = fmNodeStyle && NODE_STYLES[String(fmNodeStyle).toLowerCase()]; const nodeStyleId = overlay._lmmNodeStyle && NODE_STYLES[overlay._lmmNodeStyle] ? overlay._lmmNodeStyle : this._resolveNodeStyle(frontmatter); this._applyNodeStyleToOverlay(overlay, nodeStyleId); - if (!fmNodeStyleValid) { - this._persistFrontmatterValue(file, 'mindmap-node', overlay._lmmNodeStyle); + + // Batch frontmatter writes into a single call to avoid cascading re-renders + const fmUpdates = {}; + if (!fmLayoutValid) fmUpdates['mindmap-layout'] = layout; + if (!fmThemeValid) fmUpdates['mindmap-theme'] = overlay._lmmTheme; + if (!fmLineValid) fmUpdates['mindmap-line'] = lineId; + if (!fmNodeStyleValid) fmUpdates['mindmap-node'] = overlay._lmmNodeStyle; + if (Object.keys(fmUpdates).length) { + this._batchPersistFrontmatter(file, fmUpdates); } const parsed = this._parseStructured(content); @@ -514,6 +530,7 @@ class LightMindMapPlugin extends obsidian.Plugin { overlay._lmmPendingEdit = null; overlay._lmmEditingNode = null; + if (overlay._lmmCleanup) overlay._lmmCleanup(); overlay.empty(); const toolbar = overlay.createDiv({ cls: 'lmm-toolbar' }); @@ -597,8 +614,14 @@ class LightMindMapPlugin extends obsidian.Plugin { }; if (!treeInfo.tree) { - overlay.createDiv({ cls: 'lmm-empty' }).innerHTML = - '
🧠
Add headings (e.g. # Title, ## Branch) to render the mind map.
'; + const empty = overlay.createDiv({ cls: 'lmm-empty' }); + empty.createDiv({ cls: 'lmm-empty-icon', text: '🧠' }); + const msg = empty.createDiv(); + msg.appendText('Add headings (e.g. '); + msg.createEl('code', { text: '# Title' }); + msg.appendText(', '); + msg.createEl('code', { text: '## Branch' }); + msg.appendText(') to render the mind map.'); return; } @@ -863,11 +886,11 @@ class LightMindMapPlugin extends obsidian.Plugin { if (overlay._lmmSelected && overlay._lmmSelected._el) { overlay._lmmSelected._el.focus({ preventScroll: true }); } - this._lmmWriting = true; + overlay._lmmWriting = true; this.app.vault.modify(file, newContent).then(() => { - requestAnimationFrame(() => { this._lmmWriting = false; }); + requestAnimationFrame(() => { overlay._lmmWriting = false; }); }).catch((e) => { - this._lmmWriting = false; + overlay._lmmWriting = false; console.error('[LightMindMap] collapse persist error', e); }); } @@ -987,11 +1010,11 @@ class LightMindMapPlugin extends obsidian.Plugin { } try { - this._lmmWriting = true; + overlay._lmmWriting = true; await this.app.vault.modify(file, newContent); - requestAnimationFrame(() => { this._lmmWriting = false; }); + requestAnimationFrame(() => { overlay._lmmWriting = false; }); } catch (e) { - this._lmmWriting = false; + overlay._lmmWriting = false; console.error('[LightMindMap] persist error', e); new obsidian.Notice('Failed to save mindmap: ' + e.message); } @@ -1262,10 +1285,12 @@ class LightMindMapPlugin extends obsidian.Plugin { canvas.addEventListener('mousedown', onDown); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); - this.register(() => { + const prevCleanup = overlay._lmmCleanup; + overlay._lmmCleanup = () => { + if (prevCleanup) prevCleanup(); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); - }); + }; // ── Touch support ── let touchId = null; @@ -1732,6 +1757,28 @@ class LightMindMapPlugin extends obsidian.Plugin { } } + async _saveWithDialog(file, arrayBuffer) { + const uint8 = new Uint8Array(arrayBuffer); + const defaultName = (file.basename || 'mindmap') + '.mindmap.png'; + try { + const electron = require('electron'); + const win = electron.remote.BrowserWindow.getFocusedWindow(); + const result = await electron.remote.dialog.showSaveDialog(win, { + title: 'Export Mindmap as PNG', + defaultPath: defaultName, + filters: [{ name: 'PNG Image', extensions: ['png'] }] + }); + if (result.canceled || !result.filePath) return false; + require('fs').writeFileSync(result.filePath, Buffer.from(arrayBuffer)); + return true; + } catch (e) { + // Fallback: save directly into vault + const filePath = (file.parent ? file.parent.path + '/' : '') + defaultName; + await this.app.vault.adapter.writeBinary(filePath, uint8); + return true; + } + } + async _exportPNG(overlay) { try { const treeInfo = overlay._lmmTreeInfo; @@ -1759,6 +1806,18 @@ class LightMindMapPlugin extends obsidian.Plugin { const w = bounds.maxX - bounds.minX + PAD * 2; const h = bounds.maxY - bounds.minY + PAD * 2; + // Save original positions and temporarily shift for export + const savedPositions = []; + const shiftDx = PAD - bounds.minX; + const shiftDy = PAD - bounds.minY; + const saveAndShift = (node) => { + savedPositions.push({ node, x: node.x, y: node.y }); + node.x += shiftDx; + node.y += shiftDy; + if (!node.collapsed) node.children.forEach(saveAndShift); + }; + saveAndShift(tree); + // Create offscreen canvas const canvas = document.createElement('canvas'); canvas.width = w * scale; @@ -1799,6 +1858,12 @@ class LightMindMapPlugin extends obsidian.Plugin { // Draw nodes this._drawNodeToCanvas(ctx, tree, theme, nodeStyle, scale); + // Restore original positions + for (const { node, x, y } of savedPositions) { + node.x = x; + node.y = y; + } + // Convert canvas to blob const blob = await new Promise((resolve, reject) => { canvas.toBlob((b) => { @@ -1807,24 +1872,10 @@ class LightMindMapPlugin extends obsidian.Plugin { }, 'image/png'); }); - // Use Electron dialog to save file - const { remote } = require('electron'); - const { dialog, BrowserWindow } = remote; - const win = BrowserWindow.getFocusedWindow(); - - const defaultPath = file.parent ? file.parent.path + '/' : ''; - const result = await dialog.showSaveDialog(win, { - title: 'Export Mindmap as PNG', - defaultPath: defaultPath + file.basename + '.mindmap.png', - filters: [{ name: 'PNG Image', extensions: ['png'] }] - }); - - if (result.canceled) return; - - // Write file + // Prompt user for save location const arrayBuffer = await blob.arrayBuffer(); - const fs = require('fs'); - fs.writeFileSync(result.filePath, Buffer.from(arrayBuffer)); + const saved = await this._saveWithDialog(file, arrayBuffer); + if (!saved) return; new obsidian.Notice('Mindmap exported successfully'); } catch (e) { console.error('[LightMindMap] export error:', e);