From 63a187a15a728b251cd4bfb1d63e5eed3cebcd30 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Wed, 25 Feb 2026 22:38:33 +1300 Subject: [PATCH] feat(44-01): create SwitchNode component with toggle UI and dynamic handles - Create SwitchNode.tsx with violet theme (bg-violet-950/50, border-violet-600) - Implement toggle controls per switch (sliding toggle with peer-checked styling) - Add/remove switch management with + button and delete X button on hover - Double-click to rename switch labels with inline editing - Dynamic input handle: generic gray dashed when no connection, typed colored when connected - Dynamic output handles: only shown when inputType is set (input connected) - Output handles use 30% opacity when switch is disabled - Auto-resize height based on switch count using useEffect and setNodes - Handle color mapping matches RouterNode's HANDLE_COLORS - useUpdateNodeInternals called when switch count or inputType changes - Remove edges when switch is deleted via setEdges filter - Component is 277 lines with full toggle, add, delete, rename functionality Co-Authored-By: Claude Opus 4.6 --- src/components/nodes/SwitchNode.tsx | 277 ++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 src/components/nodes/SwitchNode.tsx diff --git a/src/components/nodes/SwitchNode.tsx b/src/components/nodes/SwitchNode.tsx new file mode 100644 index 00000000..e015f428 --- /dev/null +++ b/src/components/nodes/SwitchNode.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { memo, useMemo, useEffect, useState, useCallback } from "react"; +import { Handle, Position, useUpdateNodeInternals, useReactFlow, NodeProps } from "@xyflow/react"; +import { BaseNode } from "./BaseNode"; +import { useWorkflowStore } from "@/store/workflowStore"; +import type { WorkflowNode, SwitchNodeData, HandleType } from "@/types"; + +const HANDLE_COLORS: Record = { + image: "#10b981", // emerald — matches globals.css + text: "#3b82f6", // blue — matches globals.css + video: "#ffffff", // white — default handle style + audio: "rgb(167, 139, 250)", // violet — matches GenerateAudioNode/OutputNode + "3d": "#f97316", // orange — matches globals.css + easeCurve: "#ffffff", // white — default handle style +}; + +export const SwitchNode = memo(({ id, data, selected }: NodeProps) => { + const nodeData = data as SwitchNodeData; + const edges = useWorkflowStore((state) => state.edges); + const updateNodeData = useWorkflowStore((state) => state.updateNodeData); + const updateNodeInternals = useUpdateNodeInternals(); + const { setNodes, setEdges } = useReactFlow(); + const [editingId, setEditingId] = useState(null); + + // Derive inputType from incoming edge connection + const derivedInputType = useMemo(() => { + const inputEdge = edges.find((e) => e.target === id); + return inputEdge?.targetHandle as HandleType | undefined; + }, [edges, id]); + + // Update stored inputType when derived type changes + useEffect(() => { + const newType = derivedInputType || null; + if (newType !== nodeData.inputType) { + updateNodeData(id, { inputType: newType }); + } + }, [derivedInputType, id, nodeData.inputType, updateNodeData]); + + // Calculate handle positioning + const handleSpacing = 32; + const baseOffset = 38; // Clear the header bar + + // Dynamic height based on switch count and whether we have input + const switchCount = nodeData.switches.length; + const showOutputs = nodeData.inputType !== null; + const lastHandleTop = baseOffset + (showOutputs ? switchCount * handleSpacing : handleSpacing); + const minHeight = lastHandleTop + 40; // Extra space for add button + + // Resize node and notify React Flow when switch count or inputType changes + useEffect(() => { + setNodes((nodes) => + nodes.map((node) => { + if (node.id === id) { + const currentHeight = (node.style?.height as number) || 0; + if (currentHeight < minHeight) { + return { ...node, style: { ...node.style, height: minHeight } }; + } + } + return node; + }) + ); + updateNodeInternals(id); + }, [switchCount, showOutputs, id, minHeight, setNodes, updateNodeInternals]); + + // Handle toggle change + const handleToggle = useCallback( + (switchId: string) => { + const updatedSwitches = nodeData.switches.map((sw) => + sw.id === switchId ? { ...sw, enabled: !sw.enabled } : sw + ); + updateNodeData(id, { switches: updatedSwitches }); + }, + [id, nodeData.switches, updateNodeData] + ); + + // Handle name edit + const handleNameEdit = useCallback( + (switchId: string, newName: string) => { + const updatedSwitches = nodeData.switches.map((sw) => + sw.id === switchId ? { ...sw, name: newName } : sw + ); + updateNodeData(id, { switches: updatedSwitches }); + setEditingId(null); + }, + [id, nodeData.switches, updateNodeData] + ); + + // Handle delete switch + const handleDelete = useCallback( + (switchId: string) => { + const updatedSwitches = nodeData.switches.filter((sw) => sw.id !== switchId); + updateNodeData(id, { switches: updatedSwitches }); + + // Remove edges connected to this handle + setEdges((edges) => edges.filter((e) => !(e.source === id && e.sourceHandle === switchId))); + }, + [id, nodeData.switches, updateNodeData, setEdges] + ); + + // Handle add switch + const handleAddSwitch = useCallback(() => { + const newSwitch = { + id: Math.random().toString(36).slice(2, 9), + name: `Output ${nodeData.switches.length + 1}`, + enabled: true, + }; + updateNodeData(id, { switches: [...nodeData.switches, newSwitch] }); + }, [id, nodeData.switches, updateNodeData]); + + return ( + updateNodeData(id, { customTitle })} + onCommentChange={(comment) => updateNodeData(id, { comment })} + selected={selected} + minWidth={220} + minHeight={minHeight} + className="bg-violet-950/50 border-violet-600" + > + {/* Input handle (left) */} + {nodeData.inputType ? ( + + ) : ( + + )} + + {/* Output handles (right) - only when input connected */} + {showOutputs && + nodeData.switches.map((sw, index) => ( + + ))} + + {/* Body content */} +
+ {!showOutputs ? ( +
+ Connect input to enable outputs +
+ ) : ( + <> + {nodeData.switches.map((sw, index) => ( +
+ {/* Toggle */} +
+ + ); +}); + +SwitchNode.displayName = "SwitchNode";