diff --git a/src/app/globals.css b/src/app/globals.css index a2a645f5..cffbe916 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -29,6 +29,18 @@ body { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); } +/* Larger invisible hit area for easier clicking, especially when zoomed out */ +.react-flow__handle::before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 30px; + height: 30px; + border-radius: 50%; +} + .react-flow__handle-left { left: -5px; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4fe046e9..e192fb9f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; +import { Toast } from "@/components/Toast"; export const metadata: Metadata = { title: "Node Banana - AI Image Workflow", @@ -15,6 +16,7 @@ export default function RootLayout({ {children} + ); diff --git a/src/components/AnnotationModal.tsx b/src/components/AnnotationModal.tsx index 9ac23572..351ca479 100644 --- a/src/components/AnnotationModal.tsx +++ b/src/components/AnnotationModal.tsx @@ -53,6 +53,7 @@ export function AnnotationModal() { const stageRef = useRef(null); const transformerRef = useRef(null); + const textInputRef = useRef(null); const [image, setImage] = useState(null); const [stageSize, setStageSize] = useState({ width: 800, height: 600 }); const [scale, setScale] = useState(1); @@ -61,6 +62,9 @@ export function AnnotationModal() { const [drawStart, setDrawStart] = useState({ x: 0, y: 0 }); const [currentShape, setCurrentShape] = useState(null); const [editingTextId, setEditingTextId] = useState(null); + const [textInputPosition, setTextInputPosition] = useState<{ x: number; y: number } | null>(null); + const [pendingTextPosition, setPendingTextPosition] = useState<{ x: number; y: number } | null>(null); + const textInputCreatedAt = useRef(0); const containerRef = useRef(null); useEffect(() => { @@ -109,6 +113,8 @@ export function AnnotationModal() { if (e.key === "Escape") { if (editingTextId) { setEditingTextId(null); + setTextInputPosition(null); + setPendingTextPosition(null); } else { closeModal(); } @@ -176,17 +182,30 @@ export function AnnotationModal() { case "freehand": newShape = { ...baseShape, type: "freehand", points: [0, 0] } as FreehandShape; break; - case "text": - newShape = { ...baseShape, type: "text", text: "Text", fontSize: toolOptions.fontSize, fill: toolOptions.strokeColor } as TextShape; - addAnnotation(newShape); - setEditingTextId(id); + case "text": { + // Calculate screen position for the input + const stage = stageRef.current; + if (stage) { + const container = stage.container(); + const stageBox = container?.getBoundingClientRect(); + if (stageBox) { + const screenX = stageBox.left + pos.x * scale + position.x; + const screenY = stageBox.top + pos.y * scale + position.y; + setTextInputPosition({ x: screenX, y: screenY }); + setPendingTextPosition({ x: pos.x, y: pos.y }); + } + } + textInputCreatedAt.current = Date.now(); + setEditingTextId("new"); setIsDrawing(false); + setTimeout(() => textInputRef.current?.focus(), 0); return; + } } if (newShape) setCurrentShape(newShape); }, - [currentTool, toolOptions, getRelativePointerPosition, selectShape, addAnnotation] + [currentTool, toolOptions, getRelativePointerPosition, selectShape, addAnnotation, scale, position] ); const handleMouseMove = useCallback(() => { @@ -336,7 +355,44 @@ export function AnnotationModal() { } case "text": { const text = shape as TextShape; - return { if (currentTool === "select") setEditingTextId(shape.id); }} />; + return ( + { + const node = e.target; + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + // Reset scale and apply it to fontSize instead + node.scaleX(1); + node.scaleY(1); + const newFontSize = Math.round(text.fontSize * Math.max(scaleX, scaleY)); + updateAnnotation(shape.id, { + x: node.x(), + y: node.y(), + fontSize: newFontSize, + }); + }} + onDblClick={() => { + if (currentTool === "select") { + const stage = stageRef.current; + if (stage) { + const stageBox = stage.container().getBoundingClientRect(); + const screenX = stageBox.left + text.x * scale + position.x; + const screenY = stageBox.top + text.y * scale + position.y; + setTextInputPosition({ x: screenX, y: screenY }); + } + setEditingTextId(shape.id); + setTimeout(() => textInputRef.current?.focus(), 0); + } + }} + /> + ); } } }; @@ -355,13 +411,13 @@ export function AnnotationModal() { return (
{/* Top Bar */} -
-
+
+
{tools.map((tool) => ( ))} -
+
- - + + -
+
- +
-
- -
@@ -419,32 +475,32 @@ export function AnnotationModal() {
{/* Bottom Options Bar */} -
+
{/* Colors */} -
- Color +
+ Color {COLORS.map((color) => (
-
+
{/* Stroke Width */} -
- Size +
+ Size {STROKE_WIDTHS.map((width) => (
-
+
{/* Fill Toggle */} {/* Zoom */} -
- - {Math.round(scale * 100)}% - +
+ + {Math.round(scale * 100)}% +
- {/* Text Editing Modal */} - {editingTextId && ( -
-
- a.id === editingTextId) as TextShape)?.text || ""} - 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); - }} - /> -
- - -
-
-
+ {/* Inline Text Input */} + {editingTextId && textInputPosition && ( + a.id === editingTextId) as TextShape)?.text || ""} + className="fixed z-[110] bg-transparent border-none outline-none" + style={{ + left: textInputPosition.x, + top: textInputPosition.y, + fontSize: `${toolOptions.fontSize * scale}px`, + color: editingTextId === "new" ? toolOptions.strokeColor : ((annotations.find((a) => a.id === editingTextId) as TextShape)?.fill || toolOptions.strokeColor), + minWidth: "100px", + caretColor: "white", + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + const value = (e.target as HTMLInputElement).value; + if (value.trim()) { + if (editingTextId === "new" && pendingTextPosition) { + // Create new text annotation + const newShape: TextShape = { + id: `shape-${Date.now()}`, + type: "text", + x: pendingTextPosition.x, + y: pendingTextPosition.y, + text: value, + fontSize: toolOptions.fontSize, + fill: toolOptions.strokeColor, + stroke: toolOptions.strokeColor, + strokeWidth: toolOptions.strokeWidth, + opacity: toolOptions.opacity, + }; + addAnnotation(newShape); + } else { + updateAnnotation(editingTextId, { text: value }); + } + } else if (editingTextId !== "new") { + deleteAnnotation(editingTextId); + } + setEditingTextId(null); + setTextInputPosition(null); + setPendingTextPosition(null); + } + if (e.key === "Escape") { + if (editingTextId !== "new") { + const currentText = (annotations.find((a) => a.id === editingTextId) as TextShape)?.text; + if (!currentText) { + deleteAnnotation(editingTextId); + } + } + setEditingTextId(null); + setTextInputPosition(null); + setPendingTextPosition(null); + } + }} + onBlur={(e) => { + // Ignore blur events that happen immediately after creation (within 200ms) + // This prevents the click that created the input from also triggering blur + if (Date.now() - textInputCreatedAt.current < 200) { + e.target.focus(); + return; + } + + const value = e.target.value; + if (value.trim()) { + if (editingTextId === "new" && pendingTextPosition) { + // Create new text annotation + const newShape: TextShape = { + id: `shape-${Date.now()}`, + type: "text", + x: pendingTextPosition.x, + y: pendingTextPosition.y, + text: value, + fontSize: toolOptions.fontSize, + fill: toolOptions.strokeColor, + stroke: toolOptions.strokeColor, + strokeWidth: toolOptions.strokeWidth, + opacity: toolOptions.opacity, + }; + addAnnotation(newShape); + } else { + updateAnnotation(editingTextId, { text: value }); + } + } else if (editingTextId !== "new") { + deleteAnnotation(editingTextId); + } + setEditingTextId(null); + setTextInputPosition(null); + setPendingTextPosition(null); + }} + /> )}
); diff --git a/src/components/EdgeToolbar.tsx b/src/components/EdgeToolbar.tsx new file mode 100644 index 00000000..a9d31ea0 --- /dev/null +++ b/src/components/EdgeToolbar.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useWorkflowStore } from "@/store/workflowStore"; +import { useMemo, useEffect, useState, useRef } from "react"; + +export function EdgeToolbar() { + const { edges, toggleEdgePause, removeEdge } = useWorkflowStore(); + const [clickPosition, setClickPosition] = useState<{ x: number; y: number } | null>(null); + const previousSelectedEdgeId = useRef(null); + + const selectedEdge = useMemo( + () => edges.find((edge) => edge.selected), + [edges] + ); + + // Track mouse position when edge selection changes + useEffect(() => { + const handleMouseDown = (e: MouseEvent) => { + // Check if clicking on an edge + const target = e.target as Element; + if (target.closest('.react-flow__edge')) { + setClickPosition({ x: e.clientX, y: e.clientY - 40 }); // 40px above click + } + }; + + document.addEventListener('mousedown', handleMouseDown); + return () => document.removeEventListener('mousedown', handleMouseDown); + }, []); + + // Reset click position when edge is deselected + useEffect(() => { + if (!selectedEdge && previousSelectedEdgeId.current) { + setClickPosition(null); + } + previousSelectedEdgeId.current = selectedEdge?.id || null; + }, [selectedEdge]); + + const toolbarPosition = clickPosition; + + const handleTogglePause = () => { + if (selectedEdge) { + toggleEdgePause(selectedEdge.id); + } + }; + + const handleDelete = () => { + if (selectedEdge) { + removeEdge(selectedEdge.id); + } + }; + + if (!toolbarPosition || !selectedEdge) return null; + + const hasPause = selectedEdge.data?.hasPause; + + return ( +
+ + +
+ ); +} diff --git a/src/components/FloatingActionBar.tsx b/src/components/FloatingActionBar.tsx index 06c16f2b..6ce45814 100644 --- a/src/components/FloatingActionBar.tsx +++ b/src/components/FloatingActionBar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState, useEffect } from "react"; +import { useRef, useState, useEffect, useMemo } from "react"; import { useWorkflowStore, EdgeStyle, WorkflowFile } from "@/store/workflowStore"; import { NodeType } from "@/types"; import { useReactFlow } from "@xyflow/react"; @@ -133,12 +133,37 @@ function GenerateComboButton() { } export function FloatingActionBar() { - const { isRunning, executeWorkflow, stopWorkflow, validateWorkflow, edgeStyle, setEdgeStyle, saveWorkflow, loadWorkflow } = + const { nodes, isRunning, executeWorkflow, regenerateNode, stopWorkflow, validateWorkflow, edgeStyle, setEdgeStyle, saveWorkflow, loadWorkflow } = useWorkflowStore(); const fileInputRef = useRef(null); + const [runMenuOpen, setRunMenuOpen] = useState(false); + const runMenuRef = useRef(null); const { valid, errors } = validateWorkflow(); + // Get the selected node (if exactly one is selected) + const selectedNode = useMemo(() => { + const selected = nodes.filter((n) => n.selected); + return selected.length === 1 ? selected[0] : null; + }, [nodes]); + + // Close run menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (runMenuRef.current && !runMenuRef.current.contains(event.target as Node)) { + setRunMenuOpen(false); + } + }; + + if (runMenuOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [runMenuOpen]); + const toggleEdgeStyle = () => { setEdgeStyle(edgeStyle === "angular" ? "curved" : "angular"); }; @@ -151,6 +176,20 @@ export function FloatingActionBar() { } }; + const handleRunFromSelected = () => { + if (selectedNode) { + executeWorkflow(selectedNode.id); + setRunMenuOpen(false); + } + }; + + const handleRunSelectedOnly = () => { + if (selectedNode) { + regenerateNode(selectedNode.id); + setRunMenuOpen(false); + } + }; + const handleSave = () => { saveWorkflow(); }; @@ -240,54 +279,123 @@ export function FloatingActionBar() {
- + + {/* Dropdown chevron button */} + {!isRunning && valid && ( + + + +
)} - +
); diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 00000000..2fe43470 --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useEffect } from "react"; +import { create } from "zustand"; + +interface ToastState { + message: string | null; + type: "info" | "success" | "warning" | "error"; + show: (message: string, type?: "info" | "success" | "warning" | "error") => void; + hide: () => void; +} + +export const useToast = create((set) => ({ + message: null, + type: "info", + show: (message, type = "info") => set({ message, type }), + hide: () => set({ message: null }), +})); + +const typeStyles = { + info: "bg-neutral-800 border-neutral-600 text-neutral-100", + success: "bg-green-900 border-green-700 text-green-100", + warning: "bg-orange-900 border-orange-600 text-orange-100", + error: "bg-red-900 border-red-700 text-red-100", +}; + +const typeIcons = { + info: ( + + + + ), + success: ( + + + + ), + warning: ( + + + + ), + error: ( + + + + ), +}; + +export function Toast() { + const { message, type, hide } = useToast(); + + useEffect(() => { + if (message) { + const timer = setTimeout(() => { + hide(); + }, 4000); + return () => clearTimeout(timer); + } + }, [message, hide]); + + if (!message) return null; + + return ( +
+
+ {typeIcons[type]} + {message} + +
+
+ ); +} diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index b5370ae9..196ea4dd 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -28,6 +28,7 @@ import { import { EditableEdge } from "./edges"; import { ConnectionDropMenu } from "./ConnectionDropMenu"; import { MultiSelectToolbar } from "./MultiSelectToolbar"; +import { EdgeToolbar } from "./EdgeToolbar"; import { NodeType } from "@/types"; const nodeTypes: NodeTypes = { @@ -119,33 +120,109 @@ function WorkflowCanvasInner() { [onConnect, nodes] ); - // Handle connection dropped on empty space + // Define which handles each node type has + const getNodeHandles = useCallback((nodeType: string): { inputs: string[]; outputs: string[] } => { + switch (nodeType) { + case "imageInput": + return { inputs: [], outputs: ["image"] }; + case "annotation": + return { inputs: ["image"], outputs: ["image"] }; + case "prompt": + return { inputs: [], outputs: ["text"] }; + case "nanoBanana": + return { inputs: ["image", "text"], outputs: ["image"] }; + case "llmGenerate": + return { inputs: ["text", "image"], outputs: ["text"] }; + case "output": + return { inputs: ["image"], outputs: [] }; + default: + return { inputs: [], outputs: [] }; + } + }, []); + + // Handle connection dropped on empty space or on a node const handleConnectEnd: OnConnectEnd = useCallback( (event, connectionState) => { - // Only show menu if connection was not completed (dropped on empty space) - if (!connectionState.isValid && connectionState.fromNode) { - const { clientX, clientY } = event as MouseEvent; - - // Get the handle type from the connection state - const handleId = connectionState.fromHandle?.id || null; - const handleType = (handleId === "image" || handleId === "text") ? handleId : null; - - // Determine if we're dragging from a source or target handle - const connectionType = connectionState.fromHandle?.type === "source" ? "source" : "target"; - - const flowPos = screenToFlowPosition({ x: clientX, y: clientY }); - - setConnectionDrop({ - position: { x: clientX, y: clientY }, - flowPosition: flowPos, - handleType, - connectionType, - sourceNodeId: connectionState.fromNode.id, - sourceHandleId: handleId, - }); + // If connection was completed normally, nothing to do + if (connectionState.isValid || !connectionState.fromNode) { + return; } + + const { clientX, clientY } = event as MouseEvent; + const fromHandleId = connectionState.fromHandle?.id || null; + const fromHandleType = (fromHandleId === "image" || fromHandleId === "text") ? fromHandleId : null; + const isFromSource = connectionState.fromHandle?.type === "source"; + + // Check if we dropped on a node by looking for node elements under the cursor + const elementsUnderCursor = document.elementsFromPoint(clientX, clientY); + const nodeElement = elementsUnderCursor.find((el) => { + // React Flow nodes have data-id attribute + return el.closest(".react-flow__node"); + }); + + if (nodeElement) { + const nodeWrapper = nodeElement.closest(".react-flow__node") as HTMLElement; + const targetNodeId = nodeWrapper?.dataset.id; + + if (targetNodeId && targetNodeId !== connectionState.fromNode.id && fromHandleType) { + const targetNode = nodes.find((n) => n.id === targetNodeId); + + if (targetNode) { + const targetHandles = getNodeHandles(targetNode.type || ""); + + // Find a compatible handle on the target node + let compatibleHandle: string | null = null; + + if (isFromSource) { + // Dragging from output, need an input on target that matches type + if (targetHandles.inputs.includes(fromHandleType)) { + compatibleHandle = fromHandleType; + } + } else { + // Dragging from input, need an output on target that matches type + if (targetHandles.outputs.includes(fromHandleType)) { + compatibleHandle = fromHandleType; + } + } + + if (compatibleHandle) { + // Create the connection + const connection: Connection = isFromSource + ? { + source: connectionState.fromNode.id, + sourceHandle: fromHandleId, + target: targetNodeId, + targetHandle: compatibleHandle, + } + : { + source: targetNodeId, + sourceHandle: compatibleHandle, + target: connectionState.fromNode.id, + targetHandle: fromHandleId, + }; + + if (isValidConnection(connection)) { + handleConnect(connection); + return; // Connection made, don't show menu + } + } + } + } + } + + // No node under cursor or no compatible handle - show the drop menu + const flowPos = screenToFlowPosition({ x: clientX, y: clientY }); + + setConnectionDrop({ + position: { x: clientX, y: clientY }, + flowPosition: flowPos, + handleType: fromHandleType, + connectionType: isFromSource ? "source" : "target", + sourceNodeId: connectionState.fromNode.id, + sourceHandleId: fromHandleId, + }); }, - [screenToFlowPosition] + [screenToFlowPosition, nodes, getNodeHandles, handleConnect] ); // Handle node selection from drop menu @@ -247,7 +324,10 @@ function WorkflowCanvasInner() { setConnectionDrop(null); }, []); - // Keyboard shortcuts for stacking selected nodes + // Get copy/paste functions from store + const { copySelectedNodes, pasteNodes } = useWorkflowStore(); + + // Keyboard shortcuts for copy/paste and stacking selected nodes useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // Ignore if user is typing in an input field @@ -258,6 +338,20 @@ function WorkflowCanvasInner() { return; } + // Handle copy (Ctrl/Cmd + C) + if ((event.ctrlKey || event.metaKey) && event.key === "c") { + event.preventDefault(); + copySelectedNodes(); + return; + } + + // Handle paste (Ctrl/Cmd + V) + if ((event.ctrlKey || event.metaKey) && event.key === "v") { + event.preventDefault(); + pasteNodes(); + return; + } + const selectedNodes = nodes.filter((node) => node.selected); if (selectedNodes.length < 2) return; @@ -312,7 +406,7 @@ function WorkflowCanvasInner() { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [nodes, onNodesChange]); + }, [nodes, onNodesChange, copySelectedNodes, pasteNodes]); const handleDragOver = useCallback((event: DragEvent) => { event.preventDefault(); @@ -514,6 +608,9 @@ function WorkflowCanvasInner() { {/* Multi-select toolbar */} + + {/* Edge toolbar */} +
); } diff --git a/src/components/edges/EditableEdge.tsx b/src/components/edges/EditableEdge.tsx index e01eb398..c1b0644d 100644 --- a/src/components/edges/EditableEdge.tsx +++ b/src/components/edges/EditableEdge.tsx @@ -9,9 +9,9 @@ import { useReactFlow, } from "@xyflow/react"; import { useWorkflowStore } from "@/store/workflowStore"; -import { NanoBananaNodeData } from "@/types"; +import { NanoBananaNodeData, WorkflowEdgeData } from "@/types"; -interface EdgeData { +interface EdgeData extends WorkflowEdgeData { offsetX?: number; offsetY?: number; } @@ -21,6 +21,7 @@ const EDGE_COLORS = { image: "#10b981", // Green for image connections prompt: "#3b82f6", // Blue for prompt connections default: "#94a3b8", // Gray for unknown + pause: "#f97316", // Orange for paused edges }; export function EditableEdge({ @@ -48,6 +49,7 @@ export function EditableEdge({ const edgeData = data as EdgeData | undefined; const offsetX = edgeData?.offsetX ?? 0; const offsetY = edgeData?.offsetY ?? 0; + const hasPause = edgeData?.hasPause ?? false; // Check if target node is a Generate node that's currently loading const isTargetLoading = useMemo(() => { @@ -59,14 +61,15 @@ export function EditableEdge({ return false; }, [target, nodes]); - // Determine edge color based on handle type + // Determine edge color based on handle type (orange if paused) const edgeColor = useMemo(() => { + if (hasPause) return EDGE_COLORS.pause; // Use source handle to determine color (or target if source is not available) const handleType = sourceHandleId || targetHandleId; if (handleType === "image") return EDGE_COLORS.image; if (handleType === "prompt") return EDGE_COLORS.prompt; return EDGE_COLORS.default; - }, [sourceHandleId, targetHandleId]); + }, [hasPause, sourceHandleId, targetHandleId]); // Generate a unique gradient ID for this edge const gradientId = `pulse-gradient-${id}`; @@ -217,6 +220,23 @@ export function EditableEdge({ stroke="transparent" className="react-flow__edge-interaction" /> + + {/* Pause indicator near target connection point */} + {hasPause && ( + + {/* Background circle */} + + {/* Pause bars */} + + + + )} + {/* Draggable handles on segments */} {(selected || isDragging) && handlePositions.map((handle, index) => ( diff --git a/src/components/nodes/AnnotationNode.tsx b/src/components/nodes/AnnotationNode.tsx index 9594a522..91ca4b59 100644 --- a/src/components/nodes/AnnotationNode.tsx +++ b/src/components/nodes/AnnotationNode.tsx @@ -117,7 +117,7 @@ export function AnnotationNode({ id, data, selected }: NodeProps