Browse Source
- 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 <noreply@anthropic.com>handoff-20260429-1057
1 changed files with 277 additions and 0 deletions
@ -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<HandleType, string> = { |
|||
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<WorkflowNode>) => { |
|||
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<string | null>(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 ( |
|||
<BaseNode |
|||
id={id} |
|||
title="Switch" |
|||
customTitle={nodeData.customTitle} |
|||
comment={nodeData.comment} |
|||
onCustomTitleChange={(customTitle) => 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 ? ( |
|||
<Handle |
|||
type="target" |
|||
position={Position.Left} |
|||
id={nodeData.inputType} |
|||
data-handletype={nodeData.inputType} |
|||
style={{ |
|||
top: baseOffset, |
|||
backgroundColor: HANDLE_COLORS[nodeData.inputType], |
|||
width: 12, |
|||
height: 12, |
|||
border: "2px solid #1e1e1e", |
|||
}} |
|||
/> |
|||
) : ( |
|||
<Handle |
|||
type="target" |
|||
position={Position.Left} |
|||
id="generic-input" |
|||
style={{ |
|||
top: baseOffset, |
|||
backgroundColor: "#6b7280", |
|||
width: 12, |
|||
height: 12, |
|||
border: "2px dashed #1e1e1e", |
|||
}} |
|||
/> |
|||
)} |
|||
|
|||
{/* Output handles (right) - only when input connected */} |
|||
{showOutputs && |
|||
nodeData.switches.map((sw, index) => ( |
|||
<Handle |
|||
key={`output-${sw.id}`} |
|||
type="source" |
|||
position={Position.Right} |
|||
id={sw.id} |
|||
data-handletype={nodeData.inputType} |
|||
style={{ |
|||
top: baseOffset + index * handleSpacing, |
|||
backgroundColor: HANDLE_COLORS[nodeData.inputType!], |
|||
opacity: sw.enabled ? 1 : 0.3, |
|||
width: 12, |
|||
height: 12, |
|||
border: "2px solid #1e1e1e", |
|||
}} |
|||
/> |
|||
))} |
|||
|
|||
{/* Body content */} |
|||
<div className="px-2 py-1 space-y-1"> |
|||
{!showOutputs ? ( |
|||
<div className="text-[10px] text-neutral-500 text-center py-2"> |
|||
Connect input to enable outputs |
|||
</div> |
|||
) : ( |
|||
<> |
|||
{nodeData.switches.map((sw, index) => ( |
|||
<div |
|||
key={sw.id} |
|||
className="flex items-center gap-2 group py-0.5" |
|||
> |
|||
{/* Toggle */} |
|||
<label className="relative inline-flex items-center cursor-pointer"> |
|||
<input |
|||
type="checkbox" |
|||
className="sr-only peer" |
|||
checked={sw.enabled} |
|||
onChange={() => handleToggle(sw.id)} |
|||
/> |
|||
<div className="w-8 h-4 bg-neutral-600 peer-checked:bg-violet-500 rounded-full peer transition-colors relative"> |
|||
<div className="absolute top-0.5 left-0.5 bg-white h-3 w-3 rounded-full transition-transform peer-checked:translate-x-4" /> |
|||
</div> |
|||
</label> |
|||
|
|||
{/* Name */} |
|||
{editingId === sw.id ? ( |
|||
<input |
|||
type="text" |
|||
className="flex-1 bg-neutral-700 text-neutral-100 text-xs px-1 py-0.5 rounded border border-violet-500 outline-none" |
|||
defaultValue={sw.name} |
|||
autoFocus |
|||
onBlur={(e) => handleNameEdit(sw.id, e.target.value)} |
|||
onKeyDown={(e) => { |
|||
if (e.key === "Enter") { |
|||
handleNameEdit(sw.id, e.currentTarget.value); |
|||
} else if (e.key === "Escape") { |
|||
setEditingId(null); |
|||
} |
|||
}} |
|||
/> |
|||
) : ( |
|||
<span |
|||
className={`flex-1 text-xs cursor-text ${ |
|||
sw.enabled ? "text-neutral-300" : "text-neutral-500" |
|||
}`}
|
|||
onDoubleClick={() => setEditingId(sw.id)} |
|||
> |
|||
{sw.name} |
|||
</span> |
|||
)} |
|||
|
|||
{/* Delete button */} |
|||
<button |
|||
className="opacity-0 group-hover:opacity-100 text-neutral-400 hover:text-red-400 transition-opacity" |
|||
onClick={() => handleDelete(sw.id)} |
|||
title="Delete switch" |
|||
> |
|||
<svg |
|||
className="w-3 h-3" |
|||
fill="none" |
|||
stroke="currentColor" |
|||
viewBox="0 0 24 24" |
|||
> |
|||
<path |
|||
strokeLinecap="round" |
|||
strokeLinejoin="round" |
|||
strokeWidth={2} |
|||
d="M6 18L18 6M6 6l12 12" |
|||
/> |
|||
</svg> |
|||
</button> |
|||
</div> |
|||
))} |
|||
|
|||
{/* Add switch button */} |
|||
<button |
|||
className="w-full flex items-center justify-center gap-1 text-neutral-400 hover:text-white text-xs py-1 mt-2 rounded hover:bg-violet-900/30 transition-colors" |
|||
onClick={handleAddSwitch} |
|||
> |
|||
<svg |
|||
className="w-3 h-3" |
|||
fill="none" |
|||
stroke="currentColor" |
|||
viewBox="0 0 24 24" |
|||
> |
|||
<path |
|||
strokeLinecap="round" |
|||
strokeLinejoin="round" |
|||
strokeWidth={2} |
|||
d="M12 4v16m8-8H4" |
|||
/> |
|||
</svg> |
|||
Add Switch |
|||
</button> |
|||
</> |
|||
)} |
|||
</div> |
|||
</BaseNode> |
|||
); |
|||
}); |
|||
|
|||
SwitchNode.displayName = "SwitchNode"; |
|||
Loading…
Reference in new issue