39948-vm/frontend/src/components/RuntimeElement.tsx

176 lines
5.7 KiB
TypeScript

/**
* 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<RuntimeElementProps> = ({
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<string, unknown>,
);
// 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 = (
<UiElementRenderer
element={element}
resolveUrl={resolveUrl}
onGalleryCardClick={onGalleryCardClick}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/>
);
// Check if we need the inner wrapper for effects
const needsEffectWrapper = !isDisabled && hasAnyEffects(effectProperties);
return (
<div
className='absolute cursor-pointer'
style={positionStyle}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={isDisabled ? -1 : 0}
aria-disabled={isDisabled}
{...(!isDisabled && !needsEffectWrapper ? eventHandlers : {})}
>
{needsEffectWrapper ? (
// Inner wrapper handles hover/focus/active effects independently from animation
<div style={innerEffectStyle} {...eventHandlers}>
{content}
</div>
) : (
content
)}
</div>
);
};
export default RuntimeElement;