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/
*/build/
package-lock.json
CLAUDE.md
.claude/
AGENTS.md
.codex/

View File

@ -25,6 +25,7 @@ class Tour_pagesDBApi extends GenericDBApi {
'slug',
'background_image_url',
'background_video_url',
'background_embed_url',
'background_audio_url',
'ui_schema_json',
];
@ -72,6 +73,7 @@ class Tour_pagesDBApi extends GenericDBApi {
sort_order: data.sort_order || null,
background_image_url: data.background_image_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_autoplay:
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,
},
background_embed_url: {
type: DataTypes.TEXT,
},
background_audio_url: {
type: DataTypes.TEXT,
},

View File

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

View File

@ -448,6 +448,11 @@ class ProjectsService extends BaseProjectsService {
assetPathMap.get(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) {
pageData.background_audio_url =
assetPathMap.get(pageData.background_audio_url) ||

View File

@ -1,7 +1,7 @@
/**
* 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.
* For video backgrounds, includes playback settings (autoplay, loop, muted, start/end time).
*/
@ -15,7 +15,7 @@ import type {
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
interface BackgroundSettingsEditorProps {
type: 'image' | 'video' | 'audio';
type: 'image' | 'video' | 'embed' | 'audio';
value: string;
options: AssetOption[];
durationNote?: string;
@ -38,6 +38,7 @@ interface BackgroundSettingsEditorProps {
const LABELS: Record<string, string> = {
image: 'Background image',
video: 'Background video',
embed: 'Background 360',
audio: 'Background audio',
};

View File

@ -1,7 +1,7 @@
/**
* 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.
* 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 PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
import { baseURLApi } from '../../config';
import { buildChromeFreeEmbedUrl } from '../../lib/embedUrl';
/**
* Schedule a callback to run after the next browser paint.
@ -45,6 +46,7 @@ interface HTMLVideoElementWithRVFC extends HTMLVideoElement {
interface CanvasBackgroundProps {
backgroundImageUrl?: string;
backgroundVideoUrl?: string;
backgroundEmbedUrl?: string;
backgroundAudioUrl?: string;
previousBgImageUrl?: string;
previousBgVideoUrl?: string;
@ -76,6 +78,7 @@ interface CanvasBackgroundProps {
const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
backgroundImageUrl,
backgroundVideoUrl,
backgroundEmbedUrl,
backgroundAudioUrl,
previousBgImageUrl,
previousBgVideoUrl,
@ -96,6 +99,20 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
audioStoragePath,
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.
// 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.
@ -141,7 +158,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
// Track video buffering via canplay/waiting events
useEffect(() => {
const video = videoRef.current;
if (!backgroundVideoUrl || !video) {
if (!backgroundVideoUrl || hasEmbedBackground || !video) {
setIsVideoBuffering(false);
// 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
@ -170,7 +187,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('waiting', handleWaiting);
};
}, [backgroundVideoUrl, onVideoBufferStateChange]);
}, [backgroundVideoUrl, hasEmbedBackground, onVideoBufferStateChange]);
// Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration)
const videoSrc = useMemo(() => {
@ -413,7 +430,17 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
{/* Background image - z-1 keeps it below backdrop blur layer (z-5).
Image layer stays visible while video buffers (fallback behavior).
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
className='pointer-events-none absolute inset-0 z-1 h-full w-full select-none'
style={{
@ -473,7 +500,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
webkit-playsinline is legacy attribute for older iOS versions.
preload="metadata" is required for iOS Safari video initialization.
Video fades in when ready (opacity transition from 0 to 1). */}
{activeVideoUrl && (
{activeVideoUrl && !hasEmbedBackground && (
<video
ref={videoRef}
key={`bg_video_${activeVideoUrl}`}

View File

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

View File

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

View File

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

View File

@ -14,6 +14,9 @@ import type {
InfoPanelSectionType,
InfoPanelItemType,
InfoPanelSectionInstance,
InfoPanelImageClickAction,
InfoPanelLinkClickAction,
InfoPanelMediaOpenMode,
} from '../../types/constructor';
import {
getInfoPanelSections,
@ -24,8 +27,11 @@ import { addFallbackAssetOption } from '../../lib/constructorHelpers';
interface InfoPanelSettingsSectionCompactProps {
element: CanvasElement;
imageAssetOptions: AssetOption[];
videoAssetOptions: AssetOption[];
iconAssetOptions: AssetOption[];
embedAssetOptions?: AssetOption[];
pages: Array<{ id: string; slug?: string; name?: string }>;
activePageId: string;
onChange: (prop: string, value: string | number | boolean) => void;
// Section operations (new per-instance API)
onMoveSection?: (sectionId: string, direction: 'up' | 'down') => void;
@ -85,13 +91,229 @@ const ALL_SECTION_TYPES: InfoPanelSectionType[] = [
'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<
InfoPanelSettingsSectionCompactProps
> = ({
element,
imageAssetOptions,
videoAssetOptions,
iconAssetOptions,
embedAssetOptions = [],
pages,
activePageId,
onChange,
onMoveSection,
onRemoveSection,
@ -319,6 +541,14 @@ const InfoPanelSettingsSectionCompact: React.FC<
placeholder='Header text (shown if no image)'
/>
</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'>
Styling configured in CSS tab.
</p>
@ -351,6 +581,14 @@ const InfoPanelSettingsSectionCompact: React.FC<
placeholder='Information'
/>
</div>
<LinkDestinationFields
action={section.clickAction}
targetPageSlug={section.targetPageSlug}
externalUrl={section.externalUrl}
pages={pages}
activePageId={activePageId}
onChange={(patch) => onUpdateSection?.(section.id, patch)}
/>
</div>
);
@ -381,6 +619,14 @@ const InfoPanelSettingsSectionCompact: React.FC<
placeholder='Description text...'
/>
</div>
<LinkDestinationFields
action={section.clickAction}
targetPageSlug={section.targetPageSlug}
externalUrl={section.externalUrl}
pages={pages}
activePageId={activePageId}
onChange={(patch) => onUpdateSection?.(section.id, patch)}
/>
</div>
);
@ -518,6 +764,16 @@ const InfoPanelSettingsSectionCompact: React.FC<
</option>
))}
</select>
<LinkDestinationFields
action={span.clickAction}
targetPageSlug={span.targetPageSlug}
externalUrl={span.externalUrl}
pages={pages}
activePageId={activePageId}
onChange={(patch) =>
onUpdateSpan?.(section.id, span.id, patch)
}
/>
</div>
))}
{sectionSpans.length === 0 && (
@ -614,6 +870,14 @@ const InfoPanelSettingsSectionCompact: React.FC<
placeholder='8'
/>
</div>
<MediaOpenModeField
value={section.mediaOpenMode}
onChange={(mode) =>
onUpdateSection?.(section.id, {
mediaOpenMode: mode,
})
}
/>
{sectionImages.map((image, imgIndex) => (
<div
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'
value={image.itemType || 'image'}
onChange={(e) =>
onUpdateImage?.(section.id, image.id, {
itemType: e.target.value as 'image' | '360',
})
onUpdateImage?.(
section.id,
image.id,
mediaTypePatch(
e.target.value as InfoPanelItemType,
),
)
}
>
<option value='image'>Image</option>
<option value='video'>Video</option>
<option value='360'>360° Embed</option>
</select>
</div>
@ -664,6 +933,29 @@ const InfoPanelSettingsSectionCompact: React.FC<
}
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
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...'
/>
<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>
))}
{sectionImages.length === 0 && (
@ -793,41 +1104,31 @@ const InfoPanelSettingsSectionCompact: React.FC<
placeholder='8'
/>
</div>
<MediaOpenModeField
value={section.mediaOpenMode}
onChange={(mode) =>
onUpdateSection?.(section.id, {
mediaOpenMode: mode,
})
}
/>
{/* Layout settings */}
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-0.5 block text-[10px] font-medium text-white/70'>
Preview Height
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-0.5 text-xs'
value={element.infoPanelImagesPreviewHeight || ''}
onChange={(e) =>
onChange(
'infoPanelImagesPreviewHeight',
e.target.value,
)
}
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>
<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>
{/* Styling settings (uses card styling props) */}
@ -918,11 +1219,12 @@ const InfoPanelSettingsSectionCompact: React.FC<
const newType = e.target
.value as InfoPanelItemType;
onUpdateImage?.(section.id, image.id, {
itemType: newType,
...mediaTypePatch(newType),
});
}}
>
<option value='image'>Image</option>
<option value='video'>Video</option>
<option value='360'>360° Trigger</option>
</select>
</div>
@ -939,6 +1241,9 @@ const InfoPanelSettingsSectionCompact: React.FC<
onChange={(e) =>
onUpdateImage?.(section.id, image.id, {
imageUrl: e.target.value,
videoUrl: '',
embedUrl: '',
iconUrl: '',
})
}
>
@ -957,6 +1262,37 @@ const InfoPanelSettingsSectionCompact: React.FC<
))}
</select>
</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>
@ -1036,6 +1372,25 @@ const InfoPanelSettingsSectionCompact: React.FC<
placeholder='Optional caption...'
/>
</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>
);
})}

