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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user