Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions src/components/GroupsOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ interface GroupControlsProps {

// Renders the group header and resize handles - displayed above nodes (z-index 5)
function GroupControls({ groupId, zoom }: GroupControlsProps) {
const { groups, updateGroup, deleteGroup, moveGroupNodes, toggleGroupLock } = useWorkflowStore();
const { groups, selectedGroupId, setSelectedGroupId, updateGroup, deleteGroupWithNodes, moveGroupNodes, toggleGroupLock } = useWorkflowStore();
const group = groups[groupId];

const [isEditing, setIsEditing] = useState(false);
Expand Down Expand Up @@ -132,8 +132,8 @@ function GroupControls({ groupId, zoom }: GroupControlsProps) {
);

const handleDelete = useCallback(() => {
deleteGroup(groupId);
}, [groupId, deleteGroup]);
deleteGroupWithNodes(groupId);
}, [groupId, deleteGroupWithNodes]);

const handleToggleLock = useCallback(() => {
toggleGroupLock(groupId);
Expand All @@ -142,6 +142,7 @@ function GroupControls({ groupId, zoom }: GroupControlsProps) {
// Header drag handlers
const handleHeaderMouseDown = useCallback(
(e: React.MouseEvent) => {
setSelectedGroupId(groupId);
if (
(e.target as HTMLElement).closest("button") ||
(e.target as HTMLElement).closest("input")
Expand All @@ -153,7 +154,7 @@ function GroupControls({ groupId, zoom }: GroupControlsProps) {
setIsDragging(true);
dragStartRef.current = { x: e.clientX, y: e.clientY };
},
[]
[groupId, setSelectedGroupId]
);

// Resize handlers
Expand Down Expand Up @@ -282,7 +283,9 @@ function GroupControls({ groupId, zoom }: GroupControlsProps) {
>
{/* Header - interactive */}
<div
className="absolute top-0 left-0 right-0 flex items-center gap-2 px-3 cursor-grab active:cursor-grabbing select-none rounded-t-xl pointer-events-auto"
className={`absolute top-0 left-0 right-0 flex items-center gap-2 px-3 cursor-grab active:cursor-grabbing select-none rounded-t-xl pointer-events-auto ${
selectedGroupId === groupId ? "ring-2 ring-white/60" : ""
}`}
style={{ backgroundColor: bgColor, height: HEADER_HEIGHT }}
onMouseDown={handleHeaderMouseDown}
>
Expand Down
7 changes: 5 additions & 2 deletions src/components/KeyboardShortcutsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ const shortcutGroups: ShortcutGroup[] = [
title: "General",
shortcuts: [
{ keys: [`${modKey}`, "Enter"], description: "Run workflow" },
{ keys: [`${modKey}`, "Z"], description: "Undo last canvas change" },
{ keys: [`${modKey}`, "Shift", "Z"], description: "Redo canvas change" },
{ keys: [`${modKey}`, "Y"], description: "Redo canvas change" },
{ keys: [`${modKey}`, "C"], description: "Copy selected nodes" },
{ keys: [`${modKey}`, "D"], description: "Duplicate selected nodes/groups" },
{ keys: [`${modKey}`, "V"], description: "Paste nodes / image / text" },
{ keys: ["?"], description: "Show keyboard shortcuts" },
],
Expand Down Expand Up @@ -49,7 +53,7 @@ const shortcutGroups: ShortcutGroup[] = [
shortcuts: [
{ keys: ["Scroll"], description: "Zoom in / out" },
{ keys: ["Trackpad"], description: "Pan (macOS)" },
{ keys: ["Delete"], description: "Delete selected nodes" },
{ keys: ["Delete"], description: "Delete selected nodes or selected group" },
],
},
];
Expand Down Expand Up @@ -146,4 +150,3 @@ export function KeyboardShortcutsDialog({ isOpen, onClose }: KeyboardShortcutsDi
</div>
);
}

87 changes: 83 additions & 4 deletions src/components/WorkflowCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ const findScrollableAncestor = (target: HTMLElement, deltaX: number, deltaY: num
};

export function WorkflowCanvas() {
const { nodes, edges, groups, onNodesChange, onEdgesChange, onConnect, addNode, updateNodeData, loadWorkflow, getNodeById, addToGlobalHistory, setNodeGroupId, executeWorkflow, isModalOpen, showQuickstart, setShowQuickstart, navigationTarget, setNavigationTarget, captureSnapshot, applyEditOperations, setWorkflowMetadata, canvasNavigationSettings, setShortcutsDialogOpen } =
const { nodes, edges, groups, selectedGroupId, setSelectedGroupId, deleteSelection, onNodesChange, onEdgesChange, onConnect, addNode, updateNodeData, loadWorkflow, getNodeById, addToGlobalHistory, setNodeGroupId, executeWorkflow, isModalOpen, showQuickstart, setShowQuickstart, navigationTarget, setNavigationTarget, captureSnapshot, applyEditOperations, setWorkflowMetadata, canvasNavigationSettings, setShortcutsDialogOpen, undo, redo, canUndo, canRedo, _pushHistorySnapshot: pushHistorySnapshot } =
useWorkflowStore();
const { screenToFlowPosition, getViewport, zoomIn, zoomOut, setViewport, setCenter } = useReactFlow();
const { show: showToast } = useToast();
Expand All @@ -251,6 +251,7 @@ export function WorkflowCanvas() {
const [isBuildingWorkflow, setIsBuildingWorkflow] = useState(false);
const [showNewProjectSetup, setShowNewProjectSetup] = useState(false);
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const lastUndoRedoAtRef = useRef(0);

// Detect if canvas is empty for showing quickstart
const isCanvasEmpty = nodes.length === 0;
Expand Down Expand Up @@ -979,8 +980,15 @@ export function WorkflowCanvas() {
setConnectionDrop(null);
}, []);

const handleBeforeDelete = useCallback(() => {
if (!isModalOpen) {
pushHistorySnapshot();
}
return true;
}, [isModalOpen, pushHistorySnapshot]);

// Get copy/paste functions and clipboard from store
const { copySelectedNodes, pasteNodes, clearClipboard, clipboard } = useWorkflowStore();
const { copySelectedNodes, pasteNodes, duplicateSelectedNodes, clearClipboard, clipboard } = useWorkflowStore();

// Add non-passive wheel listener to handle zoom/pan and prevent browser navigation
// This replaces the onWheel prop which is passive by default and can't preventDefault
Expand Down Expand Up @@ -1067,6 +1075,11 @@ export function WorkflowCanvas() {
return;
}

// Let modal-specific keyboard handlers (e.g. Annotation modal) own undo/redo.
if (isModalOpen) {
return;
}

// Handle keyboard shortcuts dialog (? key)
if (event.key === "?" && !event.ctrlKey && !event.metaKey) {
event.preventDefault();
Expand All @@ -1081,13 +1094,76 @@ export function WorkflowCanvas() {
return;
}

// Undo / Redo
if ((event.ctrlKey || event.metaKey) && (event.key === "z" || event.key === "Z")) {
event.preventDefault();
if (event.repeat) return;
const now = Date.now();
if (now - lastUndoRedoAtRef.current < 120) return;
lastUndoRedoAtRef.current = now;
if (event.shiftKey) {
if (canRedo) redo();
} else {
if (canUndo) undo();
}
return;
}

if ((event.ctrlKey || event.metaKey) && (event.key === "y" || event.key === "Y")) {
event.preventDefault();
if (event.repeat) return;
const now = Date.now();
if (now - lastUndoRedoAtRef.current < 120) return;
lastUndoRedoAtRef.current = now;
if (canRedo) redo();
return;
}

// Handle copy (Ctrl/Cmd + C)
if ((event.ctrlKey || event.metaKey) && event.key === "c") {
event.preventDefault();
copySelectedNodes();
return;
}

// Handle duplicate (Ctrl/Cmd + D)
if ((event.ctrlKey || event.metaKey) && (event.key === "d" || event.key === "D")) {
event.preventDefault();
duplicateSelectedNodes({ selectedGroupId });
return;
}

// Delete selected group (group + contained nodes + connected edges)
if (event.key === "Delete" || event.key === "Backspace") {
const selectedNodes = nodes.filter((node) => node.selected);
const selectedNodeIds = selectedNodes.map((node) => node.id);

const selectedGroupIds = new Set<string>();
if (selectedGroupId) selectedGroupIds.add(selectedGroupId);

if (selectedNodes.length > 0) {
const candidateGroupIds = Array.from(
new Set(selectedNodes.map((node) => node.groupId).filter((groupId): groupId is string => !!groupId))
);

for (const groupId of candidateGroupIds) {
const groupNodes = nodes.filter((node) => node.groupId === groupId);
const selectedSet = new Set(selectedNodeIds);
const isEntireGroupSelected =
groupNodes.length > 0 && groupNodes.every((node) => selectedSet.has(node.id));
if (isEntireGroupSelected) {
selectedGroupIds.add(groupId);
}
}
}

if (selectedGroupIds.size > 0 || selectedNodeIds.length > 0) {
event.preventDefault();
deleteSelection(selectedNodeIds, Array.from(selectedGroupIds));
return;
}
}

// Helper to get viewport center position in flow coordinates
const getViewportCenter = () => {
const viewport = getViewport();
Expand Down Expand Up @@ -1330,7 +1406,7 @@ export function WorkflowCanvas() {
]);
});
}
}, [nodes, onNodesChange, copySelectedNodes, pasteNodes, clearClipboard, clipboard, getViewport, addNode, updateNodeData, executeWorkflow, setShortcutsDialogOpen]);
}, [isModalOpen, selectedGroupId, deleteSelection, nodes, onNodesChange, copySelectedNodes, pasteNodes, duplicateSelectedNodes, clearClipboard, clipboard, getViewport, addNode, updateNodeData, executeWorkflow, setShortcutsDialogOpen, undo, redo, canUndo, canRedo]);

useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
Expand Down Expand Up @@ -1644,12 +1720,15 @@ export function WorkflowCanvas() {
onConnect={handleConnect}
onConnectEnd={handleConnectEnd}
onNodeDragStop={handleNodeDragStop}
onPaneClick={() => setSelectedGroupId(null)}
onNodeClick={() => setSelectedGroupId(null)}
onSelectionChange={handleSelectionChange}
onBeforeDelete={handleBeforeDelete}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
isValidConnection={isValidConnection}
fitView
deleteKeyCode={["Backspace", "Delete"]}
deleteKeyCode={null}
multiSelectionKeyCode="Shift"
selectionOnDrag={
canvasNavigationSettings.selectionMode === "altDrag" || canvasNavigationSettings.selectionMode === "shiftDrag"
Expand Down
9 changes: 7 additions & 2 deletions src/components/__tests__/GroupsOverlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ vi.mock("@xyflow/react", () => ({
// Mock the workflow store
const mockUpdateGroup = vi.fn();
const mockDeleteGroup = vi.fn();
const mockDeleteGroupWithNodes = vi.fn();
const mockMoveGroupNodes = vi.fn();
const mockToggleGroupLock = vi.fn();
const mockSetSelectedGroupId = vi.fn();
const mockUseWorkflowStore = vi.fn();

vi.mock("@/store/workflowStore", () => ({
Expand Down Expand Up @@ -51,8 +53,11 @@ const createMockGroup = (overrides: Partial<Group> = {}): Group => ({
// Default store state factory
const createDefaultState = (overrides: { groups?: Record<string, Group> } = {}) => ({
groups: {},
selectedGroupId: null,
setSelectedGroupId: mockSetSelectedGroupId,
updateGroup: mockUpdateGroup,
deleteGroup: mockDeleteGroup,
deleteGroupWithNodes: mockDeleteGroupWithNodes,
moveGroupNodes: mockMoveGroupNodes,
toggleGroupLock: mockToggleGroupLock,
...overrides,
Expand Down Expand Up @@ -386,7 +391,7 @@ describe("GroupControlsOverlay", () => {
});

describe("Delete Group", () => {
it("should call deleteGroup when delete button is clicked", () => {
it("should call deleteGroupWithNodes when delete button is clicked", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
groups: { "group-1": createMockGroup() },
Expand All @@ -396,7 +401,7 @@ describe("GroupControlsOverlay", () => {
render(<GroupControlsOverlay />);

fireEvent.click(screen.getByTitle("Delete group"));
expect(mockDeleteGroup).toHaveBeenCalledWith("group-1");
expect(mockDeleteGroupWithNodes).toHaveBeenCalledWith("group-1");
});
});

Expand Down
Loading