Files
turbostarter/_bmad-output/implementation-artifacts/2-9-node-selection-and-manual-repositioning.md
Alejandro Gutiérrez 9d13d0f562 feat: implement Story 2.9 — Node selection and manual repositioning
Add node/edge selection visuals, drag-to-reposition with manuallyPositioned
persistence, multi-select via Shift+drag, selectedNodeIds store state for
Epic 3 badge integration, and code review fixes (dimmed+selected opacity,
single-source selection clearing, Map-based drag lookup).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 01:31:19 +00:00

24 KiB

Story 2.9: Node Selection and Manual Repositioning

Status: done

Story

As a user, I want to select nodes/edges and manually reposition them on the canvas, so that I can fine-tune layouts after auto-arrangement.

Acceptance Criteria

  1. Given I click on a node on the canvas, When the node is clicked, Then it shows a selected state with a --node-selected highlight border, And a visual indicator distinguishes the selected node from unselected ones.

  2. Given I click on an edge on the canvas, When the edge is clicked, Then it highlights with --edge-selected color, And edge metadata (label, type) remains visible.

  3. Given I have a node selected, When I drag it to a new position, Then the node moves smoothly following my cursor, And connected edges update their routing in real-time, And the new position is persisted to the graph data (manuallyPositioned: true prevents auto-layout from overriding).

  4. Given I want to select multiple elements, When I hold Shift and drag a rectangle selection area on the canvas, Then all nodes within the rectangle are selected, And I can drag the entire group to reposition.

  5. Given I click on empty canvas area, When no element is under the cursor, Then all selections are cleared.

