"use client"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { Handle, Node, NodeProps, Position, useReactFlow } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useCommentNavigation } from "@/hooks/useCommentNavigation"; import { useWorkflowStore } from "@/store/workflowStore"; import { ArrayNodeData } from "@/types"; import { parseTextToArray } from "@/utils/arrayParser"; type ArrayNodeType = Node; function arraysEqual(a: string[], b: string[]): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; } return true; } export function ArrayNode({ id, data, selected }: NodeProps) { const nodeData = data; const commentNavigation = useCommentNavigation(id); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const addNode = useWorkflowStore((state) => state.addNode); const onConnect = useWorkflowStore((state) => state.onConnect); const nodes = useWorkflowStore((state) => state.nodes); const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs); const edges = useWorkflowStore((state) => state.edges); const { setNodes } = useReactFlow(); const lastSyncedInputRef = useRef(null); const lastDerivedWriteRef = useRef(null); const hasIncomingTextConnection = useMemo(() => { return edges.some((edge) => { if (edge.target !== id) return false; const handle = edge.targetHandle || "text"; return handle === "text" || handle.startsWith("text-") || handle.includes("prompt"); }); }, [edges, id]); // Pull upstream text into this node whenever the connected input changes. useEffect(() => { if (!hasIncomingTextConnection) return; const { text } = getConnectedInputs(id); if ( text !== null && text !== nodeData.inputText && text !== lastSyncedInputRef.current ) { lastSyncedInputRef.current = text; updateNodeData(id, { inputText: text }); } }, [hasIncomingTextConnection, getConnectedInputs, id, nodeData.inputText, updateNodeData]); const parsed = useMemo(() => { return parseTextToArray(nodeData.inputText, { splitMode: nodeData.splitMode, delimiter: nodeData.delimiter, regexPattern: nodeData.regexPattern, trimItems: nodeData.trimItems, removeEmpty: nodeData.removeEmpty, }); }, [ nodeData.inputText, nodeData.splitMode, nodeData.delimiter, nodeData.regexPattern, nodeData.trimItems, nodeData.removeEmpty, ]); // Keep derived outputs in node data so execution/edges always read the latest values. useEffect(() => { const nextOutputText = JSON.stringify(parsed.items); const writeSignature = `${parsed.error ?? ""}::${nextOutputText}`; const needsSync = parsed.error !== nodeData.error || nextOutputText !== (nodeData.outputText ?? "[]") || !arraysEqual(parsed.items, nodeData.outputItems || []); if (!needsSync) return; if (lastDerivedWriteRef.current === writeSignature) return; lastDerivedWriteRef.current = writeSignature; if ( parsed.error !== nodeData.error || nextOutputText !== (nodeData.outputText ?? "[]") || !arraysEqual(parsed.items, nodeData.outputItems || []) ) { updateNodeData(id, { outputItems: parsed.items, outputText: nextOutputText, error: parsed.error, }); } }, [id, nodeData.error, nodeData.outputItems, nodeData.outputText, parsed.error, parsed.items, updateNodeData]); const handleModeChange = useCallback( (e: React.ChangeEvent) => { updateNodeData(id, { splitMode: e.target.value as ArrayNodeData["splitMode"] }); }, [id, updateNodeData] ); const previewItems = parsed.items; const handleAutoRouteToPrompts = useCallback(() => { const items = nodeData.outputItems || []; if (items.length === 0) return; const sourceNode = nodes.find((n) => n.id === id); if (!sourceNode) return; const sourceWidth = (sourceNode.style?.width as number) || 360; const baseX = sourceNode.position.x + sourceWidth + 220; const baseY = sourceNode.position.y; const promptHeight = 220; const verticalGap = 24; const previousSelected = nodeData.selectedOutputIndex ?? null; items.forEach((item, index) => { const promptNodeId = addNode( "prompt", { x: baseX, y: baseY + index * (promptHeight + verticalGap) }, { prompt: item } ); // Force each generated edge to bind to the matching array item index. updateNodeData(id, { selectedOutputIndex: index }); onConnect({ source: id, sourceHandle: "text", target: promptNodeId, targetHandle: "text", }); }); updateNodeData(id, { selectedOutputIndex: previousSelected }); }, [addNode, id, nodeData.outputItems, nodeData.selectedOutputIndex, nodes, onConnect, updateNodeData]); // Reset selection if it no longer points to a valid parsed item. useEffect(() => { const selected = nodeData.selectedOutputIndex; if (selected !== null && (selected < 0 || selected >= previewItems.length)) { updateNodeData(id, { selectedOutputIndex: null }); } }, [id, nodeData.selectedOutputIndex, previewItems.length, updateNodeData]); // Auto-resize node height to fit all parsed lines so users don't need to scroll. useEffect(() => { const baseHeight = 360; const perItemHeight = 30; const newHeight = Math.max(baseHeight, 270 + previewItems.length * perItemHeight); setNodes((nodes) => nodes.map((node) => node.id === id ? { ...node, style: { ...node.style, height: newHeight } } : node ) ); }, [id, previewItems.length, setNodes]); return ( updateNodeData(id, { customTitle: title || undefined })} onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} selected={selected} commentNavigation={commentNavigation ?? undefined} hasError={!!nodeData.error} minWidth={320} minHeight={300} headerButtons={ } > {/* Single text output point (each outgoing edge receives a separate item) */}
{nodeData.splitMode === "delimiter" && (
updateNodeData(id, { delimiter: e.target.value })} placeholder="*" className="nodrag nopan bg-neutral-900 border border-neutral-700 rounded px-2 py-1 text-[11px] text-neutral-100 focus:outline-none focus:ring-1 focus:ring-neutral-600" />
)} {nodeData.splitMode === "regex" && (
updateNodeData(id, { regexPattern: e.target.value })} placeholder="/\\n+/" className="nodrag nopan bg-neutral-900 border border-neutral-700 rounded px-2 py-1 text-[11px] text-neutral-100 focus:outline-none focus:ring-1 focus:ring-neutral-600" />
)}
Parsed Items ({previewItems.length})
{nodeData.error ? (
{nodeData.error}
) : previewItems.length === 0 ? (
No items parsed
) : (
{previewItems.map((item, index) => { const isSelected = nodeData.selectedOutputIndex === index; return (
); })}
)}
{nodeData.selectedOutputIndex !== null ? `Next wire uses item ${nodeData.selectedOutputIndex + 1}` : "No selection: wires advance in order from item 1"}
); }