2025-11-04 18:29:28 +08:00
|
|
|
import React, { useRef, useEffect, useState } from 'react';
|
2025-11-03 21:22:16 +08:00
|
|
|
import { NodeTemplate, NodeTemplates } from '@esengine/behavior-tree';
|
2025-11-04 18:29:28 +08:00
|
|
|
import { Search, X, LucideIcon, ChevronDown, ChevronRight } from 'lucide-react';
|
2025-11-03 21:22:16 +08:00
|
|
|
|
|
|
|
|
interface QuickCreateMenuProps {
|
|
|
|
|
visible: boolean;
|
|
|
|
|
position: { x: number; y: number };
|
|
|
|
|
searchText: string;
|
|
|
|
|
selectedIndex: number;
|
|
|
|
|
mode: 'create' | 'replace';
|
|
|
|
|
iconMap: Record<string, LucideIcon>;
|
|
|
|
|
onSearchChange: (text: string) => void;
|
|
|
|
|
onIndexChange: (index: number) => void;
|
|
|
|
|
onNodeSelect: (template: NodeTemplate) => void;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 18:29:28 +08:00
|
|
|
interface CategoryGroup {
|
|
|
|
|
category: string;
|
|
|
|
|
templates: NodeTemplate[];
|
|
|
|
|
isExpanded: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-03 21:22:16 +08:00
|
|
|
export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
|
|
|
|
visible,
|
|
|
|
|
position,
|
|
|
|
|
searchText,
|
|
|
|
|
selectedIndex,
|
|
|
|
|
iconMap,
|
|
|
|
|
onSearchChange,
|
|
|
|
|
onIndexChange,
|
|
|
|
|
onNodeSelect,
|
|
|
|
|
onClose
|
|
|
|
|
}) => {
|
|
|
|
|
const selectedNodeRef = useRef<HTMLDivElement>(null);
|
2025-11-04 18:29:28 +08:00
|
|
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
|
|
|
|
const [shouldAutoScroll, setShouldAutoScroll] = useState(false);
|
2025-11-03 21:22:16 +08:00
|
|
|
|
|
|
|
|
const allTemplates = NodeTemplates.getAllTemplates();
|
|
|
|
|
const searchTextLower = searchText.toLowerCase();
|
|
|
|
|
const filteredTemplates = searchTextLower
|
|
|
|
|
? allTemplates.filter((t: NodeTemplate) => {
|
|
|
|
|
const className = t.className || '';
|
|
|
|
|
return t.displayName.toLowerCase().includes(searchTextLower) ||
|
|
|
|
|
t.description.toLowerCase().includes(searchTextLower) ||
|
|
|
|
|
t.category.toLowerCase().includes(searchTextLower) ||
|
|
|
|
|
className.toLowerCase().includes(searchTextLower);
|
|
|
|
|
})
|
|
|
|
|
: allTemplates;
|
|
|
|
|
|
2025-11-04 18:29:28 +08:00
|
|
|
const categoryGroups: CategoryGroup[] = React.useMemo(() => {
|
|
|
|
|
const groups = new Map<string, NodeTemplate[]>();
|
|
|
|
|
|
|
|
|
|
filteredTemplates.forEach((template: NodeTemplate) => {
|
|
|
|
|
const category = template.category || '未分类';
|
|
|
|
|
if (!groups.has(category)) {
|
|
|
|
|
groups.set(category, []);
|
|
|
|
|
}
|
|
|
|
|
groups.get(category)!.push(template);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return Array.from(groups.entries()).map(([category, templates]) => ({
|
|
|
|
|
category,
|
|
|
|
|
templates,
|
|
|
|
|
isExpanded: searchTextLower ? true : expandedCategories.has(category)
|
|
|
|
|
})).sort((a, b) => a.category.localeCompare(b.category));
|
|
|
|
|
}, [filteredTemplates, expandedCategories, searchTextLower]);
|
|
|
|
|
|
|
|
|
|
const flattenedTemplates = React.useMemo(() => {
|
2025-11-23 14:49:37 +08:00
|
|
|
return categoryGroups.flatMap((group) =>
|
2025-11-04 18:29:28 +08:00
|
|
|
group.isExpanded ? group.templates : []
|
|
|
|
|
);
|
|
|
|
|
}, [categoryGroups]);
|
|
|
|
|
|
|
|
|
|
const toggleCategory = (category: string) => {
|
2025-11-23 14:49:37 +08:00
|
|
|
setExpandedCategories((prev) => {
|
2025-11-04 18:29:28 +08:00
|
|
|
const newSet = new Set(prev);
|
|
|
|
|
if (newSet.has(category)) {
|
|
|
|
|
newSet.delete(category);
|
|
|
|
|
} else {
|
|
|
|
|
newSet.add(category);
|
|
|
|
|
}
|
|
|
|
|
return newSet;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (allTemplates.length > 0 && expandedCategories.size === 0) {
|
2025-11-23 14:49:37 +08:00
|
|
|
const categories = new Set(allTemplates.map((t) => t.category || '未分类'));
|
2025-11-04 18:29:28 +08:00
|
|
|
setExpandedCategories(categories);
|
|
|
|
|
}
|
|
|
|
|
}, [allTemplates, expandedCategories.size]);
|
|
|
|
|
|
2025-11-03 21:22:16 +08:00
|
|
|
useEffect(() => {
|
2025-11-04 18:29:28 +08:00
|
|
|
if (shouldAutoScroll && selectedNodeRef.current) {
|
2025-11-03 21:22:16 +08:00
|
|
|
selectedNodeRef.current.scrollIntoView({
|
|
|
|
|
block: 'nearest',
|
|
|
|
|
behavior: 'smooth'
|
|
|
|
|
});
|
2025-11-04 18:29:28 +08:00
|
|
|
setShouldAutoScroll(false);
|
2025-11-03 21:22:16 +08:00
|
|
|
}
|
2025-11-04 18:29:28 +08:00
|
|
|
}, [selectedIndex, shouldAutoScroll]);
|
2025-11-03 21:22:16 +08:00
|
|
|
|
|
|
|
|
if (!visible) return null;
|
|
|
|
|
|
2025-11-04 18:29:28 +08:00
|
|
|
let globalIndex = -1;
|
|
|
|
|
|
2025-11-03 21:22:16 +08:00
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<style>{`
|
|
|
|
|
.quick-create-menu-list::-webkit-scrollbar {
|
|
|
|
|
width: 8px;
|
|
|
|
|
}
|
|
|
|
|
.quick-create-menu-list::-webkit-scrollbar-track {
|
|
|
|
|
background: #1e1e1e;
|
|
|
|
|
}
|
|
|
|
|
.quick-create-menu-list::-webkit-scrollbar-thumb {
|
|
|
|
|
background: #3c3c3c;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
}
|
|
|
|
|
.quick-create-menu-list::-webkit-scrollbar-thumb:hover {
|
|
|
|
|
background: #4c4c4c;
|
|
|
|
|
}
|
2025-11-04 18:29:28 +08:00
|
|
|
.category-header {
|
|
|
|
|
transition: background-color 0.15s;
|
|
|
|
|
}
|
|
|
|
|
.category-header:hover {
|
|
|
|
|
background-color: #3c3c3c;
|
|
|
|
|
}
|
2025-11-03 21:22:16 +08:00
|
|
|
`}</style>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: 'fixed',
|
|
|
|
|
left: `${position.x}px`,
|
|
|
|
|
top: `${position.y}px`,
|
|
|
|
|
width: '300px',
|
2025-11-04 18:29:28 +08:00
|
|
|
maxHeight: '500px',
|
2025-11-03 21:22:16 +08:00
|
|
|
backgroundColor: '#2d2d2d',
|
|
|
|
|
borderRadius: '6px',
|
|
|
|
|
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
|
|
|
|
zIndex: 1000,
|
|
|
|
|
display: 'flex',
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
overflow: 'hidden'
|
|
|
|
|
}}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
{/* 搜索框 */}
|
|
|
|
|
<div style={{
|
|
|
|
|
padding: '12px',
|
|
|
|
|
borderBottom: '1px solid #3c3c3c',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: '8px'
|
|
|
|
|
}}>
|
|
|
|
|
<Search size={16} style={{ color: '#999', flexShrink: 0 }} />
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="搜索节点..."
|
|
|
|
|
autoFocus
|
|
|
|
|
value={searchText}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
onSearchChange(e.target.value);
|
|
|
|
|
onIndexChange(0);
|
|
|
|
|
}}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
onClose();
|
|
|
|
|
} else if (e.key === 'ArrowDown') {
|
|
|
|
|
e.preventDefault();
|
2025-11-04 18:29:28 +08:00
|
|
|
setShouldAutoScroll(true);
|
|
|
|
|
onIndexChange(Math.min(selectedIndex + 1, flattenedTemplates.length - 1));
|
2025-11-03 21:22:16 +08:00
|
|
|
} else if (e.key === 'ArrowUp') {
|
|
|
|
|
e.preventDefault();
|
2025-11-04 18:29:28 +08:00
|
|
|
setShouldAutoScroll(true);
|
2025-11-03 21:22:16 +08:00
|
|
|
onIndexChange(Math.max(selectedIndex - 1, 0));
|
2025-11-04 18:29:28 +08:00
|
|
|
} else if (e.key === 'Enter' && flattenedTemplates.length > 0) {
|
2025-11-03 21:22:16 +08:00
|
|
|
e.preventDefault();
|
2025-11-04 18:29:28 +08:00
|
|
|
const selectedTemplate = flattenedTemplates[selectedIndex];
|
2025-11-03 21:22:16 +08:00
|
|
|
if (selectedTemplate) {
|
|
|
|
|
onNodeSelect(selectedTemplate);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
style={{
|
|
|
|
|
flex: 1,
|
|
|
|
|
background: 'transparent',
|
|
|
|
|
border: 'none',
|
|
|
|
|
outline: 'none',
|
|
|
|
|
color: '#ccc',
|
|
|
|
|
fontSize: '14px',
|
|
|
|
|
padding: '4px'
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
style={{
|
|
|
|
|
background: 'transparent',
|
|
|
|
|
border: 'none',
|
|
|
|
|
color: '#999',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
padding: '4px',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<X size={16} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 节点列表 */}
|
|
|
|
|
<div
|
|
|
|
|
className="quick-create-menu-list"
|
|
|
|
|
style={{
|
|
|
|
|
flex: 1,
|
|
|
|
|
overflowY: 'auto',
|
2025-11-04 18:29:28 +08:00
|
|
|
padding: '4px'
|
2025-11-03 21:22:16 +08:00
|
|
|
}}
|
|
|
|
|
>
|
2025-11-04 18:29:28 +08:00
|
|
|
{categoryGroups.length === 0 ? (
|
2025-11-03 21:22:16 +08:00
|
|
|
<div style={{
|
|
|
|
|
padding: '20px',
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
color: '#666',
|
|
|
|
|
fontSize: '12px'
|
|
|
|
|
}}>
|
|
|
|
|
未找到匹配的节点
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2025-11-04 18:29:28 +08:00
|
|
|
categoryGroups.map((group) => {
|
2025-11-03 21:22:16 +08:00
|
|
|
return (
|
2025-11-04 18:29:28 +08:00
|
|
|
<div key={group.category} style={{ marginBottom: '4px' }}>
|
|
|
|
|
<div
|
|
|
|
|
className="category-header"
|
|
|
|
|
onClick={() => toggleCategory(group.category)}
|
|
|
|
|
style={{
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: '6px',
|
|
|
|
|
padding: '8px 12px',
|
|
|
|
|
backgroundColor: '#1e1e1e',
|
|
|
|
|
borderRadius: '3px',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
userSelect: 'none'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{group.isExpanded ? (
|
|
|
|
|
<ChevronDown size={14} style={{ color: '#999', flexShrink: 0 }} />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronRight size={14} style={{ color: '#999', flexShrink: 0 }} />
|
2025-11-03 21:22:16 +08:00
|
|
|
)}
|
2025-11-04 18:29:28 +08:00
|
|
|
<span style={{
|
|
|
|
|
color: '#aaa',
|
|
|
|
|
fontSize: '12px',
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
flex: 1
|
|
|
|
|
}}>
|
|
|
|
|
{group.category}
|
|
|
|
|
</span>
|
|
|
|
|
<span style={{
|
|
|
|
|
color: '#666',
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
backgroundColor: '#2d2d2d',
|
|
|
|
|
padding: '2px 6px',
|
|
|
|
|
borderRadius: '10px'
|
|
|
|
|
}}>
|
|
|
|
|
{group.templates.length}
|
|
|
|
|
</span>
|
2025-11-03 21:22:16 +08:00
|
|
|
</div>
|
2025-11-04 18:29:28 +08:00
|
|
|
|
|
|
|
|
{group.isExpanded && (
|
|
|
|
|
<div style={{ paddingLeft: '8px', paddingTop: '4px' }}>
|
|
|
|
|
{group.templates.map((template: NodeTemplate) => {
|
|
|
|
|
globalIndex++;
|
|
|
|
|
const IconComponent = template.icon ? iconMap[template.icon] : null;
|
|
|
|
|
const className = template.className || '';
|
|
|
|
|
const isSelected = globalIndex === selectedIndex;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={template.className || template.displayName}
|
|
|
|
|
ref={isSelected ? selectedNodeRef : null}
|
|
|
|
|
onClick={() => onNodeSelect(template)}
|
|
|
|
|
onMouseEnter={() => onIndexChange(globalIndex)}
|
|
|
|
|
style={{
|
|
|
|
|
padding: '8px 12px',
|
|
|
|
|
marginBottom: '4px',
|
|
|
|
|
backgroundColor: isSelected ? '#0e639c' : '#1e1e1e',
|
|
|
|
|
borderLeft: `3px solid ${template.color || '#666'}`,
|
|
|
|
|
borderRadius: '3px',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
transition: 'all 0.15s',
|
|
|
|
|
transform: isSelected ? 'translateX(2px)' : 'translateX(0)'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div style={{
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: '8px',
|
|
|
|
|
marginBottom: '4px'
|
|
|
|
|
}}>
|
|
|
|
|
{IconComponent && (
|
|
|
|
|
<IconComponent size={14} style={{ color: template.color || '#999', flexShrink: 0 }} />
|
|
|
|
|
)}
|
|
|
|
|
<div style={{ flex: 1 }}>
|
|
|
|
|
<div style={{
|
|
|
|
|
color: '#ccc',
|
|
|
|
|
fontSize: '13px',
|
|
|
|
|
fontWeight: '500',
|
|
|
|
|
marginBottom: '2px'
|
|
|
|
|
}}>
|
|
|
|
|
{template.displayName}
|
|
|
|
|
</div>
|
|
|
|
|
{className && (
|
|
|
|
|
<div style={{
|
|
|
|
|
color: '#666',
|
|
|
|
|
fontSize: '10px',
|
|
|
|
|
fontFamily: 'Consolas, Monaco, monospace',
|
|
|
|
|
opacity: 0.8
|
|
|
|
|
}}>
|
|
|
|
|
{className}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
color: '#999',
|
|
|
|
|
lineHeight: '1.4'
|
|
|
|
|
}}>
|
|
|
|
|
{template.description}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-11-03 21:22:16 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|