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,
infoPanelTriggerFontFamily,
infoPanelDisabled,
infoPanelOpenByDefault,
// Header section
infoPanelHeaderImageUrl,
infoPanelHeaderText,
@ -186,6 +187,19 @@ const InfoPanelSettingsSection: React.FC<InfoPanelSettingsSectionProps> = ({
Disable this info panel
</label>
</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>

View File

@ -400,6 +400,22 @@ const InfoPanelSettingsSectionCompact: React.FC<
</label>
</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>
<label className='mb-1 block text-[11px] font-medium text-white/70'>
Icon

View File

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

View File

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

View File

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

View File

@ -53,6 +53,10 @@ interface InfoPanelOverlayProps {
onOpenExternalUrl?: (url: string) => void;
/** Callback when a media item should replace the current screen background */
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;
/** Callback when panel position changes (edit mode only) */
onPanelPositionChange?: (xPercent: number, yPercent: number) => void;
@ -140,6 +144,8 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
onNavigateToPage,
onOpenExternalUrl,
onUseAsBackground,
renderBackdrop = true,
onBackdropClose,
isEditMode = false,
onPanelPositionChange,
active360ItemId,
@ -371,20 +377,20 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === overlayRef.current && !isEditMode) {
onClose();
(onBackdropClose || onClose)();
}
},
[onClose, isEditMode],
[onBackdropClose, onClose, isEditMode],
);
// Handle touch on backdrop
const handleBackdropTouch = useCallback(
(e: React.TouchEvent) => {
if (e.target === overlayRef.current && !isEditMode) {
onClose();
(onBackdropClose || onClose)();
}
},
[onClose, isEditMode],
[onBackdropClose, onClose, isEditMode],
);
// Extract panel styling from element
@ -462,16 +468,20 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
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}
/>
{renderBackdrop && (
<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

View File

@ -142,6 +142,19 @@ export const isElementFlagEnabled = (value: unknown): boolean =>
value === 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
*/

View File

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

View File

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

View File

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

View File

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