/* * @Author: ZhaoYing * @Date: 2026-02-03 15:40:13 * @Last Modified by: ZhaoYing * @Last Modified time: 2026-04-16 13:57:30 */ import { useState, useRef, useEffect, useLayoutEffect, type FC } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx'; import { Flex, Space, Checkbox } from 'antd' import { useTranslation } from 'react-i18next'; import type { Suggestion } from '../Editor/plugin/AutocompletePlugin' interface VariableSelectProps { options: Suggestion[]; value?: string | string[]; allowClear?: boolean; filterBooleanType?: boolean; multiple?: boolean; size?: 'small' | 'middle' | 'large'; placeholder?: string; variant?: 'outlined' | 'borderless' | 'filled'; className?: string; onChange?: (value: string | string[], option: Suggestion | Suggestion[] | undefined) => void; } const VariableSelect: FC = ({ placeholder, options, value, allowClear = true, onChange, size = 'middle', filterBooleanType = false, multiple = false, variant = 'outlined', className, }) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const [expandedParentKey, setExpandedParentKey] = useState(null); const [activeIndex, setActiveIndex] = useState(-1); const [activePanel, setActivePanel] = useState<'main' | 'child'>('main'); const [childActiveIndex, setChildActiveIndex] = useState(-1); const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 }); const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 }); const containerRef = useRef(null); const dropdownRef = useRef(null); const itemRefs = useRef>(new Map()); const childItemRefs = useRef>(new Map()); const activeKeyRef = useRef(null); const CHILD_PANEL_HEIGHT = 280; // max-h-60 (240) + header (~40) const calcChildPos = (key: string) => { const el = itemRefs.current.get(key); if (!el) return; const rect = el.getBoundingClientRect(); const dropdownEl = dropdownRef.current; if (!dropdownEl) return; const dropdownRect = dropdownEl.getBoundingClientRect(); const dropdownBottom = dropdownRect.bottom; const actualChildHeight = Math.min(CHILD_PANEL_HEIGHT, dropdownRect.height); // Bottom-align child panel with main panel const top = Math.max(10, dropdownBottom - actualChildHeight); setChildPanelPos({ top, right: window.innerWidth - rect.left + 8 }); }; // Calculate dropdown position (runs synchronously after DOM paint to avoid flicker) useLayoutEffect(() => { if (!open || !containerRef.current) return; const triggerRect = containerRef.current.getBoundingClientRect(); const MARGIN = 8; const width = triggerRect.width; // Set initial width/left immediately; top will be refined once dropdownRef is available if (!dropdownRef.current) { setDropdownPos({ top: triggerRect.bottom + MARGIN, left: triggerRect.left, width }); return; } const dropdownHeight = dropdownRef.current.offsetHeight; const dropdownWidth = dropdownRef.current.offsetWidth; const left = Math.min(triggerRect.left, window.innerWidth - dropdownWidth - 10); const spaceBelow = window.innerHeight - triggerRect.bottom - MARGIN; const spaceAbove = triggerRect.top - MARGIN; const top = (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) ? triggerRect.bottom + MARGIN : Math.max(MARGIN, triggerRect.top - dropdownHeight - MARGIN); setDropdownPos({ top, left, width }); // Re-calculate child panel position if expanded if (expandedParentKey) calcChildPos(expandedParentKey); }, [open, search, Array.isArray(value) ? value.length : 0, options.length, expandedParentKey]); const filteredOptions = filterBooleanType ? options.filter(o => o.dataType !== 'boolean') : options; const allSuggestions = filteredOptions.flatMap(o => o.children ? [o, ...o.children] : [o]); const suggestionMap = new Map(allSuggestions.map(s => [`{{${s.value}}}`, s])); const selectedValues = multiple ? (Array.isArray(value) ? value : []) : []; const selectedSuggestion = !multiple && value ? suggestionMap.get(value as string) : undefined; const parentOfSelected = !multiple && value ? filteredOptions.find(o => o.children?.some(c => `{{${c.value}}}` === value)) : undefined; const expandedParent = expandedParentKey ? filteredOptions.find(o => o.key === expandedParentKey) ?? null : null; const groupedSuggestions = filteredOptions.reduce((groups: Record, s) => { const nodeId = s.nodeData.id as string; if (!groups[nodeId]) groups[nodeId] = []; groups[nodeId].push(s); return groups; }, {}); const filteredGroups = search ? Object.entries(groupedSuggestions).reduce((acc: Record, [nodeId, suggestions]) => { const matched = suggestions.filter(s => s.label.toLowerCase().includes(search.toLowerCase()) || s.value.toLowerCase().includes(search.toLowerCase()) || s.children?.some(c => c.label.toLowerCase().includes(search.toLowerCase())) ); if (matched.length) acc[nodeId] = matched; return acc; }, {}) : groupedSuggestions; useEffect(() => { if (!expandedParentKey) return; calcChildPos(expandedParentKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [dropdownPos, expandedParentKey]); useEffect(() => { if (!open) return; const updatePos = () => { if (!containerRef.current || !dropdownRef.current) return; const triggerRect = containerRef.current.getBoundingClientRect(); const dropdownHeight = dropdownRef.current.offsetHeight; const dropdownWidth = dropdownRef.current.offsetWidth; const MARGIN = 8; const left = Math.min(triggerRect.left, window.innerWidth - dropdownWidth - 10); const spaceBelow = window.innerHeight - triggerRect.bottom - MARGIN; const spaceAbove = triggerRect.top - MARGIN; let top: number; if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) { top = triggerRect.bottom + MARGIN; } else { top = triggerRect.top - dropdownHeight - MARGIN; if (top < MARGIN) top = MARGIN; } setDropdownPos(prev => ({ ...prev, top, left })); }; document.addEventListener('scroll', updatePos, true); return () => document.removeEventListener('scroll', updatePos, true); }, [open]); useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { const target = e.target as Node; const childPanel = document.getElementById('variable-select-child-panel'); if ( !containerRef.current?.contains(target) && !dropdownRef.current?.contains(target) && !childPanel?.contains(target) ) { setOpen(false); setSearch(''); setExpandedParentKey(null); setChildPanelPos({ top: 0, right: 0 }); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open]); // Flat list of all visible selectable items (main panel only, no children expanded inline) const flatItems = Object.values(filteredGroups).flat(); useEffect(() => { setActiveIndex(-1); setActivePanel('main'); setChildActiveIndex(-1); }, [open, search]); useEffect(() => { if (activeIndex < 0 || activeIndex >= flatItems.length) { setExpandedParentKey(null); return; } const s = flatItems[activeIndex]; activeKeyRef.current = s.key; itemRefs.current.get(s.key)?.scrollIntoView({ block: 'nearest' }); if (s.children?.length) { calcChildPos(s.key); setExpandedParentKey(s.key); } else { setExpandedParentKey(null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeIndex]); useEffect(() => { if (!expandedParent?.children?.length || childActiveIndex < 0) return; const child = expandedParent.children[childActiveIndex]; if (child) childItemRefs.current.get(child.key)?.scrollIntoView({ block: 'nearest' }); }, [childActiveIndex, expandedParent]); useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { const children = expandedParent?.children ?? []; if (activePanel === 'child') { if (e.key === 'ArrowDown') { e.preventDefault(); setChildActiveIndex(i => Math.min(i + 1, children.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setChildActiveIndex(i => Math.max(i - 1, 0)); } else if (e.key === 'ArrowRight') { e.preventDefault(); setActivePanel('main'); setChildActiveIndex(-1); } else if (e.key === 'Enter' && childActiveIndex >= 0 && childActiveIndex < children.length) { e.preventDefault(); const child = children[childActiveIndex]; if (!child.disabled) handleSelect(child); } else if (e.key === 'Escape') { setOpen(false); } } else { if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex(i => Math.min(i + 1, flatItems.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)); } else if (e.key === 'ArrowLeft') { e.preventDefault(); if (expandedParent?.children?.length) { setActivePanel('child'); setChildActiveIndex(0); } } else if (e.key === 'Enter' && activeIndex >= 0 && activeIndex < flatItems.length) { e.preventDefault(); const s = flatItems[activeIndex]; if (!s.disabled) handleSelect(s); } else if (e.key === 'Escape') { setOpen(false); } } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, activeIndex, activePanel, childActiveIndex, flatItems, expandedParent]); const handleSelect = (suggestion: Suggestion) => { if (multiple) { const key = `{{${suggestion.value}}}`; const next = selectedValues.includes(key) ? selectedValues.filter(v => v !== key) : [...selectedValues, key]; const nextOptions = next.map(v => suggestionMap.get(v)).filter(Boolean) as Suggestion[]; onChange?.(next, nextOptions); } else { onChange?.(`{{${suggestion.value}}}`, suggestion); setOpen(false); setSearch(''); setExpandedParentKey(null); } }; const handleClear = (e: React.MouseEvent) => { e.stopPropagation(); onChange?.(multiple ? [] : '', multiple ? [] : undefined); }; const sep = /; const isConversation = (parentOfSelected ?? selectedSuggestion)?.group === 'CONVERSATION' || (selectedSuggestion ? filteredOptions.some(o => o.group === 'CONVERSATION' && o.children?.some(c => `{{${c.value}}}` === value)) : false); const nodeData = (parentOfSelected ?? selectedSuggestion)?.nodeData; return (
{/* Trigger */}
setOpen(o => !o)} > {multiple ? ( selectedValues.length > 0 ? ( {selectedValues.map(v => { const s = suggestionMap.get(v); if (!s) return null; const parent = filteredOptions.find(o => o.children?.some(c => `{{${c.value}}}` === v)); const nd = s.nodeData; const isConv = (parent ?? s)?.group === 'CONVERSATION' || filteredOptions.some(o => o.group === 'CONVERSATION' && o.children?.some(c => `{{${c.value}}}` === v)); return ( {!isConv && nd?.icon &&
} {!isConv && nd?.name && {nd.name}{sep}} {parent ? <>{parent.label}{sep}{s.label} : s.label} { e.stopPropagation(); handleSelect(s); }} >✕ ); })} ) : ( {placeholder} ) ) : selectedSuggestion ? (
{!isConversation && nodeData?.icon &&
} {!isConversation && nodeData?.name && {nodeData.name}} {!isConversation && nodeData?.name && {sep}} {parentOfSelected ? <>{parentOfSelected.label}{sep}{selectedSuggestion.label} : selectedSuggestion.label}
) : ( {placeholder} )} {allowClear && ( 0 : !!selectedSuggestion) ? 'rb:opacity-100 rb:cursor-pointer' : 'rb:opacity-0 rb:pointer-events-none' )} onClick={handleClear} >✕ )}
{/* Dropdown via portal */} {open && createPortal(
{Object.entries(filteredGroups).map(([nodeId, suggestions], index) => { const nd = suggestions[0].nodeData; return (
{nd.name}
{suggestions.map(s => { const isSelected = multiple ? selectedValues.includes(`{{${s.value}}}`) : `{{${s.value}}}` === value; const isExpanded = expandedParent?.key === s.key; const hasChildren = !!s.children?.length; return ( { if (el) itemRefs.current.set(s.key, el); }} className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { 'rb:bg-[#F6F6F6]': isSelected || isExpanded || flatItems.indexOf(s) === activeIndex, 'rb:cursor-not-allowed rb:opacity-65': s.disabled, 'rb:cursor-pointer': !s.disabled, })} align="center" justify="space-between" onClick={() => { if (s.disabled) return; if (hasChildren) { calcChildPos(s.key); setExpandedParentKey(prev => prev === s.key ? null : s.key); } handleSelect(s); }} onMouseEnter={() => { if (hasChildren) { calcChildPos(s.key); setExpandedParentKey(s.key); } else { setExpandedParentKey(null); } }} >
{multiple && ( )} {`{x}`} {s.label}
{s.dataType && {s.dataType}} {hasChildren &&
}
); })}
); })} {Object.keys(filteredGroups).length === 0 && (
{t('workflow.variableSelect.empty')}
)}
, document.body )} {/* Child panel via portal — escapes overflow clipping */} {open && expandedParent?.children?.length && createPortal(
setExpandedParentKey(expandedParentKey)} >
{expandedParent.nodeData.name}.{expandedParent.label} {expandedParent.dataType}
{expandedParent.children.map((child, ci) => { const isSelected = multiple ? selectedValues.includes(`{{${child.value}}}`) : `{{${child.value}}}` === value; const isChildActive = activePanel === 'child' && ci === childActiveIndex; return ( { if (el) childItemRefs.current.set(child.key, el); }} className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { 'rb:bg-[#F6F6F6]': isSelected || isChildActive, 'rb:cursor-not-allowed rb:opacity-65': child.disabled, 'rb:cursor-pointer': !child.disabled, })} align="center" justify="space-between" onClick={() => !child.disabled && handleSelect(child)} > {multiple && ( )} {child.label} {child.dataType && {child.dataType}} ); })}
, document.body )}
); }; export default VariableSelect