diff --git a/src/app/globals.css b/src/app/globals.css index bb36dbbd..31982290 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -8,7 +8,7 @@ color-scheme: dark; --canvas-bg: #101112; --canvas-background: var(--canvas-bg); - --canvas-grid: #ffffff; + --canvas-grid: #454545; --surface-1: #171819; --surface-2: #202122; --surface-3: #2a2b2d; @@ -62,7 +62,7 @@ color-scheme: light; --canvas-bg: #f0f0f0; --canvas-background: linear-gradient(132deg, #E9DAEE 3.04%, #E9E9E9 50%, #DAD6E5 96.96%); - --canvas-grid: #EBEBEB; + --canvas-grid: #CBC4DD; --surface-1: #ffffff; --surface-2: #ffffff; --surface-3: #f3f3f2; diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 0016cf0f..9858b72e 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -158,6 +158,11 @@ interface ConnectionDropState { sourceNodeType?: NodeType; sourceNodeId: string | null; sourceHandleId: string | null; + sourceGroupId?: string; +} + +function getNodeGroupId(node: Node): string | undefined { + return (node as Node & { groupId?: string }).groupId; } function uniqueConnectionMediaTypes(mediaTypes: ConcreteHandleType[]): ConcreteHandleType[] { @@ -248,6 +253,7 @@ interface MultiAngleDialogState { sourceNodeId: string; sourceHandleId: string; flowPosition: { x: number; y: number }; + sourceGroupId?: string; settings: MultiAngleSettings; } @@ -424,6 +430,7 @@ export function WorkflowCanvas() { const onEdgesChange = useWorkflowStore((state) => state.onEdgesChange); const onConnect = useWorkflowStore((state) => state.onConnect); const addNode = useWorkflowStore((state) => state.addNode); + const addNodeToGroup = useWorkflowStore((state) => state.addNodeToGroup); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const loadWorkflow = useWorkflowStore((state) => state.loadWorkflow); const getNodeById = useWorkflowStore((state) => state.getNodeById); @@ -461,6 +468,17 @@ export function WorkflowCanvas() { const reactFlowWrapper = useRef(null); const tutorialViewportSet = useRef(false); + const addNodeWithOptionalGroup = useCallback(( + type: NodeType, + position: { x: number; y: number }, + sourceGroupId: string | undefined, + initialData?: Parameters[2] + ) => ( + sourceGroupId + ? addNodeToGroup(type, position, sourceGroupId, initialData) + : addNode(type, position, initialData) + ), [addNode, addNodeToGroup]); + const syncZoomPercent = useCallback(() => { setZoomPercent(Math.round(getViewport().zoom * 100)); }, [getViewport]); @@ -849,12 +867,13 @@ export function WorkflowCanvas() { if (!source) return; const { width } = getNodeDimensions(node); + const absolutePosition = absoluteNodePositions.get(node.id) ?? node.position; setCanvasNodeMenu(null); setConnectionDrop({ position: clientPosition, flowPosition: { - x: node.position.x + width + 160, - y: node.position.y, + x: absolutePosition.x + width + 160, + y: absolutePosition.y, }, intent: { direction: "fromOutput", @@ -869,6 +888,7 @@ export function WorkflowCanvas() { sourceNodeType: node.type as NodeType, sourceNodeId: node.id, sourceHandleId: source.handleId, + sourceGroupId: getNodeGroupId(node), }); if (tutorialActive) { @@ -876,7 +896,7 @@ export function WorkflowCanvas() { } return true; - }, [edges, tutorialActive]); + }, [absoluteNodePositions, edges, tutorialActive]); const openConnectionMenuFromHandle = useCallback((request: NodeHandleMenuRequest) => { if (isModalOpen) return; @@ -896,11 +916,12 @@ export function WorkflowCanvas() { if (!handleType) return; const { width } = getNodeDimensions(node); + const absolutePosition = absoluteNodePositions.get(node.id) ?? node.position; setConnectionDrop({ position: request.clientPosition, flowPosition: { - x: node.position.x + width + 160, - y: node.position.y, + x: absolutePosition.x + width + 160, + y: absolutePosition.y, }, intent: { ...intent, @@ -913,15 +934,17 @@ export function WorkflowCanvas() { sourceNodeType: node.type as NodeType, sourceNodeId: node.id, sourceHandleId: request.handleId || source?.handleId || SINGLE_OUTPUT_HANDLE_ID, + sourceGroupId: getNodeGroupId(node), }); } else { const { width } = getNodeDimensions(node); const handleType = intent.mediaTypes[0] ?? null; + const absolutePosition = absoluteNodePositions.get(node.id) ?? node.position; setConnectionDrop({ position: request.clientPosition, flowPosition: { - x: node.position.x - width - 120, - y: node.position.y, + x: absolutePosition.x - width - 120, + y: absolutePosition.y, }, intent, handleType, @@ -929,13 +952,14 @@ export function WorkflowCanvas() { mode: "drag", sourceNodeId: node.id, sourceHandleId: request.handleId || SINGLE_INPUT_HANDLE_ID, + sourceGroupId: getNodeGroupId(node), }); } if (tutorialActive) { useFTUXStore.getState().setConnectionMenuShown(true); } - }, [edges, isModalOpen, nodes, tutorialActive]); + }, [absoluteNodePositions, edges, isModalOpen, nodes, tutorialActive]); const nodeHandleMenuValue = useMemo(() => ({ openConnectionMenuFromHandle, @@ -1053,7 +1077,7 @@ export function WorkflowCanvas() { // Get current groupId of the node const currentNode = nodes.find((n) => n.id === node.id); - const currentGroupId = currentNode?.groupId; + const currentGroupId = currentNode ? getNodeGroupId(currentNode) : undefined; // Update groupId if it changed if (targetGroupId !== currentGroupId) { @@ -1316,6 +1340,7 @@ export function WorkflowCanvas() { mode: "drag", sourceNodeId: connectionState.fromNode.id, sourceHandleId: fromHandleId, + sourceGroupId: getNodeGroupId(connectionState.fromNode), }); // Tutorial tracking @@ -1328,7 +1353,7 @@ export function WorkflowCanvas() { // Handle the splitGrid action - uses automated grid detection const handleSplitGridAction = useCallback( - async (sourceNodeId: string, flowPosition: { x: number; y: number }) => { + async (sourceNodeId: string, flowPosition: { x: number; y: number }, sourceGroupId?: string) => { const sourceNode = getNodeById(sourceNodeId); if (!sourceNode) return; @@ -1373,10 +1398,14 @@ export function WorkflowCanvas() { const row = Math.floor(index / grid.cols); const col = index % grid.cols; - const nodeId = addNode("imageInput", { - x: flowPosition.x + col * (nodeWidth + gap), - y: flowPosition.y + row * (nodeHeight + gap), - }); + const nodeId = addNodeWithOptionalGroup( + "imageInput", + { + x: flowPosition.x + col * (nodeWidth + gap), + y: flowPosition.y + row * (nodeHeight + gap), + }, + sourceGroupId + ); // Get dimensions from the split image const img = new Image(); @@ -1397,7 +1426,7 @@ export function WorkflowCanvas() { setIsSplitting(false); } }, - [getNodeById, edges, addNode, updateNodeData, t] + [getNodeById, edges, addNodeWithOptionalGroup, updateNodeData, t] ); // Helper to get image from a node @@ -1520,12 +1549,12 @@ export function WorkflowCanvas() { async (selection: { type: NodeType | MenuAction; isAction: boolean }) => { if (!connectionDrop) return; - const { flowPosition, sourceNodeId, sourceHandleId, connectionType, handleType } = connectionDrop; + const { flowPosition, sourceNodeId, sourceHandleId, sourceGroupId, connectionType, handleType } = connectionDrop; // Handle actions differently from node creation if (selection.isAction) { if (selection.type === "splitGridImmediate" && sourceNodeId) { - handleSplitGridAction(sourceNodeId, flowPosition); + handleSplitGridAction(sourceNodeId, flowPosition, sourceGroupId); } else if ( selection.type === "multiAngleImage" && sourceNodeId && @@ -1537,6 +1566,7 @@ export function WorkflowCanvas() { sourceNodeId, sourceHandleId, flowPosition, + sourceGroupId, settings: { ...DEFAULT_MULTI_ANGLE_SETTINGS }, }); } @@ -1548,7 +1578,12 @@ export function WorkflowCanvas() { const nodeType = selection.type as NodeType; // Create the new node at the drop position (empty - tutorial will populate after connection) - const newNodeId = addNode(nodeType, flowPosition, await buildDefaultGenerationNodeData(nodeType)); + const newNodeId = addNodeWithOptionalGroup( + nodeType, + flowPosition, + sourceGroupId, + await buildDefaultGenerationNodeData(nodeType) + ); // Tutorial tracking if (tutorialActive && nodeType === "nanoBanana") { @@ -1787,7 +1822,7 @@ export function WorkflowCanvas() { selectSingleNode(newNodeId); setConnectionDrop(null); }, - [connectionDrop, addNode, onConnect, nodes, edges, handleSplitGridAction, getImageFromNode, updateNodeData, tutorialActive, selectSingleNode] + [connectionDrop, addNodeWithOptionalGroup, onConnect, nodes, edges, handleSplitGridAction, getImageFromNode, updateNodeData, tutorialActive, selectSingleNode] ); const handleCloseDropMenu = useCallback(() => { @@ -1859,9 +1894,10 @@ export function WorkflowCanvas() { return; } - const newNodeId = addNode( + const newNodeId = addNodeWithOptionalGroup( "nanoBanana", multiAngleDialog.flowPosition, + multiAngleDialog.sourceGroupId, buildMultiAngleNodeData(sourceImage, multiAngleDialog.settings) ); @@ -1874,7 +1910,7 @@ export function WorkflowCanvas() { selectSingleNode(newNodeId); setMultiAngleDialog(null); await regenerateNode(newNodeId); - }, [addNode, getImageFromNode, multiAngleDialog, onConnect, regenerateNode, selectSingleNode, showToast, t]); + }, [addNodeWithOptionalGroup, getImageFromNode, multiAngleDialog, onConnect, regenerateNode, selectSingleNode, showToast, t]); // Get copy/paste functions and clipboard from store const copySelectedNodes = useWorkflowStore((state) => state.copySelectedNodes); diff --git a/src/store/__tests__/workflowStore.integration.test.ts b/src/store/__tests__/workflowStore.integration.test.ts index cb8b7409..f151d0d4 100644 --- a/src/store/__tests__/workflowStore.integration.test.ts +++ b/src/store/__tests__/workflowStore.integration.test.ts @@ -3042,6 +3042,43 @@ describe("workflowStore integration tests", () => { }); + describe("addNodeToGroup", () => { + it("creates a grouped child using absolute canvas coordinates", () => { + useWorkflowStore.setState({ + nodes: [ + { + id: "group-node-group-1", + type: "group", + position: { x: 400, y: 300 }, + data: { groupId: "group-1" }, + style: { width: 800, height: 600 }, + } as WorkflowNode, + createTestNode("prompt-1", "prompt", { text: "source" }, { x: 40, y: 50 }), + ], + edges: [], + groups: { + "group-1": { + id: "group-1", + name: "Test Group", + color: "neutral" as const, + position: { x: 400, y: 300 }, + size: { width: 800, height: 600 }, + locked: false, + }, + }, + }); + + const nodeId = useWorkflowStore.getState().addNodeToGroup("imageInput", { x: 760, y: 380 }, "group-1"); + const node = useWorkflowStore.getState().nodes.find((item) => item.id === nodeId); + + expect(node?.groupId).toBe("group-1"); + expect(node?.parentId).toBe("group-node-group-1"); + expect(node?.position).toEqual({ x: 360, y: 80 }); + expect(node?.extent).toBeUndefined(); + expect(node?.expandParent).toBeUndefined(); + }); + }); + describe("setNodeGroupId with non-standard node types", () => { it("should assign and remove groupId for easeCurve, videoStitch, and audioInput nodes", () => { const group = { diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 4b896e7e..4e1ad346 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -251,6 +251,7 @@ interface WorkflowStore { // Node operations addNode: (type: NodeType, position: XYPosition, initialData?: Partial) => string; + addNodeToGroup: (type: NodeType, position: XYPosition, groupId: string, initialData?: Partial) => string; convertNodeType: (nodeId: string, type: NodeType, initialData?: Partial) => void; updateNodeData: (nodeId: string, data: Partial) => void; removeNode: (nodeId: string) => void; @@ -1043,6 +1044,55 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ return id; }, + addNodeToGroup: (type: NodeType, position: XYPosition, groupId: string, initialData?: Partial) => { + const group = get().groups[groupId]; + if (!group) return get().addNode(type, position, initialData); + + const id = `${type}-${++nodeIdCounter}`; + const { width, height } = defaultNodeDimensions[type]; + const state = get(); + const nodeById = new Map(state.nodes.map((node) => [node.id, node])); + const collisionNodes = state.nodes + .filter((node) => node.type !== "group") + .map((node) => ({ + ...node, + position: getAbsoluteNodePosition(node, nodeById), + })); + const finalPosition = findNearestFreePosition(position, type, collisionNodes); + const defaultData = createDefaultNodeData(type); + const nodeData = initialData + ? ({ ...defaultData, ...initialData } as WorkflowNodeData) + : defaultData; + const groupNodeId = getGroupNodeId(groupId); + + const newNode: WorkflowNode = { + id, + type, + parentId: groupNodeId, + groupId, + position: { + x: finalPosition.x - group.position.x, + y: finalPosition.y - group.position.y, + }, + data: nodeData, + style: { width, height }, + }; + + pushUndoCheckpoint(get, set); + set((state) => ({ + nodes: [ + ...(state.nodes.some((node) => node.id === groupNodeId) ? [] : [createGroupNode(group)]), + ...state.nodes, + newNode, + ], + hasUnsavedChanges: true, + })); + + get().incrementManualChangeCount(); + + return id; + }, + convertNodeType: (nodeId: string, type: NodeType, initialData?: Partial) => { const sourceNode = get().nodes.find((node) => node.id === nodeId); if (!sourceNode) return;