/** * ElementEditorPanel Component * * Renders the element editor sidebar in the constructor. * Handles element settings, background settings, and transition creation. * * Uses ConstructorContext for all state - only receives local UI props. */ import React from 'react'; import { useConstructorContext, useConstructorElements, useConstructorBackground, useConstructorAssets, useConstructorCollectionOps, useConstructorDuration, useConstructorNavigation, useConstructorEditorTab, useConstructorMenu, } from '../../context/ConstructorContext'; import { ElementSettingsTabsCompact, StyleSettingsSectionCompact, EffectsSettingsSectionCompact, CommonSettingsSectionCompact, DescriptionSettingsSectionCompact, MediaSettingsSectionCompact, GallerySettingsSectionCompact, CarouselSettingsSectionCompact, GalleryCarouselSettingsSectionCompact, GallerySectionStyleInputs, InfoPanelSettingsSectionCompact, InfoPanelStyleInputs, extractNumericValue, } from '../ElementSettings'; import BackgroundSettingsEditor from './BackgroundSettingsEditor'; import ElementEditorHeader from './ElementEditorHeader'; import NavigationSettingsSectionCompact from '../ElementSettings/NavigationSettingsSectionCompact'; import { normalizeAppearDelaySec, normalizeAppearDurationSec, isNavigationElementType, isDescriptionElementType, isGalleryElementType, isCarouselElementType, isMediaElementType, isVideoPlayerElementType, isInfoPanelElementType, } from '../../lib/elementDefaults'; import type { CanvasElement } from '../../types/constructor'; import { getSystemControlAnchorBounds, type SystemUiControlSettings, } from '../../types/uiControls'; import { FONT_OPTIONS } from '../../lib/fonts'; import { addFallbackAssetOption } from '../../lib/constructorHelpers'; import { opacityToPercentInput, percentInputToOpacityNumber, } from '../../lib/opacityPercent'; type NavigationElementType = 'navigation_next' | 'navigation_prev'; // ============================================================================ // Props Interface (Local UI props only) // ============================================================================ interface ElementEditorPanelProps { /** Ref for outside click detection */ elementEditorRef: React.RefObject; /** Draggable position */ position: { x: number; y: number }; /** Whether panel is collapsed */ isCollapsed: boolean; /** Toggle collapse state */ onToggleCollapse: () => void; /** Start dragging the panel */ onDragStart: (event: React.MouseEvent) => void; /** Panel title */ title: string; } // ============================================================================ // CSS Property Handler // ============================================================================ /** * Handle CSS property changes with unit conversion */ const handleCssPropertyChange = ( prop: string, value: string | number | boolean, onUpdateElement: (patch: Partial) => void, ) => { const numericProps = [ 'width', 'height', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight', 'border', 'borderRadius', 'gap', ]; const getUnit = (p: string) => { if (['width', 'minWidth', 'maxWidth'].includes(p)) return 'vw'; if (['height', 'minHeight', 'maxHeight'].includes(p)) return 'vh'; if (['border', 'borderRadius'].includes(p)) return 'px'; if (p === 'gap') return 'rem'; return ''; }; if (numericProps.includes(prop)) { const trimmed = String(value || '').trim(); if (prop === 'border') { onUpdateElement({ [prop]: trimmed ? `${trimmed}px solid currentColor` : 'none', }); } else if (prop === 'borderRadius') { onUpdateElement({ [prop]: trimmed ? `${trimmed}px` : undefined, }); } else { const unit = getUnit(prop); onUpdateElement({ [prop]: trimmed ? `${trimmed}${unit}` : undefined, }); } } else { onUpdateElement({ [prop]: value === '' ? undefined : value, }); } }; // ============================================================================ // Component // ============================================================================ export function ElementEditorPanel({ elementEditorRef, position, isCollapsed, onToggleCollapse, onDragStart, title, }: ElementEditorPanelProps) { // Get state from context const { selectedElement, selectedElementId, updateSelectedElement, removeSelectedElement, selectedSystemControl, resolvedUiControlsSettings, uiControlsCanvasAspectRatio, updateSystemControl, } = useConstructorElements(); const { selectedMenuItem } = useConstructorMenu(); const { pageBackground, setBackgroundImageUrl, setBackgroundVideoUrl, setBackgroundEmbedUrl, setBackgroundAudioUrl, setBackgroundVideoSettings, setBackgroundAudioSettings, } = useConstructorBackground(); const { assetOptions } = useConstructorAssets(); const { galleryCards, galleryInfoSpans, carouselSlides, infoPanelSectionOps, } = useConstructorCollectionOps(); const { getDuration, durationNotes } = useConstructorDuration(); const { pages, activePageId, allowedNavigationTypes, normalizeNavigationType, } = useConstructorNavigation(); const { activeTab, setActiveTab } = useConstructorEditorTab(); // ============================================================================ // Render // ============================================================================ const selectedSystemControlSettings = selectedSystemControl ? resolvedUiControlsSettings[selectedSystemControl] : null; const selectedSystemControlBounds = selectedSystemControlSettings ? getSystemControlAnchorBounds( selectedSystemControlSettings.anchor, selectedSystemControlSettings.buttonSizePercent, uiControlsCanvasAspectRatio, ) : null; const updateSelectedSystemControl = ( patch: Partial, ) => { if (!selectedSystemControl) return; updateSystemControl(selectedSystemControl, patch); }; return (
{!isCollapsed && ( <> {selectedSystemControl && selectedSystemControlSettings && (
updateSelectedSystemControl({ xPercent: Math.max( selectedSystemControlBounds?.minX ?? 0, Math.min( selectedSystemControlBounds?.maxX ?? 100, Number(event.target.value) || 0, ), ), }) } />
updateSelectedSystemControl({ yPercent: Math.max( selectedSystemControlBounds?.minY ?? 0, Math.min( selectedSystemControlBounds?.maxY ?? 100, Number(event.target.value) || 0, ), ), }) } />
updateSelectedSystemControl({ order: Number(event.target.value) || 0, }) } />
{[ ['Default icon', 'defaultIconUrl'], ['Active icon', 'activeIconUrl'], ].map(([label, key]) => { const value = selectedSystemControlSettings[ key as keyof typeof selectedSystemControlSettings ] as string; return (
); })}
{[ ['Button size (%)', 'buttonSizePercent'], ['Icon size (%)', 'iconSizePercent'], ['Radius (%)', 'borderRadiusPercent'], ['Z-index', 'zIndex'], ].map(([label, key]) => (
{ const value = Number(event.target.value) || 0; if (key === 'buttonSizePercent') { const bounds = getSystemControlAnchorBounds( selectedSystemControlSettings.anchor, value, uiControlsCanvasAspectRatio, ); updateSelectedSystemControl({ buttonSizePercent: value, xPercent: Math.max( bounds.minX, Math.min( bounds.maxX, selectedSystemControlSettings.xPercent, ), ), yPercent: Math.max( bounds.minY, Math.min( bounds.maxY, selectedSystemControlSettings.yPercent, ), ), }); return; } updateSelectedSystemControl({ [key]: value, } as Partial); }} />
))}
{[ ['Default BG', 'defaultBackgroundColor'], ['Active BG', 'activeBackgroundColor'], ['Hover BG', 'hoverBackgroundColor'], ['Icon color', 'color'], ['Default border', 'defaultBorderColor'], ['Active border', 'activeBorderColor'], ['Opacity (%)', 'opacity'], ['Shadow', 'boxShadow'], ].map(([label, key]) => (
updateSelectedSystemControl({ [key]: key === 'opacity' ? percentInputToOpacityNumber(event.target.value) : event.target.value, } as Partial) } />
))}
)} {/* Background Image Settings */} {!selectedSystemControl && selectedMenuItem === 'background_image' && ( { setBackgroundImageUrl(value); if (value) { setBackgroundVideoUrl(''); setBackgroundEmbedUrl(''); } }} /> )} {/* Background Video Settings */} {!selectedSystemControl && selectedMenuItem === 'background_video' && ( { setBackgroundVideoUrl(value); if (value) { setBackgroundImageUrl(''); setBackgroundEmbedUrl(''); } }} videoAutoplay={pageBackground.videoSettings.autoplay} videoLoop={pageBackground.videoSettings.loop} videoMuted={pageBackground.videoSettings.muted} videoStartTime={pageBackground.videoSettings.startTime} videoEndTime={pageBackground.videoSettings.endTime} onVideoSettingsChange={setBackgroundVideoSettings} /> )} {/* Background 360 Settings */} {selectedMenuItem === 'background_embed' && ( { setBackgroundEmbedUrl(value); if (value) { setBackgroundImageUrl(''); setBackgroundVideoUrl(''); } }} /> )} {/* Background Audio Settings */} {selectedMenuItem === 'background_audio' && ( )} {/* Element Settings */} {selectedElement && ( <> setActiveTab(tab as 'general' | 'css' | 'effects') } tabs={[ { id: 'general', label: 'General' }, { id: 'css', label: 'CSS' }, { id: 'effects', label: 'Effects' }, ]} /> {/* General Tab */} {activeTab === 'general' && ( <> {/* Common settings for all elements except info_panel */} {!isInfoPanelElementType(selectedElement.type) && ( { if (prop === 'label') { updateSelectedElement({ label: value }); } else if (prop === 'appearDelaySec') { updateSelectedElement({ appearDelaySec: normalizeAppearDelaySec(value), }); } else if (prop === 'appearDurationSec') { updateSelectedElement({ appearDurationSec: normalizeAppearDurationSec(value), }); } }} /> )} {/* Navigation Settings */} {isNavigationElementType(selectedElement.type) && ( { if (prop === 'type') { if (typeof value === 'object') { const nextType = (value.type || selectedElement.type) as NavigationElementType; updateSelectedElement({ ...normalizeNavigationType( selectedElement, nextType, ), ...value, }); } else { const nextType = value as NavigationElementType; updateSelectedElement( normalizeNavigationType( selectedElement, nextType, ), ); } } else if (prop === 'transitionVideoUrl') { const nextVideoUrl = value as string; const resolvedDuration = getDuration(nextVideoUrl); updateSelectedElement({ transitionVideoUrl: nextVideoUrl, transitionDurationSec: resolvedDuration || undefined, }); } else if (prop === 'targetPageSlug') { updateSelectedElement({ targetPageSlug: value as string, targetPageId: '', }); } else if (prop === 'navigationTargetMode') { if (typeof value === 'object') { updateSelectedElement(value); } } else { updateSelectedElement({ [prop]: value, }); } }} /> )} {/* Description Settings */} {isDescriptionElementType(selectedElement.type) && ( updateSelectedElement({ [prop]: value }) } /> )} {/* Media Settings */} {isMediaElementType(selectedElement.type) && ( updateSelectedElement({ [prop]: value }) } /> )} {/* Gallery Settings */} {isGalleryElementType(selectedElement.type) && ( <> updateSelectedElement(patch)} onAddInfoSpan={galleryInfoSpans.add} onUpdateInfoSpan={galleryInfoSpans.update} onRemoveInfoSpan={galleryInfoSpans.remove} onAddCard={galleryCards.add} onUpdateCard={galleryCards.update} onRemoveCard={galleryCards.remove} /> )} {/* Carousel Settings */} {isCarouselElementType(selectedElement.type) && ( )} {/* Info Panel Settings */} {isInfoPanelElementType(selectedElement.type) && ( updateSelectedElement({ [prop]: value }) } // Section operations (ID-based) onMoveSection={infoPanelSectionOps.move} onRemoveSection={infoPanelSectionOps.remove} onAddSection={infoPanelSectionOps.add} onUpdateSection={infoPanelSectionOps.update} // Per-section item operations onAddSpan={infoPanelSectionOps.addSpan} onUpdateSpan={infoPanelSectionOps.updateSpan} onRemoveSpan={infoPanelSectionOps.removeSpan} onAddImage={infoPanelSectionOps.addImage} onUpdateImage={infoPanelSectionOps.updateImage} onRemoveImage={infoPanelSectionOps.removeImage} /> )} )} {/* CSS Styles Tab */} {activeTab === 'css' && ( <> {/* Gallery Section Styles (shown first for gallery elements) */} {isGalleryElementType(selectedElement.type) && (

Gallery Section Styles

updateSelectedElement({ [prop]: value || undefined }) } showFont showDimensions showTextAlign /> updateSelectedElement({ [prop]: value || undefined }) } showFont showTextAlign /> updateSelectedElement({ [prop]: value || undefined }) } showFont showGap showColumns showTextAlign /> updateSelectedElement({ [prop]: value || undefined }) } showGap showColumns showTitleStyles showAspectRatio />

