FlowWeb / src /features /canvas /components /nodes /ConditionNode.jsx
danylokhodus's picture
fix: resolve edge persistence for image nodes and update connection styles
d963725
Raw
History Blame Contribute Delete
10.2 kB
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;