import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { socketSpecAcceptsType } from './constants'; import { outputTypeCanConnectToTarget } from './connectionUtils'; import { compareMenuNodes, compareMenuCategories } from './canvasEvents'; export default function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterSpec = null, filterDirection, selectedNodeCount = 0, onCreateGroup = null, }: { x: number; y: number; nodeDefs: Record; onAdd: (className: string, def: any) => void; onClose: () => void; filterType?: string | null; filterSpec?: any; filterDirection?: string | null; selectedNodeCount?: number; onCreateGroup?: (() => void) | null; }) { const [openCat, setOpenCat] = useState(null); const [search, setSearch] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const menuRef = useRef(null); const [menuPos, setMenuPos] = useState({ x, y }); const subMenuRef = useRef(null); const [subPos, setSubPos] = useState({ x: 0, y: 0 }); const catRowRefs = useRef>({}); const selectedItemRef = useRef(null); // Group by category, optionally filtering to compatible nodes const categories = useMemo(() => { const cats: Record = {}; for (const [className, def] of Object.entries(nodeDefs) as [string, any][]) { if (filterType && filterDirection) { if (filterDirection === 'source') { const req = def.input.required || {}; const opt = def.input.optional || {}; const allInputs = { ...req, ...opt }; const hasMatch = Object.values(allInputs).some((spec: any) => { return socketSpecAcceptsType(filterType, spec); }); if (!hasMatch) continue; } else { const hasMatch = def.output.some((type: string, idx: number) => outputTypeCanConnectToTarget(type, filterSpec || filterType, def.output_accepted_types?.[idx] || []) ); if (!hasMatch) continue; } } const menuCategories = Array.isArray(def.menu_categories) && def.menu_categories.length > 0 ? def.menu_categories : [{ category: def.category || 'uncategorized', category_order: def.category_order, menu_order: def.menu_order, }]; for (const menuCategory of menuCategories) { const cat = menuCategory?.category || def.category || 'uncategorized'; if (!cats[cat]) { cats[cat] = { name: cat, order: Number.isFinite(menuCategory?.category_order) ? menuCategory.category_order : Number.MAX_SAFE_INTEGER, items: [], }; } cats[cat].order = Math.min( cats[cat].order, Number.isFinite(menuCategory?.category_order) ? menuCategory.category_order : Number.MAX_SAFE_INTEGER, ); cats[cat].items.push({ className, def, menu_order: Number.isFinite(menuCategory?.menu_order) ? menuCategory.menu_order : def.menu_order, }); } } return Object.values(cats) .map((category: any) => ({ ...category, items: [...category.items].sort(compareMenuNodes), })) .sort(compareMenuCategories); }, [nodeDefs, filterDirection, filterSpec, filterType]); // Flat filtered list for search const searchResults = useMemo(() => { if (!search.trim()) return null; const q = search.toLowerCase(); const results: { className: string; def: any }[] = []; const seen = new Set(); for (const category of categories) { for (const { className, def } of category.items) { if (seen.has(className)) continue; const name = (def.display_name || className).toLowerCase(); const keywords: string[] = Array.isArray(def.keywords) ? def.keywords : []; const keywordMatch = keywords.some((kw) => String(kw).toLowerCase().includes(q)); if (name.includes(q) || keywordMatch) { results.push({ className, def }); seen.add(className); } } } return results; }, [search, categories]); // Reset selection to top whenever results change useEffect(() => { setSelectedIndex(0); }, [searchResults]); // Scroll selected item into view useEffect(() => { selectedItemRef.current?.scrollIntoView({ block: 'nearest' }); }, [selectedIndex]); const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { if (!searchResults || searchResults.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex((i) => Math.min(i + 1, searchResults.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); const item = searchResults[selectedIndex]; if (item) { onAdd(item.className, item.def); onClose(); } } }, [searchResults, selectedIndex, onAdd, onClose]); // Clamp main menu position to viewport on mount useEffect(() => { const el = menuRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; let nx = x, ny = y; if (x + rect.width > vw) nx = vw - rect.width - 8; if (y + rect.height > vh) ny = vh - rect.height - 8; if (nx < 4) nx = 4; if (ny < 4) ny = 4; setMenuPos({ x: nx, y: ny }); }, [x, y]); // Position submenu next to the hovered category row, clamped to viewport useEffect(() => { if (!openCat) return; const rowEl = catRowRefs.current[openCat]; const subEl = subMenuRef.current; if (!rowEl || !subEl) return; const rowRect = rowEl.getBoundingClientRect(); const menuRect = menuRef.current!.getBoundingClientRect(); const subRect = subEl.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; // Horizontal: prefer right side, fall back to left let sx = menuRect.right - 1; if (sx + subRect.width > vw - 8) { sx = menuRect.left - subRect.width + 1; } if (sx < 4) sx = 4; // Vertical: align top with hovered row, clamp to viewport let sy = rowRect.top; if (sy + subRect.height > vh - 8) { sy = vh - subRect.height - 8; } if (sy < 4) sy = 4; setSubPos({ x: sx, y: sy }); }, [openCat]); const handleCatEnter = useCallback((cat: string) => { setOpenCat(cat); }, []); if (categories.length === 0) { return (
e.stopPropagation()}>
No compatible nodes
); } const catNames = categories.map((category) => category.name); const categoryMap = Object.fromEntries(categories.map((category) => [category.name, category.items])); return ( <>
e.stopPropagation()} onMouseLeave={(e) => { // Close submenu only if mouse didn't move into the submenu const related = e.relatedTarget; if (subMenuRef.current && subMenuRef.current.contains(related as globalThis.Node | null)) return; setOpenCat(null); }} >
Add Node
{ setSearch(e.target.value); setOpenCat(null); }} onKeyDown={handleSearchKeyDown} autoFocus />
{!filterType && selectedNodeCount > 1 && typeof onCreateGroup === 'function' && (
{ onCreateGroup(); onClose(); }} > create group
)} {searchResults ? (
{searchResults.length === 0 ? (
No matches
) : ( searchResults.map(({ className, def }, idx) => (
{ onAdd(className, def); onClose(); }} onMouseEnter={() => setSelectedIndex(idx)} > {def.display_name || className}
)) )}
) : (
{catNames.map((cat) => (
{ catRowRefs.current[cat] = el; }} className={`ctx-cat-item${openCat === cat ? ' ctx-cat-active' : ''}`} onMouseEnter={() => handleCatEnter(cat)} > {cat}
))}
)}
{/* Submenu rendered as a sibling, positioned at computed screen coords */} {openCat && categoryMap[openCat] && (
e.stopPropagation()} onMouseLeave={(e) => { const related = e.relatedTarget; if (menuRef.current && menuRef.current.contains(related as globalThis.Node | null)) return; setOpenCat(null); }} > {categoryMap[openCat].map(({ className, def }: { className: string; def: any }) => (
{ onAdd(className, def); onClose(); }} > {def.display_name || className}
))}
)} ); }