thu-maic--openmaic
183 行
6.2 KiB
TypeScript
183 行
6.2 KiB
TypeScript
'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 '@openmaic/dsl';
|
|
|
|
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.
|
|
*/
|
|
interface SpotlightOverlayProps {
|
|
/**
|
|
* DOM id prefix used to locate the target element. Playback screens render
|
|
* elements as `screen-element-<id>` (default); the editor canvas renders
|
|
* them as `editable-element-<id>` and passes that prefix so the exact same
|
|
* spotlight effect plays in Pro mode.
|
|
*/
|
|
domIdPrefix?: string;
|
|
}
|
|
|
|
export function SpotlightOverlay({ domIdPrefix = 'screen-element-' }: SpotlightOverlayProps = {}) {
|
|
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(`${domIdPrefix}${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, domIdPrefix]);
|
|
|
|
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. 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>
|
|
);
|
|
}
|