ContextMenu.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import { useEffect, useRef, useState, useLayoutEffect } from 'react';
  2. import { ChevronRight } from 'lucide-react';
  3. export interface ContextMenuItem {
  4. label: string;
  5. icon?: React.ReactNode;
  6. onClick: () => void;
  7. danger?: boolean;
  8. disabled?: boolean;
  9. divider?: boolean;
  10. submenu?: ContextMenuItem[];
  11. }
  12. interface ContextMenuProps {
  13. x: number;
  14. y: number;
  15. items: ContextMenuItem[];
  16. onClose: () => void;
  17. }
  18. export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
  19. const menuRef = useRef<HTMLDivElement>(null);
  20. const [activeSubmenu, setActiveSubmenu] = useState<number | null>(null);
  21. const submenuTimeoutRef = useRef<number | null>(null);
  22. const [position, setPosition] = useState({ x, y, visible: false });
  23. const [openSubmenuLeft, setOpenSubmenuLeft] = useState(false);
  24. useEffect(() => {
  25. const handleClickOutside = (e: MouseEvent) => {
  26. if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
  27. onClose();
  28. }
  29. };
  30. const handleEscape = (e: KeyboardEvent) => {
  31. if (e.key === 'Escape') {
  32. onClose();
  33. }
  34. };
  35. const handleScroll = () => {
  36. onClose();
  37. };
  38. document.addEventListener('mousedown', handleClickOutside);
  39. document.addEventListener('keydown', handleEscape);
  40. document.addEventListener('scroll', handleScroll, true);
  41. return () => {
  42. document.removeEventListener('mousedown', handleClickOutside);
  43. document.removeEventListener('keydown', handleEscape);
  44. document.removeEventListener('scroll', handleScroll, true);
  45. if (submenuTimeoutRef.current) {
  46. clearTimeout(submenuTimeoutRef.current);
  47. }
  48. };
  49. }, [onClose]);
  50. // Adjust position to keep menu in viewport - use useLayoutEffect for synchronous measurement
  51. useLayoutEffect(() => {
  52. if (menuRef.current) {
  53. // Force a reflow to get accurate measurements
  54. menuRef.current.style.visibility = 'hidden';
  55. menuRef.current.style.display = 'block';
  56. const rect = menuRef.current.getBoundingClientRect();
  57. const viewportWidth = window.innerWidth;
  58. const viewportHeight = window.innerHeight;
  59. const padding = 8;
  60. let adjustedX = x;
  61. let adjustedY = y;
  62. // Adjust horizontal position - if menu would overflow right, shift left
  63. if (x + rect.width > viewportWidth - padding) {
  64. adjustedX = Math.max(padding, viewportWidth - rect.width - padding);
  65. }
  66. // Also check if starting position is negative
  67. if (adjustedX < padding) {
  68. adjustedX = padding;
  69. }
  70. // Adjust vertical position - if menu would overflow bottom, shift up
  71. if (y + rect.height > viewportHeight - padding) {
  72. adjustedY = Math.max(padding, viewportHeight - rect.height - padding);
  73. }
  74. // Also check if starting position is negative
  75. if (adjustedY < padding) {
  76. adjustedY = padding;
  77. }
  78. // Check if submenus should open to the left (more space on left than right)
  79. const submenuWidth = 180;
  80. const spaceOnRight = viewportWidth - adjustedX - rect.width;
  81. const spaceOnLeft = adjustedX;
  82. // Only open left if there's not enough space on right AND there's enough space on left
  83. setOpenSubmenuLeft(spaceOnRight < submenuWidth && spaceOnLeft > submenuWidth);
  84. setPosition({ x: adjustedX, y: adjustedY, visible: true });
  85. }
  86. }, [x, y]);
  87. const handleMouseEnterSubmenu = (index: number) => {
  88. if (submenuTimeoutRef.current) {
  89. clearTimeout(submenuTimeoutRef.current);
  90. submenuTimeoutRef.current = null;
  91. }
  92. setActiveSubmenu(index);
  93. };
  94. const handleMouseLeaveSubmenu = () => {
  95. submenuTimeoutRef.current = window.setTimeout(() => {
  96. setActiveSubmenu(null);
  97. }, 150);
  98. };
  99. return (
  100. <div
  101. ref={menuRef}
  102. className="fixed z-50 min-w-[180px] max-w-[280px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1"
  103. style={{
  104. left: position.x,
  105. top: position.y,
  106. visibility: position.visible ? 'visible' : 'hidden'
  107. }}
  108. >
  109. {items.map((item, index) => {
  110. if (item.divider) {
  111. return <div key={index} className="my-1 border-t border-bambu-dark-tertiary" />;
  112. }
  113. const hasSubmenu = item.submenu && item.submenu.length > 0;
  114. return (
  115. <div
  116. key={index}
  117. className="relative"
  118. onMouseEnter={() => hasSubmenu && handleMouseEnterSubmenu(index)}
  119. onMouseLeave={() => hasSubmenu && handleMouseLeaveSubmenu()}
  120. >
  121. <button
  122. onMouseEnter={() => hasSubmenu && handleMouseEnterSubmenu(index)}
  123. onClick={() => {
  124. if (hasSubmenu) {
  125. // Toggle submenu on click as well
  126. setActiveSubmenu(activeSubmenu === index ? null : index);
  127. } else if (!item.disabled) {
  128. item.onClick();
  129. onClose();
  130. }
  131. }}
  132. disabled={item.disabled}
  133. className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
  134. item.disabled
  135. ? 'text-bambu-gray cursor-not-allowed'
  136. : item.danger
  137. ? 'text-red-400 hover:bg-red-400/10'
  138. : 'text-white hover:bg-bambu-dark-tertiary'
  139. } ${hasSubmenu && activeSubmenu === index ? 'bg-bambu-dark-tertiary' : ''}`}
  140. >
  141. {item.icon && <span className="w-4 h-4 flex-shrink-0 flex items-center justify-center">{item.icon}</span>}
  142. <span className="flex-1">{item.label}</span>
  143. {hasSubmenu && <ChevronRight className="w-4 h-4 text-bambu-gray" />}
  144. </button>
  145. {/* Submenu */}
  146. {hasSubmenu && activeSubmenu === index && (
  147. <div
  148. className={`absolute top-0 min-w-[160px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 overflow-hidden max-h-[300px] overflow-y-auto z-[60] ${
  149. openSubmenuLeft ? 'right-full mr-1' : 'left-full ml-1'
  150. }`}
  151. onMouseEnter={() => {
  152. if (submenuTimeoutRef.current) {
  153. clearTimeout(submenuTimeoutRef.current);
  154. submenuTimeoutRef.current = null;
  155. }
  156. }}
  157. onMouseLeave={() => handleMouseLeaveSubmenu()}
  158. >
  159. {item.submenu!.map((subItem, subIndex) => (
  160. <button
  161. key={subIndex}
  162. onClick={() => {
  163. if (!subItem.disabled) {
  164. subItem.onClick();
  165. onClose();
  166. }
  167. }}
  168. disabled={subItem.disabled}
  169. className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
  170. subItem.disabled
  171. ? 'text-bambu-gray cursor-not-allowed'
  172. : subItem.danger
  173. ? 'text-red-400 hover:bg-red-400/10'
  174. : 'text-white hover:bg-bambu-dark-tertiary'
  175. }`}
  176. >
  177. {subItem.icon && <span className="w-4 h-4 flex-shrink-0 flex items-center justify-center">{subItem.icon}</span>}
  178. {subItem.label}
  179. </button>
  180. ))}
  181. </div>
  182. )}
  183. </div>
  184. );
  185. })}
  186. </div>
  187. );
  188. }