39948-vm/frontend/docs/constructor-page-editor.md
2026-07-03 16:11:24 +02:00

1867 lines
71 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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: true` from 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
```typescript
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
```typescript
// 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
```typescript
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.
```tsx
<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:**
```typescript
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:
```typescript
// 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
```typescript
// 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
```typescript
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:
```typescript
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
```typescript
// 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:
```typescript
// 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 `src` on every render, even with `unoptimized` prop
- 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
```typescript
// 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.
```typescript
// 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
```typescript
// 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
```typescript
// Stored in tour_page
background_audio_url: string;
// Rendered as hidden audio
<audio
src={backgroundAudioUrl}
autoPlay
loop
className="hidden"
/>
```
---
## Transition Configuration
### Transition Properties
```typescript
// 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
```typescript
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.
```typescript
// 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
```typescript
// 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
```tsx
<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
```typescript
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
```typescript
// 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.
```typescript
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.
```typescript
// 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:**
1. Collect storage paths from current page + neighbors (depth=1)
2. Batch fetch presigned URLs from backend (`POST /api/file/presign`)
3. Download asset directly from S3 (or via proxy if CORS not configured)
4. Store in browser Cache API under download URL
5. **Store in Cache API under storage key** (enables post-refresh lookups)
6. Create blob URL from cache
7. Pre-decode images for instant paint
8. **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:
```typescript
// 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:
```typescript
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:
1. **pageLinks** - Navigation connections (`from_pageId → to_pageId`) for the neighbor graph
2. **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:
```typescript
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:**
1. `getReadyBlobUrl(storageKey)` - instant (same session)
2. `getCachedBlobUrl(storageKey)` - Cache API (post-refresh)
3. `getReadyBlobUrl(resolvedUrl)` - fallback
4. `getCachedBlobUrl(resolvedUrl)` - fallback
5. 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)
```typescript
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.
```typescript
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:
1. Duplicate saves the active page first so the new page matches the current
constructor state.
2. The constructor calls `POST /api/tour_pages/:id/duplicate` with a generated
copy name and slug.
3. 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.
4. Asset URLs, transition URLs, and navigation `targetPageSlug` values 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:
1. Fetches all `tour_pages` and `project_audio_tracks` from source environment
2. Hard-deletes existing records in target environment (`force: true` for paranoid models)
3. Bulk creates cloned records with target environment
4. Preserves slugs and `targetPageSlug` references (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
```typescript
// 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
```typescript
// 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
```typescript
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[].id`
- `galleryInfoSpans[].id`
- `carouselSlides[].id`
- `infoPanelSections[].id`
- `infoPanelSections[].spans[].id`
- `infoPanelSections[].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 + C` copies the selected element
- `Cmd/Ctrl + V` pastes 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.
```typescript
// 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:
```typescript
// 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
```typescript
// Via hook
updateSelectedElement({ label: 'New Label' });
// Or update any element by ID
updateElement(elementId, { xPercent: 50, yPercent: 50 });
```
### Delete Element
```typescript
// Via hook - removes selected element and clears selection
removeSelectedElement();
```
---
## Page Management
Page operations are managed by the `useConstructorPageActions` hook.
### Hook Usage
```typescript
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.
```typescript
// Via hook - handles loading state, API call, and reload
await createPage();
```
### Switch Page
Page switching uses `usePageSwitch` for smooth background transitions:
```typescript
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
```typescript
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:
1. Resolving blob URLs from preload cache
2. Pre-decoding images before display
3. 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:**
1. **On project creation**: Global defaults (`element_type_defaults`) are automatically snapshotted to project defaults (`project_element_defaults`)
2. **Constructor loads project defaults**: Fetches `/api/project-element-defaults?projectId=xxx` (NOT global defaults)
3. **On element creation**: `createDefaultElement()` creates base element, then `mergeElementWithDefaults()` applies project-specific settings
4. **Element stored**: Final element with all settings saved in `tour_pages.ui_schema_json`
**Key utilities:**
```typescript
// 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:
```typescript
// 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;
}
```