import React, { useEffect, useRef } from 'react'; /** * CommandMenu - Autocomplete dropdown for slash commands * * @param {Array} commands - Array of command objects to display * @param {number} selectedIndex - Currently selected command index * @param {Function} onSelect - Callback when a command is selected * @param {Function} onClose - Callback when menu should close * @param {Object} position - Position object { top, left } for absolute positioning * @param {boolean} isOpen - Whether the menu is open * @param {Array} frequentCommands - Array of frequently used command objects */ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, position = { top: 0, left: 0 }, isOpen = false, frequentCommands = [] }) => { const menuRef = useRef(null); const selectedItemRef = useRef(null); // Calculate responsive positioning const getMenuPosition = () => { const isMobile = window.innerWidth < 640; const viewportHeight = window.innerHeight; const menuHeight = 300; // Max height of menu if (isMobile) { // On mobile, calculate bottom position dynamically to appear above the input // Use the bottom value which is calculated as: window.innerHeight - textarea.top + spacing const inputBottom = position.bottom || 90; // Use provided bottom or default return { position: 'fixed', bottom: `${inputBottom}px`, // Position above the input with spacing already included left: '16px', right: '16px', width: 'auto', maxWidth: 'calc(100vw - 32px)', maxHeight: 'min(50vh, 300px)' // Limit to smaller of 50vh or 300px }; } // On desktop, use provided position but ensure it stays on screen return { position: 'fixed', top: `${Math.max(16, Math.min(position.top, viewportHeight - 316))}px`, left: `${position.left}px`, width: 'min(400px, calc(100vw - 32px))', maxWidth: 'calc(100vw - 32px)', maxHeight: '300px' }; }; const menuPosition = getMenuPosition(); // Close menu when clicking outside useEffect(() => { const handleClickOutside = (event) => { if (menuRef.current && !menuRef.current.contains(event.target) && isOpen) { onClose(); } }; if (isOpen) { document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; } }, [isOpen, onClose]); // Scroll selected item into view useEffect(() => { if (selectedItemRef.current && menuRef.current) { const menuRect = menuRef.current.getBoundingClientRect(); const itemRect = selectedItemRef.current.getBoundingClientRect(); if (itemRect.bottom > menuRect.bottom) { selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } else if (itemRect.top < menuRect.top) { selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } }, [selectedIndex]); if (!isOpen) { return null; } // Show a message if no commands are available if (commands.length === 0) { return (
No commands available
); } // Add frequent commands as a special group if provided const hasFrequentCommands = frequentCommands.length > 0; // Group commands by namespace const groupedCommands = commands.reduce((groups, command) => { const namespace = command.namespace || command.type || 'other'; if (!groups[namespace]) { groups[namespace] = []; } groups[namespace].push(command); return groups; }, {}); // Add frequent commands as a separate group if (hasFrequentCommands) { groupedCommands['frequent'] = frequentCommands; } // Order: frequent, builtin, project, user, other const namespaceOrder = hasFrequentCommands ? ['frequent', 'builtin', 'project', 'user', 'other'] : ['builtin', 'project', 'user', 'other']; const orderedNamespaces = namespaceOrder.filter(ns => groupedCommands[ns]); const namespaceLabels = { frequent: '⭐ Frequently Used', builtin: 'Built-in Commands', project: 'Project Commands', user: 'User Commands', other: 'Other Commands' }; // Calculate global index for each command let globalIndex = 0; const commandsWithIndex = []; orderedNamespaces.forEach(namespace => { groupedCommands[namespace].forEach(command => { commandsWithIndex.push({ ...command, globalIndex: globalIndex++, namespace }); }); }); return (
{orderedNamespaces.map((namespace) => (
{orderedNamespaces.length > 1 && (
{namespaceLabels[namespace] || namespace}
)} {groupedCommands[namespace].map((command) => { const cmdWithIndex = commandsWithIndex.find(c => c.name === command.name && c.namespace === namespace); const isSelected = cmdWithIndex && cmdWithIndex.globalIndex === selectedIndex; return (
onSelect && onSelect(command, cmdWithIndex.globalIndex, true)} onClick={() => onSelect && onSelect(command, cmdWithIndex.globalIndex, false)} style={{ display: 'flex', alignItems: 'flex-start', padding: '10px 12px', borderRadius: '6px', cursor: 'pointer', backgroundColor: isSelected ? '#eff6ff' : 'transparent', transition: 'background-color 100ms ease-in-out', marginBottom: '2px' }} onMouseDown={(e) => e.preventDefault()} // Prevent textarea blur >
{/* Command icon based on namespace */} {namespace === 'builtin' && '⚡'} {namespace === 'project' && '📁'} {namespace === 'user' && '👤'} {namespace === 'other' && '📝'} {/* Command name */} {command.name} {/* Command metadata badge */} {command.metadata?.type && ( {command.metadata.type} )}
{/* Command description */} {command.description && (
{command.description}
)}
{/* Selection indicator */} {isSelected && ( )}
); })}
))} {/* Default light mode styles */}
); }; export default CommandMenu;