mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 15:36:18 +00:00
feat: add advanced wired variable tools UI
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user