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

71 KiB
Raw Blame History

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

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 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

// 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
};

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:

  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:

// 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

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:

  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:

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)

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:

  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-stagePublishService.saveToStage()copyEnvironment(dev, stage) (runs in background)
  • Publish to Prod (blocking): POST /publishPublishService.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

// 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[].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.

// 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:

  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:

// 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;
}