"use client"; import { useEffect, useRef, useCallback } from "react"; import { createPortal } from "react-dom"; // --- Types --- export type ContextMenuAction = | "open" | "newFile" | "newFolder" | "rename" | "duplicate" | "copy" | "paste" | "moveTo" | "getInfo" | "delete"; export type ContextMenuItem = { action: ContextMenuAction; label: string; shortcut?: string; icon?: React.ReactNode; disabled?: boolean; danger?: boolean; separator?: false; } | { separator: true; }; export type ContextMenuTarget = | { kind: "file"; path: string; name: string; isSystem: boolean } | { kind: "folder"; path: string; name: string; isSystem: boolean } | { kind: "empty" }; // --- Menu item definitions per target kind --- function getMenuItems(target: ContextMenuTarget): ContextMenuItem[] { const isSystem = target.kind !== "empty" && target.isSystem; if (target.kind === "file") { return [ { action: "open", label: "Open" }, { separator: true }, { action: "rename", label: "Rename", shortcut: "Enter", disabled: isSystem }, { action: "duplicate", label: "Duplicate", shortcut: "\u2318D", disabled: isSystem }, { action: "copy", label: "Copy Path", shortcut: "\u2318C" }, { separator: true }, { action: "getInfo", label: "Get Info", shortcut: "\u2318I" }, { separator: true }, { action: "delete", label: "Move to Trash", shortcut: "\u2318\u232B", disabled: isSystem, danger: true }, ]; } if (target.kind === "folder") { return [ { action: "open", label: "Open" }, { separator: true }, { action: "newFile", label: "New File", shortcut: "\u2318N", disabled: isSystem }, { action: "newFolder", label: "New Folder", shortcut: "\u21E7\u2318N", disabled: isSystem }, { separator: true }, { action: "rename", label: "Rename", shortcut: "Enter", disabled: isSystem }, { action: "duplicate", label: "Duplicate", shortcut: "\u2318D", disabled: isSystem }, { action: "copy", label: "Copy Path", shortcut: "\u2318C" }, { separator: true }, { action: "getInfo", label: "Get Info", shortcut: "\u2318I" }, { separator: true }, { action: "delete", label: "Move to Trash", shortcut: "\u2318\u232B", disabled: isSystem, danger: true }, ]; } // Empty area return [ { action: "newFile", label: "New File", shortcut: "\u2318N" }, { action: "newFolder", label: "New Folder", shortcut: "\u21E7\u2318N" }, { separator: true }, { action: "paste", label: "Paste", shortcut: "\u2318V", disabled: true }, ]; } // --- Lock icon for system files --- function LockIcon() { return ( ); } // --- Context Menu Component --- type ContextMenuProps = { x: number; y: number; target: ContextMenuTarget; onAction: (action: ContextMenuAction) => void; onClose: () => void; }; export function ContextMenu({ x, y, target, onAction, onClose }: ContextMenuProps) { const menuRef = useRef(null); const items = getMenuItems(target); const isSystem = target.kind !== "empty" && target.isSystem; // Clamp position to viewport const clampedPos = useRef({ x, y }); useEffect(() => { const el = menuRef.current; if (!el) {return;} const rect = el.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; let cx = x; let cy = y; if (cx + rect.width > vw - 8) {cx = vw - rect.width - 8;} if (cy + rect.height > vh - 8) {cy = vh - rect.height - 8;} if (cx < 8) {cx = 8;} if (cy < 8) {cy = 8;} clampedPos.current = { x: cx, y: cy }; el.style.left = `${cx}px`; el.style.top = `${cy}px`; }, [x, y]); // Close on click-outside, escape, scroll useEffect(() => { function handleClickOutside(e: MouseEvent) { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { onClose(); } } function handleKeyDown(e: KeyboardEvent) { if (e.key === "Escape") {onClose();} } function handleScroll() { onClose(); } document.addEventListener("mousedown", handleClickOutside, true); document.addEventListener("keydown", handleKeyDown, true); window.addEventListener("scroll", handleScroll, true); return () => { document.removeEventListener("mousedown", handleClickOutside, true); document.removeEventListener("keydown", handleKeyDown, true); window.removeEventListener("scroll", handleScroll, true); }; }, [onClose]); const handleItemClick = useCallback( (action: ContextMenuAction, disabled?: boolean) => { if (disabled) {return;} onAction(action); onClose(); }, [onAction, onClose], ); return createPortal(
{/* System file badge */} {isSystem && (
System file (locked)
)} {items.map((item, i) => { if ("separator" in item && item.separator) { return (
); } const menuItem = item; const isDisabled = menuItem.disabled; return ( ); })} {/* Global animation style */}
, document.body, ); }