Add node drag and keyboard restructuring

This commit is contained in:
Lywooye 2026-06-28 01:14:49 -07:00
parent 411036a5fe
commit 733efdb152
3 changed files with 265 additions and 0 deletions

View file

@ -12,6 +12,7 @@ Feature-rich mindmap plugin — multiple layouts, themes, node shapes & line sty
- **Three structure modes:** choose **Heading**, **Hybrid**, or **List** from the toolbar.
- **Automatic format detection:** converting an existing Markdown note detects heading-only, heading-plus-list, or list-only structure and writes `mindmap-structure`.
- **Editable source compatibility:** canvas edits are written back using the selected Markdown structure mode.
- **Drag and keyboard restructuring:** move nodes by dragging, reorder siblings with `Shift + ArrowUp/ArrowDown`, promote with `Shift + Tab` or `Mod + ArrowLeft`, and demote with `Mod + ArrowRight`.
- **Safer mode switching:** heading mode is blocked for maps deeper than six levels, because Markdown headings cannot represent that depth.
## Preview
@ -169,6 +170,10 @@ 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** |
| Move sibling up / down | Select node, press **Shift + ArrowUp / ArrowDown** |
| Promote node | Select node, press **Shift + Tab** or **Mod + ArrowLeft** |
| Demote under previous sibling | Select node, press **Mod + ArrowRight** |
| Drag node | Drag onto another node; top/bottom drops reorder, middle drops as child |
| Right-click context menu | Right-click on node |
- The root node cannot be deleted.
@ -275,6 +280,7 @@ MIT
- **三种结构模式:**工具栏可直接选择 **Heading**、**Hybrid**、**List**。
- **自动识别格式:**把已有 Markdown 转为导图时,会自动识别纯标题、标题加列表、纯列表,并写入 `mindmap-structure`
- **源文档可编辑同步:**在画布上编辑节点后,会按当前结构模式写回 Markdown。
- **拖拽和快捷键调整结构:**可拖拽节点改变位置和父级;`Shift + ArrowUp/ArrowDown` 调整同级顺序,`Shift + Tab` 或 `Mod + ArrowLeft` 提升一级,`Mod + ArrowRight` 降到上一个同级节点下面。
- **更安全的模式切换:**超过 6 层的导图不会被切到纯标题模式,避免 Markdown 表达不了深层结构。
## 预览
@ -429,6 +435,10 @@ Markdown 只定义了六级标题。对于更深的导图,可以在标题后
| 添加子节点(无需编辑) | 选中节点后按 **Tab** |
| 删除节点 | 选中节点后按 **Delete****Backspace**|
| 折叠/展开节点 | 选中节点后按 **Space** |
| 上下移动同级节点 | 选中节点后按 **Shift + ArrowUp / ArrowDown** |
| 提升节点层级 | 选中节点后按 **Shift + Tab****Mod + ArrowLeft** |
| 降到上一个同级节点下面 | 选中节点后按 **Mod + ArrowRight** |
| 拖拽节点 | 拖到目标节点上;上/下边缘表示前后排序,中间表示作为子节点 |
| 右键上下文菜单 | 右键点击节点 |
- 根节点不可删除。

224
main.js
View file

@ -1072,6 +1072,7 @@ class LightMindMapPlugin extends obsidian.Plugin {
// ────────────────────────────────────────────────────────────────
_attachNodeHandlers(el, node, overlay) {
el.draggable = this._isMovableNode(node);
el.addEventListener('mousedown', (e) => {
if (el.isContentEditable) return;
e.stopPropagation();
@ -1090,6 +1091,7 @@ class LightMindMapPlugin extends obsidian.Plugin {
el.addEventListener('contextmenu', (e) => {
this._showNodeContextMenu(overlay, node, e);
});
this._attachNodeDragHandlers(el, node, overlay);
el.addEventListener('keydown', (e) => {
if (el.isContentEditable) {
if (overlay._lmmMention && this._handleMentionKeydown(overlay, e)) return;
@ -1111,6 +1113,8 @@ class LightMindMapPlugin extends obsidian.Plugin {
e.preventDefault();
this._cancelEdit(overlay, node);
}
} else if (this._handleStructureKeydown(overlay, node, e)) {
return;
} else {
if (e.key === 'Enter') {
e.preventDefault();
@ -1224,6 +1228,226 @@ class LightMindMapPlugin extends obsidian.Plugin {
this._exitEditMode(overlay, node);
}
_isMovableNode(node) {
return Boolean(node && !node.isVirtual && node.parent);
}
_isAncestorNode(ancestor, node) {
let cur = node && node.parent;
while (cur) {
if (cur === ancestor) return true;
cur = cur.parent;
}
return false;
}
_attachNodeDragHandlers(el, node, overlay) {
el.addEventListener('dragstart', (e) => {
if (!this._isMovableNode(node) || el.isContentEditable || e.target.closest('.lmm-link')) {
e.preventDefault();
return;
}
overlay._lmmDragNode = node;
el.classList.add('lmm-drag-source');
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', node.text || '');
}
});
el.addEventListener('dragover', (e) => {
const dragNode = overlay._lmmDragNode;
const action = this._getDropAction(e, dragNode, node);
if (!action) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
this._showDropIndicator(overlay, el, action);
});
el.addEventListener('dragleave', (e) => {
if (el.contains(e.relatedTarget)) return;
el.classList.remove('lmm-drop-before', 'lmm-drop-after', 'lmm-drop-child');
});
el.addEventListener('drop', (e) => {
const dragNode = overlay._lmmDragNode;
const action = this._getDropAction(e, dragNode, node);
this._clearDropIndicators(overlay);
if (!action) return;
e.preventDefault();
this._moveDroppedNode(overlay, dragNode, node, action);
});
el.addEventListener('dragend', () => {
el.classList.remove('lmm-drag-source');
overlay._lmmDragNode = null;
this._clearDropIndicators(overlay);
});
}
_getDropAction(e, dragNode, targetNode) {
if (!this._isMovableNode(dragNode) || !targetNode || targetNode.isVirtual) return null;
if (dragNode === targetNode || this._isAncestorNode(dragNode, targetNode)) return null;
const rect = targetNode._el && targetNode._el.getBoundingClientRect();
if (!rect || rect.height <= 0) return 'child';
const y = (e.clientY - rect.top) / rect.height;
if (y < 0.25 && targetNode.parent) return 'before';
if (y > 0.75 && targetNode.parent) return 'after';
return 'child';
}
_showDropIndicator(overlay, el, action) {
this._clearDropIndicators(overlay);
el.classList.add('lmm-drop-' + action);
}
_clearDropIndicators(overlay) {
if (!overlay) return;
overlay.querySelectorAll('.lmm-drop-before, .lmm-drop-after, .lmm-drop-child').forEach((el) => {
el.classList.remove('lmm-drop-before', 'lmm-drop-after', 'lmm-drop-child');
});
}
_moveDroppedNode(overlay, node, target, action) {
if (action === 'before') return this._moveNodeRelative(overlay, node, target, 'before');
if (action === 'after') return this._moveNodeRelative(overlay, node, target, 'after');
return this._moveNodeAsChild(overlay, node, target);
}
_detachNode(node) {
if (!node || !node.parent) return false;
const siblings = node.parent.children;
const idx = siblings.indexOf(node);
if (idx < 0) return false;
siblings.splice(idx, 1);
return true;
}
_insertNodeAt(node, parent, index) {
if (!node || !parent || !parent.children) return false;
const safeIndex = Math.max(0, Math.min(index, parent.children.length));
parent.children.splice(safeIndex, 0, node);
node.parent = parent;
return true;
}
_persistMove(overlay, node) {
overlay._lmmSelected = node;
overlay._lmmPendingEdit = null;
this._persistAndRelayout(overlay);
return true;
}
_moveNodeRelative(overlay, node, target, placement) {
if (!this._isMovableNode(node) || !target || !target.parent) return false;
if (node === target || this._isAncestorNode(node, target)) return false;
const targetParent = target.parent;
const oldParent = node.parent;
const oldIndex = oldParent.children.indexOf(node);
let targetIndex = targetParent.children.indexOf(target);
if (oldIndex < 0 || targetIndex < 0) return false;
if (oldParent === targetParent && oldIndex < targetIndex) targetIndex -= 1;
if (!this._detachNode(node)) return false;
if (placement === 'after') targetIndex += 1;
this._insertNodeAt(node, targetParent, targetIndex);
return this._persistMove(overlay, node);
}
_moveNodeAsChild(overlay, node, target) {
if (!this._isMovableNode(node) || !target || target.isVirtual) return false;
if (node === target || this._isAncestorNode(node, target)) return false;
if (!this._detachNode(node)) return false;
if (target.collapsed) target.collapsed = false;
this._insertNodeAt(node, target, target.children.length);
return this._persistMove(overlay, node);
}
_moveNodeWithinSiblings(overlay, node, delta) {
if (!this._isMovableNode(node)) return false;
const siblings = node.parent.children;
const idx = siblings.indexOf(node);
const next = idx + delta;
if (idx < 0 || next < 0 || next >= siblings.length) return false;
siblings.splice(idx, 1);
siblings.splice(next, 0, node);
return this._persistMove(overlay, node);
}
_promoteNode(overlay, node) {
if (!this._isMovableNode(node)) return false;
const parent = node.parent;
if (parent.isVirtual) return false;
if (!parent.parent) {
const treeInfo = overlay._lmmTreeInfo;
if (!treeInfo || treeInfo.tree !== parent) return false;
const fileName = (overlay._lmmFile && overlay._lmmFile.basename) || 'Mind Map';
const virtualRoot = {
level: (parent.level || treeInfo.baseLevel || 1) - 1,
rawText: fileName,
text: fileName,
children: [parent],
parent: null,
bodyRaw: '',
dirty: false,
isNew: false,
collapsed: false,
isVirtual: true,
};
parent.parent = virtualRoot;
treeInfo.tree = virtualRoot;
treeInfo.virtualRoot = true;
}
const grandparent = parent.parent;
if (!grandparent) return false;
const parentIndex = grandparent.children.indexOf(parent);
if (parentIndex < 0 || !this._detachNode(node)) return false;
this._insertNodeAt(node, grandparent, parentIndex + 1);
return this._persistMove(overlay, node);
}
_demoteNode(overlay, node) {
if (!this._isMovableNode(node)) return false;
const siblings = node.parent.children;
const idx = siblings.indexOf(node);
if (idx <= 0) return false;
const newParent = siblings[idx - 1];
if (!newParent || newParent.isVirtual || this._isAncestorNode(node, newParent)) return false;
if (!this._detachNode(node)) return false;
if (newParent.collapsed) newParent.collapsed = false;
this._insertNodeAt(node, newParent, newParent.children.length);
return this._persistMove(overlay, node);
}
_handleStructureKeydown(overlay, node, e) {
if (e.shiftKey && e.key === 'ArrowUp') {
e.preventDefault();
this._moveNodeWithinSiblings(overlay, node, -1);
return true;
}
if (e.shiftKey && e.key === 'ArrowDown') {
e.preventDefault();
this._moveNodeWithinSiblings(overlay, node, 1);
return true;
}
if (e.shiftKey && e.key === 'Tab') {
e.preventDefault();
this._promoteNode(overlay, node);
return true;
}
if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowLeft') {
e.preventDefault();
this._promoteNode(overlay, node);
return true;
}
if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowRight') {
e.preventDefault();
this._demoteNode(overlay, node);
return true;
}
return false;
}
// ─── Wiki-link mention autocomplete ────────────────────────────
_getMentionQuery(el) {

View file

@ -200,6 +200,37 @@
filter: brightness(1.05);
}
.lmm-node.lmm-drag-source {
opacity: 0.45;
}
.lmm-node.lmm-drop-child {
outline: 3px solid var(--lmm-theme-root-accent, #6366F1);
outline-offset: 5px;
z-index: 9;
}
.lmm-node.lmm-drop-before::before,
.lmm-node.lmm-drop-after::after {
content: '';
position: absolute;
left: -10px;
right: -10px;
height: 3px;
border-radius: 999px;
background: var(--lmm-theme-root-accent, #6366F1);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--lmm-theme-root-accent, #6366F1) 18%, transparent);
pointer-events: none;
}
.lmm-node.lmm-drop-before::before {
top: -9px;
}
.lmm-node.lmm-drop-after::after {
bottom: -9px;
}
.lmm-node:focus {
outline: none;
}