176 lines
5.7 KiB
TypeScript
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;
|