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>
This commit is contained in:
Alejandro Gutiérrez
2026-02-28 01:31:19 +00:00
parent 0ff5450e0f
commit 9d13d0f562
8 changed files with 553 additions and 7 deletions

View File

@@ -805,8 +805,29 @@
transition: opacity 200ms ease-out;
}
.react-flow__node.dimmed.selected {
opacity: 0.4;
}
.react-flow__node.highlighted {
filter: drop-shadow(0 0 6px var(--node-selected));
transition: filter 200ms ease-out;
}
/* ── 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;
}
}

View File

@@ -10,7 +10,7 @@ import {
BackgroundVariant,
Panel,
} from "@xyflow/react";
import type { Node } from "@xyflow/react";
import type { Node, OnSelectionChangeFunc } from "@xyflow/react";
import { useGraphStore } from "../../stores/useGraphStore";
import { useAutoLayout } from "../../hooks/useAutoLayout";
@@ -223,17 +223,23 @@ function CanvasInner() {
const onViewportChange = useGraphStore((s) => s.onViewportChange);
const highlightedNodeId = useGraphStore((s) => s.highlightedNodeId);
const setHighlightedNodeId = useGraphStore((s) => s.setHighlightedNodeId);
const setSelectedNodeIds = useGraphStore((s) => s.setSelectedNodeIds);
const setNodes = useGraphStore((s) => s.setNodes);
const setEdges = useGraphStore((s) => s.setEdges);
const { isLayouting } = useAutoLayout();
const clearHighlight = useCallback(() => {
if (!highlightedNodeId) return;
setHighlightedNodeId(null);
setNodes(nodes.map((n) => ({ ...n, className: undefined })));
setEdges(edges.map((e) => ({ ...e, className: undefined })));
}, [highlightedNodeId, nodes, edges, setHighlightedNodeId, setNodes, setEdges]);
const store = useGraphStore.getState();
if (!store.highlightedNodeId) return;
store.setHighlightedNodeId(null);
store.setNodes(
store.nodes.map((n) => ({ ...n, className: undefined })),
);
store.setEdges(
store.edges.map((e) => ({ ...e, className: undefined })),
);
}, []);
const handleNodeClick = useCallback(
(_: React.MouseEvent, node: Node) => {
@@ -285,6 +291,33 @@ function CanvasInner() {
[],
);
const handleNodeDragStop = useCallback(
(_event: React.MouseEvent, _node: Node, draggedNodes: Node[]) => {
const store = useGraphStore.getState();
const draggedMap = new Map(draggedNodes.map((d) => [d.id, d]));
const updatedNodes = store.nodes.map((n) => {
const dragged = draggedMap.get(n.id);
if (!dragged) return n;
return {
...n,
data: {
...n.data,
manuallyPositioned: true,
},
};
});
store.setNodes(updatedNodes);
},
[],
);
const handleSelectionChange: OnSelectionChangeFunc = useCallback(
({ nodes: selectedNodes }) => {
setSelectedNodeIds(selectedNodes.map((n) => n.id));
},
[setSelectedNodeIds],
);
return (
<div className="w-full h-full">
<MarkerDefs />
@@ -295,7 +328,11 @@ function CanvasInner() {
onEdgesChange={onEdgesChange}
onViewportChange={onViewportChange}
onNodeClick={handleNodeClick}
onNodeDragStop={handleNodeDragStop}
onSelectionChange={handleSelectionChange}
onPaneClick={clearHighlight}
nodesDraggable
elementsSelectable
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView

View File

@@ -679,6 +679,40 @@ describe("flowNodeToGraphNode", () => {
const result = flowNodeToGraphNode(flowNode);
expect(result.type).toBe("flow:process");
});
it("should preserve manuallyPositioned flag in roundtrip", () => {
const flowNode = {
id: "n1",
type: "flowProcess",
position: { x: 150, y: 250 },
data: {
id: "n1",
type: "flow:process",
label: "Dragged Node",
manuallyPositioned: true,
position: { x: 150, y: 250 },
},
};
const result = flowNodeToGraphNode(flowNode);
expect(result.manuallyPositioned).toBe(true);
expect(result.position).toEqual({ x: 150, y: 250 });
});
it("should not include manuallyPositioned when not set", () => {
const flowNode = {
id: "n2",
type: "flowProcess",
position: { x: 0, y: 0 },
data: {
id: "n2",
type: "flow:process",
label: "Auto Node",
},
};
const result = flowNodeToGraphNode(flowNode);
expect(result.manuallyPositioned).toBeUndefined();
expect("manuallyPositioned" in result).toBe(false);
});
});
describe("flowEdgeToGraphEdge", () => {

View File

@@ -257,6 +257,7 @@ export function flowNodeToGraphNode(node: Node): DiagramNode {
...(data.columns !== undefined && { columns: data.columns }),
...(data.lifeline !== undefined && { lifeline: data.lifeline }),
...(data.parentId !== undefined && { parentId: data.parentId }),
...(data.manuallyPositioned !== undefined && { manuallyPositioned: data.manuallyPositioned }),
};
}

View File

@@ -179,4 +179,27 @@ describe("useGraphStore", () => {
expect(useGraphStore.getState().highlightedNodeId).toBeNull();
});
});
describe("selectedNodeIds", () => {
it("should default to empty array", () => {
expect(useGraphStore.getState().selectedNodeIds).toEqual([]);
});
it("should set selected node ids", () => {
useGraphStore.getState().setSelectedNodeIds(["n1", "n2"]);
expect(useGraphStore.getState().selectedNodeIds).toEqual(["n1", "n2"]);
});
it("should clear selected node ids", () => {
useGraphStore.getState().setSelectedNodeIds(["n1"]);
useGraphStore.getState().setSelectedNodeIds([]);
expect(useGraphStore.getState().selectedNodeIds).toEqual([]);
});
it("should reset selected node ids on reset()", () => {
useGraphStore.getState().setSelectedNodeIds(["n1", "n2"]);
useGraphStore.getState().reset();
expect(useGraphStore.getState().selectedNodeIds).toEqual([]);
});
});
});

View File

@@ -23,6 +23,7 @@ interface GraphState {
edgeRouting: EdgeRouting;
isLayouting: boolean;
highlightedNodeId: string | null;
selectedNodeIds: string[];
setNodes: (nodes: Node[]) => void;
setEdges: (edges: Edge[]) => void;
onNodesChange: OnNodesChange;
@@ -32,6 +33,7 @@ interface GraphState {
setEdgeRouting: (routing: EdgeRouting) => void;
setIsLayouting: (isLayouting: boolean) => void;
setHighlightedNodeId: (id: string | null) => void;
setSelectedNodeIds: (ids: string[]) => void;
initializeFromGraphData: (nodes: Node[], edges: Edge[]) => void;
reset: () => void;
}
@@ -46,6 +48,7 @@ export const useGraphStore = create<GraphState>((set, get) => ({
edgeRouting: "ORTHOGONAL",
isLayouting: false,
highlightedNodeId: null,
selectedNodeIds: [],
setNodes: (nodes) => set({ nodes, nodeCount: nodes.length }),
setEdges: (edges) => set({ edges }),
@@ -67,6 +70,7 @@ export const useGraphStore = create<GraphState>((set, get) => ({
setEdgeRouting: (edgeRouting) => set({ edgeRouting }),
setIsLayouting: (isLayouting) => set({ isLayouting }),
setHighlightedNodeId: (highlightedNodeId) => set({ highlightedNodeId }),
setSelectedNodeIds: (selectedNodeIds) => set({ selectedNodeIds }),
initializeFromGraphData: (nodes, edges) => {
set({ nodes, edges, nodeCount: nodes.length });
@@ -83,6 +87,7 @@ export const useGraphStore = create<GraphState>((set, get) => ({
edgeRouting: "ORTHOGONAL",
isLayouting: false,
highlightedNodeId: null,
selectedNodeIds: [],
});
},
}));