added ability to set open info panel state by default
This commit is contained in:
parent
e14db16290
commit
e5ad49d07b
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -269,6 +269,7 @@ export interface InfoPanelSettingsSectionProps {
|
||||
infoPanelTriggerLabel: string;
|
||||
infoPanelTriggerFontFamily: string;
|
||||
infoPanelDisabled: boolean;
|
||||
infoPanelOpenByDefault: boolean;
|
||||
// Header section
|
||||
infoPanelHeaderImageUrl?: string;
|
||||
infoPanelHeaderText?: string;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -394,6 +394,7 @@ const ElementTypeDefaultDetailsPage = () => {
|
||||
form.state.infoPanelTriggerFontFamily
|
||||
}
|
||||
infoPanelDisabled={form.state.infoPanelDisabled}
|
||||
infoPanelOpenByDefault={form.state.infoPanelOpenByDefault}
|
||||
// Header section
|
||||
infoPanelHeaderImageUrl={
|
||||
form.state.infoPanelHeaderImageUrl
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user