implemented:

- three destinations for info panel thumbnails : the panel image preview, new page, external page (URL). Two destinations for other elements (other page, external URL)
- toggle for info panel media opening (fullscreen or in the panel)
- ability to replace background with info panel media (image, video, 360 panorama)
- ability to make 360 panorama as page background
- global mute button
-
This commit is contained in:
Dmitri 2026-06-15 07:50:45 +02:00
parent ade1afab7c
commit 6413c7bdf0
40 changed files with 2247 additions and 505 deletions

4
.gitignore vendored
View File

@ -5,5 +5,5 @@ node_modules/
**/node_modules/ **/node_modules/
*/build/ */build/
package-lock.json package-lock.json
CLAUDE.md AGENTS.md
.claude/ .codex/

View File

@ -25,6 +25,7 @@ class Tour_pagesDBApi extends GenericDBApi {
'slug', 'slug',
'background_image_url', 'background_image_url',
'background_video_url', 'background_video_url',
'background_embed_url',
'background_audio_url', 'background_audio_url',
'ui_schema_json', 'ui_schema_json',
]; ];
@ -72,6 +73,7 @@ class Tour_pagesDBApi extends GenericDBApi {
sort_order: data.sort_order || null, sort_order: data.sort_order || null,
background_image_url: data.background_image_url || null, background_image_url: data.background_image_url || null,
background_video_url: data.background_video_url || null, background_video_url: data.background_video_url || null,
background_embed_url: data.background_embed_url || null,
background_audio_url: data.background_audio_url || null, background_audio_url: data.background_audio_url || null,
background_audio_autoplay: background_audio_autoplay:
data.background_audio_autoplay !== undefined data.background_audio_autoplay !== undefined

View File

@ -0,0 +1,12 @@
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('tour_pages', 'background_embed_url', {
type: Sequelize.TEXT,
allowNull: true,
});
},
async down(queryInterface) {
await queryInterface.removeColumn('tour_pages', 'background_embed_url');
},
};

View File

@ -62,6 +62,10 @@ module.exports = function (sequelize, DataTypes) {
type: DataTypes.TEXT, type: DataTypes.TEXT,
}, },
background_embed_url: {
type: DataTypes.TEXT,
},
background_audio_url: { background_audio_url: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
}, },

View File

@ -18,6 +18,7 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
'sort_order', 'sort_order',
'background_image_url', 'background_image_url',
'background_video_url', 'background_video_url',
'background_embed_url',
'background_audio_url', 'background_audio_url',
'background_loop', 'background_loop',
'requires_auth', 'requires_auth',

View File