General Element Styles

)} {/* Info Panel Section Styles - order: Trigger โ†’ Header โ†’ Info Panel โ†’ Image Detail */} {isInfoPanelElementType(selectedElement.type) && (
{/* Trigger Button Styles (uses general StyleSettingsSectionCompact) */}

Trigger Button Styles

handleCssPropertyChange( prop, value, updateSelectedElement, ) } /> {/* Info Panel Container - contains all panel and section styles */}

Info Panel

{/* Panel Position/Size/Style */} updateSelectedElement({ [prop]: value || undefined, }) } hideOverlay /> {/* Header Section Styles */} updateSelectedElement({ [prop]: value || undefined, }) } showFont showDimensions showTextAlign /> {/* Title Section Styles */}

Title Section

updateSelectedElement({ infoPanelTitleBackgroundColor: e.target.value || undefined, }) } placeholder='transparent' />
updateSelectedElement({ infoPanelTitleColor: e.target.value, }) } />
updateSelectedElement({ infoPanelTitleFontSize: e.target.value || undefined, }) } placeholder='16' />
updateSelectedElement({ infoPanelTitlePadding: e.target.value || undefined, }) } placeholder='4 8' />
{/* Text Section Styles */}

Text Section

updateSelectedElement({ infoPanelTextBackgroundColor: e.target.value || undefined, }) } placeholder='transparent' />
updateSelectedElement({ infoPanelTextColor: e.target.value, }) } />
updateSelectedElement({ infoPanelTextFontSize: e.target.value || undefined, }) } placeholder='14' />
updateSelectedElement({ infoPanelTextPadding: e.target.value || undefined, }) } placeholder='4 8' />
{/* Spans Section Styles */}

