Files
label-designer/src/components/AnchoredMenu.tsx
2026-06-13 03:41:20 +08:00

111 lines
2.9 KiB
TypeScript

import React, { useLayoutEffect, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
const MENU_GAP = 4;
const VIEWPORT_PAD = 8;
function useAnchoredMenuPosition(
open: boolean,
anchorRef: React.RefObject<HTMLElement | null>,
menuRef: React.RefObject<HTMLElement | null>
) {
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
useLayoutEffect(() => {
if (!open) {
setCoords(null);
return;
}
const update = () => {
const anchor = anchorRef.current;
const menu = menuRef.current;
if (!anchor || !menu) return;
const anchorRect = anchor.getBoundingClientRect();
const menuWidth = menu.offsetWidth;
const menuHeight = menu.offsetHeight;
let top = anchorRect.bottom + MENU_GAP;
if (top + menuHeight > window.innerHeight - VIEWPORT_PAD) {
top = anchorRect.top - menuHeight - MENU_GAP;
}
top = Math.max(
VIEWPORT_PAD,
Math.min(top, window.innerHeight - menuHeight - VIEWPORT_PAD)
);
let left = anchorRect.right - menuWidth;
left = Math.max(
VIEWPORT_PAD,
Math.min(left, window.innerWidth - menuWidth - VIEWPORT_PAD)
);
setCoords({ top, left });
};
update();
const raf = requestAnimationFrame(update);
window.addEventListener('resize', update);
window.addEventListener('scroll', update, true);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update, true);
};
}, [open, anchorRef, menuRef]);
return coords;
}
interface AnchoredMenuProps {
open: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLElement | null>;
children: React.ReactNode;
className?: string;
}
export const AnchoredMenu: React.FC<AnchoredMenuProps> = ({
open,
onClose,
anchorRef,
children,
className = '',
}) => {
const menuRef = useRef<HTMLDivElement | null>(null);
const coords = useAnchoredMenuPosition(open, anchorRef, menuRef);
useEffect(() => {
if (!open) return;
const handlePointerDown = (e: PointerEvent) => {
const target = e.target as Node;
if (menuRef.current?.contains(target) || anchorRef.current?.contains(target)) return;
onClose();
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [open, onClose, anchorRef]);
if (!open) return null;
return createPortal(
<div
ref={menuRef}
role="menu"
className={className}
style={{
position: 'fixed',
top: coords?.top ?? 0,
left: coords?.left ?? 0,
visibility: coords ? 'visible' : 'hidden',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>,
document.body
);
};