diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx index 38417183..3f4c0d6b 100644 --- a/components/ui/context-menu.tsx +++ b/components/ui/context-menu.tsx @@ -1,6 +1,6 @@ "use client"; -import { forwardRef, useState, useRef, useEffect } from "react"; +import { forwardRef, useState, useRef, useEffect, useLayoutEffect } from "react"; import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; import { ChevronRight } from "lucide-react"; @@ -17,26 +17,72 @@ interface ContextMenuProps { children: React.ReactNode; } +const VIEWPORT_MARGIN = 10; + export const ContextMenu = forwardRef( ({ isOpen, position, onClose: _onClose, children }, ref) => { const [mounted, setMounted] = useState(false); + const [adjustedPosition, setAdjustedPosition] = useState(null); + const localRef = useRef(null); useEffect(() => { setMounted(true); }, []); + // Measure the rendered menu and clamp it inside the viewport before the + // browser paints. We hide the element until this runs so the user never + // sees the menu jump from an unclamped position to a clamped one. + useLayoutEffect(() => { + if (!isOpen) { + setAdjustedPosition(null); + return; + } + const node = localRef.current; + if (!node) return; + + const rect = node.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + + let x = position.x; + let y = position.y; + + if (x + rect.width > vw - VIEWPORT_MARGIN) { + x = vw - rect.width - VIEWPORT_MARGIN; + } + if (y + rect.height > vh - VIEWPORT_MARGIN) { + y = vh - rect.height - VIEWPORT_MARGIN; + } + x = Math.max(VIEWPORT_MARGIN, x); + y = Math.max(VIEWPORT_MARGIN, y); + + setAdjustedPosition({ x, y }); + }, [isOpen, position.x, position.y]); + + const setRefs = (node: HTMLDivElement | null) => { + localRef.current = node; + if (typeof ref === "function") { + ref(node); + } else if (ref) { + ref.current = node; + } + }; + if (!mounted || !isOpen) return null; + const renderPosition = adjustedPosition ?? position; + const isPositioned = adjustedPosition !== null; + return createPortal(
{ } const MENU_WIDTH = 200; -const MENU_HEIGHT = 320; // Initial estimate; refined after mount via layout effect +const MENU_HEIGHT = 320; // Approximate max height const VIEWPORT_MARGIN = 10; export function useContextMenu(): UseContextMenuReturn { @@ -57,32 +57,6 @@ export function useContextMenu(): UseContextMenuReturn { return { x, y }; }, []); - // Re-clamp position once we can measure the actual rendered menu — the - // initial estimate uses a fixed height which can be too small for menus - // with many items, causing the bottom to be clipped off-screen. - useLayoutEffect(() => { - if (!contextMenu.isOpen || !menuRef.current) return; - const rect = menuRef.current.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - let x = contextMenu.position.x; - let y = contextMenu.position.y; - - if (x + rect.width > viewportWidth - VIEWPORT_MARGIN) { - x = viewportWidth - rect.width - VIEWPORT_MARGIN; - } - if (y + rect.height > viewportHeight - VIEWPORT_MARGIN) { - y = viewportHeight - rect.height - VIEWPORT_MARGIN; - } - x = Math.max(VIEWPORT_MARGIN, x); - y = Math.max(VIEWPORT_MARGIN, y); - - if (x !== contextMenu.position.x || y !== contextMenu.position.y) { - setContextMenu((prev) => ({ ...prev, position: { x, y } })); - } - }, [contextMenu.isOpen, contextMenu.position.x, contextMenu.position.y]); - const openContextMenu = useCallback((e: React.MouseEvent, data: T) => { e.preventDefault(); e.stopPropagation();