Spaces:
Running
Running
| import React from 'react'; | |
| import { Handle, Position, useReactFlow, useUpdateNodeInternals } from '@xyflow/react'; | |
| import { useTranslation } from 'react-i18next'; | |
| import { HelpCircle } from 'lucide-react'; | |
| import { isDefaultLabel } from '../../utils'; | |
| const ConditionNode = ({ id, data, selected }) => { | |
| const { t } = useTranslation(); | |
| const { setNodes } = useReactFlow(); | |
| const updateNodeInternals = useUpdateNodeInternals(); | |
| const getOptions = () => { | |
| if (data.options && data.options.length > 0) { | |
| return data.options; | |
| } | |
| return [ | |
| { id: 'opt-yes', label: t('common.yes', 'Yes') }, | |
| { id: 'opt-no', label: t('common.no', 'No') } | |
| ]; | |
| }; | |
| const getSelectedOptions = () => { | |
| if (data.selectedOptions) { | |
| return data.selectedOptions; | |
| } | |
| if (data.decision === 'True') return ['opt-yes']; | |
| if (data.decision === 'False') return ['opt-no']; | |
| return []; | |
| }; | |
| const toggleOption = (optionId) => { | |
| const currentSelected = getSelectedOptions(); | |
| const isMultiSelect = !!data.multiSelect; | |
| let newSelected; | |
| if (isMultiSelect) { | |
| if (currentSelected.includes(optionId)) { | |
| newSelected = currentSelected.filter(id => id !== optionId); | |
| } else { | |
| newSelected = [...currentSelected, optionId]; | |
| } | |
| } else { | |
| if (currentSelected.includes(optionId)) { | |
| newSelected = []; // Unselect | |
| } else { | |
| newSelected = [optionId]; // Select only this one | |
| } | |
| } | |
| if (data.updateNodeData) { | |
| data.updateNodeData(id, { selectedOptions: newSelected, decision: undefined }); | |
| } else { | |
| setNodes((nds) => | |
| nds.map((n) => n.id === id ? { ...n, data: { ...n.data, selectedOptions: newSelected, decision: undefined } } : n) | |
| ); | |
| } | |
| }; | |
| const handleConfigure = (e) => { | |
| e.stopPropagation(); | |
| window.dispatchEvent(new CustomEvent('configure-condition-node', { detail: { nodeId: id } })); | |
| }; | |
| const options = getOptions(); | |
| const selectedOptions = getSelectedOptions(); | |
| const serializedOptions = JSON.stringify(options); | |
| React.useEffect(() => { | |
| const timer = setTimeout(() => { | |
| updateNodeInternals(id); | |
| }, 50); | |
| return () => clearTimeout(timer); | |
| }, [id, serializedOptions, updateNodeInternals]); | |
| const labelText = isDefaultLabel(data.label) ? t('canvas.new_condition', 'New Condition?') : data.label; | |
| const labelLength = labelText.length; | |
| // Use a beautifully scaled static node container size of 200px to ensure | |
| // React Flow edge anchors are always 100% stable and perfectly snapped. | |
| const size = 200; | |
| const center = size / 2; | |
| // Choose optimal font size based on text length to ensure it always fits beautifully | |
| let fontSizeClass = 'text-sm'; | |
| if (labelLength > 80) { | |
| fontSizeClass = 'text-[11px]'; | |
| } else if (labelLength > 50) { | |
| fontSizeClass = 'text-xs'; | |
| } | |
| // Symmetrical placement generator for outer corner handles and badges fanned out radially | |
| const getHandleCoords = (index, total) => { | |
| let angle; | |
| if (total === 1) { | |
| angle = Math.PI / 2; // Straight down | |
| } else { | |
| // Distribute evenly across the bottom half-circle (from Math.PI [Left] to 0 [Right]) | |
| angle = Math.PI - (index / (total - 1)) * Math.PI; | |
| } | |
| const cosA = Math.cos(angle); | |
| const sinA = Math.sin(angle); | |
| // Intersection of radial ray with the boundary of a diamond of half-diagonal = center (100) | |
| const r = center / (Math.abs(cosA) + Math.abs(sinA)); | |
| const x = center + r * cosA; | |
| const y = center + r * sinA; | |
| // Choose the React Flow handle side based on direction | |
| let pos = Position.Bottom; | |
| if (cosA < -0.5) { | |
| pos = Position.Left; | |
| } else if (cosA > 0.5) { | |
| pos = Position.Right; | |
| } | |
| // Dynamic offsets based on total count to prevent overlapping | |
| const badgeOffset = 45 + Math.max(0, (total - 2) * 8); | |
| const pinOffset = badgeOffset + 35; | |
| return { | |
| x, // Diamond edge x | |
| y, // Diamond edge y | |
| pos, | |
| // Badge center | |
| bx: center + (r + badgeOffset) * cosA, | |
| by: center + (r + badgeOffset) * sinA, | |
| // Pin center | |
| px: center + (r + pinOffset) * cosA, | |
| py: center + (r + pinOffset) * sinA, | |
| angle | |
| }; | |
| }; | |
| return ( | |
| <div | |
| onDoubleClick={handleConfigure} | |
| className={`relative group transition-all duration-200 ${ | |
| selected ? '-translate-y-1.5 scale-[1.02]' : '' | |
| }`} | |
| style={{ | |
| width: `${size}px`, | |
| height: `${size}px` | |
| }} | |
| > | |
| {/* Target input handle (placed exactly at the visual top corner of the diamond) */} | |
| <Handle | |
| type="target" | |
| position={Position.Top} | |
| id="main-target" | |
| className="!bg-surface-container-lowest !w-4 !h-4 !border-[3px] !border-primary shadow-[0_2px_4px_rgba(0,0,0,0.1)] hover:scale-120 transition-all !z-50 !absolute" | |
| style={{ | |
| position: 'absolute', | |
| left: `${center}px`, | |
| top: '0px', | |
| bottom: 'auto', | |
| right: 'auto', | |
| transform: 'translate(-50%, -50%)' | |
| }} | |
| /> | |
| {/* Dynamic connecting lines between handles and badges */} | |
| <svg className="absolute inset-0 pointer-events-none overflow-visible z-30"> | |
| {options.map((option, index) => { | |
| const coords = getHandleCoords(index, options.length); | |
| const isSelected = selectedOptions.includes(option.id); | |
| return ( | |
| <line | |
| key={`line-${option.id}-${options.length}-${coords.pos}-${Math.round(coords.px)}-${Math.round(coords.py)}`} | |
| x1={coords.x} | |
| y1={coords.y} | |
| x2={coords.px} | |
| y2={coords.py} | |
| stroke={isSelected ? '#22c55e' : 'var(--color-primary)'} | |
| strokeWidth={isSelected ? '2' : '1.5'} | |
| strokeDasharray={isSelected ? 'none' : '3 3'} | |
| className={isSelected ? 'opacity-85 transition-all duration-300' : 'opacity-35 transition-all duration-300'} | |
| /> | |
| ); | |
| })} | |
| </svg> | |
| {/* The Rotated Diamond Shape Background */} | |
| <div | |
| className={`absolute rounded-[2rem] rough-border bg-surface-container-lowest transition-all ${ | |
| selected ? 'border-emerald-400 ring-4 ring-emerald-400/20' : '' | |
| }`} | |
| style={{ | |
| left: '50%', | |
| top: '50%', | |
| width: `${size / 1.4142}px`, | |
| height: `${size / 1.4142}px`, | |
| transform: 'translate(-50%, -50%) rotate(45deg)', | |
| boxShadow: selected ? '6px 6px 0px 0px rgba(52, 211, 153, 0.3), inset 0 0 0 1px rgba(0,0,0,0.05)' : 'none', | |
| pointerEvents: 'none' | |
| }} | |
| /> | |
| {/* Unrotated Text Content (centered perfectly over the rotated background diamond) */} | |
| <div | |
| className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center justify-center text-center cursor-pointer select-none z-10 pointer-events-none" | |
| style={{ | |
| width: `${size * 0.65}px`, | |
| height: `${size * 0.65}px` | |
| }} | |
| > | |
| <HelpCircle size={18} className="text-primary/60 mb-1 flex-shrink-0 pointer-events-none" /> | |
| <span | |
| className={`font-display-lg ${fontSizeClass} text-primary leading-tight break-words text-center pointer-events-none`} | |
| style={{ | |
| maxWidth: `${size * 0.58}px` | |
| }} | |
| > | |
| {labelText} | |
| </span> | |
| </div> | |
| {/* Customizable Dynamic Option Badges & Source Handles along the perimeter */} | |
| {options.map((option, index) => { | |
| const coords = getHandleCoords(index, options.length); | |
| const isSelected = selectedOptions.includes(option.id); | |
| return ( | |
| <React.Fragment key={`${option.id}-${options.length}-${coords.pos}-${Math.round(coords.px)}-${Math.round(coords.py)}`}> | |
| {/* The React Flow Source Handle placed exactly at the outer radial pinCoords */} | |
| <Handle | |
| type="source" | |
| position={coords.pos} | |
| id={option.id} | |
| className="!bg-transparent !w-6 !h-6 !border-0 hover:scale-125 transition-all !z-50 !absolute cursor-crosshair flex items-center justify-center" | |
| style={{ | |
| position: 'absolute', | |
| left: `${coords.px}px`, | |
| top: `${coords.py}px`, | |
| bottom: 'auto', | |
| right: 'auto', | |
| transform: 'translate(-50%, -50%)' | |
| }} | |
| > | |
| {/* Visual dot on the outer pin position */} | |
| <div className={`w-3.5 h-3.5 rounded-full border-2 shadow-sm pointer-events-none transition-all ${ | |
| isSelected | |
| ? 'bg-[#22c55e] border-white scale-110' | |
| : 'bg-primary border-white' | |
| }`} /> | |
| </Handle> | |
| {/* Floating text badge fanned out radially between the diamond and the pin */} | |
| <div | |
| className="absolute whitespace-normal break-words max-w-[110px] pointer-events-auto z-40 text-center flex items-center justify-center" | |
| style={{ | |
| left: `${coords.bx}px`, | |
| top: `${coords.by}px`, | |
| transform: 'translate(-50%, -50%)' | |
| }} | |
| > | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| toggleOption(option.id); | |
| }} | |
| className={`px-3 py-1.5 min-w-[50px] text-[10px] font-bold uppercase tracking-wider rounded-md border-2 border-primary shadow-[2px_2px_0px_0px_var(--color-primary)] hover:scale-105 active:scale-95 transition-all cursor-pointer leading-tight ${ | |
| isSelected | |
| ? 'bg-[#22c55e] text-white border-primary' | |
| : 'bg-surface-container-lowest text-primary hover:bg-surface-variant' | |
| }`} | |
| > | |
| {option.label} | |
| </button> | |
| </div> | |
| </React.Fragment> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| }; | |
| export default ConditionNode; | |