View File

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

View File

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

View File

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

View File

@ -63,7 +63,11 @@ import {
selectByProjectAndEnv as selectProjectTransitionSettings,
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
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 type { ElementTransitionSettings } from '../types/transition';
import {
@ -189,7 +193,11 @@ export default function RuntimePresentation({
);
const [activeDetailImage, setActiveDetailImage] =
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<
string | null
>(null);
@ -279,6 +287,7 @@ export default function RuntimePresentation({
const {
currentImageUrl: navCurrentBgImageUrl,
currentVideoUrl: navCurrentBgVideoUrl,
currentEmbedUrl: navCurrentBgEmbedUrl,
currentAudioUrl: navCurrentBgAudioUrl,
previousImageUrl: navPreviousBgImageUrl,
previousVideoUrl: navPreviousBgVideoUrl,
@ -295,6 +304,7 @@ export default function RuntimePresentation({
onVideoBufferStateChange,
onTransitionEnded,
navigateToPage: navNavigateToPage,
setBackgroundDirectly: navSetBackgroundDirectly,
resetToIdle: navResetToIdle,
startTransition,
} = navState;
@ -437,7 +447,11 @@ export default function RuntimePresentation({
useEffect(() => {
if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) {
// Only initialize when backgrounds are empty (initial load)
if (!navCurrentBgImageUrl && !navCurrentBgVideoUrl) {
if (
!navCurrentBgImageUrl &&
!navCurrentBgVideoUrl &&
!navCurrentBgEmbedUrl
) {
lastInitializedPageIdRef.current = selectedPage.id;
navNavigateToPage(selectedPage);
}
@ -446,6 +460,7 @@ export default function RuntimePresentation({
selectedPage,
navCurrentBgImageUrl,
navCurrentBgVideoUrl,
navCurrentBgEmbedUrl,
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
// navShowSpinner: true when phase is 'preparing', 'loading_bg', or 'transition_done'
// 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)
const backgroundImageUrl = navCurrentBgImageUrl;
const backgroundVideoUrl = navCurrentBgVideoUrl;
const backgroundEmbedUrl = navCurrentBgEmbedUrl;
const backgroundAudioUrl = navCurrentBgAudioUrl;
// Background video playback settings from selected page
@ -700,13 +817,47 @@ export default function RuntimePresentation({
selectedPage?.background_audio_end_time != null
? parseFloat(String(selectedPage.background_audio_end_time))
: 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
// Videos start muted (for iOS autoplay), user can unmute via sound button
// Global sound control starts muted for browser autoplay compatibility.
const soundControl = useVideoSoundControl({
pageHasSound: pageVideoMuted === false, // Show button when page allows sound
pageHasSound: pageVideoMuted === false,
hasBackgroundVideo: Boolean(backgroundVideoUrl),
videoUrl: backgroundVideoUrl, // Track video changes for page navigation reset
hasBackgroundAudio: Boolean(backgroundAudioUrl),
hasElementAudio,
});
// Note: useBackgroundVideoPlayback is handled internally by CanvasBackground component
@ -843,6 +994,7 @@ export default function RuntimePresentation({
<CanvasBackground
backgroundImageUrl={backgroundImageUrl}
backgroundVideoUrl={backgroundVideoUrl}
backgroundEmbedUrl={backgroundEmbedUrl}
backgroundAudioUrl={backgroundAudioUrl}
previousBgImageUrl={navPreviousBgImageUrl}
previousBgVideoUrl={navPreviousBgVideoUrl}
@ -885,7 +1037,7 @@ export default function RuntimePresentation({
{navShowElements && (
<div
data-testid='page-elements-wrapper'
className='absolute inset-0 z-[46]'
className='pointer-events-none absolute inset-0 z-[46]'
>
{pageElements.map((element: CanvasElement) => (
<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 */}
{activeInfoPanel && (
<>
@ -1016,14 +1197,20 @@ export default function RuntimePresentation({
onClose={() => {
setActiveInfoPanel(null);
setActiveDetailImage(null);
setActiveInfoPanelGallery(null);
setRuntimeSelectedImageId(null);
}}
resolveUrl={resolveUrlWithBlob}
letterboxStyles={letterboxStyles}
cssVars={cssVars}
onImageClick={(image) => setActiveDetailImage(image)}
onOpenGallery={handleInfoPanelOpenGallery}
onUseAsBackground={handleInfoPanelUseAsBackground}
onSelectImage={(imageId) =>
setRuntimeSelectedImageId(imageId)
}
onNavigateToPage={handleInfoPanelNavigateToPage}
onOpenExternalUrl={handleInfoPanelOpenExternalUrl}
active360ItemId={
activeDetailImage?.itemType === '360'
? activeDetailImage.id
@ -1037,6 +1224,7 @@ export default function RuntimePresentation({
onClose={() => setActiveDetailImage(null)}
resolveUrl={resolveUrlWithBlob}
letterboxStyles={letterboxStyles}
cssVars={cssVars}
/>
)}
</>
@ -1047,19 +1235,21 @@ export default function RuntimePresentation({
{/* Controls: Offline toggle, Fullscreen, and Sound buttons */}
{/* Positioned outside canvas to avoid scaling with canvas transform */}
<RuntimeControls
projectId={project?.id || null}
projectSlug={projectSlug}
projectName={project?.name}
pages={pages}
isFullscreen={isFullscreen}
toggleFullscreen={toggleFullscreen}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
showSoundButton={soundControl.showSoundButton}
isMuted={soundControl.isMuted}
onSoundToggle={soundControl.toggleSound}
/>
{!activeGalleryCarousel && !activeInfoPanelGallery && (
<RuntimeControls
projectId={project?.id || null}
projectSlug={projectSlug}
projectName={project?.name}
pages={pages}
isFullscreen={isFullscreen}
toggleFullscreen={toggleFullscreen}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
showSoundButton={soundControl.showSoundButton}
isMuted={soundControl.isMuted}
onSoundToggle={soundControl.toggleSound}
/>
)}
{/* Toast notifications for offline download status */}
<ToastContainer

View File

@ -9,7 +9,11 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import Icon from '@mdi/react';
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 { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
import {
@ -17,9 +21,11 @@ import {
extractGallerySlideOverride,
} from '../../lib/resolveSlideTransition';
import { useSlideTransition } from '../../hooks/useSlideTransition';
import { useGlobalAudioMute } from '../../hooks/useGlobalAudioMute';
import { buildChromeFreeEmbedUrl, isValidEmbedUrl } from '../../lib/embedUrl';
interface GalleryCarouselOverlayProps {
cards: GalleryCard[];
cards: Array<GalleryCard | GalleryCarouselMediaItem>;
initialIndex: number;
onClose: () => void;
resolveUrl?: (url: string | undefined) => string;
@ -58,6 +64,36 @@ interface GalleryCarouselOverlayProps {
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) =>
Math.min(Math.max(value, min), max);
@ -89,6 +125,7 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
galleryElement,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const { isMuted } = useGlobalAudioMute();
// Resolve slide transition with cascade
const slideTransition = resolveSlideTransition(
@ -385,12 +422,21 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
};
const currentCard = cards[displayIndex];
const mediaType = getMediaType(currentCard);
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 (
<div
ref={overlayRef}
className='fixed inset-0 z-50 overflow-hidden bg-black'
className='fixed inset-0 z-[120] overflow-hidden bg-black'
onClick={(e) => {
// Only close if clicking the background, not buttons
if (e.target === overlayRef.current && !isEditMode) {
@ -406,17 +452,44 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
className='overflow-hidden'
style={letterboxStyles || { position: 'absolute', inset: 0 }}
>
{/* Fullscreen image */}
{imageUrl && (
{/* Fullscreen media */}
{mediaType === 'image' && imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageUrl}
alt={currentCard?.title || ''}
alt={mediaTitle}
className='absolute inset-0 h-full w-full object-contain'
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
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) */}
{slideTransition.type === 'fade' && (
<div

View File

@ -1,7 +1,7 @@
/**
* ImageDetailPanel Component
*
* Displays enlarged image or 360/iframe embed.
* Displays enlarged image, video, or 360/iframe embed.
* Positioned absolutely within the canvas (not fullscreen).
* Supports embed URL validation for security.
*/
@ -16,42 +16,8 @@ import React, {
import type { CanvasElement, InfoPanelImage } from '../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
import { getFontByKey, getFontStyle } from '../../lib/fonts';
/**
* Allowed embed domains for security
*/
const ALLOWED_EMBED_DOMAINS = [
'matterport.com',
'my.matterport.com',
'kuula.co',
'roundme.com',
'sketchfab.com',
'youtube.com',
'www.youtube.com',
'vimeo.com',
'player.vimeo.com',
'google.com',
'maps.google.com',
'www.google.com',
'docs.google.com',
'drive.google.com',
'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;
}
};
import { useGlobalAudioMute } from '../../hooks/useGlobalAudioMute';
import { buildChromeFreeEmbedUrl, isValidEmbedUrl } from '../../lib/embedUrl';
interface ImageDetailPanelProps {
element: CanvasElement;
@ -59,6 +25,8 @@ interface ImageDetailPanelProps {
onClose: () => void;
resolveUrl?: (url: string | undefined) => string;
letterboxStyles?: React.CSSProperties;
/** CSS custom properties including --cu for canvas units */
cssVars?: React.CSSProperties;
isEditMode?: boolean;
onDetailPositionChange?: (xPercent: number, yPercent: number) => void;
}
@ -69,10 +37,12 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
onClose,
resolveUrl,
letterboxStyles,
cssVars,
isEditMode = false,
onDetailPositionChange,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const { isMuted } = useGlobalAudioMute();
const panelRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
@ -235,6 +205,8 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
const isEmbed = image?.itemType === '360' && image?.embedUrl;
const embedUrl = image?.embedUrl ?? '';
const isValidEmbed = isEmbed && isValidEmbedUrl(embedUrl);
const embedSrc = isValidEmbed ? buildChromeFreeEmbedUrl(embedUrl) : '';
const isVideo = image?.itemType === 'video' && image?.videoUrl;
const hasImage = !!image;
// Convert numeric values to canvas units for responsive scaling
@ -387,7 +359,10 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
<div
ref={containerRef}
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 */}
<div
@ -398,7 +373,7 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
tabIndex={-1}
style={{
...panelStyle,
pointerEvents: 'auto', // Ensure panel receives events
pointerEvents: isEditMode ? 'none' : 'auto',
cursor:
isEditMode && onDetailPositionChange
? isDragging
@ -407,12 +382,24 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
: undefined,
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={
isEditMode && onDetailPositionChange ? handleDragStart : undefined
}
>
{/* Control buttons - only show for regular images (embeds have their own controls) */}
{!isEmbed && image?.imageUrl && (
{isEditMode && onDetailPositionChange && (
<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 */}
<button
@ -519,7 +506,7 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
// Embed iframe
isValidEmbed ? (
<iframe
src={embedUrl}
src={embedSrc}
title={image?.caption || 'Embedded content'}
className='w-full h-full border-0'
sandbox='allow-scripts allow-same-origin allow-presentation allow-popups'
@ -555,6 +542,22 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
</p>
</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 ? (
// Regular image
// eslint-disable-next-line @next/next/no-img-element

View File

@ -17,6 +17,8 @@ import React, {
import type {
CanvasElement,
InfoPanelImage,
InfoPanelImageClickAction,
InfoPanelLinkClickAction,
InfoPanelSectionInstance,
} from '../../types/constructor';
import { getInfoPanelSections } from '../../types/constructor';
@ -30,8 +32,6 @@ import {
buildInfoPanelCardStyle,
buildInfoPanelCardTitleStyle,
buildInfoPanelCardGridStyleWithSection,
buildInfoPanelWrapperStyle,
buildImagesPreviewStyle,
} from '../../lib/infoPanelSectionStyles';
interface InfoPanelOverlayProps {
@ -43,8 +43,16 @@ interface InfoPanelOverlayProps {
cssVars?: React.CSSProperties;
/** Callback when an image/360 is clicked. Pass null to close the detail panel. */
onImageClick: (image: InfoPanelImage | null) => void;
/** Callback when an image is selected in the images section preview */
/** 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;
/** 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;
/** Callback when panel position changes (edit mode only) */
onPanelPositionChange?: (xPercent: number, yPercent: number) => void;
@ -52,6 +60,74 @@ interface InfoPanelOverlayProps {
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> = ({
element,
onClose,
@ -59,7 +135,11 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
letterboxStyles,
cssVars,
onImageClick,
onOpenGallery,
onSelectImage,
onNavigateToPage,
onOpenExternalUrl,
onUseAsBackground,
isEditMode = false,
onPanelPositionChange,
active360ItemId,
@ -77,11 +157,6 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
panelY: number;
} | null>(null);
// Track selected image per section (local UI state)
const [selectedImagePerSection, setSelectedImagePerSection] = useState<
Record<string, string>
>({});
// Section styles computed from element settings
const headerStyle = useMemo(
() => buildInfoPanelHeaderStyle(element),
@ -98,18 +173,106 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
() => buildInfoPanelCardTitleStyle(element),
[element],
);
const wrapperStyle = useMemo(
() => buildInfoPanelWrapperStyle(element),
[element],
);
const imagesPreviewStyle = useMemo(
() => buildImagesPreviewStyle(element),
[element],
);
// Get section instances
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
useEffect(() => {
requestAnimationFrame(() => setIsVisible(true));
@ -334,7 +497,7 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
? 'grabbing'
: 'grab'
: undefined,
pointerEvents: 'auto', // Ensure panel receives events
pointerEvents: isEditMode ? 'none' : 'auto',
}}
onClick={(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'
style={{
borderRadius: `${toCU(panelBorderRadius)} ${toCU(panelBorderRadius)} 0 0`,
pointerEvents: 'auto',
}}
onMouseDown={handleDragStart}
>
@ -396,14 +560,30 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
section.headerImageUrl ?? element.infoPanelHeaderImageUrl;
const headerText =
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
if (headerImageUrl) {
return (
<div
key={section.id}
{...headerClickProps}
style={{
...headerStyle,
...getClickableStyle(section, headerStyle),
padding: 0,
overflow: 'hidden',
}}
@ -424,7 +604,11 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
}
if (headerText) {
return (
<div key={section.id} style={headerStyle}>
<div
key={section.id}
{...headerClickProps}
style={getClickableStyle(section, headerStyle)}
>
{headerText}
</div>
);
@ -441,12 +625,24 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
key={section.id}
id='info-panel-title'
style={{
...titleStyle,
...getClickableStyle(section, titleStyle),
marginTop:
isEditMode && onPanelPositionChange
? '16px'
: 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}
</h2>
@ -458,7 +654,22 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
const textContent = section.text ?? panelText;
if (!textContent) return null;
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}
</p>
);
@ -478,7 +689,18 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
return (
<div key={section.id} style={spanGridStyle}>
{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 ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
@ -494,7 +716,7 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
) : (
span.text
)}
</div>
</button>
))}
</div>
);
@ -527,7 +749,13 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
overflow: 'hidden', // Respect borderRadius on children
}}
className='focus:outline-none'
onClick={() => onImageClick(image)}
onClick={() =>
handleImageDestination(
image,
section,
sectionImages,
)
}
aria-label={image.caption || 'View image'}
>
{image.itemType === '360' ? (
@ -549,6 +777,29 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
</svg>
<span className='text-xs'>360/Embed</span>
</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 ? (
// Regular image thumbnail
// eslint-disable-next-line @next/next/no-img-element
@ -606,35 +857,8 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
// Use section-level 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
if (imageItems.length === 0 && triggerItems.length === 0)
return null;
// Use per-section selected state, fallback to first image
const selectedId =
selectedImagePerSection[section.id] || imageItems[0]?.id;
const selectedImage = imageItems.find(
(img) => img.id === selectedId,
);
// Handler to update selected image for this section
const handleSelectImage = (imageId: string) => {
setSelectedImagePerSection((prev) => ({
...prev,
[section.id]: imageId,
}));
// Also call the optional external handler
onSelectImage?.(imageId);
};
if (sectionImages.length === 0) return null;
// Build grid style with per-section settings
const gridStyle = buildInfoPanelCardGridStyleWithSection(
@ -663,140 +887,137 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
gap: gridStyle.gap || 'calc(8 * var(--cu, 1px))',
}}
>
{/* Large Preview - always visible */}
<div
style={{
...imagesPreviewStyle,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{selectedImage?.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(selectedImage.imageUrl)}
alt={selectedImage.caption || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
}}
draggable={false}
/>
) : (
<div className='text-white/40 text-sm'>
{imageItems.length === 0
? 'No images added'
: 'No image selected'}
</div>
)}
</div>
{/* Thumbnail Grid */}
<div style={gridStyle}>
{/* Image thumbnails - click to select for preview */}
{imageItems.map((img) => (
<button
key={img.id}
type='button'
style={{
...thumbnailStyle,
opacity: img.id === selectedId ? 1 : 0.6,
}}
className='transition-opacity hover:opacity-100 focus:outline-none'
onClick={() => handleSelectImage(img.id)}
aria-label={img.caption || 'Select image'}
aria-pressed={img.id === selectedId}
>
{img.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(img.imageUrl)}
alt=''
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
draggable={false}
/>
) : (
<div className='w-full h-full flex items-center justify-center text-white/40'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-6 h-6'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'
/>
</svg>
</div>
)}
</button>
))}
{sectionImages.map((img) => {
const itemType = img.itemType || 'image';
const is360 = itemType === '360';
const isVideo = itemType === 'video';
{/* 360° trigger buttons - click to open 360° view */}
{triggerItems.map((img) => (
<button
key={img.id}
type='button'
style={thumbnailStyle}
className='trigger-360 focus:outline-none'
onClick={() => {
// Toggle behavior: if already open, close; otherwise open
if (active360ItemId === img.id) {
onImageClick(null);
} else {
onImageClick(img);
return (
<button
key={img.id}
type='button'
style={thumbnailStyle}
className={
is360
? 'trigger-360 focus:outline-none'
: isVideo
? 'trigger-video focus:outline-none'
: 'transition-opacity hover:opacity-100 focus:outline-none'
}
}}
aria-label={
active360ItemId === img.id
? 'Close 360° view'
: 'Open 360° view'
}
aria-pressed={active360ItemId === img.id}
>
{img.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(img.iconUrl)}
alt='360°'
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
}}
draggable={false}
/>
) : (
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-8 h-8 mb-1'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-4.247m0 0A8.966 8.966 0 0 1 3 12c0-1.97.633-3.79 1.706-5.27'
onClick={() => {
if (!isVideo && !is360) {
onSelectImage?.(img.id);
}
handleImageDestination(
img,
section,
sectionImages,
);
}}
aria-label={
is360
? active360ItemId === img.id
? 'Close 360° view'
: 'Open 360° view'
: img.caption ||
(isVideo ? 'Open video' : 'Select image')
}
aria-pressed={
is360 ? active360ItemId === img.id : undefined
}
>
{is360 ? (
img.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(img.iconUrl)}
alt='360°'
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
}}
draggable={false}
/>
</svg>
<span className='text-xs'>360°</span>
</div>
)}
</button>
))}
) : (
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-8 h-8 mb-1'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-4.247m0 0A8.966 8.966 0 0 1 3 12c0-1.97.633-3.79 1.706-5.27'
/>
</svg>
<span className='text-xs'>360°</span>
</div>
)
) : 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>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,6 +24,7 @@ interface TourPage {
ui_schema_json?: string;
background_image_url?: string;
background_video_url?: string;
background_embed_url?: string;
background_audio_url?: string;
background_loop?: boolean;
// Background video playback settings
@ -128,6 +129,7 @@ export function useConstructorPageActions({
const {
imageUrl: backgroundImageUrl,
videoUrl: backgroundVideoUrl,
embedUrl: backgroundEmbedUrl,
audioUrl: backgroundAudioUrl,
videoSettings: {
autoplay: backgroundVideoAutoplay,
@ -209,6 +211,7 @@ export function useConstructorPageActions({
ui_schema_json: schemaToSave,
background_image_url: backgroundImageUrl,
background_video_url: backgroundVideoUrl,
background_embed_url: backgroundEmbedUrl,
background_audio_url: backgroundAudioUrl,
background_loop: Boolean(backgroundAudioUrl),
background_video_autoplay: backgroundVideoAutoplay,
@ -335,6 +338,7 @@ export function useConstructorPageActions({
sort_order: maxSortOrder + 1,
background_image_url: '',
background_video_url: '',
background_embed_url: '',
background_audio_url: '',
background_loop: 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:
* - backgroundImageUrl, setBackgroundImageUrl
* - backgroundVideoUrl, setBackgroundVideoUrl
* - backgroundEmbedUrl, setBackgroundEmbedUrl
* - backgroundAudioUrl, setBackgroundAudioUrl
* - backgroundVideoAutoplay, setBackgroundVideoAutoplay
* - backgroundVideoLoop, setBackgroundVideoLoop
@ -29,6 +30,7 @@ import {
interface TourPageData {
background_image_url?: string;
background_video_url?: string;
background_embed_url?: string;
background_audio_url?: string;
background_video_autoplay?: boolean;
background_video_loop?: boolean;
@ -59,6 +61,7 @@ export interface UsePageBackgroundResult {
/** Update individual URL */
setImageUrl: (url: string) => void;
setVideoUrl: (url: string) => void;
setEmbedUrl: (url: string) => void;
setAudioUrl: (url: string) => void;
/** Update video settings */
@ -74,6 +77,7 @@ export interface UsePageBackgroundResult {
// These allow gradual migration of components
backgroundImageUrl: string;
backgroundVideoUrl: string;
backgroundEmbedUrl: string;
backgroundAudioUrl: string;
backgroundVideoAutoplay: 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) => {
setBackground((prev) => ({
...prev,
@ -181,6 +192,7 @@ export function usePageBackground(
updateFromPage,
setImageUrl,
setVideoUrl,
setEmbedUrl,
setAudioUrl,
setVideoSettings,
setAudioSettings,
@ -189,6 +201,7 @@ export function usePageBackground(
// Legacy compatibility: flat values for gradual migration
backgroundImageUrl: background.imageUrl,
backgroundVideoUrl: background.videoUrl,
backgroundEmbedUrl: background.embedUrl,
backgroundAudioUrl: background.audioUrl,
backgroundVideoAutoplay: background.videoSettings.autoplay,
backgroundVideoLoop: background.videoSettings.loop,

View File

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

View File

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

View File

@ -19,15 +19,29 @@ class BackgroundAudioController {
private waitingForInteraction = 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 {
this.audioElement = audio;
this.waitingForInteraction = false;
if (audio && this.muted) {
audio.pause();
}
}
setWaitingForInteraction(waiting: boolean): void {
this.waitingForInteraction = waiting;
if (waiting && this.hasUserInteracted && this.audioElement) {
if (
waiting &&
this.hasUserInteracted &&
!this.muted &&
this.audioElement
) {
this.audioElement.play().catch(() => undefined);
this.waitingForInteraction = false;
}
@ -37,7 +51,7 @@ class BackgroundAudioController {
if (this.hasUserInteracted) return;
this.hasUserInteracted = true;
if (this.waitingForInteraction && this.audioElement) {
if (this.waitingForInteraction && !this.muted && this.audioElement) {
this.audioElement.play().catch(() => undefined);
this.waitingForInteraction = false;
}
@ -61,11 +75,48 @@ class BackgroundAudioController {
if (this.foregroundCount > 0) {
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);
}
}
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 {
this.foregroundCount = 0;
this.wasPaused = false;

View File

@ -11,9 +11,16 @@ import type {
GalleryCard,
GalleryInfoSpan,
CarouselSlide,
InfoPanelImageClickAction,
InfoPanelImage,
InfoPanelInfoSpan,
InfoPanelLinkClickAction,
InfoPanelMediaOpenMode,
InfoPanelSectionInstance,
InfoPanelSectionType,
NavigationButtonKind,
} from '../types/constructor';
import { DEFAULT_INFO_PANEL_SECTIONS } from '../types/constructor';
import { ELEMENT_STYLE_PROPS } from './elementStyles';
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 {
...base,
...typeDefaults,
@ -294,13 +313,172 @@ export const normalizeCarouselSlide = (
*/
export const normalizeInfoPanelImage = (
image: Record<string, unknown>,
): InfoPanelImage => ({
id: String(image?.id || createLocalId()),
imageUrl: image?.imageUrl ? String(image.imageUrl) : undefined,
embedUrl: image?.embedUrl ? String(image.embedUrl) : undefined,
caption: image?.caption ? String(image.caption) : undefined,
itemType: (image?.itemType as 'image' | '360') || 'image',
});
): InfoPanelImage => {
const clickAction =
image.clickAction === 'target_page' ||
image.clickAction === 'external_url' ||
image.clickAction === 'panel' ||
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.
@ -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;
};
@ -464,6 +663,12 @@ export const parseElementSettings = (
);
}
if (Array.isArray(settings.infoPanelSections)) {
settings.infoPanelSections = normalizeInfoPanelSections(
settings.infoPanelSections,
);
}
return settings as Partial<CanvasElement>;
};
@ -711,6 +916,11 @@ export const buildElementSettings = (
addIfNotEmpty(settings, 'detailBorderRadius', element.detailBorderRadius);
addIfNotEmpty(settings, 'detailPadding', element.detailPadding);
addIfNotEmpty(settings, 'detailOverlayColor', element.detailOverlayColor);
if (Array.isArray(element.infoPanelSections)) {
settings.infoPanelSections = normalizeInfoPanelSections(
element.infoPanelSections,
);
}
}
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;
}
@ -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.
*
@ -140,50 +273,46 @@ export function extractPageLinksAndElements(
});
}
// Build synthetic page link for navigation elements
const targetSlug =
el.targetPageSlug && typeof el.targetPageSlug === 'string'
? el.targetPageSlug
: '';
// Build synthetic page links for navigation elements and Info Panel targets
const targetSlugs = collectTargetPageSlugs(el);
const legacyTargetId =
el.targetPageId && typeof el.targetPageId === 'string'
? el.targetPageId
: '';
// Resolve slug to page ID (prefer slug, fall back to legacy ID)
let resolvedTargetPageId = '';
if (targetSlug) {
resolvedTargetPageId = slugToIdMap.get(targetSlug) || '';
} else if (legacyTargetId) {
targetSlugs.forEach((targetSlug, index) => {
addPageLink(
pageLinks,
page,
el,
targetSlug,
slugToIdMap,
`${preloadElements.length}-${index}`,
);
});
if (targetSlugs.length === 0 && legacyTargetId) {
// Legacy: targetPageId might be a slug or an ID
resolvedTargetPageId =
slugToIdMap.get(legacyTargetId) || legacyTargetId;
}
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) {
const hasTransitionVideo =
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string';
const hasReverseVideo =
el.reverseVideoUrl && typeof el.reverseVideoUrl === 'string';
pageLinks.push({
id: `synthetic-${page.id}-${el.id || preloadElements.length}`,
from_pageId: page.id,
to_pageId: resolvedTargetPageId,
is_active: true,
transition:
hasTransitionVideo || hasReverseVideo
? {
id: `transition-${el.id || preloadElements.length}`,
video_url: hasTransitionVideo
? (el.transitionVideoUrl as string)
: undefined,
reverse_video_url: hasReverseVideo
? (el.reverseVideoUrl as string)
: undefined,
}
: undefined,
});
const legacySlug = slugToIdMap.has(legacyTargetId)
? legacyTargetId
: '';
if (legacySlug) {
addPageLink(
pageLinks,
page,
el,
legacySlug,
slugToIdMap,
`${preloadElements.length}-legacy`,
);
} else if (legacyTargetId && legacyTargetId !== page.id) {
pageLinks.push({
id: `synthetic-${page.id}-${el.id || preloadElements.length}-legacy`,
from_pageId: page.id,
to_pageId: legacyTargetId,
is_active: true,
});
}
}
});
});
@ -226,50 +355,32 @@ export function extractPageLinksOnly(
: [];
pageElements.forEach((el) => {
// Build synthetic page link for navigation elements
const targetSlug =
el.targetPageSlug && typeof el.targetPageSlug === 'string'
? el.targetPageSlug
: '';
// Build synthetic page links for navigation elements and Info Panel targets
const targetSlugs = collectTargetPageSlugs(el);
const legacyTargetId =
el.targetPageId && typeof el.targetPageId === 'string'
? el.targetPageId
: '';
// Resolve slug to page ID (prefer slug, fall back to legacy ID)
let resolvedTargetPageId = '';
if (targetSlug) {
resolvedTargetPageId = slugToIdMap.get(targetSlug) || '';
} else if (legacyTargetId) {
targetSlugs.forEach((targetSlug, index) => {
addPageLink(pageLinks, page, el, targetSlug, slugToIdMap, `${index}`);
});
if (targetSlugs.length === 0 && legacyTargetId) {
// Legacy: targetPageId might be a slug or an ID
resolvedTargetPageId =
slugToIdMap.get(legacyTargetId) || legacyTargetId;
}
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) {
const hasTransitionVideo =
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string';
const hasReverseVideo =
el.reverseVideoUrl && typeof el.reverseVideoUrl === 'string';
pageLinks.push({
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,
});
const legacySlug = slugToIdMap.has(legacyTargetId)
? legacyTargetId
: '';
if (legacySlug) {
addPageLink(pageLinks, page, el, legacySlug, slugToIdMap, 'legacy');
} else if (legacyTargetId && legacyTargetId !== page.id) {
pageLinks.push({
id: `synthetic-${page.id}-${el.id || 'legacy'}-legacy`,
from_pageId: page.id,
to_pageId: legacyTargetId,
is_active: true,
});
}
}
});
});

View File

@ -163,6 +163,28 @@ export const extractPageImageUrls = (page: PageWithImages | null): string[] => {
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(
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(
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.
* 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(
element: Partial<CanvasElement>,
@ -872,7 +872,7 @@ export const INFO_PANEL_SECTION_STYLE_PROPS = [
'panelPadding',
'panelBorderRadius',
'panelBackdropBlur',
// Images section
// Media section
'infoPanelSelectedImageId',
'infoPanelImagesPreviewHeight',
'infoPanelImagesThumbnailSize',

View File

@ -76,6 +76,7 @@ import type {
GalleryInfoSpan,
CarouselSlide,
InfoPanelImage,
GalleryCarouselMediaItem,
} from '../types/constructor';
import type { TourPage } from '../types/entities';
@ -208,12 +209,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
updateFromPage: updateBackgroundFromPage,
setImageUrl: setBackgroundImageUrl,
setVideoUrl: setBackgroundVideoUrl,
setEmbedUrl: setBackgroundEmbedUrl,
setAudioUrl: setBackgroundAudioUrl,
setVideoSettings: setBackgroundVideoSettings,
setAudioSettings: setBackgroundAudioSettings,
// Legacy compatibility values for components that expect flat props
backgroundImageUrl,
backgroundVideoUrl,
backgroundEmbedUrl,
backgroundAudioUrl,
backgroundVideoAutoplay,
backgroundVideoLoop,
@ -225,12 +228,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
backgroundAudioEndTime,
} = usePageBackground();
// Sound control hook for iOS autoplay compatibility
// Videos start muted (for iOS autoplay), can be controlled via page settings
// Global sound control starts muted for browser autoplay compatibility.
const soundControl = useVideoSoundControl({
pageHasSound: backgroundVideoMuted === false, // Show button when page allows sound
pageHasSound: backgroundVideoMuted === false,
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
@ -292,6 +294,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
} | null>(null);
const [activeDetailImage, setActiveDetailImage] =
useState<InfoPanelImage | null>(null);
const [activeInfoPanelGallery, setActiveInfoPanelGallery] = useState<{
items: GalleryCarouselMediaItem[];
initialIndex: number;
} | null>(null);
// Current element transition settings (for CSS transitions when no video)
const [
currentElementTransitionSettings,
@ -369,15 +375,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
return selectedElement;
}, [isConstructorEditMode, selectedElement]);
// Determine which info panel element to use for overlay rendering
const infoPanelElementToRender =
activeInfoPanelElement || editModeInfoPanelElement;
// In edit mode the overlay is only a selected-element preview. Do not keep
// runtime-open Info Panel state above the canvas after selection is cleared.
const infoPanelElementToRender = isConstructorEditMode
? editModeInfoPanelElement
: activeInfoPanelElement;
const shouldShowInfoPanelOverlays = !!infoPanelElementToRender;
// Reset info panel state when switching between edit and interact modes
useEffect(() => {
setActiveInfoPanel(null);
setActiveDetailImage(null);
setActiveInfoPanelGallery(null);
}, [isConstructorEditMode]);
// Draggable panels using useDraggable hook
@ -470,6 +479,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
id: p.id,
background_image_url: p.background_image_url,
background_video_url: p.background_video_url,
background_embed_url: p.background_embed_url,
background_audio_url: p.background_audio_url,
})),
pageLinks,
@ -506,6 +516,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const {
currentImageUrl: navCurrentBgImageUrl,
currentVideoUrl: navCurrentBgVideoUrl,
currentEmbedUrl: navCurrentBgEmbedUrl,
currentAudioUrl: navCurrentBgAudioUrl,
previousImageUrl: navPreviousBgImageUrl,
previousVideoUrl: navPreviousBgVideoUrl,
@ -522,6 +533,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onVideoBufferStateChange,
onTransitionEnded,
navigateToPage: navNavigateToPage,
setBackgroundDirectly: navSetBackgroundDirectly,
resetToIdle: navResetToIdle,
startTransition,
} = navState;
@ -560,6 +572,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
id: page.id,
background_image_url: page.background_image_url,
background_video_url: page.background_video_url,
background_embed_url: page.background_embed_url,
background_audio_url: page.background_audio_url,
}
: null,
@ -578,6 +591,105 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
[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 {
isBuffering: isTransitionBuffering,
isVideoReady: isTransitionVideoReady,
@ -1154,6 +1266,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
id: activePage.id,
background_image_url: activePage.background_image_url,
background_video_url: activePage.background_video_url,
background_embed_url: activePage.background_embed_url,
background_audio_url: activePage.background_audio_url,
});
}, [activePage, navNavigateToPage]);
@ -1427,12 +1540,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
);
// Handler for info panel clicks
const handleInfoPanelClick = useCallback((element: CanvasElement) => {
if (isInfoPanelElementType(element.type)) {
setActiveInfoPanel({ elementId: element.id });
setActiveDetailImage(null);
}
}, []);
const handleInfoPanelClick = useCallback(
(element: CanvasElement) => {
if (isConstructorEditMode) return;
if (isInfoPanelElementType(element.type)) {
setActiveInfoPanel({ elementId: element.id });
setActiveDetailImage(null);
}
},
[isConstructorEditMode],
);
// Handler for gallery carousel button position changes (constructor only)
const handleGalleryCarouselButtonPositionChange = useCallback(
@ -1557,6 +1674,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
resolveUrlWithBlob,
]);
const backgroundEmbedSrc = useMemo(() => {
if (isConstructorEditMode && backgroundEmbedUrl) {
return resolveUrlWithBlob(backgroundEmbedUrl);
}
return navCurrentBgEmbedUrl;
}, [
isConstructorEditMode,
backgroundEmbedUrl,
navCurrentBgEmbedUrl,
resolveUrlWithBlob,
]);
const backgroundAudioSrc = useMemo(() => {
if (isConstructorEditMode && backgroundAudioUrl) {
return resolveUrlWithBlob(backgroundAudioUrl);
@ -1577,9 +1706,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
? 'Background image'
: selectedMenuItem === 'background_video'
? 'Background video'
: selectedMenuItem === 'background_audio'
? 'Background audio'
: selectedElement?.label || 'Element editor';
: selectedMenuItem === 'background_embed'
? 'Background 360'
: selectedMenuItem === 'background_audio'
? 'Background audio'
: selectedElement?.label || 'Element editor';
// Background image is rendered by CanvasBackground component (same as runtime)
// No CSS background-image needed on canvas div
@ -1621,6 +1752,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Background convenience setters
setBackgroundImageUrl,
setBackgroundVideoUrl,
setBackgroundEmbedUrl,
setBackgroundAudioUrl,
setBackgroundVideoSettings,
setBackgroundAudioSettings,
@ -1696,6 +1828,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
updateBackgroundFromPage,
setBackgroundImageUrl,
setBackgroundVideoUrl,
setBackgroundEmbedUrl,
setBackgroundAudioUrl,
setBackgroundVideoSettings,
elements,
@ -1842,6 +1975,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
<CanvasBackground
backgroundImageUrl={backgroundImageSrc}
backgroundVideoUrl={backgroundVideoSrc}
backgroundEmbedUrl={backgroundEmbedSrc}
backgroundAudioUrl={backgroundAudioSrc}
previousBgImageUrl={navPreviousBgImageUrl}
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 */}
{shouldShowInfoPanelOverlays && infoPanelElementToRender && (
<>
@ -2034,11 +2199,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onClose={() => {
setActiveInfoPanel(null);
setActiveDetailImage(null);
setActiveInfoPanelGallery(null);
}}
resolveUrl={resolveUrlWithBlob}
letterboxStyles={letterboxStyles}
cssVars={canvasCssVars}
onImageClick={(image) => setActiveDetailImage(image)}
onOpenGallery={handleInfoPanelOpenGallery}
onUseAsBackground={handleInfoPanelUseAsBackground}
onNavigateToPage={handleInfoPanelNavigateToPage}
onOpenExternalUrl={handleInfoPanelOpenExternalUrl}
onSelectImage={
isConstructorEditMode
? (imageId) => {
@ -2073,6 +2243,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onClose={() => setActiveDetailImage(null)}
resolveUrl={resolveUrlWithBlob}
letterboxStyles={letterboxStyles}
cssVars={canvasCssVars}
isEditMode={isConstructorEditMode}
onDetailPositionChange={
isConstructorEditMode

View File

@ -39,6 +39,7 @@ export type EditorMenuItem =
| 'none'
| 'background_image'
| 'background_video'
| 'background_embed'
| 'background_audio';
/**
@ -68,6 +69,19 @@ export interface GalleryCard {
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)
*/
@ -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
* - '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
@ -100,12 +122,21 @@ export type InfoPanelItemType = 'image' | '360';
export interface InfoPanelImage {
id: string;
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=...)
caption?: string;
/** Item type: 'image' for inline preview, '360' for embed trigger */
itemType?: InfoPanelItemType;
/** Custom icon URL for 360° trigger button */
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;
text: string;
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
* - 'cards': Legacy card grid (click opens ImageDetailPanel)
* - 'images': New inline image viewer with preview + thumbnail strip
* - 'cards': Card grid for media items
* - 'images': Media viewer with preview + thumbnail strip
*/
export type InfoPanelSectionType =
| 'header'
@ -145,6 +182,8 @@ export interface InfoPanelSectionInstance {
columns?: number;
/** Gap between items */
gap?: string;
/** Media click rendering mode for cards/media sections */
mediaOpenMode?: InfoPanelMediaOpenMode;
// Per-instance data (each section has its OWN items)
/** For 'spans' type sections */
@ -159,6 +198,12 @@ export interface InfoPanelSectionInstance {
headerImageUrl?: string;
/** For 'header' type sections - text content */
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-text', type: 'text' },
{ 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',
spans: 'Info Spans',
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 */
infoPanelSections?: InfoPanelSectionInstance[];
// Images section (inline image viewer)
/** Currently selected image ID in the images section preview */
// Media section (inline media viewer)
/** Currently selected image ID in the media section preview */
infoPanelSelectedImageId?: string;
/** Preview area height for images section (e.g., '300', 'auto') */
/** Preview area height for media section (e.g., '300', 'auto') */
infoPanelImagesPreviewHeight?: string;
/** Thumbnail size for images section (e.g., '80') */
/** Thumbnail size for media section (e.g., '80') */
infoPanelImagesThumbnailSize?: string;
// Component 3: Image Detail Panel
@ -696,6 +741,7 @@ export interface EditorElementProps {
| 'none'
| 'background_image'
| 'background_video'
| 'background_embed'
| 'background_audio';
onRemoveElement: () => void;
onUpdateElement: (patch: Partial<CanvasElement>) => void;
@ -815,6 +861,8 @@ export interface PageBackgroundState {
imageUrl: string;
/** Storage key or URL for background video */
videoUrl: string;
/** Storage key or URL for background 360/embed */
embedUrl: string;
/** Storage key or URL for background audio */
audioUrl: string;
/** Video playback settings */
@ -849,6 +897,7 @@ export const DEFAULT_AUDIO_SETTINGS: PageBackgroundAudioSettings = {
export const DEFAULT_PAGE_BACKGROUND: PageBackgroundState = {
imageUrl: '',
videoUrl: '',
embedUrl: '',
audioUrl: '',
videoSettings: { ...DEFAULT_VIDEO_SETTINGS },
audioSettings: { ...DEFAULT_AUDIO_SETTINGS },
@ -861,6 +910,7 @@ export function createPageBackgroundFromPage(
page: {
background_image_url?: string;
background_video_url?: string;
background_embed_url?: string;
background_audio_url?: string;
background_video_autoplay?: boolean;
background_video_loop?: boolean;
@ -880,6 +930,7 @@ export function createPageBackgroundFromPage(
return {
imageUrl: page.background_image_url || '',
videoUrl: page.background_video_url || '',
embedUrl: page.background_embed_url || '',
audioUrl: page.background_audio_url || '',
videoSettings: {
autoplay: page.background_video_autoplay ?? true,

View File

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