Compare commits

...

24 commits
1.3.0 ... main

Author SHA1 Message Date
ninglg
7d46f4dc0a release: 1.5.0
- feat: add node drag functionality (change parent and reorder)
- fix: optimize node editing experience
2026-07-10 15:50:39 +08:00
ninglg
c13a4a57cb release: 1.4.2
- Add drag-and-drop support for nodes
- Change node hierarchy by dragging to another node
- Reorder sibling nodes by dragging to top/bottom of another node
2026-07-09 14:48:54 +08:00
ninglg
1f51a27714 fix: remove animations causing layout jitter during drag 2026-07-09 14:36:42 +08:00
ninglg
bad1f87ee0 feat: add plugin settings tab for Obsidian settings sidebar 2026-07-09 14:13:11 +08:00
ninglg
15a38b0ea5 fix: expand drop detection zones for easier before/after triggering 2026-07-09 13:55:35 +08:00
ninglg
e785f29483 fix: separate visual feedback for child mode (circle) vs before/after mode (line) 2026-07-09 13:45:34 +08:00
ninglg
48460b593f fix: improve drop target detection with expanded hit areas for before/after 2026-07-09 13:38:57 +08:00
ninglg
fbdcc16e77 style: enhance drag-drop visual feedback with better indicators and animations 2026-07-09 13:31:57 +08:00
ninglg
05587234ac fix: improve drop indicators visibility and expand detection zones 2026-07-09 11:47:57 +08:00
ninglg
10b7d53909 fix: correct sibling reorder index calculation in _moveNode 2026-07-09 11:41:39 +08:00
ninglg
8ee63cd4c7 fix: prevent node selection during drag 2026-07-09 11:24:27 +08:00
ninglg
15a7678263 feat: add window event listeners for drag 2026-07-09 11:20:45 +08:00
ninglg
123718ce52 feat: add drag event handlers and core functions 2026-07-09 11:12:42 +08:00
ninglg
7b39ad5c2b feat: add drag helper functions 2026-07-09 11:07:11 +08:00
ninglg
082f23c51c feat: add CSS styles for drag and drop feedback 2026-07-09 11:03:05 +08:00
ninglg
50b854040a feat: add drag state variables 2026-07-09 10:57:18 +08:00
ninglg
66e432e5b7 fix: mention popup positioning in transformed containers
Append wiki-link mention popup to document.body instead of overlay
to prevent position offset when ancestor elements have CSS transforms.
2026-06-17 10:04:33 +08:00
ninglg
6a74c037c4 update description 2026-06-16 16:33:50 +08:00
ninglg
1ddd0ec595 chore: remove .gitignore from repository 2026-06-15 18:37:56 +08:00
ninglg
baf037b8f2 chore: remove CLAUDE.md from git tracking 2026-06-15 18:33:57 +08:00
ninglg
3b19dd6f54 docs: remove Breaking Changes section from release notes template 2026-06-15 18:31:40 +08:00
ninglg
57d7dc2c87 docs: add bilingual release notes requirement to CLAUDE.md 2026-06-15 18:30:43 +08:00
ninglg
84bbce1751 Release 1.4.0: add wiki-link support and mention autocomplete 2026-06-15 18:27:17 +08:00
ninglg
76dd96bf4c feature: mindmap node support bidirectional link 2026-06-15 18:24:05 +08:00
4 changed files with 821 additions and 41 deletions

View file

