/** * RuntimeElement Component * * Renders a single UI element with interactive effects at runtime. * Handles hover, focus, active states and positioning. * Delegates element styling and content to UiElementRenderer. */ import React from 'react'; import UiElementRenderer from './UiElements/UiElementRenderer'; import { useElementEffects } from '../hooks/useElementEffects'; import { useAudioEffects } from '../hooks/useAudioEffects'; import { buildTransitionStyle, buildAppearAnimationStyle, hasAnyEffects, extractEffectProperties, } from '../lib/elementEffects'; import type { CanvasElement } from '../types/constructor'; import type { ResolvedTransitionSettings } from '../types/transition'; import type { PreloadCacheProvider } from '../hooks/video'; import { isInfoPanelElementType } from '../lib/elementDefaults'; interface RuntimeElementProps { element: CanvasElement; onClick: () => void; /** Whether runtime interaction should be ignored for this element */ isDisabled?: boolean; /** Optional URL resolver for preloaded blob URLs */ resolveUrl?: (url: string | undefined) => string; /** Gallery card click handler */ onGalleryCardClick?: (cardIndex: number) => void; /** Letterbox styles for constraining fullscreen elements to canvas bounds */ letterboxStyles?: React.CSSProperties; /** Page transition settings (for slide transition cascade in carousel/gallery) */ pageTransitionSettings?: ResolvedTransitionSettings; /** Preload cache provider for video elements */ preloadCache?: PreloadCacheProvider; /** Whether this element's info panel is currently open (for visibility persistence) */ isInfoPanelOpen?: boolean; } // Clamp position to canvas bounds (0-100%) const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); const RuntimeElement: React.FC = ({ element, onClick, isDisabled = false, resolveUrl, onGalleryCardClick, letterboxStyles, pageTransitionSettings, preloadCache, isInfoPanelOpen = false, }) => { // Clamp coordinates to canvas bounds const xPercent = clamp(element.xPercent ?? 50, 0, 100); const yPercent = clamp(element.yPercent ?? 50, 0, 100); const rotation = element.rotation ?? 0; // Extract effect properties from element using helper const effectProperties = extractEffectProperties( element as unknown as Record, ); // Use effects hook for interactive states // Pass forceVisible when info panel is open to keep trigger visible const { effectStyle, eventHandlers, onPersistClick, state: effectState, } = useElementEffects(isDisabled ? {} : effectProperties, { resetKey: element.id, // Reset reveal on element change forceVisible: isInfoPanelOpen, }); // Audio effects - uses exposed state from useElementEffects // resolveUrl prop resolves to preloaded blob URLs via RuntimePresentation useAudioEffects({ hoverAudioUrl: isDisabled ? undefined : effectProperties.hoverAudioUrl, clickAudioUrl: isDisabled ? undefined : effectProperties.clickAudioUrl, volume: parseFloat(effectProperties.audioVolume || '1'), isHovered: effectState.isHovered, isActive: effectState.isActive, resolveUrl, // Resolves to cached blob URLs (passed from RuntimePresentation) resetKey: element.id, }); // Combined click handler // Skip toggle for info panel elements (their visibility is tied to panel open state) const handleClick = () => { if (isDisabled) { return; } if (!isInfoPanelElementType(element.type)) { onPersistClick(); // Toggle persistence state } onClick(); // Original navigation action }; const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); handleClick(); } }; // Build base position style (outer div - handles positioning + animation) let positionStyle: React.CSSProperties = { left: `${xPercent}%`, top: `${yPercent}%`, transform: `translate(-50%, -50%)${rotation ? ` rotate(${rotation}deg)` : ''}`, pointerEvents: 'auto', }; // Add appear animation to outer div // Animation stays on outer div to keep positioning hack working if (effectProperties.appearAnimation) { positionStyle = { ...positionStyle, ...buildAppearAnimationStyle(effectProperties), }; } // Build inner wrapper style for hover/focus/active effects // Using separate div so animation on outer div doesn't block these effects let innerEffectStyle: React.CSSProperties = {}; if (hasAnyEffects(effectProperties)) { innerEffectStyle = { ...effectStyle, ...buildTransitionStyle(effectProperties), }; } // Render content const content = ( ); // Check if we need the inner wrapper for effects const needsEffectWrapper = !isDisabled && hasAnyEffects(effectProperties); return (
{needsEffectWrapper ? ( // Inner wrapper handles hover/focus/active effects independently from animation
{content}
) : ( content )}
); }; export default RuntimeElement;