diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6cb0ea --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# Light MindMap + +An Obsidian plugin that auto-renders markdown headings as a colorful, interactive mind map — no extra syntax required. + +## How It Works + +Add `type: mindmap` to any note's frontmatter. The plugin replaces the editor/reading view with a live mind map built from the note's heading hierarchy. + +```yaml +--- +type: mindmap +--- + +# Project Alpha +## Goals +### Milestone 1 +### Milestone 2 +## Team +### Engineering +### Design +``` + +The mind map updates in real time as you edit the source. + +## Features + +### Auto-Render from Headings + +- Parses all heading levels (`#` through `######`) into a tree +- Strips inline markdown (bold, italic, links, wikilinks, code) from node labels +- When multiple top-level headings exist, a virtual root node (named after the file) is created automatically +- Fenced code blocks are skipped during parsing + +### Layouts + +Three layout modes, switchable from the toolbar or via command: + +| Layout | Description | +|--------|-------------| +| **Balanced** | Children are distributed to both sides of the root, weighted by subtree height | +| **Right** | All branches expand to the right | +| **Left** | All branches expand to the left | + +### Themes + +Six built-in color palettes: + +| Theme | Style | +|-------|-------| +| **Vibrant** | Indigo/violet/pink gradient — the default | +| **Classic** | Earth tones on a warm cream background | +| **Fresh** | Greens and teals on a light mint background | +| **Ocean** | Blues and indigos on a pale blue background | +| **Sunset** | Reds, oranges, and pinks on a warm background | +| **Midnight** | Neon accents on a dark slate background | + +Themes adapt automatically to Obsidian's dark/light mode. + +### Connection Line Styles + +| Style | Shape | Dash | +|-------|-------|------| +| **Smooth** | Cubic Bézier curve | Solid | +| **Smooth Dashed** | Cubic Bézier curve | Dashed | +| **Straight** | Direct line | Solid | +| **Right Angle** | Horizontal + vertical segments | Solid | +| **Right Angle Dashed** | Horizontal + vertical segments | Dashed | + +### Node Shapes + +| Shape | Appearance | +|-------|------------| +| **Rounded** | Rounded rectangle (default) | +| **Square** | Sharp corners | +| **Borderless** | No border or background on leaf nodes | +| **Pill** | Fully rounded capsule | + +### Pan & Zoom + +- **Drag** the canvas background to pan +- **Scroll** to pan vertically/horizontally +- **Ctrl/Cmd + Scroll** to zoom in/out around the cursor +- Toolbar buttons: **Fit** (fit all nodes into view), **+** / **−** (step zoom), **1:1** (reset to 100%) + +### Node Editing + +Nodes can be edited directly on the canvas — changes are written back to the markdown file: + +| Action | Gesture / Key | +|--------|--------------| +| Select node | Click | +| Edit node text | Double-click or **F2** | +| Confirm edit + add sibling | **Enter** | +| Confirm edit + add child | **Tab** | +| Cancel edit | **Escape** | +| Add sibling (without editing) | Select node, press **Enter** | +| Add child (without editing) | Select node, press **Tab** | +| Delete node | Select node, press **Delete** or **Backspace** | + +The root node cannot be deleted. + +### Persisted Settings + +All per-file display preferences are written to frontmatter and restored on next open: + +| Frontmatter key | Values | +|----------------|--------| +| `mindmap-layout` | `balanced` / `right` / `left` | +| `mindmap-theme` | `vibrant` / `classic` / `fresh` / `ocean` / `sunset` / `midnight` | +| `mindmap-line` | `curve` / `straight` / `polyline` / `polyline-dashed` / `curve-dashed` | +| `mindmap-node` | `rounded` / `square` / `borderless` / `circle` | + +### Toggle Source View + +- **Edit Source** button in the toolbar hides the mind map and shows a floating **Show Mindmap** button +- Command palette: **Toggle mindmap / source view** +- Command palette: **Cycle mindmap layout (balanced / right / left)** + +## Installation + +### From Obsidian Community Plugins (recommended) + +1. Open **Settings → Community plugins → Browse** +2. Search for **Light MindMap** +3. Click **Install**, then **Enable** + +### Manual + +1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/ninglg/obsidian-light-mindmap/releases/latest) +2. Copy the three files into `/.obsidian/plugins/obsidian-light-mindmap/` +3. Reload Obsidian and enable the plugin in **Settings → Community plugins** + +## Example Frontmatter + +```yaml +--- +type: mindmap +mindmap-layout: balanced +mindmap-theme: vibrant +mindmap-line: curve +mindmap-node: rounded +--- +``` + +## Compatibility + +- Minimum Obsidian version: **1.4.0** +- Desktop and mobile supported +- Works with both light and dark Obsidian themes + +## License + +MIT diff --git a/main.js b/main.js new file mode 100644 index 0000000..923e137 --- /dev/null +++ b/main.js @@ -0,0 +1,1195 @@ +'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: null, fg: null, + 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' + } +}; + +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' } +}; + +const DEFAULT_NODE_STYLE = 'rounded'; + +const HGAP = 64; +const VGAP = 18; +const ROOT_HGAP = 90; +const PAD = 60; +const PLACEHOLDER = '新标题'; + +class LightMindMapPlugin extends obsidian.Plugin { + async onload() { + this._scan = obsidian.debounce(() => this._doScan(), 120); + + 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)', + 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']; + 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); + } + }); + + this.app.workspace.onLayoutReady(() => this._doScan()); + } + + onunload() { + document.querySelectorAll('.lmm-overlay').forEach((el) => el.remove()); + document.querySelectorAll('.lmm-fab').forEach((el) => el.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; + overlay.dataset.file = file.path; + 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(); + } + + _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(); + headings.push({ + srcIdx: i, + level: m[1].length, + rawText, + text: this._stripInline(rawText), + children: [], + parent: null, + dirty: false, + isNew: false, + 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, + 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)); + const text = (node.dirty || node.isNew) + ? (node.text || PLACEHOLDER) + : (node.rawText || node.text || PLACEHOLDER); + 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: '🧠 Show Mindmap' }); + fab.onclick = () => { + overlay.classList.remove('lmm-hidden'); + fab.remove(); + }; + } + + _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); + } + } + + _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'].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); + } + + 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.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 layoutBtns = {}; + for (const l of [{ id: 'balanced', label: 'Balanced' }, { id: 'right', label: 'Right' }, { id: 'left', label: 'Left' }]) { + const btn = layoutGroup.createEl('button', { + cls: 'lmm-btn' + (l.id === layout ? ' lmm-btn-active' : ''), + text: l.label + }); + btn.onclick = () => { + if (overlay._lmmLayout === l.id) return; + overlay._lmmLayout = l.id; + Object.values(layoutBtns).forEach((b) => b.classList.remove('lmm-btn-active')); + btn.classList.add('lmm-btn-active'); + this._renderTreeIntoCanvas(overlay, false); + this._persistFrontmatterValue(file, 'mindmap-layout', l.id); + }; + layoutBtns[l.id] = btn; + } + + 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 resetBtn = zoomGroup.createEl('button', { cls: 'lmm-btn', text: '1:1' }); + + const right = toolbar.createDiv({ cls: 'lmm-toolbar-group lmm-toolbar-right' }); + const editBtn = right.createEl('button', { cls: 'lmm-btn lmm-btn-ghost', text: 'Edit Source' }); + editBtn.onclick = () => { + overlay.classList.add('lmm-hidden'); + this._showRestoreFab(view, overlay); + }; + + if (!treeInfo.tree) { + overlay.createDiv({ cls: 'lmm-empty' }).innerHTML = + '
🧠
Add headings (e.g. # Title, ## Branch) 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); + resetBtn.onclick = () => this._resetTransform(canvas, inner); + + 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); + 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'); + el.style.setProperty('--lmm-color', node.color); + el.tabIndex = 0; + el.textContent = node.text || PLACEHOLDER; + node._el = el; + this._attachNodeHandlers(el, node, overlay); + 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(); + }); + el.addEventListener('click', (e) => { + e.stopPropagation(); + if (el.isContentEditable) return; + this._selectNode(overlay, node, true); + }); + el.addEventListener('dblclick', (e) => { + e.stopPropagation(); + e.preventDefault(); + this._startEdit(overlay, node); + }); + el.addEventListener('keydown', (e) => { + if (el.isContentEditable) { + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { + e.preventDefault(); + const text = el.textContent; + this._exitEditMode(overlay, node); + this._updateNodeText(node, text); + this._addSibling(overlay, node, true); + } 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(); + this._addSibling(overlay, node, true); + } else if (e.key === 'Tab') { + e.preventDefault(); + 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); + } + } + }); + } + + _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; + 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.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.text; + this._exitEditMode(overlay, node); + this._updateNodeText(node, text); + if (node.text !== had) this._persistAndRelayout(overlay); + }; + el.addEventListener('blur', onBlur); + overlay._lmmEditingBlur = onBlur; + } + + _exitEditMode(overlay, node) { + const el = node._el; + if (!el) return; + if (overlay._lmmEditingBlur) { + el.removeEventListener('blur', overlay._lmmEditingBlur); + overlay._lmmEditingBlur = null; + } + el.contentEditable = 'false'; + el.classList.remove('lmm-editing'); + if (overlay._lmmEditingNode === node) overlay._lmmEditingNode = null; + } + + _updateNodeText(node, newText) { + const text = (newText || '').replace(/\s+/g, ' ').trim(); + if (!text) return; + if (text !== node.text) { + node.text = text; + node.dirty = true; + if (node._el) node._el.textContent = text; + } + } + + _cancelEdit(overlay, node) { + this._exitEditMode(overlay, node); + if (node._el) node._el.textContent = node.text || PLACEHOLDER; + } + + _newNode(text) { + return { + text: text || PLACEHOLDER, + rawText: text || PLACEHOLDER, + children: [], + parent: null, + bodyRaw: '', + dirty: true, + isNew: true, + 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); + } + + _deleteNode(overlay, node) { + const treeInfo = overlay._lmmTreeInfo; + if (!node) return; + if (node.isVirtual) return; + if (!node.parent) { + new obsidian.Notice('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); + + try { + await this.app.vault.modify(file, newContent); + } catch (e) { + 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; + } + + // ──────────────────────────────────────────────────────────────── + // Layout & geometry. + // ──────────────────────────────────────────────────────────────── + + _measureNodes(node) { + const r = node._el.getBoundingClientRect(); + node.width = Math.max(r.width, 40); + node.height = Math.max(r.height, 24); + for (const c of node.children) this._measureNodes(c); + } + + _computeSubtreeHeight(node) { + if (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) { + if (layout === 'right') { + this._computeSubtreeHeight(root); + this._placeRight(root, 0, 0); + } else if (layout === 'left') { + this._computeSubtreeHeight(root); + this._placeLeft(root, 0, 0); + } else { + this._layoutBalanced(root); + } + } + + _placeRight(node, x, yCenter) { + node.x = x; + node.y = yCenter - node.height / 2; + if (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.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) { + 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); + const ranked = children.map((c, i) => ({ c, i })).sort((a, b) => b.c._sth - a.c._sth); + const right = []; + const 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)); + + 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); + } + + _applyNodePositions(node) { + if (node._el) { + node._el.style.left = node.x + 'px'; + node._el.style.top = node.y + 'px'; + } + 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); + for (const c of node.children) this._computeBounds(c, b); + return b; + } + + _shiftTree(node, dx, dy) { + node.x += dx; + node.y += dy; + for (const c of node.children) this._shiftTree(c, dx, dy); + } + + _drawConnections(node, svg, layout, lineId) { + const style = LINE_STYLES[lineId] || LINE_STYLES[DEFAULT_LINE]; + for (const child of node.children) { + let parentLeft; + if (layout === 'balanced') parentLeft = child._side === 'right'; + else if (layout === 'left') parentLeft = false; + else parentLeft = true; + const startX = parentLeft ? node.x + node.width : node.x; + const startY = node.y + node.height / 2; + const endX = parentLeft ? child.x : child.x + child.width; + const 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') { + const midX = startX + (endX - startX) / 2; + d = `M ${startX} ${startY} L ${midX} ${startY} L ${midX} ${endY} L ${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); + this.register(() => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }); + 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); + } +} + +module.exports = LightMindMapPlugin; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..d0f3936 --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "obsidian-light-mindmap", + "name": "Light MindMap", + "version": "1.0.0", + "minAppVersion": "1.4.0", + "description": "Auto-render markdown headings as a colorful mind map on files with frontmatter `type: mindmap`. Per-file layout (balanced / right / left), theme (6 palettes), connection line (smooth / straight / right-angle, solid or dashed), and node shape (rounded / square / borderless / pill) — all persisted to frontmatter (`mindmap-layout`, `mindmap-theme`, `mindmap-line`, `mindmap-node`) and restored on reopen.", + "author": "ninglg", + "authorUrl": "https://github.com/ninglg", + "isDesktopOnly": false +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..bc5704a --- /dev/null +++ b/styles.css @@ -0,0 +1,418 @@ +/* ============================================================ + Light MindMap — styles + ------------------------------------------------------------ + Overlay anchored inside MarkdownView.contentEl. The view's + content container is normally positioned, so inset:0 covers + both source and reading panes. + ============================================================ */ + +.workspace-leaf-content[data-type="markdown"] .view-content { + position: relative; +} + +/* Hide the underlying source/reading panes when overlay is visible so + theme-provided translucency can never leak source text through. */ +.workspace-leaf-content[data-type="markdown"] .view-content:has(> .lmm-overlay:not(.lmm-hidden)) > .markdown-source-view, +.workspace-leaf-content[data-type="markdown"] .view-content:has(> .lmm-overlay:not(.lmm-hidden)) > .markdown-reading-view { + visibility: hidden; +} + +.lmm-overlay { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + flex-direction: column; + overflow: hidden; + background-color: #ffffff; + background-color: var(--lmm-theme-bg, var(--background-primary)); + background-image: + radial-gradient(1200px 600px at 15% 0%, var(--lmm-theme-tint-1, rgba(99, 102, 241, 0.08)), transparent 60%), + radial-gradient(900px 500px at 100% 100%, var(--lmm-theme-tint-2, rgba(236, 72, 153, 0.08)), transparent 60%); + font-family: var(--font-interface); + color: var(--lmm-theme-fg, var(--text-normal)); + isolation: isolate; +} + +.theme-dark .lmm-overlay { + background-color: #1e1e1e; + background-color: var(--lmm-theme-bg, var(--background-primary)); +} + +.lmm-overlay.lmm-hidden { + display: none; +} + +/* ---------- Toolbar ---------- */ + +.lmm-toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 8px 14px; + background: var(--background-secondary); + border-bottom: 1px solid var(--background-modifier-border); + align-items: center; + flex-shrink: 0; + backdrop-filter: blur(6px); +} + +.lmm-toolbar-group { + display: flex; + gap: 6px; + align-items: center; +} + +.lmm-toolbar-group + .lmm-toolbar-group:not(.lmm-toolbar-right) { + border-left: 1px solid var(--background-modifier-border); + padding-left: 10px; +} + +.lmm-toolbar-right { + margin-left: auto; +} + +.lmm-toolbar-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text-muted); + margin-right: 4px; + font-weight: 600; +} + +.lmm-btn { + background: var(--background-primary); + color: var(--text-normal); + border: 1px solid var(--background-modifier-border); + padding: 5px 12px; + border-radius: 8px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + line-height: 1; + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease, color 0.12s ease, border-color 0.12s ease; +} + +.lmm-btn:hover { + background: var(--background-modifier-hover); + transform: translateY(-1px); +} + +.lmm-btn:active { + transform: translateY(0); +} + +.lmm-btn-active { + background: linear-gradient(135deg, #6366F1, #8B5CF6); + color: #fff; + border-color: transparent; + box-shadow: 0 3px 10px rgba(99, 102, 241, 0.35); +} + +.lmm-btn-active:hover { + background: linear-gradient(135deg, #5b5cf0, #7c4cf0); + color: #fff; +} + +.lmm-btn-ghost { + background: transparent; +} + +.lmm-select { + background: var(--background-primary); + color: var(--text-normal); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + padding: 4px 8px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + line-height: 1.2; +} + +.lmm-select:focus { + outline: 2px solid var(--lmm-theme-root-accent, #6366F1); + outline-offset: 1px; +} + +/* ---------- Canvas ---------- */ + +.lmm-canvas { + flex: 1; + position: relative; + overflow: hidden; + cursor: grab; + background-color: var(--lmm-theme-bg, var(--background-primary)); +} + +.lmm-canvas.lmm-dragging { + cursor: grabbing; +} + +.lmm-inner { + position: absolute; + left: 0; + top: 0; + transform-origin: 0 0; + will-change: transform; +} + +.lmm-svg { + position: absolute; + left: 0; + top: 0; + pointer-events: none; + overflow: visible; +} + +.lmm-conn { + opacity: 0.9; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.10)); + transition: opacity 0.15s ease, stroke-width 0.15s ease; +} + +.lmm-nodes { + position: relative; + width: 100%; + height: 100%; +} + +/* ---------- Nodes ---------- */ + +.lmm-node { + position: absolute; + padding: 8px 14px; + background: var(--lmm-theme-bg, var(--background-primary)); + color: var(--lmm-theme-fg, var(--text-normal)); + border: 2px solid var(--lmm-color, #6366F1); + border-radius: 10px; + font-size: 13px; + line-height: 1.4; + white-space: nowrap; + cursor: default; + user-select: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.10); + transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease; + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; +} + +.lmm-node:hover { + transform: scale(1.04); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18); + z-index: 5; + filter: brightness(1.05); +} + +.lmm-node:focus { + outline: none; +} + +/* Selection ring (focused node, not editing) */ +.lmm-node.lmm-selected { + outline: 2px dashed var(--lmm-color, #6366F1); + outline-offset: 4px; + z-index: 6; +} + +.lmm-node.lmm-selected.lmm-node-root { + outline-color: var(--lmm-theme-root-accent, #8B5CF6); +} + +/* Edit mode — caret visible, text wraps, no ellipsis truncation */ +.lmm-node.lmm-editing { + white-space: normal; + overflow: visible; + text-overflow: clip; + cursor: text; + user-select: text; + -webkit-user-select: text; + outline: 2px solid #6366F1; + outline-offset: 3px; + z-index: 8; + min-width: 80px; + caret-color: currentColor; + transform: none !important; +} + +.lmm-node.lmm-editing.lmm-node-root, +.lmm-node.lmm-editing.lmm-node-d1 { + caret-color: #fff; +} + +/* Root: bold gradient capsule with glow */ +.lmm-node-root, +.lmm-node-d0 { + background: var(--lmm-theme-root-grad, linear-gradient(135deg, #6366F1, #8B5CF6 60%, #EC4899)); + color: #fff; + border: none; + font-size: 18px; + font-weight: 700; + padding: 14px 26px; + border-radius: 18px; + box-shadow: + 0 8px 28px rgba(99, 102, 241, 0.42), + 0 2px 6px rgba(139, 92, 246, 0.25), + inset 0 1px 0 rgba(255, 255, 255, 0.2); + letter-spacing: 0.3px; +} + +/* Level 1: solid colored pill, white text */ +.lmm-node-d1 { + background: var(--lmm-color); + color: #fff; + border: none; + font-weight: 600; + font-size: 15px; + padding: 10px 18px; + border-radius: 12px; + box-shadow: + 0 5px 16px rgba(0, 0, 0, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + +/* Level 2: tinted soft fill with branch-color border */ +.lmm-node-d2 { + background: color-mix(in srgb, var(--lmm-color) 16%, var(--lmm-theme-bg, var(--background-primary))); + border-color: var(--lmm-color); + border-width: 2px; + color: var(--lmm-theme-fg, var(--text-normal)); + font-weight: 600; + font-size: 13.5px; + padding: 7px 14px; +} + +/* Level 3+: lighter card with thin colored border */ +.lmm-node-d3, +.lmm-node-d4, +.lmm-node-d5, +.lmm-node-d6 { + background: var(--lmm-theme-bg, var(--background-primary)); + border-color: var(--lmm-color); + border-width: 1.5px; + font-weight: 500; + font-size: 12.5px; + padding: 6px 12px; + color: var(--lmm-theme-fg, var(--text-normal)); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} + +/* Theme-dark refinements */ +.theme-dark .lmm-node { + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45); +} +.theme-dark .lmm-node-d2 { + background: color-mix(in srgb, var(--lmm-color) 22%, var(--lmm-theme-bg, var(--background-primary))); +} + +/* Midnight theme always uses dark-style mixing regardless of obsidian theme */ +.lmm-overlay.lmm-theme-midnight .lmm-node-d2 { + background: color-mix(in srgb, var(--lmm-color) 22%, var(--lmm-theme-bg)); +} +.lmm-overlay.lmm-theme-midnight .lmm-node { + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45); +} + +/* Virtual root (used when multiple top-level headings) */ +.lmm-node-virtual { + background: linear-gradient(135deg, #475569, #1F2937) !important; + box-shadow: 0 6px 20px rgba(15, 23, 42, 0.35) !important; +} + +/* ---------- Node shape variants (mindmap-node) ---------- */ + +/* Square: straight corners on all node levels */ +.lmm-overlay.lmm-node-style-square .lmm-node, +.lmm-overlay.lmm-node-style-square .lmm-node-root, +.lmm-overlay.lmm-node-style-square .lmm-node-d0, +.lmm-overlay.lmm-node-style-square .lmm-node-d1 { + border-radius: 0; +} + +/* Borderless: drop outlines, lean on background/shadow for separation */ +.lmm-overlay.lmm-node-style-borderless .lmm-node { + border-color: transparent; + border-width: 0; + padding: 6px 12px; +} +.lmm-overlay.lmm-node-style-borderless .lmm-node-d2 { + background: transparent; +} +.lmm-overlay.lmm-node-style-borderless .lmm-node-d3, +.lmm-overlay.lmm-node-style-borderless .lmm-node-d4, +.lmm-overlay.lmm-node-style-borderless .lmm-node-d5, +.lmm-overlay.lmm-node-style-borderless .lmm-node-d6 { + background: transparent; + box-shadow: none; +} + +/* Pill: fully rounded capsule */ +.lmm-overlay.lmm-node-style-circle .lmm-node, +.lmm-overlay.lmm-node-style-circle .lmm-node-root, +.lmm-overlay.lmm-node-style-circle .lmm-node-d0, +.lmm-overlay.lmm-node-style-circle .lmm-node-d1 { + border-radius: 999px; +} + +/* ---------- Empty state ---------- */ + +.lmm-empty { + margin: auto; + padding: 60px 40px; + text-align: center; + color: var(--text-muted); + font-size: 14px; + line-height: 1.6; +} + +.lmm-empty-icon { + font-size: 56px; + margin-bottom: 14px; + filter: drop-shadow(0 4px 10px rgba(99, 102, 241, 0.35)); +} + +.lmm-empty code { + background: var(--background-secondary); + padding: 1px 6px; + border-radius: 4px; + font-size: 12.5px; +} + +/* ---------- Restore FAB (shown when overlay is hidden) ---------- */ + +.lmm-fab { + position: absolute; + bottom: 24px; + right: 24px; + z-index: 30; + padding: 10px 18px; + background: linear-gradient(135deg, #6366F1, #8B5CF6 60%, #EC4899); + color: #fff; + border: none; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.3px; + cursor: pointer; + box-shadow: + 0 8px 24px rgba(99, 102, 241, 0.42), + 0 2px 6px rgba(139, 92, 246, 0.25), + inset 0 1px 0 rgba(255, 255, 255, 0.2); + transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease; +} + +.lmm-fab:hover { + transform: translateY(-2px); + filter: brightness(1.05); + box-shadow: + 0 12px 30px rgba(99, 102, 241, 0.5), + 0 3px 8px rgba(139, 92, 246, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.25); +} + +.lmm-fab:active { + transform: translateY(0); +}