"use client"; import { useRef, useState, useEffect, useMemo, useCallback } from "react"; import { useWorkflowStore } from "@/store/workflowStore"; import { useShallow } from "zustand/shallow"; import { NodeType } from "@/types"; import { useReactFlow } from "@xyflow/react"; import { ModelSearchDialog } from "./modals/ModelSearchDialog"; import { useFTUXStore, TutorialStep } from "@/store/ftuxStore"; // All nodes menu categories const ALL_NODES_CATEGORIES: { label: string; nodes: { type: NodeType; label: string }[] }[] = [ { label: "Input", nodes: [ { type: "imageInput", label: "Image Input" }, { type: "audioInput", label: "Audio Input" }, { type: "videoInput", label: "Video Input" }, { type: "glbViewer", label: "3D Viewer" }, ], }, { label: "Text", nodes: [ { type: "prompt", label: "Prompt" }, { type: "promptConstructor", label: "Prompt Constructor" }, { type: "array", label: "Array" }, ], }, { label: "Generate", nodes: [ { type: "nanoBanana", label: "Generate Image" }, { type: "generateVideo", label: "Generate Video" }, { type: "generate3d", label: "Generate 3D" }, { type: "generateAudio", label: "Generate Audio" }, { type: "llmGenerate", label: "LLM Generate" }, ], }, { label: "Process", nodes: [ { type: "annotation", label: "Annotate" }, { type: "splitGrid", label: "Split Grid" }, { type: "videoStitch", label: "Video Stitch" }, { type: "videoTrim", label: "Video Trim" }, { type: "easeCurve", label: "Ease Curve" }, { type: "videoFrameGrab", label: "Frame Grab" }, { type: "imageCompare", label: "Image Compare" }, ], }, { label: "Route", nodes: [ { type: "router", label: "Router" }, { type: "switch", label: "Switch" }, { type: "conditionalSwitch", label: "Conditional Switch" }, ], }, { label: "Output", nodes: [ { type: "output", label: "Output" }, { type: "outputGallery", label: "Output Gallery" }, ], }, ]; // Get the center of the React Flow pane in screen coordinates function getPaneCenter() { const pane = document.querySelector('.react-flow'); if (pane) { const rect = pane.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, }; } return { x: window.innerWidth / 2, y: window.innerHeight / 2 }; } interface NodeButtonProps { type: NodeType; label: string; dataTutorial?: string; } function NodeButton({ type, label, dataTutorial }: NodeButtonProps) { const addNode = useWorkflowStore((state) => state.addNode); const { screenToFlowPosition } = useReactFlow(); const handleClick = () => { const center = getPaneCenter(); const position = screenToFlowPosition({ x: center.x, y: center.y, }); // Nodes are created empty - tutorial will populate after connection addNode(type, position); }; const handleDragStart = (event: React.DragEvent) => { event.dataTransfer.setData("application/node-type", type); event.dataTransfer.effectAllowed = "copy"; }; return ( {label} ); } function GenerateComboButton() { const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); const addNode = useWorkflowStore((state) => state.addNode); const { screenToFlowPosition } = useReactFlow(); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { setIsOpen(false); } }; if (isOpen) { document.addEventListener("mousedown", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [isOpen]); const handleAddNode = (type: NodeType) => { const center = getPaneCenter(); const position = screenToFlowPosition({ x: center.x + Math.random() * 100 - 50, y: center.y + Math.random() * 100 - 50, }); addNode(type, position); setIsOpen(false); }; const handleDragStart = (event: React.DragEvent, type: NodeType) => { event.dataTransfer.setData("application/node-type", type); event.dataTransfer.effectAllowed = "copy"; setIsOpen(false); }; return ( setIsOpen(!isOpen)} 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 flex items-center gap-1" > Generate {isOpen && ( handleAddNode("nanoBanana")} 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" > Image handleAddNode("generateVideo")} draggable onDragStart={(e) => handleDragStart(e, "generateVideo")} 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" > Video handleAddNode("generate3d")} draggable onDragStart={(e) => handleDragStart(e, "generate3d")} 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" > 3D handleAddNode("llmGenerate")} 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" > Text (LLM) )} ); } function AllNodesMenu() { const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); const addNode = useWorkflowStore((state) => state.addNode); const { screenToFlowPosition } = useReactFlow(); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { setIsOpen(false); } }; if (isOpen) { document.addEventListener("mousedown", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [isOpen]); const handleAddNode = useCallback((type: NodeType) => { const center = getPaneCenter(); const position = screenToFlowPosition({ x: center.x + Math.random() * 100 - 50, y: center.y + Math.random() * 100 - 50, }); addNode(type, position); setIsOpen(false); }, [addNode, screenToFlowPosition]); const handleDragStart = useCallback((event: React.DragEvent, type: NodeType) => { event.dataTransfer.setData("application/node-type", type); event.dataTransfer.effectAllowed = "copy"; setIsOpen(false); }, []); return ( setIsOpen(!isOpen)} 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 flex items-center gap-1" > All nodes {isOpen && ( {ALL_NODES_CATEGORIES.map((category, catIndex) => ( 0 ? " border-t border-neutral-700" : ""}`}> {category.label} {category.nodes.map((node) => ( handleAddNode(node.type)} draggable onDragStart={(e) => handleDragStart(e, node.type)} 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" > {node.label} ))} ))} )} ); } export function FloatingActionBar() { const { nodes, isRunning, currentNodeIds, executeWorkflow, regenerateNode, executeSelectedNodes, stopWorkflow, mockTutorialExecution, validateWorkflow, edgeStyle, setEdgeStyle, setModelSearchOpen, modelSearchOpen, modelSearchProvider, } = useWorkflowStore(useShallow((state) => ({ nodes: state.nodes, isRunning: state.isRunning, currentNodeIds: state.currentNodeIds, executeWorkflow: state.executeWorkflow, regenerateNode: state.regenerateNode, executeSelectedNodes: state.executeSelectedNodes, stopWorkflow: state.stopWorkflow, mockTutorialExecution: state.mockTutorialExecution, validateWorkflow: state.validateWorkflow, edgeStyle: state.edgeStyle, setEdgeStyle: state.setEdgeStyle, setModelSearchOpen: state.setModelSearchOpen, modelSearchOpen: state.modelSearchOpen, modelSearchProvider: state.modelSearchProvider, }))); // FTUX tutorial state (client-side only to avoid SSR hydration issues) const [tutorialActive, setTutorialActive] = useState(false); const [lockedFeatures, setLockedFeatures] = useState(false); const [currentTutorialStep, setCurrentTutorialStep] = useState(0); const [tutorialSteps, setTutorialSteps] = useState([]); useEffect(() => { // Subscribe to FTUX store on client-side only const unsubscribe = useFTUXStore.subscribe((state) => { setTutorialActive(state.tutorialActive); setLockedFeatures(state.lockedFeatures); setCurrentTutorialStep(state.currentTutorialStep); setTutorialSteps(state.tutorialSteps); }); // Initialize with current state const currentState = useFTUXStore.getState(); setTutorialActive(currentState.tutorialActive); setLockedFeatures(currentState.lockedFeatures); setCurrentTutorialStep(currentState.currentTutorialStep); setTutorialSteps(currentState.tutorialSteps); return unsubscribe; }, []); // Get display text for running nodes const runningNodeCount = currentNodeIds.length; const getRunningLabel = () => { if (runningNodeCount === 0) return "Running..."; if (runningNodeCount === 1) { const node = nodes.find((n) => n.id === currentNodeIds[0]); const nodeName = node?.data?.customTitle || node?.type || "node"; return `Running ${nodeName}...`; } return `Running ${runningNodeCount} nodes...`; }; const [runMenuOpen, setRunMenuOpen] = useState(false); const runMenuRef = useRef(null); const { valid, errors } = validateWorkflow(); // Get the selected nodes const selectedNodes = useMemo(() => { return nodes.filter((n) => n.selected); }, [nodes]); // Get the selected node (if exactly one is selected) const selectedNode = useMemo(() => { return selectedNodes.length === 1 ? selectedNodes[0] : null; }, [selectedNodes]); // Check if we're on the run options tutorial step const isRunOptionsTutorialStep = useMemo(() => { if (!tutorialActive || tutorialSteps.length === 0) return false; const currentStep = tutorialSteps[currentTutorialStep]; return currentStep?.id === "explain-run-options"; }, [tutorialActive, currentTutorialStep, tutorialSteps]); // Close run menu when clicking outside (but not during tutorial step) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (runMenuRef.current && !runMenuRef.current.contains(event.target as Node)) { // Don't close menu during the run options tutorial step if (!isRunOptionsTutorialStep) { setRunMenuOpen(false); } } }; if (runMenuOpen) { document.addEventListener("mousedown", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [runMenuOpen, isRunOptionsTutorialStep]); // Open run menu when tutorial step is "explain-run-options" useEffect(() => { if (isRunOptionsTutorialStep) { setRunMenuOpen(true); } }, [isRunOptionsTutorialStep]); // Close run menu when tutorial advances past run options useEffect(() => { if (tutorialActive && tutorialSteps.length > 0) { const currentStep = tutorialSteps[currentTutorialStep]; // Close menu when we're on run-workflow or later steps if (currentStep?.id === "run-workflow" || currentStep?.id === "demonstrate-downstream" || currentStep?.id === "demonstrate-complete") { setRunMenuOpen(false); } } }, [tutorialActive, currentTutorialStep, tutorialSteps]); const toggleEdgeStyle = () => { setEdgeStyle(edgeStyle === "angular" ? "curved" : "angular"); }; const handleRunClick = useCallback(() => { // Check if we're in tutorial mode const ftuxState = useFTUXStore.getState(); const currentStep = ftuxState.tutorialSteps[ftuxState.currentTutorialStep]; if (isRunning) { stopWorkflow(); } else if (ftuxState.tutorialActive && currentStep?.id === "run-workflow") { // Use mock execution for tutorial mockTutorialExecution(); } else { // Normal execution executeWorkflow(); } }, [isRunning, stopWorkflow, executeWorkflow, mockTutorialExecution]); const handleRunFromSelected = () => { if (selectedNode) { executeWorkflow(selectedNode.id); setRunMenuOpen(false); } }; const handleRunSelectedOnly = () => { if (selectedNode) { regenerateNode(selectedNode.id); setRunMenuOpen(false); } }; const handleRunSelectedNodes = () => { if (selectedNodes.length > 0) { executeSelectedNodes(selectedNodes.map((n) => n.id)); setRunMenuOpen(false); } }; return ( {/* All models button */} setModelSearchOpen(true)} title="Browse models" 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" > All models {edgeStyle === "angular" ? ( ) : ( )} {isRunning ? ( <> {runningNodeCount > 1 ? `${runningNodeCount} nodes` : "Stop"} > ) : ( <> Run > )} {/* Dropdown chevron button */} {!isRunning && valid && ( setRunMenuOpen(!runMenuOpen)} data-tutorial="floating-run-dropdown" className="flex items-center self-stretch px-1.5 rounded-r bg-white text-neutral-900 hover:bg-neutral-200 border-l border-neutral-200 transition-colors" title="Run options" > )} {/* Dropdown menu */} {runMenuOpen && !isRunning && ( { executeWorkflow(); setRunMenuOpen(false); }} 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" > Run entire workflow Run from selected node Run selected node only 0 ? "text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100" : "text-neutral-500 cursor-not-allowed" }`} title={selectedNodes.length === 0 ? "Select one or more nodes first" : `Run ${selectedNodes.length} selected node${selectedNodes.length > 1 ? 's' : ''}`} > {selectedNodes.length > 0 ? `Run ${selectedNodes.length} selected node${selectedNodes.length !== 1 ? 's' : ''}` : 'Run selected nodes'} )} {/* Model search dialog */} setModelSearchOpen(false)} initialProvider={modelSearchProvider} /> ); }