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.
 
 

298 lines
12 KiB

"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<ArrayNodeData, "array">;
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<ArrayNodeType>) {
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<string | null>(null);
const lastDerivedWriteRef = useRef<string | null>(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<HTMLSelectElement>) => {
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 (
<BaseNode
id={id}
title="Array"
customTitle={nodeData.customTitle}
comment={nodeData.comment}
onCustomTitleChange={(title) => 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={
<button
type="button"
onClick={handleAutoRouteToPrompts}
disabled={previewItems.length === 0}
className="nodrag nopan ml-2 shrink-0 p-1 rounded border border-neutral-600 text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed"
title="Auto-route to Prompts"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 3v6m0 0a2 2 0 104 0 2 2 0 00-4 0zm0 0v7a3 3 0 003 3h6m0 0a2 2 0 100 4 2 2 0 000-4zm0 0v-6a3 3 0 013-3h0" />
</svg>
</button>
}
>
<Handle type="target" position={Position.Left} id="text" data-handletype="text" />
{/* Single text output point (each outgoing edge receives a separate item) */}
<Handle type="source" position={Position.Right} id="text" data-handletype="text" style={{ top: 48 }} />
<div className="flex flex-col gap-2 flex-1 min-h-0">
<div className="grid grid-cols-[auto_1fr] gap-2 items-center">
<label className="text-[11px] text-neutral-400">Split</label>
<select
value={nodeData.splitMode}
onChange={handleModeChange}
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"
>
<option value="delimiter">Delimiter</option>
<option value="newline">Newline</option>
<option value="regex">Regex</option>
</select>
</div>
{nodeData.splitMode === "delimiter" && (
<div className="grid grid-cols-[auto_1fr] gap-2 items-center">
<label className="text-[11px] text-neutral-400">By</label>
<input
value={nodeData.delimiter}
onChange={(e) => 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"
/>
</div>
)}
{nodeData.splitMode === "regex" && (
<div className="grid grid-cols-[auto_1fr] gap-2 items-center">
<label className="text-[11px] text-neutral-400">Pattern</label>
<input
value={nodeData.regexPattern}
onChange={(e) => 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"
/>
</div>
)}
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-[11px] text-neutral-300">
<input
type="checkbox"
checked={nodeData.trimItems}
onChange={(e) => updateNodeData(id, { trimItems: e.target.checked })}
className="nodrag nopan w-3 h-3"
/>
Trim
</label>
<label className="flex items-center gap-1.5 text-[11px] text-neutral-300">
<input
type="checkbox"
checked={nodeData.removeEmpty}
onChange={(e) => updateNodeData(id, { removeEmpty: e.target.checked })}
className="nodrag nopan w-3 h-3"
/>
Remove empty
</label>
</div>
<div className="text-[10px] uppercase tracking-wide text-neutral-500">
Parsed Items ({previewItems.length})
</div>
<div className="relative flex-1 min-h-[90px] border border-neutral-700 rounded bg-neutral-900/40">
{nodeData.error ? (
<div className="p-2 text-[11px] text-red-400">{nodeData.error}</div>
) : previewItems.length === 0 ? (
<div className="p-2 text-[11px] text-neutral-500">No items parsed</div>
) : (
<div className="py-1">
{previewItems.map((item, index) => {
const isSelected = nodeData.selectedOutputIndex === index;
return (
<div key={`${index}-${item}`} className="relative pr-8">
<button
type="button"
onClick={() =>
updateNodeData(id, {
selectedOutputIndex: isSelected ? null : index,
})
}
className={`nodrag nopan w-[calc(100%-1rem)] mx-2 my-1 rounded border px-2 py-1 text-[11px] text-left truncate transition-colors ${
isSelected
? "border-blue-500 bg-blue-900/40 text-blue-200"
: "border-neutral-700 bg-neutral-900/80 text-neutral-200 hover:bg-neutral-800"
}`}
title={isSelected ? "Selected for next connection (click to unselect)" : "Click to select for next connection"}
>
{index + 1}. {item}
</button>
</div>
);
})}
</div>
)}
</div>
<div className="text-[10px] text-neutral-500">
{nodeData.selectedOutputIndex !== null
? `Next wire uses item ${nodeData.selectedOutputIndex + 1}`
: "No selection: wires advance in order from item 1"}
</div>
</div>
</BaseNode>
);
}