Spans Section

updateSelectedElement({ infoPanelSpanBackgroundColor: e.target.value || undefined, }) } placeholder='rgba(255,255,255,0.1)' />
updateSelectedElement({ infoPanelSpanColor: e.target.value, }) } />
updateSelectedElement({ infoPanelSpanFontSize: e.target.value || undefined, }) } placeholder='12' />
updateSelectedElement({ infoPanelSpanPadding: e.target.value || undefined, }) } placeholder='4 8' />
updateSelectedElement({ infoPanelSpanBorderRadius: e.target.value || undefined, }) } placeholder='6' />
{/* Media Section Styles */}

Media Section

updateSelectedElement({ infoPanelImagesPreviewHeight: e.target.value || undefined, }) } placeholder='300' />
updateSelectedElement({ infoPanelImagesThumbnailSize: e.target.value || undefined, }) } placeholder='80' />
updateSelectedElement({ infoPanelCardBackgroundColor: e.target.value || undefined, }) } placeholder='rgba(0,0,0,0.3)' />
updateSelectedElement({ infoPanelCardBorderRadius: e.target.value || undefined, }) } placeholder='8' />
updateSelectedElement({ infoPanelCardAspectRatio: e.target.value || undefined, }) } placeholder='16/9' />
updateSelectedElement({ infoPanelCardMinHeight: e.target.value || undefined, }) } placeholder='auto' />

