added ability to set open info panel state by default

This commit is contained in:
Dmitri 2026-06-28 10:20:59 +02:00
parent e14db16290
commit e5ad49d07b
11 changed files with 485 additions and 209 deletions

View File

@ -32,6 +32,7 @@ const InfoPanelSettingsSection: React.FC<InfoPanelSettingsSectionProps> = ({
infoPanelTriggerLabel, infoPanelTriggerLabel,
infoPanelTriggerFontFamily, infoPanelTriggerFontFamily,
infoPanelDisabled, infoPanelDisabled,
infoPanelOpenByDefault,
// Header section // Header section
infoPanelHeaderImageUrl, infoPanelHeaderImageUrl,
infoPanelHeaderText, infoPanelHeaderText,
@ -186,6 +187,19 @@ const InfoPanelSettingsSection: React.FC<InfoPanelSettingsSectionProps> = ({
Disable this info panel Disable this info panel
</label> </label>
</FormField> </FormField>
<FormField label='Default State'>
<label className='inline-flex items-center gap-2 text-sm'>
<input
type='checkbox'
checked={infoPanelOpenByDefault}
onChange={(e) =>
onChange('infoPanelOpenByDefault', e.target.checked)
}
/>
Open by default
</label>
</FormField>
</div> </div>
</div> </div>

View File

@ -400,6 +400,22 @@ const InfoPanelSettingsSectionCompact: React.FC<
</label> </label>
</div> </div>
<div>
<label className='flex items-center gap-2 text-[11px] font-medium text-white/70'>
<input
type='checkbox'
checked={element.infoPanelOpenByDefault || false}
onChange={(e) =>
onChange('infoPanelOpenByDefault', e.target.checked)
}
/>
Open by default
</label>
<p className='mt-1 text-[10px] text-white/50'>
Opens the panel when this page renders
</p>
</div>
<div> <div>
<label className='mb-1 block text-[11px] font-medium text-white/70'> <label className='mb-1 block text-[11px] font-medium text-white/70'>
Icon Icon

View File

@ -269,6 +269,7 @@ export interface InfoPanelSettingsSectionProps {
infoPanelTriggerLabel: string; infoPanelTriggerLabel: string;
infoPanelTriggerFontFamily: string; infoPanelTriggerFontFamily: string;
infoPanelDisabled: boolean; infoPanelDisabled: boolean;
infoPanelOpenByDefault: boolean;
// Header section // Header section
infoPanelHeaderImageUrl?: string; infoPanelHeaderImageUrl?: string;
infoPanelHeaderText?: string; infoPanelHeaderText?: string;

View File

@ -150,6 +150,7 @@ interface FormState {
infoPanelTriggerLabel: string; infoPanelTriggerLabel: string;
infoPanelTriggerFontFamily: string; infoPanelTriggerFontFamily: string;
infoPanelDisabled: boolean; infoPanelDisabled: boolean;
infoPanelOpenByDefault: boolean;
panelTitle: string; panelTitle: string;
panelText: string; panelText: string;
panelXPercent: string; panelXPercent: string;
@ -309,6 +310,7 @@ const initialState: FormState = {
infoPanelTriggerLabel: '', infoPanelTriggerLabel: '',
infoPanelTriggerFontFamily: '', infoPanelTriggerFontFamily: '',
infoPanelDisabled: false, infoPanelDisabled: false,
infoPanelOpenByDefault: false,
panelTitle: '', panelTitle: '',
panelText: '', panelText: '',
panelXPercent: '0', panelXPercent: '0',
@ -521,6 +523,7 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
settings.infoPanelTriggerFontFamily || '', settings.infoPanelTriggerFontFamily || '',
), ),
infoPanelDisabled: Boolean(settings.infoPanelDisabled), infoPanelDisabled: Boolean(settings.infoPanelDisabled),
infoPanelOpenByDefault: Boolean(settings.infoPanelOpenByDefault),
panelTitle: String(settings.panelTitle || ''), panelTitle: String(settings.panelTitle || ''),
panelText: String(settings.panelText || ''), panelText: String(settings.panelText || ''),
panelXPercent: String(settings.panelXPercent ?? 0), panelXPercent: String(settings.panelXPercent ?? 0),
@ -1067,6 +1070,7 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
settings.infoPanelTriggerFontFamily = settings.infoPanelTriggerFontFamily =
state.infoPanelTriggerFontFamily.trim(); state.infoPanelTriggerFontFamily.trim();
settings.infoPanelDisabled = state.infoPanelDisabled; settings.infoPanelDisabled = state.infoPanelDisabled;
settings.infoPanelOpenByDefault = state.infoPanelOpenByDefault;
// Panel content // Panel content
settings.panelTitle = state.panelTitle.trim(); settings.panelTitle = state.panelTitle.trim();

View File

@ -72,6 +72,7 @@ import type {
InfoPanelImage, InfoPanelImage,
} from '../types/constructor'; } from '../types/constructor';
import { import {
findDefaultOpenInfoPanelElements,
isElementFlagEnabled, isElementFlagEnabled,
isInfoPanelElementType, isInfoPanelElementType,
} from '../lib/elementDefaults'; } from '../lib/elementDefaults';
@ -219,19 +220,20 @@ export default function RuntimePresentation({
element: CanvasElement; element: CanvasElement;
initialIndex: number; initialIndex: number;
} | null>(null); } | null>(null);
const [activeInfoPanel, setActiveInfoPanel] = useState<CanvasElement | null>( const [activeInfoPanelIds, setActiveInfoPanelIds] = useState<string[]>([]);
null, const [activeDetailImages, setActiveDetailImages] = useState<
); Record<string, InfoPanelImage | undefined>
const [activeDetailImage, setActiveDetailImage] = >({});
useState<InfoPanelImage | null>(null);
const [activeInfoPanelGallery, setActiveInfoPanelGallery] = useState<{ const [activeInfoPanelGallery, setActiveInfoPanelGallery] = useState<{
panelId: string;
items: GalleryCarouselMediaItem[]; items: GalleryCarouselMediaItem[];
initialIndex: number; initialIndex: number;
} | null>(null); } | null>(null);
// Track selected image in media section (runtime-only local state) // Track selected image in media section (runtime-only local state)
const [runtimeSelectedImageId, setRuntimeSelectedImageId] = useState< const [runtimeSelectedImageIds, setRuntimeSelectedImageIds] = useState<
string | null Record<string, string | null>
>(null); >({});
const defaultInfoPanelPageIdRef = useRef<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);
@ -472,6 +474,37 @@ export default function RuntimePresentation({
} }
}, [selectedPage]); }, [selectedPage]);
const activeInfoPanelElements = useMemo(
() =>
activeInfoPanelIds
.map((panelId) =>
pageElements.find((element) => element.id === panelId),
)
.filter((element): element is CanvasElement => Boolean(element)),
[activeInfoPanelIds, pageElements],
);
const activeInfoPanelGalleryElement = activeInfoPanelGallery
? pageElements.find(
(element) => element.id === activeInfoPanelGallery.panelId,
) || null
: null;
useEffect(() => {
if (!selectedPage) return;
if (!selectedPageId || defaultInfoPanelPageIdRef.current === selectedPageId)
return;
defaultInfoPanelPageIdRef.current = selectedPageId;
const defaultOpenInfoPanels =
findDefaultOpenInfoPanelElements(pageElements);
setActiveInfoPanelIds(defaultOpenInfoPanels.map((element) => element.id));
setActiveDetailImages({});
setActiveInfoPanelGallery(null);
setRuntimeSelectedImageIds({});
}, [pageElements, selectedPage, selectedPageId]);
// Set initial backgrounds when page first loads (before preload cache is populated) // Set initial backgrounds when page first loads (before preload cache is populated)
// The condition ensures this only runs once on initial load when backgrounds are empty. // The condition ensures this only runs once on initial load when backgrounds are empty.
// After that, navigateToPage handles all subsequent navigation explicitly. // After that, navigateToPage handles all subsequent navigation explicitly.
@ -626,10 +659,10 @@ export default function RuntimePresentation({
const targetPage = pages.find((page) => page.slug === targetPageSlug); const targetPage = pages.find((page) => page.slug === targetPageSlug);
if (!targetPage) return; if (!targetPage) return;
setActiveInfoPanel(null); setActiveInfoPanelIds([]);
setActiveDetailImage(null); setActiveDetailImages({});
setActiveInfoPanelGallery(null); setActiveInfoPanelGallery(null);
setRuntimeSelectedImageId(null); setRuntimeSelectedImageIds({});
navigateToPage(targetPage.id); navigateToPage(targetPage.id);
}, },
[navigateToPage, pages, transitionPhase, isBuffering], [navigateToPage, pages, transitionPhase, isBuffering],
@ -643,7 +676,7 @@ export default function RuntimePresentation({
}, []); }, []);
const handleInfoPanelUseAsBackground = useCallback( const handleInfoPanelUseAsBackground = useCallback(
(item: InfoPanelImage) => { (panelId: string, item: InfoPanelImage) => {
const mediaType = const mediaType =
item.itemType === 'video' item.itemType === 'video'
? 'video' ? 'video'
@ -670,14 +703,20 @@ export default function RuntimePresentation({
: '', : '',
navCurrentBgAudioUrl, navCurrentBgAudioUrl,
); );
setActiveDetailImage(null); setActiveDetailImages((current) => {
setActiveInfoPanelGallery(null); const next = { ...current };
delete next[panelId];
return next;
});
setActiveInfoPanelGallery((current) =>
current?.panelId === panelId ? null : current,
);
}, },
[navCurrentBgAudioUrl, navSetBackgroundDirectly], [navCurrentBgAudioUrl, navSetBackgroundDirectly],
); );
const handleInfoPanelOpenGallery = useCallback( const handleInfoPanelOpenGallery = useCallback(
(items: InfoPanelImage[], initialIndex: number) => { (panelId: string, items: InfoPanelImage[], initialIndex: number) => {
const activeItemId = items[initialIndex]?.id; const activeItemId = items[initialIndex]?.id;
const galleryItems = items const galleryItems = items
.map<GalleryCarouselMediaItem | null>((item) => { .map<GalleryCarouselMediaItem | null>((item) => {
@ -704,8 +743,13 @@ export default function RuntimePresentation({
.filter((item): item is GalleryCarouselMediaItem => Boolean(item)); .filter((item): item is GalleryCarouselMediaItem => Boolean(item));
if (galleryItems.length === 0) return; if (galleryItems.length === 0) return;
setActiveDetailImage(null); setActiveDetailImages((current) => {
const next = { ...current };
delete next[panelId];
return next;
});
setActiveInfoPanelGallery({ setActiveInfoPanelGallery({
panelId,
items: galleryItems, items: galleryItems,
initialIndex: Math.max( initialIndex: Math.max(
0, 0,
@ -738,8 +782,14 @@ export default function RuntimePresentation({
// Handle info panel click // Handle info panel click
if (isInfoPanelElementType(element.type)) { if (isInfoPanelElementType(element.type)) {
setActiveInfoPanel(element); setActiveInfoPanelIds((current) =>
setActiveDetailImage(null); current.includes(element.id) ? current : [...current, element.id],
);
setActiveDetailImages((current) => {
const next = { ...current };
delete next[element.id];
return next;
});
return; return;
} }
@ -1090,7 +1140,7 @@ export default function RuntimePresentation({
} }
: undefined : undefined
} }
isInfoPanelOpen={activeInfoPanel?.id === element.id} isInfoPanelOpen={activeInfoPanelIds.includes(element.id)}
/> />
))} ))}
</div> </div>
@ -1184,82 +1234,154 @@ export default function RuntimePresentation({
/> />
)} )}
{activeInfoPanelGallery && ( {activeInfoPanelGallery && activeInfoPanelGalleryElement && (
<GalleryCarouselOverlay <GalleryCarouselOverlay
cards={activeInfoPanelGallery.items} cards={activeInfoPanelGallery.items}
initialIndex={activeInfoPanelGallery.initialIndex} initialIndex={activeInfoPanelGallery.initialIndex}
onClose={() => setActiveInfoPanelGallery(null)} onClose={() => setActiveInfoPanelGallery(null)}
resolveUrl={resolveUrlWithBlob} resolveUrl={resolveUrlWithBlob}
prevIconUrl={activeInfoPanel?.galleryCarouselPrevIconUrl} prevIconUrl={
nextIconUrl={activeInfoPanel?.galleryCarouselNextIconUrl} activeInfoPanelGalleryElement.galleryCarouselPrevIconUrl
backIconUrl={activeInfoPanel?.galleryCarouselBackIconUrl} }
backLabel={activeInfoPanel?.galleryCarouselBackLabel || 'BACK'} nextIconUrl={
prevX={activeInfoPanel?.galleryCarouselPrevX} activeInfoPanelGalleryElement.galleryCarouselNextIconUrl
prevY={activeInfoPanel?.galleryCarouselPrevY} }
nextX={activeInfoPanel?.galleryCarouselNextX} backIconUrl={
nextY={activeInfoPanel?.galleryCarouselNextY} activeInfoPanelGalleryElement.galleryCarouselBackIconUrl
backX={activeInfoPanel?.galleryCarouselBackX} }
backY={activeInfoPanel?.galleryCarouselBackY} backLabel={
prevWidth={activeInfoPanel?.galleryCarouselPrevWidth} activeInfoPanelGalleryElement.galleryCarouselBackLabel ||
prevHeight={activeInfoPanel?.galleryCarouselPrevHeight} 'BACK'
nextWidth={activeInfoPanel?.galleryCarouselNextWidth} }
nextHeight={activeInfoPanel?.galleryCarouselNextHeight} prevX={activeInfoPanelGalleryElement.galleryCarouselPrevX}
backWidth={activeInfoPanel?.galleryCarouselBackWidth} prevY={activeInfoPanelGalleryElement.galleryCarouselPrevY}
backHeight={activeInfoPanel?.galleryCarouselBackHeight} nextX={activeInfoPanelGalleryElement.galleryCarouselNextX}
nextY={activeInfoPanelGalleryElement.galleryCarouselNextY}
backX={activeInfoPanelGalleryElement.galleryCarouselBackX}
backY={activeInfoPanelGalleryElement.galleryCarouselBackY}
prevWidth={
activeInfoPanelGalleryElement.galleryCarouselPrevWidth
}
prevHeight={
activeInfoPanelGalleryElement.galleryCarouselPrevHeight
}
nextWidth={
activeInfoPanelGalleryElement.galleryCarouselNextWidth
}
nextHeight={
activeInfoPanelGalleryElement.galleryCarouselNextHeight
}
backWidth={
activeInfoPanelGalleryElement.galleryCarouselBackWidth
}
backHeight={
activeInfoPanelGalleryElement.galleryCarouselBackHeight
}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
isEditMode={false} isEditMode={false}
pageTransitionSettings={transitionSettings} pageTransitionSettings={transitionSettings}
galleryElement={activeInfoPanel || undefined} galleryElement={activeInfoPanelGalleryElement}
/> />
)} )}
{/* Info Panel Overlay */} {/* Info Panel Overlay */}
{activeInfoPanel && ( {activeInfoPanelElements.map((activeInfoPanel, panelIndex) => {
<> const selectedImageId =
<InfoPanelOverlay runtimeSelectedImageIds[activeInfoPanel.id];
element={ const panelDetailImage = activeDetailImages[activeInfoPanel.id];
runtimeSelectedImageId
? { return (
...activeInfoPanel, <React.Fragment key={activeInfoPanel.id}>
infoPanelSelectedImageId: runtimeSelectedImageId, <InfoPanelOverlay
} element={
: activeInfoPanel selectedImageId
} ? {
onClose={() => { ...activeInfoPanel,
setActiveInfoPanel(null); infoPanelSelectedImageId: selectedImageId,
setActiveDetailImage(null); }
setActiveInfoPanelGallery(null); : activeInfoPanel
setRuntimeSelectedImageId(null); }
}} onClose={() => {
resolveUrl={resolveUrlWithBlob} setActiveInfoPanelIds((current) =>
letterboxStyles={letterboxStyles} current.filter(
cssVars={cssVars} (panelId) => panelId !== activeInfoPanel.id,
onImageClick={(image) => setActiveDetailImage(image)} ),
onOpenGallery={handleInfoPanelOpenGallery} );
onUseAsBackground={handleInfoPanelUseAsBackground} setActiveDetailImages((current) => {
onSelectImage={(imageId) => const next = { ...current };
setRuntimeSelectedImageId(imageId) delete next[activeInfoPanel.id];
} return next;
onNavigateToPage={handleInfoPanelNavigateToPage} });
onOpenExternalUrl={handleInfoPanelOpenExternalUrl} setActiveInfoPanelGallery((current) =>
active360ItemId={ current?.panelId === activeInfoPanel.id
activeDetailImage?.itemType === '360' ? null
? activeDetailImage.id : current,
: null );
} setRuntimeSelectedImageIds((current) => {
/> const next = { ...current };
{activeDetailImage && ( delete next[activeInfoPanel.id];
<ImageDetailPanel return next;
element={activeInfoPanel} });
image={activeDetailImage} }}
onClose={() => setActiveDetailImage(null)}
resolveUrl={resolveUrlWithBlob} resolveUrl={resolveUrlWithBlob}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
cssVars={cssVars} cssVars={cssVars}
renderBackdrop={panelIndex === 0}
onBackdropClose={() => {
setActiveInfoPanelIds([]);
setActiveDetailImages({});
setActiveInfoPanelGallery(null);
setRuntimeSelectedImageIds({});
}}
onImageClick={(image) =>
setActiveDetailImages((current) => ({
...current,
[activeInfoPanel.id]: image,
}))
}
onOpenGallery={(items, initialIndex) =>
handleInfoPanelOpenGallery(
activeInfoPanel.id,
items,
initialIndex,
)
}
onUseAsBackground={(item) =>
handleInfoPanelUseAsBackground(activeInfoPanel.id, item)
}
onSelectImage={(imageId) =>
setRuntimeSelectedImageIds((current) => ({
...current,
[activeInfoPanel.id]: imageId,
}))
}
onNavigateToPage={handleInfoPanelNavigateToPage}
onOpenExternalUrl={handleInfoPanelOpenExternalUrl}
active360ItemId={
panelDetailImage?.itemType === '360'
? panelDetailImage.id
: null
}
/> />
)} {panelDetailImage && (
</> <ImageDetailPanel
)} element={activeInfoPanel}
image={panelDetailImage}
onClose={() =>
setActiveDetailImages((current) => {
const next = { ...current };
delete next[activeInfoPanel.id];
return next;
})
}
resolveUrl={resolveUrlWithBlob}
letterboxStyles={letterboxStyles}
cssVars={cssVars}
/>
)}
</React.Fragment>
);
})}
</BackdropPortalProvider> </BackdropPortalProvider>
</div> </div>
{/* End inner canvas container */} {/* End inner canvas container */}

View File

@ -53,6 +53,10 @@ interface InfoPanelOverlayProps {
onOpenExternalUrl?: (url: string) => void; onOpenExternalUrl?: (url: string) => void;
/** Callback when a media item should replace the current screen background */ /** Callback when a media item should replace the current screen background */
onUseAsBackground?: (image: InfoPanelImage) => void; onUseAsBackground?: (image: InfoPanelImage) => void;
/** Whether this overlay instance should render the fullscreen backdrop layer */
renderBackdrop?: boolean;
/** Optional close handler for backdrop clicks; defaults to onClose */
onBackdropClose?: () => void;
isEditMode?: boolean; isEditMode?: boolean;
/** Callback when panel position changes (edit mode only) */ /** Callback when panel position changes (edit mode only) */
onPanelPositionChange?: (xPercent: number, yPercent: number) => void; onPanelPositionChange?: (xPercent: number, yPercent: number) => void;
@ -140,6 +144,8 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
onNavigateToPage, onNavigateToPage,
onOpenExternalUrl, onOpenExternalUrl,
onUseAsBackground, onUseAsBackground,
renderBackdrop = true,
onBackdropClose,
isEditMode = false, isEditMode = false,
onPanelPositionChange, onPanelPositionChange,
active360ItemId, active360ItemId,
@ -371,20 +377,20 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
const handleBackdropClick = useCallback( const handleBackdropClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (e.target === overlayRef.current && !isEditMode) { if (e.target === overlayRef.current && !isEditMode) {
onClose(); (onBackdropClose || onClose)();
} }
}, },
[onClose, isEditMode], [onBackdropClose, onClose, isEditMode],
); );
// Handle touch on backdrop // Handle touch on backdrop
const handleBackdropTouch = useCallback( const handleBackdropTouch = useCallback(
(e: React.TouchEvent) => { (e: React.TouchEvent) => {
if (e.target === overlayRef.current && !isEditMode) { if (e.target === overlayRef.current && !isEditMode) {
onClose(); (onBackdropClose || onClose)();
} }
}, },
[onClose, isEditMode], [onBackdropClose, onClose, isEditMode],
); );
// Extract panel styling from element // Extract panel styling from element
@ -462,16 +468,20 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
return ( return (
<> <>
{/* Backdrop overlay - separate from panel for correct z-index stacking */} {/* Backdrop overlay - separate from panel for correct z-index stacking */}
<div {renderBackdrop && (
ref={overlayRef} <div
className='fixed inset-0 z-[51] overflow-hidden' ref={overlayRef}
style={{ className='fixed inset-0 z-[51] overflow-hidden'
backgroundColor: isOverlayVisible ? panelOverlayColor : 'transparent', style={{
pointerEvents: isEditMode ? 'none' : 'auto', backgroundColor: isOverlayVisible
}} ? panelOverlayColor
onClick={handleBackdropClick} : 'transparent',
onTouchEnd={handleBackdropTouch} pointerEvents: isEditMode ? 'none' : 'auto',
/> }}
onClick={handleBackdropClick}
onTouchEnd={handleBackdropTouch}
/>
)}
{/* Inner container constrained to canvas bounds */} {/* Inner container constrained to canvas bounds */}
<div <div

View File

@ -142,6 +142,19 @@ export const isElementFlagEnabled = (value: unknown): boolean =>
value === true || value === true ||
(typeof value === 'string' && value.trim().toLowerCase() === 'true'); (typeof value === 'string' && value.trim().toLowerCase() === 'true');
/**
* Find all enabled Info Panel elements configured to open with the page.
*/
export const findDefaultOpenInfoPanelElements = (
elements: CanvasElement[],
): CanvasElement[] =>
elements.filter(
(element) =>
isInfoPanelElementType(element.type) &&
isElementFlagEnabled(element.infoPanelOpenByDefault) &&
!isElementFlagEnabled(element.infoPanelDisabled),
);
/** /**
* Normalize appearDelaySec value * Normalize appearDelaySec value
*/ */

View File

@ -64,6 +64,7 @@ import {
normalizeAppearDurationSec, normalizeAppearDurationSec,
ELEMENT_TYPE_LABELS, ELEMENT_TYPE_LABELS,
getNavigationButtonKind, getNavigationButtonKind,
findDefaultOpenInfoPanelElements,
isElementFlagEnabled, isElementFlagEnabled,
isNavigationElementType, isNavigationElementType,
isDescriptionElementType, isDescriptionElementType,
@ -320,12 +321,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
initialIndex: number; initialIndex: number;
} | null>(null); } | null>(null);
// Info panel overlay state // Info panel overlay state
const [activeInfoPanel, setActiveInfoPanel] = useState<{ const [activeInfoPanelIds, setActiveInfoPanelIds] = useState<string[]>([]);
elementId: string; const defaultInfoPanelPageIdRef = useRef<string | null>(null);
} | null>(null); const elementsPageIdRef = useRef<string | null>(null);
const [activeDetailImage, setActiveDetailImage] = const [activeDetailImages, setActiveDetailImages] = useState<
useState<InfoPanelImage | null>(null); Record<string, InfoPanelImage | undefined>
>({});
const [activeInfoPanelGallery, setActiveInfoPanelGallery] = useState<{ const [activeInfoPanelGallery, setActiveInfoPanelGallery] = useState<{
panelId: string;
items: GalleryCarouselMediaItem[]; items: GalleryCarouselMediaItem[];
initialIndex: number; initialIndex: number;
} | null>(null); } | null>(null);
@ -430,11 +433,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
); );
}, [activeGalleryCarousel, elements]); }, [activeGalleryCarousel, elements]);
// Look up current element for info panel (so it receives updates from element editor) // Look up current elements for info panels (so they receive updates).
const activeInfoPanelElement = useMemo(() => { const activeInfoPanelElements = useMemo(
if (!activeInfoPanel) return null; () =>
return elements.find((el) => el.id === activeInfoPanel.elementId) || null; activeInfoPanelIds
}, [activeInfoPanel, elements]); .map((panelId) => elements.find((element) => element.id === panelId))
.filter((element): element is CanvasElement => Boolean(element)),
[activeInfoPanelIds, elements],
);
// In edit mode, show overlay when info_panel element is selected (even without click) // In edit mode, show overlay when info_panel element is selected (even without click)
const editModeInfoPanelElement = useMemo(() => { const editModeInfoPanelElement = useMemo(() => {
@ -445,18 +451,42 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// In edit mode the overlay is only a selected-element preview. Do not keep // In edit mode the overlay is only a selected-element preview. Do not keep
// runtime-open Info Panel state above the canvas after selection is cleared. // runtime-open Info Panel state above the canvas after selection is cleared.
const infoPanelElementToRender = isConstructorEditMode const infoPanelElementsToRender = isConstructorEditMode
? editModeInfoPanelElement ? editModeInfoPanelElement
: activeInfoPanelElement; ? [editModeInfoPanelElement]
const shouldShowInfoPanelOverlays = !!infoPanelElementToRender; : []
: activeInfoPanelElements;
const shouldShowInfoPanelOverlays = infoPanelElementsToRender.length > 0;
const activeInfoPanelGalleryElement = activeInfoPanelGallery
? elements.find(
(element) => element.id === activeInfoPanelGallery.panelId,
) || null
: null;
// Reset info panel state when switching between edit and interact modes // Reset info panel state when switching between edit and interact modes
useEffect(() => { useEffect(() => {
setActiveInfoPanel(null); setActiveInfoPanelIds([]);
setActiveDetailImage(null); setActiveDetailImages({});
setActiveInfoPanelGallery(null); setActiveInfoPanelGallery(null);
}, [isConstructorEditMode]); }, [isConstructorEditMode]);
useEffect(() => {
if (isConstructorEditMode) {
defaultInfoPanelPageIdRef.current = null;
return;
}
if (elementsPageIdRef.current !== activePageId) return;
if (!activePageId || defaultInfoPanelPageIdRef.current === activePageId)
return;
defaultInfoPanelPageIdRef.current = activePageId;
const defaultOpenInfoPanels = findDefaultOpenInfoPanelElements(elements);
setActiveInfoPanelIds(defaultOpenInfoPanels.map((element) => element.id));
setActiveDetailImages({});
setActiveInfoPanelGallery(null);
}, [activePageId, elements, isConstructorEditMode]);
// Draggable panels using useDraggable hook // Draggable panels using useDraggable hook
const { position: toolbarPosition, onDragStart: onToolbarDragStart } = const { position: toolbarPosition, onDragStart: onToolbarDragStart } =
useDraggable({ useDraggable({
@ -668,8 +698,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
pages.find((page) => page.slug === targetPageSlug) || null; pages.find((page) => page.slug === targetPageSlug) || null;
if (!targetPage) return; if (!targetPage) return;
setActiveInfoPanel(null); setActiveInfoPanelIds([]);
setActiveDetailImage(null); setActiveDetailImages({});
setActiveInfoPanelGallery(null); setActiveInfoPanelGallery(null);
switchToPage(targetPage).then(() => { switchToPage(targetPage).then(() => {
clearSelection(); clearSelection();
@ -688,7 +718,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
}, []); }, []);
const handleInfoPanelUseAsBackground = useCallback( const handleInfoPanelUseAsBackground = useCallback(
(item: InfoPanelImage) => { (panelId: string, item: InfoPanelImage) => {
const mediaType = const mediaType =
item.itemType === 'video' item.itemType === 'video'
? 'video' ? 'video'
@ -715,14 +745,20 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
: '', : '',
navCurrentBgAudioUrl, navCurrentBgAudioUrl,
); );
setActiveDetailImage(null); setActiveDetailImages((current) => {
setActiveInfoPanelGallery(null); const next = { ...current };
delete next[panelId];
return next;
});
setActiveInfoPanelGallery((current) =>
current?.panelId === panelId ? null : current,
);
}, },
[navCurrentBgAudioUrl, navSetBackgroundDirectly], [navCurrentBgAudioUrl, navSetBackgroundDirectly],
); );
const handleInfoPanelOpenGallery = useCallback( const handleInfoPanelOpenGallery = useCallback(
(items: InfoPanelImage[], initialIndex: number) => { (panelId: string, items: InfoPanelImage[], initialIndex: number) => {
const activeItemId = items[initialIndex]?.id; const activeItemId = items[initialIndex]?.id;
const galleryItems = items const galleryItems = items
.map<GalleryCarouselMediaItem | null>((item) => { .map<GalleryCarouselMediaItem | null>((item) => {
@ -749,8 +785,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
.filter((item): item is GalleryCarouselMediaItem => Boolean(item)); .filter((item): item is GalleryCarouselMediaItem => Boolean(item));
if (galleryItems.length === 0) return; if (galleryItems.length === 0) return;
setActiveDetailImage(null); setActiveDetailImages((current) => {
const next = { ...current };
delete next[panelId];
return next;
});
setActiveInfoPanelGallery({ setActiveInfoPanelGallery({
panelId,
items: galleryItems, items: galleryItems,
initialIndex: Math.max( initialIndex: Math.max(
0, 0,
@ -1102,13 +1143,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
if (createdPage?.id) { if (createdPage?.id) {
setSuccessMessage('Page duplicated.'); setSuccessMessage('Page duplicated.');
} }
}, [ }, [activePage, activePageId, duplicatePage, existingSlugs, saveConstructor]);
activePage,
activePageId,
duplicatePage,
existingSlugs,
saveConstructor,
]);
const handleShowDeletePageModal = useCallback(() => { const handleShowDeletePageModal = useCallback(() => {
if (!activePageId || !activePage) { if (!activePageId || !activePage) {
@ -1135,7 +1170,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const currentIndex = sortedPages.findIndex( const currentIndex = sortedPages.findIndex(
(page) => page.id === activePageId, (page) => page.id === activePageId,
); );
const remainingPages = sortedPages.filter((page) => page.id !== activePageId); const remainingPages = sortedPages.filter(
(page) => page.id !== activePageId,
);
const fallbackPage = const fallbackPage =
currentIndex >= 0 currentIndex >= 0
? remainingPages[Math.min(currentIndex, remainingPages.length - 1)] ? remainingPages[Math.min(currentIndex, remainingPages.length - 1)]
@ -1226,6 +1263,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
useEffect(() => { useEffect(() => {
if (!activePage) { if (!activePage) {
elementsPageIdRef.current = null;
setElements([]); setElements([]);
clearSelection(); clearSelection();
updateBackgroundFromPage(null); updateBackgroundFromPage(null);
@ -1480,6 +1518,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
}) })
: []; : [];
elementsPageIdRef.current = activePage.id;
setElements(normalizedElements); setElements(normalizedElements);
setSelectedMenuItem('none'); setSelectedMenuItem('none');
@ -1831,8 +1870,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
(element: CanvasElement) => { (element: CanvasElement) => {
if (isConstructorEditMode) return; if (isConstructorEditMode) return;
if (isInfoPanelElementType(element.type)) { if (isInfoPanelElementType(element.type)) {
setActiveInfoPanel({ elementId: element.id }); setActiveInfoPanelIds((current) =>
setActiveDetailImage(null); current.includes(element.id) ? current : [...current, element.id],
);
setActiveDetailImages((current) => {
const next = { ...current };
delete next[element.id];
return next;
});
} }
}, },
[isConstructorEditMode], [isConstructorEditMode],
@ -2385,9 +2430,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
) )
} }
onInfoPanelClick={() => handleInfoPanelClick(element)} onInfoPanelClick={() => handleInfoPanelClick(element)}
isInfoPanelOpen={ isInfoPanelOpen={activeInfoPanelIds.includes(
activeInfoPanel?.elementId === element.id element.id,
} )}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
pageTransitionSettings={transitionSettings} pageTransitionSettings={transitionSettings}
preloadCache={{ preloadCache={{
@ -2493,105 +2538,153 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
/> />
)} )}
{activeInfoPanelGallery && ( {activeInfoPanelGallery && activeInfoPanelGalleryElement && (
<GalleryCarouselOverlay <GalleryCarouselOverlay
cards={activeInfoPanelGallery.items} cards={activeInfoPanelGallery.items}
initialIndex={activeInfoPanelGallery.initialIndex} initialIndex={activeInfoPanelGallery.initialIndex}
onClose={() => setActiveInfoPanelGallery(null)} onClose={() => setActiveInfoPanelGallery(null)}
resolveUrl={resolveUrlWithBlob} resolveUrl={resolveUrlWithBlob}
prevIconUrl={infoPanelElementToRender?.galleryCarouselPrevIconUrl} prevIconUrl={activeInfoPanelGalleryElement.galleryCarouselPrevIconUrl}
nextIconUrl={infoPanelElementToRender?.galleryCarouselNextIconUrl} nextIconUrl={activeInfoPanelGalleryElement.galleryCarouselNextIconUrl}
backIconUrl={infoPanelElementToRender?.galleryCarouselBackIconUrl} backIconUrl={activeInfoPanelGalleryElement.galleryCarouselBackIconUrl}
backLabel={ backLabel={
infoPanelElementToRender?.galleryCarouselBackLabel || 'BACK' activeInfoPanelGalleryElement.galleryCarouselBackLabel || 'BACK'
} }
prevX={infoPanelElementToRender?.galleryCarouselPrevX} prevX={activeInfoPanelGalleryElement.galleryCarouselPrevX}
prevY={infoPanelElementToRender?.galleryCarouselPrevY} prevY={activeInfoPanelGalleryElement.galleryCarouselPrevY}
nextX={infoPanelElementToRender?.galleryCarouselNextX} nextX={activeInfoPanelGalleryElement.galleryCarouselNextX}
nextY={infoPanelElementToRender?.galleryCarouselNextY} nextY={activeInfoPanelGalleryElement.galleryCarouselNextY}
backX={infoPanelElementToRender?.galleryCarouselBackX} backX={activeInfoPanelGalleryElement.galleryCarouselBackX}
backY={infoPanelElementToRender?.galleryCarouselBackY} backY={activeInfoPanelGalleryElement.galleryCarouselBackY}
prevWidth={infoPanelElementToRender?.galleryCarouselPrevWidth} prevWidth={activeInfoPanelGalleryElement.galleryCarouselPrevWidth}
prevHeight={infoPanelElementToRender?.galleryCarouselPrevHeight} prevHeight={activeInfoPanelGalleryElement.galleryCarouselPrevHeight}
nextWidth={infoPanelElementToRender?.galleryCarouselNextWidth} nextWidth={activeInfoPanelGalleryElement.galleryCarouselNextWidth}
nextHeight={infoPanelElementToRender?.galleryCarouselNextHeight} nextHeight={activeInfoPanelGalleryElement.galleryCarouselNextHeight}
backWidth={infoPanelElementToRender?.galleryCarouselBackWidth} backWidth={activeInfoPanelGalleryElement.galleryCarouselBackWidth}
backHeight={infoPanelElementToRender?.galleryCarouselBackHeight} backHeight={activeInfoPanelGalleryElement.galleryCarouselBackHeight}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
isEditMode={false} isEditMode={false}
pageTransitionSettings={transitionSettings} pageTransitionSettings={transitionSettings}
galleryElement={infoPanelElementToRender || undefined} galleryElement={activeInfoPanelGalleryElement}
/> />
)} )}
{/* Info Panel Overlay */} {/* Info Panel Overlay */}
{shouldShowInfoPanelOverlays && infoPanelElementToRender && ( {shouldShowInfoPanelOverlays &&
<> infoPanelElementsToRender.map(
<InfoPanelOverlay (infoPanelElementToRender, panelIndex) => {
element={infoPanelElementToRender} const panelDetailImage =
onClose={() => { activeDetailImages[infoPanelElementToRender.id];
setActiveInfoPanel(null);
setActiveDetailImage(null); return (
setActiveInfoPanelGallery(null); <React.Fragment key={infoPanelElementToRender.id}>
}} <InfoPanelOverlay
resolveUrl={resolveUrlWithBlob} element={infoPanelElementToRender}
letterboxStyles={letterboxStyles} onClose={() => {
cssVars={canvasCssVars} setActiveInfoPanelIds((current) =>
onImageClick={(image) => setActiveDetailImage(image)} current.filter(
onOpenGallery={handleInfoPanelOpenGallery} (panelId) => panelId !== infoPanelElementToRender.id,
onUseAsBackground={handleInfoPanelUseAsBackground} ),
onNavigateToPage={handleInfoPanelNavigateToPage} );
onOpenExternalUrl={handleInfoPanelOpenExternalUrl} setActiveDetailImages((current) => {
onSelectImage={ const next = { ...current };
isConstructorEditMode delete next[infoPanelElementToRender.id];
? (imageId) => { return next;
updateSelectedElement({
infoPanelSelectedImageId: imageId,
}); });
setActiveInfoPanelGallery((current) =>
current?.panelId === infoPanelElementToRender.id
? null
: current,
);
}}
resolveUrl={resolveUrlWithBlob}
letterboxStyles={letterboxStyles}
cssVars={canvasCssVars}
renderBackdrop={panelIndex === 0}
onBackdropClose={() => {
setActiveInfoPanelIds([]);
setActiveDetailImages({});
setActiveInfoPanelGallery(null);
}}
onImageClick={(image) =>
setActiveDetailImages((current) => ({
...current,
[infoPanelElementToRender.id]: image,
}))
} }
: undefined onOpenGallery={(items, initialIndex) =>
} handleInfoPanelOpenGallery(
isEditMode={isConstructorEditMode} infoPanelElementToRender.id,
onPanelPositionChange={ items,
isConstructorEditMode initialIndex,
? (xPercent, yPercent) => { )
updateSelectedElement({
panelXPercent: xPercent,
panelYPercent: yPercent,
});
} }
: undefined onUseAsBackground={(item) =>
} handleInfoPanelUseAsBackground(
active360ItemId={ infoPanelElementToRender.id,
activeDetailImage?.itemType === '360' item,
? activeDetailImage.id )
: null }
} onNavigateToPage={handleInfoPanelNavigateToPage}
/> onOpenExternalUrl={handleInfoPanelOpenExternalUrl}
{/* In edit mode, always show detail panel (with placeholder if no image selected) */} onSelectImage={
{(activeDetailImage || isConstructorEditMode) && ( isConstructorEditMode
<ImageDetailPanel ? (imageId) => {
element={infoPanelElementToRender} updateSelectedElement({
image={activeDetailImage} infoPanelSelectedImageId: imageId,
onClose={() => setActiveDetailImage(null)} });
resolveUrl={resolveUrlWithBlob} }
letterboxStyles={letterboxStyles} : undefined
cssVars={canvasCssVars} }
isEditMode={isConstructorEditMode} isEditMode={isConstructorEditMode}
onDetailPositionChange={ onPanelPositionChange={
isConstructorEditMode isConstructorEditMode
? (xPercent, yPercent) => { ? (xPercent, yPercent) => {
updateSelectedElement({ updateSelectedElement({
detailXPercent: xPercent, panelXPercent: xPercent,
detailYPercent: yPercent, panelYPercent: yPercent,
}); });
}
: undefined
}
active360ItemId={
panelDetailImage?.itemType === '360'
? panelDetailImage.id
: null
}
/>
{/* In edit mode, always show detail panel (with placeholder if no image selected) */}
{(panelDetailImage || isConstructorEditMode) && (
<ImageDetailPanel
element={infoPanelElementToRender}
image={panelDetailImage}
onClose={() =>
setActiveDetailImages((current) => {
const next = { ...current };
delete next[infoPanelElementToRender.id];
return next;
})
} }
: undefined resolveUrl={resolveUrlWithBlob}
} letterboxStyles={letterboxStyles}
/> cssVars={canvasCssVars}
)} isEditMode={isConstructorEditMode}
</> onDetailPositionChange={
)} isConstructorEditMode
? (xPercent, yPercent) => {
updateSelectedElement({
detailXPercent: xPercent,
detailYPercent: yPercent,
});
}
: undefined
}
/>
)}
</React.Fragment>
);
},
)}
{/* Create Page Modal */} {/* Create Page Modal */}
<CreatePageModal <CreatePageModal

View File

@ -394,6 +394,7 @@ const ElementTypeDefaultDetailsPage = () => {
form.state.infoPanelTriggerFontFamily form.state.infoPanelTriggerFontFamily
} }
infoPanelDisabled={form.state.infoPanelDisabled} infoPanelDisabled={form.state.infoPanelDisabled}
infoPanelOpenByDefault={form.state.infoPanelOpenByDefault}
// Header section // Header section
infoPanelHeaderImageUrl={ infoPanelHeaderImageUrl={
form.state.infoPanelHeaderImageUrl form.state.infoPanelHeaderImageUrl

View File

@ -593,6 +593,7 @@ const ProjectElementDefaultDetailsPage = () => {
form.state.infoPanelTriggerFontFamily form.state.infoPanelTriggerFontFamily
} }
infoPanelDisabled={form.state.infoPanelDisabled} infoPanelDisabled={form.state.infoPanelDisabled}
infoPanelOpenByDefault={form.state.infoPanelOpenByDefault}
// Header section // Header section
infoPanelHeaderImageUrl={form.state.infoPanelHeaderImageUrl} infoPanelHeaderImageUrl={form.state.infoPanelHeaderImageUrl}
infoPanelHeaderText={form.state.infoPanelHeaderText} infoPanelHeaderText={form.state.infoPanelHeaderText}

View File

@ -472,6 +472,7 @@ export interface CanvasElement extends BaseCanvasElement {
infoPanelTriggerLabel?: string; infoPanelTriggerLabel?: string;
infoPanelTriggerFontFamily?: string; infoPanelTriggerFontFamily?: string;
infoPanelDisabled?: boolean; infoPanelDisabled?: boolean;
infoPanelOpenByDefault?: boolean;
// Component 2: Info Panel (section-based like Gallery) // Component 2: Info Panel (section-based like Gallery)
// Header section // Header section