diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8c0abd7..d22d342 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -180,50 +180,185 @@ function serializeGraph(nodes, edges) { // ── Context menu component ──────────────────────────────────────────── function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirection }) { + const [openCat, setOpenCat] = useState(null); + const [search, setSearch] = useState(''); + 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({}); + // Group by category, optionally filtering to compatible nodes - const categories = {}; - for (const [className, def] of Object.entries(nodeDefs)) { - // If filtering: only show nodes with a matching input or output - if (filterType && filterDirection) { - if (filterDirection === 'source') { - // Dragged from an output — show nodes that have a matching INPUT - const req = def.input.required || {}; - const opt = def.input.optional || {}; - const allInputs = { ...req, ...opt }; - const hasMatch = Object.values(allInputs).some((spec) => { - const [type] = Array.isArray(spec) ? spec : [spec]; - return type === filterType; - }); - if (!hasMatch) continue; - } else { - // Dragged from an input — show nodes that have a matching OUTPUT - if (!def.output.includes(filterType)) continue; + const categories = useMemo(() => { + const cats = {}; + for (const [className, def] of Object.entries(nodeDefs)) { + 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) => { + const [type] = Array.isArray(spec) ? spec : [spec]; + return type === filterType; + }); + if (!hasMatch) continue; + } else { + if (!def.output.includes(filterType)) continue; + } + } + const cat = def.category || 'uncategorized'; + if (!cats[cat]) cats[cat] = []; + cats[cat].push({ className, def }); + } + return cats; + }, [nodeDefs, filterType, filterDirection]); + + // Flat filtered list for search + const searchResults = useMemo(() => { + if (!search.trim()) return null; + const q = search.toLowerCase(); + const results = []; + for (const items of Object.values(categories)) { + for (const { className, def } of items) { + const name = (def.display_name || className).toLowerCase(); + if (name.includes(q)) results.push({ className, def }); } } + return results; + }, [search, categories]); - const cat = def.category || 'uncategorized'; - if (!categories[cat]) categories[cat] = []; - categories[cat].push({ className, def }); - } + // 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) => { + setOpenCat(cat); + }, []); if (Object.keys(categories).length === 0) { return ( -
e.stopPropagation()}> +
e.stopPropagation()}>
No compatible nodes
); } + const catNames = Object.keys(categories).sort(); + return ( -
e.stopPropagation()} - > - {Object.entries(categories).map(([cat, items]) => ( -
-
{cat}
- {items.map(({ className, def }) => ( + <> +
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)) return; + setOpenCat(null); + }} + > +
Add Node
+
+ { setSearch(e.target.value); setOpenCat(null); }} + autoFocus + /> +
+ + {searchResults ? ( +
+ {searchResults.length === 0 ? ( +
No matches
+ ) : ( + searchResults.map(({ className, def }) => ( +
{ onAdd(className, def); onClose(); }} + > + {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 && categories[openCat] && ( +
e.stopPropagation()} + onMouseLeave={(e) => { + const related = e.relatedTarget; + if (menuRef.current && menuRef.current.contains(related)) return; + setOpenCat(null); + }} + > + {categories[openCat].map(({ className, def }) => (
))}
- ))} -
+ )} + ); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 5b84a67..c79483e 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -408,23 +408,76 @@ html, body, #root { border: 1px solid #0f3460; border-radius: 6px; min-width: 180px; - max-height: 60vh; - overflow-y: auto; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); padding: 4px 0; } -.context-category { - padding: 6px 12px 3px; - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; - color: #64748b; - border-top: 1px solid #0f3460; +.ctx-title { + padding: 6px 12px; + font-size: 13px; + font-weight: 600; + color: #94a3b8; + border-bottom: 1px solid #0f3460; } -.context-category:first-child { - border-top: none; + +.ctx-search-row { + padding: 6px 8px; + border-bottom: 1px solid #0f3460; +} +.ctx-search { + width: 100%; + background: #0f172a; + color: #e0e0e0; + border: 1px solid #334155; + border-radius: 4px; + padding: 4px 8px; + font-size: 12px; + outline: none; +} +.ctx-search:focus { + border-color: #3a7abf; +} +.ctx-search::placeholder { + color: #475569; +} + +.ctx-list { + max-height: 50vh; + overflow-y: auto; +} + +/* ── Category leaf items ── */ +.ctx-cat-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 12px; + font-size: 12px; + cursor: pointer; + color: #e0e0e0; +} +.ctx-cat-item:hover, +.ctx-cat-active { + background: #0f3460; +} +.ctx-cat-label { + text-transform: capitalize; +} +.ctx-cat-arrow { + font-size: 8px; + color: #64748b; + margin-left: 12px; + flex-shrink: 0; +} +.ctx-cat-active .ctx-cat-arrow { + color: #e0e0e0; +} + +/* ── Submenu panel (separate fixed-position sibling) ── */ +.ctx-submenu { + position: fixed; + max-height: 50vh; + overflow-y: auto; } .context-item { @@ -432,6 +485,7 @@ html, body, #root { font-size: 12px; cursor: pointer; color: #e0e0e0; + white-space: nowrap; } .context-item:hover { background: #0f3460;