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,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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 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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
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),
|
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;
|
||||||
},
|
},
|
||||||
|
|||||||
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
|
* 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
interface UseConstructorDataResult {
|
||||||
// Project
|
// Project
|
||||||
project: { name: string } | null;
|
project: { name: string; design_width?: number; design_height?: number } | null;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
|
||||||
// Pages
|
// Pages
|
||||||
|
|||||||
@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
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.
|
* 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.
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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%;
|
||||||
|
|||||||
@ -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' />
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user