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,
|
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).
|
* 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 UiElementRenderer from '../UiElements/UiElementRenderer';
|
||||||
import { useElementEffects } from '../../hooks/useElementEffects';
|
import { useElementEffects } from '../../hooks/useElementEffects';
|
||||||
import {
|
import {
|
||||||
@ -19,6 +19,7 @@ import {
|
|||||||
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
|
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
|
||||||
import type { ResolvedTransitionSettings } from '../../types/transition';
|
import type { ResolvedTransitionSettings } from '../../types/transition';
|
||||||
import type { PreloadCacheProvider } from '../../hooks/video';
|
import type { PreloadCacheProvider } from '../../hooks/video';
|
||||||
|
import { isInfoPanelElementType } from '../../lib/elementDefaults';
|
||||||
|
|
||||||
interface CanvasElementProps {
|
interface CanvasElementProps {
|
||||||
element: CanvasElementType;
|
element: CanvasElementType;
|
||||||
@ -36,6 +37,10 @@ interface CanvasElementProps {
|
|||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
) => void;
|
) => 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 */
|
/** Letterbox styles for constraining fullscreen elements to canvas bounds */
|
||||||
letterboxStyles?: React.CSSProperties;
|
letterboxStyles?: React.CSSProperties;
|
||||||
/** Page transition settings (for slide transition cascade in carousel/gallery) */
|
/** Page transition settings (for slide transition cascade in carousel/gallery) */
|
||||||
@ -53,6 +58,8 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
|||||||
resolveUrl,
|
resolveUrl,
|
||||||
onGalleryCardClick,
|
onGalleryCardClick,
|
||||||
onCarouselButtonPositionChange,
|
onCarouselButtonPositionChange,
|
||||||
|
onInfoPanelClick,
|
||||||
|
isInfoPanelOpen = false,
|
||||||
letterboxStyles,
|
letterboxStyles,
|
||||||
pageTransitionSettings,
|
pageTransitionSettings,
|
||||||
preloadCache,
|
preloadCache,
|
||||||
@ -86,8 +93,10 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Use effects hook - disabled in edit mode to avoid interfering with dragging
|
// 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,
|
isEditMode ? {} : effectProperties,
|
||||||
|
{ forceVisible: isInfoPanelOpen },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clamp position to canvas bounds (0-100%)
|
// 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
|
// Handle keyboard interaction for accessibility
|
||||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onClick();
|
handleClick();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -141,7 +159,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
|||||||
className='absolute cursor-pointer'
|
className='absolute cursor-pointer'
|
||||||
style={positionStyle}
|
style={positionStyle}
|
||||||
onMouseDown={isEditMode ? onMouseDown : undefined}
|
onMouseDown={isEditMode ? onMouseDown : undefined}
|
||||||
onClick={onClick}
|
onClick={handleClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
{...(!isEditMode && !needsEffectWrapper ? eventHandlers : {})}
|
{...(!isEditMode && !needsEffectWrapper ? eventHandlers : {})}
|
||||||
>
|
>
|
||||||
@ -155,6 +173,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
|||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
onGalleryCardClick={onGalleryCardClick}
|
onGalleryCardClick={onGalleryCardClick}
|
||||||
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
|
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
|
||||||
|
onInfoPanelClick={onInfoPanelClick}
|
||||||
letterboxStyles={letterboxStyles}
|
letterboxStyles={letterboxStyles}
|
||||||
pageTransitionSettings={pageTransitionSettings}
|
pageTransitionSettings={pageTransitionSettings}
|
||||||
preloadCache={preloadCache}
|
preloadCache={preloadCache}
|
||||||
@ -168,6 +187,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
|||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
onGalleryCardClick={onGalleryCardClick}
|
onGalleryCardClick={onGalleryCardClick}
|
||||||
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
|
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
|
||||||
|
onInfoPanelClick={onInfoPanelClick}
|
||||||
letterboxStyles={letterboxStyles}
|
letterboxStyles={letterboxStyles}
|
||||||
pageTransitionSettings={pageTransitionSettings}
|
pageTransitionSettings={pageTransitionSettings}
|
||||||
preloadCache={preloadCache}
|
preloadCache={preloadCache}
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import {
|
|||||||
mdiChevronDown,
|
mdiChevronDown,
|
||||||
mdiImageMultiple,
|
mdiImageMultiple,
|
||||||
mdiViewCarousel,
|
mdiViewCarousel,
|
||||||
mdiTooltipText,
|
|
||||||
mdiSwapHorizontal,
|
mdiSwapHorizontal,
|
||||||
mdiText,
|
mdiText,
|
||||||
mdiPlus,
|
mdiPlus,
|
||||||
@ -20,6 +19,7 @@ import {
|
|||||||
mdiChevronRight,
|
mdiChevronRight,
|
||||||
mdiMusicNote,
|
mdiMusicNote,
|
||||||
mdiVideo,
|
mdiVideo,
|
||||||
|
mdiInformationOutline,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import BaseIcon from '../BaseIcon';
|
import BaseIcon from '../BaseIcon';
|
||||||
import BaseButton from '../BaseButton';
|
import BaseButton from '../BaseButton';
|
||||||
@ -92,7 +92,7 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
const triggerBtnClass =
|
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';
|
'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 =
|
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
|
// Collapsed state
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
@ -225,15 +225,6 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<MenuActionButton
|
|
||||||
icon={mdiSwapHorizontal}
|
|
||||||
label='Transition'
|
|
||||||
onClick={() =>
|
|
||||||
handleMenuAction(() =>
|
|
||||||
onSelectMenuItem('create_transition'),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MenuActionButton
|
<MenuActionButton
|
||||||
icon={mdiImageMultiple}
|
icon={mdiImageMultiple}
|
||||||
label='Gallery'
|
label='Gallery'
|
||||||
@ -248,13 +239,6 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
handleMenuAction(() => onAddElement('carousel'))
|
handleMenuAction(() => onAddElement('carousel'))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<MenuActionButton
|
|
||||||
icon={mdiTooltipText}
|
|
||||||
label='Tooltip'
|
|
||||||
onClick={() =>
|
|
||||||
handleMenuAction(() => onAddElement('tooltip'))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MenuActionButton
|
<MenuActionButton
|
||||||
icon={mdiText}
|
icon={mdiText}
|
||||||
label='Description'
|
label='Description'
|
||||||
@ -276,6 +260,13 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
handleMenuAction(() => onAddElement('audio_player'))
|
handleMenuAction(() => onAddElement('audio_player'))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<MenuActionButton
|
||||||
|
icon={mdiInformationOutline}
|
||||||
|
label='Info Panel'
|
||||||
|
onClick={() =>
|
||||||
|
handleMenuAction(() => onAddElement('info_panel'))
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ClickOutside>
|
</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;
|
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
|
* Element editor panel props
|
||||||
*/
|
*/
|
||||||
@ -229,14 +213,6 @@ export interface ElementEditorPanelProps {
|
|||||||
backgroundVideoEndTime: number | null;
|
backgroundVideoEndTime: number | null;
|
||||||
onBackgroundVideoSettingsChange: (settings: VideoPlaybackSettings) => void;
|
onBackgroundVideoSettingsChange: (settings: VideoPlaybackSettings) => void;
|
||||||
|
|
||||||
// Transition form
|
|
||||||
newTransitionName: string;
|
|
||||||
newTransitionVideoUrl: string;
|
|
||||||
newTransitionSupportsReverse: boolean;
|
|
||||||
transitionVideoOptions: AssetOption[];
|
|
||||||
newTransitionDurationNote: string;
|
|
||||||
isCreatingTransition: boolean;
|
|
||||||
|
|
||||||
// Asset options for elements
|
// Asset options for elements
|
||||||
imageAssetOptions: AssetOption[];
|
imageAssetOptions: AssetOption[];
|
||||||
videoAssetOptions: 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 React from 'react';
|
||||||
import type { StyleSettingsSectionProps } from './types';
|
import type { StyleSettingsSectionProps } from './types';
|
||||||
|
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||||
|
|
||||||
const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||||
values,
|
values,
|
||||||
@ -134,6 +135,23 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
|||||||
placeholder='e.g. 1'
|
placeholder='e.g. 1'
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
|
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
|
||||||
Font size
|
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 CommonSettingsSectionCompact } from './CommonSettingsSectionCompact';
|
||||||
export { default as NavigationSettingsSection } from './NavigationSettingsSection';
|
export { default as NavigationSettingsSection } from './NavigationSettingsSection';
|
||||||
export { default as NavigationSettingsSectionCompact } from './NavigationSettingsSectionCompact';
|
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 DescriptionSettingsSection } from './DescriptionSettingsSection';
|
||||||
export { default as DescriptionSettingsSectionCompact } from './DescriptionSettingsSectionCompact';
|
export { default as DescriptionSettingsSectionCompact } from './DescriptionSettingsSectionCompact';
|
||||||
export { default as MediaSettingsSection } from './MediaSettingsSection';
|
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 CarouselSettingsSection } from './CarouselSettingsSection';
|
||||||
export { default as CarouselSettingsSectionCompact } from './CarouselSettingsSectionCompact';
|
export { default as CarouselSettingsSectionCompact } from './CarouselSettingsSectionCompact';
|
||||||
export { default as GalleryCarouselSettingsSectionCompact } from './GalleryCarouselSettingsSectionCompact';
|
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
|
// Hook
|
||||||
export { useElementSettingsForm } from './useElementSettingsForm';
|
export { useElementSettingsForm } from './useElementSettingsForm';
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import type {
|
|||||||
GalleryInfoSpan,
|
GalleryInfoSpan,
|
||||||
CarouselSlide,
|
CarouselSlide,
|
||||||
AssetOption,
|
AssetOption,
|
||||||
|
InfoPanelSectionType,
|
||||||
|
InfoPanelSectionInstance,
|
||||||
} from '../../types/constructor';
|
} from '../../types/constructor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -113,20 +115,6 @@ export interface NavigationSettingsSectionProps {
|
|||||||
pageOptions?: { value: string; label: string }[];
|
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
|
* Props for description element settings
|
||||||
*/
|
*/
|
||||||
@ -265,6 +253,94 @@ export interface GalleryCarouselSettingsSectionProps {
|
|||||||
iconAssetOptions: AssetOption[];
|
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
|
* Value normalization helpers
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -12,6 +12,12 @@ import type {
|
|||||||
CanvasElementType,
|
CanvasElementType,
|
||||||
GalleryCard,
|
GalleryCard,
|
||||||
CarouselSlide,
|
CarouselSlide,
|
||||||
|
InfoPanelSectionType,
|
||||||
|
InfoPanelSectionInstance,
|
||||||
|
} from '../../types/constructor';
|
||||||
|
import {
|
||||||
|
DEFAULT_INFO_PANEL_SECTIONS,
|
||||||
|
generateSectionId,
|
||||||
} from '../../types/constructor';
|
} from '../../types/constructor';
|
||||||
import { parseJsonObject } from '../../lib/parseJson';
|
import { parseJsonObject } from '../../lib/parseJson';
|
||||||
import {
|
import {
|
||||||
@ -24,11 +30,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
createLocalId,
|
createLocalId,
|
||||||
isNavigationElementType,
|
isNavigationElementType,
|
||||||
isTooltipElementType,
|
|
||||||
isDescriptionElementType,
|
isDescriptionElementType,
|
||||||
isGalleryElementType,
|
isGalleryElementType,
|
||||||
isCarouselElementType,
|
isCarouselElementType,
|
||||||
isMediaElementType,
|
isMediaElementType,
|
||||||
|
isInfoPanelElementType,
|
||||||
} from '../../lib/elementDefaults';
|
} from '../../lib/elementDefaults';
|
||||||
|
|
||||||
interface UseElementSettingsFormOptions {
|
interface UseElementSettingsFormOptions {
|
||||||
@ -109,12 +115,6 @@ interface FormState {
|
|||||||
transitionReverseMode: 'auto_reverse' | 'separate_video';
|
transitionReverseMode: 'auto_reverse' | 'separate_video';
|
||||||
reverseVideoUrl: string;
|
reverseVideoUrl: string;
|
||||||
|
|
||||||
// Tooltip settings
|
|
||||||
tooltipTitle: string;
|
|
||||||
tooltipText: string;
|
|
||||||
tooltipTitleFontFamily: string;
|
|
||||||
tooltipTextFontFamily: string;
|
|
||||||
|
|
||||||
// Description settings
|
// Description settings
|
||||||
descriptionTitle: string;
|
descriptionTitle: string;
|
||||||
descriptionText: string;
|
descriptionText: string;
|
||||||
@ -143,6 +143,78 @@ interface FormState {
|
|||||||
// Complex arrays
|
// Complex arrays
|
||||||
galleryCards: GalleryCard[];
|
galleryCards: GalleryCard[];
|
||||||
carouselSlides: CarouselSlide[];
|
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 = {
|
const initialState: FormState = {
|
||||||
@ -210,10 +282,6 @@ const initialState: FormState = {
|
|||||||
transitionVideoUrl: '',
|
transitionVideoUrl: '',
|
||||||
transitionReverseMode: 'auto_reverse',
|
transitionReverseMode: 'auto_reverse',
|
||||||
reverseVideoUrl: '',
|
reverseVideoUrl: '',
|
||||||
tooltipTitle: '',
|
|
||||||
tooltipText: '',
|
|
||||||
tooltipTitleFontFamily: '',
|
|
||||||
tooltipTextFontFamily: '',
|
|
||||||
descriptionTitle: '',
|
descriptionTitle: '',
|
||||||
descriptionText: '',
|
descriptionText: '',
|
||||||
descriptionTitleFontSize: '',
|
descriptionTitleFontSize: '',
|
||||||
@ -233,6 +301,71 @@ const initialState: FormState = {
|
|||||||
galleryTextFontFamily: '',
|
galleryTextFontFamily: '',
|
||||||
galleryCards: [],
|
galleryCards: [],
|
||||||
carouselSlides: [],
|
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) {
|
export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
||||||
@ -241,11 +374,11 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
|||||||
|
|
||||||
// Type detection using shared helpers from elementDefaults
|
// Type detection using shared helpers from elementDefaults
|
||||||
const isNavigationType = isNavigationElementType(elementType);
|
const isNavigationType = isNavigationElementType(elementType);
|
||||||
const isTooltipType = isTooltipElementType(elementType);
|
|
||||||
const isDescriptionType = isDescriptionElementType(elementType);
|
const isDescriptionType = isDescriptionElementType(elementType);
|
||||||
const isGalleryType = isGalleryElementType(elementType);
|
const isGalleryType = isGalleryElementType(elementType);
|
||||||
const isCarouselType = isCarouselElementType(elementType);
|
const isCarouselType = isCarouselElementType(elementType);
|
||||||
const isMediaType = isMediaElementType(elementType);
|
const isMediaType = isMediaElementType(elementType);
|
||||||
|
const isInfoPanelType = isInfoPanelElementType(elementType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply settings from JSON to form state
|
* Apply settings from JSON to form state
|
||||||
@ -333,10 +466,6 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
|||||||
? 'separate_video'
|
? 'separate_video'
|
||||||
: 'auto_reverse',
|
: 'auto_reverse',
|
||||||
reverseVideoUrl: String(settings.reverseVideoUrl || ''),
|
reverseVideoUrl: String(settings.reverseVideoUrl || ''),
|
||||||
tooltipTitle: String(settings.tooltipTitle || ''),
|
|
||||||
tooltipText: String(settings.tooltipText || ''),
|
|
||||||
tooltipTitleFontFamily: String(settings.tooltipTitleFontFamily || ''),
|
|
||||||
tooltipTextFontFamily: String(settings.tooltipTextFontFamily || ''),
|
|
||||||
descriptionTitle: String(settings.descriptionTitle || ''),
|
descriptionTitle: String(settings.descriptionTitle || ''),
|
||||||
descriptionText: String(settings.descriptionText || ''),
|
descriptionText: String(settings.descriptionText || ''),
|
||||||
descriptionTitleFontSize: String(
|
descriptionTitleFontSize: String(
|
||||||
@ -377,6 +506,109 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
|||||||
caption: String(slide?.caption ?? ''),
|
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
|
* Build settings JSON for saving
|
||||||
*/
|
*/
|
||||||
@ -700,15 +984,6 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
|||||||
settings.reverseVideoUrl = state.reverseVideoUrl.trim();
|
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
|
// Description type settings
|
||||||
// Note: Color/fontSize/fontWeight cascade from General Element Styles via CSS inheritance
|
// Note: Color/fontSize/fontWeight cascade from General Element Styles via CSS inheritance
|
||||||
// Only set section-specific values if explicitly configured (allows inheritance)
|
// Only set section-specific values if explicitly configured (allows inheritance)
|
||||||
@ -773,15 +1048,243 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
|||||||
settings.mediaMuted = state.mediaMuted;
|
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;
|
return settings;
|
||||||
}, [
|
}, [
|
||||||
state,
|
state,
|
||||||
isNavigationType,
|
isNavigationType,
|
||||||
isTooltipType,
|
|
||||||
isDescriptionType,
|
isDescriptionType,
|
||||||
isGalleryType,
|
isGalleryType,
|
||||||
isCarouselType,
|
isCarouselType,
|
||||||
isMediaType,
|
isMediaType,
|
||||||
|
isInfoPanelType,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -794,11 +1297,11 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
|||||||
|
|
||||||
// Type checks
|
// Type checks
|
||||||
isNavigationType,
|
isNavigationType,
|
||||||
isTooltipType,
|
|
||||||
isDescriptionType,
|
isDescriptionType,
|
||||||
isGalleryType,
|
isGalleryType,
|
||||||
isCarouselType,
|
isCarouselType,
|
||||||
isMediaType,
|
isMediaType,
|
||||||
|
isInfoPanelType,
|
||||||
|
|
||||||
// Gallery operations
|
// Gallery operations
|
||||||
addGalleryCard,
|
addGalleryCard,
|
||||||
@ -810,6 +1313,11 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
|||||||
removeCarouselSlide,
|
removeCarouselSlide,
|
||||||
updateCarouselSlide,
|
updateCarouselSlide,
|
||||||
|
|
||||||
|
// Info panel section ordering operations
|
||||||
|
moveInfoPanelSection,
|
||||||
|
removeInfoPanelSection,
|
||||||
|
addInfoPanelSection,
|
||||||
|
|
||||||
// Build JSON
|
// Build JSON
|
||||||
buildSettingsJson,
|
buildSettingsJson,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
import type { CanvasElement } from '../types/constructor';
|
import type { CanvasElement } from '../types/constructor';
|
||||||
import type { ResolvedTransitionSettings } from '../types/transition';
|
import type { ResolvedTransitionSettings } from '../types/transition';
|
||||||
import type { PreloadCacheProvider } from '../hooks/video';
|
import type { PreloadCacheProvider } from '../hooks/video';
|
||||||
|
import { isInfoPanelElementType } from '../lib/elementDefaults';
|
||||||
|
|
||||||
interface RuntimeElementProps {
|
interface RuntimeElementProps {
|
||||||
element: CanvasElement;
|
element: CanvasElement;
|
||||||
@ -32,6 +33,8 @@ interface RuntimeElementProps {
|
|||||||
pageTransitionSettings?: ResolvedTransitionSettings;
|
pageTransitionSettings?: ResolvedTransitionSettings;
|
||||||
/** Preload cache provider for video elements */
|
/** Preload cache provider for video elements */
|
||||||
preloadCache?: PreloadCacheProvider;
|
preloadCache?: PreloadCacheProvider;
|
||||||
|
/** Whether this element's info panel is currently open (for visibility persistence) */
|
||||||
|
isInfoPanelOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp position to canvas bounds (0-100%)
|
// Clamp position to canvas bounds (0-100%)
|
||||||
@ -46,6 +49,7 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
|||||||
letterboxStyles,
|
letterboxStyles,
|
||||||
pageTransitionSettings,
|
pageTransitionSettings,
|
||||||
preloadCache,
|
preloadCache,
|
||||||
|
isInfoPanelOpen = false,
|
||||||
}) => {
|
}) => {
|
||||||
// Clamp coordinates to canvas bounds
|
// Clamp coordinates to canvas bounds
|
||||||
const xPercent = clamp(element.xPercent ?? 50, 0, 100);
|
const xPercent = clamp(element.xPercent ?? 50, 0, 100);
|
||||||
@ -58,16 +62,21 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Use effects hook for interactive states
|
// Use effects hook for interactive states
|
||||||
|
// Pass forceVisible when info panel is open to keep trigger visible
|
||||||
const { effectStyle, eventHandlers, onPersistClick } = useElementEffects(
|
const { effectStyle, eventHandlers, onPersistClick } = useElementEffects(
|
||||||
effectProperties,
|
effectProperties,
|
||||||
{
|
{
|
||||||
resetKey: element.id, // Reset reveal on element change
|
resetKey: element.id, // Reset reveal on element change
|
||||||
|
forceVisible: isInfoPanelOpen,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Combined click handler
|
// Combined click handler
|
||||||
|
// Skip toggle for info panel elements (their visibility is tied to panel open state)
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
onPersistClick(); // Toggle persistence state
|
if (!isInfoPanelElementType(element.type)) {
|
||||||
|
onPersistClick(); // Toggle persistence state
|
||||||
|
}
|
||||||
onClick(); // Original navigation action
|
onClick(); // Original navigation action
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,8 @@ import RuntimeControls from './Runtime/RuntimeControls';
|
|||||||
import RuntimeElement from './RuntimeElement';
|
import RuntimeElement from './RuntimeElement';
|
||||||
import TransitionPreviewOverlay from './Constructor/TransitionPreviewOverlay';
|
import TransitionPreviewOverlay from './Constructor/TransitionPreviewOverlay';
|
||||||
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
||||||
|
import InfoPanelOverlay from './UiElements/InfoPanelOverlay';
|
||||||
|
import ImageDetailPanel from './UiElements/ImageDetailPanel';
|
||||||
import { BackdropPortalProvider } from './BackdropPortal';
|
import { BackdropPortalProvider } from './BackdropPortal';
|
||||||
import { RotatePrompt } from './RotatePrompt';
|
import { RotatePrompt } from './RotatePrompt';
|
||||||
import CanvasBackground from './Constructor/CanvasBackground';
|
import CanvasBackground from './Constructor/CanvasBackground';
|
||||||
@ -60,7 +62,8 @@ import {
|
|||||||
selectByProjectAndEnv as selectProjectTransitionSettings,
|
selectByProjectAndEnv as selectProjectTransitionSettings,
|
||||||
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
|
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
|
||||||
import type { TransitionPhase } from '../types/presentation';
|
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 type { ElementTransitionSettings } from '../types/transition';
|
||||||
import {
|
import {
|
||||||
entityToProjectSettings,
|
entityToProjectSettings,
|
||||||
@ -180,6 +183,15 @@ export default function RuntimePresentation({
|
|||||||
element: CanvasElement;
|
element: CanvasElement;
|
||||||
initialIndex: number;
|
initialIndex: number;
|
||||||
} | null>(null);
|
} | 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 transitionVideoRef = useRef<HTMLVideoElement>(null);
|
||||||
const lastInitializedPageIdRef = useRef<string | null>(null);
|
const lastInitializedPageIdRef = useRef<string | null>(null);
|
||||||
@ -555,6 +567,13 @@ export default function RuntimePresentation({
|
|||||||
|
|
||||||
const handleElementClick = useCallback(
|
const handleElementClick = useCallback(
|
||||||
(element: CanvasElement) => {
|
(element: CanvasElement) => {
|
||||||
|
// Handle info panel click
|
||||||
|
if (isInfoPanelElementType(element.type)) {
|
||||||
|
setActiveInfoPanel(element);
|
||||||
|
setActiveDetailImage(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Block navigation while transition is actively playing or buffering
|
// Block navigation while transition is actively playing or buffering
|
||||||
if (
|
if (
|
||||||
isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)
|
isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)
|
||||||
@ -857,6 +876,7 @@ export default function RuntimePresentation({
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
isInfoPanelOpen={activeInfoPanel?.id === element.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -949,6 +969,49 @@ export default function RuntimePresentation({
|
|||||||
galleryElement={activeGalleryCarousel.element}
|
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>
|
</BackdropPortalProvider>
|
||||||
</div>
|
</div>
|
||||||
{/* End inner canvas container */}
|
{/* 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') {
|
if (item.elementType === 'gallery' || item.elementType === 'carousel') {
|
||||||
return (
|
return (
|
||||||
<div className='flex gap-1'>
|
<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 { useElementWrapperStyle } from './shared/useElementWrapperStyle';
|
||||||
import {
|
import {
|
||||||
isNavigationElementType,
|
isNavigationElementType,
|
||||||
isTooltipElementType,
|
|
||||||
isDescriptionElementType,
|
isDescriptionElementType,
|
||||||
isGalleryElementType,
|
isGalleryElementType,
|
||||||
isCarouselElementType,
|
isCarouselElementType,
|
||||||
@ -24,12 +23,12 @@ import {
|
|||||||
isLogoElementType,
|
isLogoElementType,
|
||||||
isSpotElementType,
|
isSpotElementType,
|
||||||
isPopupElementType,
|
isPopupElementType,
|
||||||
|
isInfoPanelElementType,
|
||||||
} from '../../lib/elementDefaults';
|
} from '../../lib/elementDefaults';
|
||||||
|
|
||||||
// Import per-type components
|
// Import per-type components
|
||||||
import NavigationElement from './elements/NavigationElement';
|
import NavigationElement from './elements/NavigationElement';
|
||||||
import GalleryElement from './elements/GalleryElement';
|
import GalleryElement from './elements/GalleryElement';
|
||||||
import TooltipElement from './elements/TooltipElement';
|
|
||||||
import DescriptionElement from './elements/DescriptionElement';
|
import DescriptionElement from './elements/DescriptionElement';
|
||||||
import CarouselElement from './elements/CarouselElement';
|
import CarouselElement from './elements/CarouselElement';
|
||||||
import LogoElement from './elements/LogoElement';
|
import LogoElement from './elements/LogoElement';
|
||||||
@ -37,6 +36,7 @@ import SpotElement from './elements/SpotElement';
|
|||||||
import VideoPlayerElement from './elements/VideoPlayerElement';
|
import VideoPlayerElement from './elements/VideoPlayerElement';
|
||||||
import AudioPlayerElement from './elements/AudioPlayerElement';
|
import AudioPlayerElement from './elements/AudioPlayerElement';
|
||||||
import PopupElement from './elements/PopupElement';
|
import PopupElement from './elements/PopupElement';
|
||||||
|
import InfoPanelElement from './elements/InfoPanelElement';
|
||||||
|
|
||||||
export interface UiElementRendererProps {
|
export interface UiElementRendererProps {
|
||||||
element: CanvasElement;
|
element: CanvasElement;
|
||||||
@ -52,6 +52,8 @@ export interface UiElementRendererProps {
|
|||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
) => void;
|
) => void;
|
||||||
|
// Info panel click callback
|
||||||
|
onInfoPanelClick?: () => void;
|
||||||
// Letterbox styles for constraining fullscreen elements to canvas bounds
|
// Letterbox styles for constraining fullscreen elements to canvas bounds
|
||||||
letterboxStyles?: React.CSSProperties;
|
letterboxStyles?: React.CSSProperties;
|
||||||
// Page transition settings (for slide transition cascade in carousel/gallery)
|
// Page transition settings (for slide transition cascade in carousel/gallery)
|
||||||
@ -73,6 +75,7 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
|
|||||||
isEditMode = false,
|
isEditMode = false,
|
||||||
onGalleryCardClick,
|
onGalleryCardClick,
|
||||||
onCarouselButtonPositionChange,
|
onCarouselButtonPositionChange,
|
||||||
|
onInfoPanelClick,
|
||||||
letterboxStyles,
|
letterboxStyles,
|
||||||
pageTransitionSettings,
|
pageTransitionSettings,
|
||||||
preloadCache,
|
preloadCache,
|
||||||
@ -93,9 +96,6 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
|
|||||||
if (isGalleryElementType(element.type)) {
|
if (isGalleryElementType(element.type)) {
|
||||||
return <GalleryElement {...commonProps} onCardClick={onGalleryCardClick} />;
|
return <GalleryElement {...commonProps} onCardClick={onGalleryCardClick} />;
|
||||||
}
|
}
|
||||||
if (isTooltipElementType(element.type)) {
|
|
||||||
return <TooltipElement {...commonProps} />;
|
|
||||||
}
|
|
||||||
if (isDescriptionElementType(element.type)) {
|
if (isDescriptionElementType(element.type)) {
|
||||||
return <DescriptionElement {...commonProps} />;
|
return <DescriptionElement {...commonProps} />;
|
||||||
}
|
}
|
||||||
@ -125,6 +125,9 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
|
|||||||
if (isPopupElementType(element.type)) {
|
if (isPopupElementType(element.type)) {
|
||||||
return <PopupElement {...commonProps} />;
|
return <PopupElement {...commonProps} />;
|
||||||
}
|
}
|
||||||
|
if (isInfoPanelElementType(element.type)) {
|
||||||
|
return <InfoPanelElement {...commonProps} onClick={onInfoPanelClick} />;
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback for unknown types
|
// Fallback for unknown types
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -9,7 +9,6 @@ export const UI_ELEMENT_TYPES: ElementType[] = [
|
|||||||
'nav_button',
|
'nav_button',
|
||||||
'spot',
|
'spot',
|
||||||
'description',
|
'description',
|
||||||
'tooltip',
|
|
||||||
'gallery',
|
'gallery',
|
||||||
'carousel',
|
'carousel',
|
||||||
'logo',
|
'logo',
|
||||||
@ -84,26 +83,6 @@ const defaultSettingsByType: Record<string, ElementSettings> = {
|
|||||||
text: 'Description text',
|
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: {
|
gallery: {
|
||||||
style: {
|
style: {
|
||||||
color: '#111827',
|
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 { buildElementStyle } from '../../../lib/elementStyles';
|
||||||
import { toCU } from '../../../lib/canvasScale';
|
import { toCU } from '../../../lib/canvasScale';
|
||||||
import {
|
import {
|
||||||
isTooltipElementType,
|
|
||||||
isDescriptionElementType,
|
isDescriptionElementType,
|
||||||
isNavigationElementType,
|
isNavigationElementType,
|
||||||
isGalleryElementType,
|
isGalleryElementType,
|
||||||
isCarouselElementType,
|
isCarouselElementType,
|
||||||
isLogoElementType,
|
isLogoElementType,
|
||||||
isSpotElementType,
|
isSpotElementType,
|
||||||
|
isInfoPanelElementType,
|
||||||
} from '../../../lib/elementDefaults';
|
} from '../../../lib/elementDefaults';
|
||||||
|
|
||||||
interface UseElementWrapperStyleOptions {
|
interface UseElementWrapperStyleOptions {
|
||||||
@ -50,11 +50,11 @@ export function useElementWrapperStyle({
|
|||||||
// Determine element characteristics
|
// Determine element characteristics
|
||||||
const hasIconDrivenSize =
|
const hasIconDrivenSize =
|
||||||
Boolean(element.iconUrl) &&
|
Boolean(element.iconUrl) &&
|
||||||
(isTooltipElementType(element.type) ||
|
(isDescriptionElementType(element.type) ||
|
||||||
isDescriptionElementType(element.type) ||
|
|
||||||
isNavigationElementType(element.type) ||
|
isNavigationElementType(element.type) ||
|
||||||
isLogoElementType(element.type) ||
|
isLogoElementType(element.type) ||
|
||||||
isSpotElementType(element.type));
|
isSpotElementType(element.type) ||
|
||||||
|
isInfoPanelElementType(element.type));
|
||||||
|
|
||||||
const hasTransparentBackground =
|
const hasTransparentBackground =
|
||||||
(isDescriptionElementType(element.type) &&
|
(isDescriptionElementType(element.type) &&
|
||||||
@ -62,9 +62,9 @@ export function useElementWrapperStyle({
|
|||||||
(!element.backgroundColor ||
|
(!element.backgroundColor ||
|
||||||
element.backgroundColor === 'transparent')) ||
|
element.backgroundColor === 'transparent')) ||
|
||||||
(isNavigationElementType(element.type) && Boolean(element.iconUrl)) ||
|
(isNavigationElementType(element.type) && Boolean(element.iconUrl)) ||
|
||||||
isTooltipElementType(element.type) ||
|
|
||||||
isGalleryElementType(element.type) ||
|
isGalleryElementType(element.type) ||
|
||||||
isCarouselElementType(element.type);
|
isCarouselElementType(element.type) ||
|
||||||
|
isInfoPanelElementType(element.type);
|
||||||
|
|
||||||
// Navigation elements (with or without icon) should be centered
|
// Navigation elements (with or without icon) should be centered
|
||||||
const isNavigationElement = isNavigationElementType(element.type);
|
const isNavigationElement = isNavigationElementType(element.type);
|
||||||
|
|||||||
@ -80,6 +80,7 @@ export const PRELOAD_CONFIG = {
|
|||||||
'galleryCarouselPrevIconUrl',
|
'galleryCarouselPrevIconUrl',
|
||||||
'galleryCarouselNextIconUrl',
|
'galleryCarouselNextIconUrl',
|
||||||
'galleryCarouselBackIconUrl',
|
'galleryCarouselBackIconUrl',
|
||||||
|
'infoPanelHeaderImageUrl',
|
||||||
'src',
|
'src',
|
||||||
'url',
|
'url',
|
||||||
'poster',
|
'poster',
|
||||||
@ -95,10 +96,16 @@ export const PRELOAD_CONFIG = {
|
|||||||
'galleryCarouselPrevIconUrl',
|
'galleryCarouselPrevIconUrl',
|
||||||
'galleryCarouselNextIconUrl',
|
'galleryCarouselNextIconUrl',
|
||||||
'galleryCarouselBackIconUrl',
|
'galleryCarouselBackIconUrl',
|
||||||
|
'infoPanelHeaderImageUrl',
|
||||||
'src',
|
'src',
|
||||||
] as const,
|
] as const,
|
||||||
nested: ['galleryCards', 'carouselSlides', 'galleryInfoSpans'] as const,
|
nested: [
|
||||||
nestedUrlFields: ['imageUrl', 'videoUrl', 'iconUrl'] as const,
|
'galleryCards',
|
||||||
|
'carouselSlides',
|
||||||
|
'galleryInfoSpans',
|
||||||
|
'infoPanelSections',
|
||||||
|
] as const,
|
||||||
|
nestedUrlFields: ['imageUrl', 'videoUrl', 'iconUrl'] as const, // embedUrl is external, not preloaded
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,10 @@ import type {
|
|||||||
GalleryCard,
|
GalleryCard,
|
||||||
GalleryInfoSpan,
|
GalleryInfoSpan,
|
||||||
CarouselSlide,
|
CarouselSlide,
|
||||||
|
InfoPanelImage,
|
||||||
|
InfoPanelInfoSpan,
|
||||||
|
InfoPanelSectionType,
|
||||||
|
InfoPanelSectionInstance,
|
||||||
AssetOption,
|
AssetOption,
|
||||||
} from '../types/constructor';
|
} from '../types/constructor';
|
||||||
import type { TourPage, Asset } from '../types/entities';
|
import type { TourPage, Asset } from '../types/entities';
|
||||||
@ -61,24 +65,38 @@ export interface CarouselSlideOperations {
|
|||||||
remove: (slideId: string) => void;
|
remove: (slideId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
export interface InfoPanelSectionOperations {
|
||||||
// Transition Creation State
|
/** Move section up or down */
|
||||||
// ============================================================================
|
move: (sectionId: string, direction: 'up' | 'down') => void;
|
||||||
|
/** Remove a section instance */
|
||||||
export interface TransitionCreationState {
|
remove: (sectionId: string) => void;
|
||||||
name: string;
|
/** Add a new section of the given type (generates unique ID) */
|
||||||
videoUrl: string;
|
add: (sectionType: InfoPanelSectionType) => void;
|
||||||
supportsReverse: boolean;
|
/** Update section instance settings (columns, gap) */
|
||||||
isCreating: boolean;
|
update: (sectionId: string, patch: Partial<InfoPanelSectionInstance>) => void;
|
||||||
}
|
/** Add a span to a specific section */
|
||||||
|
addSpan: (sectionId: string) => void;
|
||||||
export interface TransitionCreationActions {
|
/** Update a span in a specific section */
|
||||||
setName: (name: string) => void;
|
updateSpan: (
|
||||||
setVideoUrl: (url: string) => void;
|
sectionId: string,
|
||||||
setSupportsReverse: (value: boolean) => void;
|
spanId: string,
|
||||||
create: () => void;
|
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
|
// Context Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -140,12 +158,14 @@ export interface ConstructorContextValue {
|
|||||||
audio: AssetOption[];
|
audio: AssetOption[];
|
||||||
transitionVideo: AssetOption[];
|
transitionVideo: AssetOption[];
|
||||||
icon: AssetOption[];
|
icon: AssetOption[];
|
||||||
|
embed: AssetOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Gallery/Carousel operations
|
// Gallery/Carousel/InfoPanel operations
|
||||||
galleryCards: GalleryCardOperations;
|
galleryCards: GalleryCardOperations;
|
||||||
galleryInfoSpans: GalleryInfoSpanOperations;
|
galleryInfoSpans: GalleryInfoSpanOperations;
|
||||||
carouselSlides: CarouselSlideOperations;
|
carouselSlides: CarouselSlideOperations;
|
||||||
|
infoPanelSectionOps: InfoPanelSectionOperations;
|
||||||
|
|
||||||
// Duration resolver
|
// Duration resolver
|
||||||
getDuration: (url: string) => number | undefined;
|
getDuration: (url: string) => number | undefined;
|
||||||
@ -156,15 +176,11 @@ export interface ConstructorContextValue {
|
|||||||
backgroundAudio: string;
|
backgroundAudio: string;
|
||||||
selectedMedia: string;
|
selectedMedia: string;
|
||||||
selectedTransition: string;
|
selectedTransition: string;
|
||||||
newTransition: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Transition preview
|
// Transition preview
|
||||||
onPreviewTransition: (direction: 'forward' | 'back') => void;
|
onPreviewTransition: (direction: 'forward' | 'back') => void;
|
||||||
|
|
||||||
// Transition creation
|
|
||||||
transitionCreation: TransitionCreationState & TransitionCreationActions;
|
|
||||||
|
|
||||||
// Navigation settings
|
// Navigation settings
|
||||||
allowedNavigationTypes: NavigationElementType[];
|
allowedNavigationTypes: NavigationElementType[];
|
||||||
normalizeNavigationType: (
|
normalizeNavigationType: (
|
||||||
@ -327,7 +343,7 @@ export function useConstructorAssets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select gallery/carousel operations
|
* Select gallery/carousel/infoPanel operations
|
||||||
*/
|
*/
|
||||||
export function useConstructorCollectionOps() {
|
export function useConstructorCollectionOps() {
|
||||||
const ctx = useConstructorContext();
|
const ctx = useConstructorContext();
|
||||||
@ -336,8 +352,14 @@ export function useConstructorCollectionOps() {
|
|||||||
galleryCards: ctx.galleryCards,
|
galleryCards: ctx.galleryCards,
|
||||||
galleryInfoSpans: ctx.galleryInfoSpans,
|
galleryInfoSpans: ctx.galleryInfoSpans,
|
||||||
carouselSlides: ctx.carouselSlides,
|
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
|
* Select navigation settings
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -34,6 +34,8 @@ export interface AssetOptionsResult {
|
|||||||
transitionVideo: AssetOption[];
|
transitionVideo: AssetOption[];
|
||||||
/** Icon image assets */
|
/** Icon image assets */
|
||||||
icon: AssetOption[];
|
icon: AssetOption[];
|
||||||
|
/** Embed assets (360° panoramas, iframes) */
|
||||||
|
embed: AssetOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseAssetOptionsOptions {
|
export interface UseAssetOptionsOptions {
|
||||||
@ -96,6 +98,18 @@ export function useAssetOptions({
|
|||||||
// Icon assets
|
// Icon assets
|
||||||
const iconOptions = useMemo(() => buildIconAssetOptions(assets), [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(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
image: imageOptions,
|
image: imageOptions,
|
||||||
@ -104,6 +118,7 @@ export function useAssetOptions({
|
|||||||
audio: audioOptions,
|
audio: audioOptions,
|
||||||
transitionVideo: transitionVideoOptions,
|
transitionVideo: transitionVideoOptions,
|
||||||
icon: iconOptions,
|
icon: iconOptions,
|
||||||
|
embed: embedOptions,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
imageOptions,
|
imageOptions,
|
||||||
@ -112,6 +127,7 @@ export function useAssetOptions({
|
|||||||
audioOptions,
|
audioOptions,
|
||||||
transitionVideoOptions,
|
transitionVideoOptions,
|
||||||
iconOptions,
|
iconOptions,
|
||||||
|
embedOptions,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,15 @@ import type {
|
|||||||
GalleryCard,
|
GalleryCard,
|
||||||
GalleryInfoSpan,
|
GalleryInfoSpan,
|
||||||
CarouselSlide,
|
CarouselSlide,
|
||||||
|
InfoPanelImage,
|
||||||
|
InfoPanelInfoSpan,
|
||||||
|
InfoPanelSectionType,
|
||||||
|
InfoPanelSectionInstance,
|
||||||
|
} from '../types/constructor';
|
||||||
|
import {
|
||||||
|
getInfoPanelSections,
|
||||||
|
generateSectionId,
|
||||||
|
generateItemId,
|
||||||
} from '../types/constructor';
|
} from '../types/constructor';
|
||||||
import {
|
import {
|
||||||
createDefaultElement,
|
createDefaultElement,
|
||||||
@ -20,6 +29,7 @@ import {
|
|||||||
isGalleryElementType,
|
isGalleryElementType,
|
||||||
isCarouselElementType,
|
isCarouselElementType,
|
||||||
isNavigationElementType,
|
isNavigationElementType,
|
||||||
|
isInfoPanelElementType,
|
||||||
getNavigationButtonLabel,
|
getNavigationButtonLabel,
|
||||||
getNavigationButtonKind,
|
getNavigationButtonKind,
|
||||||
ELEMENT_TYPE_LABELS,
|
ELEMENT_TYPE_LABELS,
|
||||||
@ -100,6 +110,40 @@ interface UseConstructorElementsResult {
|
|||||||
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
|
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
|
||||||
remove: (slideId: string) => 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) */
|
/** Update element position (for drag operations) */
|
||||||
updateElementPosition: (
|
updateElementPosition: (
|
||||||
elementId: string,
|
elementId: string,
|
||||||
@ -433,6 +477,201 @@ export function useConstructorElements({
|
|||||||
[selectedElement, updateSelectedElement],
|
[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 {
|
return {
|
||||||
elements,
|
elements,
|
||||||
setElements,
|
setElements,
|
||||||
@ -448,6 +687,7 @@ export function useConstructorElements({
|
|||||||
galleryCards,
|
galleryCards,
|
||||||
galleryInfoSpans,
|
galleryInfoSpans,
|
||||||
carouselSlides,
|
carouselSlides,
|
||||||
|
infoPanelSectionOps,
|
||||||
updateElementPosition,
|
updateElementPosition,
|
||||||
normalizeNavigationType: normalizeNavigationElementType,
|
normalizeNavigationType: normalizeNavigationElementType,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -75,21 +75,12 @@ interface UseConstructorPageActionsResult {
|
|||||||
isSavingToStage: boolean;
|
isSavingToStage: boolean;
|
||||||
/** Whether page creation is in progress */
|
/** Whether page creation is in progress */
|
||||||
isCreatingPage: boolean;
|
isCreatingPage: boolean;
|
||||||
/** Whether transition creation is in progress */
|
|
||||||
isCreatingTransition: boolean;
|
|
||||||
/** Save current constructor state */
|
/** Save current constructor state */
|
||||||
saveConstructor: () => Promise<void>;
|
saveConstructor: () => Promise<void>;
|
||||||
/** Save dev content to stage environment */
|
/** Save dev content to stage environment */
|
||||||
saveToStage: () => Promise<void>;
|
saveToStage: () => Promise<void>;
|
||||||
/** Create a new page with the given name and slug */
|
/** Create a new page with the given name and slug */
|
||||||
createPage: (pageName: string, slug: string) => Promise<void>;
|
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 [isSaving, setIsSaving] = useState(false);
|
||||||
const [isSavingToStage, setIsSavingToStage] = useState(false);
|
const [isSavingToStage, setIsSavingToStage] = useState(false);
|
||||||
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
||||||
const [isCreatingTransition, setIsCreatingTransition] = useState(false);
|
|
||||||
|
|
||||||
// Polling hook for reverse video generation status
|
// Polling hook for reverse video generation status
|
||||||
const { startPolling } = useReverseVideoPolling({
|
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 {
|
return {
|
||||||
isSaving,
|
isSaving,
|
||||||
isSavingToStage,
|
isSavingToStage,
|
||||||
isCreatingPage,
|
isCreatingPage,
|
||||||
isCreatingTransition,
|
|
||||||
saveConstructor,
|
saveConstructor,
|
||||||
saveToStage,
|
saveToStage,
|
||||||
createPage,
|
createPage,
|
||||||
createTransition,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,8 @@ interface UseElementEffectsOptions {
|
|||||||
resetKey?: string | number;
|
resetKey?: string | number;
|
||||||
/** Whether appear animation has completed (to coordinate with reveal) */
|
/** Whether appear animation has completed (to coordinate with reveal) */
|
||||||
appearAnimationCompleted?: boolean;
|
appearAnimationCompleted?: boolean;
|
||||||
|
/** Force visibility regardless of hover state (e.g., when info panel is open) */
|
||||||
|
forceVisible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseElementEffectsResult {
|
interface UseElementEffectsResult {
|
||||||
@ -73,7 +75,11 @@ export function useElementEffects(
|
|||||||
effects: Partial<ElementEffectProperties>,
|
effects: Partial<ElementEffectProperties>,
|
||||||
options?: UseElementEffectsOptions,
|
options?: UseElementEffectsOptions,
|
||||||
): UseElementEffectsResult {
|
): UseElementEffectsResult {
|
||||||
const { resetKey, appearAnimationCompleted = true } = options ?? {};
|
const {
|
||||||
|
resetKey,
|
||||||
|
appearAnimationCompleted = true,
|
||||||
|
forceVisible = false,
|
||||||
|
} = options ?? {};
|
||||||
|
|
||||||
const [state, setState] = useState<ElementEffectState>({
|
const [state, setState] = useState<ElementEffectState>({
|
||||||
isHovered: false,
|
isHovered: false,
|
||||||
@ -164,21 +170,27 @@ export function useElementEffects(
|
|||||||
// Apply hover reveal style (controls opacity for reveal elements)
|
// Apply hover reveal style (controls opacity for reveal elements)
|
||||||
// Only apply initial opacity AFTER appear animation completes to avoid conflict
|
// Only apply initial opacity AFTER appear animation completes to avoid conflict
|
||||||
if (hasHoverReveal(effects) && appearAnimationCompleted) {
|
if (hasHoverReveal(effects) && appearAnimationCompleted) {
|
||||||
// With persist OR click-persisted: stays visible
|
// Priority: forceVisible > hoverRevealPersist > isClickPersisted > isHovered
|
||||||
// Without: only visible while hovering
|
// 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 =
|
const shouldShow =
|
||||||
effects.hoverRevealPersist || state.isClickPersisted
|
forceVisible ||
|
||||||
? state.isRevealed || state.isClickPersisted
|
(effects.hoverRevealPersist
|
||||||
: state.isHovered;
|
? state.isRevealed
|
||||||
|
: state.isClickPersisted
|
||||||
|
? state.isRevealed || state.isClickPersisted
|
||||||
|
: state.isHovered);
|
||||||
effectStyle = {
|
effectStyle = {
|
||||||
...effectStyle,
|
...effectStyle,
|
||||||
...buildHoverRevealStyle(effects, shouldShow),
|
...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)
|
// 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)) {
|
if (shouldApplyHover && !state.isActive && hasHoverEffects(effects)) {
|
||||||
const hoverStyle = buildHoverStyle(effects);
|
const hoverStyle = buildHoverStyle(effects);
|
||||||
if (hasHoverReveal(effects)) {
|
if (hasHoverReveal(effects)) {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* useIconPreload Hook
|
* useIconPreload Hook
|
||||||
*
|
*
|
||||||
* Preloads icon images for smooth rendering without flash.
|
* 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';
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
|||||||
@ -154,7 +154,6 @@ export function buildDurationProbeTargets({
|
|||||||
backgroundVideoUrl,
|
backgroundVideoUrl,
|
||||||
backgroundAudioUrl,
|
backgroundAudioUrl,
|
||||||
selectedElement,
|
selectedElement,
|
||||||
newTransitionVideoUrl,
|
|
||||||
elements,
|
elements,
|
||||||
isMediaElementType,
|
isMediaElementType,
|
||||||
isVideoPlayerElementType,
|
isVideoPlayerElementType,
|
||||||
@ -166,7 +165,6 @@ export function buildDurationProbeTargets({
|
|||||||
type: string;
|
type: string;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
} | null;
|
} | null;
|
||||||
newTransitionVideoUrl?: string;
|
|
||||||
elements?: Array<{
|
elements?: Array<{
|
||||||
type: string;
|
type: string;
|
||||||
transitionVideoUrl?: string;
|
transitionVideoUrl?: string;
|
||||||
@ -199,10 +197,6 @@ export function buildDurationProbeTargets({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newTransitionVideoUrl) {
|
|
||||||
targets.push({ source: newTransitionVideoUrl, mediaType: 'video' });
|
|
||||||
}
|
|
||||||
|
|
||||||
elements?.forEach((element) => {
|
elements?.forEach((element) => {
|
||||||
if (!isNavigationElementType(element.type)) return;
|
if (!isNavigationElementType(element.type)) return;
|
||||||
if (element.transitionVideoUrl) {
|
if (element.transitionVideoUrl) {
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import type {
|
|||||||
import {
|
import {
|
||||||
isGalleryElementType,
|
isGalleryElementType,
|
||||||
isCarouselElementType,
|
isCarouselElementType,
|
||||||
isTooltipElementType,
|
|
||||||
isDescriptionElementType,
|
isDescriptionElementType,
|
||||||
isNavigationElementType,
|
isNavigationElementType,
|
||||||
isMediaElementType,
|
isMediaElementType,
|
||||||
@ -96,7 +95,6 @@ export const getElementButtonTitle = (element: CanvasElement): string => {
|
|||||||
return `${element.label} (${element.carouselSlides?.length || 0})`;
|
return `${element.label} (${element.carouselSlides?.length || 0})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTooltipElementType(element.type)) return element.tooltipTitle ?? '';
|
|
||||||
if (isDescriptionElementType(element.type))
|
if (isDescriptionElementType(element.type))
|
||||||
return element.descriptionTitle ?? '';
|
return element.descriptionTitle ?? '';
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import type {
|
|||||||
GalleryCard,
|
GalleryCard,
|
||||||
GalleryInfoSpan,
|
GalleryInfoSpan,
|
||||||
CarouselSlide,
|
CarouselSlide,
|
||||||
|
InfoPanelImage,
|
||||||
NavigationButtonKind,
|
NavigationButtonKind,
|
||||||
} from '../types/constructor';
|
} from '../types/constructor';
|
||||||
import { ELEMENT_STYLE_PROPS } from './elementStyles';
|
import { ELEMENT_STYLE_PROPS } from './elementStyles';
|
||||||
@ -89,13 +90,13 @@ export const ELEMENT_TYPE_LABELS: Record<CanvasElementType, string> = {
|
|||||||
navigation_prev: 'Navigation: Back',
|
navigation_prev: 'Navigation: Back',
|
||||||
spot: 'Hotspot',
|
spot: 'Hotspot',
|
||||||
description: 'Description',
|
description: 'Description',
|
||||||
tooltip: 'Tooltip',
|
|
||||||
gallery: 'Gallery',
|
gallery: 'Gallery',
|
||||||
carousel: 'Carousel',
|
carousel: 'Carousel',
|
||||||
logo: 'Logo',
|
logo: 'Logo',
|
||||||
video_player: 'Video Player',
|
video_player: 'Video Player',
|
||||||
audio_player: 'Audio Player',
|
audio_player: 'Audio Player',
|
||||||
popup: 'Popup',
|
popup: 'Popup',
|
||||||
|
info_panel: 'Info Panel',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -141,11 +142,6 @@ export const TYPE_SPECIFIC_DEFAULTS: Partial<
|
|||||||
iconUrl: '',
|
iconUrl: '',
|
||||||
transitionReverseMode: 'auto_reverse',
|
transitionReverseMode: 'auto_reverse',
|
||||||
},
|
},
|
||||||
tooltip: {
|
|
||||||
iconUrl: '',
|
|
||||||
tooltipTitle: 'Tooltip title',
|
|
||||||
tooltipText: 'Tooltip text',
|
|
||||||
},
|
|
||||||
description: {
|
description: {
|
||||||
iconUrl: '',
|
iconUrl: '',
|
||||||
descriptionTitle: 'TITLE',
|
descriptionTitle: 'TITLE',
|
||||||
@ -184,6 +180,12 @@ export const TYPE_SPECIFIC_DEFAULTS: Partial<
|
|||||||
iconUrl: '',
|
iconUrl: '',
|
||||||
},
|
},
|
||||||
popup: {},
|
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 ?? ''),
|
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.
|
* Merge an element with project/global defaults.
|
||||||
* Used when loading elements from the database or creating new elements.
|
* Used when loading elements from the database or creating new elements.
|
||||||
@ -521,23 +536,6 @@ export const buildElementSettings = (
|
|||||||
addIfNotEmpty(settings, 'reverseVideoUrl', element.reverseVideoUrl);
|
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
|
// Description type settings
|
||||||
if (isDescriptionElementType(elementType)) {
|
if (isDescriptionElementType(elementType)) {
|
||||||
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
|
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
|
||||||
@ -654,6 +652,63 @@ export const buildElementSettings = (
|
|||||||
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
|
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;
|
return settings;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -670,12 +725,6 @@ export const isNavigationElementType = (
|
|||||||
): type is 'navigation_next' | 'navigation_prev' =>
|
): type is 'navigation_next' | 'navigation_prev' =>
|
||||||
type === 'navigation_next' || type === '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
|
* 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' =>
|
export const isPopupElementType = (type: string): type is 'popup' =>
|
||||||
type === '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;
|
margin?: string;
|
||||||
padding?: string;
|
padding?: string;
|
||||||
gap?: string;
|
gap?: string;
|
||||||
|
fontFamily?: string;
|
||||||
fontSize?: string;
|
fontSize?: string;
|
||||||
lineHeight?: string;
|
lineHeight?: string;
|
||||||
fontWeight?: string;
|
fontWeight?: string;
|
||||||
@ -233,6 +234,7 @@ export const ELEMENT_STYLE_PROPS = [
|
|||||||
'margin',
|
'margin',
|
||||||
'padding',
|
'padding',
|
||||||
'gap',
|
'gap',
|
||||||
|
'fontFamily',
|
||||||
'fontSize',
|
'fontSize',
|
||||||
'lineHeight',
|
'lineHeight',
|
||||||
'fontWeight',
|
'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 TransitionPreviewOverlay from '../components/Constructor/TransitionPreviewOverlay';
|
||||||
import CanvasElementComponent from '../components/Constructor/CanvasElement';
|
import CanvasElementComponent from '../components/Constructor/CanvasElement';
|
||||||
import GalleryCarouselOverlay from '../components/UiElements/GalleryCarouselOverlay';
|
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 ElementEditorPanel from '../components/Constructor/ElementEditorPanel';
|
||||||
import CreatePageModal from '../components/Constructor/CreatePageModal';
|
import CreatePageModal from '../components/Constructor/CreatePageModal';
|
||||||
import { BackdropPortalProvider } from '../components/BackdropPortal';
|
import { BackdropPortalProvider } from '../components/BackdropPortal';
|
||||||
@ -58,10 +60,10 @@ import {
|
|||||||
ELEMENT_TYPE_LABELS,
|
ELEMENT_TYPE_LABELS,
|
||||||
getNavigationButtonKind,
|
getNavigationButtonKind,
|
||||||
isNavigationElementType,
|
isNavigationElementType,
|
||||||
isTooltipElementType,
|
|
||||||
isDescriptionElementType,
|
isDescriptionElementType,
|
||||||
isMediaElementType,
|
isMediaElementType,
|
||||||
isVideoPlayerElementType,
|
isVideoPlayerElementType,
|
||||||
|
isInfoPanelElementType,
|
||||||
clamp,
|
clamp,
|
||||||
} from '../lib/elementDefaults';
|
} from '../lib/elementDefaults';
|
||||||
import type {
|
import type {
|
||||||
@ -72,6 +74,7 @@ import type {
|
|||||||
GalleryCard,
|
GalleryCard,
|
||||||
GalleryInfoSpan,
|
GalleryInfoSpan,
|
||||||
CarouselSlide,
|
CarouselSlide,
|
||||||
|
InfoPanelImage,
|
||||||
} from '../types/constructor';
|
} from '../types/constructor';
|
||||||
import type { TourPage } from '../types/entities';
|
import type { TourPage } from '../types/entities';
|
||||||
|
|
||||||
@ -250,10 +253,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
const [isInitializing, setIsInitializing] = useState(true);
|
const [isInitializing, setIsInitializing] = useState(true);
|
||||||
const isLoading = isInitializing || isDataLoading;
|
const isLoading = isInitializing || isDataLoading;
|
||||||
// isSaving, isSavingToStage, isCreatingPage are managed by useConstructorPageActions hook
|
// 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 [errorMessage, setErrorMessage] = useState('');
|
||||||
const [successMessage, setSuccessMessage] = useState('');
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
|
|
||||||
@ -282,6 +281,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
elementId: string;
|
elementId: string;
|
||||||
initialIndex: number;
|
initialIndex: number;
|
||||||
} | null>(null);
|
} | 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)
|
// Current element transition settings (for CSS transitions when no video)
|
||||||
const [
|
const [
|
||||||
currentElementTransitionSettings,
|
currentElementTransitionSettings,
|
||||||
@ -309,6 +314,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
galleryCards,
|
galleryCards,
|
||||||
galleryInfoSpans,
|
galleryInfoSpans,
|
||||||
carouselSlides,
|
carouselSlides,
|
||||||
|
infoPanelSectionOps,
|
||||||
updateElementPosition,
|
updateElementPosition,
|
||||||
normalizeNavigationType,
|
normalizeNavigationType,
|
||||||
} = useConstructorElements({
|
} = useConstructorElements({
|
||||||
@ -345,6 +351,30 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
);
|
);
|
||||||
}, [activeGalleryCarousel, elements]);
|
}, [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
|
// Draggable panels using useDraggable hook
|
||||||
const { position: toolbarPosition, onDragStart: onToolbarDragStart } =
|
const { position: toolbarPosition, onDragStart: onToolbarDragStart } =
|
||||||
useDraggable({
|
useDraggable({
|
||||||
@ -610,7 +640,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
const preloadableTypes: CanvasElementType[] = [
|
const preloadableTypes: CanvasElementType[] = [
|
||||||
'navigation_next',
|
'navigation_next',
|
||||||
'navigation_prev',
|
'navigation_prev',
|
||||||
'tooltip',
|
|
||||||
'description',
|
'description',
|
||||||
];
|
];
|
||||||
const urls = elements
|
const urls = elements
|
||||||
@ -639,19 +668,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
backgroundVideoUrl,
|
backgroundVideoUrl,
|
||||||
backgroundAudioUrl,
|
backgroundAudioUrl,
|
||||||
selectedElement,
|
selectedElement,
|
||||||
newTransitionVideoUrl,
|
|
||||||
elements,
|
elements,
|
||||||
isMediaElementType,
|
isMediaElementType,
|
||||||
isVideoPlayerElementType,
|
isVideoPlayerElementType,
|
||||||
isNavigationElementType,
|
isNavigationElementType,
|
||||||
}),
|
}),
|
||||||
[
|
[backgroundAudioUrl, backgroundVideoUrl, elements, selectedElement],
|
||||||
backgroundAudioUrl,
|
|
||||||
backgroundVideoUrl,
|
|
||||||
elements,
|
|
||||||
newTransitionVideoUrl,
|
|
||||||
selectedElement,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { getDuration, getDurationNote, durationBySource } =
|
const { getDuration, getDurationNote, durationBySource } =
|
||||||
@ -668,8 +690,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
return getDurationNote(selectedElement.mediaUrl || '');
|
return getDurationNote(selectedElement.mediaUrl || '');
|
||||||
}, [getDurationNote, selectedElement]);
|
}, [getDurationNote, selectedElement]);
|
||||||
|
|
||||||
const newTransitionDurationNote = getDurationNote(newTransitionVideoUrl);
|
|
||||||
|
|
||||||
const selectedTransitionDurationNote = useMemo(() => {
|
const selectedTransitionDurationNote = useMemo(() => {
|
||||||
if (!selectedElement || !isNavigationElementType(selectedElement.type)) {
|
if (!selectedElement || !isNavigationElementType(selectedElement.type)) {
|
||||||
return 'Duration: unknown';
|
return 'Duration: unknown';
|
||||||
@ -677,12 +697,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
return getDurationNote(selectedElement.transitionVideoUrl || '');
|
return getDurationNote(selectedElement.transitionVideoUrl || '');
|
||||||
}, [getDurationNote, selectedElement]);
|
}, [getDurationNote, selectedElement]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (newTransitionVideoUrl) return;
|
|
||||||
if (!assetOptions.transitionVideo.length) return;
|
|
||||||
setNewTransitionVideoUrl(assetOptions.transitionVideo[0].value);
|
|
||||||
}, [newTransitionVideoUrl, assetOptions.transitionVideo]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setElements((prev) => {
|
setElements((prev) => {
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
@ -754,11 +768,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
isSaving,
|
isSaving,
|
||||||
isSavingToStage,
|
isSavingToStage,
|
||||||
isCreatingPage,
|
isCreatingPage,
|
||||||
isCreatingTransition,
|
|
||||||
saveConstructor,
|
saveConstructor,
|
||||||
saveToStage,
|
saveToStage,
|
||||||
createPage,
|
createPage,
|
||||||
createTransition,
|
|
||||||
} = useConstructorPageActions({
|
} = useConstructorPageActions({
|
||||||
projectId,
|
projectId,
|
||||||
project,
|
project,
|
||||||
@ -1021,10 +1033,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
typeof item.galleryCarouselBackHeight === 'string'
|
typeof item.galleryCarouselBackHeight === 'string'
|
||||||
? item.galleryCarouselBackHeight
|
? item.galleryCarouselBackHeight
|
||||||
: undefined,
|
: undefined,
|
||||||
tooltipTitle:
|
|
||||||
typeof item.tooltipTitle === 'string' ? item.tooltipTitle : '',
|
|
||||||
tooltipText:
|
|
||||||
typeof item.tooltipText === 'string' ? item.tooltipText : '',
|
|
||||||
descriptionTitle:
|
descriptionTitle:
|
||||||
typeof item.descriptionTitle === 'string'
|
typeof item.descriptionTitle === 'string'
|
||||||
? item.descriptionTitle
|
? 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)
|
// Handler for gallery carousel button position changes (constructor only)
|
||||||
const handleGalleryCarouselButtonPositionChange = useCallback(
|
const handleGalleryCarouselButtonPositionChange = useCallback(
|
||||||
(button: 'prev' | 'next' | 'back', x: number, y: number) => {
|
(button: 'prev' | 'next' | 'back', x: number, y: number) => {
|
||||||
@ -1457,7 +1473,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
(element: CanvasElement) => {
|
(element: CanvasElement) => {
|
||||||
const isPreloadableIconElement =
|
const isPreloadableIconElement =
|
||||||
(isNavigationElementType(element.type) ||
|
(isNavigationElementType(element.type) ||
|
||||||
isTooltipElementType(element.type) ||
|
|
||||||
isDescriptionElementType(element.type)) &&
|
isDescriptionElementType(element.type)) &&
|
||||||
Boolean(element.iconUrl);
|
Boolean(element.iconUrl);
|
||||||
|
|
||||||
@ -1547,9 +1562,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
? 'Background video'
|
? 'Background video'
|
||||||
: selectedMenuItem === 'background_audio'
|
: selectedMenuItem === 'background_audio'
|
||||||
? 'Background audio'
|
? 'Background audio'
|
||||||
: selectedMenuItem === 'create_transition'
|
: selectedElement?.label || 'Element editor';
|
||||||
? 'Create transition'
|
|
||||||
: selectedElement?.label || 'Element editor';
|
|
||||||
|
|
||||||
// Background image is rendered by CanvasBackground component (same as runtime)
|
// Background image is rendered by CanvasBackground component (same as runtime)
|
||||||
// No CSS background-image needed on canvas div
|
// No CSS background-image needed on canvas div
|
||||||
@ -1561,42 +1574,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
backgroundAudio: backgroundAudioDurationNote,
|
backgroundAudio: backgroundAudioDurationNote,
|
||||||
selectedMedia: selectedMediaDurationNote,
|
selectedMedia: selectedMediaDurationNote,
|
||||||
selectedTransition: selectedTransitionDurationNote,
|
selectedTransition: selectedTransitionDurationNote,
|
||||||
newTransition: newTransitionDurationNote,
|
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
backgroundVideoDurationNote,
|
backgroundVideoDurationNote,
|
||||||
backgroundAudioDurationNote,
|
backgroundAudioDurationNote,
|
||||||
selectedMediaDurationNote,
|
selectedMediaDurationNote,
|
||||||
selectedTransitionDurationNote,
|
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)
|
// Asset options (derived from assets)
|
||||||
assetOptions,
|
assetOptions,
|
||||||
|
|
||||||
// Gallery/Carousel operations
|
// Gallery/Carousel/InfoPanel operations
|
||||||
galleryCards,
|
galleryCards,
|
||||||
galleryInfoSpans,
|
galleryInfoSpans,
|
||||||
carouselSlides,
|
carouselSlides,
|
||||||
|
infoPanelSectionOps,
|
||||||
|
|
||||||
// Duration resolver
|
// Duration resolver
|
||||||
getDuration,
|
getDuration,
|
||||||
@ -1676,9 +1660,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
// Transition preview
|
// Transition preview
|
||||||
onPreviewTransition: openTransitionPreview,
|
onPreviewTransition: openTransitionPreview,
|
||||||
|
|
||||||
// Transition creation
|
|
||||||
transitionCreation: transitionCreationState,
|
|
||||||
|
|
||||||
// Navigation settings
|
// Navigation settings
|
||||||
allowedNavigationTypes,
|
allowedNavigationTypes,
|
||||||
normalizeNavigationType,
|
normalizeNavigationType,
|
||||||
@ -1716,10 +1697,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
galleryCards,
|
galleryCards,
|
||||||
galleryInfoSpans,
|
galleryInfoSpans,
|
||||||
carouselSlides,
|
carouselSlides,
|
||||||
|
infoPanelSectionOps,
|
||||||
getDuration,
|
getDuration,
|
||||||
durationNotes,
|
durationNotes,
|
||||||
openTransitionPreview,
|
openTransitionPreview,
|
||||||
transitionCreationState,
|
|
||||||
allowedNavigationTypes,
|
allowedNavigationTypes,
|
||||||
normalizeNavigationType,
|
normalizeNavigationType,
|
||||||
saveConstructor,
|
saveConstructor,
|
||||||
@ -1929,6 +1910,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
y,
|
y,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
onInfoPanelClick={() => handleInfoPanelClick(element)}
|
||||||
|
isInfoPanelOpen={
|
||||||
|
activeInfoPanel?.elementId === element.id
|
||||||
|
}
|
||||||
letterboxStyles={letterboxStyles}
|
letterboxStyles={letterboxStyles}
|
||||||
pageTransitionSettings={transitionSettings}
|
pageTransitionSettings={transitionSettings}
|
||||||
preloadCache={{
|
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 */}
|
{/* Create Page Modal */}
|
||||||
<CreatePageModal
|
<CreatePageModal
|
||||||
isActive={isCreatePageModalActive}
|
isActive={isCreatePageModalActive}
|
||||||
|
|||||||
@ -30,11 +30,11 @@ import {
|
|||||||
EffectsSettingsSection,
|
EffectsSettingsSection,
|
||||||
CommonSettingsSection,
|
CommonSettingsSection,
|
||||||
NavigationSettingsSection,
|
NavigationSettingsSection,
|
||||||
TooltipSettingsSection,
|
|
||||||
DescriptionSettingsSection,
|
DescriptionSettingsSection,
|
||||||
MediaSettingsSection,
|
MediaSettingsSection,
|
||||||
GallerySettingsSection,
|
GallerySettingsSection,
|
||||||
CarouselSettingsSection,
|
CarouselSettingsSection,
|
||||||
|
InfoPanelSettingsSection,
|
||||||
useElementSettingsForm,
|
useElementSettingsForm,
|
||||||
} from '../../components/ElementSettings';
|
} 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 && (
|
{form.isDescriptionType && (
|
||||||
<DescriptionSettingsSection
|
<DescriptionSettingsSection
|
||||||
iconUrl={form.state.iconUrl}
|
iconUrl={form.state.iconUrl}
|
||||||
@ -397,6 +385,133 @@ const ElementTypeDefaultDetailsPage = () => {
|
|||||||
context='global'
|
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,
|
EffectsSettingsSection,
|
||||||
CommonSettingsSection,
|
CommonSettingsSection,
|
||||||
NavigationSettingsSection,
|
NavigationSettingsSection,
|
||||||
TooltipSettingsSection,
|
|
||||||
DescriptionSettingsSection,
|
DescriptionSettingsSection,
|
||||||
MediaSettingsSection,
|
MediaSettingsSection,
|
||||||
GallerySettingsSection,
|
GallerySettingsSection,
|
||||||
CarouselSettingsSection,
|
CarouselSettingsSection,
|
||||||
|
InfoPanelSettingsSection,
|
||||||
useElementSettingsForm,
|
useElementSettingsForm,
|
||||||
} from '../../components/ElementSettings';
|
} from '../../components/ElementSettings';
|
||||||
|
|
||||||
@ -128,6 +128,20 @@ const ProjectElementDefaultDetailsPage = () => {
|
|||||||
[assets],
|
[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
|
// Extract stable callback reference to avoid infinite loop
|
||||||
const applySettings = form.applySettings;
|
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 && (
|
{form.isDescriptionType && (
|
||||||
<DescriptionSettingsSection
|
<DescriptionSettingsSection
|
||||||
iconUrl={form.state.iconUrl}
|
iconUrl={form.state.iconUrl}
|
||||||
@ -582,6 +584,123 @@ const ProjectElementDefaultDetailsPage = () => {
|
|||||||
context='project'
|
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
|
| 'navigation_prev' // Back navigation button
|
||||||
| 'spot' // Hotspot/clickable area
|
| 'spot' // Hotspot/clickable area
|
||||||
| 'description' // Text description block
|
| 'description' // Text description block
|
||||||
| 'tooltip' // Hover tooltip
|
|
||||||
| 'gallery' // Image gallery
|
| 'gallery' // Image gallery
|
||||||
| 'carousel' // Image carousel
|
| 'carousel' // Image carousel
|
||||||
| 'logo' // Logo element
|
| 'logo' // Logo element
|
||||||
| 'video_player' // Video player
|
| 'video_player' // Video player
|
||||||
| 'audio_player' // Audio player
|
| 'audio_player' // Audio player
|
||||||
| 'popup'; // Popup/modal
|
| 'popup' // Popup/modal
|
||||||
|
| 'info_panel'; // Info panel with images/embeds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation button direction
|
* Navigation button direction
|
||||||
@ -39,8 +39,7 @@ export type EditorMenuItem =
|
|||||||
| 'none'
|
| 'none'
|
||||||
| 'background_image'
|
| 'background_image'
|
||||||
| 'background_video'
|
| 'background_video'
|
||||||
| 'background_audio'
|
| 'background_audio';
|
||||||
| 'create_transition';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editor tab for element property editing
|
* Editor tab for element property editing
|
||||||
@ -87,6 +86,136 @@ export interface CarouselSlide {
|
|||||||
caption: string;
|
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.
|
* Base canvas element with common positioning and styling fields.
|
||||||
* Extends ElementStyleProperties for CSS styling and ElementEffectProperties for effects.
|
* Extends ElementStyleProperties for CSS styling and ElementEffectProperties for effects.
|
||||||
@ -216,10 +345,6 @@ export interface CanvasElement extends BaseCanvasElement {
|
|||||||
carouselSlideTransitionEasing?: EasingFunction | '';
|
carouselSlideTransitionEasing?: EasingFunction | '';
|
||||||
/** Override overlay color for slide transitions */
|
/** Override overlay color for slide transitions */
|
||||||
carouselSlideTransitionOverlayColor?: string;
|
carouselSlideTransitionOverlayColor?: string;
|
||||||
tooltipTitle?: string;
|
|
||||||
tooltipText?: string;
|
|
||||||
tooltipTitleFontFamily?: string;
|
|
||||||
tooltipTextFontFamily?: string;
|
|
||||||
descriptionTitle?: string;
|
descriptionTitle?: string;
|
||||||
descriptionText?: string;
|
descriptionText?: string;
|
||||||
descriptionTitleFontSize?: string;
|
descriptionTitleFontSize?: string;
|
||||||
@ -285,6 +410,122 @@ export interface CanvasElement extends BaseCanvasElement {
|
|||||||
gallerySlideTransitionEasing?: EasingFunction | '';
|
gallerySlideTransitionEasing?: EasingFunction | '';
|
||||||
/** Override overlay color for slide transitions */
|
/** Override overlay color for slide transitions */
|
||||||
gallerySlideTransitionOverlayColor?: string;
|
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'
|
| 'logo'
|
||||||
| 'favicon'
|
| 'favicon'
|
||||||
| 'document'
|
| 'document'
|
||||||
| 'general';
|
| 'general'
|
||||||
|
| 'embed';
|
||||||
cdn_url?: string | null;
|
cdn_url?: string | null;
|
||||||
storage_key?: string | null;
|
storage_key?: string | null;
|
||||||
}
|
}
|
||||||
@ -341,13 +583,13 @@ const CANVAS_ELEMENT_TYPES: CanvasElementType[] = [
|
|||||||
'navigation_prev',
|
'navigation_prev',
|
||||||
'spot',
|
'spot',
|
||||||
'description',
|
'description',
|
||||||
'tooltip',
|
|
||||||
'gallery',
|
'gallery',
|
||||||
'carousel',
|
'carousel',
|
||||||
'logo',
|
'logo',
|
||||||
'video_player',
|
'video_player',
|
||||||
'audio_player',
|
'audio_player',
|
||||||
'popup',
|
'popup',
|
||||||
|
'info_panel',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -452,8 +694,7 @@ export interface EditorElementProps {
|
|||||||
| 'none'
|
| 'none'
|
||||||
| 'background_image'
|
| 'background_image'
|
||||||
| 'background_video'
|
| 'background_video'
|
||||||
| 'background_audio'
|
| 'background_audio';
|
||||||
| 'create_transition';
|
|
||||||
onRemoveElement: () => void;
|
onRemoveElement: () => void;
|
||||||
onUpdateElement: (patch: Partial<CanvasElement>) => void;
|
onUpdateElement: (patch: Partial<CanvasElement>) => void;
|
||||||
}
|
}
|
||||||
@ -470,27 +711,12 @@ export interface EditorBackgroundProps {
|
|||||||
onBackgroundAudioChange: (value: string) => void;
|
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
|
* Duration notes props
|
||||||
*/
|
*/
|
||||||
export interface EditorDurationNotesProps {
|
export interface EditorDurationNotesProps {
|
||||||
backgroundVideoDurationNote: string;
|
backgroundVideoDurationNote: string;
|
||||||
backgroundAudioDurationNote: string;
|
backgroundAudioDurationNote: string;
|
||||||
newTransitionDurationNote: string;
|
|
||||||
selectedMediaDurationNote: string;
|
selectedMediaDurationNote: string;
|
||||||
selectedTransitionDurationNote: string;
|
selectedTransitionDurationNote: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,7 +68,8 @@ export interface Asset extends BaseEntity {
|
|||||||
| 'transition'
|
| 'transition'
|
||||||
| 'logo'
|
| 'logo'
|
||||||
| 'favicon'
|
| 'favicon'
|
||||||
| 'document';
|
| 'document'
|
||||||
|
| 'embed';
|
||||||
cdn_url?: string;
|
cdn_url?: string;
|
||||||
storage_key?: string;
|
storage_key?: string;
|
||||||
mime_type?: string;
|
mime_type?: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user