71 KiB
Constructor Page Editor - E2E Documentation
Overview
The Constructor is a full-featured visual editor for building interactive tour pages. It provides drag-and-drop element placement, background media configuration, transition setup, and real-time preview capabilities.
Main File: frontend/src/pages/constructor.tsx
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Constructor Page │
│ State Management │ Event Handlers │ Data Loading │
│ (constructor.tsx - orchestration layer) │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ Constructor-Specific Hooks │
│ useConstructorElements │ useConstructorPageActions │
│ useCanvasElapsedTime │ useCanvasElementDrag │ useTransitionPreview │
│ useMediaDurationProbe │ useIconPreload │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ Constructor Components │
│ ConstructorToolbar │ CanvasBackground │ CanvasElement │
│ ElementEditorPanel │ PageSelector │ InteractionModeToggle │
│ TransitionPreviewOverlay │ AssetSelectCompact │ etc. │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ UI Element Components (3 files) │
│ GalleryCarouselOverlay │ UiElementRenderer │ ElementPreview │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ Shared Runtime Hooks │
│ usePreloadOrchestrator │ usePageSwitch │ useTransitionPlayback │
│ useBackgroundTransition │ useDraggable │ useOutsideClick │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ Helper Libraries │
│ elementDefaults │ elementStyles │ elementEffects │
│ constructorHelpers │ navigationHelpers │ mediaHelpers │
│ assetUrl │ parseJson │ extractPageLinks │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ Data Layer │
│ Tour Pages API │ Assets API │ Project Element Defaults │ S3 │
└─────────────────────────────────────────────────────────────────┘
Interaction Modes
Edit Mode
- Click element → Select for property editing
- Drag element → Reposition on canvas
- Global action buttons (fullscreen, sound, offline) render as system controls. They can be selected, dragged, hidden, disabled, styled, and assigned custom icons, but cannot be deleted. Hidden controls still render as ghost controls in edit mode.
- Properties panel shows selected element
- Menu shows element creation options
- Info Panel and image detail overlays are non-blocking previews in edit mode: they render from the selected element only, do not use runtime open state, and pass canvas clicks through except for their small drag handles.
Interact Mode
- Click navigation → Preview navigation/transition
- Global action buttons use runtime rendering with canvas-relative positioning and dimensions. In edit mode all system controls remain visible/selectable, hidden controls render as ghost controls, and actions are blocked so buttons can be dragged without toggling fullscreen, sound, or offline mode.
- Disabled navigation and Info Panel elements keep hover/focus/active visuals, but their actions, click audio, and click-persisted hover state are ignored
- Info Panel elements can set
infoPanelOpenByDefault: truefrom the General tab; constructor interact mode opens every enabled matching panel when the page renders, while edit mode continues to show only the selected-element preview. Detail panel and fullscreen gallery state are scoped to the originating Info Panel ID. - Cannot drag or select elements
- Used for testing page flow
- Tests navigation elements and video transitions
- Element hover/click audio effects are active. The constructor uses the same
global sound state as runtime and renders global action buttons through
RuntimeControls. Current unsaved element/background audio changes are included so the sound button appears immediately while editing. - Runtime chrome is hidden while fullscreen gallery overlays are open. In edit mode, system controls continue to render so they can be selected and edited.
Data Models
CanvasElement
interface CanvasElement extends ElementStyleProperties, ElementEffectProperties {
id: string;
type: CanvasElementType;
label: string;
// Percentage-based positioning (0-100)
xPercent: number;
yPercent: number;
// Common properties
iconUrl?: string;
mediaUrl?: string;
// Animation timing
appearDelaySec: number;
appearDurationSec: number | null;
// Navigation (for navigation elements)
navLabel?: string;
navType?: 'forward' | 'back';
navDisabled?: boolean;
targetPageSlug?: string; // Slug-based navigation (consistent across environments)
targetPageId?: string; // @deprecated - use targetPageSlug
transitionVideoUrl?: string;
transitionReverseMode?: 'auto_reverse' | 'separate_video';
reverseVideoUrl?: string;
transitionDurationSec?: number;
// Tooltip
tooltipTitle?: string;
tooltipText?: string;
tooltipTitleFontFamily?: string;
tooltipTextFontFamily?: string;
// Description
descriptionTitle?: string;
descriptionText?: string;
descriptionTitleFontSize?: string;
descriptionTextFontSize?: string;
descriptionTitleFontFamily?: string;
descriptionTextFontFamily?: string;
descriptionTitleColor?: string;
descriptionTextColor?: string;
descriptionBackgroundColor?: string;
// Gallery
galleryCards?: GalleryCard[];
galleryHeaderImageUrl?: string;
galleryTitle?: string;
galleryInfoSpans?: GalleryInfoSpan[];
galleryColumns?: number;
galleryTitleFontFamily?: string;
galleryCardFontFamily?: string;
// Gallery Carousel overlay settings
galleryCarouselPrevIconUrl?: string;
galleryCarouselNextIconUrl?: string;
galleryCarouselBackIconUrl?: string;
galleryCarouselBackLabel?: string;
galleryCarouselPrevX?: number;
galleryCarouselPrevY?: number;
galleryCarouselNextX?: number;
galleryCarouselNextY?: number;
galleryCarouselBackX?: number;
galleryCarouselBackY?: number;
galleryCarouselPrevWidth?: string;
galleryCarouselPrevHeight?: string;
galleryCarouselNextWidth?: string;
galleryCarouselNextHeight?: string;
galleryCarouselBackWidth?: string;
galleryCarouselBackHeight?: string;
// Carousel
carouselSlides?: CarouselSlide[];
carouselPrevIconUrl?: string;
carouselNextIconUrl?: string;
carouselCaptionFontFamily?: string;
// Media players
mediaAutoplay?: boolean;
mediaLoop?: boolean;
mediaMuted?: boolean;
}
// Styling properties (from ElementStyleProperties)
interface ElementStyleProperties {
width?: string;
height?: string;
minWidth?: string;
maxWidth?: string;
minHeight?: string;
maxHeight?: string;
fontSize?: string;
lineHeight?: string;
fontWeight?: string;
fontFamily?: string;
color?: string;
backgroundColor?: string;
border?: string;
borderRadius?: string;
opacity?: number;
boxShadow?: string;
padding?: string;
margin?: string;
gap?: string;
display?: string;
position?: string;
justifyContent?: string;
alignItems?: string;
textAlign?: string;
zIndex?: number;
}
// Effect properties (from ElementEffectProperties)
interface ElementEffectProperties {
appearAnimation?: AppearAnimationType;
appearAnimationDuration?: string;
appearAnimationEasing?: string;
hoverScale?: string;
hoverOpacity?: string;
hoverBackgroundColor?: string;
hoverColor?: string;
hoverBoxShadow?: string;
hoverTransitionDuration?: string;
focusScale?: string;
focusOpacity?: string;
focusOutline?: string;
focusBoxShadow?: string;
activeScale?: string;
activeOpacity?: string;
activeBackgroundColor?: string;
}
type AppearAnimationType = '' | 'fade' | 'slide-up' | 'slide-down' | 'slide-left' | 'slide-right' | 'scale';
Supporting Types
// Gallery card item
interface GalleryCard {
id: string;
imageUrl: string;
title: string;
description: string;
}
// Gallery info span (brief note badge displayed in header)
interface GalleryInfoSpan {
id: string;
text: string;
}
// Carousel slide item
interface CarouselSlide {
id: string;
imageUrl: string;
caption: string;
}
Element Types
| Type | Description |
|---|---|
navigation_next |
Forward navigation button |
navigation_prev |
Backward navigation button |
spot |
Hotspot/clickable area |
description |
Title + text block |
tooltip |
Icon with hover text overlay |
gallery |
Multi-card image gallery |
carousel |
Image carousel with navigation |
logo |
Logo element |
video_player |
Embedded video with controls |
audio_player |
Embedded audio with controls |
popup |
Popup/modal dialog |
info_panel |
Interactive info panel with sections, images, and 360 embeds |
Note: Element types are stored as TEXT (not ENUM) in the database, allowing new types to be added without migrations. There are 12 predefined element types. Default settings for each type are sourced from project_element_defaults (project-specific) which are automatically snapshotted from element_type_defaults (global) when a project is created. The constructor fetches project-specific defaults via /api/project-element-defaults?projectId=xxx.
Page Schema
interface ConstructorSchema {
elements?: CanvasElement[];
}
// Stored in tour_page.ui_schema_json as JSON string
UI Components
Canvas
The main editing area where elements are placed and manipulated.
<div
ref={canvasRef}
className="relative w-full h-full overflow-hidden"
style={{
backgroundImage: backgroundImageUrl ? `url(${backgroundImageUrl})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{/* Background Video */}
{backgroundVideoUrl && (
<video autoPlay loop muted playsInline className="absolute inset-0 object-cover" />
)}
{/* Canvas Elements */}
{elements.filter(isElementVisibleOnCanvas).map((element) => (
<CanvasElementComponent
key={element.id}
element={element}
isSelected={selectedElementId === element.id}
onSelect={() => selectElement(element.id)}
onDragStart={handleDragStart}
/>
))}
</div>
Controls Panel (Top-Left, Draggable)
┌─────────────────────────────────┐
│ Page: [Dropdown ▼] │
│ Mode: [Edit] [Interact] │
│ [Exit to Assets] │
└─────────────────────────────────┘
Features:
- Page selector dropdown
- Edit/Interact mode toggle
- Exit button to return to assets
Constructor Menu (Right Side, Collapsible)
┌─────────────────────────────────┐
│ CONSTRUCTOR MENU [×] │
├─────────────────────────────────┤
│ Background Image [▼] │
│ Background Video [▼] │
│ Background Audio [▼] │
├─────────────────────────────────┤
│ [+ Navigation: Forward] │
│ [+ Navigation: Back] │
│ [+ Hotspot] │
│ [+ Description] │
│ [+ Tooltip] │
│ [+ Gallery] │
│ [+ Carousel] │
│ [+ Logo] │
│ [+ Video Player] │
│ [+ Audio Player] │
│ [+ Popup] │
├─────────────────────────────────┤
│ [Create New Page] │
│ [Save] │
│ [Save to Stage] │
│ [Exit] │
└─────────────────────────────────┘
Note: The constructor always edits the dev environment. Changes are saved to dev pages automatically. Use "Save to Stage" to promote dev content to the stage environment for preview/review before publishing to production.
Element Editor (Right Side, Collapsible, Tabbed)
Displays context-sensitive properties based on selected element type. The editor uses a tabbed interface (via ElementEditorPanel component):
┌───────────────────────────────────────┐
│ ELEMENT EDITOR [×] │
├───────────────────────────────────────┤
│ [General] [CSS Styles] [Effects] │
├───────────────────────────────────────┤
│ Tab content based on selection │
└───────────────────────────────────────┘
Tab State:
const [elementEditorTab, setElementEditorTab] = useState<'general' | 'css' | 'effects'>('general');
General Tab - Element-specific settings:
- Common: Label, Appear delay (sec), Appear duration (sec)
- Navigation: Direction, Disabled, Icon, Target page, Transition video, Reverse mode
- Tooltip: Icon, Title, Text, Title/Text font families
- Description: Icon, Title, Text, Typography (font sizes, families, colors, background)
- Gallery: Header image, Title, Info spans (badges), Columns, Cards array (image, title, description), Title/Card font families, Carousel button icons/positions/dimensions
- Carousel: Slides array (image, caption), Prev/Next icon URLs, Caption font family
- Media: URL, Autoplay, Loop, Muted
Effects Tab - Uses effect settings from lib/elementEffects.ts:
- Appear Animation: Type (fade, slide-up/down/left/right, scale), Duration, Easing
- Hover Effects: Scale, Opacity (%), Background color, Text color, Box shadow, Transition duration
- Focus Effects: Scale, Opacity (%), Outline, Box shadow
- Active/Press Effects: Scale, Opacity (%), Background color
CSS Styles Tab - Uses StyleSettingsSectionCompact component:
- Dimensions: Width, Height, Min/Max Width, Min/Max Height
- Spacing: Margin, Padding, Gap
- Typography: Font size, Line height, Font weight, Font family
- Colors: Color, Background color
- Borders: Border, Border radius
- Effects: Opacity (%), Box shadow
- Layout: Display, Position, Justify content, Align items, Text align, Z-index
Opacity editors display clamped percentages from 0 to 100; the constructor
converts those values back to CSS opacity strings from 0 to 1 before saving
elements in tour_pages.ui_schema_json.
Element Positioning
Percentage-Based System
Elements use percentage-based positioning for responsive layouts:
// Element position stored as percentages
element.xPercent = 50; // 50% from left
element.yPercent = 30; // 30% from top
// Rendered with CSS
style={{
left: `${element.xPercent}%`,
top: `${element.yPercent}%`,
transform: 'translate(-50%, -50%)', // Center on point
}}
Drag and Drop
// Start drag
const onElementMouseDown = (event: MouseEvent, elementId: string) => {
if (mode !== 'edit') return;
const element = elements.find(e => e.id === elementId);
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
// Calculate offset from element center
const elementX = (element.xPercent / 100) * rect.width;
const elementY = (element.yPercent / 100) * rect.height;
const offsetX = event.clientX - rect.left - elementX;
const offsetY = event.clientY - rect.top - elementY;
dragStateRef.current = { elementId, offsetX, offsetY };
};
// During drag
const onPointerMove = (event: PointerEvent) => {
if (!dragStateRef.current) return;
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
// Calculate new percentage position
const x = event.clientX - rect.left - dragStateRef.current.offsetX;
const y = event.clientY - rect.top - dragStateRef.current.offsetY;
const xPercent = Math.max(0, Math.min(100, (x / rect.width) * 100));
const yPercent = Math.max(0, Math.min(100, (y / rect.height) * 100));
updateElement(dragStateRef.current.elementId, { xPercent, yPercent });
};
// End drag
const onPointerUp = () => {
dragStateRef.current = null;
};
Element Styling
Style Builder Function
const buildCanvasElementStyle = (element: CanvasElement): CSSProperties => {
const style: CSSProperties = {};
// Dimensions
if (element.width) style.width = element.width;
if (element.height) style.height = element.height;
if (element.minWidth) style.minWidth = element.minWidth;
if (element.maxWidth) style.maxWidth = element.maxWidth;
if (element.minHeight) style.minHeight = element.minHeight;
if (element.maxHeight) style.maxHeight = element.maxHeight;
// Spacing
if (element.margin) style.margin = element.margin;
if (element.padding) style.padding = element.padding;
if (element.gap) style.gap = element.gap;
// Typography
if (element.fontSize) style.fontSize = element.fontSize;
if (element.lineHeight) style.lineHeight = element.lineHeight;
if (element.fontWeight) style.fontWeight = element.fontWeight;
if (element.fontFamily) style.fontFamily = element.fontFamily;
if (element.color) style.color = element.color;
// Appearance
if (element.backgroundColor) style.backgroundColor = element.backgroundColor;
if (element.border) style.border = element.border;
if (element.borderRadius) style.borderRadius = element.borderRadius;
if (element.opacity !== undefined) style.opacity = element.opacity;
if (element.boxShadow) style.boxShadow = element.boxShadow;
// Layout
if (element.display) style.display = element.display;
if (element.position) style.position = element.position;
if (element.justifyContent) style.justifyContent = element.justifyContent;
if (element.alignItems) style.alignItems = element.alignItems;
if (element.textAlign) style.textAlign = element.textAlign;
if (element.zIndex !== undefined) style.zIndex = element.zIndex;
return style;
};
zIndex is also applied to the outer absolutely-positioned element wrapper in
both constructor and runtime rendering. This is required for sibling elements to
stack according to the CSS panel value instead of only by array/render order.
Animation Timing
Element Visibility
Elements can appear and disappear based on timing:
interface CanvasElement {
appearDelaySec: number; // Seconds before element appears
appearDurationSec: number | null; // Duration visible (null = forever)
}
const isElementVisibleOnCanvas = (element: CanvasElement): boolean => {
const delay = element.appearDelaySec || 0;
// Not yet visible
if (canvasElapsedSec < delay) return false;
// Always visible (no duration limit)
if (element.appearDurationSec === null) return true;
// Check if within visibility window
const endTime = delay + element.appearDurationSec;
return canvasElapsedSec <= endTime;
};
Canvas Timer
// Track elapsed time for animation
const [canvasElapsedSec, setCanvasElapsedSec] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCanvasElapsedSec((prev) => prev + 0.1);
}, 100);
return () => clearInterval(interval);
}, []);
// Reset on page change
useEffect(() => {
setCanvasElapsedSec(0);
}, [activePageId]);
Performance Note: The 100ms timer causes component re-renders every 100ms. This is intentional for element visibility animations but requires careful handling of image components (see Image Rendering Strategy below).
Background Media
Image Rendering Strategy
The constructor uses a conditional rendering approach for images to prevent performance issues:
// For blob URLs: use native <img> (no re-fetch on re-render)
// For regular URLs: use Next.js Image (optimization benefits)
{url.startsWith('blob:') ? (
<img src={url} className="..." />
) : (
<NextImage src={url} fill unoptimized />
)}
Why this matters:
- Next.js Image component re-fetches the
srcon every render, even withunoptimizedprop - The 100ms canvas timer causes ~10 re-renders per second
- For blob URLs this would cause thousands of unnecessary fetch requests
- Native
<img>tags are cached by the browser and don't re-fetch
Applied to:
- Background images (blob URLs from preload cache)
- Element icons (tooltip, description, gallery, carousel)
- Any dynamically resolved asset URL that may be a blob
| URL Type | Component | Reason |
|---|---|---|
blob:... |
Native <img> |
No re-fetch, already in memory |
https://... |
<NextImage unoptimized> |
Optimization benefits, less frequent re-renders |
| Presigned S3 | <NextImage unoptimized> |
Falls back gracefully |
Background Image
// Stored in tour_page
background_image_url: string;
// Applied to canvas
style={{
backgroundImage: `url(${backgroundImageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
Background Video
Background videos support configurable playback settings for precise control over autoplay, looping, and time boundaries.
// Stored in tour_page
background_video_url: string;
background_video_autoplay: boolean; // Default: true
background_video_loop: boolean; // Default: true
background_video_muted: boolean; // Default: true
background_video_start_time: number | null; // Start time in seconds (0.1s precision)
background_video_end_time: number | null; // End time in seconds (0.1s precision)
// Rendered using useBackgroundVideoPlayback hook
const { videoRef } = useBackgroundVideoPlayback({
videoUrl: backgroundVideoUrl,
autoplay: backgroundVideoAutoplay,
loop: backgroundVideoLoop,
muted: backgroundVideoMuted,
startTime: backgroundVideoStartTime,
endTime: backgroundVideoEndTime,
});
<video
ref={videoRef}
src={backgroundVideoUrl}
autoPlay={backgroundVideoAutoplay}
loop={backgroundVideoEndTime == null ? backgroundVideoLoop : false}
muted={backgroundVideoMuted}
playsInline
className="absolute inset-0 w-full h-full object-cover -z-10"
/>
Playback Settings (configured in BackgroundSettingsEditor):
| Setting | Type | Default | Description |
|---|---|---|---|
| Autoplay | boolean | true | Start video playback automatically |
| Loop | boolean | true | Loop video continuously |
| Sound | boolean | false (muted) | Enable audio (toggled as "Sound" in UI) |
| Start (sec) | number | null | Start playback at this time |
| End (sec) | number | null | Stop/loop at this time |
Note: When end_time is set, native HTML5 loop is disabled and looping is handled via JavaScript to properly seek back to start_time.
Background 360/Embed
// Stored in tour_page
background_embed_url: string;
// Rendered by CanvasBackground as a full-canvas iframe layer
<iframe
src={backgroundEmbedUrl}
className="absolute inset-0 h-full w-full border-0"
/>
The constructor background dropdown includes Background 360, sourced from asset_type='embed' assets. Selecting a 360/embed background clears image and video background URLs; background audio remains independent.
Constructor asset selectors load the full project asset list through
useConstructorData() and then filter options client-side by asset_type and
type for image, background image, video, audio, transition, icon, and embed
dropdowns. There is no frontend limit applied to the project asset query.
Background Audio
// Stored in tour_page
background_audio_url: string;
// Rendered as hidden audio
<audio
src={backgroundAudioUrl}
autoPlay
loop
className="hidden"
/>
Transition Configuration
Transition Properties
// On navigation elements
transitionVideoUrl: string; // Forward transition video
transitionReverseMode: 'auto_reverse' | 'separate_video';
reverseVideoUrl: string; // Separate back transition video
transitionDurationSec: number; // Auto-detected from video
Reverse Modes
| Mode | Description |
|---|---|
auto_reverse |
Play forward video in reverse for back navigation |
separate_video |
Use separate video for back navigation |
Transition Preview
interface TransitionPreviewState {
videoUrl: string;
storageKey: string; // Raw storage path for cache lookup (enables presigned URL independence)
reverseVideoUrl?: string;
reverseStorageKey?: string;
reverseMode: 'none' | 'reverse' | 'separate';
durationSec?: number;
title: string;
}
// Preview workflow
const previewTransition = async (element: CanvasElement, direction: 'forward' | 'back') => {
// 1. Set preview state with storage key for cache lookup
setTransitionPreview({
videoUrl: element.transitionVideoUrl,
storageKey: element.transitionVideoUrl, // Raw path for instant blob URL lookup
reverseMode: direction === 'forward' ? 'none' :
element.transitionReverseMode === 'separate_video' ? 'separate' : 'reverse',
reverseVideoUrl: element.reverseVideoUrl,
reverseStorageKey: element.reverseVideoUrl,
durationSec: element.transitionDurationSec,
title: `${element.navLabel || element.label} · ${direction}`,
});
// 2. useTransitionPlayback resolves video source:
// a) Try getReadyBlobUrl(storageKey) - instant O(1) Map lookup
// b) Try getCachedBlobUrl(storageKey) - Cache API lookup (~5ms)
// c) Fallback to network fetch with presigned URL
// 3. Video loads and plays (forward or reverse)
// 4. On complete: switch to target page via usePageSwitch
// 5. Clear preview state
};
Gallery Carousel Overlay
When a user clicks on a gallery card in interact mode, a fullscreen carousel overlay opens showing the card images. The overlay uses GalleryCarouselOverlay component with customizable navigation buttons.
// Gallery element carousel settings (stored in element)
interface GalleryCarouselSettings {
galleryCarouselPrevIconUrl?: string; // Previous button icon
galleryCarouselNextIconUrl?: string; // Next button icon
galleryCarouselBackIconUrl?: string; // Back/close button icon
galleryCarouselBackLabel?: string; // Back button label (default: 'BACK')
// Button positions (percentage-based)
galleryCarouselPrevX?: number;
galleryCarouselPrevY?: number;
galleryCarouselNextX?: number;
galleryCarouselNextY?: number;
galleryCarouselBackX?: number;
galleryCarouselBackY?: number;
// Button dimensions (CSS values)
galleryCarouselPrevWidth?: string;
galleryCarouselPrevHeight?: string;
galleryCarouselNextWidth?: string;
galleryCarouselNextHeight?: string;
galleryCarouselBackWidth?: string;
galleryCarouselBackHeight?: string;
}
// Constructor edit mode: buttons are draggable for positioning
{activeGalleryCarousel && (
<GalleryCarouselOverlay
cards={activeGalleryCarousel.element.galleryCards || []}
initialIndex={activeGalleryCarousel.initialIndex}
onClose={() => setActiveGalleryCarousel(null)}
resolveUrl={resolveUrlWithBlob}
prevIconUrl={element.galleryCarouselPrevIconUrl}
nextIconUrl={element.galleryCarouselNextIconUrl}
backIconUrl={element.galleryCarouselBackIconUrl}
backLabel={element.galleryCarouselBackLabel || 'BACK'}
prevX={element.galleryCarouselPrevX}
prevY={element.galleryCarouselPrevY}
// ... other position/dimension props
isEditMode={isConstructorEditMode}
onButtonPositionChange={handleCarouselButtonPositionChange}
/>
)}
Features:
- Fullscreen overlay with centered card image
- Previous/Next navigation with customizable icons
- Back button to close overlay
- In constructor edit mode: drag buttons to reposition them
- Button positions saved as percentage coordinates in element settings
Asset Selection
Asset Filtering
// Image assets for backgrounds
const backgroundImageOptions = assets.filter(
(a) => a.asset_type === 'image' && isBackgroundImageAsset(a)
);
// Video assets for backgrounds and players
const videoAssetOptions = assets.filter(
(a) => a.asset_type === 'video'
);
// Transition videos (tagged in asset name or type)
const transitionVideoOptions = assets.filter(
(a) => a.asset_type === 'video' && /\[TRANSITION\]/i.test(a.name)
);
// Icon assets for buttons and tooltips
const iconAssetOptions = assets.filter(
(a) => a.asset_type === 'image' && isIconAsset(a)
);
Asset Selector Component
<select
value={selectedAssetUrl}
onChange={(e) => updateElement({ iconUrl: e.target.value })}
>
<option value="">Select asset...</option>
{iconAssetOptions.map((asset) => (
<option key={asset.id} value={asset.cdn_url}>
{asset.name}
</option>
))}
{/* Fallback for custom URLs */}
{selectedAssetUrl && !iconAssetOptions.find(a => a.cdn_url === selectedAssetUrl) && (
<option value={selectedAssetUrl}>
{selectedAssetUrl} (custom)
</option>
)}
</select>
Duration Detection
Media Duration Probing
const probeMediaDuration = async (url: string): Promise<number | null> => {
return new Promise((resolve) => {
const media = document.createElement('video');
media.preload = 'metadata';
const timeout = setTimeout(() => {
media.remove();
resolve(null);
}, 12000);
media.onloadedmetadata = () => {
clearTimeout(timeout);
const duration = media.duration;
media.remove();
if (isFinite(duration) && duration > 0) {
resolve(duration);
} else {
resolve(null);
}
};
media.onerror = () => {
clearTimeout(timeout);
media.remove();
resolve(null);
};
media.src = url;
});
};
Auto-Populate Duration
// When transition video selected
const handleTransitionVideoChange = async (videoUrl: string) => {
updateElement({ transitionVideoUrl: videoUrl });
// Auto-detect duration
const duration = await probeMediaDuration(videoUrl);
if (duration) {
updateElement({ transitionDurationSec: duration });
}
};
If a page save includes an auto-reverse transition video that fails the backend
guardrails, the save request fails with an explicit validation message. The
guardrails currently cover both very large source files (16 GiB+) and videos
whose stored resolution and duration imply too much decoded frame data for the
current VM. The constructor surfaces that message in its top-left error banner,
so the editor sees immediately why the page was not saved.
Preload Integration
usePreloadOrchestrator
The preload orchestrator handles asset caching and provides instant blob URLs for smooth transitions.
const preloadOrchestrator = usePreloadOrchestrator({
pages: pages.map((p) => ({
id: p.id,
background_image_url: p.background_image_url,
background_video_url: p.background_video_url,
background_audio_url: p.background_audio_url,
})),
pageLinks, // Navigation connections from extractPageLinksAndElements
elements: allPagesPreloadElements, // Asset URLs from extractPageLinksAndElements
currentPageId: activePageId,
enabled: !isLoading && !!activePageId,
// maxNeighborDepth defaults to 1 - only preload immediate neighbors
});
// Page switch hook for smooth background transitions (uses blob URLs from preload cache)
const pageSwitch = usePageSwitch({
preloadCache: preloadOrchestrator
? {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
preloadedUrls: preloadOrchestrator.preloadedUrls,
}
: undefined,
});
Blob URL System
Assets are downloaded directly from S3, cached in the browser's Cache API, and served as blob URLs for instant display. The system uses storage key mapping to enable reliable cache lookups regardless of which presigned URL was used for download.
// Lookup by storage key (canonical path - most reliable)
const readyUrl = preloadOrchestrator.getReadyBlobUrl(storagePath);
// Lookup by resolved URL (fallback)
const readyUrl = preloadOrchestrator.getReadyBlobUrl(resolvedUrl);
// Fallback: get cached blob URL from Cache API
const cachedUrl = await preloadOrchestrator.getCachedBlobUrl(storagePath);
// Check if URL is preloaded
const isReady = preloadOrchestrator.preloadedUrls.has(storagePath);
Preload Flow:
- Collect storage paths from current page + neighbors (depth=1)
- Batch fetch presigned URLs from backend (
POST /api/file/presign) - Download asset directly from S3 (or via proxy if CORS not configured)
- Store in browser Cache API under download URL
- Store in Cache API under storage key (enables post-refresh lookups)
- Create blob URL from cache
- Pre-decode images for instant paint
- Map both download URL and storage key to blob URL in readyBlobUrlsRef
Storage Key Mapping (Key Feature):
The storage key is the canonical identifier (e.g., assets/project-123/video.mp4) that remains constant, while presigned URLs change with each request (different signatures). By mapping storage keys to blob URLs:
| Scenario | Download URL | Storage Key | Lookup Result |
|---|---|---|---|
| Same session | https://s3...?Sig=ABC |
assets/vid.mp4 |
✅ Instant via storage key |
| New presigned URL | https://s3...?Sig=XYZ |
assets/vid.mp4 |
✅ Instant via storage key |
| Page refresh | N/A (cache cleared) | assets/vid.mp4 |
✅ From Cache API |
usePageSwitch Integration
The constructor uses usePageSwitch for smooth page transitions without black flashes:
// Helper to switch pages without flash
const switchToPage = useCallback(async (page: TourPage | null) => {
// Update storage path state for editing/saving purposes
setBackgroundImageUrl(page?.background_image_url || '');
setBackgroundVideoUrl(page?.background_video_url || '');
setBackgroundAudioUrl(page?.background_audio_url || '');
// Use hook to resolve and set blob URLs for display
await pageSwitch.switchToPage(
page
? {
id: page.id,
background_image_url: page.background_image_url,
background_video_url: page.background_video_url,
background_audio_url: page.background_audio_url,
}
: null,
);
}, [pageSwitch]);
// Canvas background uses resolved blob URLs
// User's selection (backgroundImageUrl) takes priority over pageSwitch state
// This ensures manual changes via dropdown are immediately reflected
const backgroundImageSrc = backgroundImageUrl
? resolveUrlWithBlob(backgroundImageUrl) // Resolves via blob cache or direct URL
: pageSwitch.currentBgImageUrl; // Fallback during transitions
The resolveUrlWithBlob callback depends on preloadOrchestrator.readyUrlsVersion to re-render when blob URLs become ready after preload.
The hook provides:
- previousBgImageUrl - Previous background for overlay during transition
- currentBgImageUrl - Resolved blob URL (or presigned URL) for current page
- isSwitching - Whether a page switch is in progress
- isNewBgReady - Whether the new background has been decoded and is ready to paint
- markBackgroundReady() - Call when background image loads
- clearPreviousBackground() - Remove the overlay after new background is ready
Extracting Page Links and Elements
Navigation elements and preload assets are extracted from ui_schema_json using the shared utility:
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
// In loadData():
const { pageLinks, preloadElements } = extractPageLinksAndElements(pageRows);
setPageLinks(pageLinks); // For neighbor graph (navigation connections)
setAllPagesPreloadElements(preloadElements); // For preload queue (asset URLs)
The utility extracts:
- pageLinks - Navigation connections (
from_pageId → to_pageId) for the neighbor graph - preloadElements - Element asset URLs (icons, media, transitions) for preloading
This enables preloading of connected pages and their assets based on navigation structure.
useTransitionPlayback Integration
The constructor uses useTransitionPlayback for video transition playback with preload cache integration:
const { isBuffering: isReverseBuffering } = useTransitionPlayback({
videoRef: transitionVideoRef,
transition: transitionPreview
? {
videoUrl: resolveAssetPlaybackUrl(transitionPreview.videoUrl),
storageKey: transitionPreview.storageKey, // Raw path for cache lookup
reverseMode: transitionPreview.reverseMode,
reverseVideoUrl: transitionPreview.reverseVideoUrl
? resolveAssetPlaybackUrl(transitionPreview.reverseVideoUrl)
: undefined,
durationSec: transitionPreview.durationSec,
targetPageId: pendingNavigationPageId || undefined,
displayName: transitionPreview.title,
}
: null,
onComplete: async (targetPageId) => {
// Switch to target page using usePageSwitch
await switchToPage(targetPage);
// Clear transition state
setTransitionPreview(null);
},
preload: {
preloadedUrls: preloadOrchestrator.preloadedUrls,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, // Instant blob URL lookup
},
});
Key Parameters:
| Parameter | Purpose |
|---|---|
storageKey |
Raw storage path for cache lookup (independent of presigned URL signature) |
getReadyBlobUrl |
O(1) instant blob URL lookup by storage key |
getCachedBlobUrl |
Cache API lookup (~5ms) for post-refresh scenarios |
preloadedUrls |
Set of URLs that have been preloaded |
Video Source Resolution Order:
getReadyBlobUrl(storageKey)- instant (same session)getCachedBlobUrl(storageKey)- Cache API (post-refresh)getReadyBlobUrl(resolvedUrl)- fallbackgetCachedBlobUrl(resolvedUrl)- fallback- Network fetch (last resort)
Save Workflow
Environment Model
The constructor implements a dev → stage → production publishing flow:
┌─────────────────┐ Save to Stage ┌─────────────────┐ Publish ┌─────────────────┐
│ dev (edit) │ ───────────────────── │ stage (review) │ ─────────────── │ production │
└─────────────────┘ └─────────────────┘ └─────────────────┘
▲
│
Constructor
edits here
- Save - Saves current page to the dev environment
- Save and Save to Stage buttons reserve their timestamp subtitle row and share
the same compact control height, so
Just now/last-stage status text does not misalign the toolbar. - Duplicate Page - Saves the active dev page, then immediately creates a new independent copy as the last page with copied settings and elements
- Delete Page - Confirms and deletes the active dev page, then selects the next page in presentation order when available
- Save to Stage - Copies ALL dev content to stage for review (calls
POST /publish/save-to-stage) - Publish - Copies stage content to production (done from project workspace page)
Save Function (to Dev)
const saveConstructor = async () => {
setSaving(true);
try {
// Serialize elements to JSON
const ui_schema_json = JSON.stringify({
elements: elements,
});
// Update tour page via API (always saves to dev environment)
await dispatch(tourPagesActions.update({
id: activePageId,
data: {
ui_schema_json,
background_image_url: backgroundImageUrl,
background_video_url: backgroundVideoUrl,
background_audio_url: backgroundAudioUrl,
},
}));
// Reload data to refresh
await loadData();
setSuccessMessage('Saved successfully');
} catch (error) {
setErrorMessage('Failed to save');
} finally {
setSaving(false);
}
};
Save to Stage Function
Note: Save to Stage is non-blocking - the API returns immediately while the copy operation continues in the background.
const saveToStage = async () => {
if (!projectId) {
onError?.('Project ID is required to save to stage.');
return;
}
// First save current work to dev
await saveConstructor();
try {
setIsSavingToStage(true);
// Non-blocking: returns immediately, copy runs in background
await axios.post('/publish/save-to-stage', { projectId });
onSuccess?.('Saved to stage.');
} catch (error) {
const message = error?.response?.data?.message || error?.message || 'Failed to save to stage.';
onError?.(message);
} finally {
setIsSavingToStage(false);
}
};
Data Persistence
| Field | Storage | Environment |
|---|---|---|
| Elements | tour_page.ui_schema_json (JSON string) |
dev (edited), stage/prod (copied) |
| Navigation | tour_page.ui_schema_json (targetPageSlug) |
dev (edited), stage/prod (copied) |
| Transitions | tour_page.ui_schema_json (transitionVideoUrl) |
dev (edited), stage/prod (copied) |
| Background image | tour_page.background_image_url |
dev (edited), stage/prod (copied) |
| Background video | tour_page.background_video_url |
dev (edited), stage/prod (copied) |
| Background 360/embed | tour_page.background_embed_url |
dev (edited), stage/prod (copied) |
| Background video settings | tour_page.background_video_* (autoplay, loop, muted, start_time, end_time) |
dev (edited), stage/prod (copied) |
| Background audio | tour_page.background_audio_url |
dev (edited), stage/prod (copied) |
| Audio tracks | project_audio_tracks table |
dev (edited), stage/prod (copied) |
Note: All element data, navigation links, and transitions are stored directly in ui_schema_json. No separate tables are used.
Page Duplication
The constructor supports one-click duplication for pages in the dev environment:
- Duplicate saves the active page first so the new page matches the current constructor state.
- The constructor calls
POST /api/tour_pages/:id/duplicatewith a generated copy name and slug. - The backend duplicates the page record, appends it at the end of the
presentation order, deep-copies
ui_schema_json, and regenerates element and nested item IDs. - Asset URLs, transition URLs, and navigation
targetPageSlugvalues are preserved. The duplicated page is selected after reload and is independent from the source page.
Duplication is dev-only. Stage and production receive duplicated pages only through Save to Stage and Publish.
Page Deletion
The constructor toolbar exposes a delete action for the active page when the
user has DELETE_TOUR_PAGES. Deletion uses the same DELETE /api/tour_pages/:id
endpoint as the Pages & Transitions page, but shows a confirmation modal before
calling the API. After deletion, the constructor invalidates tour page queries,
selects the next page in display order, or clears the editor when no pages
remain.
Backend Publish Flow:
- Save to Stage (non-blocking):
POST /publish/save-to-stage→PublishService.saveToStage()→copyEnvironment(dev, stage)(runs in background) - Publish to Prod (blocking):
POST /publish→PublishService.publishToProduction()→copyEnvironment(stage, production)
The copyEnvironment method:
- Fetches all
tour_pagesandproject_audio_tracksfrom source environment - Hard-deletes existing records in target environment (
force: truefor paranoid models) - Bulk creates cloned records with target environment
- Preserves slugs and
targetPageSlugreferences (consistent across environments)
State Management
State is distributed across the main component and specialized hooks.
Hook-Managed State
| Hook | State Managed |
|---|---|
useConstructorElements |
elements, selectedElementId, selectedElement, constructor-local element clipboard, galleryCards, galleryInfoSpans, carouselSlides |
useConstructorPageActions |
isSaving, isSavingToStage, isCreatingPage, isDuplicatingPage, page save/create/duplicate API calls |
useTransitionPreview |
transitionPreview, pendingNavigationPageId |
useCanvasElapsedTime |
canvasElapsedSec |
useCanvasElementDrag |
Drag state for element positioning |
useMediaDurationProbe |
Media duration cache with getDuration, getDurationNote |
useIconPreload |
preloadedIconUrlMap |
useDraggable |
Panel positions (controls, menu, editor) |
usePageSwitch |
currentBgImageUrl, currentBgVideoUrl, currentBgAudioUrl, previousBgImageUrl, isSwitching, isNewBgReady |
Local Component State
// Page management
const [pages, setPages] = useState<TourPage[]>([]);
const [activePageId, setActivePageId] = useState('');
const [isCreatePageModalActive, setIsCreatePageModalActive] = useState(false);
const [isDeletePageModalActive, setIsDeletePageModalActive] = useState(false);
const [isDeletingPage, setIsDeletingPage] = useState(false);
// Background URLs (storage paths for editing)
const [backgroundImageUrl, setBackgroundImageUrl] = useState('');
const [backgroundVideoUrl, setBackgroundVideoUrl] = useState('');
const [backgroundAudioUrl, setBackgroundAudioUrl] = useState('');
// UI state
const [constructorInteractionMode, setConstructorInteractionMode] =
useState<'edit' | 'interact'>('edit');
const [selectedMenuItem, setSelectedMenuItem] = useState<EditorMenuItem>('none');
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isEditorCollapsed, setIsEditorCollapsed] = useState(false);
const [elementEditorTab, setElementEditorTab] = useState<'general' | 'css' | 'effects'>('general');
// Loading and messages
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [successMessage, setSuccessMessage] = useState('');
// Element defaults (loaded from project_element_defaults)
const [uiElementDefaultsByType, setUiElementDefaultsByType] = useState<
Partial<Record<CanvasElementType, Partial<CanvasElement>>>
>({});
Refs
// DOM references
const canvasRef = useRef<HTMLDivElement>(null);
const elementEditorRef = useRef<HTMLDivElement>(null);
const transitionVideoRef = useRef<HTMLVideoElement | null>(null);
// Page initialization tracking
const lastInitializedPageIdRef = useRef<string | null>(null);
const didSetInitialCanvasFocus = useRef(false);
const selectedElementIdRef = useRef<string>('');
Element CRUD Operations
Element CRUD operations are managed by the useConstructorElements hook.
Hook Usage
const {
elements,
setElements,
selectedElementId,
selectedElement,
copiedElement,
canPasteElement,
selectElement,
clearSelection,
addElement,
updateSelectedElement,
removeSelectedElement,
copySelectedElement,
pasteCopiedElement,
galleryCards, // { add, update, remove } for gallery cards
galleryInfoSpans, // { add, update, remove } for info span badges
carouselSlides, // { add, update, remove } for carousel slides
updateElementPosition,
normalizeNavigationType, // Convert between navigation_next/navigation_prev
} = useConstructorElements({
initialElements: [],
elementDefaultsByType: uiElementDefaultsByType,
allowedNavigationTypes,
initialSelectedElementId: elementIdFromRoute, // Route parameter support
onElementSelected: () => setSelectedMenuItem('none'),
onSelectionCleared: () => setSelectedMenuItem('none'),
onElementAdded: () => setSuccessMessage('Element added.'),
onElementRemoved: () => setSuccessMessage('Element removed.'),
onElementCopied: () => setSuccessMessage('Element copied.'),
onElementPasted: () => setSuccessMessage('Element pasted.'),
});
Element Copy/Paste
The constructor supports separate Copy and Paste actions for UI elements in edit mode. Copy stores the selected element in a constructor-local clipboard. The clipboard survives page switches inside the same constructor session, so an element can be copied from one page and pasted into another page in the same presentation.
Paste appends a new independent element to the active page's elements array
and selects it for editing. The clone preserves all non-identity settings from
the source element, including position, dimensions, navigation targets, links,
media URLs, transition settings, effects, and CSS styles. Only instance IDs are
regenerated:
- Element
id galleryCards[].idgalleryInfoSpans[].idcarouselSlides[].idinfoPanelSections[].idinfoPanelSections[].spans[].idinfoPanelSections[].images[].id
Pasted elements are not auto-saved. They become persistent when the existing
Save or Save to Stage flow serializes the active page's elements into
tour_pages.ui_schema_json. The constructor keeps a synchronous latest-elements
reference so a pasted element is included even when the user clicks Save or Stage
immediately after Paste, before React has completed a visible re-render.
The main constructor toolbar exposes dedicated element Copy and Paste buttons in
an Elements actions group. Page selector, page reorder, new page, duplicate,
delete, and background controls are kept in a separate Page actions group so
page-level and element-level copy controls are visually distinct. Copy is
enabled when an element is selected. Paste is enabled when the constructor
clipboard contains an element, including after switching to another page in the
same presentation. Keyboard shortcuts are also available in edit mode:
Cmd/Ctrl + Ccopies the selected elementCmd/Ctrl + Vpastes the copied element into the active page
Shortcuts are ignored while typing in inputs, textareas, selects, or contenteditable fields so native text editing behavior is preserved.
Create Element
Element creation is a two-step process: create base element, then merge with project defaults.
// Inside useConstructorElements hook
const addElement = (type: CanvasElementType) => {
// Step 1: Create base element with hardcoded defaults
const baseElement = createDefaultElement(type, elements.length);
// Step 2: Merge with project-specific defaults from project_element_defaults
const newElement = mergeElementWithDefaults(
baseElement,
elementDefaultsByType[type], // Project defaults loaded at startup
);
setElements([...elements, newElement]);
selectElement(newElement.id);
};
// createDefaultElement provides structure-specific defaults (gallery cards, carousel slides, etc.)
// mergeElementWithDefaults applies styling defaults (colors, fonts, sizes) from project settings
Note: The uiElementDefaultsByType map is populated during constructor initialization by fetching /api/project-element-defaults?projectId=xxx and normalizing the response with buildElementDefaultsMap().
Load Elements (from ui_schema_json)
When loading existing elements from a page, the merge uses preferElementValues: true to preserve saved values:
// In the activePage useEffect
const normalizedElements = schema.elements.map((item) => {
const normalizedElement = { /* normalize fields from JSON */ };
// Merge with defaults, but PREFER existing element values
return mergeElementWithDefaults(
normalizedElement,
uiElementDefaultsByType[elementType],
{ preferElementValues: true } // Key difference from create
);
});
Merge Behavior:
- Creating new elements: Defaults override base element (project styling applied)
- Loading existing elements: Element values preserved, defaults only fill empty/null fields
- Style properties: If element has empty/null/undefined style prop, use default value
Update Element
// Via hook
updateSelectedElement({ label: 'New Label' });
// Or update any element by ID
updateElement(elementId, { xPercent: 50, yPercent: 50 });
Delete Element
// Via hook - removes selected element and clears selection
removeSelectedElement();
Page Management
Page operations are managed by the useConstructorPageActions hook.
Hook Usage
const {
isSaving,
isSavingToStage,
isCreatingPage,
saveConstructor,
saveToStage,
createPage,
} = useConstructorPageActions({
projectId,
pages,
activePage,
activePageId,
elements,
backgroundImageUrl,
backgroundVideoUrl,
backgroundAudioUrl,
onReload: loadData,
onSetActivePageId: setActivePageId,
onError: setErrorMessage,
onSuccess: setSuccessMessage,
});
Create Page
Pages are always created in the dev environment. They must be promoted to stage via "Save to Stage" and then published to production.
// Via hook - handles loading state, API call, and reload
await createPage();
Switch Page
Page switching uses usePageSwitch for smooth background transitions:
const switchToPage = useCallback(async (page: TourPage | null) => {
// Update storage path state for editing/saving
setBackgroundImageUrl(page?.background_image_url || '');
setBackgroundVideoUrl(page?.background_video_url || '');
setBackgroundAudioUrl(page?.background_audio_url || '');
// Use hook to resolve blob URLs and switch with smooth transition
await pageSwitch.switchToPage(
page ? {
id: page.id,
background_image_url: page.background_image_url,
background_video_url: page.background_video_url,
background_audio_url: page.background_audio_url,
} : null,
() => setActivePageId(page?.id || ''),
);
}, [pageSwitch]);
Reorder Pages
The constructor toolbar provides compact up/down arrow buttons next to the page
selector. The page dropdown labels include an explicit ordinal prefix
(1. Page name, 2. Page name, etc.) so the current order is visible before
and after a move. The up button is disabled for the first page, the down button
is disabled for the last page, and both buttons are disabled while a reorder
request is in flight.
Reordering posts the full ordered page ID list to
POST /api/tour_pages/reorder, which updates only sort_order for pages in
the dev environment. The active page remains selected after the query cache is
invalidated and pages are reloaded.
Runtime presentations use the same sort_order; the first page after sorting
is the presentation entry page. Stage receives reordered dev pages only after
Save to Stage, and production receives reordered stage pages only after Publish.
The reorder endpoint intentionally rejects non-dev environments. Users cannot directly reorder stage or production pages from the constructor; those environments are updated only by the existing promotion flow.
Icon Preloading
Icon preloading is managed by the useIconPreload hook.
Hook Usage
const iconPreloadTargets = useMemo(() => {
const preloadableTypes: CanvasElementType[] = [
'navigation_next', 'navigation_prev', 'tooltip', 'description',
];
return elements
.filter(el => preloadableTypes.includes(el.type) && Boolean(el.iconUrl))
.map(el => resolveAssetPlaybackUrl(el.iconUrl))
.filter(Boolean);
}, [elements]);
const { preloadedUrlMap: preloadedIconUrlMap } = useIconPreload({
iconUrls: iconPreloadTargets,
enabled: !isLoading,
});
Page Image Preloading
Page backgrounds and element icons are preloaded by usePreloadOrchestrator and served as instant blob URLs. The usePageSwitch hook handles smooth transitions between pages by:
- Resolving blob URLs from preload cache
- Pre-decoding images before display
- Managing previous/current background overlay during transitions
Key Files Reference
| File | Purpose |
|---|---|
pages/constructor.tsx |
Main constructor page component (orchestration layer) |
types/constructor.ts |
TypeScript types for elements, normalization utilities, and grouped props for ElementEditorPanel |
Constructor-Specific Hooks
| File | Purpose |
|---|---|
hooks/useConstructorElements.ts |
Element CRUD, defaults merging, nested item helpers, latest-elements ref, and constructor-local element clipboard |
hooks/useConstructorPageActions.ts |
Page save/create/duplicate and Save to Stage operations |
hooks/useTransitionPreview.ts |
Transition preview state management |
hooks/useCanvasElapsedTime.ts |
Canvas elapsed time tracking for element visibility |
hooks/useCanvasElementDrag.ts |
Element dragging with percentage positioning |
hooks/useMediaDurationProbe.ts |
Media duration detection with caching |
hooks/useIconPreload.ts |
Icon preloading for smooth rendering |
hooks/useOutsideClick.ts |
Outside click detection for deselection |
hooks/useDraggable.ts |
Draggable panel positioning |
Constructor Components
| File | Purpose |
|---|---|
components/Constructor/CanvasBackground.tsx |
Background image/video/audio rendering |
components/Constructor/CanvasElement.tsx |
Canvas element rendering |
components/Constructor/ConstructorToolbar.tsx |
Floating main toolbar with mode, Page actions, Elements actions, Save, Stage, Exit, and Collapse controls |
components/Constructor/ConstructorMenu.tsx |
Legacy/collapsible menu component retained for compatibility |
components/Constructor/ConstructorControlsPanel.tsx |
Legacy/secondary controls panel |
components/Constructor/ElementEditorPanel.tsx |
Element editor panel with tabs |
components/Constructor/ElementEditorHeader.tsx |
Editor header with collapse/remove controls that do not start panel drag |
components/Constructor/TransitionPreviewOverlay.tsx |
Transition video overlay |
components/Constructor/PageSelector.tsx |
Page dropdown selector with ordinal labels and toolbar sizing support |
components/Constructor/InteractionModeToggle.tsx |
Edit/Interact mode toggle with compact toolbar layout |
components/Constructor/AssetSelectCompact.tsx |
Compact asset dropdown |
components/Constructor/BackgroundSettingsEditor.tsx |
Background media settings form |
components/Constructor/CreateTransitionForm.tsx |
Transition creation form |
components/Constructor/MenuActionButton.tsx |
Menu action button component |
Helper Libraries
| File | Purpose |
|---|---|
lib/elementDefaults.ts |
Element type labels, create functions, type guards |
lib/constructorHelpers.ts |
Asset helpers (getAssetLabel, getAssetSourceValue, clamp) |
lib/navigationHelpers.ts |
Navigation target resolution, direction helpers |
lib/mediaHelpers.ts |
Media duration and format helpers |
lib/extractPageLinks.ts |
Extract pageLinks and preloadElements from ui_schema_json |
lib/elementStyles.ts |
Build CSS styles from element properties |
lib/elementEffects.ts |
Build animation and interaction effects (hover, focus, active, appear) |
UI Element Components
| File | Purpose |
|---|---|
components/UiElements/GalleryCarouselOverlay.tsx |
Fullscreen carousel overlay for gallery cards with draggable navigation buttons |
components/UiElements/UiElementRenderer.tsx |
Generic element renderer for runtime/preview |
components/UiElements/ElementPreview.tsx |
Element preview component |
Shared Runtime Hooks
| File | Purpose |
|---|---|
hooks/usePreloadOrchestrator.ts |
Asset preloading with blob URL caching |
hooks/usePageSwitch.ts |
Smooth page transitions with preload integration |
hooks/useTransitionPlayback.ts |
Transition video playback |
hooks/useBackgroundTransition.ts |
Background transition clearing |
hooks/useBackgroundVideoPlayback.ts |
Background video playback with time control |
Other Files
| File | Purpose |
|---|---|
config/preload.config.ts |
Preload settings: priority weights, maxDepth, asset field names |
components/Assets/useAssetUploader.ts |
File upload |
stores/tour_pages/tour_pagesSlice.ts |
Page state management |
stores/assets/assetsSlice.ts |
Asset state management |
pages/element-type-defaults.tsx |
Global element defaults admin page |
pages/element-type-defaults/[id].tsx |
Global element default editor |
pages/project-element-defaults.tsx |
Project element defaults list |
pages/project-element-defaults/[id].tsx |
Project element default editor |
Shared ElementSettings Components
These components are shared across global defaults, project defaults, and constructor pages:
| File | Purpose |
|---|---|
components/ElementSettings/index.ts |
Barrel exports for all components |
components/ElementSettings/types.ts |
Shared types (SettingsContext, validation helpers) |
components/ElementSettings/useElementSettingsForm.ts |
Form state management hook for defaults pages |
components/ElementSettings/ElementSettingsTabs.tsx |
Tab navigation (full-size for admin pages) |
components/ElementSettings/StyleSettingsSection.tsx |
CSS styling fields (full-size for admin pages) |
components/ElementSettings/StyleSettingsSectionCompact.tsx |
CSS styling fields (compact for constructor) |
components/ElementSettings/CommonSettingsSection.tsx |
Label, position, appear timing fields |
components/ElementSettings/NavigationSettingsSection.tsx |
Navigation element settings |
components/ElementSettings/TooltipSettingsSection.tsx |
Tooltip element settings |
components/ElementSettings/DescriptionSettingsSection.tsx |
Description element settings |
components/ElementSettings/MediaSettingsSection.tsx |
Video/audio player settings |
components/ElementSettings/GallerySettingsSection.tsx |
Gallery cards editor |
components/ElementSettings/CarouselSettingsSection.tsx |
Carousel slides editor |
Context-aware rendering: Components accept a context prop ('global', 'project', or 'constructor') to render appropriate UI (e.g., asset selectors in constructor vs plain text inputs in admin pages).
Limitations
Not Implemented
- Undo/redo history
- Element grouping
- Snap-to-grid
- Alignment tools
- Element locking
- Animation timeline editor
- Element rotation UI
Workarounds
| Feature | Workaround |
|---|---|
| Z-index | Set via zIndex property in element |
| Rotation | Set via CSS transform in style_json |
| Grouping | Create container element manually |
Integration Points
| System | Integration |
|---|---|
| Assets API | Load project assets for selectors |
| Tour Pages API | CRUD page operations (always on dev environment) |
| Project Element Defaults API | Constructor uses this - Project-specific defaults (/api/project-element-defaults?projectId=xxx) |
| Element Type Defaults API | Global platform defaults - used for snapshotting, not directly by constructor (/api/element-type-defaults) |
| Publish API | Save to Stage functionality (POST /publish/save-to-stage) |
| Preload System | Asset preloading via usePreloadOrchestrator (S3 direct download → Cache API → blob URLs) |
| Page Switch | Smooth transitions via usePageSwitch (blob URL resolution, background overlay during switch) |
| Runtime | Constructor output displayed in runtime (stage/production environments) |
Note: Navigation and transitions are stored directly in tour_pages.ui_schema_json (as targetPageSlug and transitionVideoUrl). No separate API tables exist for these.
Element Defaults Hierarchy (Three-Tier System)
The system uses a three-tier defaults cascade:
┌────────────────────────────────────────────────────────────┐
│ Tier 1: element_type_defaults (Global) │
│ Field: default_settings_json │
│ Seeded: 12 predefined element types │
│ Admin: /element-type-defaults │
└────────────────────────────────────────────────────────────┘
↓ (snapshot on project creation)
┌────────────────────────────────────────────────────────────┐
│ Tier 2: project_element_defaults (Project-Specific) │
│ Field: settings_json │
│ Created: Auto-snapshotted when project is created │
│ Admin: /project-element-defaults?projectId=xxx │
└────────────────────────────────────────────────────────────┘
↓ (merge on element creation)
┌────────────────────────────────────────────────────────────┐
│ Tier 3: tour_pages.ui_schema_json (Instance) │
│ Each element has its own settings in elements[] │
│ Edited: Constructor page │
└────────────────────────────────────────────────────────────┘
How it works:
- On project creation: Global defaults (
element_type_defaults) are automatically snapshotted to project defaults (project_element_defaults) - Constructor loads project defaults: Fetches
/api/project-element-defaults?projectId=xxx(NOT global defaults) - On element creation:
createDefaultElement()creates base element, thenmergeElementWithDefaults()applies project-specific settings - Element stored: Final element with all settings saved in
tour_pages.ui_schema_json
Key utilities:
// types/constructor.ts - API response normalization
normalizeElementDefault(row) → NormalizedElementDefault
buildElementDefaultsMap(defaults) → Record<CanvasElementType, Partial<CanvasElement>>
// lib/elementDefaults.ts - Element creation and merging
createDefaultElement(type, index) → CanvasElement
mergeElementWithDefaults(element, defaults, options) → CanvasElement
isCanvasElementType(value) → boolean
ELEMENT_TYPE_LABELS → Record<CanvasElementType, string>
Merge behavior: The mergeElementWithDefaults() function applies project defaults, with element values taking precedence over defaults (except for empty/null values which inherit from defaults).
This allows each project to customize element appearance while maintaining platform-wide base settings.
Grouped Props for ElementEditorPanel
The types/constructor.ts file defines grouped prop interfaces for the ElementEditorPanel component, improving type safety and code organization:
// Editor layout props
interface EditorLayoutProps {
elementEditorRef: React.RefObject<HTMLDivElement | null>;
position: { x: number; y: number };
isCollapsed: boolean;
onToggleCollapse: () => void;
onDragStart: (event: React.MouseEvent) => void;
}
// Editor state props
interface EditorStateProps {
title: string;
activeTab: 'general' | 'css' | 'effects';
onTabChange: (tab: 'general' | 'css' | 'effects') => void;
}
// Selected element props
interface EditorElementProps {
selectedElement: CanvasElement | null;
selectedMenuItem: 'none' | 'background_image' | 'background_video' | 'background_audio' | 'create_transition';
onRemoveElement: () => void;
onUpdateElement: (patch: Partial<CanvasElement>) => void;
}
// Background settings props
interface EditorBackgroundProps { ... }
// Transition creation props
interface EditorTransitionProps { ... }
// Duration notes props
interface EditorDurationNotesProps { ... }
// Asset options props
interface EditorAssetOptionsProps { ... }
// Navigation settings props
interface EditorNavigationProps { ... }
// Gallery/Carousel collection operations
interface EditorCollectionOpsProps {
galleryCards: { add, update, remove };
galleryInfoSpans: { add, update, remove };
carouselSlides: { add, update, remove };
}
// Media utilities
interface EditorMediaUtilsProps {
getDuration: (url: string) => number | undefined;
}