feat: support clickable external links in mindmap nodes

Markdown links in heading text are now rendered as clickable links
on the canvas. Bump version to 1.2.2.
This commit is contained in:
ninglg 2026-06-03 18:37:12 +08:00
parent 4fec5776de
commit 4bed360341
5 changed files with 73 additions and 12 deletions

View file

@ -143,6 +143,10 @@ 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)**
### External 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.
### Export PNG
Click the **Export PNG** button in the toolbar to save the current mindmap as a high-resolution PNG image (2x scale). A system file dialog will let you choose the save location.

View file

@ -144,6 +144,10 @@ type: mindmap
- 命令面板:**Toggle mindmap / source view**
- 命令面板:**Cycle mindmap layout (balanced / right / left)**
### 外部链接
标题中的 Markdown 链接(`[文本](url)`)会直接在思维导图画布上渲染为可点击的链接。点击链接会在默认浏览器中打开。编辑节点时,链接会显示为纯文本以便修改。
### 导出 PNG
点击工具栏中的 **Export PNG** 按钮,将当前思维导图保存为高清 PNG 图片2 倍分辨率)。系统文件对话框允许你选择保存位置。

54
main.js
View file

@ -235,6 +235,37 @@ class LightMindMapPlugin extends obsidian.Plugin {
.trim();
}
_renderNodeContent(el, node, overlay) {
const raw = node.rawText || node.text || '';
if (!raw.includes('](')) {
el.textContent = node.text || PLACEHOLDER;
return;
}
el.textContent = '';
const re = /\[([^\]]+)\]\(([^)]+)\)/g;
let last = 0;
let m;
while ((m = re.exec(raw)) !== null) {
if (m.index > last) {
el.appendChild(document.createTextNode(raw.slice(last, m.index)));
}
const a = document.createElement('a');
a.className = 'lmm-link';
a.textContent = m[1];
a.href = m[2];
a.target = '_blank';
a.rel = 'noopener';
el.appendChild(a);
last = m.index + m[0].length;
}
if (last < raw.length) {
el.appendChild(document.createTextNode(raw.slice(last)));
}
if (el.childNodes.length === 0) {
el.textContent = node.text || PLACEHOLDER;
}
}
_parseStructured(content) {
const { frontmatterRaw, body } = this._splitFrontmatter(content);
const lines = body.split('\n');
@ -356,9 +387,7 @@ class LightMindMapPlugin extends obsidian.Plugin {
_serializeNode(node, level) {
const cap = Math.min(6, Math.max(1, level));
let text = (node.dirty || node.isNew)
? (node.text || PLACEHOLDER)
: (node.rawText || node.text || PLACEHOLDER);
let text = node.rawText || node.text || PLACEHOLDER;
if (node.collapsed) {
const inner = text.replace(/^\*(?!\*)(.+)\*(?!\*)$/, '$1');
text = '*' + inner + '*';
@ -713,13 +742,13 @@ class LightMindMapPlugin extends obsidian.Plugin {
if (node.collapsed && node.children.length) {
const textSpan = document.createElement('span');
textSpan.className = 'lmm-node-text';
textSpan.textContent = node.text || PLACEHOLDER;
this._renderNodeContent(textSpan, node, overlay);
el.appendChild(textSpan);
const badge = document.createElement('span');
badge.className = 'lmm-collapse-badge';
el.appendChild(badge);
} else {
el.textContent = node.text || PLACEHOLDER;
this._renderNodeContent(el, node, overlay);
}
node._el = el;
this._attachNodeHandlers(el, node, overlay);
@ -740,6 +769,7 @@ class LightMindMapPlugin extends obsidian.Plugin {
el.addEventListener('click', (e) => {
e.stopPropagation();
if (el.isContentEditable) return;
if (e.target.closest('.lmm-link')) return;
this._selectNode(overlay, node, true);
});
el.addEventListener('dblclick', (e) => {
@ -820,6 +850,7 @@ class LightMindMapPlugin extends obsidian.Plugin {
}
this._selectNode(overlay, node, false);
overlay._lmmEditingNode = node;
el.textContent = node.rawText || node.text || PLACEHOLDER;
el.contentEditable = 'true';
el.classList.add('lmm-editing');
el.spellcheck = false;
@ -835,10 +866,10 @@ class LightMindMapPlugin extends obsidian.Plugin {
if (overlay._lmmEditingBlur === onBlur) overlay._lmmEditingBlur = null;
if (!el.isContentEditable) return;
const text = el.textContent;
const had = node.text;
const had = node.rawText || node.text;
this._exitEditMode(overlay, node);
this._updateNodeText(node, text);
if (node.text !== had) this._persistAndRelayout(overlay);
if ((node.rawText || node.text) !== had) this._persistAndRelayout(overlay);
};
el.addEventListener('blur', onBlur);
overlay._lmmEditingBlur = onBlur;
@ -854,21 +885,22 @@ class LightMindMapPlugin extends obsidian.Plugin {
el.contentEditable = 'false';
el.classList.remove('lmm-editing');
if (overlay._lmmEditingNode === node) overlay._lmmEditingNode = null;
this._renderNodeContent(el, node, overlay);
}
_updateNodeText(node, newText) {
const text = (newText || '').replace(/\s+/g, ' ').trim();
if (!text) return;
if (text !== node.text) {
node.text = text;
const hadRaw = node.rawText || node.text;
if (text !== hadRaw) {
node.rawText = text;
node.text = this._stripInline(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;
}
_toggleCollapse(overlay, node) {

View file

@ -1,7 +1,7 @@
{
"id": "light-mindmap",
"name": "Light Mindmap",
"version": "1.2.1",
"version": "1.2.2",
"minAppVersion": "1.4.0",
"description": "Auto-renders markdown headings as a colorful, interactive mindmap — no extra syntax required.",
"author": "Light Ning",

View file

@ -560,3 +560,24 @@
.lmm-fab:active {
transform: translateY(0);
}
/* ── Node links ── */
.lmm-link {
color: inherit;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
cursor: pointer;
pointer-events: auto;
}
.lmm-link:hover {
text-decoration-style: solid;
opacity: 0.85;
}
.lmm-node.lmm-editing .lmm-link {
text-decoration: none;
pointer-events: none;
}