Caption Overlay

updateSelectedElement({ infoPanelCardTitleBackgroundColor: e.target.value || undefined, }) } placeholder='rgba(0,0,0,0.6)' />
updateSelectedElement({ infoPanelCardTitleColor: e.target.value, }) } />
updateSelectedElement({ infoPanelCardTitleFontSize: e.target.value || undefined, }) } placeholder='12' />
updateSelectedElement({ infoPanelCardTitlePadding: e.target.value || undefined, }) } placeholder='4 8' />
{/* Image Detail Panel - no overlay (InfoPanelOverlay provides backdrop) */} updateSelectedElement({ [prop]: value || undefined }) } hideOverlay showCaptionFont />
)} {/* General element styles (skip for info_panel - already shown above) */} {!isInfoPanelElementType(selectedElement.type) && ( handleCssPropertyChange( prop, value, updateSelectedElement, ) } /> )} )} {/* Effects Tab */} {activeTab === 'effects' && ( { // Handle slide transition properties with proper prefixes if (prop === 'slideTransitionType') { const typedValue = (value || undefined) as | 'fade' | 'none' | '' | undefined; if (selectedElement.type === 'gallery') { updateSelectedElement({ gallerySlideTransitionType: typedValue, }); } else if (selectedElement.type === 'carousel') { updateSelectedElement({ carouselSlideTransitionType: typedValue, }); } } else if (prop === 'slideTransitionDurationMs') { const ms = value ? parseInt(value, 10) : undefined; const typedMs = ms !== undefined && ms > 0 ? ms : ''; if (selectedElement.type === 'gallery') { updateSelectedElement({ gallerySlideTransitionDurationMs: typedMs, }); } else if (selectedElement.type === 'carousel') { updateSelectedElement({ carouselSlideTransitionDurationMs: typedMs, }); } } else if (prop === 'slideTransitionEasing') { // Cast to proper type - form values are validated by select options type EasingValue = | 'ease-in-out' | 'ease-in' | 'ease-out' | 'linear' | '' | undefined; const typedEasing = (value || undefined) as EasingValue; if (selectedElement.type === 'gallery') { updateSelectedElement({ gallerySlideTransitionEasing: typedEasing, }); } else if (selectedElement.type === 'carousel') { updateSelectedElement({ carouselSlideTransitionEasing: typedEasing, }); } } else if (prop === 'slideTransitionOverlayColor') { if (selectedElement.type === 'gallery') { updateSelectedElement({ gallerySlideTransitionOverlayColor: value || undefined, }); } else if (selectedElement.type === 'carousel') { updateSelectedElement({ carouselSlideTransitionOverlayColor: value || undefined, }); } } else if ( prop === 'hoverReveal' || prop === 'hoverRevealPersist' || prop === 'hoverPersistOnClick' ) { // Boolean properties - convert 'true' string to boolean updateSelectedElement({ [prop]: value === 'true', }); } else { // Standard effect properties updateSelectedElement({ [prop]: value || undefined, }); } }} audioAssetOptions={assetOptions.audio} /> )} )} )}
); } export default ElementEditorPanel;