@takazudo/mindmap-board
React component library for an interactive mind map with two view modes: an outline (collapsible tree list) and a visual mind map (SVG connector layout). Built on @takazudo/mindmap-parser for data types. The board keeps view settings explicit so a host can persist them with the owning frame.
Main Exports
import {
MindMapBoard,
OutlineView,
MindMapView,
MindMapNodeComponent,
SvgConnectors,
} from "@takazudo/mindmap-board";
import {
useMindMapKeyboard,
flattenVisibleNodes,
defaultKeyBindings,
MINDMAP_KEY_SINK_ATTRIBUTE,
} from "@takazudo/mindmap-board";
// Pure grammar resolvers (no React, no DOM).
import {
resolveArrowTarget,
resolveStructuralArrow,
resolveAddWellSide,
reduceSelection,
getDeleteFallbackFocus,
} from "@takazudo/mindmap-board";
import type {
MindMapBoardProps,
OutlineViewProps,
MindMapViewProps,
MindMapNodeProps,
SvgConnectorsProps,
ConnectorLine,
UseMindMapKeyboardOptions,
UseMindMapKeyboardReturn,
MindMapKeyBindings,
MindmapPlacement,
MindmapViewState,
MindmapLayoutGeometry,
MindmapArrowDirection,
MindmapArrowTarget,
MindmapGrammarView,
MindmapSelectionAction,
MindmapStructuralArrow,
} from "@takazudo/mindmap-board";MindMapBoard
The main container component. Renders either an outline view or a visual mind map depending on the view prop. Manages focus, collapse state, and inline editing internally.
import { MindMapBoard } from "@takazudo/mindmap-board";
import type { MindMapTree } from "@takazudo/mindmap-parser";
function MyMindMap({ tree }: { tree: MindMapTree }) {
return (
<MindMapBoard
tree={tree}
view="outline"
onNodeAdd={(parentId, label) => { /* ... */ }}
onNodeRemove={(nodeId) => { /* ... */ }}
onNodeLabelUpdate={(nodeId, newLabel) => { /* ... */ }}
/>
);
}Props
| Prop | Type | Description |
|---|---|---|
tree | MindMapTree | Tree data from mindmap-parser |
view | "outline" | "mindmap" | Which view to render |
onNodeAdd? | (parentId: string, label: string) => void | Called when a node is added |
onNodeRemove? | (nodeId: string) => void | Called when a node is deleted |
onNodeRemoveSelection? | (nodeIds: readonly string[], confirm: () => void) => void | Optional host confirmation gate for a captured delete request |
onNodeMove? | (nodeId: string, newParentId: string, index?: number) => void | Called when a node is reparented |
onNodeLabelUpdate? | (nodeId: string, newLabel: string) => void | Called when a node label is edited |
focusedNodeId? | string | null | Controlled focus state; always the primary of the selection |
onFocusChange? | (nodeId: string | null) => void | Focus change callback |
selectedNodeIds? | readonly string[] | Ordered multi-selection, last id = primary. Controlled-or-internal like focusedNodeId |
onSelectionChange? | (selectedNodeIds: readonly string[]) => void | Selection change callback |
onNodeAddAtIndex? | (parentId: string, label: string, index: number) => void | Indexed insert (Return puts the sibling directly below) |
onNodeMoveInSiblings? | (nodeId: string, direction: -1 | 1) => void | Reorder among siblings |
mindmapViewState? | MindmapViewState | Map placement, node width, Outline density, and structure-guide settings |
onMindmapViewStateChange? | (state: MindmapViewState) => void | Called with a normalized view-state candidate; the host owns frame persistence (or can keep it controlled) |
MindmapViewState accepts placement: "free" | "vertical" | "horizontal", nodeWidthMode: "free" | "maxWidth" | "fixed", maxNodeWidth (clamped to 180–800px), fixedNodeWidth (clamped to 180–360px), outlineDensity: "comfortable" | "compact", and showStructureGuides. Free width measures every visible label without a cap; Max width caps measured labels at maxNodeWidth; Fixed gives all nodes the same fixedNodeWidth. Structured placements use orthogonal connectors and place collapsed badges outward on the right. Free placement retains each branch's remembered left/right side. Omitted or invalid values are normalized to the canonical defaults by the board; the package does not persist them itself. A dedicated frame host should serialize this state with that frame, while a Note Tray host may keep it session-local.
MindmapViewState also carries rootChildSides: the per-file record of which side each depth-1 branch was pushed to in free placement. It is id-remapped alongside collapsedIds, inherited by new siblings, inserted parents and outdented nodes, and every side switch is one undo step. A host that wants side switching must supply the mindmapViewState/onMindmapViewStateChange pair — the layout reads the hints off that same prop and the board keeps no internal fallback for them.
Selection
selectedNodeIds is ordered and its last id is the primary — the node the + well, the add/edit commands and aria-activedescendant follow. The board derives it rather than mirroring it into state: when the supplied array does not end in focusedNodeId (a bare focus change from an arrow key, Find, or a host write) the selection collapses to the focused node alone. Single-selection hosts therefore need no new wiring. ⌘/⇧-click toggle membership, a background rubber-band replaces it, and ⌘A selects every node.
⌘+arrow commands act on the whole selection: collectTopLevelSelectionIds drops ids whose ancestor is also selected (and the root), the grammar is resolved per remaining node, selection order is preserved, the block stops together if any member cannot move, and the whole batch is one undo step (history entries are whole-document snapshots).
Hosts that need a confirmation before deleting can provide onNodeRemoveSelection. The callback receives the node ids captured for that request and a confirm function. Keep that pair with the open confirmation and call confirm() once after the user accepts; cancel by leaving it unused. If the document changes while the confirmation is open, discard the saved callback without invoking it; MindmapPane follows this rule. The board applies an accepted captured deletion as one history operation, so a single undo restores it. The existing onNodeRemove(nodeId) callback remains supported for hosts that own single-node removal; when both callbacks are supplied, the selection callback handles delete requests.
OutlineView
A collapsible tree list with indentation. Click triangle icons to fold/unfold branches. Double-click a node to edit its label inline.
import { OutlineView } from "@takazudo/mindmap-board";
<OutlineView
root={tree.root}
focusedNodeId={focusedId}
collapsedIds={collapsedSet}
onFocus={(nodeId) => setFocused(nodeId)}
onToggleCollapse={(nodeId) => toggleCollapse(nodeId)}
onLabelUpdate={(nodeId, newLabel) => updateLabel(nodeId, newLabel)}
/>Props
| Prop | Type | Description |
|---|---|---|
root | MindMapNode | Root node of the tree |
focusedNodeId | string | null | Currently focused node |
collapsedIds | Set<string> | Set of collapsed node IDs |
editingNodeId? | string | null | Node currently in inline-edit mode |
onFocus | (nodeId: string) => void | Focus callback |
onToggleCollapse | (nodeId: string) => void | Collapse toggle callback |
onLabelUpdate? | (nodeId: string, newLabel: string) => void | Label edit callback |
onEditStart? | (nodeId: string) => void | Called when inline edit begins |
onEditEnd? | () => void | Called when inline edit ends |
MindMapView
A visual tree layout with the root node centered and SVG connector lines linking parent and child nodes. Click to focus, double-click to edit.
It reports each settled layout back to the board through onLayoutGeometryChange(geometry: MindmapLayoutGeometry) — positions (node centers), nodeSizes and sides. The board keeps the latest report in a ref rather than state, because only the keyboard grammar reads it and it reads it lazily at keydown time; putting it in state would re-render the whole board on every layout.
Hooks
useMindMapKeyboard
Keyboard grammar for the board. Attaches a keydown listener to a container element and resolves every arrow through mindmap-grammar.ts.
import { useMindMapKeyboard } from "@takazudo/mindmap-board";
const { containerRef } = useMindMapKeyboard({
tree,
focusedNodeId,
collapsedIds,
onFocusChange: setFocused,
onSelectionChange: applySelectionAction,
onToggleCollapse: toggleCollapse,
onAddNode: handleAdd,
onAddNodeAtIndex: handleAddAtIndex,
onRemoveNode: handleRemove,
onDeleteKeepingChildren: handleDeleteKeepingChildren,
onStructuralArrow: handleStructuralArrow,
onOutdentSelection: handleOutdentSelection,
onEditNode: handleEdit,
view: "mindmap",
placement: "vertical",
layoutSides,
getLayoutGeometry: () => layoutGeometryRef.current,
editingNodeId,
});Default key bindings (defaultKeyBindings):
| Key | Action |
|---|---|
ArrowDown / ArrowUp | Move the selection (placement-aware, see the grammar below) |
ArrowLeft / ArrowRight | Move the selection or change the fold (placement-aware) |
Shift+arrow | Extend or shrink the selection along the same targets |
Mod+arrow | Structural move of the whole selection (reorder / indent / outdent / switch side) |
Tab | Add a child |
Shift+Tab | Plain outdent, in every placement |
Enter | Add a sibling directly below (root → child) |
Alt+Enter | Add a sibling above |
Shift+Enter | Add a new main branch, greedily balanced |
Alt+Tab | Insert a parent above the node |
Alt+. | Toggle fold |
Backspace / Delete | Delete the selected subtrees |
Alt+Backspace | Delete the selection, promoting its children in place |
Mod+Enter | Edit the label, pre-selected |
Mod+A | Select all |
Mod+F | Open Find |
Mod+Shift+F | Toggle focus mode |
Mod+= / Mod+- / Mod+0 / Mod+Shift+0 | Zoom in / out / actual size / zoom to fit |
Mod+Shift+8 | Center the selection |
Mod+Shift+1 / Mod+Shift+2 | Show map / show outline |
Escape | Clear the selection |
There are no bare-letter bindings. J K H L O A D E F and a bare . were removed in epic #6546: a printable key on a selected node starts editing its label, so a single character can never be a board shortcut. The writing app's settings section marks every mindmap entry allowSingleKey: false to match.
Shift+arrow is read from the physical arrow key: extending the selection has no binding of its own and is deliberately not a rebindable settings entry. Plain navigation (moveDown / moveUp / collapse / expand) and the structural Mod+arrows (moveNodeUp / moveNodeDown / indentNode / outdentNode) both go through keyBindings and stay configurable; the direction the grammar acts on is whichever of those four bindings matched. Mod+Z / Mod+Shift+Z are fixed.
While editingNodeId is non-null the inline editor owns the keys: the key-sink exemption is off and undo/redo stay blocked.
flattenVisibleNodes
Utility that flattens a tree into a visible node list, skipping children of collapsed nodes. Used internally by useMindMapKeyboard.
import { flattenVisibleNodes } from "@takazudo/mindmap-board";
const visibleNodes = flattenVisibleNodes(tree.root, collapsedIds);mindmap-grammar.ts
The pure resolver layer. No React, no DOM — every export is a function over the tree plus geometry the board already computed. Placements do not each carry ad-hoc rules; they pick a row of the grammar table in epic #6546.
| Export | Resolves |
|---|---|
resolveArrowTarget | A plain arrow → { id } (move), { expand } / { collapse } (fold first), or null |
resolveStructuralArrow | A Mod+arrow → moveUp / moveDown / indent / outdent / switchSide / moveBranchPrev / moveBranchNext |
reduceSelection | A MindmapSelectionAction (set / toggle / extend / selectAll / clear) against the current selection |
getDeleteFallbackFocus | Post-delete focus: previous sibling → next sibling → parent |
resolveAddWellSide | Which side the + well sits on, or null for the outline |
The contract, one row per key family (epic #6546):
free | vertical | horizontal | outline | |
|---|---|---|---|---|
| Left/Right | side-aware: toward root = parent (directly, no collapse-first); away = vertically nearest child (expand first if collapsed); root → nearest child on that side | Left = collapse if expanded, else parent (root no-op); Right = expand, else first child | branch root: previous/next branch column; deeper: as vertical | as vertical |
| Up/Down | spatial: nearest same-side, same-depth node above/below, crossing parents; fallback nearest of any depth | preorder previous/next (crosses parents) | root: Down → first branch; branch root: Up → root, Down → expand if collapsed, else first child; deeper: previous/next within the branch column | visible-row previous/next |
| Shift+arrows | extend / shrink the selection along the same targets | same | same | Shift+Up/Down only |
| Cmd+Up/Down | reorder among same-side siblings | reorder among siblings | depth 1: Up = no-op, Down = indent under previous branch; deeper: reorder | reorder |
| Cmd+Left/Right | toward root = outdent, away = indent (mirrored on the left side); a depth-1 branch pushed toward the root switches side | Right = indent, Left = outdent (depth 1: announced no-op) | depth 1: reorder branch columns; deeper: Right = indent, Left = outdent | Right = indent, Left = outdent |
| Shift+Tab | plain outdent | plain outdent | plain outdent | plain outdent |
+ well | outer side of the node | right edge | right edge | none |
| Rubber-band | yes | yes | yes | n/a |
view is "map" | "outline" because placement alone cannot express the outline, which is a separate view of the same tree. free navigation needs both layoutSides (which side of the root a node hangs on) and positions (node centers from layoutMindMap) — its Up/Down is spatial, picking the nearest same-side, same-depth node and crossing parents, with a nearest-of-any-depth fallback. The structured placements and the outline resolve without geometry.
Two deliberate deviations from one-rule-everywhere, decided in the epic:
freeLeft/Right is side-aware and goes to the parent directly (no collapse-first), whilevertical,horizontaland the outline keep today's collapse-else-parent / expand-else-first-child behaviour.In
free, a depth-1 branch pushed toward the root switches side rather than becoming a no-op outdent.
resolveAddWellSide returns the node's own side in free (so left-side nodes get a left-edge well), "right" in vertical and horizontal, and null in the outline, which has no well.
Key sink and focus ownership
Type-to-edit cannot be implemented by focusing a fresh textarea on the first keystroke: an IME composition cannot be transferred between elements once it has started, so a Japanese composition begun on the tree container would be lost. Instead one InlineNodeEditor in mode: "sink" stays mounted and focused on the primary selected node, invisible until input begins. The same DOM instance survives the sink → editing transition, so the composition and the already-typed character carry over. onBeginTypeEdit fires on the first input or compositionstart and the board flips mode to "editing".
useMindMapKeyboard normally bails out when a textarea has focus. The sink is exempted through the MINDMAP_KEY_SINK_ATTRIBUTE (data-mindmap-key-sink) marker, so board keys still reach the grammar while the sink holds focus.
Focus ownership rules (#6557):
The sink may take DOM focus only while the board already owns it and no overlay does — never from the find bar, a context menu, a dialog, settings or another pane. The board answers through
canAutoFocus, read at the instant the sink mounts, so the checks live in refs rather than state.On a coarse pointer the sink is not focused at all, since that would raise the software keyboard on every tap; editing there is entered by tap.
Entering
mode: "editing"always focuses — that is a deliberate user action.
Consequences worth knowing:
Nothing is trimmed on commit. Leading/trailing spaces and blank lines round-trip through the label codec, and empty labels are stored the same way. Empty nodes are kept:
Escon a brand-new node commits and keeps it.Clicking the empty canvas while editing commits but does not also deselect in the same click. The next click deselects normally.
Because focus sits on the sink textarea rather than on the
role="tree"div, thearia-activedescendanton that div is no longer the focus owner's. The tree still exposesaria-multiselectableand per-rowaria-selected.
Canvas gestures
MindMapView remaps the background gestures (epic #6546):
| Gesture | Result |
|---|---|
| Wheel / two-finger scroll | Pan |
Ctrl/Cmd+wheel, trackpad pinch | Zoom toward the pointer (a pinch reaches the page as ctrl+wheel, so one branch serves both) |
| Middle-button drag | Pan |
| Mouse or pen drag on the background | Rubber-band selection |
| Touch | Unchanged — one-finger pan, pinch zoom |
shouldStartMarquee gates the rubber-band on a primary, non-touch, button-0 press, and isMarqueeBackgroundTarget confirms the press landed on empty canvas rather than a node or control. wheelZoomSensitivity scales the zoom step only; keyboard zoom, Fit and 100% are unaffected.
The + add-child well rides the transformed content on the primary node's outer side and hides while editing, while a layout tween runs, and while a node is being dragged.
The board's small fold badge remains a compact visual disc inside a 44×44px hit area. Hosts should preserve that target floor for toolbar, Outline, zoom, find, focus, and settings controls at widths through 640px while avoiding horizontal overflow.
Dependencies
@takazudo/mindmap-parser— tree data types@takazudo/color-themes— theme color tokens@takazudo/ui-components— shared UI components@panzoom/panzoom— pan and zoom for the visual mind-map viewPeer dependencies:
react >= 18,react-dom >= 18