adaptivity improvements

This commit is contained in:
Dmitri 2026-04-11 14:32:54 +04:00
parent 62b9b3ceb9
commit 0a36a87cd4
29 changed files with 1620 additions and 90 deletions

View File

@ -62,6 +62,8 @@ class ProjectsDBApi extends GenericDBApi {
logo_url: data.logo_url || null, logo_url: data.logo_url || null,
favicon_url: data.favicon_url || null, favicon_url: data.favicon_url || null,
og_image_url: data.og_image_url || null, og_image_url: data.og_image_url || null,
design_width: data.design_width !== undefined ? data.design_width : null,
design_height: data.design_height !== undefined ? data.design_height : null,
}; };
} }

View File

@ -38,6 +38,10 @@ class Tour_pagesDBApi extends GenericDBApi {
return ['environment', 'background_loop', 'requires_auth']; return ['environment', 'background_loop', 'requires_auth'];
} }
static get UUID_FIELDS() {
return ['projectId'];
}
static get CSV_FIELDS() { static get CSV_FIELDS() {
return [ return [
'id', 'id',
@ -90,6 +94,10 @@ class Tour_pagesDBApi extends GenericDBApi {
data.background_video_end_time !== undefined data.background_video_end_time !== undefined
? data.background_video_end_time ? data.background_video_end_time
: null, : null,
design_width:
data.design_width !== undefined ? data.design_width : null,
design_height:
data.design_height !== undefined ? data.design_height : null,
requires_auth: data.requires_auth || false, requires_auth: data.requires_auth || false,
ui_schema_json: data.ui_schema_json || null, ui_schema_json: data.ui_schema_json || null,
}; };
@ -203,6 +211,16 @@ class Tour_pagesDBApi extends GenericDBApi {
} }
} }
// Validate and filter by UUID fields (e.g., projectId)
for (const field of this.UUID_FIELDS) {
if (filter[field] !== undefined) {
if (!Utils.isValidUuid(filter[field])) {
return { rows: [], count: 0 };
}
where[field] = filter[field];
}
}
if (filter.active !== undefined) { if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true'; where.active = filter.active === true || filter.active === 'true';
} }

View File

@ -0,0 +1,29 @@
'use strict';
/**
* Migration: Add design canvas dimensions to projects
*
* Adds design_width and design_height columns to support
* responsive canvas scaling with project-specific aspect ratios.
*
* @type {import('sequelize-cli').Migration}
*/
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('projects', 'design_width', {
type: Sequelize.INTEGER,
allowNull: true,
defaultValue: 1920,
});
await queryInterface.addColumn('projects', 'design_height', {
type: Sequelize.INTEGER,
allowNull: true,
defaultValue: 1080,
});
},
async down(queryInterface, _Sequelize) {
await queryInterface.removeColumn('projects', 'design_width');
await queryInterface.removeColumn('projects', 'design_height');
},
};

View File

@ -0,0 +1,30 @@
'use strict';
/**
* Migration: Add design_width and design_height to tour_pages
*
* These fields store the canvas dimensions for presentations.
* They are copied from the project's design dimensions when pages are saved/published.
* This ensures presentations use the dimensions that were active at save time,
* not the current project dimensions (safe migration pattern).
*/
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('tour_pages', 'design_width', {
type: Sequelize.INTEGER,
allowNull: true,
defaultValue: null,
});
await queryInterface.addColumn('tour_pages', 'design_height', {
type: Sequelize.INTEGER,
allowNull: true,
defaultValue: null,
});
},
async down(queryInterface) {
await queryInterface.removeColumn('tour_pages', 'design_width');
await queryInterface.removeColumn('tour_pages', 'design_height');
},
};

View File

@ -53,6 +53,18 @@ module.exports = function (sequelize, DataTypes) {
type: DataTypes.TEXT, type: DataTypes.TEXT,
}, },
design_width: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 1920,
},
design_height: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 1080,
},
importHash: { importHash: {
type: DataTypes.STRING(255), type: DataTypes.STRING(255),
allowNull: true, allowNull: true,

View File

@ -103,6 +103,18 @@ module.exports = function (sequelize, DataTypes) {
defaultValue: null, defaultValue: null,
}, },
design_width: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: null,
},
design_height: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: null,
},
requires_auth: { requires_auth: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,

View File

@ -224,6 +224,63 @@ class ProjectsService extends BaseProjectsService {
} }
} }
// Clone tour pages (dev environment only - stage/production are populated via publishing)
const sourcePages = await db.tour_pages.findAll({
where: { projectId: sourceProjectId, environment: 'dev' },
transaction,
});
for (const sourcePage of sourcePages) {
const pageData = sourcePage.toJSON();
// Remove fields that should be regenerated
delete pageData.id;
delete pageData.createdAt;
delete pageData.updatedAt;
delete pageData.deletedAt;
delete pageData.deletedBy;
delete pageData.importHash;
await db.tour_pages.create(
{
...pageData,
projectId: clonedProject.id,
environment: 'dev',
source_key: sourcePage.id, // Link back to original page
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
}
// Clone audio tracks (dev environment only)
const sourceAudioTracks = await db.project_audio_tracks.findAll({
where: { projectId: sourceProjectId, environment: 'dev' },
transaction,
});
for (const sourceTrack of sourceAudioTracks) {
const trackData = sourceTrack.toJSON();
delete trackData.id;
delete trackData.createdAt;
delete trackData.updatedAt;
delete trackData.deletedAt;
delete trackData.deletedBy;
delete trackData.importHash;
await db.project_audio_tracks.create(
{
...trackData,
projectId: clonedProject.id,
environment: 'dev',
source_key: sourceTrack.id,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
}
await transaction.commit(); await transaction.commit();
return clonedProject; return clonedProject;
} catch (error) { } catch (error) {

View File

@ -72,7 +72,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
key={`bg_image_${backgroundImageUrl}`} key={`bg_image_${backgroundImageUrl}`}
src={backgroundImageUrl} src={backgroundImageUrl}
alt='Background' alt='Background'
className='absolute inset-0 h-full w-full object-cover' className='absolute inset-0 h-full w-full object-contain'
draggable={false} draggable={false}
onLoad={handleLoad} onLoad={handleLoad}
onError={handleError} onError={handleError}
@ -84,7 +84,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
alt='Background' alt='Background'
fill fill
sizes='100vw' sizes='100vw'
className='object-cover' className='object-contain'
draggable={false} draggable={false}
unoptimized unoptimized
onLoad={handleLoad} onLoad={handleLoad}
@ -100,8 +100,9 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
className='pointer-events-none absolute inset-0 z-10' className='pointer-events-none absolute inset-0 z-10'
style={{ style={{
backgroundImage: `url("${previousBgImageUrl}")`, backgroundImage: `url("${previousBgImageUrl}")`,
backgroundSize: 'cover', backgroundSize: 'contain',
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}} }}
/> />
)} )}
@ -111,7 +112,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
<video <video
ref={videoRef} ref={videoRef}
key={`bg_video_${backgroundVideoUrl}`} key={`bg_video_${backgroundVideoUrl}`}
className='absolute inset-0 z-1 h-full w-full object-cover' className='absolute inset-0 z-1 h-full w-full object-contain'
src={backgroundVideoUrl} src={backgroundVideoUrl}
autoPlay={videoAutoplay} autoPlay={videoAutoplay}
loop={useNativeLoop} loop={useNativeLoop}

View File

