From ad6746bdca59041d3145812bb62d0dbad2c4fb45 Mon Sep 17 00:00:00 2001 From: Shrimbly Date: Fri, 5 Dec 2025 19:49:29 +1300 Subject: [PATCH] multi select features --- src/app/globals.css | 6 + src/components/AnnotationModal.tsx | 2 +- src/components/FloatingActionBar.tsx | 23 +++- src/components/MultiSelectToolbar.tsx | 127 +++++++++++++++++++ src/components/WorkflowCanvas.tsx | 107 ++++++++++++---- src/components/nodes/AnnotationNode.tsx | 106 ++++++++++++++-- src/components/nodes/BaseNode.tsx | 2 +- src/components/nodes/LLMGenerateNode.tsx | 42 ++++++- src/components/nodes/NanoBananaNode.tsx | 61 +++++++-- src/store/workflowStore.ts | 151 ++++++++++++++++++++++- 10 files changed, 578 insertions(+), 49 deletions(-) create mode 100644 src/components/MultiSelectToolbar.tsx diff --git a/src/app/globals.css b/src/app/globals.css index e5b2dfca..a2a645f5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -141,6 +141,12 @@ body { background: #737373; } +/* Make the multi-selection box pass through pointer events so we can still + interact with handles, resize controls, and other node elements */ +.react-flow__nodesselection-rect { + pointer-events: none !important; +} + /* Edge pulse animation for loading connectors */ @keyframes flowPulse { 0% { diff --git a/src/components/AnnotationModal.tsx b/src/components/AnnotationModal.tsx index 006c4c71..9ac23572 100644 --- a/src/components/AnnotationModal.tsx +++ b/src/components/AnnotationModal.tsx @@ -481,7 +481,7 @@ export function AnnotationModal() { type="text" autoFocus defaultValue={(annotations.find((a) => a.id === editingTextId) as TextShape)?.text || ""} - className="w-full px-2 py-1.5 text-sm border border-gray-200 rounded mb-3 focus:outline-none focus:ring-1 focus:ring-gray-300" + className="w-full px-2 py-1.5 text-sm text-gray-900 border border-gray-200 rounded mb-3 focus:outline-none focus:ring-1 focus:ring-gray-300" onKeyDown={(e) => { if (e.key === "Enter") { updateAnnotation(editingTextId, { text: (e.target as HTMLInputElement).value }); setEditingTextId(null); } if (e.key === "Escape") setEditingTextId(null); diff --git a/src/components/FloatingActionBar.tsx b/src/components/FloatingActionBar.tsx index b1248894..06c16f2b 100644 --- a/src/components/FloatingActionBar.tsx +++ b/src/components/FloatingActionBar.tsx @@ -26,10 +26,17 @@ function NodeButton({ type, label }: NodeButtonProps) { addNode(type, position); }; + const handleDragStart = (event: React.DragEvent) => { + event.dataTransfer.setData("application/node-type", type); + event.dataTransfer.effectAllowed = "copy"; + }; + return ( @@ -71,6 +78,12 @@ function GenerateComboButton() { setIsOpen(false); }; + const handleDragStart = (event: React.DragEvent, type: NodeType) => { + event.dataTransfer.setData("application/node-type", type); + event.dataTransfer.effectAllowed = "copy"; + setIsOpen(false); + }; + return (
+ +
+ ); +} diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 055acced..b5370ae9 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -27,6 +27,7 @@ import { } from "./nodes"; import { EditableEdge } from "./edges"; import { ConnectionDropMenu } from "./ConnectionDropMenu"; +import { MultiSelectToolbar } from "./MultiSelectToolbar"; import { NodeType } from "@/types"; const nodeTypes: NodeTypes = { @@ -76,7 +77,7 @@ function WorkflowCanvasInner() { useWorkflowStore(); const { screenToFlowPosition } = useReactFlow(); const [isDragOver, setIsDragOver] = useState(false); - const [dropType, setDropType] = useState<"image" | "workflow" | null>(null); + const [dropType, setDropType] = useState<"image" | "workflow" | "node" | null>(null); const [connectionDrop, setConnectionDrop] = useState(null); const reactFlowWrapper = useRef(null); @@ -182,30 +183,64 @@ function WorkflowCanvasInner() { } } - // Create the connection based on which direction we're connecting - if (connectionType === "source" && sourceNodeId && sourceHandleId && targetHandleId) { - // Dragging from source (output), connect to new node's input - const connection: Connection = { - source: sourceNodeId, - sourceHandle: sourceHandleId, - target: newNodeId, - targetHandle: targetHandleId, - }; - onConnect(connection); - } else if (connectionType === "target" && sourceNodeId && sourceHandleId && sourceHandleIdForNewNode) { - // Dragging from target (input), connect from new node's output - const connection: Connection = { - source: newNodeId, - sourceHandle: sourceHandleIdForNewNode, - target: sourceNodeId, - targetHandle: sourceHandleId, - }; - onConnect(connection); + // Get all selected nodes to connect them all to the new node + const selectedNodes = nodes.filter((node) => node.selected); + const sourceNode = nodes.find((node) => node.id === sourceNodeId); + + // If the source node is selected and there are multiple selected nodes, + // connect all selected nodes to the new node + if (sourceNode?.selected && selectedNodes.length > 1 && sourceHandleId) { + selectedNodes.forEach((node) => { + if (connectionType === "source" && targetHandleId) { + // Dragging from source (output), connect selected nodes to new node's input + const connection: Connection = { + source: node.id, + sourceHandle: sourceHandleId, + target: newNodeId, + targetHandle: targetHandleId, + }; + if (isValidConnection(connection)) { + onConnect(connection); + } + } else if (connectionType === "target" && sourceHandleIdForNewNode) { + // Dragging from target (input), connect from new node's output to selected nodes + const connection: Connection = { + source: newNodeId, + sourceHandle: sourceHandleIdForNewNode, + target: node.id, + targetHandle: sourceHandleId, + }; + if (isValidConnection(connection)) { + onConnect(connection); + } + } + }); + } else { + // Single node connection (original behavior) + if (connectionType === "source" && sourceNodeId && sourceHandleId && targetHandleId) { + // Dragging from source (output), connect to new node's input + const connection: Connection = { + source: sourceNodeId, + sourceHandle: sourceHandleId, + target: newNodeId, + targetHandle: targetHandleId, + }; + onConnect(connection); + } else if (connectionType === "target" && sourceNodeId && sourceHandleId && sourceHandleIdForNewNode) { + // Dragging from target (input), connect from new node's output + const connection: Connection = { + source: newNodeId, + sourceHandle: sourceHandleIdForNewNode, + target: sourceNodeId, + targetHandle: sourceHandleId, + }; + onConnect(connection); + } } setConnectionDrop(null); }, - [connectionDrop, addNode, onConnect] + [connectionDrop, addNode, onConnect, nodes] ); const handleCloseDropMenu = useCallback(() => { @@ -283,6 +318,14 @@ function WorkflowCanvasInner() { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; + // Check if dragging a node type from the action bar + const hasNodeType = Array.from(event.dataTransfer.types).includes("application/node-type"); + if (hasNodeType) { + setIsDragOver(true); + setDropType("node"); + return; + } + // Check if dragging files that are images or JSON const items = Array.from(event.dataTransfer.items); const hasImageFile = items.some( @@ -313,6 +356,17 @@ function WorkflowCanvasInner() { setIsDragOver(false); setDropType(null); + // Check for node type drop from action bar + const nodeType = event.dataTransfer.getData("application/node-type") as NodeType; + if (nodeType) { + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + addNode(nodeType, position); + return; + } + const allFiles = Array.from(event.dataTransfer.files); // Check for JSON workflow files first @@ -389,7 +443,11 @@ function WorkflowCanvasInner() {

- {dropType === "workflow" ? "Drop to load workflow" : "Drop image to create node"} + {dropType === "workflow" + ? "Drop to load workflow" + : dropType === "node" + ? "Drop to create node" + : "Drop image to create node"}

@@ -409,6 +467,8 @@ function WorkflowCanvasInner() { multiSelectionKeyCode="Shift" selectionOnDrag={false} panOnDrag + selectNodesOnDrag={false} + nodeDragThreshold={5} className="bg-neutral-900" defaultEdgeOptions={{ type: "editable", @@ -451,6 +511,9 @@ function WorkflowCanvasInner() { onClose={handleCloseDropMenu} /> )} + + {/* Multi-select toolbar */} + ); } diff --git a/src/components/nodes/AnnotationNode.tsx b/src/components/nodes/AnnotationNode.tsx index f77bec2e..9594a522 100644 --- a/src/components/nodes/AnnotationNode.tsx +++ b/src/components/nodes/AnnotationNode.tsx @@ -1,9 +1,10 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useAnnotationStore } from "@/store/annotationStore"; +import { useWorkflowStore } from "@/store/workflowStore"; import { AnnotationNodeData } from "@/types"; type AnnotationNodeType = Node; @@ -11,20 +12,90 @@ type AnnotationNodeType = Node; export function AnnotationNode({ id, data, selected }: NodeProps) { const nodeData = data; const openModal = useAnnotationStore((state) => state.openModal); + const updateNodeData = useWorkflowStore((state) => state.updateNodeData); + const fileInputRef = useRef(null); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!file.type.match(/^image\/(png|jpeg|webp)$/)) { + alert("Unsupported format. Use PNG, JPG, or WebP."); + return; + } + + if (file.size > 10 * 1024 * 1024) { + alert("Image too large. Maximum size is 10MB."); + return; + } + + const reader = new FileReader(); + reader.onload = (event) => { + const base64 = event.target?.result as string; + updateNodeData(id, { + sourceImage: base64, + outputImage: null, + annotations: [], + }); + }; + reader.readAsDataURL(file); + }, + [id, updateNodeData] + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const file = e.dataTransfer.files?.[0]; + if (!file) return; + + const dt = new DataTransfer(); + dt.items.add(file); + if (fileInputRef.current) { + fileInputRef.current.files = dt.files; + fileInputRef.current.dispatchEvent(new Event("change", { bubbles: true })); + } + }, + [] + ); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); const handleEdit = useCallback(() => { const imageToEdit = nodeData.sourceImage || nodeData.outputImage; if (!imageToEdit) { - alert("No image connected. Connect an image input first."); + alert("No image available. Connect an image or load one manually."); return; } openModal(id, imageToEdit, nodeData.annotations); }, [id, nodeData, openModal]); + const handleRemove = useCallback(() => { + updateNodeData(id, { + sourceImage: null, + outputImage: null, + annotations: [], + }); + }, [id, updateNodeData]); + const displayImage = nodeData.outputImage || nodeData.sourceImage; return ( + + Annotated -
+ +
{nodeData.annotations.length > 0 ? `Edit (${nodeData.annotations.length})` : "Add annotations"}
) : ( -
- - Connect image +
fileInputRef.current?.click()} + onDrop={handleDrop} + onDragOver={handleDragOver} + className="w-full flex-1 min-h-[112px] border border-dashed border-neutral-600 rounded flex flex-col items-center justify-center cursor-pointer hover:border-neutral-500 hover:bg-neutral-700/50 transition-colors" + > + + + + + Drop, click, or connect
)} diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index eaba4c2f..a20751a0 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -37,7 +37,7 @@ export function BaseNode({ minWidth={minWidth} minHeight={minHeight} lineClassName="!border-transparent" - handleClassName="!w-0 !h-0 !opacity-0" + handleClassName="!w-3 !h-3 !bg-transparent !border-none" />
state.regenerateNode); + const isRunning = useWorkflowStore((state) => state.isRunning); + + const handleRegenerate = useCallback(() => { + regenerateNode(id); + }, [id, regenerateNode]); + + const handleClearOutput = useCallback(() => { + updateNodeData(id, { outputText: null, status: "idle", error: null }); + }, [id, updateNodeData]); + const availableModels = MODELS[nodeData.provider]; return ( @@ -88,7 +99,7 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps {/* Output preview area */} -
+
{nodeData.status === "loading" ? (
) : nodeData.outputText ? ( -

- {nodeData.outputText} -

+ <> +

+ {nodeData.outputText} +

+
+ + +
+ ) : (
diff --git a/src/components/nodes/NanoBananaNode.tsx b/src/components/nodes/NanoBananaNode.tsx index be5eec6c..8fa38c1f 100644 --- a/src/components/nodes/NanoBananaNode.tsx +++ b/src/components/nodes/NanoBananaNode.tsx @@ -55,6 +55,13 @@ export function NanoBananaNode({ id, data, selected }: NodeProps state.regenerateNode); + const isRunning = useWorkflowStore((state) => state.isRunning); + + const handleRegenerate = useCallback(() => { + regenerateNode(id); + }, [id, regenerateNode]); + const isNanoBananaPro = nodeData.model === "nano-banana-pro"; return ( @@ -98,15 +105,51 @@ export function NanoBananaNode({ id, data, selected }: NodeProps - + {/* Loading overlay */} + {nodeData.status === "loading" && ( +
+ + + + +
+ )} +
+ + +
) : (
diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index e1736ed6..b3bba0a3 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -55,6 +55,7 @@ interface WorkflowStore { isRunning: boolean; currentNodeId: string | null; executeWorkflow: () => Promise; + regenerateNode: (nodeId: string) => Promise; stopWorkflow: () => void; // Save/Load @@ -121,7 +122,7 @@ let nodeIdCounter = 0; export const useWorkflowStore = create((set, get) => ({ nodes: [], edges: [], - edgeStyle: "angular" as EdgeStyle, + edgeStyle: "curved" as EdgeStyle, isRunning: false, currentNodeId: null, @@ -636,6 +637,154 @@ export const useWorkflowStore = create((set, get) => ({ set({ isRunning: false, currentNodeId: null }); }, + regenerateNode: async (nodeId: string) => { + const { nodes, updateNodeData, getConnectedInputs, isRunning } = get(); + + if (isRunning) { + console.warn(`[Regenerate] ⚠️ Workflow is already running`); + return; + } + + const node = nodes.find((n) => n.id === nodeId); + if (!node) { + console.error(`[Regenerate] Node not found: ${nodeId}`); + return; + } + + console.log(`[Regenerate] ===== REGENERATING NODE ${nodeId} =====`); + set({ isRunning: true, currentNodeId: nodeId }); + + try { + if (node.type === "nanoBanana") { + const nodeData = node.data as NanoBananaNodeData; + + // Use stored inputs if available, otherwise get connected inputs + let images = nodeData.inputImages; + let text = nodeData.inputPrompt; + + if (!images || images.length === 0 || !text) { + const inputs = getConnectedInputs(nodeId); + images = inputs.images; + text = inputs.text; + } + + if (!images || images.length === 0 || !text) { + updateNodeData(nodeId, { + status: "error", + error: "Missing image or text input", + }); + set({ isRunning: false, currentNodeId: null }); + return; + } + + updateNodeData(nodeId, { + status: "loading", + error: null, + }); + + const response = await fetch("/api/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + images, + prompt: text, + aspectRatio: nodeData.aspectRatio, + resolution: nodeData.resolution, + model: nodeData.model, + useGoogleSearch: nodeData.useGoogleSearch, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `HTTP ${response.status}`; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.error || errorMessage; + } catch { + if (errorText) errorMessage += ` - ${errorText.substring(0, 200)}`; + } + updateNodeData(nodeId, { status: "error", error: errorMessage }); + set({ isRunning: false, currentNodeId: null }); + return; + } + + const result = await response.json(); + if (result.success && result.image) { + updateNodeData(nodeId, { + outputImage: result.image, + status: "complete", + error: null, + }); + } else { + updateNodeData(nodeId, { + status: "error", + error: result.error || "Generation failed", + }); + } + } else if (node.type === "llmGenerate") { + const nodeData = node.data as LLMGenerateNodeData; + + // Use stored input if available, otherwise get connected input + let text = nodeData.inputPrompt; + + if (!text) { + const inputs = getConnectedInputs(nodeId); + text = inputs.text; + } + + if (!text) { + updateNodeData(nodeId, { + status: "error", + error: "Missing text input", + }); + set({ isRunning: false, currentNodeId: null }); + return; + } + + updateNodeData(nodeId, { + status: "loading", + error: null, + }); + + const response = await fetch("/api/llm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt: text, + provider: nodeData.provider, + model: nodeData.model, + temperature: nodeData.temperature, + maxTokens: nodeData.maxTokens, + }), + }); + + const result = await response.json(); + if (result.success && result.text) { + updateNodeData(nodeId, { + outputText: result.text, + status: "complete", + error: null, + }); + } else { + updateNodeData(nodeId, { + status: "error", + error: result.error || "LLM generation failed", + }); + } + } + + set({ isRunning: false, currentNodeId: null }); + } catch (error) { + console.error(`[Regenerate] Error:`, error); + updateNodeData(nodeId, { + status: "error", + error: error instanceof Error ? error.message : "Regeneration failed", + }); + set({ isRunning: false, currentNodeId: null }); + } + }, + saveWorkflow: (name?: string) => { const { nodes, edges, edgeStyle } = get();