mirror of
https://github.com/ninglg/light-mindmap.git
synced 2026-07-22 17:10:29 +00:00
Compare commits
24 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d46f4dc0a | ||
|
|
c13a4a57cb | ||
|
|
1f51a27714 | ||
|
|
bad1f87ee0 | ||
|
|
15a38b0ea5 | ||
|
|
e785f29483 | ||
|
|
48460b593f | ||
|
|
fbdcc16e77 | ||
|
|
05587234ac | ||
|
|
10b7d53909 | ||
|
|
8ee63cd4c7 | ||
|
|
15a7678263 | ||
|
|
123718ce52 | ||
|
|
7b39ad5c2b | ||
|
|
082f23c51c | ||
|
|
50b854040a | ||
|
|
66e432e5b7 | ||
|
|
6a74c037c4 | ||
|
|
1ddd0ec595 | ||
|
|
baf037b8f2 | ||
|
|
3b19dd6f54 | ||
|
|
57d7dc2c87 | ||
|
|
84bbce1751 | ||
|
|
76dd96bf4c |
4 changed files with 821 additions and 41 deletions
28
README.md
28
README.md
|
|
@ -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
|
||||

|
||||
|
|
@ -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
694
main.js
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
136
styles.css
136
styles.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue