Browse Source

multi select features

handoff-20260429-1057
Shrimbly 7 months ago
parent
commit
ad6746bdca
  1. 6
      src/app/globals.css
  2. 2
      src/components/AnnotationModal.tsx
  3. 23
      src/components/FloatingActionBar.tsx
  4. 127
      src/components/MultiSelectToolbar.tsx
  5. 107
      src/components/WorkflowCanvas.tsx
  6. 106
      src/components/nodes/AnnotationNode.tsx
  7. 2
      src/components/nodes/BaseNode.tsx
  8. 42
      src/components/nodes/LLMGenerateNode.tsx
  9. 61
      src/components/nodes/NanoBananaNode.tsx
  10. 151
      src/store/workflowStore.ts

6
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% {

2
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);

23
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 (
<button
onClick={handleClick}
className="px-2.5 py-1.5 text-[11px] font-medium text-neutral-400 hover:text-neutral-100 hover:bg-neutral-700 rounded transition-colors"
draggable
onDragStart={handleDragStart}
className="px-2.5 py-1.5 text-[11px] font-medium text-neutral-400 hover:text-neutral-100 hover:bg-neutral-700 rounded transition-colors cursor-grab active:cursor-grabbing"
>
{label}
</button>
@ -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 (
<div className="relative" ref={menuRef}>
<button
@ -93,7 +106,9 @@ function GenerateComboButton() {
<div className="absolute bottom-full left-0 mb-2 bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl overflow-hidden min-w-[140px]">
<button
onClick={() => handleAddNode("nanoBanana")}
className="w-full px-3 py-2 text-left text-[11px] font-medium text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100 transition-colors flex items-center gap-2"
draggable
onDragStart={(e) => handleDragStart(e, "nanoBanana")}
className="w-full px-3 py-2 text-left text-[11px] font-medium text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100 transition-colors flex items-center gap-2 cursor-grab active:cursor-grabbing"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
@ -102,7 +117,9 @@ function GenerateComboButton() {
</button>
<button
onClick={() => handleAddNode("llmGenerate")}
className="w-full px-3 py-2 text-left text-[11px] font-medium text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100 transition-colors flex items-center gap-2"
draggable
onDragStart={(e) => handleDragStart(e, "llmGenerate")}
className="w-full px-3 py-2 text-left text-[11px] font-medium text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100 transition-colors flex items-center gap-2 cursor-grab active:cursor-grabbing"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />

127
src/components/MultiSelectToolbar.tsx

@ -0,0 +1,127 @@
"use client";
import { useReactFlow } from "@xyflow/react";
import { useWorkflowStore } from "@/store/workflowStore";
import { useMemo } from "react";
const STACK_GAP = 20;
export function MultiSelectToolbar() {
const { nodes, onNodesChange } = useWorkflowStore();
const { getViewport } = useReactFlow();
const selectedNodes = useMemo(
() => nodes.filter((node) => node.selected),
[nodes]
);
// Calculate toolbar position (centered above selected nodes)
const toolbarPosition = useMemo(() => {
if (selectedNodes.length < 2) return null;
const viewport = getViewport();
// Find bounding box of selected nodes
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
selectedNodes.forEach((node) => {
const nodeWidth = (node.style?.width as number) || node.measured?.width || 220;
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + nodeWidth);
});
// Convert flow coordinates to screen coordinates
const centerX = (minX + maxX) / 2;
const screenX = centerX * viewport.zoom + viewport.x;
const screenY = minY * viewport.zoom + viewport.y - 50; // 50px above the top
return { x: screenX, y: screenY };
}, [selectedNodes, getViewport]);
const handleStackHorizontally = () => {
if (selectedNodes.length < 2) return;
// Sort by current x position to maintain relative order
const sortedNodes = [...selectedNodes].sort((a, b) => a.position.x - b.position.x);
// Use the topmost y position as the alignment point
const alignY = Math.min(...sortedNodes.map((n) => n.position.y));
let currentX = sortedNodes[0].position.x;
sortedNodes.forEach((node) => {
const nodeWidth = (node.style?.width as number) || node.measured?.width || 220;
onNodesChange([
{
type: "position",
id: node.id,
position: { x: currentX, y: alignY },
},
]);
currentX += nodeWidth + STACK_GAP;
});
};
const handleStackVertically = () => {
if (selectedNodes.length < 2) return;
// Sort by current y position to maintain relative order
const sortedNodes = [...selectedNodes].sort((a, b) => a.position.y - b.position.y);
// Use the leftmost x position as the alignment point
const alignX = Math.min(...sortedNodes.map((n) => n.position.x));
let currentY = sortedNodes[0].position.y;
sortedNodes.forEach((node) => {
const nodeHeight = (node.style?.height as number) || node.measured?.height || 200;
onNodesChange([
{
type: "position",
id: node.id,
position: { x: alignX, y: currentY },
},
]);
currentY += nodeHeight + STACK_GAP;
});
};
if (!toolbarPosition || selectedNodes.length < 2) return null;
return (
<div
className="fixed z-[100] flex items-center gap-1 bg-neutral-800 border border-neutral-600 rounded-lg shadow-xl p-1"
style={{
left: toolbarPosition.x,
top: toolbarPosition.y,
transform: "translateX(-50%)",
}}
>
<button
onClick={handleStackHorizontally}
className="p-1.5 rounded hover:bg-neutral-700 text-neutral-400 hover:text-neutral-100 transition-colors"
title="Stack horizontally (H)"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 4h4v16H6zM14 4h4v16h-4z" />
</svg>
</button>
<button
onClick={handleStackVertically}
className="p-1.5 rounded hover:bg-neutral-700 text-neutral-400 hover:text-neutral-100 transition-colors"
title="Stack vertically (V)"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16v4H4zM4 14h16v4H4z" />
</svg>
</button>
</div>
);
}

107
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<ConnectionDropState | null>(null);
const reactFlowWrapper = useRef<HTMLDivElement>(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() {
<div className="absolute inset-0 bg-blue-500/10 z-50 pointer-events-none flex items-center justify-center">
<div className="bg-neutral-800 border border-neutral-600 rounded-lg px-6 py-4 shadow-xl">
<p className="text-neutral-200 text-sm font-medium">
{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"}
</p>
</div>
</div>
@ -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 */}
<MultiSelectToolbar />
</div>
);
}

106
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<AnnotationNodeData, "annotation">;
@ -11,20 +12,90 @@ type AnnotationNodeType = Node<AnnotationNodeData, "annotation">;
export function AnnotationNode({ id, data, selected }: NodeProps<AnnotationNodeType>) {
const nodeData = data;
const openModal = useAnnotationStore((state) => state.openModal);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<BaseNode id={id} title="Annotate" selected={selected}>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleFileChange}
className="hidden"
/>
<Handle
type="target"
position={Position.Left}
@ -40,24 +111,43 @@ export function AnnotationNode({ id, data, selected }: NodeProps<AnnotationNodeT
{displayImage ? (
<div
className="relative group cursor-pointer"
className="relative group cursor-pointer flex-1 flex flex-col min-h-0"
onClick={handleEdit}
>
<img
src={displayImage}
alt="Annotated"
className="w-full h-28 object-cover rounded"
className="w-full flex-1 min-h-0 object-cover rounded"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors rounded flex items-center justify-center">
<button
onClick={(e) => {
e.stopPropagation();
handleRemove();
}}
className="absolute top-1 right-1 w-5 h-5 bg-black/60 text-white rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors rounded flex items-center justify-center pointer-events-none">
<span className="text-[10px] font-medium text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 px-2 py-1 rounded">
{nodeData.annotations.length > 0 ? `Edit (${nodeData.annotations.length})` : "Add annotations"}
</span>
</div>
</div>
) : (
<div className="w-full h-28 border border-dashed border-neutral-600 rounded flex items-center justify-center">
<span className="text-neutral-500 text-[10px] text-center px-2">
Connect image
<div
onClick={() => 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"
>
<svg className="w-5 h-5 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span className="text-[10px] text-neutral-400 mt-1">
Drop, click, or connect
</span>
</div>
)}

2
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"
/>
<div
className={`

42
src/components/nodes/LLMGenerateNode.tsx

@ -61,6 +61,17 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
[id, updateNodeData]
);
const regenerateNode = useWorkflowStore((state) => 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<LLMGenerateNod
<div className="flex-1 flex flex-col min-h-0 gap-2">
{/* Output preview area */}
<div className="w-full flex-1 min-h-[80px] border border-dashed border-neutral-600 rounded p-2 overflow-auto">
<div className="relative w-full flex-1 min-h-[80px] border border-dashed border-neutral-600 rounded p-2 overflow-auto">
{nodeData.status === "loading" ? (
<div className="h-full flex items-center justify-center">
<svg
@ -116,9 +127,32 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
{nodeData.error || "Failed"}
</span>
) : nodeData.outputText ? (
<p className="text-[10px] text-neutral-300 whitespace-pre-wrap break-words">
{nodeData.outputText}
</p>
<>
<p className="text-[10px] text-neutral-300 whitespace-pre-wrap break-words pr-6">
{nodeData.outputText}
</p>
<div className="absolute top-1 right-1 flex gap-1">
<button
onClick={handleRegenerate}
disabled={isRunning}
className="w-5 h-5 bg-neutral-900/80 hover:bg-blue-600/80 disabled:opacity-50 disabled:cursor-not-allowed rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Regenerate"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
onClick={handleClearOutput}
className="w-5 h-5 bg-neutral-900/80 hover:bg-red-600/80 rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Clear output"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</>
) : (
<div className="h-full flex items-center justify-center">
<span className="text-neutral-500 text-[10px]">

61
src/components/nodes/NanoBananaNode.tsx

@ -55,6 +55,13 @@ export function NanoBananaNode({ id, data, selected }: NodeProps<NanoBananaNodeT
updateNodeData(id, { outputImage: null, status: "idle", error: null });
}, [id, updateNodeData]);
const regenerateNode = useWorkflowStore((state) => 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<NanoBananaNodeT
alt="Generated"
className="w-full h-full object-cover rounded"
/>
<button
onClick={handleClearImage}
className="absolute top-1 right-1 w-5 h-5 bg-neutral-900/80 hover:bg-red-600/80 rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Clear image"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Loading overlay */}
{nodeData.status === "loading" && (
<div className="absolute inset-0 bg-neutral-900/70 rounded flex items-center justify-center">
<svg
className="w-6 h-6 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
<div className="absolute top-1 right-1 flex gap-1">
<button
onClick={handleRegenerate}
disabled={isRunning}
className="w-5 h-5 bg-neutral-900/80 hover:bg-blue-600/80 disabled:opacity-50 disabled:cursor-not-allowed rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Regenerate"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
onClick={handleClearImage}
className="w-5 h-5 bg-neutral-900/80 hover:bg-red-600/80 rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Clear image"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
) : (
<div className="w-full flex-1 min-h-[112px] border border-dashed border-neutral-600 rounded flex flex-col items-center justify-center">

151
src/store/workflowStore.ts

@ -55,6 +55,7 @@ interface WorkflowStore {
isRunning: boolean;
currentNodeId: string | null;
executeWorkflow: () => Promise<void>;
regenerateNode: (nodeId: string) => Promise<void>;
stopWorkflow: () => void;
// Save/Load
@ -121,7 +122,7 @@ let nodeIdCounter = 0;
export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
nodes: [],
edges: [],
edgeStyle: "angular" as EdgeStyle,
edgeStyle: "curved" as EdgeStyle,
isRunning: false,
currentNodeId: null,
@ -636,6 +637,154 @@ export const useWorkflowStore = create<WorkflowStore>((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();

Loading…
Cancel
Save