added Info panel element
This commit is contained in:
parent
e1ff820629
commit
990fc87b95
@ -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
@ -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}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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
@ -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[];
|
||||
|
||||
@ -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 "images" 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
247
frontend/src/components/ElementSettings/InfoPanelStyleInputs.tsx
Normal file
247
frontend/src/components/ElementSettings/InfoPanelStyleInputs.tsx
Normal 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 "transparent" 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;
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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'>
|
||||
|
||||
460
frontend/src/components/UiElements/ImageDetailPanel.tsx
Normal file
460
frontend/src/components/UiElements/ImageDetailPanel.tsx
Normal 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;
|
||||
810
frontend/src/components/UiElements/InfoPanelOverlay.tsx
Normal file
810
frontend/src/components/UiElements/InfoPanelOverlay.tsx
Normal 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;
|
||||
@ -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 (
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 ?? '';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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',
|
||||
|
||||
882
frontend/src/lib/infoPanelSectionStyles.ts
Normal file
882
frontend/src/lib/infoPanelSectionStyles.ts
Normal 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];
|
||||
@ -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}
|
||||
|
||||
@ -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'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -68,7 +68,8 @@ export interface Asset extends BaseEntity {
|
||||
| 'transition'
|
||||
| 'logo'
|
||||
| 'favicon'
|
||||
| 'document';
|
||||
| 'document'
|
||||
| 'embed';
|
||||
cdn_url?: string;
|
||||
storage_key?: string;
|
||||
mime_type?: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user