import { FC, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { FaChevronRight } from 'react-icons/fa'; import allIcon from '../../../assets/images/wired/var/var_picker_all.png'; import internalIcon from '../../../assets/images/wired/var/var_picker_internal.png'; import recentIcon from '../../../assets/images/wired/var/var_picker_recent.png'; import searchClearIcon from '../../../assets/images/wired/var/ar_picker_cancel_search.png'; import searchIcon from '../../../assets/images/wired/var/var_picker_search.png'; import smartIcon from '../../../assets/images/wired/var/var_picker_smart.png'; import userMadeIcon from '../../../assets/images/wired/var/var_picker_usermade.png'; import { LocalizeText } from '../../../api'; import { Text } from '../../../common'; import { flattenWiredVariablePickerEntries, IWiredVariablePickerEntry } from './WiredVariablePickerData'; type WiredVariablePickerMode = 'all' | 'recent' | 'usermade' | 'smart' | 'internal' | 'search'; interface WiredVariablePickerProps { emptyText?: string; entries: IWiredVariablePickerEntry[]; placeholder?: string; recentScope: string; selectedToken: string; onSelect: (entry: IWiredVariablePickerEntry) => void; } const RECENT_PICKER_LIMIT = 12; const RECENT_STORAGE_PREFIX = 'nitro.wired.variable-picker.recent'; const PICKER_MODES: Array<{ icon: string; key: WiredVariablePickerMode; }> = [ { key: 'all', icon: allIcon }, { key: 'recent', icon: recentIcon }, { key: 'usermade', icon: userMadeIcon }, { key: 'smart', icon: smartIcon }, { key: 'internal', icon: internalIcon }, { key: 'search', icon: searchIcon } ]; const normalizeSearch = (value: string) => value.trim().toLocaleLowerCase(); const applyQuery = (entries: IWiredVariablePickerEntry[], query: string): IWiredVariablePickerEntry[] => { if(!query) return entries; const nextEntries: IWiredVariablePickerEntry[] = []; for(const entry of entries) { const ownMatch = entry.searchableText.toLocaleLowerCase().includes(query); const matchingChildren = entry.children?.length ? applyQuery(entry.children, query) : []; if(!ownMatch && !matchingChildren.length) continue; nextEntries.push(matchingChildren.length ? { ...entry, children: matchingChildren } : entry); } return nextEntries; }; const applyMode = (entries: IWiredVariablePickerEntry[], mode: WiredVariablePickerMode, recentTokens: string[]): IWiredVariablePickerEntry[] => { const recentSet = new Set(recentTokens); const filterEntries = (items: IWiredVariablePickerEntry[]): IWiredVariablePickerEntry[] => { const filtered: IWiredVariablePickerEntry[] = []; for(const entry of items) { if(mode === 'smart') continue; const nextChildren = entry.children?.length ? filterEntries(entry.children) : []; const childVisible = !!nextChildren.length; const selfVisible = (() => { switch(mode) { case 'recent': return recentSet.has(entry.token); case 'usermade': return entry.kind === 'custom'; case 'internal': return entry.kind === 'internal'; case 'search': case 'all': default: return true; } })(); if(!selfVisible && !childVisible) continue; filtered.push(childVisible ? { ...entry, children: nextChildren } : entry); } return filtered; }; if(mode === 'recent') { const flatEntries = flattenWiredVariablePickerEntries(entries) .filter(entry => recentSet.has(entry.token)) .sort((left, right) => recentTokens.indexOf(left.token) - recentTokens.indexOf(right.token)) .map(entry => ({ ...entry, label: entry.displayLabel })); return flatEntries.filter(entry => !entry.children?.length); } return filterEntries(entries); }; export const WiredVariablePicker: FC = props => { const { entries = [], selectedToken = '', onSelect, recentScope, placeholder = LocalizeText('wiredfurni.variable_picker.search'), emptyText = 'Nothing to display' } = props; const containerRef = useRef(null); const panelRef = useRef(null); const searchInputRef = useRef(null); const submenuRef = useRef(null); const storageKey = `${ RECENT_STORAGE_PREFIX }.${ recentScope }`; const [ isOpen, setIsOpen ] = useState(false); const [ mode, setMode ] = useState('all'); const [ query, setQuery ] = useState(''); const [ recentTokens, setRecentTokens ] = useState([]); const [ activeParentToken, setActiveParentToken ] = useState(''); const [ panelPosition, setPanelPosition ] = useState<{ left: number; top: number; width: number; } | null>(null); const [ submenuPosition, setSubmenuPosition ] = useState<{ left: number; top: number; } | null>(null); const allEntries = flattenWiredVariablePickerEntries(entries); const selectedEntry = allEntries.find(entry => (entry.token === selectedToken)) || null; const modeEntries = applyMode(entries, mode, recentTokens); const filteredEntries = applyQuery(modeEntries, normalizeSearch(query)); const activeParent = filteredEntries.find(entry => (entry.token === activeParentToken) && entry.children?.length) || null; const portalTarget = (typeof document !== 'undefined') ? (document.getElementById('draggable-windows-container') ?? document.body) : null; useEffect(() => { try { const rawValue = window.localStorage.getItem(storageKey); if(!rawValue) { setRecentTokens([]); return; } const parsedValue = JSON.parse(rawValue) as string[]; setRecentTokens(Array.isArray(parsedValue) ? parsedValue.filter(token => typeof token === 'string') : []); } catch { setRecentTokens([]); } }, [ storageKey ]); useEffect(() => { if(!isOpen) return; const handleClick = (event: MouseEvent) => { if(containerRef.current?.contains(event.target as Node)) return; if(panelRef.current?.contains(event.target as Node)) return; if(submenuRef.current?.contains(event.target as Node)) return; setIsOpen(false); setActiveParentToken(''); setPanelPosition(null); setSubmenuPosition(null); }; const handleEscape = (event: KeyboardEvent) => { if(event.key !== 'Escape') return; setIsOpen(false); setActiveParentToken(''); setPanelPosition(null); setSubmenuPosition(null); }; document.addEventListener('mousedown', handleClick); document.addEventListener('keydown', handleEscape); return () => { document.removeEventListener('mousedown', handleClick); document.removeEventListener('keydown', handleEscape); }; }, [ isOpen ]); useLayoutEffect(() => { if(!isOpen) { setPanelPosition(null); return; } const updatePanelPosition = () => { const triggerRect = containerRef.current?.getBoundingClientRect(); if(!triggerRect) { setPanelPosition(null); return; } const panelWidth = Math.max(202, Math.ceil(triggerRect.width)); const viewportPadding = 8; const left = Math.min(Math.max(viewportPadding, triggerRect.left), Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding)); setPanelPosition({ left, top: triggerRect.bottom + 2, width: panelWidth }); setActiveParentToken(''); setSubmenuPosition(null); }; updatePanelPosition(); window.addEventListener('resize', updatePanelPosition); window.addEventListener('scroll', updatePanelPosition, true); return () => { window.removeEventListener('resize', updatePanelPosition); window.removeEventListener('scroll', updatePanelPosition, true); }; }, [ isOpen ]); useEffect(() => { if(!isOpen) return; if(mode !== 'search') return; searchInputRef.current?.focus(); }, [ isOpen, mode ]); useEffect(() => { if(!activeParentToken) return; if(filteredEntries.some(entry => entry.token === activeParentToken && entry.children?.length)) return; setActiveParentToken(''); setSubmenuPosition(null); }, [ activeParentToken, filteredEntries ]); const rememberSelection = (token: string) => { if(!token) return; const nextRecentTokens = [ token, ...recentTokens.filter(currentToken => (currentToken !== token)) ].slice(0, RECENT_PICKER_LIMIT); setRecentTokens(nextRecentTokens); try { window.localStorage.setItem(storageKey, JSON.stringify(nextRecentTokens)); } catch { } }; const handleSelect = (entry: IWiredVariablePickerEntry) => { if(!entry.selectable) return; rememberSelection(entry.token); onSelect(entry); setIsOpen(false); setActiveParentToken(''); setSubmenuPosition(null); }; const activateParent = (entry: IWiredVariablePickerEntry, element: HTMLButtonElement) => { if(!entry.children?.length) { setActiveParentToken(''); setSubmenuPosition(null); return; } const rowRect = element.getBoundingClientRect(); const panelRect = panelRef.current?.getBoundingClientRect(); const submenuWidth = 140; const submenuHeight = Math.min((entry.children.length * 20) + 22, 168); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const leftAnchor = panelRect ? panelRect.right : rowRect.right; const rightSpace = viewportWidth - leftAnchor; const canOpenRight = (rightSpace >= (submenuWidth + 8)); const left = canOpenRight ? (leftAnchor + 6) : Math.max(8, (panelRect ? panelRect.left : rowRect.left) - submenuWidth - 6); const top = Math.min(Math.max(8, rowRect.top), Math.max(8, viewportHeight - submenuHeight - 8)); setSubmenuPosition({ left, top }); setActiveParentToken(entry.token); }; const renderEntry = (entry: IWiredVariablePickerEntry) => { const hasChildren = !!entry.children?.length; return ( ); }; const renderPanel = () => { if(!panelPosition) return null; return (
{ PICKER_MODES.map(button => ( )) }
search setQuery(event.target.value) } /> { !!query.length && }
{ filteredEntries.length ? filteredEntries.map(renderEntry) : { emptyText } }
); }; return (
{ isOpen && panelPosition && portalTarget && createPortal(
{ renderPanel() } { activeParent?.children?.length && submenuPosition &&
{ activeParent.children.map(child => ( )) }
}
, portalTarget ) }
); };