Tasks / Subtasks

  • Task 1: Add onNodeDragStop handler in DiagramCanvas to set manuallyPositioned flag (AC: #3)

    • 1.1: Create onNodeDragStop callback that updates data.manuallyPositioned = true and data.position for all dragged nodes
    • 1.2: Handle multi-node drag (the nodes array parameter includes all selected nodes being dragged)
    • 1.3: Wire onNodeDragStop prop on <ReactFlow> component
  • Task 2: Fix flowNodeToGraphNode roundtrip for manuallyPositioned flag (AC: #3)

    • 2.1: Add ...(data.manuallyPositioned !== undefined && { manuallyPositioned: data.manuallyPositioned }) to the conditional spread in flowNodeToGraphNode
  • Task 3: Add selection CSS styles for nodes and edges (AC: #1, #2)

    • 3.1: Add .react-flow__node.selected style using --node-selected CSS variable (box-shadow ring)
    • 3.2: Add .react-flow__edge.selected path style using --edge-selected CSS variable (stroke color + width)
    • 3.3: Add drag-in-progress visual feedback (.react-flow__node.dragging subtle shadow)
  • Task 4: Configure <ReactFlow> selection and drag props (AC: #1, #2, #4, #5)

    • 4.1: Ensure nodesDraggable is true (default, but make explicit)
    • 4.2: Ensure elementsSelectable is true (default, but make explicit)
    • 4.3: Keep default selection behavior: Shift+drag for rectangle selection, Meta/Ctrl+click for multi-select additive
    • 4.4: Keep panOnDrag={true} (default slippy-map style — do NOT switch to Figma-style selectionOnDrag as that changes the core interaction model without UX spec approval)
  • Task 5: Add selectedNodeIds state to Zustand store for Epic 3 integration (AC: #4)

    • 5.1: Add selectedNodeIds: string[] to GraphState interface
    • 5.2: Add setSelectedNodeIds setter
    • 5.3: Wire onSelectionChange prop on <ReactFlow> to update selectedNodeIds in store
    • 5.4: Clear selectedNodeIds on onPaneClick (alongside existing clearHighlight)
    • 5.5: Reset selectedNodeIds in reset() method
  • Task 6: Clear highlight state when node is selected via native selection (AC: #1, #5)

    • 6.1: In handleNodeClick, if a BFS highlight is active and user clicks a different node, clear BFS highlight classes before native selection takes over
    • 6.2: Ensure onPaneClick clears both BFS highlight AND native selection state in store
  • Task 7: Tests (AC: all)

    • 7.1: Unit tests for flowNodeToGraphNode with manuallyPositioned flag — verify roundtrip preserves the flag
    • 7.2: Unit tests for resolvePositions — verified already tested in elk-layout.test.ts (lines 464-497)
    • 7.3: Unit tests for selectedNodeIds store state — set, clear, reset
    • 7.4: All tests pass — 186 web tests, 0 regressions

Dev Notes

Overview — What This Story Builds

This story enables node selection, edge selection, multi-select via rectangle drag, and manual node repositioning on the canvas. It bridges the gap between auto-layout and user fine-tuning, and exposes selection state for future Epic 3 (badge-based AI referencing).

This story builds:

  • onNodeDragStop handler that marks dragged nodes as manuallyPositioned: true
  • Graph-converter roundtrip fix for manuallyPositioned field
  • Selection CSS styles using existing --node-selected and --edge-selected variables
  • Drag-in-progress visual feedback
  • selectedNodeIds state in Zustand store (consumed by Epic 3 badge system)
  • onSelectionChange wiring for tracking selected elements
  • Explicit <ReactFlow> selection/drag props

This story does NOT implement:

  • Badge-based element referencing in chat (Story 3.3)
  • AI-targeted modifications from selection (Epic 3)
  • Resize handles for nodes (not in scope — nodes have fixed sizes per diagram type)
  • Node deletion via keyboard (future — requires CRDT integration)
  • Figma-style selectionOnDrag mode (not approved in UX spec — default slippy-map interaction)

Architecture Compliance

MANDATORY patterns from Architecture Decision Document:

  1. Unified Graph Data Model (Decision 1): manuallyPositioned: boolean field already exists on DiagramNode (graph.ts line 28). Positions stored in position?: { x: number; y: number } (line 27). No new fields needed on the graph model.

  2. Manual position coexistence: Architecture explicitly states: "Manual repositioning must coexist with auto-layout" (line 42). ELK layout's resolvePositions() already skips manuallyPositioned nodes (elk-layout.ts line 133-134). This was pre-implemented in Story 2.2.

  3. Selection state for Epic 3 badge system: Architecture specifies that "selected nodes appear as badge candidates (for Epic 3 integration)". The selectedNodeIds array in the Zustand store provides this bridge.

  4. No position overrides map: Architecture suggests "Manual position overrides, if any, should be stored as a separate overrides map" (line 156). However, the implementation already stores manuallyPositioned directly on DiagramNode along with position — this is simpler and matches the existing pattern. The dev agent should follow the existing implementation, NOT create a separate overrides map.

  5. Component Structure: All changes are in existing files (DiagramCanvas.tsx, useGraphStore.ts, graph-converter.ts, globals.css). No new component files needed for this story.

Current State Analysis — What Already Works

Already implemented (from Stories 2.1-2.8):

Feature Status Where
manuallyPositioned field on DiagramNode Defined graph.ts line 28
position field on DiagramNode Defined graph.ts line 27
ELK skips manuallyPositioned nodes Implemented elk-layout.ts lines 133-134
onNodesChange with applyNodeChanges Implemented useGraphStore.ts lines 53-56
onEdgesChange with applyEdgeChanges Implemented useGraphStore.ts lines 58-60
--node-selected CSS variable Defined globals.css lines 16, 44
--edge-selected CSS variable Defined globals.css lines 20, 47
Nodes draggable (default) Active <ReactFlow> default prop
Elements selectable (default) Active <ReactFlow> default prop
Shift+drag rectangle selection Active <ReactFlow> default behavior
BFS path highlighting on click Active DiagramCanvas.tsx lines 238-286
onPaneClick clear highlight Active DiagramCanvas.tsx line 298

What's MISSING (this story's scope):

Feature What Needs to Happen
manuallyPositioned flag set on drag Add onNodeDragStop handler in DiagramCanvas
manuallyPositioned preserved in roundtrip Fix flowNodeToGraphNode in graph-converter.ts
Selection visual feedback (CSS) Add .react-flow__node.selected and .react-flow__edge.selected styles
Drag visual feedback (CSS) Add .react-flow__node.dragging style
selectedNodeIds in store Add to GraphState for Epic 3 bridge
onSelectionChange wiring Track selection state in store

onNodeDragStop Handler Pattern

// In DiagramCanvas.tsx — CanvasInner component
const handleNodeDragStop = useCallback(
  (_event: React.MouseEvent, _node: Node, draggedNodes: Node[]) => {
    const store = useGraphStore.getState();
    const updatedNodes = store.nodes.map((n) => {
      const dragged = draggedNodes.find((d) => d.id === n.id);
      if (!dragged) return n;
      return {
        ...n,
        data: {
          ...n.data,
          manuallyPositioned: true,
          position: dragged.position,
        },
      };
    });
    store.setNodes(updatedNodes);
  },
  [],
);

// Wire to ReactFlow:
<ReactFlow
  ...
  onNodeDragStop={handleNodeDragStop}
/>

Key points:

  • onNodeDragStop receives (event, node, nodes) — the nodes array contains ALL nodes being dragged (important for multi-select drag)
  • Must update data.manuallyPositioned = true so resolvePositions() skips these nodes on re-layout
  • Must also update data.position so flowNodeToGraphNode can persist it
  • The node's position (the @xyflow/react position) is already updated by applyNodeChanges — we just need the flag in data

flowNodeToGraphNode Fix

// In graph-converter.ts — flowNodeToGraphNode function
// Add this line to the conditional spread section (after line 259):
...(data.manuallyPositioned !== undefined && { manuallyPositioned: data.manuallyPositioned }),

This ensures that when the graph is serialized back to GraphData (for persistence, export, or AI consumption), the manuallyPositioned flag survives the roundtrip.

selectedNodeIds Store Addition

// In useGraphStore.ts — GraphState interface
selectedNodeIds: string[];
setSelectedNodeIds: (ids: string[]) => void;

// Initial state
selectedNodeIds: [],

// Setter
setSelectedNodeIds: (selectedNodeIds) => set({ selectedNodeIds }),

// Reset
selectedNodeIds: [],

onSelectionChange Wiring

// In DiagramCanvas.tsx — CanvasInner component
const setSelectedNodeIds = useGraphStore((s) => s.setSelectedNodeIds);

const handleSelectionChange = useCallback(
  ({ nodes }: { nodes: Node[]; edges: Edge[] }) => {
    setSelectedNodeIds(nodes.map((n) => n.id));
  },
  [setSelectedNodeIds],
);

// Wire to ReactFlow:
<ReactFlow
  ...
  onSelectionChange={handleSelectionChange}
/>

Selection CSS Styles

/* ── Selection & Drag Styles ─────────────────────────────────────── */

.react-flow__node.selected {
  box-shadow: 0 0 0 2px var(--node-selected);
  border-radius: 6px;
}

.react-flow__node.dragging {
  box-shadow: 0 0 0 2px var(--node-selected), 0 4px 12px rgba(0, 0, 0, 0.15);
  opacity: 0.9;
}

.react-flow__edge.selected path {
  stroke: var(--edge-selected) !important;
  stroke-width: 2.5 !important;
}

Placement: Add in the .react-flow section of globals.css (after the existing .highlighted and .dimmed styles, around line 812).

Notes:

  • !important on edge styles is needed because custom edge components set inline styles via style={{ stroke: "var(--diagram-X)" }}
  • border-radius: 6px matches the standard node border-radius used across all diagram types
  • Dragging state adds an elevation shadow for depth feedback
  • The .selected class is automatically applied by @xyflow/react — no manual class toggling needed

BFS Highlight vs. Native Selection Interaction

The current handleNodeClick applies BFS highlighting (classes: highlighted/dimmed). This coexists with native @xyflow/react .selected state, but they can conflict visually. The approach:

  1. BFS highlight is informational — shows connected paths. It uses className (highlighted/dimmed) which is separate from the .selected class.
  2. Native selection is functional — enables drag, multi-select, and tracks selectedNodeIds for badge referencing.
  3. Both can coexist — a node can be both .selected and .highlighted simultaneously. The CSS stacks correctly (box-shadow from .selected, drop-shadow from .highlighted).
  4. Pane click clears bothonPaneClick already calls clearHighlight; add setSelectedNodeIds([]) to it.

No conflict resolution needed — they operate on different visual channels (box-shadow vs. filter/opacity). Keep them independent.

Explicit ReactFlow Props

Currently <ReactFlow> relies on all defaults. For clarity and future-proofing, make selection/drag props explicit:

<ReactFlow
  nodes={nodes}
  edges={edges}
  onNodesChange={onNodesChange}
  onEdgesChange={onEdgesChange}
  onViewportChange={onViewportChange}
  onNodeClick={handleNodeClick}
  onNodeDragStop={handleNodeDragStop}
  onSelectionChange={handleSelectionChange}
  onPaneClick={clearHighlight}
  nodesDraggable
  elementsSelectable
  nodeTypes={nodeTypes}
  edgeTypes={edgeTypes}
  fitView
  colorMode="system"
  proOptions={{ hideAttribution: true }}
>

Do NOT add:

  • selectionOnDrag — changes from slippy-map to Figma-style, not approved
  • panOnDrag={false} — would break existing pan behavior
  • deleteKeyCode — node deletion not in scope (requires CRDT integration)

Project Structure Notes

  • Alignment with unified project structure: all changes in existing files under ~/modules/diagram/
  • No new component files created
  • No new packages required
  • Tests co-located next to source files

Existing Code to Reuse / Modify

File Action What
apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx MODIFY Add onNodeDragStop, onSelectionChange handlers, explicit selection props
apps/web/src/modules/diagram/stores/useGraphStore.ts MODIFY Add selectedNodeIds, setSelectedNodeIds
apps/web/src/modules/diagram/lib/graph-converter.ts MODIFY Fix flowNodeToGraphNode to preserve manuallyPositioned
apps/web/src/assets/styles/globals.css MODIFY Add selection and drag CSS styles
apps/web/src/modules/diagram/lib/elk-layout.ts READ resolvePositions already skips manuallyPositioned — verify, no changes needed
apps/web/src/modules/diagram/types/graph.ts READ manuallyPositioned and position fields already defined — no changes needed
apps/web/src/modules/diagram/hooks/useAutoLayout.ts READ Auto-layout direction/routing change triggers re-layout — manually positioned nodes are already protected by resolvePositions

Library & Framework Requirements

No new packages required. Everything built with existing dependencies:

  • @xyflow/react 12.10.1 — onNodeDragStop, onSelectionChange, applyNodeChanges, applyEdgeChanges, built-in selection/drag behavior
  • zustand 5.0.8 — selectedNodeIds state addition

Anti-Patterns to Avoid

  • NEVER switch to selectionOnDrag mode — this fundamentally changes the interaction model from slippy-map to Figma-style. The UX spec has not approved this change.
  • NEVER override deleteKeyCode — node deletion requires CRDT integration (Epic 4). Allowing delete now would cause data inconsistency.
  • NEVER put nodeTypes or edgeTypes inside the component — causes re-renders (established pattern from Stories 2.1-2.8)
  • NEVER import from reactflow — use @xyflow/react (v12+)
  • NEVER use require() — ESM-only project
  • NEVER create a separate position overrides map — use the existing manuallyPositioned field on DiagramNode
  • NEVER create barrel index.ts files — per project rules
  • NEVER modify the onNodesChange handler to filter position changes — the existing applyNodeChanges pipeline correctly handles all change types including position, selection, and dimensions
  • NEVER add resize handles — node sizes are fixed per diagram type, determined by constants
  • DO NOT break existing BFS highlight behavior — it coexists with native selection
  • DO NOT break existing tests — 180 tests must continue passing (11 test files)

Previous Story Intelligence (Story 2.8 — Flowchart)

Key learnings to carry forward:

  • 497 tests after Story 2.8, now 180 in web package (rest in other packages) — must not regress
  • Code review found: use || not ?? for empty string color guards, reuse HIDDEN_HANDLE constant
  • DiagramCanvas nodeTypes/edgeTypes MUST be defined OUTSIDE the component (performance)
  • graphNodeToFlowNode already spreads all DiagramNode fields into data — custom nodes access fields via data.* casting
  • flowNodeToGraphNode preserves tag, icon, color, w, lane, group, columns, lifeline, parentId — but MISSING manuallyPositioned (this story fixes it)

Git Intelligence

Recent commits:

  • 0ff5450 feat: implement Story 2.8 — Flowchart diagram type renderer
  • 1ff8ff8 feat: implement Stories 2.4-2.7 — E-R, Org Chart, Architecture, Sequence diagram type renderers
  • 0a7838a feat: implement Story 2.3 — BPMN diagram type renderer
  • 7dd5af1 feat: implement Story 2.2 — ELK.js auto-layout engine in Web Worker
  • 5033109 feat: implement Story 2.1 — canvas workspace with @xyflow/react and unified graph model

Established patterns:

  • Commit message: feat: implement Story X.Y — description
  • Feature code in apps/web/src/modules/diagram/
  • Co-located tests next to source files
  • CSS in globals.css — no CSS modules, no Tailwind utility classes for diagram-specific styles

Latest Tech Information

@xyflow/react 12.10.1 — Selection & Drag API:

  • onNodeDragStop: (event, node, nodes) => void — fires when drag ends. node is the primary dragged node, nodes includes all selected nodes in a multi-drag. The node's position is already updated when this fires.
  • onSelectionChange: ({ nodes, edges }) => void — fires when selection changes. Receives currently selected nodes and edges.
  • applyNodeChanges handles 6 change types: position, select, dimensions, remove, add, replace. All are already flowing through the existing store.
  • .react-flow__node.selected and .react-flow__edge.selected CSS classes are automatically applied by the library. No manual class management needed.
  • selectionKeyCode defaults to "Shift" — Shift+drag draws rectangle selection box.
  • multiSelectionKeyCode defaults to "Meta" (macOS) — Cmd+click adds to selection.
  • nodesDraggable and elementsSelectable default to true — already active.

CSS Variable Integration:

  • @xyflow/react provides --xy-node-boxshadow-selected-default for default selection styling. We override this with our custom --node-selected variable for brand consistency.
  • --xy-edge-stroke-selected-default for edges — we override with --edge-selected.

References

  • [Source: _bmad-output/planning-artifacts/epics.md#Story 2.9] — Full AC: selection states, drag repositioning, multi-select, position persistence, badge candidates
  • [Source: _bmad-output/planning-artifacts/epics.md#Technical Notes] — @xyflow/react built-in selection/drag, onNodesChange/onEdgesChange, manuallyPositioned, selection state for Epic 3
  • [Source: _bmad-output/planning-artifacts/architecture.md#line 42] — "Manual repositioning must coexist with auto-layout"
  • [Source: _bmad-output/planning-artifacts/architecture.md#line 156] — Lean JSON model, manual position overrides approach
  • [Source: _bmad-output/planning-artifacts/ux-design-specification.md#line 93] — "Manual repositioning is optional for fine-tuning"
  • [Source: _bmad-output/planning-artifacts/ux-design-specification.md#line 243] — "Clean auto-layout by default, manual repositioning optional"
  • [Source: _bmad-output/planning-artifacts/ux-design-specification.md#line 255] — "Rectangle drag selection as AI scope" for badge referencing
  • [Source: apps/web/src/modules/diagram/types/graph.ts#27-28] — DiagramNode.position and DiagramNode.manuallyPositioned already defined
  • [Source: apps/web/src/modules/diagram/lib/elk-layout.ts#133-134] — resolvePositions skips manuallyPositioned nodes
  • [Source: apps/web/src/modules/diagram/lib/graph-converter.ts#244-260] — flowNodeToGraphNode missing manuallyPositioned
  • [Source: apps/web/src/modules/diagram/stores/useGraphStore.ts#53-56] — onNodesChange with applyNodeChanges already handles position changes
  • [Source: apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx#291-324] — ReactFlow component to add selection/drag props
  • [Source: apps/web/src/assets/styles/globals.css#16,20,44,47] — --node-selected and --edge-selected CSS variables already defined

Dev Agent Record

Agent Model Used

Claude Opus 4.6

Debug Log References

None — clean implementation, no blockers.

Completion Notes List

  • All 7 tasks completed with 0 issues
  • handleNodeDragStop uses Set for O(1) lookup of dragged node IDs (optimization over story's suggested find() pattern)
  • clearHighlight refactored to use useGraphStore.getState() pattern for consistency with other handlers, also clears selectedNodeIds
  • resolvePositions tests for manuallyPositioned already existed in elk-layout.test.ts (lines 464-497) — verified, no duplication needed
  • 6 new tests added: 2 in graph-converter.test.ts (manuallyPositioned roundtrip), 4 in useGraphStore.test.ts (selectedNodeIds)
  • Total web tests: 186 (was 180), all passing

File List

File Action Description
apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx MODIFIED Added onNodeDragStop, onSelectionChange handlers, explicit nodesDraggable/elementsSelectable props, selectedNodeIds clearing in clearHighlight
apps/web/src/modules/diagram/stores/useGraphStore.ts MODIFIED Added selectedNodeIds: string[] state, setSelectedNodeIds setter, included in reset()
apps/web/src/modules/diagram/lib/graph-converter.ts MODIFIED Added manuallyPositioned conditional spread in flowNodeToGraphNode
apps/web/src/assets/styles/globals.css MODIFIED Added .react-flow__node.selected, .react-flow__node.dragging, .react-flow__edge.selected path CSS styles
apps/web/src/modules/diagram/lib/graph-converter.test.ts MODIFIED Added 2 tests for manuallyPositioned roundtrip preservation
apps/web/src/modules/diagram/stores/useGraphStore.test.ts MODIFIED Added 4 tests for selectedNodeIds (default, set, clear, reset)

Senior Developer Review (AI)

Reviewer: Mou — 2026-02-28 Review Agent: Claude Opus 4.6 Outcome: Approved with fixes applied

Issues Found: 0 High, 4 Medium, 3 Low — all fixed

# Severity Issue Fix Applied
M1 MEDIUM .dimmed opacity (0.2) suppresses .selected box-shadow ring Added .react-flow__node.dimmed.selected { opacity: 0.4 } override in globals.css
M2 MEDIUM Double-update of selectedNodeIds on pane click (clearHighlight + onSelectionChange) Removed setSelectedNodeIds([]) from clearHighlight; onSelectionChange is now single source of truth
M3 MEDIUM Redundant data.position in handleNodeDragStop (written but never consumed by serializer) Removed position: dragged.position from data spread; only manuallyPositioned: true remains
M4 MEDIUM No component-level tests for new handler logic Acknowledged — unit coverage is solid; component tests deferred to E2E framework setup
L1 LOW Non-null assertion ! after find() Replaced with Map.get() pattern
L2 LOW O(m) find() after O(1) Set check Replaced Set + find() with single Map<string, Node> for O(1) lookup
L3 LOW Unnecessary store update when selectedNodeIds already empty Resolved by M2 fix — clearHighlight no longer touches selectedNodeIds

All ACs verified as implemented. All tasks marked [x] confirmed done. 186 tests passing, 0 regressions.