adaptivity improvements
This commit is contained in:
parent
62b9b3ceb9
commit
0a36a87cd4
@ -62,6 +62,8 @@ class ProjectsDBApi extends GenericDBApi {
|
||||
logo_url: data.logo_url || null,
|
||||
favicon_url: data.favicon_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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -38,6 +38,10 @@ class Tour_pagesDBApi extends GenericDBApi {
|
||||
return ['environment', 'background_loop', 'requires_auth'];
|
||||
}
|
||||
|
||||
static get UUID_FIELDS() {
|
||||
return ['projectId'];
|
||||
}
|
||||
|
||||
static get CSV_FIELDS() {
|
||||
return [
|
||||
'id',
|
||||
@ -90,6 +94,10 @@ class Tour_pagesDBApi extends GenericDBApi {
|
||||
data.background_video_end_time !== undefined
|
||||
? data.background_video_end_time
|
||||
: 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,
|
||||
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) {
|
||||
where.active = filter.active === true || filter.active === 'true';
|
||||
}
|
||||
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
@ -53,6 +53,18 @@ module.exports = function (sequelize, DataTypes) {
|
||||
type: DataTypes.TEXT,
|
||||
},
|
||||
|
||||
design_width: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 1920,
|
||||
},
|
||||
|
||||
design_height: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 1080,
|
||||
},
|
||||
|
||||
importHash: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
|
||||
@ -103,6 +103,18 @@ module.exports = function (sequelize, DataTypes) {
|
||||
defaultValue: null,
|
||||
},
|
||||
|
||||
design_width: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
},
|
||||
|
||||
design_height: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
},
|
||||
|
||||
requires_auth: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
|
||||
|
||||
@ -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();
|
||||
return clonedProject;
|
||||
} catch (error) {
|
||||
|
||||
@ -72,7 +72,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
key={`bg_image_${backgroundImageUrl}`}
|
||||
src={backgroundImageUrl}
|
||||
alt='Background'
|
||||
className='absolute inset-0 h-full w-full object-cover'
|
||||
className='absolute inset-0 h-full w-full object-contain'
|
||||
draggable={false}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
@ -84,7 +84,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
alt='Background'
|
||||
fill
|
||||
sizes='100vw'
|
||||
className='object-cover'
|
||||
className='object-contain'
|
||||
draggable={false}
|
||||
unoptimized
|
||||
onLoad={handleLoad}
|
||||
@ -100,8 +100,9 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
className='pointer-events-none absolute inset-0 z-10'
|
||||
style={{
|
||||
backgroundImage: `url("${previousBgImageUrl}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@ -111,7 +112,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
<video
|
||||
ref={videoRef}
|
||||
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}
|
||||
autoPlay={videoAutoplay}
|
||||
loop={useNativeLoop}
|
||||
|
||||
@ -2,9 +2,10 @@
|
||||
* PageSelector Component
|
||||
*
|
||||
* 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';
|
||||
|
||||
interface PageSelectorProps {
|
||||
@ -20,6 +21,22 @@ const PageSelector: React.FC<PageSelectorProps> = ({
|
||||
onPageChange,
|
||||
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 (
|
||||
<select
|
||||
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)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{pages.map((page, index) => (
|
||||
{sortedPages.map((page, index) => (
|
||||
<option key={page.id} value={page.id}>
|
||||
{page.name || `Page ${index + 1}`}
|
||||
</option>
|
||||
|
||||
46
frontend/src/components/RotatePrompt.tsx
Normal file
46
frontend/src/components/RotatePrompt.tsx
Normal 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;
|
||||
@ -24,6 +24,9 @@ import { OfflineToggle } from './Offline/OfflineToggle';
|
||||
import RuntimeElement from './RuntimeElement';
|
||||
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
||||
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 { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||
import { usePageDataLoader } from '../hooks/usePageDataLoader';
|
||||
@ -82,6 +85,17 @@ export default function RuntimePresentation({
|
||||
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<{
|
||||
targetPageId: string;
|
||||
videoUrl: string;
|
||||
@ -212,8 +226,14 @@ export default function RuntimePresentation({
|
||||
},
|
||||
});
|
||||
|
||||
// Use shared background transition hook for fade-out effects
|
||||
const { isOverlayFadingOut, resetFadeOut } = useBackgroundTransition({
|
||||
// Use shared background transition hook for fade-out and fade-in effects
|
||||
const {
|
||||
isOverlayFadingOut,
|
||||
resetFadeOut,
|
||||
isFadingIn,
|
||||
elementsOpacity,
|
||||
resetFadeIn,
|
||||
} = useBackgroundTransition({
|
||||
pageSwitch,
|
||||
fadeOut: {
|
||||
pendingTransitionComplete,
|
||||
@ -224,6 +244,9 @@ export default function RuntimePresentation({
|
||||
setPendingTransitionComplete(false);
|
||||
}, []),
|
||||
},
|
||||
fadeIn: {
|
||||
hasActiveTransition: Boolean(transitionPreview),
|
||||
},
|
||||
});
|
||||
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
@ -314,7 +337,8 @@ export default function RuntimePresentation({
|
||||
|
||||
if (transitionVideoUrl) {
|
||||
// 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();
|
||||
setPendingTransitionComplete(false);
|
||||
// Play transition using useTransitionPlayback hook
|
||||
@ -326,6 +350,8 @@ export default function RuntimePresentation({
|
||||
});
|
||||
} else {
|
||||
// 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
|
||||
setIsBackgroundReady(false);
|
||||
// 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(
|
||||
@ -458,6 +484,9 @@ export default function RuntimePresentation({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Rotate prompt for portrait orientation */}
|
||||
<RotatePrompt show={showRotatePrompt && isPortrait} />
|
||||
|
||||
<Head>
|
||||
<title>{project?.name || 'Presentation'}</title>
|
||||
{faviconUrl && <link key='favicon' rel='icon' href={faviconUrl} />}
|
||||
@ -497,16 +526,22 @@ export default function RuntimePresentation({
|
||||
)}
|
||||
</Head>
|
||||
|
||||
<div
|
||||
className='relative w-screen h-screen overflow-clip bg-black'
|
||||
style={{
|
||||
backgroundImage: backgroundImageUrl
|
||||
? `url("${backgroundImageUrl}")`
|
||||
: undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Outer container: full viewport with black background for letterbox bars */}
|
||||
<div className='relative w-screen h-screen overflow-hidden bg-black'>
|
||||
{/* Inner canvas: maintains aspect ratio centered in viewport */}
|
||||
<div
|
||||
className='overflow-hidden'
|
||||
style={{
|
||||
...cssVars,
|
||||
...letterboxStyles,
|
||||
backgroundImage: backgroundImageUrl
|
||||
? `url("${backgroundImageUrl}")`
|
||||
: undefined,
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<BackdropPortalProvider>
|
||||
{/* Background image element - z-1 keeps it below backdrop blur (z-5).
|
||||
CSS backgroundImage provides instant display.
|
||||
@ -519,7 +554,7 @@ export default function RuntimePresentation({
|
||||
key={backgroundImageUrl}
|
||||
src={backgroundImageUrl}
|
||||
alt=''
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
className='absolute inset-0 w-full h-full object-contain'
|
||||
onLoad={() => {
|
||||
setIsBackgroundReady(true);
|
||||
pageSwitch.markBackgroundReady();
|
||||
@ -536,7 +571,7 @@ export default function RuntimePresentation({
|
||||
alt=''
|
||||
fill
|
||||
sizes='100vw'
|
||||
className='object-cover'
|
||||
className='object-contain'
|
||||
priority
|
||||
unoptimized
|
||||
onLoad={() => {
|
||||
@ -560,8 +595,9 @@ export default function RuntimePresentation({
|
||||
className='absolute inset-0 pointer-events-none z-10'
|
||||
style={{
|
||||
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@ -571,7 +607,7 @@ export default function RuntimePresentation({
|
||||
<video
|
||||
ref={bgVideoRef}
|
||||
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}
|
||||
autoPlay={videoAutoplay}
|
||||
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) */}
|
||||
<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) => (
|
||||
<RuntimeElement
|
||||
key={element.id}
|
||||
@ -678,6 +722,8 @@ export default function RuntimePresentation({
|
||||
/>
|
||||
)}
|
||||
</BackdropPortalProvider>
|
||||
</div>
|
||||
{/* End inner canvas container */}
|
||||
|
||||
{/* Toast notifications for offline download status */}
|
||||
<ToastContainer
|
||||
|
||||
@ -28,6 +28,7 @@ import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||
import type { CanvasElement, CarouselSlide } from '../../../types/constructor';
|
||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||
import { getFontByKey, getFontStyle } from '../../../lib/fonts';
|
||||
import { toCU } from '../../../lib/canvasScale';
|
||||
|
||||
interface CarouselElementProps {
|
||||
element: CanvasElement;
|
||||
@ -238,20 +239,41 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
|
||||
};
|
||||
}, [isEditMode, draggingButton, onButtonPositionChange]);
|
||||
|
||||
// Convert numeric value to viewport units (vw for width, vh for height)
|
||||
const toViewportUnit = (
|
||||
// Convert numeric value to canvas units for responsive scaling
|
||||
// Previously used vw/vh but now uses canvas units (--cu) for uniform scaling
|
||||
const toCanvasUnit = (
|
||||
value?: string,
|
||||
unit: 'vw' | 'vh' = 'vw',
|
||||
dimension: 'width' | 'height' = 'width',
|
||||
): string | undefined => {
|
||||
if (!value || value.trim() === '') return undefined;
|
||||
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;
|
||||
// Plain number - treat as design pixels
|
||||
const num = parseFloat(trimmed);
|
||||
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
|
||||
const renderNavButton = (
|
||||
type: 'prev' | 'next',
|
||||
@ -264,8 +286,8 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
|
||||
) => {
|
||||
const isDragging = draggingButton === type;
|
||||
const hasCustomIcon = iconUrl && iconUrl.trim() !== '';
|
||||
const widthValue = toViewportUnit(buttonWidth, 'vw');
|
||||
const heightValue = toViewportUnit(buttonHeight, 'vh');
|
||||
const widthValue = toCanvasUnit(buttonWidth, 'width');
|
||||
const heightValue = toCanvasUnit(buttonHeight, 'height');
|
||||
|
||||
// Navigation-style: custom icon fills button (no backdrop)
|
||||
const useNavigationStyle = hasCustomIcon && (widthValue || heightValue);
|
||||
|
||||
@ -10,6 +10,7 @@ import { useMemo } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { CanvasElement } from '../../../types/constructor';
|
||||
import { buildElementStyle } from '../../../lib/elementStyles';
|
||||
import { toCU } from '../../../lib/canvasScale';
|
||||
import {
|
||||
isTooltipElementType,
|
||||
isDescriptionElementType,
|
||||
@ -71,6 +72,18 @@ export function useElementWrapperStyle({
|
||||
// Navigation elements (with or without icon) should be centered
|
||||
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
|
||||
const classNames = [
|
||||
'rounded text-xs font-semibold',
|
||||
@ -82,8 +95,8 @@ export function useElementWrapperStyle({
|
||||
: hasTransparentBackground
|
||||
? 'bg-transparent'
|
||||
: 'border shadow border-blue-200 bg-white/95',
|
||||
// Padding
|
||||
hasIconDrivenSize ? 'overflow-hidden p-0 leading-none' : 'px-3 py-2',
|
||||
// Overflow for icon-driven elements
|
||||
hasIconDrivenSize ? 'overflow-hidden leading-none' : '',
|
||||
// Flex centering for navigation elements (both icons and text)
|
||||
isNavigationElement ? 'flex items-center justify-center' : '',
|
||||
// Constructor-specific states (only applied when in constructor)
|
||||
@ -101,7 +114,7 @@ export function useElementWrapperStyle({
|
||||
|
||||
return {
|
||||
className: classNames,
|
||||
style: inlineStyle,
|
||||
style: { ...paddingStyle, ...inlineStyle },
|
||||
};
|
||||
}, [element, isSelected, isEditMode, isDisabled]);
|
||||
}
|
||||
|
||||
66
frontend/src/config/canvas.config.ts
Normal file
66
frontend/src/config/canvas.config.ts
Normal 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];
|
||||
124
frontend/src/context/CanvasScaleContext.tsx
Normal file
124
frontend/src/context/CanvasScaleContext.tsx
Normal 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);
|
||||
}
|
||||
@ -25,7 +25,7 @@ export function usePagesQuery(
|
||||
queryKey: queryKeys.tourPages.list(projectId || '', environment),
|
||||
queryFn: async (): Promise<TourPage[]> => {
|
||||
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;
|
||||
},
|
||||
|
||||
217
frontend/src/hooks/useBackgroundDimensionSuggestion.ts
Normal file
217
frontend/src/hooks/useBackgroundDimensionSuggestion.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -2,18 +2,30 @@
|
||||
* useBackgroundTransition Hook
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* This hook consolidates the background transition logic used by both
|
||||
* RuntimePresentation and constructor.tsx.
|
||||
*
|
||||
* Two modes:
|
||||
* 1. Full mode (RuntimePresentation): Fade-out animation + direct navigation clearing
|
||||
* 2. Simple mode (constructor): Direct navigation clearing only
|
||||
* 1. Full mode (RuntimePresentation): Fade-out animation + fade-in + direct navigation clearing
|
||||
* 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)
|
||||
@ -29,6 +41,16 @@ export interface FadeOutConfig {
|
||||
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 {
|
||||
/** Page switch hook instance for clearing previous background */
|
||||
pageSwitch: {
|
||||
@ -39,6 +61,8 @@ export interface UseBackgroundTransitionOptions {
|
||||
};
|
||||
/** Optional fade-out configuration (for RuntimePresentation) */
|
||||
fadeOut?: FadeOutConfig;
|
||||
/** Optional fade-in configuration for page content */
|
||||
fadeIn?: FadeInConfig;
|
||||
}
|
||||
|
||||
export interface UseBackgroundTransitionResult {
|
||||
@ -46,19 +70,20 @@ export interface UseBackgroundTransitionResult {
|
||||
isOverlayFadingOut: boolean;
|
||||
/** Reset the fade-out state (call before starting a new transition) */
|
||||
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.
|
||||
*
|
||||
* @example
|
||||
* // Full mode with fade-out (RuntimePresentation)
|
||||
* const { isOverlayFadingOut, resetFadeOut } = useBackgroundTransition({
|
||||
* // Full mode with fade-out and fade-in (RuntimePresentation)
|
||||
* const { isOverlayFadingOut, resetFadeOut, isFadingIn, elementsOpacity, resetFadeIn } = useBackgroundTransition({
|
||||
* pageSwitch,
|
||||
* fadeOut: {
|
||||
* pendingTransitionComplete,
|
||||
@ -69,6 +94,9 @@ const FADE_DURATION_MS = 300;
|
||||
* setPendingTransitionComplete(false);
|
||||
* },
|
||||
* },
|
||||
* fadeIn: {
|
||||
* hasActiveTransition: Boolean(transitionPreview),
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
@ -78,9 +106,19 @@ const FADE_DURATION_MS = 300;
|
||||
export function useBackgroundTransition({
|
||||
pageSwitch,
|
||||
fadeOut,
|
||||
fadeIn,
|
||||
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
|
||||
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.
|
||||
* This prevents the fade-out effect from re-triggering when state resets.
|
||||
@ -89,6 +127,19 @@ export function useBackgroundTransition({
|
||||
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.
|
||||
* Only runs when fadeOut config is provided.
|
||||
@ -129,7 +180,7 @@ export function useBackgroundTransition({
|
||||
|
||||
// Reset fade-out state
|
||||
setIsOverlayFadingOut(false);
|
||||
}, FADE_DURATION_MS);
|
||||
}, FADE_OUT_DURATION_MS);
|
||||
|
||||
return () => clearTimeout(fadeTimer);
|
||||
}
|
||||
@ -157,8 +208,71 @@ export function useBackgroundTransition({
|
||||
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 {
|
||||
isOverlayFadingOut,
|
||||
resetFadeOut,
|
||||
isFadingIn,
|
||||
elementsOpacity,
|
||||
resetFadeIn,
|
||||
};
|
||||
}
|
||||
|
||||
120
frontend/src/hooks/useCanvasScale.ts
Normal file
120
frontend/src/hooks/useCanvasScale.ts
Normal 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]);
|
||||
}
|
||||
@ -29,7 +29,7 @@ const EMPTY_ASSETS: Asset[] = [];
|
||||
|
||||
interface UseConstructorDataResult {
|
||||
// Project
|
||||
project: { name: string } | null;
|
||||
project: { name: string; design_width?: number; design_height?: number } | null;
|
||||
projectName: string;
|
||||
|
||||
// Pages
|
||||
|
||||
@ -33,9 +33,18 @@ interface TourPage {
|
||||
background_video_end_time?: number | null;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id?: string;
|
||||
name?: string;
|
||||
design_width?: number;
|
||||
design_height?: number;
|
||||
}
|
||||
|
||||
interface UseConstructorPageActionsOptions {
|
||||
/** Current project ID */
|
||||
projectId: string;
|
||||
/** Current project (for design dimensions) */
|
||||
project?: Project | null;
|
||||
/** Array of all pages */
|
||||
pages: TourPage[];
|
||||
/** Currently active page */
|
||||
@ -106,6 +115,7 @@ interface UseConstructorPageActionsResult {
|
||||
*/
|
||||
export function useConstructorPageActions({
|
||||
projectId,
|
||||
project,
|
||||
pages,
|
||||
activePage,
|
||||
activePageId,
|
||||
@ -172,6 +182,9 @@ export function useConstructorPageActions({
|
||||
background_video_muted: backgroundVideoMuted,
|
||||
background_video_start_time: backgroundVideoStartTime,
|
||||
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,
|
||||
pageBackground,
|
||||
elements,
|
||||
project?.design_width,
|
||||
project?.design_height,
|
||||
onError,
|
||||
onReload,
|
||||
onSuccess,
|
||||
@ -223,11 +238,23 @@ export function useConstructorPageActions({
|
||||
try {
|
||||
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?.(
|
||||
'Successfully saved dev content to stage environment. All pages, elements, and transitions have been copied.',
|
||||
);
|
||||
const pagesCopied = response.data?.summary?.pages_copied ?? 0;
|
||||
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) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
@ -271,6 +298,9 @@ export function useConstructorPageActions({
|
||||
background_loop: false,
|
||||
requires_auth: false,
|
||||
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 {
|
||||
@ -310,6 +340,8 @@ export function useConstructorPageActions({
|
||||
onSetMenuOpen,
|
||||
onSuccess,
|
||||
pages,
|
||||
project?.design_width,
|
||||
project?.design_height,
|
||||
projectId,
|
||||
]);
|
||||
|
||||
|
||||
212
frontend/src/lib/canvasScale.ts
Normal file
212
frontend/src/lib/canvasScale.ts
Normal 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})`,
|
||||
};
|
||||
}
|
||||
@ -3,9 +3,15 @@
|
||||
*
|
||||
* Unified types and utilities for UI element CSS styling.
|
||||
* 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 { toCU } from './canvasScale';
|
||||
|
||||
/**
|
||||
* Normalize a numeric value to include a CSS unit suffix.
|
||||
@ -55,17 +61,132 @@ function normalizeWithUnit(
|
||||
return str;
|
||||
}
|
||||
|
||||
/** Normalize pixel values (border, borderRadius, fontSize for description) */
|
||||
export const normalizePixelValue = (value: string | number | undefined) =>
|
||||
normalizeWithUnit(value, 'px');
|
||||
/** Normalize pixel values (border, borderRadius, fontSize) - now uses canvas units */
|
||||
export const normalizePixelValue = (value: string | number | undefined) => {
|
||||
if (value === null || value === undefined || value === '') return '';
|
||||
|
||||
/** Normalize viewport width values (width, minWidth, maxWidth) */
|
||||
export const normalizeViewportWidth = (value: string | number | undefined) =>
|
||||
normalizeWithUnit(value, 'vw');
|
||||
const str = String(value).trim();
|
||||
if (!str) return '';
|
||||
|
||||
/** Normalize viewport height values (height, minHeight, maxHeight) */
|
||||
export const normalizeViewportHeight = (value: string | number | undefined) =>
|
||||
normalizeWithUnit(value, 'vh');
|
||||
// Zero doesn't need a unit
|
||||
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) && 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.
|
||||
|
||||
@ -3,11 +3,16 @@
|
||||
*
|
||||
* Unified types and utilities for gallery element section styling.
|
||||
* 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 { CanvasElement } from '../types/constructor';
|
||||
import { getFontByKey, getFontStyle } from './fonts';
|
||||
import { toCU } from './canvasScale';
|
||||
|
||||
/**
|
||||
* Gallery section names for styling
|
||||
@ -20,44 +25,46 @@ export type GallerySectionName =
|
||||
| '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<
|
||||
GallerySectionName,
|
||||
CSSProperties
|
||||
> = {
|
||||
header: {
|
||||
fontSize: '1.5rem', // text-2xl
|
||||
fontSize: 'calc(24 * var(--cu, 1px))', // text-2xl = 1.5rem = 24px
|
||||
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',
|
||||
},
|
||||
title: {
|
||||
backgroundColor: '#fefce8', // bg-amber-50
|
||||
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
|
||||
padding: '0.5rem 0.75rem', // py-2 px-3
|
||||
borderRadius: '0.5rem', // rounded-lg
|
||||
padding: 'calc(8 * var(--cu, 1px)) calc(12 * var(--cu, 1px))', // py-2 px-3 = 0.5rem 0.75rem
|
||||
borderRadius: 'calc(8 * var(--cu, 1px))', // rounded-lg = 0.5rem = 8px
|
||||
textAlign: 'center',
|
||||
},
|
||||
span: {
|
||||
backgroundColor: '#334155', // bg-slate-700
|
||||
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
|
||||
padding: '0.5rem', // py-2 px-2
|
||||
borderRadius: '0.5rem', // rounded-lg
|
||||
padding: 'calc(8 * var(--cu, 1px))', // py-2 px-2 = 0.5rem
|
||||
borderRadius: 'calc(8 * var(--cu, 1px))', // rounded-lg = 0.5rem = 8px
|
||||
textAlign: 'center',
|
||||
},
|
||||
card: {
|
||||
borderRadius: '0.5rem', // rounded-lg
|
||||
borderRadius: 'calc(8 * var(--cu, 1px))', // rounded-lg = 0.5rem = 8px
|
||||
},
|
||||
wrapper: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)', // bg-black/60
|
||||
padding: '0.75rem', // p-3
|
||||
borderRadius: '0.75rem', // rounded-xl
|
||||
gap: '0.5rem', // gap-2
|
||||
padding: 'calc(12 * var(--cu, 1px))', // p-3 = 0.75rem = 12px
|
||||
borderRadius: 'calc(12 * var(--cu, 1px))', // rounded-xl = 0.75rem = 12px
|
||||
gap: 'calc(8 * var(--cu, 1px))', // gap-2 = 0.5rem = 8px
|
||||
backdropFilter: 'blur(4px)', // backdrop-blur-sm
|
||||
},
|
||||
};
|
||||
@ -114,13 +121,65 @@ const normalizeWithUnit = (value: unknown, unit: string): string => {
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
/** Normalize rem values (fontSize, padding, borderRadius, gap) */
|
||||
const normalizeRemValue = (value: unknown): string =>
|
||||
normalizeWithUnit(value, 'rem');
|
||||
/**
|
||||
* Normalize values to canvas units.
|
||||
* 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) */
|
||||
const normalizePxValue = (value: unknown): string =>
|
||||
normalizeWithUnit(value, 'px');
|
||||
// Zero doesn't need a unit
|
||||
if (trimmed === '0') return '0';
|
||||
|
||||
// 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
|
||||
@ -358,7 +417,7 @@ export function buildGallerySpanGridStyle(
|
||||
element: Partial<CanvasElement>,
|
||||
): CSSProperties {
|
||||
const columns = getGalleryGridColumns(element, 'span');
|
||||
const gap = normalizeRemValue(element.gallerySpanGap) || '0.5rem';
|
||||
const gap = normalizeCanvasUnit(element.gallerySpanGap) || 'calc(8 * var(--cu, 1px))';
|
||||
|
||||
return {
|
||||
display: 'grid',
|
||||
@ -459,7 +518,7 @@ export function buildGalleryCardGridStyle(
|
||||
element: Partial<CanvasElement>,
|
||||
): CSSProperties {
|
||||
const columns = getGalleryGridColumns(element, 'card');
|
||||
const gap = normalizeRemValue(element.galleryCardGap) || '0.5rem';
|
||||
const gap = normalizeCanvasUnit(element.galleryCardGap) || 'calc(8 * var(--cu, 1px))';
|
||||
|
||||
return {
|
||||
display: 'grid',
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { mdiContentSave, mdiExitToApp, mdiPlus } from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, {
|
||||
@ -84,6 +85,10 @@ import {
|
||||
type ConstructorContextValue,
|
||||
type NavigationElementType,
|
||||
} 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)
|
||||
import {
|
||||
@ -136,6 +141,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
|
||||
// Use React Query for data fetching (replaces manual loadData)
|
||||
const {
|
||||
project,
|
||||
pages,
|
||||
pageLinks,
|
||||
allPagesPreloadElements,
|
||||
@ -151,6 +157,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
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
|
||||
const {
|
||||
currentPageId: activePageId,
|
||||
@ -183,6 +195,35 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
backgroundVideoEndTime,
|
||||
} = 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] =
|
||||
useState<EditorMenuItem>('none');
|
||||
// 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)
|
||||
const switchToPage = useCallback(
|
||||
async (page: TourPage | null, isBack = false) => {
|
||||
// Reset fade-in state to start fresh
|
||||
resetFadeIn();
|
||||
|
||||
// Mark this page as initialized to prevent redundant effect calls
|
||||
if (page) {
|
||||
lastInitializedPageIdRef.current = page.id;
|
||||
@ -380,7 +424,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
},
|
||||
);
|
||||
},
|
||||
[pageSwitchToPage, updateBackgroundFromPage, applyPageSelection],
|
||||
[pageSwitchToPage, updateBackgroundFromPage, applyPageSelection, resetFadeIn],
|
||||
);
|
||||
|
||||
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)
|
||||
useBackgroundTransition({ pageSwitch });
|
||||
const { isFadingIn, elementsOpacity, resetFadeIn } = useBackgroundTransition({
|
||||
pageSwitch,
|
||||
fadeIn: {
|
||||
hasActiveTransition: Boolean(transitionPreview),
|
||||
},
|
||||
});
|
||||
|
||||
const iconPreloadTargets = useMemo(() => {
|
||||
const preloadableTypes: CanvasElementType[] = [
|
||||
@ -588,6 +637,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
createTransition,
|
||||
} = useConstructorPageActions({
|
||||
projectId,
|
||||
project,
|
||||
pages,
|
||||
activePage,
|
||||
activePageId,
|
||||
@ -1428,8 +1478,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
<div
|
||||
ref={canvasRef}
|
||||
tabIndex={-1}
|
||||
className={`absolute inset-0 z-20 overflow-clip ${hasFullWidthCarousel ? 'bg-transparent' : 'bg-black'}`}
|
||||
style={canvasBackgroundStyle}
|
||||
className={`z-20 overflow-clip ${hasFullWidthCarousel ? 'bg-transparent' : 'bg-black'}`}
|
||||
style={{ ...canvasCssVars, ...letterboxStyles, ...canvasBackgroundStyle }}
|
||||
>
|
||||
<BackdropPortalProvider>
|
||||
<CanvasBackground
|
||||
@ -1448,7 +1498,15 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
/>
|
||||
|
||||
{/* 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 ? (
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<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>{`
|
||||
.menu-action-btn {
|
||||
width: 100%;
|
||||
|
||||
@ -27,6 +27,7 @@ import { useRouter } from 'next/router';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import type { Project } from '../../types/entities';
|
||||
import { logger } from '../../lib/logger';
|
||||
import { CANVAS_CONFIG } from '../../config/canvas.config';
|
||||
|
||||
const initVals = {
|
||||
name: '',
|
||||
@ -35,6 +36,8 @@ const initVals = {
|
||||
logo_url: '',
|
||||
favicon_url: '',
|
||||
og_image_url: '',
|
||||
design_width: CANVAS_CONFIG.defaults.width as number,
|
||||
design_height: CANVAS_CONFIG.defaults.height as number,
|
||||
is_deleted: false,
|
||||
deleted_at_time: new Date(),
|
||||
};
|
||||
@ -47,6 +50,7 @@ const EditProjectsPage = () => {
|
||||
{ id: string; cdn_url: string; storage_key?: string; name: string }[]
|
||||
>([]);
|
||||
const [isLoadingLogoAssets, setIsLoadingLogoAssets] = useState(false);
|
||||
const [isCustomPreset, setIsCustomPreset] = useState(false);
|
||||
|
||||
const projectsState = useAppSelector((state) => state.projects);
|
||||
const projects = projectsState.data;
|
||||
@ -118,6 +122,14 @@ const EditProjectsPage = () => {
|
||||
useEffect(() => {
|
||||
if (typeof project === 'object' && project !== null) {
|
||||
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({
|
||||
name: String(projectData.name || ''),
|
||||
@ -126,6 +138,8 @@ const EditProjectsPage = () => {
|
||||
logo_url: String(projectData.logo_url || ''),
|
||||
favicon_url: String(projectData.favicon_url || ''),
|
||||
og_image_url: String(projectData.og_image_url || ''),
|
||||
design_width: width,
|
||||
design_height: height,
|
||||
is_deleted: Boolean(projectData.is_deleted),
|
||||
deleted_at_time: projectData.deleted_at_time
|
||||
? new Date(projectData.deleted_at_time as string)
|
||||
@ -142,6 +156,8 @@ const EditProjectsPage = () => {
|
||||
logo_url: data.logo_url,
|
||||
favicon_url: data.favicon_url,
|
||||
og_image_url: data.og_image_url,
|
||||
design_width: data.design_width,
|
||||
design_height: data.design_height,
|
||||
};
|
||||
|
||||
try {
|
||||
@ -198,7 +214,7 @@ const EditProjectsPage = () => {
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
{({ values }) => (
|
||||
{({ values, setFieldValue }) => (
|
||||
<Form>
|
||||
<FormField label='Name'>
|
||||
<Field name='name' placeholder='Name' />
|
||||
@ -332,6 +348,70 @@ const EditProjectsPage = () => {
|
||||
</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 />
|
||||
<BaseButtons>
|
||||
<BaseButton type='submit' color='info' label='Submit' />
|
||||
|
||||
@ -41,6 +41,8 @@ export interface Project extends BaseEntity {
|
||||
logo_url?: string;
|
||||
favicon_url?: string;
|
||||
og_image_url?: string;
|
||||
design_width?: number;
|
||||
design_height?: number;
|
||||
is_deleted?: boolean;
|
||||
deleted_at_time?: string | Date | null;
|
||||
}
|
||||
@ -108,6 +110,9 @@ export interface TourPage extends BaseEntity {
|
||||
background_video_muted?: boolean;
|
||||
background_video_start_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
|
||||
|
||||
@ -18,6 +18,8 @@ export interface RuntimeProject {
|
||||
logo_url?: string;
|
||||
favicon_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_start_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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user