'use client'; import { useEffect, useState } from 'react'; import { resolveRefs, type RefResolver } from './resolveRef'; import type { HighlightTarget, PointDirective } from './types'; import { waitForVisible } from './waitForVisible'; /** Is a rect fully inside the viewport. */ function isOnScreen(rect: DOMRect): boolean { return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= window.innerHeight && rect.right <= window.innerWidth ); } /** * Resolve `point` directives into live, geometry-tracked highlight * targets. * * - resolves each ref to a DOM element (drops stale ones); * - scrolls an off-screen target into view and waits until it is * actually visible before exposing it — so the spotlight is drawn at * the element's settled position, never mid-scroll; * - measures rects and keeps them fresh on scroll / resize; * - moves focus into a target when the directive asked for it; * - drops a target automatically if its element leaves the DOM. * * One effect owns the whole lifecycle — resolve, side-effects, and the * geometry subscription — so there is no ordering ambiguity between * separate effects. */ export function useHighlightTargets( directives: PointDirective[], resolver: RefResolver | null, ): HighlightTarget[] { const [targets, setTargets] = useState([]); useEffect(() => { // Resolve every directive that wants a highlight to a live element. const wanted = directives.filter((d) => d.highlight !== false); const resolved = resolveRefs( wanted.map((d) => d.ref), resolver, ); const pairs = resolved .map(({ ref, element }) => { const directive = wanted.find((d) => d.ref === ref); return directive ? { element, directive } : null; }) .filter( (p): p is { element: HTMLElement; directive: PointDirective } => p !== null, ); if (pairs.length === 0) { setTargets([]); return; } let cancelled = false; // A pair is only drawn once `ready` is true. Already-visible // targets are ready immediately; off-screen ones become ready // after their scroll-into-view settles (or its safety timeout). const ready = new WeakSet(); // Measure all *ready* targets — called now and on every // scroll / resize. Off-screen-but-not-yet-settled targets are // skipped so their spotlight is never drawn at a stale position. const measure = () => { const next: HighlightTarget[] = []; for (const { element, directive } of pairs) { if (!element.isConnected) continue; // element left the DOM if (!ready.has(element)) continue; // still scrolling into view next.push({ element, rect: element.getBoundingClientRect(), label: directive.label, focus: directive.focus ?? false, }); } setTargets(next); }; let frame = 0; const schedule = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(measure); }; // Split targets into the ones already on screen and the ones that // need to be scrolled into view first. const offScreen: HTMLElement[] = []; for (const { element } of pairs) { if (isOnScreen(element.getBoundingClientRect())) { ready.add(element); } else { offScreen.push(element); } } // Scroll each off-screen target into view, then wait until it is // actually visible before marking it ready and re-measuring. for (const element of offScreen) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); void waitForVisible(element).then(() => { if (cancelled || !element.isConnected) return; ready.add(element); schedule(); }); } // Focus a target if requested — after its scroll-into-view settles // so focus() does not fight the smooth scroll. const focusPair = pairs.find((p) => p.directive.focus); if (focusPair) { const el = focusPair.element; const applyFocus = () => { if (cancelled || !el.isConnected) return; // Make a non-focusable element focusable so focus() lands. const nativelyFocusable = [ 'INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A', ].includes(el.tagName); if (el.tabIndex < 0 && !nativelyFocusable) el.tabIndex = -1; el.focus({ preventScroll: true }); }; if (isOnScreen(el.getBoundingClientRect())) { applyFocus(); } else { void waitForVisible(el).then(applyFocus); } } // Initial measure (draws any already-visible targets) + keep // geometry fresh. measure(); window.addEventListener('scroll', schedule, true); window.addEventListener('resize', schedule); const observer = new ResizeObserver(schedule); for (const { element } of pairs) observer.observe(element); return () => { cancelled = true; cancelAnimationFrame(frame); window.removeEventListener('scroll', schedule, true); window.removeEventListener('resize', schedule); observer.disconnect(); }; }, [directives, resolver]); return targets; }