You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

2338 lines
91 KiB

"use client";
import { useCallback, useRef, useState, useEffect, DragEvent, useMemo } from "react";
import {
ReactFlow,
Background,
Controls,
MiniMap,
NodeTypes,
EdgeTypes,
Connection,
Edge,
useReactFlow,
OnConnectEnd,
Node,
OnSelectionChangeParams,
ViewportPortal,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useWorkflowStore, WorkflowFile } from "@/store/workflowStore";
import { useShallow } from "zustand/shallow";
import { useToast } from "@/components/Toast";
import dynamic from "next/dynamic";
import {
ImageInputNode,
AudioInputNode,
VideoInputNode,
AnnotationNode,
PromptNode,
ArrayNode,
PromptConstructorNode,
GenerateImageNode,
GenerateVideoNode,
Generate3DNode,
GenerateAudioNode,
LLMGenerateNode,
SplitGridNode,
OutputNode,
OutputGalleryNode,
ImageCompareNode,
VideoStitchNode,
EaseCurveNode,
VideoTrimNode,
VideoFrameGrabNode,
RouterNode,
SwitchNode,
ConditionalSwitchNode,
} from "./nodes";
// Lazy-load GLBViewerNode to avoid bundling three.js for users who don't use 3D nodes
const GLBViewerNode = dynamic(() => import("./nodes/GLBViewerNode").then(mod => ({ default: mod.GLBViewerNode })), { ssr: false });
import { EditableEdge, ReferenceEdge, SharedEdgeGradients } from "./edges";
import { ConnectionDropMenu, MenuAction } from "./ConnectionDropMenu";
import { MultiSelectToolbar } from "./MultiSelectToolbar";
import { EdgeToolbar } from "./EdgeToolbar";
import { GlobalImageHistory } from "./GlobalImageHistory";
import { GroupBackgroundsPortal, GroupControlsOverlay } from "./GroupsOverlay";
import { NodeType, NanoBananaNodeData, HandleType, PromptNodeData, LLMGenerateNodeData, PromptConstructorNodeData, AvailableVariable } from "@/types";
import { defaultNodeDimensions } from "@/store/utils/nodeDefaults";
import { FloatingNodeHeader } from "./nodes/FloatingNodeHeader";
import { ControlPanel } from "./nodes/ControlPanel";
import { detectAndSplitGrid } from "@/utils/gridSplitter";
import { logger } from "@/utils/logger";
import { WelcomeModal } from "./quickstart";
import { ProjectSetupModal } from "./ProjectSetupModal";
import { ChatPanel } from "./ChatPanel";
import { EditOperation } from "@/lib/chat/editOperations";
import { stripBinaryData } from "@/lib/chat/contextBuilder";
import { PromptEditorModal } from "./modals/PromptEditorModal";
import { PromptConstructorEditorModal } from "./modals/PromptConstructorEditorModal";
import { resolveTextSourcesThroughRouters } from "@/store/utils/connectedInputs";
import { wouldCreateCycle } from "@/store/utils/executionUtils";
import { parseVarTags } from "@/utils/parseVarTags";
import { AnnotationModal } from "./AnnotationModal";
import { browseRegistry } from "@/utils/browseRegistry";
import { useInlineParameters } from "@/hooks/useInlineParameters";
import { SplitGridSettingsModal } from "./SplitGridSettingsModal";
import { createPortal } from "react-dom";
import { useAnnotationStore } from "@/store/annotationStore";
const nodeTypes: NodeTypes = {
imageInput: ImageInputNode,
audioInput: AudioInputNode,
videoInput: VideoInputNode,
annotation: AnnotationNode,
prompt: PromptNode,
array: ArrayNode,
promptConstructor: PromptConstructorNode,
nanoBanana: GenerateImageNode,
generateVideo: GenerateVideoNode,
generate3d: Generate3DNode,
generateAudio: GenerateAudioNode,
llmGenerate: LLMGenerateNode,
splitGrid: SplitGridNode,
output: OutputNode,
outputGallery: OutputGalleryNode,
imageCompare: ImageCompareNode,
videoStitch: VideoStitchNode,
easeCurve: EaseCurveNode,
videoTrim: VideoTrimNode,
videoFrameGrab: VideoFrameGrabNode,
router: RouterNode,
switch: SwitchNode,
conditionalSwitch: ConditionalSwitchNode,
glbViewer: GLBViewerNode,
};
const edgeTypes: EdgeTypes = {
editable: EditableEdge,
reference: ReferenceEdge,
};
// Connection validation rules
// - Image handles (green) can only connect to image handles
// - Text handles (blue) can only connect to text handles
// - Video handles can only connect to generateVideo or output nodes
// Helper to determine handle type from handle ID
// For dynamic handles, we use naming convention: image inputs contain "image", text inputs are "prompt" or "negative_prompt"
const getHandleType = (handleId: string | null | undefined): "image" | "text" | "video" | "audio" | "3d" | "easeCurve" | null => {
if (!handleId) return null;
// Generic Router handles — return null to allow any type connection
if (handleId === "generic-input" || handleId === "generic-output") return null;
// EaseCurve handles (must check before other types)
if (handleId === "easeCurve") return "easeCurve";
// 3D handles
if (handleId === "3d") return "3d";
// Standard handles
if (handleId === "video") return "video";
if (handleId === "audio" || handleId.startsWith("audio")) return "audio";
if (handleId === "image" || handleId === "text") return handleId;
// Dynamic handles - check naming patterns (including indexed: text-0, image-0)
if (handleId.includes("video")) return "video";
if (handleId.startsWith("image-") || handleId.includes("image") || handleId.includes("frame")) return "image";
if (handleId.startsWith("text-") || handleId === "prompt" || handleId === "negative_prompt" || handleId.includes("prompt")) return "text";
return null;
};
// Define which handles each node type has
const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[] } => {
switch (nodeType) {
case "imageInput":
return { inputs: ["reference"], outputs: ["image"] };
case "audioInput":
return { inputs: ["audio"], outputs: ["audio"] };
case "videoInput":
return { inputs: ["video"], outputs: ["video"] };
case "annotation":
return { inputs: ["image"], outputs: ["image"] };
case "prompt":
return { inputs: ["text"], outputs: ["text"] };
case "array":
return { inputs: ["text"], outputs: ["text"] };
case "promptConstructor":
return { inputs: ["text"], outputs: ["text"] };
case "nanoBanana":
return { inputs: ["image", "text"], outputs: ["image"] };
case "generateVideo":
return { inputs: ["image", "text", "audio"], outputs: ["video"] };
case "generate3d":
return { inputs: ["image", "text"], outputs: ["3d"] };
case "generateAudio":
return { inputs: ["text"], outputs: ["audio"] };
case "llmGenerate":
return { inputs: ["text", "image"], outputs: ["text"] };
case "splitGrid":
return { inputs: ["image"], outputs: ["reference"] };
case "output":
return { inputs: ["image", "video", "audio"], outputs: [] };
case "outputGallery":
return { inputs: ["image"], outputs: [] };
case "imageCompare":
return { inputs: ["image"], outputs: [] };
case "videoStitch":
return { inputs: ["video", "audio"], outputs: ["video"] };
case "easeCurve":
return { inputs: ["video", "easeCurve"], outputs: ["video", "easeCurve"] };
case "videoTrim":
return { inputs: ["video"], outputs: ["video"] };
case "videoFrameGrab":
return { inputs: ["video"], outputs: ["image"] };
case "router":
return { inputs: ["image", "text", "video", "audio", "3d", "easeCurve", "generic-input"], outputs: ["image", "text", "video", "audio", "3d", "easeCurve", "generic-output"] };
case "switch":
// Switch has one input handle (generic-input when disconnected, typed when connected)
// Output handles are dynamic based on switches array, all matching inputType
return { inputs: ["generic-input"], outputs: [] }; // Outputs handled dynamically in SwitchNode
case "conditionalSwitch":
// Conditional Switch has one text input and dynamic rule outputs + default
return { inputs: ["text"], outputs: [] }; // Outputs handled dynamically in ConditionalSwitchNode
case "glbViewer":
return { inputs: ["3d"], outputs: ["image"] };
default:
return { inputs: [], outputs: [] };
}
};
interface ConnectionDropState {
position: { x: number; y: number };
flowPosition: { x: number; y: number };
handleType: "image" | "text" | "video" | "audio" | "3d" | "easeCurve" | null;
connectionType: "source" | "target";
sourceNodeId: string | null;
sourceHandleId: string | null;
}
// Detect if running on macOS for platform-specific trackpad behavior
const isMacOS = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
// Detect if a wheel event is from a mouse (vs trackpad)
const isMouseWheel = (event: WheelEvent): boolean => {
// Mouse scroll wheel typically uses deltaMode 1 (lines) or has large discrete deltas
// Trackpad uses deltaMode 0 (pixels) with smaller, smoother deltas
if (event.deltaMode === 1) return true; // DOM_DELTA_LINE = mouse
// Fallback: large delta values suggest mouse wheel
const threshold = 50;
return Math.abs(event.deltaY) >= threshold &&
Math.abs(event.deltaY) % 40 === 0; // Mouse deltas often in multiples
};
// Check if an element can scroll and has room to scroll in the given direction
const canElementScroll = (element: HTMLElement, deltaX: number, deltaY: number): boolean => {
const style = window.getComputedStyle(element);
const overflowY = style.overflowY;
const overflowX = style.overflowX;
const canScrollY = overflowY === 'auto' || overflowY === 'scroll';
const canScrollX = overflowX === 'auto' || overflowX === 'scroll';
// Check if there's room to scroll in the delta direction
if (canScrollY && deltaY !== 0) {
const hasVerticalScroll = element.scrollHeight > element.clientHeight;
if (hasVerticalScroll) {
// Check if we can scroll further in the delta direction
if (deltaY > 0 && element.scrollTop < element.scrollHeight - element.clientHeight) {
return true; // Can scroll down
}
if (deltaY < 0 && element.scrollTop > 0) {
return true; // Can scroll up
}
}
}
if (canScrollX && deltaX !== 0) {
const hasHorizontalScroll = element.scrollWidth > element.clientWidth;
if (hasHorizontalScroll) {
if (deltaX > 0 && element.scrollLeft < element.scrollWidth - element.clientWidth) {
return true; // Can scroll right
}
if (deltaX < 0 && element.scrollLeft > 0) {
return true; // Can scroll left
}
}
}
return false;
};
// Find if the target element or any ancestor is scrollable
const findScrollableAncestor = (target: HTMLElement, deltaX: number, deltaY: number): HTMLElement | null => {
let current: HTMLElement | null = target;
while (current && !current.classList.contains('react-flow')) {
// Check for nowheel class (React Flow convention for elements that should handle their own scroll)
if (current.classList.contains('nowheel') || current.tagName === 'TEXTAREA') {
if (canElementScroll(current, deltaX, deltaY)) {
return current;
}
}
current = current.parentElement;
}
return null;
};
/** Shared ref so child components (BaseNode) can check panning state without re-rendering */
export const isPanningRef = { current: false };
/** Shared ref so child components (BaseNode) can skip hover updates during node drags */
export const isDraggingNodeRef = { current: false };
export function WorkflowCanvas() {
const { nodes, edges, groups, isModalOpen, showQuickstart, navigationTarget, canvasNavigationSettings, dimmedNodeIds, skippedNodeIds } =
useWorkflowStore(useShallow((state) => ({
nodes: state.nodes,
edges: state.edges,
groups: state.groups,
isModalOpen: state.isModalOpen,
showQuickstart: state.showQuickstart,
navigationTarget: state.navigationTarget,
canvasNavigationSettings: state.canvasNavigationSettings,
dimmedNodeIds: state.dimmedNodeIds,
skippedNodeIds: state.skippedNodeIds,
})));
const onNodesChange = useWorkflowStore((state) => state.onNodesChange);
const onEdgesChange = useWorkflowStore((state) => state.onEdgesChange);
const onConnect = useWorkflowStore((state) => state.onConnect);
const addNode = useWorkflowStore((state) => state.addNode);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const loadWorkflow = useWorkflowStore((state) => state.loadWorkflow);
const getNodeById = useWorkflowStore((state) => state.getNodeById);
const addToGlobalHistory = useWorkflowStore((state) => state.addToGlobalHistory);
const setNodeGroupId = useWorkflowStore((state) => state.setNodeGroupId);
const executeWorkflow = useWorkflowStore((state) => state.executeWorkflow);
const setShowQuickstart = useWorkflowStore((state) => state.setShowQuickstart);
const setNavigationTarget = useWorkflowStore((state) => state.setNavigationTarget);
const captureSnapshot = useWorkflowStore((state) => state.captureSnapshot);
const applyEditOperations = useWorkflowStore((state) => state.applyEditOperations);
const setWorkflowMetadata = useWorkflowStore((state) => state.setWorkflowMetadata);
const setShortcutsDialogOpen = useWorkflowStore((state) => state.setShortcutsDialogOpen);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const clearWorkflow = useWorkflowStore((state) => state.clearWorkflow);
const setHoveredNodeId = useWorkflowStore((state) => state.setHoveredNodeId);
const openAnnotationModal = useAnnotationStore((state) => state.openModal);
const { screenToFlowPosition, getViewport, zoomIn, zoomOut, setViewport, setCenter } = useReactFlow();
const { show: showToast } = useToast();
const [isDragOver, setIsDragOver] = useState(false);
const [dropType, setDropType] = useState<"image" | "audio" | "workflow" | "node" | null>(null);
const [connectionDrop, setConnectionDrop] = useState<ConnectionDropState | null>(null);
const [isSplitting, setIsSplitting] = useState(false);
const [isChatOpen, setIsChatOpen] = useState(false);
const [isBuildingWorkflow, setIsBuildingWorkflow] = useState(false);
const [showNewProjectSetup, setShowNewProjectSetup] = useState(false);
const [expandingNode, setExpandingNode] = useState<{ id: string; type: string } | null>(null);
const reactFlowWrapper = useRef<HTMLDivElement>(null);
// Detect if canvas is empty for showing quickstart
const isCanvasEmpty = nodes.length === 0;
// Handle comment navigation - center viewport on target node
useEffect(() => {
if (navigationTarget) {
const targetNode = nodes.find((n) => n.id === navigationTarget.nodeId);
if (targetNode) {
// Calculate center of node
const nodeWidth = (targetNode.style?.width as number) || 300;
const nodeHeight = (targetNode.style?.height as number) || 280;
const centerX = targetNode.position.x + nodeWidth / 2;
const centerY = targetNode.position.y + nodeHeight / 2;
// Navigate to node center with animation, zoomed out to 0.7 for better context
setCenter(centerX, centerY, { duration: 300, zoom: 0.7 });
}
// Clear navigation target after navigating
setNavigationTarget(null);
}
}, [navigationTarget, nodes, setCenter, setNavigationTarget]);
// Apply dimming className to nodes downstream of disabled Switch outputs or skipped by optional inputs
const allNodes = useMemo(() => {
return nodes.map((node) => {
// Never dim Switch or ConditionalSwitch nodes themselves
if (node.type === "switch" || node.type === "conditionalSwitch") return node;
const isDimmed = dimmedNodeIds.has(node.id);
const isSkipped = skippedNodeIds.has(node.id);
const extraClasses = [
isDimmed ? "switch-dimmed" : "",
isSkipped ? "node-skipped" : "",
].filter(Boolean).join(" ");
// Preserve existing className if any, add/remove dimmed/skipped classes
const baseClass = (node.className || "").replace(/\bswitch-dimmed\b/g, "").replace(/\bnode-skipped\b/g, "").trim();
const newClass = extraClasses ? `${baseClass} ${extraClasses}`.trim() : baseClass;
// Only create new node object if className changed
if (node.className === newClass) return node;
return { ...node, className: newClass };
});
}, [nodes, dimmedNodeIds, skippedNodeIds]);
// Node title mapping for FloatingNodeHeaders
const NODE_TITLES: Record<string, string> = {
imageInput: 'Image Input',
audioInput: 'Audio Input',
videoInput: 'Video Input',
annotation: 'Annotation',
prompt: 'Prompt',
array: 'Array',
promptConstructor: 'Prompt Constructor',
nanoBanana: 'Generate Image',
generateVideo: 'Generate Video',
generate3d: 'Generate 3D',
generateAudio: 'Generate Audio',
llmGenerate: 'LLM Generate',
splitGrid: 'Split Grid',
output: 'Output',
outputGallery: 'Output Gallery',
imageCompare: 'Image Compare',
videoStitch: 'Video Stitch',
easeCurve: 'Ease Curve',
videoTrim: 'Video Trim',
videoFrameGrab: 'Frame Grab',
router: 'Router',
switch: 'Switch',
conditionalSwitch: 'Conditional Switch',
glbViewer: '3D Viewer',
};
// Helper to get node title (used for FloatingNodeHeader)
const getNodeTitle = useCallback((node: Node) => {
// For generate nodes, check for selectedModel display name
if (node.type === "nanoBanana" || node.type === "generateVideo" || node.type === "generate3d" || node.type === "generateAudio") {
const model = (node.data as any)?.selectedModel;
if (model?.displayName) return model.displayName;
}
// For LLM nodes, check for selectedLLMModel or selectedModel
if (node.type === "llmGenerate") {
const model = (node.data as any)?.selectedLLMModel || (node.data as any)?.selectedModel;
if (model?.displayName) return model.displayName;
if (model?.name) return model.name;
}
return NODE_TITLES[node.type || ""] || "Node";
}, []);
// Wire comment/title change callbacks for FloatingNodeHeaders
const handleCustomTitleChange = useCallback((nodeId: string, title: string) => {
updateNodeData(nodeId, { customTitle: title || undefined });
}, [updateNodeData]);
const handleCommentChange = useCallback((nodeId: string, comment: string) => {
updateNodeData(nodeId, { comment: comment || undefined });
}, [updateNodeData]);
// Stable callback for running a node from its header
const handleRunNode = useCallback((nodeId: string) => {
regenerateNode(nodeId);
}, [regenerateNode]);
// Inline parameters mode (for showing Browse in header)
const { inlineParametersEnabled } = useInlineParameters();
// Stable callback for expanding a node from its header
const handleExpandNode = useCallback((nodeId: string, nodeType: string) => {
if (nodeType === 'annotation') {
const node = getNodeById(nodeId);
if (!node) return;
const imageToEdit = (node.data as any)?.outputImage || (node.data as any)?.image;
if (!imageToEdit) return;
openAnnotationModal(nodeId, imageToEdit, (node.data as any)?.annotations);
} else {
setExpandingNode({ id: nodeId, type: nodeType });
}
}, [getNodeById, openAnnotationModal]);
// Check if a node was dropped into a group and add it to that group
const handleNodeDragStop = useCallback(
(_event: React.MouseEvent, node: Node) => {
// Skip if it's a group node
if (node.id.startsWith("group-")) return;
const defaults = defaultNodeDimensions[node.type as NodeType] || { width: 300, height: 280 };
const nodeWidth = node.measured?.width || (node.style?.width as number) || defaults.width;
const nodeHeight = node.measured?.height || (node.style?.height as number) || defaults.height;
const nodeCenterX = node.position.x + nodeWidth / 2;
const nodeCenterY = node.position.y + nodeHeight / 2;
// Check if node center is inside any group
let targetGroupId: string | undefined;
for (const group of Object.values(groups)) {
const inBoundsX = nodeCenterX >= group.position.x && nodeCenterX <= group.position.x + group.size.width;
const inBoundsY = nodeCenterY >= group.position.y && nodeCenterY <= group.position.y + group.size.height;
if (inBoundsX && inBoundsY) {
targetGroupId = group.id;
break;
}
}
// Get current groupId of the node
const currentNode = nodes.find((n) => n.id === node.id);
const currentGroupId = currentNode?.groupId;
// Update groupId if it changed
if (targetGroupId !== currentGroupId) {
setNodeGroupId(node.id, targetGroupId);
}
},
[groups, nodes, setNodeGroupId]
);
// Connection validation - checks if a connection is valid based on handle types and node types
// Defined inside component to have access to nodes array for video validation
const isValidConnection = useCallback(
(connection: Connection | Edge): boolean => {
const sourceType = getHandleType(connection.sourceHandle);
const targetType = getHandleType(connection.targetHandle);
// Switch input: accept any type (generic-input handle)
const targetNode = nodes.find((n) => n.id === connection.target);
const sourceNode = nodes.find((n) => n.id === connection.source);
if (targetNode?.type === "switch" && connection.targetHandle === "generic-input") return true;
// Switch output: the type is determined by inputType stored in node data
if (sourceNode?.type === "switch") {
const switchData = sourceNode.data as { inputType?: string | null };
if (switchData.inputType && targetType) {
return switchData.inputType === targetType;
}
// If inputType not set yet, allow connection (will be resolved)
return true;
}
// Conditional Switch: text input only, text outputs only
if (targetNode?.type === "conditionalSwitch") {
return sourceType === "text";
}
if (sourceNode?.type === "conditionalSwitch") {
return targetType === "text";
}
// If we can't determine types, allow the connection
if (!sourceType || !targetType) return true;
// EaseCurve connections: only between easeCurve nodes (or router)
if (sourceType === "easeCurve" || targetType === "easeCurve") {
const targetNode = nodes.find((n) => n.id === connection.target);
const sourceNode = nodes.find((n) => n.id === connection.source);
if (targetNode?.type === "router" || sourceNode?.type === "router") return true;
if (sourceType !== "easeCurve" || targetType !== "easeCurve") return false;
return targetNode?.type === "easeCurve";
}
// Video connections have special rules
if (sourceType === "video") {
// Video source can ONLY connect to:
// 1. generateVideo nodes (for video-to-video)
// 2. videoStitch nodes (for concatenation)
// 3. output nodes (for display)
const targetNode = nodes.find((n) => n.id === connection.target);
if (!targetNode) return false;
const targetNodeType = targetNode.type;
if (targetNodeType === "generateVideo" || targetNodeType === "videoStitch" || targetNodeType === "easeCurve" || targetNodeType === "videoTrim" || targetNodeType === "videoFrameGrab" || targetNodeType === "videoInput" || targetNodeType === "output" || targetNodeType === "router") {
// For output node, we allow video even though its handle is typed as "image"
// because output node can display both images and videos
return true;
}
// Video cannot connect to other node types
return false;
}
// 3D connections: 3d handles can only connect to matching 3d handles (or router)
if (sourceType === "3d" || targetType === "3d") {
// Allow 3d connections to router nodes
const sourceNode = nodes.find((n) => n.id === connection.source);
const targetNode = nodes.find((n) => n.id === connection.target);
if (sourceNode?.type === "router" || targetNode?.type === "router") return true;
return sourceType === "3d" && targetType === "3d";
}
// Audio connections: audio handles connect to audio handles, plus output node (or router)
if (sourceType === "audio" || targetType === "audio") {
if (sourceType === "audio") {
const targetNode = nodes.find((n) => n.id === connection.target);
if (targetNode?.type === "output" || targetNode?.type === "router") return true;
}
return sourceType === "audio" && targetType === "audio";
}
// Standard type matching for image and text
// Image handles connect to image handles, text handles connect to text handles
return sourceType === targetType;
},
[nodes]
);
const handleConnect = useCallback(
(connection: Connection) => {
if (!isValidConnection(connection)) return;
// For imageCompare nodes, redirect to the second handle if the first is occupied
const resolveImageCompareHandle = (conn: Connection, batchUsed?: Set<string>): Connection => {
const targetNode = nodes.find((n) => n.id === conn.target);
if (targetNode?.type === "imageCompare" && conn.targetHandle === "image") {
const imageOccupied = edges.some(
(e) => e.target === conn.target && e.targetHandle === "image"
) || batchUsed?.has("image");
if (imageOccupied) {
return { ...conn, targetHandle: "image-1" };
}
}
return conn;
};
// For Router nodes, resolve generic handles to typed handles
const resolveRouterHandle = (conn: Connection): Connection => {
const targetNode = nodes.find((n) => n.id === conn.target);
if (targetNode?.type !== "router") return conn;
// If targeting a generic handle, transform to typed handle
if (conn.targetHandle === "generic-input") {
const sourceType = getHandleType(conn.sourceHandle);
if (sourceType) {
return { ...conn, targetHandle: sourceType };
}
}
return conn;
};
// For Router source nodes, resolve generic output handles to typed handles
const resolveRouterSourceHandle = (conn: Connection): Connection => {
const sourceNode = nodes.find((n) => n.id === conn.source);
if (sourceNode?.type !== "router") return conn;
if (conn.sourceHandle === "generic-output") {
const targetType = getHandleType(conn.targetHandle);
if (targetType) {
return { ...conn, sourceHandle: targetType };
}
}
return conn;
};
// For Switch nodes, resolve generic-input to the source's handle type and update inputType
const resolveSwitchHandle = (conn: Connection): Connection => {
const targetNode = nodes.find((n) => n.id === conn.target);
if (targetNode?.type !== "switch") return conn;
// If targeting the generic-input handle, resolve to the source handle type
if (conn.targetHandle === "generic-input") {
const sourceType = getHandleType(conn.sourceHandle);
if (sourceType) {
// Update the Switch node's inputType in data so output handles render
updateNodeData(conn.target, { inputType: sourceType as HandleType });
return { ...conn, targetHandle: sourceType };
}
}
return conn;
};
// Get all selected nodes
const selectedNodes = nodes.filter((node) => node.selected);
const sourceNode = nodes.find((node) => node.id === connection.source);
// If the source node is selected and there are multiple selected nodes,
// connect all selected nodes that have the same source handle type
if (sourceNode?.selected && selectedNodes.length > 1 && connection.sourceHandle) {
const batchUsed = new Set<string>();
selectedNodes.forEach((node) => {
// Skip if this is already the connection source
if (node.id === connection.source) {
let resolved = resolveImageCompareHandle(connection, batchUsed);
resolved = resolveRouterHandle(resolved);
resolved = resolveRouterSourceHandle(resolved);
resolved = resolveSwitchHandle(resolved);
// Resolve videoStitch handles for batch connections
const tgtNode = nodes.find((n) => n.id === resolved.target);
if (tgtNode?.type === "videoStitch" && resolved.targetHandle?.startsWith("video-")) {
for (let i = 0; i < 50; i++) {
const candidateHandle = `video-${i}`;
const isOccupied = edges.some(
(e) => e.target === resolved.target && e.targetHandle === candidateHandle
) || batchUsed.has(candidateHandle);
if (!isOccupied) {
resolved = { ...resolved, targetHandle: candidateHandle };
batchUsed.add(candidateHandle);
break;
}
}
}
if (resolved.targetHandle) batchUsed.add(resolved.targetHandle);
// Check for cycle and mark as loop edge if detected
if (wouldCreateCycle(resolved.source!, resolved.target!, edges)) {
onConnect(resolved, { isLoop: true, loopCount: 3 });
} else {
onConnect(resolved);
}
return;
}
// Check if this node actually has the same output handle type
const nodeHandles = getNodeHandles(node.type || "");
if (!nodeHandles.outputs.includes(connection.sourceHandle as string)) {
// This node doesn't have the same output handle type, skip it
return;
}
// Create connection from this selected node to the same target
let multiConnection: Connection = {
source: node.id,
sourceHandle: connection.sourceHandle,
target: connection.target,
targetHandle: connection.targetHandle,
};
// Resolve videoStitch handle for batch connections
const targetNode = nodes.find((n) => n.id === multiConnection.target);
if (targetNode?.type === "videoStitch" && multiConnection.targetHandle?.startsWith("video-")) {
for (let i = 0; i < 50; i++) {
const candidateHandle = `video-${i}`;
const isOccupied = edges.some(
(e) => e.target === multiConnection.target && e.targetHandle === candidateHandle
) || batchUsed.has(candidateHandle);
if (!isOccupied) {
multiConnection = { ...multiConnection, targetHandle: candidateHandle };
batchUsed.add(candidateHandle);
break;
}
}
}
let resolved = resolveImageCompareHandle(multiConnection, batchUsed);
resolved = resolveRouterHandle(resolved);
resolved = resolveRouterSourceHandle(resolved);
resolved = resolveSwitchHandle(resolved);
if (resolved.targetHandle) batchUsed.add(resolved.targetHandle);
if (isValidConnection(resolved)) {
// Check for cycle and mark as loop edge if detected
if (wouldCreateCycle(resolved.source!, resolved.target!, edges)) {
onConnect(resolved, { isLoop: true, loopCount: 3 });
} else {
onConnect(resolved);
}
}
});
} else {
// Single connection
let resolved = resolveImageCompareHandle(connection);
resolved = resolveRouterHandle(resolved);
resolved = resolveRouterSourceHandle(resolved);
resolved = resolveSwitchHandle(resolved);
// Check for cycle and mark as loop edge if detected
if (wouldCreateCycle(resolved.source!, resolved.target!, edges)) {
onConnect(resolved, { isLoop: true, loopCount: 3 });
} else {
onConnect(resolved);
}
}
},
[onConnect, nodes, edges]
);
// Handle connection dropped on empty space or on a node
const handleConnectEnd: OnConnectEnd = useCallback(
(event, connectionState) => {
// 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;
let fromHandleType = getHandleType(fromHandleId); // Use getHandleType for dynamic handles
const isFromSource = connectionState.fromHandle?.type === "source";
// Switch output handles have dynamic IDs — resolve type from node's inputType
if (!fromHandleType && connectionState.fromNode.type === "switch") {
const switchData = connectionState.fromNode.data as { inputType?: string | null };
if (switchData.inputType) {
fromHandleType = switchData.inputType as "image" | "text" | "video" | "audio" | "3d" | "easeCurve";
}
}
// ConditionalSwitch output handles have dynamic IDs (rule-xxx, default) — always text type
if (!fromHandleType && connectionState.fromNode.type === "conditionalSwitch") {
fromHandleType = "text";
}
// Helper to find a compatible handle on a node by type
const findCompatibleHandle = (
node: Node,
handleType: "image" | "text" | "video" | "audio" | "3d" | "easeCurve",
needInput: boolean,
batchUsed?: Set<string>
): string | null => {
// Check for dynamic inputSchema first
const nodeData = node.data as { inputSchema?: Array<{ name: string; type: string }> };
if (nodeData.inputSchema && nodeData.inputSchema.length > 0) {
if (needInput) {
// Find input handles matching the type
const matchingInputs = nodeData.inputSchema.filter(i => i.type === handleType);
const numHandles = matchingInputs.length;
if (numHandles > 0) {
// Find the first unoccupied indexed handle by checking existing edges and batchUsed
for (let i = 0; i < numHandles; i++) {
const candidateHandle = `${handleType}-${i}`;
const isOccupied = edges.some(
(edge) => edge.target === node.id && edge.targetHandle === candidateHandle
) || batchUsed?.has(candidateHandle);
if (!isOccupied) {
return candidateHandle;
}
}
// All handles are occupied
return null;
}
}
// Output handle - check for video, 3d, or image type
if (handleType === "video") return "video";
if (handleType === "3d") return "3d";
return handleType === "image" ? "image" : null;
}
// VideoStitch has dynamic indexed video input handles (video-0, video-1, ...)
if (node.type === "videoStitch" && needInput && handleType === "video") {
for (let i = 0; i < 50; i++) {
const candidateHandle = `video-${i}`;
const isOccupied = edges.some(
(edge) => edge.target === node.id && edge.targetHandle === candidateHandle
) || batchUsed?.has(candidateHandle);
if (!isOccupied) return candidateHandle;
}
return null;
}
// Router accepts any type — use typed handle if exists, otherwise generic
if (node.type === "router" && needInput) {
// Router accepts any type — use typed handle if that type is already active
return handleType;
}
if (node.type === "router" && !needInput) {
return handleType;
}
// Switch accepts any type on input, outputs match inputType
if (node.type === "switch" && needInput) {
return "generic-input";
}
if (node.type === "switch" && !needInput) {
const switchData = node.data as { switches?: Array<{ id: string; enabled: boolean }> };
// Return first enabled switch output handle ID
if (switchData.switches && switchData.switches.length > 0) {
const firstEnabled = switchData.switches.find(s => s.enabled);
if (firstEnabled) return firstEnabled.id;
}
return null;
}
// Conditional Switch: text input, dynamic rule outputs
if (node.type === "conditionalSwitch" && handleType === "text") {
if (needInput) {
return "text";
} else {
// Return first rule ID from node data
const condData = node.data as { rules?: Array<{ id: string }> };
if (condData.rules && condData.rules.length > 0) {
return condData.rules[0].id;
}
return "default";
}
}
// Fall back to static handles
const staticHandles = getNodeHandles(node.type || "");
const handleList = needInput ? staticHandles.inputs : staticHandles.outputs;
// First try exact match
if (handleList.includes(handleType)) return handleType;
// For video output connecting to output node, allow "image" input (output node accepts both)
if (handleType === "video" && needInput && node.type === "output") {
return "image";
}
// For audio output connecting to output node, use the "audio" input handle
if (handleType === "audio" && needInput && node.type === "output") {
return "audio";
}
// Then check each handle's type
for (const h of handleList) {
if (getHandleType(h) === handleType) return h;
}
return null;
};
// 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) {
// Find a compatible handle on the target node
const compatibleHandle = findCompatibleHandle(
targetNode,
fromHandleType,
isFromSource // need input if dragging from output
);
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, nodes, edges, handleConnect]
);
// Handle the splitGrid action - uses automated grid detection
const handleSplitGridAction = useCallback(
async (sourceNodeId: string, flowPosition: { x: number; y: number }) => {
const sourceNode = getNodeById(sourceNodeId);
if (!sourceNode) return;
// Get the output image from the source node
let sourceImage: string | null = null;
if (sourceNode.type === "nanoBanana") {
sourceImage = (sourceNode.data as NanoBananaNodeData).outputImage;
} else if (sourceNode.type === "imageInput") {
sourceImage = (sourceNode.data as { image: string | null }).image;
} else if (sourceNode.type === "annotation") {
sourceImage = (sourceNode.data as { outputImage: string | null }).outputImage;
}
if (!sourceImage) {
alert("No image available to split. Generate or load an image first.");
return;
}
const sourceNodeData = sourceNode.type === "nanoBanana" ? sourceNode.data as NanoBananaNodeData : null;
setIsSplitting(true);
try {
const { grid, images } = await detectAndSplitGrid(sourceImage);
if (images.length === 0) {
alert("Could not detect grid in image.");
setIsSplitting(false);
return;
}
// Calculate layout for the new nodes
const nodeWidth = 300;
const nodeHeight = 280;
const gap = 20;
// Add split images to global history
images.forEach((imageData: string, index: number) => {
const row = Math.floor(index / grid.cols);
const col = index % grid.cols;
addToGlobalHistory({
image: imageData,
timestamp: Date.now() + index,
prompt: `Split ${row + 1}-${col + 1} from ${grid.rows}x${grid.cols} grid`,
aspectRatio: sourceNodeData?.aspectRatio || "1:1",
model: sourceNodeData?.model || "nano-banana",
});
});
// Create ImageInput nodes arranged in a grid matching the layout
images.forEach((imageData: string, index: number) => {
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),
});
// Get dimensions from the split image
const img = new Image();
img.onload = () => {
updateNodeData(nodeId, {
image: imageData,
filename: `split-${row + 1}-${col + 1}.png`,
dimensions: { width: img.width, height: img.height },
});
};
img.src = imageData;
});
} catch (error) {
console.error("[SplitGrid] Error:", error);
alert("Failed to split image grid: " + (error instanceof Error ? error.message : "Unknown error"));
} finally {
setIsSplitting(false);
}
},
[getNodeById, addNode, updateNodeData, addToGlobalHistory]
);
// Helper to get image from a node
const getImageFromNode = useCallback((nodeId: string): string | null => {
const node = getNodeById(nodeId);
if (!node) return null;
switch (node.type) {
case "imageInput":
return (node.data as { image: string | null }).image;
case "annotation":
return (node.data as { outputImage: string | null }).outputImage;
case "nanoBanana":
return (node.data as { outputImage: string | null }).outputImage;
default:
return null;
}
}, [getNodeById]);
// Handle workflow generation from chat conversation
const handleBuildWorkflow = useCallback(async (description: string) => {
setIsBuildingWorkflow(true);
try {
const response = await fetch("/api/quickstart", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
description,
contentLevel: "full",
}),
});
const data = await response.json();
if (data.success && data.workflow) {
captureSnapshot(); // Capture BEFORE loading new workflow
await loadWorkflow(data.workflow, undefined, { preserveSnapshot: true });
setIsChatOpen(false);
showToast("Workflow generated successfully", "success");
} else {
showToast(data.error || "Failed to generate workflow", "error");
}
} catch (error) {
console.error("Error generating workflow:", error);
showToast("Failed to generate workflow. Please try again.", "error");
} finally {
setIsBuildingWorkflow(false);
}
}, [loadWorkflow, showToast, captureSnapshot]);
// Create lightweight workflow state for chat (strip base64 images)
const chatWorkflowState = useMemo(() => {
const strippedNodes = stripBinaryData(nodes);
return {
nodes: strippedNodes.map(n => ({
id: n.id,
type: n.type,
position: n.position,
data: n.data,
})),
edges: edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle || undefined,
targetHandle: e.targetHandle || undefined,
})),
};
}, [nodes, edges]);
// Compute selected node IDs for chat context scoping
const selectedNodeIds = useMemo(() => nodes.filter(n => n.selected).map(n => n.id), [nodes]);
// Handle applying edit operations from chat
const handleApplyEdits = useCallback((operations: EditOperation[]) => {
captureSnapshot(); // Snapshot before AI edits
const result = applyEditOperations(operations);
if (result.applied > 0) {
showToast(`Applied ${result.applied} edit(s)`, "success");
}
if (result.skipped.length > 0) {
console.warn('Skipped operations:', result.skipped);
}
return result;
}, [captureSnapshot, applyEditOperations, showToast]);
// Handle node selection from drop menu
const handleMenuSelect = useCallback(
(selection: { type: NodeType | MenuAction; isAction: boolean }) => {
if (!connectionDrop) return;
const { flowPosition, sourceNodeId, sourceHandleId, connectionType, handleType } = connectionDrop;
// Handle actions differently from node creation
if (selection.isAction) {
if (selection.type === "splitGridImmediate" && sourceNodeId) {
handleSplitGridAction(sourceNodeId, flowPosition);
}
setConnectionDrop(null);
return;
}
// Regular node creation
const nodeType = selection.type as NodeType;
// Create the new node at the drop position
const newNodeId = addNode(nodeType, flowPosition);
// If creating an annotation node from an image source, populate it with the source image
if (nodeType === "annotation" && connectionType === "source" && handleType === "image" && sourceNodeId) {
const sourceImage = getImageFromNode(sourceNodeId);
if (sourceImage) {
updateNodeData(newNodeId, { sourceImage, outputImage: sourceImage });
}
}
// Determine the correct handle IDs for the new node based on its type
let targetHandleId: string | null = null;
let sourceHandleIdForNewNode: string | null = null;
// Map handle type to the correct handle ID based on node type
// Note: New nodes start with default handles (image, text) before a model is selected
// Router accepts and outputs all types — use the connection's handle type
if (nodeType === "router") {
if (handleType) {
targetHandleId = handleType;
sourceHandleIdForNewNode = handleType;
}
} else if (nodeType === "switch") {
// Switch input: use the actual type so the edge stores the correct handle type
// (onConnect bypasses resolveSwitchHandle, so we must resolve here)
targetHandleId = handleType || "generic-input";
if (handleType) {
updateNodeData(newNodeId, { inputType: handleType as HandleType });
}
// Switch outputs use dynamic handle IDs (switch entry IDs)
sourceHandleIdForNewNode = null;
} else if (nodeType === "conditionalSwitch") {
// Conditional Switch: text input and dynamic rule outputs
targetHandleId = "text";
// Source handle is the first rule ID or "default"
const nodeDataCheck = nodes.find(n => n.id === newNodeId);
if (nodeDataCheck && nodeDataCheck.data) {
const condData = nodeDataCheck.data as { rules?: Array<{ id: string }> };
sourceHandleIdForNewNode = condData.rules && condData.rules.length > 0 ? condData.rules[0].id : "default";
} else {
sourceHandleIdForNewNode = "default";
}
} else if (handleType === "image") {
if (nodeType === "annotation" || nodeType === "output" || nodeType === "splitGrid" || nodeType === "outputGallery" || nodeType === "imageCompare") {
targetHandleId = "image";
// annotation also has an image output
if (nodeType === "annotation") {
sourceHandleIdForNewNode = "image";
}
} else if (nodeType === "nanoBanana" || nodeType === "generateVideo") {
targetHandleId = "image";
} else if (nodeType === "imageInput") {
sourceHandleIdForNewNode = "image";
}
} else if (handleType === "text") {
if (nodeType === "nanoBanana" || nodeType === "generateVideo" || nodeType === "generateAudio" || nodeType === "llmGenerate") {
targetHandleId = "text";
// llmGenerate also has a text output
if (nodeType === "llmGenerate") {
sourceHandleIdForNewNode = "text";
}
} else if (nodeType === "prompt" || nodeType === "promptConstructor" || nodeType === "array") {
// prompt, promptConstructor, and array can receive and output text
targetHandleId = "text";
sourceHandleIdForNewNode = "text";
}
} else if (handleType === "video") {
if (nodeType === "videoInput") {
// VideoInput accepts video input and outputs video
targetHandleId = "video";
sourceHandleIdForNewNode = "video";
} else if (nodeType === "videoStitch") {
// VideoStitch has dynamic video-N inputs and a video output
targetHandleId = "video-0";
sourceHandleIdForNewNode = "video";
} else if (nodeType === "easeCurve") {
// EaseCurve accepts video input and outputs video
targetHandleId = "video";
sourceHandleIdForNewNode = "video";
} else if (nodeType === "videoTrim") {
// VideoTrim accepts video input and outputs video
targetHandleId = "video";
sourceHandleIdForNewNode = "video";
} else if (nodeType === "videoFrameGrab") {
// VideoFrameGrab accepts video input and outputs image
targetHandleId = "video";
sourceHandleIdForNewNode = "image";
} else if (nodeType === "generateVideo") {
// GenerateVideo outputs video
sourceHandleIdForNewNode = "video";
} else if (nodeType === "output") {
// Output accepts video on its image handle (it detects video content type)
targetHandleId = "image";
}
} else if (handleType === "audio") {
if (nodeType === "audioInput") {
// Audio node: accepts audio input and outputs audio
targetHandleId = "audio";
sourceHandleIdForNewNode = "audio";
} else if (nodeType === "generateAudio") {
// GenerateAudio outputs audio (no audio input to wire to)
sourceHandleIdForNewNode = "audio";
} else if (nodeType === "generateVideo") {
// GenerateVideo accepts audio input and outputs video
targetHandleId = "audio";
sourceHandleIdForNewNode = "video";
} else if (nodeType === "videoStitch") {
// VideoStitch accepts audio
targetHandleId = "audio";
} else if (nodeType === "output") {
// Output accepts audio on its audio handle
targetHandleId = "audio";
}
} else if (handleType === "3d") {
if (nodeType === "glbViewer") {
targetHandleId = "3d";
} else if (nodeType === "nanoBanana") {
sourceHandleIdForNewNode = "3d";
}
} else if (handleType === "easeCurve") {
if (nodeType === "easeCurve") {
targetHandleId = "easeCurve";
sourceHandleIdForNewNode = "easeCurve";
}
}
// 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) {
const batchUsed = new Set<string>();
selectedNodes.forEach((node) => {
if (connectionType === "source" && targetHandleId) {
// For imageCompare, alternate between image and image-1
let resolvedTargetHandle = targetHandleId;
if (nodeType === "imageCompare" && targetHandleId === "image" && batchUsed.has("image")) {
resolvedTargetHandle = "image-1";
}
// For videoStitch, find next available video-N handle
if (nodeType === "videoStitch" && targetHandleId.startsWith("video-")) {
for (let i = 0; i < 50; i++) {
const candidateHandle = `video-${i}`;
if (!batchUsed.has(candidateHandle)) {
resolvedTargetHandle = candidateHandle;
break;
}
}
}
batchUsed.add(resolvedTargetHandle);
// Dragging from source (output), connect selected nodes to new node's input
const connection: Connection = {
source: node.id,
sourceHandle: sourceHandleId,
target: newNodeId,
targetHandle: resolvedTargetHandle,
};
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, nodes, handleSplitGridAction, getImageFromNode, updateNodeData]
);
const handleCloseDropMenu = useCallback(() => {
setConnectionDrop(null);
}, []);
// Get copy/paste functions and clipboard from store
const copySelectedNodes = useWorkflowStore((state) => state.copySelectedNodes);
const pasteNodes = useWorkflowStore((state) => state.pasteNodes);
const clearClipboard = useWorkflowStore((state) => state.clearClipboard);
const clipboard = useWorkflowStore((state) => state.clipboard);
const undo = useWorkflowStore((state) => state.undo);
const redo = useWorkflowStore((state) => state.redo);
// Add non-passive wheel listener to handle zoom/pan and prevent browser navigation
// This replaces the onWheel prop which is passive by default and can't preventDefault
useEffect(() => {
const wrapper = reactFlowWrapper.current;
if (!wrapper) return;
const handleWheelNonPassive = (event: WheelEvent) => {
// Skip if modal is open
if (isModalOpen) return;
// Check if scrolling over a scrollable element
const target = event.target as HTMLElement;
const scrollableElement = findScrollableAncestor(target, event.deltaX, event.deltaY);
if (scrollableElement) return;
const { zoomMode } = canvasNavigationSettings;
// Check if zoom should be triggered based on settings
const shouldZoom =
zoomMode === "scroll" ||
(zoomMode === "altScroll" && event.altKey) ||
(zoomMode === "ctrlScroll" && (event.ctrlKey || event.metaKey));
// Pinch gesture (ctrlKey + trackpad) always zooms regardless of settings
if (event.ctrlKey && !event.altKey) {
event.preventDefault();
if (event.deltaY < 0) zoomIn();
else zoomOut();
return;
}
// On macOS, differentiate trackpad from mouse
if (isMacOS) {
if (isMouseWheel(event)) {
// Mouse wheel → zoom if settings allow
if (shouldZoom) {
event.preventDefault();
if (event.deltaY < 0) zoomIn();
else zoomOut();
}
} else {
// Trackpad scroll
if (shouldZoom) {
// Zoom
event.preventDefault();
if (event.deltaY < 0) zoomIn();
else zoomOut();
} else {
// Pan (also prevent horizontal swipe navigation)
event.preventDefault();
const viewport = getViewport();
setViewport({
x: viewport.x - event.deltaX,
y: viewport.y - event.deltaY,
zoom: viewport.zoom,
});
}
}
return;
}
// Non-macOS
if (shouldZoom) {
event.preventDefault();
if (event.deltaY < 0) zoomIn();
else zoomOut();
}
};
wrapper.addEventListener('wheel', handleWheelNonPassive, { passive: false });
return () => {
wrapper.removeEventListener('wheel', handleWheelNonPassive);
};
}, [isModalOpen, zoomIn, zoomOut, getViewport, setViewport, canvasNavigationSettings]);
// Keyboard shortcuts for copy/paste and stacking selected nodes
const handleKeyDown = useCallback((event: KeyboardEvent) => {
// Ignore if user is typing in an input field
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
return;
}
// Handle keyboard shortcuts dialog (? key)
if (event.key === "?" && !event.ctrlKey && !event.metaKey) {
event.preventDefault();
setShortcutsDialogOpen(true);
return;
}
// Handle workflow execution (Ctrl/Cmd + Enter)
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
executeWorkflow();
return;
}
// Handle copy (Ctrl/Cmd + C)
if ((event.ctrlKey || event.metaKey) && event.key === "c") {
event.preventDefault();
copySelectedNodes();
return;
}
// Handle undo (Ctrl/Cmd + Z) and redo (Ctrl/Cmd + Shift + Z)
// Normalize key to lowercase — Shift makes event.key uppercase ("Z" instead of "z")
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") {
event.preventDefault();
if (event.shiftKey) {
redo();
} else {
undo();
}
return;
}
// Helper to get viewport center position in flow coordinates
const getViewportCenter = () => {
const viewport = getViewport();
const centerX = (-viewport.x + window.innerWidth / 2) / viewport.zoom;
const centerY = (-viewport.y + window.innerHeight / 2) / viewport.zoom;
return { centerX, centerY };
};
// Handle node creation hotkeys (Shift + key)
if (event.shiftKey && !event.ctrlKey && !event.metaKey) {
const key = event.key.toLowerCase();
let nodeType: NodeType | null = null;
switch (key) {
case "p":
nodeType = "prompt";
break;
case "r":
nodeType = "router";
break;
case "i":
nodeType = "imageInput";
break;
case "g":
nodeType = "nanoBanana";
break;
case "v":
nodeType = "generateVideo";
break;
case "l":
nodeType = "llmGenerate";
break;
case "a":
nodeType = "annotation";
break;
case "t":
nodeType = "generateAudio";
break;
case "y":
nodeType = "videoInput";
break;
}
if (nodeType) {
event.preventDefault();
const { centerX, centerY } = getViewportCenter();
// Offset by half the default node dimensions to center it
const defaultDimensions: Record<NodeType, { width: number; height: number }> = {
imageInput: { width: 300, height: 280 },
audioInput: { width: 300, height: 200 },
videoInput: { width: 300, height: 280 },
annotation: { width: 300, height: 280 },
prompt: { width: 320, height: 220 },
array: { width: 360, height: 360 },
promptConstructor: { width: 340, height: 280 },
nanoBanana: { width: 300, height: 300 },
generateVideo: { width: 300, height: 300 },
generate3d: { width: 300, height: 300 },
generateAudio: { width: 300, height: 280 },
llmGenerate: { width: 320, height: 360 },
splitGrid: { width: 300, height: 320 },
output: { width: 320, height: 320 },
outputGallery: { width: 320, height: 360 },
imageCompare: { width: 400, height: 360 },
videoStitch: { width: 400, height: 280 },
easeCurve: { width: 340, height: 480 },
videoTrim: { width: 360, height: 360 },
videoFrameGrab: { width: 320, height: 320 },
router: { width: 200, height: 80 },
switch: { width: 220, height: 120 },
conditionalSwitch: { width: 260, height: 180 },
glbViewer: { width: 360, height: 380 },
};
const dims = defaultDimensions[nodeType];
addNode(nodeType, { x: centerX - dims.width / 2, y: centerY - dims.height / 2 });
return;
}
}
// Handle paste (Ctrl/Cmd + V)
if ((event.ctrlKey || event.metaKey) && event.key === "v") {
event.preventDefault();
// If we have nodes in the internal clipboard, prioritize pasting those
if (clipboard && clipboard.nodes.length > 0) {
pasteNodes();
clearClipboard(); // Clear so next paste uses system clipboard
return;
}
// Check system clipboard for images first, then text
navigator.clipboard.read().then(async (items) => {
for (const item of items) {
// Check for image
const imageType = item.types.find(type => type.startsWith('image/'));
if (imageType) {
const blob = await item.getType(imageType);
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
const img = new Image();
img.onload = () => {
// Check if an imageInput node is selected - if so, update it instead of creating new
const selectedImageInputNode = nodes.find(
(node) => node.selected && node.type === "imageInput"
);
if (selectedImageInputNode) {
// Update the selected imageInput node with the pasted image
updateNodeData(selectedImageInputNode.id, {
image: dataUrl,
imageRef: undefined,
filename: `pasted-${Date.now()}.png`,
dimensions: { width: img.width, height: img.height },
});
} else {
// No imageInput node selected - create a new one at viewport center
const viewport = getViewport();
const centerX = (-viewport.x + window.innerWidth / 2) / viewport.zoom;
const centerY = (-viewport.y + window.innerHeight / 2) / viewport.zoom;
// ImageInput node default dimensions: 300x280
const nodeId = addNode("imageInput", { x: centerX - 150, y: centerY - 140 });
updateNodeData(nodeId, {
image: dataUrl,
filename: `pasted-${Date.now()}.png`,
dimensions: { width: img.width, height: img.height },
});
}
};
img.src = dataUrl;
};
reader.readAsDataURL(blob);
return; // Exit after handling image
}
// Check for text
if (item.types.includes('text/plain')) {
const blob = await item.getType('text/plain');
const text = await blob.text();
if (text.trim()) {
const viewport = getViewport();
const centerX = (-viewport.x + window.innerWidth / 2) / viewport.zoom;
const centerY = (-viewport.y + window.innerHeight / 2) / viewport.zoom;
// Prompt node default dimensions: 320x220
const nodeId = addNode("prompt", { x: centerX - 160, y: centerY - 110 });
updateNodeData(nodeId, { prompt: text });
return; // Exit after handling text
}
}
}
}).catch(() => {
// Clipboard API failed - nothing to paste
});
return;
}
const selectedNodes = nodes.filter((node) => node.selected);
if (selectedNodes.length < 2) return;
const STACK_GAP = 20;
if (event.key === "v" || event.key === "V") {
// Stack vertically - 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;
});
} else if (event.key === "h" || event.key === "H") {
// Stack horizontally - 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;
});
} else if (event.key === "g" || event.key === "G") {
// Arrange as grid
const count = selectedNodes.length;
const cols = Math.ceil(Math.sqrt(count));
// Sort nodes by their current position (top-to-bottom, left-to-right)
const sortedNodes = [...selectedNodes].sort((a, b) => {
const rowA = Math.floor(a.position.y / 100);
const rowB = Math.floor(b.position.y / 100);
if (rowA !== rowB) return rowA - rowB;
return a.position.x - b.position.x;
});
// Find the starting position (top-left of bounding box)
const startX = Math.min(...sortedNodes.map((n) => n.position.x));
const startY = Math.min(...sortedNodes.map((n) => n.position.y));
// Get max node dimensions for consistent spacing
const maxWidth = Math.max(
...sortedNodes.map((n) => (n.style?.width as number) || (n.measured?.width) || 220)
);
const maxHeight = Math.max(
...sortedNodes.map((n) => (n.style?.height as number) || (n.measured?.height) || 200)
);
// Position each node in the grid
sortedNodes.forEach((node, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
onNodesChange([
{
type: "position",
id: node.id,
position: {
x: startX + col * (maxWidth + STACK_GAP),
y: startY + row * (maxHeight + STACK_GAP),
},
},
]);
});
}
}, [nodes, onNodesChange, copySelectedNodes, pasteNodes, clearClipboard, clipboard, getViewport, addNode, updateNodeData, executeWorkflow, setShortcutsDialogOpen, undo, redo]);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
// Fix for React Flow selection bug where nodes with undefined bounds get incorrectly selected.
// Uses statistical outlier detection to identify and deselect nodes that are clearly
// outside the actual selection area.
const handleSelectionChange = useCallback(({ nodes: selectedNodes }: OnSelectionChangeParams) => {
if (selectedNodes.length <= 1) return;
// Get positions of all selected nodes
const positions = selectedNodes.map(n => ({
id: n.id,
x: n.position.x,
y: n.position.y,
}));
// Calculate IQR-based bounds for outlier detection
const sortedX = [...positions].sort((a, b) => a.x - b.x);
const sortedY = [...positions].sort((a, b) => a.y - b.y);
const q1X = sortedX[Math.floor(sortedX.length * 0.25)].x;
const q3X = sortedX[Math.floor(sortedX.length * 0.75)].x;
const q1Y = sortedY[Math.floor(sortedY.length * 0.25)].y;
const q3Y = sortedY[Math.floor(sortedY.length * 0.75)].y;
const iqrX = q3X - q1X;
const iqrY = q3Y - q1Y;
// Outlier threshold: 3x IQR from quartiles
const minX = q1X - iqrX * 3;
const maxX = q3X + iqrX * 3;
const minY = q1Y - iqrY * 3;
const maxY = q3Y + iqrY * 3;
// Find and deselect outliers
const outliers = positions.filter(p =>
p.x < minX || p.x > maxX || p.y < minY || p.y > maxY
);
if (outliers.length > 0) {
onNodesChange(
outliers.map(o => ({
type: 'select' as const,
id: o.id,
selected: false,
}))
);
}
}, [onNodesChange]);
const handleDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
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 a history image
const hasHistoryImage = Array.from(event.dataTransfer.types).includes("application/history-image");
if (hasHistoryImage) {
setIsDragOver(true);
setDropType("image");
return;
}
// Check if dragging files that are images or JSON
const items = Array.from(event.dataTransfer.items);
const hasImageFile = items.some(
(item) => item.kind === "file" && item.type.startsWith("image/")
);
const hasJsonFile = items.some(
(item) => item.kind === "file" && item.type === "application/json"
);
const hasAudioFile = items.some(
(item) => item.kind === "file" && item.type.startsWith("audio/")
);
if (hasJsonFile) {
setIsDragOver(true);
setDropType("workflow");
} else if (hasAudioFile) {
setIsDragOver(true);
setDropType("audio");
} else if (hasImageFile) {
setIsDragOver(true);
setDropType("image");
}
}, []);
const handleDragLeave = useCallback((event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragOver(false);
setDropType(null);
}, []);
const handleDrop = useCallback(
(event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
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;
}
// Check for history image drop
const historyImageData = event.dataTransfer.getData("application/history-image");
if (historyImageData) {
try {
const { image, prompt } = JSON.parse(historyImageData);
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// Create ImageInput node with the history image
const nodeId = addNode("imageInput", position);
// Get image dimensions and update node
const img = new Image();
img.onload = () => {
updateNodeData(nodeId, {
image: image,
filename: `history-${Date.now()}.png`,
dimensions: { width: img.width, height: img.height },
});
};
img.src = image;
return;
} catch (err) {
console.error("Failed to parse history image data:", err);
}
}
const allFiles = Array.from(event.dataTransfer.files);
// Check for JSON workflow files first
const jsonFiles = allFiles.filter((file) => file.type === "application/json" || file.name.endsWith(".json"));
if (jsonFiles.length > 0) {
const file = jsonFiles[0];
const reader = new FileReader();
reader.onload = async (e) => {
try {
const workflow = JSON.parse(e.target?.result as string) as WorkflowFile;
if (workflow.version && workflow.nodes && workflow.edges) {
await loadWorkflow(workflow);
} else {
alert("Invalid workflow file format");
}
} catch {
alert("Failed to parse workflow file");
}
};
reader.readAsText(file);
return;
}
// Handle audio files
const audioFiles = allFiles.filter((file) => file.type.startsWith("audio/"));
if (audioFiles.length > 0) {
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
audioFiles.forEach((file, index) => {
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
const nodeId = addNode("audioInput", {
x: position.x + index * 240,
y: position.y,
});
updateNodeData(nodeId, {
audioFile: dataUrl,
filename: file.name,
format: file.type,
});
};
reader.readAsDataURL(file);
});
return;
}
// Handle image files
const imageFiles = allFiles.filter((file) => file.type.startsWith("image/"));
if (imageFiles.length === 0) return;
// Get the drop position in flow coordinates
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// Create a node for each dropped image
imageFiles.forEach((file, index) => {
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
// Create image to get dimensions
const img = new Image();
img.onload = () => {
// Add the node at the drop position (offset for multiple files)
const nodeId = addNode("imageInput", {
x: position.x + index * 240,
y: position.y,
});
// Update the node with the image data
updateNodeData(nodeId, {
image: dataUrl,
filename: file.name,
dimensions: { width: img.width, height: img.height },
});
};
img.src = dataUrl;
};
reader.readAsDataURL(file);
});
},
[screenToFlowPosition, addNode, updateNodeData, loadWorkflow]
);
return (
<div
ref={reactFlowWrapper}
className={`flex-1 bg-canvas-bg relative ${isDragOver ? "ring-2 ring-inset ring-blue-500" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drop overlay indicator */}
{isDragOver && (
<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"
: dropType === "node"
? "Drop to create node"
: dropType === "audio"
? "Drop audio to create node"
: "Drop image to create node"}
</p>
</div>
</div>
)}
{/* Splitting indicator */}
{isSplitting && (
<div className="absolute inset-0 bg-black/50 z-50 flex items-center justify-center">
<div className="bg-neutral-800 border border-neutral-600 rounded-lg px-6 py-4 shadow-xl flex items-center gap-3">
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<p className="text-neutral-200 text-sm font-medium">Splitting image grid...</p>
</div>
</div>
)}
{/* Welcome Modal */}
{showQuickstart && (
<WelcomeModal
onWorkflowGenerated={async (workflow, directoryPath) => {
await loadWorkflow(workflow, directoryPath);
setShowQuickstart(false);
}}
onClose={() => setShowQuickstart(false)}
onNewProject={() => {
clearWorkflow();
setShowQuickstart(false);
setShowNewProjectSetup(true);
}}
/>
)}
{/* New Project Setup Modal */}
{showNewProjectSetup && (
<ProjectSetupModal
isOpen={showNewProjectSetup}
mode="new"
onSave={(id, name, directoryPath) => {
setWorkflowMetadata(id, name, directoryPath);
setShowNewProjectSetup(false);
}}
onClose={() => {
setShowNewProjectSetup(false);
setShowQuickstart(true);
}}
/>
)}
<ReactFlow
nodes={allNodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={handleConnect}
onConnectEnd={handleConnectEnd}
onMoveStart={() => { isPanningRef.current = true; setHoveredNodeId(null); document.documentElement.classList.add("canvas-interacting"); }}
onMoveEnd={() => { isPanningRef.current = false; document.documentElement.classList.remove("canvas-interacting"); }}
onNodeDragStart={() => { isDraggingNodeRef.current = true; document.documentElement.classList.add("canvas-interacting"); }}
onNodeDragStop={(event, node) => { isDraggingNodeRef.current = false; document.documentElement.classList.remove("canvas-interacting"); handleNodeDragStop(event, node); }}
onSelectionChange={handleSelectionChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
isValidConnection={isValidConnection}
fitView
deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode="Shift"
selectionOnDrag={
canvasNavigationSettings.selectionMode === "altDrag" || canvasNavigationSettings.selectionMode === "shiftDrag"
? false
: canvasNavigationSettings.panMode === "always"
? false
: isMacOS && !isModalOpen
}
selectionKeyCode={
isModalOpen ? null
: canvasNavigationSettings.selectionMode === "altDrag" ? "Alt"
: canvasNavigationSettings.selectionMode === "shiftDrag" ? "Shift"
: "Shift"
}
panOnDrag={
isModalOpen
? false
: canvasNavigationSettings.panMode === "always"
? true
: canvasNavigationSettings.panMode === "middleMouse"
? [2]
: !isMacOS
}
selectNodesOnDrag={false}
nodeDragThreshold={5}
nodeClickDistance={5}
zoomOnScroll={false}
zoomOnPinch={!isModalOpen}
minZoom={0.1}
maxZoom={4}
defaultViewport={{ x: 0, y: 0, zoom: 1 }}
panActivationKeyCode={
isModalOpen
? null
: canvasNavigationSettings.panMode === "space"
? "Space"
: null
}
nodesDraggable={!isModalOpen}
nodesConnectable={!isModalOpen}
elementsSelectable={!isModalOpen}
className="bg-neutral-900"
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{
type: "editable",
animated: false,
}}
>
<SharedEdgeGradients />
<GroupBackgroundsPortal />
<GroupControlsOverlay />
<Background color="#404040" gap={20} size={1} />
<Controls className="bg-neutral-800 border border-neutral-700 rounded-lg shadow-lg [&>button]:bg-neutral-800 [&>button]:border-neutral-700 [&>button]:fill-neutral-300 [&>button:hover]:bg-neutral-700 [&>button:hover]:fill-neutral-100" />
<MiniMap
className="bg-neutral-800 border border-neutral-700 rounded-lg shadow-lg"
maskColor="rgba(0, 0, 0, 0.6)"
pannable
zoomable
nodeColor={(node) => {
switch (node.type) {
case "imageInput":
return "#3b82f6";
case "audioInput":
return "#a78bfa";
case "videoInput":
return "#c084fc"; // purple-400 (video input, distinct from generateVideo's #9333ea)
case "annotation":
return "#8b5cf6";
case "prompt":
return "#f97316";
case "array":
return "#a3e635";
case "promptConstructor":
return "#f472b6";
case "nanoBanana":
return "#22c55e";
case "generateVideo":
return "#9333ea";
case "generate3d":
return "#fb923c";
case "generateAudio":
return "#d946ef"; // fuchsia-500 (audio/TTS)
case "llmGenerate":
return "#06b6d4";
case "splitGrid":
return "#f59e0b";
case "output":
return "#ef4444";
case "outputGallery":
return "#ec4899";
case "imageCompare":
return "#14b8a6";
case "videoStitch":
return "#f97316";
case "easeCurve":
return "#bef264"; // lime-300 (easy-peasy-ease)
case "videoTrim":
return "#60a5fa"; // blue-400 (trim/cut)
case "videoFrameGrab":
return "#38bdf8"; // sky-400 (image from video)
case "router":
return "#6b7280"; // neutral-500 (gray/slate utility theme)
case "switch":
return "#8b5cf6"; // violet-500 (distinct from Router)
case "conditionalSwitch":
return "#06b6d4"; // cyan-500 (distinct from Router gray and Switch violet)
case "glbViewer":
return "#0ea5e9"; // sky-500 (3D viewport)
default:
return "#94a3b8";
}
}}
/>
<ViewportPortal>
{allNodes.map((node) => {
// Groups don't get floating headers
if (node.type === "group" as any) return null;
const defaultWidth = defaultNodeDimensions[node.type as NodeType]?.width ?? 250;
const headerWidth = node.measured?.width || (node.style?.width as number) || defaultWidth;
// Browse button for generate nodes in inline-parameters mode
const showBrowse = inlineParametersEnabled && (
node.type === "nanoBanana" || node.type === "generateVideo" ||
node.type === "generate3d" || node.type === "generateAudio"
);
const browseAction = showBrowse ? (
<button
onClick={() => browseRegistry.open(node.id)}
className="nodrag nopan text-[10px] py-0.5 px-1.5 bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
Browse
</button>
) : undefined;
// Optional toggle for input nodes
const isInputNode = node.type === "imageInput" || node.type === "audioInput" || node.type === "prompt";
const isOptional = !!(node.data as any)?.isOptional;
const optionalToggle = isInputNode ? (
<button
onClick={() => updateNodeData(node.id, { isOptional: !isOptional })}
className={`nodrag nopan text-[10px] py-0.5 px-1.5 rounded transition-colors ${
isOptional
? "bg-amber-600/80 hover:bg-amber-500/80 text-white border border-amber-500/50"
: "bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 text-neutral-400"
}`}
title={isOptional ? "This input is optional — empty inputs will be skipped" : "Mark as optional — empty inputs will skip this branch"}
>
{isOptional ? "Optional" : "Required"}
</button>
) : undefined;
return (
<FloatingNodeHeader
key={`header-${node.id}`}
id={node.id}
type={node.type as NodeType}
isInLockedGroup={!!(node.data as any)?.isInLockedGroup}
isExecuting={!!(node.data as any)?.isExecuting}
focusedCommentNodeId={(node.data as any)?.focusedCommentNodeId}
position={node.position}
width={headerWidth}
selected={!!node.selected}
title={getNodeTitle(node)}
customTitle={node.data?.customTitle}
comment={node.data?.comment}
provider={(node.data as any)?.selectedModel?.provider}
headerAction={browseAction}
headerButtons={optionalToggle}
onCustomTitleChange={handleCustomTitleChange}
onCommentChange={handleCommentChange}
onRunNode={handleRunNode}
onExpandNode={handleExpandNode}
/>
);
})}
</ViewportPortal>
</ReactFlow>
{/* Connection drop menu */}
{connectionDrop && connectionDrop.handleType && (
<ConnectionDropMenu
position={connectionDrop.position}
handleType={connectionDrop.handleType}
connectionType={connectionDrop.connectionType}
onSelect={handleMenuSelect}
onClose={handleCloseDropMenu}
/>
)}
{/* Multi-select toolbar */}
<MultiSelectToolbar />
{/* Edge toolbar */}
<EdgeToolbar />
{/* Global image history */}
<GlobalImageHistory />
{/* Chat toggle button - hidden for now */}
{/* Chat panel */}
<ChatPanel
isOpen={isChatOpen}
onClose={() => setIsChatOpen(false)}
onBuildWorkflow={handleBuildWorkflow}
isBuildingWorkflow={isBuildingWorkflow}
onApplyEdits={handleApplyEdits}
workflowState={chatWorkflowState}
selectedNodeIds={selectedNodeIds}
/>
{/* Control panel - renders on right side when a configurable node is selected */}
<ControlPanel />
{/* Expansion modals - rendered via portal when expand button is clicked */}
{expandingNode && expandingNode.type === 'prompt' && (() => {
const node = getNodeById(expandingNode.id);
if (!node) return null;
return createPortal(
<PromptEditorModal
isOpen={true}
initialPrompt={(node.data as any)?.prompt || ''}
onSubmit={(prompt) => {
updateNodeData(expandingNode.id, { prompt });
setExpandingNode(null);
}}
onClose={() => setExpandingNode(null)}
/>,
document.body
);
})()}
{expandingNode && expandingNode.type === 'promptConstructor' && (() => {
const node = getNodeById(expandingNode.id);
if (!node) return null;
// Compute available variables from connected text nodes (same logic as PromptConstructorNode)
const directTextNodes = edges
.filter((e) => e.target === expandingNode.id && e.targetHandle === "text")
.map((e) => nodes.find((n) => n.id === e.source))
.filter((n): n is typeof nodes[0] => n !== undefined);
const connectedTextNodes = resolveTextSourcesThroughRouters(directTextNodes, nodes, edges);
const vars: AvailableVariable[] = [];
const usedNames = new Set<string>();
connectedTextNodes.forEach((cn) => {
if (cn.type === "prompt") {
const promptData = cn.data as PromptNodeData;
if (promptData.variableName) {
vars.push({ name: promptData.variableName, value: promptData.prompt || "", nodeId: cn.id });
usedNames.add(promptData.variableName);
}
}
});
connectedTextNodes.forEach((cn) => {
let text: string | null = null;
if (cn.type === "prompt") text = (cn.data as PromptNodeData).prompt || null;
else if (cn.type === "llmGenerate") text = (cn.data as LLMGenerateNodeData).outputText || null;
else if (cn.type === "promptConstructor") {
const pcData = cn.data as PromptConstructorNodeData;
text = pcData.outputText ?? pcData.template ?? null;
}
if (text) {
parseVarTags(text).forEach(({ name, value }) => {
if (!usedNames.has(name)) {
vars.push({ name, value, nodeId: `${cn.id}-var-${name}` });
usedNames.add(name);
}
});
}
});
return createPortal(
<PromptConstructorEditorModal
isOpen={true}
initialTemplate={(node.data as PromptConstructorNodeData)?.template || ''}
availableVariables={vars}
onSubmit={(template) => {
updateNodeData(expandingNode.id, { template });
setExpandingNode(null);
}}
onClose={() => setExpandingNode(null)}
/>,
document.body
);
})()}
{expandingNode && expandingNode.type === 'splitGrid' && (() => {
const node = getNodeById(expandingNode.id);
if (!node) return null;
return (
<SplitGridSettingsModal
nodeId={expandingNode.id}
nodeData={node.data as any}
onClose={() => setExpandingNode(null)}
/>
);
})()}
{/* AnnotationModal is globally managed by annotationStore */}
<AnnotationModal />
</div>
);
}