Files
Nitro-V3/src/components/wired/views/WiredVariablePicker.tsx
T
2026-04-02 04:44:04 +02:00

414 lines
16 KiB
TypeScript

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<WiredVariablePickerProps> = props =>
{
const { entries = [], selectedToken = '', onSelect, recentScope, placeholder = LocalizeText('wiredfurni.variable_picker.search'), emptyText = 'Nothing to display' } = props;
const containerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const submenuRef = useRef<HTMLDivElement>(null);
const storageKey = `${ RECENT_STORAGE_PREFIX }.${ recentScope }`;
const [ isOpen, setIsOpen ] = useState(false);
const [ mode, setMode ] = useState<WiredVariablePickerMode>('all');
const [ query, setQuery ] = useState('');
const [ recentTokens, setRecentTokens ] = useState<string[]>([]);
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 (
<button
key={ entry.id }
type="button"
className={ `nitro-wired__variable-picker-row ${ entry.selectable ? '' : 'is-disabled' } ${ selectedToken === entry.token ? 'is-selected' : '' }` }
onMouseEnter={ event => activateParent(entry, event.currentTarget) }
onClick={ event =>
{
if(hasChildren)
{
activateParent(entry, event.currentTarget);
return;
}
if(entry.selectable) handleSelect(entry);
} }>
<span className="nitro-wired__variable-picker-row-label">{ entry.label }</span>
{ hasChildren && <FaChevronRight className="nitro-wired__variable-picker-row-arrow" /> }
</button>
);
};
const renderPanel = () =>
{
if(!panelPosition) return null;
return (
<div
ref={ panelRef }
className="nitro-wired__variable-picker-panel is-portal"
style={ { left: panelPosition.left, top: panelPosition.top, width: panelPosition.width } }>
<div className="nitro-wired__variable-picker-toolbar">
{ PICKER_MODES.map(button => (
<button
key={ button.key }
type="button"
className={ `nitro-wired__variable-picker-mode ${ mode === button.key ? 'is-active' : '' }` }
onClick={ () =>
{
setMode(button.key);
if(button.key === 'search') setTimeout(() => searchInputRef.current?.focus(), 0);
} }>
<img src={ button.icon } alt={ button.key } />
</button>
)) }
</div>
<div className="nitro-wired__variable-picker-search">
<img className="nitro-wired__variable-picker-search-icon" src={ searchIcon } alt="search" />
<input
ref={ searchInputRef }
className="nitro-wired__variable-picker-search-input"
placeholder={ placeholder }
type="text"
value={ query }
onChange={ event => setQuery(event.target.value) } />
{ !!query.length &&
<button type="button" className="nitro-wired__variable-picker-clear" onClick={ () => setQuery('') }>
<img src={ searchClearIcon } alt="clear" />
</button> }
</div>
<div className="nitro-wired__variable-picker-list">
{ filteredEntries.length
? filteredEntries.map(renderEntry)
: <Text small className="nitro-wired__variable-picker-empty">{ emptyText }</Text> }
</div>
</div>
);
};
return (
<div className="nitro-wired__variable-picker" ref={ containerRef }>
<button type="button" className="form-select form-select-sm nitro-wired__variable-picker-trigger" onClick={ () => setIsOpen(value => !value) }>
<span className={ selectedEntry ? '' : 'nitro-wired__variable-picker-placeholder' }>{ selectedEntry?.displayLabel || placeholder }</span>
</button>
{ isOpen && panelPosition && portalTarget && createPortal(
<div className="nitro-wired nitro-wired__variable-picker-portal">
{ renderPanel() }
{ activeParent?.children?.length && submenuPosition &&
<div
ref={ submenuRef }
className="nitro-wired__variable-picker-submenu"
style={ { left: submenuPosition.left, top: submenuPosition.top } }>
{ activeParent.children.map(child => (
<button
key={ child.id }
type="button"
className={ `nitro-wired__variable-picker-row nitro-wired__variable-picker-subrow ${ child.selectable ? '' : 'is-disabled' } ${ selectedToken === child.token ? 'is-selected' : '' }` }
onClick={ () => handleSelect(child) }>
<span className="nitro-wired__variable-picker-row-label">{ child.label }</span>
</button>
)) }
</div> }
</div>,
portalTarget
) }
</div>
);
};