# 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
{/* Background Video */} {backgroundVideoUrl && (
``` ### 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 (no re-fetch on re-render) // For regular URLs: use Next.js Image (optimization benefits) {url.startsWith('blob:') ? ( ) : ( )} ``` **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 `` 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 `` | No re-fetch, already in memory | | `https://...` | `` | Optimization benefits, less frequent re-renders | | Presigned S3 | `` | 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, });