created shared components for UI elements for constructor and presentations
This commit is contained in:
parent
b66cf94fb4
commit
7b21006086
@ -70,9 +70,13 @@ frontend/src/
|
||||
│
|
||||
├── components/ # React components (PascalCase)
|
||||
│ ├── Assets/ # Asset management components
|
||||
│ ├── Constructor/ # Tour builder components
|
||||
│ ├── Constructor/ # Tour builder components (CanvasElement, etc.)
|
||||
│ ├── ElementSettings/ # Shared element settings form components
|
||||
│ ├── UiElements/ # Element type components
|
||||
│ ├── UiElements/ # Unified element rendering (WYSIWYG consistency)
|
||||
│ │ ├── UiElementRenderer.tsx # Main entry point (used by both constructor & runtime)
|
||||
│ │ ├── shared/ # Shared hooks (useElementWrapperStyle)
|
||||
│ │ └── elements/ # Per-type components (NavigationElement, GalleryElement, etc.)
|
||||
│ ├── RuntimeElement.tsx # Runtime element wrapper (position, effects)
|
||||
│ ├── Generic/ # Generic CRUD components
|
||||
│ ├── CardBox.tsx # Card container
|
||||
│ ├── NavBar.tsx # Top navigation
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -2,19 +2,11 @@
|
||||
* CanvasElement Component
|
||||
*
|
||||
* Individual element rendered on the constructor canvas.
|
||||
* Handles positioning, styling, and interaction modes.
|
||||
* Handles positioning and interaction, delegates styling to UiElementRenderer.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ElementContentRenderer from '../UiElements/ElementContentRenderer';
|
||||
import { buildElementStyle } from '../../lib/elementStyles';
|
||||
import {
|
||||
isTooltipElementType,
|
||||
isDescriptionElementType,
|
||||
isNavigationElementType,
|
||||
isGalleryElementType,
|
||||
isCarouselElementType,
|
||||
} from '../../lib/elementDefaults';
|
||||
import UiElementRenderer from '../UiElements/UiElementRenderer';
|
||||
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
|
||||
|
||||
interface CanvasElementProps {
|
||||
@ -37,58 +29,31 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
||||
onMouseDown,
|
||||
resolveUrl,
|
||||
}) => {
|
||||
const hasIconDrivenSize =
|
||||
Boolean(element.iconUrl) &&
|
||||
(isTooltipElementType(element.type) ||
|
||||
isDescriptionElementType(element.type) ||
|
||||
isNavigationElementType(element.type));
|
||||
|
||||
const isNavigationIconElement =
|
||||
Boolean(element.iconUrl) && isNavigationElementType(element.type);
|
||||
|
||||
const hasTransparentBackground =
|
||||
(isDescriptionElementType(element.type) &&
|
||||
!element.iconUrl &&
|
||||
(!element.descriptionBackgroundColor ||
|
||||
element.descriptionBackgroundColor === 'transparent')) ||
|
||||
(isNavigationElementType(element.type) && Boolean(element.iconUrl)) ||
|
||||
isTooltipElementType(element.type) ||
|
||||
isGalleryElementType(element.type) ||
|
||||
isCarouselElementType(element.type);
|
||||
|
||||
const elementStyle = buildElementStyle(element);
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
data-constructor-element-id={element.id}
|
||||
className={`absolute rounded text-xs font-semibold text-left ${
|
||||
hasTransparentBackground ? '' : 'border shadow'
|
||||
} ${
|
||||
hasIconDrivenSize ? 'overflow-hidden p-0 leading-none' : 'px-3 py-2'
|
||||
} ${isNavigationIconElement ? 'flex items-center justify-center' : ''} ${
|
||||
isEditMode
|
||||
? 'cursor-move'
|
||||
: isDisabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer'
|
||||
} ${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-50 border shadow'
|
||||
: hasTransparentBackground
|
||||
? 'bg-transparent'
|
||||
: 'border-blue-200 bg-white/95'
|
||||
}`}
|
||||
className='absolute'
|
||||
style={{
|
||||
...elementStyle,
|
||||
left: `${element.xPercent}%`,
|
||||
top: `${element.yPercent}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
// Reset button defaults to let UiElementRenderer control styling
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
}}
|
||||
onMouseDown={isEditMode ? onMouseDown : undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ElementContentRenderer element={element} resolveUrl={resolveUrl} />
|
||||
<UiElementRenderer
|
||||
element={element}
|
||||
resolveUrl={resolveUrl}
|
||||
isSelected={isSelected}
|
||||
isEditMode={isEditMode}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,12 +2,13 @@
|
||||
* RuntimeElement Component
|
||||
*
|
||||
* Renders a single UI element with interactive effects at runtime.
|
||||
* Handles hover, focus, and active states for element effects.
|
||||
* 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 { buildElementStyle } from '../lib/elementStyles';
|
||||
import {
|
||||
buildTransitionStyle,
|
||||
buildAppearAnimationStyle,
|
||||
@ -18,13 +19,14 @@ import {
|
||||
interface RuntimeElementProps {
|
||||
element: any;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
/** Optional URL resolver for preloaded blob URLs */
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
}
|
||||
|
||||
const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
||||
element,
|
||||
onClick,
|
||||
children,
|
||||
resolveUrl,
|
||||
}) => {
|
||||
const xPercent = element.xPercent ?? 0;
|
||||
const yPercent = element.yPercent ?? 0;
|
||||
@ -53,49 +55,45 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
||||
// Use effects hook for interactive states
|
||||
const { effectStyle, eventHandlers } = useElementEffects(effectProperties);
|
||||
|
||||
// Build base element style
|
||||
const baseStyle: React.CSSProperties = {
|
||||
// Build base position style
|
||||
let positionStyle: React.CSSProperties = {
|
||||
left: `${xPercent}%`,
|
||||
top: `${yPercent}%`,
|
||||
transform: `translate(-50%, -50%)${rotation ? ` rotate(${rotation}deg)` : ''}`,
|
||||
...buildElementStyle(element),
|
||||
};
|
||||
|
||||
// Merge transform if effect style has transform
|
||||
let mergedStyle: React.CSSProperties = { ...baseStyle };
|
||||
|
||||
// Handle transform merging - effect transform overrides base (except for position)
|
||||
if (effectStyle.transform) {
|
||||
// Preserve the translate and rotation, add effect transform
|
||||
mergedStyle.transform = `translate(-50%, -50%)${rotation ? ` rotate(${rotation}deg)` : ''} ${effectStyle.transform}`;
|
||||
positionStyle.transform = `translate(-50%, -50%)${rotation ? ` rotate(${rotation}deg)` : ''} ${effectStyle.transform}`;
|
||||
// Remove transform from effectStyle to avoid double application
|
||||
const { transform, ...restEffectStyle } = effectStyle;
|
||||
mergedStyle = { ...mergedStyle, ...restEffectStyle };
|
||||
positionStyle = { ...positionStyle, ...restEffectStyle };
|
||||
} else {
|
||||
mergedStyle = { ...mergedStyle, ...effectStyle };
|
||||
positionStyle = { ...positionStyle, ...effectStyle };
|
||||
}
|
||||
|
||||
// Add transition if element has any effects
|
||||
if (hasAnyEffects(effectProperties)) {
|
||||
const transitionStyle = buildTransitionStyle(effectProperties);
|
||||
mergedStyle = { ...mergedStyle, ...transitionStyle };
|
||||
positionStyle = { ...positionStyle, ...transitionStyle };
|
||||
}
|
||||
|
||||
// Add appear animation if configured
|
||||
if (effectProperties.appearAnimation) {
|
||||
const animationStyle = buildAppearAnimationStyle(effectProperties);
|
||||
mergedStyle = { ...mergedStyle, ...animationStyle };
|
||||
positionStyle = { ...positionStyle, ...animationStyle };
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='absolute cursor-pointer'
|
||||
style={mergedStyle}
|
||||
style={positionStyle}
|
||||
onClick={onClick}
|
||||
tabIndex={0}
|
||||
{...eventHandlers}
|
||||
>
|
||||
{children}
|
||||
<UiElementRenderer element={element} resolveUrl={resolveUrl} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -20,7 +20,6 @@ import BaseButton from './BaseButton';
|
||||
import CardBox from './CardBox';
|
||||
import { OfflineToggle } from './Offline/OfflineToggle';
|
||||
import RuntimeElement from './RuntimeElement';
|
||||
import { ElementContentRenderer } from './UiElements/ElementContentRenderer';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||
@ -347,12 +346,6 @@ export default function RuntimePresentation({
|
||||
[preloadOrchestrator],
|
||||
);
|
||||
|
||||
// Render element content based on type
|
||||
// Use shared ElementContentRenderer for WYSIWYG consistency with constructor
|
||||
const renderElementContent = (element: any) => (
|
||||
<ElementContentRenderer element={element} resolveUrl={resolveUrlWithBlob} />
|
||||
);
|
||||
|
||||
// Use resolved URLs from shared hook (blob URLs if cached, otherwise original URLs)
|
||||
// Blob URLs render instantly since data is local in memory
|
||||
const backgroundImageUrl = pageSwitch.currentBgImageUrl;
|
||||
@ -470,9 +463,8 @@ export default function RuntimePresentation({
|
||||
key={element.id}
|
||||
element={element}
|
||||
onClick={() => handleElementClick(element)}
|
||||
>
|
||||
{renderElementContent(element)}
|
||||
</RuntimeElement>
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,314 +0,0 @@
|
||||
/**
|
||||
* ElementContentRenderer
|
||||
*
|
||||
* Single source of truth for rendering UI element content.
|
||||
* Used by both constructor (editor) and runtime (presentation) to ensure
|
||||
* WYSIWYG consistency - what you see in the editor is exactly what appears in production.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type {
|
||||
CanvasElement,
|
||||
GalleryCard,
|
||||
CarouselSlide,
|
||||
} from '../../types/constructor';
|
||||
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
|
||||
import {
|
||||
isNavigationElementType,
|
||||
isTooltipElementType,
|
||||
isDescriptionElementType,
|
||||
isVideoPlayerElementType,
|
||||
isAudioPlayerElementType,
|
||||
isGalleryElementType,
|
||||
isCarouselElementType,
|
||||
isLogoElementType,
|
||||
isSpotElementType,
|
||||
isPopupElementType,
|
||||
} from '../../lib/elementDefaults';
|
||||
|
||||
export interface ElementContentRendererProps {
|
||||
element: CanvasElement;
|
||||
/** Optional URL resolver - use for preloaded blob URLs */
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the inner content of a UI element.
|
||||
* This component handles all element types and renders them consistently
|
||||
* across constructor and runtime contexts.
|
||||
*/
|
||||
export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
|
||||
element,
|
||||
resolveUrl,
|
||||
}) => {
|
||||
// Use custom resolver if provided, otherwise fallback to standard resolution
|
||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||
// Navigation buttons (navigation_next, navigation_prev)
|
||||
if (isNavigationElementType(element.type)) {
|
||||
if (element.iconUrl) {
|
||||
// Icon fills button dimensions from element, flexible for content when not set
|
||||
const imgStyle: React.CSSProperties = {
|
||||
width: element.width ? '100%' : 'auto',
|
||||
height: element.height ? '100%' : 'auto',
|
||||
objectFit: 'contain',
|
||||
};
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolve(element.iconUrl)}
|
||||
alt='Navigation'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Text-only navigation button - no background here, parent element handles styling
|
||||
return (
|
||||
<span className='px-4 py-2 text-sm'>
|
||||
{element.navLabel ||
|
||||
(element.type === 'navigation_next' ? 'Next' : 'Back')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Tooltip element
|
||||
if (isTooltipElementType(element.type)) {
|
||||
if (element.iconUrl) {
|
||||
const imgStyle: React.CSSProperties = {
|
||||
width: element.width ? '100%' : 'auto',
|
||||
height: element.height ? '100%' : 'auto',
|
||||
objectFit: 'contain',
|
||||
};
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolve(element.iconUrl)}
|
||||
alt='Tooltip'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Text-only tooltip - no background here, parent element handles styling
|
||||
return (
|
||||
<div className='p-3 max-w-[200px]'>
|
||||
<p className='font-bold text-sm'>{element.tooltipTitle}</p>
|
||||
<p className='text-xs opacity-70'>{element.tooltipText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Description element
|
||||
if (isDescriptionElementType(element.type)) {
|
||||
if (element.iconUrl) {
|
||||
const imgStyle: React.CSSProperties = {
|
||||
width: element.width ? '100%' : 'auto',
|
||||
height: element.height ? '100%' : 'auto',
|
||||
objectFit: 'contain',
|
||||
};
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolve(element.iconUrl)}
|
||||
alt='Description'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const bgColor = element.descriptionBackgroundColor || 'transparent';
|
||||
return (
|
||||
<div className='p-4 rounded' style={{ backgroundColor: bgColor }}>
|
||||
<p
|
||||
className='font-bold'
|
||||
style={{
|
||||
fontSize: element.descriptionTitleFontSize || '24px',
|
||||
fontFamily: element.descriptionTitleFontFamily || 'inherit',
|
||||
color: element.descriptionTitleColor || '#ffffff',
|
||||
}}
|
||||
>
|
||||
{element.descriptionTitle || ''}
|
||||
</p>
|
||||
{element.descriptionText && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: element.descriptionTextFontSize || '16px',
|
||||
fontFamily: element.descriptionTextFontFamily || 'inherit',
|
||||
color: element.descriptionTextColor || '#ffffff',
|
||||
}}
|
||||
>
|
||||
{element.descriptionText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Video player
|
||||
if (isVideoPlayerElementType(element.type) && element.mediaUrl) {
|
||||
return (
|
||||
<video
|
||||
className='w-full h-full object-cover rounded'
|
||||
src={resolve(element.mediaUrl)}
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
muted={Boolean(element.mediaMuted)}
|
||||
playsInline
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Audio player
|
||||
if (isAudioPlayerElementType(element.type) && element.mediaUrl) {
|
||||
return (
|
||||
<audio
|
||||
className='w-full'
|
||||
src={resolve(element.mediaUrl)}
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Gallery
|
||||
if (isGalleryElementType(element.type)) {
|
||||
const cards: GalleryCard[] = element.galleryCards || [];
|
||||
return (
|
||||
<div className='grid grid-cols-3 gap-2 p-2 bg-black/50 rounded min-w-[150px]'>
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className='relative aspect-square min-w-[40px] min-h-[40px]'
|
||||
>
|
||||
{card.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolve(card.imageUrl)}
|
||||
alt={card.title || ''}
|
||||
className='absolute inset-0 w-full h-full object-cover rounded'
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Carousel
|
||||
if (isCarouselElementType(element.type)) {
|
||||
const slides: CarouselSlide[] = element.carouselSlides || [];
|
||||
const firstSlide = slides[0];
|
||||
return (
|
||||
<div className='relative w-full h-full min-w-[120px] min-h-[80px]'>
|
||||
{firstSlide?.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolve(firstSlide.imageUrl)}
|
||||
alt={firstSlide.caption || 'Carousel slide'}
|
||||
className='w-full h-full object-cover rounded'
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
{/* Carousel navigation overlay */}
|
||||
<div className='absolute bottom-2 left-0 right-0 flex justify-center gap-1'>
|
||||
{slides.map((slide, index) => (
|
||||
<div
|
||||
key={slide.id}
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
index === 0 ? 'bg-white' : 'bg-white/50'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Prev/Next icons if configured */}
|
||||
{element.carouselPrevIconUrl && (
|
||||
<div className='absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolve(element.carouselPrevIconUrl)}
|
||||
alt='Previous'
|
||||
className='w-full h-full object-contain'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{element.carouselNextIconUrl && (
|
||||
<div className='absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolve(element.carouselNextIconUrl)}
|
||||
alt='Next'
|
||||
className='w-full h-full object-contain'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Logo
|
||||
if (isLogoElementType(element.type)) {
|
||||
if (element.iconUrl) {
|
||||
const imgStyle: React.CSSProperties = {
|
||||
width: element.width ? '100%' : 'auto',
|
||||
height: element.height ? '100%' : 'auto',
|
||||
objectFit: 'contain',
|
||||
};
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolve(element.iconUrl)}
|
||||
alt='Logo'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Text-only logo - no background here, parent element handles styling
|
||||
return (
|
||||
<span className='px-4 py-2 font-bold'>{element.label || 'LOGO'}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Spot (hotspot)
|
||||
if (isSpotElementType(element.type)) {
|
||||
if (element.iconUrl) {
|
||||
const imgStyle: React.CSSProperties = {
|
||||
width: element.width ? '100%' : 'auto',
|
||||
height: element.height ? '100%' : 'auto',
|
||||
objectFit: 'contain',
|
||||
};
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolve(element.iconUrl)}
|
||||
alt='Hotspot'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Default spot indicator
|
||||
return (
|
||||
<div className='w-8 h-8 rounded-full bg-blue-500/70 border-2 border-white animate-pulse' />
|
||||
);
|
||||
}
|
||||
|
||||
// Popup - no background here, parent element handles styling
|
||||
if (isPopupElementType(element.type)) {
|
||||
return (
|
||||
<span className='px-4 py-2 text-sm'>{element.label || 'Popup'}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for unknown types - no background here, parent element handles styling
|
||||
return (
|
||||
<span className='px-4 py-2 text-sm'>{element.label || element.type}</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default ElementContentRenderer;
|
||||
111
frontend/src/components/UiElements/UiElementRenderer.tsx
Normal file
111
frontend/src/components/UiElements/UiElementRenderer.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
/**
|
||||
* UiElementRenderer Component
|
||||
*
|
||||
* Unified UI Element Renderer - single source of truth for element rendering.
|
||||
* Used by both CanvasElement (constructor) and RuntimeElement (presentation)
|
||||
* to ensure WYSIWYG consistency.
|
||||
*
|
||||
* Renders any UI element with consistent styling by delegating to per-type components.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { CanvasElement } from '../../types/constructor';
|
||||
import { useElementWrapperStyle } from './shared/useElementWrapperStyle';
|
||||
import {
|
||||
isNavigationElementType,
|
||||
isTooltipElementType,
|
||||
isDescriptionElementType,
|
||||
isGalleryElementType,
|
||||
isCarouselElementType,
|
||||
isVideoPlayerElementType,
|
||||
isAudioPlayerElementType,
|
||||
isLogoElementType,
|
||||
isSpotElementType,
|
||||
isPopupElementType,
|
||||
} from '../../lib/elementDefaults';
|
||||
|
||||
// Import per-type components
|
||||
import NavigationElement from './elements/NavigationElement';
|
||||
import GalleryElement from './elements/GalleryElement';
|
||||
import TooltipElement from './elements/TooltipElement';
|
||||
import DescriptionElement from './elements/DescriptionElement';
|
||||
import CarouselElement from './elements/CarouselElement';
|
||||
import LogoElement from './elements/LogoElement';
|
||||
import SpotElement from './elements/SpotElement';
|
||||
import VideoPlayerElement from './elements/VideoPlayerElement';
|
||||
import AudioPlayerElement from './elements/AudioPlayerElement';
|
||||
import PopupElement from './elements/PopupElement';
|
||||
|
||||
export interface UiElementRendererProps {
|
||||
element: CanvasElement;
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
// Constructor-specific props (optional)
|
||||
isSelected?: boolean;
|
||||
isEditMode?: boolean;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified UI Element Renderer
|
||||
*
|
||||
* Renders any UI element with consistent styling.
|
||||
* Used by both CanvasElement (constructor) and RuntimeElement (presentation).
|
||||
*/
|
||||
export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
|
||||
element,
|
||||
resolveUrl,
|
||||
isSelected = false,
|
||||
isEditMode = false,
|
||||
isDisabled = false,
|
||||
}) => {
|
||||
const { className, style } = useElementWrapperStyle({
|
||||
element,
|
||||
isSelected,
|
||||
isEditMode,
|
||||
isDisabled,
|
||||
});
|
||||
|
||||
// Common props for all element types
|
||||
const commonProps = { element, resolveUrl, className, style };
|
||||
|
||||
// Delegate to type-specific component
|
||||
if (isNavigationElementType(element.type)) {
|
||||
return <NavigationElement {...commonProps} />;
|
||||
}
|
||||
if (isGalleryElementType(element.type)) {
|
||||
return <GalleryElement {...commonProps} />;
|
||||
}
|
||||
if (isTooltipElementType(element.type)) {
|
||||
return <TooltipElement {...commonProps} />;
|
||||
}
|
||||
if (isDescriptionElementType(element.type)) {
|
||||
return <DescriptionElement {...commonProps} />;
|
||||
}
|
||||
if (isCarouselElementType(element.type)) {
|
||||
return <CarouselElement {...commonProps} />;
|
||||
}
|
||||
if (isVideoPlayerElementType(element.type)) {
|
||||
return <VideoPlayerElement {...commonProps} />;
|
||||
}
|
||||
if (isAudioPlayerElementType(element.type)) {
|
||||
return <AudioPlayerElement {...commonProps} />;
|
||||
}
|
||||
if (isLogoElementType(element.type)) {
|
||||
return <LogoElement {...commonProps} />;
|
||||
}
|
||||
if (isSpotElementType(element.type)) {
|
||||
return <SpotElement {...commonProps} />;
|
||||
}
|
||||
if (isPopupElementType(element.type)) {
|
||||
return <PopupElement {...commonProps} />;
|
||||
}
|
||||
|
||||
// Fallback for unknown types
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<span className='px-4 py-2 text-sm'>{element.label || element.type}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UiElementRenderer;
|
||||
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* AudioPlayerElement Component
|
||||
*
|
||||
* Audio player element with controls.
|
||||
* Renders with unified wrapper styling + content.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { CanvasElement } from '../../../types/constructor';
|
||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||
|
||||
interface AudioPlayerElementProps {
|
||||
element: CanvasElement;
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
className: string;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const AudioPlayerElement: React.FC<AudioPlayerElementProps> = ({
|
||||
element,
|
||||
resolveUrl,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||
|
||||
if (!element.mediaUrl) {
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<span className='px-4 py-2 text-sm'>Audio Player</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<audio
|
||||
className='w-full'
|
||||
src={resolve(element.mediaUrl)}
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioPlayerElement;
|
||||
@ -0,0 +1,81 @@
|
||||
/**
|
||||
* CarouselElement Component
|
||||
*
|
||||
* Carousel element - slideshow of images with navigation.
|
||||
* Renders with unified wrapper styling + content.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { CanvasElement, CarouselSlide } from '../../../types/constructor';
|
||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||
|
||||
interface CarouselElementProps {
|
||||
element: CanvasElement;
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
className: string;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const CarouselElement: React.FC<CarouselElementProps> = ({
|
||||
element,
|
||||
resolveUrl,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||
const slides: CarouselSlide[] = element.carouselSlides || [];
|
||||
const firstSlide = slides[0];
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<div className='relative w-full h-full min-w-[120px] min-h-[80px]'>
|
||||
{firstSlide?.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolve(firstSlide.imageUrl)}
|
||||
alt={firstSlide.caption || 'Carousel slide'}
|
||||
className='w-full h-full object-cover rounded'
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
{/* Carousel navigation overlay */}
|
||||
<div className='absolute bottom-2 left-0 right-0 flex justify-center gap-1'>
|
||||
{slides.map((slide, index) => (
|
||||
<div
|
||||
key={slide.id}
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
index === 0 ? 'bg-white' : 'bg-white/50'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Prev/Next icons if configured */}
|
||||
{element.carouselPrevIconUrl && (
|
||||
<div className='absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolve(element.carouselPrevIconUrl)}
|
||||
alt='Previous'
|
||||
className='w-full h-full object-contain'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{element.carouselNextIconUrl && (
|
||||
<div className='absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolve(element.carouselNextIconUrl)}
|
||||
alt='Next'
|
||||
className='w-full h-full object-contain'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CarouselElement;
|
||||
@ -0,0 +1,81 @@
|
||||
/**
|
||||
* DescriptionElement Component
|
||||
*
|
||||
* Description element with icon or styled text content.
|
||||
* Renders with unified wrapper styling + content.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { CanvasElement } from '../../../types/constructor';
|
||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||
|
||||
interface DescriptionElementProps {
|
||||
element: CanvasElement;
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
className: string;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const DescriptionElement: React.FC<DescriptionElementProps> = ({
|
||||
element,
|
||||
resolveUrl,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||
|
||||
// With icon: render image
|
||||
if (element.iconUrl) {
|
||||
const imgStyle: CSSProperties = {
|
||||
width: element.width ? '100%' : 'auto',
|
||||
height: element.height ? '100%' : 'auto',
|
||||
objectFit: 'contain',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolve(element.iconUrl)}
|
||||
alt='Description'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Without icon: render styled text description
|
||||
const bgColor = element.descriptionBackgroundColor || 'transparent';
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<div className='p-4 rounded' style={{ backgroundColor: bgColor }}>
|
||||
<p
|
||||
className='font-bold'
|
||||
style={{
|
||||
fontSize: element.descriptionTitleFontSize || '24px',
|
||||
fontFamily: element.descriptionTitleFontFamily || 'inherit',
|
||||
color: element.descriptionTitleColor || '#ffffff',
|
||||
}}
|
||||
>
|
||||
{element.descriptionTitle || ''}
|
||||
</p>
|
||||
{element.descriptionText && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: element.descriptionTextFontSize || '16px',
|
||||
fontFamily: element.descriptionTextFontFamily || 'inherit',
|
||||
color: element.descriptionTextColor || '#ffffff',
|
||||
}}
|
||||
>
|
||||
{element.descriptionText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DescriptionElement;
|
||||
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* GalleryElement Component
|
||||
*
|
||||
* Gallery element - grid of image cards.
|
||||
* Renders with unified wrapper styling + content.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { CanvasElement, GalleryCard } from '../../../types/constructor';
|
||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||
|
||||
interface GalleryElementProps {
|
||||
element: CanvasElement;
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
className: string;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const GalleryElement: React.FC<GalleryElementProps> = ({
|
||||
element,
|
||||
resolveUrl,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||
const cards: GalleryCard[] = element.galleryCards || [];
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<div className='grid grid-cols-3 gap-2 p-2 bg-black/50 rounded min-w-[150px]'>
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className='relative aspect-square min-w-[40px] min-h-[40px]'
|
||||
>
|
||||
{card.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolve(card.imageUrl)}
|
||||
alt={card.title || ''}
|
||||
className='absolute inset-0 w-full h-full object-cover rounded'
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GalleryElement;
|
||||
57
frontend/src/components/UiElements/elements/LogoElement.tsx
Normal file
57
frontend/src/components/UiElements/elements/LogoElement.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* LogoElement Component
|
||||
*
|
||||
* Logo element with icon or text fallback.
|
||||
* Renders with unified wrapper styling + content.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { CanvasElement } from '../../../types/constructor';
|
||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||
|
||||
interface LogoElementProps {
|
||||
element: CanvasElement;
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
className: string;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const LogoElement: React.FC<LogoElementProps> = ({
|
||||
element,
|
||||
resolveUrl,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||
|
||||
// With icon: render image
|
||||
if (element.iconUrl) {
|
||||
const imgStyle: CSSProperties = {
|
||||
width: element.width ? '100%' : 'auto',
|
||||
height: element.height ? '100%' : 'auto',
|
||||
objectFit: 'contain',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolve(element.iconUrl)}
|
||||
alt='Logo'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Without icon: render text logo
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<span className='px-4 py-2 font-bold'>{element.label || 'LOGO'}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogoElement;
|
||||
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* NavigationElement Component
|
||||
*
|
||||
* Navigation button element (navigation_next, navigation_prev).
|
||||
* Renders with unified wrapper styling + content.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { CanvasElement } from '../../../types/constructor';
|
||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||
|
||||
interface NavigationElementProps {
|
||||
element: CanvasElement;
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
className: string;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const NavigationElement: React.FC<NavigationElementProps> = ({
|
||||
element,
|
||||
resolveUrl,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||
|
||||
// With icon: render image
|
||||
if (element.iconUrl) {
|
||||
const imgStyle: CSSProperties = {
|
||||
width: element.width ? '100%' : 'auto',
|
||||
height: element.height ? '100%' : 'auto',
|
||||
objectFit: 'contain',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolve(element.iconUrl)}
|
||||
alt='Navigation'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Without icon: render text label
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<span className='px-4 py-2 text-sm'>
|
||||
{element.navLabel ||
|
||||
(element.type === 'navigation_next' ? 'Next' : 'Back')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationElement;
|
||||
31
frontend/src/components/UiElements/elements/PopupElement.tsx
Normal file
31
frontend/src/components/UiElements/elements/PopupElement.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* PopupElement Component
|
||||
*
|
||||
* Popup trigger element.
|
||||
* Renders with unified wrapper styling + content.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { CanvasElement } from '../../../types/constructor';
|
||||
|
||||
interface PopupElementProps {
|
||||
element: CanvasElement;
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
className: string;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const PopupElement: React.FC<PopupElementProps> = ({
|
||||
element,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<span className='px-4 py-2 text-sm'>{element.label || 'Popup'}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopupElement;
|
||||
57
frontend/src/components/UiElements/elements/SpotElement.tsx
Normal file
57
frontend/src/components/UiElements/elements/SpotElement.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* SpotElement Component
|
||||
*
|
||||
* Hotspot element with icon or default indicator.
|
||||
* Renders with unified wrapper styling + content.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { CanvasElement } from '../../../types/constructor';
|
||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||
|
||||
interface SpotElementProps {
|
||||
element: CanvasElement;
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
className: string;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const SpotElement: React.FC<SpotElementProps> = ({
|
||||
element,
|
||||
resolveUrl,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||
|
||||
// With icon: render image
|
||||
if (element.iconUrl) {
|
||||
const imgStyle: CSSProperties = {
|
||||
width: element.width ? '100%' : 'auto',
|
||||
height: element.height ? '100%' : 'auto',
|
||||
objectFit: 'contain',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolve(element.iconUrl)}
|
||||
alt='Hotspot'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Without icon: render default spot indicator
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<div className='w-8 h-8 rounded-full bg-blue-500/70 border-2 border-white animate-pulse' />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpotElement;
|
||||
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* TooltipElement Component
|
||||
*
|
||||
* Tooltip element with icon or text content.
|
||||
* Renders with unified wrapper styling + content.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { CanvasElement } from '../../../types/constructor';
|
||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||
|
||||
interface TooltipElementProps {
|
||||
element: CanvasElement;
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
className: string;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const TooltipElement: React.FC<TooltipElementProps> = ({
|
||||
element,
|
||||
resolveUrl,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||
|
||||
// With icon: render image
|
||||
if (element.iconUrl) {
|
||||
const imgStyle: CSSProperties = {
|
||||
width: element.width ? '100%' : 'auto',
|
||||
height: element.height ? '100%' : 'auto',
|
||||
objectFit: 'contain',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolve(element.iconUrl)}
|
||||
alt='Tooltip'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Without icon: render text tooltip
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<div className='p-3 max-w-[200px]'>
|
||||
<p className='font-bold text-sm'>{element.tooltipTitle}</p>
|
||||
<p className='text-xs opacity-70'>{element.tooltipText}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TooltipElement;
|
||||
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* VideoPlayerElement Component
|
||||
*
|
||||
* Video player element with controls.
|
||||
* Renders with unified wrapper styling + content.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { CanvasElement } from '../../../types/constructor';
|
||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||
|
||||
interface VideoPlayerElementProps {
|
||||
element: CanvasElement;
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
className: string;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
|
||||
element,
|
||||
resolveUrl,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||
|
||||
if (!element.mediaUrl) {
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<span className='px-4 py-2 text-sm'>Video Player</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<video
|
||||
className='w-full h-full object-cover rounded'
|
||||
src={resolve(element.mediaUrl)}
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
muted={Boolean(element.mediaMuted)}
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayerElement;
|
||||
@ -0,0 +1,107 @@
|
||||
/**
|
||||
* useElementWrapperStyle Hook
|
||||
*
|
||||
* Single source of truth for UI element wrapper styling.
|
||||
* Used by both CanvasElement (constructor) and RuntimeElement (presentation)
|
||||
* to ensure WYSIWYG consistency.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { CanvasElement } from '../../../types/constructor';
|
||||
import { buildElementStyle } from '../../../lib/elementStyles';
|
||||
import {
|
||||
isTooltipElementType,
|
||||
isDescriptionElementType,
|
||||
isNavigationElementType,
|
||||
isGalleryElementType,
|
||||
isCarouselElementType,
|
||||
isLogoElementType,
|
||||
isSpotElementType,
|
||||
} from '../../../lib/elementDefaults';
|
||||
|
||||
interface UseElementWrapperStyleOptions {
|
||||
element: CanvasElement;
|
||||
/** Constructor-specific: show selection styling */
|
||||
isSelected?: boolean;
|
||||
/** Constructor-specific: show edit mode styling */
|
||||
isEditMode?: boolean;
|
||||
/** Constructor-specific: show disabled styling */
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
interface ElementWrapperStyle {
|
||||
className: string;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that returns consistent wrapper styling for any UI element.
|
||||
* Ensures constructor and runtime render elements identically.
|
||||
*
|
||||
* @param options - Element and optional constructor state flags
|
||||
* @returns Object with className and style for the element wrapper
|
||||
*/
|
||||
export function useElementWrapperStyle({
|
||||
element,
|
||||
isSelected = false,
|
||||
isEditMode = false,
|
||||
isDisabled = false,
|
||||
}: UseElementWrapperStyleOptions): ElementWrapperStyle {
|
||||
return useMemo(() => {
|
||||
// Determine element characteristics
|
||||
const hasIconDrivenSize =
|
||||
Boolean(element.iconUrl) &&
|
||||
(isTooltipElementType(element.type) ||
|
||||
isDescriptionElementType(element.type) ||
|
||||
isNavigationElementType(element.type) ||
|
||||
isLogoElementType(element.type) ||
|
||||
isSpotElementType(element.type));
|
||||
|
||||
const hasTransparentBackground =
|
||||
(isDescriptionElementType(element.type) &&
|
||||
!element.iconUrl &&
|
||||
(!element.descriptionBackgroundColor ||
|
||||
element.descriptionBackgroundColor === 'transparent')) ||
|
||||
(isNavigationElementType(element.type) && Boolean(element.iconUrl)) ||
|
||||
isTooltipElementType(element.type) ||
|
||||
isGalleryElementType(element.type) ||
|
||||
isCarouselElementType(element.type);
|
||||
|
||||
// Navigation elements (with or without icon) should be centered
|
||||
const isNavigationElement = isNavigationElementType(element.type);
|
||||
|
||||
// Build className - same logic for both constructor and runtime
|
||||
const classNames = [
|
||||
'rounded text-xs font-semibold',
|
||||
// Text alignment: center for navigation buttons, left for others
|
||||
isNavigationElement ? 'text-center' : 'text-left',
|
||||
// Background/border - selected state overrides transparent
|
||||
isSelected
|
||||
? 'border shadow border-blue-500 bg-blue-50'
|
||||
: hasTransparentBackground
|
||||
? 'bg-transparent'
|
||||
: 'border shadow border-blue-200 bg-white/95',
|
||||
// Padding
|
||||
hasIconDrivenSize ? 'overflow-hidden p-0 leading-none' : 'px-3 py-2',
|
||||
// Flex centering for navigation elements (both icons and text)
|
||||
isNavigationElement ? 'flex items-center justify-center' : '',
|
||||
// Constructor-specific states (only applied when in constructor)
|
||||
isEditMode
|
||||
? 'cursor-move'
|
||||
: isDisabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
// Build inline style from element properties
|
||||
const inlineStyle = buildElementStyle(element);
|
||||
|
||||
return {
|
||||
className: classNames,
|
||||
style: inlineStyle,
|
||||
};
|
||||
}, [element, isSelected, isEditMode, isDisabled]);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user