@ -2,7 +2,7 @@
**English** | [简体中文](#License)
Auto-renders markdown headings as a colorful, interactive mindmap — no extra syntax required.
Feature-rich mindmap plugin — multiple layouts, themes, node shapes & line styles, wikilink support, node collapse, PNG export — all from plain markdown headings, no custom syntax needed.
## Preview
![Light MindMap Preview](preview.png)
@ -120,6 +120,8 @@ Nodes can be edited directly on the canvas — changes are written back to the m
| Add child (without editing) | Select node, press **Tab** |
| Delete node | Select node, press **Delete** or **Backspace** |
| Collapse / expand node | Select node, press **Space** |
| Drag node (change parent) | Drag node to center of another node |
| Drag node (reorder) | Drag node to top/bottom of another node |
| Right-click context menu | Right-click on node |
- The root node cannot be deleted.
@ -160,9 +162,15 @@ All per-file display preferences are written to frontmatter and restored on next
- Command palette: **Toggle mindmap / source view**
- Command palette: **Cycle mindmap layout (balanced / right / left / tree / radial)**
### External Links
### Links & Wiki-links
Markdown links in heading text (`[text](url)`) are rendered as clickable links directly on the mindmap canvas. Clicking a link opens it in the default browser. During node editing, links are shown as plain text for easy modification.
Markdown links (`[text](url)`) and wiki-links (`[[Note]]`, `[[Note|Alias]]`) in heading text are rendered as clickable links on the mindmap canvas.
- **External links** (`http://...`) open in the default browser
- **Internal links** (relative paths like `./note.md`) open in a new Obsidian tab
- **Wiki-links** (`[[Note]]`) open the target note in a new Obsidian tab — supports shortest paths, relative paths, and aliases natively
During node editing, typing `[[` triggers an autocomplete popup that searches all vault notes. Use **↑↓** to navigate, **Enter** to insert, **Escape** to dismiss. Links are shown as plain text while editing for easy modification.
### Export PNG
@ -208,7 +216,7 @@ MIT
[English](#light-mindmap) | **简体中文**
自动将 Markdown 标题渲染为色彩丰富、可交互的思维导图——无需额外语法。
功能丰富的思维导图插件——多种布局、主题、节点形状与连线样式支持双向链接、节点折叠、PNG 导出——基于纯 Markdown 标题渲染,无需任何自定义语法。
## 预览
@ -327,6 +335,8 @@ type: mindmap
| 添加子节点(无需编辑) | 选中节点后按 **Tab** |
| 删除节点 | 选中节点后按 **Delete****Backspace**|
| 折叠/展开节点 | 选中节点后按 **Space** |
| 拖拽节点(改变父节点) | 拖拽节点到另一个节点的中心区域 |
| 拖拽节点(调整顺序) | 拖拽节点到另一个节点的上方或下方区域 |
| 右键上下文菜单 | 右键点击节点 |
- 根节点不可删除。
@ -367,9 +377,15 @@ type: mindmap
- 命令面板:**Toggle mindmap / source view**
- 命令面板:**Cycle mindmap layout (balanced / right / left / tree / radial)**
### 外部链接
### 链接与双向链接
标题中的 Markdown 链接(`[文本](url)`)会直接在思维导图画布上渲染为可点击的链接。点击链接会在默认浏览器中打开。编辑节点时,链接会显示为纯文本以便修改。
标题中的 Markdown 链接(`[文本](url)`)和 Wiki 链接(`[[笔记]]`、`[[笔记|别名]]`)会直接在思维导图画布上渲染为可点击的链接。
- **外部链接**`http://...`)在默认浏览器中打开
- **内部链接**(相对路径如 `./note.md`)在新的 Obsidian 标签页中打开
- **Wiki 链接**`[[笔记]]`)在新的 Obsidian 标签页中打开目标笔记——原生支持最短路径、相对路径和别名
编辑节点时,输入 `[[` 会触发自动补全弹窗,搜索所有 vault 笔记。使用 **↑↓** 导航、**Enter** 插入、**Escape** 关闭。编辑模式下链接显示为纯文本以便修改。
### 导出 PNG

694
main.js
View file

