mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
414 lines
16 KiB
TypeScript
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>
|
|
);
|
|
};
|