'use strict'; const obsidian = require('obsidian'); const THEMES = { vibrant: { name: 'Vibrant', palette: ['#F87171', '#FB923C', '#FBBF24', '#A3E635', '#34D399', '#22D3EE', '#60A5FA', '#A78BFA', '#F472B6', '#F43F5E', '#10B981', '#0EA5E9'], rootGrad: 'linear-gradient(135deg, #6366F1, #8B5CF6 60%, #EC4899)', bg: '#FFFFFF', fg: '#1F2937', tint1: 'rgba(99,102,241,0.08)', tint2: 'rgba(236,72,153,0.08)', rootAccent: '#8B5CF6' }, classic: { name: 'Classic', palette: ['#19807E', '#383B70', '#A05A2C', '#7B9BC6', '#C47A82', '#3A8A8C', '#B85C5C', '#5D6D7E', '#8B7355', '#4A6B8A', '#9C6B4F', '#6B7A8F'], rootGrad: 'linear-gradient(135deg, #FFD613, #FFB800 60%, #F59E0B)', bg: '#FFFEF6', fg: '#1F2937', tint1: 'rgba(255,214,19,0.10)', tint2: 'rgba(255,184,0,0.06)', rootAccent: '#FFB800' }, fresh: { name: 'Fresh', palette: ['#10B981', '#22D3EE', '#84CC16', '#14B8A6', '#06B6D4', '#34D399', '#A3E635', '#0EA5E9', '#65A30D', '#0891B2', '#16A34A', '#0D9488'], rootGrad: 'linear-gradient(135deg, #10B981, #14B8A6 60%, #0EA5E9)', bg: '#F0FDF4', fg: '#0F172A', tint1: 'rgba(16,185,129,0.10)', tint2: 'rgba(34,211,238,0.08)', rootAccent: '#10B981' }, ocean: { name: 'Ocean', palette: ['#0EA5E9', '#06B6D4', '#3B82F6', '#6366F1', '#8B5CF6', '#0891B2', '#0284C7', '#2563EB', '#4F46E5', '#7C3AED', '#0E7490', '#1E40AF'], rootGrad: 'linear-gradient(135deg, #0284C7, #2563EB 60%, #4F46E5)', bg: '#EFF6FF', fg: '#0F172A', tint1: 'rgba(14,165,233,0.10)', tint2: 'rgba(99,102,241,0.08)', rootAccent: '#2563EB' }, sunset: { name: 'Sunset', palette: ['#F43F5E', '#F97316', '#FBBF24', '#EF4444', '#EC4899', '#FB923C', '#F59E0B', '#DC2626', '#DB2777', '#EA580C', '#D97706', '#BE185D'], rootGrad: 'linear-gradient(135deg, #F97316, #F43F5E 60%, #EC4899)', bg: '#FFF7ED', fg: '#1F2937', tint1: 'rgba(249,115,22,0.10)', tint2: 'rgba(244,63,94,0.08)', rootAccent: '#F43F5E' }, midnight: { name: 'Midnight', palette: ['#22D3EE', '#A78BFA', '#F472B6', '#34D399', '#FBBF24', '#60A5FA', '#FB923C', '#F87171', '#10B981', '#A3E635', '#8B5CF6', '#0EA5E9'], rootGrad: 'linear-gradient(135deg, #1799F3, #FF7722 60%, #FFD07C)', bg: '#202531', fg: '#E2E8F0', tint1: 'rgba(23,153,243,0.12)', tint2: 'rgba(255,119,34,0.08)', rootAccent: '#1799F3' }, slate: { name: 'Slate', palette: ['#38BDF8', '#818CF8', '#22D3EE', '#2DD4BF', '#A78BFA', '#34D399', '#67E8F9', '#C084FC', '#5EEAD4', '#7DD3FC', '#93C5FD', '#6EE7B7'], rootGrad: 'linear-gradient(135deg, #0EA5E9, #6366F1 60%, #8B5CF6)', bg: '#2C3341', fg: '#CBD5E1', tint1: 'rgba(14,165,233,0.10)', tint2: 'rgba(99,102,241,0.08)', rootAccent: '#6366F1' } }; const DEFAULT_THEME = 'vibrant'; const LINE_STYLES = { curve: { name: 'Smooth', shape: 'curve', dash: null }, straight: { name: 'Straight', shape: 'straight', dash: null }, polyline: { name: 'Right Angle', shape: 'polyline', dash: null }, 'polyline-dashed': { name: 'Right Angle Dashed', shape: 'polyline', dash: '6 4' }, 'curve-dashed': { name: 'Smooth Dashed', shape: 'curve', dash: '6 4' } }; const DEFAULT_LINE = 'curve'; const NODE_STYLES = { rounded: { name: 'Rounded' }, square: { name: 'Square' }, borderless: { name: 'Borderless' }, circle: { name: 'Pill' }, doodle: { name: 'Doodle' } }; const DEFAULT_NODE_STYLE = 'rounded'; const HGAP = 64; const VGAP = 18; const ROOT_HGAP = 90; const PAD = 60; const PLACEHOLDER = 'New Title'; class LightMindMapPlugin extends obsidian.Plugin { async onload() { this._scan = obsidian.debounce(() => this._doScan(), 120); this._fileCache = null; this._fileCacheTime = 0; this.registerEvent(this.app.workspace.on('file-open', () => this._scan())); this.registerEvent(this.app.workspace.on('active-leaf-change', () => this._scan())); this.registerEvent(this.app.workspace.on('layout-change', () => this._scan())); this.registerEvent(this.app.workspace.on('editor-change', () => this._scan())); this.registerEvent(this.app.metadataCache.on('changed', () => this._scan())); this.addCommand({ id: 'lmm-toggle-source', name: 'Toggle mindmap / source view', callback: () => { const v = this.app.workspace.getActiveViewOfType(obsidian.MarkdownView); if (!v) return; const o = v.contentEl.querySelector(':scope > .lmm-overlay'); if (!o) return; if (o.classList.contains('lmm-hidden')) { o.classList.remove('lmm-hidden'); this._removeRestoreFab(v); } else { o.classList.add('lmm-hidden'); this._showRestoreFab(v, o); } } }); this.addCommand({ id: 'lmm-cycle-layout', name: 'Cycle mindmap layout (balanced / right / left / tree / radial)', callback: () => { const v = this.app.workspace.getActiveViewOfType(obsidian.MarkdownView); if (!v) return; const o = v.contentEl.querySelector(':scope > .lmm-overlay'); if (!o) return; const layouts = ['balanced', 'right', 'left', 'tree', 'radial']; const cur = o._lmmLayout || 'balanced'; const next = layouts[(layouts.indexOf(cur) + 1) % layouts.length]; o._lmmLayout = next; if (o._lmmTreeInfo) this._renderTreeIntoCanvas(o, false); this._persistFrontmatterValue(o._lmmFile, 'mindmap-layout', next); } }); // Right-click menu: create new mindmap file in folder this.registerEvent(this.app.workspace.on('file-menu', (menu, file) => { if (!(file instanceof obsidian.TFolder)) return; menu.addItem((item) => { const lang = window.localStorage.getItem('language') || 'en'; const title = lang.startsWith('zh') ? '新建轻量级脑图' : 'Create light mindmap'; item .setTitle(title) .setIcon('brain') .onClick(async () => { const base = 'New Mindmap'; let name = base + '.md'; let i = 1; while (this.app.vault.getAbstractFileByPath(file.path + '/' + name)) { name = base + ' ' + (++i) + '.md'; } const content = '---\ntype: mindmap\n---\n\n# New\n'; const created = await this.app.vault.create(file.path + '/' + name, content); await this.app.workspace.openLinkText(created.path, '', true); }); }); })); this.app.workspace.onLayoutReady(() => this._doScan()); // Inject SVG filter for doodle node style this._injectDoodleFilter(); } onunload() { 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() { const leaves = this.app.workspace.getLeavesOfType('markdown'); for (const leaf of leaves) { const view = leaf.view; if (!(view instanceof obsidian.MarkdownView)) continue; const file = view.file; if (!file) continue; const cache = this.app.metadataCache.getFileCache(file); const fm = cache && cache.frontmatter; const isMindmap = fm && String(fm.type).toLowerCase() === 'mindmap'; const existing = view.contentEl.querySelector(':scope > .lmm-overlay'); if (isMindmap) { let content; try { content = view.editor ? view.editor.getValue() : await this.app.vault.cachedRead(file); } catch (e) { content = await this.app.vault.cachedRead(file); } let overlay = existing; if (!overlay) { overlay = view.contentEl.createDiv({ cls: 'lmm-overlay' }); } if (overlay._lmmFile !== file) { overlay._lmmTheme = null; overlay._lmmLayout = null; overlay._lmmLine = null; overlay._lmmNodeStyle = null; overlay._lmmLastContent = null; } if (overlay._lmmLastContent === content) continue; if (overlay._lmmWriting && existing) { overlay._lmmLastContent = content; continue; } overlay.dataset.file = file.path; if (overlay.classList.contains('lmm-hidden')) { overlay._lmmNeedsRerender = true; overlay._lmmStaleContent = content; overlay._lmmStaleFm = fm; overlay._lmmStaleName = file.basename; continue; } try { this._render(overlay, content, fm, file.basename, view, file); } catch (e) { console.error('[LightMindMap] render error', e); overlay.empty(); overlay.createDiv({ cls: 'lmm-empty', text: 'Mind map render error: ' + e.message }); } } else if (existing) { existing.remove(); this._removeRestoreFab(view); } } } // ──────────────────────────────────────────────────────────────── // Parsing — preserves frontmatter, pre-heading body, and each // heading's associated body so we can serialize back losslessly. // ──────────────────────────────────────────────────────────────── _splitFrontmatter(content) { const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); if (!m) return { frontmatterRaw: '', frontmatter: null, body: content }; let parsed = null; try { parsed = obsidian.parseYaml(m[1]) || {}; } catch (e) {} return { frontmatterRaw: m[0], frontmatter: parsed, body: content.slice(m[0].length) }; } _stripInline(text) { return text .replace(/!?\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, p1, p2) => p2 || p1) .replace(/!?\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/__([^_]+)__/g, '$1') .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2') .replace(/(^|[^_])_([^_\n]+)_/g, '$1$2') .replace(/~~([^~]+)~~/g, '$1') .replace(/==([^=]+)==/g, '$1') .replace(/<[^>]+>/g, '') .trim(); } _renderNodeContent(el, node, overlay) { const raw = node.rawText || node.text || ''; if (!raw.includes('](') && !raw.includes('[[')) { el.textContent = node.text || PLACEHOLDER; return; } el.textContent = ''; const sourcePath = overlay._lmmFile ? overlay._lmmFile.path : ''; const dir = sourcePath ? sourcePath.substring(0, sourcePath.lastIndexOf('/')) : ''; // Collect all links (markdown and wiki-link) with positions const links = []; let m; const mdRe = /\[([^\]]*)\]\(([^)]*)\)/g; while ((m = mdRe.exec(raw)) !== null) { links.push({ start: m.index, end: m.index + m[0].length, type: 'md', text: m[1], target: m[2] }); } const wikiRe = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g; while ((m = wikiRe.exec(raw)) !== null) { links.push({ start: m.index, end: m.index + m[0].length, type: 'wiki', target: m[1], text: m[2] || m[1] }); } links.sort((a, b) => a.start - b.start); let last = 0; for (const link of links) { if (link.start < last) continue; // skip overlapping if (link.start > last) el.appendChild(document.createTextNode(raw.slice(last, link.start))); const a = document.createElement('a'); a.className = 'lmm-link'; a.textContent = link.text; if (link.type === 'md') { const href = link.target; a.href = href; const isExternal = /^https?:\/\//.test(href) || href.startsWith('obsidian://'); if (isExternal) { a.target = '_blank'; a.rel = 'noopener'; } else { a.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const fp = obsidian.normalizePath(dir ? dir + '/' + href : href); this.app.workspace.openLinkText(fp, '', 'tab'); }); } } else { // wiki-link: Obsidian API resolves shortest/relative paths natively a.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.app.workspace.openLinkText(link.target, sourcePath, 'tab'); }); } el.appendChild(a); last = link.end; } if (last < raw.length) { el.appendChild(document.createTextNode(raw.slice(last))); } if (el.childNodes.length === 0) { el.textContent = node.text || PLACEHOLDER; } } _parseStructured(content) { const { frontmatterRaw, body } = this._splitFrontmatter(content); const lines = body.split('\n'); const headingRe = /^(#{1,6})\s+(.+?)\s*#*\s*$/; const fenceRe = /^(```|~~~)/; let inFence = false; let fenceMarker = null; const headings = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const fm = line.match(fenceRe); if (fm) { if (!inFence) { inFence = true; fenceMarker = fm[1]; } else if (line.startsWith(fenceMarker)) { inFence = false; fenceMarker = null; } continue; } if (inFence) continue; const m = line.match(headingRe); if (m) { const rawText = m[2].trim(); const collapsed = /^\*(?!\*)(.+)\*(?!\*)$/.test(rawText); headings.push({ srcIdx: i, level: m[1].length, rawText: collapsed ? rawText.replace(/^\*(?!\*)(.+)\*(?!\*)$/, '$1') : rawText, text: this._stripInline(rawText), children: [], parent: null, dirty: false, isNew: false, collapsed, bodyRaw: '', }); } } const preBody = headings.length > 0 ? lines.slice(0, headings[0].srcIdx).join('\n') : lines.join('\n'); for (let i = 0; i < headings.length; i++) { const start = headings[i].srcIdx + 1; const end = i + 1 < headings.length ? headings[i + 1].srcIdx : lines.length; headings[i].bodyRaw = lines.slice(start, end).join('\n'); } return { frontmatterRaw, preBody, headings }; } _buildTree(parsed, fileName) { const items = parsed.headings; if (items.length === 0) return { tree: null, virtualRoot: false, baseLevel: 1 }; const minLevel = items.reduce((a, b) => Math.min(a, b.level), 6); const tops = items.filter((i) => i.level === minLevel); let tree; let virtualRoot = false; const baseLevel = minLevel; if (tops.length > 1) { tree = { level: minLevel - 1, rawText: fileName || 'Mind Map', text: fileName || 'Mind Map', children: [], parent: null, bodyRaw: '', dirty: false, isNew: false, collapsed: false, isVirtual: true, }; virtualRoot = true; const stack = [tree]; for (const item of items) { while (stack.length && stack[stack.length - 1].level >= item.level) stack.pop(); if (stack.length === 0) stack.push(tree); const parent = stack[stack.length - 1]; parent.children.push(item); item.parent = parent; stack.push(item); } } else { tree = items[0]; tree.parent = null; const stack = [tree]; for (let i = 1; i < items.length; i++) { const item = items[i]; while (stack.length && stack[stack.length - 1].level >= item.level) stack.pop(); const parent = stack[stack.length - 1] || tree; parent.children.push(item); item.parent = parent; stack.push(item); } } return { tree, virtualRoot, baseLevel }; } // ──────────────────────────────────────────────────────────────── // Serialization back to markdown. // Non-edited nodes write their original rawText; edited/new nodes // write node.text. Frontmatter, pre-body, and per-heading bodyRaw // are preserved verbatim. // ──────────────────────────────────────────────────────────────── _serialize(parsed, treeInfo) { let out = parsed.frontmatterRaw; if (parsed.preBody && parsed.preBody.length) { out += parsed.preBody; if (!parsed.preBody.endsWith('\n')) out += '\n'; } if (treeInfo.virtualRoot) { for (const child of treeInfo.tree.children) { out += this._serializeNode(child, treeInfo.baseLevel); } } else if (treeInfo.tree) { out += this._serializeNode(treeInfo.tree, treeInfo.baseLevel); } return out; } _serializeNode(node, level) { const cap = Math.min(6, Math.max(1, level)); let text = node.rawText || node.text || PLACEHOLDER; if (node.collapsed) { const inner = text.replace(/^\*(?!\*)(.+)\*(?!\*)$/, '$1'); text = '*' + inner + '*'; } else { text = text.replace(/^\*(?!\*)(.+)\*(?!\*)$/, '$1'); } let s = '#'.repeat(cap) + ' ' + text + '\n'; if (node.bodyRaw && node.bodyRaw.length) { s += node.bodyRaw; if (!node.bodyRaw.endsWith('\n')) s += '\n'; } for (const child of node.children) { s += this._serializeNode(child, level + 1); } return s; } _assignColors(root, palette, rootColor) { const p = (palette && palette.length) ? palette : THEMES[DEFAULT_THEME].palette; root.depth = 0; root.color = rootColor || p[0] || '#6366F1'; root.children.forEach((child, i) => { const c = p[i % p.length]; this._propagateColor(child, c, 1); }); } _propagateColor(node, color, depth) { node.color = color; node.depth = depth; node.children.forEach((c) => this._propagateColor(c, color, depth + 1)); } // ──────────────────────────────────────────────────────────────── // Top-level render — toolbar + canvas containers, then renders // tree into the canvas. // ──────────────────────────────────────────────────────────────── _showRestoreFab(view, overlay) { if (!view || !view.contentEl) return; let fab = view.contentEl.querySelector(':scope > .lmm-fab'); if (fab) return; fab = view.contentEl.createEl('button', { cls: 'lmm-fab', text: 'Light Mindmap' }); fab.onclick = () => { overlay.classList.remove('lmm-hidden'); fab.remove(); if (overlay._lmmNeedsRerender) { overlay._lmmNeedsRerender = false; this._render(overlay, overlay._lmmStaleContent, overlay._lmmStaleFm, overlay._lmmStaleName, view, view.file); } else { const canvas = overlay.querySelector(':scope > .lmm-canvas'); const inner = canvas && canvas.querySelector(':scope > .lmm-inner'); if (canvas && inner) this._fitTo(canvas, inner); } }; } _removeRestoreFab(view) { if (!view || !view.contentEl) return; const fab = view.contentEl.querySelector(':scope > .lmm-fab'); if (fab) fab.remove(); } _resolveTheme(frontmatter) { const id = frontmatter && (frontmatter['mindmap-theme'] || frontmatter['mindmap_theme']); const key = id && THEMES[String(id).toLowerCase()] ? String(id).toLowerCase() : DEFAULT_THEME; return key; } _resolveLine(frontmatter) { const id = frontmatter && (frontmatter['mindmap-line'] || frontmatter['mindmap_line']); const key = id && LINE_STYLES[String(id).toLowerCase()] ? String(id).toLowerCase() : DEFAULT_LINE; return key; } _resolveNodeStyle(frontmatter) { const id = frontmatter && (frontmatter['mindmap-node'] || frontmatter['mindmap_node']); const key = id && NODE_STYLES[String(id).toLowerCase()] ? String(id).toLowerCase() : DEFAULT_NODE_STYLE; return key; } _applyNodeStyleToOverlay(overlay, nodeStyleId) { const key = NODE_STYLES[nodeStyleId] ? nodeStyleId : DEFAULT_NODE_STYLE; overlay._lmmNodeStyle = key; Object.keys(NODE_STYLES).forEach((id) => overlay.classList.remove('lmm-node-style-' + id)); overlay.classList.add('lmm-node-style-' + key); } _applyThemeToOverlay(overlay, themeId) { const theme = THEMES[themeId] || THEMES[DEFAULT_THEME]; overlay._lmmTheme = themeId in THEMES ? themeId : DEFAULT_THEME; Object.keys(THEMES).forEach((id) => overlay.classList.remove('lmm-theme-' + id)); overlay.classList.add('lmm-theme-' + overlay._lmmTheme); if (theme.bg) overlay.style.setProperty('--lmm-theme-bg', theme.bg); else overlay.style.removeProperty('--lmm-theme-bg'); if (theme.fg) overlay.style.setProperty('--lmm-theme-fg', theme.fg); else overlay.style.removeProperty('--lmm-theme-fg'); overlay.style.setProperty('--lmm-theme-root-grad', theme.rootGrad); overlay.style.setProperty('--lmm-theme-root-accent', theme.rootAccent); overlay.style.setProperty('--lmm-theme-tint-1', theme.tint1); overlay.style.setProperty('--lmm-theme-tint-2', theme.tint2); } async _persistFrontmatterValue(file, key, value) { if (!file) return; try { await this.app.fileManager.processFrontMatter(file, (fm) => { fm[key] = value; }); } catch (e) { console.error('[LightMindMap] frontmatter persist error', e); } } 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; overlay._lmmFile = file; overlay._lmmFrontmatter = frontmatter; const fmLayout = frontmatter && (frontmatter['mindmap-layout'] || frontmatter['mindmap_layout'] || frontmatter.layout); const fmLayoutValid = fmLayout && ['balanced', 'right', 'left', 'tree', 'radial'].includes(String(fmLayout)); const layout = overlay._lmmLayout || (fmLayoutValid ? String(fmLayout) : 'balanced'); overlay._lmmLayout = 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); 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; 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); // 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); const treeInfo = this._buildTree(parsed, fileBasename); overlay._lmmParsed = parsed; overlay._lmmTreeInfo = treeInfo; overlay._lmmSelected = null; overlay._lmmPendingEdit = null; overlay._lmmEditingNode = null; overlay._lmmDragging = false; overlay._lmmDragNode = null; overlay._lmmDragClone = null; overlay._lmmDropTarget = null; overlay._lmmDropPosition = null; overlay._lmmDragStartX = 0; overlay._lmmDragStartY = 0; overlay._lmmDragTimer = null; if (overlay._lmmCleanup) overlay._lmmCleanup(); overlay.empty(); const toolbar = overlay.createDiv({ cls: 'lmm-toolbar' }); const layoutGroup = toolbar.createDiv({ cls: 'lmm-toolbar-group' }); layoutGroup.createSpan({ cls: 'lmm-toolbar-label', text: 'Layout' }); const layoutSelect = layoutGroup.createEl('select', { cls: 'lmm-select' }); for (const l of [ { id: 'balanced', label: 'Balanced' }, { id: 'right', label: 'Right' }, { id: 'left', label: 'Left' }, { id: 'tree', label: 'Tree' }, { id: 'radial', label: 'Radial' }, ]) { const opt = layoutSelect.createEl('option', { value: l.id, text: l.label }); if (l.id === layout) opt.selected = true; } layoutSelect.onchange = () => { const id = layoutSelect.value; if (overlay._lmmLayout === id) return; overlay._lmmLayout = id; this._renderTreeIntoCanvas(overlay, false); this._persistFrontmatterValue(file, 'mindmap-layout', id); }; const themeGroup = toolbar.createDiv({ cls: 'lmm-toolbar-group' }); themeGroup.createSpan({ cls: 'lmm-toolbar-label', text: 'Theme' }); const themeSelect = themeGroup.createEl('select', { cls: 'lmm-select' }); for (const id of Object.keys(THEMES)) { const opt = themeSelect.createEl('option', { value: id, text: THEMES[id].name }); if (id === overlay._lmmTheme) opt.selected = true; } themeSelect.onchange = () => { const id = themeSelect.value; if (!THEMES[id] || id === overlay._lmmTheme) return; this._applyThemeToOverlay(overlay, id); if (overlay._lmmTreeInfo) this._renderTreeIntoCanvas(overlay, true); this._persistFrontmatterValue(file, 'mindmap-theme', id); }; const lineGroup = toolbar.createDiv({ cls: 'lmm-toolbar-group' }); lineGroup.createSpan({ cls: 'lmm-toolbar-label', text: 'Line' }); const lineSelect = lineGroup.createEl('select', { cls: 'lmm-select' }); for (const id of Object.keys(LINE_STYLES)) { const opt = lineSelect.createEl('option', { value: id, text: LINE_STYLES[id].name }); if (id === overlay._lmmLine) opt.selected = true; } lineSelect.onchange = () => { const id = lineSelect.value; if (!LINE_STYLES[id] || id === overlay._lmmLine) return; overlay._lmmLine = id; if (overlay._lmmTreeInfo) this._renderTreeIntoCanvas(overlay, true); this._persistFrontmatterValue(file, 'mindmap-line', id); }; const nodeGroup = toolbar.createDiv({ cls: 'lmm-toolbar-group' }); nodeGroup.createSpan({ cls: 'lmm-toolbar-label', text: 'Node' }); const nodeSelect = nodeGroup.createEl('select', { cls: 'lmm-select' }); for (const id of Object.keys(NODE_STYLES)) { const opt = nodeSelect.createEl('option', { value: id, text: NODE_STYLES[id].name }); if (id === overlay._lmmNodeStyle) opt.selected = true; } nodeSelect.onchange = () => { const id = nodeSelect.value; if (!NODE_STYLES[id] || id === overlay._lmmNodeStyle) return; this._applyNodeStyleToOverlay(overlay, id); if (overlay._lmmTreeInfo) this._renderTreeIntoCanvas(overlay, true); this._persistFrontmatterValue(file, 'mindmap-node', id); }; const zoomGroup = toolbar.createDiv({ cls: 'lmm-toolbar-group' }); zoomGroup.createSpan({ cls: 'lmm-toolbar-label', text: 'Zoom' }); const fitBtn = zoomGroup.createEl('button', { cls: 'lmm-btn', text: 'Fit' }); const zoomInBtn = zoomGroup.createEl('button', { cls: 'lmm-btn', text: '+' }); const zoomOutBtn = zoomGroup.createEl('button', { cls: 'lmm-btn', text: '−' }); const right = toolbar.createDiv({ cls: 'lmm-toolbar-group' }); const exportBtn = right.createEl('button', { cls: 'lmm-btn', text: 'Export PNG' }); exportBtn.onclick = () => this._exportPNG(overlay); const editBtn = right.createEl('button', { cls: 'lmm-btn lmm-btn-ghost', text: 'Edit Markdown' }); editBtn.onclick = () => { overlay.classList.add('lmm-hidden'); this._showRestoreFab(view, overlay); }; if (!treeInfo.tree) { 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; } const canvas = overlay.createDiv({ cls: 'lmm-canvas' }); const inner = canvas.createDiv({ cls: 'lmm-inner' }); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'lmm-svg'); inner.appendChild(svg); const nodesLayer = inner.createDiv({ cls: 'lmm-nodes' }); canvas._lmm = { tx: 0, ty: 0, scale: 1 }; overlay._lmmCanvas = canvas; overlay._lmmInner = inner; overlay._lmmSvg = svg; overlay._lmmNodesLayer = nodesLayer; this._bindPanZoom(canvas, inner, overlay); this._bindCanvasClick(canvas, overlay); fitBtn.onclick = () => this._fitTo(canvas, inner); zoomInBtn.onclick = () => this._zoomBy(canvas, inner, 1.2); zoomOutBtn.onclick = () => this._zoomBy(canvas, inner, 1 / 1.2); this._renderTreeIntoCanvas(overlay, false); } _renderTreeIntoCanvas(overlay, preserveTransform) { const canvas = overlay._lmmCanvas; const inner = overlay._lmmInner; const svg = overlay._lmmSvg; const nodesLayer = overlay._lmmNodesLayer; const treeInfo = overlay._lmmTreeInfo; const tree = treeInfo && treeInfo.tree; if (!tree || !canvas) return; const savedTransform = preserveTransform ? Object.assign({}, canvas._lmm) : null; nodesLayer.empty(); while (svg.firstChild) svg.removeChild(svg.firstChild); const theme = THEMES[overlay._lmmTheme] || THEMES[DEFAULT_THEME]; this._assignColors(tree, theme.palette, theme.rootAccent); this._createNodes(tree, nodesLayer, overlay); requestAnimationFrame(() => { this._measureNodes(tree); this._layoutTree(tree, overlay._lmmLayout, overlay); const bounds = this._computeBounds(tree); const w = bounds.maxX - bounds.minX + PAD * 2; const h = bounds.maxY - bounds.minY + PAD * 2; this._shiftTree(tree, PAD - bounds.minX, PAD - bounds.minY); this._applyNodePositions(tree); inner.style.width = w + 'px'; inner.style.height = h + 'px'; svg.setAttribute('width', w); svg.setAttribute('height', h); svg.setAttribute('viewBox', `0 0 ${w} ${h}`); this._drawConnections(tree, svg, overlay._lmmLayout, overlay._lmmLine); if (savedTransform) { canvas._lmm = savedTransform; this._applyTransform(inner, savedTransform); } else { this._fitTo(canvas, inner); } if (overlay._lmmSelected && overlay._lmmSelected._el) { overlay._lmmSelected._el.classList.add('lmm-selected'); overlay._lmmSelected._el.focus({ preventScroll: true }); } if (overlay._lmmPendingEdit) { const node = overlay._lmmPendingEdit; overlay._lmmPendingEdit = null; if (node && node._el) this._startEdit(overlay, node); } }); } _createNodes(node, layer, overlay) { const el = layer.createDiv({ cls: 'lmm-node lmm-node-d' + node.depth }); if (node.depth === 0) el.classList.add('lmm-node-root'); if (node.isVirtual) el.classList.add('lmm-node-virtual'); if (node.collapsed && node.children.length) { el.classList.add('lmm-collapsed'); el.title = node.children.length + ' hidden — Space to expand'; } el.style.setProperty('--lmm-color', node.color); el.tabIndex = 0; if (node.collapsed && node.children.length) { const textSpan = document.createElement('span'); textSpan.className = 'lmm-node-text'; this._renderNodeContent(textSpan, node, overlay); el.appendChild(textSpan); const badge = document.createElement('span'); badge.className = 'lmm-collapse-badge'; el.appendChild(badge); } else { this._renderNodeContent(el, node, overlay); } node._el = el; this._attachNodeHandlers(el, node, overlay); if (node.collapsed && node.children.length) return; for (const child of node.children) this._createNodes(child, layer, overlay); } // ──────────────────────────────────────────────────────────────── // Interaction: click to select, double-click to edit, keyboard // shortcuts for editing and structural changes. // ──────────────────────────────────────────────────────────────── _attachNodeHandlers(el, node, overlay) { el.addEventListener('mousedown', (e) => { if (el.isContentEditable) return; e.stopPropagation(); // Start drag timer for non-root, non-virtual nodes if (node.depth !== 0 && !node.isVirtual) { overlay._lmmDragStartX = e.clientX; overlay._lmmDragStartY = e.clientY; overlay._lmmDragTimer = setTimeout(() => { this._startDrag(overlay, node, e); }, 200); } }); el.addEventListener('click', (e) => { e.stopPropagation(); if (el.isContentEditable) return; if (e.target.closest('.lmm-link')) return; this._selectNode(overlay, node, true); }); el.addEventListener('dblclick', (e) => { e.stopPropagation(); e.preventDefault(); this._startEdit(overlay, node); }); el.addEventListener('contextmenu', (e) => { this._showNodeContextMenu(overlay, node, e); }); el.addEventListener('keydown', (e) => { if (el.isContentEditable) { if (overlay._lmmMention && this._handleMentionKeydown(overlay, e)) return; if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); const text = el.textContent; this._exitEditMode(overlay, node); this._updateNodeText(node, text); if (node.dirty) { this._persistAndRelayout(overlay); } } else if (e.key === 'Tab') { e.preventDefault(); const text = el.textContent; this._exitEditMode(overlay, node); this._updateNodeText(node, text); this._addChild(overlay, node, true); } else if (e.key === 'Escape') { e.preventDefault(); this._cancelEdit(overlay, node); } } else { if (e.key === 'Enter') { e.preventDefault(); if (node.depth !== 0) this._addSibling(overlay, node, true); } else if (e.key === 'Tab') { e.preventDefault(); if (node.collapsed && node.children.length) { node.collapsed = false; } this._addChild(overlay, node, true); } else if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); this._deleteNode(overlay, node); } else if (e.key === 'F2') { e.preventDefault(); this._startEdit(overlay, node); } else if (e.key === ' ') { e.preventDefault(); this._toggleCollapse(overlay, node); } } }); } _selectNode(overlay, node, focus) { if (overlay._lmmSelected && overlay._lmmSelected !== node && overlay._lmmSelected._el) { overlay._lmmSelected._el.classList.remove('lmm-selected'); } overlay._lmmSelected = node; if (node && node._el) { node._el.classList.add('lmm-selected'); if (focus) node._el.focus({ preventScroll: false }); } } _startEdit(overlay, node) { if (!node || !node._el) return; if (node.isVirtual) return; if (node.collapsed && node.children && node.children.length) { node.collapsed = false; overlay._lmmPendingEdit = node; this._persistAndRelayout(overlay); return; } const el = node._el; if (el.isContentEditable) return; if (overlay._lmmEditingNode && overlay._lmmEditingNode !== node) { this._cancelEdit(overlay, overlay._lmmEditingNode); } this._selectNode(overlay, node, false); overlay._lmmEditingNode = node; el.textContent = node.rawText || node.text || PLACEHOLDER; el.contentEditable = 'true'; el.classList.add('lmm-editing'); el.spellcheck = false; el.focus({ preventScroll: false }); const range = document.createRange(); range.selectNodeContents(el); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); const onBlur = () => { el.removeEventListener('blur', onBlur); if (overlay._lmmEditingBlur === onBlur) overlay._lmmEditingBlur = null; if (!el.isContentEditable) return; const text = el.textContent; const had = node.rawText || node.text; this._exitEditMode(overlay, node); this._updateNodeText(node, text); if ((node.rawText || node.text) !== had) this._persistAndRelayout(overlay); }; el.addEventListener('blur', onBlur); overlay._lmmEditingBlur = onBlur; const onInput = () => this._updateMentionPopup(overlay); el.addEventListener('input', onInput); overlay._lmmMentionInput = onInput; } _exitEditMode(overlay, node) { const el = node._el; if (!el) return; this._closeMentionPopup(overlay); if (overlay._lmmEditingBlur) { el.removeEventListener('blur', overlay._lmmEditingBlur); overlay._lmmEditingBlur = null; } if (overlay._lmmMentionInput) { el.removeEventListener('input', overlay._lmmMentionInput); overlay._lmmMentionInput = null; } el.contentEditable = 'false'; el.classList.remove('lmm-editing'); if (overlay._lmmEditingNode === node) overlay._lmmEditingNode = null; this._renderNodeContent(el, node, overlay); } _updateNodeText(node, newText) { const text = (newText || '').replace(/\s+/g, ' ').trim(); if (!text) return; const hadRaw = node.rawText || node.text; if (text !== hadRaw) { node.rawText = text; node.text = this._stripInline(text); node.dirty = true; } } _cancelEdit(overlay, node) { this._exitEditMode(overlay, node); } // ─── Wiki-link mention autocomplete ──────────────────────────── _getMentionQuery(el) { const sel = window.getSelection(); if (!sel.rangeCount) return null; const range = sel.getRangeAt(0); if (!range.collapsed) return null; const textNode = range.startContainer; if (textNode.nodeType !== Node.TEXT_NODE) return null; const offset = range.startOffset; const text = textNode.textContent; const before = text.slice(0, offset); const openIdx = before.lastIndexOf('[['); if (openIdx === -1) return null; const after = text.slice(offset); if (after.includes(']]')) return null; return { query: before.slice(openIdx + 2), openIdx, textNode, sel }; } _renderMentionPopup(overlay, items) { this._closeMentionPopup(overlay); const popup = document.createElement('div'); popup.className = 'lmm-mention-popup'; items.forEach((item, i) => { const row = document.createElement('div'); row.className = 'lmm-mention-item'; if (i === 0) row.classList.add('lmm-mention-active'); row.textContent = item.basename; row.title = item.path; row.addEventListener('mousedown', (e) => { e.preventDefault(); this._insertMention(overlay, item); }); popup.appendChild(row); }); document.body.appendChild(popup); const node = overlay._lmmEditingNode; if (node && node._el) { const rect = node._el.getBoundingClientRect(); popup.style.left = rect.left + 'px'; popup.style.top = rect.bottom + 4 + 'px'; } overlay._lmmMention = { popup, index: 0, items }; } _closeMentionPopup(overlay) { if (overlay._lmmMention) { overlay._lmmMention.popup.remove(); overlay._lmmMention = null; } } _updateMentionPopup(overlay) { const el = overlay._lmmEditingNode && overlay._lmmEditingNode._el; if (!el || !el.isContentEditable) return; const info = this._getMentionQuery(el); if (!info) { this._closeMentionPopup(overlay); return; } const now = Date.now(); if (!this._fileCache || now - this._fileCacheTime > 5000) { this._fileCache = this.app.vault.getMarkdownFiles(); this._fileCacheTime = now; } const files = this._fileCache; const q = info.query.toLowerCase(); let matches; if (q) { matches = files.filter(f => f.basename.toLowerCase().includes(q)).slice(0, 30); } else { matches = [...files].sort((a, b) => b.stat.mtime - a.stat.mtime).slice(0, 30); } if (matches.length === 0) { this._closeMentionPopup(overlay); return; } this._renderMentionPopup(overlay, matches); } _insertMention(overlay, file) { const node = overlay._lmmEditingNode; if (!node || !node._el) return; const el = node._el; const info = this._getMentionQuery(el); if (!info) { this._closeMentionPopup(overlay); return; } // Rebuild text: everything before [[ + query, then insert [[name]], then rest const fullText = el.textContent; const beforeQuery = fullText.slice(0, info.openIdx); const queryEnd = info.openIdx + 2 + info.query.length; const afterQuery = fullText.slice(queryEnd); const insert = '[[' + file.basename + ']]'; el.textContent = beforeQuery + insert + afterQuery; // Place cursor after the inserted link const pos = beforeQuery.length + insert.length; const range = document.createRange(); const textNode = el.firstChild; if (textNode) { range.setStart(textNode, Math.min(pos, textNode.textContent.length)); range.collapse(true); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } this._closeMentionPopup(overlay); el.focus(); } _handleMentionKeydown(overlay, e) { const m = overlay._lmmMention; if (!m) return false; const items = m.items; if (e.key === 'ArrowDown') { e.preventDefault(); m.index = (m.index + 1) % items.length; this._highlightMention(m); return true; } else if (e.key === 'ArrowUp') { e.preventDefault(); m.index = (m.index - 1 + items.length) % items.length; this._highlightMention(m); return true; } else if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); this._insertMention(overlay, items[m.index]); return true; } else if (e.key === 'Escape') { e.preventDefault(); this._closeMentionPopup(overlay); return true; } return false; } _highlightMention(m) { const rows = m.popup.querySelectorAll('.lmm-mention-item'); rows.forEach((r, i) => r.classList.toggle('lmm-mention-active', i === m.index)); rows[m.index] && rows[m.index].scrollIntoView({ block: 'nearest' }); } _toggleCollapse(overlay, node) { if (!node || !node.children || node.children.length === 0) return; const file = overlay._lmmFile; if (!file) return; node.collapsed = !node.collapsed; const newContent = this._serialize(overlay._lmmParsed, overlay._lmmTreeInfo); const selPath = overlay._lmmSelected ? this._pathFor(overlay._lmmSelected, overlay._lmmTreeInfo) : null; overlay._lmmLastContent = newContent; overlay._lmmParsed = this._parseStructured(newContent); overlay._lmmTreeInfo = this._buildTree(overlay._lmmParsed, file.basename); overlay._lmmSelected = selPath ? this._nodeAtPath(overlay._lmmTreeInfo, selPath) : null; this._renderTreeIntoCanvas(overlay, true); if (overlay._lmmSelected && overlay._lmmSelected._el) { overlay._lmmSelected._el.focus({ preventScroll: true }); } overlay._lmmWriting = true; this.app.vault.modify(file, newContent).then(() => { requestAnimationFrame(() => { overlay._lmmWriting = false; }); }).catch((e) => { overlay._lmmWriting = false; console.error('[LightMindMap] collapse persist error', e); }); } _newNode(text) { return { text: text || PLACEHOLDER, rawText: text || PLACEHOLDER, children: [], parent: null, bodyRaw: '', dirty: true, isNew: true, collapsed: false, level: 0, }; } _addSibling(overlay, node, edit) { const treeInfo = overlay._lmmTreeInfo; if (!node.parent) { // True root: promote to virtual root so we can add a sibling. if (!treeInfo.virtualRoot) { const oldRoot = treeInfo.tree; const fileName = (overlay._lmmFile && overlay._lmmFile.basename) || 'Mind Map'; const virt = { rawText: fileName, text: fileName, children: [oldRoot], parent: null, bodyRaw: '', dirty: false, isNew: false, isVirtual: true, }; oldRoot.parent = virt; treeInfo.tree = virt; treeInfo.virtualRoot = true; const newNode = this._newNode(''); newNode.parent = virt; virt.children.push(newNode); overlay._lmmPendingEdit = edit ? newNode : null; this._persistAndRelayout(overlay); return; } return; } const parent = node.parent; const idx = parent.children.indexOf(node); const newNode = this._newNode(''); newNode.parent = parent; parent.children.splice(idx + 1, 0, newNode); overlay._lmmPendingEdit = edit ? newNode : null; this._persistAndRelayout(overlay); } _addChild(overlay, node, edit) { const newNode = this._newNode(''); newNode.parent = node; node.children.push(newNode); overlay._lmmPendingEdit = edit ? newNode : null; this._persistAndRelayout(overlay); } _isZh() { return (window.localStorage.getItem('language') || 'en').startsWith('zh'); } _showNodeContextMenu(overlay, node, e) { e.preventDefault(); e.stopPropagation(); if (node.isVirtual) return; const zh = this._isZh(); const menu = new obsidian.Menu(); menu.addItem((item) => { item.setTitle(zh ? '编辑' : 'Edit') .setIcon('pencil') .onClick(() => this._startEdit(overlay, node)); }); if (node.depth !== 0) { menu.addItem((item) => { item.setTitle(zh ? '创建同级节点' : 'Add Sibling') .setIcon('plus') .onClick(() => this._addSibling(overlay, node, true)); }); } menu.addItem((item) => { item.setTitle(zh ? '创建下级节点' : 'Add Child') .setIcon('git-branch') .onClick(() => { if (node.collapsed && node.children && node.children.length) { node.collapsed = false; } this._addChild(overlay, node, true); }); }); if (node.children && node.children.length) { const collapsed = node.collapsed; menu.addItem((item) => { item.setTitle(zh ? (collapsed ? '展开' : '折叠') : (collapsed ? 'Expand' : 'Collapse')) .setIcon(collapsed ? 'chevrons-down' : 'chevrons-up') .onClick(() => this._toggleCollapse(overlay, node)); }); } if (node.parent) { menu.addSeparator(); menu.addItem((item) => { item.setTitle(zh ? '删除' : 'Delete') .setIcon('trash') .onClick(() => this._deleteNode(overlay, node)); }); } menu.showAtMouseEvent(e); } _deleteNode(overlay, node) { const treeInfo = overlay._lmmTreeInfo; if (!node) return; if (node.isVirtual) return; if (!node.parent) { new obsidian.Notice(this._isZh() ? '无法删除根节点' : 'Cannot delete the root node'); return; } const parent = node.parent; const idx = parent.children.indexOf(node); if (idx >= 0) parent.children.splice(idx, 1); if (overlay._lmmSelected === node) overlay._lmmSelected = null; if (overlay._lmmEditingNode === node) overlay._lmmEditingNode = null; if (treeInfo.virtualRoot && treeInfo.tree.children.length === 1) { const sole = treeInfo.tree.children[0]; sole.parent = null; treeInfo.tree = sole; treeInfo.virtualRoot = false; } if (treeInfo.virtualRoot && treeInfo.tree.children.length === 0) { const placeholder = this._newNode(''); placeholder.parent = treeInfo.tree; treeInfo.tree.children.push(placeholder); } this._persistAndRelayout(overlay); } // ──────────────────────────────────────────────────────────────── // Persist tree → markdown and rebuild canvas. // We re-parse our own output so each subsequent edit starts from a // clean structure (and so `bodyRaw` slots stay consistent). // ──────────────────────────────────────────────────────────────── async _persistAndRelayout(overlay) { const file = overlay._lmmFile; if (!file) return; const newContent = this._serialize(overlay._lmmParsed, overlay._lmmTreeInfo); const selPath = overlay._lmmSelected ? this._pathFor(overlay._lmmSelected, overlay._lmmTreeInfo) : null; const editPath = overlay._lmmPendingEdit ? this._pathFor(overlay._lmmPendingEdit, overlay._lmmTreeInfo) : null; overlay._lmmLastContent = newContent; overlay._lmmParsed = this._parseStructured(newContent); overlay._lmmTreeInfo = this._buildTree(overlay._lmmParsed, file.basename); overlay._lmmSelected = selPath ? this._nodeAtPath(overlay._lmmTreeInfo, selPath) : null; overlay._lmmPendingEdit = editPath ? this._nodeAtPath(overlay._lmmTreeInfo, editPath) : null; this._renderTreeIntoCanvas(overlay, true); // Restore focus synchronously so keyboard shortcuts work immediately if (overlay._lmmSelected && overlay._lmmSelected._el) { overlay._lmmSelected._el.focus({ preventScroll: true }); } try { overlay._lmmWriting = true; await this.app.vault.modify(file, newContent); requestAnimationFrame(() => { overlay._lmmWriting = false; }); } catch (e) { overlay._lmmWriting = false; console.error('[LightMindMap] persist error', e); new obsidian.Notice('Failed to save mindmap: ' + e.message); } } _pathFor(node, treeInfo) { if (!node || !treeInfo) return null; const path = []; let cur = node; while (cur && cur.parent) { const idx = cur.parent.children.indexOf(cur); if (idx < 0) return null; path.unshift(idx); cur = cur.parent; } if (cur !== treeInfo.tree) return null; return path; } _nodeAtPath(treeInfo, path) { let cur = treeInfo.tree; for (const i of path) { if (!cur || !cur.children[i]) return null; cur = cur.children[i]; } return cur; } _isDescendant(node, potentialAncestor) { let current = node; while (current) { if (current === potentialAncestor) return true; current = current.parent; } return false; } _updateNodeDepths(node) { node.depth = node.parent ? node.parent.depth + 1 : 0; if (node._el) { node._el.className = node._el.className.replace(/lmm-node-d\d+/g, ''); node._el.classList.add('lmm-node-d' + node.depth); if (node.depth === 0) node._el.classList.add('lmm-node-root'); } for (const child of node.children) { this._updateNodeDepths(child); } } _findNodeByElement(node, el) { if (node._el === el) return node; if (node.collapsed) return null; for (const child of node.children) { const found = this._findNodeByElement(child, el); if (found) return found; } return null; } _getDropPosition(mouseX, mouseY, targetNode) { const rect = targetNode._el.getBoundingClientRect(); const relativeY = mouseY - rect.top; const height = rect.height; if (relativeY < height * 0.25) return 'before'; if (relativeY > height * 0.75) return 'after'; return 'child'; } _startDrag(overlay, node, e) { overlay._lmmDragging = true; overlay._lmmDragNode = node; // Make original node semi-transparent node._el.classList.add('lmm-dragging-source'); // Create clone const clone = node._el.cloneNode(true); clone.classList.remove('lmm-dragging-source'); clone.classList.add('lmm-drag-clone'); clone.style.left = e.clientX + 'px'; clone.style.top = e.clientY + 'px'; document.body.appendChild(clone); overlay._lmmDragClone = clone; // Add dragging class to canvas if (overlay._lmmCanvas) { overlay._lmmCanvas.classList.add('lmm-node-dragging'); } } _updateDragClone(overlay, x, y) { if (!overlay._lmmDragClone) return; overlay._lmmDragClone.style.left = x + 'px'; overlay._lmmDragClone.style.top = y + 'px'; } _detectDropTarget(overlay, x, y) { // Clear previous target if (overlay._lmmDropTarget && overlay._lmmDropTarget._el) { overlay._lmmDropTarget._el.classList.remove('lmm-drop-target'); } if (overlay._lmmDropTarget && overlay._lmmDropTarget._el) { const oldBefore = overlay._lmmDropTarget._el.querySelector('.lmm-drop-before'); const oldAfter = overlay._lmmDropTarget._el.querySelector('.lmm-drop-after'); if (oldBefore) oldBefore.remove(); if (oldAfter) oldAfter.remove(); } // Find node under cursor const elements = document.elementsFromPoint(x, y); let targetNode = null; for (const el of elements) { const nodeEl = el.closest('.lmm-node'); if (nodeEl) { targetNode = this._findNodeByElement(overlay._lmmTreeInfo.tree, nodeEl); break; } } // Validate target if (targetNode && targetNode !== overlay._lmmDragNode) { // Check if target is descendant of source if (!this._isDescendant(targetNode, overlay._lmmDragNode)) { overlay._lmmDropTarget = targetNode; overlay._lmmDropPosition = this._getDropPosition(x, y, targetNode); // Highlight target targetNode._el.classList.add('lmm-drop-target'); // Show position indicator if (overlay._lmmDropPosition === 'before') { const indicator = document.createElement('div'); indicator.className = 'lmm-drop-before'; targetNode._el.appendChild(indicator); } else if (overlay._lmmDropPosition === 'after') { const indicator = document.createElement('div'); indicator.className = 'lmm-drop-after'; targetNode._el.appendChild(indicator); } return; } } overlay._lmmDropTarget = null; overlay._lmmDropPosition = null; } _moveNode(overlay, sourceNode, targetNode, position) { // 1. Remove from original parent const sourceParent = sourceNode.parent; const sourceIndex = sourceParent.children.indexOf(sourceNode); sourceParent.children.splice(sourceIndex, 1); // 2. Insert at new position if (position === 'child') { sourceNode.parent = targetNode; targetNode.children.push(sourceNode); // Expand target if collapsed if (targetNode.collapsed) { targetNode.collapsed = false; } } else { const targetParent = targetNode.parent; const targetIndex = targetParent.children.indexOf(targetNode); const insertIndex = position === 'before' ? targetIndex : targetIndex + 1; sourceNode.parent = targetParent; targetParent.children.splice(insertIndex, 0, sourceNode); } // 3. Update node depths this._updateNodeDepths(sourceNode); // 4. Persist and re-render this._persistAndRelayout(overlay); } _endDrag(overlay) { // Remove clone if (overlay._lmmDragClone) { overlay._lmmDragClone.remove(); overlay._lmmDragClone = null; } // Clear source styling if (overlay._lmmDragNode && overlay._lmmDragNode._el) { overlay._lmmDragNode._el.classList.remove('lmm-dragging-source'); } // Clear target styling if (overlay._lmmDropTarget && overlay._lmmDropTarget._el) { overlay._lmmDropTarget._el.classList.remove('lmm-drop-target'); const oldBefore = overlay._lmmDropTarget._el.querySelector('.lmm-drop-before'); const oldAfter = overlay._lmmDropTarget._el.querySelector('.lmm-drop-after'); if (oldBefore) oldBefore.remove(); if (oldAfter) oldAfter.remove(); } // Remove canvas dragging class if (overlay._lmmCanvas) { overlay._lmmCanvas.classList.remove('lmm-node-dragging'); } // Reset state overlay._lmmDragging = false; overlay._lmmDragNode = null; overlay._lmmDropTarget = null; overlay._lmmDropPosition = null; } // ──────────────────────────────────────────────────────────────── // Layout & geometry. // ──────────────────────────────────────────────────────────────── _measureNodes(node) { const r = node._el.getBoundingClientRect(); node.width = Math.max(r.width, 40); node.height = Math.max(r.height, 24); if (node.collapsed) return; for (const c of node.children) this._measureNodes(c); } _computeSubtreeHeight(node) { if (node.collapsed || node.children.length === 0) { node._sth = node.height; return node._sth; } let total = 0; for (const c of node.children) total += this._computeSubtreeHeight(c); total += (node.children.length - 1) * VGAP; node._sth = Math.max(node.height, total); return node._sth; } _layoutTree(root, layout, overlay) { if (layout === 'right') { this._computeSubtreeHeight(root); this._placeRight(root, 0, 0); } else if (layout === 'left') { this._computeSubtreeHeight(root); this._placeLeft(root, 0, 0); } else if (layout === 'tree') { this._layoutStandardTree(root); } else if (layout === 'radial') { this._layoutRadial(root, 0, 0, -1, -1); } else { this._layoutBalanced(root, overlay); } } _placeRight(node, x, yCenter) { node.x = x; node.y = yCenter - node.height / 2; if (node.collapsed || node.children.length === 0) return; const gap = node.depth === 0 ? ROOT_HGAP : HGAP; const childX = x + node.width + gap; let totalH = 0; for (const c of node.children) totalH += c._sth; totalH += (node.children.length - 1) * VGAP; let cy = yCenter - totalH / 2; for (const c of node.children) { const childYCenter = cy + c._sth / 2; this._placeRight(c, childX, childYCenter); cy += c._sth + VGAP; } } _placeLeft(node, xRight, yCenter) { node.x = xRight - node.width; node.y = yCenter - node.height / 2; if (node.collapsed || node.children.length === 0) return; const gap = node.depth === 0 ? ROOT_HGAP : HGAP; const childRight = node.x - gap; let totalH = 0; for (const c of node.children) totalH += c._sth; totalH += (node.children.length - 1) * VGAP; let cy = yCenter - totalH / 2; for (const c of node.children) { const childYCenter = cy + c._sth / 2; this._placeLeft(c, childRight, childYCenter); cy += c._sth + VGAP; } } _layoutBalanced(root, overlay) { const children = root.children; if (children.length === 0) { root.x = -root.width / 2; root.y = -root.height / 2; return; } for (const c of children) this._computeSubtreeHeight(c); let right, left; const cached = overlay && overlay._lmmSideCache; if (cached && cached.length === children.length && cached.every((s, i) => s.text === children[i].text)) { right = []; left = []; for (let i = 0; i < children.length; i++) { if (cached[i].side === 'left') left.push(children[i]); else right.push(children[i]); } } else { const ranked = children.map((c, i) => ({ c, i })).sort((a, b) => b.c._sth - a.c._sth); right = []; left = []; let rH = 0; let lH = 0; for (const { c } of ranked) { if (rH <= lH) { right.push(c); rH += c._sth + VGAP; } else { left.push(c); lH += c._sth + VGAP; } } right.sort((a, b) => children.indexOf(a) - children.indexOf(b)); left.sort((a, b) => children.indexOf(a) - children.indexOf(b)); } if (overlay) { overlay._lmmSideCache = children.map(c => ({ text: c.text, side: right.includes(c) ? 'right' : 'left' })); } root.x = -root.width / 2; root.y = -root.height / 2; root._side = 'root'; const rightX = root.x + root.width + ROOT_HGAP; let totalRH = right.reduce((s, c) => s + c._sth, 0) + Math.max(0, right.length - 1) * VGAP; let cy = -totalRH / 2; for (const c of right) { const childYCenter = cy + c._sth / 2; this._placeRight(c, rightX, childYCenter); this._markSide(c, 'right'); cy += c._sth + VGAP; } const leftRight = root.x - ROOT_HGAP; let totalLH = left.reduce((s, c) => s + c._sth, 0) + Math.max(0, left.length - 1) * VGAP; cy = -totalLH / 2; for (const c of left) { const childYCenter = cy + c._sth / 2; this._placeLeft(c, leftRight, childYCenter); this._markSide(c, 'left'); cy += c._sth + VGAP; } } _markSide(node, side) { node._side = side; for (const c of node.children) this._markSide(c, side); } _computeSubtreeWidth(node) { if (node.collapsed || node.children.length === 0) { node._stw = node.width; return node._stw; } let total = 0; for (const c of node.children) total += this._computeSubtreeWidth(c); total += (node.children.length - 1) * HGAP; node._stw = Math.max(node.width, total); return node._stw; } _layoutStandardTree(root) { this._computeSubtreeWidth(root); root.x = -root.width / 2; root.y = 0; if (root.collapsed || root.children.length === 0) return; const gap = ROOT_HGAP; const childY = root.y + root.height + gap; let totalW = root.children.reduce((s, c) => s + c._stw, 0) + (root.children.length - 1) * HGAP; let cx = -totalW / 2; for (const c of root.children) { this._placeBelow(c, cx, childY); cx += c._stw + HGAP; } } _placeBelow(node, xLeft, y) { node.x = xLeft + (node._stw - node.width) / 2; node.y = y; if (node.collapsed || node.children.length === 0) return; const gap = HGAP; const childY = y + node.height + gap; let totalW = node.children.reduce((s, c) => s + c._stw, 0) + (node.children.length - 1) * HGAP; let cx = xLeft + (node._stw - totalW) / 2; for (const c of node.children) { this._placeBelow(c, cx, childY); cx += c._stw + HGAP; } } _layoutRadial(root, cx, cy, startAngle, endAngle) { root.x = cx - root.width / 2; root.y = cy - root.height / 2; if (root.collapsed || root.children.length === 0) return; const children = root.children; const weights = children.map(c => { this._computeSubtreeHeight(c); return Math.max(c._sth, 40); }); const totalWeight = weights.reduce((a, b) => a + b, 0); const levelRadius = Math.max(180, children.length * 40); let angle; if (startAngle < 0) { angle = 0; } else { angle = startAngle; } const angleRange = startAngle < 0 ? 2 * Math.PI : endAngle - startAngle; for (let i = 0; i < children.length; i++) { const share = weights[i] / totalWeight; const childAngle = angle + share * angleRange / 2; const childCx = cx + levelRadius * Math.cos(childAngle); const childCy = cy + levelRadius * Math.sin(childAngle); const childStartAngle = angle; const childEndAngle = angle + share * angleRange; this._layoutRadial(children[i], childCx, childCy, childStartAngle, childEndAngle); angle += share * angleRange; } } _applyNodePositions(node) { if (node._el) { node._el.style.left = node.x + 'px'; node._el.style.top = node.y + 'px'; } if (node.collapsed) return; for (const c of node.children) this._applyNodePositions(c); } _computeBounds(node, b) { b = b || { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; b.minX = Math.min(b.minX, node.x); b.minY = Math.min(b.minY, node.y); b.maxX = Math.max(b.maxX, node.x + node.width); b.maxY = Math.max(b.maxY, node.y + node.height); if (node.collapsed) return b; for (const c of node.children) this._computeBounds(c, b); return b; } _shiftTree(node, dx, dy) { node.x += dx; node.y += dy; if (node.collapsed) return; for (const c of node.children) this._shiftTree(c, dx, dy); } _drawConnections(node, svg, layout, lineId) { if (node.collapsed) return; const style = LINE_STYLES[lineId] || LINE_STYLES[DEFAULT_LINE]; for (const child of node.children) { let startX, startY, endX, endY; if (layout === 'radial') { startX = node.x + node.width / 2; startY = node.y + node.height / 2; endX = child.x + child.width / 2; endY = child.y + child.height / 2; } else if (layout === 'tree') { startX = node.x + node.width / 2; startY = node.y + node.height; endX = child.x + child.width / 2; endY = child.y; } else { let parentLeft; if (layout === 'balanced') parentLeft = child._side === 'right'; else if (layout === 'left') parentLeft = false; else parentLeft = true; startX = parentLeft ? node.x + node.width : node.x; startY = node.y + node.height / 2; endX = parentLeft ? child.x : child.x + child.width; endY = child.y + child.height / 2; } const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); let d; if (style.shape === 'straight') { d = `M ${startX} ${startY} L ${endX} ${endY}`; } else if (style.shape === 'polyline') { if (layout === 'tree') { const midY = startY + (endY - startY) / 2; d = `M ${startX} ${startY} L ${startX} ${midY} L ${endX} ${midY} L ${endX} ${endY}`; } else if (layout === 'radial') { d = `M ${startX} ${startY} L ${endX} ${endY}`; } else { const midX = startX + (endX - startX) / 2; d = `M ${startX} ${startY} L ${midX} ${startY} L ${midX} ${endY} L ${endX} ${endY}`; } } else { if (layout === 'tree') { const dy = (endY - startY) * 0.4; d = `M ${startX} ${startY} C ${startX} ${startY + dy}, ${endX} ${endY - dy}, ${endX} ${endY}`; } else if (layout === 'radial') { const dx = endX - startX; const dy = endY - startY; const dist = Math.sqrt(dx * dx + dy * dy) * 0.35; const angle = Math.atan2(dy, dx); const cx1 = startX + dist * Math.cos(angle); const cy1 = startY + dist * Math.sin(angle); const cx2 = endX - dist * Math.cos(angle); const cy2 = endY - dist * Math.sin(angle); d = `M ${startX} ${startY} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${endX} ${endY}`; } else { const dx = (endX - startX) * 0.5; d = `M ${startX} ${startY} C ${startX + dx} ${startY}, ${endX - dx} ${endY}, ${endX} ${endY}`; } } path.setAttribute('d', d); path.setAttribute('stroke', child.color); path.setAttribute('stroke-width', String(Math.max(1.5, 4.5 - child.depth * 0.7))); path.setAttribute('fill', 'none'); path.setAttribute('stroke-linecap', 'round'); path.setAttribute('stroke-linejoin', 'round'); if (style.dash) path.setAttribute('stroke-dasharray', style.dash); path.classList.add('lmm-conn'); svg.appendChild(path); this._drawConnections(child, svg, layout, lineId); } } _bindCanvasClick(canvas, overlay) { canvas.addEventListener('click', (e) => { if (e.target.closest('.lmm-node')) return; if (overlay._lmmSelected && overlay._lmmSelected._el) { overlay._lmmSelected._el.classList.remove('lmm-selected'); } overlay._lmmSelected = null; }); } _bindPanZoom(canvas, inner, overlay) { let dragging = false; let sx = 0, sy = 0, stx = 0, sty = 0; const onDown = (e) => { if (e.button !== 0) return; if (e.target.closest('.lmm-node, .lmm-toolbar, .lmm-btn')) return; // Canvas background click during edit — explicitly blur so the // edit commits + persists (canvas isn't focusable, so blur won't // fire on its own). if (overlay && overlay._lmmEditingNode && overlay._lmmEditingNode._el) { overlay._lmmEditingNode._el.blur(); e.preventDefault(); return; } dragging = true; sx = e.clientX; sy = e.clientY; stx = canvas._lmm.tx; sty = canvas._lmm.ty; canvas.classList.add('lmm-dragging'); e.preventDefault(); }; const onMove = (e) => { if (!dragging) return; canvas._lmm.tx = stx + (e.clientX - sx); canvas._lmm.ty = sty + (e.clientY - sy); this._applyTransform(inner, canvas._lmm); }; const onUp = () => { dragging = false; canvas.classList.remove('lmm-dragging'); }; canvas.addEventListener('mousedown', onDown); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); const prevCleanup = overlay._lmmCleanup; overlay._lmmCleanup = () => { if (prevCleanup) prevCleanup(); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; // ── Touch support ── let touchId = null; let pinchDist = 0; let pinchMid = { x: 0, y: 0 }; let pinchScale = 1; let pinchTx = 0, pinchTy = 0; canvas.addEventListener('touchstart', (e) => { if (e.target.closest('.lmm-node, .lmm-toolbar, .lmm-btn')) return; if (overlay && overlay._lmmEditingNode && overlay._lmmEditingNode._el) { overlay._lmmEditingNode._el.blur(); e.preventDefault(); return; } if (e.touches.length === 1) { const t = e.touches[0]; touchId = t.identifier; sx = t.clientX; sy = t.clientY; stx = canvas._lmm.tx; sty = canvas._lmm.ty; canvas.classList.add('lmm-dragging'); e.preventDefault(); } else if (e.touches.length === 2) { touchId = null; canvas.classList.remove('lmm-dragging'); const [a, b] = [e.touches[0], e.touches[1]]; pinchDist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); pinchMid = { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 }; pinchScale = canvas._lmm.scale; pinchTx = canvas._lmm.tx; pinchTy = canvas._lmm.ty; e.preventDefault(); } }, { passive: false }); canvas.addEventListener('touchmove', (e) => { if (e.touches.length === 1 && touchId !== null) { const t = e.touches[0]; if (t.identifier !== touchId) return; canvas._lmm.tx = stx + (t.clientX - sx); canvas._lmm.ty = sty + (t.clientY - sy); this._applyTransform(inner, canvas._lmm); e.preventDefault(); } else if (e.touches.length === 2) { const [a, b] = [e.touches[0], e.touches[1]]; const newDist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); const factor = newDist / pinchDist; const newScale = Math.max(0.2, Math.min(3, pinchScale * factor)); const rect = canvas.getBoundingClientRect(); const ox = pinchMid.x - rect.left; const oy = pinchMid.y - rect.top; const realFactor = newScale / pinchScale; canvas._lmm.tx = ox - (ox - pinchTx) * realFactor; canvas._lmm.ty = oy - (oy - pinchTy) * realFactor; canvas._lmm.scale = newScale; this._applyTransform(inner, canvas._lmm); e.preventDefault(); } }, { passive: false }); canvas.addEventListener('touchend', (e) => { if (e.touches.length === 0) { touchId = null; canvas.classList.remove('lmm-dragging'); } else if (e.touches.length === 1) { // Switched from pinch back to single-finger pan const t = e.touches[0]; touchId = t.identifier; sx = t.clientX; sy = t.clientY; stx = canvas._lmm.tx; sty = canvas._lmm.ty; } }); canvas.addEventListener('wheel', (e) => { const isZoom = e.ctrlKey || e.metaKey; if (!isZoom) { canvas._lmm.ty -= e.deltaY; canvas._lmm.tx -= e.deltaX; this._applyTransform(inner, canvas._lmm); e.preventDefault(); return; } const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1; const rect = canvas.getBoundingClientRect(); const ox = e.clientX - rect.left; const oy = e.clientY - rect.top; const s = canvas._lmm; s.tx = ox - (ox - s.tx) * factor; s.ty = oy - (oy - s.ty) * factor; s.scale = Math.max(0.2, Math.min(3, s.scale * factor)); this._applyTransform(inner, s); e.preventDefault(); }, { passive: false }); } _applyTransform(inner, s) { inner.style.transform = `translate(${s.tx}px, ${s.ty}px) scale(${s.scale})`; } _fitTo(canvas, inner) { const cw = canvas.clientWidth; const ch = canvas.clientHeight; const iw = parseFloat(inner.style.width) || inner.clientWidth; const ih = parseFloat(inner.style.height) || inner.clientHeight; if (!iw || !ih) return; const scale = Math.min(cw / iw, ch / ih, 1); const tx = (cw - iw * scale) / 2; const ty = (ch - ih * scale) / 2; canvas._lmm = { tx, ty, scale }; this._applyTransform(inner, canvas._lmm); } _zoomBy(canvas, inner, factor) { const s = canvas._lmm; const cx = canvas.clientWidth / 2; const cy = canvas.clientHeight / 2; s.tx = cx - (cx - s.tx) * factor; s.ty = cy - (cy - s.ty) * factor; s.scale = Math.max(0.2, Math.min(3, s.scale * factor)); this._applyTransform(inner, s); } _resetTransform(canvas, inner) { canvas._lmm = { tx: 0, ty: 0, scale: 1 }; this._applyTransform(inner, canvas._lmm); } // ──────────────────────────────────────────────────────────────── // PNG Export helpers // ──────────────────────────────────────────────────────────────── _hexToRgb(hex) { const m = hex.replace('#', '').match(/^([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); if (!m) return [0, 0, 0]; return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)]; } _injectDoodleFilter() { if (document.getElementById('lmm-doodle-filter')) return; const svgNS = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(svgNS, 'svg'); svg.setAttribute('width', '0'); svg.setAttribute('height', '0'); svg.style.position = 'absolute'; svg.style.pointerEvents = 'none'; svg.innerHTML = ` `; document.body.appendChild(svg); } _isDarkColor(hex) { const [r, g, b] = this._hexToRgb(hex); const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return luminance < 0.5; } _mixColors(hex1, hex2, weight) { const [r1, g1, b1] = this._hexToRgb(hex1); const [r2, g2, b2] = this._hexToRgb(hex2); const r = Math.round(r1 + (r2 - r1) * weight); const g = Math.round(g1 + (g2 - g1) * weight); const b = Math.round(b1 + (b2 - b1) * weight); return `rgb(${r},${g},${b})`; } _roundRect(ctx, x, y, w, h, r) { r = Math.min(r, w / 2, h / 2); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r); ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r); ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r); ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); ctx.closePath(); } _drawTextToCanvas(ctx, text, x, y, maxW, font, color, align, letterSpacing) { ctx.save(); ctx.font = font; ctx.fillStyle = color; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; let displayText = text; const ls = letterSpacing || 0; // Measure with letter-spacing (CSS letter-spacing applies after each char) const measureWithLS = (t) => { let w = 0; for (let i = 0; i < t.length; i++) w += ctx.measureText(t[i]).width + ls; return w; }; let tw = measureWithLS(displayText); if (tw > maxW) { while (displayText.length > 0 && measureWithLS(displayText + '...') > maxW) { displayText = displayText.slice(0, -1); } displayText += '...'; } const alignType = align || 'left'; let drawX = x; if (alignType === 'center') { drawX = x - measureWithLS(displayText) / 2; } else if (alignType === 'right') { drawX = x - measureWithLS(displayText); } for (let i = 0; i < displayText.length; i++) { const ch = displayText[i]; ctx.fillText(ch, drawX, y); drawX += ctx.measureText(ch).width + ls; } ctx.restore(); } _drawNodeToCanvas(ctx, node, theme, nodeStyle, scale) { const x = node.x * scale; const y = node.y * scale; const w = node.width * scale; const h = node.height * scale; const r = 8 * scale; const depth = node.depth; const bgColor = theme.bg || '#FFFFFF'; const isDark = this._isDarkColor(bgColor); ctx.save(); // Draw this node this._drawSingleNodeToCanvas(ctx, node, x, y, w, h, r, depth, bgColor, isDark, theme, nodeStyle, scale); ctx.restore(); // Recursively draw children if (node.collapsed) return; for (const child of node.children) { this._drawNodeToCanvas(ctx, child, theme, nodeStyle, scale); } } _drawDoodleRect(ctx, x, y, w, h, seed) { // Create a hand-drawn style irregular rectangle const points = []; const segments = 16; const jitter = 2.5; // Simple seeded random for consistent results let s = seed || 12345; const rand = () => { s = (s * 16807 + 0) % 2147483647; return (s / 2147483647) * 2 - 1; // -1 to 1 }; // Generate points along the edges with slight jitter for (let i = 0; i <= segments; i++) { const t = i / segments; // Top edge points.push({ x: x + t * w, y: y + rand() * jitter * 0.8 }); } for (let i = 1; i <= segments; i++) { const t = i / segments; // Right edge points.push({ x: x + w + rand() * jitter, y: y + t * h }); } for (let i = 1; i <= segments; i++) { const t = i / segments; // Bottom edge points.push({ x: x + w - t * w, y: y + h + rand() * jitter * 0.8 }); } for (let i = 1; i < segments; i++) { const t = i / segments; // Left edge points.push({ x: x + rand() * jitter, y: y + h - t * h }); } // Draw smooth curve through points ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 0; i < points.length; i++) { const p0 = points[i]; const p1 = points[(i + 1) % points.length]; const p2 = points[(i + 2) % points.length]; const cpx = p1.x + (p2.x - p0.x) * 0.12; const cpy = p1.y + (p2.y - p0.y) * 0.12; ctx.quadraticCurveTo(cpx, cpy, p1.x, p1.y); } ctx.closePath(); } _drawSingleNodeToCanvas(ctx, node, x, y, w, h, r, depth, bgColor, isDark, theme, nodeStyle, scale) { const isDoodle = nodeStyle === 'doodle'; const isBorderless = nodeStyle === 'borderless'; // Use a simple hash of node position for consistent doodle randomness const seed = Math.abs(Math.floor(node.x * 100 + node.y * 37 + node.depth * 7)); const collapsed = node.collapsed && node.children && node.children.length > 0; const badgeExtra = collapsed ? 26 * scale : 0; // Apply rotation for doodle style if (isDoodle) { const centerX = x + w / 2; const centerY = y + h / 2; const rotation = ((seed % 5) - 2) * 0.2; // -0.4 to 0.4 degrees ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(rotation * Math.PI / 180); ctx.translate(-centerX, -centerY); } const fontBase = '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif'; if (depth === 0) { if (isBorderless) { const rootColor = theme.rootGrad ? (theme.rootGrad.match(/#[0-9a-fA-F]{6}/g) || ['#6366F1'])[0] : '#6366F1'; this._drawTextToCanvas(ctx, node.text, x + 26 * scale, y + h / 2, w - 52 * scale - badgeExtra, `700 ${18 * scale}px ${fontBase}`, isDark ? '#E2E8F0' : rootColor, 'left', 0.3 * scale); } else { // Root node: gradient background ctx.shadowColor = 'rgba(0,0,0,0.15)'; ctx.shadowBlur = 12 * scale; ctx.shadowOffsetY = 4 * scale; const grad = ctx.createLinearGradient(x, y, x + w, y + h); const rootGrad = theme.rootGrad || 'linear-gradient(135deg, #6366F1, #8B5CF6 60%, #EC4899)'; const gradColors = rootGrad.match(/#[0-9a-fA-F]{6}/g) || ['#6366F1', '#EC4899']; grad.addColorStop(0, gradColors[0]); grad.addColorStop(1, gradColors[gradColors.length - 1]); if (isDoodle) { this._drawDoodleRect(ctx, x, y, w, h, seed); } else { this._roundRect(ctx, x, y, w, h, r); } ctx.fillStyle = grad; ctx.fill(); ctx.shadowColor = 'transparent'; this._drawTextToCanvas(ctx, node.text, x + 26 * scale, y + h / 2, w - 52 * scale - badgeExtra, `700 ${18 * scale}px ${fontBase}`, '#FFFFFF', 'left', 0.3 * scale); } } else if (depth === 1) { if (isBorderless) { this._drawTextToCanvas(ctx, node.text, x + 18 * scale, y + h / 2, w - 36 * scale - badgeExtra, `600 ${15 * scale}px ${fontBase}`, isDark ? '#E2E8F0' : node.color); } else { // Level 1: solid color background ctx.shadowColor = 'rgba(0,0,0,0.1)'; ctx.shadowBlur = 8 * scale; ctx.shadowOffsetY = 2 * scale; if (isDoodle) { this._drawDoodleRect(ctx, x, y, w, h, seed); } else { this._roundRect(ctx, x, y, w, h, r); } ctx.fillStyle = node.color; ctx.fill(); ctx.shadowColor = 'transparent'; this._drawTextToCanvas(ctx, node.text, x + 18 * scale, y + h / 2, w - 36 * scale - badgeExtra, `600 ${15 * scale}px ${fontBase}`, '#FFFFFF'); } } else if (depth === 2) { if (isBorderless) { this._drawTextToCanvas(ctx, node.text, x + 14 * scale, y + h / 2, w - 28 * scale - badgeExtra, `600 ${13.5 * scale}px ${fontBase}`, isDark ? '#E2E8F0' : '#1F2937'); } else { // Level 2: tinted background with colored border ctx.shadowColor = 'rgba(0,0,0,0.06)'; ctx.shadowBlur = 4 * scale; if (isDoodle) { this._drawDoodleRect(ctx, x, y, w, h, seed); } else { this._roundRect(ctx, x, y, w, h, r); } ctx.fillStyle = this._mixColors(node.color, bgColor, 0.84); ctx.fill(); ctx.shadowColor = 'transparent'; ctx.strokeStyle = this._mixColors(node.color, bgColor, 0.5); ctx.lineWidth = 2 * scale; ctx.stroke(); this._drawTextToCanvas(ctx, node.text, x + 14 * scale, y + h / 2, w - 28 * scale - badgeExtra, `600 ${13.5 * scale}px ${fontBase}`, isDark ? '#E2E8F0' : '#1F2937'); } } else { if (isBorderless) { this._drawTextToCanvas(ctx, node.text, x + 12 * scale, y + h / 2, w - 24 * scale - badgeExtra, `500 ${12.5 * scale}px ${fontBase}`, isDark ? '#E2E8F0' : '#1F2937'); } else { // Level 3+: subtle border if (isDoodle) { this._drawDoodleRect(ctx, x, y, w, h, seed); } else { this._roundRect(ctx, x, y, w, h, r); } ctx.fillStyle = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)'; ctx.fill(); ctx.strokeStyle = this._mixColors(node.color, bgColor, 0.6); ctx.lineWidth = 1.5 * scale; ctx.stroke(); this._drawTextToCanvas(ctx, node.text, x + 12 * scale, y + h / 2, w - 24 * scale - badgeExtra, `500 ${12.5 * scale}px ${fontBase}`, isDark ? '#E2E8F0' : '#1F2937'); } } // Draw collapse badge if (collapsed) { const badgeR = 9 * scale; const padRight = depth === 0 ? 26 : depth === 1 ? 18 : depth === 2 ? 14 : 12; const badgeCX = x + w - padRight * scale - badgeR; const badgeCY = y + h / 2; ctx.beginPath(); ctx.arc(badgeCX, badgeCY, badgeR, 0, Math.PI * 2); ctx.fillStyle = depth <= 1 ? 'rgba(255,255,255,0.85)' : node.color; ctx.fill(); const crossLen = 10 * scale; const crossW = 2 * scale; ctx.fillStyle = depth === 0 ? '#6366F1' : depth === 1 ? node.color : '#FFFFFF'; ctx.fillRect(badgeCX - crossLen / 2, badgeCY - crossW / 2, crossLen, crossW); ctx.fillRect(badgeCX - crossW / 2, badgeCY - crossLen / 2, crossW, crossLen); } // Restore rotation for doodle style if (isDoodle) { ctx.restore(); } ctx.restore(); } _drawConnectionsToCanvas(ctx, node, layout, lineStyle, scale) { if (node.collapsed) return; const style = LINE_STYLES[lineStyle] || LINE_STYLES[DEFAULT_LINE]; for (const child of node.children) { let startX, startY, endX, endY; if (layout === 'radial') { startX = (node.x + node.width / 2) * scale; startY = (node.y + node.height / 2) * scale; endX = (child.x + child.width / 2) * scale; endY = (child.y + child.height / 2) * scale; } else if (layout === 'tree') { startX = (node.x + node.width / 2) * scale; startY = (node.y + node.height) * scale; endX = (child.x + child.width / 2) * scale; endY = child.y * scale; } else { let parentLeft; if (layout === 'balanced') parentLeft = child._side === 'right'; else if (layout === 'left') parentLeft = false; else parentLeft = true; startX = (parentLeft ? node.x + node.width : node.x) * scale; startY = (node.y + node.height / 2) * scale; endX = (parentLeft ? child.x : child.x + child.width) * scale; endY = (child.y + child.height / 2) * scale; } ctx.save(); ctx.strokeStyle = child.color; ctx.lineWidth = Math.max(1.5, 4.5 - child.depth * 0.7) * scale; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; if (style.dash) { const dashArr = style.dash.split(' ').map(Number); ctx.setLineDash(dashArr.map(d => d * scale)); } ctx.beginPath(); ctx.moveTo(startX, startY); if (style.shape === 'straight') { ctx.lineTo(endX, endY); } else if (style.shape === 'polyline') { if (layout === 'tree') { const midY = startY + (endY - startY) / 2; ctx.lineTo(startX, midY); ctx.lineTo(endX, midY); ctx.lineTo(endX, endY); } else if (layout === 'radial') { ctx.lineTo(endX, endY); } else { const midX = startX + (endX - startX) / 2; ctx.lineTo(midX, startY); ctx.lineTo(midX, endY); ctx.lineTo(endX, endY); } } else { if (layout === 'tree') { const dy = (endY - startY) * 0.4; ctx.bezierCurveTo(startX, startY + dy, endX, endY - dy, endX, endY); } else if (layout === 'radial') { const dx = endX - startX; const dy = endY - startY; const dist = Math.sqrt(dx * dx + dy * dy) * 0.35; const angle = Math.atan2(dy, dx); const cx1 = startX + dist * Math.cos(angle); const cy1 = startY + dist * Math.sin(angle); const cx2 = endX - dist * Math.cos(angle); const cy2 = endY - dist * Math.sin(angle); ctx.bezierCurveTo(cx1, cy1, cx2, cy2, endX, endY); } else { const dx = (endX - startX) * 0.5; ctx.bezierCurveTo(startX + dx, startY, endX - dx, endY, endX, endY); } } ctx.stroke(); ctx.restore(); this._drawConnectionsToCanvas(ctx, child, layout, lineStyle, scale); } } async _saveWithDialog(file, arrayBuffer) { const uint8 = new Uint8Array(arrayBuffer); const defaultName = (file.basename || 'mindmap') + '.mindmap.png'; if (this.app.isMobile) { const filePath = (file.parent ? file.parent.path + '/' : '') + defaultName; await this.app.vault.adapter.writeBinary(filePath, uint8); return true; } 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) { 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; const tree = treeInfo && treeInfo.tree; if (!tree) { new obsidian.Notice('No mindmap to export'); return; } const file = overlay._lmmFile; if (!file) { new obsidian.Notice('No file associated with mindmap'); return; } const themeKey = overlay._lmmTheme || DEFAULT_THEME; const theme = THEMES[themeKey] || THEMES[DEFAULT_THEME]; const layout = overlay._lmmLayout || 'balanced'; const lineStyle = overlay._lmmLine || DEFAULT_LINE; const nodeStyle = overlay._lmmNodeStyle || DEFAULT_NODE_STYLE; const scale = 2; // Calculate bounds const bounds = this._computeBounds(tree); 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; canvas.height = h * scale; const ctx = canvas.getContext('2d'); // Get background color let bgColor = theme.bg; if (!bgColor) { const computedStyle = getComputedStyle(overlay); bgColor = computedStyle.getPropertyValue('--lmm-theme-bg').trim() || computedStyle.getPropertyValue('--background-primary').trim() || '#FFFFFF'; } ctx.fillStyle = bgColor; ctx.fillRect(0, 0, canvas.width, canvas.height); // Slate theme: draw grid pattern if (themeKey === 'slate') { const gridSize = 32 * scale; ctx.strokeStyle = 'rgba(148, 163, 184, 0.06)'; ctx.lineWidth = 1 * scale; ctx.beginPath(); for (let gx = 0; gx <= canvas.width; gx += gridSize) { ctx.moveTo(gx, 0); ctx.lineTo(gx, canvas.height); } for (let gy = 0; gy <= canvas.height; gy += gridSize) { ctx.moveTo(0, gy); ctx.lineTo(canvas.width, gy); } ctx.stroke(); } // Draw connections this._drawConnectionsToCanvas(ctx, tree, layout, lineStyle, scale); // 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) => { if (b) resolve(b); else reject(new Error('Failed to create PNG')); }, 'image/png'); }); // Prompt user for save location const arrayBuffer = await blob.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); new obsidian.Notice('Export failed: ' + e.message); } } } module.exports = LightMindMapPlugin; /* nosourcemap */