import { useRef, useState, useLayoutEffect, useCallback } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { useSceneSelector } from '@/lib/contexts/scene-context'; import { useCanvasStore } from '@/lib/store/canvas'; import type { SlideContent } from '@/lib/types/stage'; import type { PPTElement } from '@/lib/types/slides'; interface SpotlightRect { x: number; y: number; w: number; h: number; } /** * Spotlight overlay component * * Uses DOM measurement (getBoundingClientRect) to compute spotlight position, * avoiding alignment offsets from percentage coordinate conversion. */ export function SpotlightOverlay() { const spotlightElementId = useCanvasStore.use.spotlightElementId(); const spotlightOptions = useCanvasStore.use.spotlightOptions(); const containerRef = useRef(null); const [rect, setRect] = useState(null); const elements = useSceneSelector( (content) => content.canvas.elements, ); // Compute target element position in SVG coordinate system via DOM measurement const measure = useCallback(() => { if (!spotlightElementId || !containerRef.current) { setRect(null); return; } const domElement = document.getElementById(`screen-element-${spotlightElementId}`); if (!domElement) { setRect(null); return; } // Prefer measuring .element-content (the actual rendered area for auto-height) const contentEl = domElement.querySelector('.element-content'); const targetEl = contentEl ?? domElement; const containerRect = containerRef.current.getBoundingClientRect(); const targetRect = targetEl.getBoundingClientRect(); if (containerRect.width === 0 || containerRect.height === 0) { setRect(null); return; } // Convert to SVG viewBox 0-100 coordinates setRect({ x: ((targetRect.left - containerRect.left) / containerRect.width) * 100, y: ((targetRect.top - containerRect.top) / containerRect.height) * 100, w: (targetRect.width / containerRect.width) * 100, h: (targetRect.height / containerRect.height) * 100, }); }, [spotlightElementId]); useLayoutEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- DOM measurement requires effect measure(); }, [measure, elements]); const active = !!spotlightElementId && !!spotlightOptions && !!rect; const dimness = spotlightOptions?.dimness ?? 0.7; return (
{active && rect && ( {/* White background = show mask layer (dimmed) */} {/* Black rectangle = hide mask layer (highlighted area / cutout) */} {/* Dimmed Background. No backdrop-filter: combined with SVG it breaks compositing (backdrop bypasses the mask cutout) in some browsers, leaving the focused area dimmed despite the cutout. Tailwind 3 silently dropped `backdrop-blur-[1.5px]` on SVG via --tw-* variables; Tailwind 4 emits the property directly and surfaced the bug. */} {/* THE ONE BORDER - white border */} )}
); }