added Info panel element

This commit is contained in:
Dmitri 2026-05-29 14:04:42 +02:00
parent e1ff820629
commit 990fc87b95
43 changed files with 7458 additions and 901 deletions

View File

@ -214,6 +214,111 @@ class Element_type_defaultsDBApi extends GenericDBApi {
appearDurationSec: null,
},
},
{
element_type: 'info_panel',
name: 'Info Panel',
sort_order: 12,
default_settings_json: {
label: 'Info Panel',
// Trigger position
xPercent: 5,
yPercent: 90,
infoPanelTriggerFontFamily: '',
// Hover reveal (disabled by default for minimal approach)
hoverReveal: false,
hoverRevealInitialOpacity: '1',
hoverRevealTargetOpacity: '1',
hoverRevealDuration: '0.3s',
// Header section (like Gallery)
infoPanelHeaderImageUrl: '',
infoPanelHeaderText: '',
infoPanelHeaderBackgroundColor: '',
infoPanelHeaderColor: '#ffffff',
infoPanelHeaderFontFamily: '',
infoPanelHeaderFontSize: '24',
infoPanelHeaderFontWeight: '700',
infoPanelHeaderPadding: '8',
infoPanelHeaderBorderRadius: '8',
infoPanelHeaderTextAlign: 'center',
infoPanelHeaderMinHeight: '',
// Panel content
panelTitle: 'Information',
panelText: '',
// Layout
infoPanelSectionGap: '12',
// Panel position & wrapper styling
panelXPercent: 30,
panelYPercent: 50,
panelWidth: '400',
panelHeight: 'auto',
panelBackgroundColor: 'rgba(0, 0, 0, 0.85)',
panelBorderRadius: '12',
panelPadding: '20',
panelBackdropBlur: '10px',
panelOverlayColor: 'rgba(0, 0, 0, 0.3)',
panelBorderWidth: '0',
panelBorderColor: '#ffffff',
panelBorderStyle: 'solid',
// Title section styles
panelTitleColor: '#ffffff',
panelTitleFontSize: '18',
panelTitleFontFamily: '',
infoPanelTitleBackgroundColor: '',
infoPanelTitlePadding: '4 8',
infoPanelTitleFontWeight: '600',
infoPanelTitleTextAlign: 'left',
// Text section styles
panelTextColor: '#cccccc',
panelTextFontSize: '14',
panelTextFontFamily: '',
// Span section styles
infoPanelSpanBackgroundColor: 'rgba(255, 255, 255, 0.1)',
infoPanelSpanColor: '#ffffff',
infoPanelSpanFontFamily: '',
infoPanelSpanFontSize: '12',
infoPanelSpanPadding: '4 8',
infoPanelSpanBorderRadius: '6',
infoPanelSpanGap: '8',
// Card section styles
infoPanelCardBackgroundColor: 'rgba(0, 0, 0, 0.3)',
infoPanelCardBorderRadius: '8',
infoPanelCardAspectRatio: '16/9',
infoPanelCardMinHeight: '',
infoPanelCardGap: '8',
// Card title overlay styles
infoPanelCardTitleBackgroundColor: 'rgba(0, 0, 0, 0.6)',
infoPanelCardTitleColor: '#ffffff',
infoPanelCardTitleFontFamily: '',
infoPanelCardTitleFontSize: '12',
infoPanelCardTitlePadding: '4 8',
// Detail panel
detailXPercent: 70,
detailYPercent: 50,
detailWidth: '500',
detailHeight: '400',
detailBackgroundColor: 'rgba(0, 0, 0, 0.9)',
detailBorderRadius: '12',
detailPadding: '12',
detailCaptionFontFamily: '',
detailBorderWidth: '0',
detailBorderColor: '#ffffff',
detailBorderStyle: 'solid',
// Note: detailOverlayColor removed - parent InfoPanelOverlay provides backdrop
// Section instances - using new format with per-section data/settings
infoPanelSections: [
{ id: 'default-header', type: 'header' },
{ id: 'default-title', type: 'title' },
{ id: 'default-text', type: 'text' },
{ id: 'default-spans', type: 'spans', columns: 3, gap: '8', spans: [] },
{ id: 'default-images', type: 'images', images: [] },
],
// Images section settings
infoPanelImagesPreviewHeight: '300',
infoPanelImagesThumbnailSize: '80',
appearDelaySec: 0,
appearDurationSec: null,
},
},
];
}

File diff suppressed because one or more lines are too long

View File

@ -7,7 +7,7 @@
* effects (hover/focus/active) only in preview mode (not edit mode).
*/
import React from 'react';
import React, { useCallback } from 'react';
import UiElementRenderer from '../UiElements/UiElementRenderer';
import { useElementEffects } from '../../hooks/useElementEffects';
import {
@ -19,6 +19,7 @@ import {
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
import type { ResolvedTransitionSettings } from '../../types/transition';
import type { PreloadCacheProvider } from '../../hooks/video';
import { isInfoPanelElementType } from '../../lib/elementDefaults';
interface CanvasElementProps {
element: CanvasElementType;
@ -36,6 +37,10 @@ interface CanvasElementProps {
x: number,
y: number,
) => void;
/** Info panel click handler */
onInfoPanelClick?: () => void;
/** Whether this element's info panel is currently open (for visibility persistence) */
isInfoPanelOpen?: boolean;
/** Letterbox styles for constraining fullscreen elements to canvas bounds */
letterboxStyles?: React.CSSProperties;
/** Page transition settings (for slide transition cascade in carousel/gallery) */
@ -53,6 +58,8 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
resolveUrl,
onGalleryCardClick,
onCarouselButtonPositionChange,
onInfoPanelClick,
isInfoPanelOpen = false,
letterboxStyles,
pageTransitionSettings,
preloadCache,
@ -86,8 +93,10 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
};
// Use effects hook - disabled in edit mode to avoid interfering with dragging
const { effectStyle, eventHandlers } = useElementEffects(
// Pass forceVisible when info panel is open to keep trigger visible
const { effectStyle, eventHandlers, onPersistClick } = useElementEffects(
isEditMode ? {} : effectProperties,
{ forceVisible: isInfoPanelOpen },
);
// Clamp position to canvas bounds (0-100%)
@ -122,11 +131,20 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
};
}
// Wrapped click handler - toggles persistence state in preview mode
// Skip toggle for info panel elements (their visibility is tied to panel open state)
const handleClick = useCallback(() => {
if (!isEditMode && !isInfoPanelElementType(element.type)) {
onPersistClick(); // Toggle persistence state for hover reveal
}
onClick();
}, [isEditMode, element.type, onPersistClick, onClick]);
// Handle keyboard interaction for accessibility
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick();
handleClick();
}
};
@ -141,7 +159,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
className='absolute cursor-pointer'
style={positionStyle}
onMouseDown={isEditMode ? onMouseDown : undefined}
onClick={onClick}
onClick={handleClick}
onKeyDown={handleKeyDown}
{...(!isEditMode && !needsEffectWrapper ? eventHandlers : {})}
>
@ -155,6 +173,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
isEditMode={isEditMode}
onGalleryCardClick={onGalleryCardClick}
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
onInfoPanelClick={onInfoPanelClick}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
@ -168,6 +187,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
isEditMode={isEditMode}
onGalleryCardClick={onGalleryCardClick}
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
onInfoPanelClick={onInfoPanelClick}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}

View File

@ -11,7 +11,6 @@ import {
mdiChevronDown,
mdiImageMultiple,
mdiViewCarousel,
mdiTooltipText,
mdiSwapHorizontal,
mdiText,
mdiPlus,
@ -20,6 +19,7 @@ import {
mdiChevronRight,
mdiMusicNote,
mdiVideo,
mdiInformationOutline,
} from '@mdi/js';
import BaseIcon from '../BaseIcon';
import BaseButton from '../BaseButton';
@ -92,7 +92,7 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
const triggerBtnClass =
'flex items-center gap-1.5 px-3 py-2 rounded text-sm font-medium text-white/90 hover:bg-white/20 transition-colors';
const dropdownPanelClass =
'absolute top-full left-0 mt-1 min-w-[180px] py-1 rounded-lg bg-white/50 backdrop-blur-xl border border-white/30 shadow-lg z-10';
'absolute top-full left-0 mt-1 min-w-[180px] py-1 rounded-lg bg-white/50 backdrop-blur-xl border border-white/30 shadow-lg z-10 flex flex-col items-start';
// Collapsed state
if (isCollapsed) {
@ -225,15 +225,6 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
)
}
/>
<MenuActionButton
icon={mdiSwapHorizontal}
label='Transition'
onClick={() =>
handleMenuAction(() =>
onSelectMenuItem('create_transition'),
)
}
/>
<MenuActionButton
icon={mdiImageMultiple}
label='Gallery'
@ -248,13 +239,6 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
handleMenuAction(() => onAddElement('carousel'))
}
/>
<MenuActionButton
icon={mdiTooltipText}
label='Tooltip'
onClick={() =>
handleMenuAction(() => onAddElement('tooltip'))
}
/>
<MenuActionButton
icon={mdiText}
label='Description'
@ -276,6 +260,13 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
handleMenuAction(() => onAddElement('audio_player'))
}
/>
<MenuActionButton
icon={mdiInformationOutline}
label='Info Panel'
onClick={() =>
handleMenuAction(() => onAddElement('info_panel'))
}
/>
</div>
</ClickOutside>
)}

View File

@ -1,92 +0,0 @@
/**
* CreateTransitionForm Component
*
* Form for creating a new transition (legacy functionality).
* Transitions are now typically stored directly on navigation elements.
*/
import React from 'react';
import BaseIcon from '../BaseIcon';
import { mdiSwapHorizontal } from '@mdi/js';
import type { AssetOption } from './types';
interface CreateTransitionFormProps {
name: string;
videoUrl: string;
supportsReverse: boolean;
videoOptions: AssetOption[];
durationNote: string;
isCreating: boolean;
onNameChange: (name: string) => void;
onVideoUrlChange: (url: string) => void;
onSupportsReverseChange: (value: boolean) => void;
onSubmit: () => void;
}
const CreateTransitionForm: React.FC<CreateTransitionFormProps> = ({
name,
videoUrl,
supportsReverse,
videoOptions,
durationNote,
isCreating,
onNameChange,
onVideoUrlChange,
onSupportsReverseChange,
onSubmit,
}) => {
return (
<div className='rounded border border-white/20 p-2 space-y-2'>
<p className='text-[11px] font-semibold text-white/80'>
Create next page transition
</p>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
placeholder='Name'
value={name}
onChange={(event) => onNameChange(event.target.value)}
/>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={videoUrl}
onChange={(event) => onVideoUrlChange(event.target.value)}
>
<option value=''>Transition video asset</option>
{videoOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<p className='text-[11px] text-white/60'>
Transition duration is automatic from video metadata. {durationNote}
</p>
<label className='flex items-center gap-2 text-[11px] text-white/80'>
<input
type='checkbox'
checked={supportsReverse}
onChange={(event) => onSupportsReverseChange(event.target.checked)}
/>
Supports reverse playback
</label>
<button
type='button'
className='menu-action-btn'
onClick={onSubmit}
disabled={isCreating}
>
<BaseIcon path={mdiSwapHorizontal} size={16} />
<span>
{isCreating ? 'Creating Transition...' : 'Create Transition'}
</span>
</button>
</div>
);
};
export default CreateTransitionForm;

File diff suppressed because it is too large Load Diff

View File

@ -185,22 +185,6 @@ export interface BackgroundSettingsEditorProps {
onVideoSettingsChange?: (settings: VideoPlaybackSettings) => void;
}
/**
* Create transition form props
*/
export interface CreateTransitionFormProps {
name: string;
videoUrl: string;
supportsReverse: boolean;
videoOptions: AssetOption[];
durationNote: string;
isCreating: boolean;
onNameChange: (name: string) => void;
onVideoUrlChange: (url: string) => void;
onSupportsReverseChange: (value: boolean) => void;
onSubmit: () => void;
}
/**
* Element editor panel props
*/
@ -229,14 +213,6 @@ export interface ElementEditorPanelProps {
backgroundVideoEndTime: number | null;
onBackgroundVideoSettingsChange: (settings: VideoPlaybackSettings) => void;
// Transition form
newTransitionName: string;
newTransitionVideoUrl: string;
newTransitionSupportsReverse: boolean;
transitionVideoOptions: AssetOption[];
newTransitionDurationNote: string;
isCreatingTransition: boolean;
// Asset options for elements
imageAssetOptions: AssetOption[];
videoAssetOptions: AssetOption[];

View File

@ -0,0 +1,910 @@
/**
* InfoPanelSettingsSection
*
* Full-width info panel settings for element-type-defaults and project-element-defaults pages.
* Includes: Trigger Button, Section Order, Info Panel sections, Layout, Styling, Image Detail Panel.
*/
import React, { useMemo } from 'react';
import FormField from '../FormField';
import type { InfoPanelSettingsSectionProps } from './types';
import { FONT_OPTIONS } from '../../lib/fonts';
import {
DEFAULT_INFO_PANEL_SECTIONS,
INFO_PANEL_SECTION_LABELS,
type InfoPanelSectionType,
type InfoPanelSectionInstance,
} from '../../types/constructor';
/** All available section types for "Add Section" dropdown */
const ALL_SECTION_TYPES: InfoPanelSectionType[] = [
'header',
'title',
'text',
'spans',
'cards',
'images',
];
const InfoPanelSettingsSection: React.FC<InfoPanelSettingsSectionProps> = ({
// Trigger button settings
iconUrl,
infoPanelTriggerLabel,
infoPanelTriggerFontFamily,
infoPanelDisabled,
// Header section
infoPanelHeaderImageUrl,
infoPanelHeaderText,
infoPanelHeaderBackgroundColor,
infoPanelHeaderColor,
infoPanelHeaderFontFamily,
infoPanelHeaderFontSize,
infoPanelHeaderFontWeight,
infoPanelHeaderPadding,
infoPanelHeaderBorderRadius,
infoPanelHeaderTextAlign,
infoPanelHeaderMinHeight,
// Panel content
panelTitle,
panelText,
// Span section styles
infoPanelSpanBackgroundColor,
infoPanelSpanColor,
infoPanelSpanFontFamily,
infoPanelSpanFontSize,
infoPanelSpanPadding,
infoPanelSpanBorderRadius,
// Card styling
infoPanelCardBackgroundColor,
infoPanelCardBorderRadius,
infoPanelCardAspectRatio,
infoPanelCardMinHeight,
infoPanelCardTitleBackgroundColor,
infoPanelCardTitleColor,
infoPanelCardTitleFontFamily,
infoPanelCardTitleFontSize,
infoPanelCardTitlePadding,
// Title section styling
infoPanelTitleBackgroundColor,
infoPanelTitlePadding,
infoPanelTitleFontWeight,
infoPanelTitleTextAlign,
// Panel position & styling
panelXPercent,
panelYPercent,
panelWidth,
panelHeight,
panelBackgroundColor,
panelBorderRadius,
panelPadding,
panelBackdropBlur,
panelOverlayColor,
// Detail panel position & styling (no overlay - parent InfoPanelOverlay provides backdrop)
detailXPercent,
detailYPercent,
detailWidth,
detailHeight,
detailBackgroundColor,
detailBorderRadius,
detailPadding,
detailCaptionFontFamily,
// Section instances (order + per-section settings)
infoPanelSections,
// Images section settings
infoPanelImagesPreviewHeight,
infoPanelImagesThumbnailSize,
// Handlers
onChange,
onMoveSection,
onRemoveSection,
onAddSection,
context,
iconAssetOptions = [],
imageAssetOptions = [],
}) => {
const isConstructor = context === 'constructor';
// Compute effective sections (instances with IDs)
const sections: InfoPanelSectionInstance[] = useMemo(
() =>
infoPanelSections && infoPanelSections.length > 0
? infoPanelSections
: DEFAULT_INFO_PANEL_SECTIONS,
[infoPanelSections],
);
return (
<div className='space-y-6'>
{/* Trigger Button Section */}
<div className='rounded-lg border border-gray-200 p-4 dark:border-dark-700'>
<h3 className='mb-4 text-sm font-semibold text-gray-900 dark:text-white'>
Trigger Button
</h3>
<div className='space-y-4'>
<FormField label='Button Text'>
<input
value={infoPanelTriggerLabel}
onChange={(e) =>
onChange('infoPanelTriggerLabel', e.target.value)
}
placeholder='Info'
/>
<p className='mt-1 text-xs text-gray-500'>
Shown when no icon is selected
</p>
</FormField>
<FormField label='Label Font'>
<select
value={infoPanelTriggerFontFamily}
onChange={(e) =>
onChange('infoPanelTriggerFontFamily', e.target.value)
}
>
<option value=''>Default</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</FormField>
{isConstructor ? (
<FormField label='Icon'>
<select
value={iconUrl}
onChange={(e) => onChange('iconUrl', e.target.value)}
>
<option value=''>No icon (show text)</option>
{iconAssetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormField>
) : (
<FormField label='Icon URL'>
<input
value={iconUrl}
onChange={(e) => onChange('iconUrl', e.target.value)}
placeholder='Leave empty to show text label'
/>
</FormField>
)}
<FormField label='Disabled'>
<label className='inline-flex items-center gap-2 text-sm'>
<input
type='checkbox'
checked={infoPanelDisabled}
onChange={(e) =>
onChange('infoPanelDisabled', e.target.checked)
}
/>
Disable this info panel
</label>
</FormField>
</div>
</div>
{/* Section Order */}
{onMoveSection && onRemoveSection && onAddSection && (
<div className='rounded-lg border border-gray-200 p-4 dark:border-dark-700'>
<h3 className='mb-4 text-sm font-semibold text-gray-900 dark:text-white'>
Section Order
</h3>
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
Configure which sections are displayed and in what order. You can
add multiple instances of the same section type.
</p>
<div className='space-y-2'>
{sections.map((section, index) => (
<div
key={section.id}
className='flex items-center gap-2 rounded border border-gray-200 bg-gray-50 p-2 dark:border-dark-600 dark:bg-dark-800'
>
<div className='flex flex-col gap-0.5'>
<button
type='button'
onClick={() => onMoveSection(section.id, 'up')}
disabled={index === 0}
className='rounded p-0.5 text-gray-500 hover:bg-gray-200 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-30 dark:hover:bg-dark-600 dark:hover:text-gray-300'
title='Move up'
>
<svg
className='h-3 w-3'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 15l7-7 7 7'
/>
</svg>
</button>
<button
type='button'
onClick={() => onMoveSection(section.id, 'down')}
disabled={index === sections.length - 1}
className='rounded p-0.5 text-gray-500 hover:bg-gray-200 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-30 dark:hover:bg-dark-600 dark:hover:text-gray-300'
title='Move down'
>
<svg
className='h-3 w-3'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M19 9l-7 7-7-7'
/>
</svg>
</button>
</div>
<span className='flex-1 text-sm font-medium text-gray-700 dark:text-gray-300'>
{INFO_PANEL_SECTION_LABELS[section.type]}
</span>
<button
type='button'
onClick={() => onRemoveSection(section.id)}
className='rounded p-1 text-red-500 hover:bg-red-100 hover:text-red-700 dark:hover:bg-red-900/30'
title='Remove section'
>
<svg
className='h-4 w-4'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg>
</button>
</div>
))}
{sections.length === 0 && (
<p className='py-4 text-center text-sm text-gray-500'>
No sections enabled. Add sections below.
</p>
)}
</div>
{/* Add Section - all types available (duplicates allowed) */}
<div className='mt-4 border-t border-gray-200 pt-4 dark:border-dark-600'>
<h4 className='mb-2 text-xs font-semibold uppercase text-gray-500'>
Add Section
</h4>
<div className='flex flex-wrap gap-2'>
{ALL_SECTION_TYPES.map((sectionType) => (
<button
key={sectionType}
type='button'
onClick={() => onAddSection(sectionType)}
className='rounded bg-blue-500 px-3 py-1 text-sm text-white hover:bg-blue-600'
>
+ {INFO_PANEL_SECTION_LABELS[sectionType]}
</button>
))}
</div>
</div>
</div>
)}
{/* Header Section */}
<div className='rounded-lg border border-gray-200 p-4 dark:border-dark-700'>
<h3 className='mb-4 text-sm font-semibold text-gray-900 dark:text-white'>
Header Section
</h3>
<div className='space-y-4'>
{isConstructor ? (
<FormField label='Header Image'>
<select
value={infoPanelHeaderImageUrl || ''}
onChange={(e) =>
onChange('infoPanelHeaderImageUrl', e.target.value)
}
>
<option value=''>No image (show text)</option>
{imageAssetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormField>
) : (
<FormField label='Header Image URL'>
<input
value={infoPanelHeaderImageUrl || ''}
onChange={(e) =>
onChange('infoPanelHeaderImageUrl', e.target.value)
}
placeholder='Leave empty to show header text'
/>
</FormField>
)}
<FormField label='Header Text'>
<input
value={infoPanelHeaderText || ''}
onChange={(e) => onChange('infoPanelHeaderText', e.target.value)}
placeholder='Shown when no image is selected'
/>
</FormField>
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='Background Color'>
<input
value={infoPanelHeaderBackgroundColor || ''}
onChange={(e) =>
onChange('infoPanelHeaderBackgroundColor', e.target.value)
}
placeholder='rgba(0, 0, 0, 0.3)'
/>
</FormField>
<FormField label='Text Color'>
<input
type='color'
value={infoPanelHeaderColor || '#ffffff'}
onChange={(e) =>
onChange('infoPanelHeaderColor', e.target.value)
}
className='h-10 w-full'
/>
</FormField>
<FormField label='Font Family'>
<select
value={infoPanelHeaderFontFamily || ''}
onChange={(e) =>
onChange('infoPanelHeaderFontFamily', e.target.value)
}
>
<option value=''>Default</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</FormField>
<FormField label='Font Size'>
<input
value={infoPanelHeaderFontSize || ''}
onChange={(e) =>
onChange('infoPanelHeaderFontSize', e.target.value)
}
placeholder='24'
/>
</FormField>
<FormField label='Font Weight'>
<input
value={infoPanelHeaderFontWeight || ''}
onChange={(e) =>
onChange('infoPanelHeaderFontWeight', e.target.value)
}
placeholder='700'
/>
</FormField>
<FormField label='Text Align'>
<select
value={infoPanelHeaderTextAlign || ''}
onChange={(e) =>
onChange('infoPanelHeaderTextAlign', e.target.value)
}
>
<option value=''>Default</option>
<option value='left'>Left</option>
<option value='center'>Center</option>
<option value='right'>Right</option>
</select>
</FormField>
<FormField label='Padding'>
<input
value={infoPanelHeaderPadding || ''}
onChange={(e) =>
onChange('infoPanelHeaderPadding', e.target.value)
}
placeholder='8'
/>
</FormField>
<FormField label='Border Radius'>
<input
value={infoPanelHeaderBorderRadius || ''}
onChange={(e) =>
onChange('infoPanelHeaderBorderRadius', e.target.value)
}
placeholder='8'
/>
</FormField>
<FormField label='Min Height'>
<input
value={infoPanelHeaderMinHeight || ''}
onChange={(e) =>
onChange('infoPanelHeaderMinHeight', e.target.value)
}
placeholder='auto'
/>
</FormField>
</div>
</div>
</div>
{/* Info Panel Section */}
<div className='rounded-lg border border-gray-200 p-4 dark:border-dark-700'>
<h3 className='mb-4 text-sm font-semibold text-gray-900 dark:text-white'>
Info Panel
</h3>
<div className='space-y-4'>
{/* Content */}
<FormField label='Panel Title'>
<input
value={panelTitle}
onChange={(e) => onChange('panelTitle', e.target.value)}
placeholder='Information'
/>
</FormField>
<FormField label='Panel Text' hasTextareaHeight>
<textarea
value={panelText}
onChange={(e) => onChange('panelText', e.target.value)}
className='h-28 w-full rounded border border-gray-300 p-2 dark:border-dark-700 dark:bg-dark-900'
placeholder='Description text...'
/>
</FormField>
{/* Title Section Enhanced Styling */}
<div className='border-t border-gray-200 pt-4 dark:border-dark-600'>
<h4 className='mb-3 text-xs font-semibold text-gray-500 uppercase'>
Title Section Styling
</h4>
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='Background Color'>
<input
value={infoPanelTitleBackgroundColor || ''}
onChange={(e) =>
onChange('infoPanelTitleBackgroundColor', e.target.value)
}
placeholder='transparent'
/>
</FormField>
<FormField label='Padding'>
<input
value={infoPanelTitlePadding || ''}
onChange={(e) =>
onChange('infoPanelTitlePadding', e.target.value)
}
placeholder='4 8'
/>
</FormField>
<FormField label='Font Weight'>
<input
value={infoPanelTitleFontWeight || ''}
onChange={(e) =>
onChange('infoPanelTitleFontWeight', e.target.value)
}
placeholder='600'
/>
</FormField>
<FormField label='Text Align'>
<select
value={infoPanelTitleTextAlign || ''}
onChange={(e) =>
onChange('infoPanelTitleTextAlign', e.target.value)
}
>
<option value=''>Default</option>
<option value='left'>Left</option>
<option value='center'>Center</option>
<option value='right'>Right</option>
</select>
</FormField>
</div>
</div>
{/* Position */}
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='X Position (%)'>
<input
type='number'
min='0'
max='100'
value={panelXPercent}
onChange={(e) => onChange('panelXPercent', e.target.value)}
/>
</FormField>
<FormField label='Y Position (%)'>
<input
type='number'
min='0'
max='100'
value={panelYPercent}
onChange={(e) => onChange('panelYPercent', e.target.value)}
/>
</FormField>
</div>
{/* Dimensions */}
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='Width'>
<input
value={panelWidth}
onChange={(e) => onChange('panelWidth', e.target.value)}
placeholder='400'
/>
</FormField>
<FormField label='Height'>
<input
value={panelHeight}
onChange={(e) => onChange('panelHeight', e.target.value)}
placeholder='auto'
/>
</FormField>
</div>
{/* Styling */}
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='Background Color'>
<input
value={panelBackgroundColor}
onChange={(e) =>
onChange('panelBackgroundColor', e.target.value)
}
placeholder='rgba(0, 0, 0, 0.85)'
/>
</FormField>
<FormField label='Overlay Color'>
<input
value={panelOverlayColor}
onChange={(e) => onChange('panelOverlayColor', e.target.value)}
placeholder='rgba(0, 0, 0, 0.3)'
/>
</FormField>
<FormField label='Border Radius'>
<input
value={panelBorderRadius}
onChange={(e) => onChange('panelBorderRadius', e.target.value)}
placeholder='12'
/>
</FormField>
<FormField label='Padding'>
<input
value={panelPadding}
onChange={(e) => onChange('panelPadding', e.target.value)}
placeholder='20'
/>
</FormField>
<FormField label='Backdrop Blur'>
<input
value={panelBackdropBlur}
onChange={(e) => onChange('panelBackdropBlur', e.target.value)}
placeholder='10px'
/>
</FormField>
</div>
</div>
</div>
{/* Span Styling Section */}
<div className='rounded-lg border border-gray-200 p-4 dark:border-dark-700'>
<h3 className='mb-4 text-sm font-semibold text-gray-900 dark:text-white'>
Span Styling
</h3>
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
Default styling for spans. Individual spans are managed per-section in
the constructor.
</p>
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='Background Color'>
<input
value={infoPanelSpanBackgroundColor || ''}
onChange={(e) =>
onChange('infoPanelSpanBackgroundColor', e.target.value)
}
placeholder='rgba(255, 255, 255, 0.1)'
/>
</FormField>
<FormField label='Text Color'>
<input
type='color'
value={infoPanelSpanColor || '#ffffff'}
onChange={(e) => onChange('infoPanelSpanColor', e.target.value)}
className='h-10 w-full'
/>
</FormField>
<FormField label='Font Family'>
<select
value={infoPanelSpanFontFamily || ''}
onChange={(e) =>
onChange('infoPanelSpanFontFamily', e.target.value)
}
>
<option value=''>Default</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</FormField>
<FormField label='Font Size'>
<input
value={infoPanelSpanFontSize || ''}
onChange={(e) =>
onChange('infoPanelSpanFontSize', e.target.value)
}
placeholder='12'
/>
</FormField>
<FormField label='Padding'>
<input
value={infoPanelSpanPadding || ''}
onChange={(e) => onChange('infoPanelSpanPadding', e.target.value)}
placeholder='4 8'
/>
</FormField>
<FormField label='Border Radius'>
<input
value={infoPanelSpanBorderRadius || ''}
onChange={(e) =>
onChange('infoPanelSpanBorderRadius', e.target.value)
}
placeholder='6'
/>
</FormField>
</div>
</div>
{/* Card Styling Section */}
<div className='rounded-lg border border-gray-200 p-4 dark:border-dark-700'>
<h3 className='mb-4 text-sm font-semibold text-gray-900 dark:text-white'>
Card Styling
</h3>
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
Default styling for cards. Individual cards are managed per-section in
the constructor.
</p>
<div className='space-y-4'>
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='Background Color'>
<input
value={infoPanelCardBackgroundColor || ''}
onChange={(e) =>
onChange('infoPanelCardBackgroundColor', e.target.value)
}
placeholder='rgba(0, 0, 0, 0.3)'
/>
</FormField>
<FormField label='Border Radius'>
<input
value={infoPanelCardBorderRadius || ''}
onChange={(e) =>
onChange('infoPanelCardBorderRadius', e.target.value)
}
placeholder='8'
/>
</FormField>
<FormField label='Aspect Ratio'>
<input
value={infoPanelCardAspectRatio || ''}
onChange={(e) =>
onChange('infoPanelCardAspectRatio', e.target.value)
}
placeholder='16/9'
/>
</FormField>
<FormField label='Min Height'>
<input
value={infoPanelCardMinHeight || ''}
onChange={(e) =>
onChange('infoPanelCardMinHeight', e.target.value)
}
placeholder='auto'
/>
</FormField>
</div>
{/* Card Title Styling */}
<div className='border-t border-gray-200 pt-4 dark:border-dark-600'>
<h4 className='mb-3 text-xs font-semibold text-gray-500 uppercase'>
Card Title Overlay
</h4>
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='Background Color'>
<input
value={infoPanelCardTitleBackgroundColor || ''}
onChange={(e) =>
onChange(
'infoPanelCardTitleBackgroundColor',
e.target.value,
)
}
placeholder='rgba(0, 0, 0, 0.6)'
/>
</FormField>
<FormField label='Text Color'>
<input
type='color'
value={infoPanelCardTitleColor || '#ffffff'}
onChange={(e) =>
onChange('infoPanelCardTitleColor', e.target.value)
}
className='h-10 w-full'
/>
</FormField>
<FormField label='Font Family'>
<select
value={infoPanelCardTitleFontFamily || ''}
onChange={(e) =>
onChange('infoPanelCardTitleFontFamily', e.target.value)
}
>
<option value=''>Default</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</FormField>
<FormField label='Font Size'>
<input
value={infoPanelCardTitleFontSize || ''}
onChange={(e) =>
onChange('infoPanelCardTitleFontSize', e.target.value)
}
placeholder='12'
/>
</FormField>
<FormField label='Padding'>
<input
value={infoPanelCardTitlePadding || ''}
onChange={(e) =>
onChange('infoPanelCardTitlePadding', e.target.value)
}
placeholder='4 8'
/>
</FormField>
</div>
</div>
</div>
</div>
{/* Images Section Layout */}
<div className='rounded-lg border border-gray-200 p-4 dark:border-dark-700'>
<h3 className='mb-4 text-sm font-semibold text-gray-900 dark:text-white'>
Images Section Layout
</h3>
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
Settings for the inline image viewer (preview + thumbnail strip). Use
the &quot;images&quot; section type in Section Order to enable.
</p>
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='Preview Height'>
<input
value={infoPanelImagesPreviewHeight || ''}
onChange={(e) =>
onChange('infoPanelImagesPreviewHeight', e.target.value)
}
placeholder='300 or auto'
/>
</FormField>
<FormField label='Thumbnail Size'>
<input
value={infoPanelImagesThumbnailSize || ''}
onChange={(e) =>
onChange('infoPanelImagesThumbnailSize', e.target.value)
}
placeholder='80'
/>
</FormField>
</div>
</div>
{/* Image Detail Panel Section */}
<div className='rounded-lg border border-gray-200 p-4 dark:border-dark-700'>
<h3 className='mb-4 text-sm font-semibold text-gray-900 dark:text-white'>
Image Detail Panel
</h3>
<div className='space-y-4'>
{/* Position */}
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='X Position (%)'>
<input
type='number'
min='0'
max='100'
value={detailXPercent}
onChange={(e) => onChange('detailXPercent', e.target.value)}
/>
</FormField>
<FormField label='Y Position (%)'>
<input
type='number'
min='0'
max='100'
value={detailYPercent}
onChange={(e) => onChange('detailYPercent', e.target.value)}
/>
</FormField>
</div>
{/* Dimensions */}
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='Width'>
<input
value={detailWidth}
onChange={(e) => onChange('detailWidth', e.target.value)}
placeholder='500'
/>
</FormField>
<FormField label='Height'>
<input
value={detailHeight}
onChange={(e) => onChange('detailHeight', e.target.value)}
placeholder='400'
/>
</FormField>
</div>
{/* Styling (no overlay - parent InfoPanelOverlay provides backdrop) */}
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='Background Color'>
<input
value={detailBackgroundColor}
onChange={(e) =>
onChange('detailBackgroundColor', e.target.value)
}
placeholder='rgba(0, 0, 0, 0.9)'
/>
</FormField>
<FormField label='Border Radius'>
<input
value={detailBorderRadius}
onChange={(e) => onChange('detailBorderRadius', e.target.value)}
placeholder='12'
/>
</FormField>
<FormField label='Padding'>
<input
value={detailPadding}
onChange={(e) => onChange('detailPadding', e.target.value)}
placeholder='12'
/>
</FormField>
<FormField label='Caption Font'>
<select
value={detailCaptionFontFamily}
onChange={(e) =>
onChange('detailCaptionFontFamily', e.target.value)
}
>
<option value=''>Default</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</FormField>
</div>
</div>
</div>
</div>
);
};
export default InfoPanelSettingsSection;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,247 @@
/**
* InfoPanelStyleInputs
*
* Reusable component for info panel section style inputs.
* Used in the CSS tab to configure Info Panel and Image Detail Panel styles.
*/
import React from 'react';
import { FONT_OPTIONS } from '../../lib/fonts';
interface InfoPanelStyleInputsProps {
sectionLabel: string;
prefix: 'panel' | 'detail';
values: {
xPercent: number;
yPercent: number;
width: string;
height: string;
backgroundColor: string;
borderRadius: string;
padding?: string;
backdropBlur?: string;
overlayColor?: string;
captionFontFamily?: string;
borderWidth?: string;
borderColor?: string;
borderStyle?: 'none' | 'solid' | 'dashed' | 'dotted';
};
onChange: (prop: string, value: string | number) => void;
/** Hide overlay color input (e.g., for Image Detail Panel which uses parent overlay) */
hideOverlay?: boolean;
/** Show caption font selector (for Image Detail Panel) */
showCaptionFont?: boolean;
}
/**
* Reusable inputs for info panel section styling
*/
const InfoPanelStyleInputs: React.FC<InfoPanelStyleInputsProps> = ({
sectionLabel,
prefix,
values,
onChange,
hideOverlay = false,
showCaptionFont = false,
}) => {
// Map prefix to actual property names
const propName = (field: string) => {
if (prefix === 'panel') {
return `panel${field.charAt(0).toUpperCase()}${field.slice(1)}`;
} else {
return `detail${field.charAt(0).toUpperCase()}${field.slice(1)}`;
}
};
return (
<div className='rounded border border-white/20 p-2 space-y-2'>
<p className='text-[11px] font-semibold text-white/90'>{sectionLabel}</p>
{/* Position */}
<p className='text-[10px] text-white/60'>Position:</p>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[10px] text-white/70'>X (%)</label>
<input
type='number'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.xPercent}
onChange={(e) =>
onChange(propName('xPercent'), Number(e.target.value))
}
min={0}
max={100}
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>Y (%)</label>
<input
type='number'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.yPercent}
onChange={(e) =>
onChange(propName('yPercent'), Number(e.target.value))
}
min={0}
max={100}
/>
</div>
</div>
{/* Size */}
<p className='text-[10px] text-white/60 pt-1'>Size:</p>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[10px] text-white/70'>Width</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.width}
onChange={(e) => onChange(propName('width'), e.target.value)}
placeholder={prefix === 'panel' ? '400' : '500'}
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>Height</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.height}
onChange={(e) => onChange(propName('height'), e.target.value)}
placeholder={prefix === 'panel' ? 'auto' : '400'}
/>
</div>
</div>
{/* Styling */}
<p className='text-[10px] text-white/60 pt-1'>Styling:</p>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Background
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.backgroundColor}
onChange={(e) =>
onChange(propName('backgroundColor'), e.target.value)
}
placeholder='rgba(0,0,0,0.85)'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>Radius</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.borderRadius}
onChange={(e) => onChange(propName('borderRadius'), e.target.value)}
placeholder='12'
/>
</div>
{values.padding !== undefined && (
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Padding
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.padding}
onChange={(e) => onChange(propName('padding'), e.target.value)}
placeholder='20'
/>
</div>
)}
{values.backdropBlur !== undefined && (
<div>
<label className='mb-1 block text-[10px] text-white/70'>Blur</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.backdropBlur}
onChange={(e) =>
onChange(propName('backdropBlur'), e.target.value)
}
placeholder='10px'
/>
</div>
)}
</div>
{/* Border */}
<p className='text-[10px] text-white/60 pt-1'>Border:</p>
<div className='grid grid-cols-3 gap-2'>
<div>
<label className='mb-1 block text-[10px] text-white/70'>Width</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.borderWidth || ''}
onChange={(e) => onChange(propName('borderWidth'), e.target.value)}
placeholder='0'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>Color</label>
<input
type='color'
className='w-full h-6 rounded border border-gray-300'
value={values.borderColor || '#ffffff'}
onChange={(e) => onChange(propName('borderColor'), e.target.value)}
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>Style</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.borderStyle || 'solid'}
onChange={(e) => onChange(propName('borderStyle'), e.target.value)}
>
<option value='none'>None</option>
<option value='solid'>Solid</option>
<option value='dashed'>Dashed</option>
<option value='dotted'>Dotted</option>
</select>
</div>
</div>
{/* Overlay (hidden for Image Detail Panel - parent provides backdrop) */}
{!hideOverlay && (
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Overlay color
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.overlayColor || ''}
onChange={(e) => onChange(propName('overlayColor'), e.target.value)}
placeholder='rgba(0,0,0,0.3) or transparent'
/>
<p className='mt-1 text-[9px] text-white/40'>
Use &quot;transparent&quot; for no overlay
</p>
</div>
)}
{/* Caption Font (for Image Detail Panel) */}
{showCaptionFont && (
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Caption font
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.captionFontFamily || ''}
onChange={(e) =>
onChange(propName('captionFontFamily'), e.target.value)
}
>
<option value=''>Default</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</div>
)}
</div>
);
};
export default InfoPanelStyleInputs;

