# 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 - [x] Task 1: Add `onNodeDragStop` handler in DiagramCanvas to set `manuallyPositioned` flag (AC: #3) - [x] 1.1: Create `onNodeDragStop` callback that updates `data.manuallyPositioned = true` and `data.position` for all dragged nodes - [x] 1.2: Handle multi-node drag (the `nodes` array parameter includes all selected nodes being dragged) - [x] 1.3: Wire `onNodeDragStop` prop on `` component - [x] Task 2: Fix `flowNodeToGraphNode` roundtrip for `manuallyPositioned` flag (AC: #3) - [x] 2.1: Add `...(data.manuallyPositioned !== undefined && { manuallyPositioned: data.manuallyPositioned })` to the conditional spread in `flowNodeToGraphNode` - [x] Task 3: Add selection CSS styles for nodes and edges (AC: #1, #2) - [x] 3.1: Add `.react-flow__node.selected` style using `--node-selected` CSS variable (box-shadow ring) - [x] 3.2: Add `.react-flow__edge.selected path` style using `--edge-selected` CSS variable (stroke color + width) - [x] 3.3: Add drag-in-progress visual feedback (`.react-flow__node.dragging` subtle shadow) - [x] Task 4: Configure `` selection and drag props (AC: #1, #2, #4, #5) - [x] 4.1: Ensure `nodesDraggable` is `true` (default, but make explicit) - [x] 4.2: Ensure `elementsSelectable` is `true` (default, but make explicit) - [x] 4.3: Keep default selection behavior: Shift+drag for rectangle selection, Meta/Ctrl+click for multi-select additive - [x] 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) - [x] Task 5: Add `selectedNodeIds` state to Zustand store for Epic 3 integration (AC: #4) - [x] 5.1: Add `selectedNodeIds: string[]` to `GraphState` interface - [x] 5.2: Add `setSelectedNodeIds` setter - [x] 5.3: Wire `onSelectionChange` prop on `` to update `selectedNodeIds` in store - [x] 5.4: Clear `selectedNodeIds` on `onPaneClick` (alongside existing `clearHighlight`) - [x] 5.5: Reset `selectedNodeIds` in `reset()` method - [x] Task 6: Clear highlight state when node is selected via native selection (AC: #1, #5) - [x] 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 - [x] 6.2: Ensure `onPaneClick` clears both BFS highlight AND native selection state in store - [x] Task 7: Tests (AC: all) - [x] 7.1: Unit tests for `flowNodeToGraphNode` with `manuallyPositioned` flag — verify roundtrip preserves the flag - [x] 7.2: Unit tests for `resolvePositions` — verified already tested in elk-layout.test.ts (lines 464-497) - [x] 7.3: Unit tests for `selectedNodeIds` store state — set, clear, reset - [x] 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 `` 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 | `` default prop | | Elements selectable (default) | Active | `` default prop | | Shift+drag rectangle selection | Active | `` 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 ```typescript // 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: ``` **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 ```typescript // 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 ```typescript // In useGraphStore.ts — GraphState interface selectedNodeIds: string[]; setSelectedNodeIds: (ids: string[]) => void; // Initial state selectedNodeIds: [], // Setter setSelectedNodeIds: (selectedNodeIds) => set({ selectedNodeIds }), // Reset selectedNodeIds: [], ``` ### `onSelectionChange` Wiring ```typescript // 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: ``` ### Selection CSS Styles ```css /* ── 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 both** — `onPaneClick` 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 `` relies on all defaults. For clarity and future-proofing, make selection/drag props explicit: ```tsx ``` **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` 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.**