( options?: UseContextMenuPositionOptions )
| 39 | * AgentListItem (draft + regular) and ChatPane's transcript right-click menu. |
| 40 | */ |
| 41 | export function useContextMenuPosition( |
| 42 | options?: UseContextMenuPositionOptions |
| 43 | ): UseContextMenuPositionReturn { |
| 44 | const [isOpen, setIsOpen] = useState(false); |
| 45 | const [position, setPosition] = useState<ContextMenuPosition | null>(null); |
| 46 | |
| 47 | // Long-press refs (only used when longPress option is enabled) |
| 48 | const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 49 | const touchStartPosRef = useRef<ContextMenuPosition | null>(null); |
| 50 | const longPressTriggeredRef = useRef(false); |
| 51 | |
| 52 | // When opening at cursor coordinates, render once with the anchor mounted first, |
| 53 | // then flip open in a layout effect. This avoids a one-frame "flash" at an |
| 54 | // incorrect fallback anchor before Popover finishes resolving the new anchor. |
| 55 | const pendingPositionOpenRef = useRef(false); |
| 56 | // Keep canOpen guard fresh so delayed callbacks (like long-press timers) |
| 57 | // use the latest availability instead of stale render-time closures. |
| 58 | const canOpenRef = useRef(options?.canOpen); |
| 59 | canOpenRef.current = options?.canOpen; |
| 60 | const canOpenMenu = useCallback(() => { |
| 61 | const guard = canOpenRef.current; |
| 62 | return guard ? guard() : true; |
| 63 | }, []); |
| 64 | |
| 65 | // Clean up timer on unmount |
| 66 | useEffect(() => { |
| 67 | return () => { |
| 68 | if (longPressTimerRef.current) { |
| 69 | clearTimeout(longPressTimerRef.current); |
| 70 | } |
| 71 | }; |
| 72 | }, []); |
| 73 | |
| 74 | const openAtPosition = useCallback((nextPosition: ContextMenuPosition) => { |
| 75 | pendingPositionOpenRef.current = true; |
| 76 | setIsOpen(false); |
| 77 | setPosition(nextPosition); |
| 78 | }, []); |
| 79 | |
| 80 | const close = useCallback(() => { |
| 81 | pendingPositionOpenRef.current = false; |
| 82 | setIsOpen(false); |
| 83 | setPosition(null); |
| 84 | }, []); |
| 85 | |
| 86 | const getContextMenuPositionFromEvent = useCallback( |
| 87 | (e: React.MouseEvent): ContextMenuPosition => { |
| 88 | // Keyboard-triggered activations (click/contextmenu) can report client |
| 89 | // coordinates as 0,0. Anchor those opens to the trigger element so menus |
| 90 | // appear next to the control. |
| 91 | const isKeyboardActivation = |
| 92 | (e.type === "click" && e.detail === 0) || (e.clientX === 0 && e.clientY === 0); |
| 93 | |
| 94 | if (!isKeyboardActivation) { |
| 95 | return { x: e.clientX, y: e.clientY }; |
| 96 | } |
| 97 | |
| 98 | const rect = e.currentTarget.getBoundingClientRect(); |
no test coverage detected