@ -448,6 +448,11 @@ class ProjectsService extends BaseProjectsService {
assetPathMap.get(pageData.background_video_url) || assetPathMap.get(pageData.background_video_url) ||
pageData.background_video_url; pageData.background_video_url;
} }
if (pageData.background_embed_url) {
pageData.background_embed_url =
assetPathMap.get(pageData.background_embed_url) ||
pageData.background_embed_url;
}
if (pageData.background_audio_url) { if (pageData.background_audio_url) {
pageData.background_audio_url = pageData.background_audio_url =
assetPathMap.get(pageData.background_audio_url) || assetPathMap.get(pageData.background_audio_url) ||

View File

@ -1,7 +1,7 @@
/** /**
* BackgroundSettingsEditor Component * BackgroundSettingsEditor Component
* *
* Compact editor for background image, video, or audio settings. * Compact editor for background image, video, 360/embed, or audio settings.
* Used in the element editor panel for background menu items. * Used in the element editor panel for background menu items.
* For video backgrounds, includes playback settings (autoplay, loop, muted, start/end time). * For video backgrounds, includes playback settings (autoplay, loop, muted, start/end time).
*/ */
@ -15,7 +15,7 @@ import type {
import { addFallbackAssetOption } from '../../lib/constructorHelpers'; import { addFallbackAssetOption } from '../../lib/constructorHelpers';
interface BackgroundSettingsEditorProps { interface BackgroundSettingsEditorProps {
type: 'image' | 'video' | 'audio'; type: 'image' | 'video' | 'embed' | 'audio';
value: string; value: string;
options: AssetOption[]; options: AssetOption[];
durationNote?: string; durationNote?: string;
@ -38,6 +38,7 @@ interface BackgroundSettingsEditorProps {
const LABELS: Record<string, string> = { const LABELS: Record<string, string> = {
image: 'Background image', image: 'Background image',
video: 'Background video', video: 'Background video',
embed: 'Background 360',
audio: 'Background audio', audio: 'Background audio',
}; };

View File

@ -1,7 +1,7 @@
/** /**
* CanvasBackground Component * CanvasBackground Component
* *
* Background image, video, and audio for the constructor canvas. * Background image, video, 360/embed, and audio for the constructor canvas.
* Handles blob URLs, Next.js Image optimization, and previous background overlay. * Handles blob URLs, Next.js Image optimization, and previous background overlay.
* Supports custom video playback settings (autoplay, loop, muted, start/end time). * Supports custom video playback settings (autoplay, loop, muted, start/end time).
*/ */
@ -18,6 +18,7 @@ import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayba
import { useBackgroundAudioPlayback } from '../../hooks/useBackgroundAudioPlayback'; import { useBackgroundAudioPlayback } from '../../hooks/useBackgroundAudioPlayback';
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay'; import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
import { baseURLApi } from '../../config'; import { baseURLApi } from '../../config';
import { buildChromeFreeEmbedUrl } from '../../lib/embedUrl';
/** /**
* Schedule a callback to run after the next browser paint. * Schedule a callback to run after the next browser paint.
@ -45,6 +46,7 @@ interface HTMLVideoElementWithRVFC extends HTMLVideoElement {
interface CanvasBackgroundProps { interface CanvasBackgroundProps {
backgroundImageUrl?: string; backgroundImageUrl?: string;
backgroundVideoUrl?: string; backgroundVideoUrl?: string;
backgroundEmbedUrl?: string;
backgroundAudioUrl?: string; backgroundAudioUrl?: string;
previousBgImageUrl?: string; previousBgImageUrl?: string;
previousBgVideoUrl?: string; previousBgVideoUrl?: string;
@ -76,6 +78,7 @@ interface CanvasBackgroundProps {
const CanvasBackground: React.FC<CanvasBackgroundProps> = ({ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
backgroundImageUrl, backgroundImageUrl,
backgroundVideoUrl, backgroundVideoUrl,
backgroundEmbedUrl,
backgroundAudioUrl, backgroundAudioUrl,
previousBgImageUrl, previousBgImageUrl,
previousBgVideoUrl, previousBgVideoUrl,
@ -96,6 +99,20 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
audioStoragePath, audioStoragePath,
pauseAudio = false, pauseAudio = false,
}) => { }) => {
const hasEmbedBackground = Boolean(backgroundEmbedUrl);
const embedBackgroundSrc = useMemo(
() =>
backgroundEmbedUrl ? buildChromeFreeEmbedUrl(backgroundEmbedUrl) : '',
[backgroundEmbedUrl],
);
useEffect(() => {
if (!backgroundEmbedUrl) return;
scheduleAfterPaint(() => {
onBackgroundReady?.();
});
}, [backgroundEmbedUrl, onBackgroundReady]);
// During page switching with video paused, keep showing the previous video URL. // During page switching with video paused, keep showing the previous video URL.
// This prevents black flash when the video element would remount with a new URL. // This prevents black flash when the video element would remount with a new URL.
// The old video element stays visible (paused at frozen frame) until new page is ready. // The old video element stays visible (paused at frozen frame) until new page is ready.
@ -141,7 +158,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
// Track video buffering via canplay/waiting events // Track video buffering via canplay/waiting events
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if (!backgroundVideoUrl || !video) { if (!backgroundVideoUrl || hasEmbedBackground || !video) {
setIsVideoBuffering(false); setIsVideoBuffering(false);
// CRITICAL: Also notify parent that buffering is done when there's no video // CRITICAL: Also notify parent that buffering is done when there's no video
// Without this, parent's isBackgroundVideoBuffering stays stuck at true from previous page // Without this, parent's isBackgroundVideoBuffering stays stuck at true from previous page
@ -170,7 +187,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
video.removeEventListener('canplay', handleCanPlay); video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('waiting', handleWaiting); video.removeEventListener('waiting', handleWaiting);
}; };
}, [backgroundVideoUrl, onVideoBufferStateChange]); }, [backgroundVideoUrl, hasEmbedBackground, onVideoBufferStateChange]);
// Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration) // Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration)
const videoSrc = useMemo(() => { const videoSrc = useMemo(() => {
@ -413,7 +430,17 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
{/* Background image - z-1 keeps it below backdrop blur layer (z-5). {/* Background image - z-1 keeps it below backdrop blur layer (z-5).
Image layer stays visible while video buffers (fallback behavior). Image layer stays visible while video buffers (fallback behavior).
When video is ready, image fades out via opacity transition. */} When video is ready, image fades out via opacity transition. */}
{backgroundImageUrl && ( {backgroundEmbedUrl && (
<iframe
key={`bg_embed_${embedBackgroundSrc}`}
src={embedBackgroundSrc}
title='360 background'
className='absolute inset-0 z-1 h-full w-full border-0'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; xr-spatial-tracking'
/>
)}
{backgroundImageUrl && !hasEmbedBackground && (
<div <div
className='pointer-events-none absolute inset-0 z-1 h-full w-full select-none' className='pointer-events-none absolute inset-0 z-1 h-full w-full select-none'
style={{ style={{
@ -473,7 +500,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
webkit-playsinline is legacy attribute for older iOS versions. webkit-playsinline is legacy attribute for older iOS versions.
preload="metadata" is required for iOS Safari video initialization. preload="metadata" is required for iOS Safari video initialization.
Video fades in when ready (opacity transition from 0 to 1). */} Video fades in when ready (opacity transition from 0 to 1). */}
{activeVideoUrl && ( {activeVideoUrl && !hasEmbedBackground && (
<video <video
ref={videoRef} ref={videoRef}
key={`bg_video_${activeVideoUrl}`} key={`bg_video_${activeVideoUrl}`}

View File

@ -20,6 +20,7 @@ import {
mdiMusicNote, mdiMusicNote,
mdiVideo, mdiVideo,
mdiInformationOutline, mdiInformationOutline,
mdiPanoramaHorizontal,
} from '@mdi/js'; } from '@mdi/js';
import BaseIcon from '../BaseIcon'; import BaseIcon from '../BaseIcon';
import BaseButton from '../BaseButton'; import BaseButton from '../BaseButton';
@ -186,6 +187,13 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
handleMenuAction(() => onSelectMenuItem('background_video')) handleMenuAction(() => onSelectMenuItem('background_video'))
} }
/> />
<MenuActionButton
icon={mdiPanoramaHorizontal}
label='Background 360'
onClick={() =>
handleMenuAction(() => onSelectMenuItem('background_embed'))
}
/>
<MenuActionButton <MenuActionButton
icon={mdiMusicNote} icon={mdiMusicNote}
label='Background Audio' label='Background Audio'

View File

@ -153,6 +153,7 @@ export function ElementEditorPanel({
pageBackground, pageBackground,
setBackgroundImageUrl, setBackgroundImageUrl,
setBackgroundVideoUrl, setBackgroundVideoUrl,
setBackgroundEmbedUrl,
setBackgroundAudioUrl, setBackgroundAudioUrl,
setBackgroundVideoSettings, setBackgroundVideoSettings,
setBackgroundAudioSettings, setBackgroundAudioSettings,
@ -208,7 +209,10 @@ export function ElementEditorPanel({
options={assetOptions.backgroundImage} options={assetOptions.backgroundImage}
onChange={(value) => { onChange={(value) => {
setBackgroundImageUrl(value); setBackgroundImageUrl(value);
if (value) setBackgroundVideoUrl(''); if (value) {
setBackgroundVideoUrl('');
setBackgroundEmbedUrl('');
}
}} }}
/> />
)} )}
@ -222,7 +226,10 @@ export function ElementEditorPanel({
durationNote={durationNotes.backgroundVideo} durationNote={durationNotes.backgroundVideo}
onChange={(value) => { onChange={(value) => {
setBackgroundVideoUrl(value); setBackgroundVideoUrl(value);
if (value) setBackgroundImageUrl(''); if (value) {
setBackgroundImageUrl('');
setBackgroundEmbedUrl('');
}
}} }}
videoAutoplay={pageBackground.videoSettings.autoplay} videoAutoplay={pageBackground.videoSettings.autoplay}
videoLoop={pageBackground.videoSettings.loop} videoLoop={pageBackground.videoSettings.loop}
@ -233,6 +240,22 @@ export function ElementEditorPanel({
/> />
)} )}
{/* Background 360 Settings */}
{selectedMenuItem === 'background_embed' && (
<BackgroundSettingsEditor
type='embed'
value={pageBackground.embedUrl}
options={assetOptions.embed}
onChange={(value) => {
setBackgroundEmbedUrl(value);
if (value) {
setBackgroundImageUrl('');
setBackgroundVideoUrl('');
}
}}
/>
)}
{/* Background Audio Settings */} {/* Background Audio Settings */}
{selectedMenuItem === 'background_audio' && ( {selectedMenuItem === 'background_audio' && (
<BackgroundSettingsEditor <BackgroundSettingsEditor
@ -522,8 +545,11 @@ export function ElementEditorPanel({
<InfoPanelSettingsSectionCompact <InfoPanelSettingsSectionCompact
element={selectedElement} element={selectedElement}
imageAssetOptions={assetOptions.image} imageAssetOptions={assetOptions.image}
videoAssetOptions={assetOptions.video}
iconAssetOptions={assetOptions.icon} iconAssetOptions={assetOptions.icon}
embedAssetOptions={assetOptions.embed} embedAssetOptions={assetOptions.embed}
pages={pages}
activePageId={activePageId}
onChange={(prop, value) => onChange={(prop, value) =>
updateSelectedElement({ [prop]: value }) updateSelectedElement({ [prop]: value })
} }
@ -1317,15 +1343,15 @@ export function ElementEditorPanel({
</div> </div>
</div> </div>
{/* Images Section Styles */} {/* Media Section Styles */}
<div className='rounded border border-white/10 p-2 space-y-2'> <div className='rounded border border-white/10 p-2 space-y-2'>
<p className='text-[10px] font-semibold text-white/80'> <p className='text-[10px] font-semibold text-white/80'>
Images Section Media Section
</p> </p>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
<div> <div>
<label className='mb-1 block text-[10px] text-white/70'> <label className='mb-1 block text-[10px] text-white/70'>
Preview Height Preview height
</label> </label>
<input <input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
@ -1344,7 +1370,7 @@ export function ElementEditorPanel({
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-white/70'> <label className='mb-1 block text-[10px] text-white/70'>
Thumbnail Size Thumbnail size
</label> </label>
<input <input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-2 py-1 text-xs'

View File

@ -47,6 +47,7 @@ export interface TourPage {
ui_schema_json?: string; ui_schema_json?: string;
background_image_url?: string; background_image_url?: string;
background_video_url?: string; background_video_url?: string;
background_embed_url?: string;
background_audio_url?: string; background_audio_url?: string;
background_loop?: boolean; background_loop?: boolean;
// Background video playback settings // Background video playback settings

View File

@ -90,7 +90,7 @@ const InfoPanelSettingsSection: React.FC<InfoPanelSettingsSectionProps> = ({
detailCaptionFontFamily, detailCaptionFontFamily,
// Section instances (order + per-section settings) // Section instances (order + per-section settings)
infoPanelSections, infoPanelSections,
// Images section settings // Media section settings
infoPanelImagesPreviewHeight, infoPanelImagesPreviewHeight,
infoPanelImagesThumbnailSize, infoPanelImagesThumbnailSize,
// Handlers // Handlers
@ -783,13 +783,13 @@ const InfoPanelSettingsSection: React.FC<InfoPanelSettingsSectionProps> = ({
</div> </div>
</div> </div>
{/* Images Section Layout */} {/* Media Section Layout */}
<div className='rounded-lg border border-gray-200 p-4 dark:border-dark-700'> <div className='rounded-lg border border-gray-200 p-4 dark:border-dark-700'>
<h3 className='mb-4 text-sm font-semibold text-gray-900 dark:text-white'> <h3 className='mb-4 text-sm font-semibold text-gray-900 dark:text-white'>
Images Section Layout Media Section Layout
</h3> </h3>
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'> <p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
Settings for the inline image viewer (preview + thumbnail strip). Use Settings for the inline media viewer (preview + thumbnail strip). Use
the &quot;images&quot; section type in Section Order to enable. the &quot;images&quot; section type in Section Order to enable.
</p> </p>
<div className='grid gap-3 md:grid-cols-2'> <div className='grid gap-3 md:grid-cols-2'>

View File

@ -14,6 +14,9 @@ import type {
InfoPanelSectionType, InfoPanelSectionType,
InfoPanelItemType, InfoPanelItemType,
InfoPanelSectionInstance, InfoPanelSectionInstance,
InfoPanelImageClickAction,
InfoPanelLinkClickAction,
InfoPanelMediaOpenMode,
} from '../../types/constructor'; } from '../../types/constructor';
import { import {
getInfoPanelSections, getInfoPanelSections,
@ -24,8 +27,11 @@ import { addFallbackAssetOption } from '../../lib/constructorHelpers';
interface InfoPanelSettingsSectionCompactProps { interface InfoPanelSettingsSectionCompactProps {
element: CanvasElement; element: CanvasElement;
imageAssetOptions: AssetOption[]; imageAssetOptions: AssetOption[];
videoAssetOptions: AssetOption[];
iconAssetOptions: AssetOption[]; iconAssetOptions: AssetOption[];
embedAssetOptions?: AssetOption[]; embedAssetOptions?: AssetOption[];
pages: Array<{ id: string; slug?: string; name?: string }>;
activePageId: string;
onChange: (prop: string, value: string | number | boolean) => void; onChange: (prop: string, value: string | number | boolean) => void;
// Section operations (new per-instance API) // Section operations (new per-instance API)
onMoveSection?: (sectionId: string, direction: 'up' | 'down') => void; onMoveSection?: (sectionId: string, direction: 'up' | 'down') => void;
@ -85,13 +91,229 @@ const ALL_SECTION_TYPES: InfoPanelSectionType[] = [
'images', 'images',
]; ];
const mediaTypePatch = (
itemType: InfoPanelItemType,
): Partial<InfoPanelImage> => {
if (itemType === 'video') {
return { itemType, imageUrl: '', embedUrl: '', iconUrl: '' };
}
if (itemType === '360') {
return { itemType, imageUrl: '', videoUrl: '' };
}
return { itemType, videoUrl: '', embedUrl: '', iconUrl: '' };
};
const targetPageOptions = (
pages: Array<{ id: string; slug?: string; name?: string }>,
activePageId: string,
) =>
pages
.filter((page) => page.id !== activePageId && page.slug)
.map((page, index) => ({
value: page.slug || '',
label: page.name || `Page ${index + 1}`,
}));
const LinkDestinationFields: React.FC<{
action?: InfoPanelLinkClickAction;
targetPageSlug?: string;
externalUrl?: string;
pages: Array<{ id: string; slug?: string; name?: string }>;
activePageId: string;
onChange: (patch: {
clickAction?: InfoPanelLinkClickAction;
targetPageSlug?: string;
externalUrl?: string;
}) => void;
}> = ({
action,
targetPageSlug,
externalUrl,
pages,
activePageId,
onChange,
}) => (
<div className='space-y-1 rounded border border-white/10 p-2'>
<label className='mb-0.5 block text-[10px] font-medium text-white/70'>
Click action
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={action || ''}
onChange={(e) => {
const nextAction = e.target.value as InfoPanelLinkClickAction | '';
onChange({
clickAction: nextAction || undefined,
targetPageSlug:
nextAction === 'target_page' ? targetPageSlug || '' : undefined,
externalUrl:
nextAction === 'external_url' ? externalUrl || '' : undefined,
});
}}
>
<option value=''>No click action</option>
<option value='target_page'>Target page</option>
<option value='external_url'>External URL</option>
</select>
{action === 'target_page' && (
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={targetPageSlug || ''}
onChange={(e) => onChange({ targetPageSlug: e.target.value })}
>
<option value=''>Select page...</option>
{targetPageOptions(pages, activePageId).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
{action === 'external_url' && (
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={externalUrl || ''}
onChange={(e) => onChange({ externalUrl: e.target.value })}
placeholder='https://example.com'
/>
)}
</div>
);
const MediaOpenModeField: React.FC<{
value?: InfoPanelMediaOpenMode;
onChange: (mode: InfoPanelMediaOpenMode) => void;
}> = ({ value, onChange }) => (
<div className='space-y-1 rounded border border-white/10 p-2'>
<label className='mb-0.5 block text-[10px] font-medium text-white/70'>
Media open mode
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={value || 'panel'}
onChange={(e) =>
onChange(e.target.value === 'fullscreen' ? 'fullscreen' : 'panel')
}
>
<option value='panel'>Side preview panel</option>
<option value='fullscreen'>Fullscreen gallery</option>
</select>
</div>
);
const ImageDestinationFields: React.FC<{
action?: InfoPanelImageClickAction;
targetPageSlug?: string;
externalUrl?: string;
disabled?: boolean;
pages: Array<{ id: string; slug?: string; name?: string }>;
activePageId: string;
onChange: (patch: {
clickAction?: InfoPanelImageClickAction;
targetPageSlug?: string;
externalUrl?: string;
}) => void;
}> = ({
action,
targetPageSlug,
externalUrl,
disabled = false,
pages,
activePageId,
onChange,
}) => (
<div
className={`space-y-1 rounded border border-white/10 p-2 ${
disabled ? 'opacity-50' : ''
}`}
>
<label className='mb-0.5 block text-[10px] font-medium text-white/70'>
Destination override
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs disabled:cursor-not-allowed'
disabled={disabled}
value={
action === 'target_page' || action === 'external_url' ? action : ''
}
onChange={(e) => {
const nextAction = e.target.value as
| Extract<InfoPanelImageClickAction, 'target_page' | 'external_url'>
| '';
onChange({
clickAction: nextAction || undefined,
targetPageSlug:
nextAction === 'target_page' ? targetPageSlug || '' : undefined,
externalUrl:
nextAction === 'external_url' ? externalUrl || '' : undefined,
});
}}
>
<option value=''>Use section media mode</option>
<option value='target_page'>Target page</option>
<option value='external_url'>External URL</option>
</select>
{action === 'target_page' && (
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs disabled:cursor-not-allowed'
disabled={disabled}
value={targetPageSlug || ''}
onChange={(e) => onChange({ targetPageSlug: e.target.value })}
>
<option value=''>Select page...</option>
{targetPageOptions(pages, activePageId).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
{action === 'external_url' && (
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs disabled:cursor-not-allowed'
disabled={disabled}
value={externalUrl || ''}
onChange={(e) => onChange({ externalUrl: e.target.value })}
placeholder='https://example.com'
/>
)}
{disabled && (
<p className='text-[10px] text-white/50'>
Disabled while this item is used as the screen background.
</p>
)}
</div>
);
const UseAsBackgroundField: React.FC<{
checked?: boolean;
onChange: (checked: boolean) => void;
}> = ({ checked, onChange }) => (
<label className='flex cursor-pointer items-center gap-2 rounded border border-white/10 p-2 text-[11px] text-white/80'>
<input
type='checkbox'
className='h-3 w-3 rounded border-gray-300'
checked={Boolean(checked)}
onChange={(event) => onChange(event.target.checked)}
/>
Use as screen background
</label>
);
const InfoPanelSettingsSectionCompact: React.FC< const InfoPanelSettingsSectionCompact: React.FC<
InfoPanelSettingsSectionCompactProps InfoPanelSettingsSectionCompactProps
> = ({ > = ({
element, element,
imageAssetOptions, imageAssetOptions,
videoAssetOptions,
iconAssetOptions, iconAssetOptions,
embedAssetOptions = [], embedAssetOptions = [],
pages,
activePageId,
onChange, onChange,
onMoveSection, onMoveSection,
onRemoveSection, onRemoveSection,
@ -319,6 +541,14 @@ const InfoPanelSettingsSectionCompact: React.FC<
placeholder='Header text (shown if no image)' placeholder='Header text (shown if no image)'
/> />
</div> </div>
<LinkDestinationFields
action={section.clickAction}
targetPageSlug={section.targetPageSlug}
externalUrl={section.externalUrl}
pages={pages}
activePageId={activePageId}
onChange={(patch) => onUpdateSection?.(section.id, patch)}
/>
<p className='text-[10px] text-white/50'> <p className='text-[10px] text-white/50'>
Styling configured in CSS tab. Styling configured in CSS tab.
</p> </p>
@ -351,6 +581,14 @@ const InfoPanelSettingsSectionCompact: React.FC<
placeholder='Information' placeholder='Information'
/> />
</div> </div>
<LinkDestinationFields
action={section.clickAction}
targetPageSlug={section.targetPageSlug}
externalUrl={section.externalUrl}
pages={pages}
activePageId={activePageId}
onChange={(patch) => onUpdateSection?.(section.id, patch)}
/>
</div> </div>
); );
@ -381,6 +619,14 @@ const InfoPanelSettingsSectionCompact: React.FC<
placeholder='Description text...' placeholder='Description text...'
/> />
</div> </div>
<LinkDestinationFields
action={section.clickAction}
targetPageSlug={section.targetPageSlug}
externalUrl={section.externalUrl}
pages={pages}
activePageId={activePageId}
onChange={(patch) => onUpdateSection?.(section.id, patch)}
/>
</div> </div>
); );
@ -518,6 +764,16 @@ const InfoPanelSettingsSectionCompact: React.FC<
</option> </option>
))} ))}
</select> </select>
<LinkDestinationFields
action={span.clickAction}
targetPageSlug={span.targetPageSlug}
externalUrl={span.externalUrl}
pages={pages}
activePageId={activePageId}
onChange={(patch) =>
onUpdateSpan?.(section.id, span.id, patch)
}
/>
</div> </div>
))} ))}
{sectionSpans.length === 0 && ( {sectionSpans.length === 0 && (
@ -614,6 +870,14 @@ const InfoPanelSettingsSectionCompact: React.FC<
placeholder='8' placeholder='8'
/> />
</div> </div>
<MediaOpenModeField
value={section.mediaOpenMode}
onChange={(mode) =>
onUpdateSection?.(section.id, {
mediaOpenMode: mode,
})
}
/>
{sectionImages.map((image, imgIndex) => ( {sectionImages.map((image, imgIndex) => (
<div <div
key={image.id} key={image.id}
@ -644,12 +908,17 @@ const InfoPanelSettingsSectionCompact: React.FC<
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={image.itemType || 'image'} value={image.itemType || 'image'}
onChange={(e) => onChange={(e) =>
onUpdateImage?.(section.id, image.id, { onUpdateImage?.(
itemType: e.target.value as 'image' | '360', section.id,
}) image.id,
mediaTypePatch(
e.target.value as InfoPanelItemType,
),
)
} }
> >
<option value='image'>Image</option> <option value='image'>Image</option>
<option value='video'>Video</option>
<option value='360'>360° Embed</option> <option value='360'>360° Embed</option>
</select> </select>
</div> </div>
@ -664,6 +933,29 @@ const InfoPanelSettingsSectionCompact: React.FC<
} }
placeholder='https://my.matterport.com/show/?m=...' placeholder='https://my.matterport.com/show/?m=...'
/> />
) : image.itemType === 'video' ? (
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={image.videoUrl || ''}
onChange={(e) =>
onUpdateImage?.(section.id, image.id, {
videoUrl: e.target.value,
imageUrl: '',
embedUrl: '',
})
}
>
<option value=''>Select video...</option>
{addFallbackAssetOption(
videoAssetOptions,
image.videoUrl,
`Current video`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : ( ) : (
<select <select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
@ -696,6 +988,25 @@ const InfoPanelSettingsSectionCompact: React.FC<
} }
placeholder='Image caption...' placeholder='Image caption...'
/> />
<UseAsBackgroundField
checked={image.useAsBackground}
onChange={(checked) =>
onUpdateImage?.(section.id, image.id, {
useAsBackground: checked || undefined,
})
}
/>
<ImageDestinationFields
action={image.clickAction}
targetPageSlug={image.targetPageSlug}
externalUrl={image.externalUrl}
disabled={Boolean(image.useAsBackground)}
pages={pages}
activePageId={activePageId}
onChange={(patch) =>
onUpdateImage?.(section.id, image.id, patch)
}
/>
</div> </div>
))} ))}
{sectionImages.length === 0 && ( {sectionImages.length === 0 && (
@ -793,41 +1104,31 @@ const InfoPanelSettingsSectionCompact: React.FC<
placeholder='8' placeholder='8'
/> />
</div> </div>
<MediaOpenModeField
value={section.mediaOpenMode}
onChange={(mode) =>
onUpdateSection?.(section.id, {
mediaOpenMode: mode,
})
}
/>
{/* Layout settings */} {/* Layout settings */}
<div className='grid grid-cols-2 gap-2'> <div>
<div> <label className='mb-0.5 block text-[10px] font-medium text-white/70'>
<label className='mb-0.5 block text-[10px] font-medium text-white/70'> Thumbnail Size
Preview Height </label>
</label> <input
<input className='w-full rounded border border-gray-300 px-2 py-0.5 text-xs'
className='w-full rounded border border-gray-300 px-2 py-0.5 text-xs' value={element.infoPanelImagesThumbnailSize || ''}
value={element.infoPanelImagesPreviewHeight || ''} onChange={(e) =>
onChange={(e) => onChange(
onChange( 'infoPanelImagesThumbnailSize',
'infoPanelImagesPreviewHeight', e.target.value,
e.target.value, )
) }
} placeholder='80'
placeholder='300 or auto' />
/>
</div>
<div>
<label className='mb-0.5 block text-[10px] font-medium text-white/70'>
Thumbnail Size
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-0.5 text-xs'
value={element.infoPanelImagesThumbnailSize || ''}
onChange={(e) =>
onChange(
'infoPanelImagesThumbnailSize',
e.target.value,
)
}
placeholder='80'
/>
</div>
</div> </div>
{/* Styling settings (uses card styling props) */} {/* Styling settings (uses card styling props) */}
@ -918,11 +1219,12 @@ const InfoPanelSettingsSectionCompact: React.FC<
const newType = e.target const newType = e.target
.value as InfoPanelItemType; .value as InfoPanelItemType;
onUpdateImage?.(section.id, image.id, { onUpdateImage?.(section.id, image.id, {
itemType: newType, ...mediaTypePatch(newType),
}); });
}} }}
> >
<option value='image'>Image</option> <option value='image'>Image</option>
<option value='video'>Video</option>
<option value='360'>360° Trigger</option> <option value='360'>360° Trigger</option>
</select> </select>
</div> </div>
@ -939,6 +1241,9 @@ const InfoPanelSettingsSectionCompact: React.FC<
onChange={(e) => onChange={(e) =>
onUpdateImage?.(section.id, image.id, { onUpdateImage?.(section.id, image.id, {
imageUrl: e.target.value, imageUrl: e.target.value,
videoUrl: '',
embedUrl: '',
iconUrl: '',
}) })
} }
> >
@ -957,6 +1262,37 @@ const InfoPanelSettingsSectionCompact: React.FC<
))} ))}
</select> </select>
</div> </div>
) : currentItemType === 'video' ? (
<div>
<label className='mb-0.5 block text-[10px] font-medium text-white/70'>
Video
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={image.videoUrl || ''}
onChange={(e) =>
onUpdateImage?.(section.id, image.id, {
videoUrl: e.target.value,
imageUrl: '',
embedUrl: '',
})
}
>
<option value=''>Select video...</option>
{addFallbackAssetOption(
videoAssetOptions,
image.videoUrl,
`Current video`,
).map((option) => (
<option
key={option.value}
value={option.value}
>
{option.label}
</option>
))}
</select>
</div>
) : ( ) : (
<> <>
<div> <div>
@ -1036,6 +1372,25 @@ const InfoPanelSettingsSectionCompact: React.FC<
placeholder='Optional caption...' placeholder='Optional caption...'
/> />
</div> </div>
<UseAsBackgroundField
checked={image.useAsBackground}
onChange={(checked) =>
onUpdateImage?.(section.id, image.id, {
useAsBackground: checked || undefined,
})
}
/>
<ImageDestinationFields
action={image.clickAction}
targetPageSlug={image.targetPageSlug}
externalUrl={image.externalUrl}
disabled={Boolean(image.useAsBackground)}
pages={pages}
activePageId={activePageId}
onChange={(patch) =>
onUpdateImage?.(section.id, image.id, patch)
}
/>
</div> </div>
); );
})} })}

View File

@ -331,7 +331,7 @@ export interface InfoPanelSettingsSectionProps {
detailCaptionFontFamily: string; detailCaptionFontFamily: string;
// Section instances (order + per-section settings) // Section instances (order + per-section settings)
infoPanelSections?: InfoPanelSectionInstance[]; infoPanelSections?: InfoPanelSectionInstance[];
// Images section settings // Media section settings
infoPanelImagesPreviewHeight?: string; infoPanelImagesPreviewHeight?: string;
infoPanelImagesThumbnailSize?: string; infoPanelImagesThumbnailSize?: string;
// Handlers // Handlers

View File

@ -40,11 +40,11 @@ interface RuntimeControlsProps {
canvasWidth?: number; canvasWidth?: number;
/** Canvas height in pixels (for positioning relative to canvas) */ /** Canvas height in pixels (for positioning relative to canvas) */
canvasHeight?: number; canvasHeight?: number;
/** Whether to show the sound toggle button (video has sound enabled) */ /** Whether to show the global sound toggle button */
showSoundButton?: boolean; showSoundButton?: boolean;
/** Current muted state of the video */ /** Current muted state of presentation audio */
isMuted?: boolean; isMuted?: boolean;
/** Callback to toggle sound on/off */ /** Callback to toggle all presentation sound on/off */
onSoundToggle?: () => void; onSoundToggle?: () => void;
} }
@ -146,6 +146,14 @@ function ControlButton({
spinning?: boolean; spinning?: boolean;
}) { }) {
const colors = buttonColors[color]; const colors = buttonColors[color];
const stopButtonEvent = (
event:
| React.MouseEvent<HTMLButtonElement>
| React.PointerEvent<HTMLButtonElement>
| React.TouchEvent<HTMLButtonElement>,
) => {
event.stopPropagation();
};
const buttonStyle: CSSProperties = { const buttonStyle: CSSProperties = {
display: 'inline-flex', display: 'inline-flex',
@ -167,7 +175,16 @@ function ControlButton({
<button <button
type='button' type='button'
style={buttonStyle} style={buttonStyle}
onClick={onClick} onClick={(event) => {
event.stopPropagation();
onClick();
}}
onPointerDown={stopButtonEvent}
onPointerDownCapture={stopButtonEvent}
onMouseDown={stopButtonEvent}
onMouseDownCapture={stopButtonEvent}
onTouchEnd={stopButtonEvent}
onTouchEndCapture={stopButtonEvent}
disabled={disabled} disabled={disabled}
title={title} title={title}
onMouseEnter={(e) => { onMouseEnter={(e) => {
@ -190,6 +207,15 @@ function ControlButton({
* Delete button for removing offline data (smaller, subtle styling) * Delete button for removing offline data (smaller, subtle styling)
*/ */
function DeleteButton({ onClick }: { onClick: () => void }) { function DeleteButton({ onClick }: { onClick: () => void }) {
const stopButtonEvent = (
event:
| React.MouseEvent<HTMLButtonElement>
| React.PointerEvent<HTMLButtonElement>
| React.TouchEvent<HTMLButtonElement>,
) => {
event.stopPropagation();
};
const buttonStyle: CSSProperties = { const buttonStyle: CSSProperties = {
display: 'inline-flex', display: 'inline-flex',
justifyContent: 'center', justifyContent: 'center',
@ -207,7 +233,16 @@ function DeleteButton({ onClick }: { onClick: () => void }) {
<button <button
type='button' type='button'
style={buttonStyle} style={buttonStyle}
onClick={onClick} onClick={(event) => {
event.stopPropagation();
onClick();
}}
onPointerDown={stopButtonEvent}
onPointerDownCapture={stopButtonEvent}
onMouseDown={stopButtonEvent}
onMouseDownCapture={stopButtonEvent}
onTouchEnd={stopButtonEvent}
onTouchEndCapture={stopButtonEvent}
title='Remove offline data' title='Remove offline data'
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.color = '#EF4444'; // text-red-500 e.currentTarget.style.color = '#EF4444'; // text-red-500
@ -442,10 +477,11 @@ export default function RuntimeControls({
// Canvas is centered with: left: 50%, top: 50%, transform: translate(-50%, -50%) // Canvas is centered with: left: 50%, top: 50%, transform: translate(-50%, -50%)
// So we offset from viewport edge by (viewport - canvas) / 2 + padding // So we offset from viewport edge by (viewport - canvas) / 2 + padding
const padding = 16; const padding = 16;
const rightSafetyInset = 48;
const rightOffset = const rightOffset =
canvasWidth > 0 && viewport.width > 0 canvasWidth > 0 && viewport.width > 0
? (viewport.width - canvasWidth) / 2 + padding ? (viewport.width - canvasWidth) / 2 + padding + rightSafetyInset
: padding; : padding + rightSafetyInset;
const topOffset = const topOffset =
canvasHeight > 0 && viewport.height > 0 canvasHeight > 0 && viewport.height > 0
? (viewport.height - canvasHeight) / 2 + padding ? (viewport.height - canvasHeight) / 2 + padding
@ -464,8 +500,26 @@ export default function RuntimeControls({
transformOrigin: 'top right', transformOrigin: 'top right',
}; };
const stopControlEvent = (
event:
| React.MouseEvent<HTMLDivElement>
| React.PointerEvent<HTMLDivElement>
| React.TouchEvent<HTMLDivElement>,
) => {
event.stopPropagation();
};
return ( return (
<div style={containerStyle}> <div
style={containerStyle}
onClick={stopControlEvent}
onPointerDown={stopControlEvent}
onPointerDownCapture={stopControlEvent}
onMouseDown={stopControlEvent}
onMouseDownCapture={stopControlEvent}
onTouchEnd={stopControlEvent}
onTouchEndCapture={stopControlEvent}
>
<OfflineControl <OfflineControl
projectId={projectId} projectId={projectId}
projectSlug={projectSlug} projectSlug={projectSlug}

View File

@ -100,6 +100,7 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
left: `${xPercent}%`, left: `${xPercent}%`,
top: `${yPercent}%`, top: `${yPercent}%`,
transform: `translate(-50%, -50%)${rotation ? ` rotate(${rotation}deg)` : ''}`, transform: `translate(-50%, -50%)${rotation ? ` rotate(${rotation}deg)` : ''}`,
pointerEvents: 'auto',
}; };
// Add appear animation to outer div // Add appear animation to outer div

View File

@ -63,7 +63,11 @@ import {
selectByProjectAndEnv as selectProjectTransitionSettings, selectByProjectAndEnv as selectProjectTransitionSettings,
} from '../stores/project_transition_settings/projectTransitionSettingsSlice'; } from '../stores/project_transition_settings/projectTransitionSettingsSlice';
import type { TransitionPhase } from '../types/presentation'; import type { TransitionPhase } from '../types/presentation';
import type { CanvasElement, InfoPanelImage } from '../types/constructor'; import type {
CanvasElement,
GalleryCarouselMediaItem,
InfoPanelImage,
} from '../types/constructor';
import { isInfoPanelElementType } from '../lib/elementDefaults'; import { isInfoPanelElementType } from '../lib/elementDefaults';
import type { ElementTransitionSettings } from '../types/transition'; import type { ElementTransitionSettings } from '../types/transition';
import { import {
@ -189,7 +193,11 @@ export default function RuntimePresentation({
); );
const [activeDetailImage, setActiveDetailImage] = const [activeDetailImage, setActiveDetailImage] =
useState<InfoPanelImage | null>(null); useState<InfoPanelImage | null>(null);
// Track selected image in images section (runtime-only local state) const [activeInfoPanelGallery, setActiveInfoPanelGallery] = useState<{
items: GalleryCarouselMediaItem[];
initialIndex: number;
} | null>(null);
// Track selected image in media section (runtime-only local state)
const [runtimeSelectedImageId, setRuntimeSelectedImageId] = useState< const [runtimeSelectedImageId, setRuntimeSelectedImageId] = useState<
string | null string | null
>(null); >(null);
@ -279,6 +287,7 @@ export default function RuntimePresentation({
const { const {
currentImageUrl: navCurrentBgImageUrl, currentImageUrl: navCurrentBgImageUrl,
currentVideoUrl: navCurrentBgVideoUrl, currentVideoUrl: navCurrentBgVideoUrl,
currentEmbedUrl: navCurrentBgEmbedUrl,
currentAudioUrl: navCurrentBgAudioUrl, currentAudioUrl: navCurrentBgAudioUrl,
previousImageUrl: navPreviousBgImageUrl, previousImageUrl: navPreviousBgImageUrl,
previousVideoUrl: navPreviousBgVideoUrl, previousVideoUrl: navPreviousBgVideoUrl,
@ -295,6 +304,7 @@ export default function RuntimePresentation({
onVideoBufferStateChange, onVideoBufferStateChange,
onTransitionEnded, onTransitionEnded,
navigateToPage: navNavigateToPage, navigateToPage: navNavigateToPage,
setBackgroundDirectly: navSetBackgroundDirectly,
resetToIdle: navResetToIdle, resetToIdle: navResetToIdle,
startTransition, startTransition,
} = navState; } = navState;
@ -437,7 +447,11 @@ export default function RuntimePresentation({
useEffect(() => { useEffect(() => {
if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) { if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) {
// Only initialize when backgrounds are empty (initial load) // Only initialize when backgrounds are empty (initial load)
if (!navCurrentBgImageUrl && !navCurrentBgVideoUrl) { if (
!navCurrentBgImageUrl &&
!navCurrentBgVideoUrl &&
!navCurrentBgEmbedUrl
) {
lastInitializedPageIdRef.current = selectedPage.id; lastInitializedPageIdRef.current = selectedPage.id;
navNavigateToPage(selectedPage); navNavigateToPage(selectedPage);
} }
@ -446,6 +460,7 @@ export default function RuntimePresentation({
selectedPage, selectedPage,
navCurrentBgImageUrl, navCurrentBgImageUrl,
navCurrentBgVideoUrl, navCurrentBgVideoUrl,
navCurrentBgEmbedUrl,
navNavigateToPage, navNavigateToPage,
]); ]);
@ -569,6 +584,107 @@ export default function RuntimePresentation({
], ],
); );
const handleInfoPanelNavigateToPage = useCallback(
(targetPageSlug: string) => {
if (
isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)
) {
return;
}
const targetPage = pages.find((page) => page.slug === targetPageSlug);
if (!targetPage) return;
setActiveInfoPanel(null);
setActiveDetailImage(null);
setActiveInfoPanelGallery(null);
setRuntimeSelectedImageId(null);
navigateToPage(targetPage.id);
},
[navigateToPage, pages, transitionPhase, isBuffering],
);
const handleInfoPanelOpenExternalUrl = useCallback((url: string) => {
const trimmed = url.trim();
if (!trimmed) return;
const href = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
window.open(href, '_blank', 'noopener,noreferrer');
}, []);
const handleInfoPanelUseAsBackground = useCallback(
(item: InfoPanelImage) => {
const mediaType =
item.itemType === 'video'
? 'video'
: item.itemType === '360'
? '360'
: 'image';
if (
(mediaType === 'image' && !item.imageUrl) ||
(mediaType === 'video' && !item.videoUrl) ||
(mediaType === '360' && !item.embedUrl)
) {
return;
}
navSetBackgroundDirectly(
mediaType === 'image' && item.imageUrl
? resolveAssetPlaybackUrl(item.imageUrl)
: '',
mediaType === 'video' && item.videoUrl
? resolveAssetPlaybackUrl(item.videoUrl)
: '',
mediaType === '360' && item.embedUrl
? resolveAssetPlaybackUrl(item.embedUrl)
: '',
navCurrentBgAudioUrl,
);
setActiveDetailImage(null);
setActiveInfoPanelGallery(null);
},
[navCurrentBgAudioUrl, navSetBackgroundDirectly],
);
const handleInfoPanelOpenGallery = useCallback(
(items: InfoPanelImage[], initialIndex: number) => {
const activeItemId = items[initialIndex]?.id;
const galleryItems = items
.map<GalleryCarouselMediaItem | null>((item) => {
const mediaType =
item.itemType === 'video'
? 'video'
: item.itemType === '360'
? '360'
: 'image';
if (mediaType === 'image' && !item.imageUrl) return null;
if (mediaType === 'video' && !item.videoUrl) return null;
if (mediaType === '360' && !item.embedUrl) return null;
return {
id: item.id,
imageUrl: item.imageUrl,
videoUrl: item.videoUrl,
embedUrl: item.embedUrl,
caption: item.caption,
title: item.caption,
mediaType,
};
})
.filter((item): item is GalleryCarouselMediaItem => Boolean(item));
if (galleryItems.length === 0) return;
setActiveDetailImage(null);
setActiveInfoPanelGallery({
items: galleryItems,
initialIndex: Math.max(
0,
galleryItems.findIndex((item) => item.id === activeItemId),
),
});
},
[],
);
// Page loading state from unified navigation state machine // Page loading state from unified navigation state machine
// navShowSpinner: true when phase is 'preparing', 'loading_bg', or 'transition_done' // navShowSpinner: true when phase is 'preparing', 'loading_bg', or 'transition_done'
// navShowElements: true when phase is 'idle' or 'fading_in' // navShowElements: true when phase is 'idle' or 'fading_in'
@ -673,6 +789,7 @@ export default function RuntimePresentation({
// Background URLs come directly from navigation state (already resolved) // Background URLs come directly from navigation state (already resolved)
const backgroundImageUrl = navCurrentBgImageUrl; const backgroundImageUrl = navCurrentBgImageUrl;
const backgroundVideoUrl = navCurrentBgVideoUrl; const backgroundVideoUrl = navCurrentBgVideoUrl;
const backgroundEmbedUrl = navCurrentBgEmbedUrl;
const backgroundAudioUrl = navCurrentBgAudioUrl; const backgroundAudioUrl = navCurrentBgAudioUrl;
// Background video playback settings from selected page // Background video playback settings from selected page
@ -700,13 +817,47 @@ export default function RuntimePresentation({
selectedPage?.background_audio_end_time != null selectedPage?.background_audio_end_time != null
? parseFloat(String(selectedPage.background_audio_end_time)) ? parseFloat(String(selectedPage.background_audio_end_time))
: null; : null;
const hasElementAudio = useMemo(
() =>
pageElements.some((element) => {
if (element.hoverAudioUrl || element.clickAudioUrl) return true;
if (
(element.type === 'audio_player' ||
element.type === 'video_player') &&
element.mediaUrl &&
!element.mediaMuted
) {
return true;
}
if (
element.galleryCards?.some(
(card: GalleryCarouselMediaItem) =>
card.mediaType === 'video' || Boolean(card.videoUrl),
)
) {
return true;
}
if (
element.infoPanelSections?.some((section) =>
section.images?.some(
(item: InfoPanelImage) =>
item.itemType === 'video' || Boolean(item.videoUrl),
),
)
) {
return true;
}
return false;
}),
[pageElements],
);
// Sound control hook for iOS autoplay compatibility // Global sound control starts muted for browser autoplay compatibility.
// Videos start muted (for iOS autoplay), user can unmute via sound button
const soundControl = useVideoSoundControl({ const soundControl = useVideoSoundControl({
pageHasSound: pageVideoMuted === false, // Show button when page allows sound pageHasSound: pageVideoMuted === false,
hasBackgroundVideo: Boolean(backgroundVideoUrl), hasBackgroundVideo: Boolean(backgroundVideoUrl),
videoUrl: backgroundVideoUrl, // Track video changes for page navigation reset hasBackgroundAudio: Boolean(backgroundAudioUrl),
hasElementAudio,
}); });
// Note: useBackgroundVideoPlayback is handled internally by CanvasBackground component // Note: useBackgroundVideoPlayback is handled internally by CanvasBackground component
@ -843,6 +994,7 @@ export default function RuntimePresentation({
<CanvasBackground <CanvasBackground
backgroundImageUrl={backgroundImageUrl} backgroundImageUrl={backgroundImageUrl}
backgroundVideoUrl={backgroundVideoUrl} backgroundVideoUrl={backgroundVideoUrl}
backgroundEmbedUrl={backgroundEmbedUrl}
backgroundAudioUrl={backgroundAudioUrl} backgroundAudioUrl={backgroundAudioUrl}
previousBgImageUrl={navPreviousBgImageUrl} previousBgImageUrl={navPreviousBgImageUrl}
previousBgVideoUrl={navPreviousBgVideoUrl} previousBgVideoUrl={navPreviousBgVideoUrl}
@ -885,7 +1037,7 @@ export default function RuntimePresentation({
{navShowElements && ( {navShowElements && (
<div <div
data-testid='page-elements-wrapper' data-testid='page-elements-wrapper'
className='absolute inset-0 z-[46]' className='pointer-events-none absolute inset-0 z-[46]'
> >
{pageElements.map((element: CanvasElement) => ( {pageElements.map((element: CanvasElement) => (
<RuntimeElement <RuntimeElement
@ -1001,6 +1153,35 @@ export default function RuntimePresentation({
/> />
)} )}
{activeInfoPanelGallery && (
<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}
letterboxStyles={letterboxStyles}
isEditMode={false}
pageTransitionSettings={transitionSettings}
galleryElement={activeInfoPanel || undefined}
/>
)}
{/* Info Panel Overlay */} {/* Info Panel Overlay */}
{activeInfoPanel && ( {activeInfoPanel && (
<> <>
@ -1016,14 +1197,20 @@ export default function RuntimePresentation({
onClose={() => { onClose={() => {
setActiveInfoPanel(null); setActiveInfoPanel(null);
setActiveDetailImage(null); setActiveDetailImage(null);
setActiveInfoPanelGallery(null);
setRuntimeSelectedImageId(null); setRuntimeSelectedImageId(null);
}} }}
resolveUrl={resolveUrlWithBlob} resolveUrl={resolveUrlWithBlob}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
cssVars={cssVars}
onImageClick={(image) => setActiveDetailImage(image)} onImageClick={(image) => setActiveDetailImage(image)}
onOpenGallery={handleInfoPanelOpenGallery}
onUseAsBackground={handleInfoPanelUseAsBackground}
onSelectImage={(imageId) => onSelectImage={(imageId) =>
setRuntimeSelectedImageId(imageId) setRuntimeSelectedImageId(imageId)
} }
onNavigateToPage={handleInfoPanelNavigateToPage}
onOpenExternalUrl={handleInfoPanelOpenExternalUrl}
active360ItemId={ active360ItemId={
activeDetailImage?.itemType === '360' activeDetailImage?.itemType === '360'
? activeDetailImage.id ? activeDetailImage.id
@ -1037,6 +1224,7 @@ export default function RuntimePresentation({
onClose={() => setActiveDetailImage(null)} onClose={() => setActiveDetailImage(null)}
resolveUrl={resolveUrlWithBlob} resolveUrl={resolveUrlWithBlob}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
cssVars={cssVars}
/> />
)} )}
</> </>
@ -1047,19 +1235,21 @@ export default function RuntimePresentation({
{/* Controls: Offline toggle, Fullscreen, and Sound buttons */} {/* Controls: Offline toggle, Fullscreen, and Sound buttons */}
{/* Positioned outside canvas to avoid scaling with canvas transform */} {/* Positioned outside canvas to avoid scaling with canvas transform */}
<RuntimeControls {!activeGalleryCarousel && !activeInfoPanelGallery && (
projectId={project?.id || null} <RuntimeControls
projectSlug={projectSlug} projectId={project?.id || null}
projectName={project?.name} projectSlug={projectSlug}
pages={pages} projectName={project?.name}
isFullscreen={isFullscreen} pages={pages}
toggleFullscreen={toggleFullscreen} isFullscreen={isFullscreen}
canvasWidth={canvasWidth} toggleFullscreen={toggleFullscreen}
canvasHeight={canvasHeight} canvasWidth={canvasWidth}
showSoundButton={soundControl.showSoundButton} canvasHeight={canvasHeight}
isMuted={soundControl.isMuted} showSoundButton={soundControl.showSoundButton}
onSoundToggle={soundControl.toggleSound} isMuted={soundControl.isMuted}
/> onSoundToggle={soundControl.toggleSound}
/>
)}
{/* Toast notifications for offline download status */} {/* Toast notifications for offline download status */}
<ToastContainer <ToastContainer

View File

@ -9,7 +9,11 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import Icon from '@mdi/react'; import Icon from '@mdi/react';
import { mdiChevronLeft, mdiChevronRight, mdiArrowLeft } from '@mdi/js'; import { mdiChevronLeft, mdiChevronRight, mdiArrowLeft } from '@mdi/js';
import type { GalleryCard, CanvasElement } from '../../types/constructor'; import type {
GalleryCard,
GalleryCarouselMediaItem,
CanvasElement,
} from '../../types/constructor';
import type { ResolvedTransitionSettings } from '../../types/transition'; import type { ResolvedTransitionSettings } from '../../types/transition';
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl'; import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
import { import {
@ -17,9 +21,11 @@ import {
extractGallerySlideOverride, extractGallerySlideOverride,
} from '../../lib/resolveSlideTransition'; } from '../../lib/resolveSlideTransition';
import { useSlideTransition } from '../../hooks/useSlideTransition'; import { useSlideTransition } from '../../hooks/useSlideTransition';
import { useGlobalAudioMute } from '../../hooks/useGlobalAudioMute';
import { buildChromeFreeEmbedUrl, isValidEmbedUrl } from '../../lib/embedUrl';
interface GalleryCarouselOverlayProps { interface GalleryCarouselOverlayProps {
cards: GalleryCard[]; cards: Array<GalleryCard | GalleryCarouselMediaItem>;
initialIndex: number; initialIndex: number;
onClose: () => void; onClose: () => void;
resolveUrl?: (url: string | undefined) => string; resolveUrl?: (url: string | undefined) => string;
@ -58,6 +64,36 @@ interface GalleryCarouselOverlayProps {
galleryElement?: CanvasElement; galleryElement?: CanvasElement;
} }
const getMediaType = (
card: GalleryCard | GalleryCarouselMediaItem | undefined,
): 'image' | 'video' | '360' => {
if (!card) return 'image';
if ('mediaType' in card && card.mediaType) return card.mediaType;
if ('videoUrl' in card && card.videoUrl) return 'video';
if ('embedUrl' in card && card.embedUrl) return '360';
return 'image';
};
const getMediaTitle = (
card: GalleryCard | GalleryCarouselMediaItem | undefined,
): string => {
if (!card) return '';
return (
('title' in card && card.title) ||
('caption' in card && card.caption) ||
('description' in card && card.description) ||
''
);
};
const getVideoUrl = (
card: GalleryCard | GalleryCarouselMediaItem | undefined,
): string => (card && 'videoUrl' in card && card.videoUrl ? card.videoUrl : '');
const getEmbedUrl = (
card: GalleryCard | GalleryCarouselMediaItem | undefined,
): string => (card && 'embedUrl' in card && card.embedUrl ? card.embedUrl : '');
const clamp = (value: number, min: number, max: number) => const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max); Math.min(Math.max(value, min), max);
@ -89,6 +125,7 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
galleryElement, galleryElement,
}) => { }) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl; const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const { isMuted } = useGlobalAudioMute();
// Resolve slide transition with cascade // Resolve slide transition with cascade
const slideTransition = resolveSlideTransition( const slideTransition = resolveSlideTransition(
@ -385,12 +422,21 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
}; };
const currentCard = cards[displayIndex]; const currentCard = cards[displayIndex];
const mediaType = getMediaType(currentCard);
const imageUrl = currentCard?.imageUrl ? resolve(currentCard.imageUrl) : ''; const imageUrl = currentCard?.imageUrl ? resolve(currentCard.imageUrl) : '';
const rawVideoUrl = getVideoUrl(currentCard);
const videoUrl = rawVideoUrl ? resolve(rawVideoUrl) : '';
const embedUrl = getEmbedUrl(currentCard);
const embedSrc =
embedUrl && isValidEmbedUrl(embedUrl)
? buildChromeFreeEmbedUrl(embedUrl)
: '';
const mediaTitle = getMediaTitle(currentCard);
return ( return (
<div <div
ref={overlayRef} ref={overlayRef}
className='fixed inset-0 z-50 overflow-hidden bg-black' className='fixed inset-0 z-[120] overflow-hidden bg-black'
onClick={(e) => { onClick={(e) => {
// Only close if clicking the background, not buttons // Only close if clicking the background, not buttons
if (e.target === overlayRef.current && !isEditMode) { if (e.target === overlayRef.current && !isEditMode) {
@ -406,17 +452,44 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
className='overflow-hidden' className='overflow-hidden'
style={letterboxStyles || { position: 'absolute', inset: 0 }} style={letterboxStyles || { position: 'absolute', inset: 0 }}
> >
{/* Fullscreen image */} {/* Fullscreen media */}
{imageUrl && ( {mediaType === 'image' && imageUrl && (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={imageUrl} src={imageUrl}
alt={currentCard?.title || ''} alt={mediaTitle}
className='absolute inset-0 h-full w-full object-contain' className='absolute inset-0 h-full w-full object-contain'
style={{ ...slideTransitionStyle, opacity: slideOpacity }} style={{ ...slideTransitionStyle, opacity: slideOpacity }}
draggable={false} draggable={false}
/> />
)} )}
{mediaType === 'video' && videoUrl && (
<video
key={videoUrl}
src={videoUrl}
className='absolute inset-0 h-full w-full object-contain'
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
controls
autoPlay
muted={isMuted}
playsInline
/>
)}
{mediaType === '360' && embedUrl && isValidEmbedUrl(embedUrl) && (
<iframe
key={embedSrc}
src={embedSrc}
title={mediaTitle || '360 view'}
className='absolute inset-0 h-full w-full border-0'
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; xr-spatial-tracking'
/>
)}
{mediaType === '360' && embedUrl && !isValidEmbedUrl(embedUrl) && (
<div className='absolute inset-0 flex items-center justify-center text-white/70'>
Embed domain is not allowed
</div>
)}
{/* Transition overlay (fades in during fadingOut, fades out during fadingIn) */} {/* Transition overlay (fades in during fadingOut, fades out during fadingIn) */}
{slideTransition.type === 'fade' && ( {slideTransition.type === 'fade' && (
<div <div

View File

@ -1,7 +1,7 @@
/** /**
* ImageDetailPanel Component * ImageDetailPanel Component
* *
* Displays enlarged image or 360/iframe embed. * Displays enlarged image, video, or 360/iframe embed.
* Positioned absolutely within the canvas (not fullscreen). * Positioned absolutely within the canvas (not fullscreen).
* Supports embed URL validation for security. * Supports embed URL validation for security.
*/ */
@ -16,42 +16,8 @@ import React, {
import type { CanvasElement, InfoPanelImage } from '../../types/constructor'; import type { CanvasElement, InfoPanelImage } from '../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl'; import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
import { getFontByKey, getFontStyle } from '../../lib/fonts'; import { getFontByKey, getFontStyle } from '../../lib/fonts';
import { useGlobalAudioMute } from '../../hooks/useGlobalAudioMute';
/** import { buildChromeFreeEmbedUrl, isValidEmbedUrl } from '../../lib/embedUrl';
* Allowed embed domains for security
*/
const ALLOWED_EMBED_DOMAINS = [
'matterport.com',
'my.matterport.com',
'kuula.co',
'roundme.com',
'sketchfab.com',
'youtube.com',
'www.youtube.com',
'vimeo.com',
'player.vimeo.com',
'google.com',
'maps.google.com',
'www.google.com',
'docs.google.com',
'drive.google.com',
'360stories.com',
];
/**
* Validate if the URL is from an allowed embed domain
*/
const isValidEmbedUrl = (url: string): boolean => {
try {
const parsed = new URL(url);
return ALLOWED_EMBED_DOMAINS.some(
(domain) =>
parsed.hostname === domain || parsed.hostname.endsWith('.' + domain),
);
} catch {
return false;
}
};
interface ImageDetailPanelProps { interface ImageDetailPanelProps {
element: CanvasElement; element: CanvasElement;
@ -59,6 +25,8 @@ interface ImageDetailPanelProps {
onClose: () => void; onClose: () => void;
resolveUrl?: (url: string | undefined) => string; resolveUrl?: (url: string | undefined) => string;
letterboxStyles?: React.CSSProperties; letterboxStyles?: React.CSSProperties;
/** CSS custom properties including --cu for canvas units */
cssVars?: React.CSSProperties;
isEditMode?: boolean; isEditMode?: boolean;
onDetailPositionChange?: (xPercent: number, yPercent: number) => void; onDetailPositionChange?: (xPercent: number, yPercent: number) => void;
} }
@ -69,10 +37,12 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
onClose, onClose,
resolveUrl, resolveUrl,
letterboxStyles, letterboxStyles,
cssVars,
isEditMode = false, isEditMode = false,
onDetailPositionChange, onDetailPositionChange,
}) => { }) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl; const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const { isMuted } = useGlobalAudioMute();
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@ -235,6 +205,8 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
const isEmbed = image?.itemType === '360' && image?.embedUrl; const isEmbed = image?.itemType === '360' && image?.embedUrl;
const embedUrl = image?.embedUrl ?? ''; const embedUrl = image?.embedUrl ?? '';
const isValidEmbed = isEmbed && isValidEmbedUrl(embedUrl); const isValidEmbed = isEmbed && isValidEmbedUrl(embedUrl);
const embedSrc = isValidEmbed ? buildChromeFreeEmbedUrl(embedUrl) : '';
const isVideo = image?.itemType === 'video' && image?.videoUrl;
const hasImage = !!image; const hasImage = !!image;
// Convert numeric values to canvas units for responsive scaling // Convert numeric values to canvas units for responsive scaling
@ -387,7 +359,10 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
<div <div
ref={containerRef} ref={containerRef}
className='fixed inset-0 z-[54] overflow-hidden pointer-events-none' className='fixed inset-0 z-[54] overflow-hidden pointer-events-none'
style={letterboxStyles || { position: 'absolute', inset: 0 }} style={{
...cssVars,
...(letterboxStyles || { position: 'absolute', inset: 0 }),
}}
> >
{/* Detail Panel */} {/* Detail Panel */}
<div <div
@ -398,7 +373,7 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
tabIndex={-1} tabIndex={-1}
style={{ style={{
...panelStyle, ...panelStyle,
pointerEvents: 'auto', // Ensure panel receives events pointerEvents: isEditMode ? 'none' : 'auto',
cursor: cursor:
isEditMode && onDetailPositionChange isEditMode && onDetailPositionChange
? isDragging ? isDragging
@ -407,12 +382,24 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
: undefined, : undefined,
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={
isEditMode && onDetailPositionChange ? handleDragStart : undefined
}
> >
{/* Control buttons - only show for regular images (embeds have their own controls) */} {isEditMode && onDetailPositionChange && (
{!isEmbed && image?.imageUrl && ( <div
className='absolute top-0 left-0 right-0 h-8 z-20 cursor-grab active:cursor-grabbing rounded-t-xl'
style={{
borderRadius: `${toCU(detailBorderRadius)} ${toCU(detailBorderRadius)} 0 0`,
pointerEvents: 'auto',
}}
onMouseDown={handleDragStart}
>
<div className='flex items-center justify-center h-full gap-1'>
<div className='w-8 h-1 rounded-full bg-white/30' />
</div>
</div>
)}
{/* Control buttons - embeds have their own controls */}
{!isEmbed && (image?.imageUrl || image?.videoUrl) && (
<> <>
{/* Close button */} {/* Close button */}
<button <button
@ -519,7 +506,7 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
// Embed iframe // Embed iframe
isValidEmbed ? ( isValidEmbed ? (
<iframe <iframe
src={embedUrl} src={embedSrc}
title={image?.caption || 'Embedded content'} title={image?.caption || 'Embedded content'}
className='w-full h-full border-0' className='w-full h-full border-0'
sandbox='allow-scripts allow-same-origin allow-presentation allow-popups' sandbox='allow-scripts allow-same-origin allow-presentation allow-popups'
@ -555,6 +542,22 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
</p> </p>
</div> </div>
) )
) : isVideo && image?.videoUrl ? (
<video
src={resolve(image.videoUrl)}
className='w-full h-full'
style={{
objectFit: 'contain',
opacity: isLoading ? 0 : 1,
transition: 'opacity 200ms ease-out',
}}
controls
autoPlay={!isEditMode}
muted={isMuted}
playsInline
onLoadedData={handleImageLoad}
onError={handleImageLoad}
/>
) : image?.imageUrl ? ( ) : image?.imageUrl ? (
// Regular image // Regular image
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element

View File

@ -17,6 +17,8 @@ import React, {
import type { import type {
CanvasElement, CanvasElement,
InfoPanelImage, InfoPanelImage,
InfoPanelImageClickAction,
InfoPanelLinkClickAction,
InfoPanelSectionInstance, InfoPanelSectionInstance,
} from '../../types/constructor'; } from '../../types/constructor';
import { getInfoPanelSections } from '../../types/constructor'; import { getInfoPanelSections } from '../../types/constructor';
@ -30,8 +32,6 @@ import {
buildInfoPanelCardStyle, buildInfoPanelCardStyle,
buildInfoPanelCardTitleStyle, buildInfoPanelCardTitleStyle,
buildInfoPanelCardGridStyleWithSection, buildInfoPanelCardGridStyleWithSection,
buildInfoPanelWrapperStyle,
buildImagesPreviewStyle,
} from '../../lib/infoPanelSectionStyles'; } from '../../lib/infoPanelSectionStyles';
interface InfoPanelOverlayProps { interface InfoPanelOverlayProps {
@ -43,8 +43,16 @@ interface InfoPanelOverlayProps {
cssVars?: React.CSSProperties; cssVars?: React.CSSProperties;
/** Callback when an image/360 is clicked. Pass null to close the detail panel. */ /** Callback when an image/360 is clicked. Pass null to close the detail panel. */
onImageClick: (image: InfoPanelImage | null) => void; onImageClick: (image: InfoPanelImage | null) => void;
/** Callback when an image is selected in the images section preview */ /** Callback when an image/video/360 group should open in fullscreen gallery mode */
onOpenGallery?: (items: InfoPanelImage[], initialIndex: number) => void;
/** Callback when an image thumbnail is selected */
onSelectImage?: (imageId: string) => void; onSelectImage?: (imageId: string) => void;
/** Callback when an Info Panel item targets an internal page slug */
onNavigateToPage?: (targetPageSlug: string) => void;
/** Callback when an Info Panel item targets an external URL */
onOpenExternalUrl?: (url: string) => void;
/** Callback when a media item should replace the current screen background */
onUseAsBackground?: (image: InfoPanelImage) => 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;
@ -52,6 +60,74 @@ interface InfoPanelOverlayProps {
active360ItemId?: string | null; active360ItemId?: string | null;
} }
const VideoThumbnail: React.FC<{
src: string;
caption?: string;
}> = ({ src, caption }) => {
const [isReady, setIsReady] = useState(false);
return (
<div className='relative h-full w-full overflow-hidden bg-black'>
<video
src={src}
className='h-full w-full object-cover'
muted
playsInline
preload='metadata'
aria-label={caption || 'Video thumbnail'}
style={{
pointerEvents: 'none',
opacity: isReady ? 1 : 0,
transition: 'opacity 160ms ease-out',
}}
onLoadedData={(event) => {
setIsReady(true);
const video = event.currentTarget;
if (video.duration > 0.25 && video.currentTime < 0.1) {
try {
video.currentTime = 0.1;
} catch {
// Some browsers disallow seeking before metadata is fully ready.
}
}
}}
onError={() => setIsReady(false)}
/>
{!isReady && (
<div className='absolute inset-0 flex items-center justify-center text-white/40'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='h-7 w-7'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='m5.25 5.653 8.954 5.722a.75.75 0 0 1 0 1.25L5.25 18.347A.75.75 0 0 1 4.125 17.722V6.278a.75.75 0 0 1 1.125-.625Z'
/>
</svg>
</div>
)}
<div className='absolute inset-0 flex items-center justify-center bg-black/10'>
<span className='flex h-8 w-8 items-center justify-center rounded-full bg-black/55 text-white shadow'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='currentColor'
viewBox='0 0 24 24'
className='ml-0.5 h-4 w-4'
aria-hidden='true'
>
<path d='M8 5v14l11-7z' />
</svg>
</span>
</div>
</div>
);
};
const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
element, element,
onClose, onClose,
@ -59,7 +135,11 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
letterboxStyles, letterboxStyles,
cssVars, cssVars,
onImageClick, onImageClick,
onOpenGallery,
onSelectImage, onSelectImage,
onNavigateToPage,
onOpenExternalUrl,
onUseAsBackground,
isEditMode = false, isEditMode = false,
onPanelPositionChange, onPanelPositionChange,
active360ItemId, active360ItemId,
@ -77,11 +157,6 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
panelY: number; panelY: number;
} | null>(null); } | null>(null);
// Track selected image per section (local UI state)
const [selectedImagePerSection, setSelectedImagePerSection] = useState<
Record<string, string>
>({});
// Section styles computed from element settings // Section styles computed from element settings
const headerStyle = useMemo( const headerStyle = useMemo(
() => buildInfoPanelHeaderStyle(element), () => buildInfoPanelHeaderStyle(element),
@ -98,18 +173,106 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
() => buildInfoPanelCardTitleStyle(element), () => buildInfoPanelCardTitleStyle(element),
[element], [element],
); );
const wrapperStyle = useMemo(
() => buildInfoPanelWrapperStyle(element),
[element],
);
const imagesPreviewStyle = useMemo(
() => buildImagesPreviewStyle(element),
[element],
);
// Get section instances // Get section instances
const sections = useMemo(() => getInfoPanelSections(element), [element]); const sections = useMemo(() => getInfoPanelSections(element), [element]);
const openExternalUrl = useCallback(
(url: string) => {
const trimmed = url.trim();
if (!trimmed) return;
if (onOpenExternalUrl) {
onOpenExternalUrl(trimmed);
return;
}
const href = /^https?:\/\//i.test(trimmed)
? trimmed
: `https://${trimmed}`;
window.open(href, '_blank', 'noopener,noreferrer');
},
[onOpenExternalUrl],
);
const handleLinkDestination = useCallback(
(target: {
clickAction?: InfoPanelLinkClickAction;
targetPageSlug?: string;
externalUrl?: string;
}): boolean => {
if (target.clickAction === 'target_page' && target.targetPageSlug) {
onNavigateToPage?.(target.targetPageSlug);
return true;
}
if (target.clickAction === 'external_url' && target.externalUrl) {
openExternalUrl(target.externalUrl);
return true;
}
return false;
},
[onNavigateToPage, openExternalUrl],
);
const handleImageDestination = useCallback(
(
image: InfoPanelImage,
section: InfoPanelSectionInstance,
items?: InfoPanelImage[],
) => {
if (image.useAsBackground) {
onUseAsBackground?.(image);
return;
}
const clickAction: InfoPanelImageClickAction =
image.clickAction || 'panel';
if (clickAction === 'target_page' && image.targetPageSlug) {
onNavigateToPage?.(image.targetPageSlug);
return;
}
if (clickAction === 'external_url' && image.externalUrl) {
openExternalUrl(image.externalUrl);
return;
}
const sectionMode =
section.mediaOpenMode ||
(clickAction === 'fullscreen' ? 'fullscreen' : 'panel');
if (sectionMode === 'fullscreen' && onOpenGallery && items?.length) {
onOpenGallery(
items,
Math.max(
0,
items.findIndex((item) => item.id === image.id),
),
);
return;
}
onImageClick(image);
},
[
onNavigateToPage,
onImageClick,
onOpenGallery,
onUseAsBackground,
openExternalUrl,
],
);
const getClickableStyle = (
target: {
clickAction?: InfoPanelLinkClickAction;
targetPageSlug?: string;
externalUrl?: string;
},
baseStyle: React.CSSProperties,
): React.CSSProperties => ({
...baseStyle,
cursor: target.clickAction ? 'pointer' : baseStyle.cursor,
});
// Fade in animation // Fade in animation
useEffect(() => { useEffect(() => {
requestAnimationFrame(() => setIsVisible(true)); requestAnimationFrame(() => setIsVisible(true));
@ -334,7 +497,7 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
? 'grabbing' ? 'grabbing'
: 'grab' : 'grab'
: undefined, : undefined,
pointerEvents: 'auto', // Ensure panel receives events pointerEvents: isEditMode ? 'none' : 'auto',
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()} onTouchEnd={(e) => e.stopPropagation()}
@ -345,6 +508,7 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
className='absolute top-0 left-0 right-0 h-8 cursor-grab active:cursor-grabbing rounded-t-xl' className='absolute top-0 left-0 right-0 h-8 cursor-grab active:cursor-grabbing rounded-t-xl'
style={{ style={{
borderRadius: `${toCU(panelBorderRadius)} ${toCU(panelBorderRadius)} 0 0`, borderRadius: `${toCU(panelBorderRadius)} ${toCU(panelBorderRadius)} 0 0`,
pointerEvents: 'auto',
}} }}
onMouseDown={handleDragStart} onMouseDown={handleDragStart}
> >
@ -396,14 +560,30 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
section.headerImageUrl ?? element.infoPanelHeaderImageUrl; section.headerImageUrl ?? element.infoPanelHeaderImageUrl;
const headerText = const headerText =
section.headerText ?? element.infoPanelHeaderText; section.headerText ?? element.infoPanelHeaderText;
const headerClickProps = section.clickAction
? {
role: 'button' as const,
tabIndex: 0,
onClick: () => handleLinkDestination(section),
onKeyDown: (
event: React.KeyboardEvent<HTMLDivElement>,
) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleLinkDestination(section);
}
},
}
: {};
// Image takes priority, otherwise render text // Image takes priority, otherwise render text
if (headerImageUrl) { if (headerImageUrl) {
return ( return (
<div <div
key={section.id} key={section.id}
{...headerClickProps}
style={{ style={{
...headerStyle, ...getClickableStyle(section, headerStyle),
padding: 0, padding: 0,
overflow: 'hidden', overflow: 'hidden',
}} }}
@ -424,7 +604,11 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
} }
if (headerText) { if (headerText) {
return ( return (
<div key={section.id} style={headerStyle}> <div
key={section.id}
{...headerClickProps}
style={getClickableStyle(section, headerStyle)}
>
{headerText} {headerText}
</div> </div>
); );
@ -441,12 +625,24 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
key={section.id} key={section.id}
id='info-panel-title' id='info-panel-title'
style={{ style={{
...titleStyle, ...getClickableStyle(section, titleStyle),
marginTop: marginTop:
isEditMode && onPanelPositionChange isEditMode && onPanelPositionChange
? '16px' ? '16px'
: undefined, : undefined,
}} }}
role={section.clickAction ? 'button' : undefined}
tabIndex={section.clickAction ? 0 : undefined}
onClick={() => handleLinkDestination(section)}
onKeyDown={(event) => {
if (
section.clickAction &&
(event.key === 'Enter' || event.key === ' ')
) {
event.preventDefault();
handleLinkDestination(section);
}
}}
> >
{titleContent} {titleContent}
</h2> </h2>
@ -458,7 +654,22 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
const textContent = section.text ?? panelText; const textContent = section.text ?? panelText;
if (!textContent) return null; if (!textContent) return null;
return ( return (
<p key={section.id} style={textStyle}> <p
key={section.id}
style={getClickableStyle(section, textStyle)}
role={section.clickAction ? 'button' : undefined}
tabIndex={section.clickAction ? 0 : undefined}
onClick={() => handleLinkDestination(section)}
onKeyDown={(event) => {
if (
section.clickAction &&
(event.key === 'Enter' || event.key === ' ')
) {
event.preventDefault();
handleLinkDestination(section);
}
}}
>
{textContent} {textContent}
</p> </p>
); );
@ -478,7 +689,18 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
return ( return (
<div key={section.id} style={spanGridStyle}> <div key={section.id} style={spanGridStyle}>
{sectionSpans.map((span) => ( {sectionSpans.map((span) => (
<div key={span.id} style={spanStyle}> <button
key={span.id}
type='button'
style={{
...spanStyle,
border: 'none',
cursor: span.clickAction ? 'pointer' : 'default',
}}
onClick={() => handleLinkDestination(span)}
disabled={!span.clickAction}
className='focus:outline-none disabled:cursor-default'
>
{span.iconUrl ? ( {span.iconUrl ? (
/* eslint-disable-next-line @next/next/no-img-element */ /* eslint-disable-next-line @next/next/no-img-element */
<img <img
@ -494,7 +716,7 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
) : ( ) : (
span.text span.text
)} )}
</div> </button>
))} ))}
</div> </div>
); );
@ -527,7 +749,13 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
overflow: 'hidden', // Respect borderRadius on children overflow: 'hidden', // Respect borderRadius on children
}} }}
className='focus:outline-none' className='focus:outline-none'
onClick={() => onImageClick(image)} onClick={() =>
handleImageDestination(
image,
section,
sectionImages,
)
}
aria-label={image.caption || 'View image'} aria-label={image.caption || 'View image'}
> >
{image.itemType === '360' ? ( {image.itemType === '360' ? (
@ -549,6 +777,29 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
</svg> </svg>
<span className='text-xs'>360/Embed</span> <span className='text-xs'>360/Embed</span>
</div> </div>
) : image.itemType === 'video' && image.videoUrl ? (
<VideoThumbnail
src={resolve(image.videoUrl)}
caption={image.caption}
/>
) : image.itemType === 'video' ? (
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-8 h-8 mb-1'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='m5.25 5.653 8.954 5.722a.75.75 0 0 1 0 1.25L5.25 18.347A.75.75 0 0 1 4.125 17.722V6.278a.75.75 0 0 1 1.125-.625Z'
/>
</svg>
<span className='text-xs'>Video</span>
</div>
) : image.imageUrl ? ( ) : image.imageUrl ? (
// Regular image thumbnail // Regular image thumbnail
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@ -606,35 +857,8 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
// Use section-level images // Use section-level images
const sectionImages = section.images || []; const sectionImages = section.images || [];
// Filter to get only regular images (not 360°)
const imageItems = sectionImages.filter(
(img) => img.itemType !== '360',
);
// Get 360° items for trigger buttons
const triggerItems = sectionImages.filter(
(img) => img.itemType === '360',
);
// Nothing to show if no items // Nothing to show if no items
if (imageItems.length === 0 && triggerItems.length === 0) if (sectionImages.length === 0) return null;
return null;
// Use per-section selected state, fallback to first image
const selectedId =
selectedImagePerSection[section.id] || imageItems[0]?.id;
const selectedImage = imageItems.find(
(img) => img.id === selectedId,
);
// Handler to update selected image for this section
const handleSelectImage = (imageId: string) => {
setSelectedImagePerSection((prev) => ({
...prev,
[section.id]: imageId,
}));
// Also call the optional external handler
onSelectImage?.(imageId);
};
// Build grid style with per-section settings // Build grid style with per-section settings
const gridStyle = buildInfoPanelCardGridStyleWithSection( const gridStyle = buildInfoPanelCardGridStyleWithSection(
@ -663,140 +887,137 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
gap: gridStyle.gap || 'calc(8 * var(--cu, 1px))', gap: gridStyle.gap || 'calc(8 * var(--cu, 1px))',
}} }}
> >
{/* Large Preview - always visible */}
<div
style={{
...imagesPreviewStyle,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{selectedImage?.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(selectedImage.imageUrl)}
alt={selectedImage.caption || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
}}
draggable={false}
/>
) : (
<div className='text-white/40 text-sm'>
{imageItems.length === 0
? 'No images added'
: 'No image selected'}
</div>
)}
</div>
{/* Thumbnail Grid */} {/* Thumbnail Grid */}
<div style={gridStyle}> <div style={gridStyle}>
{/* Image thumbnails - click to select for preview */} {sectionImages.map((img) => {
{imageItems.map((img) => ( const itemType = img.itemType || 'image';
<button const is360 = itemType === '360';
key={img.id} const isVideo = itemType === 'video';
type='button'
style={{
...thumbnailStyle,
opacity: img.id === selectedId ? 1 : 0.6,
}}
className='transition-opacity hover:opacity-100 focus:outline-none'
onClick={() => handleSelectImage(img.id)}
aria-label={img.caption || 'Select image'}
aria-pressed={img.id === selectedId}
>
{img.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(img.imageUrl)}
alt=''
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
draggable={false}
/>
) : (
<div className='w-full h-full flex items-center justify-center text-white/40'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-6 h-6'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'
/>
</svg>
</div>
)}
</button>
))}
{/* 360° trigger buttons - click to open 360° view */} return (
{triggerItems.map((img) => ( <button
<button key={img.id}
key={img.id} type='button'
type='button' style={thumbnailStyle}
style={thumbnailStyle} className={
className='trigger-360 focus:outline-none' is360
onClick={() => { ? 'trigger-360 focus:outline-none'
// Toggle behavior: if already open, close; otherwise open : isVideo
if (active360ItemId === img.id) { ? 'trigger-video focus:outline-none'
onImageClick(null); : 'transition-opacity hover:opacity-100 focus:outline-none'
} else {
onImageClick(img);
} }
}} onClick={() => {
aria-label={ if (!isVideo && !is360) {
active360ItemId === img.id onSelectImage?.(img.id);
? 'Close 360° view' }
: 'Open 360° view' handleImageDestination(
} img,
aria-pressed={active360ItemId === img.id} section,
> sectionImages,
{img.iconUrl ? ( );
// eslint-disable-next-line @next/next/no-img-element }}
<img aria-label={
src={resolve(img.iconUrl)} is360
alt='360°' ? active360ItemId === img.id
style={{ ? 'Close 360° view'
width: '100%', : 'Open 360° view'
height: '100%', : img.caption ||
objectFit: 'contain', (isVideo ? 'Open video' : 'Select image')
}} }
draggable={false} aria-pressed={
/> is360 ? active360ItemId === img.id : undefined
) : ( }
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'> >
<svg {is360 ? (
xmlns='http://www.w3.org/2000/svg' img.iconUrl ? (
fill='none' // eslint-disable-next-line @next/next/no-img-element
viewBox='0 0 24 24' <img
strokeWidth={1.5} src={resolve(img.iconUrl)}
stroke='currentColor' alt='360°'
className='w-8 h-8 mb-1' style={{
> width: '100%',
<path height: '100%',
strokeLinecap='round' objectFit: 'contain',
strokeLinejoin='round' }}
d='M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-4.247m0 0A8.966 8.966 0 0 1 3 12c0-1.97.633-3.79 1.706-5.27' draggable={false}
/> />
</svg> ) : (
<span className='text-xs'>360°</span> <div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
</div> <svg
)} xmlns='http://www.w3.org/2000/svg'
</button> fill='none'
))} viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-8 h-8 mb-1'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-4.247m0 0A8.966 8.966 0 0 1 3 12c0-1.97.633-3.79 1.706-5.27'
/>
</svg>
<span className='text-xs'>360°</span>
</div>
)
) : isVideo ? (
img.videoUrl ? (
<VideoThumbnail
src={resolve(img.videoUrl)}
caption={img.caption}
/>
) : (
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-8 h-8 mb-1'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='m5.25 5.653 8.954 5.722a.75.75 0 0 1 0 1.25L5.25 18.347A.75.75 0 0 1 4.125 17.722V6.278a.75.75 0 0 1 1.125-.625Z'
/>
</svg>
<span className='text-xs'>Video</span>
</div>
)
) : img.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(img.imageUrl)}
alt=''
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
draggable={false}
/>
) : (
<div className='w-full h-full flex items-center justify-center text-white/40'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-6 h-6'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'
/>
</svg>
</div>
)}
</button>
);
})}
</div> </div>
</div> </div>
); );

View File

@ -10,6 +10,7 @@ import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor'; import type { CanvasElement } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl'; import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
import { backgroundAudioController } from '../../../lib/backgroundAudioController'; import { backgroundAudioController } from '../../../lib/backgroundAudioController';
import { useGlobalAudioMute } from '../../../hooks/useGlobalAudioMute';
interface AudioPlayerElementProps { interface AudioPlayerElementProps {
element: CanvasElement; element: CanvasElement;
@ -26,6 +27,7 @@ const AudioPlayerElement: React.FC<AudioPlayerElementProps> = ({
}) => { }) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl; const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const { isMuted } = useGlobalAudioMute();
// Ducking: pause background audio when this player plays // Ducking: pause background audio when this player plays
useEffect(() => { useEffect(() => {
@ -67,7 +69,7 @@ const AudioPlayerElement: React.FC<AudioPlayerElementProps> = ({
controls controls
autoPlay={Boolean(element.mediaAutoplay)} autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)} loop={Boolean(element.mediaLoop)}
muted={Boolean(element.mediaMuted)} muted={Boolean(element.mediaMuted) || isMuted}
/> />
</div> </div>
); );

View File

@ -11,6 +11,7 @@ import type { CanvasElement } from '../../../types/constructor';
import type { PreloadCacheProvider } from '../../../hooks/video'; import type { PreloadCacheProvider } from '../../../hooks/video';
import { useVideoPlayer } from '../../../hooks/video'; import { useVideoPlayer } from '../../../hooks/video';
import { backgroundAudioController } from '../../../lib/backgroundAudioController'; import { backgroundAudioController } from '../../../lib/backgroundAudioController';
import { useGlobalAudioMute } from '../../../hooks/useGlobalAudioMute';
interface VideoPlayerElementProps { interface VideoPlayerElementProps {
element: CanvasElement; element: CanvasElement;
@ -25,12 +26,15 @@ const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
className, className,
style, style,
}) => { }) => {
const { isMuted } = useGlobalAudioMute();
const effectiveMuted = Boolean(element.mediaMuted) || isMuted;
const { videoRef, resolvedUrl, isBuffering } = useVideoPlayer({ const { videoRef, resolvedUrl, isBuffering } = useVideoPlayer({
sourceUrl: element.mediaUrl, sourceUrl: element.mediaUrl,
preloadCache, preloadCache,
autoplay: Boolean(element.mediaAutoplay), autoplay: Boolean(element.mediaAutoplay),
loop: Boolean(element.mediaLoop), loop: Boolean(element.mediaLoop),
muted: Boolean(element.mediaMuted), muted: effectiveMuted,
trackBuffering: true, trackBuffering: true,
}); });
@ -80,7 +84,7 @@ const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
controls controls
autoPlay={Boolean(element.mediaAutoplay)} autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)} loop={Boolean(element.mediaLoop)}
muted={Boolean(element.mediaMuted)} muted={effectiveMuted}
playsInline playsInline
/> />
</div> </div>

View File

@ -120,6 +120,7 @@ export interface ConstructorContextValue {
// Background convenience setters // Background convenience setters
setBackgroundImageUrl: (url: string) => void; setBackgroundImageUrl: (url: string) => void;
setBackgroundVideoUrl: (url: string) => void; setBackgroundVideoUrl: (url: string) => void;
setBackgroundEmbedUrl: (url: string) => void;
setBackgroundAudioUrl: (url: string) => void; setBackgroundAudioUrl: (url: string) => void;
setBackgroundVideoSettings: ( setBackgroundVideoSettings: (
settings: Partial<PageBackgroundVideoSettings>, settings: Partial<PageBackgroundVideoSettings>,
@ -316,6 +317,7 @@ export function useConstructorBackground() {
updateBackgroundFromPage: ctx.updateBackgroundFromPage, updateBackgroundFromPage: ctx.updateBackgroundFromPage,
setBackgroundImageUrl: ctx.setBackgroundImageUrl, setBackgroundImageUrl: ctx.setBackgroundImageUrl,
setBackgroundVideoUrl: ctx.setBackgroundVideoUrl, setBackgroundVideoUrl: ctx.setBackgroundVideoUrl,
setBackgroundEmbedUrl: ctx.setBackgroundEmbedUrl,
setBackgroundAudioUrl: ctx.setBackgroundAudioUrl, setBackgroundAudioUrl: ctx.setBackgroundAudioUrl,
setBackgroundVideoSettings: ctx.setBackgroundVideoSettings, setBackgroundVideoSettings: ctx.setBackgroundVideoSettings,
setBackgroundAudioSettings: ctx.setBackgroundAudioSettings, setBackgroundAudioSettings: ctx.setBackgroundAudioSettings,
@ -326,6 +328,7 @@ export function useConstructorBackground() {
ctx.updateBackgroundFromPage, ctx.updateBackgroundFromPage,
ctx.setBackgroundImageUrl, ctx.setBackgroundImageUrl,
ctx.setBackgroundVideoUrl, ctx.setBackgroundVideoUrl,
ctx.setBackgroundEmbedUrl,
ctx.setBackgroundAudioUrl, ctx.setBackgroundAudioUrl,
ctx.setBackgroundVideoSettings, ctx.setBackgroundVideoSettings,
ctx.setBackgroundAudioSettings, ctx.setBackgroundAudioSettings,

View File

@ -17,6 +17,7 @@
import { useEffect, useRef, useCallback, useState } from 'react'; import { useEffect, useRef, useCallback, useState } from 'react';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { backgroundAudioController } from '../lib/backgroundAudioController'; import { backgroundAudioController } from '../lib/backgroundAudioController';
import { useGlobalAudioMute } from './useGlobalAudioMute';
/** /**
* Fetch audio file with credentials and return a blob URL. * Fetch audio file with credentials and return a blob URL.
@ -79,10 +80,13 @@ export function useAudioEffects({
}: UseAudioEffectsOptions): UseAudioEffectsResult { }: UseAudioEffectsOptions): UseAudioEffectsResult {
const hoverAudioRef = useRef<HTMLAudioElement | null>(null); const hoverAudioRef = useRef<HTMLAudioElement | null>(null);
const clickAudioRef = useRef<HTMLAudioElement | null>(null); const clickAudioRef = useRef<HTMLAudioElement | null>(null);
const { isMuted } = useGlobalAudioMute();
const hoverBlobUrlRef = useRef<string | null>(null); const hoverBlobUrlRef = useRef<string | null>(null);
const clickBlobUrlRef = useRef<string | null>(null); const clickBlobUrlRef = useRef<string | null>(null);
const wasHoveredRef = useRef(false); const wasHoveredRef = useRef(false);
const wasActiveRef = useRef(false); const wasActiveRef = useRef(false);
const hoverStateInitializedRef = useRef(false);
const activeStateInitializedRef = useRef(false);
// Track which URLs we've already fetched to prevent duplicate fetches // Track which URLs we've already fetched to prevent duplicate fetches
const lastFetchedHoverUrlRef = useRef<string | null>(null); const lastFetchedHoverUrlRef = useRef<string | null>(null);
@ -240,45 +244,61 @@ export function useAudioEffects({
// Play hover audio when hover starts (plays to completion, not interrupted) // Play hover audio when hover starts (plays to completion, not interrupted)
// Sound effects layer over background audio (no ducking) // Sound effects layer over background audio (no ducking)
useEffect(() => { useEffect(() => {
if (!hoverStateInitializedRef.current || isMuted) {
hoverStateInitializedRef.current = true;
wasHoveredRef.current = isHovered;
return;
}
const justEntered = isHovered && !wasHoveredRef.current;
wasHoveredRef.current = isHovered;
if ( if (
isHovered && justEntered &&
!wasHoveredRef.current &&
hoverAudioRef.current && hoverAudioRef.current &&
hoverAudioReady && hoverAudioReady &&
backgroundAudioController.hasInteracted() backgroundAudioController.hasInteracted() &&
!isMuted
) { ) {
const audio = hoverAudioRef.current; const audio = hoverAudioRef.current;
audio.currentTime = 0; audio.currentTime = 0;
audio.play().catch(() => undefined); audio.play().catch(() => undefined);
} }
wasHoveredRef.current = isHovered; }, [isHovered, hoverAudioReady, isMuted]);
}, [isHovered, hoverAudioReady]);
// Play click audio when active state begins // Play click audio when active state begins
// Sound effects layer over background audio (no ducking) // Sound effects layer over background audio (no ducking)
useEffect(() => { useEffect(() => {
if (!activeStateInitializedRef.current || isMuted) {
activeStateInitializedRef.current = true;
wasActiveRef.current = isActive;
return;
}
const justActivated = isActive && !wasActiveRef.current;
wasActiveRef.current = isActive;
if ( if (
isActive && justActivated &&
!wasActiveRef.current &&
clickAudioRef.current && clickAudioRef.current &&
clickAudioReady && clickAudioReady &&
backgroundAudioController.hasInteracted() backgroundAudioController.hasInteracted() &&
!isMuted
) { ) {
const audio = clickAudioRef.current; const audio = clickAudioRef.current;
audio.currentTime = 0; audio.currentTime = 0;
audio.play().catch(() => undefined); audio.play().catch(() => undefined);
} }
wasActiveRef.current = isActive; }, [isActive, clickAudioReady, isMuted]);
}, [isActive, clickAudioReady]);
// Manual click audio trigger // Manual click audio trigger
const playClickAudio = useCallback(() => { const playClickAudio = useCallback(() => {
if (clickAudioRef.current && clickAudioReady) { if (clickAudioRef.current && clickAudioReady && !isMuted) {
clickAudioRef.current.currentTime = 0; clickAudioRef.current.currentTime = 0;
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
clickAudioRef.current.play().catch(() => {}); clickAudioRef.current.play().catch(() => {});
} }
}, [clickAudioReady]); }, [clickAudioReady, isMuted]);
const stopAll = useCallback(() => { const stopAll = useCallback(() => {
if (hoverAudioRef.current) { if (hoverAudioRef.current) {
@ -296,7 +316,15 @@ export function useAudioEffects({
stopAll(); stopAll();
wasHoveredRef.current = false; wasHoveredRef.current = false;
wasActiveRef.current = false; wasActiveRef.current = false;
hoverStateInitializedRef.current = false;
activeStateInitializedRef.current = false;
}, [resetKey, stopAll]); }, [resetKey, stopAll]);
useEffect(() => {
if (isMuted) {
stopAll();
}
}, [isMuted, stopAll]);
return { playClickAudio, stopAll }; return { playClickAudio, stopAll };
} }

View File

@ -13,6 +13,7 @@ import { useEffect, useRef, useCallback, type RefObject } from 'react';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { useAudioEventManager } from './audio/useAudioEventManager'; import { useAudioEventManager } from './audio/useAudioEventManager';
import { backgroundAudioController } from '../lib/backgroundAudioController'; import { backgroundAudioController } from '../lib/backgroundAudioController';
import { useGlobalAudioMute } from './useGlobalAudioMute';
// Session-scoped tracking of audio that has finished playing (when loop=false) // Session-scoped tracking of audio that has finished playing (when loop=false)
// Key: audioUrl, cleared on browser refresh // Key: audioUrl, cleared on browser refresh
@ -66,6 +67,8 @@ export function useBackgroundAudioPlayback({
paused = false, paused = false,
}: UseBackgroundAudioPlaybackOptions): UseBackgroundAudioPlaybackResult { }: UseBackgroundAudioPlaybackOptions): UseBackgroundAudioPlaybackResult {
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const { isMuted } = useGlobalAudioMute();
const effectivePaused = paused || isMuted;
// Use storage path for tracking (stable across blob URL changes) // Use storage path for tracking (stable across blob URL changes)
// Falls back to audioUrl if no storage path provided // Falls back to audioUrl if no storage path provided
@ -87,9 +90,9 @@ export function useBackgroundAudioPlayback({
startTimeRef.current = startTime; startTimeRef.current = startTime;
endTimeRef.current = endTime; endTimeRef.current = endTime;
loopRef.current = loop; loopRef.current = loop;
pausedRef.current = paused; pausedRef.current = effectivePaused;
trackingKeyRef.current = trackingKey; trackingKeyRef.current = trackingKey;
}, [startTime, endTime, loop, paused, trackingKey]); }, [startTime, endTime, loop, effectivePaused, trackingKey]);
// Register audio element with background audio controller for ducking // Register audio element with background audio controller for ducking
useEffect(() => { useEffect(() => {
@ -180,7 +183,7 @@ export function useBackgroundAudioPlayback({
if (!audio || !audioUrl) return; if (!audio || !audioUrl) return;
// External pause takes precedence over autoplay // External pause takes precedence over autoplay
if (paused) { if (effectivePaused) {
audio.pause(); audio.pause();
backgroundAudioController.setWaitingForInteraction(false); backgroundAudioController.setWaitingForInteraction(false);
return; return;
@ -194,7 +197,7 @@ export function useBackgroundAudioPlayback({
audio.pause(); audio.pause();
backgroundAudioController.setWaitingForInteraction(false); backgroundAudioController.setWaitingForInteraction(false);
} }
}, [audioUrl, paused, shouldBlockAutoplay]); }, [audioUrl, effectivePaused, shouldBlockAutoplay]);
// Session-scoped "play once" behavior when loop is disabled // Session-scoped "play once" behavior when loop is disabled
// Audio that has already played stops on revisit // Audio that has already played stops on revisit

View File

@ -526,9 +526,13 @@ export function useConstructorElements({
...(sectionType === 'cards' && { ...(sectionType === 'cards' && {
columns: 2, columns: 2,
gap: '8', gap: '8',
mediaOpenMode: 'panel',
images: [],
}),
...(sectionType === 'images' && {
mediaOpenMode: 'panel',
images: [], images: [],
}), }),
...(sectionType === 'images' && { images: [] }),
}; };
return { infoPanelSections: [...currentSections, newSection] }; return { infoPanelSections: [...currentSections, newSection] };
}); });

View File

@ -24,6 +24,7 @@ interface TourPage {
ui_schema_json?: string; ui_schema_json?: string;
background_image_url?: string; background_image_url?: string;
background_video_url?: string; background_video_url?: string;
background_embed_url?: string;
background_audio_url?: string; background_audio_url?: string;
background_loop?: boolean; background_loop?: boolean;
// Background video playback settings // Background video playback settings
@ -128,6 +129,7 @@ export function useConstructorPageActions({
const { const {
imageUrl: backgroundImageUrl, imageUrl: backgroundImageUrl,
videoUrl: backgroundVideoUrl, videoUrl: backgroundVideoUrl,
embedUrl: backgroundEmbedUrl,
audioUrl: backgroundAudioUrl, audioUrl: backgroundAudioUrl,
videoSettings: { videoSettings: {
autoplay: backgroundVideoAutoplay, autoplay: backgroundVideoAutoplay,
@ -209,6 +211,7 @@ export function useConstructorPageActions({
ui_schema_json: schemaToSave, ui_schema_json: schemaToSave,
background_image_url: backgroundImageUrl, background_image_url: backgroundImageUrl,
background_video_url: backgroundVideoUrl, background_video_url: backgroundVideoUrl,
background_embed_url: backgroundEmbedUrl,
background_audio_url: backgroundAudioUrl, background_audio_url: backgroundAudioUrl,
background_loop: Boolean(backgroundAudioUrl), background_loop: Boolean(backgroundAudioUrl),
background_video_autoplay: backgroundVideoAutoplay, background_video_autoplay: backgroundVideoAutoplay,
@ -335,6 +338,7 @@ export function useConstructorPageActions({
sort_order: maxSortOrder + 1, sort_order: maxSortOrder + 1,
background_image_url: '', background_image_url: '',
background_video_url: '', background_video_url: '',
background_embed_url: '',
background_audio_url: '', background_audio_url: '',
background_loop: false, background_loop: false,
requires_auth: false, requires_auth: false,

View File

@ -0,0 +1,27 @@
import { useCallback, useSyncExternalStore } from 'react';
import { backgroundAudioController } from '../lib/backgroundAudioController';
export function useGlobalAudioMute() {
const subscribe = useCallback(
(listener: () => void) => backgroundAudioController.subscribe(listener),
[],
);
const isMuted = useSyncExternalStore(
subscribe,
() => backgroundAudioController.isMuted(),
() => true,
);
const setMuted = useCallback((muted: boolean) => {
backgroundAudioController.setMuted(muted);
}, []);
const toggleMuted = useCallback(() => {
backgroundAudioController.toggleMuted();
}, []);
return { isMuted, setMuted, toggleMuted };
}
export default useGlobalAudioMute;

View File

@ -7,6 +7,7 @@
* Replaces: * Replaces:
* - backgroundImageUrl, setBackgroundImageUrl * - backgroundImageUrl, setBackgroundImageUrl
* - backgroundVideoUrl, setBackgroundVideoUrl * - backgroundVideoUrl, setBackgroundVideoUrl
* - backgroundEmbedUrl, setBackgroundEmbedUrl
* - backgroundAudioUrl, setBackgroundAudioUrl * - backgroundAudioUrl, setBackgroundAudioUrl
* - backgroundVideoAutoplay, setBackgroundVideoAutoplay * - backgroundVideoAutoplay, setBackgroundVideoAutoplay
* - backgroundVideoLoop, setBackgroundVideoLoop * - backgroundVideoLoop, setBackgroundVideoLoop
@ -29,6 +30,7 @@ import {
interface TourPageData { interface TourPageData {
background_image_url?: string; background_image_url?: string;
background_video_url?: string; background_video_url?: string;
background_embed_url?: string;
background_audio_url?: string; background_audio_url?: string;
background_video_autoplay?: boolean; background_video_autoplay?: boolean;
background_video_loop?: boolean; background_video_loop?: boolean;
@ -59,6 +61,7 @@ export interface UsePageBackgroundResult {
/** Update individual URL */ /** Update individual URL */
setImageUrl: (url: string) => void; setImageUrl: (url: string) => void;
setVideoUrl: (url: string) => void; setVideoUrl: (url: string) => void;
setEmbedUrl: (url: string) => void;
setAudioUrl: (url: string) => void; setAudioUrl: (url: string) => void;
/** Update video settings */ /** Update video settings */
@ -74,6 +77,7 @@ export interface UsePageBackgroundResult {
// These allow gradual migration of components // These allow gradual migration of components
backgroundImageUrl: string; backgroundImageUrl: string;
backgroundVideoUrl: string; backgroundVideoUrl: string;
backgroundEmbedUrl: string;
backgroundAudioUrl: string; backgroundAudioUrl: string;
backgroundVideoAutoplay: boolean; backgroundVideoAutoplay: boolean;
backgroundVideoLoop: boolean; backgroundVideoLoop: boolean;
@ -137,6 +141,13 @@ export function usePageBackground(
})); }));
}, []); }, []);
const setEmbedUrl = useCallback((url: string) => {
setBackground((prev) => ({
...prev,
embedUrl: url,
}));
}, []);
const setAudioUrl = useCallback((url: string) => { const setAudioUrl = useCallback((url: string) => {
setBackground((prev) => ({ setBackground((prev) => ({
...prev, ...prev,
@ -181,6 +192,7 @@ export function usePageBackground(
updateFromPage, updateFromPage,
setImageUrl, setImageUrl,
setVideoUrl, setVideoUrl,
setEmbedUrl,
setAudioUrl, setAudioUrl,
setVideoSettings, setVideoSettings,
setAudioSettings, setAudioSettings,
@ -189,6 +201,7 @@ export function usePageBackground(
// Legacy compatibility: flat values for gradual migration // Legacy compatibility: flat values for gradual migration
backgroundImageUrl: background.imageUrl, backgroundImageUrl: background.imageUrl,
backgroundVideoUrl: background.videoUrl, backgroundVideoUrl: background.videoUrl,
backgroundEmbedUrl: background.embedUrl,
backgroundAudioUrl: background.audioUrl, backgroundAudioUrl: background.audioUrl,
backgroundVideoAutoplay: background.videoSettings.autoplay, backgroundVideoAutoplay: background.videoSettings.autoplay,
backgroundVideoLoop: background.videoSettings.loop, backgroundVideoLoop: background.videoSettings.loop,

View File

@ -60,6 +60,7 @@ export interface NavigablePage {
id: string; id: string;
background_image_url?: string; background_image_url?: string;
background_video_url?: string; background_video_url?: string;
background_embed_url?: string;
background_audio_url?: string; background_audio_url?: string;
} }
@ -81,6 +82,7 @@ interface NavigationState {
// Current page URLs (resolved for display) // Current page URLs (resolved for display)
currentImageUrl: string; currentImageUrl: string;
currentVideoUrl: string; currentVideoUrl: string;
currentEmbedUrl: string;
currentAudioUrl: string; currentAudioUrl: string;
// Previous page URLs (for overlay during transition) // Previous page URLs (for overlay during transition)
@ -124,6 +126,7 @@ type NavigationAction =
payload: { payload: {
imageUrl: string; imageUrl: string;
videoUrl: string; videoUrl: string;
embedUrl: string;
audioUrl: string; audioUrl: string;
}; };
} }
@ -137,6 +140,7 @@ type NavigationAction =
payload: { payload: {
imageUrl: string; imageUrl: string;
videoUrl: string; videoUrl: string;
embedUrl: string;
audioUrl: string; audioUrl: string;
}; };
} }
@ -153,6 +157,7 @@ const initialState: NavigationState = {
phase: 'idle', phase: 'idle',
currentImageUrl: '', currentImageUrl: '',
currentVideoUrl: '', currentVideoUrl: '',
currentEmbedUrl: '',
currentAudioUrl: '', currentAudioUrl: '',
previousImageUrl: '', previousImageUrl: '',
previousVideoUrl: '', previousVideoUrl: '',
@ -205,6 +210,7 @@ function navigationReducer(
...state, ...state,
currentImageUrl: action.payload.imageUrl, currentImageUrl: action.payload.imageUrl,
currentVideoUrl: action.payload.videoUrl, currentVideoUrl: action.payload.videoUrl,
currentEmbedUrl: action.payload.embedUrl,
currentAudioUrl: action.payload.audioUrl, currentAudioUrl: action.payload.audioUrl,
}; };
@ -258,6 +264,7 @@ function navigationReducer(
phase: 'idle', phase: 'idle',
currentImageUrl: action.payload.imageUrl, currentImageUrl: action.payload.imageUrl,
currentVideoUrl: action.payload.videoUrl, currentVideoUrl: action.payload.videoUrl,
currentEmbedUrl: action.payload.embedUrl,
currentAudioUrl: action.payload.audioUrl, currentAudioUrl: action.payload.audioUrl,
previousImageUrl: '', previousImageUrl: '',
previousVideoUrl: '', previousVideoUrl: '',
@ -320,6 +327,7 @@ export interface UsePageNavigationStateResult {
// Current page URLs (for display) // Current page URLs (for display)
currentImageUrl: string; currentImageUrl: string;
currentVideoUrl: string; currentVideoUrl: string;
currentEmbedUrl: string;
currentAudioUrl: string; currentAudioUrl: string;
// Previous page URLs (for overlay) // Previous page URLs (for overlay)
@ -374,6 +382,7 @@ export interface UsePageNavigationStateResult {
setBackgroundDirectly: ( setBackgroundDirectly: (
imageUrl: string, imageUrl: string,
videoUrl: string, videoUrl: string,
embedUrl: string,
audioUrl: string, audioUrl: string,
) => void; ) => void;
@ -662,23 +671,27 @@ export function usePageNavigationState(
}); });
// Resolve URLs (may be async) // Resolve URLs (may be async)
const [imageUrl, videoUrl, audioUrl] = await Promise.all([ const [imageUrl, videoUrl, embedUrl, audioUrl] = await Promise.all([
resolveToDisplayUrl(targetPage.background_image_url), resolveToDisplayUrl(targetPage.background_image_url),
resolveMediaUrl(targetPage.background_video_url), resolveMediaUrl(targetPage.background_video_url),
resolveMediaUrl(targetPage.background_embed_url),
resolveMediaUrl(targetPage.background_audio_url), resolveMediaUrl(targetPage.background_audio_url),
]); ]);
// Update current URLs // Update current URLs
dispatch({ dispatch({
type: 'URLS_RESOLVED', type: 'URLS_RESOLVED',
payload: { imageUrl, videoUrl, audioUrl }, payload: { imageUrl, videoUrl, embedUrl, audioUrl },
}); });
// Notify caller // Notify caller
onSwitched?.(); onSwitched?.();
// For blob URLs, decode image before marking ready // For blob URLs, decode image before marking ready
if (!hasTransition && (imageUrl.startsWith('blob:') || !imageUrl)) { if (
!hasTransition &&
(embedUrl || imageUrl.startsWith('blob:') || !imageUrl)
) {
decodeImage(imageUrl).then(() => { decodeImage(imageUrl).then(() => {
dispatch({ type: 'BACKGROUND_READY' }); dispatch({ type: 'BACKGROUND_READY' });
}); });
@ -705,10 +718,15 @@ export function usePageNavigationState(
}, []); }, []);
const setBackgroundDirectly = useCallback( const setBackgroundDirectly = useCallback(
(imageUrl: string, videoUrl: string, audioUrl: string) => { (
imageUrl: string,
videoUrl: string,
embedUrl: string,
audioUrl: string,
) => {
dispatch({ dispatch({
type: 'SET_BACKGROUND_DIRECTLY', type: 'SET_BACKGROUND_DIRECTLY',
payload: { imageUrl, videoUrl, audioUrl }, payload: { imageUrl, videoUrl, embedUrl, audioUrl },
}); });
}, },
[], [],
@ -834,6 +852,7 @@ export function usePageNavigationState(
// Current page URLs // Current page URLs
currentImageUrl: state.currentImageUrl, currentImageUrl: state.currentImageUrl,
currentVideoUrl: state.currentVideoUrl, currentVideoUrl: state.currentVideoUrl,
currentEmbedUrl: state.currentEmbedUrl,
currentAudioUrl: state.currentAudioUrl, currentAudioUrl: state.currentAudioUrl,
// Previous page URLs // Previous page URLs

View File

@ -1,26 +1,29 @@
/** /**
* useVideoSoundControl Hook * useVideoSoundControl Hook
* *
* Manages video sound state with iOS autoplay compatibility. * Manages global presentation sound state with autoplay compatibility.
* Videos start muted to ensure autoplay works on iOS WebKit browsers, * Presentation audio starts muted so browsers can autoplay visual media,
* then can be unmuted by user interaction. * then can be unmuted by user interaction from RuntimeControls.
* *
* iOS WebKit autoplay policy: * Browser autoplay policy:
* - Videos CAN autoplay if they have `playsinline` AND `muted` attributes * - Muted media can autoplay in most browsers
* - Unmuted videos require user interaction native play button appears * - Unmuted audio/video usually requires user interaction
* - By forcing muted start + custom sound button, we avoid native controls * - A custom sound button provides that interaction and keeps media controls stable
*/ */
import { useState, useCallback, useRef, useEffect } from 'react'; import { useCallback } from 'react';
import { logger } from '../lib/logger'; import { backgroundAudioController } from '../lib/backgroundAudioController';
import { useGlobalAudioMute } from './useGlobalAudioMute';
export interface UseVideoSoundControlOptions { export interface UseVideoSoundControlOptions {
/** Whether page settings allow sound (background_video_muted === false) */ /** Whether page settings allow sound (background_video_muted === false) */
pageHasSound: boolean; pageHasSound: boolean;
/** Whether page has a background video */ /** Whether page has a background video */
hasBackgroundVideo: boolean; hasBackgroundVideo: boolean;
/** Current video URL - used to detect page changes (optional) */ /** Whether page has background/ambient audio */
videoUrl?: string; hasBackgroundAudio?: boolean;
/** Whether page elements have hover/click audio effects or media player sound */
hasElementAudio?: boolean;
} }
export interface UseVideoSoundControlResult { export interface UseVideoSoundControlResult {
@ -35,7 +38,7 @@ export interface UseVideoSoundControlResult {
} }
/** /**
* Hook for managing video sound state with iOS autoplay compatibility. * Hook for managing global presentation sound state with autoplay compatibility.
* *
* @example * @example
* const { isMuted, showSoundButton, toggleSound } = useVideoSoundControl({ * const { isMuted, showSoundButton, toggleSound } = useVideoSoundControl({
@ -50,50 +53,29 @@ export interface UseVideoSoundControlResult {
* onSoundToggle={toggleSound} * onSoundToggle={toggleSound}
* /> * />
* *
* // Pass to CanvasBackground (always muted for autoplay) * // Pass to CanvasBackground and native media elements
* <CanvasBackground videoMuted={isMuted} /> * <CanvasBackground videoMuted={isMuted} />
*/ */
export function useVideoSoundControl({ export function useVideoSoundControl({
pageHasSound, pageHasSound,
hasBackgroundVideo, hasBackgroundVideo,
videoUrl, hasBackgroundAudio = false,
hasElementAudio = false,
}: UseVideoSoundControlOptions): UseVideoSoundControlResult { }: UseVideoSoundControlOptions): UseVideoSoundControlResult {
// Always start muted for iOS autoplay compatibility const { isMuted } = useGlobalAudioMute();
const [isMuted, setIsMuted] = useState(true);
// Track previous video URL to detect page changes
const prevVideoUrl = useRef(videoUrl);
// Reset to muted when video changes (page navigation)
// This ensures iOS autoplay works on every page - new videos always start muted
useEffect(() => {
// Only reset if there's a new video (URL changed and we have a video)
if (prevVideoUrl.current !== videoUrl && videoUrl) {
setIsMuted(true);
logger.debug('[useVideoSoundControl] Video changed, resetting to muted', {
from: prevVideoUrl.current?.slice(-20),
to: videoUrl?.slice(-20),
});
}
prevVideoUrl.current = videoUrl;
}, [videoUrl]);
const toggleSound = useCallback(() => { const toggleSound = useCallback(() => {
setIsMuted((prev) => { backgroundAudioController.toggleMuted();
logger.debug('[useVideoSoundControl] Toggle sound:', {
from: prev,
to: !prev,
});
return !prev;
});
}, []); }, []);
return { return {
isMuted, isMuted,
// Show button only if page allows sound AND has a background video showSoundButton:
showSoundButton: pageHasSound && hasBackgroundVideo, (pageHasSound && hasBackgroundVideo) ||
hasBackgroundAudio ||
hasElementAudio,
toggleSound, toggleSound,
setMuted: setIsMuted, setMuted: (muted) => backgroundAudioController.setMuted(muted),
}; };
} }

View File

@ -19,15 +19,29 @@ class BackgroundAudioController {
private waitingForInteraction = false; private waitingForInteraction = false;
private hasUserInteracted = false; private hasUserInteracted = false;
private muted = true;
private listeners = new Set<() => void>();
private emit(): void {
this.listeners.forEach((listener) => listener());
}
register(audio: HTMLAudioElement | null): void { register(audio: HTMLAudioElement | null): void {
this.audioElement = audio; this.audioElement = audio;
this.waitingForInteraction = false; this.waitingForInteraction = false;
if (audio && this.muted) {
audio.pause();
}
} }
setWaitingForInteraction(waiting: boolean): void { setWaitingForInteraction(waiting: boolean): void {
this.waitingForInteraction = waiting; this.waitingForInteraction = waiting;
if (waiting && this.hasUserInteracted && this.audioElement) { if (
waiting &&
this.hasUserInteracted &&
!this.muted &&
this.audioElement
) {
this.audioElement.play().catch(() => undefined); this.audioElement.play().catch(() => undefined);
this.waitingForInteraction = false; this.waitingForInteraction = false;
} }
@ -37,7 +51,7 @@ class BackgroundAudioController {
if (this.hasUserInteracted) return; if (this.hasUserInteracted) return;
this.hasUserInteracted = true; this.hasUserInteracted = true;
if (this.waitingForInteraction && this.audioElement) { if (this.waitingForInteraction && !this.muted && this.audioElement) {
this.audioElement.play().catch(() => undefined); this.audioElement.play().catch(() => undefined);
this.waitingForInteraction = false; this.waitingForInteraction = false;
} }
@ -61,11 +75,48 @@ class BackgroundAudioController {
if (this.foregroundCount > 0) { if (this.foregroundCount > 0) {
this.foregroundCount--; this.foregroundCount--;
} }
if (this.foregroundCount === 0 && this.audioElement && !this.wasPaused) { if (
this.foregroundCount === 0 &&
this.audioElement &&
!this.wasPaused &&
!this.muted
) {
this.audioElement.play().catch(() => undefined); this.audioElement.play().catch(() => undefined);
} }
} }
setMuted(muted: boolean): void {
if (this.muted === muted) return;
this.muted = muted;
if (muted) {
if (this.audioElement) {
this.audioElement.pause();
}
} else {
this.notifyUserInteraction();
if (this.waitingForInteraction && this.audioElement) {
this.audioElement.play().catch(() => undefined);
this.waitingForInteraction = false;
}
}
this.emit();
}
toggleMuted(): void {
this.setMuted(!this.muted);
}
isMuted(): boolean {
return this.muted;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
reset(): void { reset(): void {
this.foregroundCount = 0; this.foregroundCount = 0;
this.wasPaused = false; this.wasPaused = false;

View File

@ -11,9 +11,16 @@ import type {
GalleryCard, GalleryCard,
GalleryInfoSpan, GalleryInfoSpan,
CarouselSlide, CarouselSlide,
InfoPanelImageClickAction,
InfoPanelImage, InfoPanelImage,
InfoPanelInfoSpan,
InfoPanelLinkClickAction,
InfoPanelMediaOpenMode,
InfoPanelSectionInstance,
InfoPanelSectionType,
NavigationButtonKind, NavigationButtonKind,
} from '../types/constructor'; } from '../types/constructor';
import { DEFAULT_INFO_PANEL_SECTIONS } from '../types/constructor';
import { ELEMENT_STYLE_PROPS } from './elementStyles'; import { ELEMENT_STYLE_PROPS } from './elementStyles';
import { GALLERY_SECTION_STYLE_PROPS } from './gallerySectionStyles'; import { GALLERY_SECTION_STYLE_PROPS } from './gallerySectionStyles';
@ -249,6 +256,18 @@ export const createDefaultElement = (
}; };
} }
// Handle Info Panel with local section IDs from the first creation step.
if (type === 'info_panel') {
return {
...base,
...typeDefaults,
infoPanelSections: normalizeInfoPanelSections(
DEFAULT_INFO_PANEL_SECTIONS,
{ regenerateIds: true },
),
};
}
return { return {
...base, ...base,
...typeDefaults, ...typeDefaults,
@ -294,13 +313,172 @@ export const normalizeCarouselSlide = (
*/ */
export const normalizeInfoPanelImage = ( export const normalizeInfoPanelImage = (
image: Record<string, unknown>, image: Record<string, unknown>,
): InfoPanelImage => ({ ): InfoPanelImage => {
id: String(image?.id || createLocalId()), const clickAction =
imageUrl: image?.imageUrl ? String(image.imageUrl) : undefined, image.clickAction === 'target_page' ||
embedUrl: image?.embedUrl ? String(image.embedUrl) : undefined, image.clickAction === 'external_url' ||
caption: image?.caption ? String(image.caption) : undefined, image.clickAction === 'panel' ||
itemType: (image?.itemType as 'image' | '360') || 'image', image.clickAction === 'fullscreen'
}); ? (image.clickAction as InfoPanelImageClickAction)
: image.clickAction === 'overview'
? 'fullscreen'
: undefined;
return {
id: String(image?.id || createLocalId()),
imageUrl: image?.imageUrl ? String(image.imageUrl) : undefined,
videoUrl: image?.videoUrl ? String(image.videoUrl) : undefined,
embedUrl: image?.embedUrl ? String(image.embedUrl) : undefined,
caption: image?.caption ? String(image.caption) : undefined,
itemType:
image?.itemType === 'video' || image?.itemType === '360'
? image.itemType
: 'image',
iconUrl: image?.iconUrl ? String(image.iconUrl) : undefined,
clickAction,
targetPageSlug: image?.targetPageSlug
? String(image.targetPageSlug)
: undefined,
externalUrl: image?.externalUrl ? String(image.externalUrl) : undefined,
};
};
/**
* Normalize an info panel info span from unknown input
*/
export const normalizeInfoPanelInfoSpan = (
span: Record<string, unknown>,
): InfoPanelInfoSpan => {
const clickAction =
span.clickAction === 'target_page' || span.clickAction === 'external_url'
? (span.clickAction as InfoPanelLinkClickAction)
: undefined;
return {
id: String(span?.id || createLocalId()),
text: String(span?.text ?? ''),
iconUrl: span?.iconUrl ? String(span.iconUrl) : undefined,
clickAction,
targetPageSlug: span?.targetPageSlug
? String(span.targetPageSlug)
: undefined,
externalUrl: span?.externalUrl ? String(span.externalUrl) : undefined,
};
};
const INFO_PANEL_SECTION_TYPES = new Set<InfoPanelSectionType>([
'header',
'title',
'text',
'spans',
'cards',
'images',
]);
const isInfoPanelSectionType = (
value: unknown,
): value is InfoPanelSectionType =>
typeof value === 'string' &&
INFO_PANEL_SECTION_TYPES.has(value as InfoPanelSectionType);
const normalizeInfoPanelColumns = (value: unknown): number | undefined => {
if (value === undefined || value === null || value === '') return undefined;
const parsed =
typeof value === 'string' ? Number(value.trim()) : Number(value);
if (!Number.isFinite(parsed)) return undefined;
return clamp(Math.round(parsed), 1, 6);
};
/**
* Normalize an info panel section instance from unknown input.
* regenerateIds is used when section templates come from defaults for a new element.
*/
export const normalizeInfoPanelSection = (
section: Record<string, unknown>,
options?: { regenerateIds?: boolean },
): InfoPanelSectionInstance => {
const type = isInfoPanelSectionType(section?.type) ? section.type : 'text';
const normalized: InfoPanelSectionInstance = {
id:
options?.regenerateIds || !section?.id
? createLocalId()
: String(section.id),
type,
};
const columns = normalizeInfoPanelColumns(section.columns);
if (columns !== undefined) {
normalized.columns = columns;
}
if (section.gap !== undefined && section.gap !== null) {
normalized.gap = String(section.gap);
}
if (
section.mediaOpenMode === 'panel' ||
section.mediaOpenMode === 'fullscreen'
) {
normalized.mediaOpenMode = section.mediaOpenMode as InfoPanelMediaOpenMode;
}
if (Array.isArray(section.spans)) {
normalized.spans = section.spans.map((span) =>
normalizeInfoPanelInfoSpan(span as Record<string, unknown>),
);
}
if (Array.isArray(section.images)) {
normalized.images = section.images.map((image) =>
normalizeInfoPanelImage(image as Record<string, unknown>),
);
}
if (section.text !== undefined && section.text !== null) {
normalized.text = String(section.text);
}
if (section.title !== undefined && section.title !== null) {
normalized.title = String(section.title);
}
if (section.headerImageUrl) {
normalized.headerImageUrl = String(section.headerImageUrl);
}
if (section.headerText !== undefined && section.headerText !== null) {
normalized.headerText = String(section.headerText);
}
if (
section.clickAction === 'target_page' ||
section.clickAction === 'external_url'
) {
normalized.clickAction = section.clickAction as InfoPanelLinkClickAction;
}
if (section.targetPageSlug) {
normalized.targetPageSlug = String(section.targetPageSlug);
}
if (section.externalUrl) {
normalized.externalUrl = String(section.externalUrl);
}
return normalized;
};
const normalizeInfoPanelSections = (
sections: unknown,
options?: { regenerateIds?: boolean },
): InfoPanelSectionInstance[] => {
if (!Array.isArray(sections)) return [];
return sections.map((section) =>
normalizeInfoPanelSection(section as Record<string, unknown>, options),
);
};
/** /**
* Merge an element with project/global defaults. * Merge an element with project/global defaults.
@ -419,6 +597,27 @@ export const mergeElementWithDefaults = (
); );
} }
// Handle info panel section instances
if (isInfoPanelElementType(merged.type)) {
const elementHasSections = Array.isArray(element.infoPanelSections);
const defaultsHasSections = Array.isArray(defaults.infoPanelSections);
const sections = preferElementValues
? elementHasSections
? element.infoPanelSections
: defaultsHasSections
? defaults.infoPanelSections
: DEFAULT_INFO_PANEL_SECTIONS
: defaultsHasSections
? defaults.infoPanelSections
: elementHasSections
? element.infoPanelSections
: DEFAULT_INFO_PANEL_SECTIONS;
merged.infoPanelSections = normalizeInfoPanelSections(sections, {
regenerateIds: !preferElementValues || !elementHasSections,
});
}
return merged; return merged;
}; };
@ -464,6 +663,12 @@ export const parseElementSettings = (
); );
} }
if (Array.isArray(settings.infoPanelSections)) {
settings.infoPanelSections = normalizeInfoPanelSections(
settings.infoPanelSections,
);
}
return settings as Partial<CanvasElement>; return settings as Partial<CanvasElement>;
}; };
@ -711,6 +916,11 @@ export const buildElementSettings = (
addIfNotEmpty(settings, 'detailBorderRadius', element.detailBorderRadius); addIfNotEmpty(settings, 'detailBorderRadius', element.detailBorderRadius);
addIfNotEmpty(settings, 'detailPadding', element.detailPadding); addIfNotEmpty(settings, 'detailPadding', element.detailPadding);
addIfNotEmpty(settings, 'detailOverlayColor', element.detailOverlayColor); addIfNotEmpty(settings, 'detailOverlayColor', element.detailOverlayColor);
if (Array.isArray(element.infoPanelSections)) {
settings.infoPanelSections = normalizeInfoPanelSections(
element.infoPanelSections,
);
}
} }
return settings; return settings;

View File

@ -0,0 +1,52 @@
/**
* Helpers for trusted third-party embed URLs.
*/
const ALLOWED_EMBED_DOMAINS = [
'matterport.com',
'my.matterport.com',
'kuula.co',
'roundme.com',
'sketchfab.com',
'youtube.com',
'www.youtube.com',
'vimeo.com',
'player.vimeo.com',
'google.com',
'maps.google.com',
'www.google.com',
'docs.google.com',
'drive.google.com',
'360stories.com',
];
export const isValidEmbedUrl = (url: string): boolean => {
try {
const parsed = new URL(url);
return ALLOWED_EMBED_DOMAINS.some(
(domain) =>
parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`),
);
} catch {
return false;
}
};
export const buildChromeFreeEmbedUrl = (url: string): string => {
try {
const parsed = new URL(url);
const hostname = parsed.hostname.replace(/^www\./, '');
if (hostname === 'kuula.co' || hostname.endsWith('.kuula.co')) {
parsed.searchParams.set('logo', '-1');
parsed.searchParams.set('info', '0');
parsed.searchParams.set('fs', '0');
parsed.searchParams.set('vr', '0');
parsed.searchParams.set('thumbs', '-1');
}
return parsed.toString();
} catch {
return url;
}
};

View File

@ -63,6 +63,61 @@ function extractAssetFields(
} }
}); });
const infoPanelSections = Array.isArray(element.infoPanelSections)
? (element.infoPanelSections as Record<string, unknown>[])
: [];
const infoPanelSectionAssets = infoPanelSections
.map((section) => {
const sectionContent: Record<string, unknown> = {};
(nestedUrlFields as readonly string[]).forEach((urlField) => {
if (section[urlField] !== undefined && section[urlField] !== '') {
sectionContent[urlField] = section[urlField];
}
});
if (
section.headerImageUrl !== undefined &&
section.headerImageUrl !== ''
) {
sectionContent.headerImageUrl = section.headerImageUrl;
}
if (Array.isArray(section.spans)) {
const spans = (section.spans as Record<string, unknown>[])
.map((span) => {
const spanContent: Record<string, unknown> = {};
if (span.iconUrl !== undefined && span.iconUrl !== '') {
spanContent.iconUrl = span.iconUrl;
}
return Object.keys(spanContent).length > 0 ? spanContent : null;
})
.filter(Boolean);
if (spans.length > 0) sectionContent.spans = spans;
}
if (Array.isArray(section.images)) {
const images = (section.images as Record<string, unknown>[])
.map((image) => {
const imageContent: Record<string, unknown> = {};
(nestedUrlFields as readonly string[]).forEach((urlField) => {
if (image[urlField] !== undefined && image[urlField] !== '') {
imageContent[urlField] = image[urlField];
}
});
return Object.keys(imageContent).length > 0 ? imageContent : null;
})
.filter(Boolean);
if (images.length > 0) sectionContent.images = images;
}
return Object.keys(sectionContent).length > 0 ? sectionContent : null;
})
.filter(Boolean);
if (infoPanelSectionAssets.length > 0) {
contentObj.infoPanelSections = infoPanelSectionAssets;
}
return contentObj; return contentObj;
} }
@ -82,6 +137,84 @@ function parseUiSchema(
} }
} }
const getString = (value: unknown): string =>
typeof value === 'string' ? value : '';
function collectTargetPageSlugs(element: Record<string, unknown>): string[] {
const slugs = new Set<string>();
const addSlug = (value: unknown) => {
const slug = getString(value);
if (slug) slugs.add(slug);
};
addSlug(element.targetPageSlug);
const sections = Array.isArray(element.infoPanelSections)
? (element.infoPanelSections as Record<string, unknown>[])
: [];
sections.forEach((section) => {
if (section.clickAction === 'target_page') {
addSlug(section.targetPageSlug);
}
if (Array.isArray(section.spans)) {
(section.spans as Record<string, unknown>[]).forEach((span) => {
if (span.clickAction === 'target_page') {
addSlug(span.targetPageSlug);
}
});
}
if (Array.isArray(section.images)) {
(section.images as Record<string, unknown>[]).forEach((image) => {
if (image.clickAction === 'target_page') {
addSlug(image.targetPageSlug);
}
});
}
});
return Array.from(slugs);
}
function addPageLink(
pageLinks: PreloadPageLink[],
page: PageWithSchema,
element: Record<string, unknown>,
targetSlug: string,
slugToIdMap: Map<string, string>,
suffix: string,
): void {
const resolvedTargetPageId = slugToIdMap.get(targetSlug) || '';
if (!resolvedTargetPageId || resolvedTargetPageId === page.id) return;
const hasTransitionVideo =
element.transitionVideoUrl &&
typeof element.transitionVideoUrl === 'string';
const hasReverseVideo =
element.reverseVideoUrl && typeof element.reverseVideoUrl === 'string';
pageLinks.push({
id: `synthetic-${page.id}-${element.id || 'element'}-${suffix}`,
from_pageId: page.id,
to_pageId: resolvedTargetPageId,
is_active: true,
transition:
hasTransitionVideo || hasReverseVideo
? {
id: `transition-${element.id || 'element'}-${suffix}`,
video_url: hasTransitionVideo
? (element.transitionVideoUrl as string)
: undefined,
reverse_video_url: hasReverseVideo
? (element.reverseVideoUrl as string)
: undefined,
}
: undefined,
});
}
/** /**
* Extract page links and preload elements from pages' ui_schema_json. * Extract page links and preload elements from pages' ui_schema_json.
* *
@ -140,50 +273,46 @@ export function extractPageLinksAndElements(
}); });
} }
// Build synthetic page link for navigation elements // Build synthetic page links for navigation elements and Info Panel targets
const targetSlug = const targetSlugs = collectTargetPageSlugs(el);
el.targetPageSlug && typeof el.targetPageSlug === 'string'
? el.targetPageSlug
: '';
const legacyTargetId = const legacyTargetId =
el.targetPageId && typeof el.targetPageId === 'string' el.targetPageId && typeof el.targetPageId === 'string'
? el.targetPageId ? el.targetPageId
: ''; : '';
// Resolve slug to page ID (prefer slug, fall back to legacy ID) targetSlugs.forEach((targetSlug, index) => {
let resolvedTargetPageId = ''; addPageLink(
if (targetSlug) { pageLinks,
resolvedTargetPageId = slugToIdMap.get(targetSlug) || ''; page,
} else if (legacyTargetId) { el,
targetSlug,
slugToIdMap,
`${preloadElements.length}-${index}`,
);
});
if (targetSlugs.length === 0 && legacyTargetId) {
// Legacy: targetPageId might be a slug or an ID // Legacy: targetPageId might be a slug or an ID
resolvedTargetPageId = const legacySlug = slugToIdMap.has(legacyTargetId)
slugToIdMap.get(legacyTargetId) || legacyTargetId; ? legacyTargetId
} : '';
if (legacySlug) {
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) { addPageLink(
const hasTransitionVideo = pageLinks,
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string'; page,
const hasReverseVideo = el,
el.reverseVideoUrl && typeof el.reverseVideoUrl === 'string'; legacySlug,
slugToIdMap,
pageLinks.push({ `${preloadElements.length}-legacy`,
id: `synthetic-${page.id}-${el.id || preloadElements.length}`, );
from_pageId: page.id, } else if (legacyTargetId && legacyTargetId !== page.id) {
to_pageId: resolvedTargetPageId, pageLinks.push({
is_active: true, id: `synthetic-${page.id}-${el.id || preloadElements.length}-legacy`,
transition: from_pageId: page.id,
hasTransitionVideo || hasReverseVideo to_pageId: legacyTargetId,
? { is_active: true,
id: `transition-${el.id || preloadElements.length}`, });
video_url: hasTransitionVideo }
? (el.transitionVideoUrl as string)
: undefined,
reverse_video_url: hasReverseVideo
? (el.reverseVideoUrl as string)
: undefined,
}
: undefined,
});
} }
}); });
}); });
@ -226,50 +355,32 @@ export function extractPageLinksOnly(
: []; : [];
pageElements.forEach((el) => { pageElements.forEach((el) => {
// Build synthetic page link for navigation elements // Build synthetic page links for navigation elements and Info Panel targets
const targetSlug = const targetSlugs = collectTargetPageSlugs(el);
el.targetPageSlug && typeof el.targetPageSlug === 'string'
? el.targetPageSlug
: '';
const legacyTargetId = const legacyTargetId =
el.targetPageId && typeof el.targetPageId === 'string' el.targetPageId && typeof el.targetPageId === 'string'
? el.targetPageId ? el.targetPageId
: ''; : '';
// Resolve slug to page ID (prefer slug, fall back to legacy ID) targetSlugs.forEach((targetSlug, index) => {
let resolvedTargetPageId = ''; addPageLink(pageLinks, page, el, targetSlug, slugToIdMap, `${index}`);
if (targetSlug) { });
resolvedTargetPageId = slugToIdMap.get(targetSlug) || '';
} else if (legacyTargetId) { if (targetSlugs.length === 0 && legacyTargetId) {
// Legacy: targetPageId might be a slug or an ID // Legacy: targetPageId might be a slug or an ID
resolvedTargetPageId = const legacySlug = slugToIdMap.has(legacyTargetId)
slugToIdMap.get(legacyTargetId) || legacyTargetId; ? legacyTargetId
} : '';
if (legacySlug) {
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) { addPageLink(pageLinks, page, el, legacySlug, slugToIdMap, 'legacy');
const hasTransitionVideo = } else if (legacyTargetId && legacyTargetId !== page.id) {
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string'; pageLinks.push({
const hasReverseVideo = id: `synthetic-${page.id}-${el.id || 'legacy'}-legacy`,
el.reverseVideoUrl && typeof el.reverseVideoUrl === 'string'; from_pageId: page.id,
to_pageId: legacyTargetId,
pageLinks.push({ is_active: true,
id: `synthetic-${page.id}-${el.id || Math.random().toString(36).slice(2)}`, });
from_pageId: page.id, }
to_pageId: resolvedTargetPageId,
is_active: true,
transition:
hasTransitionVideo || hasReverseVideo
? {
id: `transition-${el.id || Math.random().toString(36).slice(2)}`,
video_url: hasTransitionVideo
? (el.transitionVideoUrl as string)
: undefined,
reverse_video_url: hasReverseVideo
? (el.reverseVideoUrl as string)
: undefined,
}
: undefined,
});
} }
}); });
}); });

View File

@ -163,6 +163,28 @@ export const extractPageImageUrls = (page: PageWithImages | null): string[] => {
if (url && !imageUrls.includes(url)) imageUrls.push(url); if (url && !imageUrls.includes(url)) imageUrls.push(url);
} }
}); });
if (Array.isArray(item.spans)) {
item.spans.forEach((span: Record<string, unknown>) => {
const value = span.iconUrl;
if (typeof value === 'string' && value) {
const url = resolveAssetPlaybackUrl(value);
if (url && !imageUrls.includes(url)) imageUrls.push(url);
}
});
}
if (Array.isArray(item.images)) {
item.images.forEach((image: Record<string, unknown>) => {
nestedImageFields.forEach((field) => {
const value = image[field];
if (typeof value === 'string' && value) {
const url = resolveAssetPlaybackUrl(value);
if (url && !imageUrls.includes(url)) imageUrls.push(url);
}
});
});
}
}); });
} }
}); });

View File

@ -673,7 +673,7 @@ export function getInfoPanelGridColumns(
} }
/** /**
* Build CSS style object for images section preview area * Build CSS style object for media section preview area
*/ */
export function buildImagesPreviewStyle( export function buildImagesPreviewStyle(
element: Partial<CanvasElement>, element: Partial<CanvasElement>,
@ -710,7 +710,7 @@ export function buildImagesPreviewStyle(
} }
/** /**
* Build CSS style object for images section thumbnail strip container * Build CSS style object for media section thumbnail strip container
*/ */
export function buildImagesThumbnailStripStyle( export function buildImagesThumbnailStripStyle(
element: Partial<CanvasElement>, element: Partial<CanvasElement>,
@ -733,7 +733,7 @@ export function buildImagesThumbnailStripStyle(
} }
/** /**
* Build CSS style object for images section thumbnail strip container with per-section settings. * Build CSS style object for media section thumbnail strip container with per-section settings.
* Uses section instance settings (columns, gap) if available, falls back to element settings. * Uses section instance settings (columns, gap) if available, falls back to element settings.
* When columns is set, uses grid layout; otherwise uses flex layout. * When columns is set, uses grid layout; otherwise uses flex layout.
*/ */
@ -768,7 +768,7 @@ export function buildImagesThumbnailStripStyleWithSection(
} }
/** /**
* Build CSS style object for images section thumbnail buttons * Build CSS style object for media section thumbnail buttons
*/ */
export function buildImagesThumbnailStyle( export function buildImagesThumbnailStyle(
element: Partial<CanvasElement>, element: Partial<CanvasElement>,
@ -872,7 +872,7 @@ export const INFO_PANEL_SECTION_STYLE_PROPS = [
'panelPadding', 'panelPadding',
'panelBorderRadius', 'panelBorderRadius',
'panelBackdropBlur', 'panelBackdropBlur',
// Images section // Media section
'infoPanelSelectedImageId', 'infoPanelSelectedImageId',
'infoPanelImagesPreviewHeight', 'infoPanelImagesPreviewHeight',
'infoPanelImagesThumbnailSize', 'infoPanelImagesThumbnailSize',

View File

@ -76,6 +76,7 @@ import type {
GalleryInfoSpan, GalleryInfoSpan,
CarouselSlide, CarouselSlide,
InfoPanelImage, InfoPanelImage,
GalleryCarouselMediaItem,
} from '../types/constructor'; } from '../types/constructor';
import type { TourPage } from '../types/entities'; import type { TourPage } from '../types/entities';
@ -208,12 +209,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
updateFromPage: updateBackgroundFromPage, updateFromPage: updateBackgroundFromPage,
setImageUrl: setBackgroundImageUrl, setImageUrl: setBackgroundImageUrl,
setVideoUrl: setBackgroundVideoUrl, setVideoUrl: setBackgroundVideoUrl,
setEmbedUrl: setBackgroundEmbedUrl,
setAudioUrl: setBackgroundAudioUrl, setAudioUrl: setBackgroundAudioUrl,
setVideoSettings: setBackgroundVideoSettings, setVideoSettings: setBackgroundVideoSettings,
setAudioSettings: setBackgroundAudioSettings, setAudioSettings: setBackgroundAudioSettings,
// Legacy compatibility values for components that expect flat props // Legacy compatibility values for components that expect flat props
backgroundImageUrl, backgroundImageUrl,
backgroundVideoUrl, backgroundVideoUrl,
backgroundEmbedUrl,
backgroundAudioUrl, backgroundAudioUrl,
backgroundVideoAutoplay, backgroundVideoAutoplay,
backgroundVideoLoop, backgroundVideoLoop,
@ -225,12 +228,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
backgroundAudioEndTime, backgroundAudioEndTime,
} = usePageBackground(); } = usePageBackground();
// Sound control hook for iOS autoplay compatibility // Global sound control starts muted for browser autoplay compatibility.
// Videos start muted (for iOS autoplay), can be controlled via page settings
const soundControl = useVideoSoundControl({ const soundControl = useVideoSoundControl({
pageHasSound: backgroundVideoMuted === false, // Show button when page allows sound pageHasSound: backgroundVideoMuted === false,
hasBackgroundVideo: Boolean(backgroundVideoUrl), hasBackgroundVideo: Boolean(backgroundVideoUrl),
videoUrl: backgroundVideoUrl, // Track video changes for page navigation reset hasBackgroundAudio: Boolean(backgroundAudioUrl),
}); });
// Network-aware transitions: skip video on slow networks, use CSS fade instead // Network-aware transitions: skip video on slow networks, use CSS fade instead
@ -292,6 +294,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
} | null>(null); } | null>(null);
const [activeDetailImage, setActiveDetailImage] = const [activeDetailImage, setActiveDetailImage] =
useState<InfoPanelImage | null>(null); useState<InfoPanelImage | null>(null);
const [activeInfoPanelGallery, setActiveInfoPanelGallery] = useState<{
items: GalleryCarouselMediaItem[];
initialIndex: number;
} | null>(null);
// Current element transition settings (for CSS transitions when no video) // Current element transition settings (for CSS transitions when no video)
const [ const [
currentElementTransitionSettings, currentElementTransitionSettings,
@ -369,15 +375,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
return selectedElement; return selectedElement;
}, [isConstructorEditMode, selectedElement]); }, [isConstructorEditMode, selectedElement]);
// Determine which info panel element to use for overlay rendering // In edit mode the overlay is only a selected-element preview. Do not keep
const infoPanelElementToRender = // runtime-open Info Panel state above the canvas after selection is cleared.
activeInfoPanelElement || editModeInfoPanelElement; const infoPanelElementToRender = isConstructorEditMode
? editModeInfoPanelElement
: activeInfoPanelElement;
const shouldShowInfoPanelOverlays = !!infoPanelElementToRender; const shouldShowInfoPanelOverlays = !!infoPanelElementToRender;
// 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); setActiveInfoPanel(null);
setActiveDetailImage(null); setActiveDetailImage(null);
setActiveInfoPanelGallery(null);
}, [isConstructorEditMode]); }, [isConstructorEditMode]);
// Draggable panels using useDraggable hook // Draggable panels using useDraggable hook
@ -470,6 +479,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
id: p.id, id: p.id,
background_image_url: p.background_image_url, background_image_url: p.background_image_url,
background_video_url: p.background_video_url, background_video_url: p.background_video_url,
background_embed_url: p.background_embed_url,
background_audio_url: p.background_audio_url, background_audio_url: p.background_audio_url,
})), })),
pageLinks, pageLinks,
@ -506,6 +516,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const { const {
currentImageUrl: navCurrentBgImageUrl, currentImageUrl: navCurrentBgImageUrl,
currentVideoUrl: navCurrentBgVideoUrl, currentVideoUrl: navCurrentBgVideoUrl,
currentEmbedUrl: navCurrentBgEmbedUrl,
currentAudioUrl: navCurrentBgAudioUrl, currentAudioUrl: navCurrentBgAudioUrl,
previousImageUrl: navPreviousBgImageUrl, previousImageUrl: navPreviousBgImageUrl,
previousVideoUrl: navPreviousBgVideoUrl, previousVideoUrl: navPreviousBgVideoUrl,
@ -522,6 +533,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onVideoBufferStateChange, onVideoBufferStateChange,
onTransitionEnded, onTransitionEnded,
navigateToPage: navNavigateToPage, navigateToPage: navNavigateToPage,
setBackgroundDirectly: navSetBackgroundDirectly,
resetToIdle: navResetToIdle, resetToIdle: navResetToIdle,
startTransition, startTransition,
} = navState; } = navState;
@ -560,6 +572,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
id: page.id, id: page.id,
background_image_url: page.background_image_url, background_image_url: page.background_image_url,
background_video_url: page.background_video_url, background_video_url: page.background_video_url,
background_embed_url: page.background_embed_url,
background_audio_url: page.background_audio_url, background_audio_url: page.background_audio_url,
} }
: null, : null,
@ -578,6 +591,105 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
[navNavigateToPage, applyPageSelection], [navNavigateToPage, applyPageSelection],
); );
const handleInfoPanelNavigateToPage = useCallback(
(targetPageSlug: string) => {
const targetPage =
pages.find((page) => page.slug === targetPageSlug) || null;
if (!targetPage) return;
setActiveInfoPanel(null);
setActiveDetailImage(null);
setActiveInfoPanelGallery(null);
switchToPage(targetPage).then(() => {
clearSelection();
setSelectedMenuItem('none');
setErrorMessage('');
});
},
[pages, switchToPage, clearSelection],
);
const handleInfoPanelOpenExternalUrl = useCallback((url: string) => {
const trimmed = url.trim();
if (!trimmed) return;
const href = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
window.open(href, '_blank', 'noopener,noreferrer');
}, []);
const handleInfoPanelUseAsBackground = useCallback(
(item: InfoPanelImage) => {
const mediaType =
item.itemType === 'video'
? 'video'
: item.itemType === '360'
? '360'
: 'image';
if (
(mediaType === 'image' && !item.imageUrl) ||
(mediaType === 'video' && !item.videoUrl) ||
(mediaType === '360' && !item.embedUrl)
) {
return;
}
navSetBackgroundDirectly(
mediaType === 'image' && item.imageUrl
? resolveAssetPlaybackUrl(item.imageUrl)
: '',
mediaType === 'video' && item.videoUrl
? resolveAssetPlaybackUrl(item.videoUrl)
: '',
mediaType === '360' && item.embedUrl
? resolveAssetPlaybackUrl(item.embedUrl)
: '',
navCurrentBgAudioUrl,
);
setActiveDetailImage(null);
setActiveInfoPanelGallery(null);
},
[navCurrentBgAudioUrl, navSetBackgroundDirectly],
);
const handleInfoPanelOpenGallery = useCallback(
(items: InfoPanelImage[], initialIndex: number) => {
const activeItemId = items[initialIndex]?.id;
const galleryItems = items
.map<GalleryCarouselMediaItem | null>((item) => {
const mediaType =
item.itemType === 'video'
? 'video'
: item.itemType === '360'
? '360'
: 'image';
if (mediaType === 'image' && !item.imageUrl) return null;
if (mediaType === 'video' && !item.videoUrl) return null;
if (mediaType === '360' && !item.embedUrl) return null;
return {
id: item.id,
imageUrl: item.imageUrl,
videoUrl: item.videoUrl,
embedUrl: item.embedUrl,
caption: item.caption,
title: item.caption,
mediaType,
};
})
.filter((item): item is GalleryCarouselMediaItem => Boolean(item));
if (galleryItems.length === 0) return;
setActiveDetailImage(null);
setActiveInfoPanelGallery({
items: galleryItems,
initialIndex: Math.max(
0,
galleryItems.findIndex((item) => item.id === activeItemId),
),
});
},
[],
);
const { const {
isBuffering: isTransitionBuffering, isBuffering: isTransitionBuffering,
isVideoReady: isTransitionVideoReady, isVideoReady: isTransitionVideoReady,
@ -1154,6 +1266,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
id: activePage.id, id: activePage.id,
background_image_url: activePage.background_image_url, background_image_url: activePage.background_image_url,
background_video_url: activePage.background_video_url, background_video_url: activePage.background_video_url,
background_embed_url: activePage.background_embed_url,
background_audio_url: activePage.background_audio_url, background_audio_url: activePage.background_audio_url,
}); });
}, [activePage, navNavigateToPage]); }, [activePage, navNavigateToPage]);
@ -1427,12 +1540,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
); );
// Handler for info panel clicks // Handler for info panel clicks
const handleInfoPanelClick = useCallback((element: CanvasElement) => { const handleInfoPanelClick = useCallback(
if (isInfoPanelElementType(element.type)) { (element: CanvasElement) => {
setActiveInfoPanel({ elementId: element.id }); if (isConstructorEditMode) return;
setActiveDetailImage(null); if (isInfoPanelElementType(element.type)) {
} setActiveInfoPanel({ elementId: element.id });
}, []); setActiveDetailImage(null);
}
},
[isConstructorEditMode],
);
// Handler for gallery carousel button position changes (constructor only) // Handler for gallery carousel button position changes (constructor only)
const handleGalleryCarouselButtonPositionChange = useCallback( const handleGalleryCarouselButtonPositionChange = useCallback(
@ -1557,6 +1674,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
resolveUrlWithBlob, resolveUrlWithBlob,
]); ]);
const backgroundEmbedSrc = useMemo(() => {
if (isConstructorEditMode && backgroundEmbedUrl) {
return resolveUrlWithBlob(backgroundEmbedUrl);
}
return navCurrentBgEmbedUrl;
}, [
isConstructorEditMode,
backgroundEmbedUrl,
navCurrentBgEmbedUrl,
resolveUrlWithBlob,
]);
const backgroundAudioSrc = useMemo(() => { const backgroundAudioSrc = useMemo(() => {
if (isConstructorEditMode && backgroundAudioUrl) { if (isConstructorEditMode && backgroundAudioUrl) {
return resolveUrlWithBlob(backgroundAudioUrl); return resolveUrlWithBlob(backgroundAudioUrl);
@ -1577,9 +1706,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
? 'Background image' ? 'Background image'
: selectedMenuItem === 'background_video' : selectedMenuItem === 'background_video'
? 'Background video' ? 'Background video'
: selectedMenuItem === 'background_audio' : selectedMenuItem === 'background_embed'
? 'Background audio' ? 'Background 360'
: selectedElement?.label || 'Element editor'; : selectedMenuItem === 'background_audio'
? 'Background audio'
: selectedElement?.label || 'Element editor';
// Background image is rendered by CanvasBackground component (same as runtime) // Background image is rendered by CanvasBackground component (same as runtime)
// No CSS background-image needed on canvas div // No CSS background-image needed on canvas div
@ -1621,6 +1752,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Background convenience setters // Background convenience setters
setBackgroundImageUrl, setBackgroundImageUrl,
setBackgroundVideoUrl, setBackgroundVideoUrl,
setBackgroundEmbedUrl,
setBackgroundAudioUrl, setBackgroundAudioUrl,
setBackgroundVideoSettings, setBackgroundVideoSettings,
setBackgroundAudioSettings, setBackgroundAudioSettings,
@ -1696,6 +1828,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
updateBackgroundFromPage, updateBackgroundFromPage,
setBackgroundImageUrl, setBackgroundImageUrl,
setBackgroundVideoUrl, setBackgroundVideoUrl,
setBackgroundEmbedUrl,
setBackgroundAudioUrl, setBackgroundAudioUrl,
setBackgroundVideoSettings, setBackgroundVideoSettings,
elements, elements,
@ -1842,6 +1975,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
<CanvasBackground <CanvasBackground
backgroundImageUrl={backgroundImageSrc} backgroundImageUrl={backgroundImageSrc}
backgroundVideoUrl={backgroundVideoSrc} backgroundVideoUrl={backgroundVideoSrc}
backgroundEmbedUrl={backgroundEmbedSrc}
backgroundAudioUrl={backgroundAudioSrc} backgroundAudioUrl={backgroundAudioSrc}
previousBgImageUrl={navPreviousBgImageUrl} previousBgImageUrl={navPreviousBgImageUrl}
previousBgVideoUrl={navPreviousBgVideoUrl} previousBgVideoUrl={navPreviousBgVideoUrl}
@ -2026,6 +2160,37 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
/> />
)} )}
{activeInfoPanelGallery && (
<GalleryCarouselOverlay
cards={activeInfoPanelGallery.items}
initialIndex={activeInfoPanelGallery.initialIndex}
onClose={() => setActiveInfoPanelGallery(null)}
resolveUrl={resolveUrlWithBlob}
prevIconUrl={infoPanelElementToRender?.galleryCarouselPrevIconUrl}
nextIconUrl={infoPanelElementToRender?.galleryCarouselNextIconUrl}
backIconUrl={infoPanelElementToRender?.galleryCarouselBackIconUrl}
backLabel={
infoPanelElementToRender?.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}
letterboxStyles={letterboxStyles}
isEditMode={false}
pageTransitionSettings={transitionSettings}
galleryElement={infoPanelElementToRender || undefined}
/>
)}
{/* Info Panel Overlay */} {/* Info Panel Overlay */}
{shouldShowInfoPanelOverlays && infoPanelElementToRender && ( {shouldShowInfoPanelOverlays && infoPanelElementToRender && (
<> <>
@ -2034,11 +2199,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onClose={() => { onClose={() => {
setActiveInfoPanel(null); setActiveInfoPanel(null);
setActiveDetailImage(null); setActiveDetailImage(null);
setActiveInfoPanelGallery(null);
}} }}
resolveUrl={resolveUrlWithBlob} resolveUrl={resolveUrlWithBlob}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
cssVars={canvasCssVars} cssVars={canvasCssVars}
onImageClick={(image) => setActiveDetailImage(image)} onImageClick={(image) => setActiveDetailImage(image)}
onOpenGallery={handleInfoPanelOpenGallery}
onUseAsBackground={handleInfoPanelUseAsBackground}
onNavigateToPage={handleInfoPanelNavigateToPage}
onOpenExternalUrl={handleInfoPanelOpenExternalUrl}
onSelectImage={ onSelectImage={
isConstructorEditMode isConstructorEditMode
? (imageId) => { ? (imageId) => {
@ -2073,6 +2243,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onClose={() => setActiveDetailImage(null)} onClose={() => setActiveDetailImage(null)}
resolveUrl={resolveUrlWithBlob} resolveUrl={resolveUrlWithBlob}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
cssVars={canvasCssVars}
isEditMode={isConstructorEditMode} isEditMode={isConstructorEditMode}
onDetailPositionChange={ onDetailPositionChange={
isConstructorEditMode isConstructorEditMode

View File

@ -39,6 +39,7 @@ export type EditorMenuItem =
| 'none' | 'none'
| 'background_image' | 'background_image'
| 'background_video' | 'background_video'
| 'background_embed'
| 'background_audio'; | 'background_audio';
/** /**
@ -68,6 +69,19 @@ export interface GalleryCard {
description: string; description: string;
} }
export type GalleryCarouselMediaType = 'image' | 'video' | '360';
export interface GalleryCarouselMediaItem {
id: string;
imageUrl?: string;
videoUrl?: string;
embedUrl?: string;
title?: string;
caption?: string;
description?: string;
mediaType?: GalleryCarouselMediaType;
}
/** /**
* Gallery info span (brief note badge) * Gallery info span (brief note badge)
*/ */
@ -87,11 +101,19 @@ export interface CarouselSlide {
} }
/** /**
* Info panel item type for images section * Info panel item type for media section
* - 'image': Regular image displayed in inline preview * - 'image': Regular image displayed in inline preview
* - '360': 360° embed trigger that opens ImageDetailPanel * - 'video': Video asset displayed in fullscreen gallery
* - '360': 360° embed trigger displayed in fullscreen gallery
*/ */
export type InfoPanelItemType = 'image' | '360'; export type InfoPanelItemType = 'image' | 'video' | '360';
export type InfoPanelMediaOpenMode = 'panel' | 'fullscreen';
export type InfoPanelImageClickAction =
| 'panel'
| 'fullscreen'
| 'target_page'
| 'external_url';
export type InfoPanelLinkClickAction = 'target_page' | 'external_url';
/** /**
* Info panel image/embed item * Info panel image/embed item
@ -100,12 +122,21 @@ export type InfoPanelItemType = 'image' | '360';
export interface InfoPanelImage { export interface InfoPanelImage {
id: string; id: string;
imageUrl?: string; // Regular image URL (storage key) imageUrl?: string; // Regular image URL (storage key)
videoUrl?: string; // Video URL/storage key
embedUrl?: string; // 360/3D embed URL (direct URL, e.g., https://my.matterport.com/show/?m=...) embedUrl?: string; // 360/3D embed URL (direct URL, e.g., https://my.matterport.com/show/?m=...)
caption?: string; caption?: string;
/** Item type: 'image' for inline preview, '360' for embed trigger */ /** Item type: 'image' for inline preview, '360' for embed trigger */
itemType?: InfoPanelItemType; itemType?: InfoPanelItemType;
/** Custom icon URL for 360° trigger button */ /** Custom icon URL for 360° trigger button */
iconUrl?: string; iconUrl?: string;
/** Click destination for thumbnail/card items. Defaults to 'panel'. */
clickAction?: InfoPanelImageClickAction;
/** Target page slug when clickAction is 'target_page'. */
targetPageSlug?: string;
/** External URL when clickAction is 'external_url'. */
externalUrl?: string;
/** When true, clicking this media item replaces the current screen background. */
useAsBackground?: boolean;
} }
/** /**
@ -115,12 +146,18 @@ export interface InfoPanelInfoSpan {
id: string; id: string;
text: string; text: string;
iconUrl?: string; // Renders icon instead of text when set iconUrl?: string; // Renders icon instead of text when set
/** Click destination for the span. */
clickAction?: InfoPanelLinkClickAction;
/** Target page slug when clickAction is 'target_page'. */
targetPageSlug?: string;
/** External URL when clickAction is 'external_url'. */
externalUrl?: string;
} }
/** /**
* Available Info Panel section types for dynamic ordering * Available Info Panel section types for dynamic ordering
* - 'cards': Legacy card grid (click opens ImageDetailPanel) * - 'cards': Card grid for media items
* - 'images': New inline image viewer with preview + thumbnail strip * - 'images': Media viewer with preview + thumbnail strip
*/ */
export type InfoPanelSectionType = export type InfoPanelSectionType =
| 'header' | 'header'
@ -145,6 +182,8 @@ export interface InfoPanelSectionInstance {
columns?: number; columns?: number;
/** Gap between items */ /** Gap between items */
gap?: string; gap?: string;
/** Media click rendering mode for cards/media sections */
mediaOpenMode?: InfoPanelMediaOpenMode;
// Per-instance data (each section has its OWN items) // Per-instance data (each section has its OWN items)
/** For 'spans' type sections */ /** For 'spans' type sections */
@ -159,6 +198,12 @@ export interface InfoPanelSectionInstance {
headerImageUrl?: string; headerImageUrl?: string;
/** For 'header' type sections - text content */ /** For 'header' type sections - text content */
headerText?: string; headerText?: string;
/** Click destination for header/title/text sections. */
clickAction?: InfoPanelLinkClickAction;
/** Target page slug when clickAction is 'target_page'. */
targetPageSlug?: string;
/** External URL when clickAction is 'external_url'. */
externalUrl?: string;
} }
/** /**
@ -169,7 +214,7 @@ export const DEFAULT_INFO_PANEL_SECTIONS: InfoPanelSectionInstance[] = [
{ id: 'default-title', type: 'title' }, { id: 'default-title', type: 'title' },
{ id: 'default-text', type: 'text' }, { id: 'default-text', type: 'text' },
{ id: 'default-spans', type: 'spans', columns: 3, gap: '8', spans: [] }, { id: 'default-spans', type: 'spans', columns: 3, gap: '8', spans: [] },
{ id: 'default-images', type: 'images', images: [] }, { id: 'default-images', type: 'images', mediaOpenMode: 'panel', images: [] },
]; ];
/** /**
@ -181,7 +226,7 @@ export const INFO_PANEL_SECTION_LABELS: Record<InfoPanelSectionType, string> = {
text: 'Text', text: 'Text',
spans: 'Info Spans', spans: 'Info Spans',
cards: 'Cards', cards: 'Cards',
images: 'Images', images: 'Media',
}; };
/** /**
@ -503,12 +548,12 @@ export interface CanvasElement extends BaseCanvasElement {
/** Section instances with per-section columns, gap, spans, and images */ /** Section instances with per-section columns, gap, spans, and images */
infoPanelSections?: InfoPanelSectionInstance[]; infoPanelSections?: InfoPanelSectionInstance[];
// Images section (inline image viewer) // Media section (inline media viewer)
/** Currently selected image ID in the images section preview */ /** Currently selected image ID in the media section preview */
infoPanelSelectedImageId?: string; infoPanelSelectedImageId?: string;
/** Preview area height for images section (e.g., '300', 'auto') */ /** Preview area height for media section (e.g., '300', 'auto') */
infoPanelImagesPreviewHeight?: string; infoPanelImagesPreviewHeight?: string;
/** Thumbnail size for images section (e.g., '80') */ /** Thumbnail size for media section (e.g., '80') */
infoPanelImagesThumbnailSize?: string; infoPanelImagesThumbnailSize?: string;
// Component 3: Image Detail Panel // Component 3: Image Detail Panel
@ -696,6 +741,7 @@ export interface EditorElementProps {
| 'none' | 'none'
| 'background_image' | 'background_image'
| 'background_video' | 'background_video'
| 'background_embed'
| 'background_audio'; | 'background_audio';
onRemoveElement: () => void; onRemoveElement: () => void;
onUpdateElement: (patch: Partial<CanvasElement>) => void; onUpdateElement: (patch: Partial<CanvasElement>) => void;
@ -815,6 +861,8 @@ export interface PageBackgroundState {
imageUrl: string; imageUrl: string;
/** Storage key or URL for background video */ /** Storage key or URL for background video */
videoUrl: string; videoUrl: string;
/** Storage key or URL for background 360/embed */
embedUrl: string;
/** Storage key or URL for background audio */ /** Storage key or URL for background audio */
audioUrl: string; audioUrl: string;
/** Video playback settings */ /** Video playback settings */
@ -849,6 +897,7 @@ export const DEFAULT_AUDIO_SETTINGS: PageBackgroundAudioSettings = {
export const DEFAULT_PAGE_BACKGROUND: PageBackgroundState = { export const DEFAULT_PAGE_BACKGROUND: PageBackgroundState = {
imageUrl: '', imageUrl: '',
videoUrl: '', videoUrl: '',
embedUrl: '',
audioUrl: '', audioUrl: '',
videoSettings: { ...DEFAULT_VIDEO_SETTINGS }, videoSettings: { ...DEFAULT_VIDEO_SETTINGS },
audioSettings: { ...DEFAULT_AUDIO_SETTINGS }, audioSettings: { ...DEFAULT_AUDIO_SETTINGS },
@ -861,6 +910,7 @@ export function createPageBackgroundFromPage(
page: { page: {
background_image_url?: string; background_image_url?: string;
background_video_url?: string; background_video_url?: string;
background_embed_url?: string;
background_audio_url?: string; background_audio_url?: string;
background_video_autoplay?: boolean; background_video_autoplay?: boolean;
background_video_loop?: boolean; background_video_loop?: boolean;
@ -880,6 +930,7 @@ export function createPageBackgroundFromPage(
return { return {
imageUrl: page.background_image_url || '', imageUrl: page.background_image_url || '',
videoUrl: page.background_video_url || '', videoUrl: page.background_video_url || '',
embedUrl: page.background_embed_url || '',
audioUrl: page.background_audio_url || '', audioUrl: page.background_audio_url || '',
videoSettings: { videoSettings: {
autoplay: page.background_video_autoplay ?? true, autoplay: page.background_video_autoplay ?? true,

View File

@ -115,6 +115,7 @@ export interface TourPage extends BaseEntity {
// Background URL fields (direct storage paths) // Background URL fields (direct storage paths)
background_image_url?: string; background_image_url?: string;
background_video_url?: string; background_video_url?: string;
background_embed_url?: string;
background_audio_url?: string; background_audio_url?: string;
background_loop?: boolean; background_loop?: boolean;
// Background video playback settings // Background video playback settings