import React, { useLayoutEffect, useState, useRef } from 'react'; import { addScrollViewportListener, useScrollViewport, } from '../hooks/useScrollViewport'; interface PinpointOverlayProps { target: { element: HTMLElement; label: string } | null; containerRef: React.RefObject; } interface OverlayPosition { top: number; left: number; width: number; height: number; } export const PinpointOverlay: React.FC = ({ target, containerRef }) => { const [position, setPosition] = useState(null); const rafRef = useRef(0); const scrollViewport = useScrollViewport(); useLayoutEffect(() => { if (!target || !containerRef.current) { setPosition(null); return; } const updatePosition = () => { const containerRect = containerRef.current!.getBoundingClientRect(); const targetRect = target.element.getBoundingClientRect(); setPosition({ top: targetRect.top - containerRect.top, left: targetRect.left - containerRect.left, width: targetRect.width, height: targetRect.height, }); }; updatePosition(); const handleUpdate = () => { cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(updatePosition); }; // Window resize always matters, regardless of whether the scroll // viewport is ready yet. Register it unconditionally so the overlay // stays aligned during the brief window before OverlayScrollbars // delivers its viewport. window.addEventListener('resize', handleUpdate, { passive: true }); // The shared viewport is the nested main element on desktop and the page // scroller on compact touch layouts. The helper binds the latter to the // window scroll event so the overlay does not drift as Safari chrome moves. const removeScrollListener = scrollViewport ? addScrollViewportListener(scrollViewport, handleUpdate) : undefined; return () => { cancelAnimationFrame(rafRef.current); window.removeEventListener('resize', handleUpdate); removeScrollListener?.(); }; }, [target, containerRef, scrollViewport]); if (!position || !target) return null; return ( <> {/* Background wash */}
{/* Label badge */}
{target.label}
); };