View File

@ -7,6 +7,7 @@
import React from 'react';
import type { StyleSettingsSectionProps } from './types';
import { FONT_OPTIONS } from '../../lib/fonts';
const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
values,
@ -134,6 +135,23 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
placeholder='e.g. 1'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Font
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.fontFamily || ''}
onChange={(e) => onChange('fontFamily', e.target.value)}
>
<option value=''>Default</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Font size

View File

@ -1,100 +0,0 @@
/**
* TooltipSettingsSection
*
* Settings for tooltip element type.
*/
import React from 'react';
import FormField from '../FormField';
import type { TooltipSettingsSectionProps } from './types';
import { FONT_OPTIONS } from '../../lib/fonts';
const TooltipSettingsSection: React.FC<TooltipSettingsSectionProps> = ({
iconUrl,
tooltipTitle,
tooltipText,
tooltipTitleFontFamily,
tooltipTextFontFamily,
onChange,
context,
iconAssetOptions = [],
}) => {
const isConstructor = context === 'constructor';
return (
<div className='space-y-4'>
{isConstructor ? (
<FormField label='Icon'>
<select
value={iconUrl}
onChange={(event) => onChange('iconUrl', event.target.value)}
>
<option value=''>Not selected</option>
{iconAssetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormField>
) : (
<FormField label='Icon URL'>
<input
value={iconUrl}
onChange={(event) => onChange('iconUrl', event.target.value)}
/>
</FormField>
)}
<FormField label='Tooltip title'>
<input
value={tooltipTitle}
onChange={(event) => onChange('tooltipTitle', event.target.value)}
/>
</FormField>
<FormField label='Tooltip text' hasTextareaHeight>
<textarea
value={tooltipText}
onChange={(event) => onChange('tooltipText', event.target.value)}
className='h-28 w-full rounded border border-gray-300 p-2 dark:border-dark-700 dark:bg-dark-900'
/>
</FormField>
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='Title font family'>
<select
value={tooltipTitleFontFamily}
onChange={(event) =>
onChange('tooltipTitleFontFamily', event.target.value)
}
>
<option value=''>Not set</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</FormField>
<FormField label='Text font family'>
<select
value={tooltipTextFontFamily}
onChange={(event) =>
onChange('tooltipTextFontFamily', event.target.value)
}
>
<option value=''>Not set</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</FormField>
</div>
</div>
);
};
export default TooltipSettingsSection;

View File

@ -1,124 +0,0 @@
/**
* TooltipSettingsSectionCompact
*
* Compact tooltip element settings for constructor sidebar.
* Icon, title, and text fields.
*/
import React from 'react';
import type { AssetOption } from '../../types/constructor';
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
import { FONT_OPTIONS } from '../../lib/fonts';
interface TooltipSettingsSectionCompactProps {
iconUrl: string;
tooltipTitle: string;
tooltipText: string;
tooltipTitleFontFamily: string;
tooltipTextFontFamily: string;
iconAssetOptions: AssetOption[];
onChange: (prop: string, value: string) => void;
}
const TooltipSettingsSectionCompact: React.FC<
TooltipSettingsSectionCompactProps
> = ({
iconUrl,
tooltipTitle,
tooltipText,
tooltipTitleFontFamily,
tooltipTextFontFamily,
iconAssetOptions,
onChange,
}) => {
return (
<div className='space-y-2'>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Icon
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={iconUrl}
onChange={(event) => onChange('iconUrl', event.target.value)}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
iconAssetOptions,
iconUrl,
`Current icon · ${iconUrl}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Tooltip title
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={tooltipTitle}
onChange={(event) => onChange('tooltipTitle', event.target.value)}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Tooltip text
</label>
<textarea
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
rows={4}
value={tooltipText}
onChange={(event) => onChange('tooltipText', event.target.value)}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Title font family
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={tooltipTitleFontFamily}
onChange={(event) =>
onChange('tooltipTitleFontFamily', event.target.value)
}
>
<option value=''>Not set</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Text font family
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={tooltipTextFontFamily}
onChange={(event) =>
onChange('tooltipTextFontFamily', event.target.value)
}
>
<option value=''>Not set</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</div>
</div>
);
};
export default TooltipSettingsSectionCompact;

View File

@ -17,8 +17,6 @@ export { default as CommonSettingsSection } from './CommonSettingsSection';
export { default as CommonSettingsSectionCompact } from './CommonSettingsSectionCompact';
export { default as NavigationSettingsSection } from './NavigationSettingsSection';
export { default as NavigationSettingsSectionCompact } from './NavigationSettingsSectionCompact';
export { default as TooltipSettingsSection } from './TooltipSettingsSection';
export { default as TooltipSettingsSectionCompact } from './TooltipSettingsSectionCompact';
export { default as DescriptionSettingsSection } from './DescriptionSettingsSection';
export { default as DescriptionSettingsSectionCompact } from './DescriptionSettingsSectionCompact';
export { default as MediaSettingsSection } from './MediaSettingsSection';
@ -29,6 +27,9 @@ export { default as GallerySectionStyleInputs } from './GallerySectionStyleInput
export { default as CarouselSettingsSection } from './CarouselSettingsSection';
export { default as CarouselSettingsSectionCompact } from './CarouselSettingsSectionCompact';
export { default as GalleryCarouselSettingsSectionCompact } from './GalleryCarouselSettingsSectionCompact';
export { default as InfoPanelSettingsSection } from './InfoPanelSettingsSection';
export { default as InfoPanelSettingsSectionCompact } from './InfoPanelSettingsSectionCompact';
export { default as InfoPanelStyleInputs } from './InfoPanelStyleInputs';
// Hook
export { useElementSettingsForm } from './useElementSettingsForm';

View File

@ -14,6 +14,8 @@ import type {
GalleryInfoSpan,
CarouselSlide,
AssetOption,
InfoPanelSectionType,
InfoPanelSectionInstance,
} from '../../types/constructor';
/**
@ -113,20 +115,6 @@ export interface NavigationSettingsSectionProps {
pageOptions?: { value: string; label: string }[];
}
/**
* Props for tooltip element settings
*/
export interface TooltipSettingsSectionProps {
iconUrl: string;
tooltipTitle: string;
tooltipText: string;
tooltipTitleFontFamily: string;
tooltipTextFontFamily: string;
onChange: (field: string, value: string) => void;
context: ElementSettingsContext;
iconAssetOptions?: AssetOption[];
}
/**
* Props for description element settings
*/
@ -265,6 +253,94 @@ export interface GalleryCarouselSettingsSectionProps {
iconAssetOptions: AssetOption[];
}
/**
* Props for info panel element settings (non-compact version)
* Used in element-type-defaults and project-element-defaults pages
*/
export interface InfoPanelSettingsSectionProps {
// Trigger button settings
iconUrl: string;
infoPanelTriggerLabel: string;
infoPanelTriggerFontFamily: string;
infoPanelDisabled: boolean;
// Header section
infoPanelHeaderImageUrl?: string;
infoPanelHeaderText?: string;
infoPanelHeaderBackgroundColor?: string;
infoPanelHeaderColor?: string;
infoPanelHeaderFontFamily?: string;
infoPanelHeaderFontSize?: string;
infoPanelHeaderFontWeight?: string;
infoPanelHeaderPadding?: string;
infoPanelHeaderBorderRadius?: string;
infoPanelHeaderTextAlign?: string;
infoPanelHeaderMinHeight?: string;
// Panel content
panelTitle: string;
panelText: string;
// Span section styles
infoPanelSpanBackgroundColor?: string;
infoPanelSpanColor?: string;
infoPanelSpanFontFamily?: string;
infoPanelSpanFontSize?: string;
infoPanelSpanPadding?: string;
infoPanelSpanBorderRadius?: string;
// Card styling
infoPanelCardBackgroundColor?: string;
infoPanelCardBorderRadius?: string;
infoPanelCardAspectRatio?: string;
infoPanelCardMinHeight?: string;
infoPanelCardTitleBackgroundColor?: string;
infoPanelCardTitleColor?: string;
infoPanelCardTitleFontFamily?: string;
infoPanelCardTitleFontSize?: string;
infoPanelCardTitlePadding?: string;
// Title section styling
infoPanelTitleBackgroundColor?: string;
infoPanelTitlePadding?: string;
infoPanelTitleFontWeight?: string;
infoPanelTitleTextAlign?: string;
// Panel position & styling
panelXPercent: string;
panelYPercent: string;
panelWidth: string;
panelHeight: string;
panelBackgroundColor: string;
panelBorderRadius: string;
panelPadding: string;
panelBackdropBlur: string;
panelTitleColor: string;
panelTitleFontSize: string;
panelTitleFontFamily: string;
panelTextColor: string;
panelTextFontSize: string;
panelTextFontFamily: string;
panelOverlayColor: string;
// Detail panel position & styling (no overlay - parent InfoPanelOverlay provides backdrop)
detailXPercent: string;
detailYPercent: string;
detailWidth: string;
detailHeight: string;
detailBackgroundColor: string;
detailBorderRadius: string;
detailPadding: string;
detailCaptionFontFamily: string;
// Section instances (order + per-section settings)
infoPanelSections?: InfoPanelSectionInstance[];
// Images section settings
infoPanelImagesPreviewHeight?: string;
infoPanelImagesThumbnailSize?: string;
// Handlers
onChange: (field: string, value: string | boolean | number) => void;
// Section handlers (using section ID instead of type for operations)
onMoveSection?: (sectionId: string, direction: 'up' | 'down') => void;
onRemoveSection?: (sectionId: string) => void;
onAddSection?: (sectionType: InfoPanelSectionType) => void;
context: ElementSettingsContext;
iconAssetOptions?: AssetOption[];
imageAssetOptions?: AssetOption[];
}
/**
* Value normalization helpers
*/

View File

@ -12,6 +12,12 @@ import type {
CanvasElementType,
GalleryCard,
CarouselSlide,
InfoPanelSectionType,
InfoPanelSectionInstance,
} from '../../types/constructor';
import {
DEFAULT_INFO_PANEL_SECTIONS,
generateSectionId,
} from '../../types/constructor';
import { parseJsonObject } from '../../lib/parseJson';
import {
@ -24,11 +30,11 @@ import {
import {
createLocalId,
isNavigationElementType,
isTooltipElementType,
isDescriptionElementType,
isGalleryElementType,
isCarouselElementType,
isMediaElementType,
isInfoPanelElementType,
} from '../../lib/elementDefaults';
interface UseElementSettingsFormOptions {
@ -109,12 +115,6 @@ interface FormState {
transitionReverseMode: 'auto_reverse' | 'separate_video';
reverseVideoUrl: string;
// Tooltip settings
tooltipTitle: string;
tooltipText: string;
tooltipTitleFontFamily: string;
tooltipTextFontFamily: string;
// Description settings
descriptionTitle: string;
descriptionText: string;
@ -143,6 +143,78 @@ interface FormState {
// Complex arrays
galleryCards: GalleryCard[];
carouselSlides: CarouselSlide[];
// Info Panel settings
infoPanelTriggerLabel: string;
infoPanelTriggerFontFamily: string;
infoPanelDisabled: boolean;
panelTitle: string;
panelText: string;
panelXPercent: string;
panelYPercent: string;
panelWidth: string;
panelHeight: string;
panelBackgroundColor: string;
panelBorderRadius: string;
panelPadding: string;
panelBackdropBlur: string;
panelTitleColor: string;
panelTitleFontSize: string;
panelTitleFontFamily: string;
panelTextColor: string;
panelTextFontSize: string;
panelTextFontFamily: string;
panelOverlayColor: string;
detailXPercent: string;
detailYPercent: string;
detailWidth: string;
detailHeight: string;
detailBackgroundColor: string;
detailBorderRadius: string;
detailPadding: string;
detailCaptionFontFamily: string;
// Note: detailOverlayColor removed - parent InfoPanelOverlay provides backdrop
// Info Panel - Header Section (like Gallery)
infoPanelHeaderImageUrl: string;
infoPanelHeaderText: string;
infoPanelHeaderBackgroundColor: string;
infoPanelHeaderColor: string;
infoPanelHeaderFontFamily: string;
infoPanelHeaderFontSize: string;
infoPanelHeaderFontWeight: string;
infoPanelHeaderPadding: string;
infoPanelHeaderBorderRadius: string;
infoPanelHeaderTextAlign: string;
infoPanelHeaderMinHeight: string;
// Info Panel - Title Section enhanced styling
infoPanelTitleBackgroundColor: string;
infoPanelTitlePadding: string;
infoPanelTitleFontWeight: string;
infoPanelTitleTextAlign: string;
// Info Panel - Span styling
infoPanelSpanBackgroundColor: string;
infoPanelSpanColor: string;
infoPanelSpanFontFamily: string;
infoPanelSpanFontSize: string;
infoPanelSpanPadding: string;
infoPanelSpanBorderRadius: string;
// Info Panel - Card styling
infoPanelCardBackgroundColor: string;
infoPanelCardBorderRadius: string;
infoPanelCardAspectRatio: string;
infoPanelCardMinHeight: string;
infoPanelCardTitleBackgroundColor: string;
infoPanelCardTitleColor: string;
infoPanelCardTitleFontFamily: string;
infoPanelCardTitleFontSize: string;
infoPanelCardTitlePadding: string;
// Info Panel - Section instances (order + per-section settings)
infoPanelSections: InfoPanelSectionInstance[];
}
const initialState: FormState = {
@ -210,10 +282,6 @@ const initialState: FormState = {
transitionVideoUrl: '',
transitionReverseMode: 'auto_reverse',
reverseVideoUrl: '',
tooltipTitle: '',
tooltipText: '',
tooltipTitleFontFamily: '',
tooltipTextFontFamily: '',
descriptionTitle: '',
descriptionText: '',
descriptionTitleFontSize: '',
@ -233,6 +301,71 @@ const initialState: FormState = {
galleryTextFontFamily: '',
galleryCards: [],
carouselSlides: [],
// Info Panel settings
infoPanelTriggerLabel: '',
infoPanelTriggerFontFamily: '',
infoPanelDisabled: false,
panelTitle: '',
panelText: '',
panelXPercent: '0',
panelYPercent: '0',
panelWidth: '',
panelHeight: '',
panelBackgroundColor: '',
panelBorderRadius: '',
panelPadding: '',
panelBackdropBlur: '',
panelTitleColor: '',
panelTitleFontSize: '',
panelTitleFontFamily: '',
panelTextColor: '',
panelTextFontSize: '',
panelTextFontFamily: '',
panelOverlayColor: '',
detailXPercent: '0',
detailYPercent: '0',
detailWidth: '',
detailHeight: '',
detailBackgroundColor: '',
detailBorderRadius: '',
detailPadding: '',
detailCaptionFontFamily: '',
// Info Panel - Header Section
infoPanelHeaderImageUrl: '',
infoPanelHeaderText: '',
infoPanelHeaderBackgroundColor: '',
infoPanelHeaderColor: '',
infoPanelHeaderFontFamily: '',
infoPanelHeaderFontSize: '',
infoPanelHeaderFontWeight: '',
infoPanelHeaderPadding: '',
infoPanelHeaderBorderRadius: '',
infoPanelHeaderTextAlign: '',
infoPanelHeaderMinHeight: '',
// Info Panel - Title Section enhanced
infoPanelTitleBackgroundColor: '',
infoPanelTitlePadding: '',
infoPanelTitleFontWeight: '',
infoPanelTitleTextAlign: '',
// Info Panel - Span styling
infoPanelSpanBackgroundColor: '',
infoPanelSpanColor: '',
infoPanelSpanFontFamily: '',
infoPanelSpanFontSize: '',
infoPanelSpanPadding: '',
infoPanelSpanBorderRadius: '',
// Info Panel - Card styling
infoPanelCardBackgroundColor: '',
infoPanelCardBorderRadius: '',
infoPanelCardAspectRatio: '',
infoPanelCardMinHeight: '',
infoPanelCardTitleBackgroundColor: '',
infoPanelCardTitleColor: '',
infoPanelCardTitleFontFamily: '',
infoPanelCardTitleFontSize: '',
infoPanelCardTitlePadding: '',
// Info Panel - Section instances
infoPanelSections: DEFAULT_INFO_PANEL_SECTIONS.map((s) => ({ ...s })),
};
export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
@ -241,11 +374,11 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
// Type detection using shared helpers from elementDefaults
const isNavigationType = isNavigationElementType(elementType);
const isTooltipType = isTooltipElementType(elementType);
const isDescriptionType = isDescriptionElementType(elementType);
const isGalleryType = isGalleryElementType(elementType);
const isCarouselType = isCarouselElementType(elementType);
const isMediaType = isMediaElementType(elementType);
const isInfoPanelType = isInfoPanelElementType(elementType);
/**
* Apply settings from JSON to form state
@ -333,10 +466,6 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
? 'separate_video'
: 'auto_reverse',
reverseVideoUrl: String(settings.reverseVideoUrl || ''),
tooltipTitle: String(settings.tooltipTitle || ''),
tooltipText: String(settings.tooltipText || ''),
tooltipTitleFontFamily: String(settings.tooltipTitleFontFamily || ''),
tooltipTextFontFamily: String(settings.tooltipTextFontFamily || ''),
descriptionTitle: String(settings.descriptionTitle || ''),
descriptionText: String(settings.descriptionText || ''),
descriptionTitleFontSize: String(
@ -377,6 +506,109 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
caption: String(slide?.caption ?? ''),
}))
: [],
// Info Panel settings
infoPanelTriggerLabel: String(settings.infoPanelTriggerLabel || ''),
infoPanelTriggerFontFamily: String(
settings.infoPanelTriggerFontFamily || '',
),
infoPanelDisabled: Boolean(settings.infoPanelDisabled),
panelTitle: String(settings.panelTitle || ''),
panelText: String(settings.panelText || ''),
panelXPercent: String(settings.panelXPercent ?? 0),
panelYPercent: String(settings.panelYPercent ?? 0),
panelWidth: String(settings.panelWidth || ''),
panelHeight: String(settings.panelHeight || ''),
panelBackgroundColor: String(settings.panelBackgroundColor || ''),
panelBorderRadius: String(settings.panelBorderRadius || ''),
panelPadding: String(settings.panelPadding || ''),
panelBackdropBlur: String(settings.panelBackdropBlur || ''),
panelTitleColor: String(settings.panelTitleColor || ''),
panelTitleFontSize: String(settings.panelTitleFontSize || ''),
panelTitleFontFamily: String(settings.panelTitleFontFamily || ''),
panelTextColor: String(settings.panelTextColor || ''),
panelTextFontSize: String(settings.panelTextFontSize || ''),
panelTextFontFamily: String(settings.panelTextFontFamily || ''),
panelOverlayColor: String(settings.panelOverlayColor || ''),
detailXPercent: String(settings.detailXPercent ?? 0),
detailYPercent: String(settings.detailYPercent ?? 0),
detailWidth: String(settings.detailWidth || ''),
detailHeight: String(settings.detailHeight || ''),
detailBackgroundColor: String(settings.detailBackgroundColor || ''),
detailBorderRadius: String(settings.detailBorderRadius || ''),
detailPadding: String(settings.detailPadding || ''),
detailCaptionFontFamily: String(settings.detailCaptionFontFamily || ''),
// Info Panel - Header Section
infoPanelHeaderImageUrl: String(settings.infoPanelHeaderImageUrl || ''),
infoPanelHeaderText: String(settings.infoPanelHeaderText || ''),
infoPanelHeaderBackgroundColor: String(
settings.infoPanelHeaderBackgroundColor || '',
),
infoPanelHeaderColor: String(settings.infoPanelHeaderColor || ''),
infoPanelHeaderFontFamily: String(
settings.infoPanelHeaderFontFamily || '',
),
infoPanelHeaderFontSize: String(settings.infoPanelHeaderFontSize || ''),
infoPanelHeaderFontWeight: String(
settings.infoPanelHeaderFontWeight || '',
),
infoPanelHeaderPadding: String(settings.infoPanelHeaderPadding || ''),
infoPanelHeaderBorderRadius: String(
settings.infoPanelHeaderBorderRadius || '',
),
infoPanelHeaderTextAlign: String(
settings.infoPanelHeaderTextAlign || '',
),
infoPanelHeaderMinHeight: String(
settings.infoPanelHeaderMinHeight || '',
),
// Info Panel - Title Section enhanced
infoPanelTitleBackgroundColor: String(
settings.infoPanelTitleBackgroundColor || '',
),
infoPanelTitlePadding: String(settings.infoPanelTitlePadding || ''),
infoPanelTitleFontWeight: String(
settings.infoPanelTitleFontWeight || '',
),
infoPanelTitleTextAlign: String(settings.infoPanelTitleTextAlign || ''),
// Info Panel - Span styling
infoPanelSpanBackgroundColor: String(
settings.infoPanelSpanBackgroundColor || '',
),
infoPanelSpanColor: String(settings.infoPanelSpanColor || ''),
infoPanelSpanFontFamily: String(settings.infoPanelSpanFontFamily || ''),
infoPanelSpanFontSize: String(settings.infoPanelSpanFontSize || ''),
infoPanelSpanPadding: String(settings.infoPanelSpanPadding || ''),
infoPanelSpanBorderRadius: String(
settings.infoPanelSpanBorderRadius || '',
),
// Info Panel - Card styling
infoPanelCardBackgroundColor: String(
settings.infoPanelCardBackgroundColor || '',
),
infoPanelCardBorderRadius: String(
settings.infoPanelCardBorderRadius || '',
),
infoPanelCardAspectRatio: String(
settings.infoPanelCardAspectRatio || '',
),
infoPanelCardMinHeight: String(settings.infoPanelCardMinHeight || ''),
infoPanelCardTitleBackgroundColor: String(
settings.infoPanelCardTitleBackgroundColor || '',
),
infoPanelCardTitleColor: String(settings.infoPanelCardTitleColor || ''),
infoPanelCardTitleFontFamily: String(
settings.infoPanelCardTitleFontFamily || '',
),
infoPanelCardTitleFontSize: String(
settings.infoPanelCardTitleFontSize || '',
),
infoPanelCardTitlePadding: String(
settings.infoPanelCardTitlePadding || '',
),
// Info Panel - Section instances
infoPanelSections: Array.isArray(settings.infoPanelSections)
? (settings.infoPanelSections as InfoPanelSectionInstance[])
: DEFAULT_INFO_PANEL_SECTIONS.map((s) => ({ ...s })),
});
},
[],
@ -538,6 +770,58 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
[],
);
/**
* Info panel section operations
*/
const moveInfoPanelSection = useCallback(
(sectionId: string, direction: 'up' | 'down') => {
setState((prev) => {
const sections = [...prev.infoPanelSections];
const index = sections.findIndex((s) => s.id === sectionId);
if (index === -1) return prev;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= sections.length) return prev;
[sections[index], sections[newIndex]] = [
sections[newIndex],
sections[index],
];
return { ...prev, infoPanelSections: sections };
});
},
[],
);
const removeInfoPanelSection = useCallback((sectionId: string) => {
setState((prev) => ({
...prev,
infoPanelSections: prev.infoPanelSections.filter(
(s) => s.id !== sectionId,
),
}));
}, []);
const addInfoPanelSection = useCallback(
(sectionType: InfoPanelSectionType) => {
setState((prev) => {
const newSection: InfoPanelSectionInstance = {
id: generateSectionId(),
type: sectionType,
// Initialize with default settings for data sections
...(sectionType === 'spans' && { columns: 3, gap: '8', spans: [] }),
...(sectionType === 'cards' && { columns: 2, gap: '8', images: [] }),
...(sectionType === 'images' && { images: [] }),
};
return {
...prev,
infoPanelSections: [...prev.infoPanelSections, newSection],
};
});
},
[],
);
/**
* Build settings JSON for saving
*/
@ -700,15 +984,6 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
settings.reverseVideoUrl = state.reverseVideoUrl.trim();
}
// Tooltip type settings
if (isTooltipType) {
settings.iconUrl = state.iconUrl.trim();
settings.tooltipTitle = state.tooltipTitle.trim();
settings.tooltipText = state.tooltipText;
settings.tooltipTitleFontFamily = state.tooltipTitleFontFamily.trim();
settings.tooltipTextFontFamily = state.tooltipTextFontFamily.trim();
}
// Description type settings
// Note: Color/fontSize/fontWeight cascade from General Element Styles via CSS inheritance
// Only set section-specific values if explicitly configured (allows inheritance)
@ -773,15 +1048,243 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
settings.mediaMuted = state.mediaMuted;
}
// Info Panel type settings
if (isInfoPanelType) {
// Trigger button settings
settings.iconUrl = state.iconUrl.trim();
settings.infoPanelTriggerLabel = state.infoPanelTriggerLabel.trim();
settings.infoPanelTriggerFontFamily =
state.infoPanelTriggerFontFamily.trim();
settings.infoPanelDisabled = state.infoPanelDisabled;
// Panel content
settings.panelTitle = state.panelTitle.trim();
settings.panelText = state.panelText;
// Panel position & styling
settings.panelXPercent = clampPercent(state.panelXPercent);
settings.panelYPercent = clampPercent(state.panelYPercent);
const panelWidthValue = toOptionalTrimmed(state.panelWidth);
const panelHeightValue = toOptionalTrimmed(state.panelHeight);
if (panelWidthValue) settings.panelWidth = panelWidthValue;
if (panelHeightValue) settings.panelHeight = panelHeightValue;
const panelBgColorValue = toOptionalTrimmed(state.panelBackgroundColor);
const panelBorderRadiusValue = toOptionalTrimmed(state.panelBorderRadius);
const panelPaddingValue = toOptionalTrimmed(state.panelPadding);
const panelBackdropBlurValue = toOptionalTrimmed(state.panelBackdropBlur);
if (panelBgColorValue) settings.panelBackgroundColor = panelBgColorValue;
if (panelBorderRadiusValue)
settings.panelBorderRadius = panelBorderRadiusValue;
if (panelPaddingValue) settings.panelPadding = panelPaddingValue;
if (panelBackdropBlurValue)
settings.panelBackdropBlur = panelBackdropBlurValue;
// Panel text styling
const panelTitleColorValue = toOptionalTrimmed(state.panelTitleColor);
const panelTitleFontSizeValue = toOptionalTrimmed(
state.panelTitleFontSize,
);
const panelTitleFontFamilyValue = toOptionalTrimmed(
state.panelTitleFontFamily,
);
const panelTextColorValue = toOptionalTrimmed(state.panelTextColor);
const panelTextFontSizeValue = toOptionalTrimmed(state.panelTextFontSize);
const panelTextFontFamilyValue = toOptionalTrimmed(
state.panelTextFontFamily,
);
const panelOverlayColorValue = toOptionalTrimmed(state.panelOverlayColor);
if (panelTitleColorValue) settings.panelTitleColor = panelTitleColorValue;
if (panelTitleFontSizeValue)
settings.panelTitleFontSize = panelTitleFontSizeValue;
if (panelTitleFontFamilyValue)
settings.panelTitleFontFamily = panelTitleFontFamilyValue;
if (panelTextColorValue) settings.panelTextColor = panelTextColorValue;
if (panelTextFontSizeValue)
settings.panelTextFontSize = panelTextFontSizeValue;
if (panelTextFontFamilyValue)
settings.panelTextFontFamily = panelTextFontFamilyValue;
if (panelOverlayColorValue)
settings.panelOverlayColor = panelOverlayColorValue;
// Detail panel position & styling
settings.detailXPercent = clampPercent(state.detailXPercent);
settings.detailYPercent = clampPercent(state.detailYPercent);
const detailWidthValue = toOptionalTrimmed(state.detailWidth);
const detailHeightValue = toOptionalTrimmed(state.detailHeight);
if (detailWidthValue) settings.detailWidth = detailWidthValue;
if (detailHeightValue) settings.detailHeight = detailHeightValue;
const detailBgColorValue = toOptionalTrimmed(state.detailBackgroundColor);
const detailBorderRadiusValue = toOptionalTrimmed(
state.detailBorderRadius,
);
const detailPaddingValue = toOptionalTrimmed(state.detailPadding);
if (detailBgColorValue)
settings.detailBackgroundColor = detailBgColorValue;
if (detailBorderRadiusValue)
settings.detailBorderRadius = detailBorderRadiusValue;
if (detailPaddingValue) settings.detailPadding = detailPaddingValue;
const detailCaptionFontFamilyValue = toOptionalTrimmed(
state.detailCaptionFontFamily,
);
if (detailCaptionFontFamilyValue)
settings.detailCaptionFontFamily = detailCaptionFontFamilyValue;
// Note: detailOverlayColor removed - parent InfoPanelOverlay provides backdrop
// Header Section (like Gallery)
const headerImageUrlValue = toOptionalTrimmed(
state.infoPanelHeaderImageUrl,
);
const headerTextValue = toOptionalTrimmed(state.infoPanelHeaderText);
if (headerImageUrlValue)
settings.infoPanelHeaderImageUrl = headerImageUrlValue;
if (headerTextValue) settings.infoPanelHeaderText = headerTextValue;
const headerBgColorValue = toOptionalTrimmed(
state.infoPanelHeaderBackgroundColor,
);
const headerColorValue = toOptionalTrimmed(state.infoPanelHeaderColor);
const headerFontFamilyValue = toOptionalTrimmed(
state.infoPanelHeaderFontFamily,
);
const headerFontSizeValue = toOptionalTrimmed(
state.infoPanelHeaderFontSize,
);
const headerFontWeightValue = toOptionalTrimmed(
state.infoPanelHeaderFontWeight,
);
const headerPaddingValue = toOptionalTrimmed(
state.infoPanelHeaderPadding,
);
const headerBorderRadiusValue = toOptionalTrimmed(
state.infoPanelHeaderBorderRadius,
);
const headerTextAlignValue = toOptionalTrimmed(
state.infoPanelHeaderTextAlign,
);
const headerMinHeightValue = toOptionalTrimmed(
state.infoPanelHeaderMinHeight,
);
if (headerBgColorValue)
settings.infoPanelHeaderBackgroundColor = headerBgColorValue;
if (headerColorValue) settings.infoPanelHeaderColor = headerColorValue;
if (headerFontFamilyValue)
settings.infoPanelHeaderFontFamily = headerFontFamilyValue;
if (headerFontSizeValue)
settings.infoPanelHeaderFontSize = headerFontSizeValue;
if (headerFontWeightValue)
settings.infoPanelHeaderFontWeight = headerFontWeightValue;
if (headerPaddingValue)
settings.infoPanelHeaderPadding = headerPaddingValue;
if (headerBorderRadiusValue)
settings.infoPanelHeaderBorderRadius = headerBorderRadiusValue;
if (headerTextAlignValue)
settings.infoPanelHeaderTextAlign = headerTextAlignValue;
if (headerMinHeightValue)
settings.infoPanelHeaderMinHeight = headerMinHeightValue;
// Title Section enhanced styling
const titleBgColorValue = toOptionalTrimmed(
state.infoPanelTitleBackgroundColor,
);
const titlePaddingValue = toOptionalTrimmed(state.infoPanelTitlePadding);
const titleFontWeightValue = toOptionalTrimmed(
state.infoPanelTitleFontWeight,
);
const titleTextAlignValue = toOptionalTrimmed(
state.infoPanelTitleTextAlign,
);
if (titleBgColorValue)
settings.infoPanelTitleBackgroundColor = titleBgColorValue;
if (titlePaddingValue) settings.infoPanelTitlePadding = titlePaddingValue;
if (titleFontWeightValue)
settings.infoPanelTitleFontWeight = titleFontWeightValue;
if (titleTextAlignValue)
settings.infoPanelTitleTextAlign = titleTextAlignValue;
// Span styling
const spanBgColorValue = toOptionalTrimmed(
state.infoPanelSpanBackgroundColor,
);
const spanColorValue = toOptionalTrimmed(state.infoPanelSpanColor);
const spanFontFamilyValue = toOptionalTrimmed(
state.infoPanelSpanFontFamily,
);
const spanFontSizeValue = toOptionalTrimmed(state.infoPanelSpanFontSize);
const spanPaddingValue = toOptionalTrimmed(state.infoPanelSpanPadding);
const spanBorderRadiusValue = toOptionalTrimmed(
state.infoPanelSpanBorderRadius,
);
if (spanBgColorValue)
settings.infoPanelSpanBackgroundColor = spanBgColorValue;
if (spanColorValue) settings.infoPanelSpanColor = spanColorValue;
if (spanFontFamilyValue)
settings.infoPanelSpanFontFamily = spanFontFamilyValue;
if (spanFontSizeValue) settings.infoPanelSpanFontSize = spanFontSizeValue;
if (spanPaddingValue) settings.infoPanelSpanPadding = spanPaddingValue;
if (spanBorderRadiusValue)
settings.infoPanelSpanBorderRadius = spanBorderRadiusValue;
// Card styling
const cardBgColorValue = toOptionalTrimmed(
state.infoPanelCardBackgroundColor,
);
const cardBorderRadiusValue = toOptionalTrimmed(
state.infoPanelCardBorderRadius,
);
const cardAspectRatioValue = toOptionalTrimmed(
state.infoPanelCardAspectRatio,
);
const cardMinHeightValue = toOptionalTrimmed(
state.infoPanelCardMinHeight,
);
if (cardBgColorValue)
settings.infoPanelCardBackgroundColor = cardBgColorValue;
if (cardBorderRadiusValue)
settings.infoPanelCardBorderRadius = cardBorderRadiusValue;
if (cardAspectRatioValue)
settings.infoPanelCardAspectRatio = cardAspectRatioValue;
if (cardMinHeightValue)
settings.infoPanelCardMinHeight = cardMinHeightValue;
// Card title styling
const cardTitleBgColorValue = toOptionalTrimmed(
state.infoPanelCardTitleBackgroundColor,
);
const cardTitleColorValue = toOptionalTrimmed(
state.infoPanelCardTitleColor,
);
const cardTitleFontFamilyValue = toOptionalTrimmed(
state.infoPanelCardTitleFontFamily,
);
const cardTitleFontSizeValue = toOptionalTrimmed(
state.infoPanelCardTitleFontSize,
);
const cardTitlePaddingValue = toOptionalTrimmed(
state.infoPanelCardTitlePadding,
);
if (cardTitleBgColorValue)
settings.infoPanelCardTitleBackgroundColor = cardTitleBgColorValue;
if (cardTitleColorValue)
settings.infoPanelCardTitleColor = cardTitleColorValue;
if (cardTitleFontFamilyValue)
settings.infoPanelCardTitleFontFamily = cardTitleFontFamilyValue;
if (cardTitleFontSizeValue)
settings.infoPanelCardTitleFontSize = cardTitleFontSizeValue;
if (cardTitlePaddingValue)
settings.infoPanelCardTitlePadding = cardTitlePaddingValue;
// Section instances - always save (contains order and per-section settings)
settings.infoPanelSections = state.infoPanelSections;
}
return settings;
}, [
state,
isNavigationType,
isTooltipType,
isDescriptionType,
isGalleryType,
isCarouselType,
isMediaType,
isInfoPanelType,
]);
return {
@ -794,11 +1297,11 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
// Type checks
isNavigationType,
isTooltipType,
isDescriptionType,
isGalleryType,
isCarouselType,
isMediaType,
isInfoPanelType,
// Gallery operations
addGalleryCard,
@ -810,6 +1313,11 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
removeCarouselSlide,
updateCarouselSlide,
// Info panel section ordering operations
moveInfoPanelSection,
removeInfoPanelSection,
addInfoPanelSection,
// Build JSON
buildSettingsJson,
};

View File

@ -18,6 +18,7 @@ import {
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;
@ -32,6 +33,8 @@ interface RuntimeElementProps {
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%)
@ -46,6 +49,7 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
letterboxStyles,
pageTransitionSettings,
preloadCache,
isInfoPanelOpen = false,
}) => {
// Clamp coordinates to canvas bounds
const xPercent = clamp(element.xPercent ?? 50, 0, 100);
@ -58,16 +62,21 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
);
// Use effects hook for interactive states
// Pass forceVisible when info panel is open to keep trigger visible
const { effectStyle, eventHandlers, onPersistClick } = useElementEffects(
effectProperties,
{
resetKey: element.id, // Reset reveal on element change
forceVisible: isInfoPanelOpen,
},
);
// Combined click handler
// Skip toggle for info panel elements (their visibility is tied to panel open state)
const handleClick = () => {
onPersistClick(); // Toggle persistence state
if (!isInfoPanelElementType(element.type)) {
onPersistClick(); // Toggle persistence state
}
onClick(); // Original navigation action
};

View File

@ -22,6 +22,8 @@ import RuntimeControls from './Runtime/RuntimeControls';
import RuntimeElement from './RuntimeElement';
import TransitionPreviewOverlay from './Constructor/TransitionPreviewOverlay';
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
import InfoPanelOverlay from './UiElements/InfoPanelOverlay';
import ImageDetailPanel from './UiElements/ImageDetailPanel';
import { BackdropPortalProvider } from './BackdropPortal';
import { RotatePrompt } from './RotatePrompt';
import CanvasBackground from './Constructor/CanvasBackground';
@ -60,7 +62,8 @@ import {
selectByProjectAndEnv as selectProjectTransitionSettings,
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
import type { TransitionPhase } from '../types/presentation';
import type { CanvasElement } from '../types/constructor';
import type { CanvasElement, InfoPanelImage } from '../types/constructor';
import { isInfoPanelElementType } from '../lib/elementDefaults';
import type { ElementTransitionSettings } from '../types/transition';
import {
entityToProjectSettings,
@ -180,6 +183,15 @@ export default function RuntimePresentation({
element: CanvasElement;
initialIndex: number;
} | null>(null);
const [activeInfoPanel, setActiveInfoPanel] = useState<CanvasElement | null>(
null,
);
const [activeDetailImage, setActiveDetailImage] =
useState<InfoPanelImage | null>(null);
// Track selected image in images section (runtime-only local state)
const [runtimeSelectedImageId, setRuntimeSelectedImageId] = useState<
string | null
>(null);
const transitionVideoRef = useRef<HTMLVideoElement>(null);
const lastInitializedPageIdRef = useRef<string | null>(null);
@ -555,6 +567,13 @@ export default function RuntimePresentation({
const handleElementClick = useCallback(
(element: CanvasElement) => {
// Handle info panel click
if (isInfoPanelElementType(element.type)) {
setActiveInfoPanel(element);
setActiveDetailImage(null);
return;
}
// Block navigation while transition is actively playing or buffering
if (
isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)
@ -857,6 +876,7 @@ export default function RuntimePresentation({
}
: undefined
}
isInfoPanelOpen={activeInfoPanel?.id === element.id}
/>
))}
</div>
@ -949,6 +969,49 @@ export default function RuntimePresentation({
galleryElement={activeGalleryCarousel.element}
/>
)}
{/* Info Panel Overlay */}
{activeInfoPanel && (
<>
<InfoPanelOverlay
element={
runtimeSelectedImageId
? {
...activeInfoPanel,
infoPanelSelectedImageId: runtimeSelectedImageId,
}
: activeInfoPanel
}
onClose={() => {
setActiveInfoPanel(null);
setActiveDetailImage(null);
setRuntimeSelectedImageId(null);
}}
resolveUrl={resolveUrlWithBlob}
letterboxStyles={letterboxStyles}
onImageClick={(image) => setActiveDetailImage(image)}
onSelectImage={(imageId) =>
setRuntimeSelectedImageId(imageId)
}
active360ItemId={
activeDetailImage &&
(activeDetailImage.isEmbed ||
activeDetailImage.itemType === '360')
? activeDetailImage.id
: null
}
/>
{activeDetailImage && (
<ImageDetailPanel
element={activeInfoPanel}
image={activeDetailImage}
onClose={() => setActiveDetailImage(null)}
resolveUrl={resolveUrlWithBlob}
letterboxStyles={letterboxStyles}
/>
)}
</>
)}
</BackdropPortalProvider>
</div>
{/* End inner canvas container */}

View File

@ -19,17 +19,6 @@ const ElementPreview = ({ item }: { item: UiElementItem }) => {
);
}
if (item.elementType === 'tooltip') {
return (
<div
className='inline-block rounded px-2 py-1 text-xs'
style={commonStyle}
>
{item.settings.content.text || 'Tooltip'}
</div>
);
}
if (item.elementType === 'gallery' || item.elementType === 'carousel') {
return (
<div className='flex gap-1'>

View File

@ -0,0 +1,460 @@
/**
* ImageDetailPanel Component
*
* Displays enlarged image or 360/iframe embed.
* Positioned absolutely within the canvas (not fullscreen).
* Supports embed URL validation for security.
*/
import React, {
useState,
useEffect,
useCallback,
useRef,
useMemo,
} from 'react';
import type { CanvasElement, InfoPanelImage } from '../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
import { getFontByKey, getFontStyle } from '../../lib/fonts';
/**
* Allowed embed domains for security
*/
const ALLOWED_EMBED_DOMAINS = [
'matterport.com',
'my.matterport.com',
'kuula.co',
'roundme.com',
'sketchfab.com',
'youtube.com',
'www.youtube.com',
'vimeo.com',
'player.vimeo.com',
'google.com',
'maps.google.com',
'www.google.com',
'docs.google.com',
'drive.google.com',
];
/**
* Validate if the URL is from an allowed embed domain
*/
const isValidEmbedUrl = (url: string): boolean => {
try {
const parsed = new URL(url);
return ALLOWED_EMBED_DOMAINS.some(
(domain) =>
parsed.hostname === domain || parsed.hostname.endsWith('.' + domain),
);
} catch {
return false;
}
};
interface ImageDetailPanelProps {
element: CanvasElement;
image: InfoPanelImage | null;
onClose: () => void;
resolveUrl?: (url: string | undefined) => string;
letterboxStyles?: React.CSSProperties;
isEditMode?: boolean;
onDetailPositionChange?: (xPercent: number, yPercent: number) => void;
}
const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
element,
image,
onClose,
resolveUrl,
letterboxStyles,
isEditMode = false,
onDetailPositionChange,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const panelRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [embedError, setEmbedError] = useState(false);
// Drag state for edit mode
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<{
x: number;
y: number;
panelX: number;
panelY: number;
} | null>(null);
// Fade in animation
useEffect(() => {
requestAnimationFrame(() => setIsVisible(true));
}, []);
// Keyboard navigation (ESC to close)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// Focus the panel on mount
useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
panel.focus();
}, []);
// Handle backdrop click (disabled in edit mode)
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget && !isEditMode) {
onClose();
}
},
[onClose, isEditMode],
);
// Extract detail panel styling from element
const detailXPercent = element.detailXPercent ?? 75;
const detailYPercent = element.detailYPercent ?? 50;
const detailWidth = element.detailWidth ?? '500';
const detailHeight = element.detailHeight ?? '400';
const detailBackgroundColor =
element.detailBackgroundColor ?? 'rgba(0, 0, 0, 0.9)';
const detailBorderRadius = element.detailBorderRadius ?? '12';
const detailBorderWidth = element.detailBorderWidth ?? '0';
const detailBorderColor = element.detailBorderColor ?? 'transparent';
const detailBorderStyle = element.detailBorderStyle ?? 'solid';
const detailPadding = element.detailPadding ?? '12';
// Caption font style
const captionFontStyle = useMemo(() => {
const fontKey = element.detailCaptionFontFamily;
if (!fontKey) return {};
const font = getFontByKey(fontKey);
return font ? getFontStyle(font) : {};
}, [element.detailCaptionFontFamily]);
// Note: ImageDetailPanel doesn't render its own overlay backdrop.
// The parent InfoPanelOverlay already provides the backdrop when the info panel is open.
// Convert numeric values to px if needed
const toPx = (value: string): string => {
const trimmed = value.trim();
if (/[a-z%]+$/i.test(trimmed)) return trimmed;
const num = parseFloat(trimmed);
if (!Number.isFinite(num)) return trimmed;
return `${num}px`;
};
// Panel style
const panelStyle: React.CSSProperties = {
position: 'absolute',
left: `${detailXPercent}%`,
top: `${detailYPercent}%`,
transform: 'translate(-50%, -50%)',
width: `min(${toPx(detailWidth)}, calc(100vw - 32px))`,
height: `min(${toPx(detailHeight)}, calc(100dvh - 64px))`,
backgroundColor: detailBackgroundColor,
borderRadius: toPx(detailBorderRadius),
padding: toPx(detailPadding),
overflow: 'hidden',
opacity: isVisible ? 1 : 0,
transition: 'opacity 200ms ease-out',
border:
detailBorderWidth !== '0' && detailBorderWidth
? `${toPx(detailBorderWidth)} ${detailBorderStyle} ${detailBorderColor}`
: 'none',
};
// Determine content type (handle null image for edit mode placeholder)
const isEmbed = image?.isEmbed && image?.embedUrl;
const embedUrl = image?.embedUrl ?? '';
const isValidEmbed = isEmbed && isValidEmbedUrl(embedUrl);
const hasImage = !!image;
// Handle iframe load
const handleIframeLoad = () => {
setIsLoading(false);
};
// Handle iframe error
const handleIframeError = () => {
setIsLoading(false);
setEmbedError(true);
};
// Handle image load
const handleImageLoad = () => {
setIsLoading(false);
};
// Drag handlers for edit mode
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
if (!isEditMode || !onDetailPositionChange) return;
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
panelX: element.detailXPercent ?? 75,
panelY: element.detailYPercent ?? 50,
};
},
[
isEditMode,
onDetailPositionChange,
element.detailXPercent,
element.detailYPercent,
],
);
useEffect(() => {
if (!isDragging || !containerRef.current) return;
const container = containerRef.current;
const handleMouseMove = (e: MouseEvent) => {
if (!dragStartRef.current) return;
const rect = container.getBoundingClientRect();
const deltaX = e.clientX - dragStartRef.current.x;
const deltaY = e.clientY - dragStartRef.current.y;
// Convert pixel delta to percentage
const deltaXPercent = (deltaX / rect.width) * 100;
const deltaYPercent = (deltaY / rect.height) * 100;
const newX = Math.max(
0,
Math.min(100, dragStartRef.current.panelX + deltaXPercent),
);
const newY = Math.max(
0,
Math.min(100, dragStartRef.current.panelY + deltaYPercent),
);
onDetailPositionChange?.(Math.round(newX), Math.round(newY));
};
const handleMouseUp = () => {
setIsDragging(false);
dragStartRef.current = null;
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, onDetailPositionChange]);
return (
<>
{/* Click backdrop (transparent) - InfoPanelOverlay provides the visual backdrop */}
<div
className='fixed inset-0 z-[52] overflow-hidden'
style={{
backgroundColor: 'transparent',
pointerEvents: isEditMode ? 'none' : 'auto',
}}
onClick={handleBackdropClick}
/>
{/* Inner container constrained to canvas bounds */}
<div
ref={containerRef}
className='fixed inset-0 z-[54] overflow-hidden pointer-events-none'
style={letterboxStyles || { position: 'absolute', inset: 0 }}
>
{/* Detail Panel */}
<div
ref={panelRef}
role='dialog'
aria-modal='true'
aria-label={image?.caption || 'Image detail'}
tabIndex={-1}
style={{
...panelStyle,
pointerEvents: 'auto', // Ensure panel receives events
cursor:
isEditMode && onDetailPositionChange
? isDragging
? 'grabbing'
: 'grab'
: undefined,
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={
isEditMode && onDetailPositionChange ? handleDragStart : undefined
}
>
{/* Close button */}
<button
type='button'
className='absolute top-2 right-2 z-10 p-1 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors'
onClick={onClose}
aria-label='Close detail view'
>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={2}
stroke='currentColor'
className='w-5 h-5'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M6 18 18 6M6 6l12 12'
/>
</svg>
</button>
{/* Loading spinner */}
{isLoading && (
<div className='absolute inset-0 flex items-center justify-center'>
<div className='w-10 h-10 border-4 border-white/20 border-t-white rounded-full animate-spin' />
</div>
)}
{/* Content area - disable pointer events in edit mode so panel can be dragged */}
<div
className='w-full h-full'
style={{ pointerEvents: isEditMode ? 'none' : 'auto' }}
>
{!hasImage ? (
// Edit mode placeholder when no image selected
<div className='w-full h-full flex flex-col items-center justify-center text-white/50'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-16 h-16 mb-3'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'
/>
</svg>
<p className='text-sm font-medium'>Image Detail Panel</p>
<p className='text-xs mt-1 text-white/30'>
Click an image in Info Panel to preview
</p>
</div>
) : isEmbed ? (
// Embed iframe
isValidEmbed ? (
<iframe
src={embedUrl}
title={image?.caption || 'Embedded content'}
className='w-full h-full border-0'
sandbox='allow-scripts allow-same-origin allow-presentation allow-popups'
allow='accelerometer; autoplay; fullscreen; gyroscope; xr-spatial-tracking'
loading='lazy'
onLoad={handleIframeLoad}
onError={handleIframeError}
style={{
opacity: isLoading ? 0 : 1,
WebkitOverflowScrolling: 'touch',
}}
/>
) : (
// Invalid embed URL error
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-12 h-12 mb-2 text-red-400'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z'
/>
</svg>
<p className='text-sm'>Invalid or unsupported embed URL</p>
<p className='text-xs mt-1 text-white/40'>
Only trusted domains are allowed
</p>
</div>
)
) : image?.imageUrl ? (
// Regular image
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(image.imageUrl)}
alt={image?.caption || ''}
className='w-full h-full object-contain'
draggable={false}
onLoad={handleImageLoad}
style={{
opacity: isLoading ? 0 : 1,
transition: 'opacity 200ms ease-out',
}}
/>
) : (
// No content placeholder
<div className='w-full h-full flex items-center justify-center text-white/40'>
<p>No content</p>
</div>
)}
{/* Embed error state */}
{embedError && (
<div className='absolute inset-0 flex flex-col items-center justify-center text-white/60 bg-black/50'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-12 h-12 mb-2 text-red-400'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z'
/>
</svg>
<p className='text-sm'>Failed to load embed</p>
</div>
)}
</div>
{/* Caption */}
{image?.caption && (
<div className='absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3'>
<p className='text-sm text-white' style={captionFontStyle}>
{image.caption}
</p>
</div>
)}
</div>
</div>
</>
);
};
export default ImageDetailPanel;

View File

@ -0,0 +1,810 @@
/**
* InfoPanelOverlay Component
*
* Overlay panel that displays header, title, info spans, text, and image cards.
* Section-based structure similar to Gallery for consistent styling.
* Positioned absolutely within the canvas (not fullscreen).
* Supports keyboard navigation, click outside to close, and mobile touch.
*/
import React, {
useState,
useEffect,
useCallback,
useRef,
useMemo,
} from 'react';
import type {
CanvasElement,
InfoPanelImage,
InfoPanelSectionInstance,
} from '../../types/constructor';
import { getInfoPanelSections } from '../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
import {
buildInfoPanelHeaderStyle,
buildInfoPanelTitleStyle,
buildInfoPanelTextStyle,
buildInfoPanelSpanStyle,
buildInfoPanelSpanGridStyleWithSection,
buildInfoPanelCardStyle,
buildInfoPanelCardTitleStyle,
buildInfoPanelCardGridStyleWithSection,
buildInfoPanelWrapperStyle,
buildImagesPreviewStyle,
} from '../../lib/infoPanelSectionStyles';
interface InfoPanelOverlayProps {
element: CanvasElement;
onClose: () => void;
resolveUrl?: (url: string | undefined) => string;
letterboxStyles?: React.CSSProperties;
/** CSS custom properties including --cu for canvas units */
cssVars?: React.CSSProperties;
/** Callback when an image/360 is clicked. Pass null to close the detail panel. */
onImageClick: (image: InfoPanelImage | null) => void;
/** Callback when an image is selected in the images section preview */
onSelectImage?: (imageId: string) => void;
isEditMode?: boolean;
/** Callback when panel position changes (edit mode only) */
onPanelPositionChange?: (xPercent: number, yPercent: number) => void;
/** Currently active 360° item ID (for toggle state sync with parent) */
active360ItemId?: string | null;
}
const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
element,
onClose,
resolveUrl,
letterboxStyles,
cssVars,
onImageClick,
onSelectImage,
isEditMode = false,
onPanelPositionChange,
active360ItemId,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const overlayRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<{
x: number;
y: number;
panelX: number;
panelY: number;
} | null>(null);
// Track selected image per section (local UI state)
const [selectedImagePerSection, setSelectedImagePerSection] = useState<
Record<string, string>
>({});
// Section styles computed from element settings
const headerStyle = useMemo(
() => buildInfoPanelHeaderStyle(element),
[element],
);
const titleStyle = useMemo(
() => buildInfoPanelTitleStyle(element),
[element],
);
const textStyle = useMemo(() => buildInfoPanelTextStyle(element), [element]);
const spanStyle = useMemo(() => buildInfoPanelSpanStyle(element), [element]);
const cardStyle = useMemo(() => buildInfoPanelCardStyle(element), [element]);
const cardTitleStyle = useMemo(
() => buildInfoPanelCardTitleStyle(element),
[element],
);
const wrapperStyle = useMemo(
() => buildInfoPanelWrapperStyle(element),
[element],
);
const imagesPreviewStyle = useMemo(
() => buildImagesPreviewStyle(element),
[element],
);
// Get section instances
const sections = useMemo(() => getInfoPanelSections(element), [element]);
// Fade in animation
useEffect(() => {
requestAnimationFrame(() => setIsVisible(true));
}, []);
// Keyboard navigation (ESC to close)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// Focus trap
useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
// Focus the panel on mount
panel.focus();
}, []);
// Drag handling for edit mode
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
if (!isEditMode || !onPanelPositionChange) return;
e.preventDefault();
e.stopPropagation();
const panelX = element.panelXPercent ?? 50;
const panelY = element.panelYPercent ?? 50;
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
panelX,
panelY,
};
setIsDragging(true);
},
[
isEditMode,
onPanelPositionChange,
element.panelXPercent,
element.panelYPercent,
],
);
useEffect(() => {
if (!isEditMode || !isDragging || !onPanelPositionChange) return;
const handleMouseMove = (e: MouseEvent) => {
if (!dragStartRef.current || !containerRef.current) return;
const container = containerRef.current;
const rect = container.getBoundingClientRect();
const deltaX = e.clientX - dragStartRef.current.x;
const deltaY = e.clientY - dragStartRef.current.y;
// Convert pixel delta to percentage
const deltaXPercent = (deltaX / rect.width) * 100;
const deltaYPercent = (deltaY / rect.height) * 100;
const newX = Math.max(
0,
Math.min(100, dragStartRef.current.panelX + deltaXPercent),
);
const newY = Math.max(
0,
Math.min(100, dragStartRef.current.panelY + deltaYPercent),
);
onPanelPositionChange(newX, newY);
};
const handleMouseUp = () => {
dragStartRef.current = null;
setIsDragging(false);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isEditMode, isDragging, onPanelPositionChange]);
// Handle backdrop click (close when clicking outside panel)
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === overlayRef.current && !isEditMode) {
onClose();
}
},
[onClose, isEditMode],
);
// Handle touch on backdrop
const handleBackdropTouch = useCallback(
(e: React.TouchEvent) => {
if (e.target === overlayRef.current && !isEditMode) {
onClose();
}
},
[onClose, isEditMode],
);
// Extract panel styling from element
const panelXPercent = element.panelXPercent ?? 50;
const panelYPercent = element.panelYPercent ?? 50;
const panelWidth = element.panelWidth ?? '400';
const panelHeight = element.panelHeight ?? 'auto';
const panelBackgroundColor =
element.panelBackgroundColor ?? 'rgba(0, 0, 0, 0.85)';
const panelBorderRadius = element.panelBorderRadius ?? '12';
const panelBorderWidth = element.panelBorderWidth || '0';
const panelBorderColor = element.panelBorderColor || '#ffffff';
const panelBorderStyle = element.panelBorderStyle || 'solid';
const panelPadding = element.panelPadding ?? '20';
const panelBackdropBlur = element.panelBackdropBlur ?? '10px';
const panelTitle = element.panelTitle ?? 'Information';
const panelText = element.panelText ?? '';
const panelOverlayColor = element.panelOverlayColor ?? 'rgba(0, 0, 0, 0.3)';
// Check if overlay should be visible
const isOverlayVisible =
panelOverlayColor !== 'transparent' && panelOverlayColor !== 'none';
// Convert numeric values to px if needed
const toPx = (value: string): string => {
const trimmed = value.trim();
if (/[a-z%]+$/i.test(trimmed)) return trimmed;
const num = parseFloat(trimmed);
if (!Number.isFinite(num)) return trimmed;
return `${num}px`;
};
// Panel style with responsive constraints
const panelStyle: React.CSSProperties = {
position: 'absolute',
left: `${panelXPercent}%`,
top: `${panelYPercent}%`,
transform: 'translate(-50%, -50%)',
width:
panelWidth === 'auto'
? 'auto'
: `min(${toPx(panelWidth)}, calc(100vw - 32px))`,
maxHeight:
panelHeight === 'auto'
? 'calc(100dvh - 64px)'
: `min(${toPx(panelHeight)}, calc(100dvh - 64px))`,
backgroundColor: panelBackgroundColor,
borderRadius: toPx(panelBorderRadius),
border:
panelBorderWidth !== '0' && panelBorderWidth
? `${toPx(panelBorderWidth)} ${panelBorderStyle} ${panelBorderColor}`
: 'none',
padding: toPx(panelPadding),
WebkitBackdropFilter: `blur(${panelBackdropBlur})`,
backdropFilter: `blur(${panelBackdropBlur})`,
overflowY: 'auto',
opacity: isVisible ? 1 : 0,
transition: 'opacity 200ms ease-out',
// Safe area for notched devices
paddingTop: `max(${toPx(panelPadding)}, env(safe-area-inset-top))`,
paddingRight: `max(${toPx(panelPadding)}, env(safe-area-inset-right))`,
paddingBottom: `max(${toPx(panelPadding)}, env(safe-area-inset-bottom))`,
paddingLeft: `max(${toPx(panelPadding)}, env(safe-area-inset-left))`,
};
return (
<>
{/* Backdrop overlay - separate from panel for correct z-index stacking */}
<div
ref={overlayRef}
className='fixed inset-0 z-[51] overflow-hidden'
style={{
backgroundColor: isOverlayVisible ? panelOverlayColor : 'transparent',
pointerEvents: isEditMode ? 'none' : 'auto',
}}
onClick={handleBackdropClick}
onTouchEnd={handleBackdropTouch}
/>
{/* Inner container constrained to canvas bounds */}
<div
ref={containerRef}
className='fixed inset-0 z-[53] overflow-hidden pointer-events-none'
style={{
...cssVars,
...(letterboxStyles || { position: 'absolute', inset: 0 }),
}}
>
{/* Info Panel */}
<div
ref={panelRef}
role='dialog'
aria-modal='true'
aria-labelledby='info-panel-title'
tabIndex={-1}
style={{
...panelStyle,
cursor:
isEditMode && onPanelPositionChange
? isDragging
? 'grabbing'
: 'grab'
: undefined,
pointerEvents: 'auto', // Ensure panel receives events
}}
onClick={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
>
{/* Drag handle header (edit mode only) */}
{isEditMode && onPanelPositionChange && (
<div
className='absolute top-0 left-0 right-0 h-8 cursor-grab active:cursor-grabbing rounded-t-xl'
style={{
borderRadius: `${toPx(panelBorderRadius)} ${toPx(panelBorderRadius)} 0 0`,
}}
onMouseDown={handleDragStart}
>
{/* Drag indicator dots */}
<div className='flex items-center justify-center h-full gap-1'>
<div className='w-8 h-1 rounded-full bg-white/30' />
</div>
</div>
)}
{/* Close button */}
<button
type='button'
className='absolute top-2 right-2 p-1 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors z-10'
onClick={onClose}
aria-label='Close panel'
>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={2}
stroke='currentColor'
className='w-5 h-5'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M6 18 18 6M6 6l12 12'
/>
</svg>
</button>
{/* Content wrapper - disable pointer events in edit mode for dragging */}
<div
style={{
pointerEvents: isEditMode ? 'none' : 'auto',
display: 'flex',
flexDirection: 'column',
gap: toPx(element.infoPanelSectionGap ?? '12'),
}}
>
{/* Render sections in dynamic order using section instances */}
{sections.map((section) => {
switch (section.type) {
case 'header': {
// Header section: use section-level data or fall back to element-level
const headerImageUrl =
section.headerImageUrl ?? element.infoPanelHeaderImageUrl;
const headerText =
section.headerText ?? element.infoPanelHeaderText;
// Image takes priority, otherwise render text
if (headerImageUrl) {
return (
<div
key={section.id}
style={{
...headerStyle,
padding: 0,
overflow: 'hidden',
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolve(headerImageUrl)}
alt=''
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
draggable={false}
/>
</div>
);
}
if (headerText) {
return (
<div key={section.id} style={headerStyle}>
{headerText}
</div>
);
}
return null;
}
case 'title': {
// Use section-level title or fall back to element-level
const titleContent = section.title ?? panelTitle;
if (!titleContent) return null;
return (
<h2
key={section.id}
id='info-panel-title'
style={{
...titleStyle,
marginTop:
isEditMode && onPanelPositionChange
? '16px'
: undefined,
}}
>
{titleContent}
</h2>
);
}
case 'text': {
// Use section-level text or fall back to element-level
const textContent = section.text ?? panelText;
if (!textContent) return null;
return (
<p key={section.id} style={textStyle}>
{textContent}
</p>
);
}
case 'spans': {
// Use section-level spans
const sectionSpans = section.spans || [];
if (sectionSpans.length === 0) return null;
// Build grid style with per-section settings
const spanGridStyle = buildInfoPanelSpanGridStyleWithSection(
element,
section,
);
return (
<div key={section.id} style={spanGridStyle}>
{sectionSpans.map((span) => (
<div key={span.id} style={spanStyle}>
{span.iconUrl ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={resolve(span.iconUrl)}
alt={span.text || ''}
style={{
width: '1em',
height: '1em',
objectFit: 'contain',
}}
draggable={false}
/>
) : (
span.text
)}
</div>
))}
</div>
);
}
case 'cards': {
// Use section-level images
const sectionImages = section.images || [];
if (sectionImages.length === 0) return null;
// Build grid style with per-section settings
const cardGridStyle = buildInfoPanelCardGridStyleWithSection(
element,
section,
);
return (
<div key={section.id} style={cardGridStyle}>
{sectionImages.map((image) => (
<button
key={image.id}
type='button'
style={{
...cardStyle,
display: 'block', // Needed for aspectRatio to work on buttons
position: 'relative',
cursor: 'pointer',
border: 'none',
padding: 0,
overflow: 'hidden', // Respect borderRadius on children
}}
className='focus:outline-none'
onClick={() => onImageClick(image)}
aria-label={image.caption || 'View image'}
>
{image.isEmbed ? (
// Embed placeholder - shows globe icon
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-8 h-8 mb-1'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-4.247m0 0A8.966 8.966 0 0 1 3 12c0-1.97.633-3.79 1.706-5.27'
/>
</svg>
<span className='text-xs'>360/Embed</span>
</div>
) : image.imageUrl ? (
// Regular image thumbnail
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(image.imageUrl)}
alt={image.caption || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
draggable={false}
/>
) : (
// Empty placeholder
<div className='w-full h-full flex items-center justify-center text-white/40'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-8 h-8'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'
/>
</svg>
</div>
)}
{/* Card title overlay */}
{image.caption && (
<span
style={{
...cardTitleStyle,
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
}}
>
{image.caption}
</span>
)}
</button>
))}
</div>
);
}
case 'images': {
// Use section-level images
const sectionImages = section.images || [];
// Filter to get only regular images (not 360°)
const imageItems = sectionImages.filter(
(img) =>
img.itemType === 'image' ||
(!img.itemType && !img.isEmbed),
);
// Get 360° items for trigger buttons
const triggerItems = sectionImages.filter(
(img) =>
img.itemType === '360' || (!img.itemType && img.isEmbed),
);
// Nothing to show if no items
if (imageItems.length === 0 && triggerItems.length === 0)
return null;
// Use per-section selected state, fallback to first image
const selectedId =
selectedImagePerSection[section.id] || imageItems[0]?.id;
const selectedImage = imageItems.find(
(img) => img.id === selectedId,
);
// Handler to update selected image for this section
const handleSelectImage = (imageId: string) => {
setSelectedImagePerSection((prev) => ({
...prev,
[section.id]: imageId,
}));
// Also call the optional external handler
onSelectImage?.(imageId);
};
// Build grid style with per-section settings
const gridStyle = buildInfoPanelCardGridStyleWithSection(
element,
section,
);
// Thumbnail style for grid items
const thumbnailStyle: React.CSSProperties = {
...cardStyle,
display: 'block',
width: '100%',
cursor: 'pointer',
border: 'none',
padding: 0,
overflow: 'hidden',
};
return (
<div
key={section.id}
className='images-section'
style={{
display: 'flex',
flexDirection: 'column',
gap: gridStyle.gap || 'calc(8 * var(--cu, 1px))',
}}
>
{/* Large Preview - always visible */}
<div
style={{
...imagesPreviewStyle,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{selectedImage?.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(selectedImage.imageUrl)}
alt={selectedImage.caption || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
}}
draggable={false}
/>
) : (
<div className='text-white/40 text-sm'>
{imageItems.length === 0
? 'No images added'
: 'No image selected'}
</div>
)}
</div>
{/* Thumbnail Grid */}
<div style={gridStyle}>
{/* Image thumbnails - click to select for preview */}
{imageItems.map((img) => (
<button
key={img.id}
type='button'
style={{
...thumbnailStyle,
opacity: img.id === selectedId ? 1 : 0.6,
}}
className='transition-opacity hover:opacity-100 focus:outline-none'
onClick={() => handleSelectImage(img.id)}
aria-label={img.caption || 'Select image'}
aria-pressed={img.id === selectedId}
>
{img.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(img.imageUrl)}
alt=''
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
draggable={false}
/>
) : (
<div className='w-full h-full flex items-center justify-center text-white/40'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-6 h-6'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'
/>
</svg>
</div>
)}
</button>
))}
{/* 360° trigger buttons - click to open 360° view */}
{triggerItems.map((img) => (
<button
key={img.id}
type='button'
style={thumbnailStyle}
className='trigger-360 focus:outline-none'
onClick={() => {
// Toggle behavior: if already open, close; otherwise open
if (active360ItemId === img.id) {
onImageClick(null);
} else {
onImageClick(img);
}
}}
aria-label={
active360ItemId === img.id
? 'Close 360° view'
: 'Open 360° view'
}
aria-pressed={active360ItemId === img.id}
>
{img.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(img.iconUrl)}
alt='360°'
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
}}
draggable={false}
/>
) : (
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-8 h-8 mb-1'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-4.247m0 0A8.966 8.966 0 0 1 3 12c0-1.97.633-3.79 1.706-5.27'
/>
</svg>
<span className='text-xs'>360°</span>
</div>
)}
</button>
))}
</div>
</div>
);
}
default:
return null;
}
})}
</div>
</div>
</div>
</>
);
};
export default InfoPanelOverlay;

View File

@ -15,7 +15,6 @@ import type { PreloadCacheProvider } from '../../hooks/video';
import { useElementWrapperStyle } from './shared/useElementWrapperStyle';
import {
isNavigationElementType,
isTooltipElementType,
isDescriptionElementType,
isGalleryElementType,
isCarouselElementType,
@ -24,12 +23,12 @@ import {
isLogoElementType,
isSpotElementType,
isPopupElementType,
isInfoPanelElementType,
} 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';
@ -37,6 +36,7 @@ import SpotElement from './elements/SpotElement';
import VideoPlayerElement from './elements/VideoPlayerElement';
import AudioPlayerElement from './elements/AudioPlayerElement';
import PopupElement from './elements/PopupElement';
import InfoPanelElement from './elements/InfoPanelElement';
export interface UiElementRendererProps {
element: CanvasElement;
@ -52,6 +52,8 @@ export interface UiElementRendererProps {
x: number,
y: number,
) => void;
// Info panel click callback
onInfoPanelClick?: () => void;
// Letterbox styles for constraining fullscreen elements to canvas bounds
letterboxStyles?: React.CSSProperties;
// Page transition settings (for slide transition cascade in carousel/gallery)
@ -73,6 +75,7 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
isEditMode = false,
onGalleryCardClick,
onCarouselButtonPositionChange,
onInfoPanelClick,
letterboxStyles,
pageTransitionSettings,
preloadCache,
@ -93,9 +96,6 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
if (isGalleryElementType(element.type)) {
return <GalleryElement {...commonProps} onCardClick={onGalleryCardClick} />;
}
if (isTooltipElementType(element.type)) {
return <TooltipElement {...commonProps} />;
}
if (isDescriptionElementType(element.type)) {
return <DescriptionElement {...commonProps} />;
}
@ -125,6 +125,9 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
if (isPopupElementType(element.type)) {
return <PopupElement {...commonProps} />;
}
if (isInfoPanelElementType(element.type)) {
return <InfoPanelElement {...commonProps} onClick={onInfoPanelClick} />;
}
// Fallback for unknown types
return (

View File

@ -9,7 +9,6 @@ export const UI_ELEMENT_TYPES: ElementType[] = [
'nav_button',
'spot',
'description',
'tooltip',
'gallery',
'carousel',
'logo',
@ -84,26 +83,6 @@ const defaultSettingsByType: Record<string, ElementSettings> = {
text: 'Description text',
},
},
tooltip: {
style: {
color: '#ffffff',
backgroundColor: '#1f2937',
border: '1px solid #1f2937',
},
layout: {
position: 'absolute',
widthPercent: 20,
heightPercent: 8,
xPercent: 40,
yPercent: 16,
},
content: {
icon: 'mdiTooltipText',
title: 'Tooltip',
placeholder: 'Hover text',
text: 'Tooltip content',
},
},
gallery: {
style: {
color: '#111827',

View File

@ -0,0 +1,98 @@
/**
* InfoPanelElement Component
*
* Trigger button for the Info Panel.
* Renders an icon (if provided) or text label (like navigation buttons).
* Uses hover reveal effect from Task 1 for "appear on hover".
*/
import React from 'react';
import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
interface InfoPanelElementProps {
element: CanvasElement;
resolveUrl?: (url: string | undefined) => string;
className: string;
style: CSSProperties;
onClick?: () => void;
}
const InfoPanelElement: React.FC<InfoPanelElementProps> = ({
element,
resolveUrl,
className,
style,
onClick,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const isDisabled = element.infoPanelDisabled || false;
const triggerLabel = element.infoPanelTriggerLabel || 'Info';
const fontFamily = element.infoPanelTriggerFontFamily || undefined;
// Handle click with disabled check
const handleClick = () => {
if (!isDisabled && onClick) {
onClick();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
};
// Common wrapper props
const wrapperProps = {
className,
style: {
...style,
opacity: isDisabled ? 0.5 : style.opacity,
cursor: isDisabled ? 'not-allowed' : style.cursor,
},
onClick: handleClick,
role: 'button' as const,
tabIndex: isDisabled ? -1 : 0,
onKeyDown: handleKeyDown,
'aria-disabled': isDisabled,
};
// With icon: render image
if (element.iconUrl) {
const imgStyle: CSSProperties = {
width: element.width ? '100%' : 'auto',
height: element.height ? '100%' : 'auto',
objectFit: 'contain',
// Override Tailwind preflight's max-width: 100% which causes shrinking near canvas edges
maxWidth: 'none',
};
return (
<div {...wrapperProps}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolve(element.iconUrl)}
alt={triggerLabel}
style={imgStyle}
draggable={false}
/>
</div>
);
}
// Without icon: render text label (like navigation button)
const labelStyle: CSSProperties = {
fontFamily: fontFamily || 'inherit',
};
return (
<div {...wrapperProps}>
<span style={labelStyle}>{triggerLabel}</span>
</div>
);
};
export default InfoPanelElement;

View File

@ -1,81 +0,0 @@
/**
* TooltipElement Component
*
* Tooltip element with icon or text content.
* Renders with unified wrapper styling + content.
*/
import React, { useMemo } from 'react';
import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
import { getFontByKey, getFontStyle } from '../../../lib/fonts';
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;
// Resolve font keys to full CSS styles (including fontStretch for condensed variants)
const titleFontStyle = useMemo(() => {
const fontKey = element.tooltipTitleFontFamily;
if (!fontKey) return {};
const font = getFontByKey(fontKey);
return font ? getFontStyle(font) : { fontFamily: fontKey };
}, [element.tooltipTitleFontFamily]);
const textFontStyle = useMemo(() => {
const fontKey = element.tooltipTextFontFamily;
if (!fontKey) return {};
const font = getFontByKey(fontKey);
return font ? getFontStyle(font) : { fontFamily: fontKey };
}, [element.tooltipTextFontFamily]);
// With icon: render image
if (element.iconUrl) {
const imgStyle: CSSProperties = {
width: element.width ? '100%' : 'auto',
height: element.height ? '100%' : 'auto',
objectFit: 'contain',
// Override Tailwind preflight's max-width: 100% which causes shrinking near canvas edges
maxWidth: 'none',
};
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
// Inheritable styles (color, fontSize, fontWeight) cascade from General Element Styles
// Font family can be set per-section via tooltipTitleFontFamily/tooltipTextFontFamily
return (
<div className={className} style={style}>
<div className='p-3 max-w-[200px]'>
<p style={titleFontStyle}>{element.tooltipTitle}</p>
<p style={{ opacity: 0.7, ...textFontStyle }}>{element.tooltipText}</p>
</div>
</div>
);
};
export default TooltipElement;

View File

@ -12,13 +12,13 @@ import type { CanvasElement } from '../../../types/constructor';
import { buildElementStyle } from '../../../lib/elementStyles';
import { toCU } from '../../../lib/canvasScale';
import {
isTooltipElementType,
isDescriptionElementType,
isNavigationElementType,
isGalleryElementType,
isCarouselElementType,
isLogoElementType,
isSpotElementType,
isInfoPanelElementType,
} from '../../../lib/elementDefaults';
interface UseElementWrapperStyleOptions {
@ -50,11 +50,11 @@ export function useElementWrapperStyle({
// Determine element characteristics
const hasIconDrivenSize =
Boolean(element.iconUrl) &&
(isTooltipElementType(element.type) ||
isDescriptionElementType(element.type) ||
(isDescriptionElementType(element.type) ||
isNavigationElementType(element.type) ||
isLogoElementType(element.type) ||
isSpotElementType(element.type));
isSpotElementType(element.type) ||
isInfoPanelElementType(element.type));
const hasTransparentBackground =
(isDescriptionElementType(element.type) &&
@ -62,9 +62,9 @@ export function useElementWrapperStyle({
(!element.backgroundColor ||
element.backgroundColor === 'transparent')) ||
(isNavigationElementType(element.type) && Boolean(element.iconUrl)) ||
isTooltipElementType(element.type) ||
isGalleryElementType(element.type) ||
isCarouselElementType(element.type);
isCarouselElementType(element.type) ||
isInfoPanelElementType(element.type);
// Navigation elements (with or without icon) should be centered
const isNavigationElement = isNavigationElementType(element.type);

View File

@ -80,6 +80,7 @@ export const PRELOAD_CONFIG = {
'galleryCarouselPrevIconUrl',
'galleryCarouselNextIconUrl',
'galleryCarouselBackIconUrl',
'infoPanelHeaderImageUrl',
'src',
'url',
'poster',
@ -95,10 +96,16 @@ export const PRELOAD_CONFIG = {
'galleryCarouselPrevIconUrl',
'galleryCarouselNextIconUrl',
'galleryCarouselBackIconUrl',
'infoPanelHeaderImageUrl',
'src',
] as const,
nested: ['galleryCards', 'carouselSlides', 'galleryInfoSpans'] as const,
nestedUrlFields: ['imageUrl', 'videoUrl', 'iconUrl'] as const,
nested: [
'galleryCards',
'carouselSlides',
'galleryInfoSpans',
'infoPanelSections',
] as const,
nestedUrlFields: ['imageUrl', 'videoUrl', 'iconUrl'] as const, // embedUrl is external, not preloaded
},
} as const;

View File

@ -26,6 +26,10 @@ import type {
GalleryCard,
GalleryInfoSpan,
CarouselSlide,
InfoPanelImage,
InfoPanelInfoSpan,
InfoPanelSectionType,
InfoPanelSectionInstance,
AssetOption,
} from '../types/constructor';
import type { TourPage, Asset } from '../types/entities';
@ -61,24 +65,38 @@ export interface CarouselSlideOperations {
remove: (slideId: string) => void;
}
// ============================================================================
// Transition Creation State
// ============================================================================
export interface TransitionCreationState {
name: string;
videoUrl: string;
supportsReverse: boolean;
isCreating: boolean;
}
export interface TransitionCreationActions {
setName: (name: string) => void;
setVideoUrl: (url: string) => void;
setSupportsReverse: (value: boolean) => void;
create: () => void;
export interface InfoPanelSectionOperations {
/** Move section up or down */
move: (sectionId: string, direction: 'up' | 'down') => void;
/** Remove a section instance */
remove: (sectionId: string) => void;
/** Add a new section of the given type (generates unique ID) */
add: (sectionType: InfoPanelSectionType) => void;
/** Update section instance settings (columns, gap) */
update: (sectionId: string, patch: Partial<InfoPanelSectionInstance>) => void;
/** Add a span to a specific section */
addSpan: (sectionId: string) => void;
/** Update a span in a specific section */
updateSpan: (
sectionId: string,
spanId: string,
patch: Partial<InfoPanelInfoSpan>,
) => void;
/** Remove a span from a specific section */
removeSpan: (sectionId: string, spanId: string) => void;
/** Add an image to a specific section */
addImage: (sectionId: string) => void;
/** Update an image in a specific section */
updateImage: (
sectionId: string,
imageId: string,
patch: Partial<InfoPanelImage>,
) => void;
/** Remove an image from a specific section */
removeImage: (sectionId: string, imageId: string) => void;
}
// ============================================================================
// ============================================================================
// Context Types
// ============================================================================
@ -140,12 +158,14 @@ export interface ConstructorContextValue {
audio: AssetOption[];
transitionVideo: AssetOption[];
icon: AssetOption[];
embed: AssetOption[];
};
// Gallery/Carousel operations
// Gallery/Carousel/InfoPanel operations
galleryCards: GalleryCardOperations;
galleryInfoSpans: GalleryInfoSpanOperations;
carouselSlides: CarouselSlideOperations;
infoPanelSectionOps: InfoPanelSectionOperations;
// Duration resolver
getDuration: (url: string) => number | undefined;
@ -156,15 +176,11 @@ export interface ConstructorContextValue {
backgroundAudio: string;
selectedMedia: string;
selectedTransition: string;
newTransition: string;
};
// Transition preview
onPreviewTransition: (direction: 'forward' | 'back') => void;
// Transition creation
transitionCreation: TransitionCreationState & TransitionCreationActions;
// Navigation settings
allowedNavigationTypes: NavigationElementType[];
normalizeNavigationType: (
@ -327,7 +343,7 @@ export function useConstructorAssets() {
}
/**
* Select gallery/carousel operations
* Select gallery/carousel/infoPanel operations
*/
export function useConstructorCollectionOps() {
const ctx = useConstructorContext();
@ -336,8 +352,14 @@ export function useConstructorCollectionOps() {
galleryCards: ctx.galleryCards,
galleryInfoSpans: ctx.galleryInfoSpans,
carouselSlides: ctx.carouselSlides,
infoPanelSectionOps: ctx.infoPanelSectionOps,
}),
[ctx.galleryCards, ctx.galleryInfoSpans, ctx.carouselSlides],
[
ctx.galleryCards,
ctx.galleryInfoSpans,
ctx.carouselSlides,
ctx.infoPanelSectionOps,
],
);
}
@ -355,14 +377,6 @@ export function useConstructorDuration() {
);
}
/**
* Select transition creation state
*/
export function useConstructorTransitionCreation() {
const ctx = useConstructorContext();
return ctx.transitionCreation;
}
/**
* Select navigation settings
*/

View File

@ -34,6 +34,8 @@ export interface AssetOptionsResult {
transitionVideo: AssetOption[];
/** Icon image assets */
icon: AssetOption[];
/** Embed assets (360° panoramas, iframes) */
embed: AssetOption[];
}
export interface UseAssetOptionsOptions {
@ -96,6 +98,18 @@ export function useAssetOptions({
// Icon assets
const iconOptions = useMemo(() => buildIconAssetOptions(assets), [assets]);
// Embed assets (360° panoramas, iframes) - filter by type='embed'
const embedOptions = useMemo(
() =>
assets
.filter((asset) => asset.type === 'embed' && getAssetSourceValue(asset))
.map((asset) => ({
value: getAssetSourceValue(asset),
label: asset.name || getAssetSourceValue(asset),
})),
[assets],
);
return useMemo(
() => ({
image: imageOptions,
@ -104,6 +118,7 @@ export function useAssetOptions({
audio: audioOptions,
transitionVideo: transitionVideoOptions,
icon: iconOptions,
embed: embedOptions,
}),
[
imageOptions,
@ -112,6 +127,7 @@ export function useAssetOptions({
audioOptions,
transitionVideoOptions,
iconOptions,
embedOptions,
],
);
}

View File

@ -12,6 +12,15 @@ import type {
GalleryCard,
GalleryInfoSpan,
CarouselSlide,
InfoPanelImage,
InfoPanelInfoSpan,
InfoPanelSectionType,
InfoPanelSectionInstance,
} from '../types/constructor';
import {
getInfoPanelSections,
generateSectionId,
generateItemId,
} from '../types/constructor';
import {
createDefaultElement,
@ -20,6 +29,7 @@ import {
isGalleryElementType,
isCarouselElementType,
isNavigationElementType,
isInfoPanelElementType,
getNavigationButtonLabel,
getNavigationButtonKind,
ELEMENT_TYPE_LABELS,
@ -100,6 +110,40 @@ interface UseConstructorElementsResult {
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
remove: (slideId: string) => void;
};
/** Info panel section operations with per-instance support */
infoPanelSectionOps: {
/** Move section up or down */
move: (sectionId: string, direction: 'up' | 'down') => void;
/** Remove a section instance */
remove: (sectionId: string) => void;
/** Add a new section of the given type (generates unique ID) */
add: (sectionType: InfoPanelSectionType) => void;
/** Update section instance settings (columns, gap) */
update: (
sectionId: string,
patch: Partial<InfoPanelSectionInstance>,
) => void;
/** Add a span to a specific section */
addSpan: (sectionId: string) => void;
/** Update a span in a specific section */
updateSpan: (
sectionId: string,
spanId: string,
patch: Partial<InfoPanelInfoSpan>,
) => void;
/** Remove a span from a specific section */
removeSpan: (sectionId: string, spanId: string) => void;
/** Add an image to a specific section */
addImage: (sectionId: string) => void;
/** Update an image in a specific section */
updateImage: (
sectionId: string,
imageId: string,
patch: Partial<InfoPanelImage>,
) => void;
/** Remove an image from a specific section */
removeImage: (sectionId: string, imageId: string) => void;
};
/** Update element position (for drag operations) */
updateElementPosition: (
elementId: string,
@ -433,6 +477,201 @@ export function useConstructorElements({
[selectedElement, updateSelectedElement],
);
// Info panel section operations with per-instance support
const infoPanelSectionOps = useMemo(
() => ({
// Move section by ID
move: (sectionId: string, direction: 'up' | 'down') => {
if (!selectedElement || !isInfoPanelElementType(selectedElement.type))
return;
updateSelectedElement((current) => {
const sections = [...getInfoPanelSections(current)];
const index = sections.findIndex((s) => s.id === sectionId);
if (index === -1) return {};
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= sections.length) return {};
[sections[index], sections[newIndex]] = [
sections[newIndex],
sections[index],
];
return { infoPanelSections: sections };
});
},
// Remove section by ID
remove: (sectionId: string) => {
if (!selectedElement || !isInfoPanelElementType(selectedElement.type))
return;
updateSelectedElement((current) => {
const sections = getInfoPanelSections(current).filter(
(s) => s.id !== sectionId,
);
return { infoPanelSections: sections };
});
},
// Add new section (generates unique ID, allows duplicates)
add: (sectionType: InfoPanelSectionType) => {
if (!selectedElement || !isInfoPanelElementType(selectedElement.type))
return;
updateSelectedElement((current) => {
const currentSections = getInfoPanelSections(current);
const newSection: InfoPanelSectionInstance = {
id: generateSectionId(),
type: sectionType,
// Set default settings and empty data arrays
...(sectionType === 'spans' && { columns: 3, gap: '8', spans: [] }),
...(sectionType === 'cards' && {
columns: 2,
gap: '8',
images: [],
}),
...(sectionType === 'images' && { images: [] }),
};
return { infoPanelSections: [...currentSections, newSection] };
});
},
// Update section instance settings
update: (sectionId: string, patch: Partial<InfoPanelSectionInstance>) => {
if (!selectedElement || !isInfoPanelElementType(selectedElement.type))
return;
updateSelectedElement((current) => {
const sections = getInfoPanelSections(current).map((s) =>
s.id === sectionId ? { ...s, ...patch } : s,
);
return { infoPanelSections: sections };
});
},
// Add span to specific section
addSpan: (sectionId: string) => {
if (!selectedElement || !isInfoPanelElementType(selectedElement.type))
return;
updateSelectedElement((current) => {
const sections = getInfoPanelSections(current).map((s) => {
if (s.id !== sectionId || s.type !== 'spans') return s;
return {
...s,
spans: [...(s.spans || []), { id: generateItemId(), text: '' }],
};
});
return { infoPanelSections: sections };
});
},
// Update span in specific section
updateSpan: (
sectionId: string,
spanId: string,
patch: Partial<InfoPanelInfoSpan>,
) => {
if (!selectedElement || !isInfoPanelElementType(selectedElement.type))
return;
updateSelectedElement((current) => {
const sections = getInfoPanelSections(current).map((s) => {
if (s.id !== sectionId || s.type !== 'spans') return s;
return {
...s,
spans: (s.spans || []).map((span) =>
span.id === spanId ? { ...span, ...patch } : span,
),
};
});
return { infoPanelSections: sections };
});
},
// Remove span from specific section
removeSpan: (sectionId: string, spanId: string) => {
if (!selectedElement || !isInfoPanelElementType(selectedElement.type))
return;
updateSelectedElement((current) => {
const sections = getInfoPanelSections(current).map((s) => {
if (s.id !== sectionId || s.type !== 'spans') return s;
return {
...s,
spans: (s.spans || []).filter((span) => span.id !== spanId),
};
});
return { infoPanelSections: sections };
});
},
// Add image to specific section
addImage: (sectionId: string) => {
if (!selectedElement || !isInfoPanelElementType(selectedElement.type))
return;
updateSelectedElement((current) => {
const sections = getInfoPanelSections(current).map((s) => {
if (
s.id !== sectionId ||
(s.type !== 'cards' && s.type !== 'images')
)
return s;
return {
...s,
images: [
...(s.images || []),
{ id: generateItemId(), imageUrl: '' },
],
};
});
return { infoPanelSections: sections };
});
},
// Update image in specific section
updateImage: (
sectionId: string,
imageId: string,
patch: Partial<InfoPanelImage>,
) => {
if (!selectedElement || !isInfoPanelElementType(selectedElement.type))
return;
updateSelectedElement((current) => {
const sections = getInfoPanelSections(current).map((s) => {
if (
s.id !== sectionId ||
(s.type !== 'cards' && s.type !== 'images')
)
return s;
return {
...s,
images: (s.images || []).map((img) =>
img.id === imageId ? { ...img, ...patch } : img,
),
};
});
return { infoPanelSections: sections };
});
},
// Remove image from specific section
removeImage: (sectionId: string, imageId: string) => {
if (!selectedElement || !isInfoPanelElementType(selectedElement.type))
return;
updateSelectedElement((current) => {
const sections = getInfoPanelSections(current).map((s) => {
if (
s.id !== sectionId ||
(s.type !== 'cards' && s.type !== 'images')
)
return s;
return {
...s,
images: (s.images || []).filter((img) => img.id !== imageId),
};
});
return { infoPanelSections: sections };
});
},
}),
[selectedElement, updateSelectedElement],
);
return {
elements,
setElements,
@ -448,6 +687,7 @@ export function useConstructorElements({
galleryCards,
galleryInfoSpans,
carouselSlides,
infoPanelSectionOps,
updateElementPosition,
normalizeNavigationType: normalizeNavigationElementType,
};

View File

@ -75,21 +75,12 @@ interface UseConstructorPageActionsResult {
isSavingToStage: boolean;
/** Whether page creation is in progress */
isCreatingPage: boolean;
/** Whether transition creation is in progress */
isCreatingTransition: boolean;
/** Save current constructor state */
saveConstructor: () => Promise<void>;
/** Save dev content to stage environment */
saveToStage: () => Promise<void>;
/** Create a new page with the given name and slug */
createPage: (pageName: string, slug: string) => Promise<void>;
/** Create a transition (legacy - transitions are now stored on elements) */
createTransition: (params: {
name?: string;
videoUrl: string;
supportsReverse?: boolean;
durationSec?: number;
}) => Promise<void>;
}
/**
@ -144,7 +135,6 @@ export function useConstructorPageActions({
const [isSaving, setIsSaving] = useState(false);
const [isSavingToStage, setIsSavingToStage] = useState(false);
const [isCreatingPage, setIsCreatingPage] = useState(false);
const [isCreatingTransition, setIsCreatingTransition] = useState(false);
// Polling hook for reverse video generation status
const { startPolling } = useReverseVideoPolling({
@ -387,68 +377,13 @@ export function useConstructorPageActions({
],
);
const createTransition = useCallback(
async (params: {
name?: string;
videoUrl: string;
supportsReverse?: boolean;
durationSec?: number;
}) => {
if (!projectId) {
onError?.('Project is required.');
return;
}
const sanitizedVideoUrl = String(params.videoUrl || '').trim();
if (!sanitizedVideoUrl) {
onError?.('Select a transition video asset first.');
return;
}
if (!params.durationSec) {
onError?.(
'Could not resolve transition video duration yet. Please wait a moment and try again.',
);
return;
}
try {
setIsCreatingTransition(true);
// Transitions are now stored directly in navigation elements as transitionVideoUrl
// This method is kept for backwards compatibility but just shows a message
onSuccess?.(
'Transition video can be set directly on navigation elements.',
);
} catch (error: unknown) {
const axiosError = error as {
response?: { data?: { message?: string } };
};
const message =
axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) ||
'Failed to create transition.';
logger.error(
'Failed to create transition from constructor:',
error instanceof Error ? error : { error },
);
onError?.(message);
} finally {
setIsCreatingTransition(false);
}
},
[projectId, onError, onSuccess],
);
return {
isSaving,
isSavingToStage,
isCreatingPage,
isCreatingTransition,
saveConstructor,
saveToStage,
createPage,
createTransition,
};
}

View File

@ -34,6 +34,8 @@ interface UseElementEffectsOptions {
resetKey?: string | number;
/** Whether appear animation has completed (to coordinate with reveal) */
appearAnimationCompleted?: boolean;
/** Force visibility regardless of hover state (e.g., when info panel is open) */
forceVisible?: boolean;
}
interface UseElementEffectsResult {
@ -73,7 +75,11 @@ export function useElementEffects(
effects: Partial<ElementEffectProperties>,
options?: UseElementEffectsOptions,
): UseElementEffectsResult {
const { resetKey, appearAnimationCompleted = true } = options ?? {};
const {
resetKey,
appearAnimationCompleted = true,
forceVisible = false,
} = options ?? {};
const [state, setState] = useState<ElementEffectState>({
isHovered: false,
@ -164,21 +170,27 @@ export function useElementEffects(
// Apply hover reveal style (controls opacity for reveal elements)
// Only apply initial opacity AFTER appear animation completes to avoid conflict
if (hasHoverReveal(effects) && appearAnimationCompleted) {
// With persist OR click-persisted: stays visible
// Without: only visible while hovering
// Priority: forceVisible > hoverRevealPersist > isClickPersisted > isHovered
// forceVisible: external state (e.g., info panel is open)
// hoverRevealPersist: stay visible after first hover
// isClickPersisted: toggle behavior from clicking (not used when forceVisible)
const shouldShow =
effects.hoverRevealPersist || state.isClickPersisted
? state.isRevealed || state.isClickPersisted
: state.isHovered;
forceVisible ||
(effects.hoverRevealPersist
? state.isRevealed
: state.isClickPersisted
? state.isRevealed || state.isClickPersisted
: state.isHovered);
effectStyle = {
...effectStyle,
...buildHoverRevealStyle(effects, shouldShow),
};
}
// Apply hover effects when hovered OR click-persisted (but not active)
// Apply hover effects when hovered, click-persisted, or forceVisible (but not active)
// Skip opacity when hoverReveal is active (reveal controls it)
const shouldApplyHover = state.isHovered || state.isClickPersisted;
const shouldApplyHover =
state.isHovered || state.isClickPersisted || forceVisible;
if (shouldApplyHover && !state.isActive && hasHoverEffects(effects)) {
const hoverStyle = buildHoverStyle(effects);
if (hasHoverReveal(effects)) {

View File

@ -2,7 +2,7 @@
* useIconPreload Hook
*
* Preloads icon images for smooth rendering without flash.
* Used in constructor.tsx to preload navigation/tooltip/description icons.
* Used in constructor.tsx to preload navigation/description icons.
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';

View File

@ -154,7 +154,6 @@ export function buildDurationProbeTargets({
backgroundVideoUrl,
backgroundAudioUrl,
selectedElement,
newTransitionVideoUrl,
elements,
isMediaElementType,
isVideoPlayerElementType,
@ -166,7 +165,6 @@ export function buildDurationProbeTargets({
type: string;
mediaUrl?: string;
} | null;
newTransitionVideoUrl?: string;
elements?: Array<{
type: string;
transitionVideoUrl?: string;
@ -199,10 +197,6 @@ export function buildDurationProbeTargets({
});
}
if (newTransitionVideoUrl) {
targets.push({ source: newTransitionVideoUrl, mediaType: 'video' });
}
elements?.forEach((element) => {
if (!isNavigationElementType(element.type)) return;
if (element.transitionVideoUrl) {

View File

@ -13,7 +13,6 @@ import type {
import {
isGalleryElementType,
isCarouselElementType,
isTooltipElementType,
isDescriptionElementType,
isNavigationElementType,
isMediaElementType,
@ -96,7 +95,6 @@ export const getElementButtonTitle = (element: CanvasElement): string => {
return `${element.label} (${element.carouselSlides?.length || 0})`;
}
if (isTooltipElementType(element.type)) return element.tooltipTitle ?? '';
if (isDescriptionElementType(element.type))
return element.descriptionTitle ?? '';

View File

@ -11,6 +11,7 @@ import type {
GalleryCard,
GalleryInfoSpan,
CarouselSlide,
InfoPanelImage,
NavigationButtonKind,
} from '../types/constructor';
import { ELEMENT_STYLE_PROPS } from './elementStyles';
@ -89,13 +90,13 @@ export const ELEMENT_TYPE_LABELS: Record<CanvasElementType, string> = {
navigation_prev: 'Navigation: Back',
spot: 'Hotspot',
description: 'Description',
tooltip: 'Tooltip',
gallery: 'Gallery',
carousel: 'Carousel',
logo: 'Logo',
video_player: 'Video Player',
audio_player: 'Audio Player',
popup: 'Popup',
info_panel: 'Info Panel',
};
/**
@ -141,11 +142,6 @@ export const TYPE_SPECIFIC_DEFAULTS: Partial<
iconUrl: '',
transitionReverseMode: 'auto_reverse',
},
tooltip: {
iconUrl: '',
tooltipTitle: 'Tooltip title',
tooltipText: 'Tooltip text',
},
description: {
iconUrl: '',
descriptionTitle: 'TITLE',
@ -184,6 +180,12 @@ export const TYPE_SPECIFIC_DEFAULTS: Partial<
iconUrl: '',
},
popup: {},
info_panel: {
// Structural properties only - styling/effects come from global/project defaults
iconUrl: '',
infoPanelTriggerLabel: 'Info',
infoPanelDisabled: false,
},
};
/**
@ -283,6 +285,19 @@ export const normalizeCarouselSlide = (
caption: String(slide?.caption ?? ''),
});
/**
* Normalize an info panel image from unknown input
*/
export const normalizeInfoPanelImage = (
image: Record<string, unknown>,
): InfoPanelImage => ({
id: String(image?.id || createLocalId()),
imageUrl: image?.imageUrl ? String(image.imageUrl) : undefined,
embedUrl: image?.embedUrl ? String(image.embedUrl) : undefined,
caption: image?.caption ? String(image.caption) : undefined,
isEmbed: Boolean(image?.isEmbed),
});
/**
* Merge an element with project/global defaults.
* Used when loading elements from the database or creating new elements.
@ -521,23 +536,6 @@ export const buildElementSettings = (
addIfNotEmpty(settings, 'reverseVideoUrl', element.reverseVideoUrl);
}
// Tooltip type settings
if (isTooltipElementType(elementType)) {
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
addIfNotEmpty(settings, 'tooltipTitle', element.tooltipTitle);
addIfNotEmpty(settings, 'tooltipText', element.tooltipText);
addIfNotEmpty(
settings,
'tooltipTitleFontFamily',
element.tooltipTitleFontFamily,
);
addIfNotEmpty(
settings,
'tooltipTextFontFamily',
element.tooltipTextFontFamily,
);
}
// Description type settings
if (isDescriptionElementType(elementType)) {
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
@ -654,6 +652,63 @@ export const buildElementSettings = (
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
}
// Info Panel type settings
if (isInfoPanelElementType(elementType)) {
// Trigger button settings
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
addIfNotEmpty(
settings,
'infoPanelTriggerLabel',
element.infoPanelTriggerLabel,
);
addIfNotEmpty(
settings,
'infoPanelTriggerFontFamily',
element.infoPanelTriggerFontFamily,
);
if (element.infoPanelDisabled !== undefined) {
settings.infoPanelDisabled = element.infoPanelDisabled;
}
// Panel settings
addIfNotEmpty(settings, 'panelTitle', element.panelTitle);
addIfNotEmpty(settings, 'panelText', element.panelText);
// Panel position & styling
if (element.panelXPercent !== undefined)
settings.panelXPercent = element.panelXPercent;
if (element.panelYPercent !== undefined)
settings.panelYPercent = element.panelYPercent;
addIfNotEmpty(settings, 'panelWidth', element.panelWidth);
addIfNotEmpty(settings, 'panelHeight', element.panelHeight);
addIfNotEmpty(
settings,
'panelBackgroundColor',
element.panelBackgroundColor,
);
addIfNotEmpty(settings, 'panelBorderRadius', element.panelBorderRadius);
addIfNotEmpty(settings, 'panelPadding', element.panelPadding);
addIfNotEmpty(settings, 'panelBackdropBlur', element.panelBackdropBlur);
addIfNotEmpty(settings, 'panelTitleColor', element.panelTitleColor);
addIfNotEmpty(settings, 'panelTitleFontSize', element.panelTitleFontSize);
addIfNotEmpty(settings, 'panelTextColor', element.panelTextColor);
addIfNotEmpty(settings, 'panelTextFontSize', element.panelTextFontSize);
addIfNotEmpty(settings, 'panelOverlayColor', element.panelOverlayColor);
// Detail panel position & styling
if (element.detailXPercent !== undefined)
settings.detailXPercent = element.detailXPercent;
if (element.detailYPercent !== undefined)
settings.detailYPercent = element.detailYPercent;
addIfNotEmpty(settings, 'detailWidth', element.detailWidth);
addIfNotEmpty(settings, 'detailHeight', element.detailHeight);
addIfNotEmpty(
settings,
'detailBackgroundColor',
element.detailBackgroundColor,
);
addIfNotEmpty(settings, 'detailBorderRadius', element.detailBorderRadius);
addIfNotEmpty(settings, 'detailPadding', element.detailPadding);
addIfNotEmpty(settings, 'detailOverlayColor', element.detailOverlayColor);
}
return settings;
};
@ -670,12 +725,6 @@ export const isNavigationElementType = (
): type is 'navigation_next' | 'navigation_prev' =>
type === 'navigation_next' || type === 'navigation_prev';
/**
* Check if a type is a tooltip element type
*/
export const isTooltipElementType = (type: string): type is 'tooltip' =>
type === 'tooltip';
/**
* Check if a type is a description element type
*/
@ -733,3 +782,9 @@ export const isSpotElementType = (type: string): type is 'spot' =>
*/
export const isPopupElementType = (type: string): type is 'popup' =>
type === 'popup';
/**
* Check if a type is an info panel element type
*/
export const isInfoPanelElementType = (type: string): type is 'info_panel' =>
type === 'info_panel';

View File

@ -202,6 +202,7 @@ export interface ElementStyleProperties {
margin?: string;
padding?: string;
gap?: string;
fontFamily?: string;
fontSize?: string;
lineHeight?: string;
fontWeight?: string;
@ -233,6 +234,7 @@ export const ELEMENT_STYLE_PROPS = [
'margin',
'padding',
'gap',
'fontFamily',
'fontSize',
'lineHeight',
'fontWeight',

View File

@ -0,0 +1,882 @@
/**
* Info Panel Section Styles
*
* Unified types and utilities for info panel element section styling.
* Follows the same pattern as gallerySectionStyles.ts for consistency.
*
* Canvas Units (--cu):
* All dimensions now use canvas units for uniform scaling across devices.
* The --cu custom property is set by CanvasScaleProvider based on viewport size.
*/
import type { CSSProperties } from 'react';
import type {
CanvasElement,
InfoPanelSectionInstance,
} from '../types/constructor';
import { getFontByKey, getFontStyle } from './fonts';
import { toCU } from './canvasScale';
/**
* Info Panel section names for styling
*/
export type InfoPanelSectionName =
| 'header'
| 'title'
| 'text'
| 'span'
| 'card'
| 'cardTitle'
| 'wrapper'
| 'imagesPreview'
| 'imagesThumbnail'
| 'imagesThumbnailStrip';
/**
* Default values for info panel sections using canvas units.
* Canvas units (--cu) scale with viewport for consistent appearance.
*/
export const INFO_PANEL_SECTION_DEFAULTS: Record<
InfoPanelSectionName,
CSSProperties
> = {
header: {
color: '#ffffff',
fontSize: 'calc(24 * var(--cu, 1px))',
fontWeight: '700',
padding: 'calc(8 * var(--cu, 1px))',
textAlign: 'center',
borderRadius: 'calc(8 * var(--cu, 1px))',
},
title: {
color: '#ffffff',
fontSize: 'calc(18 * var(--cu, 1px))',
fontWeight: '600',
padding: 'calc(4 * var(--cu, 1px)) calc(8 * var(--cu, 1px))',
textAlign: 'left',
},
text: {
color: '#cccccc',
fontSize: 'calc(14 * var(--cu, 1px))',
},
span: {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
color: '#ffffff',
fontSize: 'calc(12 * var(--cu, 1px))',
padding: 'calc(4 * var(--cu, 1px)) calc(8 * var(--cu, 1px))',
borderRadius: 'calc(6 * var(--cu, 1px))',
textAlign: 'center',
},
card: {
backgroundColor: 'rgba(0, 0, 0, 0.3)',
borderRadius: 'calc(8 * var(--cu, 1px))',
aspectRatio: '16/9',
},
cardTitle: {
backgroundColor: 'rgba(0, 0, 0, 0.6)',
color: '#ffffff',
fontSize: 'calc(12 * var(--cu, 1px))',
padding: 'calc(4 * var(--cu, 1px)) calc(8 * var(--cu, 1px))',
},
wrapper: {
backgroundColor: 'rgba(0, 0, 0, 0.85)',
padding: 'calc(20 * var(--cu, 1px))',
borderRadius: 'calc(12 * var(--cu, 1px))',
gap: 'calc(12 * var(--cu, 1px))',
backdropFilter: 'blur(10px)',
},
imagesPreview: {
width: '100%',
minHeight: 'calc(200 * var(--cu, 1px))',
borderRadius: 'calc(8 * var(--cu, 1px))',
backgroundColor: 'rgba(0, 0, 0, 0.1)',
overflow: 'hidden',
},
imagesThumbnail: {
width: 'calc(80 * var(--cu, 1px))',
height: 'calc(80 * var(--cu, 1px))',
flexShrink: 0,
borderRadius: 'calc(8 * var(--cu, 1px))',
overflow: 'hidden',
cursor: 'pointer',
},
imagesThumbnailStrip: {
display: 'flex',
gap: 'calc(8 * var(--cu, 1px))',
overflowX: 'auto',
paddingTop: 'calc(8 * var(--cu, 1px))',
},
};
/**
* Get trimmed value from unknown input
*/
const getTrimmedValue = (value: unknown): string => {
if (value === null || value === undefined) return '';
if (value === 0) return '0';
return String(value).trim();
};
/**
* Normalize values to canvas units.
* Handles rem, px, and plain numbers.
*/
const normalizeCanvasUnit = (value: unknown): string => {
const trimmed = getTrimmedValue(value);
if (!trimmed) return '';
// Zero doesn't need a unit
if (trimmed === '0') return '0';
// Already uses canvas units - return as-is
if (trimmed.includes('var(--cu') || trimmed.includes('--cu')) return trimmed;
if (/^calc\(/i.test(trimmed) && trimmed.includes('--cu')) return trimmed;
// Complex values (contain spaces) - return as-is
if (trimmed.includes(' ')) return trimmed;
// CSS functions (calc, var, etc.) - return as-is
if (/^(calc|var|min|max|clamp)\(/i.test(trimmed)) return trimmed;
// Handle rem units - convert to canvas units (1rem = 16px)
const remMatch = trimmed.match(/^(-?\d*\.?\d+)rem$/i);
if (remMatch) {
const rem = parseFloat(remMatch[1]);
const designPx = rem * 16;
return toCU(designPx);
}
// Handle px units - convert to canvas units
const pxMatch = trimmed.match(/^(-?\d*\.?\d+)px$/i);
if (pxMatch) {
const px = parseFloat(pxMatch[1]);
return toCU(px);
}
// Already has other units - return as-is
if (/[a-z%]+$/i.test(trimmed)) return trimmed;
// Handle plain numbers - treat as px for info panel
const num = parseFloat(trimmed);
if (Number.isFinite(num)) {
if (num === 0) return '0';
return toCU(num);
}
return trimmed;
};
/**
* Apply value with default fallback and optional unit normalization
*/
const applyWithDefault = (
style: CSSProperties,
prop: keyof CSSProperties,
value: unknown,
defaultValue: unknown,
normalize?: (v: unknown) => string,
): void => {
const trimmed = normalize ? normalize(value) : getTrimmedValue(value);
if (trimmed) {
(style as Record<string, unknown>)[prop] = trimmed;
} else if (defaultValue !== undefined) {
(style as Record<string, unknown>)[prop] = defaultValue;
}
};
/**
* Apply value only if explicitly set (no default - allows CSS inheritance)
*/
const applyIfSet = (
style: CSSProperties,
prop: keyof CSSProperties,
value: unknown,
normalize?: (v: unknown) => string,
): void => {
const trimmed = normalize ? normalize(value) : getTrimmedValue(value);
if (trimmed) {
(style as Record<string, unknown>)[prop] = trimmed;
}
};
/**
* Build CSS style object for info panel header section
*/
export function buildInfoPanelHeaderStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const defaults = INFO_PANEL_SECTION_DEFAULTS.header;
const style: CSSProperties = {};
applyIfSet(style, 'backgroundColor', element.infoPanelHeaderBackgroundColor);
applyWithDefault(
style,
'color',
element.infoPanelHeaderColor,
defaults.color,
);
applyWithDefault(
style,
'fontSize',
element.infoPanelHeaderFontSize,
defaults.fontSize,
normalizeCanvasUnit,
);
applyWithDefault(
style,
'fontWeight',
element.infoPanelHeaderFontWeight,
defaults.fontWeight,
);
applyWithDefault(
style,
'padding',
element.infoPanelHeaderPadding,
defaults.padding,
normalizeCanvasUnit,
);
applyWithDefault(
style,
'borderRadius',
element.infoPanelHeaderBorderRadius,
defaults.borderRadius,
normalizeCanvasUnit,
);
applyWithDefault(
style,
'textAlign',
element.infoPanelHeaderTextAlign,
defaults.textAlign,
);
applyIfSet(
style,
'minHeight',
element.infoPanelHeaderMinHeight,
normalizeCanvasUnit,
);
applyIfSet(
style,
'maxHeight',
element.infoPanelHeaderMaxHeight,
normalizeCanvasUnit,
);
applyIfSet(style, 'width', element.infoPanelHeaderWidth, normalizeCanvasUnit);
applyIfSet(
style,
'height',
element.infoPanelHeaderHeight,
normalizeCanvasUnit,
);
// Apply font family
const fontKey = element.infoPanelHeaderFontFamily;
if (fontKey) {
const font = getFontByKey(fontKey);
if (font) {
Object.assign(style, getFontStyle(font));
} else {
style.fontFamily = fontKey;
}
}
return style;
}
/**
* Build CSS style object for info panel title section
*/
export function buildInfoPanelTitleStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const defaults = INFO_PANEL_SECTION_DEFAULTS.title;
const style: CSSProperties = {};
applyIfSet(style, 'backgroundColor', element.infoPanelTitleBackgroundColor);
// Use new infoPanel* properties with fallback to legacy panel* properties
applyWithDefault(
style,
'color',
element.infoPanelTitleColor ?? element.panelTitleColor,
defaults.color,
);
applyWithDefault(
style,
'fontSize',
element.infoPanelTitleFontSize ?? element.panelTitleFontSize,
defaults.fontSize,
normalizeCanvasUnit,
);
applyWithDefault(
style,
'fontWeight',
element.infoPanelTitleFontWeight,
defaults.fontWeight,
);
applyWithDefault(
style,
'padding',
element.infoPanelTitlePadding,
defaults.padding,
normalizeCanvasUnit,
);
applyWithDefault(
style,
'textAlign',
element.infoPanelTitleTextAlign,
defaults.textAlign,
);
// Apply font family (new infoPanel* with fallback to legacy panel*)
const fontKey =
element.infoPanelTitleFontFamily ?? element.panelTitleFontFamily;
if (fontKey) {
const font = getFontByKey(fontKey);
if (font) {
Object.assign(style, getFontStyle(font));
} else {
style.fontFamily = fontKey;
}
}
return style;
}
/**
* Build CSS style object for info panel text section
*/
export function buildInfoPanelTextStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const defaults = INFO_PANEL_SECTION_DEFAULTS.text;
const style: CSSProperties = {};
// Use new infoPanel* properties with fallback to legacy panel* properties
applyIfSet(style, 'backgroundColor', element.infoPanelTextBackgroundColor);
applyWithDefault(
style,
'color',
element.infoPanelTextColor ?? element.panelTextColor,
defaults.color,
);
applyWithDefault(
style,
'fontSize',
element.infoPanelTextFontSize ?? element.panelTextFontSize,
defaults.fontSize,
normalizeCanvasUnit,
);
applyIfSet(style, 'fontWeight', element.infoPanelTextFontWeight);
applyIfSet(
style,
'padding',
element.infoPanelTextPadding,
normalizeCanvasUnit,
);
applyIfSet(style, 'textAlign', element.infoPanelTextTextAlign);
// Apply font family (new infoPanel* with fallback to legacy panel*)
const fontKey =
element.infoPanelTextFontFamily ?? element.panelTextFontFamily;
if (fontKey) {
const font = getFontByKey(fontKey);
if (font) {
Object.assign(style, getFontStyle(font));
} else {
style.fontFamily = fontKey;
}
}
return style;
}
/**
* Build CSS style object for info panel span items
*/
export function buildInfoPanelSpanStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const defaults = INFO_PANEL_SECTION_DEFAULTS.span;
const style: CSSProperties = {};
applyWithDefault(
style,
'backgroundColor',
element.infoPanelSpanBackgroundColor,
defaults.backgroundColor,
);
applyWithDefault(style, 'color', element.infoPanelSpanColor, defaults.color);
applyWithDefault(
style,
'fontSize',
element.infoPanelSpanFontSize,
defaults.fontSize,
normalizeCanvasUnit,
);
applyIfSet(style, 'fontWeight', element.infoPanelSpanFontWeight);
applyWithDefault(
style,
'padding',
element.infoPanelSpanPadding,
defaults.padding,
normalizeCanvasUnit,
);
applyWithDefault(
style,
'borderRadius',
element.infoPanelSpanBorderRadius,
defaults.borderRadius,
normalizeCanvasUnit,
);
applyWithDefault(
style,
'textAlign',
element.infoPanelSpanTextAlign,
defaults.textAlign,
);
// Apply font family
const fontKey = element.infoPanelSpanFontFamily;
if (fontKey) {
const font = getFontByKey(fontKey);
if (font) {
Object.assign(style, getFontStyle(font));
} else {
style.fontFamily = fontKey;
}
}
return style;
}
/**
* Build CSS style object for info panel span grid container
*/
export function buildInfoPanelSpanGridStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const columns = getInfoPanelGridColumns(element, 'span');
const gap =
normalizeCanvasUnit(element.infoPanelSpanGap) || 'calc(8 * var(--cu, 1px))';
return {
display: 'grid',
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap,
};
}
/**
* Build CSS style object for info panel span grid container with per-section settings.
* Uses section instance settings (columns, gap) if available, falls back to element settings.
*/
export function buildInfoPanelSpanGridStyleWithSection(
element: Partial<CanvasElement>,
section?: InfoPanelSectionInstance,
): CSSProperties {
// Use section-level settings if available, else fall back to element-level
const columns = section?.columns ?? getInfoPanelGridColumns(element, 'span');
const gap = section?.gap
? normalizeCanvasUnit(section.gap)
: normalizeCanvasUnit(element.infoPanelSpanGap) ||
'calc(8 * var(--cu, 1px))';
return {
display: 'grid',
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap,
};
}
/**
* Build CSS style object for info panel card items
*/
export function buildInfoPanelCardStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const defaults = INFO_PANEL_SECTION_DEFAULTS.card;
const style: CSSProperties = {};
applyWithDefault(
style,
'backgroundColor',
element.infoPanelCardBackgroundColor,
defaults.backgroundColor,
);
applyWithDefault(
style,
'borderRadius',
element.infoPanelCardBorderRadius,
defaults.borderRadius,
normalizeCanvasUnit,
);
applyWithDefault(
style,
'aspectRatio',
element.infoPanelCardAspectRatio,
defaults.aspectRatio,
);
applyIfSet(
style,
'minHeight',
element.infoPanelCardMinHeight,
normalizeCanvasUnit,
);
return style;
}
/**
* Build CSS style object for info panel card title overlay
*/
export function buildInfoPanelCardTitleStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const defaults = INFO_PANEL_SECTION_DEFAULTS.cardTitle;
const style: CSSProperties = {};
applyWithDefault(
style,
'backgroundColor',
element.infoPanelCardTitleBackgroundColor,
defaults.backgroundColor,
);
applyWithDefault(
style,
'color',
element.infoPanelCardTitleColor,
defaults.color,
);
applyWithDefault(
style,
'fontSize',
element.infoPanelCardTitleFontSize,
defaults.fontSize,
normalizeCanvasUnit,
);
applyIfSet(style, 'fontWeight', element.infoPanelCardTitleFontWeight);
applyWithDefault(
style,
'padding',
element.infoPanelCardTitlePadding,
defaults.padding,
normalizeCanvasUnit,
);
// Apply font family
const fontKey = element.infoPanelCardTitleFontFamily;
if (fontKey) {
const font = getFontByKey(fontKey);
if (font) {
Object.assign(style, getFontStyle(font));
} else {
style.fontFamily = fontKey;
}
}
return style;
}
/**
* Build CSS style object for info panel card grid container
*/
export function buildInfoPanelCardGridStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const columns = getInfoPanelGridColumns(element, 'card');
const gap =
normalizeCanvasUnit(element.infoPanelCardGap) || 'calc(8 * var(--cu, 1px))';
return {
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap,
width: '100%',
};
}
/**
* Build CSS style object for info panel card grid container with per-section settings.
* Uses section instance settings (columns, gap) if available, falls back to element settings.
*/
export function buildInfoPanelCardGridStyleWithSection(
element: Partial<CanvasElement>,
section?: InfoPanelSectionInstance,
): CSSProperties {
// Use section-level settings if available, else fall back to element-level
const columns = section?.columns ?? getInfoPanelGridColumns(element, 'card');
const gap = section?.gap
? normalizeCanvasUnit(section.gap)
: normalizeCanvasUnit(element.infoPanelCardGap) ||
'calc(8 * var(--cu, 1px))';
return {
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap,
width: '100%',
};
}
/**
* Build CSS style object for info panel wrapper
*/
export function buildInfoPanelWrapperStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const defaults = INFO_PANEL_SECTION_DEFAULTS.wrapper;
const style: CSSProperties = {};
applyWithDefault(
style,
'backgroundColor',
element.panelBackgroundColor,
defaults.backgroundColor,
);
applyWithDefault(
style,
'padding',
element.panelPadding,
defaults.padding,
normalizeCanvasUnit,
);
applyWithDefault(
style,
'borderRadius',
element.panelBorderRadius,
defaults.borderRadius,
normalizeCanvasUnit,
);
applyWithDefault(
style,
'gap',
element.infoPanelSectionGap,
defaults.gap,
normalizeCanvasUnit,
);
// Backdrop blur
const blur = element.panelBackdropBlur ?? '10px';
style.backdropFilter = `blur(${blur})`;
style.WebkitBackdropFilter = `blur(${blur})`;
return style;
}
/**
* Get default grid columns for section type
*/
export function getInfoPanelGridColumns(
_element: Partial<CanvasElement>,
section: 'span' | 'card',
): number {
return section === 'span' ? 3 : 2;
}
/**
* Build CSS style object for images section preview area
*/
export function buildImagesPreviewStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const defaults = INFO_PANEL_SECTION_DEFAULTS.imagesPreview;
const style: CSSProperties = {};
applyWithDefault(style, 'width', undefined, defaults.width);
applyWithDefault(style, 'minHeight', undefined, defaults.minHeight);
applyWithDefault(
style,
'borderRadius',
element.infoPanelCardBorderRadius,
defaults.borderRadius,
normalizeCanvasUnit,
);
applyWithDefault(
style,
'backgroundColor',
element.infoPanelCardBackgroundColor,
defaults.backgroundColor,
);
applyWithDefault(style, 'overflow', undefined, defaults.overflow);
// Apply preview height if set
if (element.infoPanelImagesPreviewHeight) {
const height = normalizeCanvasUnit(element.infoPanelImagesPreviewHeight);
if (height && height !== 'auto') {
style.height = height;
}
}
return style;
}
/**
* Build CSS style object for images section thumbnail strip container
*/
export function buildImagesThumbnailStripStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const defaults = INFO_PANEL_SECTION_DEFAULTS.imagesThumbnailStrip;
const style: CSSProperties = {};
applyWithDefault(style, 'display', undefined, defaults.display);
applyWithDefault(
style,
'gap',
element.infoPanelCardGap,
defaults.gap,
normalizeCanvasUnit,
);
applyWithDefault(style, 'overflowX', undefined, defaults.overflowX);
applyWithDefault(style, 'paddingTop', undefined, defaults.paddingTop);
return style;
}
/**
* Build CSS style object for images section thumbnail strip container with per-section settings.
* Uses section instance settings (columns, gap) if available, falls back to element settings.
* When columns is set, uses grid layout; otherwise uses flex layout.
*/
export function buildImagesThumbnailStripStyleWithSection(
element: Partial<CanvasElement>,
section?: InfoPanelSectionInstance,
): CSSProperties {
const defaults = INFO_PANEL_SECTION_DEFAULTS.imagesThumbnailStrip;
const style: CSSProperties = {};
// Use section-level gap if available, else fall back to element-level
const gapValue = section?.gap ?? element.infoPanelCardGap;
const gap = gapValue ? normalizeCanvasUnit(gapValue) : defaults.gap;
// Check if columns is set - if so, use grid layout
const columns = section?.columns;
if (columns && columns > 0) {
// Grid layout when columns is specified
style.display = 'grid';
style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
style.gap = gap;
} else {
// Default flex layout (horizontal strip)
applyWithDefault(style, 'display', undefined, defaults.display);
style.gap = gap;
applyWithDefault(style, 'overflowX', undefined, defaults.overflowX);
}
applyWithDefault(style, 'paddingTop', undefined, defaults.paddingTop);
return style;
}
/**
* Build CSS style object for images section thumbnail buttons
*/
export function buildImagesThumbnailStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const defaults = INFO_PANEL_SECTION_DEFAULTS.imagesThumbnail;
const cardDefaults = INFO_PANEL_SECTION_DEFAULTS.card;
const style: CSSProperties = {};
// Use custom thumbnail size or default
const size = element.infoPanelImagesThumbnailSize
? normalizeCanvasUnit(element.infoPanelImagesThumbnailSize)
: defaults.width;
style.width = size;
style.height = size;
applyWithDefault(style, 'flexShrink', undefined, defaults.flexShrink);
applyWithDefault(
style,
'borderRadius',
element.infoPanelCardBorderRadius,
defaults.borderRadius,
normalizeCanvasUnit,
);
applyWithDefault(style, 'overflow', undefined, defaults.overflow);
applyWithDefault(style, 'cursor', undefined, defaults.cursor);
// Use same backgroundColor as card style for consistency
applyWithDefault(
style,
'backgroundColor',
element.infoPanelCardBackgroundColor,
cardDefaults.backgroundColor,
);
return style;
}
/**
* All info panel section style property names for iteration
*/
export const INFO_PANEL_SECTION_STYLE_PROPS = [
// Header
'infoPanelHeaderImageUrl',
'infoPanelHeaderText',
'infoPanelHeaderBackgroundColor',
'infoPanelHeaderColor',
'infoPanelHeaderFontFamily',
'infoPanelHeaderFontSize',
'infoPanelHeaderFontWeight',
'infoPanelHeaderPadding',
'infoPanelHeaderBorderRadius',
'infoPanelHeaderTextAlign',
'infoPanelHeaderWidth',
'infoPanelHeaderHeight',
'infoPanelHeaderMinHeight',
'infoPanelHeaderMaxHeight',
// Title
'panelTitleColor',
'panelTitleFontSize',
'panelTitleFontFamily',
'infoPanelTitleBackgroundColor',
'infoPanelTitleColor',
'infoPanelTitleFontSize',
'infoPanelTitleFontFamily',
'infoPanelTitlePadding',
'infoPanelTitleFontWeight',
'infoPanelTitleTextAlign',
// Text
'panelTextColor',
'panelTextFontSize',
'panelTextFontFamily',
'infoPanelTextBackgroundColor',
'infoPanelTextColor',
'infoPanelTextFontSize',
'infoPanelTextFontFamily',
'infoPanelTextPadding',
'infoPanelTextFontWeight',
'infoPanelTextTextAlign',
// Spans
'infoPanelSpanBackgroundColor',
'infoPanelSpanColor',
'infoPanelSpanFontFamily',
'infoPanelSpanFontSize',
'infoPanelSpanFontWeight',
'infoPanelSpanTextAlign',
'infoPanelSpanPadding',
'infoPanelSpanBorderRadius',
// Cards
'infoPanelCardBackgroundColor',
'infoPanelCardBorderRadius',
'infoPanelCardAspectRatio',
'infoPanelCardMinHeight',
// Card Title
'infoPanelCardTitleBackgroundColor',
'infoPanelCardTitleColor',
'infoPanelCardTitleFontFamily',
'infoPanelCardTitleFontSize',
'infoPanelCardTitleFontWeight',
'infoPanelCardTitlePadding',
// Wrapper/Layout
'panelBackgroundColor',
'panelPadding',
'panelBorderRadius',
'panelBackdropBlur',
// Images section
'infoPanelSelectedImageId',
'infoPanelImagesPreviewHeight',
'infoPanelImagesThumbnailSize',
] as const;
export type InfoPanelSectionStyleProp =
(typeof INFO_PANEL_SECTION_STYLE_PROPS)[number];

View File

@ -18,6 +18,8 @@ import ConstructorToolbar from '../components/Constructor/ConstructorToolbar';
import TransitionPreviewOverlay from '../components/Constructor/TransitionPreviewOverlay';
import CanvasElementComponent from '../components/Constructor/CanvasElement';
import GalleryCarouselOverlay from '../components/UiElements/GalleryCarouselOverlay';
import InfoPanelOverlay from '../components/UiElements/InfoPanelOverlay';
import ImageDetailPanel from '../components/UiElements/ImageDetailPanel';
import ElementEditorPanel from '../components/Constructor/ElementEditorPanel';
import CreatePageModal from '../components/Constructor/CreatePageModal';
import { BackdropPortalProvider } from '../components/BackdropPortal';
@ -58,10 +60,10 @@ import {
ELEMENT_TYPE_LABELS,
getNavigationButtonKind,
isNavigationElementType,
isTooltipElementType,
isDescriptionElementType,
isMediaElementType,
isVideoPlayerElementType,
isInfoPanelElementType,
clamp,
} from '../lib/elementDefaults';
import type {
@ -72,6 +74,7 @@ import type {
GalleryCard,
GalleryInfoSpan,
CarouselSlide,
InfoPanelImage,
} from '../types/constructor';
import type { TourPage } from '../types/entities';
@ -250,10 +253,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const [isInitializing, setIsInitializing] = useState(true);
const isLoading = isInitializing || isDataLoading;
// isSaving, isSavingToStage, isCreatingPage are managed by useConstructorPageActions hook
const [newTransitionName, setNewTransitionName] = useState('');
const [newTransitionVideoUrl, setNewTransitionVideoUrl] = useState('');
const [newTransitionSupportsReverse, setNewTransitionSupportsReverse] =
useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [successMessage, setSuccessMessage] = useState('');
@ -282,6 +281,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
elementId: string;
initialIndex: number;
} | null>(null);
// Info panel overlay state
const [activeInfoPanel, setActiveInfoPanel] = useState<{
elementId: string;
} | null>(null);
const [activeDetailImage, setActiveDetailImage] =
useState<InfoPanelImage | null>(null);
// Current element transition settings (for CSS transitions when no video)
const [
currentElementTransitionSettings,
@ -309,6 +314,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
galleryCards,
galleryInfoSpans,
carouselSlides,
infoPanelSectionOps,
updateElementPosition,
normalizeNavigationType,
} = useConstructorElements({
@ -345,6 +351,30 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
);
}, [activeGalleryCarousel, elements]);
// Look up current element for info panel (so it receives updates from element editor)
const activeInfoPanelElement = useMemo(() => {
if (!activeInfoPanel) return null;
return elements.find((el) => el.id === activeInfoPanel.elementId) || null;
}, [activeInfoPanel, elements]);
// In edit mode, show overlay when info_panel element is selected (even without click)
const editModeInfoPanelElement = useMemo(() => {
if (!isConstructorEditMode || !selectedElement) return null;
if (!isInfoPanelElementType(selectedElement.type)) return null;
return selectedElement;
}, [isConstructorEditMode, selectedElement]);
// Determine which info panel element to use for overlay rendering
const infoPanelElementToRender =
activeInfoPanelElement || editModeInfoPanelElement;
const shouldShowInfoPanelOverlays = !!infoPanelElementToRender;
// Reset info panel state when switching between edit and interact modes
useEffect(() => {
setActiveInfoPanel(null);
setActiveDetailImage(null);
}, [isConstructorEditMode]);
// Draggable panels using useDraggable hook
const { position: toolbarPosition, onDragStart: onToolbarDragStart } =
useDraggable({
@ -610,7 +640,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const preloadableTypes: CanvasElementType[] = [
'navigation_next',
'navigation_prev',
'tooltip',
'description',
];
const urls = elements
@ -639,19 +668,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
backgroundVideoUrl,
backgroundAudioUrl,
selectedElement,
newTransitionVideoUrl,
elements,
isMediaElementType,
isVideoPlayerElementType,
isNavigationElementType,
}),
[
backgroundAudioUrl,
backgroundVideoUrl,
elements,
newTransitionVideoUrl,
selectedElement,
],
[backgroundAudioUrl, backgroundVideoUrl, elements, selectedElement],
);
const { getDuration, getDurationNote, durationBySource } =
@ -668,8 +690,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
return getDurationNote(selectedElement.mediaUrl || '');
}, [getDurationNote, selectedElement]);
const newTransitionDurationNote = getDurationNote(newTransitionVideoUrl);
const selectedTransitionDurationNote = useMemo(() => {
if (!selectedElement || !isNavigationElementType(selectedElement.type)) {
return 'Duration: unknown';
@ -677,12 +697,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
return getDurationNote(selectedElement.transitionVideoUrl || '');
}, [getDurationNote, selectedElement]);
useEffect(() => {
if (newTransitionVideoUrl) return;
if (!assetOptions.transitionVideo.length) return;
setNewTransitionVideoUrl(assetOptions.transitionVideo[0].value);
}, [newTransitionVideoUrl, assetOptions.transitionVideo]);
useEffect(() => {
setElements((prev) => {
let hasChanges = false;
@ -754,11 +768,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
isSaving,
isSavingToStage,
isCreatingPage,
isCreatingTransition,
saveConstructor,
saveToStage,
createPage,
createTransition,
} = useConstructorPageActions({
projectId,
project,
@ -1021,10 +1033,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
typeof item.galleryCarouselBackHeight === 'string'
? item.galleryCarouselBackHeight
: undefined,
tooltipTitle:
typeof item.tooltipTitle === 'string' ? item.tooltipTitle : '',
tooltipText:
typeof item.tooltipText === 'string' ? item.tooltipText : '',
descriptionTitle:
typeof item.descriptionTitle === 'string'
? item.descriptionTitle
@ -1401,6 +1409,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
[],
);
// Handler for info panel clicks
const handleInfoPanelClick = useCallback((element: CanvasElement) => {
if (isInfoPanelElementType(element.type)) {
setActiveInfoPanel({ elementId: element.id });
setActiveDetailImage(null);
}
}, []);
// Handler for gallery carousel button position changes (constructor only)
const handleGalleryCarouselButtonPositionChange = useCallback(
(button: 'prev' | 'next' | 'back', x: number, y: number) => {
@ -1457,7 +1473,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
(element: CanvasElement) => {
const isPreloadableIconElement =
(isNavigationElementType(element.type) ||
isTooltipElementType(element.type) ||
isDescriptionElementType(element.type)) &&
Boolean(element.iconUrl);
@ -1547,9 +1562,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
? 'Background video'
: selectedMenuItem === 'background_audio'
? 'Background audio'
: selectedMenuItem === 'create_transition'
? 'Create transition'
: selectedElement?.label || 'Element editor';
: selectedElement?.label || 'Element editor';
// Background image is rendered by CanvasBackground component (same as runtime)
// No CSS background-image needed on canvas div
@ -1561,42 +1574,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
backgroundAudio: backgroundAudioDurationNote,
selectedMedia: selectedMediaDurationNote,
selectedTransition: selectedTransitionDurationNote,
newTransition: newTransitionDurationNote,
}),
[
backgroundVideoDurationNote,
backgroundAudioDurationNote,
selectedMediaDurationNote,
selectedTransitionDurationNote,
newTransitionDurationNote,
],
);
// Transition creation state for context
const transitionCreationState = useMemo(
() => ({
name: newTransitionName,
videoUrl: newTransitionVideoUrl,
supportsReverse: newTransitionSupportsReverse,
isCreating: isCreatingTransition,
setName: setNewTransitionName,
setVideoUrl: setNewTransitionVideoUrl,
setSupportsReverse: setNewTransitionSupportsReverse,
create: () =>
createTransition({
name: newTransitionName,
videoUrl: newTransitionVideoUrl,
supportsReverse: newTransitionSupportsReverse,
durationSec: getDuration(newTransitionVideoUrl),
}),
}),
[
newTransitionName,
newTransitionVideoUrl,
newTransitionSupportsReverse,
isCreatingTransition,
createTransition,
getDuration,
],
);
@ -1662,10 +1645,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Asset options (derived from assets)
assetOptions,
// Gallery/Carousel operations
// Gallery/Carousel/InfoPanel operations
galleryCards,
galleryInfoSpans,
carouselSlides,
infoPanelSectionOps,
// Duration resolver
getDuration,
@ -1676,9 +1660,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Transition preview
onPreviewTransition: openTransitionPreview,
// Transition creation
transitionCreation: transitionCreationState,
// Navigation settings
allowedNavigationTypes,
normalizeNavigationType,
@ -1716,10 +1697,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
galleryCards,
galleryInfoSpans,
carouselSlides,
infoPanelSectionOps,
getDuration,
durationNotes,
openTransitionPreview,
transitionCreationState,
allowedNavigationTypes,
normalizeNavigationType,
saveConstructor,
@ -1929,6 +1910,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
y,
)
}
onInfoPanelClick={() => handleInfoPanelClick(element)}
isInfoPanelOpen={
activeInfoPanel?.elementId === element.id
}
letterboxStyles={letterboxStyles}
pageTransitionSettings={transitionSettings}
preloadCache={{
@ -2013,6 +1998,71 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
/>
)}
{/* Info Panel Overlay */}
{shouldShowInfoPanelOverlays && infoPanelElementToRender && (
<>
<InfoPanelOverlay
element={infoPanelElementToRender}
onClose={() => {
setActiveInfoPanel(null);
setActiveDetailImage(null);
}}
resolveUrl={resolveUrlWithBlob}
letterboxStyles={letterboxStyles}
cssVars={canvasCssVars}
onImageClick={(image) => setActiveDetailImage(image)}
onSelectImage={
isConstructorEditMode
? (imageId) => {
updateSelectedElement({
infoPanelSelectedImageId: imageId,
});
}
: undefined
}
isEditMode={isConstructorEditMode}
onPanelPositionChange={
isConstructorEditMode
? (xPercent, yPercent) => {
updateSelectedElement({
panelXPercent: xPercent,
panelYPercent: yPercent,
});
}
: undefined
}
active360ItemId={
activeDetailImage &&
(activeDetailImage.isEmbed ||
activeDetailImage.itemType === '360')
? activeDetailImage.id
: null
}
/>
{/* In edit mode, always show detail panel (with placeholder if no image selected) */}
{(activeDetailImage || isConstructorEditMode) && (
<ImageDetailPanel
element={infoPanelElementToRender}
image={activeDetailImage}
onClose={() => setActiveDetailImage(null)}
resolveUrl={resolveUrlWithBlob}
letterboxStyles={letterboxStyles}
isEditMode={isConstructorEditMode}
onDetailPositionChange={
isConstructorEditMode
? (xPercent, yPercent) => {
updateSelectedElement({
detailXPercent: xPercent,
detailYPercent: yPercent,
});
}
: undefined
}
/>
)}
</>
)}
{/* Create Page Modal */}
<CreatePageModal
isActive={isCreatePageModalActive}

View File

@ -30,11 +30,11 @@ import {
EffectsSettingsSection,
CommonSettingsSection,
NavigationSettingsSection,
TooltipSettingsSection,
DescriptionSettingsSection,
MediaSettingsSection,
GallerySettingsSection,
CarouselSettingsSection,
InfoPanelSettingsSection,
useElementSettingsForm,
} from '../../components/ElementSettings';
@ -319,18 +319,6 @@ const ElementTypeDefaultDetailsPage = () => {
/>
)}
{form.isTooltipType && (
<TooltipSettingsSection
iconUrl={form.state.iconUrl}
tooltipTitle={form.state.tooltipTitle}
tooltipText={form.state.tooltipText}
tooltipTitleFontFamily={form.state.tooltipTitleFontFamily}
tooltipTextFontFamily={form.state.tooltipTextFontFamily}
onChange={handleTypeChange}
context='global'
/>
)}
{form.isDescriptionType && (
<DescriptionSettingsSection
iconUrl={form.state.iconUrl}
@ -397,6 +385,133 @@ const ElementTypeDefaultDetailsPage = () => {
context='global'
/>
)}
{form.isInfoPanelType && (
<InfoPanelSettingsSection
iconUrl={form.state.iconUrl}
infoPanelTriggerLabel={form.state.infoPanelTriggerLabel}
infoPanelTriggerFontFamily={
form.state.infoPanelTriggerFontFamily
}
infoPanelDisabled={form.state.infoPanelDisabled}
// Header section
infoPanelHeaderImageUrl={
form.state.infoPanelHeaderImageUrl
}
infoPanelHeaderText={form.state.infoPanelHeaderText}
infoPanelHeaderBackgroundColor={
form.state.infoPanelHeaderBackgroundColor
}
infoPanelHeaderColor={form.state.infoPanelHeaderColor}
infoPanelHeaderFontFamily={
form.state.infoPanelHeaderFontFamily
}
infoPanelHeaderFontSize={
form.state.infoPanelHeaderFontSize
}
infoPanelHeaderFontWeight={
form.state.infoPanelHeaderFontWeight
}
infoPanelHeaderPadding={form.state.infoPanelHeaderPadding}
infoPanelHeaderBorderRadius={
form.state.infoPanelHeaderBorderRadius
}
infoPanelHeaderTextAlign={
form.state.infoPanelHeaderTextAlign
}
infoPanelHeaderMinHeight={
form.state.infoPanelHeaderMinHeight
}
// Panel content
panelTitle={form.state.panelTitle}
panelText={form.state.panelText}
// Span styling
infoPanelSpanBackgroundColor={
form.state.infoPanelSpanBackgroundColor
}
infoPanelSpanColor={form.state.infoPanelSpanColor}
infoPanelSpanFontFamily={
form.state.infoPanelSpanFontFamily
}
infoPanelSpanFontSize={form.state.infoPanelSpanFontSize}
infoPanelSpanPadding={form.state.infoPanelSpanPadding}
infoPanelSpanBorderRadius={
form.state.infoPanelSpanBorderRadius
}
// Card styling
infoPanelCardBackgroundColor={
form.state.infoPanelCardBackgroundColor
}
infoPanelCardBorderRadius={
form.state.infoPanelCardBorderRadius
}
infoPanelCardAspectRatio={
form.state.infoPanelCardAspectRatio
}
infoPanelCardMinHeight={form.state.infoPanelCardMinHeight}
infoPanelCardTitleBackgroundColor={
form.state.infoPanelCardTitleBackgroundColor
}
infoPanelCardTitleColor={
form.state.infoPanelCardTitleColor
}
infoPanelCardTitleFontFamily={
form.state.infoPanelCardTitleFontFamily
}
infoPanelCardTitleFontSize={
form.state.infoPanelCardTitleFontSize
}
infoPanelCardTitlePadding={
form.state.infoPanelCardTitlePadding
}
// Title section styling
infoPanelTitleBackgroundColor={
form.state.infoPanelTitleBackgroundColor
}
infoPanelTitlePadding={form.state.infoPanelTitlePadding}
infoPanelTitleFontWeight={
form.state.infoPanelTitleFontWeight
}
infoPanelTitleTextAlign={
form.state.infoPanelTitleTextAlign
}
// Panel position & styling
panelXPercent={form.state.panelXPercent}
panelYPercent={form.state.panelYPercent}
panelWidth={form.state.panelWidth}
panelHeight={form.state.panelHeight}
panelBackgroundColor={form.state.panelBackgroundColor}
panelBorderRadius={form.state.panelBorderRadius}
panelPadding={form.state.panelPadding}
panelBackdropBlur={form.state.panelBackdropBlur}
panelTitleColor={form.state.panelTitleColor}
panelTitleFontSize={form.state.panelTitleFontSize}
panelTitleFontFamily={form.state.panelTitleFontFamily}
panelTextColor={form.state.panelTextColor}
panelTextFontSize={form.state.panelTextFontSize}
panelTextFontFamily={form.state.panelTextFontFamily}
panelOverlayColor={form.state.panelOverlayColor}
// Detail panel
detailXPercent={form.state.detailXPercent}
detailYPercent={form.state.detailYPercent}
detailWidth={form.state.detailWidth}
detailHeight={form.state.detailHeight}
detailBackgroundColor={form.state.detailBackgroundColor}
detailBorderRadius={form.state.detailBorderRadius}
detailPadding={form.state.detailPadding}
detailCaptionFontFamily={
form.state.detailCaptionFontFamily
}
// Section instances
infoPanelSections={form.state.infoPanelSections}
// Handlers
onChange={handleTypeChange}
onMoveSection={form.moveInfoPanelSection}
onRemoveSection={form.removeInfoPanelSection}
onAddSection={form.addInfoPanelSection}
context='global'
/>
)}
</>
)}

View File

@ -31,11 +31,11 @@ import {
EffectsSettingsSection,
CommonSettingsSection,
NavigationSettingsSection,
TooltipSettingsSection,
DescriptionSettingsSection,
MediaSettingsSection,
GallerySettingsSection,
CarouselSettingsSection,
InfoPanelSettingsSection,
useElementSettingsForm,
} from '../../components/ElementSettings';
@ -128,6 +128,20 @@ const ProjectElementDefaultDetailsPage = () => {
[assets],
);
// Build image asset options from project assets
const imageAssetOptions: AssetOption[] = useMemo(
() =>
assets
.filter(
(asset) => asset.asset_type === 'image' && getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
})),
[assets],
);
// Extract stable callback reference to avoid infinite loop
const applySettings = form.applySettings;
@ -506,18 +520,6 @@ const ProjectElementDefaultDetailsPage = () => {
/>
)}
{form.isTooltipType && (
<TooltipSettingsSection
iconUrl={form.state.iconUrl}
tooltipTitle={form.state.tooltipTitle}
tooltipText={form.state.tooltipText}
tooltipTitleFontFamily={form.state.tooltipTitleFontFamily}
tooltipTextFontFamily={form.state.tooltipTextFontFamily}
onChange={handleTypeChange}
context='project'
/>
)}
{form.isDescriptionType && (
<DescriptionSettingsSection
iconUrl={form.state.iconUrl}
@ -582,6 +584,123 @@ const ProjectElementDefaultDetailsPage = () => {
context='project'
/>
)}
{form.isInfoPanelType && (
<InfoPanelSettingsSection
iconUrl={form.state.iconUrl}
infoPanelTriggerLabel={form.state.infoPanelTriggerLabel}
infoPanelTriggerFontFamily={
form.state.infoPanelTriggerFontFamily
}
infoPanelDisabled={form.state.infoPanelDisabled}
// Header section
infoPanelHeaderImageUrl={form.state.infoPanelHeaderImageUrl}
infoPanelHeaderText={form.state.infoPanelHeaderText}
infoPanelHeaderBackgroundColor={
form.state.infoPanelHeaderBackgroundColor
}
infoPanelHeaderColor={form.state.infoPanelHeaderColor}
infoPanelHeaderFontFamily={
form.state.infoPanelHeaderFontFamily
}
infoPanelHeaderFontSize={form.state.infoPanelHeaderFontSize}
infoPanelHeaderFontWeight={
form.state.infoPanelHeaderFontWeight
}
infoPanelHeaderPadding={form.state.infoPanelHeaderPadding}
infoPanelHeaderBorderRadius={
form.state.infoPanelHeaderBorderRadius
}
infoPanelHeaderTextAlign={
form.state.infoPanelHeaderTextAlign
}
infoPanelHeaderMinHeight={
form.state.infoPanelHeaderMinHeight
}
// Panel content
panelTitle={form.state.panelTitle}
panelText={form.state.panelText}
// Span styling
infoPanelSpanBackgroundColor={
form.state.infoPanelSpanBackgroundColor
}
infoPanelSpanColor={form.state.infoPanelSpanColor}
infoPanelSpanFontFamily={form.state.infoPanelSpanFontFamily}
infoPanelSpanFontSize={form.state.infoPanelSpanFontSize}
infoPanelSpanPadding={form.state.infoPanelSpanPadding}
infoPanelSpanBorderRadius={
form.state.infoPanelSpanBorderRadius
}
// Card styling
infoPanelCardBackgroundColor={
form.state.infoPanelCardBackgroundColor
}
infoPanelCardBorderRadius={
form.state.infoPanelCardBorderRadius
}
infoPanelCardAspectRatio={
form.state.infoPanelCardAspectRatio
}
infoPanelCardMinHeight={form.state.infoPanelCardMinHeight}
infoPanelCardTitleBackgroundColor={
form.state.infoPanelCardTitleBackgroundColor
}
infoPanelCardTitleColor={form.state.infoPanelCardTitleColor}
infoPanelCardTitleFontFamily={
form.state.infoPanelCardTitleFontFamily
}
infoPanelCardTitleFontSize={
form.state.infoPanelCardTitleFontSize
}
infoPanelCardTitlePadding={
form.state.infoPanelCardTitlePadding
}
// Title section styling
infoPanelTitleBackgroundColor={
form.state.infoPanelTitleBackgroundColor
}
infoPanelTitlePadding={form.state.infoPanelTitlePadding}
infoPanelTitleFontWeight={
form.state.infoPanelTitleFontWeight
}
infoPanelTitleTextAlign={form.state.infoPanelTitleTextAlign}
// Panel position & styling
panelXPercent={form.state.panelXPercent}
panelYPercent={form.state.panelYPercent}
panelWidth={form.state.panelWidth}
panelHeight={form.state.panelHeight}
panelBackgroundColor={form.state.panelBackgroundColor}
panelBorderRadius={form.state.panelBorderRadius}
panelPadding={form.state.panelPadding}
panelBackdropBlur={form.state.panelBackdropBlur}
panelTitleColor={form.state.panelTitleColor}
panelTitleFontSize={form.state.panelTitleFontSize}
panelTitleFontFamily={form.state.panelTitleFontFamily}
panelTextColor={form.state.panelTextColor}
panelTextFontSize={form.state.panelTextFontSize}
panelTextFontFamily={form.state.panelTextFontFamily}
panelOverlayColor={form.state.panelOverlayColor}
// Detail panel
detailXPercent={form.state.detailXPercent}
detailYPercent={form.state.detailYPercent}
detailWidth={form.state.detailWidth}
detailHeight={form.state.detailHeight}
detailBackgroundColor={form.state.detailBackgroundColor}
detailBorderRadius={form.state.detailBorderRadius}
detailPadding={form.state.detailPadding}
detailCaptionFontFamily={form.state.detailCaptionFontFamily}
// Section instances
infoPanelSections={form.state.infoPanelSections}
// Handlers
onChange={handleTypeChange}
onMoveSection={form.moveInfoPanelSection}
onRemoveSection={form.removeInfoPanelSection}
onAddSection={form.addInfoPanelSection}
context='project'
iconAssetOptions={iconAssetOptions}
imageAssetOptions={imageAssetOptions}
/>
)}
</>
)}

View File

@ -19,13 +19,13 @@ export type CanvasElementType =
| 'navigation_prev' // Back navigation button
| 'spot' // Hotspot/clickable area
| 'description' // Text description block
| 'tooltip' // Hover tooltip
| 'gallery' // Image gallery
| 'carousel' // Image carousel
| 'logo' // Logo element
| 'video_player' // Video player
| 'audio_player' // Audio player
| 'popup'; // Popup/modal
| 'popup' // Popup/modal
| 'info_panel'; // Info panel with images/embeds
/**
* Navigation button direction
@ -39,8 +39,7 @@ export type EditorMenuItem =
| 'none'
| 'background_image'
| 'background_video'
| 'background_audio'
| 'create_transition';
| 'background_audio';
/**
* Editor tab for element property editing
@ -87,6 +86,136 @@ export interface CarouselSlide {
caption: string;
}
/**
* Info panel item type for images section
* - 'image': Regular image displayed in inline preview
* - '360': 360° embed trigger that opens ImageDetailPanel
*/
export type InfoPanelItemType = 'image' | '360';
/**
* Info panel image/embed item
* Note: embedUrl is a direct URL for now. Task 3 will add proper asset-based embeds.
*/
export interface InfoPanelImage {
id: string;
imageUrl?: string; // Regular image URL (storage key)
embedUrl?: string; // 360/3D embed URL (direct URL, e.g., https://my.matterport.com/show/?m=...)
caption?: string;
/** @deprecated Use itemType instead - keep for backward compatibility */
isEmbed?: boolean; // Flag to distinguish image vs embed
/** Item type: 'image' for inline preview, '360' for embed trigger */
itemType?: InfoPanelItemType;
/** Custom icon URL for 360° trigger button */
iconUrl?: string;
}
/**
* Info panel info span (brief note badge, like Gallery)
*/
export interface InfoPanelInfoSpan {
id: string;
text: string;
iconUrl?: string; // Renders icon instead of text when set
}
/**
* Available Info Panel section types for dynamic ordering
* - 'cards': Legacy card grid (click opens ImageDetailPanel)
* - 'images': New inline image viewer with preview + thumbnail strip
*/
export type InfoPanelSectionType =
| 'header'
| 'title'
| 'text'
| 'spans'
| 'cards'
| 'images';
/**
* Section instance with unique ID, settings, AND data.
* Each section instance can have its own layout settings and data items.
*/
export interface InfoPanelSectionInstance {
/** Unique ID for this section instance (e.g., 'section-abc123') */
id: string;
/** Section type */
type: InfoPanelSectionType;
// Per-instance layout settings
/** Grid columns (for spans, cards, images) */
columns?: number;
/** Gap between items */
gap?: string;
// Per-instance data (each section has its OWN items)
/** For 'spans' type sections */
spans?: InfoPanelInfoSpan[];
/** For 'cards' or 'images' type sections */
images?: InfoPanelImage[];
/** For 'text' type sections (override element.panelText) */
text?: string;
/** For 'title' type sections (override element.panelTitle) */
title?: string;
/** For 'header' type sections - image URL */
headerImageUrl?: string;
/** For 'header' type sections - text content */
headerText?: string;
}
/**
* Default sections as instances when infoPanelSections is undefined
*/
export const DEFAULT_INFO_PANEL_SECTIONS: InfoPanelSectionInstance[] = [
{ id: 'default-header', type: 'header' },
{ id: 'default-title', type: 'title' },
{ id: 'default-text', type: 'text' },
{ id: 'default-spans', type: 'spans', columns: 3, gap: '8', spans: [] },
{ id: 'default-images', type: 'images', images: [] },
];
/**
* Human-readable labels for Info Panel sections
*/
export const INFO_PANEL_SECTION_LABELS: Record<InfoPanelSectionType, string> = {
header: 'Header',
title: 'Title',
text: 'Text',
spans: 'Info Spans',
cards: 'Cards',
images: 'Images',
};
/**
* Get sections from element.
* Returns section instances or defaults if not defined.
*/
export function getInfoPanelSections(
element: Partial<CanvasElement> | null | undefined,
): InfoPanelSectionInstance[] {
if (!element) return [...DEFAULT_INFO_PANEL_SECTIONS];
if (element.infoPanelSections && element.infoPanelSections.length > 0) {
return element.infoPanelSections;
}
return [...DEFAULT_INFO_PANEL_SECTIONS];
}
/**
* Generate unique section ID
*/
export function generateSectionId(): string {
return `section-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Generate unique item ID (for spans, images within sections)
*/
export function generateItemId(): string {
return `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Base canvas element with common positioning and styling fields.
* Extends ElementStyleProperties for CSS styling and ElementEffectProperties for effects.
@ -216,10 +345,6 @@ export interface CanvasElement extends BaseCanvasElement {
carouselSlideTransitionEasing?: EasingFunction | '';
/** Override overlay color for slide transitions */
carouselSlideTransitionOverlayColor?: string;
tooltipTitle?: string;
tooltipText?: string;
tooltipTitleFontFamily?: string;
tooltipTextFontFamily?: string;
descriptionTitle?: string;
descriptionText?: string;
descriptionTitleFontSize?: string;
@ -285,6 +410,122 @@ export interface CanvasElement extends BaseCanvasElement {
gallerySlideTransitionEasing?: EasingFunction | '';
/** Override overlay color for slide transitions */
gallerySlideTransitionOverlayColor?: string;
// ═══════════════════════════════════════════════════════════════════
// Info Panel Properties (3 components with independent positioning)
// ═══════════════════════════════════════════════════════════════════
// Component 1: Trigger Button - uses existing xPercent, yPercent, width, height, iconUrl
// Also uses hover reveal from Task 1
infoPanelTriggerLabel?: string;
infoPanelTriggerFontFamily?: string;
infoPanelDisabled?: boolean;
// Component 2: Info Panel (section-based like Gallery)
// Header section
infoPanelHeaderImageUrl?: string;
infoPanelHeaderText?: string;
// Title section (content)
panelTitle?: string;
panelText?: string;
// Panel position & wrapper styling
panelXPercent?: number;
panelYPercent?: number;
panelWidth?: string;
panelHeight?: string;
panelBackgroundColor?: string;
panelBorderRadius?: string;
panelBorderWidth?: string;
panelBorderColor?: string;
panelBorderStyle?: 'none' | 'solid' | 'dashed' | 'dotted';
panelPadding?: string;
panelBackdropBlur?: string;
panelOverlayColor?: string; // Backdrop overlay color (rgba, 'none', or 'transparent')
infoPanelSectionGap?: string; // Gap between sections
// Header section styles
infoPanelHeaderBackgroundColor?: string;
infoPanelHeaderColor?: string;
infoPanelHeaderFontFamily?: string;
infoPanelHeaderFontSize?: string;
infoPanelHeaderFontWeight?: string;
infoPanelHeaderPadding?: string;
infoPanelHeaderBorderRadius?: string;
infoPanelHeaderTextAlign?: 'left' | 'center' | 'right';
infoPanelHeaderWidth?: string;
infoPanelHeaderHeight?: string;
infoPanelHeaderMinHeight?: string;
infoPanelHeaderMaxHeight?: string;
// Title section styles
panelTitleColor?: string;
panelTitleFontSize?: string;
panelTitleFontFamily?: string;
infoPanelTitleBackgroundColor?: string;
infoPanelTitleColor?: string;
infoPanelTitleFontSize?: string;
infoPanelTitleFontFamily?: string;
infoPanelTitlePadding?: string;
infoPanelTitleFontWeight?: string;
infoPanelTitleTextAlign?: 'left' | 'center' | 'right';
// Text section styles
panelTextColor?: string;
panelTextFontSize?: string;
panelTextFontFamily?: string;
infoPanelTextBackgroundColor?: string;
infoPanelTextColor?: string;
infoPanelTextFontSize?: string;
infoPanelTextFontFamily?: string;
infoPanelTextPadding?: string;
infoPanelTextFontWeight?: string;
infoPanelTextTextAlign?: 'left' | 'center' | 'right';
// Span section styles
infoPanelSpanBackgroundColor?: string;
infoPanelSpanColor?: string;
infoPanelSpanFontFamily?: string;
infoPanelSpanFontSize?: string;
infoPanelSpanFontWeight?: string;
infoPanelSpanTextAlign?: 'left' | 'center' | 'right';
infoPanelSpanPadding?: string;
infoPanelSpanBorderRadius?: string;
infoPanelSpanGap?: string;
// Card section styles
infoPanelCardBackgroundColor?: string;
infoPanelCardBorderRadius?: string;
infoPanelCardAspectRatio?: string;
infoPanelCardMinHeight?: string;
infoPanelCardGap?: string;
// Card title overlay styles
infoPanelCardTitleBackgroundColor?: string;
infoPanelCardTitleColor?: string;
infoPanelCardTitleFontFamily?: string;
infoPanelCardTitleFontSize?: string;
infoPanelCardTitleFontWeight?: string;
infoPanelCardTitlePadding?: string;
// Section instances with per-section data and settings
/** Section instances with per-section columns, gap, spans, and images */
infoPanelSections?: InfoPanelSectionInstance[];
// Images section (inline image viewer)
/** Currently selected image ID in the images section preview */
infoPanelSelectedImageId?: string;
/** Preview area height for images section (e.g., '300', 'auto') */
infoPanelImagesPreviewHeight?: string;
/** Thumbnail size for images section (e.g., '80') */
infoPanelImagesThumbnailSize?: string;
// Component 3: Image Detail Panel
detailXPercent?: number;
detailYPercent?: number;
detailWidth?: string;
detailHeight?: string;
detailBackgroundColor?: string;
detailBorderRadius?: string;
detailBorderWidth?: string;
detailBorderColor?: string;
detailBorderStyle?: 'none' | 'solid' | 'dashed' | 'dotted';
detailPadding?: string;
detailCaptionFontFamily?: string;
detailOverlayColor?: string; // Backdrop overlay color (rgba, 'none', or 'transparent')
}
/**
@ -320,7 +561,8 @@ export interface ConstructorAsset {
| 'logo'
| 'favicon'
| 'document'
| 'general';
| 'general'
| 'embed';
cdn_url?: string | null;
storage_key?: string | null;
}
@ -341,13 +583,13 @@ const CANVAS_ELEMENT_TYPES: CanvasElementType[] = [
'navigation_prev',
'spot',
'description',
'tooltip',
'gallery',
'carousel',
'logo',
'video_player',
'audio_player',
'popup',
'info_panel',
];
/**
@ -452,8 +694,7 @@ export interface EditorElementProps {
| 'none'
| 'background_image'
| 'background_video'
| 'background_audio'
| 'create_transition';
| 'background_audio';
onRemoveElement: () => void;
onUpdateElement: (patch: Partial<CanvasElement>) => void;
}
@ -470,27 +711,12 @@ export interface EditorBackgroundProps {
onBackgroundAudioChange: (value: string) => void;
}
/**
* Transition creation props
*/
export interface EditorTransitionProps {
newTransitionName: string;
newTransitionVideoUrl: string;
newTransitionSupportsReverse: boolean;
isCreatingTransition: boolean;
onNewTransitionNameChange: (value: string) => void;
onNewTransitionVideoUrlChange: (value: string) => void;
onNewTransitionSupportsReverseChange: (value: boolean) => void;
onCreateTransition: () => void;
}
/**
* Duration notes props
*/
export interface EditorDurationNotesProps {
backgroundVideoDurationNote: string;
backgroundAudioDurationNote: string;
newTransitionDurationNote: string;
selectedMediaDurationNote: string;
selectedTransitionDurationNote: string;
}

View File

@ -68,7 +68,8 @@ export interface Asset extends BaseEntity {
| 'transition'
| 'logo'
| 'favicon'
| 'document';
| 'document'
| 'embed';
cdn_url?: string;
storage_key?: string;
mime_type?: string;