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>
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
-
Given I click on a node on the canvas, When the node is clicked, Then it shows a selected state with a
--node-selectedhighlight border, And a visual indicator distinguishes the selected node from unselected ones. -
Given I click on an edge on the canvas, When the edge is clicked, Then it highlights with
--edge-selectedcolor, And edge metadata (label, type) remains visible. -
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: trueprevents auto-layout from overriding). -
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.
-
Given I click on empty canvas area, When no element is under the cursor, Then all selections are cleared.
Tasks / Subtasks
-
Task 1: Add
onNodeDragStophandler in DiagramCanvas to setmanuallyPositionedflag (AC: #3)- 1.1: Create
onNodeDragStopcallback that updatesdata.manuallyPositioned = trueanddata.positionfor all dragged nodes - 1.2: Handle multi-node drag (the
nodesarray parameter includes all selected nodes being dragged) - 1.3: Wire
onNodeDragStopprop on<ReactFlow>component
- 1.1: Create
-
Task 2: Fix
flowNodeToGraphNoderoundtrip formanuallyPositionedflag (AC: #3)- 2.1: Add
...(data.manuallyPositioned !== undefined && { manuallyPositioned: data.manuallyPositioned })to the conditional spread inflowNodeToGraphNode
- 2.1: Add
-
Task 3: Add selection CSS styles for nodes and edges (AC: #1, #2)
- 3.1: Add
.react-flow__node.selectedstyle using--node-selectedCSS variable (box-shadow ring) - 3.2: Add
.react-flow__edge.selected pathstyle using--edge-selectedCSS variable (stroke color + width) - 3.3: Add drag-in-progress visual feedback (
.react-flow__node.draggingsubtle shadow)
- 3.1: Add
-
Task 4: Configure
<ReactFlow>selection and drag props (AC: #1, #2, #4, #5)- 4.1: Ensure
nodesDraggableistrue(default, but make explicit) - 4.2: Ensure
elementsSelectableistrue(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-styleselectionOnDragas that changes the core interaction model without UX spec approval)
- 4.1: Ensure
-
Task 5: Add
selectedNodeIdsstate to Zustand store for Epic 3 integration (AC: #4)- 5.1: Add
selectedNodeIds: string[]toGraphStateinterface - 5.2: Add
setSelectedNodeIdssetter - 5.3: Wire
onSelectionChangeprop on<ReactFlow>to updateselectedNodeIdsin store - 5.4: Clear
selectedNodeIdsononPaneClick(alongside existingclearHighlight) - 5.5: Reset
selectedNodeIdsinreset()method
- 5.1: Add
-
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
onPaneClickclears both BFS highlight AND native selection state in store
- 6.1: In
-
Task 7: Tests (AC: all)
- 7.1: Unit tests for
flowNodeToGraphNodewithmanuallyPositionedflag — 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
selectedNodeIdsstore state — set, clear, reset - 7.4: All tests pass — 186 web tests, 0 regressions
- 7.1: Unit tests for
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:
onNodeDragStophandler that marks dragged nodes asmanuallyPositioned: true- Graph-converter roundtrip fix for
manuallyPositionedfield - Selection CSS styles using existing
--node-selectedand--edge-selectedvariables - Drag-in-progress visual feedback
selectedNodeIdsstate in Zustand store (consumed by Epic 3 badge system)onSelectionChangewiring 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:
-
Unified Graph Data Model (Decision 1):
manuallyPositioned: booleanfield already exists onDiagramNode(graph.ts line 28). Positions stored inposition?: { x: number; y: number }(line 27). No new fields needed on the graph model. -
Manual position coexistence: Architecture explicitly states: "Manual repositioning must coexist with auto-layout" (line 42). ELK layout's
resolvePositions()already skipsmanuallyPositionednodes (elk-layout.ts line 133-134). This was pre-implemented in Story 2.2. -
Selection state for Epic 3 badge system: Architecture specifies that "selected nodes appear as badge candidates (for Epic 3 integration)". The
selectedNodeIdsarray in the Zustand store provides this bridge. -
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
manuallyPositioneddirectly on DiagramNode along withposition— this is simpler and matches the existing pattern. The dev agent should follow the existing implementation, NOT create a separate overrides map. -
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:
onNodeDragStopreceives(event, node, nodes)— thenodesarray contains ALL nodes being dragged (important for multi-select drag)- Must update
data.manuallyPositioned = truesoresolvePositions()skips these nodes on re-layout - Must also update
data.positionsoflowNodeToGraphNodecan persist it - The node's
position(the @xyflow/react position) is already updated byapplyNodeChanges— we just need the flag indata
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:
!importanton edge styles is needed because custom edge components set inline styles viastyle={{ stroke: "var(--diagram-X)" }}border-radius: 6pxmatches the standard node border-radius used across all diagram types- Dragging state adds an elevation shadow for depth feedback
- The
.selectedclass 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:
- BFS highlight is informational — shows connected paths. It uses
className(highlighted/dimmed) which is separate from the.selectedclass. - Native selection is functional — enables drag, multi-select, and tracks
selectedNodeIdsfor badge referencing. - Both can coexist — a node can be both
.selectedand.highlightedsimultaneously. The CSS stacks correctly (box-shadow from.selected, drop-shadow from.highlighted). - Pane click clears both —
onPaneClickalready callsclearHighlight; addsetSelectedNodeIds([])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 approvedpanOnDrag={false}— would break existing pan behaviordeleteKeyCode— 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/react12.10.1 —onNodeDragStop,onSelectionChange,applyNodeChanges,applyEdgeChanges, built-in selection/drag behaviorzustand5.0.8 —selectedNodeIdsstate addition
Anti-Patterns to Avoid
- NEVER switch to
selectionOnDragmode — 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
nodeTypesoredgeTypesinside 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
manuallyPositionedfield onDiagramNode - NEVER create barrel
index.tsfiles — per project rules - NEVER modify the
onNodesChangehandler to filter position changes — the existingapplyNodeChangespipeline 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, reuseHIDDEN_HANDLEconstant - DiagramCanvas nodeTypes/edgeTypes MUST be defined OUTSIDE the component (performance)
graphNodeToFlowNodealready spreads all DiagramNode fields intodata— custom nodes access fields viadata.*castingflowNodeToGraphNodepreservestag,icon,color,w,lane,group,columns,lifeline,parentId— but MISSINGmanuallyPositioned(this story fixes it)
Git Intelligence
Recent commits:
0ff5450 feat: implement Story 2.8 — Flowchart diagram type renderer1ff8ff8 feat: implement Stories 2.4-2.7 — E-R, Org Chart, Architecture, Sequence diagram type renderers0a7838a feat: implement Story 2.3 — BPMN diagram type renderer7dd5af1 feat: implement Story 2.2 — ELK.js auto-layout engine in Web Worker5033109 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.nodeis the primary dragged node,nodesincludes all selected nodes in a multi-drag. The node'spositionis already updated when this fires.onSelectionChange: ({ nodes, edges }) => void— fires when selection changes. Receives currently selected nodes and edges.applyNodeChangeshandles 6 change types:position,select,dimensions,remove,add,replace. All are already flowing through the existing store..react-flow__node.selectedand.react-flow__edge.selectedCSS classes are automatically applied by the library. No manual class management needed.selectionKeyCodedefaults to"Shift"— Shift+drag draws rectangle selection box.multiSelectionKeyCodedefaults to"Meta"(macOS) — Cmd+click adds to selection.nodesDraggableandelementsSelectabledefault totrue— already active.
CSS Variable Integration:
- @xyflow/react provides
--xy-node-boxshadow-selected-defaultfor default selection styling. We override this with our custom--node-selectedvariable for brand consistency. --xy-edge-stroke-selected-defaultfor 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
handleNodeDragStopusesSetfor O(1) lookup of dragged node IDs (optimization over story's suggestedfind()pattern)clearHighlightrefactored to useuseGraphStore.getState()pattern for consistency with other handlers, also clearsselectedNodeIdsresolvePositionstests formanuallyPositionedalready 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.