Spaces:
Sleeping
Sleeping
| import { useState, useRef, useEffect } from 'react'; | |
| import { LucideIcon, Plus, FileText, Package, Receipt, Users, BookOpen } from 'lucide-react'; | |
| import { cn } from '@/lib/utils'; | |
| interface RadialMenuItem { | |
| icon: LucideIcon; | |
| label: string; | |
| onClick: () => void; | |
| color: string; | |
| } | |
| interface RadialMenuProps { | |
| items: RadialMenuItem[]; | |
| size?: number; | |
| className?: string; | |
| } | |
| export function RadialMenu({ items, size = 280, className }: RadialMenuProps) { | |
| const [isOpen, setIsOpen] = useState(false); | |
| const [selectedIndex, setSelectedIndex] = useState<number | null>(null); | |
| const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null); | |
| const longPressTimer = useRef<NodeJS.Timeout | null>(null); | |
| const menuRef = useRef<HTMLButtonElement>(null); | |
| const radius = size / 2; | |
| const itemRadius = 60; | |
| const centerSize = 64; | |
| // Calculate positions in thumb-friendly arc (180° from top-left to top-right, avoiding right side) | |
| const getItemPosition = (index: number) => { | |
| // Start from -135° (top-left) and spread to -45° (top-right) | |
| // This keeps all items in the reachable thumb zone | |
| const startAngle = -135; // Top-left | |
| const arcSpan = 180; // 180° arc (left side to top) | |
| const angle = startAngle + (arcSpan / (items.length - 1)) * index; | |
| const radian = (angle * Math.PI) / 180; | |
| const distance = radius - itemRadius; | |
| return { | |
| x: Math.cos(radian) * distance + radius - itemRadius / 2, | |
| y: Math.sin(radian) * distance + radius - itemRadius / 2, | |
| }; | |
| }; | |
| // Get angle from center to point (returns -180 to 180) | |
| const getAngleFromCenter = (x: number, y: number) => { | |
| const rect = menuRef.current?.getBoundingClientRect(); | |
| if (!rect) return 0; | |
| const centerX = rect.left + rect.width / 2; | |
| const centerY = rect.top + rect.height / 2; | |
| const dx = x - centerX; | |
| const dy = y - centerY; | |
| // atan2 returns -180 to 180, we want -90° to be top | |
| let angle = Math.atan2(dy, dx) * 180 / Math.PI; | |
| return angle; | |
| }; | |
| // Get selected item index based on angle (180° arc from top-left to top-right) | |
| const getSelectedItemIndex = (angle: number) => { | |
| // Map angle to our 180° arc (-135° to +45°) | |
| const startAngle = -135; | |
| const arcSpan = 180; | |
| const segmentAngle = arcSpan / (items.length - 1); | |
| // Normalize angle to -180 to 180 range | |
| let normalizedAngle = angle; | |
| while (normalizedAngle > 180) normalizedAngle -= 360; | |
| while (normalizedAngle < -180) normalizedAngle += 360; | |
| // Check if angle is within our arc | |
| if (normalizedAngle < startAngle || normalizedAngle > startAngle + arcSpan) { | |
| // Return closest edge item if outside arc | |
| if (normalizedAngle < startAngle) return 0; | |
| return items.length - 1; | |
| } | |
| // Calculate index within arc | |
| const relativeAngle = normalizedAngle - startAngle; | |
| const index = Math.round(relativeAngle / segmentAngle); | |
| return Math.max(0, Math.min(items.length - 1, index)); | |
| }; | |
| // Handle long press start | |
| const handleTouchStart = (e: React.TouchEvent) => { | |
| const touch = e.touches[0]; | |
| setTouchStart({ x: touch.clientX, y: touch.clientY }); | |
| longPressTimer.current = setTimeout(() => { | |
| setIsOpen(true); | |
| // Haptic feedback if available | |
| if (navigator.vibrate) { | |
| navigator.vibrate(50); | |
| } | |
| }, 400); // 400ms long press | |
| }; | |
| const handleMouseDown = (e: React.MouseEvent) => { | |
| setTouchStart({ x: e.clientX, y: e.clientY }); | |
| longPressTimer.current = setTimeout(() => { | |
| setIsOpen(true); | |
| }, 400); | |
| }; | |
| // Handle touch move - select item | |
| const handleTouchMove = (e: React.TouchEvent) => { | |
| if (!isOpen) return; | |
| const touch = e.touches[0]; | |
| const angle = getAngleFromCenter(touch.clientX, touch.clientY); | |
| const index = getSelectedItemIndex(angle); | |
| setSelectedIndex(index); | |
| }; | |
| const handleMouseMove = (e: React.MouseEvent) => { | |
| if (!isOpen) return; | |
| const angle = getAngleFromCenter(e.clientX, e.clientY); | |
| const index = getSelectedItemIndex(angle); | |
| setSelectedIndex(index); | |
| }; | |
| // Handle release - execute action | |
| const handleTouchEnd = () => { | |
| if (longPressTimer.current) { | |
| clearTimeout(longPressTimer.current); | |
| } | |
| if (isOpen && selectedIndex !== null) { | |
| items[selectedIndex].onClick(); | |
| // Haptic feedback | |
| if (navigator.vibrate) { | |
| navigator.vibrate([30, 20, 30]); | |
| } | |
| } | |
| setIsOpen(false); | |
| setSelectedIndex(null); | |
| setTouchStart(null); | |
| }; | |
| const handleMouseUp = () => { | |
| if (longPressTimer.current) { | |
| clearTimeout(longPressTimer.current); | |
| } | |
| if (isOpen && selectedIndex !== null) { | |
| items[selectedIndex].onClick(); | |
| } | |
| setIsOpen(false); | |
| setSelectedIndex(null); | |
| setTouchStart(null); | |
| }; | |
| // Cleanup on unmount | |
| useEffect(() => { | |
| return () => { | |
| if (longPressTimer.current) { | |
| clearTimeout(longPressTimer.current); | |
| } | |
| }; | |
| }, []); | |
| return ( | |
| <> | |
| {/* Overlay */} | |
| {isOpen && ( | |
| <div | |
| className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm" | |
| onTouchEnd={handleTouchEnd} | |
| onMouseUp={handleMouseUp} | |
| /> | |
| )} | |
| {/* FAB Button */} | |
| <div className={cn("fixed bottom-20 right-4 md:bottom-8 md:right-8 z-50", className)}> | |
| <button | |
| ref={menuRef} | |
| onTouchStart={handleTouchStart} | |
| onTouchMove={handleTouchMove} | |
| onTouchEnd={handleTouchEnd} | |
| onMouseDown={handleMouseDown} | |
| onMouseMove={handleMouseMove} | |
| onMouseUp={handleMouseUp} | |
| className={cn( | |
| "relative flex items-center justify-center", | |
| "w-16 h-16 rounded-full", | |
| "bg-primary text-primary-foreground", | |
| "shadow-lg hover:shadow-xl", | |
| "transition-all duration-200", | |
| "touch-none select-none", | |
| isOpen && "scale-110 shadow-2xl" | |
| )} | |
| style={{ | |
| WebkitTouchCallout: 'none', | |
| WebkitUserSelect: 'none', | |
| }} | |
| > | |
| <Plus className={cn( | |
| "h-7 w-7 transition-transform duration-300", | |
| isOpen && "rotate-45" | |
| )} /> | |
| {/* Ripple animation on long press */} | |
| {isOpen && ( | |
| <span className="absolute inset-0 rounded-full border-4 border-primary animate-ping" /> | |
| )} | |
| </button> | |
| {/* Radial Menu Items */} | |
| {isOpen && ( | |
| <div | |
| className="absolute" | |
| style={{ | |
| width: size, | |
| height: size, | |
| left: -(size / 2) + 32, | |
| bottom: -(size / 2) + 32, | |
| }} | |
| > | |
| {/* Center circle guide */} | |
| <div | |
| className="absolute rounded-full border-2 border-primary/30 pointer-events-none" | |
| style={{ | |
| width: centerSize, | |
| height: centerSize, | |
| left: radius - centerSize / 2, | |
| top: radius - centerSize / 2, | |
| }} | |
| /> | |
| {/* Menu Items */} | |
| {items.map((item, index) => { | |
| const pos = getItemPosition(index); | |
| const isSelected = selectedIndex === index; | |
| const Icon = item.icon; | |
| return ( | |
| <div | |
| key={index} | |
| className={cn( | |
| "absolute flex flex-col items-center justify-center gap-1", | |
| "rounded-full transition-all duration-200", | |
| "pointer-events-none" | |
| )} | |
| style={{ | |
| width: itemRadius, | |
| height: itemRadius, | |
| left: pos.x, | |
| top: pos.y, | |
| transform: `scale(${isSelected ? 1.3 : 1})`, | |
| zIndex: isSelected ? 10 : 5, | |
| }} | |
| > | |
| {/* Item circle */} | |
| <div className={cn( | |
| "w-14 h-14 rounded-full flex items-center justify-center", | |
| "bg-card border-2 shadow-lg", | |
| "transition-all duration-200", | |
| isSelected ? `border-${item.color}-500 bg-${item.color}-50 shadow-${item.color}-500/50` : "border-border" | |
| )}> | |
| <Icon className={cn( | |
| "h-6 w-6 transition-colors", | |
| isSelected ? `text-${item.color}-600` : "text-foreground" | |
| )} /> | |
| </div> | |
| {/* Label */} | |
| <span className={cn( | |
| "text-xs font-semibold px-2 py-1 rounded-full", | |
| "bg-card/90 backdrop-blur-sm shadow-md border whitespace-nowrap", | |
| "transition-all duration-200", | |
| isSelected ? `border-${item.color}-500 text-${item.color}-600` : "border-border text-foreground" | |
| )}> | |
| {item.label} | |
| </span> | |
| {/* Selection indicator */} | |
| {isSelected && ( | |
| <div className="absolute inset-0 rounded-full animate-pulse"> | |
| <div className={`w-full h-full rounded-full border-4 border-${item.color}-500/50`} /> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| {/* Connection lines from center (optional visual enhancement) */} | |
| <svg className="absolute inset-0 pointer-events-none opacity-20"> | |
| {items.map((_, index) => { | |
| const pos = getItemPosition(index); | |
| const isSelected = selectedIndex === index; | |
| return ( | |
| <line | |
| key={index} | |
| x1={radius} | |
| y1={radius} | |
| x2={pos.x + itemRadius / 2} | |
| y2={pos.y + itemRadius / 2} | |
| stroke={isSelected ? "currentColor" : "#666"} | |
| strokeWidth={isSelected ? "3" : "1"} | |
| strokeDasharray={isSelected ? "0" : "4 4"} | |
| className="transition-all duration-200" | |
| /> | |
| ); | |
| })} | |
| </svg> | |
| </div> | |
| )} | |
| </div> | |
| </> | |
| ); | |
| } | |
| // Usage hint component | |
| export function RadialMenuHint() { | |
| const [show, setShow] = useState(true); | |
| useEffect(() => { | |
| const hasSeenHint = localStorage.getItem('radial-menu-hint-seen'); | |
| if (hasSeenHint) { | |
| setShow(false); | |
| } else { | |
| setTimeout(() => { | |
| setShow(false); | |
| localStorage.setItem('radial-menu-hint-seen', 'true'); | |
| }, 5000); | |
| } | |
| }, []); | |
| if (!show) return null; | |
| return ( | |
| <div className="fixed bottom-36 right-4 md:bottom-24 md:right-8 z-40 animate-in fade-in slide-in-from-bottom-4"> | |
| <div className="bg-card border shadow-lg rounded-lg p-3 max-w-[200px]"> | |
| <p className="text-xs text-muted-foreground"> | |
| <span className="font-semibold text-foreground">Long press</span> the + button | |
| <br /> | |
| to open quick menu | |
| </p> | |
| <div className="absolute -bottom-2 right-8 w-0 h-0 border-l-8 border-r-8 border-t-8 border-transparent border-t-card" /> | |
| </div> | |
| </div> | |
| ); | |
| } | |