zudo-text

検索したい単語を入力

いつでも検索バーを開ける

@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

PropTypeDescription
treeMindMapTreeTree data from mindmap-parser
view"outline" | "mindmap"Which view to render
onNodeAdd?(parentId: string, label: string) => voidCalled when a node is added
onNodeRemove?(nodeId: string) => voidCalled when a node is deleted
onNodeRemoveSelection?(nodeIds: readonly string[], confirm: () => void) => voidOptional host confirmation gate for a captured delete request
onNodeMove?(nodeId: string, newParentId: string, index?: number) => voidCalled when a node is reparented
onNodeLabelUpdate?(nodeId: string, newLabel: string) => voidCalled when a node label is edited
focusedNodeId?string | nullControlled focus state; always the primary of the selection
onFocusChange?(nodeId: string | null) => voidFocus change callback
selectedNodeIds?readonly string[]Ordered multi-selection, last id = primary. Controlled-or-internal like focusedNodeId
onSelectionChange?(selectedNodeIds: readonly string[]) => voidSelection change callback
onNodeAddAtIndex?(parentId: string, label: string, index: number) => voidIndexed insert (Return puts the sibling directly below)
onNodeMoveInSiblings?(nodeId: string, direction: -1 | 1) => voidReorder among siblings
mindmapViewState?MindmapViewStateMap placement, node width, Outline density, and structure-guide settings
onMindmapViewStateChange?(state: MindmapViewState) => voidCalled 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

PropTypeDescription
rootMindMapNodeRoot node of the tree
focusedNodeIdstring | nullCurrently focused node
collapsedIdsSet<string>Set of collapsed node IDs
editingNodeId?string | nullNode currently in inline-edit mode
onFocus(nodeId: string) => voidFocus callback
onToggleCollapse(nodeId: string) => voidCollapse toggle callback
onLabelUpdate?(nodeId: string, newLabel: string) => voidLabel edit callback
onEditStart?(nodeId: string) => voidCalled when inline edit begins
onEditEnd?() => voidCalled 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):

KeyAction
ArrowDown / ArrowUpMove the selection (placement-aware, see the grammar below)
ArrowLeft / ArrowRightMove the selection or change the fold (placement-aware)
Shift+arrowExtend or shrink the selection along the same targets
Mod+arrowStructural move of the whole selection (reorder / indent / outdent / switch side)
TabAdd a child
Shift+TabPlain outdent, in every placement
EnterAdd a sibling directly below (root → child)
Alt+EnterAdd a sibling above
Shift+EnterAdd a new main branch, greedily balanced
Alt+TabInsert a parent above the node
Alt+.Toggle fold
Backspace / DeleteDelete the selected subtrees
Alt+BackspaceDelete the selection, promoting its children in place
Mod+EnterEdit the label, pre-selected
Mod+ASelect all
Mod+FOpen Find
Mod+Shift+FToggle focus mode
Mod+= / Mod+- / Mod+0 / Mod+Shift+0Zoom in / out / actual size / zoom to fit
Mod+Shift+8Center the selection
Mod+Shift+1 / Mod+Shift+2Show map / show outline
EscapeClear 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.

ExportResolves
resolveArrowTargetA plain arrow → { id } (move), { expand } / { collapse } (fold first), or null
resolveStructuralArrowA Mod+arrow → moveUp / moveDown / indent / outdent / switchSide / moveBranchPrev / moveBranchNext
reduceSelectionA MindmapSelectionAction (set / toggle / extend / selectAll / clear) against the current selection
getDeleteFallbackFocusPost-delete focus: previous sibling → next sibling → parent
resolveAddWellSideWhich side the + well sits on, or null for the outline

The contract, one row per key family (epic #6546):

freeverticalhorizontaloutline
Left/Rightside-aware: toward root = parent (directly, no collapse-first); away = vertically nearest child (expand first if collapsed); root → nearest child on that sideLeft = collapse if expanded, else parent (root no-op); Right = expand, else first childbranch root: previous/next branch column; deeper: as verticalas vertical
Up/Downspatial: nearest same-side, same-depth node above/below, crossing parents; fallback nearest of any depthpreorder 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 columnvisible-row previous/next
Shift+arrowsextend / shrink the selection along the same targetssamesameShift+Up/Down only
Cmd+Up/Downreorder among same-side siblingsreorder among siblingsdepth 1: Up = no-op, Down = indent under previous branch; deeper: reorderreorder
Cmd+Left/Righttoward root = outdent, away = indent (mirrored on the left side); a depth-1 branch pushed toward the root switches sideRight = indent, Left = outdent (depth 1: announced no-op)depth 1: reorder branch columns; deeper: Right = indent, Left = outdentRight = indent, Left = outdent
Shift+Tabplain outdentplain outdentplain outdentplain outdent
+ wellouter side of the noderight edgeright edgenone
Rubber-bandyesyesyesn/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:

  • free Left/Right is side-aware and goes to the parent directly (no collapse-first), while vertical, horizontal and 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: Esc on 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, the aria-activedescendant on that div is no longer the focus owner's. The tree still exposes aria-multiselectable and per-row aria-selected.

Canvas gestures

MindMapView remaps the background gestures (epic #6546):

GestureResult
Wheel / two-finger scrollPan
Ctrl/Cmd+wheel, trackpad pinchZoom toward the pointer (a pinch reaches the page as ctrl+wheel, so one branch serves both)
Middle-button dragPan
Mouse or pen drag on the backgroundRubber-band selection
TouchUnchanged — 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 view

  • Peer dependencies: react >= 18, react-dom >= 18