@ -89,9 +89,100 @@ const ROOT_HGAP = 90;
const PAD = 60;
const PLACEHOLDER = 'New Title';
class LightMindMapSettingTab extends obsidian.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Light Mindmap Settings' });
new obsidian.Setting(containerEl)
.setName('Default theme')
.setDesc('Choose the default theme for new mindmaps')
.addDropdown((dropdown) =>
dropdown
.addOption('vibrant', 'Vibrant')
.addOption('classic', 'Classic')
.addOption('fresh', 'Fresh')
.addOption('ocean', 'Ocean')
.addOption('sunset', 'Sunset')
.addOption('midnight', 'Midnight')
.addOption('slate', 'Slate')
.setValue(this.plugin.settings?.defaultTheme || 'vibrant')
.onChange(async (value) => {
this.plugin.settings.defaultTheme = value;
await this.plugin.saveSettings();
})
);
new obsidian.Setting(containerEl)
.setName('Default layout')
.setDesc('Choose the default layout for new mindmaps')
.addDropdown((dropdown) =>
dropdown
.addOption('balanced', 'Balanced')
.addOption('right', 'Right')
.addOption('left', 'Left')
.addOption('tree', 'Tree')
.addOption('radial', 'Radial')
.setValue(this.plugin.settings?.defaultLayout || 'balanced')
.onChange(async (value) => {
this.plugin.settings.defaultLayout = value;
await this.plugin.saveSettings();
})
);
new obsidian.Setting(containerEl)
.setName('Default line style')
.setDesc('Choose the default line style for new mindmaps')
.addDropdown((dropdown) =>
dropdown
.addOption('curve', 'Smooth')
.addOption('straight', 'Straight')
.addOption('polyline', 'Right Angle')
.addOption('polyline-dashed', 'Right Angle Dashed')
.addOption('curve-dashed', 'Smooth Dashed')
.setValue(this.plugin.settings?.defaultLine || 'curve')
.onChange(async (value) => {
this.plugin.settings.defaultLine = value;
await this.plugin.saveSettings();
})
);
new obsidian.Setting(containerEl)
.setName('Default node style')
.setDesc('Choose the default node style for new mindmaps')
.addDropdown((dropdown) =>
dropdown
.addOption('rounded', 'Rounded')
.addOption('square', 'Square')
.addOption('borderless', 'Borderless')
.addOption('circle', 'Pill')
.addOption('doodle', 'Doodle')
.setValue(this.plugin.settings?.defaultNodeStyle || 'rounded')
.onChange(async (value) => {
this.plugin.settings.defaultNodeStyle = value;
await this.plugin.saveSettings();
})
);
containerEl.createEl('h3', { text: 'About' });
containerEl.createEl('p', { text: 'Version: ' + this.plugin.manifest.version });
containerEl.createEl('p', { text: 'Render Markdown headings as interactive mindmaps.' });
}
}
class LightMindMapPlugin extends obsidian.Plugin {
async onload() {
await this.loadSettings();
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()));
@ -161,6 +252,9 @@ class LightMindMapPlugin extends obsidian.Plugin {
// Inject SVG filter for doodle node style
this._injectDoodleFilter();
// Register settings tab
this.addSettingTab(new LightMindMapSettingTab(this.app, this));
}
onunload() {
@ -173,6 +267,14 @@ class LightMindMapPlugin extends obsidian.Plugin {
if (doodleSvg) doodleSvg.closest('svg').remove();
}
async loadSettings() {
this.settings = Object.assign({}, { defaultTheme: 'vibrant', defaultLayout: 'balanced', defaultLine: 'curve', defaultNodeStyle: 'rounded' }, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
async _doScan() {
const leaves = this.app.workspace.getLeavesOfType('markdown');
for (const leaf of leaves) {
@ -260,26 +362,62 @@ class LightMindMapPlugin extends obsidian.Plugin {
_renderNodeContent(el, node, overlay) {
const raw = node.rawText || node.text || '';
if (!raw.includes('](')) {
if (!raw.includes('](') && !raw.includes('[[')) {
el.textContent = node.text || PLACEHOLDER;
return;
}
el.textContent = '';
const re = /\[([^\]]+)\]\(([^)]+)\)/g;
let last = 0;
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;
while ((m = re.exec(raw)) !== null) {
if (m.index > last) {
el.appendChild(document.createTextNode(raw.slice(last, m.index)));
}
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 = m[1];
a.href = m[2];
a.target = '_blank';
a.rel = 'noopener';
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 = m.index + m[0].length;
last = link.end;
}
if (last < raw.length) {
el.appendChild(document.createTextNode(raw.slice(last)));
@ -313,7 +451,7 @@ class LightMindMapPlugin extends obsidian.Plugin {
headings.push({
srcIdx: i,
level: m[1].length,
rawText,
rawText: collapsed ? rawText.replace(/^\*(?!\*)(.+)\*(?!\*)$/, '$1') : rawText,
text: this._stripInline(rawText),
children: [],
parent: null,
@ -581,6 +719,14 @@ class LightMindMapPlugin extends obsidian.Plugin {
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();
@ -790,11 +936,21 @@ class LightMindMapPlugin extends obsidian.Plugin {
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);
}, 120);
}
});
el.addEventListener('click', (e) => {
e.stopPropagation();
if (el.isContentEditable) return;
if (e.target.closest('.lmm-link')) return;
if (overlay._lmmDragging) return;
this._selectNode(overlay, node, true);
});
el.addEventListener('dblclick', (e) => {
@ -807,6 +963,7 @@ class LightMindMapPlugin extends obsidian.Plugin {
});
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;
@ -880,6 +1037,7 @@ class LightMindMapPlugin extends obsidian.Plugin {
el.contentEditable = 'true';
el.classList.add('lmm-editing');
el.spellcheck = false;
el.focus({ preventScroll: false });
const range = document.createRange();
range.selectNodeContents(el);
@ -899,15 +1057,24 @@ class LightMindMapPlugin extends obsidian.Plugin {
};
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;
@ -929,6 +1096,145 @@ class LightMindMapPlugin extends obsidian.Plugin {
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;
@ -1159,6 +1465,256 @@ class LightMindMapPlugin extends obsidian.Plugin {
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;
// Only center 40% triggers child mode, rest is before/after
// Top 30% = before, Bottom 30% = after, Middle 40% = child
if (relativeY < height * 0.3) return 'before';
if (relativeY > height * 0.7) 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');
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 using expanded hit area
const targetNode = this._findDropTargetAtPoint(overlay, x, y);
// 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);
// Different visual feedback for child vs before/after
if (overlay._lmmDropPosition === 'child') {
// Show circle highlight for becoming a child
targetNode._el.classList.add('lmm-drop-target');
} else {
// Show horizontal line indicator for before/after (no circle)
const indicator = document.createElement('div');
indicator.className = overlay._lmmDropPosition === 'before'
? 'lmm-drop-before'
: 'lmm-drop-after';
targetNode._el.appendChild(indicator);
}
return;
}
}
overlay._lmmDropTarget = null;
overlay._lmmDropPosition = null;
}
_findDropTargetAtPoint(overlay, x, y) {
// First try direct hit
const elements = document.elementsFromPoint(x, y);
for (const el of elements) {
const nodeEl = el.closest('.lmm-node');
if (nodeEl) {
const node = this._findNodeByElement(overlay._lmmTreeInfo.tree, nodeEl);
if (node && !node.isVirtual) return node;
}
}
// If no direct hit, find nearest node with generous vertical expansion
const nodes = this._collectVisibleNodes(overlay._lmmTreeInfo.tree);
let bestNode = null;
let bestDist = Infinity;
for (const node of nodes) {
if (node.isVirtual || node === overlay._lmmDragNode) continue;
if (this._isDescendant(node, overlay._lmmDragNode)) continue;
const rect = node._el.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// Very generous vertical expansion for before/after detection
const expandedTop = rect.top - 60;
const expandedBottom = rect.bottom + 60;
// Check if point is within expanded vertical range
if (y >= expandedTop && y <= expandedBottom) {
// Calculate distance - heavily favor horizontal proximity
const distX = Math.abs(x - centerX);
const distY = Math.abs(y - centerY);
// Only consider if reasonably close horizontally
if (distX < rect.width * 1.5) {
const dist = distX + distY;
if (dist < bestDist) {
bestDist = dist;
bestNode = node;
}
}
}
}
return bestNode;
}
_collectVisibleNodes(node) {
const nodes = [node];
if (!node.collapsed) {
for (const child of node.children) {
nodes.push(...this._collectVisibleNodes(child));
}
}
return nodes;
}
_moveNode(overlay, sourceNode, targetNode, position) {
// 1. Calculate target index BEFORE removing source (important for same-parent reorder)
let targetParent, insertIndex;
if (position === 'child') {
// Moving to become a child of target
targetParent = targetNode;
insertIndex = -1; // Will be pushed to end
} else {
// Moving before/after target (same parent)
targetParent = targetNode.parent;
const targetIndex = targetParent.children.indexOf(targetNode);
insertIndex = position === 'before' ? targetIndex : targetIndex + 1;
// Adjust index if source is before target in same parent (removal shifts indices)
if (targetParent === sourceNode.parent) {
const sourceIndex = targetParent.children.indexOf(sourceNode);
if (sourceIndex < insertIndex) {
insertIndex--;
}
}
}
// 2. Remove from original parent
const sourceParent = sourceNode.parent;
const sourceIndex = sourceParent.children.indexOf(sourceNode);
sourceParent.children.splice(sourceIndex, 1);
// 3. 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 {
sourceNode.parent = targetParent;
targetParent.children.splice(insertIndex, 0, sourceNode);
}
// 4. Update node depths
this._updateNodeDepths(sourceNode);
// 5. 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.
// ────────────────────────────────────────────────────────────────
@ -1513,14 +2069,50 @@ class LightMindMapPlugin extends obsidian.Plugin {
this._applyTransform(inner, canvas._lmm);
};
const onUp = () => { dragging = false; canvas.classList.remove('lmm-dragging'); };
const onDragMove = (e) => {
if (!overlay._lmmDragging) {
if (overlay._lmmDragTimer) {
const dx = e.clientX - overlay._lmmDragStartX;
const dy = e.clientY - overlay._lmmDragStartY;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
clearTimeout(overlay._lmmDragTimer);
overlay._lmmDragTimer = null;
}
}
return;
}
this._updateDragClone(overlay, e.clientX, e.clientY);
this._detectDropTarget(overlay, e.clientX, e.clientY);
};
const onDragUp = (e) => {
if (overlay._lmmDragTimer) {
clearTimeout(overlay._lmmDragTimer);
overlay._lmmDragTimer = null;
}
if (!overlay._lmmDragging) return;
if (overlay._lmmDropTarget && overlay._lmmDropPosition) {
this._moveNode(overlay, overlay._lmmDragNode, overlay._lmmDropTarget, overlay._lmmDropPosition);
}
this._endDrag(overlay);
};
canvas.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
window.addEventListener('mousemove', onDragMove);
window.addEventListener('mouseup', onDragUp);
const prevCleanup = overlay._lmmCleanup;
overlay._lmmCleanup = () => {
if (prevCleanup) prevCleanup();
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
window.removeEventListener('mousemove', onDragMove);
window.removeEventListener('mouseup', onDragUp);
};
// ── Touch support ──
@ -2049,23 +2641,73 @@ class LightMindMapPlugin extends obsidian.Plugin {
async _saveWithDialog(file, arrayBuffer) {
const uint8 = new Uint8Array(arrayBuffer);
const defaultName = (file.basename || 'mindmap') + '.mindmap.png';
try {
const electron = require('electron');
const win = electron.remote.BrowserWindow.getFocusedWindow();
const result = await electron.remote.dialog.showSaveDialog(win, {
title: 'Export Mindmap as PNG',
defaultPath: defaultName,
filters: [{ name: 'PNG Image', extensions: ['png'] }]
});
if (result.canceled || !result.filePath) return false;
require('fs').writeFileSync(result.filePath, Buffer.from(arrayBuffer));
return true;
} catch (e) {
// Fallback: save directly into vault
if (this.app.isMobile) {
const filePath = (file.parent ? file.parent.path + '/' : '') + defaultName;
await this.app.vault.adapter.writeBinary(filePath, uint8);
new obsidian.Notice('Saved to vault: ' + filePath);
return true;
}
try {
if (window.showSaveFilePicker) {
const handle = await window.showSaveFilePicker({
suggestedName: defaultName,
types: [{ description: 'PNG Image', accept: { 'image/png': ['.png'] } }]
});
const writable = await handle.createWritable();
await writable.write(arrayBuffer);
await writable.close();
return true;
}
} catch (e) {
if (e.name === 'AbortError' || e.name === 'NotAllowedError') {
return false;
}
}
try {
const electron = require('electron');
const { dialog } = electron;
if (dialog && dialog.showSaveDialog) {
const result = await dialog.showSaveDialog({
title: 'Export Mindmap as PNG',
defaultPath: defaultName,
filters: [{ name: 'PNG Image', extensions: ['png'] }]
});
if (result && result.canceled) {
return false;
}
if (result && result.filePath) {
require('fs').writeFileSync(result.filePath, Buffer.from(arrayBuffer));
return true;
}
}
} catch (e) {}
try {
const electron = require('electron');
if (electron.remote && electron.remote.dialog) {
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 && result.canceled) {
return false;
}
if (result && result.filePath) {
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);
new obsidian.Notice('Saved to vault: ' + filePath);
return true;
}
async _exportPNG(overlay) {

View file

@ -1,9 +1,9 @@
{
"id": "light-mindmap",
"name": "Light Mindmap",
"version": "1.3.0",
"version": "1.5.0",
"minAppVersion": "1.4.0",
"description": "Auto-renders markdown headings as a colorful, interactive mindmap — no extra syntax required.",
"description": "Feature-rich mindmap plugin — multiple layouts, themes, node shapes & line styles, wikilink support, node collapse, PNG export — all from plain markdown headings, no custom syntax needed.",
"author": "Light Ning",
"authorUrl": "https://github.com/ninglg",
"isDesktopOnly": false

View file

@ -290,17 +290,56 @@
cursor: text;
user-select: text;
-webkit-user-select: text;
outline: 2px solid #6366F1;
outline-offset: 3px;
outline: none;
outline-offset: 0;
z-index: 8;
min-width: 80px;
min-width: 120px;
caret-color: currentColor;
transform: none !important;
transform: scale(1.03) !important;
box-shadow:
0 0 0 2px var(--lmm-color, #6366F1),
0 0 16px var(--lmm-color, #6366F1),
0 0 28px var(--lmm-color, #6366F1) !important;
border-width: 2px !important;
border-color: var(--lmm-color, #6366F1) !important;
opacity: 0.95;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.lmm-node.lmm-editing.lmm-node-root,
.lmm-node.lmm-editing.lmm-node-d1 {
caret-color: #fff;
/* Mention autocomplete popup */
.lmm-mention-popup {
position: fixed;
z-index: 100;
min-width: 180px;
max-width: 320px;
max-height: 360px;
overflow-y: auto;
background: var(--background-primary, #fff);
border: 1px solid var(--background-modifier-border, rgba(0,0,0,0.1));
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.18);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: 4px 0;
}
.lmm-mention-item {
padding: 6px 12px;
cursor: pointer;
font-size: 13px;
color: var(--text-normal, #333);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lmm-mention-item:hover,
.lmm-mention-item.lmm-mention-active {
background: var(--background-modifier-hover, rgba(0,0,0,0.06));
}
.lmm-mention-item.lmm-mention-active {
background: var(--interactive-accent, #6366F1);
color: #fff;
}
/* Root: bold gradient capsule with glow */
@ -630,3 +669,86 @@
margin: 4px 0;
border-top: 1px solid var(--background-modifier-border);
}
/* ---------- Node Drag and Drop ---------- */
/* Dragging source node - subtle fade to show it's being moved */
.lmm-dragging-source {
opacity: 0.35 !important;
pointer-events: none;
transform: none !important;
filter: grayscale(0.3);
transition: opacity 0.15s ease, filter 0.15s ease;
}
/* Clone following mouse - prominent floating card */
.lmm-drag-clone {
position: fixed;
pointer-events: none;
opacity: 0.95;
z-index: 1000;
transform: translate(-50%, -50%) scale(1.08);
box-shadow:
0 12px 32px rgba(0, 0, 0, 0.28),
0 4px 12px rgba(0, 0, 0, 0.15),
0 0 0 2px rgba(99, 102, 241, 0.4);
border-radius: 12px;
transition: box-shadow 0.15s ease;
}
/* Drop target highlight - for child mode only */
.lmm-drop-target {
outline: 2.5px solid var(--lmm-theme-root-accent, #6366F1);
outline-offset: 5px;
box-shadow: 0 0 12px rgba(99, 102, 241, 0.35);
}
/* Drop position indicators - horizontal lines for before/after mode */
.lmm-drop-before,
.lmm-drop-after {
position: absolute;
left: -30px;
right: -30px;
height: 4px;
background: var(--lmm-theme-root-accent, #6366F1);
border-radius: 2px;
pointer-events: none;
z-index: 100;
box-shadow: 0 0 10px rgba(99, 102, 241, 0.6);
}
/* Arrow indicators on line ends */
.lmm-drop-before::before,
.lmm-drop-after::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
border: 6px solid transparent;
border-right-color: var(--lmm-theme-root-accent, #6366F1);
}
.lmm-drop-before::after,
.lmm-drop-after::after {
content: '';
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
border: 6px solid transparent;
border-left-color: var(--lmm-theme-root-accent, #6366F1);
}
.lmm-drop-before {
top: -20px;
}
.lmm-drop-after {
bottom: -20px;
}
/* Canvas dragging state */
.lmm-canvas.lmm-node-dragging {
cursor: grabbing !important;
}