|
|
|
|
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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, |
| ); |
|
|
| |
| const measure = useCallback(() => { |
| if (!spotlightElementId || !containerRef.current) { |
| setRect(null); |
| return; |
| } |
|
|
| const domElement = document.getElementById(`screen-element-${spotlightElementId}`); |
| if (!domElement) { |
| setRect(null); |
| return; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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(() => { |
| |
| 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. No backdrop-filter: combined with SVG <mask> |
| 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. */} |
| <rect |
| width="100" |
| height="100" |
| fill={`rgba(0,0,0,${dimness})`} |
| mask={`url(#mask-${spotlightElementId})`} |
| /> |
| |
| {/* 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> |
| ); |
| } |
| |