A Keyboard-First SVG Mind Map Editor With Tab-to-Add-Child and Markdown Export
Mind mapping should be as fast as thinking. Press Tab, new child. Press Enter, new sibling. Press F2, edit text. Arrow keys navigate. The whole thing runs in an SVG, laid out with a simple left-to-right tree algorithm, and exports to Markdown / OPML / JSON / PNG.
Every mind map app I've tried punishes keyboard users. You click to select, click again to edit, drag to move — and by the time you've placed three nodes, your idea is gone. A mind map tool that lives in the keyboard feels fundamentally different.
🔗 Live demo: https://sen.ltd/portfolio/mind-map/
📦 GitHub: https://github.com/sen-ltd/mind-map
Features:
- SVG rendering with automatic tree layout
- Keyboard-first: Tab (child), Enter (sibling), Del (remove), F2 (edit), arrows (navigate)
- Undo / redo (50-step history)
- Pan and zoom
- Export: SVG / PNG / Markdown / OPML / JSON
- Import: Markdown / OPML / JSON
- Auto-save to localStorage
- Japanese / English UI
- Zero dependencies, 54 tests
The tree layout algorithm
Each node gets an x coordinate based on its depth, and a y coordinate based on the average of its children:
function layoutNode(node, depth, yOffset) {
node.x = depth * HORIZONTAL_SPACING;
if (node.children.length === 0) {
node.y = yOffset.current;
yOffset.current += VERTICAL_SPACING;
return;
}
// Recurse into children first
node.children.forEach(child => layoutNode(child, depth + 1, yOffset));
// This node's y is centered on its children
const first = node.children[0];
const last = node.children[node.children.length - 1];
node.y = (first.y + last.y) / 2;
}
The yOffset is a mutable counter passed by reference — it tracks where the next leaf should be placed. Each leaf claims a row. Internal nodes derive their y from the children's average.
This is simpler than Reingold-Tilford (the "correct" tree layout) but produces acceptable results for small-to-medium trees. For thousands of nodes, you'd want the full algorithm with contour merging.
SVG foreignObject for inline editing
Text editing inside an SVG is tricky because SVG <text> doesn't accept user input. The trick is <foreignObject>:
<foreignObject x="100" y="50" width="200" height="40">
<input xmlns="http://www.w3.org/1999/xhtml" value="node text" />
</foreignObject>
This embeds an HTML input inside the SVG at arbitrary coordinates. On F2, swap the <text> element for a <foreignObject> with a focused input. On Enter or blur, swap back.
Keyboard event handling
The whole interaction is driven by a single keydown listener on the window:
window.addEventListener('keydown', (e) => {
if (editing) return; // let the input handle it
switch (e.key) {
case 'Tab':
e.preventDefault();
addChildToSelected();
break;
case 'Enter':
e.preventDefault();
addSiblingToSelected();
break;
case 'Delete':
case 'Backspace':
removeSelected();
break;
case 'F2':
startEditing();
break;
case 'ArrowUp': navigate('up'); break;
case 'ArrowDown': navigate('down'); break;
case 'ArrowLeft': navigate('parent'); break;
case 'ArrowRight': navigate('firstChild'); break;
case 'z':
if (e.ctrlKey && e.shiftKey) redo();
else if (e.ctrlKey) undo();
break;
}
});
e.preventDefault() on Tab is essential — otherwise Tab would move focus out of the SVG. Same for Enter if it's inside an input.
Undo / redo via snapshots
Every mutation pushes the previous tree onto a history stack:
function applyMutation(newTree) {
history.push(tree);
if (history.length > 50) history.shift();
redoStack = [];
tree = newTree;
render();
}
function undo() {
if (history.length === 0) return;
redoStack.push(tree);
tree = history.pop();
render();
}
Because the tree is immutable, each snapshot is just a reference — no deep copy needed. 50 steps of history cost almost nothing.
Markdown export
The outline format maps perfectly to indented Markdown:
export function toMarkdown(root) {
const lines = [];
function walk(node, depth) {
lines.push(' '.repeat(depth) + '- ' + node.text);
node.children.forEach(c => walk(c, depth + 1));
}
walk(root, 0);
return lines.join('\n');
}
Import is the inverse: count leading spaces to detect depth, build the tree by pushing/popping a parent stack.
Series
This is entry #58 in my 100+ public portfolio series.
- 📦 Repo: https://github.com/sen-ltd/mind-map
- 🌐 Live: https://sen.ltd/portfolio/mind-map/
- 🏢 Company: https://sen.ltd/

Top comments (0)