mirror of
https://github.com/ninglg/light-mindmap.git
synced 2026-07-22 06:51:49 +00:00
Add node drag and keyboard restructuring
This commit is contained in:
parent
411036a5fe
commit
733efdb152
3 changed files with 265 additions and 0 deletions
10
README.md
10
README.md
|
|
@ -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
224
main.js
|
|
@ -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) {
|
||||
|
|
|
|||
31
styles.css
31
styles.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue