DEV Community

SEN LLC
SEN LLC

Posted on

A Keyboard-First SVG Mind Map Editor With Tab-to-Add-Child and Markdown Export

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

Screenshot

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
  }
});
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)