1991 lines
71 KiB
TypeScript
1991 lines
71 KiB
TypeScript
import { mdiContentSave, mdiExitToApp, mdiPlus } from '@mdi/js';
|
|
import Head from 'next/head';
|
|
import { useRouter } from 'next/router';
|
|
import React, {
|
|
ReactElement,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { flushSync } from 'react-dom';
|
|
import BaseButton from '../components/BaseButton';
|
|
import CanvasBackground from '../components/Constructor/CanvasBackground';
|
|
import CanvasLoadingSpinner from '../components/CanvasLoadingSpinner';
|
|
import TransitionBlackOverlay from '../components/TransitionBlackOverlay';
|
|
import ConstructorToolbar from '../components/Constructor/ConstructorToolbar';
|
|
import TransitionPreviewOverlay from '../components/Constructor/TransitionPreviewOverlay';
|
|
import CanvasElementComponent from '../components/Constructor/CanvasElement';
|
|
import GalleryCarouselOverlay from '../components/UiElements/GalleryCarouselOverlay';
|
|
import ElementEditorPanel from '../components/Constructor/ElementEditorPanel';
|
|
import { BackdropPortalProvider } from '../components/BackdropPortal';
|
|
import { getPageTitle } from '../config';
|
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
|
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
|
import { usePageNavigationState } from '../hooks/usePageNavigationState';
|
|
import { usePageNavigation } from '../hooks/usePageNavigation';
|
|
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
|
import { useTransitionSettings } from '../hooks/useTransitionSettings';
|
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
|
import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
|
|
import {
|
|
fetchByProjectAndEnv as fetchProjectTransitionSettings,
|
|
selectByProjectAndEnv as selectProjectTransitionSettings,
|
|
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
|
|
import type { ElementTransitionSettings } from '../types/transition';
|
|
import {
|
|
entityToProjectSettings,
|
|
extractElementTransitionSettings,
|
|
} from '../types/transition';
|
|
import { logger } from '../lib/logger';
|
|
import { isSafari } from '../lib/browserUtils';
|
|
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
|
import { downloadManager } from '../lib/offline/DownloadManager';
|
|
import { parseJsonObject } from '../lib/parseJson';
|
|
import {
|
|
resolveNavigationTarget,
|
|
hasPlayableTransition,
|
|
getNavigationDirection,
|
|
isBackNavigation,
|
|
} from '../lib/navigationHelpers';
|
|
import {
|
|
mergeElementWithDefaults,
|
|
createLocalId,
|
|
normalizeAppearDelaySec,
|
|
normalizeAppearDurationSec,
|
|
ELEMENT_TYPE_LABELS,
|
|
getNavigationButtonKind,
|
|
isNavigationElementType,
|
|
isTooltipElementType,
|
|
isDescriptionElementType,
|
|
isMediaElementType,
|
|
isVideoPlayerElementType,
|
|
clamp,
|
|
} from '../lib/elementDefaults';
|
|
import type {
|
|
CanvasElementType,
|
|
CanvasElement,
|
|
ConstructorSchema,
|
|
EditorMenuItem,
|
|
GalleryCard,
|
|
GalleryInfoSpan,
|
|
CarouselSlide,
|
|
} from '../types/constructor';
|
|
import type { TourPage } from '../types/entities';
|
|
|
|
// Constructor-specific hooks
|
|
import {
|
|
useCanvasElapsedTime,
|
|
isElementVisibleAtTime,
|
|
} from '../hooks/useCanvasElapsedTime';
|
|
import {
|
|
useMediaDurationProbe,
|
|
buildDurationProbeTargets,
|
|
} from '../hooks/useMediaDurationProbe';
|
|
import { useIconPreload } from '../hooks/useIconPreload';
|
|
import { useOutsideClick } from '../hooks/useOutsideClick';
|
|
import { useDraggable } from '../hooks/useDraggable';
|
|
import { useCanvasElementDrag } from '../hooks/useCanvasElementDrag';
|
|
import { useTransitionPreview } from '../hooks/useTransitionPreview';
|
|
import { useConstructorPageActions } from '../hooks/useConstructorPageActions';
|
|
import { useConstructorElements } from '../hooks/useConstructorElements';
|
|
import { usePageBackground } from '../hooks/usePageBackground';
|
|
import { useConstructorData } from '../hooks/useConstructorData';
|
|
import { useAssetOptions } from '../hooks/useAssetOptions';
|
|
import { usePublishStatus } from '../hooks/usePublishStatus';
|
|
import {
|
|
ConstructorProvider,
|
|
type ConstructorContextValue,
|
|
type NavigationElementType,
|
|
} from '../context/ConstructorContext';
|
|
import { useCanvasScale } from '../hooks/useCanvasScale';
|
|
import { useVideoSoundControl } from '../hooks/useVideoSoundControl';
|
|
import { useNetworkAware } from '../hooks/useNetworkAware';
|
|
|
|
// TourPage type is imported from '../types/entities'
|
|
// NavigationElementType is imported from '../context/ConstructorContext'
|
|
|
|
type ConstructorPageProps = {
|
|
mode?: 'constructor' | 'element_edit';
|
|
};
|
|
|
|
type ConstructorInteractionMode = 'edit' | 'interact';
|
|
|
|
// Use ELEMENT_TYPE_LABELS from elementDefaults for label lookup
|
|
const labelByType = ELEMENT_TYPE_LABELS;
|
|
|
|
const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|
const router = useRouter();
|
|
const dispatch = useAppDispatch();
|
|
const globalTransitionDefaults = useAppSelector(
|
|
(state) => state.global_transition_defaults.data,
|
|
);
|
|
const canvasRef = useRef<HTMLDivElement>(null);
|
|
const elementEditorRef = useRef<HTMLDivElement>(null);
|
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
|
const [isAuthReady, setIsAuthReady] = useState(false);
|
|
const isElementEditMode = mode === 'element_edit';
|
|
|
|
const projectId = useMemo(() => {
|
|
const value = router.query.projectId;
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
}, [router.query.projectId]);
|
|
const pageElementsListHref = useMemo(() => {
|
|
if (!projectId)
|
|
return '/project-element-defaults/project-element-defaults-list';
|
|
return `/project-element-defaults/project-element-defaults-list?projectId=${encodeURIComponent(projectId)}`;
|
|
}, [projectId]);
|
|
|
|
// Project transition settings from environment-aware store (constructor always uses 'dev')
|
|
const projectTransitionSettingsEntity = useAppSelector((state) =>
|
|
projectId
|
|
? selectProjectTransitionSettings(state, projectId, 'dev')
|
|
: undefined,
|
|
);
|
|
const projectTransitionSettings = useMemo(
|
|
() => entityToProjectSettings(projectTransitionSettingsEntity),
|
|
[projectTransitionSettingsEntity],
|
|
);
|
|
|
|
const pageIdFromRoute = useMemo(() => {
|
|
const value = router.query.pageId;
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
}, [router.query.pageId]);
|
|
const elementIdFromRoute = useMemo(() => {
|
|
const value = router.query.elementId;
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
}, [router.query.elementId]);
|
|
|
|
// Use React Query for data fetching (replaces manual loadData)
|
|
const {
|
|
project,
|
|
pages,
|
|
pageLinks,
|
|
allPagesPreloadElements,
|
|
assets,
|
|
uiElementDefaultsByType,
|
|
projectName,
|
|
isLoading: isDataLoading,
|
|
isError: isDataError,
|
|
error: dataError,
|
|
refetch: refetchData,
|
|
} = useConstructorData({
|
|
projectId,
|
|
isAuthReady,
|
|
});
|
|
|
|
// Canvas scale for responsive UI elements and letterbox mode
|
|
const { cssVars: canvasCssVars, letterboxStyles } = useCanvasScale({
|
|
designWidth: project?.design_width,
|
|
designHeight: project?.design_height,
|
|
});
|
|
|
|
// Page navigation with history tracking via shared hook
|
|
const {
|
|
currentPageId: activePageId,
|
|
pageHistory,
|
|
applyPageSelection,
|
|
getNavigationContext,
|
|
setCurrentPageId: setActivePageId,
|
|
} = usePageNavigation({
|
|
pages,
|
|
trackHistory: true,
|
|
});
|
|
|
|
// Consolidated page background state (replaces 8 separate useState hooks)
|
|
const {
|
|
background: pageBackground,
|
|
setBackground: setPageBackground,
|
|
updateFromPage: updateBackgroundFromPage,
|
|
setImageUrl: setBackgroundImageUrl,
|
|
setVideoUrl: setBackgroundVideoUrl,
|
|
setAudioUrl: setBackgroundAudioUrl,
|
|
setVideoSettings: setBackgroundVideoSettings,
|
|
// Legacy compatibility values for components that expect flat props
|
|
backgroundImageUrl,
|
|
backgroundVideoUrl,
|
|
backgroundAudioUrl,
|
|
backgroundVideoAutoplay,
|
|
backgroundVideoLoop,
|
|
backgroundVideoMuted,
|
|
backgroundVideoStartTime,
|
|
backgroundVideoEndTime,
|
|
} = usePageBackground();
|
|
|
|
// Sound control hook for iOS autoplay compatibility
|
|
// Videos start muted (for iOS autoplay), can be controlled via page settings
|
|
const soundControl = useVideoSoundControl({
|
|
pageHasSound: backgroundVideoMuted === false, // Show button when page allows sound
|
|
hasBackgroundVideo: Boolean(backgroundVideoUrl),
|
|
videoUrl: backgroundVideoUrl, // Track video changes for page navigation reset
|
|
});
|
|
|
|
// Network-aware transitions: skip video on slow networks, use CSS fade instead
|
|
const { shouldUseVideoTransitions, networkInfo } = useNetworkAware();
|
|
|
|
// Fetch global transition defaults on mount
|
|
useEffect(() => {
|
|
dispatch(fetchGlobalTransitionDefaults());
|
|
}, [dispatch]);
|
|
|
|
// Fetch project transition settings for dev environment
|
|
useEffect(() => {
|
|
if (projectId) {
|
|
dispatch(
|
|
fetchProjectTransitionSettings({ projectId, environment: 'dev' }),
|
|
);
|
|
}
|
|
}, [dispatch, projectId]);
|
|
|
|
const [selectedMenuItem, setSelectedMenuItem] =
|
|
useState<EditorMenuItem>('none');
|
|
// Transition preview state managed by useTransitionPreview hook (below)
|
|
|
|
// Combined loading state: initial auth check + data loading
|
|
const [isInitializing, setIsInitializing] = useState(true);
|
|
const isLoading = isInitializing || isDataLoading;
|
|
// isSaving, isSavingToStage, isCreatingPage are managed by useConstructorPageActions hook
|
|
const [newTransitionName, setNewTransitionName] = useState('');
|
|
const [newTransitionVideoUrl, setNewTransitionVideoUrl] = useState('');
|
|
const [newTransitionSupportsReverse, setNewTransitionSupportsReverse] =
|
|
useState(true);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
const [successMessage, setSuccessMessage] = useState('');
|
|
|
|
// Auto-dismiss toast messages after 5 seconds
|
|
useEffect(() => {
|
|
if (!errorMessage) return;
|
|
const timer = setTimeout(() => setErrorMessage(''), 5000);
|
|
return () => clearTimeout(timer);
|
|
}, [errorMessage]);
|
|
|
|
useEffect(() => {
|
|
if (!successMessage) return;
|
|
const timer = setTimeout(() => setSuccessMessage(''), 5000);
|
|
return () => clearTimeout(timer);
|
|
}, [successMessage]);
|
|
|
|
const [constructorInteractionMode, setConstructorInteractionMode] =
|
|
useState<ConstructorInteractionMode>('edit');
|
|
// Note: isMenuOpen kept for context backward compatibility, not used by ConstructorToolbar
|
|
const [isMenuOpen, setIsMenuOpen] = useState(true);
|
|
const [isEditorCollapsed, setIsEditorCollapsed] = useState(false);
|
|
const [elementEditorTab, setElementEditorTab] = useState<
|
|
'general' | 'css' | 'effects'
|
|
>('general');
|
|
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
|
|
elementId: string;
|
|
initialIndex: number;
|
|
} | null>(null);
|
|
// Current element transition settings (for CSS transitions when no video)
|
|
const [
|
|
currentElementTransitionSettings,
|
|
setCurrentElementTransitionSettings,
|
|
] = useState<ElementTransitionSettings | null>(null);
|
|
|
|
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
|
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
|
return ['navigation_next', 'navigation_prev'];
|
|
}, []);
|
|
|
|
// Element CRUD operations via useConstructorElements hook
|
|
const {
|
|
elements,
|
|
setElements,
|
|
selectedElementId,
|
|
selectedElement,
|
|
selectElement,
|
|
clearSelection,
|
|
addElement,
|
|
updateSelectedElement,
|
|
removeSelectedElement,
|
|
galleryCards,
|
|
galleryInfoSpans,
|
|
carouselSlides,
|
|
updateElementPosition,
|
|
normalizeNavigationType,
|
|
} = useConstructorElements({
|
|
initialElements: [],
|
|
elementDefaultsByType: uiElementDefaultsByType,
|
|
allowedNavigationTypes,
|
|
initialSelectedElementId: elementIdFromRoute,
|
|
onElementSelected: useCallback(() => {
|
|
setSelectedMenuItem('none');
|
|
}, []),
|
|
onSelectionCleared: useCallback(() => {
|
|
setSelectedMenuItem('none');
|
|
}, []),
|
|
onElementAdded: useCallback(() => {
|
|
setSuccessMessage('Element added. Drag it to set position.');
|
|
setErrorMessage('');
|
|
}, []),
|
|
onElementRemoved: useCallback(() => {
|
|
setSuccessMessage('Element removed.');
|
|
}, []),
|
|
});
|
|
|
|
// Check if any element has full-width carousel mode enabled
|
|
const hasFullWidthCarousel = useMemo(
|
|
() => elements.some((el) => el.carouselFullWidth === true),
|
|
[elements],
|
|
);
|
|
|
|
// Look up current element for gallery carousel (so it receives updates from element editor)
|
|
const activeGalleryCarouselElement = useMemo(() => {
|
|
if (!activeGalleryCarousel) return null;
|
|
return (
|
|
elements.find((el) => el.id === activeGalleryCarousel.elementId) || null
|
|
);
|
|
}, [activeGalleryCarousel, elements]);
|
|
|
|
// Draggable panels using useDraggable hook
|
|
const { position: toolbarPosition, onDragStart: onToolbarDragStart } =
|
|
useDraggable({
|
|
initialPosition: { x: 20, y: 20 },
|
|
elementWidth: 200, // Use collapsed width for bounds - expanded can go off-screen
|
|
elementHeight: 56,
|
|
});
|
|
|
|
const { position: editorPosition, onDragStart: onElementEditorDragStart } =
|
|
useDraggable({
|
|
initialPosition: { x: 9999, y: 0 }, // Top-right corner (x will be clamped)
|
|
elementWidth: isEditorCollapsed ? 220 : 300,
|
|
elementHeight: 60,
|
|
});
|
|
|
|
const transitionVideoRef = useRef<HTMLVideoElement | null>(null);
|
|
const lastInitializedPageIdRef = useRef<string | null>(null);
|
|
const didSetInitialCanvasFocus = useRef(false);
|
|
const selectedElementIdRef = useRef<string>('');
|
|
selectedElementIdRef.current = selectedElementId;
|
|
|
|
const activePage = useMemo(
|
|
() => pages.find((item) => item.id === activePageId) || null,
|
|
[activePageId, pages],
|
|
);
|
|
|
|
// Last project-level save: most recent updatedAt across all pages
|
|
const lastProjectSaveAt = useMemo(() => {
|
|
if (!pages.length) return null;
|
|
return pages.reduce(
|
|
(latest, page) => {
|
|
if (!page.updatedAt) return latest;
|
|
if (!latest) return page.updatedAt;
|
|
return new Date(page.updatedAt) > new Date(latest)
|
|
? page.updatedAt
|
|
: latest;
|
|
},
|
|
null as string | null,
|
|
);
|
|
}, [pages]);
|
|
|
|
// Transition preview state management
|
|
const {
|
|
preview: transitionPreview,
|
|
pendingPageId: pendingNavigationPageId,
|
|
openPreview: openTransitionPreviewForElement,
|
|
openPreviewWithTarget,
|
|
closePreview: closeTransitionPreview,
|
|
} = useTransitionPreview({
|
|
isNavigationElementType,
|
|
onError: setErrorMessage,
|
|
});
|
|
|
|
// Canvas elapsed time for element visibility timing
|
|
const { elapsedSec: canvasElapsedSec } = useCanvasElapsedTime({
|
|
pageId: activePageId,
|
|
enabled: !isLoading,
|
|
});
|
|
|
|
// Element dragging with percentage positioning
|
|
const { onElementDragStart, cancelDrag: cancelElementDrag } =
|
|
useCanvasElementDrag({
|
|
canvasRef,
|
|
onPositionChange: updateElementPosition,
|
|
enabled: constructorInteractionMode === 'edit',
|
|
});
|
|
|
|
// Preload orchestrator for better DX when previewing pages
|
|
// Preloads current page + transition videos only
|
|
// STREAM-FIRST: Constructor always uses online mode
|
|
// Transition videos stream on-demand, then cache for replay
|
|
const preloadOrchestrator = usePreloadOrchestrator({
|
|
pages: pages.map((p) => ({
|
|
id: p.id,
|
|
background_image_url: p.background_image_url,
|
|
background_video_url: p.background_video_url,
|
|
background_audio_url: p.background_audio_url,
|
|
})),
|
|
pageLinks,
|
|
elements: allPagesPreloadElements,
|
|
currentPageId: activePageId,
|
|
pageHistory,
|
|
enabled: !isLoading && !!activePageId,
|
|
});
|
|
|
|
// Resolve transition settings using cascade: element → project → global
|
|
const transitionSettings = useTransitionSettings({
|
|
globalDefaults: globalTransitionDefaults,
|
|
projectSettings: projectTransitionSettings ?? null,
|
|
elementSettings: currentElementTransitionSettings,
|
|
});
|
|
|
|
// Unified page navigation state machine (replaces 6+ separate hooks)
|
|
// Uses useReducer for atomic state transitions, preventing race conditions
|
|
const navState = usePageNavigationState({
|
|
preloadCache: preloadOrchestrator
|
|
? {
|
|
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
|
|
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
|
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
|
}
|
|
: undefined,
|
|
transitionSettings,
|
|
});
|
|
|
|
// Destructure for convenience (matches previous hook interfaces)
|
|
// showElements/showSpinner are derived from the unified state machine phase:
|
|
// - showElements: true when phase is 'idle' or 'fading_in'
|
|
// - showSpinner: true when phase is 'preparing', 'loading_bg', or 'transition_done'
|
|
const {
|
|
currentImageUrl: navCurrentBgImageUrl,
|
|
currentVideoUrl: navCurrentBgVideoUrl,
|
|
currentAudioUrl: navCurrentBgAudioUrl,
|
|
previousImageUrl: navPreviousBgImageUrl,
|
|
previousVideoUrl: navPreviousBgVideoUrl,
|
|
isSwitching: navIsSwitching,
|
|
isNewBgReady: navIsNewBgReady,
|
|
pendingTransitionComplete,
|
|
isFadingIn,
|
|
showElements: navShowElements,
|
|
showSpinner: navShowSpinner,
|
|
showTransitionVideo,
|
|
transitionStyle,
|
|
lastKnownBgUrl,
|
|
onBackgroundReady: navOnBackgroundReady,
|
|
onVideoBufferStateChange,
|
|
onTransitionEnded,
|
|
navigateToPage: navNavigateToPage,
|
|
resetToIdle: navResetToIdle,
|
|
startTransition,
|
|
} = navState;
|
|
|
|
// Reset navigation state when starting a new transition
|
|
const resetFadeIn = useCallback(() => {
|
|
navResetToIdle();
|
|
}, [navResetToIdle]);
|
|
|
|
// Helper to switch pages without flash
|
|
// Uses unified navigation state machine for blob URL resolution
|
|
// isBack parameter indicates this is a back navigation (pops history instead of pushing)
|
|
const switchToPage = useCallback(
|
|
async (page: TourPage | null, isBack = false) => {
|
|
// Mark this page as initialized to prevent redundant effect calls
|
|
if (page) {
|
|
lastInitializedPageIdRef.current = page.id;
|
|
}
|
|
|
|
// Use unified navigation state machine for atomic state transitions
|
|
await navNavigateToPage(
|
|
page
|
|
? {
|
|
id: page.id,
|
|
background_image_url: page.background_image_url,
|
|
background_video_url: page.background_video_url,
|
|
background_audio_url: page.background_audio_url,
|
|
}
|
|
: null,
|
|
{
|
|
hasTransition: false, // No video transition for direct navigation
|
|
isBack,
|
|
onSwitched: () => {
|
|
if (page) {
|
|
// Use applyPageSelection for proper history management (pops on back)
|
|
applyPageSelection(page.id, isBack);
|
|
}
|
|
},
|
|
},
|
|
);
|
|
},
|
|
[navNavigateToPage, applyPageSelection],
|
|
);
|
|
|
|
const { isBuffering: isTransitionBuffering, isVideoReady: isTransitionVideoReady } = useTransitionPlayback({
|
|
videoRef: transitionVideoRef,
|
|
transition: transitionPreview
|
|
? {
|
|
videoUrl: resolveAssetPlaybackUrl(transitionPreview.videoUrl),
|
|
storageKey: transitionPreview.storageKey,
|
|
reverseMode: transitionPreview.reverseMode,
|
|
reverseVideoUrl: transitionPreview.reverseVideoUrl
|
|
? resolveAssetPlaybackUrl(transitionPreview.reverseVideoUrl)
|
|
: undefined,
|
|
reverseStorageKey: transitionPreview.reverseStorageKey, // Raw path for cache lookup
|
|
durationSec: transitionPreview.durationSec,
|
|
targetPageId: pendingNavigationPageId || undefined,
|
|
displayName: transitionPreview.title,
|
|
isBack: transitionPreview.isBack, // Pass through for history management
|
|
}
|
|
: null,
|
|
onComplete: async (targetPageId, isBack) => {
|
|
// Resume background downloads now that transition is complete
|
|
downloadManager.resumeAll();
|
|
const video = transitionVideoRef.current;
|
|
if (targetPageId) {
|
|
const targetPage = pages.find((p) => p.id === targetPageId) || null;
|
|
// Signal that transition video has ended
|
|
// State machine transitions to 'transition_done', waiting for background
|
|
onTransitionEnded();
|
|
// DON'T close preview here - it stays visible until background is ready
|
|
// The useEffect watching showTransitionVideo will close it
|
|
// Navigate to target page - state machine handles ready state
|
|
await switchToPage(targetPage, isBack ?? false);
|
|
clearSelection();
|
|
setSelectedMenuItem('none');
|
|
setErrorMessage('');
|
|
} else {
|
|
video?.removeAttribute('src');
|
|
video?.load();
|
|
closeTransitionPreview();
|
|
navResetToIdle();
|
|
}
|
|
},
|
|
timeouts: {
|
|
playbackStartMs: 3000,
|
|
hardTimeoutMs: 45000,
|
|
},
|
|
features: {
|
|
useBlobUrl: true,
|
|
preDecodeImages: false, // We handle image loading via navigation state machine
|
|
},
|
|
preload: {
|
|
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
|
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
|
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
|
|
getReadyBlob: preloadOrchestrator.getReadyBlob,
|
|
},
|
|
});
|
|
|
|
// Sync transition video buffering state with navigation state machine
|
|
// This enables unified showSpinner logic in the state machine
|
|
useEffect(() => {
|
|
const isBuffering = Boolean(transitionPreview) && isTransitionBuffering;
|
|
onVideoBufferStateChange(isBuffering);
|
|
}, [transitionPreview, isTransitionBuffering, onVideoBufferStateChange]);
|
|
|
|
// Clean up transition preview when state machine says video overlay should be hidden
|
|
// showTransitionVideo is true during 'transitioning', 'transition_done', and 'fading_in' phases
|
|
// During 'fading_in', the overlay fades out (isFadingOut=true), then removed when phase goes to 'idle'
|
|
useEffect(() => {
|
|
if (transitionPreview && !showTransitionVideo) {
|
|
closeTransitionPreview();
|
|
}
|
|
}, [transitionPreview, showTransitionVideo, closeTransitionPreview]);
|
|
|
|
const iconPreloadTargets = useMemo(() => {
|
|
const preloadableTypes: CanvasElementType[] = [
|
|
'navigation_next',
|
|
'navigation_prev',
|
|
'tooltip',
|
|
'description',
|
|
];
|
|
const urls = elements
|
|
.filter(
|
|
(element) =>
|
|
preloadableTypes.includes(element.type) && Boolean(element.iconUrl),
|
|
)
|
|
.map((element) => resolveAssetPlaybackUrl(element.iconUrl))
|
|
.filter(Boolean);
|
|
|
|
return Array.from(new Set(urls));
|
|
}, [elements]);
|
|
|
|
// Icon preloading for smooth rendering
|
|
const { preloadedUrlMap: preloadedIconUrlMap } = useIconPreload({
|
|
iconUrls: iconPreloadTargets,
|
|
enabled: !isLoading,
|
|
});
|
|
|
|
// Use the useAssetOptions hook to derive memoized asset options
|
|
const assetOptions = useAssetOptions({ assets });
|
|
// Media duration probing with caching
|
|
const durationProbeTargets = useMemo(
|
|
() =>
|
|
buildDurationProbeTargets({
|
|
backgroundVideoUrl,
|
|
backgroundAudioUrl,
|
|
selectedElement,
|
|
newTransitionVideoUrl,
|
|
elements,
|
|
isMediaElementType,
|
|
isVideoPlayerElementType,
|
|
isNavigationElementType,
|
|
}),
|
|
[
|
|
backgroundAudioUrl,
|
|
backgroundVideoUrl,
|
|
elements,
|
|
newTransitionVideoUrl,
|
|
selectedElement,
|
|
],
|
|
);
|
|
|
|
const { getDuration, getDurationNote, durationBySource } =
|
|
useMediaDurationProbe({
|
|
targets: durationProbeTargets,
|
|
});
|
|
|
|
const backgroundVideoDurationNote = getDurationNote(backgroundVideoUrl);
|
|
const backgroundAudioDurationNote = getDurationNote(backgroundAudioUrl);
|
|
const selectedMediaDurationNote = useMemo(() => {
|
|
if (!selectedElement || !isMediaElementType(selectedElement.type)) {
|
|
return 'Duration: unknown';
|
|
}
|
|
return getDurationNote(selectedElement.mediaUrl || '');
|
|
}, [getDurationNote, selectedElement]);
|
|
|
|
const newTransitionDurationNote = getDurationNote(newTransitionVideoUrl);
|
|
|
|
const selectedTransitionDurationNote = useMemo(() => {
|
|
if (!selectedElement || !isNavigationElementType(selectedElement.type)) {
|
|
return 'Duration: unknown';
|
|
}
|
|
return getDurationNote(selectedElement.transitionVideoUrl || '');
|
|
}, [getDurationNote, selectedElement]);
|
|
|
|
useEffect(() => {
|
|
if (newTransitionVideoUrl) return;
|
|
if (!assetOptions.transitionVideo.length) return;
|
|
setNewTransitionVideoUrl(assetOptions.transitionVideo[0].value);
|
|
}, [newTransitionVideoUrl, assetOptions.transitionVideo]);
|
|
|
|
useEffect(() => {
|
|
setElements((prev) => {
|
|
let hasChanges = false;
|
|
const next = prev.map((element) => {
|
|
if (!isNavigationElementType(element.type)) return element;
|
|
|
|
const resolvedDuration = getDuration(element.transitionVideoUrl || '');
|
|
const nextDuration =
|
|
Number.isFinite(resolvedDuration) && Number(resolvedDuration) > 0
|
|
? Number(resolvedDuration)
|
|
: undefined;
|
|
if (element.transitionDurationSec === nextDuration) return element;
|
|
|
|
hasChanges = true;
|
|
return {
|
|
...element,
|
|
transitionDurationSec: nextDuration,
|
|
};
|
|
});
|
|
|
|
return hasChanges ? next : prev;
|
|
});
|
|
}, [getDuration]);
|
|
|
|
// Handle initial page selection when pages are loaded
|
|
const prevPagesLengthRef = useRef(0);
|
|
useEffect(() => {
|
|
// Only set initial page when pages first load (not on every change)
|
|
if (pages.length > 0 && prevPagesLengthRef.current === 0) {
|
|
const defaultPageId = pageIdFromRoute || pages[0]?.id || '';
|
|
// Use applyPageSelection to set initial page and history
|
|
applyPageSelection(defaultPageId, false);
|
|
setIsMenuOpen(false);
|
|
setIsInitializing(false);
|
|
}
|
|
prevPagesLengthRef.current = pages.length;
|
|
}, [pages, pageIdFromRoute, applyPageSelection]);
|
|
|
|
// Handle empty project case - mark initialized when data loads with no pages
|
|
// This allows the "Create First Page" button to show
|
|
useEffect(() => {
|
|
if (!isDataLoading && pages.length === 0) {
|
|
setIsInitializing(false);
|
|
}
|
|
}, [isDataLoading, pages.length]);
|
|
|
|
// Handle query errors
|
|
useEffect(() => {
|
|
if (isDataError && dataError) {
|
|
const message = dataError.message || 'Failed to load constructor data.';
|
|
logger.error('Failed to load constructor data:', dataError);
|
|
setErrorMessage(message);
|
|
}
|
|
}, [isDataError, dataError]);
|
|
|
|
// Refetch wrapper that preserves current page
|
|
const handleReload = useCallback(async () => {
|
|
const currentPageId = activePageId;
|
|
await refetchData();
|
|
// After refetch, restore the active page if it still exists
|
|
// This is handled by the pages effect above
|
|
if (currentPageId && pages.some((p) => p.id === currentPageId)) {
|
|
setActivePageId(currentPageId);
|
|
}
|
|
}, [activePageId, pages, refetchData]);
|
|
|
|
// Page actions (save, create page, save to stage)
|
|
const {
|
|
isSaving,
|
|
isSavingToStage,
|
|
isCreatingPage,
|
|
isCreatingTransition,
|
|
saveConstructor,
|
|
saveToStage,
|
|
createPage,
|
|
createTransition,
|
|
} = useConstructorPageActions({
|
|
projectId,
|
|
project,
|
|
pages,
|
|
activePage,
|
|
activePageId,
|
|
elements,
|
|
pageBackground,
|
|
onReload: handleReload,
|
|
onSetActivePageId: setActivePageId,
|
|
onSetMenuOpen: setIsMenuOpen,
|
|
onError: setErrorMessage,
|
|
onSuccess: setSuccessMessage,
|
|
});
|
|
|
|
// Publish status for timestamp display
|
|
const { lastSavedToStage, refresh: refreshPublishStatus } = usePublishStatus({
|
|
projectId,
|
|
});
|
|
|
|
// Wrap saveToStage to refresh publish status after save
|
|
const handleSaveToStage = useCallback(async () => {
|
|
await saveToStage();
|
|
await refreshPublishStatus();
|
|
}, [saveToStage, refreshPublishStatus]);
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady || typeof window === 'undefined') return;
|
|
|
|
const token =
|
|
sessionStorage.getItem('token') || localStorage.getItem('token');
|
|
if (!token) {
|
|
setIsAuthReady(false);
|
|
setErrorMessage('Please sign in to continue.');
|
|
router.replace('/login');
|
|
return;
|
|
}
|
|
|
|
setIsAuthReady(true);
|
|
}, [router]);
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady) return;
|
|
if (projectId) return;
|
|
router.replace(
|
|
isElementEditMode
|
|
? '/project-element-defaults/project-element-defaults-list'
|
|
: '/projects/projects-list',
|
|
);
|
|
}, [isElementEditMode, projectId, router]);
|
|
|
|
// React Query handles data fetching automatically based on projectId and isAuthReady
|
|
|
|
// Panel initial positions are handled by useDraggable hooks
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady || !isAuthReady || isLoading) return;
|
|
if (didSetInitialCanvasFocus.current) return;
|
|
if (!canvasRef.current) return;
|
|
|
|
didSetInitialCanvasFocus.current = true;
|
|
requestAnimationFrame(() => {
|
|
canvasRef.current?.focus({ preventScroll: true });
|
|
});
|
|
}, [isAuthReady, isLoading, router.isReady]);
|
|
|
|
useEffect(() => {
|
|
if (!activePage) {
|
|
setElements([]);
|
|
clearSelection();
|
|
updateBackgroundFromPage(null);
|
|
return;
|
|
}
|
|
|
|
const schema = parseJsonObject<ConstructorSchema>(
|
|
activePage.ui_schema_json,
|
|
{},
|
|
);
|
|
const normalizedElements = Array.isArray(schema.elements)
|
|
? schema.elements
|
|
.filter(
|
|
(item) =>
|
|
item && item.type && labelByType[item.type as CanvasElementType],
|
|
)
|
|
.map((item) => {
|
|
const elementType = item.type as CanvasElementType;
|
|
const normalizedElement: CanvasElement = {
|
|
...item,
|
|
id: String(item.id || createLocalId()),
|
|
label:
|
|
typeof item.label === 'string' && item.label.trim()
|
|
? item.label
|
|
: labelByType[elementType],
|
|
xPercent: clamp(Number(item.xPercent || 0), 0, 100),
|
|
yPercent: clamp(Number(item.yPercent || 0), 0, 100),
|
|
appearDelaySec: normalizeAppearDelaySec(item.appearDelaySec),
|
|
appearDurationSec: normalizeAppearDurationSec(
|
|
item.appearDurationSec,
|
|
),
|
|
galleryCards: Array.isArray(item.galleryCards)
|
|
? item.galleryCards.map((card: Partial<GalleryCard>) => ({
|
|
id: String(card?.id || createLocalId()),
|
|
imageUrl: String(card?.imageUrl ?? ''),
|
|
title: String(card?.title ?? ''),
|
|
description: String(card?.description ?? ''),
|
|
}))
|
|
: undefined,
|
|
galleryHeaderImageUrl:
|
|
typeof item.galleryHeaderImageUrl === 'string'
|
|
? item.galleryHeaderImageUrl
|
|
: undefined,
|
|
galleryTitle:
|
|
typeof item.galleryTitle === 'string'
|
|
? item.galleryTitle
|
|
: undefined,
|
|
galleryInfoSpans: Array.isArray(item.galleryInfoSpans)
|
|
? item.galleryInfoSpans.map(
|
|
(span: Partial<GalleryInfoSpan>) => ({
|
|
id: String(span?.id || createLocalId()),
|
|
text: String(span?.text ?? ''),
|
|
iconUrl: span?.iconUrl ? String(span.iconUrl) : undefined,
|
|
}),
|
|
)
|
|
: undefined,
|
|
galleryColumns:
|
|
typeof item.galleryColumns === 'number'
|
|
? item.galleryColumns
|
|
: undefined,
|
|
carouselSlides: Array.isArray(item.carouselSlides)
|
|
? item.carouselSlides.map((slide: Partial<CarouselSlide>) => ({
|
|
id: String(slide?.id || createLocalId()),
|
|
imageUrl: String(slide?.imageUrl ?? ''),
|
|
caption: String(slide?.caption ?? ''),
|
|
}))
|
|
: undefined,
|
|
iconUrl: typeof item.iconUrl === 'string' ? item.iconUrl : '',
|
|
carouselPrevIconUrl:
|
|
typeof item.carouselPrevIconUrl === 'string'
|
|
? item.carouselPrevIconUrl
|
|
: '',
|
|
carouselNextIconUrl:
|
|
typeof item.carouselNextIconUrl === 'string'
|
|
? item.carouselNextIconUrl
|
|
: '',
|
|
// Carousel button positions
|
|
carouselPrevX:
|
|
typeof item.carouselPrevX === 'number'
|
|
? item.carouselPrevX
|
|
: undefined,
|
|
carouselPrevY:
|
|
typeof item.carouselPrevY === 'number'
|
|
? item.carouselPrevY
|
|
: undefined,
|
|
carouselNextX:
|
|
typeof item.carouselNextX === 'number'
|
|
? item.carouselNextX
|
|
: undefined,
|
|
carouselNextY:
|
|
typeof item.carouselNextY === 'number'
|
|
? item.carouselNextY
|
|
: undefined,
|
|
// Carousel button dimensions
|
|
carouselPrevWidth:
|
|
typeof item.carouselPrevWidth === 'string'
|
|
? item.carouselPrevWidth
|
|
: undefined,
|
|
carouselPrevHeight:
|
|
typeof item.carouselPrevHeight === 'string'
|
|
? item.carouselPrevHeight
|
|
: undefined,
|
|
carouselNextWidth:
|
|
typeof item.carouselNextWidth === 'string'
|
|
? item.carouselNextWidth
|
|
: undefined,
|
|
carouselNextHeight:
|
|
typeof item.carouselNextHeight === 'string'
|
|
? item.carouselNextHeight
|
|
: undefined,
|
|
// Gallery Carousel Settings
|
|
galleryCarouselPrevIconUrl:
|
|
typeof item.galleryCarouselPrevIconUrl === 'string'
|
|
? item.galleryCarouselPrevIconUrl
|
|
: '',
|
|
galleryCarouselNextIconUrl:
|
|
typeof item.galleryCarouselNextIconUrl === 'string'
|
|
? item.galleryCarouselNextIconUrl
|
|
: '',
|
|
galleryCarouselBackIconUrl:
|
|
typeof item.galleryCarouselBackIconUrl === 'string'
|
|
? item.galleryCarouselBackIconUrl
|
|
: '',
|
|
galleryCarouselBackLabel:
|
|
typeof item.galleryCarouselBackLabel === 'string'
|
|
? item.galleryCarouselBackLabel
|
|
: '',
|
|
galleryCarouselPrevX:
|
|
typeof item.galleryCarouselPrevX === 'number'
|
|
? item.galleryCarouselPrevX
|
|
: undefined,
|
|
galleryCarouselPrevY:
|
|
typeof item.galleryCarouselPrevY === 'number'
|
|
? item.galleryCarouselPrevY
|
|
: undefined,
|
|
galleryCarouselNextX:
|
|
typeof item.galleryCarouselNextX === 'number'
|
|
? item.galleryCarouselNextX
|
|
: undefined,
|
|
galleryCarouselNextY:
|
|
typeof item.galleryCarouselNextY === 'number'
|
|
? item.galleryCarouselNextY
|
|
: undefined,
|
|
galleryCarouselBackX:
|
|
typeof item.galleryCarouselBackX === 'number'
|
|
? item.galleryCarouselBackX
|
|
: undefined,
|
|
galleryCarouselBackY:
|
|
typeof item.galleryCarouselBackY === 'number'
|
|
? item.galleryCarouselBackY
|
|
: undefined,
|
|
galleryCarouselPrevWidth:
|
|
typeof item.galleryCarouselPrevWidth === 'string'
|
|
? item.galleryCarouselPrevWidth
|
|
: undefined,
|
|
galleryCarouselPrevHeight:
|
|
typeof item.galleryCarouselPrevHeight === 'string'
|
|
? item.galleryCarouselPrevHeight
|
|
: undefined,
|
|
galleryCarouselNextWidth:
|
|
typeof item.galleryCarouselNextWidth === 'string'
|
|
? item.galleryCarouselNextWidth
|
|
: undefined,
|
|
galleryCarouselNextHeight:
|
|
typeof item.galleryCarouselNextHeight === 'string'
|
|
? item.galleryCarouselNextHeight
|
|
: undefined,
|
|
galleryCarouselBackWidth:
|
|
typeof item.galleryCarouselBackWidth === 'string'
|
|
? item.galleryCarouselBackWidth
|
|
: undefined,
|
|
galleryCarouselBackHeight:
|
|
typeof item.galleryCarouselBackHeight === 'string'
|
|
? item.galleryCarouselBackHeight
|
|
: undefined,
|
|
tooltipTitle:
|
|
typeof item.tooltipTitle === 'string' ? item.tooltipTitle : '',
|
|
tooltipText:
|
|
typeof item.tooltipText === 'string' ? item.tooltipText : '',
|
|
descriptionTitle:
|
|
typeof item.descriptionTitle === 'string'
|
|
? item.descriptionTitle
|
|
: '',
|
|
descriptionText:
|
|
typeof item.descriptionText === 'string'
|
|
? item.descriptionText
|
|
: '',
|
|
navLabel: typeof item.navLabel === 'string' ? item.navLabel : '',
|
|
navType:
|
|
item.navType === 'back' || item.navType === 'forward'
|
|
? item.navType
|
|
: isNavigationElementType(elementType)
|
|
? getNavigationButtonKind(
|
|
elementType as NavigationElementType,
|
|
)
|
|
: undefined,
|
|
// Support both targetPageSlug (new) and targetPageId (legacy)
|
|
targetPageSlug:
|
|
typeof item.targetPageSlug === 'string'
|
|
? item.targetPageSlug
|
|
: '',
|
|
targetPageId:
|
|
typeof item.targetPageId === 'string' ? item.targetPageId : '',
|
|
transitionVideoUrl:
|
|
typeof item.transitionVideoUrl === 'string'
|
|
? item.transitionVideoUrl
|
|
: '',
|
|
transitionReverseMode:
|
|
item.transitionReverseMode === 'separate_video'
|
|
? 'separate_video'
|
|
: ('auto_reverse' as const),
|
|
reverseVideoUrl:
|
|
typeof item.reverseVideoUrl === 'string'
|
|
? item.reverseVideoUrl
|
|
: '',
|
|
transitionDurationSec: item.transitionDurationSec
|
|
? Number(item.transitionDurationSec)
|
|
: undefined,
|
|
mediaUrl: typeof item.mediaUrl === 'string' ? item.mediaUrl : '',
|
|
mediaAutoplay:
|
|
typeof item.mediaAutoplay === 'boolean'
|
|
? item.mediaAutoplay
|
|
: true,
|
|
mediaLoop:
|
|
typeof item.mediaLoop === 'boolean' ? item.mediaLoop : true,
|
|
mediaMuted:
|
|
typeof item.mediaMuted === 'boolean'
|
|
? item.mediaMuted
|
|
: isVideoPlayerElementType(item.type),
|
|
};
|
|
|
|
return mergeElementWithDefaults(
|
|
normalizedElement,
|
|
uiElementDefaultsByType[elementType],
|
|
{ preferElementValues: true },
|
|
);
|
|
})
|
|
: [];
|
|
|
|
setElements(normalizedElements);
|
|
setSelectedMenuItem('none');
|
|
|
|
const currentSelectedId = selectedElementIdRef.current;
|
|
if (!normalizedElements.length) {
|
|
clearSelection();
|
|
} else if (
|
|
elementIdFromRoute &&
|
|
normalizedElements.some((element) => element.id === elementIdFromRoute)
|
|
) {
|
|
selectElement(elementIdFromRoute);
|
|
} else if (
|
|
currentSelectedId &&
|
|
!normalizedElements.some((element) => element.id === currentSelectedId)
|
|
) {
|
|
// Current selection no longer valid
|
|
clearSelection();
|
|
}
|
|
// If current selection is still valid, do nothing (keep current)
|
|
// Update consolidated background state (replaces 8 separate setters)
|
|
updateBackgroundFromPage(activePage);
|
|
}, [
|
|
activePage,
|
|
elementIdFromRoute,
|
|
uiElementDefaultsByType,
|
|
clearSelection,
|
|
selectElement,
|
|
setElements,
|
|
updateBackgroundFromPage,
|
|
]);
|
|
|
|
// Separate effect for initial background loading (matches RuntimePresentation pattern)
|
|
// This effect ONLY handles initial page load when backgrounds are empty.
|
|
// switchToPage handles all subsequent navigation by calling navNavigateToPage directly.
|
|
// Keeping this separate prevents race conditions where state updates trigger
|
|
// this effect before activePageId has been updated via applyPageSelection.
|
|
useEffect(() => {
|
|
if (!activePage || lastInitializedPageIdRef.current === activePage.id)
|
|
return;
|
|
|
|
// Only initialize when backgrounds are EMPTY (initial load)
|
|
if (!navCurrentBgImageUrl && !navCurrentBgVideoUrl) {
|
|
lastInitializedPageIdRef.current = activePage.id;
|
|
navNavigateToPage({
|
|
id: activePage.id,
|
|
background_image_url: activePage.background_image_url,
|
|
background_video_url: activePage.background_video_url,
|
|
background_audio_url: activePage.background_audio_url,
|
|
});
|
|
}
|
|
}, [
|
|
activePage,
|
|
navCurrentBgImageUrl,
|
|
navCurrentBgVideoUrl,
|
|
navNavigateToPage,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (allowedNavigationTypes.length !== 1) return;
|
|
const forcedType = allowedNavigationTypes[0];
|
|
|
|
setElements((prev) => {
|
|
let hasChanges = false;
|
|
const nextElements = prev.map((element) => {
|
|
if (
|
|
!isNavigationElementType(element.type) ||
|
|
element.type === forcedType
|
|
)
|
|
return element;
|
|
hasChanges = true;
|
|
return normalizeNavigationType(element, forcedType);
|
|
});
|
|
return hasChanges ? nextElements : prev;
|
|
});
|
|
}, [allowedNavigationTypes, normalizeNavigationType, setElements]);
|
|
|
|
// Element drag is now handled by useCanvasElementDrag hook
|
|
|
|
useEffect(() => {
|
|
if (isConstructorEditMode) return;
|
|
cancelElementDrag();
|
|
clearSelection();
|
|
setSelectedMenuItem('none');
|
|
}, [isConstructorEditMode, cancelElementDrag, clearSelection]);
|
|
|
|
// Outside click detection to clear element/menu selection
|
|
// Ignore clicks on menu to allow menu item selection
|
|
useOutsideClick({
|
|
containerRef: elementEditorRef,
|
|
ignoreRefs: [toolbarRef],
|
|
ignoreDataAttribute: 'data-constructor-element-id',
|
|
selectedValue: selectedElementId,
|
|
onOutsideClick: useCallback(() => {
|
|
clearSelection();
|
|
setSelectedMenuItem('none');
|
|
}, [clearSelection]),
|
|
enabled:
|
|
isConstructorEditMode &&
|
|
(!!selectedElementId || selectedMenuItem !== 'none'),
|
|
});
|
|
|
|
// Thin wrappers for hook functions (handle additional state like selectedMenuItem)
|
|
const selectElementForEdit = useCallback(
|
|
(elementId: string) => {
|
|
selectElement(elementId);
|
|
// Note: setSelectedMenuItem('none') is handled by onElementSelected callback
|
|
},
|
|
[selectElement],
|
|
);
|
|
|
|
const selectMenuItemForEdit = useCallback(
|
|
(item: EditorMenuItem) => {
|
|
clearSelection();
|
|
setSelectedMenuItem(item);
|
|
},
|
|
[clearSelection],
|
|
);
|
|
|
|
// createPage, saveConstructor, saveToStage are now provided by useConstructorPageActions hook
|
|
|
|
const onElementMouseDown = (event: React.MouseEvent, elementId: string) => {
|
|
if (!isConstructorEditMode) return;
|
|
|
|
const currentElement = elements.find((item) => item.id === elementId);
|
|
if (!currentElement) return;
|
|
|
|
// Select the element for editing
|
|
selectElementForEdit(elementId);
|
|
|
|
// Start drag with current position
|
|
onElementDragStart(
|
|
event,
|
|
elementId,
|
|
currentElement.xPercent,
|
|
currentElement.yPercent,
|
|
);
|
|
};
|
|
|
|
// openTransitionPreviewForElement is now provided by useTransitionPreview hook
|
|
|
|
const openTransitionPreview = (direction: 'forward' | 'back') => {
|
|
if (
|
|
!selectedElement ||
|
|
(selectedElement.type !== 'navigation_next' &&
|
|
selectedElement.type !== 'navigation_prev')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
openTransitionPreviewForElement(selectedElement, direction);
|
|
};
|
|
|
|
const onCanvasElementClick = (element: CanvasElement) => {
|
|
if (!isConstructorEditMode) {
|
|
if (isNavigationElementType(element.type)) {
|
|
// Disable navigation while transition is playing or buffering
|
|
if (transitionPreview || isTransitionBuffering) {
|
|
return;
|
|
}
|
|
if (element.navDisabled) {
|
|
return;
|
|
}
|
|
|
|
// Cancel any pending fade to prevent stale fade state across navigations
|
|
resetFadeIn();
|
|
|
|
// Use shared navigation helpers
|
|
const direction = getNavigationDirection(element);
|
|
|
|
// Get navigation context from hook for history-based back navigation
|
|
const navContext = getNavigationContext();
|
|
|
|
// Pass history context for history-based back navigation
|
|
const navTarget = resolveNavigationTarget(element, pages, navContext);
|
|
|
|
if (!navTarget) {
|
|
// Back buttons always use history, forward buttons use target page
|
|
if (isBackNavigation(element)) {
|
|
if (!navContext.previousPageId) {
|
|
setErrorMessage(
|
|
'No previous page in history. Navigate to another page first.',
|
|
);
|
|
} else {
|
|
setErrorMessage(
|
|
'Previous page not found. It may have been deleted.',
|
|
);
|
|
}
|
|
} else {
|
|
setErrorMessage(
|
|
'No target page configured for this navigation button.',
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// For back navigation, use transition from navTarget (the forward element that brought us here)
|
|
// For forward navigation, use the element's own transition settings
|
|
const transitionSource = isBackNavigation(element)
|
|
? {
|
|
type: element.type,
|
|
transitionVideoUrl: navTarget.transitionVideoUrl,
|
|
transitionReverseMode: navTarget.transitionReverseMode,
|
|
reverseVideoUrl: navTarget.reverseVideoUrl,
|
|
}
|
|
: element;
|
|
|
|
// Extract element transition settings for CSS-based transitions
|
|
// For back navigation, use navTarget's settings (the forward element that brought us here)
|
|
// For forward navigation, use the clicked element's settings
|
|
const elementTransitionSource = isBackNavigation(element)
|
|
? navTarget
|
|
: element;
|
|
const elementSettings = extractElementTransitionSettings(
|
|
elementTransitionSource,
|
|
);
|
|
// Use flushSync to ensure state is updated synchronously before transition starts
|
|
// Without this, React's async state batching causes the transition to use OLD settings
|
|
flushSync(() => {
|
|
setCurrentElementTransitionSettings(elementSettings);
|
|
});
|
|
|
|
// Note: Background ready state is reset atomically by navigateToPage/switchToPage
|
|
|
|
// Check if transition can be played using shared helper
|
|
const canPlayTransition = hasPlayableTransition(transitionSource, direction);
|
|
|
|
// Check if video is already cached (use video even on slow network if cached)
|
|
const transitionUrl = transitionSource.transitionVideoUrl;
|
|
const isTransitionCached =
|
|
transitionUrl && preloadOrchestrator.getReadyBlobUrl(transitionUrl);
|
|
|
|
// Use video if: has playable transition AND (cached OR good network)
|
|
const useVideoTransition =
|
|
canPlayTransition && (isTransitionCached || shouldUseVideoTransitions);
|
|
|
|
if (!useVideoTransition) {
|
|
closeTransitionPreview();
|
|
|
|
// Log when skipping video due to slow network
|
|
if (canPlayTransition && !shouldUseVideoTransitions && transitionUrl) {
|
|
logger.info(
|
|
'[NAVIGATION] Skipping video transition due to slow network, downloading in background',
|
|
{
|
|
effectiveType: networkInfo.effectiveType,
|
|
downlink: networkInfo.downlink,
|
|
rtt: networkInfo.rtt,
|
|
},
|
|
);
|
|
|
|
// Start background download of transition video for future use (low priority)
|
|
downloadManager.addJob({
|
|
assetId: `transition-bg-${transitionUrl}`,
|
|
projectId: 'transition-preload',
|
|
url: resolveAssetPlaybackUrl(transitionUrl),
|
|
filename: transitionUrl.split('/').pop() || 'transition.mp4',
|
|
variantType: 'original',
|
|
assetType: 'video',
|
|
priority: 10, // Low priority - background preload
|
|
storageKey: transitionUrl,
|
|
});
|
|
}
|
|
|
|
// Use switchToPage which resolves blob URLs via navigation state machine (reduces flash)
|
|
// Pass isBack flag for proper history management
|
|
switchToPage(navTarget.page, navTarget.isBack).then(() => {
|
|
clearSelection();
|
|
setSelectedMenuItem('none');
|
|
setErrorMessage('');
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Signal navigation state machine that video transition is starting
|
|
// This sets phase to 'transitioning' so spinner shows during buffering
|
|
logger.info('[TRANSITION-START] 🚀 Starting transition', {
|
|
targetPageId: navTarget.pageId,
|
|
direction,
|
|
isBack: navTarget.isBack,
|
|
transitionVideoUrl: transitionSource.transitionVideoUrl?.slice(-60),
|
|
reverseVideoUrl: transitionSource.reverseVideoUrl?.slice(-60),
|
|
});
|
|
// Pause background downloads to give transition video exclusive bandwidth
|
|
downloadManager.pauseAll();
|
|
startTransition(navTarget.pageId, navTarget.isBack);
|
|
openPreviewWithTarget(transitionSource, direction, navTarget.pageId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
selectElementForEdit(element.id);
|
|
};
|
|
|
|
// Handler for gallery card clicks
|
|
const handleGalleryCardClick = useCallback(
|
|
(element: CanvasElement, cardIndex: number) => {
|
|
if (element.galleryCards && element.galleryCards.length > 0) {
|
|
setActiveGalleryCarousel({
|
|
elementId: element.id,
|
|
initialIndex: cardIndex,
|
|
});
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Handler for gallery carousel button position changes (constructor only)
|
|
const handleGalleryCarouselButtonPositionChange = useCallback(
|
|
(button: 'prev' | 'next' | 'back', x: number, y: number) => {
|
|
if (!activeGalleryCarousel) return;
|
|
|
|
const positionPatch =
|
|
button === 'prev'
|
|
? { galleryCarouselPrevX: x, galleryCarouselPrevY: y }
|
|
: button === 'next'
|
|
? { galleryCarouselNextX: x, galleryCarouselNextY: y }
|
|
: { galleryCarouselBackX: x, galleryCarouselBackY: y };
|
|
|
|
// Update the element by explicit ID (not by selection)
|
|
// because the gallery element may not be selected when the carousel is open
|
|
setElements((prev) =>
|
|
prev.map((el) =>
|
|
el.id === activeGalleryCarousel.elementId
|
|
? { ...el, ...positionPatch }
|
|
: el,
|
|
),
|
|
);
|
|
// No need to update activeGalleryCarousel - it stores only elementId
|
|
// and the element lookup is done via activeGalleryCarouselElement useMemo
|
|
},
|
|
[activeGalleryCarousel, setElements],
|
|
);
|
|
|
|
// Handler for carousel element button position changes (constructor only)
|
|
const handleCarouselButtonPositionChange = useCallback(
|
|
(elementId: string, button: 'prev' | 'next', x: number, y: number) => {
|
|
const positionPatch =
|
|
button === 'prev'
|
|
? { carouselPrevX: x, carouselPrevY: y }
|
|
: { carouselNextX: x, carouselNextY: y };
|
|
|
|
setElements((prev) =>
|
|
prev.map((el) =>
|
|
el.id === elementId ? { ...el, ...positionPatch } : el,
|
|
),
|
|
);
|
|
},
|
|
[setElements],
|
|
);
|
|
|
|
const isElementVisibleOnCanvas = (element: CanvasElement) =>
|
|
isElementVisibleAtTime(
|
|
canvasElapsedSec,
|
|
element.appearDelaySec,
|
|
element.appearDurationSec,
|
|
);
|
|
|
|
// Check if a single element's icon is ready (used for individual element visibility)
|
|
const isElementIconReady = useCallback(
|
|
(element: CanvasElement) => {
|
|
const isPreloadableIconElement =
|
|
(isNavigationElementType(element.type) ||
|
|
isTooltipElementType(element.type) ||
|
|
isDescriptionElementType(element.type)) &&
|
|
Boolean(element.iconUrl);
|
|
|
|
if (!isPreloadableIconElement) return true;
|
|
|
|
const playbackUrl = resolveAssetPlaybackUrl(element.iconUrl);
|
|
if (!playbackUrl) return true;
|
|
|
|
return Boolean(preloadedIconUrlMap[playbackUrl]);
|
|
},
|
|
[preloadedIconUrlMap],
|
|
);
|
|
|
|
// Check if ALL element icons are ready - used to show all elements together
|
|
// This prevents staggered element appearance where some elements show before others
|
|
const areAllElementIconsReady = useMemo(() => {
|
|
return elements.every((element) => isElementIconReady(element));
|
|
}, [elements, isElementIconReady]);
|
|
|
|
// URL resolver that uses preloaded blob URLs when available
|
|
// Depends on readyUrlsVersion to re-render when blob URLs become ready after preload
|
|
const resolveUrlWithBlob = useCallback(
|
|
(url: string | undefined): string => {
|
|
if (!url) return '';
|
|
|
|
// Try to get blob URL from preload orchestrator (instant display)
|
|
// Check storage key first (most reliable), then resolved URL
|
|
const blobUrl =
|
|
preloadOrchestrator.getReadyBlobUrl(url) ||
|
|
preloadOrchestrator.getReadyBlobUrl(resolveAssetPlaybackUrl(url));
|
|
if (blobUrl) return blobUrl;
|
|
|
|
// Fall back to standard resolution
|
|
return resolveAssetPlaybackUrl(url);
|
|
},
|
|
[preloadOrchestrator, preloadOrchestrator.readyUrlsVersion],
|
|
);
|
|
|
|
const canvasBackgroundStyle: React.CSSProperties = {};
|
|
|
|
// Unified background URL resolution
|
|
// Edit mode: use local storage paths (for immediate editing feedback)
|
|
// Interact mode: use nav state values (blob URLs from preload cache)
|
|
const backgroundImageSrc = useMemo(() => {
|
|
if (isConstructorEditMode && backgroundImageUrl) {
|
|
return resolveUrlWithBlob(backgroundImageUrl);
|
|
}
|
|
return navCurrentBgImageUrl;
|
|
}, [
|
|
isConstructorEditMode,
|
|
backgroundImageUrl,
|
|
navCurrentBgImageUrl,
|
|
resolveUrlWithBlob,
|
|
]);
|
|
|
|
const backgroundVideoSrc = useMemo(() => {
|
|
if (isConstructorEditMode && backgroundVideoUrl) {
|
|
return resolveUrlWithBlob(backgroundVideoUrl);
|
|
}
|
|
return navCurrentBgVideoUrl;
|
|
}, [
|
|
isConstructorEditMode,
|
|
backgroundVideoUrl,
|
|
navCurrentBgVideoUrl,
|
|
resolveUrlWithBlob,
|
|
]);
|
|
|
|
const backgroundAudioSrc = useMemo(() => {
|
|
if (isConstructorEditMode && backgroundAudioUrl) {
|
|
return resolveUrlWithBlob(backgroundAudioUrl);
|
|
}
|
|
return navCurrentBgAudioUrl;
|
|
}, [
|
|
isConstructorEditMode,
|
|
backgroundAudioUrl,
|
|
navCurrentBgAudioUrl,
|
|
resolveUrlWithBlob,
|
|
]);
|
|
|
|
const hasEditorSelection =
|
|
isConstructorEditMode &&
|
|
(Boolean(selectedElement) || selectedMenuItem !== 'none');
|
|
const editorTitle =
|
|
selectedMenuItem === 'background_image'
|
|
? 'Background image'
|
|
: selectedMenuItem === 'background_video'
|
|
? 'Background video'
|
|
: selectedMenuItem === 'background_audio'
|
|
? 'Background audio'
|
|
: selectedMenuItem === 'create_transition'
|
|
? 'Create transition'
|
|
: selectedElement?.label || 'Element editor';
|
|
|
|
// Background image is rendered by CanvasBackground component (same as runtime)
|
|
// No CSS background-image needed on canvas div
|
|
|
|
// Duration notes for UI display
|
|
const durationNotes = useMemo(
|
|
() => ({
|
|
backgroundVideo: backgroundVideoDurationNote,
|
|
backgroundAudio: backgroundAudioDurationNote,
|
|
selectedMedia: selectedMediaDurationNote,
|
|
selectedTransition: selectedTransitionDurationNote,
|
|
newTransition: newTransitionDurationNote,
|
|
}),
|
|
[
|
|
backgroundVideoDurationNote,
|
|
backgroundAudioDurationNote,
|
|
selectedMediaDurationNote,
|
|
selectedTransitionDurationNote,
|
|
newTransitionDurationNote,
|
|
],
|
|
);
|
|
|
|
// Transition creation state for context
|
|
const transitionCreationState = useMemo(
|
|
() => ({
|
|
name: newTransitionName,
|
|
videoUrl: newTransitionVideoUrl,
|
|
supportsReverse: newTransitionSupportsReverse,
|
|
isCreating: isCreatingTransition,
|
|
setName: setNewTransitionName,
|
|
setVideoUrl: setNewTransitionVideoUrl,
|
|
setSupportsReverse: setNewTransitionSupportsReverse,
|
|
create: () =>
|
|
createTransition({
|
|
name: newTransitionName,
|
|
videoUrl: newTransitionVideoUrl,
|
|
supportsReverse: newTransitionSupportsReverse,
|
|
durationSec: getDuration(newTransitionVideoUrl),
|
|
}),
|
|
}),
|
|
[
|
|
newTransitionName,
|
|
newTransitionVideoUrl,
|
|
newTransitionSupportsReverse,
|
|
isCreatingTransition,
|
|
createTransition,
|
|
getDuration,
|
|
],
|
|
);
|
|
|
|
// Build context value for ConstructorProvider
|
|
// This allows child components to access constructor state without prop drilling
|
|
const constructorContextValue: ConstructorContextValue = useMemo(
|
|
() => ({
|
|
// Project state
|
|
projectId,
|
|
|
|
// Page state
|
|
pages,
|
|
activePageId,
|
|
activePage,
|
|
setActivePageId,
|
|
|
|
// Background state
|
|
pageBackground,
|
|
setPageBackground,
|
|
updateBackgroundFromPage,
|
|
|
|
// Background convenience setters
|
|
setBackgroundImageUrl,
|
|
setBackgroundVideoUrl,
|
|
setBackgroundAudioUrl,
|
|
setBackgroundVideoSettings,
|
|
|
|
// Element state
|
|
elements,
|
|
setElements,
|
|
selectedElementId,
|
|
selectedElement,
|
|
selectElement,
|
|
clearSelection,
|
|
updateElement: (id: string, patch: Partial<CanvasElement>) => {
|
|
setElements((prev) =>
|
|
prev.map((el) => (el.id === id ? { ...el, ...patch } : el)),
|
|
);
|
|
},
|
|
removeElement: (id: string) => {
|
|
setElements((prev) => prev.filter((el) => el.id !== id));
|
|
if (selectedElementId === id) {
|
|
clearSelection();
|
|
}
|
|
},
|
|
updateSelectedElement,
|
|
removeSelectedElement,
|
|
|
|
// Menu state
|
|
selectedMenuItem,
|
|
setSelectedMenuItem,
|
|
isMenuOpen,
|
|
setIsMenuOpen,
|
|
|
|
// Editor state
|
|
elementEditorTab,
|
|
setElementEditorTab,
|
|
|
|
// Assets
|
|
assets,
|
|
isLoadingAssets: isDataLoading,
|
|
|
|
// Asset options (derived from assets)
|
|
assetOptions,
|
|
|
|
// Gallery/Carousel operations
|
|
galleryCards,
|
|
galleryInfoSpans,
|
|
carouselSlides,
|
|
|
|
// Duration resolver
|
|
getDuration,
|
|
|
|
// Duration notes
|
|
durationNotes,
|
|
|
|
// Transition preview
|
|
onPreviewTransition: openTransitionPreview,
|
|
|
|
// Transition creation
|
|
transitionCreation: transitionCreationState,
|
|
|
|
// Navigation settings
|
|
allowedNavigationTypes,
|
|
normalizeNavigationType,
|
|
|
|
// Actions
|
|
save: saveConstructor,
|
|
isSaving,
|
|
}),
|
|
[
|
|
projectId,
|
|
pages,
|
|
activePageId,
|
|
activePage,
|
|
pageBackground,
|
|
setPageBackground,
|
|
updateBackgroundFromPage,
|
|
setBackgroundImageUrl,
|
|
setBackgroundVideoUrl,
|
|
setBackgroundAudioUrl,
|
|
setBackgroundVideoSettings,
|
|
elements,
|
|
setElements,
|
|
selectedElementId,
|
|
selectedElement,
|
|
selectElement,
|
|
clearSelection,
|
|
updateSelectedElement,
|
|
removeSelectedElement,
|
|
selectedMenuItem,
|
|
isMenuOpen,
|
|
elementEditorTab,
|
|
assets,
|
|
isDataLoading,
|
|
assetOptions,
|
|
galleryCards,
|
|
galleryInfoSpans,
|
|
carouselSlides,
|
|
getDuration,
|
|
durationNotes,
|
|
openTransitionPreview,
|
|
transitionCreationState,
|
|
allowedNavigationTypes,
|
|
normalizeNavigationType,
|
|
saveConstructor,
|
|
isSaving,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<ConstructorProvider value={constructorContextValue}>
|
|
<Head>
|
|
<title>
|
|
{getPageTitle(isElementEditMode ? 'Edit Element' : 'Constructor')}
|
|
</title>
|
|
</Head>
|
|
<div className='relative w-screen h-screen bg-black overflow-hidden'>
|
|
<div className='absolute top-4 left-4 z-[1000] flex max-w-[80vw] flex-col gap-2'>
|
|
<p className='text-xs font-semibold text-gray-700'>
|
|
{projectName || 'Loading project...'}
|
|
</p>
|
|
{errorMessage ? (
|
|
<p className='rounded bg-red-50 px-2 py-1 text-xs text-red-600'>
|
|
{errorMessage}
|
|
</p>
|
|
) : null}
|
|
{successMessage ? (
|
|
<p className='rounded bg-green-50 px-2 py-1 text-xs text-green-700'>
|
|
{successMessage}
|
|
</p>
|
|
) : null}
|
|
|
|
{pages.length > 0 && isElementEditMode && (
|
|
<div className='flex items-center gap-2'>
|
|
<BaseButton
|
|
color='lightDark'
|
|
label='Back to Elements'
|
|
icon={mdiExitToApp}
|
|
href={pageElementsListHref}
|
|
/>
|
|
<BaseButton
|
|
color='info'
|
|
label={isSaving ? 'Saving...' : 'Save'}
|
|
icon={mdiContentSave}
|
|
onClick={saveConstructor}
|
|
disabled={isSaving}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{pages.length > 0 && !isElementEditMode && (
|
|
<ConstructorToolbar
|
|
ref={toolbarRef}
|
|
position={toolbarPosition}
|
|
onDragStart={onToolbarDragStart}
|
|
pages={pages}
|
|
activePageId={activePageId}
|
|
onPageChange={(pageId) => {
|
|
const page = pages.find((p) => p.id === pageId);
|
|
if (page) switchToPage(page);
|
|
}}
|
|
interactionMode={constructorInteractionMode}
|
|
onModeChange={setConstructorInteractionMode}
|
|
onSelectMenuItem={selectMenuItemForEdit}
|
|
allowedNavigationTypes={allowedNavigationTypes}
|
|
onAddElement={addElement}
|
|
onCreatePage={createPage}
|
|
isCreatingPage={isCreatingPage}
|
|
onSave={saveConstructor}
|
|
onSaveToStage={handleSaveToStage}
|
|
isSaving={isSaving}
|
|
isSavingToStage={isSavingToStage}
|
|
lastSavedAt={lastProjectSaveAt}
|
|
lastSavedToStageAt={lastSavedToStage}
|
|
projectId={projectId}
|
|
onExit={() =>
|
|
router.push(
|
|
projectId
|
|
? `/projects/${projectId}`
|
|
: '/projects/projects-list',
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{/* Canvas container: z-[46] creates stacking context above carousel (z-10 bg, z-45 controls) portaled to body */}
|
|
<div
|
|
ref={canvasRef}
|
|
tabIndex={-1}
|
|
className={`relative z-[46] overflow-clip ${hasFullWidthCarousel ? 'bg-transparent' : 'bg-black'}`}
|
|
style={{
|
|
...canvasCssVars,
|
|
...letterboxStyles,
|
|
...canvasBackgroundStyle,
|
|
}}
|
|
>
|
|
<BackdropPortalProvider>
|
|
{/* Safari Black Flash Prevention (video transitions only):
|
|
Persistent snapshot layer shown ONLY during video transitions.
|
|
z-[1] keeps it behind backgrounds (z-5) but above the black container. */}
|
|
{lastKnownBgUrl &&
|
|
isSafari() &&
|
|
(transitionPreview || pendingTransitionComplete) && (
|
|
<div
|
|
className='absolute inset-0 z-[1] pointer-events-none'
|
|
style={{
|
|
backgroundImage: `url("${lastKnownBgUrl}")`,
|
|
backgroundSize: 'contain',
|
|
backgroundPosition: 'center',
|
|
backgroundRepeat: 'no-repeat',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Background wrapper - z-5 keeps it BELOW carousel slide (z-10).
|
|
Previous background overlay shows during loading.
|
|
Black overlay for fade effect is rendered separately at z-[100]. */}
|
|
<div className='absolute inset-0 z-5'>
|
|
<CanvasBackground
|
|
backgroundImageUrl={backgroundImageSrc}
|
|
backgroundVideoUrl={backgroundVideoSrc}
|
|
backgroundAudioUrl={backgroundAudioSrc}
|
|
previousBgImageUrl={navPreviousBgImageUrl}
|
|
previousBgVideoUrl={navPreviousBgVideoUrl}
|
|
isSwitching={navIsSwitching}
|
|
isNewBgReady={navIsNewBgReady}
|
|
onBackgroundReady={navOnBackgroundReady}
|
|
onVideoBufferStateChange={onVideoBufferStateChange}
|
|
videoAutoplay={backgroundVideoAutoplay}
|
|
videoLoop={backgroundVideoLoop}
|
|
videoMuted={soundControl.isMuted}
|
|
videoStartTime={backgroundVideoStartTime}
|
|
videoEndTime={backgroundVideoEndTime}
|
|
videoStoragePath={
|
|
backgroundVideoUrl || activePage?.background_video_url
|
|
}
|
|
pauseVideo={
|
|
Boolean(transitionPreview) ||
|
|
pendingTransitionComplete ||
|
|
navIsSwitching
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Page loading spinner - from unified navigation state machine.
|
|
navShowSpinner is true when:
|
|
- Phase is 'preparing', 'loading_bg', 'transition_done', OR
|
|
- Video transition is active but buffering
|
|
Additionally for constructor: show while element icons are loading.
|
|
Only show in Interact mode - Edit mode doesn't need loading indicators.
|
|
Skip when video transition overlay is active - it has its own spinner. */}
|
|
{!isConstructorEditMode &&
|
|
!transitionPreview &&
|
|
(navShowSpinner ||
|
|
(navShowElements && !areAllElementIconsReady)) && (
|
|
<CanvasLoadingSpinner isVisible={true} zIndex={100} />
|
|
)}
|
|
|
|
{/* Elements container - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45).
|
|
UI controls (z-50) remain on top.
|
|
No fade animation - elements switch instantly behind the black overlay.
|
|
Shows when phase is 'idle' or 'fading_in' (navShowElements) AND all element icons are preloaded.
|
|
This ensures all elements appear together (no staggered appearance).
|
|
Exception: Edit mode always shows elements for editing (no icon preload wait). */}
|
|
{(isConstructorEditMode ||
|
|
(navShowElements && areAllElementIconsReady)) && (
|
|
<div className='absolute inset-0 z-[46]'>
|
|
{!isLoading && pages.length === 0 ? (
|
|
<div className='absolute inset-0 flex items-center justify-center'>
|
|
<BaseButton
|
|
color='info'
|
|
label={
|
|
isCreatingPage ? 'Creating...' : 'Create First Page'
|
|
}
|
|
icon={mdiPlus}
|
|
onClick={createPage}
|
|
disabled={isCreatingPage}
|
|
/>
|
|
</div>
|
|
) : (
|
|
elements.map((element) => {
|
|
// Icon preloading is now handled at container level (areAllElementIconsReady)
|
|
// Here we only check visibility based on appear delay/duration timing
|
|
const shouldRender =
|
|
selectedElementId === element.id ||
|
|
isElementVisibleOnCanvas(element);
|
|
if (!shouldRender) return null;
|
|
|
|
return (
|
|
<CanvasElementComponent
|
|
key={element.id}
|
|
element={element}
|
|
isSelected={selectedElementId === element.id}
|
|
isEditMode={isConstructorEditMode}
|
|
onClick={() => onCanvasElementClick(element)}
|
|
onMouseDown={(event) =>
|
|
onElementMouseDown(event, element.id)
|
|
}
|
|
resolveUrl={resolveUrlWithBlob}
|
|
onGalleryCardClick={(cardIndex) =>
|
|
handleGalleryCardClick(element, cardIndex)
|
|
}
|
|
onCarouselButtonPositionChange={(button, x, y) =>
|
|
handleCarouselButtonPositionChange(
|
|
element.id,
|
|
button,
|
|
x,
|
|
y,
|
|
)
|
|
}
|
|
letterboxStyles={letterboxStyles}
|
|
pageTransitionSettings={transitionSettings}
|
|
preloadCache={{
|
|
getReadyBlob: preloadOrchestrator.getReadyBlob,
|
|
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
|
}}
|
|
/>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Overlay for fade-through-color transition - z-[100] is ABOVE elements (z-[46]).
|
|
This covers the elements during page transition to hide the instant switch.
|
|
Only rendered for 'fade' type. */}
|
|
<TransitionBlackOverlay
|
|
isFadingIn={isFadingIn}
|
|
transitionType={transitionSettings.type}
|
|
transitionStyle={transitionStyle}
|
|
overlayColor={transitionSettings.overlayColor}
|
|
/>
|
|
</BackdropPortalProvider>
|
|
</div>
|
|
|
|
{/* ElementEditorPanel now uses ConstructorContext for all state */}
|
|
{pages.length > 0 && hasEditorSelection && (
|
|
<ElementEditorPanel
|
|
elementEditorRef={elementEditorRef}
|
|
position={editorPosition}
|
|
isCollapsed={isEditorCollapsed}
|
|
onToggleCollapse={() => setIsEditorCollapsed((prev) => !prev)}
|
|
onDragStart={onElementEditorDragStart}
|
|
title={editorTitle}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<TransitionPreviewOverlay
|
|
videoKey={transitionPreview?.videoUrl}
|
|
videoRef={transitionVideoRef}
|
|
isActive={Boolean(transitionPreview) && showTransitionVideo}
|
|
isBuffering={isTransitionBuffering}
|
|
isVideoReady={isTransitionVideoReady}
|
|
showSpinner={true}
|
|
letterboxStyles={letterboxStyles}
|
|
isFadingOut={isFadingIn}
|
|
fadeOutDuration={transitionSettings.durationMs}
|
|
/>
|
|
|
|
{/* Gallery Carousel Overlay */}
|
|
{activeGalleryCarousel && activeGalleryCarouselElement && (
|
|
<GalleryCarouselOverlay
|
|
cards={activeGalleryCarouselElement.galleryCards || []}
|
|
initialIndex={activeGalleryCarousel.initialIndex}
|
|
onClose={() => setActiveGalleryCarousel(null)}
|
|
resolveUrl={resolveUrlWithBlob}
|
|
prevIconUrl={activeGalleryCarouselElement.galleryCarouselPrevIconUrl}
|
|
nextIconUrl={activeGalleryCarouselElement.galleryCarouselNextIconUrl}
|
|
backIconUrl={activeGalleryCarouselElement.galleryCarouselBackIconUrl}
|
|
backLabel={
|
|
activeGalleryCarouselElement.galleryCarouselBackLabel || 'BACK'
|
|
}
|
|
prevX={activeGalleryCarouselElement.galleryCarouselPrevX}
|
|
prevY={activeGalleryCarouselElement.galleryCarouselPrevY}
|
|
nextX={activeGalleryCarouselElement.galleryCarouselNextX}
|
|
nextY={activeGalleryCarouselElement.galleryCarouselNextY}
|
|
backX={activeGalleryCarouselElement.galleryCarouselBackX}
|
|
backY={activeGalleryCarouselElement.galleryCarouselBackY}
|
|
prevWidth={activeGalleryCarouselElement.galleryCarouselPrevWidth}
|
|
prevHeight={activeGalleryCarouselElement.galleryCarouselPrevHeight}
|
|
nextWidth={activeGalleryCarouselElement.galleryCarouselNextWidth}
|
|
nextHeight={activeGalleryCarouselElement.galleryCarouselNextHeight}
|
|
backWidth={activeGalleryCarouselElement.galleryCarouselBackWidth}
|
|
backHeight={activeGalleryCarouselElement.galleryCarouselBackHeight}
|
|
letterboxStyles={letterboxStyles}
|
|
isEditMode={isConstructorEditMode}
|
|
onButtonPositionChange={handleGalleryCarouselButtonPositionChange}
|
|
pageTransitionSettings={transitionSettings}
|
|
galleryElement={activeGalleryCarouselElement}
|
|
/>
|
|
)}
|
|
|
|
<style jsx>{`
|
|
.menu-action-btn {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
padding: 6px 8px;
|
|
border-radius: 6px;
|
|
color: #1f2937;
|
|
text-align: left;
|
|
}
|
|
|
|
.menu-action-btn:hover {
|
|
background: #f3f4f6;
|
|
}
|
|
`}</style>
|
|
</ConstructorProvider>
|
|
);
|
|
};
|
|
|
|
ConstructorPage.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
|
};
|
|
|
|
export default ConstructorPage;
|