@ -2,9 +2,10 @@
* PageSelector Component * PageSelector Component
* *
* Dropdown for selecting the active page in constructor. * Dropdown for selecting the active page in constructor.
* Pages are sorted by sort_order in ascending order.
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import type { TourPage } from './types'; import type { TourPage } from './types';
interface PageSelectorProps { interface PageSelectorProps {
@ -20,6 +21,22 @@ const PageSelector: React.FC<PageSelectorProps> = ({
onPageChange, onPageChange,
disabled = false, disabled = false,
}) => { }) => {
// Sort pages by sort_order ascending, then by name
const sortedPages = useMemo(() => {
return [...pages].sort((a, b) => {
// Primary sort: sort_order ascending (undefined/null goes to end)
const orderA = typeof a.sort_order === 'number' ? a.sort_order : Number.MAX_SAFE_INTEGER;
const orderB = typeof b.sort_order === 'number' ? b.sort_order : Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) {
return orderA - orderB;
}
// Secondary sort: name ascending
const nameA = (a.name || '').toLowerCase();
const nameB = (b.name || '').toLowerCase();
return nameA.localeCompare(nameB);
});
}, [pages]);
return ( return (
<select <select
className='rounded border border-gray-300 bg-white px-3 py-2 text-sm' className='rounded border border-gray-300 bg-white px-3 py-2 text-sm'
@ -27,7 +44,7 @@ const PageSelector: React.FC<PageSelectorProps> = ({
onChange={(event) => onPageChange(event.target.value)} onChange={(event) => onPageChange(event.target.value)}
disabled={disabled} disabled={disabled}
> >
{pages.map((page, index) => ( {sortedPages.map((page, index) => (
<option key={page.id} value={page.id}> <option key={page.id} value={page.id}>
{page.name || `Page ${index + 1}`} {page.name || `Page ${index + 1}`}
</option> </option>

View File

@ -0,0 +1,46 @@
/**
* Rotate Prompt Component
*
* Shown when device is in portrait orientation to prompt user to rotate.
* Follows pattern: components/Offline/OfflineStatus.tsx
*/
import React from 'react';
import Icon from '@mdi/react';
import { mdiScreenRotation } from '@mdi/js';
interface RotatePromptProps {
/** Whether to show the prompt */
show: boolean;
/** Custom message to display */
message?: string;
}
/**
* Full-screen overlay prompting user to rotate their device.
* Displays a rotation icon and customizable message.
*/
export const RotatePrompt: React.FC<RotatePromptProps> = ({
show,
message = 'Please rotate your device for the best experience',
}) => {
if (!show) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90">
<div className="text-center text-white px-8">
<Icon
path={mdiScreenRotation}
size={3}
className="mx-auto mb-6 animate-pulse"
/>
<p className="text-lg font-medium">{message}</p>
<p className="text-sm text-gray-400 mt-2">
This presentation is optimized for landscape viewing
</p>
</div>
</div>
);
};
export default RotatePrompt;

View File

@ -24,6 +24,9 @@ import { OfflineToggle } from './Offline/OfflineToggle';
import RuntimeElement from './RuntimeElement'; import RuntimeElement from './RuntimeElement';
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay'; import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
import { BackdropPortalProvider } from './BackdropPortal'; import { BackdropPortalProvider } from './BackdropPortal';
import { RotatePrompt } from './RotatePrompt';
import { useCanvasScale } from '../hooks/useCanvasScale';
import { CANVAS_CONFIG } from '../config/canvas.config';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator'; import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { usePageDataLoader } from '../hooks/usePageDataLoader'; import { usePageDataLoader } from '../hooks/usePageDataLoader';
@ -82,6 +85,17 @@ export default function RuntimePresentation({
trackHistory: true, trackHistory: true,
}); });
// Get current page for design dimensions (presentations use page dimensions, not project)
const currentPage = pages.find((p) => p.id === selectedPageId);
// Canvas scale for responsive UI elements and letterbox mode
// Uses page's design dimensions (saved at constructor save time) for presentation isolation
const { cssVars, letterboxStyles, isPortrait, showRotatePrompt } =
useCanvasScale({
designWidth: currentPage?.design_width ?? undefined,
designHeight: currentPage?.design_height ?? undefined,
});
const [transitionPreview, setTransitionPreview] = useState<{ const [transitionPreview, setTransitionPreview] = useState<{
targetPageId: string; targetPageId: string;
videoUrl: string; videoUrl: string;
@ -212,8 +226,14 @@ export default function RuntimePresentation({
}, },
}); });
// Use shared background transition hook for fade-out effects // Use shared background transition hook for fade-out and fade-in effects
const { isOverlayFadingOut, resetFadeOut } = useBackgroundTransition({ const {
isOverlayFadingOut,
resetFadeOut,
isFadingIn,
elementsOpacity,
resetFadeIn,
} = useBackgroundTransition({
pageSwitch, pageSwitch,
fadeOut: { fadeOut: {
pendingTransitionComplete, pendingTransitionComplete,
@ -224,6 +244,9 @@ export default function RuntimePresentation({
setPendingTransitionComplete(false); setPendingTransitionComplete(false);
}, []), }, []),
}, },
fadeIn: {
hasActiveTransition: Boolean(transitionPreview),
},
}); });
const toggleFullscreen = useCallback(async () => { const toggleFullscreen = useCallback(async () => {
@ -314,7 +337,8 @@ export default function RuntimePresentation({
if (transitionVideoUrl) { if (transitionVideoUrl) {
// Reset states from previous transition before starting new one // Reset states from previous transition before starting new one
// This prevents the fade-out effect from re-triggering // This prevents the fade-out/fade-in effects from re-triggering
resetFadeIn();
resetFadeOut(); resetFadeOut();
setPendingTransitionComplete(false); setPendingTransitionComplete(false);
// Play transition using useTransitionPlayback hook // Play transition using useTransitionPlayback hook
@ -326,6 +350,8 @@ export default function RuntimePresentation({
}); });
} else { } else {
// Direct navigation - use shared hook for smooth transition // Direct navigation - use shared hook for smooth transition
// Reset fade-in state to start fresh
resetFadeIn();
// Previous background stays visible until new one is ready // Previous background stays visible until new one is ready
setIsBackgroundReady(false); setIsBackgroundReady(false);
// Mark this page as initialized to prevent redundant effect calls // Mark this page as initialized to prevent redundant effect calls
@ -337,7 +363,7 @@ export default function RuntimePresentation({
}); });
} }
}, },
[pages, pageSwitch, resetFadeOut, applyPageSelection], [pages, pageSwitch, resetFadeOut, resetFadeIn, applyPageSelection],
); );
const handleElementClick = useCallback( const handleElementClick = useCallback(
@ -458,6 +484,9 @@ export default function RuntimePresentation({
return ( return (
<> <>
{/* Rotate prompt for portrait orientation */}
<RotatePrompt show={showRotatePrompt && isPortrait} />
<Head> <Head>
<title>{project?.name || 'Presentation'}</title> <title>{project?.name || 'Presentation'}</title>
{faviconUrl && <link key='favicon' rel='icon' href={faviconUrl} />} {faviconUrl && <link key='favicon' rel='icon' href={faviconUrl} />}
@ -497,16 +526,22 @@ export default function RuntimePresentation({
)} )}
</Head> </Head>
<div {/* Outer container: full viewport with black background for letterbox bars */}
className='relative w-screen h-screen overflow-clip bg-black' <div className='relative w-screen h-screen overflow-hidden bg-black'>
style={{ {/* Inner canvas: maintains aspect ratio centered in viewport */}
backgroundImage: backgroundImageUrl <div
? `url("${backgroundImageUrl}")` className='overflow-hidden'
: undefined, style={{
backgroundSize: 'cover', ...cssVars,
backgroundPosition: 'center', ...letterboxStyles,
}} backgroundImage: backgroundImageUrl
> ? `url("${backgroundImageUrl}")`
: undefined,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
>
<BackdropPortalProvider> <BackdropPortalProvider>
{/* Background image element - z-1 keeps it below backdrop blur (z-5). {/* Background image element - z-1 keeps it below backdrop blur (z-5).
CSS backgroundImage provides instant display. CSS backgroundImage provides instant display.
@ -519,7 +554,7 @@ export default function RuntimePresentation({
key={backgroundImageUrl} key={backgroundImageUrl}
src={backgroundImageUrl} src={backgroundImageUrl}
alt='' alt=''
className='absolute inset-0 w-full h-full object-cover' className='absolute inset-0 w-full h-full object-contain'
onLoad={() => { onLoad={() => {
setIsBackgroundReady(true); setIsBackgroundReady(true);
pageSwitch.markBackgroundReady(); pageSwitch.markBackgroundReady();
@ -536,7 +571,7 @@ export default function RuntimePresentation({
alt='' alt=''
fill fill
sizes='100vw' sizes='100vw'
className='object-cover' className='object-contain'
priority priority
unoptimized unoptimized
onLoad={() => { onLoad={() => {
@ -560,8 +595,9 @@ export default function RuntimePresentation({
className='absolute inset-0 pointer-events-none z-10' className='absolute inset-0 pointer-events-none z-10'
style={{ style={{
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`, backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
backgroundSize: 'cover', backgroundSize: 'contain',
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}} }}
/> />
)} )}
@ -571,7 +607,7 @@ export default function RuntimePresentation({
<video <video
ref={bgVideoRef} ref={bgVideoRef}
key={backgroundVideoUrl} key={backgroundVideoUrl}
className='absolute inset-0 z-1 h-full w-full object-cover' className='absolute inset-0 z-1 h-full w-full object-contain'
src={backgroundVideoUrl} src={backgroundVideoUrl}
autoPlay={videoAutoplay} autoPlay={videoAutoplay}
loop={useNativeLoop} loop={useNativeLoop}
@ -581,7 +617,15 @@ export default function RuntimePresentation({
)} )}
{/* Page elements - z-40 ensures they appear above carousel background (z-10) and carousel controls (z-30) */} {/* Page elements - z-40 ensures they appear above carousel background (z-10) and carousel controls (z-30) */}
<div className='absolute inset-0 z-40'> <div
className='absolute inset-0 z-40'
style={{
opacity: elementsOpacity,
transition: isFadingIn
? `opacity ${CANVAS_CONFIG.pageTransition.fadeInDurationMs}ms ${CANVAS_CONFIG.pageTransition.easing}`
: 'none',
}}
>
{pageElements.map((element: CanvasElement) => ( {pageElements.map((element: CanvasElement) => (
<RuntimeElement <RuntimeElement
key={element.id} key={element.id}
@ -678,6 +722,8 @@ export default function RuntimePresentation({
/> />
)} )}
</BackdropPortalProvider> </BackdropPortalProvider>
</div>
{/* End inner canvas container */}
{/* Toast notifications for offline download status */} {/* Toast notifications for offline download status */}
<ToastContainer <ToastContainer

View File

@ -28,6 +28,7 @@ import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import type { CanvasElement, CarouselSlide } from '../../../types/constructor'; import type { CanvasElement, CarouselSlide } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl'; import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
import { getFontByKey, getFontStyle } from '../../../lib/fonts'; import { getFontByKey, getFontStyle } from '../../../lib/fonts';
import { toCU } from '../../../lib/canvasScale';
interface CarouselElementProps { interface CarouselElementProps {
element: CanvasElement; element: CanvasElement;
@ -238,20 +239,41 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
}; };
}, [isEditMode, draggingButton, onButtonPositionChange]); }, [isEditMode, draggingButton, onButtonPositionChange]);
// Convert numeric value to viewport units (vw for width, vh for height) // Convert numeric value to canvas units for responsive scaling
const toViewportUnit = ( // Previously used vw/vh but now uses canvas units (--cu) for uniform scaling
const toCanvasUnit = (
value?: string, value?: string,
unit: 'vw' | 'vh' = 'vw', dimension: 'width' | 'height' = 'width',
): string | undefined => { ): string | undefined => {
if (!value || value.trim() === '') return undefined; if (!value || value.trim() === '') return undefined;
const trimmed = value.trim(); const trimmed = value.trim();
// If value already has a unit, preserve it // If value already uses canvas units or calc, preserve it
if (trimmed.includes('var(--cu') || trimmed.includes('--cu')) return trimmed;
if (/^calc\(/i.test(trimmed)) return trimmed;
// If value already has other units, convert them
const vwMatch = trimmed.match(/^(-?\d*\.?\d+)vw$/i);
if (vwMatch) {
const vw = parseFloat(vwMatch[1]);
const designPx = (vw / 100) * 1920;
return toCU(designPx);
}
const vhMatch = trimmed.match(/^(-?\d*\.?\d+)vh$/i);
if (vhMatch) {
const vh = parseFloat(vhMatch[1]);
const designPx = (vh / 100) * 1080;
return toCU(designPx);
}
// If value has px or other units, preserve them
if (/[a-z%]+$/i.test(trimmed)) return trimmed; if (/[a-z%]+$/i.test(trimmed)) return trimmed;
// Plain number - treat as design pixels
const num = parseFloat(trimmed); const num = parseFloat(trimmed);
if (!Number.isFinite(num) || num <= 0) return undefined; if (!Number.isFinite(num) || num <= 0) return undefined;
return `${num}${unit}`; return toCU(num);
}; };
// Alias for backward compatibility
const toViewportUnit = toCanvasUnit;
// Render navigation button for full-width mode // Render navigation button for full-width mode
const renderNavButton = ( const renderNavButton = (
type: 'prev' | 'next', type: 'prev' | 'next',
@ -264,8 +286,8 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
) => { ) => {
const isDragging = draggingButton === type; const isDragging = draggingButton === type;
const hasCustomIcon = iconUrl && iconUrl.trim() !== ''; const hasCustomIcon = iconUrl && iconUrl.trim() !== '';
const widthValue = toViewportUnit(buttonWidth, 'vw'); const widthValue = toCanvasUnit(buttonWidth, 'width');
const heightValue = toViewportUnit(buttonHeight, 'vh'); const heightValue = toCanvasUnit(buttonHeight, 'height');
// Navigation-style: custom icon fills button (no backdrop) // Navigation-style: custom icon fills button (no backdrop)
const useNavigationStyle = hasCustomIcon && (widthValue || heightValue); const useNavigationStyle = hasCustomIcon && (widthValue || heightValue);

View File

@ -10,6 +10,7 @@ import { useMemo } from 'react';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor'; import type { CanvasElement } from '../../../types/constructor';
import { buildElementStyle } from '../../../lib/elementStyles'; import { buildElementStyle } from '../../../lib/elementStyles';
import { toCU } from '../../../lib/canvasScale';
import { import {
isTooltipElementType, isTooltipElementType,
isDescriptionElementType, isDescriptionElementType,
@ -71,6 +72,18 @@ export function useElementWrapperStyle({
// Navigation elements (with or without icon) should be centered // Navigation elements (with or without icon) should be centered
const isNavigationElement = isNavigationElementType(element.type); const isNavigationElement = isNavigationElementType(element.type);
// Determine padding based on element type
// For icon-driven elements: no padding
// For regular elements: use canvas units (12px horizontal, 8px vertical at design scale)
const paddingStyle: CSSProperties = hasIconDrivenSize
? { padding: 0 }
: {
paddingLeft: toCU(12),
paddingRight: toCU(12),
paddingTop: toCU(8),
paddingBottom: toCU(8),
};
// Build className - same logic for both constructor and runtime // Build className - same logic for both constructor and runtime
const classNames = [ const classNames = [
'rounded text-xs font-semibold', 'rounded text-xs font-semibold',
@ -82,8 +95,8 @@ export function useElementWrapperStyle({
: hasTransparentBackground : hasTransparentBackground
? 'bg-transparent' ? 'bg-transparent'
: 'border shadow border-blue-200 bg-white/95', : 'border shadow border-blue-200 bg-white/95',
// Padding // Overflow for icon-driven elements
hasIconDrivenSize ? 'overflow-hidden p-0 leading-none' : 'px-3 py-2', hasIconDrivenSize ? 'overflow-hidden leading-none' : '',
// Flex centering for navigation elements (both icons and text) // Flex centering for navigation elements (both icons and text)
isNavigationElement ? 'flex items-center justify-center' : '', isNavigationElement ? 'flex items-center justify-center' : '',
// Constructor-specific states (only applied when in constructor) // Constructor-specific states (only applied when in constructor)
@ -101,7 +114,7 @@ export function useElementWrapperStyle({
return { return {
className: classNames, className: classNames,
style: inlineStyle, style: { ...paddingStyle, ...inlineStyle },
}; };
}, [element, isSelected, isEditMode, isDisabled]); }, [element, isSelected, isEditMode, isDisabled]);
} }

View File

@ -0,0 +1,66 @@
/**
* Canvas Configuration
*
* Centralized configuration for responsive canvas scaling.
* Project-specific values come from project.design_width/design_height.
*/
export const CANVAS_CONFIG = {
// Default design dimensions (used when project doesn't specify)
defaults: {
width: 1920,
height: 1080,
},
// Common presets for project settings UI
presets: [
{ name: 'HD 16:9', width: 1920, height: 1080 },
{ name: '4K 16:9', width: 3840, height: 2160 },
{ name: 'HD 4:3', width: 1440, height: 1080 },
{ name: 'Ultra-wide 21:9', width: 2560, height: 1080 },
],
// Scaling behavior
scaling: {
mode: 'fit' as const, // 'fit' | 'fill' | 'stretch'
minScale: 0.1,
maxScale: 4.0,
},
// Orientation handling
orientation: {
showRotatePrompt: true,
minAspectRatioForPrompt: 0.8, // Show prompt if aspect < 0.8 (portrait)
},
// CSS custom property names
cssVars: {
scale: '--canvas-scale',
unit: '--cu',
designWidth: '--design-width',
designHeight: '--design-height',
},
// Page transition effects
pageTransition: {
/**
* Fade-in duration for non-transition navigation (ms).
* Applied when switching pages without a transition video.
*/
fadeInDurationMs: 500,
/**
* Fade-out duration for transition video overlay (ms).
* Applied after transition video finishes playing.
*/
fadeOutDurationMs: 300,
/**
* CSS easing function for fade animations.
*/
easing: 'ease-out' as const,
},
} as const;
export type CanvasConfig = typeof CANVAS_CONFIG;
// Type for presets
export type CanvasPreset = (typeof CANVAS_CONFIG.presets)[number];

View File

@ -0,0 +1,124 @@
/**
* Canvas Scale Context
*
* Provides canvas scale values to presentation components.
* Takes project-specific design dimensions as props.
* Follows pattern: context/DownloadContext.tsx
*/
import React, {
createContext,
useContext,
useState,
useEffect,
useMemo,
type ReactNode,
type CSSProperties,
} from 'react';
import { CANVAS_CONFIG } from '../config/canvas.config';
import { calculateCanvasScale, getCanvasCssVars } from '../lib/canvasScale';
// Context state
interface CanvasScaleState {
scale: number;
designWidth: number;
designHeight: number;
canvasWidth: number;
canvasHeight: number;
isPortrait: boolean;
}
// Context value with derived properties
interface CanvasScaleContextValue extends CanvasScaleState {
showRotatePrompt: boolean;
cssVars: CSSProperties;
}
const CanvasScaleContext = createContext<CanvasScaleContextValue | null>(null);
interface CanvasScaleProviderProps {
children: ReactNode;
designWidth?: number;
designHeight?: number;
}
export function CanvasScaleProvider({
children,
designWidth = CANVAS_CONFIG.defaults.width,
designHeight = CANVAS_CONFIG.defaults.height,
}: CanvasScaleProviderProps) {
const [viewport, setViewport] = useState({ width: 0, height: 0 });
// Resize listener (matches DownloadContext event listener pattern)
useEffect(() => {
const handleResize = () => {
setViewport({
width: window.innerWidth,
height: window.innerHeight,
});
};
// Set initial values
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Memoized context value (matches DownloadContext useMemo pattern)
const value = useMemo<CanvasScaleContextValue>(() => {
const scale = calculateCanvasScale(
viewport.width,
viewport.height,
designWidth,
designHeight,
);
const isPortrait = viewport.height > viewport.width;
const aspectRatio = viewport.width / viewport.height;
return {
scale,
designWidth,
designHeight,
canvasWidth: designWidth * scale,
canvasHeight: designHeight * scale,
isPortrait,
showRotatePrompt:
isPortrait &&
CANVAS_CONFIG.orientation.showRotatePrompt &&
aspectRatio < CANVAS_CONFIG.orientation.minAspectRatioForPrompt,
cssVars: getCanvasCssVars(
scale,
designWidth,
designHeight,
) as CSSProperties,
};
}, [viewport, designWidth, designHeight]);
return (
<CanvasScaleContext.Provider value={value}>
{children}
</CanvasScaleContext.Provider>
);
}
/**
* Hook to access canvas scale context.
* Throws if not within provider.
*/
export function useCanvasScaleContext(): CanvasScaleContextValue {
const context = useContext(CanvasScaleContext);
if (!context) {
throw new Error(
'useCanvasScaleContext must be used within CanvasScaleProvider',
);
}
return context;
}
/**
* Optional hook that returns null if not within provider.
*/
export function useCanvasScaleContextOptional(): CanvasScaleContextValue | null {
return useContext(CanvasScaleContext);
}

View File

@ -25,7 +25,7 @@ export function usePagesQuery(
queryKey: queryKeys.tourPages.list(projectId || '', environment), queryKey: queryKeys.tourPages.list(projectId || '', environment),
queryFn: async (): Promise<TourPage[]> => { queryFn: async (): Promise<TourPage[]> => {
const response = await axios.get<PagesListResponse>( const response = await axios.get<PagesListResponse>(
`tour_pages?projectId=${projectId}&environment=${environment}&limit=500`, `tour_pages?projectId=${projectId}&environment=${environment}&limit=500&field=sort_order&sort=asc`,
); );
return response.data.rows; return response.data.rows;
}, },

View File

@ -0,0 +1,217 @@
/**
* useBackgroundDimensionSuggestion Hook
*
* Detects background media dimensions and suggests canvas size updates.
* Used in constructor to prompt user when background resolution differs from project settings.
*/
import { useEffect, useCallback, useState, useRef } from 'react';
import { logger } from '../lib/logger';
interface MediaDimensions {
width: number;
height: number;
}
interface UseBackgroundDimensionSuggestionOptions {
/** URL of the background media (image or video) */
mediaUrl: string | undefined;
/** Current project design width */
currentDesignWidth: number;
/** Current project design height */
currentDesignHeight: number;
/** Callback when dimensions differ from current settings */
onSuggest: (width: number, height: number) => void;
/** Whether suggestion is enabled */
enabled?: boolean;
}
/**
* Detect dimensions of an image URL.
*/
async function getImageDimensions(
url: string,
): Promise<MediaDimensions | null> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
resolve({
width: img.naturalWidth,
height: img.naturalHeight,
});
};
img.onerror = () => {
resolve(null);
};
img.src = url;
});
}
/**
* Detect dimensions of a video URL.
*/
async function getVideoDimensions(
url: string,
): Promise<MediaDimensions | null> {
return new Promise((resolve) => {
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
resolve({
width: video.videoWidth,
height: video.videoHeight,
});
// Clean up
video.src = '';
video.load();
};
video.onerror = () => {
resolve(null);
};
video.src = url;
});
}
/**
* Determine if URL is likely a video based on extension.
*/
function isVideoUrl(url: string): boolean {
const videoExtensions = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.m4v'];
const lowercaseUrl = url.toLowerCase();
return videoExtensions.some((ext) => lowercaseUrl.includes(ext));
}
/**
* Check if URL is a resolved/loadable URL (not a raw storage path).
* Only process: http(s) URLs, blob URLs, data URLs
*/
function isLoadableUrl(url: string): boolean {
return (
url.startsWith('http://') ||
url.startsWith('https://') ||
url.startsWith('blob:') ||
url.startsWith('data:')
);
}
/**
* Get media dimensions from URL (image or video).
*/
async function getMediaDimensions(
url: string,
): Promise<MediaDimensions | null> {
if (isVideoUrl(url)) {
return getVideoDimensions(url);
}
return getImageDimensions(url);
}
/**
* Hook to detect background media dimensions and suggest canvas updates.
*
* @example
* const { suggestion, dismissSuggestion } = useBackgroundDimensionSuggestion({
* mediaUrl: backgroundImageUrl,
* currentDesignWidth: project.design_width ?? 1920,
* currentDesignHeight: project.design_height ?? 1080,
* onSuggest: (width, height) => {
* // Show suggestion UI
* },
* });
*/
export function useBackgroundDimensionSuggestion({
mediaUrl,
currentDesignWidth,
currentDesignHeight,
onSuggest,
enabled = true,
}: UseBackgroundDimensionSuggestionOptions) {
const [suggestion, setSuggestion] = useState<MediaDimensions | null>(null);
const lastCheckedUrlRef = useRef<string | null>(null);
const dismissedUrlsRef = useRef<Set<string>>(new Set());
const dismissSuggestion = useCallback(() => {
if (mediaUrl) {
dismissedUrlsRef.current.add(mediaUrl);
}
setSuggestion(null);
}, [mediaUrl]);
const acceptSuggestion = useCallback(() => {
if (suggestion) {
onSuggest(suggestion.width, suggestion.height);
setSuggestion(null);
}
}, [suggestion, onSuggest]);
useEffect(() => {
if (!enabled || !mediaUrl) {
setSuggestion(null);
return;
}
// Skip raw storage paths - only process resolved URLs (http, https, blob, data)
if (!isLoadableUrl(mediaUrl)) {
return;
}
// Skip if already checked this URL
if (lastCheckedUrlRef.current === mediaUrl) {
return;
}
// Skip if user dismissed this URL
if (dismissedUrlsRef.current.has(mediaUrl)) {
return;
}
lastCheckedUrlRef.current = mediaUrl;
const detectDimensions = async () => {
try {
const dimensions = await getMediaDimensions(mediaUrl);
if (!dimensions) {
logger.warn('[CanvasSuggestion] Failed to detect media dimensions', {
url: mediaUrl,
});
return;
}
// Check if dimensions differ significantly (allow for small rounding)
const widthDiff = Math.abs(dimensions.width - currentDesignWidth);
const heightDiff = Math.abs(dimensions.height - currentDesignHeight);
const threshold = 10; // pixels
if (widthDiff > threshold || heightDiff > threshold) {
logger.info('[CanvasSuggestion] Detected different dimensions', {
media: dimensions,
current: { width: currentDesignWidth, height: currentDesignHeight },
});
setSuggestion(dimensions);
}
} catch (error) {
logger.error(
'[CanvasSuggestion] Error detecting dimensions',
error instanceof Error ? error : { error },
);
}
};
void detectDimensions();
}, [mediaUrl, currentDesignWidth, currentDesignHeight, enabled]);
return {
/** Suggested dimensions from detected media */
suggestion,
/** Dismiss the current suggestion */
dismissSuggestion,
/** Accept the suggestion and call onSuggest callback */
acceptSuggestion,
/** Whether a suggestion is currently available */
hasSuggestion: suggestion !== null,
};
}

View File

@ -2,18 +2,30 @@
* useBackgroundTransition Hook * useBackgroundTransition Hook
* *
* Manages background transition effects when switching between pages. * Manages background transition effects when switching between pages.
* Handles the fade-out animation of the transition video overlay and * Handles the fade-out animation of the transition video overlay,
* fade-in animation for non-transition navigation, and
* coordinates with the page switch hook to clear previous backgrounds. * coordinates with the page switch hook to clear previous backgrounds.
* *
* This hook consolidates the background transition logic used by both * This hook consolidates the background transition logic used by both
* RuntimePresentation and constructor.tsx. * RuntimePresentation and constructor.tsx.
* *
* Two modes: * Two modes:
* 1. Full mode (RuntimePresentation): Fade-out animation + direct navigation clearing * 1. Full mode (RuntimePresentation): Fade-out animation + fade-in + direct navigation clearing
* 2. Simple mode (constructor): Direct navigation clearing only * 2. Simple mode (constructor): Direct navigation clearing + optional fade-in
*/ */
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback, useRef } from 'react';
import { CANVAS_CONFIG } from '../config/canvas.config';
/**
* Fade-out duration from config
*/
const FADE_OUT_DURATION_MS = CANVAS_CONFIG.pageTransition.fadeOutDurationMs;
/**
* Fade-in duration from config
*/
const FADE_IN_DURATION_MS = CANVAS_CONFIG.pageTransition.fadeInDurationMs;
/** /**
* Fade-out configuration (optional - for RuntimePresentation) * Fade-out configuration (optional - for RuntimePresentation)
@ -29,6 +41,16 @@ export interface FadeOutConfig {
onTransitionCleanup: () => void; onTransitionCleanup: () => void;
} }
/**
* Fade-in configuration (optional - for page content fade-in)
*/
export interface FadeInConfig {
/** Whether a transition video is currently active (disables fade-in) */
hasActiveTransition: boolean;
/** Optional duration override (uses config default if not provided) */
durationMs?: number;
}
export interface UseBackgroundTransitionOptions { export interface UseBackgroundTransitionOptions {
/** Page switch hook instance for clearing previous background */ /** Page switch hook instance for clearing previous background */
pageSwitch: { pageSwitch: {
@ -39,6 +61,8 @@ export interface UseBackgroundTransitionOptions {
}; };
/** Optional fade-out configuration (for RuntimePresentation) */ /** Optional fade-out configuration (for RuntimePresentation) */
fadeOut?: FadeOutConfig; fadeOut?: FadeOutConfig;
/** Optional fade-in configuration for page content */
fadeIn?: FadeInConfig;
} }
export interface UseBackgroundTransitionResult { export interface UseBackgroundTransitionResult {
@ -46,19 +70,20 @@ export interface UseBackgroundTransitionResult {
isOverlayFadingOut: boolean; isOverlayFadingOut: boolean;
/** Reset the fade-out state (call before starting a new transition) */ /** Reset the fade-out state (call before starting a new transition) */
resetFadeOut: () => void; resetFadeOut: () => void;
/** Whether page content is currently fading in */
isFadingIn: boolean;
/** Opacity value for elements container (0 during fade, 1 when complete) */
elementsOpacity: number;
/** Reset fade-in state before starting new navigation */
resetFadeIn: () => void;
} }
/**
* Duration of the fade-out animation in milliseconds
*/
const FADE_DURATION_MS = 300;
/** /**
* Hook for managing background transition effects. * Hook for managing background transition effects.
* *
* @example * @example
* // Full mode with fade-out (RuntimePresentation) * // Full mode with fade-out and fade-in (RuntimePresentation)
* const { isOverlayFadingOut, resetFadeOut } = useBackgroundTransition({ * const { isOverlayFadingOut, resetFadeOut, isFadingIn, elementsOpacity, resetFadeIn } = useBackgroundTransition({
* pageSwitch, * pageSwitch,
* fadeOut: { * fadeOut: {
* pendingTransitionComplete, * pendingTransitionComplete,
@ -69,6 +94,9 @@ const FADE_DURATION_MS = 300;
* setPendingTransitionComplete(false); * setPendingTransitionComplete(false);
* }, * },
* }, * },
* fadeIn: {
* hasActiveTransition: Boolean(transitionPreview),
* },
* }); * });
* *
* @example * @example
@ -78,9 +106,19 @@ const FADE_DURATION_MS = 300;
export function useBackgroundTransition({ export function useBackgroundTransition({
pageSwitch, pageSwitch,
fadeOut, fadeOut,
fadeIn,
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult { }: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false); const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
// Fade-in state
const [isFadingIn, setIsFadingIn] = useState(false);
const [elementsOpacity, setElementsOpacity] = useState(1);
// Track previous isSwitching state to detect transition start
const wasSwitchingRef = useRef(false);
// Track timer for cleanup
const fadeInTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** /**
* Reset fade-out state before starting a new transition. * Reset fade-out state before starting a new transition.
* This prevents the fade-out effect from re-triggering when state resets. * This prevents the fade-out effect from re-triggering when state resets.
@ -89,6 +127,19 @@ export function useBackgroundTransition({
setIsOverlayFadingOut(false); setIsOverlayFadingOut(false);
}, []); }, []);
/**
* Reset fade-in state before starting new navigation.
* Clears any in-progress fade animation.
*/
const resetFadeIn = useCallback(() => {
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
setIsFadingIn(false);
setElementsOpacity(1);
}, []);
/** /**
* Effect: Fade out and remove transition overlay when background is ready. * Effect: Fade out and remove transition overlay when background is ready.
* Only runs when fadeOut config is provided. * Only runs when fadeOut config is provided.
@ -129,7 +180,7 @@ export function useBackgroundTransition({
// Reset fade-out state // Reset fade-out state
setIsOverlayFadingOut(false); setIsOverlayFadingOut(false);
}, FADE_DURATION_MS); }, FADE_OUT_DURATION_MS);
return () => clearTimeout(fadeTimer); return () => clearTimeout(fadeTimer);
} }
@ -157,8 +208,71 @@ export function useBackgroundTransition({
pageSwitch.clearPreviousBackground, pageSwitch.clearPreviousBackground,
]); ]);
/**
* Effect: Fade-in page content on non-transition navigation.
*
* Trigger: pageSwitch.isSwitching becomes true AND no active transition
*
* Sequence:
* 1. Navigation starts (isSwitching: false true)
* 2. No transition video active
* 3. Set elementsOpacity = 0
* 4. Use double RAF to ensure paint before animation
* 5. Set elementsOpacity = 1 (CSS animates)
* 6. After duration, set isFadingIn = false
*/
useEffect(() => {
if (!fadeIn) {
wasSwitchingRef.current = pageSwitch.isSwitching;
return;
}
const { hasActiveTransition, durationMs = FADE_IN_DURATION_MS } = fadeIn;
const justStartedSwitching =
pageSwitch.isSwitching && !wasSwitchingRef.current;
wasSwitchingRef.current = pageSwitch.isSwitching;
// Start fade-in when:
// - Just started switching (transition from false to true)
// - No active transition video
if (justStartedSwitching && !hasActiveTransition) {
setIsFadingIn(true);
setElementsOpacity(0);
// Double RAF ensures opacity:0 is painted before transition starts
// (Same pattern as usePageSwitch.ts:396-397)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setElementsOpacity(1);
});
});
// Clear any existing timer
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
}
// Mark fade as complete after duration
fadeInTimerRef.current = setTimeout(() => {
setIsFadingIn(false);
fadeInTimerRef.current = null;
}, durationMs);
}
// Cleanup on unmount
return () => {
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
};
}, [pageSwitch.isSwitching, fadeIn]);
return { return {
isOverlayFadingOut, isOverlayFadingOut,
resetFadeOut, resetFadeOut,
isFadingIn,
elementsOpacity,
resetFadeIn,
}; };
} }

View File

@ -0,0 +1,120 @@
/**
* useCanvasScale Hook
*
* Standalone hook for canvas scale without context dependency.
* Takes project-specific design dimensions as parameters.
* Follows pattern: hooks/useStorageQuota.ts
*/
import { useState, useEffect, useMemo, type CSSProperties } from 'react';
import { CANVAS_CONFIG } from '../config/canvas.config';
import { calculateCanvasScale, getCanvasCssVars } from '../lib/canvasScale';
interface UseCanvasScaleOptions {
/** Project's design width */
designWidth?: number;
/** Project's design height */
designHeight?: number;
}
interface CanvasScaleResult {
/** Current scale factor (1.0 = design size) */
scale: number;
/** Design canvas width */
designWidth: number;
/** Design canvas height */
designHeight: number;
/** Calculated canvas width at current scale */
canvasWidth: number;
/** Calculated canvas height at current scale */
canvasHeight: number;
/** Whether viewport is in portrait orientation */
isPortrait: boolean;
/** Whether to show rotation prompt */
showRotatePrompt: boolean;
/** CSS custom properties for inline styles */
cssVars: CSSProperties;
/** Container styles for letterbox mode (maintains aspect ratio) */
letterboxStyles: CSSProperties;
}
/**
* Hook that calculates canvas scale based on viewport size.
* Updates automatically when window is resized.
*
* @param options - Optional design dimensions from project settings
* @returns Canvas scale values and CSS custom properties
*
* @example
* const { scale, cssVars, isPortrait } = useCanvasScale({
* designWidth: project.design_width,
* designHeight: project.design_height,
* });
*
* return <div style={cssVars}>{content}</div>;
*/
export function useCanvasScale(options?: UseCanvasScaleOptions): CanvasScaleResult {
const designWidth = options?.designWidth ?? CANVAS_CONFIG.defaults.width;
const designHeight = options?.designHeight ?? CANVAS_CONFIG.defaults.height;
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
const update = () =>
setDimensions({
width: window.innerWidth,
height: window.innerHeight,
});
// Set initial values
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return useMemo(() => {
const scale = calculateCanvasScale(
dimensions.width,
dimensions.height,
designWidth,
designHeight,
);
const isPortrait = dimensions.height > dimensions.width;
const aspectRatio =
dimensions.width > 0 ? dimensions.width / dimensions.height : 1;
const canvasWidth = designWidth * scale;
const canvasHeight = designHeight * scale;
// Letterbox styles: centered container with exact canvas dimensions
// Creates black bars (letterbox/pillarbox) when aspect ratios don't match
const letterboxStyles: CSSProperties = {
width: canvasWidth,
height: canvasHeight,
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
};
return {
scale,
designWidth,
designHeight,
canvasWidth,
canvasHeight,
isPortrait,
showRotatePrompt:
isPortrait &&
CANVAS_CONFIG.orientation.showRotatePrompt &&
aspectRatio < CANVAS_CONFIG.orientation.minAspectRatioForPrompt,
cssVars: getCanvasCssVars(
scale,
designWidth,
designHeight,
) as CSSProperties,
letterboxStyles,
};
}, [dimensions, designWidth, designHeight]);
}

View File

@ -29,7 +29,7 @@ const EMPTY_ASSETS: Asset[] = [];
interface UseConstructorDataResult { interface UseConstructorDataResult {
// Project // Project
project: { name: string } | null; project: { name: string; design_width?: number; design_height?: number } | null;
projectName: string; projectName: string;
// Pages // Pages

View File

@ -33,9 +33,18 @@ interface TourPage {
background_video_end_time?: number | null; background_video_end_time?: number | null;
} }
interface Project {
id?: string;
name?: string;
design_width?: number;
design_height?: number;
}
interface UseConstructorPageActionsOptions { interface UseConstructorPageActionsOptions {
/** Current project ID */ /** Current project ID */
projectId: string; projectId: string;
/** Current project (for design dimensions) */
project?: Project | null;
/** Array of all pages */ /** Array of all pages */
pages: TourPage[]; pages: TourPage[];
/** Currently active page */ /** Currently active page */
@ -106,6 +115,7 @@ interface UseConstructorPageActionsResult {
*/ */
export function useConstructorPageActions({ export function useConstructorPageActions({
projectId, projectId,
project,
pages, pages,
activePage, activePage,
activePageId, activePageId,
@ -172,6 +182,9 @@ export function useConstructorPageActions({
background_video_muted: backgroundVideoMuted, background_video_muted: backgroundVideoMuted,
background_video_start_time: backgroundVideoStartTime, background_video_start_time: backgroundVideoStartTime,
background_video_end_time: backgroundVideoEndTime, background_video_end_time: backgroundVideoEndTime,
// Copy project design dimensions to page for presentation isolation
design_width: project?.design_width ?? null,
design_height: project?.design_height ?? null,
}, },
}); });
@ -206,6 +219,8 @@ export function useConstructorPageActions({
activePageId, activePageId,
pageBackground, pageBackground,
elements, elements,
project?.design_width,
project?.design_height,
onError, onError,
onReload, onReload,
onSuccess, onSuccess,
@ -223,11 +238,23 @@ export function useConstructorPageActions({
try { try {
setIsSavingToStage(true); setIsSavingToStage(true);
await axios.post('/publish/save-to-stage', { projectId }); const response = await axios.post<{
success: boolean;
summary?: { pages_copied: number; audios_copied: number };
}>('/publish/save-to-stage', { projectId });
onSuccess?.( const pagesCopied = response.data?.summary?.pages_copied ?? 0;
'Successfully saved dev content to stage environment. All pages, elements, and transitions have been copied.', const audiosCopied = response.data?.summary?.audios_copied ?? 0;
);
if (pagesCopied === 0) {
onError?.(
'No pages were found to copy. Make sure you have dev pages saved before publishing to stage.',
);
} else {
onSuccess?.(
`Successfully saved to stage: ${pagesCopied} page${pagesCopied !== 1 ? 's' : ''} and ${audiosCopied} audio track${audiosCopied !== 1 ? 's' : ''} copied.`,
);
}
} catch (error: unknown) { } catch (error: unknown) {
const axiosError = error as { const axiosError = error as {
response?: { data?: { message?: string } }; response?: { data?: { message?: string } };
@ -271,6 +298,9 @@ export function useConstructorPageActions({
background_loop: false, background_loop: false,
requires_auth: false, requires_auth: false,
ui_schema_json: JSON.stringify({ elements: [] }), ui_schema_json: JSON.stringify({ elements: [] }),
// Copy project design dimensions to new page
design_width: project?.design_width ?? null,
design_height: project?.design_height ?? null,
}; };
try { try {
@ -310,6 +340,8 @@ export function useConstructorPageActions({
onSetMenuOpen, onSetMenuOpen,
onSuccess, onSuccess,
pages, pages,
project?.design_width,
project?.design_height,
projectId, projectId,
]); ]);

View File

@ -0,0 +1,212 @@
/**
* Canvas Scale Utilities
*
* Pure functions for canvas scaling calculations.
* Provides a unified scaling system using CSS custom properties (--cu).
*
* The canvas scale factor ensures all UI elements scale proportionally
* relative to a design canvas (e.g., 1920×1080), regardless of viewport size.
*/
import { CANVAS_CONFIG } from '../config/canvas.config';
/**
* Calculate scale factor for current viewport.
* Uses 'fit' mode: scale to fit within viewport while maintaining aspect ratio.
*
* @param viewportWidth - Current viewport width in pixels
* @param viewportHeight - Current viewport height in pixels
* @param designWidth - Design canvas width (default: 1920)
* @param designHeight - Design canvas height (default: 1080)
* @returns Scale factor (1.0 = design size, 2.0 = double, 0.5 = half)
*/
export function calculateCanvasScale(
viewportWidth: number,
viewportHeight: number,
designWidth: number = CANVAS_CONFIG.defaults.width,
designHeight: number = CANVAS_CONFIG.defaults.height,
): number {
if (viewportWidth <= 0 || viewportHeight <= 0) return 1;
if (designWidth <= 0 || designHeight <= 0) return 1;
const scaleX = viewportWidth / designWidth;
const scaleY = viewportHeight / designHeight;
// Use min() to fit content within viewport (may have letterbox/pillarbox)
const scale = Math.min(scaleX, scaleY);
// Clamp to configured min/max
return Math.max(
CANVAS_CONFIG.scaling.minScale,
Math.min(CANVAS_CONFIG.scaling.maxScale, scale),
);
}
/**
* Convert design pixels to CSS calc() expression using canvas units.
* The result scales with the viewport based on --cu custom property.
*
* @param designPixels - Value in design pixels (at 1920×1080 reference)
* @returns CSS calc() expression like "calc(24 * var(--cu, 1px))"
*
* @example
* toCU(24) // Returns "calc(24 * var(--cu, 1px))"
* // At 1920×1080: renders as 24px
* // At 3840×2160: renders as 48px
* // At 960×540: renders as 12px
*/
export function toCU(designPixels: number): string {
if (designPixels === 0) return '0';
return `calc(${designPixels} * var(--cu, 1px))`;
}
/**
* Check if a CSS value uses legacy units (px, rem, vw, vh).
* Used to detect values that need migration to canvas units.
*
* @param value - CSS value string to check
* @returns true if value uses legacy units
*/
export function isLegacyUnit(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
// Skip values that already use canvas units or calc
if (trimmed.includes('var(--cu') || trimmed.includes('--cu')) return false;
if (/^calc\(/i.test(trimmed) && trimmed.includes('--cu')) return false;
// Check for px, rem, vw, vh units
return /^\d*\.?\d+(px|rem|vw|vh)$/i.test(trimmed);
}
/**
* Convert viewport width (vw) value to design pixels.
*
* @param value - Value in vw units
* @param designWidth - Design canvas width
* @returns Equivalent design pixels
*/
export function vwToDesignPx(
value: number,
designWidth: number = CANVAS_CONFIG.defaults.width,
): number {
return (value / 100) * designWidth;
}
/**
* Convert viewport height (vh) value to design pixels.
*
* @param value - Value in vh units
* @param designHeight - Design canvas height
* @returns Equivalent design pixels
*/
export function vhToDesignPx(
value: number,
designHeight: number = CANVAS_CONFIG.defaults.height,
): number {
return (value / 100) * designHeight;
}
/**
* Convert rem value to design pixels.
* Assumes standard 16px root font size.
*
* @param value - Value in rem units
* @returns Equivalent design pixels
*/
export function remToDesignPx(value: number): number {
return value * 16;
}
/**
* Normalize a legacy value to canvas units.
* Handles vw, vh, px, rem values and converts them to calc() expressions.
*
* @param value - The value to normalize (string or number)
* @param property - Property type for context-aware conversion
* @param designWidth - Design canvas width for vw conversion
* @param designHeight - Design canvas height for vh conversion
* @returns Normalized CSS value using canvas units
*/
export function normalizeToCanvasUnits(
value: string | number | undefined,
property: 'width' | 'height' | 'fontSize' | 'padding' | 'borderRadius' | 'gap',
designWidth: number = CANVAS_CONFIG.defaults.width,
designHeight: number = CANVAS_CONFIG.defaults.height,
): string {
if (value === null || value === undefined || value === '') return '';
const str = String(value).trim();
if (!str) return '';
// Zero doesn't need conversion
if (str === '0') return '0';
// Already uses canvas units or calc - return as-is
if (str.includes('var(--cu') || str.includes('--cu')) return str;
if (/^calc\(/i.test(str)) return str;
// Complex values (contain spaces) - return as-is for now
if (str.includes(' ')) return str;
// Handle vw units
const vwMatch = str.match(/^(-?\d*\.?\d+)vw$/i);
if (vwMatch) {
const vw = parseFloat(vwMatch[1]);
const designPx = vwToDesignPx(vw, designWidth);
return toCU(designPx);
}
// Handle vh units
const vhMatch = str.match(/^(-?\d*\.?\d+)vh$/i);
if (vhMatch) {
const vh = parseFloat(vhMatch[1]);
const designPx = vhToDesignPx(vh, designHeight);
return toCU(designPx);
}
// Handle rem units
const remMatch = str.match(/^(-?\d*\.?\d+)rem$/i);
if (remMatch) {
const rem = parseFloat(remMatch[1]);
const designPx = remToDesignPx(rem);
return toCU(designPx);
}
// Handle px units
const pxMatch = str.match(/^(-?\d*\.?\d+)px$/i);
if (pxMatch) {
const px = parseFloat(pxMatch[1]);
return toCU(px);
}
// Plain number - interpret as design pixels
const num = parseFloat(str);
if (Number.isFinite(num)) {
return toCU(num);
}
// Fallback: return as-is
return str;
}
/**
* Generate CSS custom properties object for canvas scaling.
*
* @param scale - Current canvas scale factor
* @param designWidth - Design canvas width
* @param designHeight - Design canvas height
* @returns Object suitable for React style prop
*/
export function getCanvasCssVars(
scale: number,
designWidth: number = CANVAS_CONFIG.defaults.width,
designHeight: number = CANVAS_CONFIG.defaults.height,
): Record<string, string> {
return {
[CANVAS_CONFIG.cssVars.designWidth]: `${designWidth}`,
[CANVAS_CONFIG.cssVars.designHeight]: `${designHeight}`,
[CANVAS_CONFIG.cssVars.scale]: `${scale}`,
[CANVAS_CONFIG.cssVars.unit]: `calc(1px * ${scale})`,
};
}

View File

@ -3,9 +3,15 @@
* *
* Unified types and utilities for UI element CSS styling. * Unified types and utilities for UI element CSS styling.
* Used by constructor, RuntimePresentation, and element-type-defaults admin pages. * Used by constructor, RuntimePresentation, and element-type-defaults admin pages.
*
* Canvas Units (--cu):
* All dimensions now use canvas units for uniform scaling across devices.
* The --cu custom property is set by CanvasScaleProvider based on viewport size.
* At 1920×1080: 1cu = 1px. At 3840×2160: 1cu = 2px. At 960×540: 1cu = 0.5px.
*/ */
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { toCU } from './canvasScale';
/** /**
* Normalize a numeric value to include a CSS unit suffix. * Normalize a numeric value to include a CSS unit suffix.
@ -55,17 +61,132 @@ function normalizeWithUnit(
return str; return str;
} }
/** Normalize pixel values (border, borderRadius, fontSize for description) */ /** Normalize pixel values (border, borderRadius, fontSize) - now uses canvas units */
export const normalizePixelValue = (value: string | number | undefined) => export const normalizePixelValue = (value: string | number | undefined) => {
normalizeWithUnit(value, 'px'); if (value === null || value === undefined || value === '') return '';
/** Normalize viewport width values (width, minWidth, maxWidth) */ const str = String(value).trim();
export const normalizeViewportWidth = (value: string | number | undefined) => if (!str) return '';
normalizeWithUnit(value, 'vw');
/** Normalize viewport height values (height, minHeight, maxHeight) */ // Zero doesn't need a unit
export const normalizeViewportHeight = (value: string | number | undefined) => if (str === '0') return '0';
normalizeWithUnit(value, 'vh');
// Already uses canvas units or calc - return as-is
if (str.includes('var(--cu') || str.includes('--cu')) return str;
if (/^calc\(/i.test(str) && str.includes('--cu')) return str;
// Complex values (contain spaces) - return as-is
if (str.includes(' ')) return str;
// CSS functions (calc, var, etc.) - return as-is
if (/^(calc|var|min|max|clamp)\(/i.test(str)) return str;
// Already has px unit - convert to canvas units
const pxMatch = str.match(/^(-?\d*\.?\d+)px$/i);
if (pxMatch) {
const px = parseFloat(pxMatch[1]);
return toCU(px);
}
// Already has other units - return as-is
if (/[a-z%]+$/i.test(str)) return str;
// Handle numbers (including negative and decimals like ".5")
const num = parseFloat(str);
if (Number.isFinite(num)) {
// Zero after parsing
if (num === 0) return '0';
// Convert plain number to canvas units
return toCU(num);
}
// Fallback: return as-is
return str;
};
/** Normalize viewport width values (width, minWidth, maxWidth) - now uses canvas units */
export const normalizeViewportWidth = (value: string | number | undefined) => {
if (value === null || value === undefined || value === '') return '';
const str = String(value).trim();
if (!str) return '';
// Zero doesn't need a unit
if (str === '0') return '0';
// Already uses canvas units - return as-is
if (str.includes('var(--cu') || str.includes('--cu')) return str;
if (/^calc\(/i.test(str) && str.includes('--cu')) return str;
// Complex values (contain spaces) - return as-is
if (str.includes(' ')) return str;
// CSS functions (calc, var, etc.) - return as-is
if (/^(calc|var|min|max|clamp)\(/i.test(str)) return str;
// Handle vw units - convert to canvas units (assumes 1920 design width)
const vwMatch = str.match(/^(-?\d*\.?\d+)vw$/i);
if (vwMatch) {
const vw = parseFloat(vwMatch[1]);
const designPx = (vw / 100) * 1920; // Convert vw to design pixels
return toCU(designPx);
}
// Already has other units - return as-is
if (/[a-z%]+$/i.test(str)) return str;
// Handle numbers - interpret as design pixels
const num = parseFloat(str);
if (Number.isFinite(num)) {
if (num === 0) return '0';
return toCU(num);
}
// Fallback: return as-is
return str;
};
/** Normalize viewport height values (height, minHeight, maxHeight) - now uses canvas units */
export const normalizeViewportHeight = (value: string | number | undefined) => {
if (value === null || value === undefined || value === '') return '';
const str = String(value).trim();
if (!str) return '';
// Zero doesn't need a unit
if (str === '0') return '0';
// Already uses canvas units - return as-is
if (str.includes('var(--cu') || str.includes('--cu')) return str;
if (/^calc\(/i.test(str) && str.includes('--cu')) return str;
// Complex values (contain spaces) - return as-is
if (str.includes(' ')) return str;
// CSS functions (calc, var, etc.) - return as-is
if (/^(calc|var|min|max|clamp)\(/i.test(str)) return str;
// Handle vh units - convert to canvas units (assumes 1080 design height)
const vhMatch = str.match(/^(-?\d*\.?\d+)vh$/i);
if (vhMatch) {
const vh = parseFloat(vhMatch[1]);
const designPx = (vh / 100) * 1080; // Convert vh to design pixels
return toCU(designPx);
}
// Already has other units - return as-is
if (/[a-z%]+$/i.test(str)) return str;
// Handle numbers - interpret as design pixels
const num = parseFloat(str);
if (Number.isFinite(num)) {
if (num === 0) return '0';
return toCU(num);
}
// Fallback: return as-is
return str;
};
/** /**
* CSS style properties supported by UI elements. * CSS style properties supported by UI elements.

View File

@ -3,11 +3,16 @@
* *
* Unified types and utilities for gallery element section styling. * Unified types and utilities for gallery element section styling.
* Follows the same pattern as elementStyles.ts for consistency. * Follows the same pattern as elementStyles.ts for consistency.
*
* Canvas Units (--cu):
* All dimensions now use canvas units for uniform scaling across devices.
* The --cu custom property is set by CanvasScaleProvider based on viewport size.
*/ */
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import type { CanvasElement } from '../types/constructor'; import type { CanvasElement } from '../types/constructor';
import { getFontByKey, getFontStyle } from './fonts'; import { getFontByKey, getFontStyle } from './fonts';
import { toCU } from './canvasScale';
/** /**
* Gallery section names for styling * Gallery section names for styling
@ -20,44 +25,46 @@ export type GallerySectionName =
| 'wrapper'; | 'wrapper';
/** /**
* Default values for gallery sections to preserve current Tailwind appearance * Default values for gallery sections using canvas units.
* Canvas units (--cu) scale with viewport for consistent appearance.
* 1rem = 16px at design scale, so 0.5rem = 8cu, 1rem = 16cu, etc.
*/ */
export const GALLERY_SECTION_DEFAULTS: Record< export const GALLERY_SECTION_DEFAULTS: Record<
GallerySectionName, GallerySectionName,
CSSProperties CSSProperties
> = { > = {
header: { header: {
fontSize: '1.5rem', // text-2xl fontSize: 'calc(24 * var(--cu, 1px))', // text-2xl = 1.5rem = 24px
fontWeight: '700', // font-bold fontWeight: '700', // font-bold
padding: '0.25rem 0.5rem', // px-1 py-2 padding: 'calc(4 * var(--cu, 1px)) calc(8 * var(--cu, 1px))', // px-1 py-2 = 0.25rem 0.5rem
textAlign: 'center', textAlign: 'center',
}, },
title: { title: {
backgroundColor: '#fefce8', // bg-amber-50 backgroundColor: '#fefce8', // bg-amber-50
color: '#1e293b', // text-slate-800 color: '#1e293b', // text-slate-800
fontSize: '0.875rem', // text-sm fontSize: 'calc(14 * var(--cu, 1px))', // text-sm = 0.875rem = 14px
fontWeight: '600', // font-semibold fontWeight: '600', // font-semibold
padding: '0.5rem 0.75rem', // py-2 px-3 padding: 'calc(8 * var(--cu, 1px)) calc(12 * var(--cu, 1px))', // py-2 px-3 = 0.5rem 0.75rem
borderRadius: '0.5rem', // rounded-lg borderRadius: 'calc(8 * var(--cu, 1px))', // rounded-lg = 0.5rem = 8px
textAlign: 'center', textAlign: 'center',
}, },
span: { span: {
backgroundColor: '#334155', // bg-slate-700 backgroundColor: '#334155', // bg-slate-700
color: '#fef3c7', // text-amber-50 color: '#fef3c7', // text-amber-50
fontSize: '0.75rem', // text-xs fontSize: 'calc(12 * var(--cu, 1px))', // text-xs = 0.75rem = 12px
fontWeight: '500', // font-medium fontWeight: '500', // font-medium
padding: '0.5rem', // py-2 px-2 padding: 'calc(8 * var(--cu, 1px))', // py-2 px-2 = 0.5rem
borderRadius: '0.5rem', // rounded-lg borderRadius: 'calc(8 * var(--cu, 1px))', // rounded-lg = 0.5rem = 8px
textAlign: 'center', textAlign: 'center',
}, },
card: { card: {
borderRadius: '0.5rem', // rounded-lg borderRadius: 'calc(8 * var(--cu, 1px))', // rounded-lg = 0.5rem = 8px
}, },
wrapper: { wrapper: {
backgroundColor: 'rgba(0, 0, 0, 0.6)', // bg-black/60 backgroundColor: 'rgba(0, 0, 0, 0.6)', // bg-black/60
padding: '0.75rem', // p-3 padding: 'calc(12 * var(--cu, 1px))', // p-3 = 0.75rem = 12px
borderRadius: '0.75rem', // rounded-xl borderRadius: 'calc(12 * var(--cu, 1px))', // rounded-xl = 0.75rem = 12px
gap: '0.5rem', // gap-2 gap: 'calc(8 * var(--cu, 1px))', // gap-2 = 0.5rem = 8px
backdropFilter: 'blur(4px)', // backdrop-blur-sm backdropFilter: 'blur(4px)', // backdrop-blur-sm
}, },
}; };
@ -114,13 +121,65 @@ const normalizeWithUnit = (value: unknown, unit: string): string => {
return trimmed; return trimmed;
}; };
/** Normalize rem values (fontSize, padding, borderRadius, gap) */ /**
const normalizeRemValue = (value: unknown): string => * Normalize values to canvas units.
normalizeWithUnit(value, 'rem'); * Handles rem, px, and plain numbers.
* 1rem = 16 design pixels, so converts rem to equivalent canvas units.
*/
const normalizeCanvasUnit = (value: unknown): string => {
const trimmed = getTrimmedValue(value);
if (!trimmed) return '';
/** Normalize pixel values (for properties that use px) */ // Zero doesn't need a unit
const normalizePxValue = (value: unknown): string => if (trimmed === '0') return '0';
normalizeWithUnit(value, 'px');
// Already uses canvas units - return as-is
if (trimmed.includes('var(--cu') || trimmed.includes('--cu')) return trimmed;
if (/^calc\(/i.test(trimmed) && trimmed.includes('--cu')) return trimmed;
// Complex values (contain spaces) - check if already calc with --cu
if (trimmed.includes(' ')) return trimmed;
// CSS functions (calc, var, etc.) - return as-is
if (/^(calc|var|min|max|clamp)\(/i.test(trimmed)) return trimmed;
// Handle rem units - convert to canvas units (1rem = 16px)
const remMatch = trimmed.match(/^(-?\d*\.?\d+)rem$/i);
if (remMatch) {
const rem = parseFloat(remMatch[1]);
const designPx = rem * 16;
return toCU(designPx);
}
// Handle px units - convert to canvas units
const pxMatch = trimmed.match(/^(-?\d*\.?\d+)px$/i);
if (pxMatch) {
const px = parseFloat(pxMatch[1]);
return toCU(px);
}
// Already has other units - return as-is
if (/[a-z%]+$/i.test(trimmed)) return trimmed;
// Handle numbers (including negative and decimals like ".5")
const num = parseFloat(trimmed);
if (Number.isFinite(num)) {
if (num === 0) return '0';
// For backward compatibility, treat plain numbers as rem values
// since gallery defaults were in rem
const designPx = num * 16;
return toCU(designPx);
}
// Fallback: return as-is
return trimmed;
};
/** @deprecated Use normalizeCanvasUnit instead */
const normalizeRemValue = normalizeCanvasUnit;
/** @deprecated Use normalizeCanvasUnit instead */
const normalizePxValue = normalizeCanvasUnit;
/** /**
* Apply value with default fallback and optional unit normalization * Apply value with default fallback and optional unit normalization
@ -358,7 +417,7 @@ export function buildGallerySpanGridStyle(
element: Partial<CanvasElement>, element: Partial<CanvasElement>,
): CSSProperties { ): CSSProperties {
const columns = getGalleryGridColumns(element, 'span'); const columns = getGalleryGridColumns(element, 'span');
const gap = normalizeRemValue(element.gallerySpanGap) || '0.5rem'; const gap = normalizeCanvasUnit(element.gallerySpanGap) || 'calc(8 * var(--cu, 1px))';
return { return {
display: 'grid', display: 'grid',
@ -459,7 +518,7 @@ export function buildGalleryCardGridStyle(
element: Partial<CanvasElement>, element: Partial<CanvasElement>,
): CSSProperties { ): CSSProperties {
const columns = getGalleryGridColumns(element, 'card'); const columns = getGalleryGridColumns(element, 'card');
const gap = normalizeRemValue(element.galleryCardGap) || '0.5rem'; const gap = normalizeCanvasUnit(element.galleryCardGap) || 'calc(8 * var(--cu, 1px))';
return { return {
display: 'grid', display: 'grid',

View File

@ -1,4 +1,5 @@
import { mdiContentSave, mdiExitToApp, mdiPlus } from '@mdi/js'; import { mdiContentSave, mdiExitToApp, mdiPlus } from '@mdi/js';
import axios from 'axios';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { import React, {
@ -84,6 +85,10 @@ import {
type ConstructorContextValue, type ConstructorContextValue,
type NavigationElementType, type NavigationElementType,
} from '../context/ConstructorContext'; } from '../context/ConstructorContext';
import { useCanvasScale } from '../hooks/useCanvasScale';
import { useBackgroundDimensionSuggestion } from '../hooks/useBackgroundDimensionSuggestion';
import { CANVAS_CONFIG } from '../config/canvas.config';
import { CanvasDimensionSuggestion } from '../components/CanvasDimensionSuggestion';
// Constructor helpers (extracted utilities) // Constructor helpers (extracted utilities)
import { import {
@ -136,6 +141,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Use React Query for data fetching (replaces manual loadData) // Use React Query for data fetching (replaces manual loadData)
const { const {
project,
pages, pages,
pageLinks, pageLinks,
allPagesPreloadElements, allPagesPreloadElements,
@ -151,6 +157,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
isAuthReady, isAuthReady,
}); });
// Canvas scale for responsive UI elements and letterbox mode
const { cssVars: canvasCssVars, letterboxStyles } = useCanvasScale({
designWidth: project?.design_width,
designHeight: project?.design_height,
});
// Page navigation with history tracking via shared hook // Page navigation with history tracking via shared hook
const { const {
currentPageId: activePageId, currentPageId: activePageId,
@ -183,6 +195,35 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
backgroundVideoEndTime, backgroundVideoEndTime,
} = usePageBackground(); } = usePageBackground();
// Background dimension auto-detection for canvas size suggestions
const handleDimensionSuggestionAccept = useCallback(
async (width: number, height: number) => {
if (!projectId) return;
try {
await axios.put(`/projects/${projectId}`, {
data: { design_width: width, design_height: height },
});
// Refetch to update the project data
await refetchData();
} catch (error) {
logger.error('Failed to update project dimensions', error instanceof Error ? error : { error });
}
},
[projectId, refetchData],
);
const {
suggestion: dimensionSuggestion,
dismissSuggestion: dismissDimensionSuggestion,
acceptSuggestion: acceptDimensionSuggestion,
} = useBackgroundDimensionSuggestion({
mediaUrl: backgroundImageUrl || backgroundVideoUrl,
currentDesignWidth: project?.design_width ?? 1920,
currentDesignHeight: project?.design_height ?? 1080,
onSuggest: handleDimensionSuggestionAccept,
enabled: !isElementEditMode && !!project,
});
const [selectedMenuItem, setSelectedMenuItem] = const [selectedMenuItem, setSelectedMenuItem] =
useState<EditorMenuItem>('none'); useState<EditorMenuItem>('none');
// Transition preview state managed by useTransitionPreview hook (below) // Transition preview state managed by useTransitionPreview hook (below)
@ -354,6 +395,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// isBack parameter indicates this is a back navigation (pops history instead of pushing) // isBack parameter indicates this is a back navigation (pops history instead of pushing)
const switchToPage = useCallback( const switchToPage = useCallback(
async (page: TourPage | null, isBack = false) => { async (page: TourPage | null, isBack = false) => {
// Reset fade-in state to start fresh
resetFadeIn();
// Mark this page as initialized to prevent redundant effect calls // Mark this page as initialized to prevent redundant effect calls
if (page) { if (page) {
lastInitializedPageIdRef.current = page.id; lastInitializedPageIdRef.current = page.id;
@ -380,7 +424,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
}, },
); );
}, },
[pageSwitchToPage, updateBackgroundFromPage, applyPageSelection], [pageSwitchToPage, updateBackgroundFromPage, applyPageSelection, resetFadeIn],
); );
const { isBuffering: isReverseBuffering } = useTransitionPlayback({ const { isBuffering: isReverseBuffering } = useTransitionPlayback({
@ -437,9 +481,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
}, },
}); });
// Use shared background transition hook for direct navigation clearing // Use shared background transition hook for direct navigation clearing and fade-in
// (No fade-out needed in constructor - transitions complete immediately) // (No fade-out needed in constructor - transitions complete immediately)
useBackgroundTransition({ pageSwitch }); const { isFadingIn, elementsOpacity, resetFadeIn } = useBackgroundTransition({
pageSwitch,
fadeIn: {
hasActiveTransition: Boolean(transitionPreview),
},
});
const iconPreloadTargets = useMemo(() => { const iconPreloadTargets = useMemo(() => {
const preloadableTypes: CanvasElementType[] = [ const preloadableTypes: CanvasElementType[] = [
@ -588,6 +637,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
createTransition, createTransition,
} = useConstructorPageActions({ } = useConstructorPageActions({
projectId, projectId,
project,
pages, pages,
activePage, activePage,
activePageId, activePageId,
@ -1428,8 +1478,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
<div <div
ref={canvasRef} ref={canvasRef}
tabIndex={-1} tabIndex={-1}
className={`absolute inset-0 z-20 overflow-clip ${hasFullWidthCarousel ? 'bg-transparent' : 'bg-black'}`} className={`z-20 overflow-clip ${hasFullWidthCarousel ? 'bg-transparent' : 'bg-black'}`}
style={canvasBackgroundStyle} style={{ ...canvasCssVars, ...letterboxStyles, ...canvasBackgroundStyle }}
> >
<BackdropPortalProvider> <BackdropPortalProvider>
<CanvasBackground <CanvasBackground
@ -1448,7 +1498,15 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
/> />
{/* Elements container - z-10 ensures they appear above backdrop layer */} {/* Elements container - z-10 ensures they appear above backdrop layer */}
<div className='absolute inset-0 z-10'> <div
className='absolute inset-0 z-10'
style={{
opacity: elementsOpacity,
transition: isFadingIn
? `opacity ${CANVAS_CONFIG.pageTransition.fadeInDurationMs}ms ${CANVAS_CONFIG.pageTransition.easing}`
: 'none',
}}
>
{isLoading ? ( {isLoading ? (
<div className='absolute inset-0 flex items-center justify-center'> <div className='absolute inset-0 flex items-center justify-center'>
<p className='text-sm text-gray-500'> <p className='text-sm text-gray-500'>
@ -1585,6 +1643,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
/> />
)} )}
{/* Canvas Dimension Suggestion */}
{dimensionSuggestion && (
<CanvasDimensionSuggestion
suggestedWidth={dimensionSuggestion.width}
suggestedHeight={dimensionSuggestion.height}
currentWidth={project?.design_width ?? 1920}
currentHeight={project?.design_height ?? 1080}
onAccept={acceptDimensionSuggestion}
onDismiss={dismissDimensionSuggestion}
/>
)}
<style jsx>{` <style jsx>{`
.menu-action-btn { .menu-action-btn {
width: 100%; width: 100%;

View File

@ -27,6 +27,7 @@ import { useRouter } from 'next/router';
import { toast, ToastContainer } from 'react-toastify'; import { toast, ToastContainer } from 'react-toastify';
import type { Project } from '../../types/entities'; import type { Project } from '../../types/entities';
import { logger } from '../../lib/logger'; import { logger } from '../../lib/logger';
import { CANVAS_CONFIG } from '../../config/canvas.config';
const initVals = { const initVals = {
name: '', name: '',
@ -35,6 +36,8 @@ const initVals = {
logo_url: '', logo_url: '',
favicon_url: '', favicon_url: '',
og_image_url: '', og_image_url: '',
design_width: CANVAS_CONFIG.defaults.width as number,
design_height: CANVAS_CONFIG.defaults.height as number,
is_deleted: false, is_deleted: false,
deleted_at_time: new Date(), deleted_at_time: new Date(),
}; };
@ -47,6 +50,7 @@ const EditProjectsPage = () => {
{ id: string; cdn_url: string; storage_key?: string; name: string }[] { id: string; cdn_url: string; storage_key?: string; name: string }[]
>([]); >([]);
const [isLoadingLogoAssets, setIsLoadingLogoAssets] = useState(false); const [isLoadingLogoAssets, setIsLoadingLogoAssets] = useState(false);
const [isCustomPreset, setIsCustomPreset] = useState(false);
const projectsState = useAppSelector((state) => state.projects); const projectsState = useAppSelector((state) => state.projects);
const projects = projectsState.data; const projects = projectsState.data;
@ -118,6 +122,14 @@ const EditProjectsPage = () => {
useEffect(() => { useEffect(() => {
if (typeof project === 'object' && project !== null) { if (typeof project === 'object' && project !== null) {
const projectData = project as unknown as Record<string, unknown>; const projectData = project as unknown as Record<string, unknown>;
const width = Number(projectData.design_width) || CANVAS_CONFIG.defaults.width;
const height = Number(projectData.design_height) || CANVAS_CONFIG.defaults.height;
// Check if dimensions match a preset
const matchesPreset = CANVAS_CONFIG.presets.some(
(p) => p.width === width && p.height === height,
);
setIsCustomPreset(!matchesPreset);
setInitialValues({ setInitialValues({
name: String(projectData.name || ''), name: String(projectData.name || ''),
@ -126,6 +138,8 @@ const EditProjectsPage = () => {
logo_url: String(projectData.logo_url || ''), logo_url: String(projectData.logo_url || ''),
favicon_url: String(projectData.favicon_url || ''), favicon_url: String(projectData.favicon_url || ''),
og_image_url: String(projectData.og_image_url || ''), og_image_url: String(projectData.og_image_url || ''),
design_width: width,
design_height: height,
is_deleted: Boolean(projectData.is_deleted), is_deleted: Boolean(projectData.is_deleted),
deleted_at_time: projectData.deleted_at_time deleted_at_time: projectData.deleted_at_time
? new Date(projectData.deleted_at_time as string) ? new Date(projectData.deleted_at_time as string)
@ -142,6 +156,8 @@ const EditProjectsPage = () => {
logo_url: data.logo_url, logo_url: data.logo_url,
favicon_url: data.favicon_url, favicon_url: data.favicon_url,
og_image_url: data.og_image_url, og_image_url: data.og_image_url,
design_width: data.design_width,
design_height: data.design_height,
}; };
try { try {
@ -198,7 +214,7 @@ const EditProjectsPage = () => {
initialValues={initialValues} initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)} onSubmit={(values) => handleSubmit(values)}
> >
{({ values }) => ( {({ values, setFieldValue }) => (
<Form> <Form>
<FormField label='Name'> <FormField label='Name'>
<Field name='name' placeholder='Name' /> <Field name='name' placeholder='Name' />
@ -332,6 +348,70 @@ const EditProjectsPage = () => {
</a> </a>
)} )}
<BaseDivider />
<FormField label='Design Canvas Preset'>
<Field
name='design_preset'
as='select'
value={
isCustomPreset
? 'custom'
: CANVAS_CONFIG.presets.find(
(p) =>
p.width === values.design_width &&
p.height === values.design_height,
)?.name || 'custom'
}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
if (e.target.value === 'custom') {
setIsCustomPreset(true);
} else {
setIsCustomPreset(false);
const preset = CANVAS_CONFIG.presets.find(
(p) => p.name === e.target.value,
);
if (preset) {
setFieldValue('design_width', preset.width);
setFieldValue('design_height', preset.height);
}
}
}}
>
{CANVAS_CONFIG.presets.map((p) => (
<option key={p.name} value={p.name}>
{p.name} ({p.width}×{p.height})
</option>
))}
<option value='custom'>Custom</option>
</Field>
</FormField>
<div className='flex gap-4'>
<FormField label='Design Width (px)'>
<Field
name='design_width'
type='number'
placeholder='1920'
min='320'
max='7680'
/>
</FormField>
<FormField label='Design Height (px)'>
<Field
name='design_height'
type='number'
placeholder='1080'
min='240'
max='4320'
/>
</FormField>
</div>
<p className='text-xs text-gray-500 mt-1'>
Set to match your background image/video resolution for best
quality. UI elements scale proportionally on different screens.
</p>
<BaseDivider /> <BaseDivider />
<BaseButtons> <BaseButtons>
<BaseButton type='submit' color='info' label='Submit' /> <BaseButton type='submit' color='info' label='Submit' />

View File

@ -41,6 +41,8 @@ export interface Project extends BaseEntity {
logo_url?: string; logo_url?: string;
favicon_url?: string; favicon_url?: string;
og_image_url?: string; og_image_url?: string;
design_width?: number;
design_height?: number;
is_deleted?: boolean; is_deleted?: boolean;
deleted_at_time?: string | Date | null; deleted_at_time?: string | Date | null;
} }
@ -108,6 +110,9 @@ export interface TourPage extends BaseEntity {
background_video_muted?: boolean; background_video_muted?: boolean;
background_video_start_time?: number | null; background_video_start_time?: number | null;
background_video_end_time?: number | null; background_video_end_time?: number | null;
// Design canvas dimensions (copied from project on save for presentation isolation)
design_width?: number | null;
design_height?: number | null;
} }
// Page Element entity // Page Element entity

View File

@ -18,6 +18,8 @@ export interface RuntimeProject {
logo_url?: string; logo_url?: string;
favicon_url?: string; favicon_url?: string;
og_image_url?: string; og_image_url?: string;
design_width?: number;
design_height?: number;
} }
/** /**
@ -35,6 +37,9 @@ export interface RuntimePage extends PreloadPage {
background_video_muted?: boolean; background_video_muted?: boolean;
background_video_start_time?: number | null; background_video_start_time?: number | null;
background_video_end_time?: number | null; background_video_end_time?: number | null;
// Design canvas dimensions (copied from project on save for presentation isolation)
design_width?: number | null;
design_height?: number | null;
} }
/** /**