Spaces:
No application file
No application file
| 'use client'; | |
| 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<HTMLDivElement>(null); | |
| const [rect, setRect] = useState<SpotlightRect | null>(null); | |
| const elements = useSceneSelector<SlideContent, PPTElement[]>( | |
| (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 ( | |
| <div | |
| ref={containerRef} | |
| className="absolute inset-0 z-[100] pointer-events-none overflow-hidden" | |
| > | |
| <AnimatePresence mode="wait"> | |
| {active && rect && ( | |
| <motion.div | |
| key={`spotlight-${spotlightElementId}`} | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| className="absolute inset-0" | |
| > | |
| <svg | |
| width="100%" | |
| height="100%" | |
| viewBox="0 0 100 100" | |
| preserveAspectRatio="none" | |
| className="absolute inset-0" | |
| > | |
| <defs> | |
| <mask id={`mask-${spotlightElementId}`}> | |
| {/* White background = show mask layer (dimmed) */} | |
| <rect x="0" y="0" width="100" height="100" fill="white" /> | |
| {/* Black rectangle = hide mask layer (highlighted area / cutout) */} | |
| <motion.rect | |
| fill="black" | |
| initial={{ | |
| x: rect.x - 8, | |
| y: rect.y - 8, | |
| width: rect.w + 16, | |
| height: rect.h + 16, | |
| rx: 4, | |
| }} | |
| animate={{ | |
| x: rect.x - 0.4, | |
| y: rect.y - 0.6, | |
| width: rect.w + 0.8, | |
| height: rect.h + 1.2, | |
| rx: 1, | |
| }} | |
| transition={{ | |
| duration: 0.6, | |
| ease: [0.16, 1, 0.3, 1], | |
| }} | |
| /> | |
| </mask> | |
| </defs> | |
| {/* Dimmed Background */} | |
| <rect | |
| width="100" | |
| height="100" | |
| fill={`rgba(0,0,0,${dimness})`} | |
| mask={`url(#mask-${spotlightElementId})`} | |
| className="backdrop-blur-[1.5px]" | |
| /> | |
| {/* THE ONE BORDER - white border */} | |
| <motion.rect | |
| initial={{ | |
| x: rect.x - 4, | |
| y: rect.y - 4, | |
| width: rect.w + 8, | |
| height: rect.h + 8, | |
| opacity: 0, | |
| rx: 2, | |
| }} | |
| animate={{ | |
| x: rect.x - 0.4, | |
| y: rect.y - 0.6, | |
| width: rect.w + 0.8, | |
| height: rect.h + 1.2, | |
| opacity: 1, | |
| rx: 1, | |
| }} | |
| fill="none" | |
| stroke="rgba(255,255,255,0.7)" | |
| strokeWidth="1.2" | |
| style={{ vectorEffect: 'non-scaling-stroke' } as React.CSSProperties} | |
| transition={{ | |
| duration: 0.5, | |
| delay: 0.05, | |
| ease: [0.16, 1, 0.3, 1], | |
| }} | |
| /> | |
| </svg> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| ); | |
| } | |