diff options
Diffstat (limited to 'src/components/ContextMenu')
| -rw-r--r-- | src/components/ContextMenu/ContextMenu.tsx | 184 |
1 files changed, 184 insertions, 0 deletions
diff --git a/src/components/ContextMenu/ContextMenu.tsx b/src/components/ContextMenu/ContextMenu.tsx new file mode 100644 index 0000000..2c67116 --- /dev/null +++ b/src/components/ContextMenu/ContextMenu.tsx @@ -0,0 +1,184 @@ +import { useEffect, useRef, useState } from 'react' +import { useContextMenuStore, type ContextMenuIcon } from '@/stores/useContextMenuStore' + +function MenuIcon({ icon }: { icon: ContextMenuIcon }) { + const c = 'currentColor' + switch (icon) { + case 'undo': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" /> + </svg> + ) + case 'redo': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.13-9.36L23 10" /> + </svg> + ) + case 'cut': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><line x1="8.12" y1="8.12" x2="15.88" y2="15.88" /><line x1="15.88" y1="8.12" x2="8.12" y2="15.88" /> + </svg> + ) + case 'copy': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /> + </svg> + ) + case 'paste': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" /><rect x="8" y="2" width="8" height="4" rx="1" ry="1" /> + </svg> + ) + case 'selectAll': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><line x1="9" y1="3" x2="9" y2="21" /><line x1="15" y1="3" x2="15" y2="21" /><line x1="3" y1="9" x2="21" y2="9" /><line x1="3" y1="15" x2="21" y2="15" /> + </svg> + ) + case 'close': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /> + </svg> + ) + case 'rename': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /> + </svg> + ) + case 'trash': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> + </svg> + ) + case 'folder': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /> + </svg> + ) + case 'file': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14,2 14,8 20,8" /> + </svg> + ) + case 'link': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> + </svg> + ) + } +} + +export default function ContextMenu() { + const isOpen = useContextMenuStore((s) => s.isOpen) + const x = useContextMenuStore((s) => s.x) + const y = useContextMenuStore((s) => s.y) + const items = useContextMenuStore((s) => s.items) + const close = useContextMenuStore((s) => s.close) + + const menuRef = useRef<HTMLDivElement>(null) + const [adjustedPos, setAdjustedPos] = useState({ x, y }) + const [selectedIndex, setSelectedIndex] = useState(0) + + const visibleItems = items.filter((i) => !i.separator) + const totalItems = visibleItems.length + + useEffect(() => { + if (!isOpen || !menuRef.current) return + const rect = menuRef.current.getBoundingClientRect() + const vw = window.innerWidth + const vh = window.innerHeight + setAdjustedPos({ + x: x + rect.width > vw ? vw - rect.width - 12 : x, + y: y + rect.height > vh ? vh - rect.height - 12 : y, + }) + setSelectedIndex(0) + }, [isOpen, x, y]) + + useEffect(() => { + if (!isOpen) return + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { e.preventDefault(); close(); return } + if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex((i) => Math.min(i + 1, totalItems - 1)); return } + if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)); return } + if (e.key === 'Enter') { + e.preventDefault() + const item = visibleItems[selectedIndex] + if (item && !item.disabled) { item.action(); close() } + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [isOpen, selectedIndex, totalItems, visibleItems, close]) + + useEffect(() => { + if (!isOpen || !menuRef.current) return + function handleClickOutside(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + close() + } + } + requestAnimationFrame(() => window.addEventListener('mousedown', handleClickOutside)) + return () => window.removeEventListener('mousedown', handleClickOutside) + }, [isOpen, close]) + + if (!isOpen) return null + + return ( + <div + ref={menuRef} + className="fixed z-50 min-w-[180px] py-1 rounded-xl shadow-glass overflow-hidden context-menu" + style={{ + left: adjustedPos.x, + top: adjustedPos.y, + backgroundColor: 'var(--color-panel-bg)', + border: '1px solid var(--color-panel-border)', + }} + > + {items.map((item) => { + if (item.separator) { + return ( + <div key={item.id} className="my-1 mx-2" style={{ height: '1px', backgroundColor: 'var(--color-panel-border)' }} /> + ) + } + const idx = visibleItems.indexOf(item) + const isSelected = idx === selectedIndex + return ( + <button + key={item.id} + role="menuitem" + disabled={item.disabled} + className="w-full flex items-center gap-2.5 px-3 py-1.5 text-ui-sm text-left transition-colors duration-fast disabled:opacity-30 disabled:cursor-default" + style={{ + backgroundColor: isSelected ? 'var(--color-panel-hover)' : 'transparent', + color: item.disabled ? 'var(--color-text-muted)' : 'var(--color-text-secondary)', + }} + onMouseEnter={() => setSelectedIndex(idx)} + onClick={() => { if (!item.disabled) { item.action(); close() } }} + > + {item.icon && ( + <span className="w-[14px] shrink-0 text-text-muted"> + <MenuIcon icon={item.icon} /> + </span> + )} + {!item.icon && <span className="w-[14px] shrink-0" />} + <span className="flex-1 truncate">{item.label}</span> + {item.shortcut && ( + <span className="text-ui-xs text-text-muted ml-4">{item.shortcut}</span> + )} + </button> + ) + })} + </div> + ) +} |