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.
 
 

1140 lines
40 KiB

"use client";
import { useCallback, useRef, useState, useEffect, DragEvent, useMemo } from "react";
import {
ReactFlow,
Background,
Controls,
MiniMap,
NodeTypes,
EdgeTypes,
Connection,
Edge,
useReactFlow,
OnConnectEnd,
Node,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useWorkflowStore, WorkflowFile } from "@/store/workflowStore";
import {
ImageInputNode,
AnnotationNode,
PromptNode,
NanoBananaNode,
LLMGenerateNode,
SplitGridNode,
OutputNode,
} from "./nodes";
import { EditableEdge, ReferenceEdge } 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 } from "@/types";
import { detectAndSplitGrid } from "@/utils/gridSplitter";
const nodeTypes: NodeTypes = {
imageInput: ImageInputNode,
annotation: AnnotationNode,
prompt: PromptNode,
nanoBanana: NanoBananaNode,
llmGenerate: LLMGenerateNode,
splitGrid: SplitGridNode,
output: OutputNode,
};
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
// - NanoBanana image input accepts multiple connections
// - All other inputs accept only one connection
const isValidConnection = (connection: Edge | Connection): boolean => {
const sourceHandle = connection.sourceHandle;
const targetHandle = connection.targetHandle;
// Strict type matching: image <-> image, text <-> text
if (sourceHandle === "image" && targetHandle !== "image") {
return false;
}
if (sourceHandle === "text" && targetHandle !== "text") {
return false;
}
return true;
};
// 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 "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 "splitGrid":
return { inputs: ["image"], outputs: ["reference"] };
case "output":
return { inputs: ["image"], outputs: [] };
default:
return { inputs: [], outputs: [] };
}
};
interface ConnectionDropState {
position: { x: number; y: number };
flowPosition: { x: number; y: number };
handleType: "image" | "text" | 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;
};
export function WorkflowCanvas() {
const { nodes, edges, groups, onNodesChange, onEdgesChange, onConnect, addNode, updateNodeData, loadWorkflow, getNodeById, addToGlobalHistory, setNodeGroupId } =
useWorkflowStore();
const { screenToFlowPosition, getViewport, zoomIn, zoomOut, setViewport } = useReactFlow();
const [isDragOver, setIsDragOver] = useState(false);
const [dropType, setDropType] = useState<"image" | "workflow" | "node" | null>(null);
const [connectionDrop, setConnectionDrop] = useState<ConnectionDropState | null>(null);
const [isSplitting, setIsSplitting] = useState(false);
const reactFlowWrapper = useRef<HTMLDivElement>(null);
// Just pass regular nodes to React Flow - groups are rendered separately
const allNodes = useMemo(() => {
return nodes;
}, [nodes]);
// 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 nodeWidth = (node.style?.width as number) || 300;
const nodeHeight = (node.style?.height as number) || 280;
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]
);
const handleConnect = useCallback(
(connection: Connection) => {
if (!isValidConnection(connection)) return;
// 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) {
selectedNodes.forEach((node) => {
// Skip if this is already the connection source
if (node.id === connection.source) {
onConnect(connection);
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
const multiConnection: Connection = {
source: node.id,
sourceHandle: connection.sourceHandle,
target: connection.target,
targetHandle: connection.targetHandle,
};
if (isValidConnection(multiConnection)) {
onConnect(multiConnection);
}
});
} else {
// Single connection
onConnect(connection);
}
},
[onConnect, nodes]
);
// 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;
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, nodes, getNodeHandles, 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;
});
console.log(`[SplitGrid] Created ${images.length} nodes from ${grid.rows}x${grid.cols} grid (confidence: ${Math.round(grid.confidence * 100)}%)`);
} 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 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
if (handleType === "image") {
if (nodeType === "annotation" || nodeType === "output" || nodeType === "splitGrid") {
targetHandleId = "image";
} else if (nodeType === "nanoBanana") {
targetHandleId = "image";
} else if (nodeType === "imageInput") {
sourceHandleIdForNewNode = "image";
}
} else if (handleType === "text") {
if (nodeType === "nanoBanana" || nodeType === "llmGenerate") {
targetHandleId = "text";
// llmGenerate also has a text output
if (nodeType === "llmGenerate") {
sourceHandleIdForNewNode = "text";
}
} else if (nodeType === "prompt") {
sourceHandleIdForNewNode = "text";
}
}
// Get all selected nodes to connect them all to the new node
const selectedNodes = nodes.filter((node) => node.selected);
const sourceNode = nodes.find((node) => node.id === sourceNodeId);
// If the source node is selected and there are multiple selected nodes,
// connect all selected nodes to the new node
if (sourceNode?.selected && selectedNodes.length > 1 && sourceHandleId) {
selectedNodes.forEach((node) => {
if (connectionType === "source" && targetHandleId) {
// Dragging from source (output), connect selected nodes to new node's input
const connection: Connection = {
source: node.id,
sourceHandle: sourceHandleId,
target: newNodeId,
targetHandle: targetHandleId,
};
if (isValidConnection(connection)) {
onConnect(connection);
}
} else if (connectionType === "target" && sourceHandleIdForNewNode) {
// Dragging from target (input), connect from new node's output to selected nodes
const connection: Connection = {
source: newNodeId,
sourceHandle: sourceHandleIdForNewNode,
target: node.id,
targetHandle: sourceHandleId,
};
if (isValidConnection(connection)) {
onConnect(connection);
}
}
});
} else {
// Single node connection (original behavior)
if (connectionType === "source" && sourceNodeId && sourceHandleId && targetHandleId) {
// Dragging from source (output), connect to new node's input
const connection: Connection = {
source: sourceNodeId,
sourceHandle: sourceHandleId,
target: newNodeId,
targetHandle: targetHandleId,
};
onConnect(connection);
} else if (connectionType === "target" && sourceNodeId && sourceHandleId && sourceHandleIdForNewNode) {
// Dragging from target (input), connect from new node's output
const connection: Connection = {
source: newNodeId,
sourceHandle: sourceHandleIdForNewNode,
target: sourceNodeId,
targetHandle: sourceHandleId,
};
onConnect(connection);
}
}
setConnectionDrop(null);
},
[connectionDrop, addNode, onConnect, nodes, handleSplitGridAction, getImageFromNode, updateNodeData]
);
const handleCloseDropMenu = useCallback(() => {
setConnectionDrop(null);
}, []);
// Custom wheel handler for macOS trackpad support
const handleWheel = useCallback((event: React.WheelEvent) => {
// Check if scrolling over a scrollable element (e.g., textarea, scrollable div)
const target = event.target as HTMLElement;
const scrollableElement = findScrollableAncestor(target, event.deltaX, event.deltaY);
if (scrollableElement) {
// Let the element handle its own scroll - don't prevent default or manipulate viewport
return;
}
// Pinch gesture (ctrlKey) always zooms
if (event.ctrlKey) {
event.preventDefault();
if (event.deltaY < 0) zoomIn();
else zoomOut();
return;
}
// On macOS, differentiate trackpad from mouse
if (isMacOS) {
const nativeEvent = event.nativeEvent;
if (isMouseWheel(nativeEvent)) {
// Mouse wheel → zoom
event.preventDefault();
if (event.deltaY < 0) zoomIn();
else zoomOut();
} else {
// Trackpad scroll → pan
event.preventDefault();
const viewport = getViewport();
setViewport({
x: viewport.x - event.deltaX,
y: viewport.y - event.deltaY,
zoom: viewport.zoom,
});
}
return;
}
// Non-macOS: default zoom behavior
event.preventDefault();
if (event.deltaY < 0) zoomIn();
else zoomOut();
}, [zoomIn, zoomOut, getViewport, setViewport]);
// Get copy/paste functions and clipboard from store
const { copySelectedNodes, pasteNodes, clearClipboard, clipboard } = useWorkflowStore();
// Add non-passive wheel listener to prevent Chrome swipe navigation on macOS
useEffect(() => {
const handleWheelCapture = (event: WheelEvent) => {
// Always preventDefault on horizontal wheel to block browser back/forward navigation
// But let the event propagate so React Flow and other handlers can still process it
if (event.deltaX !== 0) {
event.preventDefault();
}
};
// Add listener with passive: false and capture phase to catch events early
const wrapper = reactFlowWrapper.current;
if (wrapper && isMacOS) {
wrapper.addEventListener('wheel', handleWheelCapture, { passive: false, capture: true });
return () => {
wrapper.removeEventListener('wheel', handleWheelCapture, true);
};
}
}, []);
// Keyboard shortcuts for copy/paste and stacking selected nodes
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Ignore if user is typing in an input field
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
return;
}
// Handle copy (Ctrl/Cmd + C)
if ((event.ctrlKey || event.metaKey) && event.key === "c") {
event.preventDefault();
copySelectedNodes();
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 "i":
nodeType = "imageInput";
break;
case "g":
nodeType = "nanoBanana";
break;
case "l":
nodeType = "llmGenerate";
break;
case "a":
nodeType = "annotation";
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 },
annotation: { width: 300, height: 280 },
prompt: { width: 320, height: 220 },
nanoBanana: { width: 300, height: 300 },
llmGenerate: { width: 320, height: 360 },
splitGrid: { width: 300, height: 320 },
output: { width: 320, height: 320 },
};
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 viewport = getViewport();
const centerX = (-viewport.x + window.innerWidth / 2) / viewport.zoom;
const centerY = (-viewport.y + window.innerHeight / 2) / viewport.zoom;
const img = new Image();
img.onload = () => {
// 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),
},
},
]);
});
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [nodes, onNodesChange, copySelectedNodes, pasteNodes, clearClipboard, clipboard, getViewport, addNode, updateNodeData]);
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"
);
if (hasJsonFile) {
setIsDragOver(true);
setDropType("workflow");
} 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 = (e) => {
try {
const workflow = JSON.parse(e.target?.result as string) as WorkflowFile;
if (workflow.version && workflow.nodes && workflow.edges) {
loadWorkflow(workflow);
} else {
alert("Invalid workflow file format");
}
} catch {
alert("Failed to parse workflow file");
}
};
reader.readAsText(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"
: "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>
)}
<ReactFlow
nodes={allNodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={handleConnect}
onConnectEnd={handleConnectEnd}
onNodeDragStop={handleNodeDragStop}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
isValidConnection={isValidConnection}
fitView
deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode="Shift"
selectionOnDrag={isMacOS}
panOnDrag={!isMacOS}
selectNodesOnDrag={false}
nodeDragThreshold={5}
zoomOnScroll={false}
zoomOnPinch={true}
minZoom={0.1}
maxZoom={4}
defaultZoom={1}
panActivationKeyCode="Space"
onWheel={handleWheel}
className="bg-neutral-900"
defaultEdgeOptions={{
type: "editable",
animated: false,
}}
>
<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)"
nodeColor={(node) => {
switch (node.type) {
case "imageInput":
return "#3b82f6";
case "annotation":
return "#8b5cf6";
case "prompt":
return "#f97316";
case "nanoBanana":
return "#22c55e";
case "llmGenerate":
return "#06b6d4";
case "splitGrid":
return "#f59e0b";
case "output":
return "#ef4444";
default:
return "#94a3b8";
}
}}
/>
</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 />
</div>
);
}