"use client"; import { useState, useCallback, useEffect, useRef } from "react"; import type { ContextMenuPosition } from "@/components/ui/context-menu"; interface ContextMenuState { isOpen: boolean; position: ContextMenuPosition; data: T | null; } export interface UseContextMenuReturn { contextMenu: ContextMenuState; /** * Open from a contextmenu or click event. Opened by keyboard (a click * from Enter/Space, or Shift+F10 / the Menu key) it anchors to the * element the handler sits on instead of the pointer. */ openContextMenu: (e: React.MouseEvent, data: T) => void; /** Open under an element, e.g. a ⋮ button: lined up with its start or end edge. */ openContextMenuFrom: (element: HTMLElement, data: T, align?: "start" | "end") => void; closeContextMenu: () => void; menuRef: React.RefObject; } const MENU_WIDTH = 200; const MENU_HEIGHT = 320; // Approximate max height const VIEWPORT_MARGIN = 10; /** Shift+F10 or the Menu key: open the focused element's context menu. */ export function isContextMenuKey(e: { key: string; shiftKey: boolean }): boolean { return e.key === "ContextMenu" || (e.shiftKey && e.key === "F10"); } /** Whether a mouse event came from the keyboard rather than a pointer. */ function openedByKeyboard(e: React.MouseEvent): boolean { if (e.type === "click") return e.detail === 0; // Shift+F10 and the Menu key fire contextmenu at 0,0 (or without a point). if (e.type === "contextmenu") return !e.clientX && !e.clientY; return false; } /** * Where the focus goes back to when the menu closes: the control inside the * opener that had it (a folder row's button, a message row's checkbox, where * Shift+F10 was pressed), else the opener itself. */ function focusReturnTarget(opener: HTMLElement): HTMLElement { const active = typeof document === "undefined" ? null : document.activeElement; return active instanceof HTMLElement && opener.contains(active) ? active : opener; } /** The position that opens a menu under an element. */ export function anchorPosition(element: HTMLElement, align: "start" | "end" = "start"): ContextMenuPosition { const rect = element.getBoundingClientRect(); return { x: rect.left, y: rect.bottom, anchor: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, align }, trigger: element, }; } export function useContextMenu(): UseContextMenuReturn { const [contextMenu, setContextMenu] = useState>({ isOpen: false, position: { x: 0, y: 0 }, data: null, }); const menuRef = useRef(null); const calculatePosition = useCallback((clientX: number, clientY: number): ContextMenuPosition => { const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let x = clientX; let y = clientY; // Adjust for right edge if (x + MENU_WIDTH > viewportWidth - VIEWPORT_MARGIN) { x = viewportWidth - MENU_WIDTH - VIEWPORT_MARGIN; } // Adjust for bottom edge if (y + MENU_HEIGHT > viewportHeight - VIEWPORT_MARGIN) { y = viewportHeight - MENU_HEIGHT - VIEWPORT_MARGIN; } // Ensure minimum position x = Math.max(VIEWPORT_MARGIN, x); y = Math.max(VIEWPORT_MARGIN, y); return { x, y }; }, []); const openContextMenu = useCallback((e: React.MouseEvent, data: T) => { e.preventDefault(); e.stopPropagation(); const target = e.currentTarget instanceof HTMLElement ? e.currentTarget : null; const trigger = target ? focusReturnTarget(target) : null; const position = target && openedByKeyboard(e) ? { ...anchorPosition(target), trigger } : { ...calculatePosition(e.clientX, e.clientY), trigger }; setContextMenu({ isOpen: true, position, data, }); }, [calculatePosition]); const openContextMenuFrom = useCallback((element: HTMLElement, data: T, align: "start" | "end" = "start") => { setContextMenu({ isOpen: true, position: anchorPosition(element, align), data }); }, []); const closeContextMenu = useCallback(() => { setContextMenu((prev) => ({ ...prev, isOpen: false, })); }, []); // Close on escape key, click outside, and scroll. (ContextMenu answers // Escape itself and keeps it from the app; this one is a fallback.) useEffect(() => { if (!contextMenu.isOpen) return; const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") { closeContextMenu(); } }; const handleClickOutside = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { closeContextMenu(); } }; const handleScroll = (e: Event) => { if (menuRef.current && e.target instanceof Node && menuRef.current.contains(e.target)) { return; } closeContextMenu(); }; const handleBlur = () => { // A focused control in the menu may have opened a system dialog that // took the focus from the window, such as a color input's (#1366). if (menuRef.current?.contains(document.activeElement)) return; closeContextMenu(); }; // Add listeners with a slight delay to prevent immediate closing const timeoutId = setTimeout(() => { document.addEventListener("keydown", handleEscape); document.addEventListener("mousedown", handleClickOutside); document.addEventListener("scroll", handleScroll, true); window.addEventListener("blur", handleBlur); }, 0); return () => { clearTimeout(timeoutId); document.removeEventListener("keydown", handleEscape); document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("scroll", handleScroll, true); window.removeEventListener("blur", handleBlur); }; }, [contextMenu.isOpen, closeContextMenu]); return { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef, }; }