SpotlightOverlay.tsx 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. 'use client';
  2. import { useRef, useState, useLayoutEffect, useCallback } from 'react';
  3. import { motion, AnimatePresence } from 'motion/react';
  4. import { useSceneSelector } from '@/lib/contexts/scene-context';
  5. import { useCanvasStore } from '@/lib/store/canvas';
  6. import type { SlideContent } from '@/lib/types/stage';
  7. import type { PPTElement } from '@/lib/types/slides';
  8. interface SpotlightRect {
  9. x: number;
  10. y: number;
  11. w: number;
  12. h: number;
  13. }
  14. /**
  15. * Spotlight overlay component
  16. *
  17. * Uses DOM measurement (getBoundingClientRect) to compute spotlight position,
  18. * avoiding alignment offsets from percentage coordinate conversion.
  19. */
  20. export function SpotlightOverlay() {
  21. const spotlightElementId = useCanvasStore.use.spotlightElementId();
  22. const spotlightOptions = useCanvasStore.use.spotlightOptions();
  23. const containerRef = useRef<HTMLDivElement>(null);
  24. const [rect, setRect] = useState<SpotlightRect | null>(null);
  25. const elements = useSceneSelector<SlideContent, PPTElement[]>(
  26. (content) => content.canvas.elements,
  27. );
  28. // Compute target element position in SVG coordinate system via DOM measurement
  29. const measure = useCallback(() => {
  30. if (!spotlightElementId || !containerRef.current) {
  31. setRect(null);
  32. return;
  33. }
  34. const domElement = document.getElementById(`screen-element-${spotlightElementId}`);
  35. if (!domElement) {
  36. setRect(null);
  37. return;
  38. }
  39. // Prefer measuring .element-content (the actual rendered area for auto-height)
  40. const contentEl = domElement.querySelector('.element-content');
  41. const targetEl = contentEl ?? domElement;
  42. const containerRect = containerRef.current.getBoundingClientRect();
  43. const targetRect = targetEl.getBoundingClientRect();
  44. if (containerRect.width === 0 || containerRect.height === 0) {
  45. setRect(null);
  46. return;
  47. }
  48. // Convert to SVG viewBox 0-100 coordinates
  49. setRect({
  50. x: ((targetRect.left - containerRect.left) / containerRect.width) * 100,
  51. y: ((targetRect.top - containerRect.top) / containerRect.height) * 100,
  52. w: (targetRect.width / containerRect.width) * 100,
  53. h: (targetRect.height / containerRect.height) * 100,
  54. });
  55. }, [spotlightElementId]);
  56. useLayoutEffect(() => {
  57. // eslint-disable-next-line react-hooks/set-state-in-effect -- DOM measurement requires effect
  58. measure();
  59. }, [measure, elements]);
  60. const active = !!spotlightElementId && !!spotlightOptions && !!rect;
  61. const dimness = spotlightOptions?.dimness ?? 0.7;
  62. return (
  63. <div
  64. ref={containerRef}
  65. className="absolute inset-0 z-[100] pointer-events-none overflow-hidden"
  66. >
  67. <AnimatePresence mode="wait">
  68. {active && rect && (
  69. <motion.div
  70. key={`spotlight-${spotlightElementId}`}
  71. initial={{ opacity: 0 }}
  72. animate={{ opacity: 1 }}
  73. exit={{ opacity: 0 }}
  74. className="absolute inset-0"
  75. >
  76. <svg
  77. width="100%"
  78. height="100%"
  79. viewBox="0 0 100 100"
  80. preserveAspectRatio="none"
  81. className="absolute inset-0"
  82. >
  83. <defs>
  84. <mask id={`mask-${spotlightElementId}`}>
  85. {/* White background = show mask layer (dimmed) */}
  86. <rect x="0" y="0" width="100" height="100" fill="white" />
  87. {/* Black rectangle = hide mask layer (highlighted area / cutout) */}
  88. <motion.rect
  89. fill="black"
  90. initial={{
  91. x: rect.x - 8,
  92. y: rect.y - 8,
  93. width: rect.w + 16,
  94. height: rect.h + 16,
  95. rx: 4,
  96. }}
  97. animate={{
  98. x: rect.x - 0.4,
  99. y: rect.y - 0.6,
  100. width: rect.w + 0.8,
  101. height: rect.h + 1.2,
  102. rx: 1,
  103. }}
  104. transition={{
  105. duration: 0.6,
  106. ease: [0.16, 1, 0.3, 1],
  107. }}
  108. />
  109. </mask>
  110. </defs>
  111. {/* Dimmed Background */}
  112. <rect
  113. width="100"
  114. height="100"
  115. fill={`rgba(0,0,0,${dimness})`}
  116. mask={`url(#mask-${spotlightElementId})`}
  117. className="backdrop-blur-[1.5px]"
  118. />
  119. {/* THE ONE BORDER - white border */}
  120. <motion.rect
  121. initial={{
  122. x: rect.x - 4,
  123. y: rect.y - 4,
  124. width: rect.w + 8,
  125. height: rect.h + 8,
  126. opacity: 0,
  127. rx: 2,
  128. }}
  129. animate={{
  130. x: rect.x - 0.4,
  131. y: rect.y - 0.6,
  132. width: rect.w + 0.8,
  133. height: rect.h + 1.2,
  134. opacity: 1,
  135. rx: 1,
  136. }}
  137. fill="none"
  138. stroke="rgba(255,255,255,0.7)"
  139. strokeWidth="1.2"
  140. style={{ vectorEffect: 'non-scaling-stroke' } as React.CSSProperties}
  141. transition={{
  142. duration: 0.5,
  143. delay: 0.05,
  144. ease: [0.16, 1, 0.3, 1],
  145. }}
  146. />
  147. </svg>
  148. </motion.div>
  149. )}
  150. </AnimatePresence>
  151. </div>
  152. );
  153. }